Screen Blueprint — TwoMore (투마)
Status: Reference
Living document. Text-based wireframe of every screen — structural hierarchy, behaviors, navigation, empty vs populated states. Update when any screen changes.
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=[Bell → Notifications, badge=unread signals + admin-attention items] 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 `SessionCard` per concurrent live session
├─ [HomeFeedSection "오늘 일정"] ▸ open|locked RSVP'd sessions remain on the scrubbed date (endTime > now) → [SessionCard] × N, host-first sort
├─ [HomeFeedSection "오늘 복기"] ▸ any `completed` session the viewer participated in on the scrubbed date → [SessionCard] × 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 "이번 주 기록"/"이번 달 기록"] → [WeekRecapCard]: RecapOutcomePanel donut (승·패·무 + win-rate) + 승/패/ELO/연승 full-width stat strip + ActivityMetricCalendar; ghosts the same layout at opacity 0.35 with a "이번 주/달 첫 경기를 기록해 보세요" prompt when the period has zero matches
└─ [HomeFeedSection "이번 주 예정"/"이번 달 예정"] ▸ any open|locked RSVP'd session in range → [SessionCard] × 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 SessionCard — implementation at packages/app/src/presentation/components/sessions/session-card.tsx, exported via @twomore/app (packages/features/sessions/src/shared/session-card.tsx is a thin re-export shim for features/sessions-internal callers, per ARCH-2). Live sessions get no separate "live card" component — LiveSessionStack renders a plain SessionCard per concurrent live session; the card's own participation row is a universal expand/collapse toggle that reveals a recessed match-detail well (SessionMatchDetail), live or otherwise. The 오늘 복기 section's status badges ride the card's note prop.
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,week-recap-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 SessionCard — 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 — past attended + future
│ scheduled, so both past and future months light up)
├─ week → [WeekMonthPicker] year·month stepper over selectable Mon–Sun week rows
└─ month → [MonthYearPicker] year·month gridTitles 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 (WeekRecapCard / 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.
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-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.
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] (ghost Key-icon button, 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.
내 클럽 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 viabuildNextSessionByClub/buildSessionCountByClub(shared/club-card-summary.ts) into a per-club next-session + upcoming countuseApprovedClubMediaByClubIds/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). - ⚠
apps/mobile/app/(tabs)/(clubs)/discover.tsx(also rendersClubsDiscoverScreen, resolves to the bare/discoverpath) has no in-app caller found — every in-app CTA usingroutes.discoverClubsresolves to the siblingapps/mobile/app/clubs/discover.tsx(/clubs/discover) instead. Possibly orphaned; not confirmed dead (deep links unchecked).
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.
경기 — app/(app)/(tabs)/(activity)/index.tsx
Header: [TabHeader] title="경기" | rightAction: "코트 찾기" → Court Directory Layout: SafeAreaView bg=surface.secondary
├─ [PillNav] tabs=["내 경기", "번개"]
├─ [FilteredSearchBar] hideSearch=true (filter chips only: location, dateTime, level/tier, format)내 경기 Tab — Populated State
├─ ◇ Live sessions (if any) — [EnrolledSessionCard] LIVE badge
├─ ◇ Upcoming sessions — [EnrolledSessionCard] date + time + club
├─ ◇ Recent history — [MatchHistoryRow] compact
└─ [FAB] icon="add" → Create Session ▸ isAdminOfAnyClub번개 Tab — Populated State
├─ [PickupCard] open pickup games in region
└─ [FAB] icon="flash" → Create PickupEmpty States (per tab)
└─ [EmptyState] variant="first-use" or "no-results" size="full"
└─ ✦ Context-aware CTA (create session, find pickups, clear filters)Gaps
noResultsDesc missing✅ Resolved — per-subject guidance text added (10 subjects)Cross-tab CTAs inconsistent✅ Resolved — uniform filter-aware pattern
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) — app/(app)/(tabs)/(profile)/index.tsx
Header: Custom primary band (avatar, name, ELO rating, tier badge) | settings gear → Settings Layout: SafeAreaView bg=surface.secondary
├─ [PrimaryBand] size=comfortable
│ ├─ avatar + name + settings gear icon (top-right)
│ ├─ [Badge] size=sm tier (using TIER_BADGE_VARIANT)
│ └─ ELO rating display + getTierColor
├─ ◇ Weekly Attendance — [WeeklyAttendance]
├─ ◇ Monthly Retrospective — [MonthlyRetrospective]
├─ ◇ Head-to-Head — [HeadToHeadSection]
├─ ◇ Stats Section
│ └─ [StatCard] size=compact (wins, losses, rate)
├─ ◇ My Clubs
│ └─ [ClubCard] size=compact → Club Dashboard
├─ ◇ ELO History
│ └─ [EloChart] animated line chart
└─ ◇ Achievements — [AchievementsSection]Empty State (per section)
Each section independently: [EmptyState size="compact"] with section-specific CTA.
Loading State
Primary band persists with skeleton content (avatar shimmer, name placeholder, rating placeholder). Stats section shows SkeletonCard. No layout shift on data arrival.
Gaps
Header disappears during loading✅ Resolved — primary band persists with skeletons
Change log (2026-04-10): Tab renamed: 프로필 → 나. Settings gear added to header. Sections expanded: WeeklyAttendance, MonthlyRetrospective, HeadToHeadSection, AchievementsSection.
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 — app/clubs/[id]/index.tsx
Header: [ScreenHeader] title=clubName | back Layout: SafeAreaView bg=background Tab pattern: <TabPanel> — always-mount (all 4 tabs stay in tree, hidden via display:none) to preserve hook order Error boundary: QueryErrorResetBoundary + ErrorBoundary wraps each tab panel
├─ [TabPanel] tabs=["홈", "소식", "기록", "관리" ▸ isAdmin]홈 Tab
Implementation: packages/features/clubs/src/club-detail/home-tab.tsx
├─ [Card tone="default"] Identity hero — lifted into HomeTab (SegmentedTabs sits at header level)
│ ├─ Banner 3:1 — ClubBannerPlaceholder (same mediaUrl precedence as club cards)
│ ├─ Identity row: [ClubLogoBubble] (36px) + name (cardTitle) + region (cardMeta)
│ ├─ Tagline (cardBody $textMuted, 2 lines, omitted when absent)
│ ├─ Chip strip sm neutral, fixed order: format → tier → focus
│ └─ Member count hero: [Text role="displayMd"] + capacity meta (cardMeta) + [ProgressBar]
├─ ▸ galleryPreviewMedia.length > 1
│ └─ [Card] 2-column 4-image grid (48% width, 1.35 aspect ratio)
├─ ▸ showMyDuesCard (isMember + unpaid/partial current month dues)
│ └─ [Pressable variant="card"] → ClubDuesScreen
│ [DuesStatusBadge] + month label + chevron
├─ ▸ isMember && nextSession
│ └─ [SessionCard] nextSession → Session Detail
├─ ▸ !isMember
│ └─ [Text role="cardBody" $textMuted] joinNudge (join motivation copy)
├─ ── Upcoming sessions list ──
│ └─ [FeedList] sessions with status open/locked/in_progress
│ └─ [SessionCard] size=default → Session Detail
│ (bulk pre-fetch: myRsvps + myPayments + sessionsRsvps + weather)
└─ ✦ "일정 만들기" → Create Session Wizard (BottomCtaBand, screen level) ▸ canCreateSession소식 Tab
├─ ✦ "글쓰기" → Create Post ▸ isAdmin
├─ ◇ Pinned Posts
│ └─ [PostCard] pinned=true → Post Detail
└─ ◇ Feed
└─ [PostCard] → Post Detail기록 Tab
├─ [RankingList]
│ └─ [MemberRow] rank + ELO → Profile Detail
└─ ◇ Season Summary관리 Tab ▸ isAdmin — Navigation
Implemented as AdminTab component (nav-hub pattern). Tab rendered by PillNav; MainTabShell/SegmentedTabs intentionally not used to preserve layout consistency with other tabs. First 4-tab PillNav usage in the codebase. 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 SettingsEmpty States (per tab — role-aware)
- 홈 (admin):
[EmptyState variant="first-use"]✦ "첫 일정 만들기" → Create Session Wizard - 홈 (member):
[EmptyState variant="first-use"]"일정이 만들어지면 여기에 나타나요" (informational, no CTA) - 소식 (admin):
[EmptyState variant="first-use"]✦ "첫 글 작성" → Create Post - 소식 (member):
[EmptyState variant="first-use"]"소식이 올라오면 여기에 나타나요" (informational, no CTA) - 기록:
[EmptyState variant="first-use"]"경기 기록이 쌓이면 순위가 나타나요" (no CTA — auto-populated) - 관리: Never empty (always shows ListRow navigation links)
Gaps
Inconsistent empty tab CTAs✅ Resolved — role-aware descriptions for all 4 tabs
Club Settings — app/clubs/[id]/settings.tsx
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 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 title=candidate displayName | back Layout: DetailShell scroll + 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
├─ [Card] identity — avatar + name + "· 게스트" + Badge "공개 일정 N회 참석"
├─ [Card] evidence (hairline-divided 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] "신청 메시지" → [Card] request.message
└─ [BottomCtaBand]
└─ [XStack] 거절 (neutral, flex=1) + 승인 (primary, flex=2) — a genuine binary
decision fork, not a state-precedence single action; both call
useDecideJoinRequest({ requestId, accept, clubId, applicantUserId })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). Header: [AppHeader] title="게시글" | back Layout: ScrollView bg=background Implementation: packages/features/clubs/src/post-detail-screen.tsx
├─ [AuthorRow] avatar + displayName + relative posted-at timestamp
├─ [PostTitle] role=cardTitle
├─ [PostBody] role=cardBody (multiline)
├─ [ImageGallery] ▸ post.images.length > 0
└─ [EmptyState] variant=compact ▸ post.deleted === true
text: "삭제된 게시글이에요" / "postDeleted" i18n keyDeferred: poll section, reaction bar, comment section, author menu (edit/delete/pin).
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 — app/clubs/join.tsx
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 — app/clubs/create.tsx
Header: [WizardShell] progress indicator | close (with draft save prompt) Layout: per-step ScrollView bg=background
Step 1: Club Name — [TextInput] + validation
Step 2: Description — [TextInput] multiline
Step 3: Sport Selection — [ChipGroup] single select
Step 4: Region — [RegionPicker] cascading (시/도 → 시/군/구)
Step 5: Home Court — [CourtSearch] → selection
Step 6: Meet Schedule — [ScheduleBuilder] day + time + frequency
Step 7: Review — summary of all steps → ✦ "모임 만들기"Draft recovery: auto-saves to AsyncStorage. Prompts restore on re-entry.
Public Club Profile — app/clubs/[id]/preview.tsx
Route helper: routes.clubPreview(clubId) Implementation: packages/features/clubs/src/public-club-profile-screen.tsx Entry point: DiscoverClubCard tap (non-members) → this screen; members bounced to full routes.club(clubId). Header: [DetailShell] title=club.name | back Layout: DetailShell scroll=false + inner ScrollView
├─ [ClubStatusBanner archivedAt] ▸ club.archivedAt != null (self-gates to null for active clubs)
├─ [Card tone="default"] Identity hero
│ ├─ Banner 3:1 — ClubBannerPlaceholder (mediaUrl from buildClubCardModel — featured → any → bannerUrl)
│ ├─ Identity row: [ClubLogoBubble] (56px) + name (cardTitle) + tagline (cardBody $textMuted, 2 lines) + region·district (cardMeta)
│ ├─ Member count hero (▸ stats loaded): [Text role="displayMd" $primary] + capacity (cardMeta) + [ProgressBar]
│ ├─ Chip strip sm neutral, fixed order: format → tier → focus → [ClubJoinabilityBadge]
│ │ (joinability stays a chip here — no banner overlay on the profile screen)
│ └─ Tags (▸ club.tags.length > 0): xs neutral badges, up to 6
├─ ── additional content cards (description, gallery, next session, etc.) ──
│ (see source for current card set — this block evolves as the profile matures)
└─ [BottomCtaBand] — join CTA (single contextual action, state-driven by joinPolicy):
open → ✦ "가입하기" → routes.joinClub
approval → ✦ "가입 신청하기" → inline ApplyPanel (or "신청 검토 중" / "다시 신청하기")
invite_only → ✦ "초대코드로 가입하기" → routes.joinClubDiscover Clubs — app/(app)/(tabs)/(clubs)/discover.tsx
Header: [ScreenHeader] title="클럽 찾기" | back Layout: SafeAreaView bg=background
├─ [FilteredSearchBar] placeholder="클럽 이름, 지역..."
│ └─ [CategoryChips] region, tier, format filters
└─ [FlatList]
└─ [DiscoverClubCard] 6-slot fixed-slot card → Public Club Profile (preview)Empty State
└─ [EmptyState variant="no-results" size="full"]2.3 Session Screens
Session Detail — app/sessions/[sessionId]/index.tsx
Header: [DetailShell] AppHeader back + sessionTitle + PulseDot ($error) ▸ in_progress | rightAction: header icon buttons — share (Share2) + group chat (MessageCircle) — no overflow/kebab menu
Layout: DetailShell scroll={false} + inner ScrollView flex={1} + BottomCtaBand sibling
├─ [SessionStatusBanner] ▸ completed | cancelled (full-width tinted banner, self-gates to null otherwise)
│
├─ ── 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 (tier badges via [MatchTeams])
│ ├─ 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 SessionCard (recruiting sessions): The status/RSVP chip pinned to the far right of the participation meta row uses RecruitmentStatusBadge (not raw SessionStatusBadge) when the viewer is not participating and the session is open or locked. sessionRecruitmentState(session, confirmedCount) drives the derivation — 모집 중 / 성사 대기 / 마감 임박 / 대기 모집 / 마감. The CapacityRing on the card takes fillColor={recruitmentFillColor(theme, state)} so the ring color matches the detail-screen ProgressBar (card↔detail visual continuity with no card fill).
Change log
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
│ ├─ [SessionCard] size="default" per completed session (bulk-prefetched RSVP/payment/weather maps)
└─ [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 SessionCards.
Empty State (per pane)
└─ [EmptyState variant="compact"]
├─ 모집 중/진행 중: t().clubSessions.empty / emptySubtitle
└─ 완료: t().clubSessions.emptyCompleted / emptyCompletedSubtitleCreate Session Wizard — app/sessions/create.tsx
Header: [WizardShell] progress | close Layout: per-step ScrollView bg=background
Step 1: Template Selection — clone from past / blank / template
Step 2: Date & Time — [DatePicker] + [TimePicker]
Step 3: Court — [CourtSearch] or venue select
Step 4: Participants — max count + auto-invite toggle
Step 5: Cost — court fee + splitting method
Step 6: Review — summary → ✦ "일정 만들기"Session Edit — app/sessions/[id]/edit.tsx
Header: [ScreenHeader] title="일정 수정" | back Layout: KeyboardAwareScrollView bg=background
├─ [RHF Form] (same fields as wizard, pre-populated)
└─ ✦ "저장" lg+primary (sticky bottom)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) — app/(app)/(tabs)/(ranking)/index.tsx
Note (2026-04-24, Phase 11p): The records/ranking tab was renamed and restructured. See the 기록 (Records) section above for the canonical records tab. The
통계route may redirect to기록— verify in route files.
Header: [TabHeader] title="기록" (i18n: t().stats.title) Layout: SafeAreaView bg=surface.secondary Tab pattern: <TabPanel> — always-mount (preserves hook order across tabs) Error boundary: QueryErrorResetBoundary + ErrorBoundary wraps tab content
├─ [PillNav] tabs=["리더보드", "전적", "분석"]Bottom tab label: 통계
리더보드 Tab
├─ [RankingView] format filter (all/singles/doubles/mixed_doubles)
└─ [RankingList]
└─ [RankRow] rank + avatar + name + ELO + trend → Public Profile (routes.userProfile)Change log (2026-04-22, Slice 5): leaderboard rows wired as
Pressable→routes.userProfile(userId). First click-through to PublicProfileScreen.
전적 Tab → Match History View
분석 Tab → Analysis View (summary + 5 detail screens)
Match History View — packages/app/src/presentation/screens/ranking/match-history-view.tsx
Layout: SectionList grouped by month
├─ [MonthHeader] "2026년 3월" + summary (W/L/rate)
└─ [MatchRow] size=compact
├─ date + opponent + score
└─ ELO change badgeAnalysis Summary — packages/app/src/presentation/screens/ranking/analysis-view.tsx
Layout: ScrollView bg=background Deep link: Profile → "내 분석" taps through to this screen | Ranking tab → ?tab=analysis
├─ ◇ Overview Cards (compact stat summary)
│ ├─ [StatCard] win rate + trend arrow
│ ├─ [StatCard] ELO + tier badge (tappable → TierInfoSheet)
│ └─ [StatCard] streak + best streak
├─ [ListRow] "포맷별 분석" → Format Detail
├─ [ListRow] "파트너 분석" → Partners Detail
├─ [ListRow] "클럽별 분석" → Clubs Detail
├─ [ListRow] "라이벌 분석" → Rivals Detail
├─ [ListRow] "활동 분석" → Activity DetailChange log (2026-04-10): Analysis detail screens restructured: format, partners, clubs, rivals, activity (replacing win-rate, elo, play-style, opponents, streaks).
Format Detail — app/(app)/(tabs)/(ranking)/analysis/format.tsx
Layout: ScrollView bg=background — format-specific stats breakdown
Partners Detail — app/(app)/(tabs)/(ranking)/analysis/partners.tsx
Layout: ScrollView bg=background — partner synergy analysis
Clubs Detail — app/(app)/(tabs)/(ranking)/analysis/clubs.tsx
Layout: ScrollView bg=background — per-club performance
Rivals Detail — app/(app)/(tabs)/(ranking)/analysis/rivals.tsx
Layout: ScrollView bg=background — H2H rival breakdown
Activity Detail — app/(app)/(tabs)/(ranking)/analysis/activity.tsx
Layout: ScrollView bg=background — activity trends + streaks
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 ranking/leaderboard-screen.tsx. Friend-row and opponent-name callers TBD. Header: [AppHeader] title=displayName | back Layout: ScrollView bg=background Implementation: packages/features/profile/src/public-profile-screen.tsx
├─ [IdentityCard] avatar + displayName + [Badge variant=neutral tier] + ELO + region
├─ ◇ Recent Matches (cap 5)
│ └─ [MatchRow] result + opponent + date
└─ [FriendshipButton] — 5 states:
none → "친구 추가" (send request)
sent → "요청 취소" (cancel pending)
received → "수락" / "거절" (accept / decline)
friends → "친구" (no-op display)
blocked → hiddenDeferred: achievements section, club memberships section, tier progress chart.
Profile Edit — app/profile/edit.tsx
Header: [ScreenHeader] title="프로필 수정" | back Layout: KeyboardAwareScrollView bg=background
├─ [AvatarPicker] camera/gallery
├─ [TextInput] name (validated via validateName)
├─ [PhoneInput] phone number
└─ ✦ "저장" lg+primary (sticky bottom)Challenges — app/challenges.tsx
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: 전체 / 코트 / 연습장 / 즐겨찾기 (facilityView drives a server-side gate; 즐겨찾기 filters the loaded list client-side against useSavedVenueIds)
└─ [FeedList] rows grouped into 시/도 [SectionHeader] sections (canonical province order; entire filtered result set loads, not paginated)
└─ [DirectoryVenueCard] static-map thumbnail + name + badge (인증됨 success | 미확인 warning — mutually exclusive) + [VenueSaveButton]
+ fixed-order fact-badge strip (facility kind → region/district → court count → indoor → surface → lighting → ball machine, each omitted when absent)
→ Venue Directory Detail (routes.directoryVenue — the nationwide-spine screen below; distinct from the club-scoped Venue Detail further down)Empty State
└─ [EmptyState] variant="compact" — cause-aware (selectVenueDirectoryEmptyCause):
trust_gated (default corroboration filter excluded results) → subtitle + ✦ "미확인 포함" CTA sets verification='all' in place
genuinely empty (no results even unconfirmed) → subtitle only, no CTAChange 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: [ScreenHeader] title=venueName | back Layout: ScrollView bg=background
├─ name (pageTitle)
├─ address (cardBody)
├─ [Badge] region · [Badge] surface type
├─ court count (cardMeta)
└─ ✦ "세션에서 이 코트 선택" lg+primary ▸ launched from session wizard court-picker stepChange 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: Pricing table, hours, facilities, booking link.
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-screen.tsx Header: [DetailShell] title=venue.name | back
├─ static-map hero image
├─ [VenueTrustRing] + [ConfidenceBars] + [SourceChips] — every fact carries a per-fact resolution tier (multi-source provenance, migration 00347) — never a bare "verified" claim with no provenance
├─ ◇ 기본 정보 — 2-column [StatTile] grid (court count, surface, location, …), each tile graded by its own fact's confidence tier
├─ ◇ 운영 시간 ▸ hasHours — resolved weekday/weekend/rest-day rows + per-day naver_place hours rows (finer granularity, additive — not a replacement for the coarser resolved buckets)
├─ ◇ 요금 ▸ feeEntries.length > 0 — weekday/weekend/night structured rows
├─ ◇ 리뷰 키워드 ▸ reviewKeywords.length > 0 — top-5 naver_place review-keyword [Badge] chips, sorted by mention count
└─ (see source for full anatomy — venue-correction modal, court/facility breakdown; this block evolves as the directory matures)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/home/src/record-detail-screen.tsx Header: [ScreenHeader] title=period label — "4/14 — 4/20" for week, "4월" for month (not "이번 주 기록") | back Layout: ScrollView bg=background Entry point: tap on hero stat block in HomeWeekView or HomeMonthView
├─ hero stats (totalMatches, winRate, longestStreak)
├─ 승/패 breakdown row
├─ ◇ Top-5 Opponents — [OpponentRow] win/loss counts
├─ ◇ Match List grouped by date
│ └─ [MatchRow] opponent + score + ELO change
└─ [EmptyState size="full"] ▸ no matches in periodChange log (2026-07-04): The equivalent day-summary card in
@twomore/records'record-detail-screen.tsx(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). TheSummaryChipgrid below is unchanged. Match-detail's empty well now uses the canonicalEmptyState.
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 — app/(tabs)/settings.tsx
Header: [TabHeader] title="설정" Layout: ScrollView bg=background
├─ [ProfileCard] size=comfortable → Profile Edit
│ └─ avatar + name + email
├─ ◇ Appearance
│ └─ [ThemeToggle] light/dark/system
├─ ◇ Notifications
│ └─ [NotificationSettings] per-category toggles
├─ ◇ Account
│ ├─ "비밀번호 변경" → password change
│ └─ "로그아웃" → confirmation → Login
└─ ◇ Support
├─ "문의하기" → email/form
└─ "버전 정보" app version displayLogin — app/auth/login.tsx
Layout: SafeAreaView bg=background (no header)
├─ [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 — app/auth/setup.tsx
Header: [WizardShell] progress | skip (limited) Layout: per-step ScrollView bg=background
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)
Header: [DetailShell/AppHeader] title="알림" | rightAction: "모두 읽음" → mark all read 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: 이전 알림] 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"`.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 — app/sessions/[id]/matches.tsx
Header: [ScreenHeader] title="경기판" | back Layout: SafeAreaView bg=background
States: config → in-progress → completed
Config State ▸ isAdmin
├─ [MatchConfig] rotation algorithm + court count
└─ ✦ "경기 시작" lg+primaryIn-Progress State
├─ [RoundTabs] Round 1 | Round 2 | ...
└─ [MatchGrid]
└─ [MatchCell] team A vs team B + score input ▸ isAdmin (live editing — scores editable mid-round via useUpdateMatchTeams)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.
Completed State
├─ [RoundTabs]
├─ [MatchGrid] (read-only, scores filled)
└─ [ResultSummary] MVP + statsPractice 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.
3. Cross-Screen Consistency Audit
| 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 > WeekRecapCard | No matches (week) | Ghost infographic at opacity 0.35 | — | — | 번개 찾기 → /activity | always |
| Home > WeekRecapCard | No matches (month) | Ghost infographic at opacity 0.35 | — | — | 번개 찾기 → /activity | 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).