From 630cfddc7060c7019815f8e82f87fb629aefebfa Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 23:01:54 -0400 Subject: feat(theme-studio): explicit absolute-vs-relative face height kind JSON collapses 2.0 to 2 on save, so a height's number type can't say whether it's a fixed 1/10pt value or a relative multiplier. The face model now carries an explicit heightMode field (abs/rel) through seed, save/load, and export. build-theme.el coerces :height from the kind: abs exports an integer, rel a float, so a relative 2.0 renders as 2.0, never 2. Faces saved before the field existed infer the kind once on load (JS: integer to abs, fractional to rel; Python keeps the authored type, so a float 2.0 seed stays relative) and persist it on the next save. The mode-line seed carries abs explicitly, and WIP.json's eight seeded heights are stamped with their kinds. Regenerating the theme from the stamped WIP.json produces an identical WIP-theme.el, so the round-trip holds. --- scripts/theme-studio/test-app-core.mjs | 55 ++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) (limited to 'scripts/theme-studio/test-app-core.mjs') diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 217ea0e6..c6e6650b 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -634,7 +634,7 @@ test('normalizePkgFace: Normal — fills every package face field', () => { assert.deepEqual(normalizePkgFace({ fg: 'blue', bold: true, inherit: 'base' }, 'default', PAL), { fg: '#67809c', bg: null, 'distant-fg': null, family: null, weight: 'bold', slant: null, underline: null, strike: null, overline: null, - inherit: 'base', height: 1, box: null, inverse: false, extend: false, + inherit: 'base', height: 1, heightMode: null, box: null, inverse: false, extend: false, source: 'default', }); }); @@ -687,7 +687,7 @@ test('buildPkgmap: Boundary — a face with no default dict still seeds blank', assert.deepEqual(m.a.f, { fg: null, bg: null, 'distant-fg': null, family: null, weight: null, slant: null, underline: null, strike: null, overline: null, - inherit: null, height: 1, box: null, inverse: false, extend: false, + inherit: null, height: 1, heightMode: null, box: null, inverse: false, extend: false, source: 'default', }); }); @@ -773,13 +773,62 @@ test('packagesForExport: Boundary — unset additive attrs are omitted', () => { } }); +// --- height kind (explicit absolute vs relative) ----------------------------- + +test('migrateLegacyFace: Normal — infers heightMode from the number on load', () => { + assert.equal(migrateLegacyFace({ height: 130 }).heightMode, 'abs'); + assert.equal(migrateLegacyFace({ height: 1.2 }).heightMode, 'rel'); +}); + +test('migrateLegacyFace: Normal — an explicit heightMode wins over inference', () => { + // the integral-float case: JSON collapsed 2.0 to 2, but the stored kind rules + assert.equal(migrateLegacyFace({ height: 2, heightMode: 'rel' }).heightMode, 'rel'); + assert.equal(migrateLegacyFace({ height: 1.5, heightMode: 'abs' }).heightMode, 'abs'); +}); + +test('migrateLegacyFace: Boundary — no height, identity, or non-number infers no kind', () => { + assert.ok(!('heightMode' in migrateLegacyFace({}))); + assert.ok(!('heightMode' in migrateLegacyFace({ height: null }))); + assert.ok(!('heightMode' in migrateLegacyFace({ height: 1 }))); + assert.ok(!('heightMode' in migrateLegacyFace({ height: 'big' }))); +}); + +test('normalizePkgFace: Normal — heightMode survives normalization', () => { + const f = normalizePkgFace({ height: 2, heightMode: 'rel' }, 'user'); + assert.equal(f.height, 2); + assert.equal(f.heightMode, 'rel'); +}); + +test('normalizePkgFace: Boundary — unset height leaves heightMode null', () => { + assert.equal(normalizePkgFace({}, 'user').heightMode, null); +}); + +test('packagesForExport: Normal — heightMode rides along with a non-default height', () => { + const m = { a: { f: normalizePkgFace({ fg: '#67809c', height: 130, heightMode: 'abs' }, 'user') } }; + const o = packagesForExport(m).a.f; + assert.equal(o.height, 130); + assert.equal(o.heightMode, 'abs'); +}); + +test('packagesForExport: Boundary — no heightMode when height is the default 1', () => { + const m = { a: { f: normalizePkgFace({ fg: '#67809c' }, 'user') } }; + assert.ok(!('heightMode' in packagesForExport(m).a.f)); +}); + +test('packagesForExport: Normal — integral rel multiplier keeps its kind through JSON (round-trip)', () => { + const m = { a: { f: normalizePkgFace({ fg: 'blue', height: 2, heightMode: 'rel' }, 'user', PAL) } }; + const back = normalizePkgFace(JSON.parse(JSON.stringify(packagesForExport(m))).a.f, 'user'); + assert.equal(back.height, 2); + assert.equal(back.heightMode, 'rel'); +}); + 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, 'distant-fg': null, family: null, weight: null, slant: null, underline: null, strike: null, overline: null, - inherit: null, height: 1, box: null, inverse: false, extend: false, + inherit: null, height: 1, heightMode: null, box: null, inverse: false, extend: false, source: 'user', }); }); -- cgit v1.2.3