feat: let extension skins declare base scheme

This commit is contained in:
Frank Song committed 2026-06-30 18:17:40 +08:00
1 parent 7c96296bf5
commit 70b019f710
5 files changed
+201 -13

No files matched your search

+9 -1
View File
@@ -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
+10
View File
@@ -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.
+37 -5
View File
@@ -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)){
+140 -3
View File
@@ -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
+5 -4
View File
@@ -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: