feat(web): four runtime themes and an accent picker that cannot fail AA - #780
Merged
Conversation
…mitives Foundation for the design system. No component migrated yet — the compat palette in index.css keeps the existing 1017 raw `paddock-N` uses rendering while the sweep lands. - Tailwind 3.4.13 -> 4.3.3 via @tailwindcss/vite; tailwind.config.js and postcss.config.js deleted, autoprefixer/postcss dropped. `can-hover:` and class-based `dark:` preserved as @custom-variant. - styles/tokens.css: OKLCH semantic tokens, light and dark ramps derived separately (the old palette reused one ramp for both, which is why light mode measured 3.75:1 at its most-used token). - lib/color.ts + styles/tokens.test.ts: parses the real stylesheet and asserts every text-on-surface pair at 4.5:1 in both modes, plus gamut and the PADDOCK_BRAND_ACCENT seam (#34). - components/ui/: Button, Card, Section, EmptyState, Input, Textarea, Select, Field, Label, Hint, Toggle, Checkbox, Chip, StatusDot, Callout, Dialog, Menu.
The sweep: 1017 raw `*-paddock-N` uses -> 0, 200 arbitrary `text-[Npx]` -> 0, 722 hand-written `dark:` variants -> 0. Every colour in every route and pane now names a meaning, and the light/dark swap happens once, at `:root`/`.dark`. The compat palette is deleted and the guard test asserts it stays deleted. Deliberate, listed changes beyond the pure refactor: - Config no longer reflows while you type. `isWide()` was called with the live per-keystroke value, so crossing 38 characters jumped a field from half to full width and re-packed every field after it. It now reads the saved baseline, so width depends on the field's identity, not the in-flight edit. - Home's five stacked grey-italic boxes become `EmptyState`s. - Dialogs get a focus trap and focus restoration; menus get arrow-key nav. - Chat messages no longer animate in; `prefers-reduced-motion` is honoured. - `text-ink-subtle` was a dead class (never defined), so ~12 spans in the transcript were silently inheriting their parent's colour. They now render. - The HTML-preview iframes are pinned light (`html-preview`): they render an arbitrary document that assumes a light page, so theming them would leave dark text on a dark surface.
…row clicks The type scale carries line-heights; the arbitrary `text-[11px]` it replaced did not, and inherited 20px from the row's `text-sm`. That shortened the chat row from 58px to 54px, which moved the row's vertical centre — where a click lands — 1px INSIDE the absolutely-positioned action strip, so clicking a chat hit an invisible `opacity-0` rename button instead of opening the chat. Measured, not guessed: on main the centre clears the strip by exactly one pixel. `leading-5` restores that. The comment says so, because the next person to tighten this line-height will otherwise rediscover it. The 1px margin is pre-existing and far too tight — the real fix is that the invisible buttons should not intercept pointer events at all, which also means a touch user currently cannot tap the bottom-right of a row without hitting a hidden action. Out of scope for a refactor; filed for follow-up. Caught by journey-chat.spec.ts's resume test.
The primitives shipped with one consumer (HomePane's EmptyState) and no tests, which is a fair thing to object to. These cover what the primitives do that the ad-hoc markup they replace did NOT: Dialog's focus trap and focus restore, Menu's arrow-key roving and outside-click dismissal, Field's aria-describedby wiring, Button's loading/disabled coupling. Class strings are deliberately not asserted — a direction is expected to change every one of them, and colour is covered by ratio rather than by name in tokens.test.ts. Also: the colour-discipline rules scanned raw lines, so a comment EXPLAINING a banned pattern tripped the rule that motivated it — the new note on the chat row's timestamp quotes the `text-[11px]` it replaced and failed the build. They now blank block comments in place (newlines preserved, so line numbers stay true) and drop `//` lines. Verified both directions: prose is ignored, and a real `text-[13px]` in a component is still reported at its exact line.
Extracts each design direction's token set into a `[data-theme]` block so one build carries all four (plus foundation) and switches between them live, and replaces the raw-hex brand seam with a hue-only picker that solves lightness for a target contrast rather than accepting one. - scripts/build-themes.mjs lifts each direction's tokens.css out of its own worktree into packages/web/src/styles/themes.css. Structural work is deliberately left behind: a runtime theme is tokens + fonts. - All five themes' faces are declared together; @font-face is lazy so this costs nothing until a theme is active. The one eager fetch, the preload in index.html, is now emitted per-theme. - lib/accent.ts takes only a hue: it reads the theme's own achieved contrast ratios as targets, keeps the theme's chroma, solves lightness, clamps chroma to sRGB, then measures what the theme actually derived and repairs anything below the floor. That last step also fixes the dark-mode hover inversion the review found in three directions. - The picker names no colour theory. A spectrum strip painted with the real solved colours, ten named swatches, and a ground tint. Co-Authored-By: Claude <noreply@anthropic.com>
…nding Three defects found by measuring the rendered page across 240 theme x mode x hue combinations rather than trusting the solver's own arithmetic: - `--accent` is stored as a bare `R G B` triple, which is not a valid CSS colour, so every read of it failed and the solver fell back to white — the whole spectrum strip rendered blank. Wrap the triple before parsing. - Two changes inside one task (pick a theme, then pick a colour) both read the same pre-render state object, so the second silently reverted the first. The appearance is now mirrored in a ref. - The channels are written as 8-bit integers and a theme may then run them through `color-mix()`; the rounding moved 3 of 110 combinations from a solved 4.50 to a rendered 4.48. Solve to 4.62 and check against 4.50. Measured after: 240/240 clear AA, 0 hover inversions, worst button 4.60:1, worst accent text 4.57:1. Ground tint costs at most 0.012 of contrast. Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
19c5330
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9a758e2e.paddock-7u2.pages.dev |
| Branch Preview URL: | https://design-theme-picker.paddock-7u2.pages.dev |
…visible A modal was the wrong container for a live preview. `Dialog` lays a 42-55% scrim plus a `backdrop-blur-sm` over the whole viewport, so the app you are recolouring is greyed out and frosted at the exact moment you want to watch it change — the preview did not work at all. - `AppearanceDock`: no backdrop, no focus trap, no scroll lock. It parks over the sidebar and leaves the main pane visible AND clickable, so you can scroll a chat with the picker open. Escape, the close button and the sidebar button all dismiss it; an outside click deliberately does not, because those are the interactions being preserved. - Compact layout: the five themes become rows (swatch beside name), which is what gets the panel under the viewport height instead of past it. - The theme previews now wear the PICKED colour, each solved for its own theme, rather than their shipped accent. `applyAccent` already took any element. And one real bug this surfaced: `readContext` cleared the inline accent to read a theme's defaults and never restored it, so repainting the spectrum strip reverted the whole app to the theme's shipped accent a frame after the picker had applied the chosen one. Reads now save and restore. Re-measured: 240/240 combinations clear AA, 0 hover inversions. 1079/1079 tests. Co-Authored-By: Claude <noreply@anthropic.com>
Ed's question was the right one: there is no good reason this lives anywhere else. Putting it where every other setting lives also solves the problem the dock existed to work around, because the config page IS a real, token-dense screen — cards, inputs, chips, a primary button, a save bar — so the picker previews itself without covering anything. - `AppearancePanel` gains a `wide` layout: five themes across, the spectrum strip and the ground tint side by side. The section is ~370px tall against the dock's 742. - `AppearanceDock` is deleted, and the sidebar entry is now a plain link to /config rather than a second surface. - Appearance renders ABOVE the restart banner, behind its own closing rule, and contributes nothing to dirtyKeys, the save payload or the configVersion check. That separation is deliberate and documented on APPEARANCE_GROUP: the rest of the page edits a file that is frozen at boot and needs a restart, this is a per-device preference that applies on click, and v0.51.0 already showed what stacking two lifecycles behind one save bar reads like. - The filter bar matches it on theme/colour/accent/tint and the theme names; "modified only" hides it, since that question is about the config file. - A rail section with no server fields no longer renders a "0" count. 1079/1079 tests. Co-Authored-By: Claude <noreply@anthropic.com>
… preview Two problems, one reported as "the accent colour doesn't appear to make any difference when I click it". 1. A DERIVED token, once repaired, was stuck. `readContext` restores whatever was inline before it looked — correct, because a read must not mutate — but that includes any token a previous run repaired, and those are flat hexes that outrank the theme's own derivation. Nothing overwrote one unless the repair fired again, so picking Teal and then Violet gave a violet button with a teal hover. `applyAccent` now clears before it applies. The contrast audit could not see this: a stale accent is still a readable one. It asserted the accent stayed legible and never that it was the colour you asked for. The new check is that each of the five derived accent tokens takes a DISTINCT value at each of 12 hues — 120 samples across 5 themes and both modes, 0 stuck. 2. /config paints only SEVEN elements with `--accent-solid`, and the largest is the Save button, which is muted while the form is clean. So the accent was applying correctly and looking like nothing had happened — which is a broken control however correct its output. The section now carries a Preview strip built from the real primitives (Button, Chip, StatusDot) plus a swatch of the hover fill, since `:hover` cannot be forced and the hover step is exactly where the contrast inversion lived. Section is 462px. 1079/1079 tests. Co-Authored-By: Claude <noreply@anthropic.com>
72 swatch buttons could be clicked but not scrubbed. It is now one continuous track — still painted with the real solved colours, since the gradient stops ARE `accentSwatches()` output, with the first stop repeated at the end because hue wraps and a gradient does not. Dragging applies on every frame; it commits on release. Getting that to 60fps took two changes, both of which are about style recalcs rather than about the colour maths: - `readAccentContext` is now exported and captured once per drag. Nothing in it depends on the picked hue — it is the theme's chroma, polarity and achieved ratios — so re-reading it per frame was clearing six inline properties and reading eight more, each read-after-write forcing a full recalc. - The repair pass reads every derived token up front and then writes, instead of interleaving; and a `live` frame skips the final read-back that exists only to feed the pre-paint cache. Measured on the running page: idle 16.7ms/frame, dragging 16.8ms/frame — no dropped frames, against 33.3ms before. 60 distinct accents over a 60-step drag. Persisting is deliberately deferred to pointerup: the five theme previews each run their own solve, and having them flicker through a scrub is worse than letting them settle. Also: keyboard support (arrows / shift / PageUp / Home / End), `role="slider"` with `aria-valuetext` overriding the number so a screen reader hears a colour name, and a label that reads continuously — "Teal", then "between Teal and Sky" — rather than blinking to "Custom" between the named colours. Re-audited: 120 hue samples over 5 themes x 2 modes, 0 below AA, 0 hover inversions, 0 stuck tokens. 1079/1079 tests. Co-Authored-By: Claude <noreply@anthropic.com>
It was scaffolding for verifying the solver, and it ended up in the one UI whose whole constraint is that it must not expose colour theory. Nobody picking a colour needs to be told a ratio, and "Readable in this theme and mode" is reassurance about a problem the user was never given. Only the display goes. The repair pass still measures every derived accent token against its floor and still clamps anything under it — the guarantee is enforced at runtime, not reported at one. `AccentReport.checks` stays for the browser audits. Section is 428px. 1079/1079 tests. Co-Authored-By: Claude <noreply@anthropic.com>
A cinematic display rather than a corporate-futurist one: near-black blue ground, structure drawn in hairlines instead of surfaces, a lit cyan plate that carries dark type, and letterspaced condensed micro-labels over calm values. Saira + Martian Mono, both variable on width so the condensing is a real instance. One new file plus one registry line, per the theme-picker contract; the only shared edits are the import, the two @font-face rules and the index.html preload map. Every rule the theme adds is gated on its own [data-theme="scifi"] selector and asserted so by its guard. Ships its own contrast guard: tokens.test.ts parses tokens.css and so only ever certifies the foundation palette.
A 2D-vector fantasy-game UI (denizen-games' "Parchment" kit) taken seriously
as a developer tool: wine chrome, parchment on dusty rose, corner brackets,
an old-style serif with a real italic.
Four things carry the direction, and they are the ones that keep it out of
the "warm cream + serif" default-AI cluster that vellum already sits next to:
- WINE CHROME, structurally. The sidebar is a deep burgundy column in both
modes. Implemented by REDECLARING the token set on the `<aside>` rather
than by painting it and hand-fixing foregrounds: custom properties resolve
per element, so every `bg-surface-*` / `text-fg-*` / `border-edge*` utility
in that subtree re-points at the wine ramp with no component changed.
(`color:` is set there too, and is not redundant — `@layer base` sets
`body { color: var(--color-fg) }`, resolved at BODY, so a row carrying no
colour utility inherits the page's dark ink and vanishes on the wine.)
- TWO WARM SURFACES THAT ACTUALLY SEPARATE. `main` puts 0.027 of perceptual
lightness between a card and the page; this puts 0.080 light / 0.060 dark.
- CORNER BRACKETS, as eight background gradients on the panel itself. Not a
pseudo-element: `::before` would need `position: relative`, which silently
re-parents every absolutely-positioned descendant. Major panels only.
- ALEGREYA + ALEGREYA SC + COURIER PRIME (OFL, self-hosted woff2). Italic on
every descriptive line, small caps on the micro-labels the app sets in
tracked uppercase, old-style figures everywhere `.tabular` does not apply.
Surgical by construction — three of us are adding themes off this branch. One
new CSS file, one `@import`, one `THEMES` entry, one font-preload entry. No
existing theme's values are touched and `themes.css` (generated) is untouched.
All decorative CSS is gated on `[data-theme="parchment"]` and inert elsewhere.
`theme-parchment.test.ts` is the same contrast suite `tokens.test.ts` runs,
pointed at these blocks — which `tokens.test.ts` does not cover, since it
parses only `tokens.css`. Plus the two things particular to this theme: every
foreground inside the wine scope, and the card-on-canvas separation claimed
above. 124 assertions; `tokens.test.ts` stays green at 84.
No changeset: this depends on unmerged branches and is not meant to land as-is.
Three things the numbers could not have told me: - The accent moved from H197 to H212. On screen H197 read as a TEAL, which is instrument's own hue (202) and made the two look adjacent in a way the token table did not. H212 is colder and bluer, 10 degrees clear. - Light mode's ground drops to L 0.955 and picks up chroma (0.007 -> 0.012). At 0.968 the canvas and the white panels were nearly the same colour and the whole screen read as one white field. Now the panels read as LIT plates on a cool ground — the inverse of dark, where they are outlined on a flat one. Hairlines darken to L 0.71 to match. - The heading rule is narrowed to h1/h2. These rules are unlayered and so outrank Tailwind, and h3/h4 here are small UI headings whose semibold is the hierarchy. Guards still green (84 + 84), and all ten named hues still re-solve at full chroma against the new plate/ink ratio of 8.03.
This was referenced Aug 8, 2026
feat(web): parchment — a sixth runtime theme (wine chrome, corner brackets, an old-style serif)
#781
Closed
An eighth runtime theme, added the way the theme system asks for one: a new file plus three registry lines. No restructuring, no other theme's values touched, nothing in `scripts/build-themes.mjs` — three of us are merging into one branch. Ed's brief was "a 1337 hacker kinda vibe", and he waived the craft floor's ban on a near-black canvas with one bright acid-green accent for this theme and no other. The waiver is not a licence to ship the generic version, so the reference is specific: VT220 P1 phosphor, the 16-colour ANSI palette as the semantic set (green ok / yellow attention / red failure / CYAN informational, because ANSI blue is unreadable on black / magenta structure), IBM 3270 and ncurses panel chrome, and Iosevka everywhere including the chrome — the one theme where a monospace UI is correct rather than affected. Light mode is not that palette inverted. It is a LINE PRINTER: ribbon ink on near-neutral paper, with the green concentrated in `--surface-sunken` so every well, input and code block lands on a greenbar bar — which is what greenbar stock actually is, white paper with green bars printed on it. Contrast is NOT waived; mid-grey comments at 2:1 is how terminal themes usually fail. `theme-terminal.test.ts` is the same assertion set as `tokens.test.ts` pointed at this file (84 green), plus hover-direction, the brand seam and an anti-convergence guard against the existing `phosphor`. Beyond the static guard, 44 rendered combinations — 11 hues x 2 tints x 2 modes, measured by painting each token rather than parsing it — came back with nothing below AA. Body text is a green-tinted near-white, not saturated #0F0: the green belongs to the chrome and the short strings, because long agent prose is what Paddock is for. Chrome is four rules, all gated on `[data-theme="terminal"]` and inert for the other seven themes: reverse-video `::selection`, a block caret, ncurses corner ticks painted as background gradients (no pseudo-elements, so no component gains a stacking context), and a dark-mode phosphor bloom on accent text. A fifth — CRT raster lines on the reverse-video fills — was built, previewed and taken back off; the file records why. Fonts: Iosevka 400/700, SIL OFL, self-hosted, subset to latin + box drawing + blocks + geometric shapes, 14 KB each. Two weights only, ranges stretched, so `font-medium` renders regular and `font-semibold` renders bold — a terminal has two weights. Co-Authored-By: Claude <noreply@anthropic.com>
# Conflicts: # packages/web/index.html # packages/web/src/index.css # packages/web/src/lib/appearance.ts
# Conflicts: # packages/web/index.html # packages/web/src/lib/appearance.ts
…der grid Merges design/theme-parchment, design/theme-terminal and design/theme-scifi. All three branched off the same commit and touched the same three registry lines, so the only conflicts were those: the `@import`, the `THEMES` entry and the `PRIMARY` preload map. Every one resolved by keeping both sides. No theme touched another's values, and `build-themes.mjs --check` still passes. Two things the three deliberately left to me, plus one I found: - The theme grid was `lg:grid-cols-5` and wrapped badly at eight. Now 2 / 4 / 8 across. At eight a card is ~104px, which has room for the label but not for the "in use" text beside it — dropped, because the selected card already says so with its accent border and fill, and `aria-pressed` carries it for a reader. - `tokens.test.ts` parses `tokens.css`, so it only ever certified the foundation palette: SEVEN of eight themes were uncertified, and the five in the generated `themes.css` had no guard at all. `styles/themes.test.ts` now walks the REGISTRY and applies the same contract to every theme in both modes — 610 assertions. It reconstructs each theme the way the cascade does (foundation base, then the theme's mode-independent block, then its mode block), so a partial theme is legal and still checked, and it fails both ways: a registered theme with no CSS, and CSS with no registry entry. Verified by breaking vellum's `--text-muted` and watching it fail with the theme, the mode, the token and the hexes. - `AppearancePanel`'s `variant="narrow"` lost its only call site when the floating dock was deleted; removed rather than left as eight dead branches. Co-Authored-By: Claude <noreply@anthropic.com>
…t eight Three things, one of which I did not write. **The chroma floor (found uncommitted in the worktree, verified and kept).** `solve` targeted the theme's own achieved ratio, which is right until that ratio is one only the theme's own hue can reach. `terminal` is the case: an acid-green plate at chroma 0.236 carrying black ink hits 13.6:1, and demanding 13.6:1 of every other hue forced a fill so light no other hue could hold its chroma there — pale mauve where Rose was asked for. Readable, and not the colour anybody picked. The target is now a preference and the AA floor is the guarantee: when the full target would cost more than a quarter of the chroma, it bisects for the highest ratio that keeps it. I verified this rather than trusting it. Measured live across 8 themes x 2 modes: 0 below AA, 0 hover inversions, and chroma retention 0.75-1.00 on the three off-hue cases the brief named — terminal+Magenta in dark now lands on rgb(255,147,247), which is magenta. **The generator's header was lying about scope.** It said themes.css is every theme. It is not, and cannot be: it lifts tokens out of a direction worktree, and the three new themes were authored directly against the token contract with no worktree to lift from. Header now states the boundary and says explicitly not to widen the generator to reach files it does not produce. Regenerated; the output is otherwise byte-identical and `--check` passes. **`blurb` had become dead data** — nothing has rendered it since the grid went to eight across. It is now the card's tooltip, which is where one-word labels at 104px get their disambiguation. Ordering documented as deliberate (neutral first, then rising distinctiveness) rather than left as merge order, and terminal's blurb no longer opens with "Phosphor", which reads as the other theme's name. Co-Authored-By: Claude <noreply@anthropic.com>
… fill's Credit where due: another agent working in this worktree spotted that the chroma floor added in ba14c05 was invisible on the themes it was written for, and localised it to the repair pass. I reproduced it before acting. The correlation is exact. On terminal dark, sweeping 12 hues: repaired hues 60, 90, 180, 210, 240 retention 0.36–0.50 un-repaired hues every other one retention 0.75–1.00 Zero overlap. `solveChannels` was doing its job everywhere it was allowed to; the repair pass was undoing it, for two reasons, both now fixed: - it re-solved using `rgbToOklch(fill).C`, the chroma of the fill it was replacing. A fill that needs repairing is usually one the gamut has already pulled in, so taking its chroma locks the degradation in — the repair faithfully reproduces the washed-out colour it was called to correct. It now solves from `max(fill, theme)` chroma. - it passed no floor, so the contrast-for-chroma fallback was disabled inside it. It now passes the AA floor, which matters for the hover step, whose target is deliberately above it. Re-measured, 8 themes x 2 modes x 12 hues = 192: terminal dark repaired hues 0.36/0.36/0.43/0.48/0.50 -> 0.44/0.42/0.51/0.56/0.59 below AA 0 hover inversions 0 worst button / accent text 4.58:1 / 4.58:1 The residual is the sRGB gamut, not the solver: no teal or blue exists at the chroma terminal's own green sits at, so those hues land as vivid as that hue can be and no more. Co-Authored-By: Claude <noreply@anthropic.com>
Ed's verdict on round one: "it's in the right direction, but it doesn't resemble actual parchment… it's meant to be a retro '90s, nostalgic video game-style thing, and it doesn't do that at all." He was right, and the diagnosis was more specific than "add texture": round one had the reference's COLOURS wearing none of its CONSTRUCTION. A game menu is built out of framed plates; round one was flat rectangles with a hairline. So this round went back to the reference at 4x magnification and itemised what it does that we did not — then built those things: 1. A DEEP MAROON SLAB across the top. The route header is now chrome, so the wine forms an L with the nav rail and frames the content the way the reference's top bar frames its panels. Both regions REDECLARE the semantic token set rather than being painted and hand-corrected, so every utility in the subtree re-points and no component changed. 2. HEAVY INK. Plates are outlined at 1.5px in a mixed-down `--parch-ink` instead of a 1px pale hairline. This is most of why the reference reads as vector art rather than as a web page. 3. THE TWO-TONE FRAME, at every scale — a lighter outer rule plus a darker corner elbow set inside it, trailing off. On plates, on the recessed wells, and inside the primary button, exactly as the reference repeats it. 4. NESTED PLATES. `bg-surface-sunken` regions become carved wells (dark inner lip, light lower edge, ink ring). And the app's several UNFILLED panels — the Home unread list, the Files list, a rounded border with no background — now get the parchment fill. The reference has no wire outlines; a frame always contains a sheet, and filling them is what makes project Home read as a menu instead of as sections on a field. 5. PILL CONTROLS. Primary is a filled wine pill with the inset light ring; secondary is an outlined pill. `.btn-subtle` is deliberately exempt — it is the bare icon button and there are dozens per screen. 6. METAL. Counts are struck gold coins with a lit rim and a shadowed underside, not tinted chips. Round one had no second accent at all. 7. SELECTION IS A RULE. The active rail row gets gilt rules above and below. 8. TEXTURE. Two tiled SVG noises (`feTurbulence`, `stitchTiles`), blended `soft-light` so they take the palette's own hue: a fine grain for panels, a streaked fibre for chrome and open ground. Also: the field went from 0.080 to 0.161 of perceptual lightness below the plates. That is at the wall, not at a taste stop — the darkest surface is what the entire ink ramp is solved against, so a darker field pushes `--text-subtle` past `--text-muted`. The three ink rungs now sit within 0.02 of each other, which is a real cost and also what a hand-drawn menu looks like. Two things worth keeping from the debugging: - `stitchTiles` stitches to the FILTER REGION, which defaults to 120% of the object box — not to the tile. Without an explicit `x/y/width/height` on the filter the tile and the stitch disagree and you get a visible 320px grid across the whole ground. It looked like a texture choice; it was a bug. - The contrast guard is structurally blind to a texture: it measures flat token pairs and will certify a legible palette printed on unreadable mottling. Amplitudes here were settled by eye on a dense transcript, and the strongest texture is deliberately on chrome and empty ground, never prose. 208 assertions green (84 foundation + 124 parchment), typecheck clean, both modes checked at 1440px on a dense transcript, project home and /config, and the hue picker re-checked at Forest — chrome and ornament hold, buttons move.
Four things the orchestrator flagged as still short of the reference, in the order it said to spend the effort: 1. PANEL-TO-GROUND SEPARATION. The value gap was already slightly wider than the reference's (0.16 vs 0.155) and the page still read flat, which said the missing half was the SHADOW, not the step. Plates now cast a real three-layer shadow — an ink contact line, a tight penumbra, a wide soft one — and the plate itself came up to 0.977, so the gap is 0.172 with the shadow doing the other half of the work. 2. BRASS. Every count badge in the app is now a struck coin (lit rim, shadowed underside), not just the ones in the rail; the unread dots go with them; and every icon the app draws in the accent — nav rows, pins, markers — becomes a fitting. `--parch-gilt-mark` resolves per scope, deep enough to read on parchment and lit enough on wine, so one rule serves both without a specificity fight. This is what stops the screen being one warm hue. 3. NAV ICONS. They were already there (the app ships an icon set); what they were missing was being metal. Covered by (2). 4. THE NESTED WELL. The chat-list column is now a recess between the wine rail and the content plate — rail, well, plate, which is the reference's frame/well/items. Carved from the SAME colour as the field rather than tinted darker: `--surface` is already the darkest surface in the theme, the one the whole ink ramp is solved against, so anything below it puts the chat-row timestamps under AA. The recess is made entirely of light. And one thing the tool-call transcript showed that no static review would: with even 22px arms, the corner elbows on a 34px one-line tool plate almost meet and the row reads as double-boxed — ornament eating a row, the exact failure the brief warns about. Arms are now ASYMMETRIC (20px horizontal, 10px vertical), so a big panel gets a frame and a one-liner gets a corner tick. Density held: 13 chat rows still visible in the column at 1440px. Checked by eye, in both modes, on the tool-call-heavy transcript as well as Home and /config — long agent prose over the grain is clean, which is the thing the contrast guard cannot see. 208 assertions green, typecheck clean.
Ed: "the parchment isn't red. You've made it kind of red or rose… just make it the actual parchment colour, and then you're pretty much there." He is right, and the mistake is worth naming because it was a reasonable one: the ground had been a dusty rose since round one, taken faithfully from the reference's own mauve backdrop. It was wrong HERE for a reason the reference cannot show you. A game menu is mostly panel with a sliver of backdrop at the edges; Paddock is mostly open field. Promote a supporting colour into the largest area on screen and it stops supporting and starts BEING the theme — which is exactly what a viewer reports back as "this is the rose one". So the ground is aged paper (#dbc7a7) with a fresh sheet on it (#fdf4de), and the whole warm ramp follows it round to the paper hues. The red is now spent only where the reference actually spends it: the chrome slab and rail, the primary button, and selection. Nothing structural moved — the frames, the brass, the wells, the ornament and both chrome regions are untouched. The ink went with it: iron gall rather than wine, a warm sepia-maroon, with every status hue re-solved against the new ground. One free win. The ink ramp had been squeezed to within 0.02 across text/muted/subtle, because the darkest surface is what the whole ramp is solved against and the rose ground was dark. Parchment is lighter, so the three rungs got their separation back (0.30 / 0.442 / 0.446) without giving up any contrast — the collapse recorded in the status file as an architectural cost turns out to have been a consequence of that one colour choice, not of the token system. Dark is unchanged apart from warming its sheet a touch toward lamp-lit vellum; it was never rose. 208 assertions green, typecheck clean, both modes checked at 1440px on Home and a dense transcript.
Ed, again: "No, it's still pink. What do you mean it's Parchment?" He was right and I was wrong three rounds running, for a reason I should have caught the first time he said it. I kept checking the TOKENS. The screen shows PIXELS, and a blend mode sits between them. Sampled off the running page against the reference attachment: reference top bar #662a3a L 0.374 (target) --parch-chrome oklch(0.332 …) (what the token said) painted chrome #7c3d49 L 0.444 (what was actually on screen) `background-blend-mode: soft-light` is asymmetric on a dark backdrop: below a base of 0.25 it lifts far more than an equally-distant darker sample lowers, and it lifts the LOW channels of a saturated colour proportionally most. So the texture was raising the deep wine chrome by 0.11 of lightness AND desaturating it toward its neighbours — a blackberry maroon rendering as a mid rose, across the nav rail and the header slab, which together are the largest saturated area on screen. "It's still pink" was a precise bug report. Both tiles now blend `multiply`, which can only darken, so the error is bounded and points the safe way: it improves light-on-dark contrast (all the chrome text) and can only hurt dark-on-light. The light-surface tile is therefore cut to a whisper — a [0.974, 1] band, under 0.005 of lightness — while the chrome tile keeps a real [0.85, 1] fibre, where darkening is free. And the chrome ramp is re-anchored on the reference's own bar, sampled rather than recalled. Measured on the rendered page after the fix: painted chrome #602533 L 0.354 (reference 0.374 — deliberately a shade under) painted ground #d9c5a6 L 0.832 (token 0.838 — the whisper, as designed) painted sheet #fbf2dc L 0.962 (token 0.968) The lesson is bigger than this theme and belongs in the status file: the contrast guard reads tokens and asserts about a colour that is not the one being displayed. It is not merely blind to texture — a blend silently moves the base colour underneath the assertion, so its numbers were WRONG, not just incomplete. Anything compositing over a surface has to be verified by sampling the rendered page. 208 assertions green, typecheck clean, both modes re-checked at 1440px.
…mpling tokens.test.ts and themes.test.ts resolve declared token values. When a theme composites (blend mode, texture tile, translucent overlay) the painted colour is not the measured one, and the guard does not report a smaller margin — it reports a number that is not on screen. parchment's soft-light texture lifted its chrome from L 0.332 to a painted 0.444 across the rail and header while every chrome assertion stayed green. Records the rule (painted pixel within delta-L 0.01 of the token), why multiply is the safer blend for texture, and the blast radius today (parchment only).
… parchment Ed's screenshot finally showed me two things I could not have got from my own. FIRST: HE IS IN DARK MODE. Every screenshot I have sent back for four rounds was light. He has been looking at a near-black room — his rail sampled #200e11, his ground #19120e — while I kept replying with pictures of cream paper. That is the whole reason we talked past each other. The dark mode was designed as "aged leather and candlelight", a defensible idea and the wrong one: a viewer in dark mode then never sees any parchment at all and correctly reports that none of it feels like parchment. Dark is now the same PARCHMENT at night rather than a different material. Hues come round to paper (78–84, not leather's 44–62) and the sheet sits at L 0.325 against a 0.235 desk — an aged page under a lamp, not a dark panel. SECOND, AND IT IS THE SAME MISTAKE I ALREADY MADE ONCE: the proportion of red. Going back to the reference at 3x, ITS LEFT MENU IS A PARCHMENT PANEL with maroon text and maroon line icons. Only the ~55px top bar is maroon — about 5% of its pixels. I had made the whole 288px full-height rail wine as well, which took the red to roughly a quarter of the screen. "That red stuff isn't Parchment" was a statement about area, and I kept answering about hue. This is exactly the error I made with the rose ground and wrote up as "promote a supporting colour into the largest area and it stops supporting and starts being the theme" — then made again, in the same file, two commits later. So the rail is now a framed parchment panel like every other plate. It keeps the app's own tokens with no scope override, which means the contrast guard's assertions cover it for the first time; its icons and rules are ink rather than brass, per the reference; and selection is a whisper of warm paper under ink rules instead of a pink bar. The wine survives on the header slab, the primary button and the accent — which is where the reference spends it. 208 assertions green, typecheck clean, both modes checked at 1920px, which is the width Ed is actually using.
Ed: "we lost the texture. Can we get that back?" We did lose it, and for a bad reason: when I swapped the blend to `multiply` I cut the light-surface tile to a [0.974, 1] whisper purely so the contrast guard's numbers would stay true. That is hiding from the problem. The right move is to TELL the guard. `theme-parchment.test.ts` now asserts every light-mode foreground against the surface MULTIPLIED BY `TEXTURE_MIN` — the darkest pixel the tile can produce — instead of against the flat token. So the texture can be as strong as the floor allows and the contrast promise holds at the worst pixel on screen rather than the average one. The factor is light-mode-only and that asymmetry is deliberate: `multiply` darkens, and a darker background under the LIGHT text of dark mode or of the wine chrome only raises the ratio, so the token is already worst case there. With that in place the band opens to [0.93, 1] and the ink re-solves against it (text-muted 0.427 -> 0.415, and the five status hues with it). THE PART WORTH KEEPING. Two attempts at "make it visible" changed nothing, and the reason is that `feTurbulence` does not fill [0, 1]. Rendered to a canvas and sampled, the mottle spans 0.51–0.89 with its median at 0.737. Both of my earlier transfer functions were written over the theoretical range, so they mapped the ENTIRE noise above 1.0 — where `multiply` clamps it — and produced three levels of variation out of 255. Measured painted spread on an empty panel: 3, then 4, now 11. Every slope and intercept in both tiles is now solved from that measured histogram: p90 lands exactly on 1.0, so most of the sheet is clean paper, and the minimum lands on the floor, so the darkest stain is a known quantity the guard can be told about. Change a `baseFrequency` or `numOctaves` and the distribution moves — the comment says to re-measure. 208 assertions green, typecheck clean, both modes checked at 1440px.
Ed: "make the theme's default colour be amber, and then we're done." `--accent` goes from the wine to a struck brass (#dca445), which also resolves something that had been sitting slightly wrong: the theme already spent metal on every count, coin, pin and fitting, so a maroon accent meant two unrelated warm hues running alongside each other. Now the accent IS the metal. The maroon stays exactly where the reference puts it — the header slab — and nowhere else. Two consequences worth recording, because neither is a value tweak: - THE FILL TAKES DARK TYPE. Gold is too light to carry cream text, so `--accent-fg` inverts to ink: a struck plate with the lettering engraved into it, which is the reference's coin rather than its button. The accent solver already handles themes that invert this way (phosphor and vellum-dark do), so the hue picker still works off it. - AMBER AS TEXT HAS TO COME DOWN TO BRONZE. Gold on cream is the hardest pair in the palette; `--accent-text` mixes 32% accent into a dark base to clear AA on the worst textured pixel. It still derives from `--accent`, so the brand seam and the picker both propagate. And one thing the guard caught that I would not have: the wine-chrome scope derived its selected surface by mixing 30% of the accent into the chrome. That was safe while the accent was a dark wine and is not when it is a light amber — mixing now LIGHTENS the very surface that scope's foregrounds are solved against, and six assertions failed. Since the rail became a parchment panel that scope is the header alone, which renders no selected row, so it drops the mix entirely. A derivation that only worked for one end of the lightness range, found by the test rather than by looking. Selection tint moves to the accent's own hue while I am here, so the active row reads as pale gold rather than as the last pink thing on screen. 208 assertions green, typecheck clean, both modes checked at 1440px.
The delta-L 0.01 rule was written when the answer was 'keep the tile tiny'. parchment's texture is back and its ground now measures delta-L 0.019 from its token -- outside that rule -- while remaining correct, because its guard models the compositing and asserts against the worst painted pixel instead. Records both routes as alternatives, the obligation route (b) carries (TEXTURE_MIN is a hand-maintained mirror of the tile's range and nothing enforces the coupling; measured 0.959 real floor vs 0.93 assumed), how to sample without clipping a glyph, and that ffmpeg is the decoder on this box.
Ed picked the four: foundation, parchment, terminal, scifi. instrument, phosphor, vellum and register are cut — they differed from each other mostly in palette and type, so shipping all four bought variety no one could name. THE CUT IS NOT A REGISTRY EDIT. `themes.css` was GENERATED by `scripts/build-themes.mjs`, which lifts token blocks out of the four direction worktrees; it held foundation plus exactly the four being dropped. Remove those and the generator's only remaining input is a worktree about to be cleaned up, producing a mechanical restatement of `tokens.css`. So both are deleted, and foundation is lifted out verbatim (diff-proven identical) into a hand-authored `styles/theme-foundation.css`. All four shipping themes now have the same shape, which is simpler than two mechanisms for one outcome. Foundation needs an explicit block despite being the default: the theme selectors are deliberately un-prefixed so a block is a self-contained scrap of its theme, which is what lets the picker render a scoped swatch of a theme the page is not currently wearing. Without a block, a foundation swatch inherits whatever theme is active. Also removed: 17 orphan woff2, their `@font-face` rules, two dead theme-scoped typography rules, and the sidebar's "Appearance" entry — a second door onto the Config section that already holds those settings. FIXED: hovering the primary button REDUCED its contrast. In dark mode the fill lightened on hover, taking its white label 5.53:1 -> 4.17:1 — below AA, on hover, on the most-clicked control in the app. The instinct behind it was not wrong about the canvas (a fill does separate better from a dark page when it lightens) but it measured the wrong pair: what the user reads is the label. Fixed at source in `tokens.css`. `tokens.test.ts` could not see this — it never paired a `-fg` with a `-solid-hover`, so every assertion read a fill at rest. Both guards now assert that pairing, twice: AA as the floor, and "hover does not lose contrast" as the craft rule in the weakest form worth enforcing. Proven to bite (4 failures on the old values). Adding it immediately caught a second instance nobody knew about: parchment LIGHT, 6.31:1 -> 5.20:1. The correct direction there is the OPPOSITE one — its plate carries dark ink, so hover must lighten. The rule is polarity, not mode. docs/DESIGN.md: §8 rewritten from "how to add a direction" (which described editing tokens.css, no longer true) to "how to add a theme"; four themes not eight; the radius scale lives in index.css's @theme, not tokens.css; and the qualification that "a theme is one file of values" holds for three of the four — parchment took a documented exemption for texture and ornament. §4's two-route compositing rule is kept intact. Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Aug 9, 2026
added 2 commits
August 9, 2026 10:27
main now carries three squashes this branch predates (#763 foundation, #768 Config restructure, #769 Home empty states), so all four of this branch's foundation commits collided with their own content. Nine conflicts. Eight were mechanically decidable by comparing each side against the foundation tip fbd48ab: - ours (main == foundation, branch adds): docs/DESIGN.md, index.css, lib/color.ts, styles/tokens.css, styles/tokens.test.ts - theirs (branch == foundation, main adds): SettingsPane.tsx, ui/Surfaces.tsx, ProjectView/HomePane.tsx The ninth, InstanceConfigForm.tsx, is the only genuinely contested file, and is resolved as a GRAFT rather than a side-pick: #768's restructure is the structure, and this branch's Appearance section is applied onto it. Concretely, main's copy is the base and this branch's own +81/-7 against the foundation tip is applied over it. Everything #768 built - the single -column rows, the fixed control slot, the absolutely-positioned dirty marker, SectionScroller - is retained; the only lines removed from main's copy are the five visibleGroups -> railGroups rewires and the field-count span, which becomes conditional so a zero-field Appearance shows no '0'. SectionScroller is pointed at railGroups too. It is the narrow-viewport form of the same rail and postdates this branch, so it was still on the unprefixed list: without this, Appearance appears in the rail above 1024px and silently vanishes below it. docs/DESIGN.md was an add/add. Taking this branch's copy is the content merge rather than a side-pick: it descends from the same foundation text, and each line it drops is deliberately superseded (the 'baseline theme is deliberately neutral' paragraph becomes the four-theme table). The §4 rule about compositing and painted pixels is present.
The merge grafted this branch's Appearance section onto #768's restructured Config screen, but the section was still written against the LOCAL `Section` component this file used to define. #768 replaced that with the shared `components/ui` primitive, which takes an entirely different props shape, so the graft applied cleanly into code that did not compile. `variant="rule"` + `first` is the exact translation: the ui primitive carries the same `scroll-mt-4` and the same `mt-9 border-t border-edge pt-7` when not first, because #768 generalised it from this very component. The `id` now has to be passed explicitly. The old local component derived it from `group.id`, and the rail jumps by `sectionDomId` — so without it the rail would list Appearance and clicking it would do nothing.
Merged
edspencer
pushed a commit
that referenced
this pull request
Aug 9, 2026
This branch is stacked on #780, which has now been squash-merged along with #763, #768 and #769 — so all four squashes' content collided with the original commits still carried here. Four conflicts, all decided the same way: this branch never touched any of them (each is byte-identical to its copy at 49bdda4, #780's head), so main's landed version wins. That matters for InstanceConfigForm.tsx in particular: main carries the reconciled graft of #768's restructure with #780's Appearance section, and this branch only has #780's pre-graft copy. Taking theirs keeps the graft, SectionScroller on railGroups, and the explicit sectionDomId the rail jumps to. Verified: all four files are byte-identical to main's, and the merge result differs from main by exactly this branch's own fleet-readout work. The readout is outside <main> and renders no <h3>, so it does not disturb the Home section-order assertions that landed with #769.
This was referenced Aug 9, 2026
edspencer
pushed a commit
that referenced
this pull request
Aug 10, 2026
root-home, adopt-row, adopt-modal, project-settings and the trigger tool picker were all shot before the design work landed (#763/#768/#769/#780/#801). Every one showed chrome, surfaces, type and accent the redesign replaced, on pages whose prose is in the present tense. Shot from the docs-media rig at v0.69.0, Foundation/dark, hue null, tint 0 — the out-of-the-box appearance. capture.mjs now pins the theme via addInitScript (the keys are read by a pre-paint script, so a post-goto evaluate catches a mid-swap frame) and asserts it applied.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Runtime theme switching and a colour picker, plus the four themes Ed chose.
Live: https://5031.dev.projects.valfenda.net/config (Appearance).
What ships
Four themes, switchable at runtime from Config → Appearance, per-browser,
applied instantly with no save and no restart. Both light and dark are designed
for each — not inverted from one another.
foundationparchmentterminalscifiAn accent picker that cannot produce an unreadable colour. The user
contributes only a colour. The theme supplies its own chroma and a target
contrast ratio, and the lightness is solved to hit it — so an accent that
fails AA is inexpressible rather than merely warned about. Optionally the same
colour tints the page ground (None / A little / More). No colour theory is
exposed anywhere: no hue, no chroma, no OKLCH, no degrees.
This composes with
PADDOCK_BRAND_ACCENT(#34) rather than competing with it.With nothing picked, the solver reads the hue of whatever the server injected
and re-solves it against the active theme, so an operator's brand colour stops
being used at whatever lightness it happened to have. Ten plausible brand hexes
were measured against one theme during the design run; two were readable. Under
this seam all ten are, because lightness stopped being an input.
The cut — four themes, not eight
The design run produced four more directions:
instrument,phosphor,vellum,register. Ed cut them: they differed from each other mostly inpalette and type, so shipping all four bought variety no one could name. Their
branches remain on the remote.
Removing them was not a registry edit.
styles/themes.csswas generatedby
scripts/build-themes.mjs, which lifts token blocks out of the fourdirection worktrees, and it held foundation plus exactly the four being dropped.
With those gone the generator's only remaining input is a worktree about to be
cleaned up, and its output is a mechanical restatement of
tokens.css. So boththe generator and its output are deleted, and foundation is lifted out verbatim
(diff-proven byte-identical) into a hand-authored
styles/theme-foundation.css.All four shipping themes now have the same shape — one file, three registry
lines — which is simpler than two mechanisms for one outcome.
Foundation still needs an explicit
[data-theme="foundation"]block despitebeing the default. The theme selectors are deliberately un-prefixed, so a block
is a self-contained scrap of its theme that works on any element — that is what
lets the picker render a live swatch of a theme the page is not wearing.
Without a block, a foundation swatch inherits whatever theme is active.
Also removed: 17 orphaned
woff2files and their@font-facerules, two deadtheme-scoped typography rules, and the sidebar's "Appearance" entry — a second
door onto the Config section that already holds those settings.
Fixed: hovering the primary button reduced its contrast
In dark mode the fill lightened on hover, taking its white label from
5.53:1 to 4.17:1 — below AA, on hover, on the most-clicked control in the
app.
The instinct behind it was not wrong about the canvas: a fill does separate
better from a dark page when it lightens. It was measuring the wrong pair. What
the user is reading is the label, and against a white label the only direction
that gains contrast is darker. Fixed at source in
tokens.css; dark now matcheslight at the 700 step, 7.45:1.
The guard could not see it. Every contrast assertion read a fill in its
resting state — a
-fgwas never once paired with a-solid-hover— so thehover token could drift anywhere and nothing noticed. Both
tokens.test.tsandthemes.test.tsnow assert that pairing twice: AA as the floor, and "hover doesnot lose contrast" as the craft rule in the weakest form worth enforcing (a
theme may legitimately hold contrast flat and move the border instead; losing it
is always a mistake). Verified to fail on the old values before being relied on.
Adding it immediately caught a second live instance nobody knew about:
parchmentlight, 6.31:1 → 5.20:1. The correct fix there is the oppositedirection — its plate is a struck brass coin carrying dark ink, so hover must
lighten. The rule is polarity, not mode, and that is now written down.
Contrast coverage
tokens.test.tsonly ever parsedtokens.css, so it certified exactly onepalette and every runtime theme shipped uncertified.
styles/themes.test.tswalks the
THEMESregistry instead and applies the full contract to every themein both modes, reconstructing each one exactly as the cascade produces it
(foundation base + theme block + mode block). It fails both ways: a theme
registered with no stylesheet, and a stylesheet nobody registered.
Known limit, documented in
docs/DESIGN.md§4 and not fixed here: the guardresolves declared token values, and the screen shows pixels. Anything that
composites — a blend mode, a texture tile — moves the painted colour away from
its token, and the guard reports a number that is not on screen. That is not a
smaller margin; it is a wrong answer.
parchmentis the only theme thatcomposites today, and it carries its own painted-pixel assertions.
Verification
npm run typecheck— clean.design/foundation, and thisrepo's workflows skip stacked PRs — only Cloudflare Pages and GitGuardian run
above. The evidence for the suites is local, and the whole stack (#763–#769,
#780–#783) needs a merge order decided before any of it lands.
🤖 Generated with Claude Code