Skip to content

Visual design audit vs industry practice

Status: Accepted · Date: 2026-09-06 · Purpose: owner-requested inspection of the shared primitives and the flat detail pages against Apple HIG, Material 3, WCAG 2.2, and Toss/Kakao/Naver conventions. The top-15 list drives the fix pass.

Scope covered: packages/ui/src primitives (tokens, text, button, badge, list/fact-row, section-header(+metrics), stat-tile/strip, cell-row, well/callout/banner, media-hero, type-hero, action-icon-row, bottom-cta-band, app-header, collapsing-top-bar, pill-nav, segmented-tabs, avatar-bubble, empty-state, skeleton*, icon, box-sizes, pressable, modal-panel), classic-grass.theme.ts + tamagui.ts semantic mapping, and the 9 flat detail screens (session-detail + sections, directory-venue-detail/**, venue-detail, match-board, record-detail, profile/public-profile, join-review-detail, public-club-profile, post-detail).

Method note: this codebase carries an unusually large amount of in-repo audit history already (owner rulings, "seam-audit," "affordance-law," dated fixes). Most obvious defects have already been found and fixed by prior passes. My findings below are either (a) contrast ratios nobody had actually computed, (b) drift between a component's own documented contract and its shipped default, or (c) a documented landmine that a later screen re-introduced.


1. Type scale and hierarchy

SevLocationFindingPractice / sourceFix
Lowtext.tsx:11-2611 roles, sizes 10/12/13/14/16/17/20/24/28/36/48px. On one screen (e.g. session-detail) up to 6 sizes render simultaneously (24 hero title, 20 StatTile value, 17 SectionHeader flat title, 14 body/list titles, 13 meta, 12 badges).M3 type scale recommends ≤5–7 roles visible per screen; Toss keeps hero/section/body/meta as 4 tiers.Acceptable as-is — each size maps to one unambiguous hierarchy tier, not decorative variation. No action needed; flagged only because it's at the upper edge.
Lowfact-row.tsx:84-97FactRow's value text has no tabularNums option (unlike StatTile/StatStrip, which default it true). Numeric facts (fees, counts) can jitter width on live updates.Toss/Kakao apps set tabular figures on any changing numeral (WCAG isn't the source here — this is a Toss convention cue).Add a tabularNums?: boolean prop to FactRow, default false (most facts are static), opt-in for money/rate rows.
Medheader-icon-button.tsx:22,44,54Doc: "Defaults to 1.8 strokeWidth (vs. Lucide's default 2) so the glyph stays crisp at that lighter tone." Code: strokeWidth = 2 (line 54) — the actual default is Lucide's un-thinned 2, not 1.8. Every header icon app-wide (back chevron, share, bell, kebab) renders 0.2pt heavier than documented/intended.Internal design-token contract (this file's own doc comment is the "practice source").Change line 54 default to 1.8.
Lowlist-row-visual.ts:33-41 vs section-header-metrics.ts:12-22flat-variant SectionHeader bumps navTitle to fontWeight:"700" "so a section reads distinctly next to FlatColumn's divider," while ListRow titles are cardBody/cardMeta at fontWeight:"600" — a 1-step weight jump between two adjacent hierarchy tiers is correct and intentional (documented), not a defect.M3 "title vs body weight contrast" guidance.None — already in line, noted for completeness.

Tabular numerals: correctly wired for hero/stat numbers (StatTile.tabularNums, StatStrip default true, ELO figures in profile/public-profile screens all pass tabularNums). Good coverage; the one gap is FactRow above.


2. Color use

SevLocationFindingPractice / sourceFix
Medclassic-grass.theme.ts:53,64 + status-badge-palette.ts:19,30info (#4B0082, deep purple) and premium are intentionally identical ("premium … intentionally == info; premium badge unused by status maps"). If premium is ever wired to a real badge, it becomes visually indistinguishable from info. Also, using purple (not blue) for "info" diverges from M3 (info=blue), HIG (info=blue/system-blue), and Toss/Kakao/Naver (all blue for informational banners) — it's a deliberate brand choice ("heritage purple accent," file header) but a first-time user has no external convention to lean on for "this banner is neutral info," not "this is a special/premium feature."Material 3 system-color semantics; Toss TDS informational banner = blue.Give premium its own hue before it's wired to anything; consider whether info should route through the more conventional blue for cross-app legibility, or keep purple but document the trade-off is deliberate (already partly done).
Highpackages/features/records/src/leaderboard-podium.tsx:41-53Podium ELO number colored directly by medalGold/medalSilver/medalBronze (themeHex) as text color on a white card — this is a decorative medal hue reused as body text color, not a semantic text tier. See §3 for the resulting contrast failure."Semantic vs decorative" — a medal hue is a decorative/identity accent, not a text-contrast-safe color; WCAG 2.2 doesn't distinguish, but industry practice (M3 "on-surface" tokens) never lets a decorative accent double as required text.Keep the medal hue on the ring/border (already done elsewhere: court-card.tsx:225), set the ELO number to $text, and let a small dot/chip carry the medal color instead.
Medvenue-detail-screen.tsx:372-391 + visual-accents.ts:97-103The 편의시설 (facilities) badge row can render up to 4 different decorative hues simultaneously: $warning (amber, lighting), $courtHard (blue, parking/shower ×2), $accentWarm, $accentCool — none map to a status meaning; they're arbitrary per-facility tints.Toss/Kakao chip rows keep decorative icon tints to 1 accent family per row, or monochrome + glyph-only differentiation, precisely to avoid "how many accents on one screen" creep.Collapse to one neutral icon tint ($textSecondary) inside a Badge variant="neutral", or restrict the accent palette to 2 hues max.
Lownaver-badge.tsx:11-27White "N" on $naver (#03C75A) — 2.25:1, fails body-text contrast, but this is a fixed third-party brand mark rendered at a fixed size (WCAG 2.2 SC 1.4.3 logotype exception applies).WCAG 2.2 SC 1.4.3 note (logos exempt).No action — already in line via the exemption; noted so it isn't mistaken for a bug in §3.
Goodbutton.tsx:85-96, action-button.tsx:39-46Text-tier discipline is strong: $text/$textSecondary/$textTertiary map consistently to primary/secondary/muted across every primitive I read; disabled uses one shared disabledText/DISABLED_OPACITY token everywhere. Status colors (success/warning/error/info) are centralized in status-badge-palette.ts and consumed only through Badge/Banner/Callout/ToneColor — no ad-hoc hex in the 9 flat screens I scanned (grep for raw hex in scope returned zero hits).Semantic-token discipline (M3 color-role system).None — genuinely well executed.

3. Contrast (WCAG 2.2 — computed from actual theme hex, classic-grass light)

All ratios below were computed with the standard relative-luminance formula against the real hex values in classic-grass.theme.ts / tamagui.ts.

PairRatioNeedsVerdict
$text (#131A14) on $card/$background17.71 / 16.594.5Pass
$textSecondary (#455D49) on $card/$background7.20 / 6.754.5Pass
$textTertiary (#58715C) on $card/$background5.34 / 5.004.5Pass
$textTertiary on $primarySubtle well (#E6F4EC)4.714.5Pass (tight)
$textTertiary on $badgeWarningBg (#FFF4D6)4.874.5Pass
Primitive-token tokens.ts:74 textTertiary (#738C78) on $card3.654.5Fail — see below
Every Badge bg/text pair (status-badge-palette.ts)4.56–10.954.5/3.0Pass (already audited in-file)
Button primary text (white) on $primary7.124.5Pass
Button danger text/border ($error) on card5.914.5/3.0Pass
Button variant="secondary" fill ($surfaceSecondary) vs $card1.073.0 (non-text UI)Fail
Button variant="outline"/ActionButton action="neutral" border ($border) vs $card1.333.0Fail
Same outline border vs canvas $background1.253.0Fail
borderSubtle hairline vs $card1.16Exempt (decorative section divider, not a control boundary)
medalGold/medalSilver/medalPlatinum as text on $card (leaderboard-podium.tsx:47)2.23 / 2.19 / 1.324.5Fail
medalBronze as text on $card3.604.5Fail (large-text floor 3.0 also missed at 16px 600, which isn't "large text")
NaverBadge white on $naver2.254.5Exempt (logo mark, SC 1.4.3)

Findings:

  • High — packages/features/records/src/leaderboard-podium.tsx:41-53. The rank-1/2/3 ELO number on the leaderboard podium is colored directly with medalGold/medalSilver/medalBronze, rendered as role="cardTitle" (16px/600) text on a white card. All three fail WCAG 2.2 §1.4.3 (needs 4.5:1 for text this size; actual 2.19–3.60:1). This is the single worst contrast failure found in the audit, and it's on a leaderboard — a screen people photograph and share. Fix: set the number to $text; move the medal hue to the ring stroke / a small dot (both already exist as patterns elsewhere, e.g. court-card.tsx:225).

  • High — button.tsx:85-102 (secondary/outline variants). secondary's fill ($surfaceSecondary #F6F8F5) is 1.07:1 against the $card/canvas ground it almost always sits on, and outline's 1px border ($border #D5E3D6) is 1.33:1 against the same ground. WCAG 2.2 SC 1.4.11 (Non-text Contrast) requires ≥3:1 for the graphical boundary that identifies a UI component. Concretely: the 거절 (reject) button in join-review-detail-screen.tsx:280-289 (action="neutral"outline) and any "이전/취소" outline button app-wide have almost no visible edge on a white/canvas ground — text is legible, but the button shape is not. This is systemic (it's the default styling for two of six button variants), not a one-off. Fix: darken $border (or give outline/secondary a 1px $borderStrong-class border) until the boundary clears 3:1; alternatively give secondary a token one step darker than $surfaceSecondary.

  • Low — tokens.ts:74 vs classic-grass.theme.ts:87. The primitive token file still carries the pre-2026-08-08 textTertiary value (#738C78, 3.65:1 — the exact failure the theme file's own comment says was fixed "2026-08-08: a touch darker for more margin above the WCAG AA 4.5:1 floor"). Nothing in the 9 audited screens reads tokens.color.textTertiary directly (grep confirmed), so it's currently inert, but it's a live footgun for the next person who reaches for tokens.ts instead of $textTertiary. Fix: sync tokens.ts:74 to #58715C so the two files can never silently disagree again.


4. Touch targets and hit areas

SevLocationFindingPractice / sourceFix
Medfact-row.tsx / fact-row-visual.ts:26-28,47-54 + real usage at directory-venue-detail/sections/fee.tsx:37-41FactRow is documented as needing no touch floor ("a static key/value row needs no touch floor") and ships zero hitSlop compensation even when onPress is passed. fee.tsx passes onPress to open an external booking URL on a 36px-tall (FACT_ROW_MIN_HEIGHT) row — an actually-interactive row at 36px with no hitSlop, 8pt under the HIG 44pt / M3 48dp floor.Apple HIG 44×44pt minimum; Material 3 48×48dp.Either forbid onPress on FactRow (route link-like facts through ListRow instead, as links.tsx correctly does), or give resolveFactRowEdgePadding-style hitSlop math to the pressable branch, mirroring ListRow's (TOUCH_TARGET.MIN - minHeight)/2 formula.
Goodpressable.tsx:89-108, list-row-visual.ts:33-41, action-icon-row-visual.ts:55-61, pill-nav.tsx:83-90Every other tappable primitive I read does the 44pt-floor hitSlop math explicitly and names it: ICON_DEFAULT_HIT_SLOP=15, ListRow's edge-aware hitSlop, ActionIconRow's formula, PillNav's PILL_HIT_SLOP=4. This is unusually disciplined — a documented, tested, reused formula rather than per-screen guesswork.HIG 44pt / M3 48dp.None.
Lowbox-sizes.ts:76-79, used by ListRow, FactRowROW_HEIGHT_LIST=40 / hairline rows at 36px both rely entirely on hitSlop to reach 44pt — correct in principle, but hitSlop is invisible to automated a11y/contrast tooling and to real device edge cases (adjacent rows' hitSlops can overlap and "steal" taps at a shared boundary). Not observed to actually misfire in the code read, but worth a note given how many rows now depend on it.HIG hitSlop guidance (Apple explicitly warns against overlapping hit regions).Spot-check on-device that consecutive ListRows' hitSlops don't overlap at real screen widths; no code change indicated from static reading alone.

5. Iconography

SevLocationFindingPractice / sourceFix
Medheader-icon-button.tsx:54See §1 — default stroke is 2.0, not the documented 1.8. This means header-chrome icons and content icons currently share one stroke weight by accident, not by design (the file explicitly argues for two).Internal contract; industry practice (iOS SF Symbols/Material icons use a lighter weight for chrome vs. content glyphs at the same size).Fix the default; low visual delta but a real doc/code split.
Goodicon.tsx:27-34One size scale (12/14/16/20/24/28), one wrapper, and the file-header documents why each size exists (12–20 inline, 24 nav-default, 28 top-of-screen). Every icon in the 9 screens I read comes through <Icon component={...} size="..."/> — zero raw <IconComponent size={number}/> in scope except CapacityRing's internal chart sizing (not an Icon-scale violation, it's a chart prop).One-scale rule (M3 icon sizing).None.
Lowmatch-board-screen.tsx:293-303 vs outcome-summary-card.tsx:2-3 vs join-review-detail-screen.tsx:307-314 / post-detail-header.tsx:117-124TypeHero.leading's own doc says the slot is "an avatar/ring/logo (56px AVATAR_SIZE.HERO)," and every other consumer honors that (AvatarBubble size="HERO"=56, OutcomeRing default=OUTCOME_RING_SIZE=56). match-board-screen.tsx passes a CapacityRing size={32} into the same slot — visibly smaller graphic weight in the identical layout position on a sibling screen.Internal contract (TypeHero's own doc); general "consistent optical weight for a fixed layout slot" principle.Bump CapacityRing to 56px in this one call site, or document a documented "small ring" exception in type-hero.tsx.
Goodflag-medallion.tsx, avatar-bubble.tsx sizing tablesFilled-vs-outline and glyph-optical-alignment concerns are handled centrally (getGlyphSize, getTextStyle scale with bubble size) rather than per screen.Consistency principle.None.

6. Spacing / alignment (sanity pass — the seam system is new)

SevLocationFindingFix
Highbutton.tsx:207-223 (documented landmine) vs join-review-detail-screen.tsx:279-300, packages/features/sessions/src/components/match-rules-editor-sheet.tsx:157-168, packages/features/sessions/src/tier-promotion-sheet.tsx:58-63button.tsx's own doc comment describes a previously-shipped bug ("wizard footer's 이전>다음 bug"): every size="lg" Button defaults to width:'100%', so two lg buttons sharing a row both demand full width; the fix mandated is "any lg button that shares a row MUST pass width=\"auto\"" — but grep across the whole repo found zero call sites that actually pass width="auto", and three live screens (a 거절/승인 decision band, a match-rules save/cancel footer, and a tier-promotion share/dismiss sheet) put two size="lg" buttons in an XStack using only flex={1}/flex={2}, never width. Because RN's flex shorthand doesn't touch flexBasis (it stays auto, which resolves to the still-present width:'100%'), when the row must shrink (which it always does — 200% demand in a definite-width row) the shrink is proportional to flexBasis, not flexGrow: both buttons get pushed toward equal width regardless of the caller's intended flex={1}/flex={2} ratio. Concretely, join-review-detail-screen.tsx's 승인 button was meant to read as 2× the width (and prominence) of 거절 — it likely does not.Add width="auto" at all three call sites (the doc comment's own prescribed fix), or better: fix it once in button.tsx by not setting width:'100%' when a flex prop is present in rest, so the landmine can't recur a fourth time.
Lowwell-tone.ts / banner.tsx:102-107 / callout.tsx:47-52Well/Card/AttachedPanel/ModalPanel all use $4 radius; Banner/Callout intentionally use $3 (documented split, "conformance audit 2026-09-01 ruling"). This reads as two radius tiers on the same canvas depending on which primitive appears — subtle but real: a Banner sitting directly above a Well (both in session-detail's hero zone) has a visibly different corner radius. Documented as intentional, not drift.None required — flagged only for completeness since the prompt asked to "note anything the seam system missed."
Lowpublic-profile-screen.tsx:95FriendshipButton's loading <Skeleton width={120} height={40} borderRadius={8}/> renders at 40px while the real ActionButton size="md" it's standing in for is 44px (box-sizes.ts ROW_HEIGHT_TOUCH). A 4px vertical layout shift occurs the instant the skeleton resolves to the real button.Change skeleton height to 44 (or import ROW_HEIGHT_TOUCH).

Everything else I sampled (FlatColumn, SEAM ladder, CellRow, edge-padding math in ListRow/FactRow) is internally consistent and unit-tested (seam-contracts.test.ts, flat-column-defaults.test.ts) — the new seam system holds up well.


7. Components vs platform idioms

SevLocationFindingPractice / sourceNote
Infomodal-panel.tsx:96-98All "sheet"-shaped surfaces (ReactionPickerModal, MatchListControlsSheet, TierInfoSheet, tier-promotion-sheet.tsx, challenge-accept-modal.tsx) render through ModalPanel, which is a centered dialog (alignItems:"center" justifyContent:"center"), never bottom-anchored. Verified via repo-wide grep: the only occurrence of the string "BottomSheet" in the codebase is a comment in tier-promotion-sheet.tsx:7 explicitly disclaiming it ("no product bottom-sheet primitive").Diverges from Material 3 (bottom sheets are the default surface for contextual pickers/action lists) and from Apple HIG's action-sheet idiom, but this is the app's own explicit, consistently-applied policy per the prompt.The idiom holds up structurally (one component, one contract, Frame/Overlay/ScrollView composition is sound), but a centered modal for something like the 5-emoji ReactionPickerModal (post-row.tsx:80-120) is a genuinely unusual choice next to Kakao/Naver/Toss, all of which use bottom sheets for reaction pickers — worth a UX gut-check even though it's policy-compliant.
Goodapp-header.tsx, collapsing-top-bar.tsx, wizard-shell.tsxBack affordance is a single ChevronLeft icon-only button everywhere (app-header.tsx:73-79, collapsing-top-bar.tsx:96-101), with accessibilityLabel always supplied via the injectable uiStrings().back registry (fixed 2026-08-31 per its own comment).Matches Toss/Kakao/Naver (icon-only chevron back, no HIG-style "‹ Previous Screen Title" label) rather than strict Apple HIG. Consistent app-wide — a deliberate, coherent platform choice, not drift.None.
Goodempty-state.tsx, skeleton-flat-page.tsx, banner.tsx (M2 "message row then action row" anatomy)Empty states, skeletons, and banners all follow one canonical anatomy each, with the "two-family rule" (banner.tsx:13-23) cleanly separating Banner (explicit choice) from Callout (actionless) from tap-anywhere rows — exactly the M3 "supporting text" pattern for banners (message → up to 2 actions, primary rightmost).M3 banner spec.None.
Goodbadge.tsx:37-41,151Badge sizing (xs/sm/md) and the outline/surface/primarySubtle variant taxonomy read as a deliberate hybrid of M3 chips (bordered/filled distinction) and Toss's compact pill labels, with an explicit non-interactive contract (badges are "a passive label, not a tappable control" — distinct radius tier from the pill-nav/chip family).M3 chip vs. Toss badge conventions.None.
Lownaver-badge.tsx, use of $naver/$kakao third-party brand colors as fixed (non-theme-reactive) tokensCorrect per convention (third-party marks must render in-brand regardless of app theme) — flagged only as a positive confirmation, not a defect.Kakao/Naver brand guidelines.None.

8. States

StateCoverage foundVerdict
LoadingSkeletonFlatPage (kind-aware layout), Skeleton/SkeletonCard/SkeletonRow/SkeletonHero, all reduced-motion-gated (skeleton.tsx:44-60)Good
EmptyEmptyState (full/compact), consistently supplied with icon+title+subtitle+optional CTA across every screen readGood
ErrorQueryErrorState (imported from @twomore/app, used everywhere) + EmptyState with retry CTAGood
OfflineOfflineEmptyState, explicit isPausedOffline branch in every screen (a real, hard-won pattern per session-detail-screen.tsx:263-266's comment)Good
DisabledOne shared DISABLED_OPACITY=0.5 (pressable.tsx:87), consistently applied to Button, ListRow, PressableGood, though a single flat 0.5 for everything is coarser than M3's asymmetric 38%/12% (content/container) — low-impact stylistic gap, not a defect
PressedCanonical VARIANTS table (pressable.tsx:73-80) — 6 named variants, each with its own scale/opacity, single source of truthGood
Focused (web)focusStyle on Button/Pressable/PillNav — web-only (RN ignores outlineWidth), appropriate for a mobile-first appGood
SelectedaccessibilityState={{selected}} wired on PillNav/SegmentedTabs/UnderlineTabGood
Missing/weakWell and Callout, FactRow, StatTile have no loading/skeleton variant of their own — every screen substitutes a generic SkeletonFlatPage shape instead, which is fine for a full-page load but means a partial refetch of just a StatStrip (e.g. RecordDetailScreen's scope-change re-fetch) has no shimmer state — likely fine given TanStack's cache-then-refetch pattern, but not verified in this pass.Low — flagged for awareness, not confirmed as a live bug.

9. Motion

SevLocationFindingPractice / source
Goodpressable.tsx:73-80, motion.ts:27-34Press scale/opacity is small and variant-specific: card 0.99/0.97, button 0.97/0.92, row 1.0/0.85 (no scale — correct for dense rows), icon 0.92/0.78. These sit comfortably inside HIG's "subtle scale, never more than a few %" guidance and M3's state-layer-first philosophy (the row/card variants literally default to a tonal state-layer instead of scale, per the "affordance-law baseline," pressable.tsx:27-30).HIG press feedback; M3 state layers.
Goodmotion.ts:99-122useReducedMotion() is the single canonical hook, consumed by Pressable (opacity-only fallback, no scale) and Skeleton (static mid-opacity instead of loop) — this is exactly right and unusually thorough for an RN app.WCAG 2.2 SC 2.3.3 (Animation from Interactions), platform reduced-motion APIs.
Lowmotion.ts:40-69Duration tokens are well-named and centralized, but I found no evidence any of the 9 screens gates SectionDivider fade/transition or CollapsingTopBar's animation="quick" cross-fade on reduced-motion — CollapsingTopBar (collapsing-top-bar.tsx:86,111) always animates. This is a background/border cross-fade (low vestibular risk), so impact is minimal, but it's the one motion surface in scope that isn't reduced-motion-aware.WCAG 2.2 SC 2.3.3 is scoped to "motion triggered by interaction... unless essential" — a chrome color cross-fade is a defensible exception, but worth a note.

10. Korean typography specifics

SevLocationFindingPractice / source
Goodfonts.ts:31-97Pretendard weights are mapped correctly per Android's face requirement (400/500/600/700, 800→700 fallback documented as a deliberate ceiling). Letter-spacing is negative and scales with size (-0.1 at 14px body down to -0.7 at the heading font's largest step) — this is exactly the right direction for Korean (0 or slightly negative at larger sizes, never positive/tracked-out), and the comment even names the reference ("Pretendard reads slightly tight; matches Toss look").KRDS / Pretendard's own recommended metrics; Toss TDS.
Goodtext.tsx:64-76 (navTitle), :126-133 (displayMd), :141-157 (displayLg)Every role whose line-height ratio would fail KRDS's ≥1.4× floor for wrapping Korean prose (navTitle 22/17≈1.29×, displayLg 44/36≈1.22×, displayXl 56/48≈1.17×) carries an explicit doc-comment caveat restricting that role to single-line/non-wrapping numeric displays, and steers wrapping prose to cardBody/cardTitle (both ≥1.4×: 22/14≈1.57×, 24/16=1.5×). This is a genuinely careful, documented mitigation — rare to see call sites this disciplined about it.KRDS Korean line-height guidance (≥140% for body text).
Lowlist-row.tsx:110-117, fact-row.tsx:107Titles/labels use numberOfLines={1} with no wordBreak/ellipsizeMode override — for long Korean compound nouns (club names, venue names) this truncates mid-syllable-block by default RN behavior, which is visually acceptable in Korean (unlike Latin scripts, mid-word truncation doesn't break legibility the same way) — flagged only as a "sanity checked, no issue found."Korean line-breaking conventions (word-break is a non-issue for CJK truncation).
Infotype-hero.tsx:66-69eyebrow sets letterSpacing={0.4} (positive tracking) — this is the one place I found positive letter-spacing on Korean text in the scanned files, which contradicts the file's own dimension-10 guidance ("0 or slightly negative"). It's a small, all-caps-style eyebrow label, so the visual harm is minimal, but it's inconsistent with fonts.ts's systemic negative-tracking policy.Same KRDS/Pretendard practice cited above.

Top 15, ranked by user-visible impact

  1. High — leaderboard-podium.tsx:41-53: medal-colored ELO text fails WCAG contrast (2.19–3.60:1 vs 4.5:1 required) on the most-shared/most-photographed screen in the app.
  2. High — button.tsx secondary/outline variants: 1.07:1 / 1.33:1 non-text contrast — every quiet-side button app-wide (거절/취소/이전, dozens of screens) has a nearly invisible boundary on white/canvas grounds.
  3. High — join-review-detail-screen.tsx:279-300, match-rules-editor-sheet.tsx:157-168, tier-promotion-sheet.tsx:58-63: documented "two lg buttons in a row" landmine (button.tsx:207-223) reproduced at 3 live call sites — likely collapses intended 1:2 (or 1:1) width ratios to equal widths, undermining the "primary reads more prominent" intent on real decision screens (approve/reject, save/cancel, dismiss/share).
  4. Med — fee.tsx:37-41 via fact-row.tsx: a real external-link row renders at 36px with zero hitSlop compensation, 8pt under the HIG/M3 floor — the one tappable FactRow in scope is also the one row missing the touch-target math every other primitive has.
  5. Med — header-icon-button.tsx:54: shipped default (2.0 stroke) contradicts the file's own documented contract (1.8) — affects every header icon in the app.
  6. Med — venue-detail-screen.tsx:372-391: up to 4 unrelated decorative accent hues in one small facilities badge row.
  7. Med — classic-grass.theme.ts / status-badge-palette.ts: info==premium by design; purple (not blue) for "info" diverges from every cited convention (M3/HIG/Toss/Kakao/Naver).
  8. Low-Med — tokens.ts:74: stale textTertiary primitive (3.65:1, pre-fix value) left inert but live as a footgun next to the corrected theme value (5.34:1).
  9. Low — match-board-screen.tsx:293-303: TypeHero.leading graphic renders at 32px vs. the documented/used 56px everywhere else — visual-weight inconsistency in a fixed layout slot.
  10. Low — public-profile-screen.tsx:95: FriendshipButton loading skeleton is 40px vs. the real button's 44px — a small layout shift on resolve.
  11. Low — collapsing-top-bar.tsx:86,111: cross-fade animation not gated on reduced-motion (only motion surface in scope that isn't).
  12. Low — type-hero.tsx:66-69: positive letter-spacing on the eyebrow label, against the app's own negative-tracking Korean-typography policy.
  13. Low — fact-row.tsx: no tabularNums option, unlike its siblings StatTile/StatStrip.
  14. Info — modal-panel.tsx: centered-dialog-only idiom for everything (including a 5-option reaction picker) is policy-compliant but a genuine UX outlier next to Kakao/Naver/Toss bottom-sheet norms — worth a product gut-check, not a bug.
  15. Info — naver-badge.tsx / medal colors as decorative fills elsewhere: confirmed correctly exempt/appropriate — included only to show they were checked and are not bugs (avoids false-positive contrast alarms on future re-audits).

Already in line with practice (brief)

  • Type system: 11 named roles, single source of truth, Korean line-height caveats explicitly documented per role (KRDS-aware).
  • Touch targets: near-universal, formula-driven hitSlop ((TOUCH_TARGET.MIN - visual) / 2), tested in *-visual.ts unit tests — one of the most disciplined touch-target systems I've seen in an RN codebase.
  • Status-color/badge contrast: status-badge-palette.ts was pre-audited against Radix-UI steps with WCAG AA verified and documented per pair, in both light and dark.
  • Motion: reduced-motion is a single canonical hook consumed by press feedback and skeleton shimmer; spring/duration tokens are centralized and named by interaction, not by value.
  • Component/platform idioms: back-chevron, empty states, skeletons, and the Banner/Callout two-family split all follow one coherent, internally-consistent grammar; the no-bottom-sheet policy is genuinely enforced (verified by repo-wide grep, not just by doc comment).
  • Korean typography: Pretendard weight-to-file mapping is correct for Android; letter-spacing direction and magnitude match the Toss-referenced target; wrapping-prose roles are steered away from the roles whose line-height ratio would fail KRDS.
  • Seam/spacing system: the new SEAM ladder, FlatColumn divider math, and edge-padding contracts in ListRow/FactRow are unit-tested and hold up under every screen I read — no drift found in the flat pages themselves, only in the pre-existing Button/FactRow primitives that predate this pass.
  • Semantic-token discipline in the 9 flat screens: zero raw hex, zero raw fontSize={number}, zero direct TouchableOpacity found in scope — every color/size/spacing value in the audited screens routes through a token or a named primitive.

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