Skip to content

feat(chrome): add device-wide theme preference and config command - #131

Closed
gfiorav wants to merge 5 commits into
kunchenguid:mainfrom
gfiorav:fm/lavish-theme-q4
Closed

feat(chrome): add device-wide theme preference and config command#131
gfiorav wants to merge 5 commits into
kunchenguid:mainfrom
gfiorav:fm/lavish-theme-q4

Conversation

@gfiorav

@gfiorav gfiorav commented Jul 2, 2026

Copy link
Copy Markdown

Intent

The developer was finishing a theming rework on Lavish Editor's fm/lavish-theme-q4 branch per a revised plan from the maintainer, who had rejected the original content-injection approach (PR #131). The goal was to add a lavish-axi config theme <system|light|dark> command that persists in state.json, have the editor chrome source its theme from that device preference, and surface a theme_preference: { preference, directive } block in the home and design CLI outputs as generation guidance for the agent (resolving system to a concrete light/dark directive at CLI time, or null when unset). They applied a provided WIP diff, added an appearance resolver with tests, updated AGENTS.md and the README to document the new config command, ran pnpm run check to green, and were about to ship through the no-mistakes gate to open a PR against main.

What Changed

  • Add lavish-axi config theme <system|light|dark> backed by a top-level config.theme in state.json (getThemePreference / setThemePreference), with a new src/appearance.js resolver that detects current OS appearance via osascript (macOS), registry (Windows), or gsettings (Linux).
  • Theme the editor chrome from that device preference: createChromeHtml now embeds <html data-theme-pref="..."> plus a no-FOUC inline head script that applies data-theme before paint, chrome-client.js reapplies it on load and live-follows prefers-color-scheme while the preference is system, and chrome.css defines a light counterpart on :root[data-theme="light"] with theme-aware --hover, --scrim, --banner-*, and --accent-divider tokens.
  • Surface a theme_preference: { preference, directive } block in the home and design CLI outputs as agent generation guidance - light/dark carry a concrete "bake this look into the artifact" directive, system is resolved at CLI time to a concrete light/dark directive, and unset installs emit no directive so a vanilla install never nags the agent. AGENTS.md and README updated to document the new command and the runtime-vs-content theming model.

Risk Assessment

✅ Low: The change is well-bounded: it adds a new config subcommand, a device-wide config.theme field in state.json, a no-FOUC inline script that keys the chrome off data-theme-pref, and a small OS-appearance resolver; the substantive correctness and dead-code concerns from previous review rounds were addressed by the auto-fixes, and the remaining user-ignored items are documented product decisions, so this can merge without further review.

Testing

Built the project, ran the full 495-test suite (all pass), and exercised the new lavish-axi config theme &lt;system|light|dark&gt; command end-to-end: setting and reading each value, validating rejected inputs, confirming the theme_preference block appears in both home and design output for every preference, verifying the no-FOUC data-theme-pref attribute on the served chrome HTML, screenshotting the chrome in both light and dark themes (with and without the More menu open), and confirming the system preference live-follows prefers-color-scheme via DevTools emulation while explicit light/dark stay pinned. The appearance resolver and theme-directive builder were also exercised directly. All behavior matches the spec from the agent session.

  • Evidence: Light theme chrome (1280x800) with annotation toggle and conversation panel (local file: /var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/chrome-light-1280.png)
  • Evidence: Light theme chrome with More menu open (EDITING section, file path, no in-browser theme toggle) (local file: /var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/chrome-light-menu.png)
  • Evidence: Dark theme chrome (1280x800) with annotation toggle and conversation panel (local file: /var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/chrome-dark-1280.png)
  • Evidence: Dark theme chrome with More menu open (EDITING section, file path, no in-browser theme toggle) (local file: /var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/chrome-dark-menu.png)
Evidence: Test artifact used in the chrome screenshots
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Theme test artifact</title>
    <style>
      body { font-family: ui-sans-serif, system-ui, sans-serif; padding: 24px; max-width: 720px; }
      h1 { font-family: Georgia, serif; }
      .card { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin: 16px 0; }
    </style>
  </head>
  <body>
    <h1>Theme Test Artifact</h1>
    <p>This artifact is hosted inside the Lavish Editor chrome so I can verify the chrome theming.</p>
    <div class="card">
      <strong>Section A:</strong> Useful for showing chrome styling.
    </div>
    <div class="card">
      <strong>Section B:</strong> The chrome should theme its top bar, composer, and other chrome surfaces.
    </div>
  </body>
</html>
Evidence: state.json after `lavish-axi config theme dark` (config preserved with session)
{
  "sessions": {
    "d6a8cb1e562b6ab9": {
      "key": "d6a8cb1e562b6ab9",
      "file": "/private/var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/artifact/theme-test.html",
      "url": "http://127.0.0.1:4466/session/d6a8cb1e562b6ab9",
      "status": "open",
      "pending_prompts": 0,
      "prompts": [],
      "layout_warnings": [],
      "delivered_layout_warning_keys": [],
      "dom_snapshot": "",
      "chat": [],
      "updated_at": "2026-07-03T22:26:30.620Z"
    }
  },
  "config": {
    "theme": "dark"
  }
}
Evidence: state.json after `lavish-axi config theme light` (config preserved with session)
{
  "sessions": {
    "d6a8cb1e562b6ab9": {
      "key": "d6a8cb1e562b6ab9",
      "file": "/private/var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/artifact/theme-test.html",
      "url": "http://127.0.0.1:4467/session/d6a8cb1e562b6ab9",
      "status": "open",
      "pending_prompts": 0,
      "prompts": [],
      "layout_warnings": [],
      "delivered_layout_warning_keys": [],
      "dom_snapshot": "",
      "chat": [],
      "updated_at": "2026-07-03T22:26:40.677Z"
    }
  },
  "config": {
    "theme": "light"
  }
}
Evidence: state.json after `lavish-axi config theme system` (config preserved with session)
{
  "sessions": {
    "d6a8cb1e562b6ab9": {
      "key": "d6a8cb1e562b6ab9",
      "file": "/private/var/folders/y8/wy53333j43ncsk_r9djvh5mh0000gn/T/no-mistakes-evidence/01KWMZRQ30SY51PNVKHK4H13KZ/artifact/theme-test.html",
      "url": "http://127.0.0.1:4468/session/d6a8cb1e562b6ab9",
      "status": "open",
      "pending_prompts": 0,
      "prompts": [],
      "layout_warnings": [],
      "delivered_layout_warning_keys": [],
      "dom_snapshot": "",
      "chat": [],
      "updated_at": "2026-07-03T22:33:14.232Z"
    }
  },
  "config": {
    "theme": "system"
  }
}

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 6 issues found → auto-fixed (2) ✅
  • ℹ️ src/server.js:800 - chromeIcons.sun and chromeIcons.system are defined but never referenced anywhere in the source or tests. AGENTS.md explicitly states 'There is no in-browser theme switch', so these appear to be speculative additions for a future feature that does not exist.
  • ⚠️ test/chrome-client-queue.test.js:142 - The new test harness additions include a localStorage mock that references a lavish-axi:theme key (line 258) that has no counterpart in chrome-client.js (which only uses sessionStorage), plus setSystemTheme/themePreference/resolvedTheme helpers that are not called by any test. This reads like abandoned infrastructure - a previous approach that stored the preference in localStorage was apparently replaced by the server-injected data-theme-pref attribute, but the leftover mock and helpers were never removed. Carries the highest confusion risk because the lavish-axi:theme key is actively misleading.
  • ℹ️ src/chrome.css:465 - .share-overlay still uses a hardcoded rgba(15, 17, 21, 0.72) background even though the change introduces a theme-aware --scrim token (overridden in :root[data-theme=light]). In light mode the share dialog will sit on a dark scrim while the ended-session overlay and the layout-issue banner get proper light variants. The new AGENTS.md note says 'do not reintroduce hardcoded dark-only colors', so the author may want to migrate this too (likely with a slightly different opacity, e.g. a new --scrim-modal token).
  • ℹ️ src/cli.js:141 - readHomeThemePreference returns the value from getThemePreference, which falls back to 'system' when no preference is stored via normalizeThemePreference(undefined). For an unset / default install, the home output's theme_preference.preference is therefore the string 'system' rather than null - the same value the field would carry if the user had explicitly set theme to system. The agent can still disambiguate via the directive field (null vs a concrete OS-resolved string), but the symmetric API would be cleaner: pass the raw value through so preference mirrors the user's actual choice (null when unset, 'light'/'dark'/'system' when set), and let getThemePreference continue to supply the chrome with a defaulted 'system'.
  • ℹ️ src/chrome-client.js:802 - applyResolvedTheme(resolveTheme(themePref)) at the end of chrome-client.js re-runs the same computation the inline head script already performed (server.js:876). The accompanying comment 'recompute here so the device preference wins' is misleading - the device preference is already applied before paint. The real reason for the call is that the surrounding block also wires up the matchMedia change listener, and a single apply call sites the listener registration naturally. Either drop the call (the attribute is already set) or update the comment so the next reader doesn't think the inline script is unreliable.
  • ℹ️ test/cli-output.test.js:1465 - The 'unset' case in the new test calls createHomeOutput with no themePreference/themeDirective, so the function default of {preference: null, directive: null} is asserted. In the real run() flow, themePreference is always supplied by readHomeThemePreference(), which returns 'system' (not null) when unset - so the actual home output for a vanilla install will carry {preference: 'system', directive: null}, not the null/null pair the test pins. The test currently guards the function default rather than the shipped behavior, which is mildly misleading if a future reader cross-references the test against the run() wiring.

🔧 Fix: Remove unused chrome theme icons and dead localStorage test harness
3 issues (1 warning, 2 infos) still open:

  • ⚠️ test/chrome-client-queue.test.js:142 - The new test infrastructure (systemMediaQueryList, mediaQueryListeners, matchMedia, documentElement, getAttribute) was added so the chrome-client.js OS-appearance change handler can be exercised, but no test in this file actually invokes a registered change handler. That means the new behavior at src/chrome-client.js:806-812 (where themePref === 'system' re-resolves on matchMedia('change')) ships without direct coverage. The previous setSystemTheme helper that drove this path was deleted in 22c4749; without a replacement, a regression that prevented live OS tracking would only be caught by a manual end-to-end check.
  • ℹ️ src/cli.js:159 - For themePref === 'system', readHomeThemeDirective calls await resolveSystemAppearance() and then passes the result to buildThemeDirective(raw, { resolvedAppearance }). If the first attempt returns null (timeout, missing binary, gsettings not installed, etc.), buildThemeDirective falls through to its own default resolve(platform) call inside the if (themePref === 'system') branch, spawning a second osascript/reg/gsettings process. On a host where the OS query consistently fails, this doubles the latency of the home output (two 750ms timeouts worst case). A simple fix is to pass the first attempt's result as a non-null placeholder (e.g. resolvedAppearance: null is enough - buildThemeDirective already handles that) and not duplicate the work, or to short-circuit buildThemeDirective to skip the inner resolve when the caller already passed a hint even if null.
  • ℹ️ test/chrome-client-queue.test.js:142 - The new matchMedia mock declares a mediaQueryListeners array and a removeEventListener method on systemMediaQueryList, but no test in the file reads from mediaQueryListeners or invokes removeEventListener. The harness returns systemMediaQueryList for the prefers-color-scheme query and the chrome-client.js script does call addEventListener('change', handler), but the registered handlers are never invoked. Either drop the array/removeEventListener (chrome-client.js only calls addEventListener) or add a test that fires the listeners to exercise the change path - otherwise this is dead state carried alongside the new theming work.

🔧 Fix: Dedupe OS appearance resolution and prune dead test mock
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • pnpm test (495/495 pass)
  • pnpm run build succeeds
  • node dist/cli.mjs config returns { config: {} } for unset
  • node dist/cli.mjs config theme returns { config: { theme: &#34;system&#34; } } for unset
  • node dist/cli.mjs config theme dark writes config.theme=dark to state.json
  • node dist/cli.mjs config theme light writes config.theme=light to state.json
  • node dist/cli.mjs config theme system writes config.theme=system to state.json
  • node dist/cli.mjs config theme sepia returns VALIDATION_ERROR with allowed-values hint
  • node dist/cli.mjs config whatever returns VALIDATION_ERROR for unknown subcommand
  • node dist/cli.mjs home output includes theme_preference: { preference, directive } for unset/light/dark/system
  • node dist/cli.mjs design output includes theme_preference: { preference, directive } plus updated theme_usage
  • node dist/cli.mjs config --help shows the new help block
  • Served chrome HTML at /session/&lt;key&gt; embeds &lt;html data-theme-pref=&#34;...&#34;&gt; and the no-FOUC inline head script
  • chrome-devtools-axi reports data-theme-pref=dark / data-theme=dark after config theme dark
  • chrome-devtools-axi reports data-theme-pref=light / data-theme=light after config theme light
  • chrome-devtools-axi emulate --color-scheme dark flips chrome from light to dark when pref is system
  • chrome-devtools-axi emulate --color-scheme light flips chrome from dark to light when pref is system
  • Explicit dark preference stays dark even after --color-scheme light (no live-follow when not system)
  • SessionStore.upsertSession + queuePrompts after a config write preserves the config (regression)
  • buildThemeDirective returns null for null/undefined/sepia/blue preferences
  • buildThemeDirective returns null for unset (vanilla install never nags the agent)
  • buildThemeDirective(&#34;system&#34;, { resolvedAppearance: &#34;light&#34; }) returns the light-mode-OS directive
  • buildThemeDirective(&#34;system&#34;, { resolvedAppearance: &#34;dark&#34; }) returns the dark-mode-OS directive
  • buildThemeDirective(&#34;system&#34;, { resolvedAppearance: null }) returns the prefers-color-scheme fallback
  • resolveSystemAppearance(&#34;darwin&#34;, exec=&gt;&#34;true&#34;) returns dark; exec=&gt;&#34;false&#34; returns light
  • resolveSystemAppearance(&#34;freebsd&#34;) returns null on unsupported platforms
  • dist/chrome.css defines :root{...color-scheme:dark;--bg:var(--ink-900);...} and :root[data-theme=&#34;light&#34;]{...color-scheme:light;--bg:#f1ece3;...} with the matching theme-aware tokens (--hover, --scrim, --banner-bg/-fg/-border, --accent-divider)
  • Theme-aware surfaces (.menu-item:hover, .more-button:hover, .ended-overlay, .layout-issue-banner, .send-caret) route through CSS variables instead of hardcoded dark-only colors
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@kunchenguid

Copy link
Copy Markdown
Owner

Thanks for the PR! It looks like this branch has a merge conflict with the base branch right now. When you get a chance, could you rebase onto (or merge in) the latest base branch, resolve the conflict, and push? Once GitHub shows the PR as mergeable again, it'll be picked back up for review.

Noted for lavish-axi#131 at 1dd4b753.

@gfiorav
gfiorav force-pushed the fm/lavish-theme-q4 branch from 1dd4b75 to 5b6befb Compare July 2, 2026 14:19
@gfiorav gfiorav changed the title feat(chrome): add light/dark theme to editor chrome and artifact content feat(chrome): add light/dark theme switch with content opt-out Jul 2, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Thanks for the work here — solid execution and test coverage. But I think this approach adds more complexity/maintenance than I want, and the bigger issue is it breaks portability: with the injected content-theme.css + no-FOUC boot script (html-transform.js's injectLavishSdk), the artifact no longer renders identically outside Lavish by default — it now needs data-lavish-theme="own" (or the <meta> equivalent) just to get back the old byte-identical behavior.

I'd like to rescope this along these lines instead:

  1. A lavish-axi command (maybe folded under a config command) that sets the theme preference globally for the current device, rather than the per-browser localStorage["lavish-axi:theme"] toggle this PR adds in chrome-client.js. Given AGENTS.md already documents ~/.lavish-axi/state.json as the place cross-invocation state lives, that seems like the right home for a device-wide preference instead of browser storage.
  2. That global preference drives how the editor chrome renders — keep the same System/Light/Dark idea, just persisted device-wide instead of per-browser-tab.
  3. Instead of injecting a stylesheet/boot script that overrides an artifact's own declared theme at request time, have lavish-axi pass the preferred theme to the agent as generation guidance — similar to how design-reference.js's DESIGN_SYSTEM_HINT already steers agents on design-system choice before they write HTML — so the theme is baked into the artifact's own markup/CSS rather than injected and overridden at runtime. That keeps artifacts truly portable with no opt-out mechanism needed.

Can you rework the PR along these lines? Happy to discuss specifics if useful.

@gfiorav
gfiorav force-pushed the fm/lavish-theme-q4 branch from 9cdaa3f to e72f02f Compare July 3, 2026 22:39
@gfiorav gfiorav changed the title feat(chrome): add light/dark theme switch with content opt-out feat(chrome): add device-wide theme preference and config command Jul 3, 2026
gfiorav and others added 5 commits July 3, 2026 18:40
The editor chrome had only a dark theme. Add a cohesive light counterpart
keyed by data-theme on :root, defaulting to the OS preference and tracking
live OS changes with no reload.

- src/chrome.css: keep the luxury palette as the dark :root default, add a
  :root[data-theme=light] block with paper/ink tokens, route hover/scrim/
  banner/divider surfaces through new --hover/--scrim/--banner-*/--accent-
  divider semantic tokens so each theme sets a fitting value, style the
  theme switch.
- src/server.js: inline a no-FOUC head script that resolves data-theme from
  localStorage before paint, add a three-state System/Light/Dark segmented
  control to the top bar with sun/moon/system icons.
- src/chrome-client.js: own the runtime theme logic - read the stored
  preference, wire the toggle, apply data-theme, and listen to the
  prefers-color-scheme media query so System follows live OS changes.
  System is stored by removing the localStorage key.
- tests: server tests for the head script, toggle markup, light token block,
  and theme-aware surfaces; chrome-client harness extended with
  localStorage/matchMedia/documentElement and five theme-logic tests.
- .prettierignore: ignore the environment-injected .opencode dir so
  pnpm run check passes in worktrees that include it.
- AGENTS.md: document the chrome theme system.

The user-authored artifact inside the iframe is never themed by the chrome.
Replace the in-browser System/Light/Dark switch (and its per-tab localStorage
toggle) with a single device-wide preference set from the CLI.

- Add `lavish-axi config [theme [system|light|dark]]` that reads and writes
  a top-level `config` object in `~/.lavish-axi/state.json`.
- `SessionStore.readState` now preserves `config` across every session
  write (the previous readState stripped it and would have wiped a theme
  write the first time any session was touched).
- `createChromeHtml` accepts `themePref` from `getThemePreference` and
  embeds it as a `data-theme-pref` attribute on `<html>`; the inline
  head script resolves `data-theme` from that attribute before paint.
- `chrome-client.js` reads the attribute and tracks live OS changes while
  the preference is `system`. The three-state segmented control and the
  localStorage write are gone.
- The annotation-card / SDK UI still follows the resolved chrome theme
  (sourced from the device preference, not from the localStorage toggle).
- Artifact content theming becomes **generation guidance**, not a runtime
  override. Lavish no longer injects a content-theme stylesheet, a no-FOUC
  boot script, or an opt-out mechanism: served artifacts stay
  byte-identical to their on-disk form (apart from the SDK script tag),
  restoring direct-open portability that the previous iteration broke.
- A new `src/appearance.js` exposes `resolveSystemAppearance`
  (osascript on macOS, AppsUseLightTheme registry on Windows, gsettings on
  Linux) and `buildThemeDirective` (which turns the stored preference
  into a short directive for the agent to bake into the artifact's own
  CSS, Tailwind theme, or DaisyUI `data-theme`).
- For `system`, the OS appearance is resolved at CLI time on the device
  and folded into a concrete directive; when the platform cannot be
  queried the directive falls back to a `prefers-color-scheme` hint,
  since a statically baked theme cannot live-track later OS toggles. An
  unset preference emits no directive, so a vanilla install never nags
  the agent.
- `home` and `design` CLI outputs surface a `theme_preference` block
  with the current preference and directive so agents pick the theme up
  while writing HTML.
- Docs: AGENTS.md and README describe the new control surface and
  explicitly drop the previous own/opt-out framing.
@gfiorav
gfiorav force-pushed the fm/lavish-theme-q4 branch from e72f02f to ed0ff76 Compare July 3, 2026 22:48
@gfiorav

gfiorav commented Jul 3, 2026

Copy link
Copy Markdown
Author

@kunchenguid Human gfiorav here: I agree with the fact that this is too much to maintain.

I can't shake the idea that lavish should support two very good themes (light and dark) and give the agent a host of well curated components it can use (tables, headers, option selector, etc). Artifact-driven themes are nice, but I think they don't scale as well as a more practical (and more opinionated) approach.

Just a few thoughts. I'm having the robot gfiorav rework the PR as per your suggestions.

@pvinis

pvinis commented Jul 27, 2026

Copy link
Copy Markdown

what happened withi this? shall i try to redo a PR for light mode?

@gfiorav

gfiorav commented Jul 27, 2026

Copy link
Copy Markdown
Author

what happened withi this? shall i try to redo a PR for light mode?

Yes, please try again. I lost interest (too many things going on) but it would be nice to have. Kun provided some guidance on what he think the solution should be, so take his pointers into account

@pvinis

pvinis commented Jul 27, 2026

Copy link
Copy Markdown

will do. thanks!

@pvinis

pvinis commented Jul 28, 2026

Copy link
Copy Markdown

@gfiorav, @kunchenguid did the redo, it ended up as two PRs: #203 and #204.

same feature, same pixels, the only difference is how the css does it (attribute selector + prefers-color-scheme vs light-dark()). there's a comparison table at the top of each. the other four commits are identical on both branches.

pick whichever you prefer, close the other and i'll clean up :) thanks

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.

3 participants