Skip to content

UI/UX Design Specs — 2026-07 Adversarial Audit Synthesis

Status: Archived Source: docs/audits/blocks/U1 through U8 (8-block adversarial UI/UX audit, 2026-07-11) Scope of this document: every finding across U1–U8 that requires a design decision — a new screen, a changed layout, a new component composition, a copy/state rule, or a canon amendment. Pure mechanical/backend-only bugs (payload-nesting mismatches, RLS predicates, localeCompare perf fixes, dead-code deletion, doc-drift corrections, route-param threading) are intentionally excluded — they carry no design judgment and belong in the implementation plan (docs/architecture/implementation-plan.md), not here. Where a finding has both a mechanical half and a design half (e.g. U8's waive audit-trail bug), only the design half is specced below; the mechanical half is noted in one line for cross-reference.

Every spec below is grounded in components/tokens that exist today — cited inline. See docs/canon/components.md, docs/canon/styling.md, docs/canon/status-and-recruitment.md, docs/canon/clubs.md, docs/canon/sessions.md, and docs/twomore_design_system/README.md for the full vocabulary this document draws from.


Vocabulary quick-reference (for this doc's citations)

  • Cardtone="default" (neutral surface) / tone="flat" (dashed-border empty state) only. Never a CTA inside a Card except the sanctioned row-level ActionButton pair exception (≤3 nested affordances, mark-done actions — session-payments-screen.tsx/session-attendance-screen.tsx/ participants-screen.tsx/pickup-host-dashboard-screen.tsx are the shipped precedent this doc extends to U2's join-request rows).
  • Badge — features/presentation bounded to neutral/warning/live/surface literals (COMP-2); domain status renders ONLY via the canonical XxxStatusBadge wrappers, which internally reach the full 8-variant intent palette (success/accent/info/muted/primarySolid/…). Never invent a 9th variant.
  • Text rolespageTitle/cardTitle/navTitle/cardBody/cardMeta/badge/ micro/displaySm|Md|Lg|Xl. Never a raw fontSize.
  • Pressable (@twomore/ui) — the one clickable primitive (variant="card|row|pill| icon|button|fab"). A tappable Card wraps in <Pressable variant="card">, never <Card onPress>.
  • ActionButton (@twomore/ui) — action="primary|secondary|neutral|cancel| tertiary|destructive" maps to the correct Button variant; danger = quiet destructive TRIGGER, destructive = loud CONFIRM-step only, paired with useConfirm.
  • FeedList / GroupedFeedList — virtualized lists; GroupedFeedList for role/category-sectioned data (mirrors club-members-screen.tsx's role groups).
  • ModalPanel — the only overlay primitive (Sheets are dev-panel only). Archetypes: read-only info, pick-from-small-set, edit-embedded-in-flow.
  • EmptyStatevariant="full" (64px icon bubble, page-level) / variant="compact" (44px, section-level); supports actionLabel/onAction — frequently built but unwired across this audit (U5 #9, U6 #4, U7 #8).
  • SectionHeader / SectionBlock — titled content groups; GroupedFeedList's renderSectionHeader for collapsible role groups (the club-members-screen.tsx "largest group collapses by default" pattern, reused for U2's join-request section).
  • XxxStatusBadge wrappersSessionStatusBadge, MatchStatusBadge, RsvpStatusBadge, DuesStatusBadge, ScoreStatusBadge, SessionPaymentStatusBadge. Score disputed already maps to live (red) in the canonical status table (docs/canon/status-and-recruitment.md) — U4's fix just needs the backing data to ever reach that state.
  • VenueTrustRing / ConfidenceBars / SourceChips (venue-confidence.tsx) — the existing multi-source trust vocabulary, currently detail-screen-only; U6 #2 asks for a compact list-card variant, not a new system.
  • PlayerIdentityChip — the tap-to-profile + prefetch avatar+name unit used by RankRow; the pattern U2/U5's dead-end rows should adopt.

U1 — Home + Notifications

_(Push-tap payload-nesting mismatch, the #1 CRITICAL finding, is a pure data-shape bug — send-push/index.ts nests IDs under context._, the client reads them top-level. No design judgment involved; routed to the impl plan.)*

1. Admin-attention count shows 3 different numbers across bell / strip / chip

Problem (HIGH): bell badge = club-count (+3), Home strip = item-count ("15건 확인 필요"), notification-center 관리 chip = club-count (3) — same underlying data, 3 different numbers on 3 surfaces a user moves between in one flow.

Design spec: standardize on item-count everywhere (the strip's number is the more truthful one — a club with 7 pending items is more urgent than a club with 1). bellCount and the 관리 chip both switch to adminExceptionItems.reduce((s,i) => s + i.model.total, 0). No new UI — the existing bell Badge (caps "9+") and chip count (caps "99+") keep their current rendering; only the number they display changes. Copy stays as-is ("N건 확인 필요" on the strip is already the right phrasing — extend it to the bell's accessibility label too, so a screen reader announcing "9개 알림" and a sighted user seeing "15건" don't disagree either).

Canon satisfied: cross-surface consistency (the same discipline docs/canon/clubs.md names for the AdminSnapshotStrip as the "start here" surface — its number must be the one every other surface agrees with).

2. Positive payment signals render with the same red icon as a failure

Problem (LOW but a real semantic mismatch): session_payment_confirmed (참가 확정, good news) renders with the identical $error-red CreditCard icon bubble as subscription_payment_failed.

Design spec: per the design system's own color discipline ("--error (red) — LIVE dot and the LIVE badge only... never as decoration"), SignalRow special-cases signal.type ∈ {session_payment_confirmed, session_payment_submitted} to a $successCheckCircle icon bubble, keeping the category-level $error CreditCard accent for every other payment signal (holds, failures, overdue-adjacent). This is a per-type override inside SignalRow's existing icon-bubble render, not a new component.

Canon satisfied: docs/twomore_design_system/README.md color discipline ("Only these colors carry meaning... error red... never as decoration").


U2 — Clubs

1. Club join-request approval has no admin UI anywhere (CRITICAL — see Wireframe A)

Problem (CRITICAL): useClubJoinRequests/useDecideJoinRequest are fully built and wired to a real RPC pair, but zero screens render them. The AdminTab snapshot strip highlights "가입 신청 N건" in red and routes to club-members-screen.tsx, which has no pending-request UI at all — an admin who taps it lands nowhere.

Design spec: add a 가입 신청 section to ClubMembersScreen, above the existing role-grouped member list, using the SAME collapsible GroupedFeedList section-header pattern already shipped for role groups (docs/canon/clubs.md: "the largest group collapses by default — its section header doubles as a count + chevron toggle"). Section header: "가입 신청 · N건" with a chevron, expanded by default when N > 0 (unlike the largest-member-group, which collapses by default — a pending-action section should default OPEN since it needs attention). Each row: Card tone="default" size="sm" containing an avatar (AvatarBubble, sourced via useClubProfiles — never a raw userId fragment) + display name (cardTitle) + optional applicant message (cardBody, numberOfLines={2}) + applied-at (cardMeta, relative date) + a trailing ActionButton action="danger" (거절) / ActionButton action="primary" (승인) pair — this is the SAME shipped exception U3 documents (row-level ActionButton pairs inside a list-row Card, matching session-payments-screen.tsx's PaymentRow and pickup-host-dashboard-screen.tsx's ApplicationRow pixel-for-pixel). 승인/거절 both call useDecideJoinRequest (needs export from @twomore/app's public index — a mechanical follow-up, not a design change). No useConfirm gate needed on either action (both are reversible from the requester's side — they can re-apply) unless the club has a cooldown-on-reject rule, in which case 거절 gets a lightweight inline useConfirm with style: 'default' (not destructive).

Canon satisfied: docs/canon/components.md "Backend-backed data never renders as dead text" + docs/canon/clubs.md role-grouped collapsible-section pattern (reused, not reinvented) + CLAUDE.md's standing "no half-built screen, dead-end text" complaint.

2. Member list & club-ranking rows are dead ends (no tap-to-profile)

Problem (HIGH): MemberRow (club-members-screen.tsx) and the ranking-tab renderItem wrap their Card in nothing — no Pressable, no route to the public profile, despite every row carrying a real userId.

Design spec: wrap both rows' Card in <Pressable variant="card" accessibilityLabel={…} onPress={() => appRouter.push(routes.publicProfile(userId))}>, mirroring the SessionCard/MyClubCard canonical pattern exactly. The kebab menu/action buttons that already exist on MemberRow keep their own onPress with e?.stopPropagation?.() as the first line (the multi-target-card contract). Ranking-tab rows gain the SAME wrap — no kebab there, so the whole row becomes the tap target, same shape as leaderboard-rank-row.tsx's RankRow (which already does this correctly — this fix makes the club-scoped leaderboard consistent with the global one it sits beside in the same tab family).

Canon satisfied: docs/canon/components.md "Backend-backed data never renders as dead text... a player name → public profile... renders as a deep link" — this is the literal violation the rule names.

3. Transfer-ownership modal can show raw truncated userIds + untranslated role text

Problem (MEDIUM, high-stakes context): a destructive, irreversible confirm flow can display a1b2c3d4 · Match_director instead of a real name and a Korean role label.

Design spec: source getDisplayName from useClubProfiles's displayName (already fetched on sibling settings surfaces) instead of the nickname-only stub map; route member.role through the existing getRoleLabel helper (hoist it to a shared location since two files now need it — club-members-screen.tsx already defines the correct Korean labels: 소유자/운영진/경기 디렉터/멤버). Row layout stays as-is (cardBody name + cardMeta role, no new component) — the fix is data-sourcing, not layout. Because this row feeds directly into a useConfirm({style:'destructive'}) step, the identity shown here IS the confirm dialog's only verification surface — correctness here is part of the confirm-flow's design contract, not a cosmetic nit.

Canon satisfied: docs/canon/components.md destructive-confirm discipline (a confirm dialog must let the user verify what they're confirming).

4. Attendance rows: flagged (tappable) vs non-flagged (inert) look identical

Problem (LOW): Pressable's dim-on-inert path only checks disabled/loading, not "has no onPress" — so a non-flagged attendance row renders at full opacity with no visual cue it's inert, indistinguishable from a flagged (tappable) row except a small warning Badge.

Design spec: add a trailing chevron Icon (ChevronRight, size="sm", $textTertiary) ONLY on flagged rows — mirrors the existing AdminNavRow/DuesRow trailing-icon convention used elsewhere in the same package, so "this row has more to tap into" reads consistently app-wide. Do not dim non-flagged rows (dimming an inert row in a list of otherwise-normal rows reads as "broken/loading," not "not tappable" — the chevron-presence signal is cleaner).

Canon satisfied: consistency with the existing AdminNavRow/DuesRow trailing-affordance convention (single source of truth for "this row navigates further").

5. AdminSnapshotStrip 5-cell / 3-per-row layout stretches unevenly

Problem (LOW, cosmetic): CELLS_PER_ROW = 3 chunks 5 cells into [3, 2]; each cell is flex={1} within its OWN row, so row 2's two cells render visibly wider than row 1's three.

Design spec: pad row 2 with an invisible flex={1} opacity={0} spacer cell sized to match row 1's cell width — cheapest fix, no visual redesign, matches the strip's existing size="sm" cell padding. (Alternative — a true 5-up single row at a smaller per-cell width — is a bigger layout change for a cosmetic-only issue; not worth it unless the strip grows a 6th cell.)

Canon satisfied: docs/canon/components.md card padding/sizing consistency.

6. 회비 partial-payment: numeric-amount UI vs. flag-only status (design decision)

Problem (CRITICAL, cross-referenced from U8): the admin types a real partial-payment ₩ amount into DuesPartialPaymentModal, taps confirm, and the number is discarded — UpdateDuesStatusInput has no field to hold it, so the row can never answer "how much is still owed."

Design spec — this is a real product decision, not a bug fix, so it's specced here rather than assumed:

  • Option A (build it out): add a paidAmount field end-to-end. DuesRow renders "₩10,000 / ₩30,000" (cardBody current, cardMeta "/ 총액" suffix) instead of the full face amount; SummaryCard's 부분납부 bucket sums actual paid amounts, not face value. DuesPartialPaymentModal keeps its numeric StyledInput but the value now flows somewhere.
  • Option B (downgrade to a flag): remove the numeric-amount StyledInput from DuesPartialPaymentModal entirely — 부분납부 becomes a pure status flag (no fake precision prompt), and the modal becomes a plain useConfirm-style ModalPanel with an optional free-text note field ("메모, 선택").

Recommendation: Option A — a 총무 tracking partial payments needs the remaining balance to actually manage collection; Option B removes the one piece of information that makes 부분납부 useful as a status. This is flagged as owner-decision-needed rather than a unilateral pick, since it's a schema commitment (entity + migration + 3 UI sites).

Canon satisfied: CLAUDE.md's standing "no half-built screen, dead-end text" complaint — either option resolves the current "looks-done, isn't" state; the current state satisfies neither.


U3 — Sessions

1. Payment-hold card must gate on RSVP liveness, and say so when it expires

Problem (HIGH): the "예약됨 · 참가비 결제" panel (with live 송금완료/checkout buttons) keeps rendering for up to ~5 minutes after its backing RSVP is auto-cancelled by the hold-expiry cron — a Toss-grade app never shows a dead-end/misleading live CTA.

Design spec: gate the reserved/submitted payment Card's render on myRsvp?.status === 'confirmed' in ADDITION to gateState (not instead of — the mechanical half of this fix is adding sessionPaymentKeys to useLiveSessionData's 10s invalidation list, which shrinks the staleness window; the render gate is the design half that makes the UI honest even inside that window). When the gate flips false while the card was showing 'reserved'/'submitted', don't just silently remove the card — render a ONE-TIME transient note in its place: Card tone="flat" with cardBody "예약이 만료돼 취소됐어요" + a muted Badge variant="surface" "만료됨", visible for the remainder of that screen session (until nav-away), then the card disappears on next mount. This follows the same "tell the user what happened" instinct as SessionStatusBanner's terminal-state banners (cancelled/completed sessions get a banner, not a silent void).

Canon satisfied: docs/canon/sessions.md status-adaptive-surfaces rule ("Active states carry status via the chip... TERMINAL states get a full-width status BANNER... because a chip can't carry 'this is over/void'") — the payment-hold's own terminal transition deserves the same treatment as a session's.

2. Formalize "row-level ActionButton pair inside a Card" as a named canon exception

Problem (MEDIUM, canon drift): 4 shipped screens (session-payments-screen.tsx, session-attendance-screen.tsx, participants-screen.tsx, pickup-host-dashboard-screen.tsx) nest ActionButton pairs directly inside Card for per-row mark-done actions — contradicting the literal canon sentence ("never put a CTA inside a card") but working correctly, invisible to the mechanical lint gate, and consistent across every "pending items with per-row actions" surface in the app (including U2's new join-request rows above, which extend this exact pattern).

Design spec: amend docs/canon/components.md's Cards section to name this exception explicitly (call it COMP-3b): a Card tone="default" size="sm" may host ≤2 ActionButtons when the row is a PENDING-ITEM row (not a navigation card) and the buttons resolve/dismiss that specific row (mark paid, mark attended, exclude, approve/ reject) — never a navigation or "view more" action. This is the cheaper of the two fix shapes the audit proposed (amend canon vs. migrate 4 screens to a different pattern) and matches what's already shipped and working.

Canon satisfied: resolves the canon-vs-code contradiction by codifying the existing, correct, working pattern rather than forcing a behavior change.

3. Header payment badge can't distinguish 결제 대기 from 확인 대기

Problem (LOW, copy gap): the SessionStatusBanner trailing badge renders SessionPaymentStatusBadge status="pending" for BOTH gateState === 'reserved' (결제 대기) and 'submitted' (확인 대기) — a member who already tapped 송금 완료 sees the same generic chip as before they tapped it, even though the body callout further down correctly differentiates.

Design spec: the header badge slot switches from the entity-status-mapped SessionPaymentStatusBadge to a small inline label driven directly by sessionPaymentGateState (the canonical 4-state taxonomy already in use everywhere else): Badge variant="warning" with label "결제 대기" for reserved, same variant with label "확인 대기" for submitted — same visual weight/color (both are still non-final, warning-tier), only the text differs, which is the cheapest fix that closes the gap without adding a new badge variant or touching the DB-backed SessionPayment entity status.

Canon satisfied: docs/canon/sessions.md payments canon — sessionPaymentGateState is named canonical precisely so no surface re-derives or erases one of its 4 states.


U4 — Matches (경기)

1. Disputed-match visual language + resolution path (pairs with the backend fix)

Problem (CRITICAL, mostly mechanical): reject_call never writes score_status = 'disputed', so the already-built HomeDisputedAlertBanner + ScoreStatusBadge "disputed" branch + findDisputedByUser query can never fire. The mechanical fix (have reject_call also UPDATE matches SET score_status = 'disputed') is a migration, not a design decision — score disputed ALREADY maps to live (red) in the canonical status-badge intent table (docs/canon/status-and-recruitment.md), so no new color/variant work is needed once the data flows.

Design spec (the design half): once score_status can persist as disputed, add a PERSISTENT marker on the match's own row wherever it's rendered post-dispute (match-card, scorecard round row) — not just the transient home banner: ScoreStatusBadge already has the branch, so this is "render the badge unconditionally when status=disputed" rather than a new component. Add a resolution path: on the scorecard, a disputed match row gets a "다시 제안하기" (re-propose) ActionButton action="tertiary" that reopens ScoreCallSheet for a fresh score call — closing the loop the audit found missing ("after a reject, the match just reverts to awaiting a fresh score call, with no record of the dispute anywhere"). HomeDisputedAlertBanner's existing CTA continues to route to the scorecard, now landing on a screen that actually shows the disputed state.

Canon satisfied: docs/canon/status-and-recruitment.md live variant for score disputed (already defined, just unreachable) + CLAUDE.md's "no half-built screen" standard.

2. Reject-action confirm + button-variant fix

Problem (HIGH): score-call-sheet.tsx/score-correction-sheet.tsx's 거부 button is a raw Button variant="destructive" fired with NO useConfirm gate — using the CONFIRM-step color as the TRIGGER, with no confirm step at all.

Design spec: swap to ActionButton action="danger" (quiet destructive trigger, matching every other reject/forfeit/remove trigger app-wide) and wrap the tap in useConfirm({ style: 'destructive', title: '제안을 거부할까요?', message: '상대방에게 알림이 가요', buttons: [...] }) — Toss-voice, states the consequence plainly. Applies identically to both sheets.

Canon satisfied: docs/canon/components.md "Trigger → danger, confirm → destructive; never swap them" + the general destructive-CTA-must-confirm rule.

3. "Agreed" checkmark renders in the red live tone — fix without a canon amendment

Problem (MEDIUM): the ✓-for-agreed cue in score-call-sheet.tsx uses Badge variant="live" (red) because success isn't in the features-bounded Badge enum (COMP-2) — the agreement cue reads as an alert, the opposite of its meaning.

Design spec: don't widen the bounded enum (that reopens COMP-2's whole guarantee for one row). Instead, drop the Badge wrapper for this cell and render a plain Icon (CheckCircle, size="sm", color="$success") + Text role="badge" "동의함" — a 2-element inline group, not a pill. This sidesteps COMP-2 entirely (icons aren't bounded by the Badge-variant enum) while getting the correct green. awaitingResponse keeps its existing Badge variant="neutral" treatment (that one IS correctly gray/neutral today).

Canon satisfied: avoids a COMP-2 canon change while fixing the semantic-color bug — consensus rows recur (score-call, score-correction, and any future all-agree flow), so this pattern should be the one reused, not a Badge-variant workaround.

4. CTA-in-Card on the live scorecard — name the exception, stop the sibling dodge

Problem (MEDIUM): live-tab.tsx's card-variant LiveMatchRow embeds a footer ActionButton inside a real Card (self-documented COMP-3 exception); its "embedded" sibling variant reproduces the identical visual surface via a plain YStack specifically to dodge the no-button-in-card lint rule — same violation, invisible to the linter.

Design spec: name this a second canon exception (COMP-3c, distinct from COMP-3b above): a live-match row is its own PER-MATCH CONTROL SURFACE, not a navigation card — legitimate because the row has no "detail destination" separate from the action itself (there's nothing else to navigate to). Once named, both variants should be HONEST: the embedded variant can go back to using a real Card (matching its visual identity) instead of a YStack workaround — the exception makes the workaround unnecessary.

Canon satisfied: closes the letter-vs-spirit gap the audit flagged — a reviewer grepping <Card> + ActionButton should find BOTH variants once this is codified, not just one.

5. Sudden-death has zero visual treatment

Problem (MEDIUM): a sudden-death-decided set is pixel-identical to a normal one on the scorecard — no way to tell from the score alone.

Design spec: mirror the existing tiebreak-superscript treatment exactly (text-first, minimal, no new iconography system). Thread a decidedBySuddenDeath flag onto the completed match/set data; in MatchScoreboard's ScoreCell, render a small superscript marker (e.g. SD in Text role="micro" $textTertiary, positioned the same way the tiebreak superscript already is) next to the final score. No modal/tooltip needed for v1 — the tiebreak precedent doesn't have one either; a curious player already knows to ask "what does SD mean" the same way they learn the tiebreak superscript.

Canon satisfied: reuses the tiebreak-superscript precedent — "make the derived state visible, not silent," same pattern, not a new one.

6. Bye/sit-out players invisible on the primary round view

Problem (MEDIUM): the bench/sit-out UI only exists inside the host-facing live-round-editor ModalPanel — the primary participant-facing round view renders courts only.

Design spec: add a lightweight "쉬는 중" row below the court list on match-board-screen.tsx's primary round view, shown whenever benchCount > 0 (sourced from the same selectBench output already computed for round generation — no new logic). Render as a Card tone="flat" (matches the empty-state dashed-border treatment, signaling "this round has no match for these people," not an error) containing small AvatarBubbles (size sm) for each benched player + Text role="cardMeta" "쉬는 중". Read-only — no action affordance needed here (the editor sheet stays the place to actually swap/reassign).

Canon satisfied: same "derived state rendered silently" fix shape as the sudden-death marker and tiebreak precedent above.

7. Unify score-entry pattern: Stepper over raw TextInput

Problem (MEDIUM): match-board uses a raw numeric TextInput; the scorecard live tab uses a Stepper. Same task, two different input mechanics depending on which of the two screens you're on.

Design spec: standardize on Stepper (xs + circle per the canonical Button-anatomy convention for numeric steppers) on match-board too, replacing the raw TextInput. Match scores are small bounded integers (0–7 typically, occasionally higher with no-ad/pro-set rules) — exactly the Stepper's designed use case, and it removes the keyboard-open/dismiss friction the TextInput currently requires per score entry.

Canon satisfied: docs/twomore_design_system/README.md Button conventions ("Stepper → xs + circle icon-only") — already the documented pattern, just not applied consistently.


U5 — Records (기록)

(EloTrendCard's reversed-array bug, the 1200 vs DEFAULT_ELO=1000 fallback mismatch, and the dead-code exports are pure bugs/cleanup with no design judgment — routed to the impl plan. TierInfoSheet's raw-View styling is a mechanical Tamagui-port with one design-adjacent note below.)

1. TierInfoSheet — Tamagui port, with correct token mapping

Problem (HIGH, canon): the tier-explainer sheet (opened from every tier badge in this block) is built entirely in raw RN View + inline style={{}} with runtime variables — manual theme.primary?.val reads instead of $primary tokens.

Design spec: convert to Tamagui YStack/XStack with $-tokens; isActive drives a Tamagui variant/prop instead of a computed style object; tierColor (a genuinely runtime-computed hex per tier) resolves via the existing themeHex(theme, ...) helper pattern already used for chart series colors, never a bare style-object hex. No layout change — this is a primitive-swap, not a redesign; flagged here only because the color mapping needs to route through the correct token helper, which is a design-system correctness question, not pure mechanics.

Canon satisfied: docs/canon/styling.md Tamagui-only rule + "theme-invariance — NEVER hardcode a hex... chart series colors resolve via themeHex()."

2. Region leaderboard: single-player region shows nothing

Problem (HIGH, edge case): PodiumRow's top3.length >= 2 gate means a region with exactly 1 ranked player — plausible at launch, the exact "most common state" the audit brief called out — renders a section title with zero rows underneath.

Design spec: lower the gate to top3.length >= 1, matching how leaderboard-club-tab.tsx and leaderboard-global-tab.tsx already render a lone entry correctly as a single podium block (no new component — just remove the region-tab-only >= 2 restriction). For the solo case specifically, consider a one-line encouragement under the podium (Toss voice, matches the design system's momentum-copy tone): "첫 번째로 랭크에 등록됐어요" — a first-mover in a sparse region deserves acknowledgment, not just a bare podium of one.

Canon satisfied: consistency across the 3 leaderboard tabs (club/region/global should degrade identically at N=1, not diverge).

3. Leaderboards cap at top-50 with no "my rank" affordance

Problem (MEDIUM): below rank 50, a viewer gets zero feedback about where they stand — no indicator, no pagination, no jump-to-rank.

Design spec: add a persistent 내 순위 row, pinned at the BOTTOM of the leaderboard list (below the FeedList, not inside it — avoids virtualization complexity), shown whenever the viewer's rank is outside the rendered top-50: Card tone="flat" containing PlayerIdentityChip (avatar + "나", reusing the same identity-chip component RankRow uses) + Text role="cardTitle" "내 순위 · N위" + ELO value (cardMeta). No tap action required for v1 (no "jump to my rank" — that needs true pagination, a bigger lift); the row's job is purely informational; it can gain a tap-to-scroll affordance once pagination exists. This mirrors how Strava/most competitive leaderboards handle "you're not on this page" — a fixed footer row, not a silent absence.

Canon satisfied: docs/canon/components.md "Backend-backed data never renders as dead text" — the viewer's own rank IS backend-backed data currently rendered as nothing.

4. Podium (top-3) rows aren't tap-to-profile; rows 4+ are

Problem (MEDIUM): PodiumEntry has no userId field — the 3 most prominent rows on any leaderboard (bigger, medal emoji, avatar) are the only ones a curious viewer can't tap through.

Design spec: add userId to PodiumEntry; wrap each PodiumCard's identity block (avatar + name) in the SAME onPressIn-prefetch + tap-to-profile pattern RankRow uses via PlayerIdentityChip. The existing tier-badge Pressable (opens TierInfoSheet) stays nested with stopPropagation — 2 nested targets on a podium card (profile + tier info) is within the ≤3-affordance rule.

Canon satisfied: same "Backend-backed data never renders as dead text" rule as U2 #2 — this is the identical gap on a different surface family.

5. New-user zero-match state is 4 separate empty fragments (Toss-filter violation — see Wireframe D)

Problem (MEDIUM, UX, high-visibility since it's the literal first-run state for every early user): the zero-match branch renders an always-full 12×7 gray activity-heatmap grid + 3 separately-empty compact EmptyStates (FormatStatsCard/TopPartnersCard/ EloTrendCard) + 3 footer LinkCards, under a HeroCard — 4 quiet "no data" mini-answers instead of one confident onboarding moment.

Design spec: replace the 3 separate compact EmptyStates (and hide the all-gray-cell heatmap in this branch — an inert grid with a "0일 활동" caption adds nothing a single sentence doesn't already say) with ONE unified EmptyState variant="full" block, placed directly under HeroCard: icon (a tennis-ball or racquet Lucide icon in the standard $primarySubtle 64px bubble), title "아직 경기 기록이 없어요", body "첫 경기를 시작하고 나만의 기록을 만들어보세요", actionLabel="번개 찾기" routing to pickup discovery (or session creation — whichever is the higher-intent entry point; discovery is lower-friction for a true first-timer). The 3 footer LinkCards stay (지난 기록/상대 전적/etc.) — they're low-stakes "see more" navigation, not competing empty-answers, so the Toss "one task per screen" rule is satisfied by the ONE empty-state block being the page's single focal task; the footer links are deliberately secondary and can stay quiet. See Wireframe D below.

Canon satisfied: docs/canon/components.md EmptyState full/compact convention + the CLAUDE.md UX filter ("Would Toss ship this? One task per screen").

6. TopPartnersCard shows partner rows as text-only (no avatar)

Problem (LOW): the one people-list row in this block skipping the graphics-over-text avatar convention every other row (RankRow, PodiumCard) follows.

Design spec: add AvatarBubble (size sm) to each partner row, sourced by threading a partner-side avatar URL through buildTopPartners() the same way MatchHistoryOpponent.avatarUrl already flows for the opponent side — the data path exists, it's just not captured for partners. Medal Badge (rank) stays alongside the avatar, not instead of it.

Canon satisfied: project memory feedback_graphics_over_text.md — "browsing surfaces use rings/avatars/day-dots/charts, not text meta lines."


U6 — Discovery

(The guest-apply onboarding-intent routing gap, club-discovery's localeCompare reintroduction, the dormant naver_place fields, and the dead routes.login push call are mechanical — routed to the impl plan. The WebView-native-module risk was verified NOT a bug — no action needed.)

1. The 미확인 포함 toggle surfaces unverified venues with zero visual distinction

Problem (HIGH, owner priority): DirectoryVenueCard only ever renders a trust signal for official/host_verified venues. Once a user opts into 미확인 포함, the resulting mixed list — corroborated-but-unofficial, single-source, and fully-unconfirmed Overpass-polygon venues — all render identically. The one surface that explains confidence honestly (VenueTrustRing + tiered summary copy) is one tap deeper, on the detail screen — not on the list where the trust decision actually needs to be made.

Design spec: add a compact trust signal to the SAME badge-strip slot the card already reserves for "인증됨": when filters.verification === 'all' is active AND the venue fails the corroborated gate (source_count < 2 OR verification_status === 'unverified'), render Badge variant="warning" label "미확인" in that slot (instead of nothing). This reuses the EXISTING multi-source confidence vocabulary (getVenueTrust/getFactTier helpers already power VenueTrustRing/ConfidenceBars on the detail screen) — no new trust taxonomy, just a compact single-badge projection of data already computed. Do not add the full ConfidenceBars strip to the list card (that would violate the summary-card density rule — a badge is the "3–6 decision-critical signals" answer here, not a mini dashboard).

Canon satisfied: docs/canon/components.md summary-card density discipline (defer the rich version to detail, but the CARD must still answer its one decision question — here, "can I trust this listing enough to plan around it") + the existing multi-source provenance system being single-sourced, just under-surfaced.

2. Directory empty state can't distinguish "no courts" from "filtered out by trust gate"

Problem (MEDIUM): a genuinely sparse region and a region with real-but-unverified courts show the identical static "검색 결과가 없어요 / 다른 지역이나 이름으로 검색해 보세요" — the copy never mentions the 미확인 포함 toggle that would actually resolve it.

Design spec: make the EmptyState body conditional on filters.verification: when verification === 'corroborated' and results are empty, use the existing actionLabel/onAction slot (already supported by EmptyState, just unused here) with actionLabel="미확인 코트 포함해서 보기" that flips the filter to 'all' directly from the empty state and re-queries — no navigation, no extra tap into the filter bar. Keep the current copy verbatim for the genuinely-zero case (verification === 'all' still empty).

Canon satisfied: docs/canon/components.md EmptyState actionLabel/onAction usage — the primitive already supports this; it's simply unwired here (same dormant-slot pattern as U7's DM empty state below).

3. Three diverged region→district filter implementations

Problem (MEDIUM, canon — single-source-of-truth violation with LIVE drift): pickup, venue directory, and club discovery each reinvented the region→district cascading filter with a different value-encoding format (region:district vs region-district vs none) AND a different cardinality (multi/multi/single-select) — all reading from the same REGIONS/getSigunguOptions option source, but with the selection model and value-encoding built 3 separate times.

Design spec: consolidate into ONE shared RegionDistrictFilterField component (net-new, lives in packages/ui or a shared cross-feature module — it's used by both clubs and activity/sessions features, so it can't live inside either feature package per hex-arch rules), composing the existing DropdownChip/SelectionChip primitives with:

  • ONE canonical encoding — recommend the venue-directory's ${regionSlug}-${구} hyphen format, since it already ships a documented collision-safety comment ("region slugs never contain a hyphen, so splitting on the first '-' is unambiguous").
  • ONE cardinality contract — multi-select (2 of 3 current screens already need it; club-discovery's single-select becomes "multi-select capped at 1" through the same component rather than a structurally different data shape).

Each of the 3 screens migrates to the shared field, dropping their private build*FilterValue/parse*FilterValue modules. This is real architecture work (a new shared component + 3 call-site migrations), not a token swap — flagged as a canon addition to docs/canon/components.md's "Chip-style tab controls" section once built.

Canon satisfied: CLAUDE.md "Single source of truth — one implementation per pattern ... extend don't bypass."


U7 — Auth / Profile / DM

1. OAuth login screen (Phase 8c) — see design spec below (no full wireframe; layout is a straightforward button stack)

Problem (CRITICAL, launch gate, owner priority): the live login screen is email/password + dev-only, with a "곧 추가돼요" disclaimer. Kakao is code-complete with no button; Google/Apple adapters unconditionally throw. Blocked externally on EXT-11 (Kakao production credentials) — but the UI can and should be designed now so implementation is a drop-in once credentials land.

Design spec: below the existing email/password form, add a Divider labeled "또는" (cardMeta, centered) followed by a vertical stack of provider buttons, each full-width (lg, per the page-CTA convention) with LOCKED-IN brand colors (per the design system's "third-party locked-in" rule): 카카오로 계속하기 (#FEE500 bg / #191919 text, primary position — Kakao is the dominant KR social-login choice), Apple로 계속하기 (Apple's own black/white per HIG — required the moment Kakao ships, per App Store guideline 4.8), Google로 계속하기 (Google's own white/gray-border per their brand guidelines). Order: Kakao → Apple → Google (usage-frequency-driven for KR). Below the buttons: the existing oauthComingSoon disclaimer text is REMOVED once buttons ship (it would contradict live buttons).

States:

  • Cancel — silent, no toast, screen stays exactly as-is (matches native OS-cancel convention; the audit confirmed this is fine AS LONG AS it's intentional, which this spec makes explicit).
  • Failure — an inline error band using the SAME error-surface the email path already has (login-screen.tsx's existing errorMessage state + its rendered error Text) — not a new error component. session-provider.tsx's OAuth path needs to re-throw (or set a readable error) the same way the email path already does, so LoginScreen can route it into the existing error surface.

Inputs: migrate the two raw hand-styled TextInputs to a shared blessed RNTextInput wrapper (the pattern delete-account-screen.tsx/change-password-screen.tsx already use correctly with a justification comment) — one wrapper component, 3 call sites converge on it, closing the single-source-of-truth gap the audit flagged.

Canon satisfied: docs/canon/styling.md Tamagui-only rule (closes the raw-inline-style violation) + docs/twomore_design_system/README.md "Third-party locked-in" color rule + wireframe-first rule (this spec stands in place of a full mockup since the layout is a straightforward vertical button stack with no novel composition).

2. signal_preferences settings screen — see Wireframe C

Problem (CRITICAL feature gap, owner priority): the full per-category (13 categories × in-app/push/severity/quiet-hours) preference model is built DB-to-adapter, but has ZERO settings UI — the route (/settings/signal-preferences) and quiet-hours i18n keys exist and are dead. Separately, 6 of 9 user_preferences notify booleans ALSO have no toggle — two parallel half-built preference systems.

Design spec: build ONE canonical screen at the existing /settings/signal-preferences route, claiming the existing route constant + i18n scaffolding rather than minting new ones. DetailShell + GroupedFeedList grouped by category-cluster (일정/회비/경기/소셜/ 시스템 — cluster the 13 raw categories into ~5 user-facing groups, since 13 flat rows would be a wall). Each row: category label (cardBody) + two Switches (인앱/푸시, labeled via small cardMeta column headers at the group top, not per-row) + a min_severity DropdownChip (전체/중요만/긴급만) for categories where severity filtering is meaningful (dues, matches — not for e.g. system announcements). A persistent-info row at the bottom: 방해 금지 시간 (quiet hours) — tap opens a ModalPanel with a simple from/to time picker, writing the existing quiet_hours JSONB shape. Decision to make explicit: this screen supersedes and RETIRES the 6 orphaned user_preferences booleans (notifySessionCreated/notifyRsvpUpdates/ notifyDuesReminder/notifyMatchResults/notifyBoardPosts/notifyAchievements) — signal_preferences becomes the single model going forward; the legacy booleans are either migrated into the new categories or deprecated, never surfaced alongside this screen (two parallel preference UIs would recreate the same confusion this fix is meant to close).

Canon satisfied: CLAUDE.md "Single source of truth" (resolves the 2-preference-system split) + docs/canon/components.md GroupedFeedList/ModalPanel usage.

Problem (HIGH, compliance): the unlabeled "푸시 알림" toggle silently grants/revokes the marketing_push PIPA purpose — a consent action hidden behind a generic-sounding row.

Design spec: settings-screen.tsx's existing "푸시 알림" row STOPS writing marketing_push (it should only ever gate service pushes — RSVP, dues, match, §50-exempt per canon). Add a NEW, separately-labeled row directly below it: "마케팅 알림 (광고)" with its own Switch, explicitly wired to grantConsent/revokeConsentpurposeKey: 'marketing_push' — matching the labeling settings-consent-management-screen.tsx already uses correctly. Both rows live in the same settings section (grouped under a "알림" SectionBlock), so the split reads as two clearly distinct choices, not a buried toggle.

Canon satisfied: docs/canon/korean-market.md / docs/canon/notifications.md marketing-push consent requirements — active, INFORMED consent, never bundled into a generically-labeled toggle.

4. Empty DM inbox — wire the dormant EmptyState action

Problem (MEDIUM, UX, owner priority): the empty-inbox copy says "메시지를 보내보세요" but offers no path to do it — the header "+" only creates GROUP chats; 1:1 threads are only ever created from a profile page elsewhere.

Design spec: wire EmptyState's existing (currently-unused) actionLabel/onAction props: actionLabel="새 메시지", onAction opens a lightweight recipient-picker ModalPanel — search field + a list of club members (useClubMembers/useClubProfiles, rows via PlayerIdentityChip) — tap a row → useInitiateDmThread → navigate straight into the new thread. This is the "pick from a small set of contextual options" ModalPanel archetype already named in canon, reused rather than inventing a new recipient-picker pattern. (Simpler alternative considered and rejected: routing to the 클럽 멤버 list — that's a tab-hop away from DM and loses the "start a conversation" intent; the in-place picker keeps the user in the DM flow.)

Canon satisfied: docs/canon/components.md EmptyState actionLabel/onAction + ModalPanel "pick from a small set of contextual options" archetype.

5. Deleted-partner thread header + change-password decorative field (small fixes batch)

Problem (MEDIUM, 2 items): (a) an unresolvable DM partner (deleted/soft-deleted account) falls back to the generic inbox title ("메시지") instead of the existing unknownUser ("알 수 없음") string already used elsewhere in the same file, with no "deactivated" indicator during the 30-day soft-delete grace window; (b) change-password's "현재 비밀번호" field is collected, gates the submit button, but is never actually validated — false assurance.

Design spec:

  • (a) swap the header fallback to strings.unknownUser; during the soft-delete grace window, add a small Badge variant="muted" label "비활성 계정" beside the header name (reuses the existing muted status-intent tier — "not active," same semantic as session cancelled/dues waived).
  • (b) decision needed: either wire real re-auth (signInWithPassword before the update call — adds a network round-trip, closes the gap honestly) or DROP the field entirely and replace it with a plain sentence (Text role="cardBody"): "현재 로그인된 계정의 비밀번호를 변경해요." Recommend dropping the field — it's the cheaper fix and it stops implying a security check that doesn't exist, which is worse than not having the field at all.

Canon satisfied: (a) single-source-of-truth for the "unresolvable identity" fallback string (matches the sender-fallback usage in the same file) + the muted status-intent tier reused correctly. (b) CLAUDE.md's UX-honesty standard — no decorative security theater.

6. Session-expired messaging on LoginScreen

Problem (LOW-MEDIUM): an expired session (proactive refresh failure or reactive guard) lands the user on LoginScreen with zero context for why.

Design spec: LoginScreen accepts an optional navigation param/reason; when the redirect reason is 'expired', render a small Text role="cardMeta" banner above the form: "세션이 만료됐어요, 다시 로그인해주세요" — one line, no icon, no modal; purely informational context so the drop to the login screen doesn't read as a random glitch.

Canon satisfied: Toss-voice copy discipline (docs/twomore_design_system/README.md — "the app narrates what's happening, not what the user should feel").


U8 — Dues (회비)

1. Bank-account / payment-instructions card — see Wireframe B

Problem (HIGH, owner priority — "the single biggest gap between 'tracks dues' and 'collects dues'"): the member-side dues screen's ONLY guidance is "납부 관련 문의는 총무에게 직접 연락해주세요" — zero account number, zero payment method, forcing every payment out of the app into a KakaoTalk DM.

Design spec: add a club-level duesPaymentInfo field (bank name + account number + holder name; OR a KakaoPay/Toss deep-link URL — the existing PAYMENT_METHODS enum (bank|cash|kakaopay|toss|other) already models which shape applies), settable from club settings (admin-only — a LabeledField+StyledInput group, likely inside a ModalPanel "edit payment info" step reached from a settings row, matching the "edit-a-single-field-in-place" archetype). Surface as a compact Card tone="default" pinned ABOVE the dues row list on BOTH the member and admin views: a leading Badge variant="neutral" label for the method (e.g. "계좌이체"), the account line (cardBody), holder name (cardMeta), and a Pressable variant="icon" copy-to-clipboard affordance (Copy icon) beside the account number. Admin view additionally shows a small edit-pencil Pressable variant="icon" in the card's top-right to jump to the settings editor. See Wireframe B for full placement alongside the bulk-mark-paid affordance (both live on the same screen and are the two highest-leverage fixes to the same core loop).

Canon satisfied: docs/canon/components.md summary-card density (this card answers exactly one decision — "how do I pay" — in ≤4 lines) + the ModalPanel edit-embedded archetype for the admin-editing path.

2. Bulk mark-paid — see Wireframe B

Problem (MEDIUM, owner priority — "총무 core-loop friction"): marking 10 members paid is 20 taps + 10 modal transitions with zero batch affordance — directly working against the CLAUDE.md UX filter for what the same doc calls the 총무's core loop.

Design spec (MVP, matches the audit's own recommended fix shape): add a "모두 납부 처리" ActionButton action="tertiary" to SummaryCard's header, visible only when canManageDues AND at least one unpaid/partial row exists for the selected month. Tapping it opens useConfirm (style: 'default', not destructive — marking paid isn't destructive, but a batch action touching N rows deserves a confirm step regardless): "미납 N명을 모두 완납 처리할까요?" On confirm, fires a NEW batch mutation (updateStatusBatch, avoiding N sequential round-trips — this part is mechanical, noted for the impl plan) that flips every currently-unpaid/partial row in the selected month to paid. Not recommended for v1: long-press multi-select with per-row checkboxes — a heavier UI surgery than the core loop needs; the single "mark all" action covers the dominant real-world case (in-person cash collection or a batch of bank-transfer screenshots reconciled at once) without the added complexity of a selection mode.

Canon satisfied: CLAUDE.md UX filter ("Would Toss ship this?") applied to the document's own named "총무's core loop."

3. Waive action needs a visible acknowledgment (member-facing design half)

Problem (CRITICAL, mostly mechanical): waiving a member's dues writes no audit trail, never notifies the member, and never resolves their outstanding overdue signals — the mechanical fixes (write cleared_by/cleared_at on waive, broaden the resolve-guard to IN ('paid','waived','partial')) belong in the impl plan. The design half: a new dues_waived signal type needs member-facing copy.

Design spec: dues_waived renders through the EXISTING SignalRow component (no new UI) with copy "회비가 면제됐어요" (matching the corrected dues_cleared_by_admin string already fixed elsewhere in this same audit — NOT the currently-dead, factually-wrong "납부 완료로 처리됐어요" string sitting in the shadowed settings.ts i18n subtree). Severity: medium (acknowledgment, not an alert) — renders with the standard neutral signal-row treatment, no special accent.

Canon satisfied: matches the existing dues_cleared_by_admin signal's corrected copy — single source of truth for "what does a dues-status-change notification say."

4. AdminTab snapshot strip must never show 0 while a member is being escalated

Problem (CRITICAL, mostly mechanical — 4 divergent overdue-count SQL formulas): the club AdminTab strip (the product's designed "start here" surface, per docs/canon/clubs.md) can show "회비 미납 0건" while a member's phone is already receiving a CRITICAL-severity 회비 심각 연체 push, because the strip's calendar-month boundary excludes the current month categorically while the signal cron uses a dues_day-precise days-past formula.

Design spec (the design commitment, not just a SQL fix): unify all 4 surfaces (Home admin badge, AdminTab snapshot strip, signal cron, dues-screen SummaryCard) to the SAME dues_day-precise due-date formula the cron already uses — this is a correctness requirement of the AdminTab strip's role as the canonical "start here" surface (docs/canon/clubs.md), not an optional consistency nice-to-have. No new UI; the existing AdminSnapshotStrip cell rendering stays as-is once its number source agrees with the other 3.

Canon satisfied: docs/canon/clubs.md R2-2 — AdminSnapshotStrip is explicitly the "start here" attention surface; its number must be trustworthy by design, not just by accident of matching formulas.

5. Mid-month member join — persistent "add missing member" action

Problem (MEDIUM-HIGH): the ONLY dues-generation entry point disappears once ANY row exists for the month — a club that gains a member on the 15th has no in-app way to create that member's row for the current month, ever.

Design spec: replace the empty-state-gated BottomCtaBand CTA with a PERSISTENT AdminNavRow-style row (or a small ActionButton action="tertiary" beside the month header, next to the existing 모두 납부 처리 button from #2) labeled "신규 멤버 회비 등록" — visible whenever canManageDues, regardless of whether dues.length === 0. Calls the SAME idempotent handleGenerateDues (already correctly filters existingUserIds, so re-invoking it for a club with 11/12 rows just inserts the missing 1st) — no new mutation needed, only a new, always-visible trigger for the existing one.

Canon satisfied: the CTA's own idempotent design already supports this; the fix is purely "don't gate a reusable action behind a one-time empty state."

6. 0-dues club shouldn't show the full 회비 admin board

Problem (LOW): a club with duesAmount === 0 (an intentional, supported "doesn't collect dues" state per the create-club wizard's own "skippable" framing) still shows a fully-functional-looking dues board, generating meaningless ₩0 "미납" rows on tap.

Design spec: hide the 회비 AdminNavRow entirely from AdminTab's nav-hub list when club.duesAmount === 0 (the row simply isn't in the list — no placeholder, no disabled state). If reached via a stale deep link, render EmptyState variant="compact" with the copy already sitting unused in the dead i18n block (duesAmountNotSet/ duesAmountNotSetDescription) + actionLabel routing to club settings' 회비 금액 field.

Canon satisfied: reuses copy already written for exactly this state (currently dead — this fix gives it a home) + avoids presenting a live-looking board for an opted-out feature.


Wireframes

Four screens need a genuine layout rethink (new composition, not a token/color swap). Grounded in real components and data shapes cited above.

Wireframe A — Club members: 가입 신청 (join-request) review section

New section on club-members-screen.tsx, using the existing role-grouped GroupedFeedList collapsible-header pattern. Closes U2 #1 (CRITICAL — dead pipeline).

┌─────────────────────────────────────┐
│ ←   멤버 관리                        │  DetailShell / AppHeader
├─────────────────────────────────────┤
│  가입 신청 · 2건               ⌄     │  SectionHeader, expanded by default
│ ┌───────────────────────────────┐   │
│ │ 🧑 김민준                      │   │  Card tone="default" size="sm"
│ │ "주 2회 참여 가능해요"          │   │  cardBody, applicant message
│ │ 신청일 7월 9일                 │   │  cardMeta
│ │              [거절]   [승인]   │   │  ActionButton danger / primary
│ └───────────────────────────────┘   │
│ ┌───────────────────────────────┐   │
│ │ 🧑 이서연                      │   │
│ │ 신청일 7월 10일                │   │
│ │              [거절]   [승인]   │   │
│ └───────────────────────────────┘   │
├─────────────────────────────────────┤
│  운영진 · 3                    ⌄     │  existing role-grouped header
│   ...                                │
│  멤버 · 24                     ⌃     │  largest group, collapsed by default
└─────────────────────────────────────┘

Wireframe B — Dues screen: bank-info card + bulk mark-paid (admin view)

Closes U8 #1 (bank info, HIGH) + U8 #2 (bulk mark-paid, MEDIUM) — both live on club-dues-screen.tsx and are the two highest-leverage fixes to the same core loop, so specced together.

┌─────────────────────────────────────┐
│ ←   회비 관리          ◀ 2026.07 ▶   │  MonthSelector (existing)
├─────────────────────────────────────┤
│ ┌───────────────────────────────┐   │  SummaryCard (existing)
│ │ 납부 진행  ████████░░  8/12    │   │  ProgressBar
│ │ 미납 4명 · 부분납부 1명         │   │  cardMeta
│ │                [모두 납부 처리] │   │  ActionButton tertiary — NEW
│ └───────────────────────────────┘   │
├─────────────────────────────────────┤
│  계좌 정보                      ✎    │  NEW compact card, admin-editable
│ ┌───────────────────────────────┐   │
│ │ [계좌이체]                     │   │  Badge neutral, PAYMENT_METHODS label
│ │ 국민은행 123456-78-901234       │   │  cardBody
│ │ 예금주 홍길동            [복사] │   │  cardMeta + Pressable icon
│ └───────────────────────────────┘   │
├─────────────────────────────────────┤
│  + 신규 멤버 회비 등록                │  NEW persistent AdminNavRow-style
├─────────────────────────────────────┤
│ 김민준            미납 ₩30,000   ⋮  │  DuesRow (existing)
│ 이서연            완납           ⋮  │
│ 박지훈            부분납부       ⋮  │
│ ...                                  │
└─────────────────────────────────────┘

Member view: same 계좌 정보 card, no edit-pencil, no 모두 납부 처리 button, no 신규 멤버 회비 등록 row — read-only version of the same composition.

Wireframe C — Notification (signal) preferences settings screen

Closes U7 #2 (CRITICAL feature gap, zero UI today). Claims the existing dead route /settings/signal-preferences.

┌─────────────────────────────────────┐
│ ←   알림 설정                        │  DetailShell
├─────────────────────────────────────┤
│  일정                                │  SectionHeader variant="list"
│ ┌───────────────────────────────┐   │            인앱  푸시
│ │ 세션 생성                      │   │             ●    ●
│ │ RSVP 변경                      │   │             ●    ○
│ └───────────────────────────────┘   │
│  회비                                │
│ ┌───────────────────────────────┐   │            인앱  푸시
│ │ 납부 알림                      │   │             ●    ●
│ │ 심각도                [전체 ⌄] │   │  DropdownChip
│ └───────────────────────────────┘   │
│  경기                                │
│ ┌───────────────────────────────┐   │            인앱  푸시
│ │ 결과 · 분쟁                    │   │             ●    ●
│ └───────────────────────────────┘   │
├─────────────────────────────────────┤
│  방해 금지 시간                       │
│  22:00 – 08:00                [설정] │  → ModalPanel time picker
└─────────────────────────────────────┘

Wireframe D — Records tab: unified zero-match onboarding state

Closes U5 #5 (Toss-filter violation — 4 separate empty fragments → 1 confident state).

┌─────────────────────────────────────┐
│ 기록                                 │  pageTitle
├─────────────────────────────────────┤
│ ┌───────────────────────────────┐   │  HeroCard (unchanged)
│ │  ELO 1000 · 브론즈              │   │
│ │  ▓▓▓░░░░░░░░░  다음 티어까지     │   │
│ └───────────────────────────────┘   │
├─────────────────────────────────────┤
│                                       │
│              🎾                      │  EmptyState variant="full"
│      아직 경기 기록이 없어요           │  title
│   첫 경기를 시작하고 나만의             │  body
│    기록을 만들어보세요                 │
│         [ 번개 찾기 ]                 │  actionLabel → discovery
│                                       │
├─────────────────────────────────────┤
│  지난 기록 보기                   >  │  footer LinkCards (unchanged,
│  상대 전적 보기                   >  │  low-stakes secondary nav)
└─────────────────────────────────────┘

(Replaces: the always-full 12×7 gray heatmap grid + 3 separately-empty compact EmptyStates inside FormatStatsCard/TopPartnersCard/EloTrendCard.)


Design-work priority order

Ranked by (a) explicit owner priority naming, (b) severity, (c) how dead-end/broken the current state reads to a user mid-flow.

  1. Club join-request admin review section (U2 #1) — CRITICAL, a fully-wired backend capability with zero UI; owner-named dead-end flow. Wireframe A.
  2. Dues bank-info card + bulk mark-paid (U8 #1, #2) — HIGH, the core 총무 money loop; owner-named "core-loop friction" + "bank-info dead-end." Wireframe B.
  3. signal_preferences settings screen (U7 #2) — CRITICAL feature gap; owner-named. Wireframe C.
  4. Marketing-push consent split (U7 #3) — HIGH, active compliance ambiguity, small fix (2 rows instead of 1).
  5. OAuth login screen (U7 #1) — CRITICAL launch gate; owner-named. Blocked externally on EXT-11 credentials, but design should be ready to drop in the moment they land.
  6. Unverified-venue badge on the discovery list card (U6 #1) — HIGH, owner-named ("unverified-venue badge honesty").
  7. Tap-to-profile: member rows, club-ranking rows, leaderboard podium (U2 #2, U5 #4) — owner-named across two blocks; low effort, high consistency payoff, same fix shape repeated 3 times.
  8. Records zero-match unified onboarding state (U5 #5) — MEDIUM but high-visibility (the literal first-run state for every early user); explicit Toss-filter violation. Wireframe D.
  9. Leaderboard "내 순위" affordance (U5 #3) — MEDIUM, structurally excludes a growing share of users as the player base grows past 50.
  10. Empty-DM 1:1 CTA (U7 #4) — MEDIUM, owner-named; wires an already-built dormant EmptyState action prop.
  11. Score-dispute visual language + resolution CTA (U4 #1) — pairs with the backend fix; owner-named ("score-dispute discoverability"); already has the right color (live/red) defined in canon, just needs the persistent badge + re-propose action.
  12. Home admin-attention count unification + positive-payment-signal color (U1 #1, #2) — cross-surface trust/consistency, low implementation cost.
  13. Reject-action confirm + button-variant fixes (U4 #2) — HIGH severity but small, 2-file fix; foot-gun prevention on the one action that produces a dispute.
  14. Sudden-death marker + bye/sit-out visibility (U4 #5, #6) — MEDIUM, same "derived state rendered silently" pattern, reuses the tiebreak-superscript precedent.
  15. Payment-hold render gating + header badge submitted-state (U3 #1, #3) — HIGH/LOW, closes a live-during-viewing dead-end CTA.
  16. Diverged region/district filter consolidation (U6 #3) — MEDIUM but the largest single architecture lift in this list (new shared component + 3 migrations); schedule after the above.
  17. Directory empty-state action wiring, agreed-checkmark color, CTA-in-card canon amendments, dues mid-month/0-dues/waive-copy small fixes (U6 #2, U4 #3/#4, U8 #3/ #5/#6, U3 #2) — MEDIUM/LOW, batch these as a cleanup pass once the above ship.

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