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:152—live: { 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):
| Token | Light | Dark |
|---|---|---|
$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:171→SessionStateInline, rendered on everySessionCardV2app-wide (home feed, club sessions tab, sessions list) whenever a session isin_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 byvenue-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:95packages/features/sessions/src/venue-owner-claims/list-screen.tsx:46packages/features/clubs/src/club-detail/club-schedule-rows.tsx:78packages/features/clubs/src/post-create-screen.tsx:290packages/features/clubs/src/club-detail-screen.tsx:484packages/features/clubs/src/shared/post-row.tsx:384packages/features/clubs/src/club-detail/ranking-tab.tsx:266,330packages/features/sessions/src/venue-contributions/court-operations-editor.tsx:92packages/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 adraw/lossΔE-0.73 near-collision, now hue+shape differentiated). No ad-hoc<Badge>composition found outside the one exempted file. Banner/Callouttone tables both resolve through the same theme-invariant badge-palette tokens ($badgeWarningBg/Text,$badgeErrorBg/Text,$badgeInfoBg/Text) — consistent fill/text pairing, consistent withBadge'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 inpackages/app,packages/features,packages/ui— the rename fully landed.- Global-primitive elevation ban: no
elevation/shadow*onCardor any other globally-instantiated primitive. The 5 hits that exist (well.tsxcomment only,band-slider-core.tsx,hue-slider.tsx,single-band-slider-core.tsxknobs,feed-list/parts.tsxscroll-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 viabadge: STATUS_BADGE_LIGHT/DARKin every theme file) — the one deliberately theme-invariant layer is actually invariant.
Ranked by visibility
- 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.
- 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.
- MEDIUM-1 — every header/back-chevron icon app-wide reads one mute-tier lighter in 3 of 4 themes (self-admitted "interim").
- MEDIUM-2 — 9-file Divider-law violation; currently invisible (values match) but a live regression waiting on the next hairline-color tweak.
- LOW — stale doc warning vs. a later ratified exception; no rendering impact.