diff --git a/THEMES.md b/THEMES.md index e759359d..1fa8c9c8 100644 --- a/THEMES.md +++ b/THEMES.md @@ -102,6 +102,12 @@ Two ways to ship it: declare it in `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS`. No code changes needed; the skin attribute can be set from your own JS. + Extensions that register a skin through `window.registerHermesSkin()` may + also set `scheme: "light"` or `scheme: "dark"` for light-only or dark-only + skins. The saved Theme preference stays unchanged, but WebUI applies the + matching effective base class while that skin is selected so System/Light or + System/Dark does not mix incompatible base tokens into the skin. + ### Tips - **Test both themes.** A skin that pops on Dark can be illegible on Light. @@ -142,7 +148,9 @@ font size. Persists alongside theme and skin. 1. **Theme:** `document.documentElement.classList.toggle('dark', isDark)` — light mode removes the class. System mode tracks - `matchMedia('(prefers-color-scheme: dark)')`. + `matchMedia('(prefers-color-scheme: dark)')`. Extension-registered skins + may declare a light/dark `scheme`; when selected, that scheme controls the + effective `.dark` class without rewriting the saved Theme preference. 2. **Skin:** `document.documentElement.dataset.skin = name` (or remove the attribute for `default`). 3. **Font size:** `document.documentElement.dataset.fontSize = size` (or diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 55543f39..ce615078 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -387,6 +387,7 @@ window.registerHermesSkin({ name: 'E-Ink', // display name (also the picker label) value: 'e-ink', // optional stable key; slugified from name if omitted label: 'E-Ink', // optional explicit picker label + scheme: 'light', // optional: force a light or dark base while selected colors: ['#000000', '#ffffff', '#555555'], // up to 3 preview swatches tokens: { // CSS design-token overrides for this skin '--bg': '#ffffff', @@ -405,6 +406,13 @@ shows up in the picker, can be selected, and persists across reloads exactly like a built-in skin. Registering the same key again updates it in place (idempotent), which is what a live theme editor relies on while the user edits. +Use `scheme` when a skin is light-only or dark-only. Accepted values are +`"light"` and `"dark"`; any other value is ignored. This does not rewrite the +user's saved Theme setting (`Light`, `Dark`, or `System Default`). It only +controls the effective base theme class while that extension skin is selected, +so a dark editor skin is not mixed with light-mode code/table tokens, and a +light E-Ink skin is not mixed with dark-mode tokens. + **Core does the security-sensitive work for you.** Because token values are written into CSS, every value is sanitized in core, once, so every theme extension inherits the guard: @@ -425,6 +433,8 @@ extension inherits the guard: - **Reserved keys are protected** — an extension cannot overwrite a built-in skin key (e.g. `default`, `ares`, `graphite`). - A descriptor with no valid tokens after sanitization is rejected entirely. +- **Skin scheme is constrained** — only `light` and `dark` are accepted. Invalid + scheme values are ignored rather than rendered into CSS. This is the supported, forward-looking way for theme-pack and theme-creator extensions to integrate with the built-in appearance system. diff --git a/static/boot.js b/static/boot.js index 4a374143..d2300cff 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1952,7 +1952,7 @@ if(window.visualViewport){ }; })(); -// ── Appearance helpers (theme = light/dark/system, skin = accent color) ────── +// ── Appearance helpers (theme = light/dark/system, skin = palette/accent) ──── const _THEMES=[ {name:'Light', value:'light', colors:['#FEFCF7','#FAF7F0','#B8860B']}, {name:'Dark', value:'dark', colors:['#0D0D1A','#141425','#FFD700']}, @@ -1990,6 +1990,7 @@ const _LEGACY_THEME_MAP={ }; let _systemThemeMq=null; let _onSystemThemeChange=null; +let _resolvedThemeBaseDark=false; function _normalizeAppearance(theme,skin){ const rawTheme=typeof theme==='string'?theme.trim().toLowerCase():''; @@ -2027,11 +2028,36 @@ function _syncThemeColorMeta(){ }catch(e){} } +function _skinKey(skin){ + return (skin&&String(skin.value||skin.name||'').toLowerCase())||''; +} + +function _findSkinEntry(key){ + const normalized=String(key||'default').toLowerCase(); + return (_SKINS||[]).find(s=>_skinKey(s)===normalized)||null; +} + +function _activeSkinScheme(){ + const key=(document.documentElement.dataset.skin||'default').toLowerCase(); + const skin=_findSkinEntry(key); + const scheme=skin&&skin._extScheme; + return scheme==='light'||scheme==='dark'?scheme:''; +} + +function _effectiveThemeDark(baseIsDark){ + const skinScheme=_activeSkinScheme(); + if(skinScheme==='dark') return true; + if(skinScheme==='light') return false; + return !!baseIsDark; +} + function _setResolvedTheme(isDark){ - document.documentElement.classList.toggle('dark',!!isDark); + _resolvedThemeBaseDark=!!isDark; + const effectiveDark=_effectiveThemeDark(_resolvedThemeBaseDark); + document.documentElement.classList.toggle('dark',effectiveDark); const link=document.getElementById('prism-theme'); if(!link){ _syncThemeColorMeta(); return; } - const want=isDark + const want=effectiveDark ?'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 @@ -2062,7 +2088,7 @@ function _applySkin(name){ const key=(name||'default').toLowerCase(); if(key==='default') delete document.documentElement.dataset.skin; else document.documentElement.dataset.skin=key; - _syncThemeColorMeta(); + _setResolvedTheme(_resolvedThemeBaseDark); } function _pickTheme(name){ @@ -2200,6 +2226,11 @@ const _ALLOWED_SKIN_TOKENS=new Set([ // with url(), expression(), semicolons, braces, or other CSS-injection vectors. const _SAFE_SKIN_VALUE_RE=/^(#(?:[0-9a-fA-F]{3,8})|rg(?:b|ba)\(\s*[0-9.,%\s/]+\)|hsl(?:a)?\(\s*[0-9.,%\s/deg]+\)|[0-9]{1,3}\s*,\s*[0-9]{1,3}\s*,\s*[0-9]{1,3}|[a-zA-Z]{3,20}|[0-9.]+(?:px|em|rem|%)?)$/; +function _sanitizeSkinScheme(scheme){ + const value=String(scheme||'').trim().toLowerCase(); + return value==='light'||value==='dark'?value:''; +} + function _sanitizeSkinTokens(tokens){ const out={}; if(!tokens||typeof tokens!=='object') return out; @@ -2244,12 +2275,13 @@ function registerHermesSkin(descriptor){ if(_RESERVED_SKIN_KEYS.has(key)) return false; // never shadow a core skin const tokens=_sanitizeSkinTokens(descriptor.tokens); if(Object.keys(tokens).length===0) return false; // nothing valid to apply + const scheme=_sanitizeSkinScheme(descriptor.scheme); // 3 swatch colors for the picker (sanitized); fall back to accent/bg/text. let colors=Array.isArray(descriptor.colors)?descriptor.colors.slice(0,3):[]; colors=colors.map(c=>String(c).trim()).filter(c=>_SAFE_SKIN_VALUE_RE.test(c)); while(colors.length<3) colors.push(tokens['--accent']||tokens['--bg']||tokens['--text']||'#888'); const label=String(descriptor.label||name).slice(0,40); - const entry={name:name.slice(0,40),value:key,label,colors,_extToken:tokens,_extension:true}; + const entry={name:name.slice(0,40),value:key,label,colors,_extToken:tokens,_extScheme:scheme,_extension:true}; const existingIdx=_SKINS.findIndex(s=>(s.value||s.name).toLowerCase()===key); if(existingIdx>=0&&_EXT_SKIN_KEYS.has(key)){ diff --git a/tests/test_extension_theme_registration.py b/tests/test_extension_theme_registration.py index 37fb1892..a7360cec 100644 --- a/tests/test_extension_theme_registration.py +++ b/tests/test_extension_theme_registration.py @@ -34,10 +34,23 @@ def test_register_api_exposed_on_window(): def test_token_sanitizer_and_allowlist_present(): assert "_sanitizeSkinTokens" in BOOT_JS + assert "_sanitizeSkinScheme" in BOOT_JS assert "_ALLOWED_SKIN_TOKENS" in BOOT_JS, "token allowlist must exist" assert "_SAFE_SKIN_VALUE_RE" in BOOT_JS, "value safety regex must exist" +def test_extension_skin_scheme_can_override_effective_theme_class(): + """Extension skins may opt into a light/dark base without changing the + stored theme preference. This is the core capability that prevents dark + editor skins from being mixed with light base tokens, and vice versa.""" + assert "function _effectiveThemeDark(baseIsDark)" in BOOT_JS + assert "if(skinScheme==='dark') return true;" in BOOT_JS + assert "if(skinScheme==='light') return false;" in BOOT_JS + assert "document.documentElement.classList.toggle('dark',effectiveDark)" in BOOT_JS + assert "const scheme=_sanitizeSkinScheme(descriptor.scheme)" in BOOT_JS + assert "_extScheme:scheme" in BOOT_JS + + def test_reserved_core_skins_are_guarded(): assert "_RESERVED_SKIN_KEYS" in BOOT_JS assert "if(_RESERVED_SKIN_KEYS.has(key)) return false" in BOOT_JS, ( @@ -139,9 +152,9 @@ let region = src.slice(a, b + endMarker.length); // The region references _SKINS / _VALID_SKINS / _applySkin / _buildSkinPicker / // _syncSkinPicker from the surrounding module — stub them. const prelude = ` - const _SKINS = [{name:'Default',colors:['#FFD700','#FFBF00','#CD7F32']}, - {name:'Ares',colors:['#FF4444','#CC3333','#992222']}]; - const _VALID_SKINS = new Set(_SKINS.map(s=>(s.value||s.name).toLowerCase())); + var _SKINS = [{name:'Default',colors:['#FFD700','#FFBF00','#CD7F32']}, + {name:'Ares',colors:['#FF4444','#CC3333','#992222']}]; + var _VALID_SKINS = new Set(_SKINS.map(s=>(s.value||s.name).toLowerCase())); function _applySkin(){} function _buildSkinPicker(){} function _syncSkinPicker(){} @@ -189,10 +202,106 @@ results.idempotent = registerHermesSkin({ results.garbage = registerHermesSkin(null); results.noTokens = registerHermesSkin({ name: 'X', value: 'x' }); +// 8. optional scheme is restricted to light/dark and stored on the skin entry +results.darkScheme = registerHermesSkin({ + name: 'Tokyo Night', value: 'tokyo-night', scheme: 'dark', + tokens: { '--bg':'#1a1b26', '--text':'#c0caf5', '--accent':'#7aa2f7' } +}); +results.invalidScheme = registerHermesSkin({ + name: 'Bad Scheme', value: 'bad-scheme', scheme: 'dark;body{display:none}', + tokens: { '--bg':'#111111', '--text':'#eeeeee' } +}); +results.schemes = {}; +for (const skin of _SKINS) { + const key = (skin.value || skin.name).toLowerCase(); + if (key === 'tokyo-night' || key === 'bad-scheme') { + results.schemes[key] = skin._extScheme || ''; + } +} + console.log(JSON.stringify(results)); """ +_SCHEME_HARNESS = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); + +const classes = new Set(); +global.document = { + documentElement: { + dataset: {}, + classList: { + toggle(name, on) { if (on) classes.add(name); else classes.delete(name); }, + contains(name) { return classes.has(name); }, + }, + }, + getElementById: () => null, + querySelectorAll: () => [], +}; +global.getComputedStyle = () => ({ getPropertyValue: () => '#141425' }); + +let systemMatches = false; +const listeners = new Set(); +global.window = { + matchMedia: () => ({ + get matches() { return systemMatches; }, + addEventListener(_name, fn) { listeners.add(fn); }, + removeEventListener(_name, fn) { listeners.delete(fn); }, + }), +}; + +const startMarker = "const _LEGACY_THEME_MAP"; +const endMarker = "function _pickTheme(name){"; +const a = src.indexOf(startMarker); +const b = src.indexOf(endMarker); +if (a < 0 || b < 0) { console.log(JSON.stringify({error: 'markers not found'})); process.exit(0); } +const region = src.slice(a, b); + +const prelude = ` + var _SKINS = [ + {name:'Default', value:'default'}, + {name:'Tokyo Night', value:'tokyo-night', _extScheme:'dark'}, + {name:'E-Ink', value:'e-ink', _extScheme:'light'} + ]; + var _VALID_THEMES = new Set(['light','dark','system']); + var _VALID_SKINS = new Set(_SKINS.map(s => (s.value || s.name).toLowerCase())); +`; +eval(prelude + region); + +function state(label) { + return { + label, + dark: document.documentElement.classList.contains('dark'), + skin: document.documentElement.dataset.skin || 'default', + }; +} + +const results = []; + +_applyTheme('light'); +_applySkin('tokyo-night'); +results.push(state('dark-scheme-overrides-light')); + +_applyTheme('system'); +_applySkin('tokyo-night'); +results.push(state('dark-scheme-overrides-system-light')); + +_applyTheme('dark'); +_applySkin('e-ink'); +results.push(state('light-scheme-overrides-dark')); + +_applySkin('default'); +results.push(state('default-restores-dark-base')); + +_applyTheme('light'); +_applySkin('default'); +results.push(state('default-restores-light-base')); + +console.log(JSON.stringify({results})); +""" + + def test_registration_and_sanitization_behavior(): node = shutil.which("node") if not node: @@ -219,3 +328,31 @@ def test_registration_and_sanitization_behavior(): assert out["idempotent"] is True, "re-registering an ext skin key is allowed" assert out["garbage"] is False, "null descriptor rejected" assert out["noTokens"] is False, "descriptor with no tokens rejected" + assert out["darkScheme"] is True, "light/dark skin scheme should be accepted" + assert out["invalidScheme"] is True, "invalid scheme should not reject otherwise valid tokens" + assert out["schemes"]["tokyo-night"] == "dark", "valid dark scheme must be stored" + assert out["schemes"]["bad-scheme"] == "", "invalid scheme must be ignored" + + +def test_extension_skin_scheme_drives_effective_dark_class(): + node = shutil.which("node") + if not node: + import pytest + pytest.skip("node not available for behavioral harness") + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False) as f: + f.write(_SCHEME_HARNESS) + harness_path = f.name + proc = subprocess.run( + [node, harness_path, str(REPO / "static" / "boot.js")], + capture_output=True, text=True, timeout=30, + ) + assert proc.returncode == 0, f"harness failed: {proc.stderr or proc.stdout}" + out = json.loads(proc.stdout.strip().splitlines()[-1]) + assert out.get("error") is None, f"harness error: {out.get('error')}" + + states = {row["label"]: row for row in out["results"]} + assert states["dark-scheme-overrides-light"]["dark"] is True + assert states["dark-scheme-overrides-system-light"]["dark"] is True + assert states["light-scheme-overrides-dark"]["dark"] is False + assert states["default-restores-dark-base"]["dark"] is True + assert states["default-restores-light-base"]["dark"] is False diff --git a/tests/test_theme_color_meta_bridge.py b/tests/test_theme_color_meta_bridge.py index f8fc3ab0..af2e03ad 100644 --- a/tests/test_theme_color_meta_bridge.py +++ b/tests/test_theme_color_meta_bridge.py @@ -109,18 +109,19 @@ class TestBootJsThemeColorSync: 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. + # The end of _applySkin must re-run resolved-theme application. That + # path now accounts for extension skin light/dark schemes and still + # calls _syncThemeColorMeta() through _setResolvedTheme(). 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" + " _setResolvedTheme(_resolvedThemeBaseDark);\n" "}" ) assert anchor in src + assert "if(!link){ _syncThemeColorMeta(); return; }" in src class TestStyleCssBgVarPresent: