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:
1 parent
4e1dacfaf8
commit
e9aac079e1
3 files changed
+159
-1
No files matched your search
+23
-1
@@ -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){
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user