aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/Makefile61
-rw-r--r--scripts/theme-studio/app-core.js32
-rw-r--r--scripts/theme-studio/app-util.js20
-rw-r--r--scripts/theme-studio/app.js30
-rw-r--r--scripts/theme-studio/generate.py30
-rwxr-xr-xscripts/theme-studio/run-tests.sh8
-rw-r--r--scripts/theme-studio/test-app-core.mjs157
-rw-r--r--scripts/theme-studio/test-app-util.mjs70
-rw-r--r--scripts/theme-studio/test_generate.py47
-rw-r--r--scripts/theme-studio/theme-studio.html73
10 files changed, 492 insertions, 36 deletions
diff --git a/scripts/theme-studio/Makefile b/scripts/theme-studio/Makefile
new file mode 100644
index 00000000..a7455b3d
--- /dev/null
+++ b/scripts/theme-studio/Makefile
@@ -0,0 +1,61 @@
+# Makefile for the theme-studio tool — a self-contained Python + JS subproject.
+# Its toolchain (python3, node, uvx, headless Chrome) is independent of the repo
+# root's Elisp/ERT world, so the build logic lives here with the code. The root
+# Makefile delegates: `make theme-studio-test` and `make theme-studio-coverage`
+# call `make -C scripts/theme-studio ...`.
+#
+# Recipes run in this directory, so the relative paths below resolve whether you
+# `cd` here or invoke via the root's `-C` delegation.
+
+# Absolute path to this directory (for `open`, which hands Chrome a file path).
+HERE := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+
+# Optional palette seed for `gen` / `open`: make gen SEED=dupre.json
+SEED ?=
+
+.PHONY: help test check coverage gen open
+
+.DEFAULT_GOAL := help
+
+help:
+ @echo "theme-studio targets:"
+ @echo " make test - Full suite: Python + Node + browser hash gates"
+ @echo " make check - Fast gate: regenerate + Python + Node (no browser)"
+ @echo " make coverage - JS (node) + generate.py (uvx coverage) numbers"
+ @echo " make gen [SEED=x.json] - Regenerate theme-studio.html (optionally from a seed)"
+ @echo " make open [SEED=x.json] - Regenerate and open the page in Chrome"
+
+test:
+ @./run-tests.sh
+
+check:
+ @./run-tests.sh --no-browser
+
+coverage:
+ @echo "== JS coverage (node --experimental-test-coverage) =="
+ @node --test --experimental-test-coverage ./*.mjs 2>/dev/null \
+ | sed -n '/start of coverage report/,/end of coverage report/p'
+ @echo ""
+ @echo "== generate.py coverage =="
+ @if command -v uvx >/dev/null 2>&1; then \
+ uvx coverage run --include='generate.py' -m unittest test_generate >/dev/null 2>&1; \
+ uvx coverage report -m; \
+ uvx coverage erase >/dev/null 2>&1; \
+ else \
+ echo "uvx not found — skipping generate.py line coverage"; \
+ echo "($$(grep -c 'def test_' test_generate.py) test_generate.py tests exist)"; \
+ fi
+
+gen:
+ @THEME_STUDIO_SEED="$(SEED)" python3 generate.py
+
+open: gen
+ @c=""; for b in google-chrome-stable google-chrome chromium chromium-browser; do \
+ command -v $$b >/dev/null 2>&1 && { c=$$b; break; }; \
+ done; \
+ if [ -n "$$c" ]; then \
+ "$$c" "$(HERE)theme-studio.html" >/dev/null 2>&1 & \
+ echo "opened theme-studio.html in $$c"; \
+ else \
+ echo "no Chromium-family browser found"; exit 1; \
+ fi
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
new file mode 100644
index 00000000..91b9b1a9
--- /dev/null
+++ b/scripts/theme-studio/app-core.js
@@ -0,0 +1,32 @@
+// Pure app logic — the package-face model and the dropdown option list — with no
+// DOM and no module globals (every dependency is a parameter). It is unit-tested
+// directly (test-app-core.mjs) and inlined into the page like colormath.js, so
+// the browser runs the same code the tests import. The app.js wrappers (pname,
+// seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the
+// live PALETTE / APPS / PKGMAP into these.
+
+// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
+function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
+
+// Seed the package-face map from the app inventory's per-face defaults.
+function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:nameToHex(d.fg,palette),bg:nameToHex(d.bg,palette),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}}return m;}
+
+// The package faces worth exporting (anything seeded or user-touched), trimmed.
+function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
+
+// Merge an imported package block into a face map, filling missing fields.
+function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}}
+
+// Effective fg/bg for a package face, following its inherit chain. seen guards
+// against an inherit cycle (returns null rather than recursing forever).
+function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[app][face];if(!f||seen[face])return null;seen[face]=1;if(f[attr])return f[attr];if(f.inherit&&map[app][f.inherit])return effResolve(map,app,f.inherit,attr,seen);return null;}
+
+// Standard swatch-dropdown option list: a default entry, then the palette. When
+// cur is set but no longer in the palette, surface it as a "(gone)" entry first.
+function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];}
+
+// Turn a theme name into a safe filename slug: collapse runs of disallowed
+// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
+function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify };
diff --git a/scripts/theme-studio/app-util.js b/scripts/theme-studio/app-util.js
new file mode 100644
index 00000000..e3f76dd8
--- /dev/null
+++ b/scripts/theme-studio/app-util.js
@@ -0,0 +1,20 @@
+// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status
+// color, and the readable text color for a background. These are kept out of
+// colormath.js (the pure math core) but are unit-tested and inlined into the page
+// the same way. textOn leans on rl from colormath; the import is for the tests —
+// generate.py strips it on inline, where rl is already present from the inlined
+// colormath core.
+import { rl } from './colormath.js';
+
+// Normalize a hex string: trim, accept an optional leading #, require exactly six
+// hex digits, lowercase the result. Returns null for anything else.
+function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;}
+
+// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5),
+// otherwise the fail red.
+function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
+
+// Pick black or white text for a background hex, by WCAG relative luminance.
+function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
+
+export { normHex, ratingColor, textOn };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index e31d5b28..c5a618e3 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -3,18 +3,22 @@ let MAP=MAP_J, PALETTE=PALETTE_J, BOLD=BOLD_J, ITALIC=ITALIC_J, UIMAP=UIMAP_J;
let LOCKED=new Set(LOCKS_J); // syntax categories whose element↔color is decided (dropdown disabled, skipped by clear-unlocked)
const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec)
// --- tier-3 package faces: pure state helpers (Phase 1) ---
-function pname(n){if(!n)return null;if(/^#/.test(n))return n;const p=PALETTE.find(p=>p[1]===n);return p?p[0]:null;}
-function seedPkgmap(){const m={};for(const app in APPS){m[app]={};for(const row of APPS[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}}return m;}
-function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
-function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}}
+// Thin wrappers over the pure logic in app-core.js (inlined further down),
+// passing the live module state. packagesForExport / mergePackagesInto live in
+// the core verbatim and are used by name.
+function pname(n){return nameToHex(n,PALETTE);}
+function seedPkgmap(){return buildPkgmap(APPS,PALETTE);}
let PKGMAP=seedPkgmap();
function esc(t){return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex,
-// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex,
-// textOn, and ratingColor stay below as UI-boundary helpers.
+// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js.
COLORMATH_J
-function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
-function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
+// Pure package-model + dropdown logic, inlined verbatim from app-core.js. The
+// wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here.
+APP_CORE_J
+// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
+// app-util.js. textOn uses rl from the colormath core above.
+APP_UTIL_J
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
// AA/AAA rating, with the rating word. Callers compute r for their own fg/bg.
function crHtml(r){return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${rating(r)}</span>`;}
@@ -65,8 +69,7 @@ function mkColorDropdown(options,cur,onPick){
// Standard option list for a swatch dropdown: a "default" entry, then the
// palette. If cur is set but no longer in the palette, surface it as a "(gone)"
// entry so the row still shows what it points at. Shared by all three tiers.
-function ddList(cur){const have=cur===''||PALETTE.some(p=>p[0]===cur);
- return [['','— default —'],...(have?PALETTE:[[cur,'(gone) '+cur],...PALETTE])];}
+function ddList(cur){return optList(cur,PALETTE);}
// Shared lock toggle for any table row. lockKey is namespaced per tier (bare
// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable
// controls — native selects/buttons/inputs are disabled; the custom swatch
@@ -189,7 +192,6 @@ function updateColor(){
for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
-function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;}
function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';}
let pkH=0,pkS=0,pkV=0.5,pickerOn=false;
let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask)
@@ -266,7 +268,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
-function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();}
function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}}
@@ -363,8 +365,8 @@ function uiSelect(face,attr){const cur=UIMAP[face][attr]||'';
const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow'];
function seedFace(d){return {fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}
function curApp(){const s=document.getElementById('appsel');return s&&s.value?s.value:Object.keys(APPS)[0];}
-function pkgEffFg(app,face,seen){seen=seen||{};const f=PKGMAP[app]&&PKGMAP[app][face];if(!f||seen[face])return null;seen[face]=1;if(f.fg)return f.fg;if(f.inherit&&PKGMAP[app][f.inherit])return pkgEffFg(app,f.inherit,seen);return null;}
-function pkgEffBg(app,face,seen){seen=seen||{};const f=PKGMAP[app]&&PKGMAP[app][face];if(!f||seen[face])return null;seen[face]=1;if(f.bg)return f.bg;if(f.inherit&&PKGMAP[app][f.inherit])return pkgEffBg(app,f.inherit,seen);return null;}
+function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);}
+function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);}
function buildAppSel(){const s=document.getElementById('appsel');if(!s)return;s.innerHTML='';for(const app in APPS){const o=document.createElement('option');o.value=app;o.textContent=APPS[app].label;s.appendChild(o);}s.onchange=pkgChanged;}
function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();}
function buildPkgTable(){
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index 0b23bc69..56aa5800 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -2,15 +2,19 @@ import json, os
HERE=os.path.dirname(os.path.abspath(__file__))
def strip_exports(src):
- """Drop ES-module `export` lines so the body loads as a classic <script>.
+ """Drop ES-module `export`/`import` lines so the body loads as a classic <script>.
- A top-level `export` is a syntax error outside a module, so it must go before
- the body is spliced into the page. test-colormath.mjs applies the identical
- strip and asserts the page carries the result verbatim (inline-integrity), so
- the two copies cannot drift. NOTE: this is line-based — the export statement in
- colormath.js must stay on a single line or the continuation lines survive.
+ A top-level `export` (or `import`) is a syntax error outside a module, so it
+ must go before the body is spliced into the page. Imports are stripped too so a
+ pure module may import a peer for its own unit tests (e.g. app-util.js imports
+ rl from colormath.js) while the inlined copy relies on the peer already being
+ in the page. The .mjs inline-integrity tests apply the identical strip and
+ assert the page carries the result verbatim, so the two copies cannot drift.
+ NOTE: this is line-based — each export/import statement must stay on a single
+ line or the continuation lines survive.
"""
- return '\n'.join(l for l in src.splitlines() if not l.startswith('export')).rstrip()
+ return '\n'.join(l for l in src.splitlines()
+ if not (l.startswith('export') or l.startswith('import'))).rstrip()
# Pure color-math core, inlined verbatim into the page so the browser runs the
# same code the Node tests import (one source of truth).
@@ -22,6 +26,13 @@ COLORMATH_BODY=strip_exports(open(os.path.join(HERE,'colormath.js')).read())
# (MAP_J, PALETTE_J, COLORMATH_J, ...); those are filled after it is spliced in.
STYLES=open(os.path.join(HERE,'styles.css')).read()
APP_BODY=open(os.path.join(HERE,'app.js')).read()
+# Pure package-model + dropdown logic, inlined into the page (and unit-tested via
+# test-app-core.mjs) the same way colormath.js is.
+APP_CORE_BODY=strip_exports(open(os.path.join(HERE,'app-core.js')).read())
+# Pure color/UI-boundary helpers (normHex/ratingColor/textOn), unit-tested via
+# test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in
+# the page from the colormath core).
+APP_UTIL_BODY=strip_exports(open(os.path.join(HERE,'app-util.js')).read())
ns={}
src=open(os.path.join(HERE,'samples.py')).read()
exec(src[:src.index('cols=')], ns)
@@ -500,8 +511,13 @@ APP_JS</script>"""
# Fill the data placeholders. str.replace is literal (no backref interpretation),
# so backslashes in the inlined JS survive intact — the escaping-bug class that
# the triple-quoted string used to cause is gone now that app.js is a real file.
+# Caveat: these tokens are replaced everywhere they appear, including inside code
+# comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
+# prose in any inlined file, or that prose gets the body spliced into it too.
def fill_data(s):
return (s.replace("COLORMATH_J",COLORMATH_BODY)
+ .replace("APP_CORE_J",APP_CORE_BODY)
+ .replace("APP_UTIL_J",APP_UTIL_BODY)
.replace("SAMPLES_J",json.dumps(SAMPLES))
.replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
.replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index d57f0044..42d24960 100755
--- a/scripts/theme-studio/run-tests.sh
+++ b/scripts/theme-studio/run-tests.sh
@@ -16,6 +16,10 @@ set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$HERE"
+# --no-browser skips the headless-Chrome hash gates for a fast inner loop.
+NO_BROWSER=0
+[ "${1:-}" = "--no-browser" ] && NO_BROWSER=1
+
fail=0
pass_msg() { printf ' PASS %s\n' "$1"; }
fail_msg() { printf ' FAIL %s\n' "$1"; fail=1; }
@@ -50,7 +54,9 @@ for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi
done
HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest"
-if [ -z "$CHROME" ]; then
+if [ "$NO_BROWSER" = 1 ]; then
+ skip_msg "browser hash gates (--no-browser)"
+elif [ -z "$CHROME" ]; then
for t in $HASHES; do skip_msg "#$t (no Chromium-family browser found)"; done
else
PROF="$(mktemp -d)"
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
new file mode 100644
index 00000000..9bf5145f
--- /dev/null
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -0,0 +1,157 @@
+// Unit tests for the pure app logic (app-core.js): the package-face model and
+// the dropdown option list. These are the functions Stage 7 made importable.
+// Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import {
+ nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify,
+} from './app-core.js';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+const PAL = [['#67809c', 'blue'], ['#e8bd30', 'gold']];
+
+test('nameToHex: Normal — resolves a palette name to its hex', () => {
+ assert.equal(nameToHex('blue', PAL), '#67809c');
+ assert.equal(nameToHex('gold', PAL), '#e8bd30');
+});
+
+test('nameToHex: Normal — a raw #hex passes through unchanged', () => {
+ assert.equal(nameToHex('#abcdef', PAL), '#abcdef');
+});
+
+test('nameToHex: Boundary/Error — null, empty, and unknown names give null', () => {
+ assert.equal(nameToHex(null, PAL), null);
+ assert.equal(nameToHex('', PAL), null);
+ assert.equal(nameToHex(undefined, PAL), null);
+ assert.equal(nameToHex('chartreuse', PAL), null);
+});
+
+test('optList: Normal — default entry then the whole palette', () => {
+ assert.deepEqual(optList('#67809c', PAL), [['', '— default —'], ...PAL]);
+});
+
+test('optList: Boundary — empty cur is "have", so no (gone) entry', () => {
+ assert.deepEqual(optList('', PAL), [['', '— default —'], ...PAL]);
+});
+
+test('optList: Error — a cur not in the palette is surfaced as (gone) first', () => {
+ const list = optList('#123456', PAL);
+ assert.deepEqual(list[0], ['', '— default —']);
+ assert.deepEqual(list[1], ['#123456', '(gone) #123456']);
+ assert.deepEqual(list.slice(2), PAL);
+});
+
+test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults', () => {
+ const apps = { 'org-mode': { faces: [
+ ['org-todo', 'todo', { fg: 'blue', bold: true }],
+ ['org-done', 'done', { inherit: 'org-todo' }],
+ ] } };
+ const m = buildPkgmap(apps, PAL);
+ assert.equal(m['org-mode']['org-todo'].fg, '#67809c');
+ assert.equal(m['org-mode']['org-todo'].bold, true);
+ assert.equal(m['org-mode']['org-todo'].source, 'default');
+ assert.equal(m['org-mode']['org-todo'].height, 1);
+ assert.equal(m['org-mode']['org-done'].inherit, 'org-todo');
+ assert.equal(m['org-mode']['org-done'].fg, null);
+});
+
+test('buildPkgmap: Boundary — a face with no default dict still seeds blank', () => {
+ const m = buildPkgmap({ a: { faces: [['f', 'f']] } }, PAL);
+ assert.deepEqual(m.a.f, {
+ fg: null, bg: null, bold: false, italic: false, underline: false,
+ strike: false, inherit: null, height: 1, source: 'default',
+ });
+});
+
+test('effResolve: Normal — a face with a value returns it', () => {
+ const m = { a: { f: { fg: '#67809c', inherit: null } } };
+ assert.equal(effResolve(m, 'a', 'f', 'fg'), '#67809c');
+});
+
+test('effResolve: Normal — follows the inherit chain when unset', () => {
+ const m = { a: {
+ base: { bg: '#0d0b0a', inherit: null },
+ mid: { bg: null, inherit: 'base' },
+ leaf: { bg: null, inherit: 'mid' },
+ } };
+ assert.equal(effResolve(m, 'a', 'leaf', 'bg'), '#0d0b0a');
+});
+
+test('effResolve: Boundary — unset with no inherit, or a missing face, gives null', () => {
+ const m = { a: { f: { fg: null, inherit: null } } };
+ assert.equal(effResolve(m, 'a', 'f', 'fg'), null);
+ assert.equal(effResolve(m, 'a', 'nope', 'fg'), null);
+});
+
+test('effResolve: Error — an inherit cycle terminates at null, no overflow', () => {
+ const m = { a: { x: { fg: null, inherit: 'y' }, y: { fg: null, inherit: 'x' } } };
+ assert.equal(effResolve(m, 'a', 'x', 'fg'), null);
+});
+
+test('packagesForExport: Normal — exports sourced faces, omits height 1', () => {
+ const m = { a: { f: {
+ fg: '#67809c', bg: null, bold: true, italic: false, underline: false,
+ strike: false, inherit: null, height: 1, source: 'user',
+ } } };
+ const out = packagesForExport(m);
+ assert.equal(out.a.f.fg, '#67809c');
+ assert.equal(out.a.f.source, 'user');
+ assert.ok(!('height' in out.a.f), 'height 1 is omitted');
+});
+
+test('packagesForExport: Boundary — keeps a non-default height', () => {
+ const m = { a: { f: { fg: null, bg: null, source: 'user', height: 1.2 } } };
+ assert.equal(packagesForExport(m).a.f.height, 1.2);
+});
+
+test('packagesForExport: Error — faces with an unknown source are skipped', () => {
+ const m = { a: { f: { fg: '#67809c', source: 'system' } } };
+ assert.deepEqual(packagesForExport(m), {});
+});
+
+test('mergePackagesInto: Normal — fills missing fields with defaults', () => {
+ const m = {};
+ mergePackagesInto(m, { a: { f: { fg: '#112233' } } });
+ assert.deepEqual(m.a.f, {
+ fg: '#112233', bg: null, bold: false, italic: false, underline: false,
+ strike: false, inherit: null, height: 1, source: 'user',
+ });
+});
+
+test('mergePackagesInto: Boundary — undefined pkgs is a no-op', () => {
+ const m = { a: { f: { fg: '#000000' } } };
+ mergePackagesInto(m, undefined);
+ assert.deepEqual(m, { a: { f: { fg: '#000000' } } });
+});
+
+test('slugify: Normal — spaces and punctuation collapse to single dashes', () => {
+ assert.equal(slugify('My Cool Theme'), 'My-Cool-Theme');
+ assert.equal(slugify('dupre revised'), 'dupre-revised');
+ assert.equal(slugify('keeps.dots_and-dashes'), 'keeps.dots_and-dashes');
+});
+
+test('slugify: Boundary — leading/trailing junk is trimmed', () => {
+ assert.equal(slugify(' spaced '), 'spaced');
+ assert.equal(slugify('!!!edges!!!'), 'edges');
+ assert.equal(slugify(''), 'theme'); // empty falls back
+});
+
+test('slugify: Error — an all-disallowed name falls back to "theme"', () => {
+ assert.equal(slugify('!!!'), 'theme');
+ assert.equal(slugify(' '), 'theme');
+});
+
+// Guards the one-source-of-truth contract, same as the colormath integrity test:
+// the page must carry app-core.js's body (sans exports) verbatim. Requires
+// `python3 generate.py` to have run first.
+const stripExports = (s) =>
+ s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, '');
+
+test('inline-integrity: theme-studio.html contains the app-core.js body verbatim', () => {
+ const body = stripExports(readFileSync(here + 'app-core.js', 'utf8'));
+ const html = readFileSync(here + 'theme-studio.html', 'utf8');
+ assert.ok(html.includes(body), 'generated page is missing the app-core.js body verbatim');
+});
diff --git a/scripts/theme-studio/test-app-util.mjs b/scripts/theme-studio/test-app-util.mjs
new file mode 100644
index 00000000..2cb08e0e
--- /dev/null
+++ b/scripts/theme-studio/test-app-util.mjs
@@ -0,0 +1,70 @@
+// Unit tests for the pure color/UI-boundary helpers (app-util.js).
+// Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { normHex, ratingColor, textOn } from './app-util.js';
+
+const here = fileURLToPath(new URL('.', import.meta.url));
+
+test('normHex: Normal — adds the #, lowercases, accepts an existing #', () => {
+ assert.equal(normHex('67809C'), '#67809c');
+ assert.equal(normHex('#E8BD30'), '#e8bd30');
+ assert.equal(normHex('#67809c'), '#67809c');
+});
+
+test('normHex: Boundary — trims surrounding whitespace; empty is null', () => {
+ assert.equal(normHex(' 67809c '), '#67809c');
+ assert.equal(normHex(''), null);
+ assert.equal(normHex(' '), null);
+ assert.equal(normHex('abc'), null); // 3-digit shorthand is not accepted
+});
+
+test('normHex: Error — bad characters and wrong length give null', () => {
+ assert.equal(normHex('#gggggg'), null);
+ assert.equal(normHex('#12345'), null); // 5 digits
+ assert.equal(normHex('#1234567'), null); // 7 digits
+ assert.equal(normHex('red'), null);
+});
+
+test('ratingColor: Normal — AAA green, AA grey, fail red', () => {
+ assert.equal(ratingColor(10), '#5d9b86');
+ assert.equal(ratingColor(5), '#a9b2bb');
+ assert.equal(ratingColor(2), '#cb6b4d');
+});
+
+test('ratingColor: Boundary — the AAA (7) and AA (4.5) thresholds are inclusive', () => {
+ assert.equal(ratingColor(7), '#5d9b86');
+ assert.equal(ratingColor(6.99), '#a9b2bb');
+ assert.equal(ratingColor(4.5), '#a9b2bb');
+ assert.equal(ratingColor(4.49), '#cb6b4d');
+});
+
+test('ratingColor: Error — zero and negative ratios are the fail color', () => {
+ assert.equal(ratingColor(0), '#cb6b4d');
+ assert.equal(ratingColor(-1), '#cb6b4d');
+});
+
+test('textOn: Normal — white text on black, black text on white', () => {
+ assert.equal(textOn('#000000'), '#fff');
+ assert.equal(textOn('#ffffff'), '#000');
+});
+
+test('textOn: Boundary — straddles the ~0.179 luminance crossover', () => {
+ assert.equal(textOn('#707070'), '#fff'); // just below the crossover
+ assert.equal(textOn('#777777'), '#000'); // just above the crossover
+});
+
+// Inline-integrity: the page must carry app-util.js's body (sans import/export)
+// verbatim — the same strip generate.py applies. Requires `python3 generate.py`.
+const stripModule = (s) =>
+ s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import')))
+ .join('\n').replace(/\s+$/, '');
+
+test('inline-integrity: theme-studio.html contains the app-util.js body verbatim', () => {
+ const body = stripModule(readFileSync(here + 'app-util.js', 'utf8'));
+ const html = readFileSync(here + 'theme-studio.html', 'utf8');
+ assert.ok(html.includes(body), 'generated page is missing the app-util.js body verbatim');
+});
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py
index 7a9079ac..ee13f8de 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -30,6 +30,12 @@ class StripExports(unittest.TestCase):
src = "export const a=1;\ncode();\nexport { a };"
self.assertEqual(generate.strip_exports(src), "code();")
+ def test_removes_import_lines_too(self):
+ # A pure module may import a peer for its own tests; the import must be
+ # stripped on inline (the peer is already in the page).
+ src = "import { rl } from './colormath.js';\nfunction f(){return rl();}"
+ self.assertEqual(generate.strip_exports(src), "function f(){return rl();}")
+
def test_matches_the_js_side_strip_so_integrity_holds(self):
# test-colormath.mjs strips with the same rule: drop lines starting with
# 'export', then trim trailing whitespace. Keep the two in lockstep.
@@ -62,7 +68,7 @@ class ColormathInlining(unittest.TestCase):
class AssembledPage(unittest.TestCase):
PLACEHOLDERS = [
- "STYLES_CSS", "APP_JS",
+ "STYLES_CSS", "APP_JS", "APP_CORE_J", "APP_UTIL_J",
"COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J",
"UIFACES_J", "UIMAP_J", "APPS_J", "BOLD_J", "MAP_J",
]
@@ -76,6 +82,20 @@ class AssembledPage(unittest.TestCase):
# checked at the point the page is built rather than after a round-trip.
self.assertIn(generate.COLORMATH_BODY, generate.HTML)
+ def test_page_carries_the_app_core_body_verbatim(self):
+ # app-core.js inlines verbatim (no data placeholders), so the inlined copy
+ # and the unit-tested module cannot drift.
+ self.assertIn(generate.APP_CORE_BODY, generate.HTML)
+
+ def test_page_carries_the_app_util_body_verbatim(self):
+ # app-util.js inlines verbatim after its import line is stripped.
+ self.assertIn(generate.APP_UTIL_BODY, generate.HTML)
+
+ def test_app_util_inlined_body_has_no_import_line(self):
+ # The `import rl` line must be gone, or the page <script> is invalid.
+ for line in generate.APP_UTIL_BODY.splitlines():
+ self.assertFalse(line.startswith("import"), f"import survived: {line!r}")
+
def test_page_carries_the_stylesheet_verbatim(self):
# styles.css has no placeholders, so it inlines verbatim: the inlined copy
# and the source file cannot drift.
@@ -92,5 +112,30 @@ class AssembledPage(unittest.TestCase):
self.assertEqual(generate.HTML.count("</script>"), 1)
+class FacesHelper(unittest.TestCase):
+ def test_strips_prefix_and_derives_label_and_merges_seed(self):
+ # Normal: the prefix comes off the label, and the per-face seed is attached.
+ rows = generate._faces(["org-todo", "org-done"], "org-", {"org-todo": {"fg": "gold"}})
+ self.assertEqual(rows, [
+ ["org-todo", "todo", {"fg": "gold"}],
+ ["org-done", "done", {}],
+ ])
+
+ def test_label_drops_face_suffix_and_spaces_remaining_dashes(self):
+ # Boundary: "-face" is removed and the rest of the dashes become spaces.
+ rows = generate._faces(["lsp-rename-placeholder-face"], "lsp-", {})
+ self.assertEqual(rows[0][1], "rename placeholder")
+
+ def test_name_without_the_prefix_is_left_intact(self):
+ # Boundary: a name that doesn't start with the prefix keeps its full text
+ # (only "-face" removal and dash-spacing apply).
+ rows = generate._faces(["shr-text"], "org-", {})
+ self.assertEqual(rows[0], ["shr-text", "shr text", {}])
+
+ def test_empty_names_gives_empty_list(self):
+ # Error/Boundary: nothing in, nothing out.
+ self.assertEqual(generate._faces([], "org-", {"org-todo": {"fg": "gold"}}), [])
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 90ef5e3e..0e04d012 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -178,15 +178,15 @@ let MAP={"kw": "#67809c", "bi": "#67809c", "pp": "#67809c", "fnd": "#a9b2bb", "f
let LOCKED=new Set([]); // syntax categories whose element↔color is decided (dropdown disabled, skipped by clear-unlocked)
const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec)
// --- tier-3 package faces: pure state helpers (Phase 1) ---
-function pname(n){if(!n)return null;if(/^#/.test(n))return n;const p=PALETTE.find(p=>p[1]===n);return p?p[0]:null;}
-function seedPkgmap(){const m={};for(const app in APPS){m[app]={};for(const row of APPS[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}}return m;}
-function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
-function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}}
+// Thin wrappers over the pure logic in app-core.js (inlined further down),
+// passing the live module state. packagesForExport / mergePackagesInto live in
+// the core verbatim and are used by name.
+function pname(n){return nameToHex(n,PALETTE);}
+function seedPkgmap(){return buildPkgmap(APPS,PALETTE);}
let PKGMAP=seedPkgmap();
function esc(t){return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex,
-// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex,
-// textOn, and ratingColor stay below as UI-boundary helpers.
+// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js.
// colormath.js — pure color-math core for theme-studio.
//
// One source of truth: node imports this module (tests); generate.py inlines its
@@ -378,8 +378,57 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) {
pairs.sort((a, b) => a.dE - b.dE);
return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest };
}
-function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
+// Pure package-model + dropdown logic, inlined verbatim from app-core.js. The
+// wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here.
+// Pure app logic — the package-face model and the dropdown option list — with no
+// DOM and no module globals (every dependency is a parameter). It is unit-tested
+// directly (test-app-core.mjs) and inlined into the page like colormath.js, so
+// the browser runs the same code the tests import. The app.js wrappers (pname,
+// seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the
+// live PALETTE / APPS / PKGMAP into these.
+
+// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
+function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
+
+// Seed the package-face map from the app inventory's per-face defaults.
+function buildPkgmap(apps,palette){const m={};for(const app in apps){m[app]={};for(const row of apps[app].faces){const face=row[0],d=row[2]||{};m[app][face]={fg:nameToHex(d.fg,palette),bg:nameToHex(d.bg,palette),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}}return m;}
+
+// The package faces worth exporting (anything seeded or user-touched), trimmed.
+function packagesForExport(map){const out={};for(const app in map){const faces={};for(const face in map[app]){const f=map[app][face];if(f.source==='default'||f.source==='user'||f.source==='cleared'){const o={fg:f.fg,bg:f.bg,bold:f.bold,italic:f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit,source:f.source};if(f.height&&f.height!==1)o.height=f.height;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;}
+
+// Merge an imported package block into a face map, filling missing fields.
+function mergePackagesInto(map,pkgs){if(!pkgs)return;for(const app in pkgs){if(!map[app])map[app]={};for(const face in pkgs[app]){const f=pkgs[app][face]||{};map[app][face]={fg:f.fg??null,bg:f.bg??null,bold:!!f.bold,italic:!!f.italic,underline:!!f.underline,strike:!!f.strike,inherit:f.inherit??null,height:f.height||1,source:f.source||'user'};}}}
+
+// Effective fg/bg for a package face, following its inherit chain. seen guards
+// against an inherit cycle (returns null rather than recursing forever).
+function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[app][face];if(!f||seen[face])return null;seen[face]=1;if(f[attr])return f[attr];if(f.inherit&&map[app][f.inherit])return effResolve(map,app,f.inherit,attr,seen);return null;}
+
+// Standard swatch-dropdown option list: a default entry, then the palette. When
+// cur is set but no longer in the palette, surface it as a "(gone)" entry first.
+function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];}
+
+// Turn a theme name into a safe filename slug: collapse runs of disallowed
+// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
+function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
+// app-util.js. textOn uses rl from the colormath core above.
+// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status
+// color, and the readable text color for a background. These are kept out of
+// colormath.js (the pure math core) but are unit-tested and inlined into the page
+// the same way. textOn leans on rl from colormath; the import is for the tests —
+// generate.py strips it on inline, where rl is already present from the inlined
+// colormath core.
+
+// Normalize a hex string: trim, accept an optional leading #, require exactly six
+// hex digits, lowercase the result. Returns null for anything else.
+function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;}
+
+// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5),
+// otherwise the fail red.
function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
+
+// Pick black or white text for a background hex, by WCAG relative luminance.
+function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
// AA/AAA rating, with the rating word. Callers compute r for their own fg/bg.
function crHtml(r){return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${rating(r)}</span>`;}
@@ -430,8 +479,7 @@ function mkColorDropdown(options,cur,onPick){
// Standard option list for a swatch dropdown: a "default" entry, then the
// palette. If cur is set but no longer in the palette, surface it as a "(gone)"
// entry so the row still shows what it points at. Shared by all three tiers.
-function ddList(cur){const have=cur===''||PALETTE.some(p=>p[0]===cur);
- return [['','— default —'],...(have?PALETTE:[[cur,'(gone) '+cur],...PALETTE])];}
+function ddList(cur){return optList(cur,PALETTE);}
// Shared lock toggle for any table row. lockKey is namespaced per tier (bare
// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable
// controls — native selects/buttons/inputs are disabled; the custom swatch
@@ -554,7 +602,6 @@ function updateColor(){
for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
-function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;}
function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';}
let pkH=0,pkS=0,pkV=0.5,pickerOn=false;
let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask)
@@ -631,7 +678,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
-function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();}
function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}}
@@ -728,8 +775,8 @@ function uiSelect(face,attr){const cur=UIMAP[face][attr]||'';
const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow'];
function seedFace(d){return {fg:pname(d.fg),bg:pname(d.bg),bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit||null,height:d.height||1,source:'default'};}
function curApp(){const s=document.getElementById('appsel');return s&&s.value?s.value:Object.keys(APPS)[0];}
-function pkgEffFg(app,face,seen){seen=seen||{};const f=PKGMAP[app]&&PKGMAP[app][face];if(!f||seen[face])return null;seen[face]=1;if(f.fg)return f.fg;if(f.inherit&&PKGMAP[app][f.inherit])return pkgEffFg(app,f.inherit,seen);return null;}
-function pkgEffBg(app,face,seen){seen=seen||{};const f=PKGMAP[app]&&PKGMAP[app][face];if(!f||seen[face])return null;seen[face]=1;if(f.bg)return f.bg;if(f.inherit&&PKGMAP[app][f.inherit])return pkgEffBg(app,f.inherit,seen);return null;}
+function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);}
+function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);}
function buildAppSel(){const s=document.getElementById('appsel');if(!s)return;s.innerHTML='';for(const app in APPS){const o=document.createElement('option');o.value=app;o.textContent=APPS[app].label;s.appendChild(o);}s.onchange=pkgChanged;}
function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();}
function buildPkgTable(){