From b1cb268807898bf44760cadf12cd9c24870555ea Mon Sep 17 00:00:00 2001 From: Paddock Date: Sun, 9 Aug 2026 18:16:48 -0400 Subject: [PATCH] fix(web): restore the dark theme's warmth, and the terracotta accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token pass neutralised the dark ground. The assistant prose card went from chroma 0.017 to 0.0075, at hue 88 rather than the old ramp's 67-77, and a palette that read as brown started reading as grey. Its LIGHTNESS barely moved (0.232 vs 0.256) — worth stating because the complaint that prompted this was "further from black", and the measurement says the opposite. Warmth, not depth. `--surface` and `--surface-raised` are restored to the pre-token UI's values, sampled from a screenshot of it rather than recovered from the config, then verified against rendered pixels: the page paints #141210 and the prose card #28221a, byte-identical to the reference. Accent text was lifted 26% toward white, which turns terracotta into salmon (#c2603c -> #d58a6f) — the change people mean when they say "the orange changed"; `--accent` itself never moved. The raw accent cannot simply come back: it measures 3.77:1 on the prose card and 3.5:1 on the active surface, both below AA, so restoring it literally would make contrast worse rather than better. The lift is now 14%, the least that clears 4.5:1 against EVERY dark surface — `--surface-active` is the binding constraint and 12% misses it by 0.05. Fills and borders carry no text and are restored exactly, which needs the mix done in sRGB rather than OKLab: the old sub-agent strip was `bg-accent/10` composited over the page, and only an sRGB mix of the same two endpoints lands on that same #251a14. `resolveColor` gains `in srgb` so the contrast suite can evaluate them. The three interaction surfaces are deliberately NOT restored to their old hexes. The old `paddock-800` was a border colour that some places reused as a fill; adopting it as `--surface-active` drops `--text-subtle` to 3.75:1. They keep the ladder's lightness and take only its warmth. All 1721 web tests pass, including the 79 contrast pairs. Light mode untouched. Co-Authored-By: Claude --- .changeset/dark-ground-warmth.md | 25 +++++++ packages/web/src/lib/color.ts | 25 ++++++- packages/web/src/styles/theme-foundation.css | 70 +++++++++++++++++--- packages/web/src/styles/tokens.css | 25 ++++--- 4 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 .changeset/dark-ground-warmth.md diff --git a/.changeset/dark-ground-warmth.md b/.changeset/dark-ground-warmth.md new file mode 100644 index 00000000..4703050b --- /dev/null +++ b/.changeset/dark-ground-warmth.md @@ -0,0 +1,25 @@ +--- +"@paddock/web": patch +--- + +Restore the dark theme's warmth. The token pass neutralised the dark ground — +the assistant prose card's chroma fell from 0.017 to 0.0075 at hue 88 rather +than the old ramp's 67–77 — so a palette that read as brown started reading as +grey. `--surface` and `--surface-raised` are back to the pre-token UI's measured +values (`#141210`, `#28221a`), sampled from a screenshot of it rather than +recovered from the config, and verified against rendered pixels. + +Accent text in dark mode was being lifted 26% toward white, turning the +terracotta into a salmon (`#c2603c` → `#d58a6f`). The lift is now the minimum +that clears 4.5:1 against **every** dark surface — 14%, giving `#cd7758`. The +raw accent cannot be restored as text: it measures 3.77:1 on the prose card and +3.5:1 on the active surface, so a literal restoration would have made contrast +worse, not better. + +Accent fills and borders are restored exactly, which required mixing them in +sRGB rather than OKLab: the old sub-agent strip was `bg-accent/10` composited +over the page, and only an sRGB mix lands on that same pixel (`#251a14`). +`resolveColor` in `lib/color.ts` gains `in srgb` support so the contrast suite +can evaluate those tokens. + +All 79 contrast pairs still pass in both modes. Light mode is untouched. diff --git a/packages/web/src/lib/color.ts b/packages/web/src/lib/color.ts index fef2b551..fb3d3196 100644 --- a/packages/web/src/lib/color.ts +++ b/packages/web/src/lib/color.ts @@ -218,8 +218,9 @@ export function resolveColor(value: string, vars: Record, depth const mixBody = callBody(v, "color-mix"); if (mixBody !== null) { const [space, first, second] = splitArgs(mixBody); - if (space.trim() !== "in oklab") { - throw new Error(`only \`in oklab\` color-mix is supported, got: ${space}`); + const spaceName = space.trim(); + if (spaceName !== "in oklab" && spaceName !== "in srgb") { + throw new Error(`only \`in oklab\` and \`in srgb\` color-mix are supported, got: ${space}`); } const one = splitPercent(first); const two = splitPercent(second); @@ -232,7 +233,7 @@ export function resolveColor(value: string, vars: Record, depth const w = (p1 as number) / total; const ca = resolveColor(one.color, vars, depth + 1); const cb = resolveColor(two.color, vars, depth + 1); - return mixOklab(ca, cb, w); + return spaceName === "in srgb" ? mixSrgb(ca, cb, w) : mixOklab(ca, cb, w); } throw new Error(`unsupported colour value: ${v}`); @@ -245,6 +246,24 @@ function splitPercent(s: string): { color: string; pct: number | null } { return { color: s.trim().slice(0, m.index).trim(), pct: Number(m[1]) / 100 }; } +/** + * Interpolate two colours in gamma-encoded sRGB, `w` being the weight of `a`. + * + * Not a worse OKLab — a DIFFERENT answer, and sometimes the one being asked for. + * `color-mix(in srgb, X 10%, Y)` is what an alpha-composited `bg-X/10` over `Y` + * evaluates to, so a token restoring a pre-token UI's translucent fill has to + * mix here to land on the same pixel. The two spaces disagree by enough to see: + * the sub-agent strip's fill differs by ~3 in each channel between them. + */ +function mixSrgb(a: Rgba, b: Rgba, w: number): Rgba { + return { + r: a.r * w + b.r * (1 - w), + g: a.g * w + b.g * (1 - w), + b: a.b * w + b.b * (1 - w), + a: a.a * w + b.a * (1 - w), + }; +} + /** Interpolate two colours in OKLab, `w` being the weight of `a`. */ function mixOklab(a: Rgba, b: Rgba, w: number): Rgba { const la = linearSrgbToOklab(decodeSrgb(a.r), decodeSrgb(a.g), decodeSrgb(a.b)); diff --git a/packages/web/src/styles/theme-foundation.css b/packages/web/src/styles/theme-foundation.css index 73641383..95559f25 100644 --- a/packages/web/src/styles/theme-foundation.css +++ b/packages/web/src/styles/theme-foundation.css @@ -101,28 +101,76 @@ --accent: 194 96 60; --accent-600: 168 78 47; --accent-700: 138 63 38; - --surface: oklch(0.187 0.006 88); - --surface-raised: oklch(0.232 0.0075 88); - --surface-sunken: oklch(0.148 0.005 88); - --surface-hover: oklch(0.245 0.008 88); - --surface-active: oklch(0.268 0.009 88); - --surface-selected: oklch(0.258 0.01 88); + /* The dark ground is WARM, and that is the theme's character rather than a + rounding of it. The first token pass neutralised it — chroma 0.0075 on the + raised surface against the old UI's 0.0170, at hue 88 rather than the old + ramp's 67-77 — and the result reads as grey-brown where it used to read as + brown. Restored to the measured values of the pre-token UI, sampled from a + screenshot of it rather than recovered from the config, so what is written + here is what was actually on screen: + + surface #141210 was `bg.dark` + surface-raised #28221a was `paddock-900` — the assistant prose card + + Note the lightness barely moved (raised 0.232 -> 0.256). The complaint that + prompted this was "further from black", but the measurement says otherwise: + what changed is CHROMA, less than half of what it was. Warmth, not depth. + + The three interaction surfaces are deliberately NOT restored to their old + hexes. The old UI's `paddock-800` (#3e3528) was a BORDER colour that a few + places also used as a fill; adopting it as `--surface-active` lifts the + ladder's top by 0.067 L and drops `--text-subtle` on it to 3.75:1, below + AA. So they keep the token ladder's lightness and take only its warmth — + chroma 0.012-0.014 at hue 75 instead of 0.008-0.009 at 88. Same reasoning + for `--surface-sunken`: the old `paddock-950` was LIGHTER than the page it + sat on, which is not what a sunken surface means here. */ + --surface: oklch(0.184 0.0052 67.5); + --surface-raised: oklch(0.256 0.017 74.8); + --surface-sunken: oklch(0.152 0.006 75); + --surface-hover: oklch(0.245 0.012 75); + --surface-active: oklch(0.268 0.014 75); + --surface-selected: oklch(0.258 0.013 75); --overlay: oklch(0 0 0 / 0.55); --overlay-strong: oklch(0 0 0 / 0.86); - --text: oklch(0.93 0.012 88); + --text: oklch(0.934 0.0124 91.5); --text-muted: oklch(0.718 0.015 88); --text-subtle: oklch(0.652 0.016 88); --text-on-solid: oklch(1 0 0); --border-subtle: oklch(0.28 0.009 88); --border: oklch(0.335 0.011 88); - --border-strong: oklch(0.525 0.014 88); + --border-strong: oklch(0.55 0.016 76); --accent-color: rgb(var(--accent)); --accent-solid: rgb(var(--accent-600)); --accent-solid-hover: rgb(var(--accent-700)); /* darker, not lighter: hover must raise the label's contrast — see tokens.css */ --accent-fg: oklch(1 0 0); - --accent-text: color-mix(in oklab, rgb(var(--accent)) 74%, white); - --accent-soft: color-mix(in oklab, rgb(var(--accent)) 16%, var(--surface)); - --accent-border: color-mix(in oklab, rgb(var(--accent)) 45%, var(--surface)); + /* Accent text was being lifted 26% toward white, which turns the terracotta + into a salmon (#c2603c -> #d58a6f) — a visibly different colour, and the + one people mean when they say "the orange changed". `--accent` itself never + moved; only its derivations did. + + The lift cannot simply be removed, though, and it is worth writing down why + rather than rediscovering it. Measured against the restored warm ground: + + raw #c2603c 3.77:1 on surface-raised, 3.5:1 on surface-active + 86% lift 4.77:1 4.60:1 — the floor, everywhere + 74% lift 5.77:1 5.5:1 — passes, but is salmon + + So the old orange as SMALL TEXT was never AA on this ground — restoring it + literally would make contrast worse, which is the opposite of the complaint + that prompted the change. 86% is the least lift that clears 4.5:1 against + EVERY dark surface, not just the raised one: `--surface-active` is the + binding constraint and 88% misses it by 0.05. As close to the original + terracotta as the floor permits. Fills and borders + below carry no text and are restored exactly. */ + --accent-text: color-mix(in oklab, rgb(var(--accent)) 86%, white); + /* Mixed in sRGB at the pre-token UI's own alphas, not in oklab at higher + percentages. The old sub-agent strip was literally `bg-accent/10` over the + page, and 10% of #c2603c over #141210 composites to #251a14 — which is the + exact pixel measured in the reference screenshot. Matching the mix SPACE + matters as much as the percentage: oklab interpolation of the same two + endpoints lands somewhere else. */ + --accent-soft: color-mix(in srgb, rgb(var(--accent)) 10%, var(--surface)); + --accent-border: color-mix(in srgb, rgb(var(--accent)) 30%, var(--surface)); --success-solid: oklch(0.535 0.1252 158); --success-fg: oklch(1 0 0); --success-text: oklch(0.79 0.1268 158); diff --git a/packages/web/src/styles/tokens.css b/packages/web/src/styles/tokens.css index 68c18a0d..19b87304 100644 --- a/packages/web/src/styles/tokens.css +++ b/packages/web/src/styles/tokens.css @@ -154,23 +154,26 @@ .dark { color-scheme: dark; - --surface: oklch(0.187 0.006 88); - --surface-raised: oklch(0.232 0.0075 88); - --surface-sunken: oklch(0.148 0.005 88); - --surface-hover: oklch(0.245 0.008 88); - --surface-active: oklch(0.268 0.009 88); - --surface-selected: oklch(0.258 0.01 88); + /* Warm dark ground — see theme-foundation.css for the measurement these came + from. Kept in lockstep with that file: foundation was lifted out of here + verbatim, and the two drifting apart is a bug waiting to happen. */ + --surface: oklch(0.184 0.0052 67.5); + --surface-raised: oklch(0.256 0.017 74.8); + --surface-sunken: oklch(0.152 0.006 75); + --surface-hover: oklch(0.245 0.012 75); + --surface-active: oklch(0.268 0.014 75); + --surface-selected: oklch(0.258 0.013 75); --overlay: oklch(0 0 0 / 0.55); --overlay-strong: oklch(0 0 0 / 0.86); - --text: oklch(0.93 0.012 88); + --text: oklch(0.934 0.0124 91.5); --text-muted: oklch(0.718 0.015 88); --text-subtle: oklch(0.652 0.016 88); --text-on-solid: oklch(1 0 0); --border-subtle: oklch(0.28 0.009 88); --border: oklch(0.335 0.011 88); - --border-strong: oklch(0.525 0.014 88); + --border-strong: oklch(0.55 0.016 76); /* On a dark canvas the raw accent already clears 4.5:1 as a fill, but not as TEXT at small sizes against `surface-raised` — so accent text is lightened @@ -194,9 +197,9 @@ --accent-solid: rgb(var(--accent-600)); --accent-solid-hover: rgb(var(--accent-700)); --accent-fg: oklch(1 0 0); - --accent-text: color-mix(in oklab, rgb(var(--accent)) 74%, white); - --accent-soft: color-mix(in oklab, rgb(var(--accent)) 16%, var(--surface)); - --accent-border: color-mix(in oklab, rgb(var(--accent)) 45%, var(--surface)); + --accent-text: color-mix(in oklab, rgb(var(--accent)) 86%, white); + --accent-soft: color-mix(in srgb, rgb(var(--accent)) 10%, var(--surface)); + --accent-border: color-mix(in srgb, rgb(var(--accent)) 30%, var(--surface)); --success-solid: oklch(0.535 0.1252 158); --success-fg: oklch(1 0 0);