Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/dark-ground-warmth.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 22 additions & 3 deletions packages/web/src/lib/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ export function resolveColor(value: string, vars: Record<string, string>, 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);
Expand All @@ -232,7 +233,7 @@ export function resolveColor(value: string, vars: Record<string, string>, 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}`);
Expand All @@ -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));
Expand Down
70 changes: 59 additions & 11 deletions packages/web/src/styles/theme-foundation.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 14 additions & 11 deletions packages/web/src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down