U4 — Matches (경기): Adversarial UI/UX Audit
Status: Archived Date: 2026-07-11 Scope: packages/features/sessions/src/{match-board-screen,match-card,match-board-helpers,swap-sheet,add-round-sheet,live-round-editor-sheet,scorecard/**}.tsx, packages/app/src/domain/rules/{rotation,substitution}.rules.ts, packages/app/src/presentation/components/sessions/match-scoreboard.tsx, packages/app/src/presentation/hooks/{composites/use-live-session-data,queries/use-match-board,mutations/use-{submit-score,update-match-teams,update-live-match-score,call-match,agree-to-call,reject-call,propose/accept/decline-referee-transfer,release-referee}}.ts, referee-transfer/score-call/score-correction RPCs (migrations 00068/00149/00152/00236/00308/00317/00385), home-disputed-alert-banner.tsx. Method: 3 parallel Sonnet research passes (match-board/scoreboard/live-editor; swap/rotation/substitution/settle-before-play; score-call consensus/dispute/referee-transfer) cross-validated directly against migrations, RLS policies, and triggers for every CRIT/HIGH claim (grepped every score_status = write site across all 385+ migrations; read matches_update/matches_delete RLS + protect_matches_scoring trigger definitions; independently re-confirmed zero consumers for the referee-transfer hooks). No code modified.
Executive-severity ranking
| # | Finding | Severity |
|---|---|---|
| 1 | Disputed-match escalation is entirely dead code — matches.score_status is never written to 'disputed' anywhere in the codebase | CRITICAL (P0) |
| 2 | Team-slot swap mutation has no server-side status gate — a completed/live match's roster can be silently rewritten via direct API | CRITICAL (P0) |
| 3 | Referee-transfer feature (5 mutation hooks + RPCs) is fully orphaned — deliberately de-rendered from UI, left wired | HIGH |
| 4 | Reject-score / reject-correction buttons fire a dispute directly with no confirm, and misuse the destructive-button convention | HIGH |
| 5 | Consensus "agreed" checkmark renders in the bounded palette's live (red/error) tone — no success option available to features | MEDIUM |
| 6 | live-tab.tsx embeds a CTA inside a Card — a self-documented COMP-3 exception, plus a sibling workaround that dodges the rule by not technically being a Card | MEDIUM |
| 7 | Sudden-death is configurable + has its own signal type, but has zero visual treatment anywhere on the scorecard | MEDIUM |
| 8 | Bye/sit-out players are surfaced only inside the live-round-editor sheet — the primary round view shows courts only | MEDIUM |
| 9 | Two divergent score-entry UX patterns for the same task (raw TextInput vs stepper) across match-board vs scorecard | MEDIUM |
| 10 | Score correction has no audit trail — original score is overwritten with no history, no before/after in the UI | LOW-MEDIUM |
| 11 | useMatchBoard — an orphaned 513-line hook, unexported, duplicating live match-board logic via a stale code path missing the 00384 settle-before-play gate | LOW (dead code / landmine) |
| 12 | match-board-helpers.ts StyleSheet.create for native TextInput bridging | INFO (already ratchet-tracked) |
1. CRITICAL — Disputed-match escalation is entirely dead code
Evidence:
- The CHECK constraint on
matches.score_statuspermits'unverified' | 'verified' | 'disputed'(supabase/migrations/00057_match_verification.sql:12), and every downstream UI branch assumes'disputed'is a reachable state:findDisputedByUserfilters.eq('score_status', 'disputed')(packages/app/src/adapters/supabase/match.supabase.ts:245-261),useMyDisputedMatcheswraps it,HomeDisputedAlertBanner/HomeDisputedAlertCard(packages/features/home/src/cards/home-disputed-alert-banner.tsx:71-146) render a top-of-feed escalation card when that query returns rows, and the canonicalScoreStatusBadgeonmatch-card.tsx:207has a dedicated "disputed" render branch. - Grepping every
score_status =write site across ALL migrations (00068,00103,00149,00152,00154,00194,00236,00317) shows exactly one transition ever performed:'unverified' → 'verified'(agree/verify paths). No migration ever setsscore_status = 'disputed'. reject_call(latest def00385_alert_model_wiring.sql:376-424) — the only user-reachable "I disagree with this score" action — does two things:DELETE FROM match_score_callsand emit amatch_score_disputedsignal (a generic notification-ledger row). It never touchesmatches.score_status.- Net effect:
findDisputedByUseralways returns[],HomeDisputedAlertCardcan never render (its ownif (disputedMatches.length === 0) return nullalways fires), and theScoreStatusBadge"disputed" branch onmatch-card.tsx:207is unreachable in practice. - Mitigating detail (checked directly): the underlying
match_score_disputedsignal IS real and DOES route through the general notification center —signal-routes.ts:20-22maps the'matchup'category toroutes.scorecard(sessionId). So a rejecter's counterpart gets a bell notification that deep-links to the scorecard. What's dead is specifically: (a) the dedicated, more-prominent home-feed escalation banner this was explicitly built for, (b) any persistent visual marker of "this match is disputed" once you land back on the scorecard (score_status silently stays whatever it was), and (c) any resolution flow — after a reject, the match just reverts to awaiting a fresh score call, with no record of the dispute anywhere in the match/session UI.
Impact: this is the exact "half-built screen, dead-end" failure pattern flagged as a standing owner complaint (see U2 finding #1, join-request approval) — a feature that reads as fully shipped (banner component, query hook, badge branch, i18n copy all exist and are wired end-to-end) but can never fire because the one field it filters on is never written by the one action meant to produce it. A user whose match gets disputed sees, at best, a transient bell notification; there's no way for them or a host to see "this match's score is in dispute" anywhere they'd naturally look (match card, scorecard, match board).
Fix shape: either (a) have reject_call also UPDATE matches SET score_status = 'disputed' WHERE id = p_match_id (and clear it back to 'unverified' when a fresh call is proposed), which makes findDisputedByUser/HomeDisputedAlertBanner/the badge branch all light up correctly with no client changes, or (b) if the intent was signal-only (no persistent state), remove the dead query/banner/badge branch and rely purely on the notification-center path. (a) is almost certainly correct — the banner and badge were clearly built assuming persistent state existed.
2. CRITICAL — Team-slot swap has no server-side status gate
Evidence:
- The UI gate is client-only:
match-card.tsx:350mounts<SwapSheet>only inside{match.status === 'scheduled' && (...)}. matches_updateRLS policy (supabase/migrations/00013_payment_history_and_match_director_policies.sql:76-89, the latest/only redefinition — superseding00011) authorizesUPDATEfor any active clubowner/admin/match_directorwith no status predicate at all. Contrastmatches_deletein the same file family, which DOES gate onstatus='scheduled'(00011_rls_indexes_triggers.sql:334) — the asymmetry shows the status check was consciously added to one policy and not the other.protect_matches_scoringtrigger (00067_field_protection_triggers.sql:32-39) protectsteam1_score, team2_score, tiebreak_score, status, started_at, completed_at, verified_by, score_status, participant_idsfrom plainauthenticatedwrites — but the four team-slot columns (team1_player1_id,team1_player2_id,team2_player1_id,team2_player2_id) are not in that protected list.updateMatchTeams(packages/app/src/adapters/supabase/match.supabase.ts:191-215), the adapter SwapSheet calls through, is a plain client.update({team1_player1_id, ...}).eq('id', matchId)— no RPC, no status filter, no server-side re-check.
Impact: any club admin/match_director (a role that exists specifically for match management, so this isn't a hypothetical attacker — it's the normal power-user role) can call updateMatchTeams against a completed or in_progress match id — via a stale UI state, a race, or trivially via devtools/direct API — and silently rewrite who played on which team AFTER the match is scored. Compare 00310_forfeit_auto_substitute.sql's own design note (quoted in-repo): "Client-side substitution would corrupt participant-scoped reads" is exactly why forfeit substitution was built as a SECURITY DEFINER RPC instead of a plain client update — the same reasoning applies here but wasn't applied to the manual swap path. Rewriting team slots post-completion corrupts ELO/rating attribution (apply_match_ratings reads these columns) and match-history participant-scoped queries with no audit trail, for matches players already believe are final.
Fix shape: add a status = 'scheduled' (or status IN ('scheduled','in_progress') if live swaps are intended) predicate to matches_update's USING clause, mirroring matches_delete; alternatively move updateMatchTeams behind a SECURITY DEFINER RPC that re-validates status server-side (matching the forfeit_session precedent already in the codebase).
3. HIGH — Referee-transfer feature fully orphaned (deliberately de-rendered, hooks left live)
Evidence:
use-propose-referee-transfer.ts,use-accept-referee-transfer.ts,use-decline-referee-transfer.ts,use-release-referee.ts,use-assign-referee.tsall exist, wrap real RPCs, and are exported from@twomore/app's public index (packages/app/src/index.ts:464,490,527,529).grep -rn "useProposeRefereeTransfer\|useAcceptRefereeTransfer\|useDeclineRefereeTransfer\|useReleaseReferee"acrosspackages/featuresandapps→ 0 hits anywhere except the hook files and the barrel export (independently re-confirmed, not just the sub-agent's claim).- This is confirmed intentional, not an oversight — explicit in-code comments:
live-row.tsx:14"Referee UI removed — backend hooks stay untouched but are not rendered here";draws-row.tsx:31(same);live-tab.tsx:165"Referee UI removed — canCall falls back to participant OR session host."match-profiles-sheet.tsx:47-51documents a referee-name display vianameMapthat was supposed to exist, but the prop is destructured asnameMap: _nameMap(line 66) and never read — a dead comment matching dead code.
Impact: a full backend capability (propose/accept/decline a referee handoff, release referee) is live in the schema and mutation layer with zero product surface — no pending-transfer badge, no accept/decline UI, anywhere. This isn't corrupting data (unlike #2), but it's a large, confusing half-feature: the next engineer who greps for "referee" finds 5 wired hooks + RPCs and reasonably assumes the feature ships, then discovers nothing renders it. Per the project's "single source of truth" + "no half-built screens" standing rules, this needs an explicit decision (finish the UI, or delete the dead hooks/RPCs) rather than sitting as silent dead weight.
Fix shape: either build the referee pending-transfer UI (badge + accept/decline row, likely on live-row.tsx/draws-row.tsx where the comments already mark the spot) or remove the 5 orphaned hooks + their RPC wiring if referee assignment is being retired in favor of "session host is always the de facto scorer" (which canCall's fallback already suggests is the current de facto model).
4. HIGH — Reject buttons fire a dispute with no confirm, misusing the destructive-button convention
Evidence:
score-call-sheet.tsx:175-184— the 거부 (reject) button is a raw<Button variant="destructive" ... onPress={handleReject}>, andhandleReject(:67-69) callsrejectCall.mutate(...)directly with nouseConfirmgate anywhere in the file (nouseConfirmimport at all).score-correction-sheet.tsx:173-181reproduces the identical pattern for rejecting a proposed score correction.- Canon (
docs/canon/components.md): "Trigger → danger, confirm → destructive; never swap them" —destructiveis "reserved for the CONFIRM step," while a first-tap trigger should bedanger. Both files usedestructiveas the ONE-TAP trigger itself, with no confirm step at all. Canon also states (session-surface conventions, general rule): "ANY CTA... whose action is destructive or mostly-irreversible... MUST route through auseConfirmwarning... before it fires the mutation — the button NEVER calls the mutation directly." - This compounds finding #1: rejecting a score call/correction is the ONE action in the whole match lifecycle that produces a "dispute" — and per finding #1, that dispute then has no visible resolution surface. A single un-confirmed tap fires an action whose only visible consequence is a bell notification to the other participant, with no in-app trace of the disagreement.
Impact: low-friction accidental disputes (a stray tap during a laggy connection, or on a small phone screen where "동의"/"거부" sit side-by-side at flex={1} each) with no undo and no visible resolution path.
Fix shape: route both reject actions through useConfirm({ style: 'destructive' }) before firing the mutation, and swap the inline trigger button to ActionButton action="danger" size-appropriate, matching every other destructive-trigger elsewhere in the app (forfeit, delete, remove).
5. MEDIUM — "Agreed" checkmark renders in the live (red/error) badge tone
Evidence:
score-call-sheet.tsx:162-166: a participant who has agreed renders<Badge variant="live" label="✓" size="xs" />; one who hasn't renders<Badge variant="neutral" label={ls.awaitingResponse} size="xs" />.Badge'slivevariant resolves tobackgroundColor: '$badgeErrorBg'/color: '$badgeErrorText'(packages/ui/src/badge.tsx:42,77) — the same red/error tone used for "라이브"/urgent status everywhere else in the app (MatchStatusBadgein-progress,SessionStatusBadgelive).- Root cause:
@twomore/bounded-variant-enums(COMP-2) restricts feature/presentation code to exactlyneutral | warning | live | surface(docs/canon/components.md) —success(green) exists in the primitive's variant set but is NOT in the bounded whitelist available to feature code, so there's no correct-color option to reach for here.
Impact: the ✓-for-agreed cue is visually coded as an alert/error (red) rather than a success/confirmation (green), which is the opposite of its meaning — a user scanning the consensus rows for "who's still holding this up" has to read the checkmark glyph rather than get a fast color read, undermining the consensus-legibility design this screen otherwise does well (see "Going well" below).
Fix shape: either widen the features-bounded Badge variant set to include success for this exact case (agree/consensus rows recur — score-call, score-correction, and any future all-agree flow), or swap to a non-Badge treatment (e.g. a green check Icon + no badge chrome) that doesn't route through the restricted enum.
6. MEDIUM — CTA embedded inside a Card on the live scorecard (self-documented COMP-3 exception + a rule-dodging sibling)
Evidence:
live-tab.tsx:300-324(LiveMatchRow, default "card" variant):<Card tone="default" size="none">wraps the match body AND a footerActionButton("경기 종료"), with an explicit in-code comment: "COMP-3 exception: live-match cards behave as the per-match control surface — the Card IS the action target."- The sibling "embedded" variant (
live-tab.tsx:264-297, used insideRoundSectionList's card-bottom) reproduces the visually-identical rounded/bordered surface via a plain styledYStackinstead ofCard, with its own comment: "the no-button-in-card rule doesn't apply here" because it's technically not a<Card>component (:280-282).
Impact: this is a real, deliberate carve-out of "Cards are display and navigation only. Never put a CTA inside a card" (docs/canon/components.md), acknowledged in-code but not reflected in the canon doc itself — and the embedded variant's workaround (swap Card for a YStack that looks identical) is a letter-vs-spirit dodge that undermines the rule's enforceability: a reviewer grepping for <Card> + ActionButton would miss the embedded variant entirely despite it being visually and functionally the same violation.
Fix shape: either (a) formally document this exception in docs/canon/components.md's Cards section (a live-match row IS its own control surface, not a navigation card — a legitimate, bounded exception worth naming explicitly so it doesn't read as drift), or (b) if the "no CTA in card" rule is meant to be absolute, redesign both variants to move 경기 종료 to a non-Card container (mirroring the honest embedded-variant naming, applied consistently and not as a workaround).
7. MEDIUM — Sudden-death has zero visual treatment on the scorecard
Evidence:
sudden_deathexists only as: a rules-config value (match-rules-editor.tsx:257,279,292-309,onTimeUp: 'sudden_death' | 'current_score_stands') and a signal type (match_sudden_death_needed,signal.entity.ts:65, copy atsignal-copy.ts:46) that fires a notification.grepforsudden_death/suddenDeathacross every scorecard render component (match-scoreboard.tsx,live-row.tsx,completed-row.tsx,draws-row.tsx,round-header-row.tsx) → 0 hits.MatchScoreboard'sScoreCell(match-scoreboard.tsx:296-323) renders games + a tiebreak superscript only — no prop or branch exists for "this game/set was decided by sudden death."
Impact: per the audit's explicit dimension ("tie/tiebreak display... does sudden death render distinctly or does it look identical to a normal score, silent ambiguity?") — confirmed: a sudden-death-decided match is pixel-identical to a normally-completed one. A player checking the scorecard after a time-capped session with onTimeUp: 'sudden_death' has no way to tell, from the scorecard alone, whether the final score reflects a full game or a single sudden-death point.
Fix shape: thread a decidedBySuddenDeath (or similar) flag onto the completed match/set data and render a small distinguishing marker (an icon + tooltip, or a muted "sudden death" caption) next to the final score, parallel to how the tiebreak superscript already distinguishes a tiebreak-decided set.
8. MEDIUM — Bye/sit-out players are invisible on the primary round view
Evidence:
- The bench/odd-count UI (
liveEditBench/liveEditNoBench) exists only insidelive-round-editor-sheet.tsx:241-256, the ModalPanel opened from an explicit edit action. - The primary match-board round view (
match-board-screen.tsx:444-458) renders courts only — no bench/sit-out section — despiterotation.rules.ts'sselectBenchlogic actively benching players every round whenplayers.lengthisn't a multiple ofplayersPerCourt.
Impact: in any odd-headcount session (a common real-world case — pickup sessions rarely land on exact multiples of 4), the player(s) sitting out a given round have no visible confirmation of that on the screen they'd naturally check ("why am I not in a match this round?") — they'd have to open the live-round-editor sheet (a host/admin-facing edit surface) to find out, which is the wrong audience for what is a participant-facing question.
Fix shape: surface a lightweight "쉬는 중" (sitting out) row/chip on the primary round view whenever benchCount > 0, sourced from the same selectBench output already computed for round generation — doesn't need the full editor sheet's affordances, just visibility.
9. MEDIUM — Two divergent score-entry UX patterns for the same task
Evidence:
- Match-board (
match-card.tsx:231-243,264-276): raw numericRNTextInputfields, typed by hand,maxLength={3}. - Scorecard live tab (
score-edit-sheet.tsx:70-110): increment/decrementSteppercontrols for the same "enter this match's score" task.
Impact: the same conceptual action (record a match score) has two different input mechanics depending on which of the two match-viewing surfaces (match-board vs scorecard) the user happens to be on — a minor but real consistency gap; a user who learns one pattern has to relearn the other.
Fix shape: unify on one input mechanic (the Stepper is more thumb-friendly and less error-prone for small integer scores; the TextInput allows faster entry for larger values) — worth a deliberate choice rather than accidental divergence, likely Stepper given match scores are small bounded integers.
10. LOW-MEDIUM — Score correction has no audit trail
Evidence:
propose_score_correction/agree_to_correction(00308_completed_match_score_correction.sql:31-98,108-172) directly overwritematches.team1_score/team2_scoreon final agreement — nooriginal_score/corrected_score/history table exists anywhere in the migrations (grepped, 0 matches).score-correction-sheet.tsxshows the proposed corrected score only during the pending call (:139-169) — once applied, there's no UI anywhere to see "this match's score was corrected from X to Y."
Impact: for a money/rating-adjacent record (scores feed apply_match_ratings/ELO), a corrected match is indistinguishable from one that was always scored that way — no accountability trail if a correction itself later turns out wrong, and no way for a curious player to understand why their ELO moved after the fact.
Fix shape: minimally, log corrections into an existing generic audit mechanism (or a lightweight match_score_corrections history row) and surface a small "정정됨" marker + tap-to-see-original on the completed match card, mirroring the tiebreak-superscript pattern of "make the derived state visible, not silent."
11. LOW (dead code / landmine) — Orphaned useMatchBoard hook
Evidence: packages/app/src/presentation/hooks/queries/use-match-board.ts (513 lines) is not exported from @twomore/app's index and has zero consumers anywhere in the repo (confirmed independently). It duplicates match-board-screen.tsx's round-generation/data logic through a materially different path (its own preset system, useMatchBoardStore, showToast) that does not implement partitionSettledMatchPool/the 00384 settle-before-play exclusion the live screen has.
Impact: currently inert, but a landmine — if ever rewired (e.g. during a refactor that "notices" this hook and starts using it), it would silently reintroduce a payment/settlement bug that was already fixed in the actively-used path.
Fix shape: delete the file, or if it's held for a planned refactor, add a top-of-file @deprecated — do not wire, missing 00384 settle-before-play gate comment so a future edit doesn't resurrect it blind.
12. INFO — match-board-helpers.ts StyleSheet.create (already ratchet-tracked)
match-board-helpers.ts:82-130 defines StyleSheet.create for scoreInput/tiebreakInput/composerStyles, explicitly commented as required because raw RNTextInput/Pressable don't accept Tamagui $tokens. This file is already in eslint.ratchet.config.mjs's no-stylesheet-create waiver list — not a fresh finding, listed for completeness only, same posture as U2 finding #11.
Going well (for balance — things checked and found correct)
- Zero raw Tamagui
Sheetusage anywhere in U4 scope — all 8 overlay components checked (live-round-editor-sheet,add-round-sheet,swap-sheet,score-edit-sheet,score-call-sheet,score-correction-sheet,match-profiles-sheet,match-rules-editor-sheet) import and renderModalPanel/ModalPanel.Overlay/ModalPanel.Framefrom@twomore/ui. The codex Sheet→ModalPanel migration fully held for this block. - Settle-before-play pool exclusion is well-designed and non-silent — dual-layer defense (
partitionSettledMatchPoolclient-side gate feedingeligiblePlayerIdsinto both round generation and the swap picker pool, PLUS a server-sidetrg_matches_settled_participantstrigger as backstop) with a genuinely clear exclusion-reason UI:UnsettledPoolNoticeshows a warning dot + count + a CTA routing straight to 정산, and raw Postgres exceptions are mapped to a friendly message (settleBeforePlayFriendlyMessage,match-board-helpers.ts:71-74). This is the audit's dimension-5 question answered correctly. - Consensus legibility (score-call, score-correction) is genuinely good apart from the color issue in #5 — both sheets render one row per participant with their real name + an explicit agreed/awaiting state, not just an opaque count.
- Substitution preview is a real fairness check, not a blind swap —
analyzeSubstitutioncomputes match-count deltas, same-round conflicts, not-confirmed status, and repeat-partner/opponent counts, surfaced per changed slot inSwapSheetbefore the user confirms. - Rotation generator (
rotation.rules.ts) is pure client-side logic with no I/O imports, and correctly benches players via least-recently-benched selection on odd headcounts (test-covered for 2-3 player edge cases). - Multi-set (Phase C) is cleanly HELD, not half-wired —
match-rules-editor.tsx:120-125has a commented-out multi-set selector with an explicit rationale (server-side set storage doesn't exist yet, so showing the option would lie to the user);MatchScoreboard'sSetCell[]prop is architecturally ready but every call site passes exactly one cell today — nothing "breaks" with multi-set data because the schema has no field to hold it in the first place. - Data wiring is clean — every mutation/query hook reviewed routes through
@/registry-equivalent app-internal paths (no direct Supabase imports from feature code); invalidation after submit-score/update-teams/live-score-update is correctly scoped;useLiveSessionDatagates its manual poll viauseIsFocusedwith a documented rationale for why it can't use per-queryrefetchInterval(shared-query contamination across other screens). - Tiebreak display + input symmetry are correct and mirror the DB constraint (
match-card.tsx:109-112requires both-or-neither tiebreak input, matching migration 00324;ScoreCellrenders the superscript only on the losing side). - Empty/loading/error states are handled consistently across match-board, spectator-scorecard, rounds-tab, and live-tab (0-matches EmptyState with CTA, skeleton-during-load, retry-on-error).
Adversarial probes run (explicit list)
- Odd player counts (2, 3 players; doubles vs singles) → traced through
rotation.rules.test.ts; bench logic degrades safely (0-court / all-benched case tested). 0/1-player pools untested but arithmetic (Math.floor) suggests safe — not verified end-to-end. - Swap-after-match-completion → traced UI gate (client-only) through RLS policy definitions and the field-protection trigger; found the CRIT server-side gap (#2).
- Forfeit mid-round → not found at per-match granularity anywhere in U4 scope; forfeit exists only at whole-session level (
forfeit_session, session-detail screen, out of this block's scope). - Multi-set data existing today → confirmed no schema field exists to hold a second set's score, so nothing silently truncates; the editor UI is honest about the gap (HELD, not lied about).
- 0-matches state → EmptyState with CTA confirmed on match-board, rounds-tab, live-tab.
- Disputed-match end-to-end trace (reject_call → score_status → query filter → banner render) → found the CRIT dead-state bug (#1), then verified the fallback notification-center path still works (mitigating detail, not a full fix).
- Referee-transfer hook consumer search → independently re-ran the grep myself (not just trusting the sub-agent) and confirmed 0 consumers outside the hook files + barrel export.
- Settle-before-play exclusion clarity → traced the full client+server chain and confirmed the exclusion reason IS surfaced (positive finding, not a gap — this was the audit's explicit dimension-5 ask).
Canon violations summary
- "Cards are display and navigation only. Never put a CTA inside a card." (
docs/canon/components.md) —live-tab.tsxcard-variant footer button, a self-documented exception, plus a sibling variant that dodges the rule by not being a literalCard(#6). - "Trigger → danger, confirm → destructive; never swap them" + destructive-CTA-must-confirm rule —
score-call-sheet.tsx/score-correction-sheet.tsxreject buttons (#4). - Bounded
Badgevariant enum (COMP-2, features restricted toneutral|warning|live|surface) has nosuccessoption, forcing a semantic color mismatch for the one recurring "agreed" consensus pattern (#5) — worth a canon amendment, not a code-only fix. - "Backend-backed data never renders as dead text" / no-half-built-screens spirit (CLAUDE.md standing owner complaint, same pattern as U2 #1) — the disputed-match banner (#1) and the referee-transfer feature (#3) are both fully-wired backend capabilities with a dead or missing UI consumer.
- Everything else audited (ModalPanel-vs-Sheet, canonical
XxxStatusBadgewrappers for match/score status, registry/port wiring,useFocusedEffect-equivalent polling gate, Pressable primitive usage, FeedList/virtualization not applicable here) was found compliant across this scope.
Improvement candidates (design-spec seeds for the synthesis pass)
- Wire
reject_callto actually setscore_status = 'disputed'(and clear it on a fresh call) — the single highest-leverage fix in this block: it makes the already-built banner, query, and badge branch all correct with zero client changes. ~1 migration. - Add a status predicate to
matches_updateRLS (or move team-slot writes behind a SECURITY DEFINER RPC) — closes the swap-after-completion data-integrity gap. ~1 migration, mirrors the existingmatches_delete/forfeit_sessionprecedents. - Referee-transfer feature decision — either build the pending-transfer UI (the comments already mark exactly where) or delete the 5 orphaned hooks + RPC wiring. Needs an owner call on whether referee assignment is still a supported concept given
canCall's host-fallback. - Reject-action confirm + button-variant fix — swap
Button variant="destructive"triggers toActionButton action="danger"+ wrap inuseConfirm, in bothscore-call-sheet.tsxandscore-correction-sheet.tsx. Trivial, 2 files. - Sudden-death visual marker + bye/sit-out visibility on the primary round view — both are "derived state rendered silently" gaps, same shape as the tiebreak-superscript pattern already solved elsewhere; reuse that precedent.
- Score-entry pattern unification (Stepper vs TextInput) and score-correction audit trail — smaller, can be scheduled independently.