Skip to content

feat(chrome): light and system appearance for the editor chrome (light-dark() variant) - #204

Open
pvinis wants to merge 12 commits into
kunchenguid:mainfrom
pvinis:pvinis/chrome-light-theme-light-dark
Open

feat(chrome): light and system appearance for the editor chrome (light-dark() variant)#204
pvinis wants to merge 12 commits into
kunchenguid:mainfrom
pvinis:pvinis/chrome-light-theme-light-dark

Conversation

@pvinis

@pvinis pvinis commented Jul 28, 2026

Copy link
Copy Markdown

closes #202

this is one of two PRs for the same feature. only one should land. #203 is the alternative. say which you want and i'll close the other same day.

pick one: the difference is one file

both PRs carry the same four base commits and the same review gate fixes, on top of one differing implementation commit. what actually differs between the two branches is src/chrome.css, its test, and a one line invariant note. they render pixel identical in both themes, so this is purely an implementation choice.

#203 #204, this one
mechanism :root[data-theme-pref="light"] plus a prefers-color-scheme media query every token carries both palettes via light-dark()
chrome.css vs main +102 / -16 +67 / -32
palette written twice, once pinned and once for system once
to keep in sync two blocks (there's a test pinning them equal) nothing
browser floor universal light-dark(), baseline may 2024
if unsupported n/a tokens invalid, chrome renders unstyled

short version: this one is smaller and has nothing to keep in sync, at the cost of needing 2024 css. #203 is the conservative pick if that floor bothers you.

for what it's worth on the floor: there's no browserslist or @supports anywhere in the repo, engines only pins node, and lavish opens a tab in the developer's own current browser. #201 is an eventsource failure on firefox 151, not a css feature problem.

what it does

lavish-axi config theme <system|light|dark>, device wide, drives the editor chrome. defaults to system.

following your notes on #131

  1. device wide preference under a config command in state.json, not per browser localStorage. done.
  2. that preference drives the chrome. done.
  3. pass the theme to the agent as generation guidance instead of injecting a stylesheet. skipped, because it's moot here. point 3 was the alternative to injection, and this never touches the artifact at all: no injected css, no boot script, no opt out attribute. artifacts stay byte identical outside lavish. feat(design): make the default artifact theme configurable via LAVISH_AXI_DEFAULT_THEME #180 owns the artifact side.

also dropped from #131: src/appearance.js. it shelled out to osascript, the windows registry and gsettings to resolve system at cli time, and that only existed to serve the agent guidance half. the chrome runs in a browser, so the browser can do it.

the chrome theme still resolves in pure css. no injected stylesheet, no boot script, no matchMedia listener.

what i can't claim any more is "zero new javascript" overall, which is what i said when i first opened this. the review gate found two real bugs in the shared commits and fixing them added js. details below. none of it changes how the theme resolves, and all of it is in the shared commits, so #203 carries exactly the same code.

the default

system means a light mode machine gets different looking chrome after upgrading. deliberate, but it is a visible change, so shout if you'd rather it default to dark and stay opt in. one line.

how

every themed token carries both palettes, so the preference only has to pick a color-scheme and the browser resolves per token:

:root { --bg: light-dark(var(--paper-300), var(--ink-900)); /* ... */ }

:root[data-theme-pref="system"] { color-scheme: light dark; }
:root[data-theme-pref="light"]  { color-scheme: light; }
:root[data-theme-pref="dark"]   { color-scheme: dark; }

system leaving both in play means it follows the os live, no matchMedia listener. chrome.css is a render blocking <link> in <head> so nothing flashes either.

no palette block is written twice, so there's nothing to keep in sync.

what the review gate changed

pushed through no-mistakes per CONTRIBUTING.md. it found two genuine bugs in the shared commits that i'd missed, both worth having:

  • state.json had a second writer. configCommand did a read modify write straight from the cli process, which breaks the invariant AGENTS.md leans on ("writes are serialized through the single server process"). concurrent with a server side queuePrompts, last writer wins and the other change is silently gone. fixed at the shared boundary: writeState is now a temp file plus rename, and config theme posts to a new POST /api/config when a server is live. it writes directly only when nothing answers /health, and refuses outright rather than writing beside a stale server.
  • the whiteboard ignored the preference. whiteboardTheme() read only prefers-color-scheme, so config theme light on a dark os gave you a light chrome around a dark excalidraw canvas. it now reads the resolved preference first.

that's the new javascript. i also fixed a test harness gap the gate's own fix exposed: the fake document in test/chrome-client-queue.test.js never modelled documentElement, so the whiteboard change failed 15 tests there until it did.

pnpm run check is green: 625 tests, 623 pass, 2 skipped (they need chrome).

chrome vs artifact

worth being explicit since it surprised me too. the artifact iframe is sandboxed without allow-same-origin, so it can't see the chrome preference, only the os. so:

  • artifact with its own prefers-color-scheme dark mode: follows the os
  • artifact with no dark mode: stays light, always, in either chrome

with the default system that means chrome and artifact now agree, which they never did before: today the chrome is pinned dark, so every light mode user already gets a light artifact inside dark chrome. the only way to get a mismatch after this is to pin the chrome against your own os, which is an explicit choice.

not trying to solve artifact theming here, that's #180.

known limitations

flagged by the review gate, left deliberately unfixed. shout if you disagree with any of these and i'll do them:

  • no light-dark() fallback. on a browser without support (chrome/edge <123, safari <17.5, firefox <120) the tokens are invalid and the chrome renders unstyled rather than degrading to the old dark palette. that is the tradeoff this variant exists to offer, and adding fallbacks would mean writing the palette twice, which is just feat(chrome): light and system appearance for the editor chrome #203. calling it out so it's a choice and not a surprise.
  • the annotation card and text highlight the sdk injects inside the artifact iframe are hardcoded dark (src/artifact-sdk.js), so a light chrome pops a dark card. that's the artifact side, so feat(design): make the default artifact theme configurable via LAVISH_AXI_DEFAULT_THEME #180.
  • color-scheme moved to :root, and the artifact iframe inherits the computed value. artifact markup and css are genuinely untouched, but ua styled surfaces in there (scrollbars, unstyled form controls) do follow the chrome preference. one path i didn't expect, documented rather than papered over.
  • a whiteboard that is already open keeps the appearance it opened with and picks up a change on next open. fixing it live means a matchMedia listener, which is more js than this is worth.
  • the lavish-design skill under .agents/ still describes the chrome as dark only, so prototyping from it produces dark only markup. it's machine generated, so regenerating it against the new palette is a separate job.

one prerequisite bug

readState rebuilt state from sessions alone, so every other top level key was dropped on the next session write. config.theme would have reset itself the moment you opened anything. fixed in the first commit with a regression test. stands on its own regardless of what you do with the rest.

also in here

  • promoted the last dark only literals to tokens (hovers, the three scrims, the layout banner, danger tint, shadow color). no visual change, pinned by tests.
  • .frame stays #fff on purpose, it's the artifact viewport, not chrome.
  • light foregrounds tuned so every pair clears wcag aa on its own surface. --accent goes dark brass because --accent-hover is used as link text on paper, not just as a button fill.

verified

matrix in a real browser via devtools color scheme emulation:

pref os light os dark
system light dark
light light light
dark dark dark

system follows the os with no reload. dark resolves to exactly the values it had before, so nothing changes for anyone who stays dark.

also checked the nested case, since light-dark() inside a custom property consumed through var() is the bit worth doubting: --shadow-color -> --shadow-tooltip -> box-shadow resolves correctly per theme.

system on a light os

system on a dark os

light chrome, menu open

the mismatch case: chrome pinned dark on a light os

the artifact follows the os, the chrome follows your pin. documented above.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 issues (1 warning, 1 info)
  • ⚠️ src/chrome.css:41 - chrome.css moves every themed token to light-dark() with no fallback, so on a browser without light-dark() support (Chrome/Edge <123, Safari <17.5, Firefox <120) the chrome loses its colors entirely rather than degrading to the previous dark palette. Custom properties store the token stream fine, but substituting it (body { background: var(--bg); color: var(--fg) } at src/chrome.css:155-156) is invalid at computed-value time, so background falls back to transparent and color to the inherited canvas default. With color-scheme: light dark still honored, a dark-OS user on such a browser gets near-black canvas plus black text — unreadable, where the base commit rendered correctly. A @supports not (color: light-dark(#000, #fff)) block restoring the ink/steel ramp on the semantic tokens would keep the old dark chrome as the floor. Flagging because PR feat(chrome): light and system appearance for the editor chrome (light-dark() variant) #204 is explicitly the light-dark() variant of feat(chrome): light and system appearance for the editor chrome #203, so the compat floor is part of the choice being made.
  • ℹ️ src/cli.js:257 - config theme &lt;value&gt; is the only CLI command that hard-fails against a version-mismatched server instead of self-healing: openCommand/pollCommand go through ensureServer, which asks the stale server to shut down (and SIGTERMs pre-handshake ones) before continuing, while this path throws and tells the user to run lavish-axi stop manually. The refusal itself is correct — writing beside a stale server would be dropped on its next session write — but reusing the existing shutdown path (shutdownServerOnPort, then the direct write, still without spawning a server) would keep the same invariant with no manual step. Deliberate per commit ff0d757, so raising rather than changing.
✅ **Test** - passed

✅ No issues found.

  • pnpm install --frozen-lockfile (deps and dist bundle were absent in the worktree)
  • node --test test/session-store.test.js test/chrome-client-queue.test.js — 70 pass, covers theme round-trip, config surviving session writes, unrecognized top-level key preservation, atomic writeState
  • node --test test/server.test.js — 164 pass, covers POST /api/config, data-theme-pref on the chrome html, color-scheme mapping, both-palette token audit, whiteboardTheme precedence
  • node --test test/cli-output.test.js — 96 pass, covers config read/write/validation, server-routed write, stale and pre-handshake server refusals
  • Manual CLI: lavish-axi config, config theme light|dark|system, config theme, config theme sepia, config colour against an isolated LAVISH_AXI_STATE_DIR with no server running
  • Manual CLI: config theme light against a live current-version server on port 4611, then curl -s .../session/&lt;key&gt; to confirm the served chrome html carries data-theme-pref
  • Manual CLI: config theme light against a fake /health server reporting version 0.1.11 — confirmed SERVER_ERROR, non-zero exit, and unchanged stored preference
  • Headless Chrome Canary via CDP (Emulation.setEmulatedMedia on prefers-color-scheme, Page.captureScreenshot): pinned dark under light OS, pinned light under dark OS, system under light OS, and system flipped to dark OS with no reload
  • Headless Chrome CDP interaction: opened the overflow menu, publish dialog, and ended overlay in light mode and read computed --hover, --scrim, --scrim-strong values
  • Headless Chrome CDP on a Mermaid artifact: inline Excalidraw whiteboard captured with theme pinned light under a dark OS and pinned dark under a light OS
  • Inspected state.json after two live sessions plus a theme write to confirm config and sessions both persist
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

pvinis added 5 commits July 28, 2026 18:23
readState rebuilt state from `sessions` alone, so anything else at the top
level of state.json was silently lost on the next session write.

spread the parsed state instead, and add get/setThemePreference on top of it
for a device-wide theme preference under config.theme. without the readState
fix that preference would reset itself the moment any session was touched.
device-wide setting stored as config.theme in state.json, so every browser on
the machine agrees instead of each one keeping its own localStorage toggle.

nothing reads the value yet, that comes next. this is just the command surface,
validation, and docs.
the chrome already had a semantic token layer over the raw ramp, but a few
surfaces still had literal colors baked into the rule: the three hovers, the
three overlay scrims, the layout banner, the danger tint, and the shadow color.

no visual change, every new token resolves to exactly the value it replaced.
this just puts them in one place so a light theme has something to reassign.

.frame stays #fff on purpose. it is the artifact viewport, not chrome, so it
must not follow the chrome theme.
createChromeHtml now emits <html data-theme-pref="system|light|dark">, read from
the device config on each session request.

chrome.css is a render-blocking <link> in <head>, so keying the theme off an
attribute means it resolves before first paint. no inline boot script, no flash
of the wrong theme, no js.

nothing styles off it yet, that's the next commit.
adds a warm paper counterpart to the semantic token layer. each themed token
carries both palettes inline through light-dark(), so the preference only has to
pick a color-scheme and the browser resolves per token.

`system` leaves color-scheme as `light dark` so it follows the OS live, `light`
and `dark` pin it. no js, no duplicated palette block, and the stylesheet is
render-blocking so nothing flashes.

light foregrounds are tuned so every pair clears WCAG AA on its own surface, and
--accent/--accent-hover go dark brass since --accent-hover is used as link text
on paper as well as a button fill.

needs light-dark(), baseline may 2024.

verified in chrome across the matrix, including the nested
--shadow-color -> --shadow-tooltip -> box-shadow chain. renders pixel-identical
to the attribute-selector variant in both themes.
@pvinis pvinis changed the title feat(chrome): light and system appearance for the editor chrome (light-dark() variant) feat(chrome): light and system appearance for the editor chrome Jul 29, 2026
@pvinis pvinis changed the title feat(chrome): light and system appearance for the editor chrome feat(chrome): light and system appearance for the editor chrome (light-dark() variant) Jul 29, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Holding this. Sibling of held #203: same chrome-only config theme work, this variant via light-dark(). Defaulting to system is a visible default-behavior change for light-mode machines — you already called that out. I am not auto-merging default chrome appearance, and I am not rebasing a CONFLICTING/DIRTY branch.

#202 is labeled ready-for-pr; that is not a merge vote. Landing waits on rebase of the existing PRs (#203 / #204).

hold-ci: required PR must be raised via no-mistakes has a FAILURE on this head (first-push signature race; later reruns are green and the body now has the Pipeline line). build-and-test is green. I will not add a no-mistakes marker.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Holding this. It is the light-dark() sibling of held #203: same lavish-axi config theme defaulting to system, which is a visible default-behavior change for light-mode machines. #203 already covers that product call, so I am not opening a second one here and I am not picking an implementation.

The branch is CONFLICTING with main (12 ahead / 34 behind). I am not rebasing a PR that needs that call, and I am not flagging it while DIRTY. ready-for-pr on #202 is not a merge vote.

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.

feat(chrome): light and system appearance for the editor chrome

2 participants