U3 — Sessions: Adversarial UI/UX Audit
Status: Archived Date: 2026-07-11 Scope: packages/features/sessions/src/** — create-session wizard + draft-resume, session-detail two-surface IA (BottomCtaBand one-CTA rule), RSVP/waitlist, roster/participants, 정산 (session-payments-screen.tsx), attendance (session-attendance-screen.tsx), UnsettledPoolNotice, host-role surfaces (호스트 대시보드, 참가자 관리, 일정 취소, 공유/수정). Method: direct code read (session-detail-screen.tsx in full, 1576 lines; all screens listed above in full) + grep sweep for canon literals (<Badge variant=, StyleSheet.create) + cross-reference against docs/canon/sessions.md, docs/canon/status-and-recruitment.md, the payments canon in CLAUDE.md, and query-key/invalidation tracing through packages/app/src/presentation/hooks/composites/use-live-session-data.ts + packages/app/src/presentation/hooks/mutations/use-session-payment*.ts. No code modified.
Executive-severity ranking
| # | Finding | Severity |
|---|---|---|
| 1 | Payment-hold gate UI can go stale for up to 5 min during an active live view — countdown disappears but the reserved-payment panel (with live 송금완료/checkout buttons) keeps rendering even after the backing RSVP is auto-cancelled | HIGH |
| 2 | Canon says "never put a CTA inside a card"; 4 screens do it anyway (self-acknowledged as intentional) | MEDIUM (canon drift) |
| 3 | Header status-banner payment badge can't distinguish 결제 대기 (reserved) from 확인 대기 (submitted) — both render SessionPaymentStatusBadge status="pending" | LOW (copy gap) |
| 4 | StyleSheet.create in 4 create-session-wizard files | INFO (already ratchet-tracked) |
1. HIGH — Payment-hold gate UI goes stale mid-view; can outlive its own RSVP
Evidence:
packages/app/src/presentation/hooks/composites/use-live-session-data.ts:170-183— the 10-second focus-gated poll that gives session-detail its "live" feel invalidatessessionKeys.detail,matchKeys.bySession,rsvpKeys.bySession,matchKeys.scoreCallsBySession,tournamentKeys.bySession,guestApplicationKeys.bySession.sessionPaymentKeysis not in this list.packages/app/src/presentation/hooks/queries/use-session-payments.ts:26—useSessionPayments(backinguseMySessionPayment, which backs the screen'sgateState) usesstaleTime: STALE_TIME.standard= 5 minutes (packages/app/src/query-keys.ts:19). Nothing refetches it proactively while the screen stays focused; onlyrefetchOnWindowFocusor a full 5-minute expiry will refresh it.packages/features/sessions/src/session-detail-screen.tsx:195-199—gateStateis computed purely frommyPayment?.status/myPayment?.submittedAt; it has no cross-check againstmyRsvp?.status.session-detail-screen.tsx:1311-1374— the "예약됨 · 참가비 결제" callout (with the 송금 완료 / dev 결제하기 buttons) renders whenevergateState === 'reserved'— full stop, regardless of whether the RSVP behind that hold is still alive.- Per CLAUDE.md's payment canon: the
expire_unpaid_session_holdscron (every 5 min) cancels the RSVP + pre-seeds an EXCUSED attendance record when an idle hold's window elapses.rsvpKeysIS in the 10s poll, somyRsvpcorrectly flips to cancelled within ~10s of the cron firing — but the payment panel doesn't listen to that signal, so it keeps showing the reserved-payment card with live buttons, now referring to a hold whose RSVP is already gone, for up to ~5 more minutes (or until the user leaves and returns / pulls to refresh). - Same staleness cuts the other way: if the host confirms 송금 완료 receipt (
useMarkPaymentPaid, which does correctly invalidatesessionPaymentInvalidationKeys,packages/app/src/presentation/hooks/mutations/use-session-payments.ts:114-119) while the paying member is sitting on session-detail, that member's own screen has no live channel to learn about it — the 10s poll doesn't touch payment keys, so their "reserved"/"submitted" card can visibly disagree with reality for minutes.
Impact: a member watching the countdown hit zero sees the timer text vanish (!holdCountdown.isPast gate at line 1319) but the surrounding "예약됨" panel + live CTAs stay exactly as they were — a dead-end/misleading state precisely of the kind CLAUDE.md's owner-complaint history calls out ("no save/complete button", stale screens). Tapping 송금 완료 on an already-expired-and-cancelled hold is a foot-gun the UI doesn't prevent or even flag.
Fix shape: add sessionPaymentKeys.bySession(sid) (and/or byUser) to the useLiveSessionData 10s invalidation list, OR gate the reserved/submitted panel's render on viewerParticipating (myRsvp?.status === 'confirmed') in addition to gateState, so an already-cancelled RSVP can never keep the payment CTA alive.
2. MEDIUM — Canon "no CTA in a card" vs. 4 shipped screens that do it
Evidence:
docs/canon/components.md:11— unqualified: "Cards are display and navigation only. Never put a CTA inside a card."session-detail-screen.tsx:1104-1106narrows this in a code comment to "Button doesn't sit inside a Card here, so ActionButton is fine (COMP-3 only forbids Button-in-Card)" — i.e. the team already knows the literal rule is broader than what's mechanically enforced (@twomore/no-button-in-cardonly matches raw<Button>JSX, confirmed atpackages/eslint-plugin/rules/no-button-in-card.js).- Four session screens nest
ActionButtondirectly insideCard:session-payments-screen.tsx:87-146—PaymentRow:<Card tone="default" size="sm">wrapping 납부확인/면제ActionButtons.session-attendance-screen.tsx:85-124—ParticipantRow:<Card tone="default" size="sm">wrapping 출석/결석ActionButtons.participants-screen.tsx:69-97—ParticipantSectionrow:<Card tone="default" size="sm">wrapping a 제외ActionButton.pickup-host-dashboard-screen.tsx:315-340— the pending-applications<Card tone="default" size="md">wraps everyApplicationRow, each of which has 승인/거절ActionButtons.
Assessment: this is not drift-by-accident — it is a consistent, working, self-documented pattern across every "row of pending items with per-row mark-done actions" surface in the sessions feature, and functionally these read fine (small pill buttons, clearly scoped to their row). But it directly contradicts the literal canon sentence, is invisible to the mechanical gate, and is exactly the shape of rule the audit is meant to surface. docs/canon/components.md:12's own "≤3 nested affordances, Pressable is the canonical clickable" carve-out would suggest these rows should be Pressable-driven, not ActionButton-in-Card.
Fix shape: either (a) amend docs/canon/components.md to explicitly sanction "row-level ActionButton pairs inside a list-row Card" as a named exception (matching what's already shipped), or (b) migrate the 4 rows above to the Pressable-based nested-affordance pattern. (a) is cheaper and matches actual usage; (b) is a real behavior change across 4 files.
3. LOW — Header payment badge can't show 확인 대기 (submitted) distinctly
Evidence: session-detail-screen.tsx:1083-1096 — the SessionStatusBanner trailing slot renders <SessionPaymentStatusBadge status="pending" size="sm" /> for both gateState === 'reserved' and gateState === 'submitted'. Meanwhile the body callout further down (lines 1311-1374) DOES differentiate — "예약됨" (reserved, with 송금완료/checkout buttons) vs. "제출됨" (submitted, awaiting host confirm, no buttons). So a member who already tapped 송금 완료 still sees the same generic "결제 대기" chip at the top of the screen as before they tapped it.
Root cause: SessionPayment.status (the DB enum) is only pending|paid|waived; submitted is a purely client-derived state from submittedAt, so SessionPaymentStatusBadge (which maps the entity status) structurally can't express it without a new prop. Soft/copy gap, not a data violation — flagging for completeness since the payments canon explicitly calls out sessionPaymentGateState's 4-state taxonomy (free/reserved/ submitted/confirmed) as canonical, and the header chip erases one of the four everywhere it's used.
4. INFO — Ratchet-tracked StyleSheet.create (not new)
create-session-screen.tsx:459, create-session-fields.tsx:44, create-session-step3.tsx:383, create-session-step4.tsx:305 all use StyleSheet.create, violating STYLE-1 literally — but all 4 are already listed in eslint.ratchet.config.mjs (lines ~22-100) with a documented rationale (RN-native Pressable/SafeAreaView/ScrollView primitives that Tamagui can't style-extract). Confirmed real, confirmed already tracked — not a new finding, no action needed beyond the existing ratchet-shrink backlog.
What works (verified, not just assumed)
- SessionInfoModel is the actual single source, not just documented as one.
session-detail-screen.tsx:345-358resolvesbuildSessionInfoModelonce and every downstream card (SessionIdentityCardcost/cancellation,SessionWeatherCardrainRisk) consumes the projection — grepped for ad-hoc re-derivation of cost/eligibility in the sessions package and found none. - The owner's historical "정산/attendance have no save or complete button" complaint (R1) is resolved, not reproduced. Both
session-payments-screen.tsx:491-503andsession-attendance-screen.tsx:312-323end in a realBottomCtaBandcloseout CTA, correctly disabled/labelled by unresolved count (allSettled/unresolvedCount > 0), and both are pure UI acknowledgements (appRouter.back()), not fake buttons. - Payment confirmation authority correctly matches migration 00303:
session-payments-screen.tsx:366gates on!canManageDues && !isSessionHost(host OR dues-manager), not admin-only. UnsettledPoolNotice+partitionSettledMatchPoolare correctly wired end-to-end intomatch-board-screen.tsx:161,359— the client mirrors the server settle-before-play trigger rather than re-deriving eligibility ad-hoc.- Draft-resume is genuinely scoped per-club (
create-session-screen.tsx:118-126,store.setDraftClub) — confirms the 2026-05-27 revival fix is intact, not regressed. - COMP-2 badge-literal compliance is clean: grepped every
<Badge variant="...">in the sessions package — all instances use only the sanctionedneutral/warning/liveliterals (plus one dynamicrefundState-driven variant insession-payments-screen.tsx:109, explicitly commented as COMP-2-safe since it's a runtime value, not a literal). No violations found. BottomCtaBandprecedence is exhaustive and state-aware: traced every branch insession-detail-screen.tsx:559-898(open/locked/in_progress/ completed × host/participant/guest/tier-blocked/cooldown) — exactly one CTA renders per state, refund-ack correctly overrides everything else, no dead/no-op buttons found.- Host≠club-admin pickup flows are fully built, not stubbed:
pickup-host-dashboard-screen.tsxhas real approve/reject + bulk-approve (2+) withuseConfirmgates, correctly permission-gated tosession.createdBy === userId.
Adversarial probes run (that came back clean)
- Traced
routes.clubSession('', sessionId)inpickup-host-dashboard-screen.tsx:227as a suspected clubId-loss bug — false lead:routes.ts:75takes_clubIdunused, route resolves to/sessions/${sessionId}regardless. - Checked free-vs-fee session create/review round-trip (
create-session-step4.tsxtoggle clearscostPerPersonon OFF;create-session-step6.tsx:49review row reflects it) — correct. - Checked host===viewer at every session-status CTA branch — each state branches
isHostdistinctly and correctly (초대하기/경기 시작/경기 보드/ 정산 vs the participant paths). - Checked settle-before-play + 0-match/social-session attendance auto-recording claim against the attendance screen's own state hint (
session-attendance-screen.tsx:262) — consistent with the documented migration 00384 behavior; no UI contradiction found.
Canon violations summary
- Payment-gate render (§1) doesn't cross-check
viewerParticipating— arguably a violation of "surfaces never re-derive gate values ad-hoc" in spirit (it partially re-derives by omission: it trusts a payment-only signal for a state that also depends on RSVP liveness). - §2 — literal canon text ("never CTA in a card") vs. 4 shipped screens; flagged as a doc/code reconciliation item, not a functional bug.