From dd90eca92f8ffc60094c9e956c8730b94956eb33 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 06:02:49 -0500 Subject: test(theme-studio): extract app-core.js and unit-test the app logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/theme-studio/app-core.js | 28 +++++++ scripts/theme-studio/app.js | 19 +++-- scripts/theme-studio/generate.py | 4 + scripts/theme-studio/test-app-core.mjs | 140 +++++++++++++++++++++++++++++++++ scripts/theme-studio/test_generate.py | 7 +- scripts/theme-studio/theme-studio.html | 44 +++++++++-- 6 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 scripts/theme-studio/app-core.js create mode 100644 scripts/theme-studio/test-app-core.mjs (limited to 'scripts/theme-studio') diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js new file mode 100644 index 00000000..34fb8051 --- /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 e31d5b28..6767accf 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,'&').replace(//g,'>');} // 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 0b23bc69..e2a26f82 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""" # 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 00000000..0befeb43 --- /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 7a9079ac..077382e4 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 90ef5e3e..219a4a03 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,'&').replace(//g,'>');} // 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(){ -- cgit v1.2.3