From 64153c8d995f1603986f3b44ccbdf9ddb21dfd55 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 18 Jun 2026 21:42:40 -0500 Subject: feat(theme-studio): widen the face model with the additive attributes This is Phase 2 of the face-attribute expansion. The model now carries distant-fg, family, overline, inverse, and extend in final shape across all three tiers, and inherit and height are no longer package-only (a ui or syntax face can set them too). I kept bold/italic/underline/strike as the legacy booleans for now. The cutover to weight/slant and the underline/strike object forms lands in the next phase with the editor widgets that force it, so the representation and the controls that drive it move together. face_specs.py holds the canonical defaults. In app-core.js, normalizePkgFace and packagesForExport carry and emit the new attrs: distant-fg resolves through the palette like fg/bg, and each attr exports only when set, so existing presets re-export unchanged. app.js syntaxBlank, uiFaceBlank, and seedFace match the shape. Nothing changed shape, so dupre, distinguished, sterling, now, theme, and WIP all emit byte-identical themes. make check green: Python 58, Node 193, ERT 40. --- scripts/theme-studio/app-core.js | 4 +-- scripts/theme-studio/app.js | 6 ++-- scripts/theme-studio/face_specs.py | 20 +++++++++--- scripts/theme-studio/test-app-core.mjs | 57 ++++++++++++++++++++++++++++++---- scripts/theme-studio/test_generate.py | 20 +++++++++++- scripts/theme-studio/theme-studio.html | 12 +++---- 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 74b441b96..10fa0c570 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -17,14 +17,14 @@ function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p function normalizePkgFace(d,source,palette){ d=d||{}; const resolve=(v)=>palette?nameToHex(v,palette):v; - return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,inherit:d.inherit??null,height:d.height||1,box:d.box??null,source:source||d.source||'user'}; + return {fg:resolve(d.fg)??null,bg:resolve(d.bg)??null,'distant-fg':resolve(d['distant-fg'])??null,family:d.family??null,bold:!!d.bold,italic:!!d.italic,underline:!!d.underline,strike:!!d.strike,overline:d.overline??null,inherit:d.inherit??null,height:d.height||1,box:d.box??null,inverse:!!d.inverse,extend:!!d.extend,source:source||d.source||'user'}; } // 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){m[app][row[0]]=normalizePkgFace(row[2],'default',palette);}}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;if(f.box)o.box=f.box;faces[face]=o;}}if(Object.keys(faces).length)out[app]=faces;}return out;} +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['distant-fg'])o['distant-fg']=f['distant-fg'];if(f.family)o.family=f.family;if(f.overline)o.overline=f.overline;if(f.inverse)o.inverse=true;if(f.extend)o.extend=true;if(f.height&&f.height!==1)o.height=f.height;if(f.box)o.box=f.box;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]=normalizePkgFace(f,f.source||'user');}}} diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 3949268d0..82dccc111 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -4,7 +4,7 @@ let MAP=MAP_J, PALETTE=PALETTE_J, SYNTAX=SYNTAX_J, UIMAP=UIMAP_J; let LOCKED=new Set(LOCKS_J); // rows whose choice is decided (controls disabled, skipped by erase/reset batch actions) const DELTAE_MIN=0.02; // OKLab ΔE below this = colors too close to tell apart (perceptual-metrics spec) const DEFAULT_UIMAP=JSON.parse(JSON.stringify(UIMAP)); -function syntaxBlank(k){return {fg:MAP[k]||null,bg:null,bold:false,italic:false,underline:false,strike:false,box:null};} +function syntaxBlank(k){return {fg:MAP[k]||null,bg:null,'distant-fg':null,family:null,bold:false,italic:false,underline:false,strike:false,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} function syncSyntaxCache(k){const s=SYNTAX[k]||syntaxBlank(k);MAP[k]=s.fg||'';} function syncAllSyntaxCache(){CATS.forEach(c=>syncSyntaxCache(c[0]));} function syncSyntaxFromCache(){CATS.forEach(c=>{const k=c[0];syntaxFace(k).fg=MAP[k]||null;});} @@ -514,8 +514,8 @@ function buildMockFrame(){ function uiSelect(face,attr){const cur=UIMAP[face][attr]||''; return mkColorDropdown(ddList(cur),cur,h=>{UIMAP[face][attr]=h||null;paintUI(face);buildMockFrame();},{compact:true,defaultHex:attr==='fg'?effFg(null):effBg(null)});} const BASE_INHERITS=['fixed-pitch','variable-pitch','default','link','bold','italic','shadow']; -function uiFaceBlank(){return {fg:null,bg:null,bold:false,italic:false,underline:false,strike:false};} -function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,inherit:d.inherit,height:d.height,box:d.box},'default');} +function uiFaceBlank(){return {fg:null,bg:null,'distant-fg':null,family:null,bold:false,italic:false,underline:false,strike:false,overline:null,box:null,inverse:false,extend:false,inherit:null,height:null};} +function seedFace(d){return normalizePkgFace({fg:pname(d.fg),bg:pname(d.bg),'distant-fg':pname(d['distant-fg']),family:d.family,bold:d.bold,italic:d.italic,underline:d.underline,strike:d.strike,overline:d.overline,inherit:d.inherit,height:d.height,box:d.box,inverse:d.inverse,extend:d.extend},'default');} function curApp(){const s=document.getElementById('viewsel');const v=s&&s.value;return (v&&v[0]!=='@')?v:Object.keys(APPS)[0];} function pkgEffFg(app,face,seen){return effResolve(PKGMAP,app,face,'fg',seen);} function pkgEffBg(app,face,seen){return effResolve(PKGMAP,app,face,'bg',seen);} diff --git a/scripts/theme-studio/face_specs.py b/scripts/theme-studio/face_specs.py index 20894cd6d..697eec50f 100644 --- a/scripts/theme-studio/face_specs.py +++ b/scripts/theme-studio/face_specs.py @@ -5,22 +5,32 @@ from __future__ import annotations from typing import Any +# The full per-face attribute model. inherit and height live here (every tier +# can set them now, not just packages). bold/italic/underline/strike stay as the +# legacy booleans for this phase; the weight/slant/underline-object cutover lands +# with the editor widgets that force it. distant-fg, family, overline, inverse, +# and extend are added in their final shape (no legacy form to migrate). STYLE_DEFAULTS: dict[str, Any] = { "fg": None, "bg": None, + "distant-fg": None, + "family": None, "bold": False, "italic": False, "underline": False, "strike": False, + "overline": None, "box": None, -} - -PACKAGE_DEFAULTS: dict[str, Any] = { - **STYLE_DEFAULTS, + "inverse": False, + "extend": False, "inherit": None, - "height": 1, + "height": None, } +# Kept as a distinct name for callers, but inherit/height are no longer +# package-only, so the package defaults are now the same full set. +PACKAGE_DEFAULTS: dict[str, Any] = dict(STYLE_DEFAULTS) + def face_spec(spec: dict[str, Any] | None = None, *, package: bool = False) -> dict[str, Any]: out = dict(PACKAGE_DEFAULTS if package else STYLE_DEFAULTS) diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 8f62ae55a..f45a72be5 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -630,16 +630,38 @@ test('buildPkgmap: Normal — seeds faces, resolving names and applying defaults test('normalizePkgFace: Normal — fills every package face field', () => { assert.deepEqual(normalizePkgFace({ fg: 'blue', bold: true, inherit: 'base' }, 'default', PAL), { - fg: '#67809c', bg: null, bold: true, italic: false, underline: false, - strike: false, inherit: 'base', height: 1, box: null, source: 'default', + fg: '#67809c', bg: null, 'distant-fg': null, family: null, bold: true, + italic: false, underline: false, strike: false, overline: null, + inherit: 'base', height: 1, box: null, inverse: false, extend: false, + source: 'default', }); }); +test('normalizePkgFace: Normal — carries the additive attribute model', () => { + const f = normalizePkgFace({ + fg: 'blue', 'distant-fg': '#222222', family: 'Iosevka', + overline: { color: '#abcdef' }, inverse: true, extend: 1, height: 1.4, + }, 'user', PAL); + assert.equal(f['distant-fg'], '#222222'); + assert.equal(f.family, 'Iosevka'); + assert.deepEqual(f.overline, { color: '#abcdef' }); + assert.equal(f.inverse, true); + assert.equal(f.extend, true); // coerced to boolean + assert.equal(f.height, 1.4); +}); + +test('normalizePkgFace: Boundary — distant-fg resolves through the palette', () => { + const f = normalizePkgFace({ 'distant-fg': 'blue' }, 'user', PAL); + assert.equal(f['distant-fg'], '#67809c'); +}); + 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, box: null, source: 'default', + fg: null, bg: null, 'distant-fg': null, family: null, bold: false, + italic: false, underline: false, strike: false, overline: null, + inherit: null, height: 1, box: null, inverse: false, extend: false, + source: 'default', }); }); @@ -689,12 +711,35 @@ test('packagesForExport: Error — faces with an unknown source are skipped', () assert.deepEqual(packagesForExport(m), {}); }); +test('packagesForExport: Normal — emits additive attrs only when set', () => { + const m = { a: { f: normalizePkgFace({ + fg: '#67809c', 'distant-fg': '#222222', family: 'Iosevka', + overline: { color: '#abcdef' }, inverse: true, extend: true, + }, 'user') } }; + const o = packagesForExport(m).a.f; + assert.equal(o['distant-fg'], '#222222'); + assert.equal(o.family, 'Iosevka'); + assert.deepEqual(o.overline, { color: '#abcdef' }); + assert.equal(o.inverse, true); + assert.equal(o.extend, true); +}); + +test('packagesForExport: Boundary — unset additive attrs are omitted', () => { + const m = { a: { f: normalizePkgFace({ fg: '#67809c' }, 'user') } }; + const o = packagesForExport(m).a.f; + for (const k of ['distant-fg', 'family', 'overline', 'inverse', 'extend']) { + assert.ok(!(k in o), k + ' is omitted when unset'); + } +}); + 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, box: null, source: 'user', + fg: '#112233', bg: null, 'distant-fg': null, family: null, bold: false, + italic: false, underline: false, strike: false, overline: null, + inherit: null, height: 1, box: null, inverse: false, extend: false, + source: 'user', }); }); diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index a0ca91e06..91f8b4bd0 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -195,24 +195,42 @@ class FaceSpecDefaults(unittest.TestCase): self.assertEqual(ui_face_spec({"bg": "#ffffff", "bold": True}), { "fg": None, "bg": "#ffffff", + "distant-fg": None, + "family": None, "bold": True, "italic": False, "underline": False, "strike": False, + "overline": None, "box": None, + "inverse": False, + "extend": False, + "inherit": None, + "height": None, }) + def test_ui_face_spec_carries_inherit_and_height(self): + # inherit/height are no longer package-only; a ui face can set them. + spec = ui_face_spec({"inherit": "shadow", "height": 1.3}) + self.assertEqual(spec["inherit"], "shadow") + self.assertEqual(spec["height"], 1.3) + def test_package_face_spec_fills_structure_fields(self): self.assertEqual(package_face_spec({"inherit": "base", "height": 1.2}), { "fg": None, "bg": None, + "distant-fg": None, + "family": None, "bold": False, "italic": False, "underline": False, "strike": False, + "overline": None, + "box": None, + "inverse": False, + "extend": False, "inherit": "base", "height": 1.2, - "box": None, }) def test_generated_color_names_are_base_columns_when_legacy(self): diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index f0bbd178f..d58859eac 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -272,11 +272,11 @@