Skip to content

feat(theme): expose active --bg via <meta name="theme-color"> for native chrome bridges#1748

Closed
nesquena-hermes wants to merge 1 commit intomasterfrom
fix/theme-color-meta
Closed

feat(theme): expose active --bg via <meta name="theme-color"> for native chrome bridges#1748
nesquena-hermes wants to merge 1 commit intomasterfrom
fix/theme-color-meta

Conversation

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

feat(theme): expose active --bg via <meta name="theme-color"> for native chrome bridges

Why

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 reading getComputedStyle().backgroundColor of the topmost opaque element. The approach is fragile in two real ways:

  1. Sample-point overlay collisions. Any opaque modal / lightbox / settings panel / file-tree overlay / image preview that covers any of the three sample points and stays visible past the 2.5-second stability gate makes the bridge fire the overlay's colour. With multi-window / native tabs that one tab's overlay-derived colour gets propagated to every other tab.
  2. Pixel sampling is also IPC-heavy. 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:

<!-- theme-color: surfaces the active theme's background to native chrome ... -->
<meta name="theme-color" content="#FEFCF7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0D0D1A" media="(prefers-color-scheme: dark)">
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#0D0D1A':'#FEFCF7';var m=document.getElementById('hermes-theme-color');if(m)m.setAttribute('content',c);}catch(e){}})()</script>
  • The two prefers-color-scheme variants cover browsers and contexts that read theme-color before any JS executes.
  • The third id="hermes-theme-color" tag is the runtime tag that boot.js updates as the user toggles theme/skin. Native wrappers read it via document.getElementById('hermes-theme-color').content.
  • The inline seeder runs synchronously in <head> and corrects the runtime tag from localStorage before any external JS loads. Without it, a user whose explicit light theme overrides the OS-default dark would see one frame of dark chrome before boot.js catches up.

static/boot.js (24 lines added)

New helper plus three call sites:

function _syncThemeColorMeta(){
  try{
    const meta=document.getElementById('hermes-theme-color');
    if(!meta) return;
    const bg=getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
    if(bg) meta.setAttribute('content',bg);
  }catch(e){}
}
  • Called from _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.
  • Called from _applySkin() so every skin switch (Default → Sienna → Sisyphus → Charizard → etc.) lands the correct distinct background in the meta tag.
  • Reads getComputedStyle(html).getPropertyValue('--bg') rather than a per-skin lookup table — every skin's --bg value reaches the tag automatically with no maintenance.

tests/test_theme_color_meta_bridge.py (131 lines, 10 tests)

Three test classes:

  1. TestIndexHtmlMetaTags — confirms both prefers-color-scheme variants and the stable-id runtime tag are present, and that the inline pre-paint seeder reads localStorage and writes via setAttribute.
  2. TestBootJsThemeColorSync — confirms the helper is defined, reads getComputedStyle(...) --bg, targets the known hermes-theme-color id, is called from both _setResolvedTheme paths, and is called from _applySkin. Uses literal anchor blocks so any drift in the surrounding code triggers a clear test failure.
  3. TestStyleCssBgVarPresent — confirms :root light and :root.dark still define --bg. Renames or removals would silently break the bridge.

Verification

Tests

4606 passed, 2 skipped, 3 xpassed, 1 warning, 8 subtests passed in 142.78s

(+10 new tests; 0 regressions.)

Live browser check on port 8789

Verified end-to-end via the test server:

Theme + skin Computed --bg Meta tag content Match?
light + default #FEFCF7 #FEFCF7
light + sienna #FAF9F5 #FAF9F5
dark + sienna #1F1E1C #1F1E1C

Each theme + skin combination produces the correct distinct hex in the runtime meta tag, with no flicker or transient mismatch.

Server-side smoke

$ curl -s http://127.0.0.1:8789/ | grep theme-color
<meta name="theme-color" content="#FEFCF7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0D0D1A" media="(prefers-color-scheme: dark)">
<meta name="theme-color" id="hermes-theme-color" content="#0D0D1A">

Companion PR (Mac side)

The Swift app companion PR will land at hermes-webui/hermes-swift-mac. It replaces the effectiveBackgroundAt(x, y) pixel-sampling in BrowserWindowController.swift's themeBridgeScript with a document.querySelector('meta[name="theme-color"][id="hermes-theme-color"]').content read, 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.

…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.
Copy link
Copy Markdown
Owner

@nesquena nesquena left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-scheme variants for the pre-JS case + one id="hermes-theme-color" runtime tag) plus an inline pre-paint seeder script that reads localStorage.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 reads getComputedStyle(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 --bg defaults).

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):

  1. Browser reads the two static <meta name="theme-color" media="(prefers-color-scheme: ...)"> tags. WebKit picks the one matching the OS color scheme.
  2. 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>:

  1. Reads localStorage.getItem('hermes-theme') (or default 'dark'). Resolves 'system' via matchMedia('(prefers-color-scheme:dark)').
  2. Picks #0D0D1A (dark) or #FEFCF7 (light) and calls meta.setAttribute('content', c) on the runtime tag. Critically: this happens BEFORE boot.js loads, so a user whose explicit light theme overrides an OS-default dark doesn't see one frame of dark chrome.

Runtime updates (user toggles theme/skin):

  1. _setResolvedTheme(isDark) flips documentElement.classList.dark, then calls _syncThemeColorMeta() from both exit paths.
  2. _applySkin(name) updates documentElement.dataset.skin, then calls _syncThemeColorMeta().
  3. 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):

  1. 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 to light|dark|system) and the <link rel="stylesheet"> for static/style.css. So legacy values (slate, solarized, monokai, etc.) are migrated to a clean dark|light|system value BEFORE the seeder reads them — no else branch needed for unknowns.
  • 'system' resolution: seeder explicitly handles systemmatchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light', identical to the theme-bootstrap script above. No drift.
  • Both _setResolvedTheme exit 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 the return and after the link.href update. Test test_set_resolved_theme_calls_sync_in_both_branches locks 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.css via getComputedStyle (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/catch around the getComputedStyle read: 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 static prefers-color-scheme defaults.
  • Comment block above the helper documents the three consumers (Safari status bar, iOS PWA, native WKWebView wrappers) and why the getComputedStyle approach 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 --bg defaults).
  • node -c static/boot.js — clean.
  • Full suite (excluding pre-existing macOS bash 3.2 test_ctl_script.py failures 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-scheme variants in index.html, and the seeder fallback). If the canonical light/dark --bg values in style.css ever change, these need to follow. The 2 TestStyleCssBgVarPresent tests 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 of style.css :root --bg at build time, but not in scope here.
  • The seeder's t==='dark' ? dark : light branch absorbs invalid values into light: should be impossible (the theme-bootstrap script normalises localStorage to dark|light|system before this runs), but a stray race where localStorage is 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) match style.css:4 (:root --bg:#FEFCF7) and style.css:70 (:root.dark --bg:#0D0D1A) verbatim. No drift today.
  • The PR description references --bg-1 in 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 -1 suffix). 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.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator Author

Thanks @nesquena-hermes — this shipped in v0.51.11 (commit 9900248) as part of a 3-PR full-sweep batch release. Stage rebased your branch onto current master, ran the full pre-release gate (4622 pytest, browser tests, Opus advisor verdict SHIP), and merged via release PR #1751.

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 git pull + restart.

Release notes: https://github.com/nesquena/hermes-webui/releases/tag/v0.51.11

nesquena-hermes added a commit to hermes-webui/hermes-swift-mac that referenced this pull request May 6, 2026
…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>
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 6, 2026
pull Bot pushed a commit to soitun/hermes-webui that referenced this pull request May 6, 2026
…-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.
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.

2 participants