feat: let extension skins declare base scheme
This commit is contained in:
1 parent
7c96296bf5
commit
70b019f710
5 files changed
+201
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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)){
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user