Skip to content

Color system audit (research-based)

Status: Accepted · Ruled 2026-09-10 · Date: 2026-09-06 · Purpose: owner-requested check that the Classic Grass palette is research-based (OKLCH ladders, M3 tones, WCAG 2.2 + APCA, CVD simulation) with a proposal for the parts that are not.

Method: wrote a from-scratch Node script (sRGB→linear→OKLab/OKLCh, sRGB→CIE XYZ→Lab/LCh, WCAG 2.x relative-luminance contrast, APCA-W3 0.1.9 SAPC formula, Machado/Oliveira/Fluet 2009 protanopia/deuteranopia matrices, OKLab-Euclidean ΔE) at /tmp/claude-1000/-home-sungkyu08-Dev-twomore-v2/9e052da6-463b-47d5-958b-a7a7f703ba4b/scratchpad/color-audit.mjs (+extra2.mjs/extra3.mjs). No repo files touched. M3 "tone" is approximated by CIE L* per Material's own definition ("Tone…is similar to lightness (L*) in the CIELAB color space" — m3.material.io/styles/color/system/how-the-system-works).

Files read: packages/ui/src/tokens.ts, packages/app/src/config/themes/classic-grass.theme.ts, packages/app/src/config/themes/status-badge-palette.ts, packages/app/src/config/themes/tamagui.ts, packages/app/src/config/themes/theme.types.ts, packages/ui/src/tone-colors.ts, packages/ui/src/badge.tsx, packages/app/src/presentation/components/status/status-variant-maps.ts, packages/app/src/presentation/components/status/match-status-dot.tsx, docs/twomore_design_system/colors_and_type.css, memory reference_classic_grass_token_palette.md.

1. Brand scale (OKLCH / CIE LCh)

stephexOKLCHLab (M3-tone proxy)
brand50#E6F4ECL=.955 C=.018 H=161.1°L*=95.0
brand100#CDE8D8L=.907 C=.036 H=159.6°L*=89.7
brand200#A5D6B8L=.833 C=.066 H=157.7°L*=81.6
brand300#75BF95L=.744 C=.097 H=158.1°L*=71.7
brand400#3B9A68L=.616 C=.118 H=157.1°L*=57.1
brand500#147A45L=.512 C=.121 H=154.2°L*=44.9
brand600#006633L=.447 C=.116 H=152.7°L*=37.4
brand700#004D26L=.368 C=.094 H=153.2°L*=28.0
brand800#003D1FL=.316 C=.079 H=154.8°L*=21.7
brand900#002E17L=.265 C=.065 H=155.7°L*=15.6

Lightness steps: dL runs -0.048, -0.073, -0.089, -0.128, -0.105, -0.064, -0.079, -0.052, -0.051 — a 2.7× spread between the smallest and largest step (brand300→400 is the outlier). Not Material-3-style fixed tones (0/10/…/100) nor Tailwind-v4-style even OKLCH L steps. Chroma curve: 0.018→0.036→0.066→0.097→0.118→0.121 (peak, brand500)→0.116→0.094→0.079→0.065 — correctly bell-shaped, peaking mid-scale, per Radix/M3 convention. Sound.Hue drift: 161.1°→155.7° end to end, ~5.4° total, mostly monotonic with small reversals (+0.6°, +1.5°, +1.0° near the dark end) — minor, not a real outlier.

2. Gray scale (OKLCH / CIE LCh)

stephexOKLCHdH from prior
gray50#F8FAF8L=.983 C=.003 H=145.5°
gray100#F0F5F1L=.965 C=.007 H=151.9°+6.3
gray200#DDE8DEL=.920 C=.018 H=148.1°-3.8
gray300#C4D4C5L=.854 C=.027 H=147.1°-1.0
gray400#8FA492L=.697 C=.035 H=149.3°+2.2
gray500#617A65L=.553 C=.043 H=149.5°+0.1
gray600#455D49L=.453 C=.043 H=149.4°-0.1
gray700#31443AL=.367 C=.030 H=161.5°+12.1 (outlier)
gray800#1D2C24L=.277 C=.025 H=160.9°-0.6
gray900#101C15L=.212 C=.022 H=158.3°-2.6
gray950#0A120CL=.172 C=.017 H=152.0°-6.3

Flag: gray700 is a hue/chroma kink — hue jumps +12.1° and chroma drops disproportionately (-0.014 vs a flat -0.000 the step before) right at that one step, then the scale swings back. Root cause: gray700 (#31443A) is reused verbatim as colorsDark.gray200 (a dark-mode border/mid-tone) — it looks tuned for its dark-mode role, not for evenness of the light ramp. Hue-shift claim vs reality: the token-file comment says grays are "hue-shifted ~15° from Tailwind." Actual hues sit at 145–161° — i.e., the same hue family as brand (152.7°), not merely offset by 15° from a neutral. This is fine as tinted-neutral design (cf. Radix Sage/Mint), but the comment's framing is imprecise — it's a brand-hue-matched neutral, not a small offset. Dark-mode gray scale is not independently designedcolorsDark.gray (theme file) is a literal reversal of the same 10 light hex codes (dark g50=light gray900, dark g900=light gray50). By contrast, the primary dark scale (p50…p900) is an independently tuned ladder (different hexes, not a reversal of the light primary scale) and the STATUS_BADGE_DARK palette explicitly documents desaturating hues to avoid dark-background "colour vibration." The gray scale got neither treatment — an inconsistent methodology across the three neutral/brand ramps.

3. Semantic mapping — tonal inversion check

Surfaces (L values)

cardbackgroundtertiaryelevated
Light1.0000.9770.9551.000
Dark0.2380.1580.3040.294

Light-mode surfaces are packed into a ΔL of only 0.045 total (card→tertiary) — near-imperceptible by fill alone (contrast ratios ~1.02–1.1:1 between them); the design already leans on 1px borders to separate them, so this isn't broken, but it means light-mode surface hierarchy is not carried by lightness — it's carried by hairline borders + the tertiary hue tint. Dark mode uses a real ΔL ladder (background < card < elevated < tertiary, spread of 0.146) — a genuine tonal structure, not just swapped hexes. But note tertiary flips rank: in light it's the darkest of the three canonical surfaces; in dark it's the lightest of all four. That's an inversion of relative role, not of absolute polarity — probably intentional (tertiary = "lifted highlight" in both modes) but worth confirming against actual screens.

Text tiers (L values)

primarysecondarytertiary
Light0.2080.4530.522
Dark0.9630.8970.769

Correctly monotonic and correctly inverted in both directions — sound, real tonal inversion, not swapped hex.

Primary brand tone vs M3's tone-40(light)/tone-80(dark) rule: our light primary.DEFAULT (#006633) sits at L*=37.4 — 2.4 dE(OKLab) from an M3-style tone-40 derivation on the same hue/chroma (#0e6d39, L*=40.1) — essentially aligned with the M3 rule already. Our dark primary.DEFAULT (#4DB380) sits at L*=66.0 — 12.3 dE(OKLab) from an M3-style tone-80 target (#84d99c, L*=80.1) — dark-mode primary undershoots M3's dark-primary lightness guidance by ~14 L* units. This directly correlates with finding §4 below (marginal APCA on the dark primary button).

4. Contrast matrix (WCAG 2.x + APCA-W3 0.1.9)

Text on surfaces

modetext tiersurfaceWCAGAPCA |Lc|
Lightprimarycard/bg/tertiary17.71 / 16.59 / 15.62104.5 / 99.9 / 95.9
Lightsecondarycard/bg/tertiary7.20 / 6.75 / 6.3585.0 / 80.4 / 76.4
Lighttertiarycard/bg/tertiary5.34 / 5.00 / 4.7176.6 / 72.0 / 68.0
Darkprimarycard/bg/tertiary/elevated14.83 / 17.51 / 11.97 / 12.4398.0 / 99.7 / 94.9 / 95.5
Darksecondarycard/bg/tertiary/elevated12.27 / 14.49 / 9.90 / 10.2884.8 / 86.6 / 81.8 / 82.4
Darktertiarycard/bg/tertiary/elevated8.11 / 9.57 / 6.54 / 6.7960.9 / 62.6 / 57.8 ⚠ / 58.4

All pass WCAG AA body-text (4.5:1) comfortably. Flag: dark textTertiary on surfaceTertiary (57.8) and on elevated (58.4) fall just under APCA's ~60 Lc body-text guidance, despite a generous 6.5–6.8:1 WCAG ratio — a case where APCA catches a real perceptual shortfall WCAG misses on a dark, low-chroma background. Reserve tertiary text on those two surfaces for large/bold text, or lighten dark textTertiary slightly.

Primary-filled button

modetextfillWCAGAPCA
Light#FFFFFF#0066337.12-89.1 (huge headroom)
Dark#131A14#4DB3806.8152.0
ink#006633 on white7.1284.1

Flag: dark-mode primary button text is only Lc 52 — below the ~60 body-text target (ties to the M3 tone-80 gap above: lightening dark primary.DEFAULT toward tone-80 would fix this).

Badges

variantLight WCAGLight APCADark WCAGDark |APCA|
success4.5667.95.7353.5 ⚠
warning4.7869.46.6461.8
error5.1869.37.0760.9
info10.9585.98.4464.6
accent4.6767.95.5052.5 ⚠
neutral5.2270.97.6459.1
muted4.9968.84.9438.3 ⚠⚠

All pass WCAG AA. On APCA, dark-mode muted (38.3) clearly fails even a lax bold-text threshold (~45); success/accent (52–54) sit below the 60 body-text target though likely OK given badge text is bold 10–13px (APCA's own bolder-text tables relax the requirement somewhat, but there's no headroom). This is a genuine spot the WCAG-only badge derivation (documented in status-badge-palette.ts's own header comment, citing Radix step3/11) doesn't catch.

Accent/callout pairs (warm/cool) are all solid: WCAG 5.4–10.95, APCA 64.6–85.9 both modes — sound.

5. Status colors

statuslight OKLCH Hlight Lab hdark OKLCH Hconventional range (brief)
success154.8°153.1°157.5°~140° green
warning74.3°75.8°83.9°70–80° amber
error27.0°33.3°24.0°25–30° red
info301.7°314.1°298.0°~250° blue

Warning and error land squarely in the conventional bands. Success sits a bit warm of "textbook green" (140°) and, more notably, is only ~2° from brand primary's own hue (152.7°) — success and "this is yours/brand" pills are near hue-twins, distinguished only by lightness/chroma/context, not hue. Info is a deliberate departure — TwoMore uses a documented "heritage purple" (#4B0082, hue ~302°) instead of the conventional blue (~250–260°); 43.7° hue delta from a reference info-blue (#2563EB). This is an intentional brand choice already called out in the theme file's own comment, not an error — flagging for awareness that it breaks platform-convention priming for "info."

Colorblind simulation (Machado et al. 2009 matrices, text/ink colors, OKLab ΔE — JND ≈ 2–3):

pairnormal ΔEprotanopia ΔEdeuteranopia ΔE
success–warning15.46.76.9
success–error26.612.54.7
warning–error12.68.42.2
success–info34.930.326.4
warning–info33.429.732.2
error–info30.122.330.3

For deuteranopia (the more common red-green CVD), warning and error nearly collapse into the same color (ΔE 2.2), and success/error drop to ΔE 4.7 — the classic red-green confusion, expected but concretely quantified here. Mitigant: every Badge requires a text label (not a bare color chip) — WCAG 1.4.1 "not by color alone" is satisfied at the badge level.

Real exception found — not colorblind-dependent: WinResultBadge's mode="dot" (recent-match-form strip, match-status-dot.tsx/status-badges.tsx) renders a bare color dot with no label. Its "draw" ($badgeNeutralText #60646C) and "loss" ($badgeMutedText #626570) colors are ΔE 0.73, contrast ratio 1.022:1 in light mode — essentially the identical gray for any viewer, not just colorblind ones. Dark mode is somewhat better (ΔE 13.2, 1.64:1) but still weak. This is a genuine, non-hypothetical usability bug in the dot-mode rendering, independent of the palette-research question.

Badge background fills (pale tints) are mutually near-indistinguishable under CVD simulation (ΔE < 5 for essentially every pair) — but this matches documented intent (badge.tsx: "text alone carries WCAG AA contrast... fill is supplementary"), so not a real gap by itself.

6. Brand green vs. benchmarks

hexOKLCHLab
ours brand600#006633L=.447 C=.116 H=152.7°L*=37.4 C*=44.2
ours dark primary#4DB380L=.693 C=.122 H=158.9°L*=66.0 C*=45.2
Apple system green (light)#34C759L=.730 C=.194 H=147.4°L*=71.1 C*=75.0
Apple system green (dark)#30D158L=.756 C=.208 H=147.0°L*=74.1 C*=80.7
Naver green#03C75AL=.725 C=.201 H=149.5°L*=70.6 C*=77.6
Toss blue 500 (public)#3182F6L=.620 C=.191 H=258.2°L*=55.3 C*=67.0

Hue-wise, our brand (152.7°), Apple green (147.4°), and Naver green (149.5°) all sit within an 8° band — TwoMore's green is squarely in the same hue family Korean/Apple consumer greens use, just at much lower chroma (0.116 vs 0.19–0.21) and much lower lightness (L*37 vs L*71–74). That's a deliberate "forest/Wimbledon" identity choice, not a hue mistake — the seed passes as directionally correct. As fill under white text: WCAG 7.12, APCA 89 — strong. As ink on white: WCAG 7.12, APCA 84 — strong. Toss's blue is only usable here as a methodology reference (an evenly-engineered 50–900 ramp), not a hue comparison — different color family entirely.

7. Proposal — research-based token spec

Keep the brand hue (152.7°) — the data doesn't indicate the seed itself fails; it's directionally aligned with Apple/Naver green and passes every contrast test as both fill and ink.

Proposed brand ladder — fixed hue 152.7°, OKLCH L on an even-ish curve (0.97→0.20), chroma as an explicit bell (peak 0.15 at the step-5/600 position, tapering to 0.02/0.05 at the ends), replacing the current uneven ΔL steps:

stepcurrentproposedΔE(OKLab)
50#E6F4EC#ECF9EF1.5
100#CDE8D8#D2F1DA2.5
200#A5D6B8#AFE3BD3.8
300#75BF95#88D29D5.8
400#3B9A68#55B9789.4
500#147A45#219F5811.2
600 (primary)#006633#0082408.7
700#004D26#00652E7.9
800#003D1F#00481F3.9
900#002E17#0030131.1

All ΔE values are moderate (biggest at the mid-scale, where the current ramp is most uneven) — this is a tuning, not a rebrand. Recommendation: this is a worked methodology example, not a final mandate — the current ramp already reads fine visually; the concrete fix that matters is smoothing the brand300→400 jump and, more importantly, the gray700 kink and the dark-mode gray reversal (see §2), which are the actual defects, not the brand hue/chroma itself.

Proposed gray ladder direction: fixed hue = 152.7° (match brand, drop the "~15°" mischaracterization), even-ish L targets, chroma bell 0.004→0.020→0.010. This pulls the mid-scale (400–600) lighter/less saturated than today by design-intent (today's gray400–600 are unusually dark/saturated for a "neutral" role) — ΔE 12–15 at those steps, meaning this is a bigger, opinionated change the owner should review visually (e.g. via a wireframe) before adopting, versus the brand ladder which is a light tuning.

Semantic tone numbers (M3-style, L* basis): light primary ≈ tone-37 (M3 target tone-40, gap −2.6 dE — already essentially correct); dark primary ≈ tone-66 (M3 target tone-80, gap −12.3 dE — recommend lightening dark primary.DEFAULT toward L*≈75–78 to close the APCA-52 button-text gap and match M3's dark-primary-lightness rule; e.g. #70D5A2, which is already the existing token primary.light in dark mode at L*=78.3 — no new color needed, just reconsider which step fills the "primary button" slot in dark mode).

Status hues: keep warning (74–84°) and error (24–33°) as-is — already in the conventional bands and well-separated from each other and from brand. Keep info's purple as a documented brand choice, but given the ΔE 2.2–4.7 deuteranopia collapse, treat text-labeled badges as the safe usage (already true) and eliminate bare-color-only status encoding, starting with WinResultBadge's dot mode — draw (#60646C) vs loss (#626570) need either divergent hues (not just divergent semantic-token names) or a non-color differentiator (size, fill vs outline, an accompanying glyph) since they are currently ΔE 0.73 apart in light mode.

Fix immediately (near-zero risk, no visual redesign needed):

  • packages/ui/src/tokens.ts warning: '#B45309' vs classic-grass.theme.ts status.warning: '#946200' — these are supposed to be the same value per the tokens.ts file header ("Hex values align with the default Classic Grass theme") but have drifted (ΔE 6.7). Reconcile to one source of truth.
  • docs/twomore_design_system/colors_and_type.css is substantially stale vs. the live tokens (e.g. --brand-500: #10B94E vs actual #147A45; --success: #006633 vs actual #14804A; --warning: #D97706 vs actual #946200; --medal-gold: #FFD700 vs actual #D9A621) — this is the exact "dead-theme-file" risk the project's own research-methodology doc warns about; any wireframe grounded in this CSS file is grounded in the wrong palette.
  • WinResultBadge dot mode: give draw/loss visually distinct tokens (not near-identical grays).
  • Dark-mode badge.muted (APCA 38.3) and badge.success/badge.accent (APCA 52–54): nudge lightness/chroma up slightly; WCAG already passes so this is a small refinement, not a rebuild.
  • Dark-mode textTertiary on surfaceTertiary/elevated (APCA 57.8/58.4): lighten text.tertiary (dark) marginally or restrict it to large/bold text on those two surfaces.

7.1 Concrete ladders (v2, 2026-09-06 — rendered on the board, PART 10)

Computed by palette-proposal.mjs (OKLCH → sRGB, gamut-clipped by chroma reduction), hue fixed at 152.7° for every ladder. The v1 gray targets in §7 put textTertiary on gray500 (L 0.62 → 3.60:1 on white, fails AA); v2 moves the two text tiers one step down the ladder and re-spaces gray600–900 so each semantic text tier keeps ≥ 4.5:1 on its surface.

brandproposedgray lightproposedgray dark (designed)proposed
50#E5FAEA50#F8FBF950#090C0A
100#C8EED2100#F0F5F1100#111512
200#9DDCAF200#DFE7E1200#1A201B
300#6EC489300#CAD4CC300#262D28
400#3BA764400#A8B5AB400#374039
500#058A48500#7E8A80500#515B53
600#006D37600#626C64600#707B72
700#015228700#4F5851700#919B94
800#003B1B800#393F3A800#B5BDB7
900#022711900#232824900#D7DCD8
950#0D100E950#EFF3F0

Semantic map and contrast on the canonical surface:

tokenlightratiodarkratio
textgray95019.13dark95014.81
textSecondarygray7007.38dark90011.94
textTertiarygray6005.46dark8008.63
borderStronggray5003.60dark6003.76
bordergray300dark500
borderSubtlegray200dark300
backgroundgray50dark50
card#FFFFFFdark200
surfaceSecondarygray100dark300
primarybrand600#70D5A2
onPrimary#FFFFFF6.48dark5010.98
primarySubtlebrand50dark300

Status: awaiting owner review on the board; app tokens (tokens.ts, the Classic Grass theme) are untouched until then.

Ruling (2026-09-10)

The owner delegated this decision. Read this section before the proposal below, and note the correction in it — it is the more useful half.

Correction: this document's own status line was wrong

§7.1 ends with "awaiting owner review on the board; app tokens (tokens.ts, the Classic Grass theme) are untouched until then." That was false within hours of being written. Commit c9bcaac8, dated the same day and titled "OKLCH palette live", shipped the full §7.1 brand and light-gray ladders. They have been in front of the owner on device ever since.

I ruled against adopting the swap while believing the tokens were untouched, and that ruling was carried out as a revert of a palette that had been live for four days. Reverting the status quo is the restyle; adopting what already ships is not. The revert has been undone and tokens.ts plus the Classic Grass light ladders match c9bcaac8 byte for byte again.

The lesson is the one this repo already writes down and keeps re-learning: verify what actually runs, never what a document claims about the code. A status line is a claim, and this one outlived its truth by four days. The ladders live in tokens.ts; that file is the answer to "what palette are we on", not this document.

What is settled

In force, unchanged from what ships: the §7.1 brand and light-gray ladders. Not because the proposal persuaded a review, but because they are the status quo and no defect argues against them.

Adopted on top, each a defect with a named cause:

  • The dark gray ladder is now independently designed in all four themes, instead of a literal reversal of the light hexes. The primary dark scale and the status-badge dark palette each already had a designed ladder; the gray scale had neither, which is the methodology inconsistency §2 identifies. Dark mode is user-selectable and defaults to system, so this reached real users.
  • Every "Fix immediately" item in §7: the warning drift between tokens.ts and the themes is now structurally impossible rather than merely corrected (the themes import the token); the dark primary-button APCA gap is closed in all four themes by re-slotting to a step each ramp already contained, no new colour (52.0 → 69.4 in Classic Grass, and 45.7 → 65.9 in the worst of the others); dark badge.muted moved 38.3 → 48.1 with success/accent following; dark textTertiary on the two darker surfaces is lifted; and WinResultBadge no longer distinguishes draw from loss by colour alone, at ΔE 0.73, but by fill versus outline.

Rejected: nothing. The earlier draft of this ruling rejected the ladder swap. That rejection rested on the false premise above and is withdrawn.

What replaces "review it by eye"

scripts/check-color-contrast.mjs runs in yarn check. It resolves all four registered themes in both modes, derives the pairs from theme structure rather than a hand-written list so a new token or theme is covered automatically, gates WCAG 2.2 hard, reports APCA, and names its exemptions (the fixed Naver brand mark under the 1.4.3 logotype carve-out) instead of lowering a threshold. It ships with a self-test that mutates a passing pair and asserts the gate catches it.

It immediately found what a hand audit could not: the 2026-09-06 pass fixed Classic Grass only, and the other three themes still carry its defects.

docs/twomore_design_system/colors_and_type.css, which §7 flagged as substantially stale and therefore a false ground for any wireframe, is now generated from the live tokens and gated for drift.

8. Already sound (no action needed)

  • Brand chroma curve: correctly bell-shaped, peaking at brand500 — matches Radix/M3 convention.
  • Text-tier ladder (primary→secondary→tertiary) is a genuine, correctly-inverted contrast ladder in both light and dark — not swapped hex.
  • Dark-mode primary ramp (p50…p900) is an independently tuned scale, not a reversal of the light scale — correct methodology (unlike gray).
  • All light-mode text/surface pairs and nearly all dark-mode pairs clear both WCAG AA and APCA body-text thresholds with real headroom.
  • Badge palette's own derivation is already documented and WCAG-verified against Radix step3/step11 roles (status-badge-palette.ts header comment) — the right process, just missing an APCA pass.
  • Warning and error status hues already sit in conventional ranges and are well-separated from brand and from each other.
  • Brand green passes comfortably as both a white-text fill (WCAG 7.12, APCA 89) and as ink on white (WCAG 7.12, APCA 84).
  • Every status/recruitment badge ships with a text label, not a bare color chip — satisfies "not by color alone" at the component level everywhere except the one dot-mode exception noted above.

Sources cited

  • Material Design 3 color system (tone ≈ L* in CIELAB; tone-40 light-primary / tone-80 dark-primary convention) — m3.material.io/styles/color/system/how-the-system-works
  • WCAG 2.2 contrast (1.4.3 AA 4.5:1 body / 3:1 large, 1.4.11 non-text 3:1, 1.4.1 not-by-color-alone) — w3.org/TR/WCAG22
  • APCA-W3 0.1.9 (SAPC) — github.com/Myndex/apca-w3 / w3.org/WAI/GL/task-forces/silver/wiki/Visual_Contrast_of_Text_Subgroup
  • Radix Colors scale rationale (12-step, bg/text role pairs) — radix-ui.com/colors/docs/palette-composition/scales
  • Tailwind CSS v4 OKLCH color note — tailwindcss.com/blog/tailwindcss-v4 (oklch palette rationale)
  • Björn Ottosson, OKLab — bottosson.github.io/posts/oklab (Euclidean distance in OKLab as a practical ΔE)
  • Machado, Oliveira & Fluet 2009, physiologically-based CVD simulation matrices

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