Skip to content

feat(web): four runtime themes and an accent picker that cannot fail AA - #780

Merged
edspencer merged 37 commits into
mainfrom
design/theme-picker
Aug 9, 2026
Merged

feat(web): four runtime themes and an accent picker that cannot fail AA#780
edspencer merged 37 commits into
mainfrom
design/theme-picker

Conversation

@edspencer

@edspencer edspencer commented Aug 8, 2026

Copy link
Copy Markdown
Owner

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.

theme it is
foundation the neutral base — warm ground, terracotta accent
parchment a 90s RPG menu — wine chrome, brass fittings, corner brackets, an old-style serif
terminal green phosphor and ANSI in the dark; greenbar and ribbon ink in the light
scifi deep-space ground and luminous cyan — hairlines, telemetry, glow

An 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 in
palette 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.css was generated
by scripts/build-themes.mjs, which lifts token blocks out of the four
direction 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 both
the 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 despite
being 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 woff2 files and 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 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 matches
light at the 700 step, 7.45:1.

The guard could not see it. Every contrast assertion read a fill in its
resting state — a -fg was never once paired with a -solid-hover — so the
hover token could drift anywhere and nothing noticed. Both tokens.test.ts and
themes.test.ts 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 (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:
parchment light, 6.31:1 → 5.20:1. The correct fix there is the opposite
direction — 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.ts only ever parsed tokens.css, so it certified exactly one
palette and every runtime theme shipped uncertified. styles/themes.test.ts
walks the THEMES registry instead and applies the full contract to every theme
in 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 guard
resolves 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. parchment is the only theme that
composites today, and it carries its own painted-pixel assertions.

Verification

  • npm run typecheck — clean.
  • Web suite — 1697/1697.
  • Style guards — 702/702 across all four themes × both modes.

⚠️ This PR gets no repo CI. It is based on design/foundation, and this
repo'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

Paddock and others added 6 commits August 7, 2026 19:48
…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 8, 2026

Copy link
Copy Markdown

Deploying paddock with  Cloudflare Pages  Cloudflare Pages

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

View logs

HomeLab Agent and others added 8 commits August 8, 2026 13:21
…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.
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>
HomeLab Agent and others added 11 commits August 8, 2026 15:59
# 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.
Paddock and others added 9 commits August 8, 2026 17:44
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>
@edspencer edspencer changed the title spike: runtime theme + hue-only accent picker over all four directions feat(web): four runtime themes and an accent picker that cannot fail AA Aug 9, 2026
Base automatically changed from design/foundation to main August 9, 2026 13:14
HomeLab Agent 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.
@edspencer
edspencer merged commit 75c3935 into main Aug 9, 2026
5 checks passed
@edspencer
edspencer deleted the design/theme-picker branch August 9, 2026 14:39
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant