aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/app-core.js38
-rw-r--r--scripts/theme-studio/app_inventory.py3
-rw-r--r--scripts/theme-studio/browser-gates.js2
-rw-r--r--scripts/theme-studio/face_data.py1
-rw-r--r--scripts/theme-studio/generate.py2
-rw-r--r--scripts/theme-studio/test-app-core.mjs17
-rw-r--r--scripts/theme-studio/test-ramp.mjs105
-rw-r--r--scripts/theme-studio/theme-studio.html38
8 files changed, 7 insertions, 199 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index d99e5e364..23f73961b 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -89,46 +89,10 @@ function dropdownRowTextColor(hex,shown,textOnFn){
return shown?textOnFn(shown):'';
}
-// 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)'],...palette])];}
-
// 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};
-}
-
// --- background-contrast safety (palette-ramps spec, Phase 3) ----------------
// An overlay background sits behind many foregrounds at once, so its real
// constraint is the worst-case contrast over the whole set, not one fg/bg pair.
@@ -378,4 +342,4 @@ function spanNeighborHex(cur,palette,ground,dir){
return null;
}
-export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, optList, paletteOptionList, spanNeighborHex, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
diff --git a/scripts/theme-studio/app_inventory.py b/scripts/theme-studio/app_inventory.py
index 28b641a94..26044d359 100644
--- a/scripts/theme-studio/app_inventory.py
+++ b/scripts/theme-studio/app_inventory.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
+from collections.abc import Sequence
from typing import Any
@@ -38,7 +39,7 @@ def face_label(face: str, prefix: str) -> str:
return label.replace("-face", "").replace("-", " ")
-def face_rows(names: list[str], prefix: str, seed: dict[str, dict[str, Any]]) -> list[list[Any]]:
+def face_rows(names: Sequence[str], prefix: str, seed: dict[str, dict[str, Any]]) -> list[list[Any]]:
return [[face, face_label(face, prefix), seed.get(face, {})] for face in names]
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index fb828eaf8..ad7a586df 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -458,7 +458,7 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
// Column-strip gate (open with #columntest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
-if(location.hash==='#columntest'||location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();
diff --git a/scripts/theme-studio/face_data.py b/scripts/theme-studio/face_data.py
index a662d612c..d9b129150 100644
--- a/scripts/theme-studio/face_data.py
+++ b/scripts/theme-studio/face_data.py
@@ -161,7 +161,6 @@ MU4E_FACES=("mu4e-title-face mu4e-context-face mu4e-modeline-face mu4e-ok-face m
"mu4e-header-title-face mu4e-header-key-face mu4e-header-value-face mu4e-header-face mu4e-header-highlight-face mu4e-header-marks-face "
"mu4e-unread-face mu4e-flagged-face mu4e-replied-face mu4e-forwarded-face mu4e-draft-face mu4e-trashed-face mu4e-related-face "
"mu4e-contact-face mu4e-special-header-value-face mu4e-url-number-face mu4e-link-face "
- ""
"mu4e-footer-face mu4e-region-code mu4e-system-face mu4e-highlight-face mu4e-compose-separator-face").split()
MU4E_SEED={
"mu4e-title-face":{"fg":"blue","bold":True},"mu4e-context-face":{"fg":"blue","bold":True},"mu4e-modeline-face":{"fg":"silver"},"mu4e-ok-face":{"fg":"sage","bold":True},"mu4e-warning-face":{"fg":"gold","bold":True},
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index e3ec3981c..523f3206d 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -212,7 +212,7 @@ UIMAP=build_uimap(UI_FACES,DEFAULTS)
# this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
# Placed after every default it overrides (notably UIMAP) so the merge has targets.
# Mirrors what the in-page Import does, so reseed and import agree.
-LOCKS=[]; ITALIC=[k for k,v in ITALIC_MAP.items() if v]
+LOCKS=[]
# THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
# Unset starts empty: only bg/fg are in the palette.
_seed=os.environ.get('THEME_STUDIO_SEED')
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index e98e511e5..42ce4e0a2 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
- nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, optList, paletteOptionList, spanNeighborHex, slugify,
+ nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify,
clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet,
} from './app-core.js';
import { planPaletteGenerator, entriesForGeneratedColumn } from './palette-generator-core.js';
@@ -33,21 +33,6 @@ test('nameToHex: Boundary/Error — null, empty, and unknown names give 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)']);
- assert.deepEqual(list.slice(2), PAL);
-});
-
test('paletteOptionList: Normal — color choices follow visual column ordering', () => {
const pal = [
['#67809c', 'blue'],
diff --git a/scripts/theme-studio/test-ramp.mjs b/scripts/theme-studio/test-ramp.mjs
deleted file mode 100644
index 0c447ff47..000000000
--- a/scripts/theme-studio/test-ramp.mjs
+++ /dev/null
@@ -1,105 +0,0 @@
-// 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 5fb563863..0c7f28cc3 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -573,46 +573,10 @@ function dropdownRowTextColor(hex,shown,textOnFn){
return shown?textOnFn(shown):'';
}
-// 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)'],...palette])];}
-
// 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};
-}
-
// --- background-contrast safety (palette-ramps spec, Phase 3) ----------------
// An overlay background sits behind many foregrounds at once, so its real
// constraint is the worst-case contrast over the whole set, not one fg/bg pair.
@@ -2933,7 +2897,7 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
// Column-strip gate (open with #columntest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
-if(location.hash==='#columntest'||location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+if(location.hash==='#columntest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
setSyntaxFg('bg','#0d0b0a');setSyntaxFg('p','#f0fef0');
PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();