Skip to content

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

#FindingSeverity
1Disputed-match escalation is entirely dead code — matches.score_status is never written to 'disputed' anywhere in the codebaseCRITICAL (P0)
2Team-slot swap mutation has no server-side status gate — a completed/live match's roster can be silently rewritten via direct APICRITICAL (P0)
3Referee-transfer feature (5 mutation hooks + RPCs) is fully orphaned — deliberately de-rendered from UI, left wiredHIGH
4Reject-score / reject-correction buttons fire a dispute directly with no confirm, and misuse the destructive-button conventionHIGH
5Consensus "agreed" checkmark renders in the bounded palette's live (red/error) tone — no success option available to featuresMEDIUM
6live-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 CardMEDIUM
7Sudden-death is configurable + has its own signal type, but has zero visual treatment anywhere on the scorecardMEDIUM
8Bye/sit-out players are surfaced only inside the live-round-editor sheet — the primary round view shows courts onlyMEDIUM
9Two divergent score-entry UX patterns for the same task (raw TextInput vs stepper) across match-board vs scorecardMEDIUM
10Score correction has no audit trail — original score is overwritten with no history, no before/after in the UILOW-MEDIUM
11useMatchBoard — an orphaned 513-line hook, unexported, duplicating live match-board logic via a stale code path missing the 00384 settle-before-play gateLOW (dead code / landmine)
12match-board-helpers.ts StyleSheet.create for native TextInput bridgingINFO (already ratchet-tracked)

1. CRITICAL — Disputed-match escalation is entirely dead code

Evidence:

  • The CHECK constraint on matches.score_status permits 'unverified' | 'verified' | 'disputed' (supabase/migrations/00057_match_verification.sql:12), and every downstream UI branch assumes 'disputed' is a reachable state: findDisputedByUser filters .eq('score_status', 'disputed') (packages/app/src/adapters/supabase/match.supabase.ts:245-261), useMyDisputedMatches wraps 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 canonical ScoreStatusBadge on match-card.tsx:207 has 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 sets score_status = 'disputed'.
  • reject_call (latest def 00385_alert_model_wiring.sql:376-424) — the only user-reachable "I disagree with this score" action — does two things: DELETE FROM match_score_calls and emit a match_score_disputed signal (a generic notification-ledger row). It never touches matches.score_status.
  • Net effect: findDisputedByUser always returns [], HomeDisputedAlertCard can never render (its own if (disputedMatches.length === 0) return null always fires), and the ScoreStatusBadge "disputed" branch on match-card.tsx:207 is unreachable in practice.
  • Mitigating detail (checked directly): the underlying match_score_disputed signal IS real and DOES route through the general notification center — signal-routes.ts:20-22 maps the 'matchup' category to routes.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:350 mounts <SwapSheet> only inside {match.status === 'scheduled' && (...)}.
  • matches_update RLS policy (supabase/migrations/00013_payment_history_and_match_director_policies.sql:76-89, the latest/only redefinition — superseding 00011) authorizes UPDATE for any active club owner/admin/match_director with no status predicate at all. Contrast matches_delete in the same file family, which DOES gate on status='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_scoring trigger (00067_field_protection_triggers.sql:32-39) protects team1_score, team2_score, tiebreak_score, status, started_at, completed_at, verified_by, score_status, participant_ids from plain authenticated writes — 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.ts all 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" across packages/features and apps0 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-51 documents a referee-name display via nameMap that was supposed to exist, but the prop is destructured as nameMap: _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}>, and handleReject (:67-69) calls rejectCall.mutate(...) directly with no useConfirm gate anywhere in the file (no useConfirm import at all).
  • score-correction-sheet.tsx:173-181 reproduces the identical pattern for rejecting a proposed score correction.
  • Canon (docs/canon/components.md): "Trigger → danger, confirm → destructive; never swap them" — destructive is "reserved for the CONFIRM step," while a first-tap trigger should be danger. Both files use destructive as 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 a useConfirm warning... 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's live variant resolves to backgroundColor: '$badgeErrorBg' / color: '$badgeErrorText' (packages/ui/src/badge.tsx:42,77) — the same red/error tone used for "라이브"/urgent status everywhere else in the app (MatchStatusBadge in-progress, SessionStatusBadge live).
  • Root cause: @twomore/bounded-variant-enums (COMP-2) restricts feature/presentation code to exactly neutral | 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 footer ActionButton ("경기 종료"), 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 inside RoundSectionList's card-bottom) reproduces the visually-identical rounded/bordered surface via a plain styled YStack instead of Card, 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_death exists 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 at signal-copy.ts:46) that fires a notification.
  • grep for sudden_death/suddenDeath across every scorecard render component (match-scoreboard.tsx, live-row.tsx, completed-row.tsx, draws-row.tsx, round-header-row.tsx) → 0 hits. MatchScoreboard's ScoreCell (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 inside live-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 — despite rotation.rules.ts's selectBench logic actively benching players every round when players.length isn't a multiple of playersPerCourt.

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 numeric RNTextInput fields, typed by hand, maxLength={3}.
  • Scorecard live tab (score-edit-sheet.tsx:70-110): increment/decrement Stepper controls 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 overwrite matches.team1_score/team2_score on final agreement — no original_score/corrected_score/history table exists anywhere in the migrations (grepped, 0 matches).
  • score-correction-sheet.tsx shows 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 Sheet usage 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 render ModalPanel/ModalPanel.Overlay/ModalPanel.Frame from @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 (partitionSettledMatchPool client-side gate feeding eligiblePlayerIds into both round generation and the swap picker pool, PLUS a server-side trg_matches_settled_participants trigger as backstop) with a genuinely clear exclusion-reason UI: UnsettledPoolNotice shows 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 swapanalyzeSubstitution computes match-count deltas, same-round conflicts, not-confirmed status, and repeat-partner/opponent counts, surfaced per changed slot in SwapSheet before 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-wiredmatch-rules-editor.tsx:120-125 has 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's SetCell[] 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; useLiveSessionData gates its manual poll via useIsFocused with a documented rationale for why it can't use per-query refetchInterval (shared-query contamination across other screens).
  • Tiebreak display + input symmetry are correct and mirror the DB constraint (match-card.tsx:109-112 requires both-or-neither tiebreak input, matching migration 00324; ScoreCell renders 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)

  1. 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.
  2. 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).
  3. 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).
  4. 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).
  5. 0-matches state → EmptyState with CTA confirmed on match-board, rounds-tab, live-tab.
  6. 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).
  7. 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.
  8. 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.tsx card-variant footer button, a self-documented exception, plus a sibling variant that dodges the rule by not being a literal Card (#6).
  • "Trigger → danger, confirm → destructive; never swap them" + destructive-CTA-must-confirm rulescore-call-sheet.tsx / score-correction-sheet.tsx reject buttons (#4).
  • Bounded Badge variant enum (COMP-2, features restricted to neutral|warning|live|surface) has no success option, 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 XxxStatusBadge wrappers 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)

  1. Wire reject_call to actually set score_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.
  2. Add a status predicate to matches_update RLS (or move team-slot writes behind a SECURITY DEFINER RPC) — closes the swap-after-completion data-integrity gap. ~1 migration, mirrors the existing matches_delete/forfeit_session precedents.
  3. 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.
  4. Reject-action confirm + button-variant fix — swap Button variant="destructive" triggers to ActionButton action="danger" + wrap in useConfirm, in both score-call-sheet.tsx and score-correction-sheet.tsx. Trivial, 2 files.
  5. 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.
  6. Score-entry pattern unification (Stepper vs TextInput) and score-correction audit trail — smaller, can be scheduled independently.

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