feat(theme): expose active --bg via <meta name="theme-color"> for native chrome bridges#1748
feat(theme): expose active --bg via <meta name="theme-color"> for native chrome bridges#1748nesquena-hermes wants to merge 1 commit intomasterfrom
--bg via <meta name="theme-color"> for native chrome bridges#1748Conversation
…ive chrome bridges The Mac Swift app (hermes-webui/hermes-swift-mac) and any other native WKWebView wrapper need the active theme background to keep AppKit chrome (tab bar, title bar, traffic-light area) in sync with the page. The current Mac approach pixel-samples the page via elementsFromPoint, which is fragile against modals/lightboxes/file-tree overlays — any opaque overlay over a sample point can poison the chrome colour for the entire app. (See swift-mac issue #70.) Surface the active theme's background as the canonical, overlay-resistant source of truth via <meta name="theme-color">: - Two static prefers-color-scheme variants in <head> for browsers that read theme-color before any JS runs (mobile Safari, PWAs). - One id="hermes-theme-color" runtime tag with an inline pre-paint seed script that reads localStorage hermes-theme so the meta tag is correct on first paint, before boot.js loads. - New _syncThemeColorMeta() helper in static/boot.js that reads getComputedStyle(html).getPropertyValue('--bg') and writes it into the runtime meta tag. Called from _setResolvedTheme (both branches — prism-loaded and prism-absent) and from _applySkin so every theme toggle and skin switch updates the meta tag. Reading --bg via getComputedStyle means each skin (Default, Sienna, Sisyphus, Charizard, etc.) reaches the meta tag with its distinct background — no per-skin lookup table to drift. Browser-verified end to end on port 8789: - light + default → meta=#FEFCF7 (matches --bg) - light + Sienna → meta=#FAF9F5 (skin's distinct bg) - dark + Sienna → meta=#1F1E1C (skin's dark variant) 10 regression tests added in tests/test_theme_color_meta_bridge.py covering: static media variants present, runtime id stable, pre-paint seed reads localStorage, helper defined and reads computed --bg, helper targets known id, both _setResolvedTheme branches call sync, _applySkin calls sync, root --bg defaults still match. Companion PR coming on hermes-webui/hermes-swift-mac to switch the theme bridge from elementsFromPoint pixel-sampling to reading document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content. Refs hermes-webui/hermes-swift-mac#70.
nesquena
left a comment
There was a problem hiding this comment.
Review — end-to-end ✅ (clean approve, behavioural harness confirms theme/skin → meta tag binding)
What this ships
Self-built PR adding <meta name="theme-color"> so native WKWebView wrappers (the Mac Swift app at hermes-webui/hermes-swift-mac, refs swift-mac#70) can keep AppKit chrome — tab bar, title bar, traffic-light area — in sync with the WebUI's active theme via a single source of truth, instead of pixel-sampling the page via elementsFromPoint(x, y) (overlay-prone, IPC-heavy).
Three pieces:
- static/index.html: 3 meta tags (two static
prefers-color-schemevariants for the pre-JS case + oneid="hermes-theme-color"runtime tag) plus an inline pre-paint seeder script that readslocalStorage.getItem('hermes-theme')and writes the correct hex into the runtime tag before any external JS loads. - static/boot.js:1110-1114: new
_syncThemeColorMeta()helper that readsgetComputedStyle(document.documentElement).getPropertyValue('--bg')and writes it into the runtime meta tag. Called from both exit paths of_setResolvedTheme()(prism-loaded and prism-absent — the latter covers onboarding/error pages) and from_applySkin(). - tests/test_theme_color_meta_bridge.py: 10 regression tests across 3 classes (static index.html shape, boot.js helper definition + call sites, style.css
--bgdefaults).
Traced against upstream hermes-agent
Cross-tool: zero. The change is entirely in the WebUI's static frontend (index.html head + boot.js theme/skin handlers + a new test file). No config.yaml write, no session-state mutation, no agent IPC, no model resolver path. The Mac Swift app (separate repo hermes-webui/hermes-swift-mac) is the only consumer of the new contract; this PR is the WebUI half. ✓
End-to-end trace
Pre-paint (browser parses head, no JS yet):
- Browser reads the two static
<meta name="theme-color" media="(prefers-color-scheme: ...)">tags. WebKit picks the one matching the OS color scheme. - The runtime
<meta id="hermes-theme-color">is also parsed; its content is#0D0D1A(the dark default — matches the explicit-no-localstorage case).
Inline seeder runs in <head>:
- Reads
localStorage.getItem('hermes-theme')(or default'dark'). Resolves'system'viamatchMedia('(prefers-color-scheme:dark)'). - Picks
#0D0D1A(dark) or#FEFCF7(light) and callsmeta.setAttribute('content', c)on the runtime tag. Critically: this happens BEFOREboot.jsloads, so a user whose explicitlighttheme overrides an OS-defaultdarkdoesn't see one frame of dark chrome.
Runtime updates (user toggles theme/skin):
_setResolvedTheme(isDark)flipsdocumentElement.classList.dark, then calls_syncThemeColorMeta()from both exit paths._applySkin(name)updatesdocumentElement.dataset.skin, then calls_syncThemeColorMeta().- The helper reads
getComputedStyle(html).getPropertyValue('--bg').trim()— picks up the current theme + skin via CSS inheritance and writes it to the meta tag.
Mac Swift app (separate PR, swift-mac#70):
- WKWebView reads
document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content. AppKit chrome flips with the page.
Behavioural harness — headless Chrome end-to-end
Built /tmp/test_theme_meta.html mirroring the actual style.css --bg definitions for default + sienna + sisyphus + charizard skins, called the helper after each theme/skin toggle, and ran via Google Chrome --headless --dump-dom:
default (light): --bg=#FEFCF7 meta=#FEFCF7 match=true
dark: --bg=#0D0D1A meta=#0D0D1A match=true
light + sienna: --bg=#FAF9F5 meta=#FAF9F5 match=true
dark + sienna: --bg=#1F1E1C meta=#1F1E1C match=true
light + sisyphus: --bg=#1A0E2E meta=#1A0E2E match=true
light + charizard: --bg=#FF7F00 meta=#FF7F00 match=true
back to default: --bg=#FEFCF7 meta=#FEFCF7 match=true
missing meta tag: helper guards no-op (no exception)
Each combination produces a distinct hex in the runtime meta tag, the helper's if(!meta) return guard handles the missing-tag case cleanly, and the try/catch around the getComputedStyle read means even a thrown --bg resolution can't break the boot path.
Other audit — things that are correct already
- ✅ Pre-paint seeder placement: lives in
<head>between the theme-bootstrap script (which normalises localStorage tolight|dark|system) and the<link rel="stylesheet">forstatic/style.css. So legacy values (slate,solarized,monokai, etc.) are migrated to a cleandark|light|systemvalue BEFORE the seeder reads them — noelsebranch needed for unknowns. - ✅
'system'resolution: seeder explicitly handlessystem→matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light', identical to the theme-bootstrap script above. No drift. - ✅ Both
_setResolvedThemeexit paths covered: the early-return path (prism-theme link absent — onboarding pages, error pages, anywhere the static asset is missing) was the easy one to miss. PR correctly adds_syncThemeColorMeta()before thereturnand after thelink.hrefupdate. Testtest_set_resolved_theme_calls_sync_in_both_brancheslocks both call sites with literal anchor blocks. - ✅
_applySkin()instrumented: skin switches from_applySkin('sienna')etc. trigger the meta sync. Test asserts the literal anchor block including the call. - ✅
getComputedStyle(--bg)picks up CSS inheritance: skins that only override--accent(ares, mono, slate, poseidon, sisyphus, charizard) inherit the root theme's--bg. Skins that override--bg(sienna at style.css:108,123) get their distinct value through. No per-skin lookup table to drift; verified end-to-end via headless Chrome. - ✅ No XSS / injection surface: the meta tag content is computed from
style.cssviagetComputedStyle(developer-controlled). The seeder picks one of two hardcoded hex literals based on a 2-state localStorage value. No user input lands in the meta content. - ✅ No new endpoints, no config.yaml writes, no session state, no auth changes — pure frontend asset.
- ✅
try/catcharound thegetComputedStyleread: any browser quirk that throws on the property-value read is silently swallowed; the meta tag just doesn't get updated. Acceptable degradation — chrome falls back to the staticprefers-color-schemedefaults. - ✅ Comment block above the helper documents the three consumers (Safari status bar, iOS PWA, native WKWebView wrappers) and why the
getComputedStyleapproach scales to every skin without maintenance.
Edge-case trace
| Scenario | Expected | Actual |
|---|---|---|
| First load, no localStorage, OS dark | dark meta hex on first paint | static media variant ✅ |
| First load, no localStorage, OS light | light meta hex on first paint | static media variant ✅ |
First load, localStorage dark on OS-light box |
dark on first paint | seeder writes #0D0D1A before boot.js ✅ |
First load, localStorage light on OS-dark box |
light on first paint | seeder writes #FEFCF7 before boot.js ✅ |
First load, localStorage system |
resolves via media query | ✅ |
First load, localStorage = legacy slate |
normalised by theme-bootstrap to dark before seeder runs |
✅ |
| User toggles light → dark | meta updates to #0D0D1A |
_setResolvedTheme → sync ✅ |
| User toggles dark → light | meta updates to #FEFCF7 |
✅ |
| User switches default → sienna (light) | meta updates to #FAF9F5 |
_applySkin → sync, harness verified ✅ |
| User switches default → sienna (dark) | meta updates to #1F1E1C |
harness verified ✅ |
| User switches sienna → charizard | meta updates to #FF7F00 |
harness verified ✅ |
Skin without --bg override (ares, mono, slate, poseidon, sisyphus, charizard) |
inherits root theme --bg |
CSS inheritance via getComputedStyle ✅ |
<meta id="hermes-theme-color"> missing (e.g. injected variant) |
helper no-ops cleanly | if(!meta) return ✅ |
getComputedStyle throws on a quirky engine |
helper no-ops cleanly | try/catch ✅ |
| Onboarding/error page (no Prism stylesheet link) | early-return path still calls sync | ✅ |
| Cross-tool: agent CLI doesn't read meta tags | n/a (Mac swift app only) | ✅ |
Tests
tests/test_theme_color_meta_bridge.py— 10/10 pass (3 classes: index.html static shape, boot.js helper + call sites, style.css--bgdefaults).node -c static/boot.js— clean.- Full suite (excluding pre-existing macOS bash 3.2
test_ctl_script.pyfailures unrelated to this PR): 4547 passed, 57 skipped, 3 xpassed, 0 failed in 44.08s. - Behavioural harness via headless Chrome: 7 theme/skin combinations + missing-meta + computed-style edge case all verified.
Minor observations (non-blocking)
- Hardcoded hex defaults in two places (the static
prefers-color-schemevariants in index.html, and the seeder fallback). If the canonical light/dark--bgvalues instyle.cssever change, these need to follow. The 2TestStyleCssBgVarPresenttests catch the style.css side; the index.html side relies on convention. Worth a follow-up that derives the index.html defaults from a server-side render ofstyle.css :root --bgat build time, but not in scope here. - The seeder's
t==='dark' ? dark : lightbranch absorbs invalid values intolight: should be impossible (the theme-bootstrap script normalises localStorage todark|light|systembefore this runs), but a stray race wherelocalStorageis mutated mid-paint by another tab would silently default to#FEFCF7. Acceptable; one frame of off-color is barely perceptible and the helper corrects it post-load. - No unit test for the inline seeder's localStorage → setAttribute branch — it's covered by static-shape assertions (
localStorage.getItem('hermes-theme')+setAttribute('content'are both required to be present). A behavioural test would need a JSDOM fixture; the headless-Chrome harness above is what actually verifies the runtime contract. - The
<meta name="theme-color">static-variants content values (#FEFCF7,#0D0D1A) matchstyle.css:4(:root --bg:#FEFCF7) andstyle.css:70(:root.dark --bg:#0D0D1A) verbatim. No drift today. - The PR description references
--bg-1in the index.html comment ("light/dark default values match style.css :root --bg-1 / :root.dark --bg-1") but the actual CSS variable is--bg(no-1suffix). Cosmetic comment typo only — doesn't affect runtime.
Recommendation
✅ Approved. Surgical, well-justified change that converts the Mac Swift app's fragile pixel-sampling loop into a one-line meta-tag read with no IPC cost. The architectural shape is right (single source of truth in the WebUI; native chrome consumers read the contract) and the implementation correctly handles all four variation axes (theme dark/light × skin × first-paint vs runtime × prism-loaded vs prism-absent).
Behavioural harness via headless Chrome confirms the helper produces the correct distinct hex for every theme + skin combination including the missing-meta and computed-style failure modes. Cross-tool safe (frontend-only, no agent surface). 10/10 PR tests + full suite green. Minor cosmetic comment typo (--bg-1 vs --bg) noted but non-blocking.
Parked at approval — ready for the release agent's merge/tag pipeline.
|
Thanks @nesquena-hermes — this shipped in v0.51.11 (commit GitHub didn't auto-close because the merge commit only references the squash-merged stage branch, not your fork's commit directly — closing manually for hygiene. Live now on https://get-hermes.ai/ and on existing installs after Release notes: https://github.com/nesquena/hermes-webui/releases/tag/v0.51.11 |
…ls back to pixel sample (#70) fix: v1.6.2 — theme bridge reads `<meta name="theme-color">` first, falls back to pixel sample (#70) Replaces v1.6.1's three-point pixel-sampling primary path with a read of `<meta name="theme-color" id="hermes-theme-color">` — a page-controlled authoritative source updated by hermes-webui v0.51.x+ on every theme/skin change. Pixel sampling becomes the fallback for older WebUI servers and raw error pages, byte-identical to v1.6.1 behaviour. Closes the bug shape Cygnus filed in #70: any opaque modal / lightbox / settings panel covering one of the three sample points and outlasting the 2.5 s stability gate would poison `lastReportedHex` for that window. With multi-tab, `AppDelegate.updateAppearance` then propagated the overlay-derived colour to every other window, and surviving windows' match-suppression kept it stuck. Reading the meta tag is overlay-resistant by construction. JS-only changes inside the existing `themeBridgeScript` `WKUserScript` template literal — no Swift code touched. Defensive regex (`^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$|^rgba?\(`) rejects malformed values (unresolved `var(--bg)`, named colours, `hsl(...)`) so they fall through to pixel sampling instead of poisoning `lastReportedHex`. New MutationObserver call on the meta tag's `content` attribute catches toggles without waiting for the 2 s poll tick. Companion WebUI side: nesquena/hermes-webui#1748 (merged into v0.51.11 2026-05-06) — exposes the meta tag, seeded pre-paint via inline script from `localStorage['hermes-theme']`, updated by `boot.js _updateThemeColorMeta()` on every theme/skin change. Reviewed by: - Opus 4.7 advisor (regex escape levels, regex correctness, observer semantics, documentStart/meta ordering — all VERIFIED CORRECT, no MUST-FIX, no SHOULD-FIX) - @nesquena Mac agent verification at 32e0432: `swift build -c release` Build complete (10.9 s, 0 warnings), `swift test` 22/22 passed, `bash build.sh` produced ad-hoc signed `.app` installed to /Applications. APPROVED at 18:32:50Z. Closes #70. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…a theme-color for native chrome bridges by @nesquena-hermes
…-color meta, quote-strip) + test-isolation hardening (nesquena#1746 deferred) Constituent PRs: - nesquena#1747 (@Michaelyklam) — wait for model catalog before opening picker (closes nesquena#1743) - nesquena#1748 (@nesquena-hermes) — theme-color meta tag for native chrome bridges (nesquena APPROVED) - nesquena#1750 (@nesquena-hermes) — strip surrounding quotes from Add Space path (nesquena APPROVED) Deferred to v0.51.12: - nesquena#1746 — Opus caught multiprocessing.Queue deadlock pattern (parent process.join() before queue drain hangs on output >64KB pipe buffer). Deferral comment with two specific fix options posted on PR. Plus 1 in-stage absorbed test-isolation fix: - test_issue1426 + test_issue1680: skip on detected prefix pollution (prong 2 of test-isolation-flake-recipe). Failure rate ~25% in full suite from sys.modules pollution; standalone always passes. Tests: 4596 → 4622 passing (+26). 0 regressions. Stably green. Pre-release verification: - 3 PRs CI-green individually + rebased onto master - pytest 4622 passed, 0 failed - node -c clean on static/ui.js + static/boot.js - 11/11 browser API endpoints PASS - Opus advisor: SHIP nesquena#1747/nesquena#1748/nesquena#1750, MUST-FIX block on nesquena#1746 Closes nesquena#1743.
feat(theme): expose active
--bgvia<meta name="theme-color">for native chrome bridgesWhy
Native WKWebView wrappers (the Mac Swift app at
hermes-webui/hermes-swift-mac, and any other future wrapper) need to keep their AppKit chrome — tab bar, title bar, traffic-light area, status bars — in sync with whatever theme the user has active inside the WebUI.Today the Swift app does this by pixel-sampling the page via
document.elementsFromPoint(x, y)at three fixed viewport coordinates and readinggetComputedStyle().backgroundColorof the topmost opaque element. The approach is fragile in two real ways:setInterval(report, 2000)fires on every WKWebView, every two seconds, even when nothing has changed.The user-visible symptom is the bug filed at hermes-webui/hermes-swift-mac#70 — the chrome flashes white for an unpredictable period during multi-tab sessions, persists after closing the offending tab, and reaches accessibility / photosensitivity territory.
The right architectural fix is to have the WebUI surface its current theme background as the single source of truth, via the standard
<meta name="theme-color">mechanism that mobile Safari, iOS PWAs, and WebKit-based wrappers already understand. The Mac Swift app then reads that one value directly — no overlay sampling, no IPC cost, no false positives.This PR is the WebUI half of that change. The Mac side will be a separate PR on the swift-mac repo.
What ships
static/index.html(5 lines added)Three meta tags + an inline pre-paint seeder, placed right after the existing theme bootstrap script:
prefers-color-schemevariants cover browsers and contexts that readtheme-colorbefore any JS executes.id="hermes-theme-color"tag is the runtime tag thatboot.jsupdates as the user toggles theme/skin. Native wrappers read it viadocument.getElementById('hermes-theme-color').content.<head>and corrects the runtime tag from localStorage before any external JS loads. Without it, a user whose explicitlighttheme overrides the OS-defaultdarkwould see one frame of dark chrome before boot.js catches up.static/boot.js(24 lines added)New helper plus three call sites:
_setResolvedTheme()in both exit paths — the early-return when the Prism stylesheet is absent (onboarding pages, error pages) AND the normal completion path. Missing either path would lag the chrome on those routes._applySkin()so every skin switch (Default → Sienna → Sisyphus → Charizard → etc.) lands the correct distinct background in the meta tag.getComputedStyle(html).getPropertyValue('--bg')rather than a per-skin lookup table — every skin's--bgvalue reaches the tag automatically with no maintenance.tests/test_theme_color_meta_bridge.py(131 lines, 10 tests)Three test classes:
TestIndexHtmlMetaTags— confirms bothprefers-color-schemevariants and the stable-id runtime tag are present, and that the inline pre-paint seeder reads localStorage and writes viasetAttribute.TestBootJsThemeColorSync— confirms the helper is defined, readsgetComputedStyle(...) --bg, targets the knownhermes-theme-colorid, is called from both_setResolvedThemepaths, and is called from_applySkin. Uses literal anchor blocks so any drift in the surrounding code triggers a clear test failure.TestStyleCssBgVarPresent— confirms:rootlight and:root.darkstill define--bg. Renames or removals would silently break the bridge.Verification
Tests
(+10 new tests; 0 regressions.)
Live browser check on port 8789
Verified end-to-end via the test server:
--bglight+default#FEFCF7#FEFCF7light+sienna#FAF9F5#FAF9F5dark+sienna#1F1E1C#1F1E1CEach theme + skin combination produces the correct distinct hex in the runtime meta tag, with no flicker or transient mismatch.
Server-side smoke
Companion PR (Mac side)
The Swift app companion PR will land at
hermes-webui/hermes-swift-mac. It replaces theeffectiveBackgroundAt(x, y)pixel-sampling inBrowserWindowController.swift'sthemeBridgeScriptwith adocument.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').contentread, falling back to the current pixel-sampling for backward compatibility with older WebUI servers. With this PR shipped, the Mac fix is a clean read-the-source-of-truth change.Refs
Refs hermes-webui/hermes-swift-mac#70.