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
| Symbol | Meaning |
|---|---|
→ | 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 slot — HomeDisputedAlertCard (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
SessionCardprimitive.SessionCardauto-fetches weather (icon inSessionHeaderRow 1) viauseSessionWeather— no caller props required. Week/month hero stat blocks are nowPressable→/records/[period]. HomeWeekView + HomeMonthView show top-3 RSVP'd upcoming sessions + "더 보기" →/activity(replaces single next-session teaser).HomeLiveAlertBannermounted at the top ofHomeFeedScreenabove PillNav — renders only when a user session hasstatus=in_progress; shows compact score rows (cap 3) + tap →/sessions/[sessionId]/scorecard.HomeTodayViewempty-state "오늘은 경기가 없어요" is nowPressable→/activity.SessionCardgained optionalfooterprop;LiveSessionCarduses it to inject match rows. Badge height parity: competitive/LIVE/tier badges bumped tosize="sm". Change log (2026-06-11, WeekRecapCard ghost empty state): Whenentries.length === 0the card no longer disappears or shows a standaloneEmptyState. Instead it renders the SAME infographic layout ghosted atopacity={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 thePersonalRecordsSectionteaser-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).WeekRecapCardnow renders through the shared canonicalRecapOutcomePanel(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/MonthYearPickergained the same schedule awarenessDayCalendarPickeralready had, via a new pair of pure bucket-count selectors (bucketSessionCountsByWeek/bucketSessionCountsByMonth,packages/app/src/presentation/utils/session-date-buckets.ts).RsvpRepositoryPort.findParticipatingSessionDatesno 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 aSetfor presence. AddedgetMonthGridRange(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):
HomeAdminExceptionCardretired. The needsAttention header slot now shows only a quiet one-lineHomeAdminAttentionStrippointing 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):
HomeAdminAttentionStripbecame a warning-tinted card ($badgeWarningBg/$badgeWarningText) and moved OUTSIDE the needsAttentionHomeFeedSection(no section title chrome — it's a pointer, not a section); content simplified to "알림 N건" (bold) + trailing chevron, no dot, no cta copy.HomeDisputedAlertCardkeeps its existingHomeFeedSectionwrapper unchanged.Change log (2026-07-11, R5 owner QA round 5):
HomeAdminAttentionStripnow takesitems(not a barecount) — 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 (surfacevariant) 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 rendersSessionCardV2instead of the retiredSessionCard— 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 inRecordDetailScreen(OutcomeSummaryCard/DaySummaryCard, sameRecapOutcomePanel), which is exactly where the new card's 자세히 row drills into. Home week/month now renderRecapSummaryCard(packages/features/home/src/cards/recap-summary-card.tsx, wireframe-first perdocs/twomore_design_system/wireframes/home.jsxRecapSummaryCard/RecapSummaryCardMonth): a header row (label + 자세히 + chevron) → a hero row (label-less 40pxSegmentedDonutChartreused directly, notRecapOutcomePanel— 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 chunkingActivityMetricCalendar.metricRows()uses). Empty state is the SAME card atopacity 0.3with a centered overlay pill — no separate ghost layout or CTA row. Whole card is onePressable→routes.record(period, {start, end}).
Clubs (클럽) — apps/mobile/app/(tabs)/(clubs)/index.tsx → ClubListScreen (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: true — apps/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 WizardCard 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/discoverscreen (ClubsDiscoverScreen), not an in-shell switch to the 탐색 pill — and it's the ONLY CTA (EmptyStatehas 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) - Populated →
ClubCardlist, 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-refreshStates:
- Loading → 4×
SkeletonCard - Error →
QueryErrorState(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-awarenoDiscoverResultsFiltered/noDiscoverResultsFilteredSubtitlestrings exist in i18n but are wired only into the separateclubs-discover-screen.tsx, not this tab) - Populated →
ClubCardlist + footer loading-more spinner (t().common.loadingMore "더 불러오는 중") + scroll hint (t().common.scrollForMore "아래로 스크롤해 더 보기") once content overflows the viewport
Data
useAuthUserId()— viewer id, threaded into everyClubCardfor role/RSVP-aware affordancesuseMyClubs(userId)—packages/app/src/presentation/hooks/queries/use-clubs.ts:21,staleTime: STALE_TIME.stable; doubles as the 탐색 tab's membership-exclusion sourceuseClubUpcomingSessions(userId)—use-sessions.ts:160,staleTime: STALE_TIME.realtime; reduced client-side viabuildUpcomingSessionsByClub(shared/club-card-summary.ts) into a per-clubSession[]capped at 2, soonest first (Phase 5's two-session upgrade —buildNextSessionByClub/buildSessionCountByClubare 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 aMap<sessionId, SessionCardModelEntry>, passed straight through asClubCard'ssessionCardModelsByIdprop — 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.frequentuseClubDiscoveryStatsByClubIds— bulk capacity/avatar-stack stats for the cards,use-discover-clubs.tsuseViewerDiscoverClubs(filters, searchQuery)(shared/use-viewer-discover-clubs.ts) — composesuseMyClubs(exclusion) +useDiscoverClubsInfinite(use-discover-clubs.ts:38, cursor-paginated,staleTime: STALE_TIME.frequent) + the two bulk hooks above; also firesuseRecordClubGrowthEvent(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/DiscoverClubCardas separate components — both were merged into oneisMember-gatedClubCard(commit b5a93851, OTA group c45e73be→4b4eca36, 2026-07-20). Out of scope here (card anatomy is that doc's job, not this one's). ✅ Resolved 2026-09-08 —(tabs)/(clubs)/discover.tsxduplicate discover surfaceClubsDiscoverScreen(a verbatim copy of the 탐색 pane) was retired;routes.discoverClubs=/clubs?tab=discoveropens 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), theunreadPostsIndicatori18n key, andbuildSessionCountByClub(shared/club-card-summary.ts:75). Cleanup-candidate list — seeclub-surfaces-unification.mdPhase 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 aDayDotsRow(월→일 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 frommodel.chipGroups— 플레이 (format · tier) and 사람들 (member composition · focus · top 2 tags) — social signals are deliberate card content. Discovery avatar stacks respectprofile_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
DateTilecalendar 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 관리 로우):
MyClubCardgets aClubRoleBadge("운영",accentvariant) overlaidposition="absolute" top="$2" right="$2"on the banner for owner/admin viewers — the same slotClubJoinabilityBadgeoccupies onDiscoverClubCard, empty for a plain member. The card's 관리 로우 (ClubAdminRow) is now its own nestedPressable variant="row" stateLayer→routes.club(clubId, { tab: 'admin' })(the club detail route now supports atabdeep-link param), and its breakdown renders asBadge 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):
ClubAdminRowsimplified to the SAME warning-tinted treatment as the Home admin strip — "알림 N건" (bold) + trailing chevron only, no dot, no breakdownBadgechip strip.ClubAdminRowModel.parts/breakdownTextare 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.tsxroute,[TabHeader] title="모임", a[FAB], two named card componentsMyClubCard/DiscoverClubCardwith hand-duplicated anatomy, and a two-CTA empty state) — same drift class as the old Home entry. Re-derived fromclub-list-screen.tsx: shell isMainTabShell(title "클럽" + header Key-icon join action), sub-tabs render throughSegmentedTabs(PillNav chrome + lazy-mount pane cache), the create-club action is aBottomCtaBand(not a FAB, and 내 클럽-tab-only), and both tabs now share oneisMember-gatedClubCard. 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
ClubCardbranches (ClubBannerPlaceholder's newaspectRatioprop, default 3 unchanged for the club-detail hero + public profile hero). The member branch's 다가오는 일정 zone (previouslySectionEyebrow+Well+ the bespokeClubNextSessionWell, ~401px card) retires in favor of a plainDivider+SessionCardV2's newvariant="embedded"(no outerCardchrome; same conditional-strip → venue-tile-left body →RecruitmentMeteranatomy as the feed card) — "E1 flat embed" perdocs/architecture/club-surfaces-unification.md§3.10.ClubNextSessionWell(shared/club-next-session-well.tsx) is deleted; its only consumer was this card. Data spine:MyClubsTabbulk-models just each club's next session viauseSessionCardModels(see Data above) and threads the result throughClubCard's newnextSessionCardModelprop — 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 (+
ClubRoleBadgefor admins + a "새 글 N" unread indicator, bulk-fetched via the newuseUnreadPostCountsByClubIds) → the SAME conditionalderiveSessionStrip/SessionStripViewattention band as before, now followed by a compact venue-led row (VenueTile's newsizeprop at 48px + venue/date-time/format/plain-text-participation text) instead of the full embeddedSessionCardV2card — the embedded variant and its data spine (useSessionCardModels) still supply the underlyingSessionCardModelEntry, just rendered narrower. No next session → a quiet "예정된 일정 없음" line.ClubAdminRowunchanged, now the card's last zone. Discover glimpse: photo only whenmodel.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 compactDayDotsRow(newvariant="compact"— fixed-size non-stretching cells, model's newregularMeetDaysfield), 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/BannerCapacityClusteroverlay is gone from both branches (now dead — zero remaining consumers ofBannerCapacityClusterin the app).SessionCardV2'sembeddedvariant 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.tsxrewritten 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 generativeFlagMedallion) + 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, allcardMetagray-ladder, each row navigating straight to that session's detail;ClubAdminRowstays last. The §3.12 attention-strip/SessionStripViewband and the "새 글 N" unread indicator are BOTH gone (useUnreadPostCountsByClubIdshas zero remaining callers — see Gaps). Discover branch:AvatarStackis gone, replaced by a facts line (📍 region · 👥 composition + age band) + a compactDayDotsRow appearance="dotWeek"rhythm row + one three-tone chip row (characterneutral→ time-bucketprimarySubtle→ termsoutline); no exact times anywhere (click-through rule). Data spine:MyClubsTabnow threadsupcomingSessions/sessionCardModelsById(2-session map) instead of the old singlenextSession/nextSessionCardModelprops. 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 branchGaps
- ⚠ This entry described a 2-tab (내 경기/번개) PillNav-inside-body design with
FilteredSearchBar,EnrolledSessionCard,PickupCard, and per-tab FABs — none of that exists in the currentActivityScreen. 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
SessionCardprimitive. DropdownChip + RangeChip height parity fix (h=36). PickupFilterBaralignItems="center". LiveSessionCard empty-state row removed. Change log (2026-07-12, 3.6 discovery UX): 번개 tab'sPickupFilterBarregion/district cascade now goes through the sharedRegionDistrictFiltercomponent — the same primitive Court Directory'sVenueFilterBaruses — 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 outHero 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):
headerRightbecame exactly twoHeaderIconButtons — DM inbox (MessageSquare, unread dot) +Settingsgear. TheShare2stat-card-share icon was removed from the header; sharing moved to a row insideProfileMenuCard.Change log (2026-09-05, flat detail-page migration wave 3):
ProfileScreenmoved ontoMainTabShell ground="canvas"+TypeHero+FlatColumn+StatStrip(s), retiringProfileHeroCard's box (perdocs/design/detail-pages-flat-build-spec-2026-09-05.md§3.10). At ship, the hero badge row wasEloTierBadge(tappable →TierInfoSheet) +TrustTierBadge+ReliabilityPill, and the stat grid was a literal 3+2 split (two separately-griddedStatStrips) whenglobalAttendanceRatewas 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):HairlineList→RowList. 2026-09-06 (seam system): hero-zone blocks inFlatColumn lead, seams fromSEAM.
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 SettingsHistory
Retired body (pre-2026-08-22 "C redesign"): 4-tab
[TabPanel]design — tabs=["홈", "소식", "기록", "관리" ▸ isAdmin],[ScreenHeader]title=clubName,SafeAreaViewlayout,<TabPanel>always-mount pattern. 홈 tab wasCard-anatomy (identity hero banner 3:1 → chip strip → member-count hero → session list → BottomCtaBand "일정 만들기"); 소식 was a standalone pinned/feedPostCardlist with a "글쓰기" CTA; 기록 wasRankingList/Season Summary(implementation namedclub-detail/ranking-tab.tsx, a filename that no longer exists — seerecords-pane.tsxin 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_HEIGHTunified intopackages/ui/src/collapsing-top-bar.tsx(was a localBAR_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 sharedgetRoleLabelKorean role text (was the raw enum, e.g. "Match_director") — both sourced from the new SSoTpackages/app/src/utils/role-labels.ts, also dedup'd out ofclub-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 emitsclub_role_changedsignal (medium, 7d expiry). - Remove member:
useRemoveMember({ clubId, userId })→ confirmation sheet. - Overlays are
ModalPanel(not a bottom sheet — the one legalSheetis the dev panel). - 게스트 지명:
useNominateForMembership({ clubId, userId })→nominate_for_membershipRPC → creates a pendingclub_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)readsget_recommendable_guests(non-member guests the CALLER co-attended with); row toggle callsuseRecommendMembership/useUnrecommendMembership(membership_recommendationsinsert/delete), which invalidate both the admin dossier (prospectKeys.summary) and this list (recommendableGuestKeys.byClub) soalreadyRecommendedflips 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 becameTypeHero+ aSectionBlock variant="flat"FactList(every "no X" fallback string kept verbatim); the messageCardbecame a plainSectionBlockparagraph. Card count: 3 → 0. ✚ i18njoinReview.detailEvidenceTitle(ko/en, "근거"/"Evidence") for the new section title. ✚ testIDsjoin-review.reject/join-review.approve. First characterization test for this screen added atsrc/__tests__/join-review-detail-screen.test.tsx(admin evidence + both CTAs, non-admin permission empty state,decide.isPendingdisables both) — written and verified green against the pre-migration screen, then kept green through the migration.
Key actions
- 승인/거절:
useDecideJoinRequest(...)→decide_join_requestRPC (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 DetailEmpty State
└─ [EmptyState variant="first-use" size="full"]
└─ ✦ "첫 글 작성" → Create Post ▸ isAdminPost 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 —
TypeHeroauthor identity, oneFlatColumn(body → session-link → action row → 댓글SectionBlockwith the count asmeta), comment rows as block peers with a hand-applied divider/edge contract,CommentComposeras theDetailShell footerband;author-row.tsxdeleted.Note (2026-09-05, flat detail-page migration wave 4c, parity-only): the local
backgroundColor="$card"override on every render branch was retired forDetailShell 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 codeSuccess 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 increate-club-screen.tsx'srenderStep/isNextEnabled/primaryHintswitch, not the file name) is inserted between 회비 and 소개, carryingdefaultGuestFee/guestVisitLimitout 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.tsxintopackages/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 sameStep2/Step3/step3Valid/ScheduleEditStatesurfacecreate-club-screen.tsximports.Note (2026-09-04, god-file decomposition wave A1):
create-club-screen.tsx,create-club-step1.tsx, andcreate-club-helpers.ts(all three over 600 lines) were further split, behaviour-identical, same exports/testIDs/copy:create-club-screen.tsx(1,078→576) —isClubStepValid→create-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;FlagSwatchButton→create-club/step1/flag-swatch-button.tsx;pickLogoUri→create-club/step1/logo-picker.ts.create-club-helpers.ts(617→162, now a barrel) —withLegacyScheduleMirror→create-club/schedule-mirror.ts; the schedule/fee permutation helpers →create-club/schedule-permutations.ts;resumeClubDraft→create-club/resume-draft.ts; the option lists →create-club/option-lists.ts;overlayLivePickCounts→create-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.joinClubHistory
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 andshared/club-detail-hero/*;shared/club-photo-seam.tsxstays,ClubCardstill uses it) retired forDetailShell ground="canvas"+MediaHero kind="album"+FlatColumn+SectionBlocks (card count 5+hero → 0). The guest fact grid (형식/실력/가입 방식/홈 코트) re-homed as aFactListwith 회비 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.tsxmigrated 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";SessionStripViewstays the top ribbon, full-bleed, directly under the header; a newSessionHeroSection(session-detail/sections/session-hero-section.tsx) renders a full-bleedMediaHero(the venue static map viauseVenueHeroImage, title/meta/badges carrying date · time · venue · D-day · weather glance, tappable to the legacyroutes.venue) or aTypeHerofallback, absorbing the retired 장소 (venue-section.tsx, deleted) and 날씨 (weather-section.tsx, deleted) cards; everything below lives in ONEFlatColumn: a newStatStrip(확정 인원 · 내 참가비 · 참석 마감) →LiveParticipantBar(nowBanner tone="error") →RefundBanners(nowBanners, tone preserves the pre-existing amber/red overdue escalation) →NoticeSection(now a titlelessWell tone="primary", testIDsection-noticekept on the Well) →PaymentGateBanner(unchanged) → 참여SectionBlock variant="flat"(a capped 6-rowHairlineListofListRows + "외 N명" overflow, replacingSessionStatusParticipationCard'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"(boxlessSessionMatchupsCard, annotated// figure:since its per-match rows are compositeMatchRowHeader+MatchScoreboardpairs too entangled to flatten intoListRowthis wave) → 정보SectionBlock variant="flat"+FactList(retiresSessionIdentityCard's box; same fields) →HostCalloutSection(pending-apps nowBanner tone="primary"with an action; the host row keepsPickupHostCardboxed — annotated// figure:— since forking its identity/message-CTA hooks is out of this wave's file scope and it's also consumed bypublic-pickup-preview-screen.tsx) → 호스트 도구SectionBlock variant="flat"+HairlineList/ListRow(same six destinations;ListRowhas nodisabled/destructiveprops, so the 경기 규칙 row just drops itsonPresswhile 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-branchprimaryActionuntouched. 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 retiredSkeletonHero(nowSkeletonFlatPage). 2026-09-06 (Divider law):HairlineList→RowList— rows carry no divider (peers are separated by spacing;dividersonly for multi-component block peers such as match rows);FlatColumndraws the full-bleed section divider itself ($5each side);FactRowlostfirst; flat section titles arenavTitle700. 2026-09-06 (seam system): sections gated at the call site, hero-zone blocks inFlatColumn lead, the band inDetailShell footer(pinned; tail 16), seams fromSEAM(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 ~8useState/useMemovalues across nine<SectionBlock>JSX clusters) is now a ~506-line orchestrator. EverySectionBlock-wrapped cluster + the loose banners around them moved topackages/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 tosession-detail/use-session-detail-data.ts, alongside the prior wave'suse-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.tsownshandleGeneratePreview/handleCommitPreview+ their shared state (step/selectedStyle/previewRounds/isGenerating) since one builds the preview object the other consumes;add-round/use-manual-round.tsowns the actual mutation write (commitMatchInputs, injected into the preview hook) + the manual composer's ownhandleSaveManual— neither reads the preview hook's state, both reuse the same write path. The prop contract moved toadd-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, 27useState) 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 underpackages/features/sessions/src/edit-session/<kebab>-edit-modal.tsx(named exports, exported props interfaces);ConfigFormitself moved toedit-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 overpackages/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), andhost-tools-gates.ts(the 호스트 도구showTools*Rowcluster). Each is a plainbuildXxxfunction (auseXxxname would tripreact-hooks/rules-of-hookson the call after thesession == nullguard). SameSessionDetailActionsreturn shape — pinned bysrc/__tests__/use-session-detail-actions.test.tsx(31 cases viarenderHook).
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 topackages/features/sessions/src/match-board/use-match-board-data.ts(UseMatchBoardDataResult); every JSX cluster tomatch-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, mirroringsession-detail-overlays.tsx), each self-gating where the monolith used a ternary. Pinned bysrc/__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 toscorecard/live-tab/live-match-row.tsx; the whole derivation chain (buildFlatItems/buildStructuredSectionspure builders, renderItem/renderMatchBody, admin round-start CTA, sticky pending-call callout) toscorecard/live-tab/use-live-tab-state.tsx;live-tab.tsxonly picksRoundSectionListvsFeedList.scorecard/king-of-court-ladder.tsx(617) likewise movedTeamMovementRow/ReasonBadge/CourtCardand theresolveTeamMovementhelpers toscorecard/king-of-court/*. Pinned bysrc/__tests__/live-tab.test.tsxandsrc/__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-shapedSessionSaveButton 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 avariant?: 'card' | 'header'prop —'card'is the unchanged SessionCard top-right affordance (bare 22pt icon),'header'renders throughHeaderIconButtonitself 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.HeaderIconButtonand the underlyingIconprimitive (packages/ui/src) gained an additive optionalfillprop (SVG fill, not theme-token resolved) to support the filled-when-saved state; both default toundefinedso every otherHeaderIconButtoncall site is unaffected. The passive RIGHT-polesavedmark onSessionStripViewis unchanged — it stays a status indicator, not a toggle.2026-07-27 (SessionCardV2 rollout,
d21f1fba): the list-facingSessionCardreferenced by the "Status chip" note below is retired to a rollback-only subtree; every list surface (Home, Club Home Tab, Club Session List, 경기) now rendersSessionCardV2. 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) andLateBudgetDots(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 16pxCourtLinesTiles (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 anArrivalExpansionCarddirectly below that row (round chips 도착..도착+5 plus a 시간으로 chip openingDateTimePickerModal, 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 utilarrivesAtToRound(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 keepsarrivesAtfor display.Rsvp.arrivesAtnow flows end-to-end into the roster badge (오후 7:40 · 3라운드부터, new i18navailabilityArrivedAtRound/availabilityTimeChip, both ko/en) via an extendedavailabilityMapvalue shape ({fromRound, arrivesAt}) threaded from all threeAddRoundSheetmount 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)) androtationMinutes(session.rotationMinutes). The now-fully-replacedcycleArrivalRoundutil (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'sDetailShellheader is no longer a static "토너먼트" title — it now carries a livesubtitle(in_progress:tournaments.continueChipLabel(formatLabel, latestRoundNumber), the same words as session-detail's 이어보기 chip; other statuses:formatLabel · tournaments.status.<key>) plus a status-gatedPulseDottitleLeadingwhen 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 oneNavRow("경기 방식 선택") opening a single registry-backed picker (listStrategies(), two groups: 라운드 방식/토너먼트).AddRoundSheet'spreselectKingOfCourt: booleangeneralized topreselectStyle?: SelectedStyle(also updated the KOTC ladder's own call site);CreateTournamentModalgained apreselectedStrategyIdprop that skips its select step straight to a new 대회 설정 step (조별 인원/진출 방식/조 편성 knobs — bracket-only, ≥9 entrants, sinceround_robin_groups'sinithas nooptionsparameter 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):TournamentContinueChipnow surfaces at EVERY session status wheneveruseTournamentBySessionresolves an active (non-completed/cancelled) row. session-detail-screen.tsx gained its ownAddRoundSheetmount (a third site alongside match-board-screen.tsx / king-of-court-ladder.tsx) wired with the same settle-before-playpartitionSettledMatchPoolgate.2026-07-13 (M1 format-aware tier-gate badge): The tier-band eligibility gate (
tierBlockedinsession-detail-screen.tsx→ disables RSVP +t().sessionDetail.tierBandRequiredexplainer) now reads the viewer's rating from the session's own pool —singlesEloforsingles,doublesElofordoubles/singles_doubles/mixed_doubles— instead of the cross-poolelo_rating, matching the server-sidersvps_enforce_tiertrigger fix (migration00424, same cross-pool bug). Grandfather skip for already-confirmed RSVPs unchanged.2026-07-04 (session-action wiring — forfeit/step-out + payment rails):
forfeit_session(migration00310, 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 protectedmatches.participant_idscache in sync (SECDEF-only — a client.update()is reverted by the 00067protect_matches_scoringtrigger). Host vs participant: the RPC auto-detectscreated_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 stayPressable(COMP-3 forbidsButtoninCard). Phase 2 (97484a51+ migration00309) — session group chat is find-or-create:dm_threads.session_id+ partial-unique index + get-or-createcreate_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 extractedSessionNavRow); removed the header kebab (session-actions-menudeleted) — GPS 체크인 + 기권하기 now render inline while live;edit-session-screenstripped 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+recruitmentFillColorfor open/locked sessions;CapacityRingandProgressBartake optionalfillColor. 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).
BottomCtaBandholds exactly one state-precedence CTA; non-CTA host/admin actions consolidated in settings-icon Popover. Session moved to top-level cross-cutting routeapp/sessions/[sessionId]/index.tsx.SessionStatusBannerfor terminal states.useLiveSessionDatacomposer 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 ▸ canManageScheduleEach 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 / emptyCompletedSubtitleCreate 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), andcreate-session-step2.tsx(806) split intopackages/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.tsxpre-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 stateOnly 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) → RecordsLeaderboardScreenChange log (2026-04-24, Phase 11p): New
@twomore/recordspackage.RecordsScreenreplaces 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):TierInfoSheetrebuilt onto Tamagui$-token primitives (tier-color dot stays a style escape hatch, same constraint asmedalColor). The empty-state branches above are now driven by one canonicalselectRecordsEmptyState()selector (replacing 4 ad-hoc fragments) — including the new "any scope, zero matches" branch. AuseMonthlyChallengeBridge(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 ▸ loadingEmpty State
└─ [EmptyState] variant=compact ▸ no matches in selected scopeChange log (2026-07-12, PS10 R10):
MatchHistoryEntry.partnerAvatarUrl(doubles/mixed-doubles partner's avatar URL) added touseMatchHistory(use-match-history.ts). Data-layer only —MatchHistoryRowhere 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-rateEmpty State (per scope)
└─ [EmptyState] variant=compact ▸ no data for scopeChange log (2026-04-24, Phase 11p): Extracted from old
@twomore/homeleaderboard view into dedicatedRecordsLeaderboardScreenin@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(andfindTopByRating)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.tsnow applies a plain-string secondary sort onid(neverlocaleCompare) afterelo_rating DESC. The LIMIT-boundary tie (which rows land inside vs. just outside top-50) still needs a SQLORDER 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.tsx → HeadToHeadScreen (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 titleBadge — facts 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/playCards retired (perdocs/design/detail-pages-flat-build-spec-2026-09-05.md§3.11). At ship, the hero wasTypeHerowithregion · districtas a separate meta line andEloTierBadge+ReliabilityPillas 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 + rewardCourt 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)Pick intent (VenueSearchTakeover → VenueFinder intent="pick", wizard venue search)
├─ [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 closesEmpty 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 CTAHistory
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 tosessions/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 tosessions/venue-finder/use-venue-finder-data.ts; pure row-shaping (buildRows/buildNearbyRows/SIDO_ORDER/NAV_CHIPS/directoryVenueToPickResult, plus theVenuePickResulttype the composer re-exports) moved tosessions/venue-finder/venue-finder-rows.ts; the chrome header (search bar + finderHint + nav-chip row), the browse-modeFeedList(loading/error/empty states), and the search-modeVenueSearchResultsListwrapper each moved to their ownsessions/venue-finder/venue-finder-{header,browse-list,search-results}.tsxwith 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 overonPick/pickedVenueIdsdirectly. Same exportedVenueFinder/VenueFinderProps/VenuePickResultsurface, samevenue.*testIDs (Maestro flows 11/12 unchanged) — pinned by a new characterization test,sessions/__tests__/venue-finder.test.tsx(explore-vs-pick intent, nav-chiprole=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 aBadge) is retired — replaced by ONE glyph row (kind icon + district text + lucide-icon fact glyphs for court count/indoor/lighting/parking, each with its ownaccessibilityLabel; 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). Anunconfirmed-trust row's map thumbnail renders atTERMINAL_MUTE_OPACITY. Search-mode results (VenueSearchResultsList) now render through a virtualizedFeedList(recycling={false}) instead of an un-windowedScrollView.VenueSearchTakeover's pick intent no longer firesonPickon row tap — a tap only selects (ring + check); a new sticky footerActionButton("이 테니스장으로" / "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 getaccessibilityRole="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: 전체/코트/연습장/샵/즐겨찾기) toVenueFinder/VenueDirectoryPort#search—facilityView: 'shop'maps tofacility_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/CourtCardtree that no longer exists).DirectoryVenueCardgained 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 sharedRegionDistrictFiltercomponent (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 modalVenue 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",MediaHeroreplacing the boxedVenueCardtile,StatStrip/ActionIconRowreplacing the 5-button row and theHeaderActionsMenu(retired — its 4 items became icons),FactListreplacing the localDetailRow(retired). Cards 4 → 0. Option B (folding into the directory venue spine oncecourt_venuescarries avenue_id) stays tracked separately. First characterization test added (__tests__/venue-detail-screen.test.tsx) — pricing/hours/booking facts, the action-icon gating onbookingUrl/bookingPhone, and the booking-alertBottomCtaBandlabel 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
CreateSessionScreendebounced 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/Wellreplacing everyCard/SpecListin this subtree (10 → 0 Cards); retiredparts/hero.tsx,parts/trust-card.tsx,parts/stat-tile.tsx,parts/action-tile.tsx,parts/action-row.tsx, andsections/cta.tsx(the create-session CTA moved to a pinnedBottomCtaBand). New ✚sections/sources.tsx(정보 출처) and headerHeaderActionsMenu(공유 → OS share of name+address; 신고하기 → the existingReportSheet, reused fromvenue-correction/report-sheet.tsxwithout touching that file).basicInfonow renders only the facts NOT already in the heroStatStrip(전화/조명/위치) 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 onDirectoryVenue/VenueSourceValuesyet). Everyvenue.detail.*/venue.correct.*testID preserved (Maestro flows 11/12 unaffected);directory-venue-detail-screen.test.tsxupdated 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):HairlineList→RowList— rows carry no divider (peers are separated by spacing;dividersonly for multi-component block peers such as match rows);FlatColumndraws the full-bleed section divider itself ($5each side);FactRowlostfirst; flat section titles arenavTitle700. 2026-09-06 (seam system): sections gated at the call site, hero-zone blocks inFlatColumn lead, the band inDetailShell footer(pinned; tail 16), seams fromSEAM(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 onvenue.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 renderedvenue.detail.section.<key>testIDs matchsectionOrderForKind(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 (
isShophid 이용료/코트 구성).directory-venue-detail-helpers.ts'ssectionOrderForKind(kind)is now the single source of the per-kind order —shopmoves 운영시간/결제 수단/바로가기 directly under 기본 정보;practice/screenmove 이용료/편의시설 above 리뷰 키워드;full_court/unknownkeep the original order. 코트 구성's array position never moves between kinds. The screen builds asectionRenderers: Record<VenueDetailSectionKey, ReactNode>(each entry's render condition byte-identical to its old inline JSX) and renderssectionOrderForKind(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.tslocks 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, 31useState— one per correctable field plus batch-confirm state) is now a ~380-lineModalPanelshell + field composition + theEDITABLE_FIELDS_BY_KINDgating. Every field moved to its ownuse<Field>Fieldhook +<XField>component underpackages/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 SAMEvenue.correct.<field>testID/labels. Shared render primitives (FieldCard,BooleanToggleField) + pure helpers moved tovenue-correction/field-shared.tsx; the batch-confirm row's UI tovenue-correction/batch-confirm-row.tsxand its eligibility/confirm-all orchestration tovenue-correction/use-batch-confirm.ts; the submit+toast flow tovenue-correction/use-submit-corrections.ts; the footer submit'sSubmitVenueFactCorrectionInput[]assembly to the purevenue-correction/build-submission-inputs.ts; the 18 field-hook calls +hasChanges/resettovenue-correction/use-correction-fields.ts; the graded-fields block and the "더 보기" collapsed panel tovenue-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 tovenue-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 inbuildSubmissionInputs's field order, and a shop fixture assertsvenue.correct.court_countnever renders.Finding 5 (
VenueCorrectionModal): the editor was one flat scroll of always-visible sections (8 for a shop, ~18 forfull_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'sConfidenceTier).venue-correction-modal-helpers.tsadds 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 attier: 'high'collapse into a single "정보가 정확해요"SectionBlockat the TOP of the scroll, each field aSelectionChip; tapping the row's action (venue.correct.batchConfirm.action) firesuseCorrectVenueFactfor every pending chip's CURRENT (seed, unedited) value in onePromise.allSettledbatch, tapping one chip instead expands just that field back into its normal full section (expandedBatchFieldsstate) —operating_hours/fee_scheduleare 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 existingvenue.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.tscovers 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 fromEDITABLE_FIELDS_BY_KIND[venue.facilityKind](single source of truth,packages/app/src/domain/entities/venue-correction.entity.ts) instead of a one-offisShopternary, 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), andhas_parking/has_lighting/has_shower(있음/없음 toggles, mirroringhas_indoor) — kind gating mirrors the server exactly (shopstill can't touch court facts;practice/screenstill can't touch court_count/courts/has_lighting).DirectoryVenueDetailScreengrew matchingEditGlyphButtonentry points (name/address/kind badge in the hero, 바로가기 and 편의시설 section headers), each gated byisFieldEditableFor, 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 tohostConfirmTitleShop), andVenueCorrectionModal's 코트 수/코트 구성/이용료 fields — while keeping address/phone/hours/review-keywords for any kind. Six new data-gated sections render for ANY kind when the underlyingvenues/venue_source_observationsdata 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). NewDirectoryVenuefields:ratingValue/ratingCount/reviewCount/bookingUrl/websiteUrl/amenities/hasParking/hasShower/hasShop/kakaoPlaceId(findById-only projection). NewVenueSourceValueskeys (dormantnaver_placescrape, 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.55on 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 existinguseCorrectVenueFacthook (ModalPanel confirm), routed through migration00423's widenedsubmit_venue_correction(now accepts any boolean forclosed, 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_placesource-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 periodRecord 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(boxedRecapOutcomePaneldonut+strip cards) retired — replaced byTypeHero(title/meta text + a 56pxOutcomeRing,record-detail/outcome-summary-card.tsx) and aStatStrip. The activity calendar's$surfacepanel-in-Cardis gone —ActivityMetricSelector/ActivityMetricCalendarrender directly on the canvas insideSectionBlock variant="flat".OpponentContextCardand the match list both dropped theirCard+Divideranatomy forHairlineLists ofListRow/MatchTimelineRow(computeOpponentsnow also returnslosses/drawsper opponent for the "N승 M패" sub-line). Card count on this slice: 7 → 0.RecordDayDetailScreenmoved to its own file (record-detail/record-day-detail-screen.tsx) to satisfy the one-FlatColumn-per-file lint rule.RefreshControladded to both screens (matchHistoryQ.refetch).Change log (2026-07-04, historical): The equivalent day-summary card (
DaySummaryCard) was migrated onto the shared canonicalRecapOutcomePanel(donut + win-rate hero + full-width 승·패·ELO·연승 breakdown stat strip) — the same componentOutcomeSummaryCardand home'sWeekRecapCardalready used — killing the last hand-rolled donut+legend block (commitc31f27db). BothRecapOutcomePanel-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 configuredLIVE 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 roundsRead-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 && !duesExistsSignal 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_updateemitsdues_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"]✦ "회비 생성" →CreateDuesInputflow ▸ 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
useClubAttendanceSummarycallsattendanceRecords.findByClub(clubId)via@/registry; client-side derivation producesClubAttendanceMemberSummary[](sorted, withlatestStrikeRecordIdandisFlaggedflags).@freshness frequent.useMemberStrikeRecordscallsattendanceRecords.findStrikesByMember(userId, clubId)via@/registry; SQL filtersstrike_value > 0 AND final_status != 'excused'and joinssessions(title, date).@freshness frequent.useExcuseAttendancecallsattendanceRecords.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 enabledTotal 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/ThemeOption→settings/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+primaryProfile 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
ActionButtonto a quiet right-aligned text link on the 새 소식 band's[SectionHeader]row — samet().notifications.markAllReadcopy, sameacknowledgeVisibleUnreadSignalsmutation, gated onunreadSignalCount > 0(unchanged gate, new location).CenterRow'sbandvariant gained an optionalmarkAllReadflag set only on theband:freshrow.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 insignalBand()— same 3 bands, zero behavior change (seealert-state.test.ts's table-driven regression guard). Notification center headerRight gains a settings gear (HeaderIconButton+Settingsicon →routes.signalPreferences) alongside "모두 읽음", in anXStack. Pull-to-refresh now also refetches the admin-attention snapshots (useMyAdminClubAttention's newrefetch), not just the signal feed. Home's bell (packages/features/home/src/cards/notification-bell.tsx, extracted from the inlinehome-feed-screen.tsxheaderRight) is now TWO-STATE viaderiveBellState: an amberattentionbadge ($badgeWarningBg/$badgeWarningText) when an admin-attention obligation is open, else a redunreadbadge ($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 asrsvp); every other payment type (holds,session_payment_submitted, expiry, refunds, subscription/fee signals) stays on the$errorcategory 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) —CenterRowgained a{kind:'band'}member and{kind:'signal'}rows now carry aband(signalBand(), pure function of the existingseverityfield, 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-bandSignalRows gain a deadlineBadgesourced fromexpiresAt(formatDeadline). The 이전 알림 band gains T3 grouping —groupEarlierBandSignalsfolds same-day same-club low-severity social signals into a singleGroupRowcard. 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 — seedocs/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 freshfirstSeenAtonce any counter changes. The dismiss filter lives INSIDEuseMyAdminClubAttention, 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. UnreadSignalRowcategory badges and the admin row's "관리" badge switched to the newsurfaceBadge variant ($surfacebg /$textSecondarytext) — 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
$primarySubtlesurface 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 renamedNotificationAdminAttentionRows→ singleAdminAttentionRow. (R3, same week: "관리 필요" SectionBlock retired; "관리" filter chip added; chip strip became a wrappingXStack, not a horizontalScrollView.)
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 && canManageLifecycleCard 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/useEndSessionwired. 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"+TypeHeroreplaced the boxedHeroCard; 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 oneGlyphFactRow, no separate eyebrow/badges row).UnsettledPoolNoticemovedBanner→Callout tone="warning". 2026-09-06 (seam-audit P7): the screen had never passedscroll={false}, soDetailShell's ownScrollViewwas silently swallowing the "pinned"BottomCtaBand+ overlay cluster — both now render inDetailShell footerfor real. 2026-09-06 (seam-audit P1):UnsettledPoolNotice/AllDoneBannergated at the call site instead of relying on their own internalreturn null, which had been producing an orphan/doubled hairline inFlatColumn'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 weatherTMI 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 membersData:
useDmMessages(archive pages) +useDmLatestMessages(5s newest-page poll) +useDmThread(participant + request status). No realtime hook.useMarkThreadReadfires once on mount to clear the unread badge.useSendDmMessagefollows the Phase 14d queueable pattern (offline-safe).A blocking
QueryErrorStatereplaces 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 compactQueryErrorStatebanner 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 rideDetailShell ground="canvas"+ oneFlatColumnwith the CTA infooter: edit-session (설정 section, pinned 저장), match-rules, participants (grouped list,Wellplaceholder), session-attendance (Wellsummary + roster), public-pickup-preview (TypeHero→ 정보FactList→ host figure → silhouetteWell→ location → notice,ApplyCtafooter), session-payments (FactListsummary → bare payment rows, one computed footer), pickup-host-dashboard (대기중인 신청RowList dividers→ danger zone), tournament-board (Wellbanner → 순위 rows → round figures → danger zone, 다음 라운드 footer), spectator-scorecard + standings tab + king-of-court ladder. Two sanctioned// figure:cards remain (tournament match card, standingsImpactCard).Change log (2026-09-06, rhythm audit — every screen): no screen types a scroll inset any more. Every
ScrollView/FeedList/ModalPanel.ScrollViewcomposes aSCROLL_CONTENTpreset from@twomore/ui(pageis the shell default, so ~20 screens that restated it now pass nothing;list/railedListinside a shell;banded/railedBandedunder 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 rideRowList dividers;MiniBarMeterreplaces the two hand-drawn bar meters (venue confidence, add-round feasibility);MapPinMarkeris 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 areason: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).
| Issue | Screens Affected | Severity | Status |
|---|---|---|---|
| Clubs tab | Medium | ✅ Resolved — all CTAs use Button component with consistent variant/size | |
| 경기, Discover, Court Directory | Medium | ✅ Resolved — per-subject guidance text added (10 subjects) | |
| Home | High | ✅ Resolved — welcome card (text only) + single Card with 4 ListRows | |
| Profile tab | Low | ✅ Resolved — primary band persists with skeleton content | |
| Club Dashboard | Medium | ✅ Resolved — role-aware descriptions for all 4 tabs | |
| 경기 tab | Low | ✅ Resolved — uniform filter-aware pattern across all 3 tabs | |
| Club Dashboard 홈 tab | Medium | ✅ Resolved — members see informational text | |
| Progressive disclosure undocumented | Session Detail | Info | Documented — correct behavior, no fix needed |
| 7 screens | Medium | ✅ Resolved — replaced with spacing[X] tokens | |
| 3 wizard steps | Critical | ✅ Resolved — replaced with t() i18n calls | |
| Notifications | Low | ✅ Resolved — added size="full" | |
| Clubs tab | Medium | ✅ Resolved — PillNav (클럽 / 번개) always visible | |
| Home FTUE, Clubs tab | Medium | ✅ Resolved — 번개 만들기/참여하기 added to FTUE and 번개 tab | |
| ClubActionFooter | Medium | ✅ Resolved — replaced with Button component | |
| Multiple screens | Low | ✅ Resolved — py-20 reduced to py-10 | |
| 경기 > 분석 tab | Low | ✅ Resolved — empty state wrapped in ScrollView | |
| Tab bar | Medium | ✅ Resolved — renamed to 경기 | |
| Ranking, Club Dashboard, My Stats | High | ✅ Resolved — all tab screens use TabView or TabPanel | |
| Ranking, Club Dashboard, My Stats | Medium | ✅ Resolved — QueryErrorResetBoundary + ErrorBoundary wraps all tab content | |
| Auth layout, Club detail layout | Medium | ✅ Resolved — ErrorBoundary added to auth and club detail layouts |
4. Empty State Decision Matrix
| Screen | State | Component | Variant | Size | CTA | CTA Condition |
|---|---|---|---|---|---|---|
| Home > RecapSummaryCard | No matches (week) | Same card, dimmed opacity 0.3 + centered overlay pill | — | — | Whole card → /records/week (empty) | always |
| Home > RecapSummaryCard | No matches (month) | Same card, dimmed opacity 0.3 + centered overlay pill | — | — | Whole card → /records/month (empty) | always |
| Home | No clubs (FTUE) | WelcomeCard + Card with ListRows | — | full | 4 ListRows (클럽/초대/번개/참여) | always |
| 클럽 tab | No clubs | EmptyState + Buttons | first-use | full | "클럽 만들기" / "초대코드 입력" | always |
| 번개 tab | No pickups | EmptyState + Buttons | first-use | full | "번개 만들기" / "번개 참여하기" | always |
| 경기 (per tab) | No activities | EmptyState | no-results | full | Cross-tab navigation | always |
| Profile > Stats | No matches | EmptyState | first-use | compact | none | — |
| Profile > My Clubs | No clubs | EmptyState | first-use | compact | "모임 찾기" → Discover | always |
| Profile > ELO | No history | EmptyState | first-use | compact | none | — |
| Profile > Achievements | No progress | EmptyState | first-use | compact | "도전 과제 보기" → Challenges | always |
| Club Dashboard > 홈 | No sessions | EmptyState | first-use | full | "첫 일정 만들기" | isAdmin |
| Club Dashboard > 소식 | No posts | EmptyState | first-use | full | "첫 글 작성" | isAdmin |
| Club Dashboard > 기록 | No matches | EmptyState | first-use | full | none (auto-populated) | — |
| Board Feed | No posts | EmptyState | first-use | full | "첫 글 작성" | isAdmin |
| Session List | No sessions | EmptyState | first-use | full | "첫 일정 만들기" | isAdmin |
| Discover Clubs | No results | EmptyState | no-results | full | none | — |
| Court Directory | No results | EmptyState | no-results | full | none | — |
| Notifications | No notifications | EmptyState | all-done | full | none | — |
| Dues Board | No dues configured | EmptyState | first-use | full | "회비 설정" | 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 (includingModalPanel.ScrollView) — the Frame has no resolved height so flex children collapse to 0. - NEVER
heightor percentagemaxHeighton the ScrollView — percentages resolve against the auto-height Frame → 0. The ScrollView ships withflexShrink: 1baked 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).