feat(theme): expose active --bg via <meta name="theme-color"> for native 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.
This commit is contained in:
nesquena-hermes authored and test committed 2026-05-06 17:24:23 +00:00
1 parent 4e1dacfaf8
commit e9aac079e1
3 files changed
+159 -1

No files matched your search

+23 -1
View File
@@ -1095,16 +1095,37 @@ function _normalizeAppearance(theme,skin){
return {theme:nextTheme,skin:nextSkin};
}
// Sync <meta name="theme-color"> with the active theme's computed --bg.
// This surfaces the WebUI's exact theme background to:
// 1. Mobile Safari status bar (the prefers-color-scheme media variants in index.html
// cover the pre-load case; this updater handles user-toggled changes mid-session).
// 2. iOS PWA / Add to Home Screen status bar.
// 3. Native WKWebView wrappers (e.g. hermes-swift-mac) that read this attribute as
// the source of truth for AppKit chrome (tab bar, title bar, traffic-light area)
// instead of pixel-sampling — overlay-resistant and IPC-free.
// Reading getComputedStyle(html).getPropertyValue('--bg') picks up the active skin
// (Default, Sienna, Sisyphus, Charizard, etc.) so each skin's distinct paint reaches
// the meta tag.
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){}
}
function _setResolvedTheme(isDark){
document.documentElement.classList.toggle('dark',!!isDark);
const link=document.getElementById('prism-theme');
if(!link) return;
if(!link){ _syncThemeColorMeta(); return; }
const want=isDark
?'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css'
:'https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css';
// No SRI integrity on theme CSS — jsdelivr edge nodes serve different
// digests for the same pinned version, causing intermittent blocking (#1100).
if(link.href!==want){ link.integrity=''; link.href=want; }
_syncThemeColorMeta();
}
function _applyTheme(name){
@@ -1129,6 +1150,7 @@ function _applySkin(name){
const key=(name||'default').toLowerCase();
if(key==='default') delete document.documentElement.dataset.skin;
else document.documentElement.dataset.skin=key;
_syncThemeColorMeta();
}
function _pickTheme(name){
+5
View File
@@ -17,6 +17,11 @@
<script>(function(){var path=location.pathname,marker='/session/',i=path.indexOf(marker),p;i>=0?p=(path.slice(0,i+1)||'/'):p=(path.endsWith('/')?path:(path.replace(/\/[^\/]*$/,'/')||'/'));document.write('<base href="'+location.origin+p+'">');})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<!-- theme-color: surfaces the active theme's background to native chrome (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --bg-1 / :root.dark --bg-1. -->
<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>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
+131
View File
@@ -0,0 +1,131 @@
"""Regression tests for the <meta name="theme-color"> bridge.
Covers:
- index.html declares the static prefers-color-scheme media variants (light + dark).
- index.html declares a single `id="hermes-theme-color"` meta tag for runtime updates.
- Inline pre-paint script reads localStorage `hermes-theme` and seeds the meta tag
before any external JS loads (no flash of wrong colour for native chrome).
- boot.js defines `_syncThemeColorMeta()` and calls it from `_setResolvedTheme()`
(covering both prism-loaded and prism-absent paths) and from `_applySkin()`.
- The helper reads `getComputedStyle(html).getPropertyValue('--bg')`, which means
every skin (Default, Sienna, Sisyphus, Charizard, etc.) reaches the meta tag.
This bridge is the source of truth that native WKWebView wrappers
(hermes-webui/hermes-swift-mac) read instead of pixel-sampling the page —
overlay-resistant (modals/lightboxes don't poison it) and IPC-free.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
INDEX = ROOT / "static" / "index.html"
BOOT = ROOT / "static" / "boot.js"
STYLE = ROOT / "static" / "style.css"
class TestIndexHtmlMetaTags:
def test_static_prefers_color_scheme_variants_present(self):
"""Two static <meta name="theme-color"> tags with media queries cover the
pre-load case for browsers that use the OS color scheme as a hint.
"""
src = INDEX.read_text(encoding="utf-8")
assert 'name="theme-color"' in src
assert 'media="(prefers-color-scheme: light)"' in src
assert 'media="(prefers-color-scheme: dark)"' in src
def test_runtime_theme_color_meta_has_stable_id(self):
"""A third theme-color meta tag (no media query) carries id="hermes-theme-color"
so boot.js can update it on theme/skin change. The id is the contract the
Mac Swift app reads via `document.getElementById('hermes-theme-color')`.
"""
src = INDEX.read_text(encoding="utf-8")
assert 'id="hermes-theme-color"' in src
# Must be on a meta tag (not some other element)
assert '<meta name="theme-color" id="hermes-theme-color"' in src
def test_inline_pre_paint_script_seeds_meta(self):
"""An inline script in <head> seeds the runtime meta tag from localStorage
before any external JS loads. This prevents a single-frame flash of the
OS-default theme-color when the user has explicitly chosen the opposite.
"""
src = INDEX.read_text(encoding="utf-8")
assert "hermes-theme-color" in src
# The seeder must read from the same localStorage key the theme bootstrap uses.
assert "localStorage.getItem('hermes-theme')" in src
# And must call setAttribute('content', ...) on the meta tag.
assert "setAttribute('content'" in src or 'setAttribute("content"' in src
class TestBootJsThemeColorSync:
def test_sync_helper_defined(self):
src = BOOT.read_text(encoding="utf-8")
assert "function _syncThemeColorMeta()" in src
def test_sync_helper_reads_computed_bg_var(self):
"""The helper must read the computed --bg CSS custom property so each skin's
background reaches the meta tag (Sienna gets terracotta, Sisyphus gets purple,
etc.).
"""
src = BOOT.read_text(encoding="utf-8")
# The helper reads getComputedStyle on documentElement and extracts --bg.
assert "getComputedStyle(document.documentElement).getPropertyValue('--bg')" in src
def test_sync_helper_targets_known_meta_id(self):
"""The helper must target the same id declared in index.html. Drift here
is the most common way a one-line frontend change silently breaks the
Swift app's theme-color reader.
"""
src = BOOT.read_text(encoding="utf-8")
assert "getElementById('hermes-theme-color')" in src
def test_set_resolved_theme_calls_sync_in_both_branches(self):
"""_setResolvedTheme has two exit paths:
1. Early return when the Prism stylesheet is absent (onboarding pages,
error pages, etc.).
2. Normal completion after possibly toggling the Prism stylesheet href.
Both paths must update the meta tag — otherwise the Mac chrome would lag
the page on those paths.
"""
src = BOOT.read_text(encoding="utf-8")
# Path 1 — the early return must call the sync first.
assert "if(!link){ _syncThemeColorMeta(); return; }" in src
# Path 2 — the trailing call must follow the link-href update.
assert (
"if(link.href!==want){ link.integrity=''; link.href=want; }\n"
" _syncThemeColorMeta();"
) in src
def test_apply_skin_calls_sync(self):
"""Switching skin (Default → Sienna → Sisyphus, etc.) recomputes --bg and
must update the meta tag so the Mac chrome flips with the page.
"""
src = BOOT.read_text(encoding="utf-8")
# The end of _applySkin must call the sync.
# We assert the literal anchor block from the recent edit so any drift
# in surrounding code triggers a clear test failure.
anchor = (
"function _applySkin(name){\n"
" const key=(name||'default').toLowerCase();\n"
" if(key==='default') delete document.documentElement.dataset.skin;\n"
" else document.documentElement.dataset.skin=key;\n"
" _syncThemeColorMeta();\n"
"}"
)
assert anchor in src
class TestStyleCssBgVarPresent:
"""The bridge depends on every theme/skin defining the --bg CSS variable.
These are the canonical locations as of v0.51.x — if any are missing or
renamed the meta-tag reader returns empty and the Mac chrome reverts to the
static prefers-color-scheme defaults.
"""
def test_root_light_defines_bg(self):
src = STYLE.read_text(encoding="utf-8")
# :root (light default) at the top of the file defines --bg.
assert "--bg:#FEFCF7" in src or "--bg: #FEFCF7" in src
def test_root_dark_defines_bg(self):
src = STYLE.read_text(encoding="utf-8")
assert "--bg:#0D0D1A" in src or "--bg: #0D0D1A" in src