Skip to content

Detail pages — flat language build spec (spacing · alignment · shared components · models · interactions) — 2026-09-05

Status: Draft · companion to detail-pages-flat-language-2026-09-05.md (audit + grammar + decisions) and the board /wireframes/detail-flat-language. Owner reaction 2026-09-05: "generally a better layout — work out the spacing, alignment, shared components, models and interactions + behaviors for all of the pages." This document is that working-out. It assumes the four board decisions land on their recommendations (seam $6, title beneath the hero, tint = recess only, DetailShell ground opt-in); say otherwise and only §1.2 and §2.1 move.

Every behavior listed under a page is read from the live code (agent audits 2026-09-05, file:line in the session record) and is a must-keep unless marked ✂ (retired) or ✚ (new). Nothing here is built.


1. Spacing and alignment system

1.1 The rails

RailValueRule
Page rail$4 = 16px left/rightEvery text column, fact row, list row, stat strip, and section title aligns to this rail. Nothing is inset a second time.
Full-bleed band0Only media heroes (map/photo/album) and the pinned CTA band cross the rail. Section dividers (FlatColumn) and the top hairline of BottomCtaBand are full-bleed; rows carry no hairline.
Leading column40px avatar (MD, the member-row tier) / 20px glyph + $3 gapList rows with a leading avatar or glyph put text at rail + leading + $3; rows carry no hairline (Divider law).
Trailing columnbadge / chevron, $3 gapRight-aligned; the row reserves no fixed width — trailing content sets it.

1.2 The seams (one owner each — section-rhythm law extended)

SeamValueOwner
chrome → content (header/hero bottom → first section)SEAM.hero = $4 = 16 (hero identity → first section); SEAM.chrome = $3 = 12 for header chrome → first contentthe page column's paddingTop (FlatColumn top)
section → sectionSEAM.section = $6 = 24 · full-bleed 1px $borderSubtle · 24 (Divider law; the seam ladder retuned 20→24 on 2026-09-06 because 20 is not a rung)the page column's gap + divider (FlatColumn)
section title → content$2 = 8SectionBlock (unchanged)
row → row (lists, fact rows)0 — no line; the row's $2 vertical padding is the rhythm (tightened 2026-09-06) (RowList; dividers only when each peer is a multi-component block)the row
stat strip cell → cellSEAM.item = 8 gap with the 1px cell divider centered in it (4 · line · 4; inset 4px each end); 8 padding inside each cellStatStrip
inside a row (title → sub)$1 = 4 (ListRow; matches DuesRow)the row
inside a well (icon → text)$2 = 8Well
last section → bottomSEAM.tail = $9 = 48 with no band; SEAM.rail = $4 = 16 above a pinned DetailShell footer bandDetailShell (shell-ground resolver)
hero image/chrome → identity blockSEAM.hero = $4 = 16 on BOTH heroes (retuned from SEAM.chrome=12 in the 2026-09-06 HERO CANON pass), $1 = 4 eyebrow → titlethe hero (TYPE_HERO_PADDING_TOP/MEDIA_HERO_IDENTITY_PADDING_TOP)

Edge rule (2026-09-06): the first child of a section carries no top padding and the last no bottom padding (RowList/FactList clone edge), section headers have no min-height slack, no block adds an outer margin. Hero zone: stat strips and hero facts sit in FlatColumn lead, never as sections. Call-site gating: every FlatColumn child is gated where it is placed.

Drift signatures to reject in review: a wrapper paddingVertical around a SectionBlock; a hand-drawn Divider between two sections (FlatColumn owns it); a hairline between two ListRow/FactRow peers; a hairline hugging a Well/StatStrip/hero; a marginTop on the first row of a list.

Canon anchor (owner, 2026-09-05). Club detail (club-place-header.tsx, club-identity-header.tsx, use-collapsing-banner.ts, the pane rows) is THE canonical spacing configuration; every value in §1 is read from it, and when this spec and club detail disagree, club detail wins and this spec is corrected. Audit trail: docs/design/detail-pages-flat-canon-audit-2026-09-05.md.

1.3 Row metrics

RowVertical paddingMin heightText roles
Fact row$2 = 836 (static — no touch floor; peers sit tight, owner 2026-09-06)label cardMeta $textTertiary · value cardBody 600 $text (tone $primary / $warning / $success allowed)
List row (tappable)$2 = 840 (ROW_HEIGHT_LIST; 44pt touch floor closed by hitSlop)title cardBody 600 · sub cardMeta $textTertiary
List row (dense, ≤ 1 line)$1 = 436 (ROW_HEIGHT_HAIRLINE; hitSlop to 44)title cardMeta 600
Stat strip cell$2 top / $1 bottom56value displaySm 700 (tabular nums) · label cardMeta
Section title$0 top, $2 bottom (owned)$7 = 32cardTitle title · cardMeta meta · cardMeta 700 $primary right action
Well$3 = 12 all sidescardBody
CTA band$3 top · $4 sides · $4 bottom (+ safe inset)button md

Hairline: 1px $borderSubtle. Vertical alignment inside rows: alignItems="center"; fact-row values right-aligned and ellipsized at one line (numberOfLines={1}), labels never truncate (they wrap the value first).

1.4 Typography ladder (existing roles, no additions)

SlotRolepx
Media/typographic hero titlepageTitle24/32 700 — the club-identity-header role (canon). displayMd (28/36) was one step too large next to the club name on its own surface; retuned 2026-09-05.
Hero metacardBody $textSecondary14/22
Section titlenavTitle 70017/22 700 — one step above the 16px cardTitle list header so a section boundary reads as a boundary without a line (Divider law); displaySm still reads as a second page title
Row titlecardBody 60014/22
Row sub / labels / metacardMeta13/18 500
Badgebadge12/16 600
Stat valuedisplaySm tabular20/28 700

1.5 Alignment rules

  1. Everything left-aligns to the rail except stat-strip cells (centered within their cell) and fact-row values (right).
  2. HERO CANON (revised 2026-09-06): one badge home — every badge wraps as ONE row directly under the title, before the meta lines, $2 gap, in caller order (entity state → verification → category); the title line and the media overlay carry none (titleBadge/MediaHero status retired); two badges max — overflow goes to the meta line (Badge placement law, canon components.md).
  3. Chevrons () sit at the rail's right edge, $textTertiary, size md; a row with a trailing badge has no chevron.
  4. Icons in list rows: 20px, $textSecondary, optically centered on the first text line, not the row.
  5. Numbers: tabularNums on stat strips, fact rows with money/time, and rankings.
  6. Korean line-height: never below 1.4× (all roles already comply).
  7. Group spread (owner, 2026-09-05). Any horizontal group of peers — tabs, segmented controls, action icons, button pairs in a CTA band, stat cells, chip sets that fit on one line — fills the rail edge to edge with the gap as the constant and the item width as the variable: equal flex={1} columns separated by the canonical gap ($2 icon items / stat cells / chips — a cell divider sits centered in the gap; $3 button pairs), the first column on the rail and content centered inside each column (ActionIconRow); never space-between, never a centered fixed-width cluster ($3 for buttons, $2 for chips, none for hairline-separated cells). Never justifyContent="center" with fixed-width items, which crams the group in the middle. Chip sets that overflow wrap left-aligned; scroll rails start at the rail and bleed right.

1.6 Dark mode

Ground $card dark value (near-black), hairline $borderSubtle dark value, tints ($surfaceSecondary, badge backgrounds) keep their semantic roles. No component adds a theme-specific override. Contrast checks in ui-contracts.test.ts extend to the hairline-on-ground pair (decorative, so no 3:1 requirement, but it must stay visible: ≥ 1.15:1).


2. Shared components (@twomore/ui) — API and models

Everything below is additive; nothing existing changes signature. Names are final unless the owner renames.

2.1 Shells

DetailShellground?: 'tinted' | 'canvas' (default 'tinted' until wave 5, then 'canvas' and the prop is removed). canvas paints $card and drops the default content padding to { padding: 0, paddingBottom: $9 } so heroes bleed; pages compose FlatColumn themselves. Everything else unchanged (AppHeader, scroll, refreshControl, perfLabel).

MainTabShell ✚ same ground prop (profile tab root).

PlaceShell unchanged (club detail keeps its full-bleed-top collapsing chrome).

2.2 Layout

FlatColumn ✚ — YStack paddingHorizontal="$4" paddingTop="$4" gap="$6"; props top?: SpaceToken = '$4', gap?: SpaceToken = '$6'. The ONE gap-bearing column per page (section-rhythm law). A page has exactly one; nested columns are forbidden (lint: a FlatColumn inside a FlatColumn).

SectionBlockvariant="flat": SectionHeader in cardTitle, minHeight $7, paddingBottom $2, meta and right slots unchanged; no inset (the column owns the rail). Existing block/list variants stay for non-flat pages until wave 5.

2.3 Content primitives

ComponentPropsModel it rendersNotes
FactRowlabel: string, value: ReactNode, tone?: 'default'|'primary'|'warning'|'success'|'muted', first?: boolean, onPress?: () => void, trailing?: ReactNode, testID?any label/value pairFactList ✚ wraps SpecItem[] (the existing SpecList item shape, emoji retired) and sets first on index 0. SpecList becomes a thin alias for one release, then ✂. Replaces venue-detail-screen's DetailRow, join-review's inline rows.
RowListchildren (rows), dividers?: booleanStacks rows with NO separators (Divider law: peers are separated by spacing). dividers clones hairline onto every child but the last — only for multi-component block peers (match rows, review blocks). Replaced RowList 2026-09-06.
ListRow ✚ (promote NavRow's anatomy)leading?, title, sub?, trailing?, onPress?, chevron?: boolean, dense?: boolean, accessibilityLabel?, testID?any entity rowPressable variant="row" when onPress; minHeight 44 (36 dense). Existing NavRow, MemberRow, DuesRow, PostFeedRow keep their files but conform to these metrics (they already do since 2026-08-25).
CellRowcells: Array<{ key, content, testID? }>, dividers? (default true), testID? — equal flex:1 columns, content centered, SEAM.item GAP between cells, SEAM.item PADDING inside each cell, optional 1px cell divider centered in the gap (4 · line · 4) and inset SEAM.element at each endstat strips, action rows, any horizontal run of like itemsThe ONE row-group layout; StatStrip and ActionIconRow are item renderers over it (owner 2026-09-06).
StatStripcells: Array<{ label, value?, valueSlot?, tone?, tabularNums? }> (2–4), testIDPrefix?CellRow with dividers on, item = StatTile wrap="flat"; lives in FlatColumn leadStatTile inputsComposes StatTile wrap="flat" cells with vertical hairlines; equal flex. Replaces StatTile wrap="card" grids, directory-venue-detail/parts/stat-tile.tsx (✂), SdHeroStrip-style tinted strips.
MediaHeromedia: { kind: 'map'|'photo'|'album', uri?: string, aspect?: number } (no aspect → the fixed 132px band of ClubAlbumBanner, the canon; aspect is the opt-in for a taller map/photo), title, meta?, badges?: ReactNode[] (HERO CANON, 2026-09-06: the ONE badge row — entity state → verification → category, in caller order; status/titleBadge retired — the media band carries only graphics), avatar?: ReactNode (56px identity avatar, sits flush in the identity row beside the title — the banner-overlap treatment is retired, as on club detail), onMediaPress?, testID?session (venue map), venue (static map), club (album)Image full-bleed, no radius; identity block at rail with paddingTop SEAM.hero ($4, HERO CANON 2026-09-06); title pageTitle; badges row directly under the title, before meta, $2 gap. Album kind delegates paging to ClubAlbumBanner (dots + count pill preserved).
TypeHeroeyebrow?, title, meta?, badges?: ReactNode[] (HERO CANON, 2026-09-06: the ONE badge row, same order/placement rule as MediaHero; titleBadge retired), leading?: ReactNode (top-aligned avatar/ring), testID?profile, public profile, record period, applicant, match boardRail-aligned; paddingTop SEAM.hero ($4, HERO CANON 2026-09-06).
ActionIconRowitems: Array<{ icon, label, onPress, disabled?, active?, accessibilityLabel, testID? }> (3–5)venue quick actions40px $primarySubtle circles, $3 label, $5 gap, centered; replaces ActionTile (✂).
Wellunchanged ($surfaceSecondary, $4 radius, $3 pad) ✚ tone?: 'neutral'|'primary'|'warning'host notice, empty states, recessed tilesNever bordered.
Callout / BannerunchangedsignalsRaw tinted XStacks in refund-banners.tsx and host-callout-section.tsx migrate to these (inconsistency 5).
BottomCtaBandunchangedthe one primary actionPost detail's CommentComposer adopts its padding/hairline (keeps its 40px send circle + hitSlop).
EmptyStateunchanged; on flat pages wrap compact in a Wellrecessed prop
Skeleton*SkeletonFlatPage = hero block (image or type) + 3 hairline rows + a striploadingReplaces SkeletonHero + 3×SkeletonCard on migrated pages so the skeleton matches the grammar.

2.4 Lint and tests (prevention)

  • ✚ ESLint @twomore/no-card-on-canvas: Card inside a DetailShell ground="canvas" / PlaceShell subtree is an error unless annotated // figure: <reason> (allowed figures: SummaryCard, shelf tile, list-embedded session card).
  • ✚ ESLint @twomore/one-flat-column: at most one FlatColumn per screen file.
  • ui-contracts.test.ts: FactRow/ListRow min heights, StatStrip hairline tokens, FlatColumn gap = $5 + full-bleed section divider.
  • Each migrated screen keeps its characterization test; the test asserts section testIDs in order and the CTA copy per state exactly as today.

3. Page specs

Legend: Layout = top-to-bottom on the canvas with seams; Model = hooks/types; Interactions = every tap; CTA = the pinned band matrix; States = loading/empty/error/offline/refresh; Chrome = header, menu, scroll behavior. ✂ retired, ✚ new.

3.1 Session detail (wave 1, flagship)

Model. useSessionDetailModelSessionInfoModel (gate order eligibility·when·where·conditions·kind·who·cost·urgency·action; detail projects all), useGenerateRoundInputs, useSessionDetailActions (primaryAction with refund-ack override), useSessionDetailData (header menu, manner-tag tour), sessionPaymentGateState (free/reserved/submitted/confirmed).

Layout.

  1. DetailShell ground="canvas" · header title = session title, PulseDot when in_progress, right = SessionSaveButton + HeaderActionsMenu (share, group chat).
  2. SessionStripView size="detail" stays as the top ribbon (never vanishes; falls back to the status word).
  3. MediaHero kind="map" (the SessionVenueCard static map when courtVenueId/location exists; TypeHero when neither) — title, meta = date · time · venue · court, badges = D-day (urgency), weather chip (conditions, only when weather != null), rain-risk badge. ✂ 장소 and 날씨 sections (their content moves here; the map remains tappable → routes.venue(courtVenueId) — keep the legacy destination until 3.3 folds).
  4. FlatColumn:
    • StatStrip viewer relation: 확정 인원 (who), 내 참가비 (cost.perPerson, $primary), 참석 마감 (when.rsvpDeadline).
    • LiveParticipantBarBanner tone="error" with the forfeit action (only in_progress && isConfirmedParticipant).
    • RefundBannersBanner tone="warning" (pending refund) / Banner tone="primary" with action → routes.sessionPayments (host obligation). ✂ raw XStack panels.
    • 공지 → Well tone="primary" with 📣 (only when description).
    • PaymentGateBanner unchanged (Banner/Callout), reserved shows the hold countdown + 송금 완료 action.
    • SectionBlock variant="flat" 참여 (meta N명 확정 · 예비 M, right 전체routes.sessionParticipants): RowList of ListRows (avatar · name (나) · tier · role sub · trailing RSVP badge) capped at 6 + "외 N명" row; tournament continue chip and the format-chooser ListRow (when canChooseFormat) follow.
    • SectionBlock variant="flat" 경기 (status !== 'cancelled'): SessionMatchupsCard content rendered boxless (its per-match rows become ListRows).
    • SectionBlock variant="flat" 정보: FactList — 형식·실력 (kind, eligibility.tierBand), 참가비 (cost), 참석 마감, 취소 가능 (cost.cancellationWindowHours), 주최 (host name · club) — the SessionIdentityCard fields, boxless. ✂ SessionIdentityCard box.
    • HostCalloutSection: pending-applications → Banner tone="primary" with action → routes.sessionHostDashboard; PickupHostCardListRow (avatar · host name · club sub · chevron → public profile).
    • SectionBlock variant="flat" 호스트 도구 (showHostTools): RowList of the existing six NavRows → ListRow (수정, 참가자 관리, 경기 규칙, 호스트 관리 + pending badge, 출석 확인 + count, 경기 종료).
    • SectionBlock variant="flat" tone="danger" 위험 구역 (hostCancelAction != null || showDelete): two Button variant="danger-outline" rows.
  5. BottomCtaBand when primaryAction != null; AttachStep coachmark wrapper preserved.
  6. SessionDetailOverlays unchanged.

CTA matrix — verbatim from primary-action.tsx (23 branches, refund-ack override first): open/host 초대하기 · open/confirmed 참가 취소 · open/waitlisted 대기 취소 · open/strikes≥2 disabled + cooldown text · guest rejected/pending/approved/none branches · tier-blocked disabled + band text · eligible 참가 확정하기 (+ push soft prompt) · locked/host 경기 시작 · locked/others 대기 등록 · in_progress/host 경기 보드로 관리 · in_progress/participant 실시간 스코어카드 보기 · completed/host+paid 정산하기 · completed/participant 매너 태그 남기기 · non-participant null. Helper text above a disabled button renders as cardMeta $textTertiary inside the band (unchanged).

Interactions. Map tap → legacy venue route; participant row → public profile (✚, today the roster card is static; keep disabled for guests per RLS); 전체 → participants screen; every NavRow destination unchanged; forfeit/cancel/delete/end confirms with their exact copy and destructive style; share → OS sheet (accessibilityLabel share-session stays for Maestro 06).

States. !screenReadySkeletonFlatPage(kind="map"); isErrorEmptyState full + retry; isPausedOfflineOfflineEmptyState; !session → not-found with back; RefreshControl on the scroll view (isRefetching/refetchSession).

Chrome. Fixed header (no collapse: the map hero is 140px, not a place banner). section-* testIDs preserved on each SectionBlock.

3.2 Directory venue detail (wave 2)

Model. useDirectoryVenueDetail discriminated union; sectionOrderForKind(facilityKind) (default / shop / practice orders, courts keeps its slot); getVenueTrust; corrections hooks; useStaticMapImage.

Layout.

  1. DetailShell ground="canvas" · title = venue name · right ✚ HeaderActionsMenu (공유, 신고하기 → ReportSheet; replaces the bottom "신고" entry).
  2. MediaHero kind="map": status = facility-kind badge (testID="venue.detail.kind"), title = name + edit glyph (venue.detail.edit.name), meta = address (venue.detail.address) + edit glyph + distance, badges = trust tier (VenueTrustBadge with source count), 미확인 muted variant. ✂ scrim text, ✂ VenueTrustCard (its ring/source chips move to a 정보 출처 fact row group below). Edit glyphs still gated by isFieldEditableFor(kind, field).
  3. ClosedVenueBannerBanner tone="warning" with 재개장 신고 action (kept above the column, full rail).
  4. FlatColumn (all wrapped in opacity={isClosed ? TERMINAL_MUTE_OPACITY : 1}):
    • StatStrip from statTiles (코트 · 종류(실내/실외 · 표면) · 1시간 요금 $primary; shop: 영업 · 결제 · 평점). ✂ local parts/stat-tile.tsx.
    • ActionIconRow: 길찾기 (openMapPlace) · 전화 (tel:, disabled without phone) · 저장 (toggleSaveVenue, active state) · 공유 · (재개장 신고 when closed). ✂ ActionTile.
    • Sections in sectionOrderForKind order, each SectionBlock variant="flat" + testID="venue.detail.section.<key>": 운영 시간 (FactList, meta = 지금 영업 중/마감, right = source label), 이용 요금 (FactList, 예약 link row $primary), 편의시설 (chip wrap + edit glyph), 결제 (chips), 링크 (RowList of ListRow with external icon + edit glyph), 별칭 (FactList), 평점 (StatStrip 2 cells or a single fact row), 리뷰 키워드 (chips), 블로그 리뷰 (ListRows → Linking.openURL), 사진 (thumb grid, $2 gap, 3-up), 코트 (RowList grouped by surface), 정보 출처 ✚ (FactList: 출처 N개 · 마지막 확인 · SourceChips).
    • ✚ 여기서 열린 일정 (SectionBlock with ListRow session rows, right = 전체) — only when the venue has public upcoming sessions; data from useClubSessions-style query by courtVenueId (new read, Protocol A bulk-safe).
    • 호스트 확인 → ListRow (🛡 확인해주기 · chevron → correction modal).
    • 검토 대기 중인 제안 (correctionRows.length > 0) → RowList of rows with 승인/거절 trailing buttons (approver) or status badge (own).
  5. BottomCtaBand ✚ pinned: 이 테니스장에서 일정 만들기 → routes.createSession('pickup', presets) (today an in-scroll full-width Pressable; pinned per the one-CTA law).
  6. VenueCorrectionModal mounted when userId != null (unchanged, all venue.correct.* testIDs).

Interactions. Every edit glyph → correction modal (same setCorrectionOpen); reopen report from banner action and icon row both → submitReopenReport; links/blog rows → Linking.openURL; map press → openMapPlace.

States. loading → SkeletonFlatPage(kind="map"); error → QueryErrorState + refetch; empty → EmptyState; ✚ RefreshControl (refetch); ✚ offline branch via isPausedOffline if the composite exposes it (it does not today — add to useDirectoryVenueDetail).

3.3 Venue detail (legacy court_venues) (wave 2)

Distinct entity (useVenue on court_venues, booking alert) reached from routes.venue (session detail's map, VenueRow default press). Two options; the spec takes A and records B:

  • A (this wave): re-skin on the same grammar without changing data: DetailShell ground="canvas" · MediaHero kind="map" (the VenueCard map) · StatStrip (표면 · 코트 · 요금) · ActionIconRow (길찾기 · 지도 · 예약 사이트 · 전화 — the four HeaderActionsMenu items become icons; menu ✂) · FactList 이용 요금 / 운영시간 / 예약 (일정 · 전화 · 링크 · 메모) · 편의시설 chips · 메모 paragraph · BottomCtaBand 알림 설정/해제 when hasBookingAlert. ✂ local DetailRow.
  • B (later, data work): fold into 3.2 once court_venues rows carry a venue_id to the spine; then routes.venue redirects to routes.directoryVenue and this screen is deleted. Tracked in the venue backlog, not this migration.

3.4 Match board (wave 4)

Model. useMatchBoardData (canManageLifecycle, isOpen/isLive/canAddRound/canEditLiveRound, rounds, settle-before-play pool, generator inputs).

Layout. DetailShell ground="canvas" (keyboardShouldPersistTaps="handled" kept) · TypeHero (eyebrow = date · venue, title = session title, badges = players · courts · format, right = progress ring completed/total) ✂ HeroCard · FlatColumn: UnsettledPoolNoticeCallout tone="warning"; RoundActionsRow unchanged (pill scroller + 라운드 추가 + 팀 재배치, each with its own gate) but rendered directly on the canvas with $3 bottom hairline ✂ its wrapper; MatchListSection three-way branch kept: empty-ever → EmptyState recessed with 대진 짜기; empty-this-round → Well text ✂ Card tone="flat"; list → MatchCards (already figures: a match card in a list is a legal box per §3.6 of the language doc); AllDoneBannerWell tone="primary" with ✓ + 세션 종료 button (allDone && isLive && canManageLifecycle). ✚ BottomCtaBand for 경기 시작 (isOpen && canManageLifecycle) — the one lifecycle CTA moves to the band; 라운드 추가/팀 재배치 stay inline because they are round-scoped tools, not the page's action. Overlays unchanged (TierPromotionSheet auto-open preserved).

States. SkeletonFlatPage(kind="type"); error + retry; ✚ offline branch (isPausedOffline is available on the composite, currently unread). RefreshControl kept. ✚ testIDs: matchboard.start, matchboard.round.<n>, matchboard.add-round, matchboard.live-edit, matchboard.end (Maestro 04 keys on text today; add ids without changing copy).

3.5 Record detail + record day (wave 4)

Model. useRecordDetailData (range, scope, recap, opponents, matchGroups, outcomeSegments, activityMetricDays, list controls); day variant inline.

Layout (period). DetailShell ground="canvas" title = period label · TypeHero (eyebrow = range, title = 승률 62% or N경기, meta = 24승 9패 · 3무, right = win/loss/draw donut 56px) ✂ OutcomeSummaryCard · FlatColumn: MatchHistoryScopeSelector chips (only when periodEntries.length > 0, $3 bottom hairline); StatStrip (ELO 변동 $primary/$error · 활동일 · 티어 or 연승); SectionBlock variant="flat" 활동 with the metric selector as right and ActivityMetricCalendar on the canvas (day cell → routes.recordDay(date, scope) — the only navigation, unchanged) ✂ the $surface panel-in-card; SectionBlock 상대 전적 (opponents.length > 0): RowList of ListRow (avatar · name · W–L sub · trailing win-rate + 3px progress) top 5, "더 보기" row expands to 10 (local state); SectionBlock 경기 목록 with MatchListControlsTrigger as right (badge = activeCount): grouped RowList of MatchTimelineRows (static rows, no press — unchanged), group headers as cardMeta eyebrows with $4 top gap. Day view: TypeHero (title = date, meta = W–L, right = donut) + StatStrip (ELO 변동 · 최종 레이팅 · 경기 수) + the match RowList. No CTA band on either.

States. QueryBoundarySkeletonFlatPage(kind="type"); empty → EmptyState full; ✚ RefreshControl (matchHistoryQ.refetch); ✚ testIDs records.scope.<key>, records.calendar.day.<date>, records.controls.

3.6 Club detail (already flat — wave 4 residue only)

Keep everything: PlaceShell, CollapsingHeaderLayer, use-collapsing-banner (TOP_BAR_HEIGHT 48, threshold 84, hysteresis 24, CHROME_CONTENT_GAP 12, per-tab offset memory, chips pin logic, a11y announcements, legacy tab remaps, non-member bounce). Residue: 기록 pane's two Card tone="default"RowList (ranking rows already flat; the head row and the empty-leaderboard card become a hairline head row and an EmptyState recessed). ✚ unify the duplicated 48 into one exported TOP_BAR_HEIGHT from collapsing-top-bar.tsx.

3.7 Public club profile (wave 4)

Model. usePublicClubProfileData (isMember bounce, join policy, joinRequestQ.status, isActiveGuest, guest sessions, stats, regular meets, gallery); growth events (profile_view, join_tap, application_start, application_submit, session_interest).

Layout. DetailShell ground="canvas" title = club name, right = HeaderActionsMenu (공유 only) · archived → Banner tone="neutral" · MediaHero kind="album" (paging gallery kept, dots + count pill), avatar = flag/logo bottom-left overlap, seamRight = capacity + faces pill (tap disabled for non-members), title = club name, meta = region · 설립 · members, badges = joinability (ClubJoinabilityBadge) · tier range · focus. ✂ ClubDetailHero one-card (this is its only remaining consumer); the guest fact grid (형식 · 실력 · 가입 방식 · 홈 코트) is re-homed as the first FactList below (nothing is lost). · FlatColumn: FactList 클럽 정보 (형식 · 실력 · 가입 방식 · 홈 코트 · 회비); SectionBlock 정기 일정 (DayDotsRow + next-session ListRowroutes.clubSession + session_interest, "외 N개" badge in meta); 참여 가능한 일정 (isActiveGuest: SessionCardV2 variant="embedded" rows + 방문 현황 Callout); 소개 (paragraph, only when description); 갤러리 (3-up thumbs, testIDs kept); application panel → Well containing the message StyledInput (club.public-profile.application-message, 500 cap) when applyPanelOpen. JoinCtaBand unchanged: the 7-state matrix (approval × pending/rejected/none × panel open; open → join mutation → replace to club; invite_only → routes.joinClub), testID club.public-profile.join-cta / application-submit.

States. QueryBoundarySkeletonFlatPage(kind="album"); not found → EmptyState; ✚ RefreshControl. Member bounce and join-success bounce effects unchanged.

3.8 Post detail (already flat — parity only)

Keep the zone anatomy exactly (post block → session link row → images → action row → load earlier → comments → composer), viewerUserId sentinel gating, reaction picker (5 types) vs feed toggle, mark-read-on-mount, KeyboardAvoidingView asymmetry. Changes: DetailShell ground="canvas" replaces the local $card override ✂; CommentComposer adopts BottomCtaBand metrics (top hairline, $4 sides, paddingBottom = max(insets.bottom, 16)) keeping the 40px send circle + hitSlop; zone paddings normalized to the rail ($4/$3, already). Section titles: none (a thread has no sections) — this page is the exception the grammar allows for a single-stream browsing surface.

3.9 Join review detail (wave 4)

Model. useClubContext (admin gate), useProfile, useMyJoinRequest, useProspectAttendanceSummary (RPC), useDecideJoinRequest, useProfilesByIds.

Layout. DetailShell ground="canvas" · TypeHero (right = avatar LG, title = name, meta = 게스트 · 첫 참석 date, badges = N회 참석) · FlatColumn: FactList 근거 (첫 참석 · 마지막 참석 · 함께 친 멤버 (count + AvatarStack as value) · 매너 태그 (badges as value) · 추천 (count + faces)) with the exact "no X" fallbacks; SectionBlock variant="flat" 신청 메시지 → paragraph or noMessage in $textTertiary. BottomCtaBand: 거절 (flex 1, neutral) / 승인 (flex 2, primary), both disabled={decide.isPending}; success → back().

States. offline → OfflineEmptyState; loading → SkeletonFlatPage(kind="type"); error → QueryErrorState (retries both); permission → EmptyState; not found → EmptyState. ✚ testIDs join-review.approve / join-review.reject.

3.10 Profile (own, tab root) (wave 3)

Model. usePlayerProfileData (Protocol G: all queries at root, one QueryBoundary), useMannerTagCounts, useAchievements (capped by entitlements.achievementsVisibleCount), useAttendanceSummary, useDmUnreadCount, computeStats (draw-excluded win rate, streak breaks on draw, 50+ cap).

Layout. MainTabShell ground="canvas" (title 프로필, right = DM icon with unread dot + settings gear, testID="tab.profile") · TypeHero (title = name, meta = club · since, badges = EloTierBadge (tap → TierInfoSheet, hitSlop 9) · TrustTierBadge · ReliabilityPill, right = avatar XL) · FlatColumn: ELO line ✚ as a FactRow-styled hero fact (displayMd $primary value + next-tier progress 3px bar beneath) ✂ hero card; StatStrip (총 경기 50+ cap · 승률 · 연승 / 이번 달 · 출석률 when present — two strips of 3 and 2–3, or one strip of 4 when no attendance) ✂ wrap="card" tiles; hasMatches === falseEmptyState recessed in place of the strips; SectionBlock 친구 (meta = count, right = 전체 → routes.profileFriends): AvatarStack row (prefetch on onPressIn), empty → Well with 찾기 → routes.profileFriendsAdd; SectionBlock 업적 (meta N/Total, right = 전체 → routes.achievements): recessed 44px tiles (icon) in a row + the latest as a ListRow; SectionBlock 출석 (own profile only — AttendanceSection self-gate kept; DataSection hideWhenAbsent): 8-week bar strip ($primary / $bg pills) + rate · streak FactRows + strike Callout tone="warning" when ≥ 1; SectionBlock 매너 태그: chip wrap with ×count; RowList menu: 통계 공유 (when profile loaded) · 연습 기록 · 환경설정 · 로그아웃 (danger, confirm). No CTA band.

States. QueryBoundary unified → SkeletonFlatPage(kind="type"); RefreshControl with the isFetched && fetchStatus === 'fetching' formula; Sentry.TimeToFullDisplay after settle; useScrollPerf('profile').

3.11 Public profile (wave 3)

Model. usePublicProfile (masked SECDEF view; masked fields hide naturally), useMatchHistory (50 cap parity), useFriendshipFriendshipButton 6-state matrix (loading skeleton · none → 친구 추가 · blocked → null · accepted → 친구 (confirm → remove) · pending outgoing → 요청됨 (confirm → cancel) · pending incoming → 거절 + 수락 immediate); DM icon → useInitiateDmThread.

Layout. DetailShell ground="canvas" (title = name, right = DM icon unless self/anon) · TypeHero (title, meta = region · district, badges = EloTierBadge · ReliabilityPill, right = avatar XL) · FlatColumn: ELO hero fact (displayMd $primary); StatStrip (총 세션 · 출석률 · 신뢰 등급 via valueSlot); FactList 플레이 (선호 형식 badge only when |singlesElo − doublesElo| > 50, 승률 line); ✚ SectionBlock 친구 beat only when isFriend (showSocialBeat) → Well "이미 친구". ✂ the "flowing card" rule and both cards. BottomCtaBand = FriendshipButton (hidden when isSelf || !myId). Screen-local ErrorBoundary kept.

3.12 Applicant / tournament (existing redesign boards)

applicant-detail-redesign is 3.9; tournament-detail-redesign (trophy hero, standings) follows 3.4's grammar: TypeHero + StatStrip + RowList standings; its board is regrounded in wave 4.


4. Interaction and behavior rules shared by all pages

  1. One pinned CTA per page (BottomCtaBand); secondary and outbound actions live in the header HeaderActionsMenu (); destructive items get the one divider and $error tint (unchanged). Pages with no page-level action have no band (record, profile, post).
  2. Tap feedback: Pressable variant="row" press state ($surfaceSecondary fill, no scale) for rows; ActionIconRow circles use pressStyle opacity 0.7; badges are never tappable except EloTierBadge (→ TierInfoSheet).
  3. Confirmations: every mutation that cancels, deletes, ends, forfeits, removes a friend, or cancels a request keeps its useConfirm dialog with destructive style and the exact copy in ko/*.ts.
  4. Haptics/toasts: unchanged per page (haptic.success() + toast on join/apply/confirm success).
  5. Pull-to-refresh on every scrollable detail page (✚ on venue, public club profile, record, join review); refreshing uses the isFetched && fetchStatus === 'fetching' formula app-wide (✚ lint: refreshing={q.isFetching} is an error).
  6. Offline: pages whose composite exposes isPausedOffline render OfflineEmptyState (✚ match board, venue detail); others fall through to error + retry.
  7. Focus: Protocol L freeze/thaw for tabbed panes (club detail); no per-page focus refetch (TanStack staleness).
  8. Deep links: unchanged route files and param guards; legacy remaps (news→home, ranking→records, tab=admin bounce) preserved.
  9. Accessibility: every ListRow with onPress has an accessibilityLabel = title + sub; StatStrip cells expose label: value; hero title is the page's accessibilityRole="header"; collapse/tab announcements on club detail unchanged; all Maestro-pinned testIDs listed per page are preserved verbatim; new ids added where a flow keys on copy today (match board, records, join review).
  10. Keyboard: pages with inputs (post detail composer, public club profile application panel, match board score entry) keep keyboardShouldPersistTaps="handled" and their KeyboardAvoidingView settings.

5. Build order and gates

WavePagesPrimitives it needsExit gate
PDetailShell/MainTabShell ground, FlatColumn, SectionBlock flat, FactRow/FactList, RowList, ListRow, StatStrip, MediaHero, TypeHero, ActionIconRow, Well tone, SkeletonFlatPage, two lint rules, ui-contractsyarn check; contracts test; wireframe kit regrounded
1session detailPcharacterization test (section ids + 23 CTA branches) green; preview OTA; device QA rung 6
2directory venue detail, venue detail (A)P + venue sessions readMaestro 11/12 green on emulator; OTA; device QA
3profile, public profilePtests; OTA; device QA
4record (+day), join review, match board, public club profile, club 기록 pane, tournament board regroundPMaestro 04/08/09 green; OTA
5flip ground default, delete prop, retire Card usages flagged by the lint, canon edits (§7 of the language doc), reground the six redesign boardsno-card-on-canvas passes with zero annotations outside the figure list

Each wave: board iteration with the owner → code → yarn check → screen-blueprint pointer → preview OTA → device QA. Visual acceptance per wave is on device (hairline contrast, hero legibility, dark mode).

Markdown remains the source of truth. Run yarn docs:check before handoff.