Skip to content

Adversarial audit — color, typography, elevation & tone

Status: Active · Owner ask: "are there any components or styles or behaviors that are not in sync or harmony?" — color/typography/elevation/tone dimension. All findings open as of 2026-09-10.

Scope: theme files (packages/app/src/config/themes/*), packages/ui/src/tokens.ts, packages/ui/src/text.tsx, text-grammar.ts (META_TEXT), badge.tsx, banner.tsx, callout.tsx, card.tsx, status-badge/variant maps, and cross-screen usage in packages/app/src/presentation + packages/features. Nothing was changed.

Severity counts: 2 HIGH, 2 MEDIUM, 1 LOW/latent. Typography roles, META_TEXT grammar, status→Badge-variant mapping (status-variant-maps.ts), and Banner/Callout tone-to-badge-palette mirroring are all clean — see "Clean" section.


HIGH-1 — Two different reds for the SAME "live now" signal, visible on one screen, in dark mode

Files:

  • packages/app/src/presentation/components/status/status-badges.tsx:333-335 (SessionStripChipBadge) — pulse color = stripPaletteToken(chip.palette)
  • packages/app/src/presentation/components/sessions/session-strip-copy.ts:45-50 (PALETTE_TOKEN) — error: '$badgeErrorText'
  • packages/app/src/domain/session-strip.ts:152live: { emphasis: 'urgent', palette: 'error' }
  • vs. packages/features/sessions/src/session-detail-screen.tsx:338, packages/app/src/presentation/components/status/match-status-dot.tsx:32, packages/features/sessions/src/tournament-board-screen.tsx:323 — all <PulseDot color="$error" />

Resolved values (classic-grass.theme.ts, and identical across all 4 themes since success/warning/error are theme-invariant):

TokenLightDark
$error (colors.status.error)#B8332E#D4736E
$badgeErrorText (STATUS_BADGE_DARK.error.text)#B8332E#FCA5A5

Light mode: identical (#B8332E == #B8332E) — no visible bug there. Dark mode: #D4736E vs #FCA5A5 — two visibly different reds (#FCA5A5 is lighter/pinker; this is exactly the color the project's own canon calls out).

Which is correct per canon: docs/canon/components.md (Cards section) states verbatim: "the session-DETAIL screen additionally keeps a red PulseDot (color $error — the true live-red; NOT $badgeErrorText, which is salmon-pink in dark mode)." So $error is the documented-correct live-pulse color; SessionStripChipBadge's use of stripPaletteToken('error')$badgeErrorText is the documented-wrong one.

User-visible symptom: SessionStripChipBadge is the canonical renderer for the strip lead chip, consumed by:

  • session-strip-ui.tsx:171SessionStateInline, rendered on every SessionCardV2 app-wide (home feed, club sessions tab, sessions list) whenever a session is in_progress.
  • session-hero-section.tsx:114 → the session-detail hero's lead badge.

The detail hero's SessionStripChipBadge pulse and the same screen's sticky-header PulseDot (session-detail-screen.tsx:338) render side by side, for the same live session, at the same time, in two different reds, in dark mode only. This is the single most directly-comparable, highest-visibility color drift found.

Fix: SessionStripChipBadge's icon should use <PulseDot color="$error" /> (or a live-specific override) rather than routing the pulse through stripPaletteToken, which was designed for the strip fill/text tone, not the true-red pulse cue. stripPaletteToken can stay as-is for non-live palettes (warning/success/neutral); only the live/error pulse needs the carve-out already established at the other three call sites.


HIGH-2 — Hard-court swatch renders off-brand purple (or theme-drifting blue) instead of the app's own $courtHard token

Files:

  • packages/features/sessions/src/directory-venue-detail/format-helpers.ts:243-249 (SURFACE_SWATCH_COLOR) — hard: '$info', clay: '$warning', artificial_grass: '$success'
  • Rendered at packages/features/sessions/src/directory-venue-detail/sections/courts.tsx:63-64,79 — a solid filled 8px dot, backgroundColor={swatchColor}, leading each surface-group row in the venue detail's "코트 구성" (courts) section.
  • vs. the app's actual single-source surface-color system: packages/app/src/config/visual-accents.ts:87-93 (SURFACE_ACCENTS) — hard: '$courtHard', clay: '$courtClay', grass/artificial_grass: '$courtGreen' — consumed by venue-row.tsx, venue-link.tsx, venue-monogram.tsx, venue-card.tsx, venue-hero.tsx, session-card-v2.tsx (6 call sites).

Resolved values (Classic Grass, the default theme):

Surface$courtHard (correct, used everywhere else)$info (what the swatch actually uses)
hard#247BA0 (blue-teal)#4B0082 (deep purple)

$info also varies by theme (Melbourne Blue #006FA8, Paris Clay #1F6F8B, NY Hardcourt #185A9D — all blue-ish, coincidentally closer to $courtHard in those 3 themes) while $courtHard itself is deliberately stable blue-teal in every theme. So the bug is worst in the app's own default theme, where the courts-list swatch and every other hard-court indicator on the same venue disagree by hue family entirely (purple vs. blue), and best-case elsewhere it's merely a coincidental near-match, not a designed one. clay$warning (#946200 amber-brown) also diverges from $courtClay (#B84A2E orange-red) everywhere; artificial_grass$success (#14804A) is the closest miss vs. $courtGreen (#006633), both dark greens.

User-visible symptom: A venue's detail screen already shows its surface via $courtHard/$courtClay/$courtGreen in the hero photo tile, the venue card, and any session card referencing that venue. Scrolling to the "코트 구성" section on the same screen shows a differently-colored dot for the identical fact (this venue's surface = hard court), most jarring in the default theme where it flips hue family (blue → purple).

Fix: replace SURFACE_SWATCH_COLOR's three entries with $courtHard/$courtClay/$courtGreen (same token family getSurfaceAccent/SURFACE_ACCENTS already uses) instead of borrowing $info/$warning/$success. The file's own comment says this was forked only because surfaceCode (raw ingest string) has "no shared color mapping yet" vs. the SurfaceType enum getSurfaceAccent handles — that's a type gap, not a reason to pick different hues; the swatch can still map to the same three court tokens by string key.


MEDIUM-1 — Chrome-icon tone is one full mute-tier lighter in 3 of 4 themes (self-documented as "interim")

Files: packages/app/src/config/themes/classic-grass.theme.ts:104-106 (light) / :224-226 (dark) vs. paris-clay.theme.ts:88-90/:189-191, melbourne-blue.theme.ts:88-90/:189-191, new-york-hardcourt.theme.ts:88-90/:189-191.

Resolved values: Classic Grass's icon.muted = its own text.secondary value in both modes (light #4F5851, dark #D7DCD8) — confirmed by the theme's own comment: "Chrome-icon tone ... the text.secondary value." The other three themes' icon.muted instead equal their own text.tertiary (one tier more receded) — e.g. Melbourne Blue light #5C7384 (its text.tertiary, not text.secondary #41566B), and each carries the comment "Chrome-icon tone — interim: this theme's tertiary text hue."

Which is correct per canon: docs/canon/styling.md's visual-consistency canon and Classic Grass's own history (the "black-FILL bug" note) establish $textSecondary/icon.muted at the SECONDARY tier as the grounded, tuned choice for nav-chrome icons (back chevrons, header-right icons via HeaderIconButton, $iconMuted). The other three themes' own comments admit they're running the un-tuned tertiary tier as a placeholder.

User-visible symptom: Every HeaderIconButton (back arrow, every AppHeader/DetailShell right-slot icon app-wide) reads visibly quieter/lower-contrast the moment a user switches from Classic Grass to Paris Clay, Melbourne Blue, or New York Hardcourt — a global, every-screen contrast shift that has nothing to do with each theme's brand identity and everything to do with an unfinished port of the 2026-09-06 icon-tone tuning pass.

Root cause, confirmed: the 2026-09-06 OKLCH/contrast audit (docs/design/color-system-audit-2026-09-06.md, file-header "Files read" list) touched only tokens.ts, classic-grass.theme.ts, status-badge-palette.ts, tamagui.ts, theme.types.ts — it never opened the other three theme files, so their gray/text ladders (and downstream icon.muted choice) never received the same pass.

Fix: point icon.muted at text.secondary in the other three theme files (mechanical, one line each × 2 modes), or, if the alternate themes' designer intends a genuinely different icon tone, promote that to a documented decision instead of an "interim" comment.


MEDIUM-2 — 9 feature files hand-roll borderBottomWidth/borderTopWidth as an inter-row separator, bypassing Divider/HAIRLINE

Files (all borderBottomWidth={1} borderBottomColor="$borderSubtle" used as a row/section separator, none is the sanctioned EditFieldRow):

  • packages/features/clubs/src/club-detail/feed-auto-rows.tsx:95
  • packages/features/sessions/src/venue-owner-claims/list-screen.tsx:46
  • packages/features/clubs/src/club-detail/club-schedule-rows.tsx:78
  • packages/features/clubs/src/post-create-screen.tsx:290
  • packages/features/clubs/src/club-detail-screen.tsx:484
  • packages/features/clubs/src/shared/post-row.tsx:384
  • packages/features/clubs/src/club-detail/ranking-tab.tsx:266,330
  • packages/features/sessions/src/venue-contributions/court-operations-editor.tsx:92
  • packages/features/sessions/src/venue-owner-claims/transfer-proposals.tsx:31

Resolved values today: all 9 sites use the literal 1 + "$borderSubtle", which is currently byte-identical to HAIRLINE.width/HAIRLINE.color (packages/ui/src/hairline.ts) and to Divider's own rendering — so there is no live pixel difference today. This is a latent-drift / enforcement-gap finding, not a currently-visible clash.

Which is correct per canon: docs/canon/components.md states dividers are "<Divider /> ... never a hand-rolled ... borderBottomWidth/borderTopWidth inter-row separator," with EditFieldRow as the only sanctioned exception. None of these 9 sites is EditFieldRow.

Why it's not caught: the existing @twomore/no-hand-hairline ESLint rule (packages/eslint-plugin/rules/no-hand-hairline.js) only targets the other hand-rolled recipe — a <YStack height={HAIRLINE.width}> drawn as a full line — and its own docstring explicitly exempts borderBottomWidth={HAIRLINE.width} chrome edges as legal. It does not, and was never meant to, catch a literal-valued borderBottomWidth={1}/borderBottomColor="$borderSubtle" used as a row separator (that's a different anti-pattern from the one F4/2026-09-07 fixed).

Risk / fix: if HAIRLINE.color/Divider's hairline token is ever retuned (the kind of change this same audit cycle already did to borders in dark mode across all 4 themes), these 9 sites will NOT pick it up and will silently diverge from every other divider in the app. Migrate to <Divider inset="card" />/bare <Divider /> per the existing 2026-07-19 playbook; consider widening the lint rule to also flag literal borderBottomWidth/borderTopWidth + "$borderSubtle" combinations in packages/features/**.


LOW — META_TEXT.secondary/eyebrow deliberately double-mute against the styling.md warning that reads them as unintentional

Files: packages/ui/src/text-grammar.ts:40-45 (META_TEXT.secondary = { role: 'cardMeta', color: '$textTertiary' }, META_TEXT.eyebrow = same + fontWeight: '700') vs. docs/canon/styling.md line 88: "Putting $textTertiary on a cardMeta/cardBody Text is a 'double-mute' that's almost always unintentional."

cardMeta's role definition (text.tsx:91-97) already bakes color: '$textSecondary'; META_TEXT.secondary/eyebrow explicitly override that to $textTertiary — exactly the pattern styling.md warns against, but this is a named, ratified exception (owner ruling 2026-09-06, Table B, per the file's own header comment), not sloppy drift. Flagging only because styling.md (reviewed 2026-07-19) still reads as a blanket warning and doesn't cross-reference the later META_TEXT ruling that deliberately does the thing it warns against for 2 of 4 tiers — a doc-sync note, not a rendering bug.


Clean / verified no drift

  • Status→Badge-variant mapping (status-variant-maps.ts): every domain enum (session/match/RSVP/dues/score/session-payment/consent/club-joinability/recruitment/attendance/achievement/ELO-delta/win-result) maps through one central, commented table, each choice justified inline (e.g. WIN_RESULT_DOT_TOKENS's 2026-09-06 fix for a draw/loss ΔE-0.73 near-collision, now hue+shape differentiated). No ad-hoc <Badge> composition found outside the one exempted file.
  • Banner/Callout tone tables both resolve through the same theme-invariant badge-palette tokens ($badgeWarningBg/Text, $badgeErrorBg/Text, $badgeInfoBg/Text) — consistent fill/text pairing, consistent with Badge's own variants.
  • Typography roles (text.tsx): single 11-role table, every role's size/weight/lineHeight is fixed and commented with its rationale; no evidence of a second competing type scale.
  • $textMuted/$textSubtle (the pre-2026-06-23 deprecated names): zero remaining references anywhere in packages/app, packages/features, packages/ui — the rename fully landed.
  • Global-primitive elevation ban: no elevation/shadow* on Card or any other globally-instantiated primitive. The 5 hits that exist (well.tsx comment only, band-slider-core.tsx, hue-slider.tsx, single-band-slider-core.tsx knobs, feed-list/parts.tsx scroll-hint badge, toast-host.tsx) are all single-instance, bounded controls (one slider knob, one toast, one scroll badge) — exactly the sanctioned "argued locally" exception the canon carves out, not a reintroduction of the Android-flattening regression.
  • STATUS_BADGE_LIGHT/STATUS_BADGE_DARK: identical across all 4 themes (spread verbatim via badge: STATUS_BADGE_LIGHT/DARK in every theme file) — the one deliberately theme-invariant layer is actually invariant.

Ranked by visibility

  1. HIGH-1 — dual live-red PulseDot, visible side-by-side on the session-detail screen in dark mode, contradicts the project's own documented rule.
  2. HIGH-2 — hard-court swatch renders purple instead of blue in the default theme, directly comparable to 6 other correct call sites for the same venue.
  3. MEDIUM-1 — every header/back-chevron icon app-wide reads one mute-tier lighter in 3 of 4 themes (self-admitted "interim").
  4. MEDIUM-2 — 9-file Divider-law violation; currently invisible (values match) but a live regression waiting on the next hairline-color tweak.
  5. LOW — stale doc warning vs. a later ratified exception; no rendering impact.

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