Skip to content

Screen Blueprint — archive of the pre-2026-09-11 single-file document

Status: Archived

Superseded on 2026-09-11 by the per-area current-state blueprints in docs/architecture/screen-blueprints/ (index: screen-blueprint.md). Kept verbatim for the change-log paragraphs; nothing here is instruction — ~30 entries described retired designs when it was archived.


Living document. Text-based wireframe of every screen — structural hierarchy, behaviors, navigation, empty vs populated states. Update when any screen changes.

Page-by-page QA (2026-09-11): the machine-derived companion lives in docs/workflows/page-inventory.json (every route: screen, shell, boards, change signal; regenerate with yarn docs:page-inventory), page-details.json (per-screen purpose, anatomy, states, actions, blueprint text with ratified/draft status, known issues), page-qa-checklist.json (generic + per-shell + per-area checks). Entries here marked draft in page-details.json were written from the component code, not ratified by the owner; a knownIssues entry naming doc drift means this file and the screen disagree — the screen wins.


1. Conventions

SymbolMeaning
Navigation destination
Conditional render (role, data, feature flag)
Call-to-action button
Section (collapsible or grouping)
Gap / inconsistency flagged for fix

Empty state strategy: Per empty-state-ux-analysis.md — structural chrome (headers, tabs, nav) always persists. Layout matches populated state spatially. Sections with no data use progressive disclosure (don't render) unless the section is a primary feature.

Terminology: Per terminology-guide.md — 클럽 = club tab, 일정 = session, 경기 = match/activity tab, pickup = 번개 (engineering term), 참가 = RSVP, 가입 = club membership.

Component shorthand: [C] = component name, size= / variant= per component-variant-guide.

Tab pattern: All tab-switching screens must use <TabView> (lazy-mount, PillNav-controlled) or <TabPanel> (always-mount, keeps all panels in the tree with display:none). Never use conditional rendering ({activeTab === "x" && <Component />}) for tab content that contains hooks — it breaks hook order on tab switch. Both components wrap content in QueryErrorResetBoundary + ErrorBoundary for per-tab error isolation.


2. Screen Blueprints


2.1 Tab Screens


Home — app/(app)/(tabs)/(home)/index.tsx

Header: [MainTabShell] title=일간/주간/월간 브리핑 (tracks selected granularity) | headerRight=[NotificationBell → Notifications; two-state badge via deriveBellState — amber attention when an admin-attention obligation is open, else red unread when there are unread signals, else no badge; bell glyph is 24px (size="xl", strokeWidth={1.8}) since the 2026-07-31 header-icon-system rework — was 28px ("2xl")] Layout: SafeAreaView bg=surface.secondary Rendering: home-feed-screen.tsx — a temporal briefing (granularity pill + one composed view per pane), not a card feed

Temporal Briefing Architecture

Home is not a vertically scrolling feed of independent cards — that design (10 priority-ordered cards, each null-returning) was retired in the 2026-04-17 rewrite. It is a temporal briefing: MainTabShell carries a granularity [PillNav] (일 · 주 · 월, useHomePeriodStore) that swaps in exactly ONE of three self-contained view components. Each view composes its own fixed section order — nothing contributes an item to a shared cross-view list. Structurally, FeedList's data is a single-row array per active pane ([{ kind: 'temporal', slice }]); the one row renders the whole view. FeedList here supplies pull-to-refresh + persistent header/footer slots, not a multi-item feed.

├─ [MainTabShell] title=일간/주간/월간 브리핑 (tracks granularity) · headerRight=[Bell→Notifications]
├─ [PillNav] tabs=["일","주","월"] → useHomePeriodStore.setGranularity (resets anchor to today)
└─ [FeedList] one row = the active temporal view
   ├─ header ▸ hasDisputedMatches || hasAdminExceptions — CROSS-PANE, persists across 일/주/월
   │  ├─ [HomeFeedSection tone="danger" title=needsAttention] → [HomeDisputedAlertCard] ▸ viewer has a disputed match score
   │  └─ [HomeAdminAttentionStrip] ▸ viewer admins/manages dues on a club with open exceptions (sibling card, no section chrome)
   ├─ item → HomeTodayView | HomeWeekView | HomeMonthView (per selected granularity)
   └─ footer ▸ CROSS-PANE → [HomeRecommendationsSection]
      ├─ [HomeClubDiscoveryCardContent] ▸ viewer has zero clubs
      └─ [HomeNearbyPickupsCardContent] ▸ open pickups exist in the viewer's region

일간 (HomeTodayView) — a navigable day, not hardcoded to "today" (see Period navigation below for the scrubber mechanics):

├─ [PeriodNavPanel] day scrubber
├─ [HomeFeedSection "진행 중"] ▸ a session is `in_progress` AND date = the REAL present day (clock-independent of the scrubbed anchor) → [LiveSessionStack]: one plain `SessionCardV2` per concurrent live session
├─ [HomeFeedSection "오늘 일정"] ▸ open|locked RSVP'd sessions remain on the scrubbed date (endTime > now) → [SessionCardV2] × N, host-first sort
├─ [HomeFeedSection "오늘 복기"] ▸ any `completed` session the viewer participated in on the scrubbed date → [SessionCardV2] × N, each with a status note (precedence: 점수 이슈 → 확인 필요 → ELO ± → 완료)
└─ ▸ all three empty → SkeletonCard (loading) | QueryErrorState (fetch failed) | [Pressable→/activity] "오늘 예정된 경기가 없어요" (+ first-idle FAB coachmark for zero-club users)

주간 / 월간 (HomeWeekView / HomeMonthView) — same two-section shape, ranged to the scrubbed week/month; live sessions are intentionally excluded (오늘 only):

├─ [PeriodNavPanel] week/month scrubber
├─ [HomeFeedSection "이번 주 기록"/"이번 달 기록"] → [RecapSummaryCard]: header (label + 자세히 → `routes.record`) + hero row (40px `SegmentedDonutChart` + "N경기 X승 Y패 Z무" + 승률 [+ N일 활동 for month] + ELO Δ pill) + day-dot strip (week, 월–일) / month dot-grid (7×weeks); zero matches → the SAME card dimmed to opacity 0.3 with a centered "아직 이번 주/달 기록이 없어요" overlay pill (whole card stays pressable through to the empty records page)
└─ [HomeFeedSection "이번 주 예정"/"이번 달 예정"] ▸ any open|locked RSVP'd session in range → [SessionCardV2] × N, host-first sort, unbounded (no top-N cap)

Both the recap and the upcoming-sessions query are server-ranged to the scrubbed {from, to} window (not a client-filtered fixed page), so navigating deep into the past no longer under-counts.

Key principle: sections no longer decide their own feed position across a shared list — each temporal view owns a fixed section order, and a section renders only when its own condition holds (progressive disclosure WITHIN the view). The FeedList header/footer are the only slots that persist across all three granularities; everything else lives inside whichever view is active.

Session cards across all three views are the canonical SessionCardV2 (SessionCardV2 slice ③, 2026-07-27) — implementation at packages/app/src/presentation/components/sessions/session-card-v2.tsx, exported via @twomore/app. SessionCardV2 is a zero-data-hooks projection of SessionInfoModel: a conditional attention TopStrip (from deriveSessionAttention, see Conventions › Session Surfaces) → a 64×64 venue satellite tile + venue/when/chips lines → a closing status+join line (host/RSVP/saved badges + RecruitmentMeter), the whole card one Pressable tap target through to session detail. Live sessions get no separate "live card" component — LiveSessionStack renders a plain SessionCardV2 per concurrent live session (the live attention kind drives its own TopStrip, pulsing-dot treatment). The prior SessionCard (session-card.tsx, 1,871-LOC subtree) remains in the tree only as a rollback artifact — no live render site uses it (see session-card-v2-decision.md).

Files: packages/features/home/src/home-feed-screen.tsx (shell), views/{home-today-view,home-week-view,home-month-view}.tsx (panes), cards/{live-session-stack,recap-summary-card,home-disputed-alert-banner,home-admin-attention-strip}.tsx, shared/{home-feed-section,home-recommendations-section}.tsx.

needsAttention header slotHomeDisputedAlertCard (viewer's own disputed match scores) renders inside a HomeFeedSection (tone="danger", title t().home.sections.needsAttention) when there are disputed matches. HomeAdminAttentionStrip (R3, 2026-07 owner QA round 3) renders as a SIBLING standalone card below it, deliberately OUTSIDE that HomeFeedSection — it is a pointer, not a permanent titled section. The whole slot is null when neither condition holds. The strip itself is a warning-tinted card ($badgeWarningBg/$badgeWarningText, the canonical warning intent pair): a headline row ("알림 N건", bold, N = the total attention-item count across all admin clubs, R5) + trailing chevron, and (R5) a second row of aggregated category badges (surface variant, e.g. 가입 2 · 송금확인 1 …, summed across the viewer's admin clubs) — still no per-club breakdown, that stays in the Notification Center. Renders null when there is nothing to show. Fed by useMyAdminClubAttention(userId) (packages/app/src/presentation/hooks/queries/use-my-admin-club-attention.ts) — a bulk useMyClubs → per-club membership → per-admin-club useClubAdminSnapshot fan-out over the SAME membership key/port useClubRole uses, deliberately NOT useHomeData's AggregateRole (hardcoded to the first 3 clubs, silently drops admin status beyond them). Row model (buildClubAdminRowModel/resolveClubAdminRow) lives in packages/app/src/presentation/utils/club-admin-attention.ts — hoisted out of features/clubs into @twomore/app so features/home can reuse it (ARCH-2: feature packages cannot import a sibling feature). The per-club breakdown row (club name + "처리 필요 N건" + quiet breakdown, capped at 3 rows + "외 N개 클럽" overflow, deep-linking to that club's 관리 탭 via routes.club(clubId, { tab: 'admin' })) now lives ONLY in the Notification Center's admin-attention rows — see below. The bell badge count on MainTabShell's headerRight = unread signals + sumAdminAttentionTotal (item-count across the viewer's admin clubs, not club-count — PS8 §2.2/§3.2), so it reflects both destinations behind the bell.

Session cards use the canonical SessionCardV2 — see "Temporal Briefing Architecture" above for the current implementation path and the live-card model (no separate LiveSessionCard component; see also Conventions › Session Surfaces).

Period navigation (오늘/이번 주/이번 달 as a NAVIGATION AXIS)

The 홈 pill row (일 · 주 · 월) no longer just switches which fixed period you view — it picks the granularity; within a granularity you scrub an anchor date backward/forward, like a calendar app, not three static filters.

├─ [PillNav] tabs=["일", "주", "월"] → useHomePeriodStore.setGranularity (resets anchor to today)
├─ [PeriodNavPanel] one row: ‹  {label}  ›  (+ inline "오늘/이번 주/이번 달" marker chip ▸ anchored on now)
│  ├─ side arrows (‹ ›) → step(±1) — prev/next whole unit of the current granularity
│  └─ center tap → toggles the scrubber open below (no chevron, no "오늘로" reset chip —
│     the granularity pill itself is return-to-now: re-picking/switching resets anchor=today)
└─ ▸ scrubberOpen — one picker per granularity, tap-to-open, auto-collapses on select (pickAnchor):
   ├─ day   → [DayCalendarPicker] Sunday-first month grid, Korean weekend colors, session dots
   │           (datesWithSessions from useMyParticipatingSessionDates, ranged to the full
   │           42-cell grid via getMonthGridRange — past attended + future scheduled, so both
   │           past/future months AND muted adjacent-month spillover cells light up)
   ├─ week  → [WeekMonthPicker] year·month stepper over selectable Mon–Sun week rows, each
   │           with a trailing "N개" schedule-count badge (bucketSessionCountsByWeek over the
   │           same source; 0/absent → no badge)
   └─ month → [MonthYearPicker] year·month grid, each cell showing "N개" in the reserved
              marker line when it has sessions (bucketSessionCountsByMonth over the browsed
              year; "이번 달" wins the line on the current month, count wins otherwise)

Titles read the axis, not a fixed period: 일간/주간/월간 브리핑 (not "오늘의/이번 주/이번 달 브리핑"). The day view (HomeTodayView) is now navigable — Live section stays gated to the actual present day (in_progress && date === today, clock-anchored, unaffected by scrubbing); Scheduled/recap sections read the scrubbed targetDate via useActiveAndRecentSessions/useTodayRecapSessions. Week/month recap (RecapSummaryCard / month view) fetch a real server-side {from, to} date range (useMatchHistory) instead of client-filtering the last ~50 matches, so deep-past periods stop under-counting.

Files: packages/app/src/presentation/stores/home-period.store.ts (MMKV-persisted granularity; anchor/scrubberOpen reset each launch), packages/app/src/presentation/components/period-nav-panel.tsx, packages/features/home/src/components/{month-year-picker,week-month-picker,day-calendar-picker}.tsx.

History

Change log (2026-04-17): Complete rewrite. Replaced zone-based HomeTemplate (4 fixed zones + useHomeZones + computeUserState 3D engine) with Toss-style feed. 10 independent cards, each null-returns when irrelevant. No state classifier. Cards own their own hooks. Change log (2026-04-20): Session cards migrated to canonical SessionCard primitive. SessionCard auto-fetches weather (icon in SessionHeader Row 1) via useSessionWeather — no caller props required. Week/month hero stat blocks are now Pressable/records/[period]. HomeWeekView + HomeMonthView show top-3 RSVP'd upcoming sessions + "더 보기" → /activity (replaces single next-session teaser). HomeLiveAlertBanner mounted at the top of HomeFeedScreen above PillNav — renders only when a user session has status=in_progress; shows compact score rows (cap 3) + tap → /sessions/[sessionId]/scorecard. HomeTodayView empty-state "오늘은 경기가 없어요" is now Pressable/activity. SessionCard gained optional footer prop; LiveSessionCard uses it to inject match rows. Badge height parity: competitive/LIVE/tier badges bumped to size="sm". Change log (2026-06-11, WeekRecapCard ghost empty state): When entries.length === 0 the card no longer disappears or shows a standalone EmptyState. Instead it renders the SAME infographic layout ghosted at opacity={0.35} — outcome section (placeholder donut ring with a single flat-grey segment + "0" count + "0%" win-rate) and activity section (inactive calendar grid), matching the PersonalRecordsSection teaser-mode idiom. Below the ghosted panels, at full opacity, an inviting prompt ("이번 주 첫 경기를 기록해 보세요" / "이번 달 첫 경기를 기록해 보세요") is shown, followed by a 번개 찾기 CTA row (Pressable variant="row"routes.activity) with a ChevronRight icon. The details-footer link row (전적 보기 → routes.record) renders after the ghost panels as in the filled branch.

Change log (2026-07-04, period navigation axis): 오늘/이번 주/이번 달 became the navigable granularity/anchor axis described above (useHomePeriodStore + PeriodNavPanel + MonthYearPicker/WeekMonthPicker/DayCalendarPicker). WeekRecapCard now renders through the shared canonical RecapOutcomePanel (donut + win-rate + full-width 승·패·ELO·연승 breakdown strip) — see Record Detail change log for the sibling migration that killed the last duplicated donut block.

Change log (2026-07-31, picker schedule indicators): WeekMonthPicker/MonthYearPicker gained the same schedule awareness DayCalendarPicker already had, via a new pair of pure bucket-count selectors (bucketSessionCountsByWeek/bucketSessionCountsByMonth, packages/app/src/presentation/utils/session-date-buckets.ts). RsvpRepositoryPort.findParticipatingSessionDates no longer dedupes server-side — it returns one date per participated session (a same-day double-booking now appears twice) so week/month rows can bucket-count real session counts, not just distinct days; the day picker still collapses the raw array to a Set for presence. Added getMonthGridRange (packages/app/src/config/date-utils.ts) so the day picker's query is ranged to the actual 42-cell grid (not just the calendar month), fixing muted adjacent-month spillover cells never lighting up.

Change log (2026-07-10, R2-3 admin exception card): the needsAttention header slot — previously disputed-matches only — now also carries HomeAdminExceptionCard, per club-ops-backlog visibility for admins/owners. See the "needsAttention header slot" note above for the full model.

Change log (2026-07-11, A2 admin exception → quiet strip): HomeAdminExceptionCard retired. The needsAttention header slot now shows only a quiet one-line HomeAdminAttentionStrip pointing at the bell/Notification Center; the per-club breakdown (row composition unchanged) moved into the Notification Center's admin-attention rows — see the Notifications entry below. The bell badge count now includes the admin-attention club count alongside unread signals.

Change log (2026-07-11, R3 owner QA round 3): HomeAdminAttentionStrip became a warning-tinted card ($badgeWarningBg/$badgeWarningText) and moved OUTSIDE the needsAttention HomeFeedSection (no section title chrome — it's a pointer, not a section); content simplified to "알림 N건" (bold) + trailing chevron, no dot, no cta copy. HomeDisputedAlertCard keeps its existing HomeFeedSection wrapper unchanged.

Change log (2026-07-11, R5 owner QA round 5): HomeAdminAttentionStrip now takes items (not a bare count) — the headline count is the total attention-item count across the viewer's admin clubs (was the club count), and a second row of aggregated category badges (surface variant) renders under the headline. See the Notifications entry below for the sibling admin-row dismiss/first-seen/surface-badge changes this shares a source hook with.

Change log (2026-07-27, SessionCardV2 rollout, d21f1fba): every session-list render site across Home (진행 중/오늘 일정/오늘 복기/이번 주·달 예정), Club Home Tab, Club Session List, and 경기 (Activity) now renders SessionCardV2 instead of the retired SessionCard — see the updated "Session cards" note above and Conventions › Session Surfaces for the model.

Change log (2026-07-31, RecapSummaryCard): WeekRecapCard (RecapOutcomePanel donut + full-width stat strip + ActivityMetricCalendar + scope selector + ghost empty state) retired from Home — its full analytics live on in RecordDetailScreen (OutcomeSummaryCard/DaySummaryCard, same RecapOutcomePanel), which is exactly where the new card's 자세히 row drills into. Home week/month now render RecapSummaryCard (packages/features/home/src/cards/recap-summary-card.tsx, wireframe-first per docs/twomore_design_system/wireframes/home.jsx RecapSummaryCard/RecapSummaryCardMonth): a header row (label + 자세히 + chevron) → a hero row (label-less 40px SegmentedDonutChart reused directly, not RecapOutcomePanel — its fixed donut+hero+strip layout doesn't fit a compact card) + record + win-rate (+ "N일 활동" for month) + an ELO Δ pill → a 7-day dot strip (week) or a sequential 7-col dot grid (month, same non-weekday-aligned chunking ActivityMetricCalendar.metricRows() uses). Empty state is the SAME card at opacity 0.3 with a centered overlay pill — no separate ghost layout or CTA row. Whole card is one Pressableroutes.record(period, {start, end}).


Clubs (클럽) — apps/mobile/app/(tabs)/(clubs)/index.tsxClubListScreen (packages/features/clubs/src/club-list-screen.tsx)

Header: [MainTabShell] title=t().clubList.pageTitle ("클럽") | headerRight=[ClubJoinHeaderAction] (HeaderIconButton Key-icon, a11y label t().clubList.joinWithInviteCode "초대 코드로 바로 가입하기" → routes.joinClub()) Layout: SafeAreaView (edges=['top']) bg=$background (packages/ui/src/main-tab-shell.tsx:55 — NOT surface.secondary, the old claim here) Bottom tab label: literal '클럽' (hardcoded in Tabs.Screen options, not t()-routed), icon=Shield, lazy: trueapps/mobile/app/(tabs)/_layout.tsx

├─ [MainTabShell]
│  ├─ title "클럽" + headerRight [ClubJoinHeaderAction]
│  ├─ [SegmentedTabs] — PillNav chrome + lazy-mount-on-first-visit pane cache
│  │  (`display:none` toggle on the inactive pane; `preloadTabs="visited"`)
│  │  tabs=[t().clubList.tabMyClubs "내 클럽", t().clubList.tabDiscover "탐색"]
│  ├─ renderTab(activeTab) → <MyClubsTab/> | <DiscoverTab/>  (each wrapped in [PerfProfiler], no user-visible effect)
│  └─ bottomCta ▸ activeTab === 'my' → [BottomCtaBand][ActionButton lg+primary] t().clubList.createClubCta "클럽 만들기" → Create Club Wizard

Card anatomy is shared by both tabs — a single isMember-gated ClubCard (packages/features/clubs/src/shared/club-card.tsx; MyClubCard/DiscoverClubCard as separate components were retired 2026-07-20). See Conventions › Clubs for zone composition — this entry covers the TAB's list structure only.

Correction (verified against club-card.tsx, 2026-09-11): the card anatomy narrative in the History section below stops at 2026-08-03 and is stale on two checkable points the code has since moved past (Phase 5 "iteration 22" + D23-E, both undocumented here since card anatomy is canon/clubs.md's job, not this file's — flagging only because the History text below asserts the opposite): (1) the member branch's up to-2 session rows are now render-only — the rows themselves don't navigate, only the card body does (the 2026-08-03 note's "each row navigating straight to that session's detail" no longer holds); (2) ClubAdminRow is retired from the card body entirely — admin obligations now surface only as a small alarm-count circle overlaid on the photo seam's top-right corner (the 2026-08-03 note's "ClubAdminRow stays last" no longer holds). The discover branch also gained a terms row (🔒 join policy · 💳 dues, owner direction 2026-09-07) below its identity fact chips, which predates the last History entry.

내 클럽 Tab (MyClubsTab)
├─ [FeedList] perfLabel="clubs-mine" — header ALWAYS renders (loading/error/empty/populated alike)
│  ├─ header → [MyClubListControls] → [SectionHeader variant="list"] title=t().clubList.sectionMyClubs "내 클럽"
│  ├─ item → [ClubCard isMember=true] × N → Club Dashboard
│  │  sort: `sortClubsByNextSessionAndRegion` — soonest upcoming session first (clubs with none sort last), tiebreak region → district → name
│  └─ pull-to-refresh; contentContainerStyle paddingBottom=100 (clears the BottomCtaBand)

States:

  • Loading (first fetch, no cached data) → 4× SkeletonCard; header still renders
  • Error (fetch failed, no data) → QueryErrorState (retry); header still renders
  • Empty (0 joined clubs) → [EmptyState] icon=Users variant="full" title=t().clubList.noMyClubs "아직 가입한 클럽이 없어요" subtitle=t().clubList.noMyClubsSubtitle "테니스 크루를 탐색하고 첫 클럽에 가입해보세요" actionLabel=t().clubList.noMyClubsCta "클럽 탐색하기" → appRouter.push(routes.discoverClubs). ⚠ Pushes OUT to the separate /clubs/discover screen (ClubsDiscoverScreen), not an in-shell switch to the 탐색 pill — and it's the ONLY CTA (EmptyState has no secondary-action slot; the old doc's second "초대코드 입력" button does not exist here — that entry point is the always-visible header Key icon instead)
  • PopulatedClubCard list, session-sorted (see above)
탐색 Tab (DiscoverTab)
├─ [FeedList] perfLabel="clubs-discover"
│  ├─ header → [ClubDiscoveryControls] → [ExpandableFilterBar] search (t().discovery.searchPlaceholder "클럽 이름, 지역으로 검색") + collapsible [DiscoverFilterPanel] (7 chips: 종목 · 지역 · 시군구 ▸ region selected · 티어 range · 요일 · 시간대 · 가입 방식) + reset (t().clubList.filterClear "초기화")
│  │  └─ [SectionHeader variant="list"] title=t().clubList.sectionDiscoverResults "추천 클럽"
│  ├─ item → [ClubCard isMember=false] × N → Public Club Profile (preview)
│  │  sort: `sortClubCardModelsForDiscovery` — discoveryScore desc, tiebreak next-session asc, then region → title
│  │  excludes clubs the viewer already belongs to (`useViewerDiscoverClubs` diffs against `useMyClubs`)
│  ├─ infinite scroll → onEndReached → fetchNextPage (cursor pagination via `useDiscoverClubsInfinite`)
│  └─ pull-to-refresh

States:

  • Loading → 4× SkeletonCard
  • ErrorQueryErrorState (retry)
  • Empty (0 results) → [EmptyState] icon=Compass variant="compact" title=t().clubList.noDiscoverResults "탐색 가능한 클럽이 없어요" subtitle=t().clubList.noDiscoverResultsSubtitle "새로운 클럽이 곧 등록될 거예요" — same copy regardless of active filters (the filter-aware noDiscoverResultsFiltered/noDiscoverResultsFilteredSubtitle strings exist in i18n but are wired only into the separate clubs-discover-screen.tsx, not this tab)
  • PopulatedClubCard list + footer loading-more spinner (t().common.loadingMore "더 불러오는 중") + scroll hint (t().common.scrollForMore "아래로 스크롤해 더 보기") once content overflows the viewport
Data
  • useAuthUserId() — viewer id, threaded into every ClubCard for role/RSVP-aware affordances
  • useMyClubs(userId)packages/app/src/presentation/hooks/queries/use-clubs.ts:21, staleTime: STALE_TIME.stable; doubles as the 탐색 tab's membership-exclusion source
  • useClubUpcomingSessions(userId)use-sessions.ts:160, staleTime: STALE_TIME.realtime; reduced client-side via buildUpcomingSessionsByClub (shared/club-card-summary.ts) into a per-club Session[] capped at 2, soonest first (Phase 5's two-session upgrade — buildNextSessionByClub/buildSessionCountByClub are no longer called from this screen, see the Gaps note below). useSessionCardModels (@twomore/app) then bulk-models BOTH capped sessions per club in ONE call for the whole 내 클럽 list (Protocol A) into a Map<sessionId, SessionCardModelEntry>, passed straight through as ClubCard's sessionCardModelsById prop — the card's clickable session rows never re-derive a session model.
  • useApprovedClubMediaByClubIds / useApprovedPublicClubMediaByClubIds — bulk per-parent-scope siblings (Protocol A), use-club-media.ts:76 / :93, staleTime: STALE_TIME.frequent
  • useClubDiscoveryStatsByClubIds — bulk capacity/avatar-stack stats for the cards, use-discover-clubs.ts
  • useViewerDiscoverClubs(filters, searchQuery) (shared/use-viewer-discover-clubs.ts) — composes useMyClubs (exclusion) + useDiscoverClubsInfinite (use-discover-clubs.ts:38, cursor-paginated, staleTime: STALE_TIME.frequent) + the two bulk hooks above; also fires useRecordClubGrowthEvent (discovery_appearance) for the first 12 visible clubs per data change — growth analytics, not user-visible
Gaps
  • CTA visual mismatch ✅ Resolved — all CTAs use Button component with consistent variant/size
  • Conventions › Clubs still names MyClubCard/DiscoverClubCard as separate components — both were merged into one isMember-gated ClubCard (commit b5a93851, OTA group c45e73be→4b4eca36, 2026-07-20). Out of scope here (card anatomy is that doc's job, not this one's).
  • (tabs)/(clubs)/discover.tsx duplicate discover surface ✅ Resolved 2026-09-08 — ClubsDiscoverScreen (a verbatim copy of the 탐색 pane) was retired; routes.discoverClubs = /clubs?tab=discover opens the pane, and /clubs/discover (mobile + web) is a redirect alias (duplication audit).
  • ⚠ Phase 5 (e1bd5ed4) deleted the card's unread-indicator zone but left its data plumbing live with zero remaining call sites: useUnreadPostCountsByClubIds (use-posts.ts:191, still exported from @twomore/app), the unreadPostsIndicator i18n key, and buildSessionCountByClub (shared/club-card-summary.ts:75). Cleanup-candidate list — see club-surfaces-unification.md Phase 5 slice E note.
History

Change log (2026-04-10): Tab restructured: 모임 → 클럽 (bottom tab label). Sub-tabs simplified to 내 클럽 / 탐색 (removed separate 번개 tab). Club cards now use rich 3-row layout with ClubBannerIcon, TierRangeBadge, FORMAT_BADGE_VARIANT, member count, next session. MeetFeedCard, ClubFeedCard deleted. Change log (2026-06-12): Club card anatomy unified to a fixed-slot design driven by buildClubCardModel. MyClubCard: 5 slots (banner → identity row → people line → schedule line → chip strip). DiscoverClubCard: 6 slots (same + tagline slot 3 + ClubJoinabilityBadge overlaid top-right on banner). Banner mediaUrl precedence: featured-kind media → any approved media → bannerUrl → preset. Schedule line is flat text (label + value·detail, no bordered box). Chip strip fixed order: format → tier → focus; tags and proof-signal badges removed. Discover card routes to PublicClubProfileScreen (preview) not a dead-end code prompt.

Change log (2026-06-12 v2 — visual vitals): The people line and 정기-모임 text are replaced by graphics: a vitals row (32px capacity ring + N/M count on the left, AvatarStack of ≤5 member avatars with +N overflow on the right — fed by migration 00259's member_avatar_urls) and a DayDotsRow (월→일 dots, primaryDay filled). The schedule line renders only for an actual next session (tone === 'next'); the chip strip shrinks to format → tier (focus chip is profile-only). The card's only prose is the tagline (discover) and the next-session line.

Change log (2026-06-12 v3 — owner caveats): Schedule area split into labeled 정기 일정 (DayDotsRow fullWidth + time-slot meta) and 다음 일정 sections. Chips become two categorized groups from model.chipGroups — 플레이 (format · tier) and 사람들 (member composition · focus · top 2 tags) — social signals are deliberate card content. Discovery avatar stacks respect profile_visibility.avatar (migration 00260; default public, controllable from 설정 → 프라이버시). Public profile rework (same visual language, persuasive alive→social→proof→act order) follows in the same batch.

Change log (2026-06-12 v4 — recomposition): Cards collapse to 4 zones (hero-social + week strip): AvatarStack (outlined) straddles the banner bottom edge; identity row + tagline are the only prose; the week strip drops its text labels — DayDotsRow + time slot with the next session as a DateTile calendar glyph (new @twomore/ui primitive; date·time·location text line removed); one quiet utility row (24px capacity ring + N/M + categorized chips). Shared composition in club-vitals.ts.

Change log (2026-07-11, B1 role badge + pressable 관리 로우): MyClubCard gets a ClubRoleBadge ("운영", accent variant) overlaid position="absolute" top="$2" right="$2" on the banner for owner/admin viewers — the same slot ClubJoinabilityBadge occupies on DiscoverClubCard, empty for a plain member. The card's 관리 로우 (ClubAdminRow) is now its own nested Pressable variant="row" stateLayerroutes.club(clubId, { tab: 'admin' }) (the club detail route now supports a tab deep-link param), and its breakdown renders as Badge variant="live" size="sm" chips (one per non-zero part) instead of a joined text string.

Change log (2026-07-11, R3 owner QA round 3): ClubAdminRow simplified to the SAME warning-tinted treatment as the Home admin strip — "알림 N건" (bold) + trailing chevron only, no dot, no breakdown Badge chip strip. ClubAdminRowModel.parts/breakdownText are unchanged (still consumed by the Notification Center's admin-attention rows); this row just stops rendering them. The 운영 banner badge is untouched.

Change log (2026-07-21, re-grounded from code): This entry described a retired design (nonexistent app/(app)/(tabs)/(clubs)/index.tsx route, [TabHeader] title="모임", a [FAB], two named card components MyClubCard/DiscoverClubCard with hand-duplicated anatomy, and a two-CTA empty state) — same drift class as the old Home entry. Re-derived from club-list-screen.tsx: shell is MainTabShell (title "클럽" + header Key-icon join action), sub-tabs render through SegmentedTabs (PillNav chrome + lazy-mount pane cache), the create-club action is a BottomCtaBand (not a FAB, and 내 클럽-tab-only), and both tabs now share one isMember-gated ClubCard. Added explicit loading/error/empty/populated states per tab (previously undocumented) and the data-hook list.

Change log (2026-08-01, §3.10 v21 compact member card — BUILT): Banner goes 3:1 → 5:1 on BOTH ClubCard branches (ClubBannerPlaceholder's new aspectRatio prop, default 3 unchanged for the club-detail hero + public profile hero). The member branch's 다가오는 일정 zone (previously SectionEyebrow + Well + the bespoke ClubNextSessionWell, ~401px card) retires in favor of a plain Divider + SessionCardV2's new variant="embedded" (no outer Card chrome; same conditional-strip → venue-tile-left body → RecruitmentMeter anatomy as the feed card) — "E1 flat embed" per docs/architecture/club-surfaces-unification.md §3.10. ClubNextSessionWell (shared/club-next-session-well.tsx) is deleted; its only consumer was this card. Data spine: MyClubsTab bulk-models just each club's next session via useSessionCardModels (see Data above) and threads the result through ClubCard's new nextSessionCardModel prop — the discover branch is unaffected (it never rendered a next-session zone). Discover-card chip treatment (§3.10 "Open decision B") is UNDECIDED and untouched by this change.

Change log (2026-08-02, §3.12 ROLE-DEFINED CARDS — FROZEN, BUILT; superseded 2026-08-03, see below): §3.10's shared banner/straddle skeleton is gone on BOTH branches — the two anatomies now diverge below a shared identity floor (logo 36px · name · region·member-count) instead of sharing a banner. Member tracker: no banner, no tier badge; identity row (+ ClubRoleBadge for admins + a "새 글 N" unread indicator, bulk-fetched via the new useUnreadPostCountsByClubIds) → the SAME conditional deriveSessionStrip/SessionStripView attention band as before, now followed by a compact venue-led row (VenueTile's new size prop at 48px + venue/date-time/format/plain-text-participation text) instead of the full embedded SessionCardV2 card — the embedded variant and its data spine (useSessionCardModels) still supply the underlying SessionCardModelEntry, just rendered narrower. No next session → a quiet "예정된 일정 없음" line. ClubAdminRow unchanged, now the card's last zone. Discover glimpse: photo only when model.isRealImage (new field — true photo/banner, never the preset-gradient placeholder) renders a bare 16:9 image + recruit-badge overlay; no photo ⇒ no band at all (the majority-state design, not a fallback). Body swaps the old fact-line/tagline/joinPolicy-dues-chip-row for graphics: AvatarStack (real faces, ≤4 photo/32px · 5 bare/44px) + count, a compact DayDotsRow (new variant="compact" — fixed-size non-stretching cells, model's new regularMeetDays field), a quiet terms line (region · dues · join policy when non-open), and character-only chips (no more joinPolicy/dues badges in the chip row). ClubBannerPlaceholder's straddled logo/BannerCapacityCluster overlay is gone from both branches (now dead — zero remaining consumers of BannerCapacityCluster in the app). SessionCardV2's embedded variant has zero remaining club-card call sites after this change but is kept (other future consumers).

Change log (2026-08-03, Phase 5 FINAL card compositions — SHIPPED, commits 7299ab89/e1bd5ed4): club-card.tsx rewritten again, replacing §3.12's shared-identity-floor anatomy with a shared PHOTO+SEAM floor. Both branches now open with a 3:1 photo window (real photo → banner → defaultClubPhoto(clubId), a deterministic pick from a curated stock set — ALWAYS a real image) and a seam straddling the photo/body boundary: a 40px flag circle bottom-left (club logo, else a generative FlagMedallion) + a capacity chip bottom-right. Headline on both branches: name + a $success "N분 전 활동" activity indicator. Member branch: no region/tier in the headline; below it, up to 2 clickable session rows (upgraded from §3.12's single venue-led row) — status dot + date (62px) + time range (82px) + venue (flex) + count, all cardMeta gray-ladder, each row navigating straight to that session's detail; ClubAdminRow stays last. The §3.12 attention-strip/SessionStripView band and the "새 글 N" unread indicator are BOTH gone (useUnreadPostCountsByClubIds has zero remaining callers — see Gaps). Discover branch: AvatarStack is gone, replaced by a facts line (📍 region · 👥 composition + age band) + a compact DayDotsRow appearance="dotWeek" rhythm row + one three-tone chip row (character neutral → time-bucket primarySubtle → terms outline); no exact times anywhere (click-through rule). Data spine: MyClubsTab now threads upcomingSessions/sessionCardModelsById (2-session map) instead of the old single nextSession/nextSessionCardModel props. Full composition + rationale: Conventions › Clubs; build trail: club-surfaces-unification.md § "Phase 5".


경기 — apps/mobile/app/(tabs)/(sessions)/index.tsx · apps/web/app/(tabs)/activity/page.tsx

Implementation: ActivityScreen at packages/features/activity/src/activity-screen.tsx Header/shell: MainTabShell<ActiveTab> title=t().activityScreen.pageTitle ("경기") — the tab switcher is the SHELL's own tab row (no in-body PillNav), 4 tabs, preloadTabs="visited" (lazy-mount-on-first-visit, cached thereafter) Layout: per-tab FeedList (virtualized)

Verified against activity-screen.tsx, 2026-09-11 — this entry previously described a retired 2-tab/FAB design; corrected below.

├─ [MainTabShell] tabs=[내 경기, 번개 찾기, 저장됨, 테니스장 찾기]
│  (`ActiveTab` = 'my_matches' | 'pickup_finder' | 'saved' | 'courts'; `?tab=` route
│  param opens a pane directly, e.g. `routes.activityPickups`)
├─ 내 경기   (MyMatchesTab)    → [FeedList] the viewer's RSVP'd sessions (`useMyParticipatingSessions`)
│  ├─ header → [ActivityListControls] filters (format/date)
│  └─ item → [SessionCardV2] × N (bulk-modeled via `useSessionCardModels`) → routes.clubSession
├─ 번개 찾기 (PickupFinderTab) → [FeedList] infinite, open pickups (`useOpenPickupsInfinite`)
│  ├─ header → [ActivityListControls] filters + GPS-defaulted region (dismissible hint)
│  ├─ excludes sessions the viewer already RSVP'd (checked against `useMyRsvps`)
│  └─ item → [SessionCardV2] × N → routes.sessionPreview
├─ 저장됨   (SavedSessionsTab) → [FeedList] bookmarked sessions (`useSavedSessionIds` → `useSessionsByIds`)
│  └─ item → [SessionCardV2] × N → routes.clubSession
└─ 테니스장 찾기 (courts)       → [VenueDirectoryContent] — the SAME shared body Court Directory's
   standalone route wraps (one implementation, two mount points; see Court Directory entry)

No FAB on this screen. The only pinned action is [BottomCtaBand][Button lg+primary] t().activityScreen.createPickupCta "번개 만들기" → routes.createSession('pickup'), rendered ONLY while activeTab === 'pickup_finder'.

Empty States (per tab)
└─ [EmptyState] variant="compact" size — filter-aware (distinct copy for "genuinely empty" vs
   "filtered to empty", the latter with a "필터 초기화" action) · 저장됨 has no filter-aware branch
Gaps
  • ⚠ This entry described a 2-tab (내 경기/번개) PillNav-inside-body design with FilteredSearchBar, EnrolledSessionCard, PickupCard, and per-tab FABs — none of that exists in the current ActivityScreen. Corrected above; see History for when the 4-tab shell-switcher design shipped (not separately dated in prior entries — the drift predates this restructure and its ship date could not be recovered from this file alone).
History

Change log (2026-04-10): Tab restructured: sub-tabs changed from 라이브/예정/모집중 to 내 경기/번개. Context-aware FABs: "add" for 내 경기 (admin only), "flash" for 번개 (all users). FilteredSearchBar uses hideSearch mode with filter chips only. Change log (2026-04-20): Session cards in 내 경기 use canonical SessionCard primitive. DropdownChip + RangeChip height parity fix (h=36). PickupFilterBar alignItems="center". LiveSessionCard empty-state row removed. Change log (2026-07-12, 3.6 discovery UX): 번개 tab's PickupFilterBar region/district cascade now goes through the shared RegionDistrictFilter component — the same primitive Court Directory's VenueFilterBar uses — replacing a hand-rolled duplicate of the region→district cascade logic. Rendered output unchanged (labels/chip sizing preserved via props).


나 (Profile) — apps/mobile/app/(tabs)/(profile)/index.tsx · apps/web/app/(tabs)/profile/page.tsx

Implementation: ProfileScreen at packages/features/profile/src/profile-screen.tsx Header/shell: MainTabShell testID="tab.profile" ground="canvas" title=t().profileScreen.pageTitle | headerRight = DM inbox HeaderIconButton (MessageSquare, unread dot) + Settings gear → routes.settings Layout: MainTabShell canvas ground, ONE ScrollView + FlatColumn body (flat detail-page migration, no boxed cards)

Verified against profile-screen.tsx, 2026-09-11 — corrects both the pre-2026-09-05 body this entry carried and the 2026-09-05 note's hero description, which the code has since moved past (owner ruling 2026-09-08, "D9").

├─ [TypeHero] leading=[AvatarBubble size=HERO], title=displayName,
│  titleBadge=[TrustTierBadge tier], facts=[GlyphFactRow] 🥇 ELO tier → 🏳 club chip (AvatarBubble
│  16px + club name, ▸ primaryClubId set) → 🎾 tennis career years (▸ tennisStartYear set)
├─ [FlatColumn]
│  ├─ lead (one hero-zone block, no divider between its parts):
│  │  ├─ ELO hero fact — [Pressable variant="row"] → opens [TierInfoSheet] (currentElo, matchesPlayed)
│  │  │  ├─ "ELO" label + [Text role="displayMd" $primary] formatted ELO value
│  │  │  ├─ [ProgressBar height=3] progress to next tier
│  │  │  └─ "N점 남음 다음 등급까지" ▸ a next tier exists
│  │  └─ stats: ▸ !hasMatches → [Well][EmptyState variant="compact"] "아직 경기가 없어요"
│  │     ▸ hasAttendance (globalAttendanceRate set) → [StatStrip columns=3] 5 cells (총 경기 ·
│  │       승률 · 연속 승리 · 이번 달 · 출석률) — ONE shared 3-column grid, not two separately-
│  │       gridded strips (2026-09-08 spacing fix: row 2's 2 cells now pad to row 1's 3-col width)
│  │     ▸ else → [StatStrip] 4 cells (총 경기 · 승률 · 연속 승리 · 이번 달)
│  ├─ [FriendsSummaryCard] ▸ userId — AvatarStack row or a recessed empty-state well
│  ├─ [AchievementsPreviewSection] ▸ userId — 44px recessed icon tiles + latest as a ListRow
│  ├─ [AttendanceSection] ▸ userId — own-profile only, hides entirely (no empty container) with no data
│  ├─ [MannerTagsSection] ▸ userId — Badge chip wrap of received manner tags
│  └─ [ProfileMenuCard] settings menu (RowList of ListRows) — 공유 row (own profile, hidden until
│     loaded) + 로그아웃 as a local danger row (ListRow has no danger tone) → confirm → sign out

Hero identity pattern (owner ruling 2026-09-08, "D9"): titleBadge is the trust tier only; facts folds ELO tier → club → tennis career into one GlyphFactRow. The tap-to-open-TierInfoSheet affordance lives on the ELO hero fact below the hero (not on a small hero badge), and ReliabilityPill's content moved into TierInfoSheet's matchesPlayed prop instead of rendering as a separate hero chip — both EloTierBadge and a standalone ReliabilityPill hero chip, as named in the prior (2026-09-05) note below, are gone.

Card count in the file: 0. Every query the rendered surface depends on fires at the screen root (Protocol G / useScreenReady pattern) so the whole screen gates on one unified skeleton (SkeletonFlatPage kind="type") rather than a cascading per-section reveal.

History

Change log (2026-04-10): Tab renamed: 프로필 → 나. Settings gear added to header. Sections expanded: WeeklyAttendance, MonthlyRetrospective, HeadToHeadSection, AchievementsSection.

Change log (2026-07-31, header-icon audit): headerRight became exactly two HeaderIconButtons — DM inbox (MessageSquare, unread dot) + Settings gear. The Share2 stat-card-share icon was removed from the header; sharing moved to a row inside ProfileMenuCard.

Change log (2026-09-05, flat detail-page migration wave 3): ProfileScreen moved onto MainTabShell ground="canvas" + TypeHero + FlatColumn + StatStrip(s), retiring ProfileHeroCard's box (per docs/design/detail-pages-flat-build-spec-2026-09-05.md §3.10). At ship, the hero badge row was EloTierBadge (tappable → TierInfoSheet) + TrustTierBadge + ReliabilityPill, and the stat grid was a literal 3+2 split (two separately-gridded StatStrips) when globalAttendanceRate was present — both superseded by the 2026-09-08 "D9" ruling and the 2026-09-08 spacing fix described in the current body above. 2026-09-06 (Divider law): HairlineListRowList. 2026-09-06 (seam system): hero-zone blocks in FlatColumn lead, seams from SEAM.


My Stats — app/(app)/(tabs)/(profile)/stats.tsx (if exists)

Note: Stats functionality has been consolidated into the 기록 tab (RecordsScreen). Profile screen links to records routes for history and analysis drill-downs.


2.2 Club Detail Screens


Club Dashboard — apps/mobile/app/clubs/[clubId]/index.tsx · apps/web/app/clubs/[clubId]/page.tsx

Implementation: ClubDetailScreen at packages/features/clubs/src/club-detail-screen.tsx ("C redesign", 당근 모임 grammar — owner board club-detail-redesign.jsx "C 최종 확정 22s", 2026-08-22) Layout: PlaceShell (bottom-inset-only scaffold, banner extends under the status bar) → ONE shared ClubPlaceHeader (banner → identity → underline nav) in a CollapsingHeaderLayer translated by the active pane's own scroll → SegmentedTabs chrome="none" pane engine (preload="visited") → a two-state CollapsingTopBar (scrim-over-banner ↔ solid+title, overlaying everything, right=HeaderActionsMenu)

MAJOR CORRECTION (verified against club-detail-screen.tsx, 2026-09-11) — this is the single most-drifted entry in this document. The previously-documented 4-tab TabPanel design (홈/소식/기록/관리, [ScreenHeader]/SafeAreaView) does not exist any more. The screen was rebuilt around a collapsing-banner "place" shell with a DIFFERENT, 6-item pane nav, and 관리 moved out of the tab strip entirely into top-bar chrome. Corrected below; the retired 4-tab body (still useful as a record of the 관리 tab's ListRow content, which survives unchanged under its new host — see below) moved to History.

├─ [CollapsingTopBar] title=club.name | back | right=[HeaderActionsMenu] 공유 (member) ·
│  관리 (isAnyAdmin, → routes.clubAdmin) · 나가기 (member, non-owner, destructive)
├─ [ClubPlaceHeader] banner → identity → underline nav — ONE instance, translates with scroll,
│  never remounts on tab switch (2026-08-23 flicker fix)
├─ [SegmentedTabs chrome="none"] items=[홈 (badge=unreadPostCount), 일정, 채팅, 기록, 멤버, 회비]
│  ├─ 홈      → [HomeTab] — 소식 dissolved into the home feed (categories, migration 00491);
│  │            feed-filter chips pin under the collapsed bar once scrolled past
│  ├─ 일정    → [ClubSessionsPane] + [SessionSegmentChips] (upcoming/…, pinned like home's chips)
│  ├─ 채팅    → [ClubChatPane] ▸ authed (auth resolves independently of club/members —
│  │            [InterimPane loading] while authLoading, not the coming-soon empty state)
│  ├─ 기록    → [ClubRecordsPane]
│  ├─ 멤버    → [ClubMembersPane]
│  └─ 회비    → [ClubDuesPane]
└─ [BottomCtaBand] contextual — "글쓰기" (홈, isMember) | "일정 만들기" (일정, canManageSchedule)

Non-members bounce to routes.clubPreview. A legacy ?tab=admin deep link bounces to routes.clubAdmin(clubId) (the routed admin screen, see below) instead of selecting an in-shell tab. Pane internals (HomeTab/ClubSessionsPane/ClubChatPane/ClubRecordsPane/ClubMembersPane/ClubDuesPane bodies) are NOT re-verified against current source in this pass — the 홈/기록 sub-section prose in History predates this rebuild and should not be trusted for anything below the tab-list level; read the pane files directly (packages/features/clubs/src/club-detail/{home-tab,sessions-pane,chat-pane,records-pane,members-pane,dues-pane}.tsx) before citing this doc as evidence about them.

관리 — now routed chrome, not a tab

관리 is CHROME (top-bar ⋯ menu → routes.clubAdmin(clubId)ClubAdminScreen, packages/features/clubs/src/club-admin-screen.tsx), gated isAnyAdmin (bounces non-admins back to the club). ClubAdminScreen is explicitly "just a DetailShell frame around" the SAME AdminTab component the old 4-tab design used — so the content below, previously documented as the "관리 Tab", is still accurate; only its host changed from an in-shell pane to a standalone DetailShell route.

Gate: canManageMembers || canManageDues.

Attention snapshot strip (R2-2) — a 5-tile grid (AdminSnapshotStrip) at the TOP of the tab, fed by useClubAdminSnapshot(clubId) (gated canManageClub, matching the get_club_admin_snapshot RPC's owner/admin-only authorization). Fixed order: 송금 확인 (submittedTransfersCount) / 가입 신청 (pendingJoinRequestsCount) / 회비 미납 (overdueDuesCount) / 신고 (openReportsCount, count-only — no review surface exists yet) / 출석 주의 (attendanceFlaggedCount). A tile with count > 0 highlights via $badgeErrorBg/$badgeErrorText; zero-count tiles stay quiet ($card/$border). Replaces (absorbs) the former per-row badge counts on 멤버 (join requests) and 회비 (overdue dues) — those rows no longer carry a badge prop. Cell model: buildAdminSnapshotCells in packages/features/clubs/src/club-detail/admin-snapshot-cells.ts.

├─ [ListRow] "멤버 관리" → ClubMembersScreen      ▸ canManageMembers
├─ [ListRow] "회비 관리" → ClubDuesScreen         ▸ canManageDues
├─ [ListRow] "출석 현황" → ClubAttendanceScreen   ▸ canManageClub
├─ [ListRow] "일정 관리" (stub → "곧 추가돼요")
├─ [ListRow] "게시판 관리" (stub → "곧 추가돼요")
├─ [ListRow] "코트 관리" → Venue List             ▸ canManageCourts (stub)
└─ [ListRow] "클럽 설정" → Club Settings
History

Retired body (pre-2026-08-22 "C redesign"): 4-tab [TabPanel] design — tabs=["홈", "소식", "기록", "관리" ▸ isAdmin], [ScreenHeader] title=clubName, SafeAreaView layout, <TabPanel> always-mount pattern. 홈 tab was Card-anatomy (identity hero banner 3:1 → chip strip → member-count hero → session list → BottomCtaBand "일정 만들기"); 소식 was a standalone pinned/feed PostCard list with a "글쓰기" CTA; 기록 was RankingList/Season Summary (implementation named club-detail/ranking-tab.tsx, a filename that no longer exists — see records-pane.tsx in the current body above). Role-aware empty states existed per tab (홈/소식 admin-vs-member copy, 기록 always-populated/no-CTA, 관리 never-empty).

Change log (2026-09-05, flat detail-page migration wave 4c residue): the (then-)기록 tab's two Card tone="default" empty-leaderboard wrappers → Well; TOP_BAR_HEIGHT unified into packages/ui/src/collapsing-top-bar.tsx (was a local BAR_HEIGHT).


Club Settings — apps/mobile/app/clubs/[clubId]/settings.tsx · apps/web/app/clubs/[clubId]/settings/page.tsx

Implementation: ClubSettingsScreen (@twomore/clubs). The body below is not re-verified against the current source — out of this pass's scope.

Header: [ScreenHeader] title="모임 설정" | back Layout: KeyboardAwareScrollView bg=background

├─ [RHF Form]
│  ├─ [TextInput] name (validated)
│  ├─ [TextInput] description (multiline)
│  ├─ [NumberStepper] maxMembers
│  └─ [CurrencyInput] duesAmount
├─ ◇ Danger Zone
│  ├─ ✦ "모임 나가기" → confirmation dialog
│  └─ ✦ "모임 삭제" → confirmation dialog ▸ isOwner
└─ ✦ "저장" lg+primary (sticky bottom)

Change log (2026-07-12, PS6 M6): Danger Zone's "대표 권한 넘기기" picker (TransferOwnershipModal) now shows the real member display name (was a sliced-UUID fallback) and the shared getRoleLabel Korean role text (was the raw enum, e.g. "Match_director") — both sourced from the new SSoT packages/app/src/utils/role-labels.ts, also dedup'd out of club-members-screen.tsx.


Club Members — apps/mobile/app/(tabs)/(clubs)/[clubId]/members.tsx · apps/web/app/(tabs)/clubs/[clubId]/members/page.tsx

Route helper: routes.clubMembers(clubId). Gate: canManageMembers. Implementation: packages/features/clubs/src/club-members-screen.tsx (screen owns tab-switch state + layout only; MemberRow/JoinReviewRow/RecommendGuestRow, the list header content, the roster/join-review/recommend-guest derivations, and the kebab-menu action handlers live in packages/features/clubs/src/club-members/ — god-file decomposition, behaviour-identical, 2026-09-04) Header: DetailShell title="멤버 관리" | back Layout: DetailShell scroll=false; content area is GroupedFeedList

Key hooks: useClubMembers(clubId), useClubProfiles(clubId), useClubRole(clubId), useUpdateMemberRole, useRemoveMember, useClubJoinRequests(clubId, 'pending'), useProfilesByIds, useNominateForMembership (join-review pipeline, migration 00415), useRecommendableGuests, useRecommendMembership, useUnrecommendMembership (member-vouch browse, migration 00415)

├─ (header, admin only) 가입 심사 — pending club_join_request rows
│  ├─ [SectionHeader] "심사 대상" + count  ▸ ctx.role.isAdmin, only when count > 0
│  │  └─ [JoinReviewRow] avatar + name + "공개 일정 N회 · 매너 M · 함께 K명" (or "신규")
│  │        + source Badge (지명/신청) + chevron → routes.joinReview(clubId, userId)
│  └─ [Pressable card] "게스트 지명" → NominateGuestModal (always visible to admins)
│        └─ [SearchBar] by-name search (useProfileSearch) → "지명하기" → nominate_for_membership
├─ (header, ALL active members — NOT admin-gated) 추천할 만한 게스트 — get_recommendable_guests rows
│  └─ [SectionHeader] "추천할 만한 게스트" + count  ▸ isActiveMember, only when count > 0
│     └─ [RecommendGuestRow] avatar + name + "함께 N회" (co_attended_count)
│           + [ActionButton] sibling "추천"(primary)/"추천됨"(neutral) toggle
│           → useRecommendMembership / useUnrecommendMembership
├─ [GroupedFeedList] sections keyed by role
│  ├─ ◇ 대표
│  │  └─ [MemberRow] avatar + displayName + role badge (대표)
│  ├─ ◇ 총무
│  │  └─ [MemberRow] avatar + displayName + role badge (총무)
│  ├─ ◇ 경기이사
│  │  └─ [MemberRow] avatar + displayName + role badge (경기이사)
│  └─ ◇ 멤버
│     └─ [MemberRow] avatar + displayName + role badge (멤버)
│        └─ [KebabIcon] ▸ isAdmin → ModalPanel
│           ├─ "대표로 변경" / "총무로 변경" / "경기이사로 변경" / "멤버로 변경"
│           └─ "멤버 제거" (destructive)
Key actions
  • Role change: useUpdateMemberRole({ clubId, userId, role }) → server emits club_role_changed signal (medium, 7d expiry).
  • Remove member: useRemoveMember({ clubId, userId }) → confirmation sheet.
  • Overlays are ModalPanel (not a bottom sheet — the one legal Sheet is the dev panel).
  • 게스트 지명: useNominateForMembership({ clubId, userId })nominate_for_membership RPC → creates a pending club_join_request (source='admin_nominated'); hint-mapped Korean error toast on rejection (already-member / already-pending / no-attendance-history / archived).
  • 추천할 만한 게스트 (member-vouch browse, member-visible): useRecommendableGuests(clubId) reads get_recommendable_guests (non-member guests the CALLER co-attended with); row toggle calls useRecommendMembership/useUnrecommendMembership (membership_recommendations insert/delete), which invalidate both the admin dossier (prospectKeys.summary) and this list (recommendableGuestKeys.byClub) so alreadyRecommended flips immediately.
Empty State
  • No members: [EmptyState variant="compact"] "멤버가 없어요" (edge case — owner always present)

Join Review Detail — apps/mobile/app/clubs/[clubId]/join-review/[userId].tsx

Route helper: routes.joinReview(clubId, userId). Gate: ctx.role.isAdmin (literal owner/admin — matches decide_join_request's and get_prospect_attendance_summary's own hard-coded role check, not the configurable canManageMembers permission). Implementation: packages/features/clubs/src/join-review-detail-screen.tsx Header: DetailShell ground="canvas" title=candidate displayName | back Layout: DetailShell scroll + FlatColumn + BottomCtaBand

Key hooks: useClubContext(clubId), useProfile(userId), useMyJoinRequest(clubId, userId) (reused — mechanically "the club_join_request row for this pair", RLS-readable by any club owner/admin), useProspectAttendanceSummary(clubId, userId), useProfilesByIds, useDecideJoinRequest

├─ [TypeHero] right=[AvatarBubble size=LG], title=name, meta="게스트 · {첫 참석}", badges=Badge "N회 참석"
├─ [FlatColumn]
│  ├─ [SectionBlock variant=flat] "근거" → [FactList] (hairline rows)
│  │  ├─ "첫 · 마지막 참석" → formatRelativeTime(firstAttendedAt) / formatRelativeTime(mostRecentAttendedAt)
│  │  ├─ "함께 친 멤버" → count + [AvatarStack] (co_attendees, resolved via useProfilesByIds)
│  │  ├─ "받은 매너" → [Badge] per MANNER_TAG_ICONS[type] + t().trust[type] + count
│  │  └─ "가입 추천" → count + [AvatarStack] (recommenders, resolved via useProfilesByIds)
│  └─ [SectionBlock variant=flat] "신청 메시지" → paragraph (request.message or noMessage in $textTertiary)
└─ [BottomCtaBand]
   └─ [XStack] 거절 (neutral, flex=1, testID join-review.reject) + 승인 (primary, flex=2,
      testID join-review.approve) — a genuine binary decision fork, not a state-precedence
      single action; both call useDecideJoinRequest({ requestId, accept, clubId, applicantUserId })

2026-09-05 (flat detail-page migration, wave 4b, detail-pages-flat-build-spec-2026-09-05.md §3.9): The identity + evidence Cards became TypeHero + a SectionBlock variant="flat" FactList (every "no X" fallback string kept verbatim); the message Card became a plain SectionBlock paragraph. Card count: 3 → 0. ✚ i18n joinReview.detailEvidenceTitle (ko/en, "근거"/"Evidence") for the new section title. ✚ testIDs join-review.reject/join-review.approve. First characterization test for this screen added at src/__tests__/join-review-detail-screen.test.tsx (admin evidence + both CTAs, non-admin permission empty state, decide.isPending disables both) — written and verified green against the pre-migration screen, then kept green through the migration.

Key actions
  • 승인/거절: useDecideJoinRequest(...)decide_join_request RPC (00320, unchanged) → invalidates join-request + club-members + clubAdminKeys.snapshot → navigates back on success.
  • Guards (in order): offline cold-start → loading skeleton → query error → non-admin (EmptyState "권한이 없어요") → request not found (already decided elsewhere).
Empty States
  • Non-admin deep link: [EmptyState variant="full"] "권한이 없어요"
  • Request already decided/withdrawn: [EmptyState variant="full"] "가입 신청을 찾을 수 없어요"


Board Feed — app/clubs/[id]/board.tsx

Header: [ScreenHeader] title="게시판" | rightAction: "글쓰기" → Create Post ▸ isAdmin Layout: SafeAreaView bg=background

├─ ◇ Pinned Posts
│  └─ [PostCard] variant=pinned → Post Detail
└─ ◇ Recent Posts
   └─ [PostCard] variant=default → Post Detail
Empty State
└─ [EmptyState variant="first-use" size="full"]
   └─ ✦ "첫 글 작성" → Create Post ▸ isAdmin

Post Detail — app/(tabs)/(clubs)/[clubId]/board/[postId]/index.tsx · apps/web/app/clubs/[clubId]/board/[postId]/page.tsx

Route helper: routes.clubPost(clubId, postId). Caller: club detail screen post cards (Pressable). Implementation: PostDetailScreen at packages/features/clubs/src/post-detail-screen.tsx (subcomponents under post-detail/*) Header: DetailShell title=strings.board.postDetailTitle | back Layout: KeyboardAvoidingView (iOS padding, Android no-op — relies on app.json's pan mode) → DetailShell scroll={false}, footer = pinned CommentComposer ▸ authed → content is a FeedList whose header is the post itself

Verified against post-detail-screen.tsx + post-detail/post-detail-header.tsx, 2026-09-11 — this entry's body predated the 2026-09-06 flat-system rebuild; the correcting note had been misfiled under the Venue Directory Detail entry below. Corrected here.

├─ [FeedList] header = the post (PostDetailHeader):
│  ├─ [TypeHero] title=author displayName (NEVER post.title — the author is always the hero's
│  │  subject; a present post title renders as an in-body heading line instead),
│  │  titleBadge=[TrustTierBadge], facts=[GlyphFactRow] 🏷 club → 🕐 relative time
│  │  leading=[AvatarBubble size=HERO]
│  └─ [FlatColumn]
│     ├─ post content — resolvedTitle (rare) → audience [Badge] (public/official) → body lines
│     │  → full-bleed images
│     ├─ [Pressable variant="row"] 세션으로 이동 ▸ post.type==='session_link' && linkUrl
│     ├─ [PostActionRow] ▸ viewerUserId — reaction picker (5 types) · comment count · share
│     └─ [SectionBlock variant="flat" title=댓글 meta=commentCount] → 이전 댓글 더 보기 [ActionButton]
├─ [FeedList] body = comment rows ([MemoCommentRow] avatar + header + body + reply/delete,
│  hand-applied RowList divider contract since FeedList renders its own rows) ▸ authed
└─ [BottomCtaBand]-style footer = [CommentComposer] ▸ authed (reply-target chip when replying)

Not-found (post.deleted === true / post fetch resolves null) → EmptyState variant="full" icon=Newspaper title=strings.board.postDeleted. Deferred: poll section.

History

Change log (2026-09-06, seam system / owner ruling "no separate grammar"): the screen used to hand-roll its own flat hairline-zone grammar (every zone owning its own border/padding independently). Rebuilt onto the same primitives every other flat detail page uses — TypeHero author identity, one FlatColumn (body → session-link → action row → 댓글 SectionBlock with the count as meta), comment rows as block peers with a hand-applied divider/edge contract, CommentComposer as the DetailShell footer band; author-row.tsx deleted.

Note (2026-09-05, flat detail-page migration wave 4c, parity-only): the local backgroundColor="$card" override on every render branch was retired for DetailShell ground="canvas" — same paint, one owner. Superseded the next day by the seam-system rebuild above.


Create Post — app/clubs/[id]/board/create.tsx

Header: [ScreenHeader] title="글쓰기" | rightAction: "게시" (publish) Layout: KeyboardAwareScrollView bg=background

├─ [PillNav] tabs=["일반", "투표"]
├─ [TextInput] title
├─ [TextInput] body (multiline, rich)
├─ [ImagePicker] ▸ tab === "일반"
└─ [PollBuilder] ▸ tab === "투표"
   ├─ [PollOptionInput] × N
   └─ ✦ "선택지 추가"

Join Club — apps/mobile/app/join-club.tsx · apps/web/app/join-club/page.tsx

Route correction (2026-09-11): the route is root-level join-club, not app/clubs/join.tsx. Implementation: JoinClubScreen (@twomore/clubs), taking initialInviteCode/originClubId from the route's code/clubId params. The body below is not re-verified against the current source — out of this pass's scope.

Header: [ScreenHeader] title="초대코드 입력" | back Layout: SafeAreaView bg=background

├─ [TextInput] variant=code (6-digit invite code)
├─ [ClubPreview] ▸ valid code entered (name, members, description)
└─ ✦ "가입하기" lg+primary ▸ valid code
Success State
└─ [SuccessAnimation] + "가입 완료!" → Club Dashboard (auto-navigate)

Create Club Wizard — apps/mobile/app/create-club.tsx · apps/web/app/create-club/page.tsx

Implementation: create-club-screen.tsx (packages/features/clubs/src/create-club-step{1..7}.tsx + create-club-step-guests.tsx + create-club-step8.tsx; internals decomposed under create-club/*, god-file wave, 2026-09-04 — see History) Shell: WizardShell (totalSteps={STEP_COUNT}, progress indicator, close → dismissDirtyTitle confirm if dirty). Each step body is a sequential RevealStack (canon/wizards.md field grammar) inside a per-step ScrollView. Draft recovery: live-draft auto-resumes silently on re-entry from store.currentStep, surfaced via one draftResumeToast action-toast with undo (no banner). Free-text scopes (이름/월 회비/게스트 참가비/소개) are SAVE-THEN-PROCEED: typing lives in a component-local buffer only, and the draft (and its MMKV persistence) commits once, on the 저장 press — never per keystroke.

Step 1 (이름·꾸미기)  — 클럽 이름을 정해요 [TextInput] → 대표 색상 → 로고 fork:
                        [업로드 | 문양] — 문양 reveals 모노그램/크기/모양/패턴
Step 2 (지역)         — 어디서 활동하나요? 자주 가는 코트 검색/선택 (최대 5곳)
                        or region fallback. Picked row trailing (2026-08-30
                        arc): 홈 코트 SelectionChip (state toggle — filled on
                        the home pick; tap an outlined chip to promote in
                        place) · chevron (config disclosure) · swap
                        (ArrowLeftRight = replace-in-place, arms the picker)
                        + trash (flush icon pair). Row press opens the
                        AttachedPanel: venue FULL-name title row + 정보 수정
                        pencil (→ /directory-venues/[id] correction flow) →
                        사용하는 코트 chips → 코트 이름. Reveals are
                        TARGETED (scroll-to-node), never scroll-to-end.
Step 3 (일정)         — 정기 일정이 있나요? [있어요|아직 없어요] fork; 있어요 →
                        per-venue schedule loop: 활동 장소 → 요일 → 반복
                        (DropdownChip) → 시간 (TimeBandSlider start/end band)
Step 4 (스타일)        — 어떻게 치나요? sequential reveal: 구성 → 형식 → 실력
                        (무관/범위 설정 fork, TierRangeSelector) → 성격
Step 5 (인원)         — 몇 명과 함께할까요? 1인당 경기 수 → 참석률 가정 →
                        모집 목표 (계산된 인원 결과)
Step 6 (회비)         — 회비를 계산해 볼까요? 코트비/운영비 → 월 회비 계산
                        (제안 금액 적용) → 가입 방식
Step 7 (게스트)       — 게스트를 받을까요? [허용|비허용] fork (REQUIRED, not
                        skippable) → 허용 → 참가비 (defaultGuestFee) → 방문
                        횟수 제한 (0-10 Stepper, 0 = 제한 없음)
Step 8 (소개)         — 클럽을 소개해요: 소개 [TextInput] → 공개 설정 →
                        사진 정책
Step 9 (확인)         — 확인해 주세요: full review (WizardReviewRow per
                        section, tap-to-edit jumps back armed with
                        "returnToReview") → ✦ "클럽 만들기"

Edit-from-review: tapping a review row jumps to that step with the primary CTA replaced by backToReview ("검토로 돌아가기"), returning straight to Step 9 instead of walking forward through the remaining steps. While armed, the secondary (skip) slot on a skippable step is hidden rather than relabeled — the primary already reads backToReview.

History

Change log (2026-08-17, re-grounded from code): Previous entry (2026-08-15) described an 8-step flow. Re-derived from the owner-ratified 2026-08-16 guest-step reshape: a new REQUIRED 게스트 step (create-club-step-guests.tsx — deliberately un-numbered; step ORDER lives only in create-club-screen.tsx's renderStep/isNextEnabled/primaryHint switch, not the file name) is inserted between 회비 and 소개, carrying defaultGuestFee/guestVisitLimit out of Step 6; 늦은 취소 (late-cancel) was retired app-wide and dropped from Step 6 entirely. The wizard is now 9 steps (STEP_COUNT).

Note (2026-09-04, god-file decomposition): Step 2 and Step 3's internals (pure helpers, presentational subcomponents, and the schedule/pick CRUD hooks) were split out of create-club-step2.tsx/create-club-step3.tsx into packages/features/clubs/src/create-club/ (step3-helpers.ts, venue-meet-card.tsx, use-step3-schedule.ts, use-active-schedule.ts, use-schedule-edit-state.ts, step3/*.tsx, home-court-toggle.tsx, use-step2-picks.ts) — behaviour-identical, both step files still export the same Step2/Step3/step3Valid/ScheduleEditState surface create-club-screen.tsx imports.

Note (2026-09-04, god-file decomposition wave A1): create-club-screen.tsx, create-club-step1.tsx, and create-club-helpers.ts (all three over 600 lines) were further split, behaviour-identical, same exports/testIDs/copy: create-club-screen.tsx (1,078→576) — isClubStepValidcreate-club/screen/step-validity.ts; primaryHint()create-club/screen/primary-hint.ts; the five edit-scope state pairs → create-club/screen/use-edit-state-slot.ts; footer consolidation → create-club/screen/active-edit-scope.ts; submit orchestration → create-club/screen/use-club-submit.ts. create-club-step1.tsx (810→281) — the "꾸미기" reveal item → create-club/step1/decor-section.tsx; FlagSwatchButtoncreate-club/step1/flag-swatch-button.tsx; pickLogoUricreate-club/step1/logo-picker.ts. create-club-helpers.ts (617→162, now a barrel) — withLegacyScheduleMirrorcreate-club/schedule-mirror.ts; the schedule/fee permutation helpers → create-club/schedule-permutations.ts; resumeClubDraftcreate-club/resume-draft.ts; the option lists → create-club/option-lists.ts; overlayLivePickCountscreate-club/live-pick-counts.ts.


Public Club Profile — app/clubs/[id]/preview.tsx

Route helper: routes.clubPreview(clubId) Implementation: packages/features/clubs/src/public-club-profile-screen.tsx (data reads/sections decomposed under public-club-profile/* — see History) Entry point: DiscoverClubCard tap (non-members) → this screen; members bounced to full routes.club(clubId). Header: [DetailShell ground="canvas"] title=club.name | back | right=[HeaderActionsMenu] 공유 only Layout: DetailShell scroll=true (managed), content padding 0 (canvas default) — MediaHero bleeds to the rail, FlatColumn owns the $4 rail beneath it

├─ [Banner tone="neutral"] ▸ club.archivedAt != null  (self-gates to null for active clubs)
├─ [MediaHero kind="album"] — full-bleed swipeable photo pager (dots + "N/M" count pill)
│  ├─ avatar: [ClubLogoBubble] or [FlagMedallion] (56px), bottom-left overlap
│  ├─ seamRight: capacity+faces pill (AvatarStack + capacityLabel), tap → clubMembers (disabled for non-members)
│  ├─ title: club.name · meta: region · 설립 연도 · 멤버 N명
│  └─ badges: [ClubJoinabilityBadge] · [TierRangeBadge] ▸ tier set · focus [Badge] ▸ focus ≠ balanced
├─ [FlatColumn]
│  ├─ [SectionBlock variant="flat"] 클럽 정보 → [FactList] 형식 · 실력 · 가입 방식 · 홈 코트 ▸ set · 회비
│  ├─ [SectionBlock variant="flat"] 정기 일정 (meta="외 N개" ▸ extra upcoming)
│  │  └─ [DayDotsRow] + per-meet rows (multi-meet) or time-slot fallback + next-session [ListRow] (DateTile leading) → Session Detail + session_interest event
│  ├─ [SectionBlock variant="flat"] 참여 가능한 일정 ▸ isActiveGuest
│  │  └─ [SessionCardV2 variant="embedded"] rows (hairline-separated) + 방문 현황 [Callout] ▸ guestVisitLimit set
│  ├─ [SectionBlock variant="flat"] 소개 ▸ club.description
│  ├─ [SectionBlock variant="flat"] 갤러리 (testID club.public-profile.gallery) → 3-up thumbs or empty copy
│  └─ [Well] application panel ▸ applyPanelOpen — title + body + [StyledInput] message (500 cap)
└─ [BottomCtaBand] (`JoinCtaBand`) — join CTA (single contextual action, state-driven by joinPolicy):
   open        → ✦ "가입하기"      → routes.joinClub
   approval    → ✦ "가입 신청하기" → inline ApplyPanel (or "신청 검토 중" / "다시 신청하기")
   invite_only → ✦ "초대코드로 가입하기" → routes.joinClub
History

Change log (2026-09-05, flat detail-page migration wave 4c, §3.7): the former ONE-card ClubDetailHero (this screen was its only remaining consumer — deleted along with its dead member-only M1 projection and shared/club-detail-hero/*; shared/club-photo-seam.tsx stays, ClubCard still uses it) retired for DetailShell ground="canvas" + MediaHero kind="album" + FlatColumn + SectionBlocks (card count 5+hero → 0). The guest fact grid (형식/실력/가입 방식/홈 코트) re-homed as a FactList with 회비 added.

Note (2026-09-04, god-file decomposition): query reads + derived state → public-club-profile/use-public-club-profile-data.ts; guest-sessions body → public-club-profile/guest-sessions-section.tsx; 정기 일정 body → public-club-profile/activity-card-section.tsx; gallery body → public-club-profile/gallery-card-section.tsx; join CTA → public-club-profile/join-cta-band.tsx (unchanged, 7-state matrix).


Discover Clubs — retired as a screen (2026-09-08)

/clubs/discover is a redirect alias into the 클럽 tab's 탐색 pane (routes.discoverClubs = /clubs?tab=discover). The pane is documented under the 클럽 tab above; there is no separate discover screen any more (duplication audit).


2.3 Session Screens


Session Detail — app/sessions/[sessionId]/index.tsx

Header: [DetailShell] AppHeader back + sessionTitle + PulseDot ($error) ▸ in_progress | rightAction: save/bookmark (SessionSaveButton variant="header", auth-gated, signature slot) + [HeaderActionsMenu] overflow (t().common.more) containing share (Share2) + group chat (MessageCircle), each row conditionally present per the same gates the old standalone icons used; renders null when both are absent — header-icon-system rework, screens wave A (2026-07-31)
Layout: DetailShell scroll={false} + inner ScrollView flex={1} + BottomCtaBand sibling

├─ [SessionStripView] size="detail" ▸ deriveSessionStrip(model, saved) — top band, self-gates to null when there's nothing to say; ranked attention lead (LIVE / 마감 / 송금 대기 / …) OR the terminal `ended` band (completed | cancelled) on the left, host/RSVP/saved marks + a bookmark indicator on the right — same renderer as the list card (`session-strip-ui.tsx`); the standalone `SessionStatusBanner` this line used to be has been deleted, absorbed into the strip

├─ ── Live participant utilities ── ▸ in_progress && isConfirmedParticipant
│  └─ inline [ActionButton] row: GPS 체크인 (gps-checkin) + host? 경기에서 빠지기 (neutral) : 기권하기 (forfeit, destructive) → useConfirm
│     (formerly the header kebab's only two items — now inline, no menu)
│     forfeit_session (00310) auto-substitutes the player out of live+upcoming matches (bench→waitlist,
│     participant_ids synced); host step-out keeps hosting + suppresses the admin signal

├─ ── 결제 액션 callout ── ▸ gateState 'reserved' (confirmed viewer, fee session)
│  └─ [송금 완료] (attest, manual rail — primary) · [결제하기] PortOne pay button DEV-ONLY (not wired for real users)

├─ ── SURFACE 1 "일정 정보" (LOGISTICS) ──
│  iOS-grouped-list card, internal [Divider] hairlines, NO outer SectionBlock title
│  ├─ Identity beat
│  │  ├─ [sessionTitle] cardTitle + [SessionStatusBadge | RecruitmentStatusBadge] + [RsvpStatusBadge] ▸ viewer
│  │  │   ↳ open/locked + not-participating: [RecruitmentStatusBadge] via sessionRecruitmentState
│  │  │   ↳ in_progress: [SessionStatusBadge] live
│  │  │   ↳ confirmed/waitlisted: [RsvpStatusBadge]
│  │  ├─ weather/TMI cluster: condition emoji + temp + 🟢🟡🟠🔴 playability (tappable → playability-info-sheet)
│  │  └─ chip strip: format → style → tier → rotation
│  ├─ Cost beat: 참가비 chip ▸ participation_fee > 0
│  ├─ 공지/description ▸ present
│  ├─ [Divider]
│  ├─ 장소 venue tile (two-zone, tappable → in-app venue detail)
│  │  ├─ static-map image (Naver Static Map via static-map proxy) → opens Naver Maps app on tap
│  │  └─ venue · surface · courts + chevron
│  ├─ [Divider]
│  └─ 날씨 weather beat: condition emoji + temp + rain-risk verdict (conditions.rainRisk)

├─ ── SURFACE 2 "참여 · 경기" (PEOPLE) ──
│  iOS-grouped-list card, internal [Divider] hairlines
│  ├─ Host card: [PickupHostCard] (pickup) | organizer identity row (club)
│  ├─ [Divider]
│  ├─ Participation hero (ONE displayMd hero count + [ProgressBar fillColor=recruitmentFillColor(state)])
│  │  ├─ [ProgressBar] fill (amber ▸ almostFull; muted ▸ closed; $primary otherwise)
│  │  ├─ expandable roster (collapsed → full list friends-first on tap)
│  │  │  └─ per-player [EloTierBadge]
│  │  ├─ deadline countdown ▸ open
│  │  └─ waitlist count ▸ locked
│  ├─ [Divider]
│  ├─ [TournamentContinueChip] "이어보기" ▸ an ACTIVE tournament exists (status setup|in_progress) —
│  │  EVERY session status, not just open/locked (T-3 stranded-tournament fix) → routes.tournamentBoard
│  ├─ [NavRow] "경기 방식 선택" ▸ (isHost||canManageSchedule||canManageMatches) && open|locked|in_progress
│  │  → [FormatChooserSheet] (format-chooser-sheet.tsx) — ONE registry-backed picker, two groups
│  │  (라운드 방식: 4 rotation presets + KDK/순위매칭전/코트승급전; 토너먼트: the 4 kind:'tournament'
│  │  strategies, host-only + entitlement-gated + hidden once an active tournament exists). Selecting a
│  │  round format opens this screen's own [AddRoundSheet] mount (preselected, commits → routes.matchBoard);
│  │  selecting a tournament format opens [CreateTournamentModal]'s setup step (preselected, skips its
│  │  own format-select step). Replaces the old stacked 경기 만들기 + 토너먼트 만들기/이어보기 buttons.
│  └─ [session-matchups-card] — viewer-personal-lens matchups
│     ├─ viewer's own match rows ([MatchRowHeader] + [MatchScoreboard] — tier badges + ELO tug-of-war bar, mirrors the live scorecard; `MatchTeams` is retired)
│     ├─ completed row: [Pencil pill] "점수 수정" ▸ canCorrect — direct 1-tap → correction sheet
│     │  (stopPropagation, row still opens MatchProfilesSheet on tap elsewhere)
│     └─ "전체 대진표 보기 · N경기" link → spectator scorecard

├─ ── 호스트 도구 (SectionBlock, status-gated) ── ▸ host/manager — the flattened former
│  │  "일정 관리" hub (edit-session-screen is now config-only)
│  └─ [NavRow] rows (reusing `SessionNavRow`, verbatim gates/routes/mutations from the hub):
│     일정 수정 → sessionEdit · 참가자 관리 → sessionParticipants · 경기 규칙 → sessionMatchRules
│     (disabled while a match is in progress) · 호스트 관리 → sessionHostDashboard (label shows
│     pending-application count) · 출석 확인 → sessionAttendance · 경기 종료 (destructive, confirm)
│     — NOTE: 경기 보드 and 정산 are NOT duplicated here; they are the host's band CTA below

├─ ── 위험 구역 (SectionBlock tone="danger") ── ▸ host, open/locked only
│  ├─ 일정 취소 (moved out of the band — Phase 3)
│  └─ 일정 삭제 (destructive, gated: solo or cancelled+fully-refunded only; hidden live/completed)

└─ [BottomCtaBand] — EXACTLY ONE state-precedence host-lifecycle-aware CTA (sibling of ScrollView)
   ├─ refund pending (viewer) → 환불 받았어요 (HIGHEST precedence, overrides all below)
   ├─ open + host → 초대하기 (useShareSession)
   ├─ open + participant → RSVP / cancel (cooldown/tier-gated disabled when applicable)
   ├─ locked + host → 경기 시작 (confirm-gated useStartSession)
   ├─ locked + participant → cancel / join waitlist
   ├─ in_progress + host → 경기 보드 (routes.matchBoard)
   ├─ in_progress + confirmed participant → 라이브 스코어보드 보기 (routes.scorecard)
   ├─ completed + host + hasPaidParticipants → 정산 (routes.sessionPayments)
   ├─ completed + confirmed participant (incl. host who also played a free session) → 매너 태그 (ModalPanel)
   └─ else → null (no band)

Group chat is find-or-create (migration 00309): the header MessageCircle icon reopens the session's existing thread (useThreadBySession) when one exists; otherwise it falls through to the create-group compose flow, whose create_group_thread RPC is itself a get-or-create keyed on session_id (partial-unique index dm_threads_session_group) — one thread per session, no duplicate-room bug on repeat taps.

Status chip on the retired SessionCard (recruiting sessions, historical): the status/RSVP chip pinned to the far right of the participation meta row used RecruitmentStatusBadge (not raw SessionStatusBadge) when the viewer was not participating and the session was open or locked, with the CapacityRing taking fillColor={recruitmentFillColor(theme, state)} for card↔detail ring/bar color continuity. Describes the rollback-only session-card.tsx subtree, not the live SessionCardV2 — its StatusJoinLine shows RsvpStatusBadge only for the viewer's OWN RSVP status (no derived recruitment badge), and recruitment pressure instead colors the RecruitmentMeter fill bar via the same recruitmentFillColor (session-compact-row.tsx).

History

Implementation (flat, wave 1) — 2026-09-05: session-detail-screen.tsx migrated to the flat detail-page language (docs/design/detail-pages-flat-build-spec-2026-09-05.md §3.1, the flagship of the migration): DetailShell ground="canvas"; SessionStripView stays the top ribbon, full-bleed, directly under the header; a new SessionHeroSection (session-detail/sections/session-hero-section.tsx) renders a full-bleed MediaHero (the venue static map via useVenueHeroImage, title/meta/badges carrying date · time · venue · D-day · weather glance, tappable to the legacy routes.venue) or a TypeHero fallback, absorbing the retired 장소 (venue-section.tsx, deleted) and 날씨 (weather-section.tsx, deleted) cards; everything below lives in ONE FlatColumn: a new StatStrip (확정 인원 · 내 참가비 · 참석 마감) → LiveParticipantBar (now Banner tone="error") → RefundBanners (now Banners, tone preserves the pre-existing amber/red overdue escalation) → NoticeSection (now a titleless Well tone="primary", testID section-notice kept on the Well) → PaymentGateBanner (unchanged) → 참여 SectionBlock variant="flat" (a capped 6-row HairlineList of ListRows + "외 N명" overflow, replacing SessionStatusParticipationCard's fill-hero+expand card — the confirmed count moved to the StatStrip, the friends/deadline/quorum/waitlist context-meta row is a deliberate drop) → 경기 SectionBlock variant="flat" (boxless SessionMatchupsCard, annotated // figure: since its per-match rows are composite MatchRowHeader+MatchScoreboard pairs too entangled to flatten into ListRow this wave) → 정보 SectionBlock variant="flat" + FactList (retires SessionIdentityCard's box; same fields) → HostCalloutSection (pending-apps now Banner tone="primary" with an action; the host row keeps PickupHostCard boxed — annotated // figure: — since forking its identity/message-CTA hooks is out of this wave's file scope and it's also consumed by public-pickup-preview-screen.tsx) → 호스트 도구 SectionBlock variant="flat" + HairlineList/ListRow (same six destinations; ListRow has no disabled/destructive props, so the 경기 규칙 row just drops its onPress while blocked and 경기 종료 keeps its destructive confirm without the retired red resting label) → 위험 구역 SectionBlock variant="flat" tone="danger" (unchanged buttons). Loading → SkeletonFlatPage kind="map". BottomCtaBand + the 23-branch primaryAction untouched. Every section's visibility boolean, order (per the spec's explicit point-4 sequence — PaymentGateBanner moved earlier, before 참여/경기/정보/HostCallout), testID, confirm dialog, and copy are unchanged; the characterization test (src/__tests__/session-detail-screen.test.tsx) was extended only where it asserted the retired 장소/날씨 boxes (now the hero) and the retired SkeletonHero (now SkeletonFlatPage). 2026-09-06 (Divider law): HairlineListRowList — rows carry no divider (peers are separated by spacing; dividers only for multi-component block peers such as match rows); FlatColumn draws the full-bleed section divider itself ($5 each side); FactRow lost first; flat section titles are navTitle 700. 2026-09-06 (seam system): sections gated at the call site, hero-zone blocks in FlatColumn lead, the band in DetailShell footer (pinned; tail 16), seams from SEAM (top 16 · section 24 · edge rule on rows).

2026-09-04 (session-detail-screen god-file split, behavior-identical): session-detail-screen.tsx (1,017 lines, one component closing over ~8 useState/useMemo values across nine <SectionBlock> JSX clusters) is now a ~506-line orchestrator. Every SectionBlock-wrapped cluster + the loose banners around them moved to packages/features/sessions/src/session-detail/sections/<kebab>.tsx (live-participant-bar, refund-banners, notice-section, venue-section, weather-section, info-section, host-callout-section, payment-gate-banner, participation-section, matches-section, host-tools-section, danger-zone-section, session-detail-overlays), each with an explicit props contract (only what it reads; callbacks passed in) and, where the monolith self-gated with a ternary, its own internal null-return so the screen composes them unconditionally. The screen's remaining up-front derivations (header ⋯ menu action list + the manner-tag coachmark plumbing) moved to session-detail/use-session-detail-data.ts, alongside the prior wave's use-session-detail-model.ts / use-generate-round-inputs.ts / use-session-detail-actions.tsx. Pure move — no logic/behavior/testID changes — pinned by a new characterization test (src/__tests__/session-detail-screen.test.tsx).

2026-09-04 (add-round-sheet god-file split, behavior-identical): add-round-sheet.tsx (1,168 lines, one component) is now a 375-line orchestrator (ModalPanel shell + composing hooks/components into the three steps). Flow state + the generate/commit pipeline split by the producer/consumer rule: add-round/use-round-preview.ts owns handleGeneratePreview/handleCommitPreview + their shared state (step/selectedStyle/previewRounds/isGenerating) since one builds the preview object the other consumes; add-round/use-manual-round.ts owns the actual mutation write (commitMatchInputs, injected into the preview hook) + the manual composer's own handleSaveManual — neither reads the preview hook's state, both reuse the same write path. The prop contract moved to add-round/sheet-props.ts (doc-comment-heavy, dominated the orchestrator's line count on its own). Presentational blocks: add-round/{style-picker,preview-list,manual-entry-rows,footer-cta,feasibility-panel,availability-roster}.tsx. Pure move — no logic/behavior/testID changes — pinned by a new characterization test (src/__tests__/add-round-sheet.test.tsx).

2026-09-04 (edit-session-screen god-file split, behavior-identical): edit-session-screen.tsx (1007 lines, 27 useState) is now a thin shell (loading/error/locked-status gates around <ConfigForm />). The 5 inline field-edit modals (TimeEditModal/MaxPlayersEditModal/CourtsEditModal/CompositionEditModal/TierEditModal) each moved to their own file under packages/features/sessions/src/edit-session/<kebab>-edit-modal.tsx (named exports, exported props interfaces); ConfigForm itself moved to edit-session/config-form.tsx. Pure move — no logic/behavior/testID changes — pinned by a new characterization test (src/__tests__/edit-session-screen.test.tsx).

2026-09-04 (use-session-detail-actions god-file split, behavior-identical): use-session-detail-actions.tsx (793 lines, one non-hook composite function computing eight concern groups) is now a 163-line composition shell over packages/features/sessions/src/session-detail/actions/: rsvp-cancel-actions.tsx (tier-band eligibility gate + confirmed/waitlisted cancel JSX), host-cancel-action.tsx, share-and-chat-actions.ts, primary-action.tsx (the single-CTA state machine across open/locked/in_progress/completed × host/participant/guest incl. the guest-application sub-flow), refund-and-delete-actions.tsx (refund-ack override — highest precedence — + the delete flow's three gated branches), and host-tools-gates.ts (the 호스트 도구 showTools*Row cluster). Each is a plain buildXxx function (a useXxx name would trip react-hooks/rules-of-hooks on the call after the session == null guard). Same SessionDetailActions return shape — pinned by src/__tests__/use-session-detail-actions.test.tsx (31 cases via renderHook).

2026-09-04 (match-board-screen god-file split, behavior-identical): match-board-screen.tsx (629 lines) is now a ~248-line orchestrator. Every derivation (ELO-delta/name/gender/elo maps, the settle-before-play eligible-pool gate, round grouping + active-round state, availability map, generator inputs, start/end-session handlers) moved to packages/features/sessions/src/match-board/use-match-board-data.ts (UseMatchBoardDataResult); every JSX cluster to match-board/sections/<kebab>.tsx (hero-card, start-session-button, round-actions-row, match-list, all-done-banner, match-board-overlays — the last bundles AddRoundSheet/LiveRoundEditorSheet/TierPromotionSheet, mirroring session-detail-overlays.tsx), each self-gating where the monolith used a ternary. Pinned by src/__tests__/match-board-screen.test.tsx.

2026-09-04 (scorecard god-file splits, behavior-identical): scorecard/live-tab.tsx (844 lines) is a 103-line composer: LiveMatchRow (per-match card + its 3 lazy sheets, card/embedded variants) moved verbatim to scorecard/live-tab/live-match-row.tsx; the whole derivation chain (buildFlatItems/buildStructuredSections pure builders, renderItem/renderMatchBody, admin round-start CTA, sticky pending-call callout) to scorecard/live-tab/use-live-tab-state.tsx; live-tab.tsx only picks RoundSectionList vs FeedList. scorecard/king-of-court-ladder.tsx (617) likewise moved TeamMovementRow/ReasonBadge/CourtCard and the resolveTeamMovement helpers to scorecard/king-of-court/*. Pinned by src/__tests__/live-tab.test.tsx and src/__tests__/king-of-court-ladder.test.tsx.

2026-07-29 (bookmark toggle moves to the header title bar): the save/bookmark TOGGLE's canonical home is now the session-detail title bar — a HeaderIconButton-shaped SessionSaveButton variant="header" immediately before the share icon (personal state-toggle first, outbound actions after). SessionSaveButton (packages/app/src/presentation/components/sessions/session-save-button.tsx, now exported from @twomore/app) gained a variant?: 'card' | 'header' prop — 'card' is the unchanged SessionCard top-right affordance (bare 22pt icon), 'header' renders through HeaderIconButton itself so sizing/hit-target chrome matches its share/message siblings exactly. Same optimistic toggle machinery (useSavedSessionIds + useToggleSaveSession, spring pop gated on reduced-motion) backs both — never duplicated. HeaderIconButton and the underlying Icon primitive (packages/ui/src) gained an additive optional fill prop (SVG fill, not theme-token resolved) to support the filled-when-saved state; both default to undefined so every other HeaderIconButton call site is unaffected. The passive RIGHT-pole saved mark on SessionStripView is unchanged — it stays a status indicator, not a toggle.

2026-07-27 (SessionCardV2 rollout, d21f1fba): the list-facing SessionCard referenced by the "Status chip" note below is retired to a rollback-only subtree; every list surface (Home, Club Home Tab, Club Session List, 경기) now renders SessionCardV2. This screen's own detail IA (SessionHeader/BottomCtaBand/matchups card) is unaffected — see Conventions › Session Surfaces.

2026-07-18 (UX-audit-approved-wireframe build — add-round-sheet T-9/T-10/D1a): add-round-sheet.tsx's 경기 만들기 config step got three owner-approved-wireframe fixes. T-9 (all-graphic feasibility tiles): the games and 지각 여유 stat tiles now carry a 28px graphic above their bold value, matching tile 1's CapacityRing rhythm — GamesRangeBars (ascending $primary/$borderSubtle bars, filled count = min games, capped at 6 bars) and LateBudgetDots (a fixed 4-dot row, filled = maxLateBudget, 5+ still shows 4 filled since the value text carries the real number). T-10 (court grid ceiling): RoundOccupancyCard's per-round court graphic gained a middle tier — 5-8 courts now render as a 4-column x 2-row grid of 16px CourtLinesTiles (previously collapsed straight to the ratio+ProgressBar fallback at 5); >8 remains the ratio+ProgressBar summary, now a documented deliberate ceiling. D1a (inline arrival picker): the arrival-roster row's tap-to-cycle control (a single round-number step per tap) is replaced by an inline expansion card — tapping a row's badge toggles an ArrivalExpansionCard directly below that row (round chips 도착..도착+5 plus a 시간으로 chip opening DateTimePickerModal, mirroring create-session-step1.tsx's picker props exactly), flush-aligned with the roster row's own avatar/badge column edges (no arbitrary indent — it's just another child of the same un-padded roster list). A new pure util arrivesAtToRound (packages/app/src/presentation/utils/arrives-at-to-round.ts) derives a round from the picked wall-clock time + session.rotationMinutes; a derived round at/behind the session's real next round clamps to 도착 but keeps arrivesAt for display. Rsvp.arrivesAt now flows end-to-end into the roster badge (오후 7:40 · 3라운드부터, new i18n availabilityArrivedAtRound/availabilityTimeChip, both ko/en) via an extended availabilityMap value shape ({fromRound, arrivesAt}) threaded from all three AddRoundSheet mount sites (match-board-screen.tsx, king-of-court-ladder.tsx, session-detail-screen.tsx), each of which also now passes two new required props — sessionStart (combineDateAndTime(session.date, session.startTime)) and rotationMinutes (session.rotationMinutes). The now-fully-replaced cycleArrivalRound util (zero remaining consumers) was deleted along with its test and index export rather than left as dead code.

2026-07-18 (matchmaking cosmetic-tail wave — D3 live tournament-board header): tournament-board-screen.tsx's DetailShell header is no longer a static "토너먼트" title — it now carries a live subtitle (in_progress: tournaments.continueChipLabel(formatLabel, latestRoundNumber), the same words as session-detail's 이어보기 chip; other statuses: formatLabel · tournaments.status.<key>) plus a status-gated PulseDot titleLeading when in_progress — the exact header treatment session-detail-screen.tsx already canonizes. Zero new i18n keys. (Same wave, non-screen: 조별리그's engine now honors the knockout-size knob engine-side only — no UI knob exposure for it yet, deferred wireframe-first.)

2026-07-17 (P5 unified 경기 방식 chooser — T-3/T-4 IA consolidation): New format-chooser-sheet.tsx (FormatChooserSheet + TournamentContinueChip) replaces the SURFACE-2 경기 만들기 + 토너먼트 만들기/이어보기 two-button stack with one NavRow ("경기 방식 선택") opening a single registry-backed picker (listStrategies(), two groups: 라운드 방식/토너먼트). AddRoundSheet's preselectKingOfCourt: boolean generalized to preselectStyle?: SelectedStyle (also updated the KOTC ladder's own call site); CreateTournamentModal gained a preselectedStrategyId prop that skips its select step straight to a new 대회 설정 step (조별 인원/진출 방식/조 편성 knobs — bracket-only, ≥9 entrants, since round_robin_groups's init has no options parameter at all despite the "조별" name) plus a read-only 경기 시간 row linking to 경기 규칙. Fixes T-3 (a running tournament becoming unreachable once the session left open/locked): TournamentContinueChip now surfaces at EVERY session status whenever useTournamentBySession resolves an active (non-completed/cancelled) row. session-detail-screen.tsx gained its own AddRoundSheet mount (a third site alongside match-board-screen.tsx / king-of-court-ladder.tsx) wired with the same settle-before-play partitionSettledMatchPool gate.

2026-07-13 (M1 format-aware tier-gate badge): The tier-band eligibility gate (tierBlocked in session-detail-screen.tsx → disables RSVP + t().sessionDetail.tierBandRequired explainer) now reads the viewer's rating from the session's own pool — singlesElo for singles, doublesElo for doubles/singles_doubles/mixed_doubles — instead of the cross-pool elo_rating, matching the server-side rsvps_enforce_tier trigger fix (migration 00424, same cross-pool bug). Grandfather skip for already-confirmed RSVPs unchanged.

2026-07-04 (session-action wiring — forfeit/step-out + payment rails): forfeit_session (migration 00310, supersedes the deferred 00206 Phase 2/3) now auto-substitutes the withdrawing player out of every live + upcoming match one round at a time (bench-confirmed → best-effort waitlist promotion), keeping the protected matches.participant_ids cache in sync (SECDEF-only — a client .update() is reverted by the 00067 protect_matches_scoring trigger). Host vs participant: the RPC auto-detects created_by — a host stepping out keeps hosting and the admin signal is suppressed (client renders the neutral 경기에서 빠지기 CTA); a participant forfeit signals admins, now excluding the actor. Verified end-to-end against the local DB (bench sub, host NOT-NULL-slot sub + signal suppression, no-sub nullable null-out). Payment: the in-app 결제하기 (PortOne) rail is hidden for real users (mock adapter + dev-gated confirm) — shown only in local dev; the manual 송금 완료 rail is the sole primary CTA.

2026-07-04 (session-detail button redesign, 5 phases): Phase 1 (d41e87cc) — canonical verb-only button labels, explanation moved to helper text above the button (cooldown/tier-gate pattern); in-card CTAs stay Pressable (COMP-3 forbids Button in Card). Phase 2 (97484a51 + migration 00309) — session group chat is find-or-create: dm_threads.session_id + partial-unique index + get-or-create create_group_thread. Phase 3 (731b2edb) — the host's single band CTA became state-contextual: 초대하기 (open) → 경기 시작 (locked) → 경기 보드 (in_progress) → 정산 (completed, if paid); 일정 취소 moved to the danger zone. Phase 4 (6fc7b3d6) — flattened the 일정 관리 hub into an inline status-gated 호스트 도구 SectionBlock (reusing extracted SessionNavRow); removed the header kebab (session-actions-menu deleted) — GPS 체크인 + 기권하기 now render inline while live; edit-session-screen stripped to config-only. Phase 5 (98d5376b) — a direct 점수 수정 pill on the completed match row opens the correction sheet in one tap (was a 3-tap chain through the profiles sheet).

2026-06-29: Added RecruitmentStatusBadge + recruitmentFillColor for open/locked sessions; CapacityRing and ProgressBar take optional fillColor. Updated chip selection rule: non-participating viewers on recruiting sessions see the derived recruitment badge, not raw session status.

2026-06-19: Rewrote to two-grouped-surfaces IA (LOGISTICS leads, PEOPLE follows). BottomCtaBand holds exactly one state-precedence CTA; non-CTA host/admin actions consolidated in settings-icon Popover. Session moved to top-level cross-cutting route app/sessions/[sessionId]/index.tsx. SessionStatusBanner for terminal states. useLiveSessionData composer for all live queries.

Prior (pre-2026-06-19): Single-card layout with LiveBanner + SessionInfoCard + RSVPButton + MatchCard + CostSplitCard + inline Admin Actions. Superseded — see CLAUDE.md Components rules for canonical IA.


Session List (ClubSessionsScreen) — app/clubs/[clubId]/sessions/index.tsx (mobile) · apps/web/app/clubs/[clubId]/sessions/page.tsx (web)

Implementation: packages/features/clubs/src/club-sessions-screen.tsx. Header: [DetailShell] title={club.name} · 일정 | onBack Layout: SegmentedTabs (preload="all", so all three panes mount on first paint) inside a flex={1} YStack; bottom BottomCtaBand CTA ("일정 만들기" → Create Session Wizard) rendered only when canManageSchedule.

├─ [SegmentedTabs] items=[모집 중, 진행 중, 완료]
│  ├─ 모집 중   (open + locked)   → SinglePageTab → useClubSessions(clubId, ['open','locked'])
│  ├─ 진행 중   (in_progress)     → SinglePageTab → useClubSessions(clubId, ['in_progress'])
│  └─ 완료      (completed)       → CompletedTab  → useCompletedClubSessions(clubId) infinite
│     ├─ [SessionCardV2] per completed session (bulk pre-fetch via `useSessionCardModels`)
└─ [BottomCtaBand] "일정 만들기" → Create Session Wizard ▸ canManageSchedule

Each pane is React.memo; the canonical FeedList/SkeletonCard/EmptyState stack backs all three (multi-tab content with per-pane hooks).

(00384 settle-before-play) — the R2-4 closeout strip was retired: attendance is match-derived and automatic on completion, unsettled holds auto-cancel at completion, and the snapshot no longer exposes closeout counters. The 완료 pane renders plain SessionCardV2s.

Empty State (per pane)
└─ [EmptyState variant="compact"]
   ├─ 모집 중/진행 중: t().clubSessions.empty / emptySubtitle
   └─ 완료:            t().clubSessions.emptyCompleted / emptyCompletedSubtitle

Create Session Wizard — app/sessions/create.tsx

Implementation: CreateSessionScreen at packages/features/sessions/src/create-session/create-session-screen.tsx (packages/features/sessions/src/create-session-screen.tsx is now a thin re-export shim — kept for index.ts/groundedIn path stability). Screen file owns only the useWizardNavigation wiring + the WizardShell JSX; draft-resume/persist field mappings, the mount-time resume/reset lifecycle, submit orchestration, per-step validity gates, footer copy, and the edit-scope fold each live in their own module under create-session/ (draft-resume.ts/draft-persist.ts/use-draft-lifecycle.ts/use-submit-session.ts/step-validity.ts/wizard-copy.ts/use-edit-scopes.ts), with the active-step switch in step-renderer.tsx. Step 1 (create-session/step1/) and Step 2 (create-session/step2/) are similarly split — their own top-level create-session-step1.tsx/create-session-step2.tsx files are re-export shims too. Header: [WizardShell] progress | close Layout: per-step ScrollView bg=background

Step 1: 언제 — date + time band (+ 정기 일정/지난 일정 prefill)
Step 2: 어디서 — venue search/pick (+ 사용 코트 수)
Step 3: 어떻게 치나요 — 구성 → 형식 → 실력 → 성격
Step 4: 몇 명이서 — 경기 시간 → suggested 모집 인원 + 최소 인원
Step 5: 옵션 — 참가비 + 참석 마감 + 반복 (skippable)
Step 6: 경기 규칙 — match rules
Step 7: 확인 — review → ✦ "일정 만들기"
History

Change log (2026-09-04, god-file decomposition, wave A2): create-session-screen.tsx (1,029 lines), create-session-step1.tsx (602), and create-session-step2.tsx (806) split into packages/features/sessions/src/create-session/ — no behavior change; every exported name/testID/accessibility label/i18n key preserved. Characterization tests (create-session-step1.test.tsx, create-session-step2.test.tsx, added ahead of the split; create-session-screen.test.tsx pre-existing) cover the wizard nav machine, the 정기 일정/지난 일정 prefill blocks, and the venue-pick/court-usage-seeding hooks — all pass unchanged pre- and post-split.


Session Edit — apps/mobile/app/sessions/[sessionId]/edit.tsx · apps/web/app/sessions/[sessionId]/edit/page.tsx

Implementation: EditSessionScreen at packages/features/sessions/src/edit-session-screen.tsx (config-only — cancellation/모집 관리 rows live inline on Session Detail's 호스트 도구 section instead); modals + form under edit-session/* Header: DetailShell title=strings.sessionDetail.editDetails | back Layout: DetailShell + ONE FlatColumn holding a single SectionBlock variant="flat"; footer = pinned BottomCtaBand 저장 (reported up from ConfigForm via onFooterStateChange)

Verified against edit-session-screen.tsx, 2026-09-11 — this entry previously described a plain RHF form; corrected below.

├─ [SectionBlock variant="flat" title=설정] → [ConfigForm]
│  └─ per-field [EditFieldRow]s (own dividers, no Card wrapper) — tap opens a focused editor
│     modal per field: [TimeEditModal] · [MaxPlayersEditModal] · [CourtsEditModal] ·
│     [CompositionEditModal] · [TierEditModal] — same pattern as club-settings' InfoRow
└─ [BottomCtaBand] "저장" ("저장 중" while pending) — disabled/error text from ConfigForm's footer state

Only reachable while session.status === 'open' (guarded here even though the 호스트 도구 row only routes here in that state) — else EmptyState variant="full" "수정할 수 없어요" + 뒤로가기.


2.4 Ranking Screens


기록 (Records) — app/(app)/(tabs)/(records)/index.tsx

Header: [MainTabShell] title=t().rankingScreen.pageTitle Layout: MainTabShell (SafeAreaView top-edge + pageTitle header) Implementation: RecordsScreen at packages/features/records/src/records-screen.tsx (@twomore/records) Bottom tab label: 기록

├─ [PillNav] scope=["우리 클럽", "친구", "지역", "전국"]
├─ [HeroCard] tier badge + ELO number + progress bar to next tier (tappable → TierInfoSheet)
│  └─ [SkeletonHero] ▸ loading
├─ ── Scope: friends, no friends yet ──
│  └─ [EmptyState] variant=compact ✦ "친구 찾기" → /profile/friends/add
├─ ── Scope: friends, friends exist but no shared matches ──
│  └─ [EmptyState] variant=compact
├─ ── Any scope, zero matches (PS10 R9 unified empty state) ──
│  └─ [EmptyState] variant=full ✦ "번개 찾기" → discoverPickups
└─ ── All other scopes (populated) ──
   ├─ [FormatStatsCard] 종목별 승률 — win-rate bars per format (singles/doubles/mixed)
   ├─ [TopPartnersCard] 최고의 파트너 — top-3 partners with medal badges
   ├─ [EloTrendCard] 최근 ELO 추이 — 10-match bar chart + net ELO change
   ├─ → 전적 보기 (flat Card footer link) → RecordsHistoryScreen
   └─ → 전체 리더보드 보기 (flat Card footer link) → RecordsLeaderboardScreen

Change log (2026-04-24, Phase 11p): New @twomore/records package. RecordsScreen replaces the old 통계 tab as the records/analytics surface. Scope selector client-side filters match history by club / friends / regional / national. RecordDetailScreen (period drill-down from home hero cards) stays in @twomore/home — it is a home tab surface. Change log (2026-07-12, 3.7 records UX): TierInfoSheet rebuilt onto Tamagui $-token primitives (tier-color dot stays a style escape hatch, same constraint as medalColor). The empty-state branches above are now driven by one canonical selectRecordsEmptyState() selector (replacing 4 ad-hoc fragments) — including the new "any scope, zero matches" branch. A useMonthlyChallengeBridge(uid) side-effect hook (no rendered UI — drives challenge-unlock toasts) mounts here, and redundantly-safe at app root (_layout.tsx) so unlocks fire even for a user who never opens 기록; the two mounts dedupe via the TanStack query-key cache.


Records History — app/(app)/(tabs)/(records)/history.tsx · app/records/history.tsx

Route: routes.recordsHistory Implementation: RecordsHistoryScreen at packages/features/records/src/records-history-screen.tsx Header: [DetailShell] title="전적" | back Layout: DetailShell (scroll=false) with GroupedFeedList (SectionList, grouped by month)

├─ [PillNav] scope=["우리 클럽", "친구", "지역", "전국"]
└─ [GroupedFeedList] sections=groupByMonth(scopedMatches)
   └─ ── per section header ── month label (e.g. "2026년 3월")
   └─ [MatchHistoryRow] date + club + format + opponent + score + win/loss badge + ELO delta
      └─ [SkeletonRow] × 6 ▸ loading
Empty State
└─ [EmptyState] variant=compact ▸ no matches in selected scope

Change log (2026-07-12, PS10 R10): MatchHistoryEntry.partnerAvatarUrl (doubles/mixed-doubles partner's avatar URL) added to useMatchHistory (use-match-history.ts). Data-layer only — MatchHistoryRow here doesn't render an avatar yet, so no row shows a partner photo today; the field is available for a future renderer.


Records Leaderboard — app/records/leaderboard.tsx · app/records/leaderboard/page.tsx

Route: routes.recordsLeaderboard Implementation: RecordsLeaderboardScreen (also aliased LeaderboardScreen) at packages/features/records/src/records-leaderboard-screen.tsx Header: [DetailShell] title="리더보드" | back Layout: DetailShell with ScrollView body

├─ [PillNav] scope=["우리 클럽", "지역", "전국", "시즌"]
├─ ── Scope: 우리 클럽 ──
│  ├─ [RankingList] per-club ELO → Public Profile (routes.userProfile)
│  │  └─ [MemberRow] rank + avatar + name + tier + ELO (highlight: viewer's row)
│  └─ [MyRankRow] "내 순위" footer ▸ viewer's rank > 50 (outside the rendered top-50; computed client-side from already-fetched data, no dedicated query)
├─ ── Scope: 지역 ──
│  ├─ [RegionalRankCard] percentile gauge + viewer's rank in region
│  └─ [MyRankRow] "내 순위" footer ▸ same > 50 condition
├─ ── Scope: 전국 ──
│  ├─ [GlobalRankingList] global ELO → Public Profile
│  │  └─ [MemberRow] rank + avatar + name + tier + ELO
│  └─ [MyRankRow] "내 순위" footer ▸ same > 50 condition (>100th national rank flagged for a future masking-safe RPC — profiles slice 3 held)
└─ ── Scope: 시즌 ──
   ├─ [PillNav] format=["전체", "복식", "단식", "혼복"]
   └─ [SeasonStandingsList] current quarter standings → Public Profile
      └─ [MemberRow] rank + avatar + name + wins/matches + win-rate
Empty State (per scope)
└─ [EmptyState] variant=compact ▸ no data for scope

Change log (2026-04-24, Phase 11p): Extracted from old @twomore/home leaderboard view into dedicated RecordsLeaderboardScreen in @twomore/records. 4-scope layout with season sub-tab. Change log (2026-07-12, 3.7 records UX): MyRankRow (packages/features/records/src/my-rank-row.tsx) added as a shared footer row on 우리 클럽/지역/전국 (not 시즌, untouched) — one implementation reused by all 3 tabs rather than 3 hand-rolled copies. Change log (2026-07-12, PS10 R8): Leaderboard tie order stabilized. get_public_profile_leaderboard (and findTopByRating) RETURNS SETOF jsonb, so PostgREST has no column for a client .order('id') to chain onto — equal-ELO rows could arrive in an unstable order across calls. profile.supabase.ts now applies a plain-string secondary sort on id (never localeCompare) after elo_rating DESC. The LIMIT-boundary tie (which rows land inside vs. just outside top-50) still needs a SQL ORDER BY id — flagged as a follow-up migration.


통계 (Stats) cluster — retired, route group gone (verified 2026-09-11)

This entry used to describe an (ranking) tab group (app/(app)/(tabs)/(ranking)/index.tsx + .../analysis/{format,partners,clubs,rivals,activity}.tsx) and a packages/app/src/presentation/screens/ranking/ screens directory. Neither exists any more. apps/mobile/app/ranking.tsx is now a one-line Redirect to routes.records, and the presentation/screens/ranking/ directory is gone from the tree. The canonical records/ranking surface is the 기록 (Records) entry above (RecordsScreen, @twomore/records).

The one piece of that old cluster with a live replacement: 라이벌 분석 (rival/head-to-head breakdown) now lives at apps/mobile/app/records/analysis/rivals.tsxHeadToHeadScreen (packages/features/records/src/head-to-head-screen.tsx) — this screen has no dedicated entry in this document; see Coverage gaps. The 포맷/파트너/클럽/활동 detail breakdowns this cluster described do not have a confirmed live equivalent — not re-verified here (out of this pass's scope); check packages/features/records/src/ for what, if anything, replaced them before citing this doc as evidence either way.


2.5 Other Screens


Public Profile — app/profile/[userId].tsx · apps/web/app/profile/[userId]/page.tsx

Route helper: routes.userProfile(userId). Redirects to /profile tab when userId === viewer id. First caller: leaderboard rows in records-leaderboard-screen.tsx. Implementation: PublicProfileScreen at packages/features/profile/src/public-profile-screen.tsx Header: DetailShell title=displayName (or strings.notFound) | back | headerRight = DM HeaderIconButton (MessageSquare) ▸ not self-view + authed Layout: DetailShell ground="canvas" scroll={false} → own ScrollView + FlatColumn, footer = pinned BottomCtaBand ▸ friendship band showing

Verified against public-profile-screen.tsx, 2026-09-11 — corrects the 2026-09-05 note's hero description, which the code has since moved past (owner ruling 2026-09-08, "D9"; see History).

├─ [TypeHero] leading=[AvatarBubble size=HERO], title=displayName, NO titleBadge (no single
│  entity-state concept to promote on this screen), facts=[GlyphFactRow]
│  🥇 ELO tier → 📍 region+district (one merged fact, e.g. "송파구 잠실") → 📊 reliability
├─ [FlatColumn]
│  ├─ lead (one hero-zone block):
│  │  ├─ ELO hero fact — "ELO" label + [Text role="displayMd" $primary] value, no progress bar
│  │  │  (that's an own-profile-only affordance)
│  │  └─ [StatStrip] 총 세션 · 출석률 · 신뢰 등급 (valueSlot=[TrustTierBadge size=sm])
│  ├─ [SectionBlock variant="flat" title=플레이] → [FactList]
│  │  ├─ 형식 ▸ |singlesElo − doublesElo| > 50 — [Badge variant=neutral] 단식/복식 선호
│  │  └─ 승률 — "최근 N경기 P%" off the actual fetched sample size (not the 50-match fetch cap)
│  └─ [Well tone="primary"] 친구 beat ▸ isFriend — "이미 친구예요" note
└─ [BottomCtaBand] ▸ profile resolved && !isSelf && authed && relationship !== 'blocked'
   └─ [FriendshipButton] — 5 states:
      none     → "친구 추가" (send request)
      sent     → "요청 취소" (cancel pending)
      received → "수락" / "거절" (accept / decline)
      friends  → "친구" (no-op display, long-press/tap → confirm-remove)
      blocked  → hidden (button itself returns null; the band also self-gates on this so it
                 never mounts as an empty frame)

Card count in the file: 0. useMatchHistory sample: 50 matches (own-profile screen's same window, so win-rate carries the same reliability regardless of whose profile is viewed). Loading → SkeletonFlatPage kind="type".

Hero identity pattern (owner ruling 2026-09-08, "D9"): no titleBadgefacts carries ELO tier → region·district → reliability, all as plain glyph facts (not badge components). This screen has no TierInfoSheet to move the reliability content into (unlike profile-screen.tsx), so reliability stays a fact here; trust tier instead renders as a TrustTierBadge inside the StatStrip's 신뢰 등급 cell.

History

Deferred: achievements section, club memberships section, tier progress chart.

Change log (2026-09-05, flat detail-page migration wave 3): DetailShell ground="canvas" replaced the tinted ground; both identity/play Cards retired (per docs/design/detail-pages-flat-build-spec-2026-09-05.md §3.11). At ship, the hero was TypeHero with region · district as a separate meta line and EloTierBadge + ReliabilityPill as hero badges — both superseded by the 2026-09-08 "D9" ruling described in the current body above (region+district folded into one fact; reliability is a plain fact, not a badge; trust tier moved to the StatStrip).


Profile Edit — apps/mobile/app/settings/edit-profile.tsx · apps/web/app/settings/edit-profile/page.tsx

Route correction (2026-09-11): the route is settings/edit-profile, not app/profile/edit.tsx (that path doesn't exist — Profile Edit is reached from the Settings screen's 프로필 편집 row, not a standalone /profile/edit). Implementation: EditProfileScreen at packages/features/profile/src/edit-profile-screen.tsx. The body below is not re-verified against the current source — out of this pass's scope.

Header: [ScreenHeader] title="프로필 수정" | back Layout: KeyboardAwareScrollView bg=background

├─ [AvatarPicker] camera/gallery
├─ [TextInput] name (validated via validateName)
├─ [PhoneInput] phone number
└─ ✦ "저장" lg+primary (sticky bottom)

Challenges — apps/mobile/app/clubs/[clubId]/challenges.tsx · apps/web/app/clubs/[clubId]/challenges/page.tsx

Route correction (2026-09-11): there is no standalone app/challenges.tsx — challenges are club-scoped only. Implementation: packages/features/clubs/src/club-challenges-screen.tsx (not the same file this entry's body below was written against). The body below is not re-verified against the current club-scoped screen — out of this pass's scope; treat the route as the only confirmed fact here.

Header: [ScreenHeader] title="도전 과제" | back Layout: ScrollView bg=background

├─ [ProgressCard] size=comfortable (overall completion %)
└─ ◇ Category Sections (collapsible)
   ├─ [SectionHeader] "첫 걸음" / "연승왕" / etc.
   └─ [ChallengeRow] icon + title + progress bar + reward

Court Directory — app/courts/index.tsx (mobile + web) · also mounted as the 경기 탭's "코트 찾기" segment

Route helper: routes.courts. Header: DetailShell title=t().courts.title | back Layout: DetailShell scroll={false} Implementation: CourtsDirectoryScreen (packages/features/sessions/src/courts-directory-screen.tsx) wraps the shared VenueDirectoryContent body (packages/app/src/presentation/components/sessions/venue-directory-content.tsx) — ONE implementation, two mount points (this standalone route + the 경기 탭's 테니스장 찾기 segment inside ActivityScreen); lives in @twomore/app because ARCH-2 forbids one feature package (sessions) importing a sibling feature package (activity) directly.

├─ [VenueFilterBar] search + [RegionDistrictFilter] region/district multi-select cascade + capability + indoor + 미확인-포함 toggle
├─ [SelectionChip] nav row: 전체 / 코트 / 연습장 / 샵 / 즐겨찾기 (accessibilityRole="tab" — a mutually-exclusive view-switching row; facilityView drives a server-side gate — 샵ⓘ facility_kind='shop', gear/stringing retail, never a playable court, excluded from 전체; 즐겨찾기 filters the loaded list client-side against useSavedVenueIds)
└─ [FeedList] browse mode: rows grouped into 시/도 [SectionHeader] sections (canonical province order; entire filtered result set loads, not paginated) · search mode (typed query, `recycling={false}`): [VenueSearchResultsList]'s flattened row stream, `onEndReached` drives spine pagination
   └─ [DirectoryVenueCard] static-map thumbnail (muted `TERMINAL_MUTE_OPACITY` for `unconfirmed` trust rows) + name + [VenueTrustBadge] (인증됨/확인됨/미확인) + [VenueSaveButton]
      + ONE glyph row: leading distance badge (가까운 순 only) → kind icon → district text → compact fact glyphs (court count, indoor, night lighting, parking — lucide icons, each with its own accessibilityLabel, omitted when absent) → trailing price badge
      → Venue Directory Detail (routes.directoryVenue — the nationwide-spine screen below; distinct from the club-scoped Venue Detail further down)
├─ [AppHeader] showBack — header owns back (wizard-nav law)
├─ [VenueFinder intent="pick"] a row tap only SELECTS (ring + check, DirectoryVenueCard's `selected`) — no longer fires onPick immediately
│  └─ search-mode Naver-tail rows: dashed frame + "네이버에서 찾음 · 저장하면 추가돼요" caption (replaces the plain attribution line in this intent only)
└─ [ActionButton] sticky footer, renders only once something is staged — "이 테니스장으로" (single-pick) | "N곳 선택" (multi-pick) — the ONE call site that actually fires the consumer's onPick, replaying every staged pick in tap order (including the Naver adopt-on-pick double-emit), then closes
Empty State
└─ [EmptyState] variant="compact" — cause-aware (selectVenueDirectoryEmptyCause):
   trust_gated (default corroboration filter excluded results) → subtitle + ✦ "미확인 포함" CTA sets verification='all' in place
   genuinely empty (no results even unconfirmed)              → subtitle only, no CTA
History

Change log (2026-09-05, VenueFinder god-file split, behavior-identical): venue-finder.tsx (857 lines — the last app file over ARCH-10's 600-line ceiling, docs/architecture/god-file-refactor-2026-09-04.md) is now a 380-line composer. Filter/view state (query control, region/district/facet/verification, active nav chip, search-mode/suggest-phase derivations) moved to sessions/venue-finder/use-venue-finder-state.ts; the query orchestration (browse directory query, 즐겨찾기 by-id read, view counts, the infinite spine + submit-gated Naver search-mode legs, and 가까운 순's own location-permission ask) moved to sessions/venue-finder/use-venue-finder-data.ts; pure row-shaping (buildRows/buildNearbyRows/SIDO_ORDER/NAV_CHIPS/directoryVenueToPickResult, plus the VenuePickResult type the composer re-exports) moved to sessions/venue-finder/venue-finder-rows.ts; the chrome header (search bar + finderHint + nav-chip row), the browse-mode FeedList (loading/error/empty states), and the search-mode VenueSearchResultsList wrapper each moved to their own sessions/venue-finder/venue-finder-{header,browse-list,search-results}.tsx with explicit props. The composer still owns intent-aware press/pick wiring (handlePress, the search-row pick mappers, the Naver adopt-on-pick double-emit) since those close over onPick/pickedVenueIds directly. Same exported VenueFinder/VenueFinderProps/VenuePickResult surface, same venue.* testIDs (Maestro flows 11/12 unchanged) — pinned by a new characterization test, sessions/__tests__/venue-finder.test.tsx (explore-vs-pick intent, nav-chip role=tab + active-view wiring, 가까운 순 permission-gating, and the browse↔search-mode result-list handoff including the id-less-Naver-row double-emit).

Change log (2026-09-04, card anatomy v2 + pick-intent select-then-confirm): DirectoryVenueCard's fixed-order text-badge strip (facility kind/region/court count/indoor/surface/lighting/ball machine, each a Badge) is retired — replaced by ONE glyph row (kind icon + district text + lucide-icon fact glyphs for court count/indoor/lighting/parking, each with its own accessibilityLabel; distance leads and price trails, unchanged). Surface and ball-machine are no longer shown on this card (no room; the filter panel owns that taxonomy). An unconfirmed-trust row's map thumbnail renders at TERMINAL_MUTE_OPACITY. Search-mode results (VenueSearchResultsList) now render through a virtualized FeedList (recycling={false}) instead of an un-windowed ScrollView. VenueSearchTakeover's pick intent no longer fires onPick on row tap — a tap only selects (ring + check); a new sticky footer ActionButton ("이 테니스장으로" / "N곳 선택") is the one call site that relays the staged pick(s), then closes; Naver-tail rows in this intent render in a dashed frame with an adopt-on-pick caption. Nav chips get accessibilityRole="tab". (venue-finder decision board panels B/C + the paired engineering audit, 2026-09-04.)

Change log (2026-09-03, 샵 nav chip): Added a 'shop' facility view (chip order: 전체/코트/연습장/샵/즐겨찾기) to VenueFinder/VenueDirectoryPort#searchfacilityView: 'shop' maps to facility_kind = 'shop' in both the RPC (p_facility_kinds: ['shop']) and the list-builder path (.eq('facility_kind', 'shop')); shops stay excluded from 'all' (packages/app/src/adapters/supabase/venue-directory.supabase.ts, packages/app/src/presentation/components/sessions/venue-finder.tsx).

Change log (2026-07-12, 3.6 discovery UX): Rewrote this section to match the actual current implementation (previously described a SearchBar/LocationPicker/FilterChips/SectionList/CourtCard tree that no longer exists). DirectoryVenueCard gained the 미확인 confidence-honesty badge (only renders once the 미확인-포함 toggle actually admits a low-confidence venue — under the default filter it never appears, by construction). Empty state became cause-aware. Region/district filtering now goes through the shared RegionDistrictFilter component (packages/app/src/presentation/components/region-district-filter.tsx) — also used by the 번개 pickup filter bar (see 경기 tab change log below) — replacing ~90 lines of duplicated cascade logic across the two call sites.


Venue List — app/clubs/[id]/venues.tsx

Header: [ScreenHeader] title="코트 관리" | rightAction: "추가" → create modal ▸ isAdmin Layout: SafeAreaView bg=background

└─ [VenueCard] size=default → Venue Detail
   └─ edit action ▸ isAdmin → edit modal

Venue Detail — app/venues/[id].tsx (mobile) · app/(app)/venues/[id]/page.tsx (web)

Route helper: routes.venue(venueId) from packages/app/src/navigation/routes.ts Implementation: VenueDetailScreen at packages/features/sessions/src/venue-detail-screen.tsx Header: [DetailShell ground="canvas"] title=venueName | back Layout: DetailShell ground="canvas" — flat $card ground, no boxed cards

├─ [MediaHero kind="map"] the venue's static map (useVenueHeroImage precedence) — title=name, meta=region+address
├─ [FlatColumn]
│  ├─ [StatStrip] 표면 · 코트 · 요금 (each cell skipped when its fact is unknown)
│  ├─ [ActionIconRow] 길찾기 · 지도 · 예약 사이트 (bookingUrl) · 전화 (bookingPhone)
│  ├─ ◇ 이용 요금 — [FactList] 평일/주말/야간 추가, or noPricing text
│  ├─ ◇ 운영 시간 ▸ hoursLabel — plain text row
│  ├─ ◇ 시설 정보 ▸ facilityItems.length > 0 — [Badge] chip wrap
│  ├─ ◇ 예약 정보 — [FactList] 일정/전화/링크/메모, or noBookingInfo text
│  └─ ◇ 메모 ▸ notes — paragraph
└─ [BottomCtaBand] 알림 설정/해제 ▸ hasBookingAlert (bookingOpenDay + bookingOpenTime)
History

Change log (2026-09-05, flat detail-page migration, wave 2 option A): Re-skinned onto the flat grammar without changing data (docs/design/detail-pages-flat-build-spec-2026-09-05.md §3.3) — DetailShell ground="canvas", MediaHero replacing the boxed VenueCard tile, StatStrip/ActionIconRow replacing the 5-button row and the HeaderActionsMenu (retired — its 4 items became icons), FactList replacing the local DetailRow (retired). Cards 4 → 0. Option B (folding into the directory venue spine once court_venues carries a venue_id) stays tracked separately. First characterization test added (__tests__/venue-detail-screen.test.tsx) — pricing/hours/booking facts, the action-icon gating on bookingUrl/bookingPhone, and the booking-alert BottomCtaBand label flip.

Change log (2026-04-24, Phase 11p): Venue detail strengthened — address, region badge, surface-type badge, court count, and session-wizard CTA all functional. Court finder step in CreateSessionScreen debounced with recent-venues suggestions (useRecentVenues + usePublicCourts). Planned: Option B data fold (see above).


Venue Directory Detail — app/directory-venues/[id].tsx (mobile)

Route helper: routes.directoryVenue(venueId). Distinct from Venue Detail above — that screen reads a club-scoped court_venues row (admin-curated); this one reads the platform-ingested nationwide directory spine (public.venues, via useDirectoryVenue/useVenueCourts) and is read-only (no edit affordances — only a stub host-confirm CTA with no backend yet). Reached from Court Directory's DirectoryVenueCard tap. Implementation: DirectoryVenueDetailScreen at packages/features/sessions/src/directory-venue-detail/directory-venue-detail-screen.tsx (packages/features/sessions/src/directory-venue-detail-screen.tsx is now a thin re-export shim — kept for index.ts/groundedIn path stability). Layout + the per-kind section map only; data/derivation lives in use-directory-venue-detail.ts, one component per content section under sections/ (기본 정보/다른 이름/운영시간/이용료/리뷰 키워드/평점/편의시설/결제 수단/바로가기/블로그/사진/시설/코트 구성/정보 출처/호스트 확인/검토 대기), shared pieces (edit-glyph button, closed banner, correction row, photo grid, etc.) under parts/. Header: [DetailShell ground="canvas"] title=venue.name | back, right=[HeaderActionsMenu] 공유/신고하기

├─ [MediaHero kind="map"] status=facility-kind [Badge] (venue.detail.kind), title=name, badges=[VenueTrustBadge]
├─ edit-glyph row — venue.detail.edit.name glyph · venue.detail.address text (+ distance) · address edit glyph (MediaHero has no title/meta node slot — see file header)
├─ [Banner tone="warning"] ▸ isClosed — 재개장 신고 action
├─ [FlatColumn] (opacity=TERMINAL_MUTE_OPACITY when isClosed)
│  ├─ [StatStrip] hero facts (non-shop: 코트/종류/1시간 요금; shop: 결제/평점)
│  ├─ [ActionIconRow] 길찾기 · 전화 (disabled without phone) · 저장 · 공유 · 재개장 신고 (isClosed)
│  ├─ per-kind content sections in `sectionOrderForKind(facilityKind)` order (FactList/HairlineList/chips — see change log below for the per-section primitive)
│  ├─ ◇ 정보 출처 ✚ — [FactList] 출처 N개 + [SourceChips] (folds the retired VenueTrustCard's ring/chips; not part of `sectionOrderForKind` — a fixed utility row like 호스트 확인/검토 대기 below)
│  ├─ 호스트 확인 — [ListRow] → correction modal
│  └─ 검토 대기 중인 제안 ▸ correctionRows.length > 0 — [HairlineList] of correction rows
└─ [BottomCtaBand] 이 테니스장에서 일정 만들기
History

Change log (2026-09-05, flat detail-page migration, wave 2): Re-skinned onto the flat grammar (docs/design/detail-pages-flat-build-spec-2026-09-05.md §3.2, docs/canon/components.md §116 the Canvas law) — DetailShell ground="canvas", MediaHero/StatStrip/ActionIconRow/FactList/HairlineList/ListRow/Well replacing every Card/SpecList in this subtree (10 → 0 Cards); retired parts/hero.tsx, parts/trust-card.tsx, parts/stat-tile.tsx, parts/action-tile.tsx, parts/action-row.tsx, and sections/cta.tsx (the create-session CTA moved to a pinned BottomCtaBand). New ✚ sections/sources.tsx (정보 출처) and header HeaderActionsMenu (공유 → OS share of name+address; 신고하기 → the existing ReportSheet, reused from venue-correction/report-sheet.tsx without touching that file). basicInfo now renders only the facts NOT already in the hero StatStrip (전화/조명/위치) and drops when the strip covers everything. Deferred (out of this wave's touch scope): "여기서 열린 일정" (needs a new venue-scoped sessions read in @twomore/app) and a "마지막 확인" timestamp in 정보 출처 (no such field exists on DirectoryVenue/VenueSourceValues yet). Every venue.detail.*/venue.correct.* testID preserved (Maestro flows 11/12 unaffected); directory-venue-detail-screen.test.tsx updated for the new component set, same core assertions (section order per kind, the three Maestro-critical testIDs, loading/error/empty/offline branches). 2026-09-06 (Divider law): HairlineListRowList — rows carry no divider (peers are separated by spacing; dividers only for multi-component block peers such as match rows); FlatColumn draws the full-bleed section divider itself ($5 each side); FactRow lost first; flat section titles are navTitle 700. 2026-09-06 (seam system): sections gated at the call site, hero-zone blocks in FlatColumn lead, the band in DetailShell footer (pinned; tail 16), seams from SEAM (top 16 · section 24 · edge rule on rows).

Change log (2026-09-04, god-file decomposition): The 1,676-line screen split into packages/features/sessions/src/directory-venue-detail/ — no behavior change, every testID/accessibilityLabel preserved (Maestro flows 11/12 still key on venue.detail.kind/venue.detail.edit.name/venue.detail.address). directory-venue-detail-screen.test.tsx (added ahead of the split) renders a mocked venue per kind (full_court/practice/shop) and asserts the rendered venue.detail.section.<key> testIDs match sectionOrderForKind(kind) order plus the three Maestro testIDs.

Change log (2026-09-04, UX audit findings 5+8 — progressive disclosure + per-kind section order): Two data-driven layout changes, both grounded in docs/design/venue-surfaces-ux-audit-2026-09-04.md, neither touching the submit/approval contract (SubmitVenueFactCorrectionInput/useCorrectVenueFact) or the correction entity.

Finding 8 (this screen): the content-section order below the trust card was one fixed JSX sequence for every kind, with only SUPPRESSION varying (isShop hid 이용료/코트 구성). directory-venue-detail-helpers.ts's sectionOrderForKind(kind) is now the single source of the per-kind order — shop moves 운영시간/결제 수단/바로가기 directly under 기본 정보; practice/screen move 이용료/편의시설 above 리뷰 키워드; full_court/unknown keep the original order. 코트 구성's array position never moves between kinds. The screen builds a sectionRenderers: Record<VenueDetailSectionKey, ReactNode> (each entry's render condition byte-identical to its old inline JSX) and renders sectionOrderForKind(facilityKind).map(key => sectionRenderers[key]) — a kind's IA is a data edit in the helper file, never a JSX reshuffle at the call site. directory-venue-detail-helpers.test.ts locks every order as a permutation of the same 13 keys plus the courts-position and shop/practice invariants.

Change log (2026-09-04, VenueCorrectionModal god-file split, behavior-identical): venue-correction-modal.tsx (~1,300 lines, 31 useState — one per correctable field plus batch-confirm state) is now a ~380-line ModalPanel shell + field composition + the EDITABLE_FIELDS_BY_KIND gating. Every field moved to its own use<Field>Field hook + <XField> component under packages/features/sessions/src/venue-correction/fields/<field>-field.tsx (18 fields: name/address/facility_kind/telephone/court_count/courts/capabilities/has_indoor/operating_hours/fee_schedule/location/website_url/booking_url/amenities/has_parking/has_shower/has_lighting/closed), each still rendering the SAME venue.correct.<field> testID/labels. Shared render primitives (FieldCard, BooleanToggleField) + pure helpers moved to venue-correction/field-shared.tsx; the batch-confirm row's UI to venue-correction/batch-confirm-row.tsx and its eligibility/confirm-all orchestration to venue-correction/use-batch-confirm.ts; the submit+toast flow to venue-correction/use-submit-corrections.ts; the footer submit's SubmitVenueFactCorrectionInput[] assembly to the pure venue-correction/build-submission-inputs.ts; the 18 field-hook calls + hasChanges/reset to venue-correction/use-correction-fields.ts; the graded-fields block and the "더 보기" collapsed panel to venue-correction/graded-fields-section.tsx/venue-correction/more-fields-panel.tsx (pure composition, still rendering each field's own hook state); the report-reason sheet to venue-correction/report-sheet.tsx. Pure move — no logic/behavior/testID/submit-contract change — pinned by a new characterization test (__tests__/venue-correction-modal.test.tsx): a full_court fixture changing court_count/telephone/has_lighting asserts the mutation received exactly those 3 inputs in buildSubmissionInputs's field order, and a shop fixture asserts venue.correct.court_count never renders.

Finding 5 (VenueCorrectionModal): the editor was one flat scroll of always-visible sections (8 for a shop, ~18 for full_court) with no signal distinguishing an already-corroborated fact from one that needs a look — despite the modal already carrying that signal per field (getFactTier/getLocationTier's ConfidenceTier). venue-correction-modal-helpers.ts adds two pure groupings driven by that tier: (a) batchConfirmEligibleFields — the simple-valued graded facts (court_count when unrostered, telephone when seeded, capabilities, has_indoor, has_lighting, location) currently at tier: 'high' collapse into a single "정보가 정확해요" SectionBlock at the TOP of the scroll, each field a SelectionChip; tapping the row's action (venue.correct.batchConfirm.action) fires useCorrectVenueFact for every pending chip's CURRENT (seed, unedited) value in one Promise.allSettled batch, tapping one chip instead expands just that field back into its normal full section (expandedBatchFields state) — operating_hours/fee_schedule are graded too but excluded (no cheap "confirm as-is" value exists ahead of opening their composer), so they always render as a normal section. (b) collapsibleFieldsFor — the fields with NO per-fact confidence tier at all (name/address/facility_kind/courts/website_url/booking_url/amenities/has_parking/has_shower, i.e. the 00573 metadata additions + 코트 구성) fold behind a collapsed "더 보기" row (venue.correct.more.toggle) + AttachedPanel (venue.correct.more.panel, wizards.md's whole-row-tap grammar), folded by default, re-tap folds it back. Every field keeps its existing venue.correct.<field> testID wherever it ends up rendering (chip-collapsed, expanded, or inside 더 보기) — a Maestro flow now needs to expand/open the relevant group first. venue-correction-modal-helpers.test.ts covers the eligibility table (roster/seed-value/tier/editable-for-kind guards) and the collapsed-field filter.

Change log (2026-09-04, migration 00573 — editable metadata for every kind): VenueCorrectionModal (packages/features/sessions/src/venue-correction-modal.tsx) now renders its sections from EDITABLE_FIELDS_BY_KIND[venue.facilityKind] (single source of truth, packages/app/src/domain/entities/venue-correction.entity.ts) instead of a one-off isShop ternary, with 9 new correctable fields: name/address/facility_kind (segmented full_court/practice/screen/shop chips), website_url/booking_url (https-validated text), amenities (chip-toggle + free-text add, ≤20), and has_parking/has_lighting/has_shower (있음/없음 toggles, mirroring has_indoor) — kind gating mirrors the server exactly (shop still can't touch court facts; practice/screen still can't touch court_count/courts/has_lighting). DirectoryVenueDetailScreen grew matching EditGlyphButton entry points (name/address/kind badge in the hero, 바로가기 and 편의시설 section headers), each gated by isFieldEditableFor, and its pending-review field/value label switches now cover all 9 new fields.

Change log (2026-09-03, shop-kind gating + 00560 metadata sections): facilityKind === 'shop' (gear/stringing retail, migration 00560) now suppresses every court-only block — court count stat tile, 코트 구성 (courts-by-surface), 요금, "코트" host-confirm wording (swaps to hostConfirmTitleShop), and VenueCorrectionModal's 코트 수/코트 구성/이용료 fields — while keeping address/phone/hours/review-keywords for any kind. Six new data-gated sections render for ANY kind when the underlying venues/venue_source_observations data exists: 네이버 방문자 평점 (rating+review count), 편의시설 (amenities, falling back to parking/shower flags), 결제 수단 (payment methods), 바로가기 (booking/website/네이버 플레이스/카카오맵/블로그/톡톡 link rows), 블로그 리뷰 (top 6), and 사진 (horizontal thumbnail strip, per-image hide-on-error). New DirectoryVenue fields: ratingValue/ratingCount/reviewCount/bookingUrl/websiteUrl/amenities/hasParking/hasShower/hasShop/kakaoPlaceId (findById-only projection). New VenueSourceValues keys (dormant naver_place scrape, same PS9 U6-6 pattern): naverPhotos/naverBlogReviews/naverPaymentInfo/naverBlogUrl/naverTalktalkUrl/naverRoadAddress.

Change log (2026-07-13, V3 court-reopen banner + tile): Closed venues (closedAt/closureReason, data-plumbed in the entry below but with no UI until now) surface a top warning banner ("폐업으로 표시된 코트예요" + closure-reason line, $badgeWarningBg/$badgeWarningText) leading the scroll, a "재개 제보" tile appended to the action row (call · directions · save · reopen when closed), and terminal-state muting (opacity=0.55 on every section below — same treatment as an archived club card). Both the banner's inline link and the tile submit {field:'closed', value:false} via the existing useCorrectVenueFact hook (ModalPanel confirm), routed through migration 00423's widened submit_venue_correction (now accepts any boolean for closed, gated by the same reputation trust ladder as every other fact).

Change log (2026-07-12, 3.6 discovery UX / PS9 U6-6): Naver deep-data surfaced — per-day business hours (additive rows under 운영 시간) + top-5 review-keyword badges (new 리뷰 키워드 section) — sourced from naver_place source-observation rows already fetched unconditionally on this screen (no migration, no new round-trip; the data was dormant on the adapter before this).


Record Detail — app/records/[period].tsx (mobile) · app/(app)/records/[period]/page.tsx (web)

Route helper: routes.record(period) from packages/app/src/navigation/routes.ts Implementation: RecordDetailScreen at packages/features/records/src/record-detail-screen.tsx; RecordDayDetailScreen at record-detail/record-day-detail-screen.tsx (its own file since wave 4a — @twomore/one-flat-column caps a file at one <FlatColumn>), both re-exported from record-detail-screen.tsx for @twomore/records. Pure helpers in record-detail/helpers.ts; presentational pieces in record-detail/{outcome-summary-card,opponent-context-card,match-timeline-row}.tsx; RecordDetailScreen's full derived-data memo chain in record-detail/use-record-detail-data.ts. Header: [AppHeader] title=period label — "4/14 — 4/20" for week, "4월" for month (not "이번 주 기록") | back Layout: DetailShell ground="canvas" — flat $card ground, no boxed cards Entry point: tap on hero stat block in HomeWeekView or HomeMonthView

├─ [TypeHero] eyebrow=period label · title=win-rate or match count · meta=W-L-D + count · right=56px outcome ring
├─ [FlatColumn]
│  ├─ [MatchHistoryScopeSelector] chips, $3 bottom hairline
│  ├─ [StatStrip] ELO 변동 (tone by sign) · 활동일 · 티어 또는 연승
│  ├─ [SectionBlock variant="flat"] 활동 — right=[ActivityMetricSelector], [ActivityMetricCalendar] on the bare canvas (no $surface panel)
│  ├─ [SectionBlock variant="flat"] 상대 전적 — [HairlineList] of [ListRow] (avatar · name · W-L sub · win-rate + progress bar), top 5 → 더 보기 → 10
│  └─ [SectionBlock variant="flat"] 경기 기록 — right=[MatchListControlsTrigger], grouped [HairlineList] of [MatchTimelineRow]
└─ [EmptyState variant="full"] ▸ no matches in period

Record Day (RecordDayDetailScreen, routes.recordDay(date, scope)): same grammar, no scope-selector row — [TypeHero] (title=date, meta=W-L-D, right=ring) → [FlatColumn][StatStrip] (ELO 변동 · 최종 레이팅 · 경기 수) → [HairlineList] of [MatchTimelineRow]. No CTA band on either screen.

History

Change log (2026-09-05, wave 4a flat detail-page migration): OutcomeSummaryCard/DaySummaryCard (boxed RecapOutcomePanel donut+strip cards) retired — replaced by TypeHero (title/meta text + a 56px OutcomeRing, record-detail/outcome-summary-card.tsx) and a StatStrip. The activity calendar's $surface panel-in-Card is gone — ActivityMetricSelector/ActivityMetricCalendar render directly on the canvas inside SectionBlock variant="flat". OpponentContextCard and the match list both dropped their Card+Divider anatomy for HairlineLists of ListRow/MatchTimelineRow (computeOpponents now also returns losses/draws per opponent for the "N승 M패" sub-line). Card count on this slice: 7 → 0. RecordDayDetailScreen moved to its own file (record-detail/record-day-detail-screen.tsx) to satisfy the one-FlatColumn-per-file lint rule. RefreshControl added to both screens (matchHistoryQ.refetch).

Change log (2026-07-04, historical): The equivalent day-summary card (DaySummaryCard) was migrated onto the shared canonical RecapOutcomePanel (donut + win-rate hero + full-width 승·패·ELO·연승 breakdown stat strip) — the same component OutcomeSummaryCard and home's WeekRecapCard already used — killing the last hand-rolled donut+legend block (commit c31f27db). Both RecapOutcomePanel-based cards were retired in the 2026-09-05 flat migration above.


Spectator Scorecard — app/sessions/[sessionId]/scorecard.tsx (mobile) · app/(app)/sessions/[sessionId]/scorecard/page.tsx (web)

Route helper: routes.scorecard(sessionId) from packages/app/src/navigation/routes.ts Implementation: SpectatorScorecardScreen at packages/features/sessions/src/spectator-scorecard-screen.tsx Header: [ScreenHeader] title="경기 현황" | back Layout: SafeAreaView bg=surface.secondary Tab pattern: <PillNav> — auto-selects default tab from match state (live → rounds → draws) Realtime: useMatchRealtime subscription at screen level

├─ [PillNav] tabs=["대진표", "LIVE", "라운드 결과"]
대진표 Tab
└─ [DrawGrid] round-robin or bracket draw (read-only)
   └─ [EmptyState size="full"] ▸ no draws configured
LIVE Tab
└─ [MatchGrid] current-round matches with live scores
   └─ [MatchCell] team A vs team B + live score (read-only; winner side highlighted)
   └─ [EmptyState size="full"] ▸ no in-progress matches
라운드 결과 Tab
└─ [RoundResultList] completed rounds
   └─ [RoundHeader] round label + summary
   └─ [MatchResultRow] teams + final score (winner side highlighted)
   └─ [EmptyState size="full"] ▸ no completed rounds

Read-only — no score inputs. Realtime updates auto-reflect score changes without refresh.


Club Dues — apps/mobile/app/(tabs)/(clubs)/[clubId]/dues.tsx · apps/web/app/(tabs)/clubs/[clubId]/dues/page.tsx

Route helper: routes.clubDues(clubId). Gate: canManageDues. Implementation: packages/features/clubs/src/club-dues-screen.tsx Header: DetailShell title="회비 관리" | back Layout: DetailShell scroll=false; content uses FeedList

Key hooks: useClubDues(clubId, month), useClubRole(clubId), useUpdateDuesStatus, useAuth (for clearedByUserId)

├─ [PillNav] month selector ← YYYY년 MM월 →
├─ [Card tone="default"] summary
│  ├─ [Text role="cardMeta"] "납부 완료 N명"
│  ├─ [Text role="cardMeta"] "미납 N명"
│  ├─ [Text role="cardMeta"] "일부 납부 N명"
│  └─ [Text role="cardTitle"] collection rate %
├─ [FeedList]
│  └─ [DuesRow] member name + amount + [Badge]
│     ├─ Badge variant="neutral"  ▸ status === 'paid'
│     ├─ Badge variant="warning"  ▸ status === 'unpaid' | 'partial'
│     └─ [KebabIcon] ▸ isAdmin → Tamagui Sheet
│        ├─ "납부 완료"    → useUpdateDuesStatus(status='paid', clearedByUserId=me)
│        ├─ "일부 납부"    → useUpdateDuesStatus(status='partial')
│        ├─ "면제"        → useUpdateDuesStatus(status='exempt')
│        └─ "메모"        → inline memo input
└─ ✦ "이번 달 회비 생성" lg+primary (BottomCtaBand) ▸ canManageDues && !duesExists
Signal flow (server-side)

When admin marks a dues row paid (status='paid', cleared_by IS NOT NULL, cleared_by != dues.user_id):

  • DB trigger signals_on_dues_update emits dues_cleared_by_admin (medium severity, no expiry).
  • When same user self-pays: trigger emits dues_paid (low, 24h) — existing behavior unchanged.
  • Client never dispatches signals directly.
Empty State
  • No dues for month: [EmptyState variant="full"] ✦ "회비 생성" → CreateDuesInput flow ▸ canManageDues

Club Attendance — apps/mobile/app/(tabs)/(clubs)/[clubId]/attendance.tsx · apps/web/app/(tabs)/clubs/[clubId]/attendance/page.tsx

Route helper: routes.clubAttendance(clubId). Gate: canManageClub (owner / 총무). Implementation: packages/features/clubs/src/club-attendance-screen.tsx (Phase 6 Part 3b/ii) Header: DetailShell title="출석 현황" | back Layout: DetailShell scroll=false; content uses FeedList

Key hooks: useClubAttendanceSummary(clubId), useMemberStrikeRecords(userId, clubId, enabled) (lazy-fetched per ExcuseSheet open), useExcuseAttendance, useClubMembers, useClubProfiles, useClubRole

├─ [Card tone="default" size="hero"] hero summary
│  ├─ [Text role="pageTitle"] club.name
│  ├─ [Text role="cardMeta"] "추적 중 N명 · 주의 멤버 N명"
│  └─ [Text role="cardMeta" color="$textSubtle"] subtitle
├─ [FeedList]
│  └─ [AttendanceMemberRow] sorted: flagged → strikesInWindow desc → name asc
│     ├─ name + [Badge variant="warning" "주의"]  ▸ strikesInWindow >= 3
│     └─ stats row: 참석률 % · 참석 N · Strike N (warning-tinted ▸ > 0) · 전체 N
│        ▸ tap a flagged row → ExcuseSheet (Tamagui Sheet at 70% snap)
└─ [EmptyState variant="compact"] ▸ no attendance records yet
   icon=ClipboardCheck title="아직 출석 기록이 없어요"
ExcuseSheet (per-flagged-member, opened from row tap)
├─ [Sheet] modal snapPoints=[70] dismissOnSnapToBottom
│  ├─ [Sheet.Handle]
│  ├─ [Text role="cardTitle"] "{name}의 strike 기록"
│  ├─ [Text role="cardMeta"]   "Strike N"
│  ├─ [Text role="cardMeta" color="$textSubtle"] "각 기록을 인정하면 해당 strike이 즉시 사라져요"
│  ├─ [Sheet.ScrollView]
│  │  └─ [StrikeRecordRow]  one per row, up to 20, newest first
│  │     ├─ [Text role="cardBody"] sessionTitle ?? formatStrikeDate
│  │     ├─ [Badge variant="warning" size="sm"] strike-type label (불참 / 지각 취소)
│  │     ├─ [Text role="cardMeta"] formatStrikeDate · "Strike {strikeValue}"
│  │     └─ [Button variant="outline" size="sm"] "사유 인정" → useExcuseAttendance
│  └─ [Button variant="ghost" size="md"] "닫기"

The ScrollView is Tamagui's Sheet.ScrollView (not RN's bare ScrollView) so the sheet's drag gesture stays composable with the inner scroll.

Permission Gate (non-admin)
├─ [EmptyState variant="full"] icon=ShieldAlert
│  ├─ title="접근 권한이 없어요"
│  └─ subtitle="출석 현황은 클럽 관리자만 볼 수 있어요"
Hex arch + cache wiring
  • useClubAttendanceSummary calls attendanceRecords.findByClub(clubId) via @/registry; client-side derivation produces ClubAttendanceMemberSummary[] (sorted, with latestStrikeRecordId and isFlagged flags). @freshness frequent.
  • useMemberStrikeRecords calls attendanceRecords.findStrikesByMember(userId, clubId) via @/registry; SQL filters strike_value > 0 AND final_status != 'excused' and joins sessions(title, date). @freshness frequent.
  • useExcuseAttendance calls attendanceRecords.excuseRecord(recordId) via @/registry (RPC pass-through). Invalidates: summaryByUser + byClub + byMember + strikeRecords + profile.detail. The sheet's row list shrinks live as 총무 excuses individual rows.
Empty State
  • No flagged members yet (rows array empty after filter): [EmptyState variant="compact"] icon=ClipboardCheck "아직 출석 기록이 없어요" subtitle="경기가 끝나면 여기에 출석 기록이 쌓여요"

Settings — apps/mobile/app/settings.tsx · apps/web/app/settings/page.tsx

Implementation: SettingsScreen at packages/features/profile/src/settings-screen.tsx (composition root — data hooks, mutation handlers, screen-level effects) + packages/features/profile/src/settings/* (one file per section, god-file decomposition wave B2, 2026-09-04) Header: DetailShell title=t().settingsScreen.pageTitle | back Layout: DetailShell + ONE FlatColumn (flat canvas migration F4 — every former card is a flat section separated by FlatColumn's own hairline, never a boxed Card)

Verified against settings-screen.tsx + settings/*.tsx, 2026-09-11 — this entry's body predated the real screen; corrected below.

├─ [IdentitySection] avatar + display name + email — passive, no actions
├─ [AppearanceSection] theme picker (chip row) + light/dark/system toggle
├─ [SecondaryNavSection] (RowList) — 알림 toggles then nav rows:
│  ├─ 알림 수신 [Switch] · 일정 리마인더 [Switch] · DM 푸시 미리보기 [Switch]
│  ├─ "프로필 편집" → routes.editProfile
│  ├─ "비밀번호 변경" → routes.changePassword
│  ├─ "알림 설정" → routes.signalPreferences
│  ├─ "프라이버시" → routes.settingsPrivacy
│  ├─ "동의 관리" → routes.consentManagement
│  ├─ "코트 제보 내역" → routes.venueContributions() (relocated here from the court-finder band, owner 2026-09-07)
│  ├─ "누락된 코트 제보하기" → routes.newVenueContribution()
│  └─ "차단 목록" → routes.settingsBlockedUsers
├─ [AppInfoSection] (RowList) — 둘러보기 다시 보기 (resets coachmark seen-flags) · 공지사항 ·
│  문의하기 · 이용약관 · 개인정보 처리방침 · 앱 버전 (display) · 업데이트 확인 (OTA check)
├─ [DestructiveSection] — 로그아웃 (confirm) · 회원 탈퇴 → routes.deleteAccount
└─ [DeveloperSection] ▸ developer-mode store accessible — hidden trigger for non-dev users;
   the mode toggle + 온보딩 리셋 row live inside once enabled

Total visible rows: ≤ 21 (per the file's own header comment).

History

Note (2026-09-04, god-file decomposition wave B2): settings-screen.tsx (651→317) was split, behaviour-identical, same export/props/testIDs/copy: SettingsRow/SectionCard/ThemeOptionsettings/components.tsx; the identity card → settings/identity-section.tsx; the theme-picker + appearance-chip card → settings/appearance-section.tsx; the notification-toggle + nav-destination card → settings/secondary-nav-section.tsx; the notices/contact/terms/version/update-check card → settings/app-info-section.tsx; the logout/delete-account card → settings/destructive-section.tsx; the developer-mode card + hidden long-press trigger → settings/developer-section.tsx. New characterization test: settings/__tests__/settings-screen.test.tsx.


Login — gate-rendered, not a route (corrected 2026-09-11)

Route correction: app/auth/login.tsx does not exist — there is no apps/mobile/app/auth/ directory at all. LoginScreen (imported from @twomore/app) is rendered directly by AuthGate inside apps/mobile/app/_layout.tsx ("renders LoginScreen when unauthenticated, Stack otherwise" per that file's own header comment) — it is a gate, not a navigable screen. The body below (BrandingSection, Kakao/Apple/email buttons) is not re-verified against LoginScreen's current source — out of this pass's scope; treat it as unconfirmed until read against the real component.

├─ [BrandingSection] logo + tagline "두 명 더 모이면, 게임 시작"
├─ ✦ "카카오로 시작" → Kakao OAuth (primary, kakao yellow)
├─ ✦ "Apple로 계속하기" → Apple OAuth ▸ iOS only
├─ ✦ "이메일로 로그인" → email form (ghost)
└─ [EmailForm] ▸ email mode selected
   ├─ [TextInput] email
   ├─ [TextInput] password (secure)
   └─ ✦ "로그인" lg+primary

Profile Setup — gate-rendered, not a route (corrected 2026-09-11)

Route correction: app/auth/setup.tsx does not exist. The onboarding flow is OnboardingWizardScreen (packages/features/onboarding/src/onboarding-wizard-screen.tsx), rendered by the same AuthGate in apps/mobile/app/_layout.tsx for an authenticated-but-not-onboarded user (postOnboardingDestination prop) — a gate, not a navigable screen, and this doc has no entry for it under either name. The step list below is not re-verified against onboarding-wizard-screen.tsx's current source — out of this pass's scope; treat it as unconfirmed.

Step 1: Name — [TextInput] validated
Step 2: Avatar — [AvatarPicker]
Step 3: Skill Level — [LevelSelector] beginner/intermediate/advanced
Step 4: Play Style — [StyleSelector] singles/doubles/both
Step 5: Region — [RegionPicker] → ✦ "시작하기"

Notifications — app/notifications.tsx (NotificationCenterScreen, packages/features/home/src/notification-center-screen.tsx)

Implementation (god-file split, 2026-09-04, behavior-identical): NotificationCenterScreen stays exported from notification-center-screen.tsx — pure helpers (buildFilters, filterSignals, getCategoryLabel) in notification-center/helpers.ts, presentational row components (FilterChip, SignalRow/MemoSignalRow, GroupRow/MemoGroupRow) in notification-center/components.tsx, and the shared FilterValue/FilterItem types in notification-center/types.ts.

Header: [DetailShell/AppHeader] title="알림" | rightAction: HeaderIconButton(Settings) → routes.signalPreferences (sole signature action — header-icon-system rework, screens wave A, 2026-07-31). "모두 읽음" relocated off the header onto the 새 소식 band's own [SectionHeader] right slot (quiet cardMeta/$textSecondary text link, same acknowledgeVisibleUnreadSignals mutation), rendered only when that band is non-empty (every 새 소식 row is unread by construction). Layout: DetailShell (non-scroll) → FeedList with a composed header

└─ [FeedList] header =
   └─ [FilterChip strip] (SelectionChip row, wrapping XStack — NOT horizontal
      scroll) — all/unread/관리(admin, only when count > 0)/category
└─ [FeedList body] — union `CenterRow` ({kind:'admin'} | {kind:'band'} |
   {kind:'signal'} | {kind:'group'}), grouped into 3 severity bands (see
   `docs/architecture/alert-model-contract.md` §Band model — driven purely
   by the existing `severity` field via `signalBand()`, NO schema change).
   Filter-chip selection narrows the list FIRST; bands group whatever
   remains. Empty bands render nothing (no orphan headers); each non-empty
   band opens with a `[SectionHeader variant="list"]` label row
   (할 일 / 새 소식 / 이전 알림).
   ├─ [band: 할 일] active AND severity ∈ {critical, high} — pinned FIRST,
   │  leaves the band on server-side RESOLVE, not on ack.
   │  ├─ [AdminAttentionRow] — VIRTUAL, non-persisted; one per club the
   │  │  viewer admins with a non-zero attention total that isn't currently
   │  │  snoozed (see dismiss below). Renders as an ordinary 할 일 citizen —
   │  │  no special chrome beyond the shared warning tint (`$badgeWarningBg`
   │  │  on the Card frame, tone stays 'default'): centered icon bubble
   │  │  (AlertTriangle) + club name (700) + quiet breakdown + footer
   │  │  ["관리" Badge (`surface` variant) … relative first-seen time at the
   │  │  FAR right, aligned under the close button]. No chevron, no
   │  │  read-state. Row → that club's 관리 탭 via
   │  │  `routes.club(clubId, { tab: 'admin' })`. Fed by
   │  │  `useMyAdminClubAttention(userId)` (same source as Home strip + bell
   │  │  badge). Hidden whenever a non-"관리"/non-"전체" filter is selected.
   │  └─ [SignalRow, band="todo"] SAME warning-tint language as
   │     AdminAttentionRow (`$badgeWarningBg`) — one amber language for
   │     "action needed" — taking priority over the fresh-band unread tint.
   │     Gains a deadline `[Badge]` next to the category badge whenever
   │     `signal.expiresAt` is set (`formatDeadline(expiresAt, now)`) — e.g.
   │     the payment-hold or RSVP-deadline countdown.
   ├─ [band: 새 소식] severity ∈ {medium, low} AND unread — standard row,
   │  `$primarySubtle` unread tint (R4, unchanged). Band header carries the
   │  relocated "모두 읽음" text link in its `[SectionHeader]` `right` slot
   │  (2026-07-31 header-icon-system rework).
   └─ [band: 이전 알림] read.
      ├─ [SignalRow, band="earlier"] individual read signals, plain card
      │  (no tint).
      └─ [GroupRow] T3 collapse: low-severity SOCIAL-category read signals
         for the same (clubId, day) fold into one row (title
         "{클럽이름} · 새 글 N개"), body sourced from the group's `latest`
         signal; tap routes to the club, not a specific signal destination.
         `groupEarlierBandSignals` / `isSignalGroupUnit` (`@twomore/app`)
         own the grouping at hook time — the resolver itself does zero
         grouping work.

[SignalRow] anatomy (todo/fresh/earlier bands share it): centered icon
bubble + title + body + footer spans the card's full width (dismiss-button
gutter sits on the text block only) [category Badge (`surface` on
unread/todo, `neutral` on read) … deadline Badge (todo band only) …
relative time at the FAR right, aligned under the close button (marginLeft
auto)]. Unread = `$primarySubtle` Card tint + 700 title (NO dot, NO 안 읽음
badge — tint+weight dual cue; settles to plain card on read). onPress →
acknowledge + deep link destination (`getSignalRoute`). Dismiss icon
top-right (stopPropagation).

Both row kinds (admin + signal) share the same dismiss button: 40pt visual
circle + hitSlop (was 32pt), anchored top-right via `position="absolute"`.
History

Change log (2026-07-31, header-icon-system rework — screens wave A): headerRight drops to a SINGLE glyph (Settings only) per the ≤2-glyph composition rule (this screen only ever had one signature action to begin with). "모두 읽음" moves from the header ActionButton to a quiet right-aligned text link on the 새 소식 band's [SectionHeader] row — same t().notifications.markAllRead copy, same acknowledgeVisibleUnreadSignals mutation, gated on unreadSignalCount > 0 (unchanged gate, new location). CenterRow's band variant gained an optional markAllRead flag set only on the band:fresh row.

Change log (2026-07-31, alert-system streamlining): the band/admin classification above is now a formal model (packages/app/src/domain/rules/alert-state.ts) rather than logic embedded only in signalBand() — same 3 bands, zero behavior change (see alert-state.test.ts's table-driven regression guard). Notification center headerRight gains a settings gear (HeaderIconButton + Settings icon → routes.signalPreferences) alongside "모두 읽음", in an XStack. Pull-to-refresh now also refetches the admin-attention snapshots (useMyAdminClubAttention's new refetch), not just the signal feed. Home's bell (packages/features/home/src/cards/notification-bell.tsx, extracted from the inline home-feed-screen.tsx headerRight) is now TWO-STATE via deriveBellState: an amber attention badge ($badgeWarningBg/$badgeWarningText) when an admin-attention obligation is open, else a red unread badge ($badgeErrorBg/$badgeErrorText, the pre-existing single state) when there are unread events, else no badge — replacing the single always-red badge. accessibilityLabel is now dynamic (notifications.bellA11yLabel / bellA11yLabelWithObligation).

Change log (2026-07-12, PS8 4.6): the payment-category icon-bubble accent no longer renders every payment signal in error-red — getSignalCategoryAccent('payment', 'session_payment_confirmed') now overrides to $success (terminal 참가 확정, reusing the same positive icon as rsvp); every other payment type (holds, session_payment_submitted, expiry, refunds, subscription/fee signals) stays on the $error category default.

Change log (2026-07-11, alert-model wiring, commit 5dec3455): notification center rewritten onto the 3-band model (docs/architecture/alert-model-contract.md) — CenterRow gained a {kind:'band'} member and {kind:'signal'} rows now carry a band (signalBand(), pure function of the existing severity field, no schema change). Admin-attention rows render INSIDE the 할 일 band as ordinary citizens (they already shared its warning tint; now they share its band membership too), not a separate pinned slot. Todo-band SignalRows gain a deadline Badge sourced from expiresAt (formatDeadline). The 이전 알림 band gains T3 grouping — groupEarlierBandSignals folds same-day same-club low-severity social signals into a single GroupRow card. 4 previously-dropped DB-emitted types (session_player_forfeited, club_ownership_transferred, club_archived, join_request_decided) and 5 new payment/join-request types are now rendered — see docs/specifications/signal-system.md §6.

Change log (2026-07-11, R5 owner QA round 5): full SignalRow behavior parity for admin rows — a top-right dismiss (snooze) button, same anchor and size as SignalRow's, backed by a new MMKV persist store (admin-attention-ack.store.ts): it snoozes a club's EXACT counter-state via a 5-counter fingerprint, and the row reappears with a fresh firstSeenAt once any counter changes. The dismiss filter lives INSIDE useMyAdminClubAttention, so the bell badge, Home strip, and 관리 chip all agree with the center for free. The redundant "알림 N건" end meta is gone (the breakdown body already carries the counts) — the footer now shows the relative first-seen time instead. Both row kinds: the dismiss button grew to a 40pt visual circle + hitSlop (was 32pt); the footer now spans the card's full width (the gutter moved onto the text block only) so the trailing time sits at the FAR right, aligned under the close button. Unread SignalRow category badges and the admin row's "관리" badge switched to the new surface Badge variant ($surface bg / $textSecondary text) — a colored badge on a same-hue tinted card was rendering as bare text.

Change log (2026-07-11, R4 owner QA round 4): admin rows moved INTO the FeedList as union rows under the chips (the stacked header block above the chips is gone, with its 3-row cap + "외 N개 클럽" overflow — every admin club now gets a row). Canonical row format for BOTH kinds: colored unread dot and the 안 읽음/읽음 text badge removed — unread state is a $primarySubtle surface tint + bold title (tinted-row convention: Facebook/LinkedIn/X, paired with PatternFly/Gmail bold-title so state never rides color alone); icon bubble vertically centered; relative time (or the admin count) sits at the END of the footer row. Component renamed NotificationAdminAttentionRows → single AdminAttentionRow. (R3, same week: "관리 필요" SectionBlock retired; "관리" filter chip added; chip strip became a wrapping XStack, not a horizontal ScrollView.)

Empty State
└─ [EmptyState variant="all-done" size="full"]
   "새로운 알림이 없어요"

Match Board — apps/mobile/app/match-board.tsx (?sessionId= query param) · apps/web/app/match-board/page.tsx

Implementation: MatchBoardScreen at packages/features/sessions/src/match-board-screen.tsx Header: DetailShell title=sessionTitle(session) | back Layout: DetailShell (own ScrollView) + FlatColumn; footer = pinned BottomCtaBand (lifecycle CTA) + MatchBoardOverlays (sheet cluster)

Verified against match-board-screen.tsx, 2026-09-11 — corrects the 2026-09-05 note's hero anatomy, superseded per the file's own header comment (owner ruling 2026-09-08); also retires the stale config/in-progress/completed state model, which no longer matches the code (see History).

├─ [TypeHero] title = venue name ▸ session has one, else the generated session title
│  facts=[GlyphFactRow] 📅 date → 👥 confirmed player count → 🎾 format (▸ title didn't
│  already state it via the venue) → 🏷 court count
│  leading=[CapacityRing] completed/total ▸ hasMatches
├─ [FlatColumn]
│  ├─ [UnsettledPoolNotice] Callout tone="warning" ▸ unsettledPlayerIds.length > 0 —
│  │  settle-before-play: confirmed participants excluded from matches pending payment
│  ├─ [RoundActionsRow] ▸ hasMatches — round tabs + 라운드 추가 (canAddRound) + 팀 재배치 (canEditLiveRound)
│  ├─ [MatchListSection] active round's `MatchCard`s (sanctioned figure — `{/* figure */}`) or
│  │  a `Well` empty/no-round-matches notice; score correction available on completed matches
│  └─ [AllDoneBanner] Well tone="primary" ▸ allDone && isLive && canManageLifecycle — "경기 종료" CTA
└─ [BottomCtaBand] "경기 시작" (`StartSessionButton`) ▸ isOpen && canManageLifecycle

Card count in the file: 0 (HeroCard, AllDoneBanner's card, and MatchListSection's Card tone="flat" all retired for the flat grammar — MatchCard itself stays a sanctioned figure). Loading → SkeletonFlatPage kind="type"; offline → OfflineEmptyState; error → EmptyState variant="full" with retry.

History

Change log (2026-04-24, Phase 11p): Live round editing added — admins can update scores on an in-progress round without ending it. useStartSession / useEndSession wired. Optimistic score update with rollback.

2026-09-05 (flat detail-page migration, wave 4b, detail-pages-flat-build-spec-2026-09-05.md §3.4): DetailShell ground="canvas" + TypeHero replaced the boxed HeroCard; at ship the hero was described as eyebrow=date·venue / title=session title / a badges row (players/courts/format) — superseded by the 2026-09-08 hero-identity ruling in the current body above (facts fold into one GlyphFactRow, no separate eyebrow/badges row). UnsettledPoolNotice moved BannerCallout tone="warning". 2026-09-06 (seam-audit P7): the screen had never passed scroll={false}, so DetailShell's own ScrollView was silently swallowing the "pinned" BottomCtaBand + overlay cluster — both now render in DetailShell footer for real. 2026-09-06 (seam-audit P1): UnsettledPoolNotice/AllDoneBanner gated at the call site instead of relying on their own internal return null, which had been producing an orphan/doubled hairline in FlatColumn's divider math.


Practice Tracker — app/profile/practice.tsx

Route helper: routes.practice Implementation: packages/features/profile/src/practice-log-screen.tsx Entry point: ProfileMenuCard "연습 기록" row (프로필 탭) — previously orphaned (built with zero nav call sites), wired 2026-07-17. Header: [DetailShell] title="연습 기록" | back Layout: FeedList bg=background (scroll=false, BottomCtaBand pinned)

├─ [Card] size=lg summary ("N회 · 총 M분")
├─ [FeedList] → [PracticeRow] type icon + date + duration [Badge]
└─ [BottomCtaBand] "연습 추가하기" → [ModalPanel] AddPracticeSheet
   (type [PillNav] + duration [PillNav] → useCreatePracticeLog)

Weather Insight Sheet — app/weather-insight.tsx (BottomSheet)

Header: [BottomSheet] title="날씨 정보" | close Layout: BottomSheet with TabView

├─ [LocationSelector] current location / region picker
├─ [CalendarPicker] date selector (default: today)
├─ [TabView] tabs=["날씨", "TMI", "예보"]
날씨 Tab
├─ [WeatherSummaryCard] temperature, humidity, wind, sky condition
└─ [HourlyForecast] horizontal scroll of hourly weather
TMI Tab
├─ [TMIScoreCard] size=comfortable — TMI score gauge (0-100)
├─ [TMIFactorBreakdown] temperature, wind, precipitation, humidity scores
└─ [TMIRecommendation] play/caution/avoid guidance text
예보 Tab
├─ [DailyForecastList] 7-day forecast
│  └─ [DailyForecastRow] date + high/low + sky + TMI mini-score
└─ [WeatherWarningBanner] ▸ active 기상특보

Added 2026-04-07: Weather insight sheet accessible from HomeHeader weather display. 3-tab layout (날씨/TMI/예보) with location selector and CalendarPicker for date navigation.


DM Thread — app/messages/[threadId].tsx (routes.dmThread(threadId))

Header: [DetailShell] title = group title (or newGroup fallback) / other participant's displayName (or inboxTitle fallback) | subtitle = member count (group only) | titleLeading = AvatarStack (group, ≤3 shown) or AvatarBubble (1:1) | back | headerRight ▸ group: HeaderIconButton(MoreHorizontal) → opens ParticipantsModal; ▸ 1:1: HeaderActionsMenu (신고 default tone, 차단 destructive — header-icon-system rework, 2026-07-31: replaced a bespoke overflow trigger, ModalPanel under the hood, never a Tamagui Popover) Layout: DetailShell scroll={false}KeyboardAvoidingView (iOS padding + header offset; Android no-op — softwareKeyboardLayoutMode: 'pan' in app.json already pans the viewport) → FeedList (message log) + persistent bottom bar Implementation: DmThreadScreen (packages/features/messaging/src/dm-thread-screen.tsx), orchestrating a ./dm-thread/ module split — presentational sub-components (ThreadInfoHeader, DmMessageRow, ComposeBar, RequestGateBar, ParticipantsModal) + 4 state-island hooks (use-dm-thread-data, use-dm-chat-items, use-dm-composer, use-dm-thread-actions) + a renderer hook (use-dm-message-renderer)

├─ [FeedList] chat log, FlashList v2 chat API — maintainVisibleContentPosition
│  ={startRenderingFromBottom:true} renders from the bottom (NOT `inverted`,
│  deprecated in v2)
│  ├─ [ThreadInfoHeader] centered avatar + name + start date·time — FeedList
│  │  header, top of conversation
│  └─ [DmMessageRow] per message — own messages right-aligned ($primary
│     bubble, white text, tail bottom-right); theirs left-aligned
│     ($surfaceSecondary bubble, tail bottom-left, avatar on the first
│     message of a run, sender name above bubble ▸ group only). Timestamp on
│     the LAST message of a run; sent/read status on the viewer's last own
│     message.
└─ ◇ Bottom bar (persistent UI, NOT BottomCtaBand) ▸ request status
   ├─ [RequestGateBar] ▸ status ∈ {pending, declined} — DB blocks sends
   │  until accepted. Recipient of a pending request sees accept/decline;
   │  initiator sees "waiting"; declined is informational-only. Groups skip
   │  this gate entirely (always accepted).
   └─ [ComposeBar] ▸ status = accepted (or group) — image picker + rounded
      text input + filled circular send button
[ParticipantsModal] (ModalPanel) ▸ group threads, opened from headerRight
├─ member list — [AvatarBubble] + name + role badge ▸ admin
├─ rename (admin only) — inline title edit via `useRenameGroupThread`
├─ remove member (admin only) — per-row, `useRemoveThreadParticipant`
└─ ✦ "나가기" (leave) — bottom of list, all members

Data: useDmMessages (archive pages) + useDmLatestMessages (5s newest-page poll) + useDmThread (participant + request status). No realtime hook. useMarkThreadRead fires once on mount to clear the unread badge. useSendDmMessage follows the Phase 14d queueable pattern (offline-safe).

A blocking QueryErrorState replaces the whole body only when there are zero messages to show; a failed poll/refetch with existing messages on screen instead renders a non-blocking compact QueryErrorState banner above the list (pull-to-refresh or inline retry, thread stays open).


3. Cross-Screen Consistency Audit

Change log (2026-09-06, evening — hero canon + wave 5): every hero (TypeHero/MediaHero) opens 16 under the chrome, eyebrow above the identity row, one badge row under the title (no title-line or overlay badges), meta one fact per line. Ten more session screens ride DetailShell ground="canvas" + one FlatColumn with the CTA in footer: edit-session (설정 section, pinned 저장), match-rules, participants (grouped list, Well placeholder), session-attendance (Well summary + roster), public-pickup-preview (TypeHero → 정보 FactList → host figure → silhouette Well → location → notice, ApplyCta footer), session-payments (FactList summary → bare payment rows, one computed footer), pickup-host-dashboard (대기중인 신청 RowList dividers → danger zone), tournament-board (Well banner → 순위 rows → round figures → danger zone, 다음 라운드 footer), spectator-scorecard + standings tab + king-of-court ladder. Two sanctioned // figure: cards remain (tournament match card, standings ImpactCard).

Change log (2026-09-06, rhythm audit — every screen): no screen types a scroll inset any more. Every ScrollView/FeedList/ModalPanel.ScrollView composes a SCROLL_CONTENT preset from @twomore/ui (page is the shell default, so ~20 screens that restated it now pass nothing; list/railedList inside a shell; banded/railedBanded under a pinned band; sheet), enforced by @twomore/no-raw-content-inset. Peer divider loops on the manner-tags sheet, guest rating block, session matchups, scorecard rounds/standings and the host dashboard now ride RowList dividers; MiniBarMeter replaces the two hand-drawn bar meters (venue confidence, add-round feasibility); MapPinMarker is the session card's pin; the four dead legacy session cards (identity, status-participation, venue, weather) are deleted; every remaining raw size/radius/opacity is a named constant or carries a reason: comment. See docs/design/rhythm-alignment-audit-2026-09-06.md for the per-screen classes and the design-level queue (wave 5 legacy card grammar, onboarding value props, create-club shell).

IssueScreens AffectedSeverityStatus
Empty CTA mismatchClubs tabMedium✅ Resolved — all CTAs use Button component with consistent variant/size
Missing noResultsDesc경기, Discover, Court DirectoryMedium✅ Resolved — per-subject guidance text added (10 subjects)
FTUE bare fallbackHomeHigh✅ Resolved — welcome card (text only) + single Card with 4 ListRows
Header disappears in loadingProfile tabLow✅ Resolved — primary band persists with skeleton content
Inconsistent empty tab CTAsClub DashboardMedium✅ Resolved — role-aware descriptions for all 4 tabs
Cross-tab CTA inconsistency경기 tabLow✅ Resolved — uniform filter-aware pattern across all 3 tabs
No member empty CTAClub Dashboard 홈 tabMedium✅ Resolved — members see informational text
Progressive disclosure undocumentedSession DetailInfoDocumented — correct behavior, no fix needed
Hardcoded padding values7 screensMedium✅ Resolved — replaced with spacing[X] tokens
Hardcoded Korean a11y labels3 wizard stepsCritical✅ Resolved — replaced with t() i18n calls
Notification EmptyState sizeNotificationsLow✅ Resolved — added size="full"
모임 tab PillNav hidden in emptyClubs tabMedium✅ Resolved — PillNav (클럽 / 번개) always visible
번개 path missingHome FTUE, Clubs tabMedium✅ Resolved — 번개 만들기/참여하기 added to FTUE and 번개 tab
Card-styled PressablesClubActionFooterMedium✅ Resolved — replaced with Button component
EmptyState excessive paddingMultiple screensLow✅ Resolved — py-20 reduced to py-10
Analysis empty not scrollable경기 > 분석 tabLow✅ Resolved — empty state wrapped in ScrollView
활동 tab namingTab barMedium✅ Resolved — renamed to 경기
Tab conditional rendering breaks hooksRanking, Club Dashboard, My StatsHigh✅ Resolved — all tab screens use TabView or TabPanel
Missing ErrorBoundary on tab layoutsRanking, Club Dashboard, My StatsMedium✅ Resolved — QueryErrorResetBoundary + ErrorBoundary wraps all tab content
Missing ErrorBoundary on layoutsAuth layout, Club detail layoutMedium✅ Resolved — ErrorBoundary added to auth and club detail layouts

4. Empty State Decision Matrix

ScreenStateComponentVariantSizeCTACTA Condition
Home > RecapSummaryCardNo matches (week)Same card, dimmed opacity 0.3 + centered overlay pillWhole card → /records/week (empty)always
Home > RecapSummaryCardNo matches (month)Same card, dimmed opacity 0.3 + centered overlay pillWhole card → /records/month (empty)always
HomeNo clubs (FTUE)WelcomeCard + Card with ListRowsfull4 ListRows (클럽/초대/번개/참여)always
클럽 tabNo clubsEmptyState + Buttonsfirst-usefull"클럽 만들기" / "초대코드 입력"always
번개 tabNo pickupsEmptyState + Buttonsfirst-usefull"번개 만들기" / "번개 참여하기"always
경기 (per tab)No activitiesEmptyStateno-resultsfullCross-tab navigationalways
Profile > StatsNo matchesEmptyStatefirst-usecompactnone
Profile > My ClubsNo clubsEmptyStatefirst-usecompact"모임 찾기" → Discoveralways
Profile > ELONo historyEmptyStatefirst-usecompactnone
Profile > AchievementsNo progressEmptyStatefirst-usecompact"도전 과제 보기" → Challengesalways
Club Dashboard > 홈No sessionsEmptyStatefirst-usefull"첫 일정 만들기"isAdmin
Club Dashboard > 소식No postsEmptyStatefirst-usefull"첫 글 작성"isAdmin
Club Dashboard > 기록No matchesEmptyStatefirst-usefullnone (auto-populated)
Board FeedNo postsEmptyStatefirst-usefull"첫 글 작성"isAdmin
Session ListNo sessionsEmptyStatefirst-usefull"첫 일정 만들기"isAdmin
Discover ClubsNo resultsEmptyStateno-resultsfullnone
Court DirectoryNo resultsEmptyStateno-resultsfullnone
NotificationsNo notificationsEmptyStateall-donefullnone
Dues BoardNo dues configuredEmptyStatefirst-usefull"회비 설정"isAdmin

5. Modal / Bottom-Sheet Primitives

Tamagui Sheet

Used for draggable bottom-anchored panels (ExcuseSheet, MannerTagsSheet, AttendanceRosterSheet, ConfirmSheet, etc.). Scrollable content inside a Sheet must use Sheet.ScrollView — not RN's bare ScrollView — so the drag gesture composes correctly with the inner scroll.

ModalPanel (packages/ui/src/modal-panel.tsx)

Used for transient focused tasks that need full focus but should not navigate away: confirmations, selectors, short forms, detail previews. Does NOT use bottom-sheet dragging; exposes an explicit close control.

Canonical layout contract (Frame is auto-height, capped at 88% viewport):

<ModalPanel open={open} onOpenChange={setOpen}>
  <ModalPanel.Overlay />
  <ModalPanel.Frame>
    {fixed header}
    <ModalPanel.ScrollView>{scrollable body}</ModalPanel.ScrollView>
    {fixed footer — normal sibling, NEVER position="absolute"}
  </ModalPanel.Frame>
</ModalPanel>

Rules (violations cause collapsed panel or layout failure):

  • NEVER flex={1} on Frame children (including ModalPanel.ScrollView) — the Frame has no resolved height so flex children collapse to 0.
  • NEVER height or percentage maxHeight on the ScrollView — percentages resolve against the auto-height Frame → 0. The ScrollView ships with flexShrink: 1 baked in; that is the only sizing it needs.
  • Keyboard: the root bakes in KeyboardAvoidingView; text-input consumers need no local handling.

Canonical consumers: ConfirmSheet (simple body, no ScrollView needed), RangeChip date picker (header → ModalPanel.ScrollView → footer save button).

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