diff options
| -rw-r--r-- | scripts/theme-studio/app-core.js | 28 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 4 | ||||
| -rw-r--r-- | scripts/theme-studio/test-app-core.mjs | 140 | ||||
| -rw-r--r-- | scripts/theme-studio/test_generate.py | 7 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 44 |
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 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,'<').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</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 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,'<').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(){ |
