aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-09 06:02:49 -0500
committerCraig Jennings <c@cjennings.net>2026-06-09 06:02:49 -0500
commit892b981875a75bff1b484a30aacd39ef4143b01f (patch)
tree67e829c0e9c4cd98d2dffce590e078be36f2fb9a
parent01b1af5ab6f5e21277818dcfc148c9429e296abf (diff)
downloaddotemacs-892b981875a75bff1b484a30aacd39ef4143b01f.tar.gz
dotemacs-892b981875a75bff1b484a30aacd39ef4143b01f.zip
test(theme-studio): extract app-core.js and unit-test the app logic
The refactor's goal was to make the app logic testable; this realizes it. Pulled the pure package-face model and the dropdown option list into app-core.js — nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve (the inherit-chain resolver behind pkgEffFg/pkgEffBg), and optList — with every dependency passed as a parameter so there is no DOM and no module-global reliance. generate.py inlines it into the page the same way it inlines colormath.js (strip exports, placeholder, integrity check), so the browser runs the same code the tests import. app.js keeps thin wrappers (pname, seedPkgmap, ddList, pkgEffFg, pkgEffBg) that pass the live PALETTE / APPS / PKGMAP into the core, so no call site changed and the built DOM is byte-identical to before. test-app-core.mjs adds 18 Normal/Boundary/Error tests over the extracted logic — name resolution, the seed/export/merge round trip, the inherit chain including a cycle that must terminate at null, and the "(gone)" dropdown entry — plus an inline-integrity check that the page carries the core verbatim. The node suite goes 25 to 43 tests; python templating gains the app-core integrity assertion.
-rw-r--r--scripts/theme-studio/app-core.js28
-rw-r--r--scripts/theme-studio/app.js19
-rw-r--r--scripts/theme-studio/generate.py4
-rw-r--r--scripts/theme-studio/test-app-core.mjs140
-rw-r--r--scripts/theme-studio/test_generate.py7
-rw-r--r--scripts/theme-studio/theme-studio.html44
6 files changed, 225 insertions, 17 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
new file mode 100644
index 000000000..34fb8051f
--- /dev/null
+++ b/scripts/theme-studio/app-core.js
@@ -0,0 +1,28 @@
+// 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])];}
+
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index e31d5b28d..6767accf4 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -3,16 +3,20 @@ 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.
COLORMATH_J
+// Pure package-model + dropdown logic, inlined verbatim from app-core.js. The
+// wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here.
+APP_CORE_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';}
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
@@ -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
@@ -363,8 +366,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 0b23bc699..e2a26f825 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -22,6 +22,9 @@ 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())
ns={}
src=open(os.path.join(HERE,'samples.py')).read()
exec(src[:src.index('cols=')], ns)
@@ -502,6 +505,7 @@ APP_JS</script>"""
# the triple-quoted string used to cause is gone now that app.js is a real file.
def fill_data(s):
return (s.replace("COLORMATH_J",COLORMATH_BODY)
+ .replace("APP_CORE_J",APP_CORE_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/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
new file mode 100644
index 000000000..0befeb439
--- /dev/null
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -0,0 +1,140 @@
+// 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,
+} 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' } } });
+});
+
+// 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_generate.py b/scripts/theme-studio/test_generate.py
index 7a9079ac8..077382e4c 100644
--- a/scripts/theme-studio/test_generate.py
+++ b/scripts/theme-studio/test_generate.py
@@ -62,7 +62,7 @@ class ColormathInlining(unittest.TestCase):
class AssembledPage(unittest.TestCase):
PLACEHOLDERS = [
- "STYLES_CSS", "APP_JS",
+ "STYLES_CSS", "APP_JS", "APP_CORE_J",
"COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J",
"UIFACES_J", "UIMAP_J", "APPS_J", "BOLD_J", "MAP_J",
]
@@ -76,6 +76,11 @@ 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_stylesheet_verbatim(self):
# styles.css has no placeholders, so it inlines verbatim: the inlined copy
# and the source file cannot drift.
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 90ef5e3e7..219a4a035 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -178,10 +178,11 @@ 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,
@@ -378,6 +379,34 @@ 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 };
}
+// 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])];}
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';}
// The contrast-cell readout shared by every table: a WCAG ratio colored by its
@@ -430,8 +459,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
@@ -728,8 +756,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(){