aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/app-core.js40
-rw-r--r--scripts/theme-studio/test-app-core.mjs2
-rw-r--r--scripts/theme-studio/test-ramp.mjs105
-rw-r--r--scripts/theme-studio/theme-studio.html36
4 files changed, 181 insertions, 2 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 0e3cfe49..7c3bcc45 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -4,6 +4,12 @@
// 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.
+//
+// The imports below are for the Node tests; generate.py strips them on inline,
+// where normHex (app-util.js) and the colormath helpers are already present from
+// the bodies inlined above this one.
+import { normHex } from './app-util.js';
+import { oklch2hex, srgb2oklab, oklab2oklch } from './colormath.js';
// 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;}
@@ -29,4 +35,36 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
-export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify };
+// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and
+// +1..+n (the base itself is excluded — it already lives in the palette),
+// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per
+// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only
+// the farthest step loses most of its color). Every step is gamut-clamped and
+// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted}
+// where adjusted names any knob clamped/rounded into range, or {steps:[],
+// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown.
+function ramp(baseHex,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {steps:[],error:'bad-hex'};
+ const o=opts||{},adjusted=[];
+ const knob=(name,def,lo,hi,isInt)=>{
+ const v=o[name];
+ if(typeof v!=='number'||!isFinite(v))return def;
+ const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r));
+ if(c!==v)adjusted.push(name);
+ return c;
+ };
+ const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false);
+ const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex));
+ const steps=[];
+ for(let off=-n;off<=n;off++){
+ if(off===0)continue;
+ const L=Math.min(1,Math.max(0,L0+off*stepL));
+ const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t);
+ const {hex:h,clamped}=oklch2hex(L,C,H0);
+ steps.push({hex:h,clamped,offset:off});
+ }
+ return {steps,adjusted};
+}
+
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp };
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 16202525..39a44967 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -148,7 +148,7 @@ test('slugify: Error — an all-disallowed name falls back to "theme"', () => {
// 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+$/, '');
+ s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))).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'));
diff --git a/scripts/theme-studio/test-ramp.mjs b/scripts/theme-studio/test-ramp.mjs
new file mode 100644
index 00000000..0c447ff4
--- /dev/null
+++ b/scripts/theme-studio/test-ramp.mjs
@@ -0,0 +1,105 @@
+// Unit tests for the ramp generator (app-core.js `ramp`). Phase 1 of the
+// palette-ramps spec: one base color -> a harmonized tonal ramp by stepping
+// OKLCH lightness on a held hue, easing chroma toward the extremes, and
+// gamut-clamping each step. Pure, no DOM. Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { ramp } from './app-core.js';
+import { srgb2oklab, oklab2oklch, rl } from './colormath.js';
+
+const HEXRE = /^#[0-9a-f]{6}$/;
+const baseLCH = (hex) => oklab2oklch(srgb2oklab(hex));
+
+test('ramp: Normal — default opts give 2n steps, darkest-to-lightest, base excluded', () => {
+ const r = ramp('#67809c'); // mid blue
+ assert.deepEqual(r.adjusted, []);
+ assert.equal(r.steps.length, 4); // n=2 -> -2,-1,+1,+2
+ assert.deepEqual(r.steps.map(s => s.offset), [-2, -1, 1, 2]);
+ for (const s of r.steps) assert.match(s.hex, HEXRE, `${s.hex} is a 6-digit hex`);
+ // Lightness rises monotonically across the ordered steps.
+ const ls = r.steps.map(s => rl(s.hex));
+ for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'each step lighter than the last');
+ // Base sits between -1 and +1 in lightness.
+ const baseL = rl('#67809c');
+ assert.ok(rl(r.steps[1].hex) < baseL && baseL < rl(r.steps[2].hex), 'base brackets the inner steps');
+});
+
+test('ramp: Normal — holds the hue across in-gamut steps', () => {
+ const base = '#67809c';
+ const H0 = baseLCH(base).H;
+ // chromaEase 0 keeps chroma up so the recovered hue is well-defined (near-gray
+ // steps have an ill-defined hue that 8-bit quantization can swing).
+ const r = ramp(base, { chromaEase: 0 });
+ for (const s of r.steps) {
+ if (s.clamped) continue; // a clamped step may drift hue; only assert on clean ones
+ const dH = Math.abs(baseLCH(s.hex).H - H0);
+ assert.ok(Math.min(dH, 360 - dH) < 3.0, `step ${s.offset} holds hue (${dH.toFixed(2)} deg off)`);
+ }
+});
+
+test('ramp: Normal — chroma eases toward the extremes (outer step less chromatic than inner)', () => {
+ const base = '#67809c';
+ const r = ramp(base, { n: 2, chromaEase: 0.8 });
+ const inner = baseLCH(r.steps[1].hex).C; // offset -1
+ const outer = baseLCH(r.steps[0].hex).C; // offset -2
+ assert.ok(outer < inner, 'the farther step carries less chroma');
+});
+
+test('ramp: Normal — chromaEase 0 holds chroma flat', () => {
+ const base = '#67809c';
+ const C0 = baseLCH(base).C;
+ const r = ramp(base, { n: 1, stepL: 0.06, chromaEase: 0 });
+ for (const s of r.steps) {
+ if (s.clamped) continue;
+ assert.ok(Math.abs(baseLCH(s.hex).C - C0) < 0.01, 'chroma held within tolerance');
+ }
+});
+
+test('ramp: Boundary — near-white base clamps the lighter steps at L=1', () => {
+ const r = ramp('#f6f6f6', { n: 2, stepL: 0.08 });
+ assert.equal(r.steps.length, 4);
+ const lightest = r.steps[r.steps.length - 1];
+ assert.match(lightest.hex, HEXRE);
+ assert.ok(rl(lightest.hex) > 0.9, 'lightest step is near white');
+});
+
+test('ramp: Boundary — near-black base clamps the darker steps at L=0', () => {
+ const r = ramp('#0b0b0b', { n: 2, stepL: 0.08 });
+ assert.equal(r.steps.length, 4);
+ const darkest = r.steps[0];
+ assert.match(darkest.hex, HEXRE);
+ assert.ok(rl(darkest.hex) < 0.05, 'darkest step is near black');
+});
+
+test('ramp: Boundary — n clamps to [1,4] and reports the adjustment', () => {
+ const lo = ramp('#67809c', { n: 0 });
+ assert.equal(lo.steps.length, 2); // clamped to n=1
+ assert.ok(lo.adjusted.includes('n'));
+ const hi = ramp('#67809c', { n: 9 });
+ assert.equal(hi.steps.length, 8); // clamped to n=4
+ assert.ok(hi.adjusted.includes('n'));
+ const frac = ramp('#67809c', { n: 2.7 });
+ assert.equal(frac.steps.length, 6); // rounded to 3, in range, still flagged as adjusted
+ assert.ok(frac.adjusted.includes('n'));
+});
+
+test('ramp: Boundary — stepL and chromaEase clamp to range and report', () => {
+ const r = ramp('#67809c', { stepL: 0.5, chromaEase: 2 });
+ assert.ok(r.adjusted.includes('stepL'));
+ assert.ok(r.adjusted.includes('chromaEase'));
+ assert.equal(r.steps.length, 4);
+});
+
+test('ramp: Error — malformed hex returns a structured bad-hex, not a throw', () => {
+ for (const bad of ['nope', '#xyz', '#12', '12345g', null, undefined, '']) {
+ const r = ramp(bad);
+ assert.deepEqual(r, { steps: [], error: 'bad-hex' }, `${String(bad)} -> bad-hex`);
+ }
+});
+
+test('ramp: Boundary — a six-digit hex without the leading # is accepted', () => {
+ const r = ramp('67809c');
+ assert.equal(r.steps.length, 4);
+ assert.ok(!r.error);
+});
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 50e0a519..e81fb59c 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -386,6 +386,10 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) {
// 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.
+//
+// The imports below are for the Node tests; generate.py strips them on inline,
+// where normHex (app-util.js) and the colormath helpers are already present from
+// the bodies inlined above this one.
// 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;}
@@ -410,6 +414,38 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r
// Turn a theme name into a safe filename slug: collapse runs of disallowed
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+
+// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and
+// +1..+n (the base itself is excluded — it already lives in the palette),
+// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per
+// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only
+// the farthest step loses most of its color). Every step is gamut-clamped and
+// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted}
+// where adjusted names any knob clamped/rounded into range, or {steps:[],
+// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown.
+function ramp(baseHex,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {steps:[],error:'bad-hex'};
+ const o=opts||{},adjusted=[];
+ const knob=(name,def,lo,hi,isInt)=>{
+ const v=o[name];
+ if(typeof v!=='number'||!isFinite(v))return def;
+ const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r));
+ if(c!==v)adjusted.push(name);
+ return c;
+ };
+ const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false);
+ const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex));
+ const steps=[];
+ for(let off=-n;off<=n;off++){
+ if(off===0)continue;
+ const L=Math.min(1,Math.max(0,L0+off*stepL));
+ const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t);
+ const {hex:h,clamped}=oklch2hex(L,C,H0);
+ steps.push({hex:h,clamped,offset:off});
+ }
+ return {steps,adjusted};
+}
// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
// app-util.js. textOn uses rl from the colormath core above.
// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status