aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/README.md67
-rw-r--r--scripts/theme-studio/app-core.js225
-rw-r--r--scripts/theme-studio/app.js324
-rw-r--r--scripts/theme-studio/generate.py3
-rwxr-xr-xscripts/theme-studio/run-tests.sh2
-rw-r--r--scripts/theme-studio/styles.css12
-rw-r--r--scripts/theme-studio/test-app-core.mjs2
-rw-r--r--scripts/theme-studio/test-contrast.mjs111
-rw-r--r--scripts/theme-studio/test-families.mjs213
-rw-r--r--scripts/theme-studio/test-ramp.mjs105
-rw-r--r--scripts/theme-studio/theme-studio.html560
11 files changed, 1547 insertions, 77 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index 044ccc2e..caee7b24 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -42,7 +42,9 @@ The runner regenerates the page, runs the Python templating tests
(`test-colormath.mjs`, including the inline-integrity check), a syntax check of
the spliced page script, and the browser hash gates in headless Chrome
(`#selftest`, `#cursortest`, `#readouttest`, `#deltatest`, `#oklchtest`,
-`#planetest`). It exits non-zero on any failure. The browser gates need a
+`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#contrasttest`,
+`#safetest`, `#healtest`, `#familytest`, `#counttest`, `#baseedittest`,
+`#roundtriptest`). It exits non-zero on any failure. The browser gates need a
Chromium-family browser; without one they report SKIPPED rather than passing
silently. The pure color math and the extracted picker logic (`planeCell`,
`paletteWarnings`) live in `colormath.js` so they are unit-tested directly in
@@ -65,10 +67,11 @@ Node; the DOM glue is covered by the browser hash gates.
Three tiers of faces, plus the palette:
-- **Palette** — named colors. Add by hex or with the in-page color picker
+- **Palette** — named colors, shown grouped into hue *families* (see Color
+ families below). Add by hex or with the in-page color picker
(saturation/value square, hue slider, palette reuse chips, live contrast
- readout, and an any / AA+ / AAA legibility mask). Remove, rename, reorder with
- arrows or drag. The colors serving as background and foreground are locked.
+ readout, and an any / AA+ / AAA legibility mask). Remove and rename per chip;
+ the colors serving as background and foreground are locked.
The picker also shows perceptual readouts beside the WCAG ratio: the OKLCH
coordinates (lightness, chroma, hue°) and the APCA Lc contrast against the
@@ -92,6 +95,62 @@ Three tiers of faces, plus the palette:
per face, shown in a live mock Emacs buffer.
- **Package faces** — per-package face tables with a live preview (below).
+## Color families
+
+The palette is displayed as **families**: colors grouped into vertical columns by
+their actual color, dark at the top and light at the bottom, columns arranged left
+to right. Grouping is derived from the hex on every render — never from the name —
+so renaming a color to anything never moves it between columns. The flat palette
+underneath is unchanged (export stays a flat `[hex, name]` list); families are a
+view over it, and the per-chip rename/remove still work.
+
+- **Grouping.** Chromatic colors bucket by their nearest perceptual hue (red,
+ orange, yellow, green, teal, blue, purple, pink). Near-neutrals — grays, the
+ background and foreground ramps — collapse into one neutral column ordered by
+ lightness, using a lightness-scaled chroma threshold so a faint pale tint keeps
+ its hue while a faint mid gray reads as neutral. Columns sort by hue; the ground
+ strip (the `bg` and `fg` assignments) pins first, neutrals next. (Hue-adjacent
+ warm colors like olive-greens and golds can still share a column — a known
+ limitation, since by hue they really are adjacent.)
+- **The count control** under each chromatic column sets how many steps sit on
+ each side of the family's base (its most-saturated color). Setting N regenerates
+ the family as a symmetric base ±N tonal ramp via `ramp()` — lighter and darker
+ steps on the base's hue with chroma easing toward the extremes — *replacing* the
+ column's current colors. N=0 collapses to the base alone.
+- **Editing a base** recolors the whole family: change a base color and the family
+ regenerates from it at the same count.
+- **References follow.** When a regenerate changes a step's hex, any face assigned
+ to that step is re-pointed to the new hex. A step *removed* by lowering the count
+ leaves its references showing "(gone)" — visible and recoverable, never a silent
+ jump to a different color.
+
+The standalone ramp generator is gone; fanning a color into a ramp is now "add the
+color, then raise its column's count."
+
+## Background-contrast safety
+
+Keep background tints readable. Works in OKLCH; the pure math is in `app-core.js`
+(`fgSetFor`, `floor`, `lMax`), the DOM in `app.js`.
+
+**Worst-case contrast.** A background overlay sits behind many foregrounds at
+once, so one fg/bg contrast pair is the wrong number. For the covered overlay
+faces — `region`, `hl-line`, `highlight`, `lazy-highlight`, `isearch` — the
+contrast cell shows the *worst-case floor*: the lowest contrast over the face's
+foreground set (the syntax-token colors plus the default foreground), naming the
+*limiting foreground* that sets it. A tint that clears the default text but fails
+the darkest token reads FAIL, with that token named. Package faces and the other
+UI rows keep their single-pair readout.
+
+The verdict is WCAG: AA (4.5) by default, AAA (7) selectable. APCA Lc stays a
+picker-only diagnostic and does not drive PASS/FAIL.
+
+**Safe lightness.** In the OKLCH picker, the "safe for" selector picks one
+covered face. The Chroma×Lightness plane then shades the lightness band too light
+to keep that face readable over its foreground set, with the L_max ceiling as the
+band's lower edge. If even pure black can't satisfy the target (a foreground is
+too dark), the whole plane shades; that is a true finding about the foreground,
+not a tool bug.
+
## Package faces
Pick an application from the dropdown to edit its faces. Each row has a
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 0e3cfe49..60ee1410 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, contrast } 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,221 @@ 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};
+}
+
+// --- 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.
+
+// The closed v1 set of code-overlay faces whose worst-case floor we compute.
+// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added
+// explicitly rather than by a heuristic. Shared by app.js and the tests.
+const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch'];
+
+// A covered face's foreground set: the distinct syntax-token colors plus the
+// default foreground, each labeled (syntax role preferred, else 'default').
+// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns
+// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the
+// face isn't in the covered set) or 'empty' (no syntax assignments constrain it).
+function fgSetFor(face,state){
+ const covered=(state&&state.covered)||COVERED_FACES;
+ if(!covered.includes(face))return {set:[],reason:'out-of-scope'};
+ const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex);
+ if(!syn.length)return {set:[],reason:'empty'};
+ const byHex=new Map();
+ const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;};
+ if(state&&state.defaultFg)add(state.defaultFg,'default',false);
+ for(const a of syn)add(a.hex,a.role||a.hex,true);
+ return {set:[...byHex.values()]};
+}
+
+// Worst-case (minimum) WCAG contrast of a background against a foreground set,
+// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty
+// set returns nulls so the caller can show the no-set readout instead of a floor.
+function floor(bgHex,fgSet){
+ if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null};
+ let best=Infinity,lh=null,ll=null;
+ for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}}
+ return {ratio:best,limitingHex:lh,limitingLabel:ll};
+}
+
+// The lightest background at (hue, chroma) whose worst-case floor over fgSet still
+// clears target (a WCAG ratio). Scans L up from black to bracket the first
+// dark-side crossing, then binary-searches it to tol 0.001. status:
+// 'ok' - a ceiling L was found
+// 'none' - even pure black fails (a foreground is too dark for the target)
+// 'all' - no foreground set to constrain (vacuously safe everywhere)
+// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there)
+function lMax(hue,chroma,fgSet,target){
+ if(!fgSet||!fgSet.length)return {L:1,status:'all'};
+ const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};};
+ if(at(0).r<target)return {L:null,status:'none'};
+ let loL=0,hiL=null;
+ for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;}
+ if(hiL===null)return {L:1,status:'all'};
+ for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;}
+ return {L:loL,status:at(loL).clamped?'clamp':'ok'};
+}
+
+// --- color families (color-families spec, Phase 1) ---------------------------
+// Families are a display grouping derived from the hex every render — never from
+// names — so renaming a color can't move it. The flat palette stays the editable
+// truth; these pure functions group it, regenerate a family's ramp, and plan the
+// assignment re-point across a regenerate.
+
+function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
+function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
+function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);}
+
+// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
+// insight): the mid-tones need more chroma to read as a hue. Floored at both ends
+// rather than tapering to zero, so pale warm grays stay neutral (and pure white,
+// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay
+// colored. Tuned on real palettes (Codex + Fable color-sorting reviews).
+function neutralThreshold(L){
+ if(L<=0.2)return 0.020;
+ if(L<0.6)return 0.020+0.015*(L-0.2)/0.4;
+ if(L<0.85)return 0.035-0.017*(L-0.6)/0.25;
+ return 0.018;
+}
+// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL):
+// hue must match tightly at equal lightness and may drift across a lightness gap,
+// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise
+// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma
+// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is
+// compatible. Source: ~/color-sorting-fable.org.
+function pairRatio(a,b){
+ const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H);
+ const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI);
+ return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL));
+}
+// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two
+// clusters whose worst cross-pair is most compatible, stopping when no merge has
+// every cross-pair compatible. Complete linkage makes single-linkage chaining
+// structurally impossible — two ramps can't fuse through their converging pale
+// ends because their mid-lightness members stay far apart.
+function clusterChromatic(ms){
+ let cl=ms.map(m=>[m]);
+ const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b))));
+ for(;;){
+ let best=null;
+ for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};}
+ if(!best||best.d>1)break;
+ cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1);
+ }
+ return cl;
+}
+// A family from its members: base is the most-saturated member (tie toward
+// mid-lightness), the anchor for a generated ramp.
+function makeFamily(ms,neutral){
+ let base=ms[0];
+ for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m;
+ return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))};
+}
+// Group a flat palette into the ground strip plus families. ground is {bg,fg}:
+// those two hexes form the pinned ground strip even when absent from the palette,
+// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
+// (chroma below the lightness-scaled threshold) form one neutral family; the rest
+// cluster by lightness-conditioned complete linkage (clusterChromatic).
+function familiesFromPalette(palette,ground){
+ const bg=ground&&ground.bg,fg=ground&&ground.fg;
+ const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
+ const groundStrip=[];
+ if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
+ if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
+ const neutrals=[],chromatic=[];
+ for(const [hex,name] of palette){
+ if(gset.has(hex.toLowerCase()))continue;
+ const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
+ (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m);
+ }
+ const families=[];
+ if(neutrals.length)families.push(makeFamily(neutrals,true));
+ for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false));
+ return {ground:groundStrip,families};
+}
+// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
+// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted
+// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}.
+function regenFamily(baseHex,n,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {members:[],error:'bad-hex'};
+ const k=Math.min(4,Math.max(0,Math.round(n||0)));
+ if(k===0)return {members:[{hex,offset:0,clamped:false}]};
+ const r=ramp(hex,Object.assign({},opts,{n:k}));
+ if(r.error)return {members:[],error:r.error};
+ const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ return {members};
+}
+// Rank a family's current member hexes by lightness and give each a signed offset
+// from the base (the matching hex, or the nearest by lightness if the base isn't
+// present). Lets a regenerate match old positions to new ramp offsets.
+function rankByLightness(memberHexes,baseHex){
+ const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L);
+ let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase());
+ if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});}
+ return items.map((m,i)=>({hex:m.hex,offset:i-bi}));
+}
+// Plan the assignment re-point for a regenerate: for each old ranked member, the
+// new member at the same offset is the same position. {map:[[old,new]]} for
+// positions whose hex changed; {removed:[hex]} for positions with no new
+// counterpart (the caller leaves their references a visible "(gone)").
+function stepRepointPlan(oldRanked,newMembers){
+ const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[];
+ for(const o of oldRanked){
+ const nh=byOff.get(o.offset);
+ if(nh===undefined)removed.push(o.hex);
+ else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]);
+ }
+ return {map,removed};
+}
+
+// Order a family's members dark to light by OKLCH lightness.
+function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});}
+// Order families for display: neutrals first (by base lightness), then chromatic
+// by base hue, ties broken by base lightness then base hex. Each family's members
+// are lightness-sorted. Display-only — the stored palette order is untouched.
+function sortFamilies(families){
+ const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};});
+ keyed.sort((a,b)=>{
+ if(a.neutral!==b.neutral)return a.neutral?-1:1;
+ if(a.neutral&&b.neutral)return a.L-b.L;
+ const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness
+ if(ah!==bh)return ah-bh;
+ if(a.L!==b.L)return a.L-b.L;
+ return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0;
+ });
+ return keyed.map(k=>sortFamilyMembers(k.f));
+}
+
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index d0fed81c..0b6663ee 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -121,7 +121,7 @@ function buildTable(){
const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt';
function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';}
function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);}
- const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}});
+ const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();});
styleEx();styleCr();
const lkTd=mkLockCell(kind,[dd]);
// style buttons
@@ -138,7 +138,23 @@ function buildTable(){
tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd);
tb.appendChild(tr);}
}
-let dragFrom=null,selectedIdx=null;
+let selectedIdx=null;
+// When a named palette color is deleted, remember its hex keyed by name so that
+// recreating a color with the same name can re-bind the assignments still pointing
+// at the old (now "(gone)") hex. Consumed once per name; cleared on import.
+let lastGone={};
+// Re-point every assignment — syntax map, UI faces, package faces — from one hex
+// to another. Used when a palette color's value is edited and when a deleted name
+// is recreated.
+function repointHex(oldHex,newHex){
+ if(oldHex===newHex)return;
+ for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;}
+ for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;}
+ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
+}
+// On adding a color, if its name matches a recently-deleted one, re-bind the
+// stranded assignments to the new hex. Returns true when a heal context existed.
+function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;}
// Pairwise OKLab ΔE over the palette. Returns the sub-threshold pairs (sorted
// closest-first) and each color's nearest-neighbor distance for its chip title.
// Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it.
@@ -150,35 +166,90 @@ function renderPaletteWarnings(warnings,overflow){
if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`;
w.innerHTML=html;w.style.display='block';
}
+// One palette chip for PALETTE[i], with its remove / rename / select handlers.
+// Families sort deterministically, so the old move-arrow / drag reordering is gone.
+function paletteChip(i,nearest){
+ const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i];
+ const locked=(hex===MAP['bg']||hex===MAP['p']);
+ const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;
+ d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3));
+ const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
+ d.innerHTML=`${rm}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`;
+ if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();};
+ d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();};
+ d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);};
+ return d;
+}
+// Render the palette as hue families: the pinned ground strip, then hue-sorted
+// family strips, each dark to light. Grouping is derived from the hex by
+// familiesFromPalette every render, so renaming a color never moves it. The flat
+// PALETTE stays the editable truth; chips keep their per-chip controls.
function renderPalette(){
const p=document.getElementById('pals');p.innerHTML='';
const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5);
- PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex);
- const nde=nearest[i];
- const locked=(hex===MAP['bg']||hex===MAP['p']);
- const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true;
- d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3));
- const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">&#8249;</button>`:'';
- const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">&#8250;</button>`:'';
- const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
- d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`;
- if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();};
- if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);};
- if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);};
- d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();};
- d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);};
- d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');};
- d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));};
- d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');};
- d.ondragleave=()=>d.classList.remove('over');
- d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();};
- p.appendChild(d);});
+ const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const used=new Set();
+ const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;};
+ const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;};
+ const gs=strip(' ground');gs.dataset.family='ground';
+ ground.forEach(g=>{
+ const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex);
+ if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));}
+ else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex;
+ sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);}
+ });
+ // The too-similar warning stays on the full flat palette: a generated ramp's
+ // steps are a stepL apart (well above the warning's ΔE threshold), so they never
+ // trigger it, and any pair that does is a genuine near-duplicate worth flagging.
+ sortFamilies(families).forEach(f=>{
+ const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base;
+ f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));});
+ if(!f.neutral)s.appendChild(familyCountControl(f));
+ });
renderPaletteWarnings(warnings,overflow);
buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
}
+// The per-family count control under a chromatic strip. Its value is the family's
+// current per-side reach; setting N regenerates the family as base ±N.
+function familyCountControl(f){
+ const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset)));
+ const d=document.createElement('div');d.className='fcount';
+ d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">&#177; <input type="number" min="0" max="4" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0)));
+ return d;
+}
+// Regenerate a family as a symmetric base ±N ramp, replacing its current members.
+// References to a surviving position (matched by signed lightness rank) follow the
+// new hex; references to a position removed by lowering N leave their old hex,
+// which is no longer in the palette and so renders as "(gone)".
+// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving
+// references and leaving removed ones on their now-gone hex. Returns the removed
+// count, or null on a bad base. Shared by the count control and the base edit.
+function regenFamilyInPlace(oldHexes,baseHex,baseName,n){
+ const r=regenFamily(baseHex,n,{});
+ if(r.error){notify('cannot regenerate from '+baseHex,true);return null;}
+ const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members);
+ const oldSet=new Set(oldHexes.map(h=>h.toLowerCase()));
+ let at=PALETTE.length;
+ for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;}
+ for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1);
+ const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]);
+ PALETTE.splice(Math.min(at,PALETTE.length),0,...entries);
+ for(const [o,nw] of plan.map)repointHex(o,nw);
+ return plan.removed.length;
+}
+function setFamilyCount(baseHex,n){
+ const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase());
+ if(!fam)return;
+ const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color';
+ const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n);
+ if(removed===null)return;
+ selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();
+ notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false);
+}
function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);}
function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();}
-function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();}
function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);}
function updateColor(){
if(selectedIdx===null){notify('click a palette color to select it first',true);return;}
@@ -186,10 +257,17 @@ function updateColor(){
const newHex=curHex();
const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1];
if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;}
+ // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count.
+ const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families;
+ const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase());
+ const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0;
PALETTE[i]=[newHex,newName];
- for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;}
- for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;}
- for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
+ repointHex(oldHex,newHex);
+ if(fam&&count>0){
+ const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
+ regenFamilyInPlace(oldHexes,newHex,newName,count);
+ closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return;
+ }
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';}
@@ -217,12 +295,30 @@ function paintOklchPlane(H){
if(T&&contrast(cell.hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
_planeCache={key,data:ctx.getImageData(0,0,w,h)};
}
+// --- safe-lightness guidance (spec Phase 5) ----------------------------------
+let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '')
+function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();}
+// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace
+// readable over its foreground set, with the L_max ceiling as the band's lower
+// edge. One marker computed via lMax at the current chroma, not a per-pixel mask.
+function paintSafeBand(C,H){
+ const el=document.getElementById('svsafe');if(!el)return;
+ if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;}
+ const fs=fgSetForFace(pkSafeFace);
+ if(fs.reason||!fs.set.length){el.style.display='none';return;}
+ const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET);
+ if(res.status==='all'){el.style.display='none';return;}
+ el.style.display='block';el.style.top='0px';
+ el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px';
+ el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')';
+}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;
const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;
if(pkModel==='oklch'){const [L,C,H]=readOklch();sv.style.background='#15120f';paintOklchPlane(H);
document.getElementById('svcur').style.left=(Math.min(1,C/OKLCH_CMAX)*w)+'px';
document.getElementById('svcur').style.top=((1-L)*h)+'px';
- document.getElementById('huecur').style.top=((H/360)*hh)+'px';return;}
+ document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;}
+ const sb=document.getElementById('svsafe');if(sb)sb.style.display='none';
sv.style.background=`linear-gradient(to top,#000,rgba(0,0,0,0)),linear-gradient(to right,#fff,rgba(255,255,255,0)),hsl(${pkH},100%,50%)`;
document.getElementById('svcur').style.left=(pkS*w)+'px';document.getElementById('svcur').style.top=((1-pkV)*h)+'px';document.getElementById('huecur').style.top=((pkH/360)*hh)+'px';drawMask();}
function pkReadout(h){const e=document.getElementById('pkhex');if(e)e.textContent=h;const c=document.getElementById('pkcon');if(c){const r=contrast(h,MAP['bg']);c.textContent=r.toFixed(1)+' '+rating(r);c.style.color=ratingColor(r);}
@@ -253,6 +349,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl
function pkOutside(e){if(!e.target.closest('#picker')&&!e.target.closest('#swatch'))closePicker();}
function pkDrag(el,fn){el.addEventListener('pointerdown',e=>{e.preventDefault();fn(e);const mv=ev=>fn(ev),up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);};document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);});}
function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;sw.style.background=curHex();sw.onclick=()=>pickerOn?closePicker():openPicker();
+ const sf=document.getElementById('safefor');if(sf&&sf.options.length<=1)COVERED_FACES.forEach(f=>{const o=document.createElement('option');o.value=f;o.textContent=f;sf.appendChild(o);});
pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();const fx=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
if(pkModel==='oklch'){setOklchInputs(1-fy,fx*OKLCH_CMAX,readOklch()[2]);pkOklchSet();}else{pkS=fx;pkV=1-fy;pkSet();}});
pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();const fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
@@ -266,7 +363,10 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s
function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim();
if(!name){notify('name the color before adding it',true);return;}
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
- PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
+ PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker();
+ renderPalette();buildTable();buildUITable();
+ if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();}
+ notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
@@ -280,7 +380,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1);
try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]});
const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle();
}catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}}
-function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments);
+function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments);
BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true);
LOCKED=new Set(d.locks||[]);
if(d.ui)Object.assign(UIMAP,d.ui);
@@ -736,8 +836,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8
function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';}
function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();}
function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
+// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
+let WORST_TARGET=4.5;
+// The live v1 foreground set for a covered overlay face: the syntax-token colors
+// (every assignable category except the ground) plus the default foreground.
+function fgSetForFace(face){
+ const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])}));
+ return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']});
+}
+// The worst-case contrast cell for a covered face: the floor over its foreground
+// set against its effective background, naming the limiting foreground. Returns
+// null for an out-of-scope face so the caller keeps the single-pair readout.
+function worstCellHtml(face){
+ const r=fgSetForFace(face);
+ if(r.reason==='out-of-scope')return null;
+ if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>';
+ const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL';
+ const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict;
+ return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`;
+}
+// Repaint every covered overlay face (their floors depend on the syntax palette,
+// so a syntax-color edit has to refresh them even though it doesn't rebuild the table).
+function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});}
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box);
- const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}
+ const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -914,3 +1037,144 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2;
const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);}
+// Worst-case readout gate (open with #contrasttest): a covered overlay face shows
+// the floor over its foreground set and names the limiting foreground, an
+// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set".
+if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000';
+ UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false};
+ buildUITable();
+ const cell=document.getElementById('uicr-region');
+ A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent));
+ A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent));
+ A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict');
+ const fl=floor('#202830',fgSetForFace('region').set);
+ A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex);
+ A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg');
+ const ml=document.getElementById('uicr-mode-line');
+ A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)');
+ A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent));
+ MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable();
+ const empty=document.getElementById('uicr-region');
+ A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent));
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
+// lightness band for a selected covered face and hides it when no face is selected.
+if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP);
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['bg']='#000000';
+ document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
+ setSafeFace('region');
+ const band=document.getElementById('svsafe');
+ A(band&&band.style.display==='block','safe band shows for an in-scope face');
+ A(band&&parseFloat(band.style.height)>0,'safe band has a positive height: '+(band&&band.style.height));
+ setSafeFace('');
+ A(band&&band.style.display==='none','safe band hidden when no face is selected');
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);
+ setPkModel('hsv');closePicker();
+ document.title='SAFETEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Gone-rebind gate (open with #healtest): deleting a named color then recreating
+// the name re-points the assignments stranded on the old hex to the new color.
+if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
+ PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable();
+ const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue');
+ A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button');
+ if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click();
+ A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted');
+ A(lastGone['blue']==='#67809c','delete recorded the gone name->hex');
+ document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor();
+ A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']);
+ A(!('blue' in lastGone),'heal consumed the gone entry');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel;
+ renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
+ document.title='HEALTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Family-strip gate (open with #familytest): the palette renders as the pinned
+// ground strip plus hue families, chips keep their controls, and renaming a color
+// to anything leaves it in the same strip (grouping is by hex, not name).
+if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx;
+ MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();
+ const strips=[...document.querySelectorAll('#pals .fstrip')];
+ A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first');
+ A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg');
+ A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length);
+ const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red');
+ A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls');
+ const redFamily=redChip&&redChip.closest('.fstrip').dataset.family;
+ const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette();
+ const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd');
+ A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette();
+ document.title='FAMILYTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Count-control gate (open with #counttest): the per-family count regenerates the
+// family — count up adds symmetric steps, count down drops the extremes, a
+// reference to a surviving step follows the new hex, a reference to a removed step
+// is left on its old (now-gone) hex.
+if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
+ MAP['bg']='#000000';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
+ regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change
+ const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down
+ UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false};
+ UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false};
+ selectedIdx=null;renderPalette();
+ setFamilyCount('#67809c',1);
+ const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down');
+ A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex');
+ const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex;
+ A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg);
+ setFamilyCount('#67809c',3);
+ const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase());
+ const have=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
+ document.title='COUNTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Base-edit + ground-edit gate (open with #baseedittest): editing a family base
+// recolors the whole family at the same count and references follow; editing a
+// ground swatch writes the bg/fg assignment.
+if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
+ MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
+ regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false};
+ renderPalette();buildUITable();
+ selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c');
+ document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal';
+ updateColor();
+ const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral);
+ A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex');
+ A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length));
+ A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette');
+ A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex');
+ // ground edit: select bg, change hex, MAP.bg follows
+ selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a');
+ document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground';
+ updateColor();
+ A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']);
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
+ document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Round-trip gate (open with #roundtriptest): export stays a flat palette and
+// import needs no family reconstruction, so export → import → export is identical.
+if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const before=JSON.stringify(exportObj());
+ applyImported(before);
+ const after=JSON.stringify(exportObj());
+ A(before===after,'export → import → export is byte-identical');
+ const obj=JSON.parse(after);
+ A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list');
+ document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index 9315faad..b6e2fc73 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -451,10 +451,11 @@ STYLES_CSS</style>
<span id="palmsg"></span>
<div id="picker" class="picker">
<div class="prow">
- <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div>
+ <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div>
<div id="hue" class="hue"><div id="huecur" class="huecur"></div></div>
</div>
<div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div>
+ <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div>
<div class="oklchctl" id="oklchctl">
<div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div>
<div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div>
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index e364d431..2f46602c 100755
--- a/scripts/theme-studio/run-tests.sh
+++ b/scripts/theme-studio/run-tests.sh
@@ -53,7 +53,7 @@ CHROME=""
for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi
done
-HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest"
+HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest"
if [ "$NO_BROWSER" = 1 ]; then
skip_msg "browser hash gates (--no-browser)"
elif [ -z "$CHROME" ]; then
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index 72541ca0..cc074dac 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -23,13 +23,16 @@
.cat{color:#b4b1a2} .ex{font-size:17px}
.sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0}
.sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496}
- .pals{display:flex;gap:8px;flex-wrap:wrap}
+ .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start}
+ .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent}
+ .fstrip.ground{border-color:#252321;background:#161412}
+ .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center}
+ .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center}
.palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d}
.palwarn .pwh{font-weight:bold;margin-bottom:2px}
.palwarn .pwl{opacity:.92}
.pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab}
- .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none}
- .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0}
+ .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none}
.pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7}
.pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6}
.palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
@@ -55,13 +58,14 @@
.oklchctl .ocrow input[type=number]{width:62px;background:#252321;color:#cdced1;border:1px solid #3a3a3a;border-radius:3px;font:10pt monospace;padding:1px 3px}
.oklchctl .pclamp{display:none;color:#cb6b4d;margin-top:3px}
.oklchctl .pclamp.show{display:block}
- .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none}
+ .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none;z-index:3}
.hue{position:relative;width:34px;height:320px;border-radius:4px;cursor:ns-resize;background:linear-gradient(to bottom,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
.huecur{position:absolute;left:-2px;right:-2px;height:4px;background:#fff;border:1px solid #0008;transform:translateY(-50%);pointer-events:none}
.pinfo{display:flex;justify-content:space-between;margin:10px 2px 4px;font:12pt monospace;color:#cdced1}
.pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad}
.pinfo2 span{cursor:default}
.pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer}
+ .svsafe{position:absolute;left:0;width:100%;background:rgba(203,107,77,0.30);border-bottom:2px solid #cb6b4d;pointer-events:none;z-index:2}
.palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer}
#palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
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-contrast.mjs b/scripts/theme-studio/test-contrast.mjs
new file mode 100644
index 00000000..9baf5bcc
--- /dev/null
+++ b/scripts/theme-studio/test-contrast.mjs
@@ -0,0 +1,111 @@
+// Unit tests for the background-contrast safety core (app-core.js): fgSetFor,
+// floor, and lMax. Phase 3 of the palette-ramps spec. A background overlay sits
+// behind many foregrounds at once, so its real constraint is the worst-case
+// (minimum) contrast over that foreground set, and the lightest background that
+// keeps the floor above the target. Pure, no DOM. Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fgSetFor, floor, lMax, COVERED_FACES } from './app-core.js';
+import { contrast, oklch2hex } from './colormath.js';
+
+const DEFAULT_FG = '#f0fef0';
+const stateWith = (syntaxAssignments) => ({ covered: COVERED_FACES, syntaxAssignments, defaultFg: DEFAULT_FG });
+
+// --- fgSetFor ---------------------------------------------------------------
+
+test('fgSetFor: Normal — covered face gets default fg plus the distinct syntax colors', () => {
+ const r = fgSetFor('region', stateWith([
+ { role: 'keyword', hex: '#67809c' },
+ { role: 'string', hex: '#a3b18a' },
+ ]));
+ assert.equal(r.reason, undefined);
+ assert.equal(r.set.length, 3); // default + 2 syntax
+ const hexes = r.set.map(e => e.hex);
+ assert.ok(hexes.includes('#f0fef0') && hexes.includes('#67809c') && hexes.includes('#a3b18a'));
+ assert.equal(r.set.find(e => e.hex === '#67809c').label, 'keyword');
+ assert.equal(r.set.find(e => e.hex === '#f0fef0').label, 'default');
+});
+
+test('fgSetFor: Boundary — a syntax hex equal to the default collapses, role label wins', () => {
+ const r = fgSetFor('region', { covered: COVERED_FACES, syntaxAssignments: [{ role: 'keyword', hex: '#f0fef0' }], defaultFg: '#f0fef0' });
+ assert.equal(r.set.length, 1);
+ assert.equal(r.set[0].label, 'keyword'); // role preferred over 'default'
+});
+
+test('fgSetFor: Boundary — null/blank syntax hexes are dropped', () => {
+ const r = fgSetFor('isearch', stateWith([{ role: 'a', hex: null }, { role: 'b', hex: '#112233' }]));
+ assert.equal(r.set.length, 2); // default + the one real hex
+ assert.ok(r.set.some(e => e.hex === '#112233'));
+});
+
+test('fgSetFor: Error — a face outside the covered set is out-of-scope', () => {
+ const r = fgSetFor('mode-line', stateWith([{ role: 'keyword', hex: '#67809c' }]));
+ assert.deepEqual(r, { set: [], reason: 'out-of-scope' });
+});
+
+test('fgSetFor: Error — a covered face with no syntax assignments is empty', () => {
+ const r = fgSetFor('hl-line', stateWith([]));
+ assert.deepEqual(r, { set: [], reason: 'empty' });
+});
+
+test('fgSetFor: Normal — every covered face is in scope', () => {
+ for (const f of COVERED_FACES) {
+ const r = fgSetFor(f, stateWith([{ role: 'kw', hex: '#67809c' }]));
+ assert.equal(r.reason, undefined, `${f} should be covered`);
+ }
+});
+
+// --- floor ------------------------------------------------------------------
+
+test('floor: Normal — the keyword-blue worst case sets the floor and is named', () => {
+ // sterling's keyword blue is the darkest foreground; against a lifted highlight
+ // background it is the limiting color while the light default still clears.
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const bg = '#202830';
+ const r = floor(bg, fgSet);
+ assert.equal(r.limitingHex, '#67809c');
+ assert.equal(r.limitingLabel, 'keyword');
+ assert.ok(Math.abs(r.ratio - contrast('#67809c', bg)) < 1e-9);
+ assert.ok(r.ratio < contrast('#f0fef0', bg), 'the floor is below the default-fg contrast');
+});
+
+test('floor: Boundary — a single-entry set makes that entry the limit', () => {
+ const r = floor('#000000', [{ hex: '#67809c', label: 'keyword' }]);
+ assert.equal(r.limitingHex, '#67809c');
+ assert.ok(Math.abs(r.ratio - contrast('#67809c', '#000000')) < 1e-9);
+});
+
+test('floor: Error — an empty set returns nulls, not a bogus ratio', () => {
+ assert.deepEqual(floor('#000000', []), { ratio: null, limitingHex: null, limitingLabel: null });
+});
+
+// --- lMax -------------------------------------------------------------------
+
+const F = (L, chroma, hue, fgSet) => floor(oklch2hex(L, chroma, hue).hex, fgSet).ratio;
+
+test('lMax: Normal — finds the lightest safe background; the floor brackets the target', () => {
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const r = lMax(0, 0, fgSet, 4.5);
+ assert.equal(r.status, 'ok');
+ assert.ok(r.L > 0 && r.L < 1);
+ assert.ok(F(r.L, 0, 0, fgSet) >= 4.5 - 0.05, 'floor at L_max clears the target');
+ assert.ok(F(Math.min(1, r.L + 0.05), 0, 0, fgSet) < 4.5, 'just above L_max the floor fails');
+});
+
+test('lMax: Boundary — no L satisfies the target when a foreground is too dark', () => {
+ const r = lMax(0, 0, [{ hex: '#1a1a1a', label: 'dim' }], 4.5);
+ assert.equal(r.status, 'none'); // even pure-black background can't lift #1a1a1a to AA
+});
+
+test('lMax: Boundary — an empty foreground set is vacuously safe everywhere', () => {
+ const r = lMax(0, 0, [], 4.5);
+ assert.deepEqual(r, { L: 1, status: 'all' });
+});
+
+test('lMax: Boundary — requesting an unreachable chroma at the ceiling reports clamp', () => {
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const r = lMax(250, 0.3, fgSet, 4.5); // 0.3 chroma is out of gamut at the dark ceiling
+ assert.equal(r.status, 'clamp');
+ assert.ok(r.L > 0 && r.L < 1);
+});
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs
new file mode 100644
index 00000000..c6602aeb
--- /dev/null
+++ b/scripts/theme-studio/test-families.mjs
@@ -0,0 +1,213 @@
+// Unit tests for the color-families model (app-core.js): grouping a flat palette
+// into hue families, regenerating a family's ramp, ranking members by lightness,
+// and planning the assignment re-point across a regenerate. Phase 1 of the
+// color-families spec. Pure, no DOM. Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies } from './app-core.js';
+import { oklch2hex, srgb2oklab, oklab2oklch } from './colormath.js';
+
+// Build a palette entry at a controlled OKLCH hue so clustering is deterministic.
+const at = (L, C, H, name) => [oklch2hex(L, C, H).hex, name || ('c' + H)];
+
+// --- familiesFromPalette ----------------------------------------------------
+
+test('familiesFromPalette: Normal — separated hues split into one family each', () => {
+ const pal = [at(0.6, 0.1, 30, 'red'), at(0.6, 0.1, 150, 'green'), at(0.6, 0.1, 270, 'blue')];
+ const { ground, families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 3, 'three separated hues -> three families');
+ assert.equal(ground.length, 2, 'ground strip carries bg and fg');
+ for (const f of families) assert.equal(f.members.length, 1);
+});
+
+test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => {
+ const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 1, 'a near hue-pair is one family');
+ assert.equal(families[0].members.length, 2);
+});
+
+test('familiesFromPalette: Boundary — well-separated hues split', () => {
+ const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 2);
+});
+
+test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => {
+ // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing
+ const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(families.length, 2, 'not one chained family');
+});
+
+test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => {
+ const paleBlue = oklch2hex(0.9, 0.03, 255).hex; // light, faint -> still blue
+ const midGray = oklch2hex(0.6, 0.025, 100).hex; // mid, faint -> reads neutral
+ const { families } = familiesFromPalette([[paleBlue, 'paleblue'], [midGray, 'graytone']], { bg: '#000000', fg: '#ffffff' });
+ const neutral = families.find(f => f.neutral);
+ assert.ok(neutral && neutral.members.some(m => m.name === 'graytone'), 'mid faint color is neutral');
+ assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'paleblue')), 'pale tint stays chromatic');
+});
+
+test('familiesFromPalette: Boundary — near-neutral colors form a separate family', () => {
+ const pal = [at(0.6, 0.1, 250, 'blue'), at(0.5, 0.004, 250, 'gray')]; // gray below the chroma threshold
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ const neutral = families.find(f => f.neutral);
+ assert.ok(neutral, 'a neutral family exists');
+ assert.ok(neutral.members.some(m => m.name === 'gray'));
+ assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue')));
+});
+
+// --- real-palette grouping (the hard cases the color-sorting reviews measured) ---
+
+// The contested region of the distinguished/sterling palette: the gold ramp and
+// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart.
+const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']];
+const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']];
+const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name));
+
+test('familiesFromPalette: Normal — the gold and olive ramps separate', () => {
+ const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' });
+ const gold = famOf(families, 'yellow'), olive = famOf(families, 'green');
+ assert.notEqual(gold, olive, 'gold and olive are different families');
+ assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens');
+ assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows');
+});
+
+test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => {
+ // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together
+ const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ const blue = famOf(families, 'blue');
+ assert.equal(blue.members.length, 3, 'all three blues in one family');
+});
+
+test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => {
+ const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch
+ const neutral = families.find(f => f.neutral);
+ for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral');
+ assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic');
+});
+
+test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => {
+ // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart
+ const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family');
+});
+
+test('familiesFromPalette: Boundary — grouping is independent of palette order', () => {
+ const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']];
+ const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families
+ .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | ');
+ const ref = key(base);
+ for (const seed of [1, 2, 3]) { // a few deterministic shuffles
+ const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]);
+ assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping');
+ }
+});
+
+test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => {
+ const pal = [at(0.6, 0.1, 250, 'blue')];
+ const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ assert.equal(ground.length, 2);
+ assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a' && g.role === 'bg'));
+ assert.ok(ground.some(g => g.role === 'fg'));
+});
+
+test('familiesFromPalette: Boundary — a chip at a ground hex is not duplicated into a family', () => {
+ const pal = [['#0d0b0a', 'ground'], at(0.6, 0.1, 250, 'blue')];
+ const { ground, families } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a'));
+ assert.ok(!families.some(f => f.members.some(m => m.hex.toLowerCase() === '#0d0b0a')), 'ground chip stays out of families');
+});
+
+// --- regenFamily ------------------------------------------------------------
+
+test('regenFamily: Normal — n steps each side plus the base, ordered by offset', () => {
+ const r = regenFamily('#67809c', 2);
+ assert.equal(r.members.length, 5);
+ assert.deepEqual(r.members.map(m => m.offset), [-2, -1, 0, 1, 2]);
+ assert.equal(r.members.find(m => m.offset === 0).hex, '#67809c');
+});
+
+test('regenFamily: Boundary — n=0 is the base alone, no ramp() clamp to 1', () => {
+ const r = regenFamily('#67809c', 0);
+ assert.deepEqual(r.members, [{ hex: '#67809c', offset: 0, clamped: false }]);
+});
+
+test('regenFamily: Error — a malformed base returns a structured bad-hex', () => {
+ assert.deepEqual(regenFamily('nope', 2), { members: [], error: 'bad-hex' });
+});
+
+// --- rankByLightness --------------------------------------------------------
+
+test('rankByLightness: Normal — offsets are signed distance from the base by lightness', () => {
+ const members = regenFamily('#67809c', 2).members.map(m => m.hex);
+ const ranked = rankByLightness(members, '#67809c');
+ const base = ranked.find(m => m.hex === '#67809c');
+ assert.equal(base.offset, 0);
+ const sorted = [...ranked].sort((a, b) => a.offset - b.offset);
+ assert.deepEqual(sorted.map(m => m.offset), [-2, -1, 0, 1, 2]);
+});
+
+test('rankByLightness: Boundary — a base not among the members ranks by nearest lightness', () => {
+ const members = ['#222222', '#888888', '#dddddd'];
+ const ranked = rankByLightness(members, '#8a8a8a'); // near the mid member
+ const mid = ranked.find(m => m.hex === '#888888');
+ assert.equal(mid.offset, 0, 'nearest-lightness member is the base rank');
+});
+
+// --- stepRepointPlan --------------------------------------------------------
+
+test('stepRepointPlan: Normal — surviving offsets map old->new, changed hex only', () => {
+ const oldR = [{ hex: '#111111', offset: -1 }, { hex: '#222222', offset: 0 }, { hex: '#333333', offset: 1 }];
+ const neu = [{ hex: '#111111', offset: -1 }, { hex: '#aaaaaa', offset: 0 }, { hex: '#444444', offset: 1 }];
+ const { map, removed } = stepRepointPlan(oldR, neu);
+ assert.deepEqual(removed, []);
+ assert.deepEqual(map, [['#222222', '#aaaaaa'], ['#333333', '#444444']]); // -1 unchanged, skipped
+});
+
+test('stepRepointPlan: Boundary — an offset with no new counterpart is removed, not repointed', () => {
+ const oldR = [{ hex: '#000033', offset: -3 }, { hex: '#222222', offset: 0 }];
+ const neu = [{ hex: '#222222', offset: 0 }]; // count dropped, -3 gone
+ const { map, removed } = stepRepointPlan(oldR, neu);
+ assert.deepEqual(map, []);
+ assert.deepEqual(removed, ['#000033']);
+});
+
+// --- sortFamilies -----------------------------------------------------------
+
+const fam = (baseHex, neutral, members) => ({ base: baseHex, neutral: !!neutral, members: (members || [baseHex]).map(h => ({ hex: h, name: h })) });
+
+test('sortFamilies: Normal — chromatic families order by base hue', () => {
+ const fams = [fam(oklch2hex(0.6, 0.1, 270).hex), fam(oklch2hex(0.6, 0.1, 30).hex), fam(oklch2hex(0.6, 0.1, 150).hex)];
+ const sorted = sortFamilies(fams);
+ const hues = sorted.map(f => Math.round(oklab2oklch(srgb2oklab(f.base)).H));
+ for (let i = 1; i < hues.length; i++) assert.ok(hues[i] > hues[i - 1], 'ascending hue: ' + hues.join(','));
+});
+
+test('sortFamilies: Boundary — neutral families pin ahead of chromatic ones', () => {
+ const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false), fam('#808080', true)]);
+ assert.equal(sorted[0].neutral, true, 'neutral first');
+ assert.equal(sorted[1].neutral, false);
+});
+
+test('sortFamilies: Normal — members within a family sort dark to light', () => {
+ const members = ['#dddddd', '#222222', '#888888'];
+ const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false, members)]);
+ const ls = sorted[0].members.map(m => oklab2oklch(srgb2oklab(m.hex)).L);
+ for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'ascending lightness');
+});
+
+test('sortFamilies: Boundary — order is (hue, then lightness); a hue tie falls to lightness', () => {
+ const bases = [oklch2hex(0.6, 0.1, 200).hex, oklch2hex(0.5, 0.1, 200).hex, oklch2hex(0.6, 0.1, 40).hex];
+ const sorted = sortFamilies(bases.map(b => fam(b, false)));
+ const key = h => { const c = oklab2oklch(srgb2oklab(h)); return [Math.round(c.H), c.L]; };
+ for (let i = 1; i < sorted.length; i++) {
+ const [h0, l0] = key(sorted[i - 1].base), [h1, l1] = key(sorted[i].base);
+ assert.ok(h0 < h1 || (h0 === h1 && l0 <= l1), `order at ${i}: hue ${h0}/${h1} L ${l0.toFixed(3)}/${l1.toFixed(3)}`);
+ }
+});
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..33358704 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -25,13 +25,16 @@
.cat{color:#b4b1a2} .ex{font-size:17px}
.sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0}
.sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496}
- .pals{display:flex;gap:8px;flex-wrap:wrap}
+ .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start}
+ .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent}
+ .fstrip.ground{border-color:#252321;background:#161412}
+ .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center}
+ .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center}
.palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d}
.palwarn .pwh{font-weight:bold;margin-bottom:2px}
.palwarn .pwl{opacity:.92}
.pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab}
- .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none}
- .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0}
+ .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none}
.pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7}
.pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6}
.palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
@@ -57,13 +60,14 @@
.oklchctl .ocrow input[type=number]{width:62px;background:#252321;color:#cdced1;border:1px solid #3a3a3a;border-radius:3px;font:10pt monospace;padding:1px 3px}
.oklchctl .pclamp{display:none;color:#cb6b4d;margin-top:3px}
.oklchctl .pclamp.show{display:block}
- .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none}
+ .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none;z-index:3}
.hue{position:relative;width:34px;height:320px;border-radius:4px;cursor:ns-resize;background:linear-gradient(to bottom,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
.huecur{position:absolute;left:-2px;right:-2px;height:4px;background:#fff;border:1px solid #0008;transform:translateY(-50%);pointer-events:none}
.pinfo{display:flex;justify-content:space-between;margin:10px 2px 4px;font:12pt monospace;color:#cdced1}
.pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad}
.pinfo2 span{cursor:default}
.pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer}
+ .svsafe{position:absolute;left:0;width:100%;background:rgba(203,107,77,0.30);border-bottom:2px solid #cb6b4d;pointer-events:none;z-index:2}
.palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer}
#palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
@@ -103,10 +107,11 @@
<span id="palmsg"></span>
<div id="picker" class="picker">
<div class="prow">
- <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div>
+ <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div>
<div id="hue" class="hue"><div id="huecur" class="huecur"></div></div>
</div>
<div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div>
+ <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div>
<div class="oklchctl" id="oklchctl">
<div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div>
<div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div>
@@ -386,6 +391,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 +419,223 @@ 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};
+}
+
+// --- 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.
+
+// The closed v1 set of code-overlay faces whose worst-case floor we compute.
+// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added
+// explicitly rather than by a heuristic. Shared by app.js and the tests.
+const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch'];
+
+// A covered face's foreground set: the distinct syntax-token colors plus the
+// default foreground, each labeled (syntax role preferred, else 'default').
+// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns
+// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the
+// face isn't in the covered set) or 'empty' (no syntax assignments constrain it).
+function fgSetFor(face,state){
+ const covered=(state&&state.covered)||COVERED_FACES;
+ if(!covered.includes(face))return {set:[],reason:'out-of-scope'};
+ const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex);
+ if(!syn.length)return {set:[],reason:'empty'};
+ const byHex=new Map();
+ const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;};
+ if(state&&state.defaultFg)add(state.defaultFg,'default',false);
+ for(const a of syn)add(a.hex,a.role||a.hex,true);
+ return {set:[...byHex.values()]};
+}
+
+// Worst-case (minimum) WCAG contrast of a background against a foreground set,
+// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty
+// set returns nulls so the caller can show the no-set readout instead of a floor.
+function floor(bgHex,fgSet){
+ if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null};
+ let best=Infinity,lh=null,ll=null;
+ for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}}
+ return {ratio:best,limitingHex:lh,limitingLabel:ll};
+}
+
+// The lightest background at (hue, chroma) whose worst-case floor over fgSet still
+// clears target (a WCAG ratio). Scans L up from black to bracket the first
+// dark-side crossing, then binary-searches it to tol 0.001. status:
+// 'ok' - a ceiling L was found
+// 'none' - even pure black fails (a foreground is too dark for the target)
+// 'all' - no foreground set to constrain (vacuously safe everywhere)
+// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there)
+function lMax(hue,chroma,fgSet,target){
+ if(!fgSet||!fgSet.length)return {L:1,status:'all'};
+ const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};};
+ if(at(0).r<target)return {L:null,status:'none'};
+ let loL=0,hiL=null;
+ for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;}
+ if(hiL===null)return {L:1,status:'all'};
+ for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;}
+ return {L:loL,status:at(loL).clamped?'clamp':'ok'};
+}
+
+// --- color families (color-families spec, Phase 1) ---------------------------
+// Families are a display grouping derived from the hex every render — never from
+// names — so renaming a color can't move it. The flat palette stays the editable
+// truth; these pure functions group it, regenerate a family's ramp, and plan the
+// assignment re-point across a regenerate.
+
+function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
+function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
+function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);}
+
+// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
+// insight): the mid-tones need more chroma to read as a hue. Floored at both ends
+// rather than tapering to zero, so pale warm grays stay neutral (and pure white,
+// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay
+// colored. Tuned on real palettes (Codex + Fable color-sorting reviews).
+function neutralThreshold(L){
+ if(L<=0.2)return 0.020;
+ if(L<0.6)return 0.020+0.015*(L-0.2)/0.4;
+ if(L<0.85)return 0.035-0.017*(L-0.6)/0.25;
+ return 0.018;
+}
+// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL):
+// hue must match tightly at equal lightness and may drift across a lightness gap,
+// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise
+// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma
+// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is
+// compatible. Source: ~/color-sorting-fable.org.
+function pairRatio(a,b){
+ const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H);
+ const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI);
+ return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL));
+}
+// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two
+// clusters whose worst cross-pair is most compatible, stopping when no merge has
+// every cross-pair compatible. Complete linkage makes single-linkage chaining
+// structurally impossible — two ramps can't fuse through their converging pale
+// ends because their mid-lightness members stay far apart.
+function clusterChromatic(ms){
+ let cl=ms.map(m=>[m]);
+ const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b))));
+ for(;;){
+ let best=null;
+ for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};}
+ if(!best||best.d>1)break;
+ cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1);
+ }
+ return cl;
+}
+// A family from its members: base is the most-saturated member (tie toward
+// mid-lightness), the anchor for a generated ramp.
+function makeFamily(ms,neutral){
+ let base=ms[0];
+ for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m;
+ return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))};
+}
+// Group a flat palette into the ground strip plus families. ground is {bg,fg}:
+// those two hexes form the pinned ground strip even when absent from the palette,
+// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
+// (chroma below the lightness-scaled threshold) form one neutral family; the rest
+// cluster by lightness-conditioned complete linkage (clusterChromatic).
+function familiesFromPalette(palette,ground){
+ const bg=ground&&ground.bg,fg=ground&&ground.fg;
+ const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
+ const groundStrip=[];
+ if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
+ if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
+ const neutrals=[],chromatic=[];
+ for(const [hex,name] of palette){
+ if(gset.has(hex.toLowerCase()))continue;
+ const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
+ (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m);
+ }
+ const families=[];
+ if(neutrals.length)families.push(makeFamily(neutrals,true));
+ for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false));
+ return {ground:groundStrip,families};
+}
+// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
+// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted
+// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}.
+function regenFamily(baseHex,n,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {members:[],error:'bad-hex'};
+ const k=Math.min(4,Math.max(0,Math.round(n||0)));
+ if(k===0)return {members:[{hex,offset:0,clamped:false}]};
+ const r=ramp(hex,Object.assign({},opts,{n:k}));
+ if(r.error)return {members:[],error:r.error};
+ const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ return {members};
+}
+// Rank a family's current member hexes by lightness and give each a signed offset
+// from the base (the matching hex, or the nearest by lightness if the base isn't
+// present). Lets a regenerate match old positions to new ramp offsets.
+function rankByLightness(memberHexes,baseHex){
+ const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L);
+ let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase());
+ if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});}
+ return items.map((m,i)=>({hex:m.hex,offset:i-bi}));
+}
+// Plan the assignment re-point for a regenerate: for each old ranked member, the
+// new member at the same offset is the same position. {map:[[old,new]]} for
+// positions whose hex changed; {removed:[hex]} for positions with no new
+// counterpart (the caller leaves their references a visible "(gone)").
+function stepRepointPlan(oldRanked,newMembers){
+ const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[];
+ for(const o of oldRanked){
+ const nh=byOff.get(o.offset);
+ if(nh===undefined)removed.push(o.hex);
+ else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]);
+ }
+ return {map,removed};
+}
+
+// Order a family's members dark to light by OKLCH lightness.
+function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});}
+// Order families for display: neutrals first (by base lightness), then chromatic
+// by base hue, ties broken by base lightness then base hex. Each family's members
+// are lightness-sorted. Display-only — the stored palette order is untouched.
+function sortFamilies(families){
+ const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};});
+ keyed.sort((a,b)=>{
+ if(a.neutral!==b.neutral)return a.neutral?-1:1;
+ if(a.neutral&&b.neutral)return a.L-b.L;
+ const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness
+ if(ah!==bh)return ah-bh;
+ if(a.L!==b.L)return a.L-b.L;
+ return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0;
+ });
+ return keyed.map(k=>sortFamilyMembers(k.f));
+}
// 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
@@ -531,7 +757,7 @@ function buildTable(){
const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt';
function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';}
function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);}
- const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}});
+ const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();});
styleEx();styleCr();
const lkTd=mkLockCell(kind,[dd]);
// style buttons
@@ -548,7 +774,23 @@ function buildTable(){
tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd);
tb.appendChild(tr);}
}
-let dragFrom=null,selectedIdx=null;
+let selectedIdx=null;
+// When a named palette color is deleted, remember its hex keyed by name so that
+// recreating a color with the same name can re-bind the assignments still pointing
+// at the old (now "(gone)") hex. Consumed once per name; cleared on import.
+let lastGone={};
+// Re-point every assignment — syntax map, UI faces, package faces — from one hex
+// to another. Used when a palette color's value is edited and when a deleted name
+// is recreated.
+function repointHex(oldHex,newHex){
+ if(oldHex===newHex)return;
+ for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;}
+ for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;}
+ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
+}
+// On adding a color, if its name matches a recently-deleted one, re-bind the
+// stranded assignments to the new hex. Returns true when a heal context existed.
+function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;}
// Pairwise OKLab ΔE over the palette. Returns the sub-threshold pairs (sorted
// closest-first) and each color's nearest-neighbor distance for its chip title.
// Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it.
@@ -560,35 +802,90 @@ function renderPaletteWarnings(warnings,overflow){
if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`;
w.innerHTML=html;w.style.display='block';
}
+// One palette chip for PALETTE[i], with its remove / rename / select handlers.
+// Families sort deterministically, so the old move-arrow / drag reordering is gone.
+function paletteChip(i,nearest){
+ const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i];
+ const locked=(hex===MAP['bg']||hex===MAP['p']);
+ const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;
+ d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3));
+ const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
+ d.innerHTML=`${rm}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`;
+ if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();};
+ d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();};
+ d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);};
+ return d;
+}
+// Render the palette as hue families: the pinned ground strip, then hue-sorted
+// family strips, each dark to light. Grouping is derived from the hex by
+// familiesFromPalette every render, so renaming a color never moves it. The flat
+// PALETTE stays the editable truth; chips keep their per-chip controls.
function renderPalette(){
const p=document.getElementById('pals');p.innerHTML='';
const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5);
- PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex);
- const nde=nearest[i];
- const locked=(hex===MAP['bg']||hex===MAP['p']);
- const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true;
- d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3));
- const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">&#8249;</button>`:'';
- const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">&#8250;</button>`:'';
- const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
- d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`;
- if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();};
- if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);};
- if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);};
- d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();};
- d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);};
- d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');};
- d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));};
- d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');};
- d.ondragleave=()=>d.classList.remove('over');
- d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();};
- p.appendChild(d);});
+ const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const used=new Set();
+ const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;};
+ const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;};
+ const gs=strip(' ground');gs.dataset.family='ground';
+ ground.forEach(g=>{
+ const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex);
+ if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));}
+ else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex;
+ sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);}
+ });
+ // The too-similar warning stays on the full flat palette: a generated ramp's
+ // steps are a stepL apart (well above the warning's ΔE threshold), so they never
+ // trigger it, and any pair that does is a genuine near-duplicate worth flagging.
+ sortFamilies(families).forEach(f=>{
+ const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base;
+ f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));});
+ if(!f.neutral)s.appendChild(familyCountControl(f));
+ });
renderPaletteWarnings(warnings,overflow);
buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
}
+// The per-family count control under a chromatic strip. Its value is the family's
+// current per-side reach; setting N regenerates the family as base ±N.
+function familyCountControl(f){
+ const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset)));
+ const d=document.createElement('div');d.className='fcount';
+ d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">&#177; <input type="number" min="0" max="4" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0)));
+ return d;
+}
+// Regenerate a family as a symmetric base ±N ramp, replacing its current members.
+// References to a surviving position (matched by signed lightness rank) follow the
+// new hex; references to a position removed by lowering N leave their old hex,
+// which is no longer in the palette and so renders as "(gone)".
+// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving
+// references and leaving removed ones on their now-gone hex. Returns the removed
+// count, or null on a bad base. Shared by the count control and the base edit.
+function regenFamilyInPlace(oldHexes,baseHex,baseName,n){
+ const r=regenFamily(baseHex,n,{});
+ if(r.error){notify('cannot regenerate from '+baseHex,true);return null;}
+ const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members);
+ const oldSet=new Set(oldHexes.map(h=>h.toLowerCase()));
+ let at=PALETTE.length;
+ for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;}
+ for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1);
+ const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]);
+ PALETTE.splice(Math.min(at,PALETTE.length),0,...entries);
+ for(const [o,nw] of plan.map)repointHex(o,nw);
+ return plan.removed.length;
+}
+function setFamilyCount(baseHex,n){
+ const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase());
+ if(!fam)return;
+ const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color';
+ const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n);
+ if(removed===null)return;
+ selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();
+ notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false);
+}
function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);}
function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();}
-function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();}
function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);}
function updateColor(){
if(selectedIdx===null){notify('click a palette color to select it first',true);return;}
@@ -596,10 +893,17 @@ function updateColor(){
const newHex=curHex();
const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1];
if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;}
+ // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count.
+ const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families;
+ const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase());
+ const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0;
PALETTE[i]=[newHex,newName];
- for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;}
- for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;}
- for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;}
+ repointHex(oldHex,newHex);
+ if(fam&&count>0){
+ const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
+ regenFamilyInPlace(oldHexes,newHex,newName,count);
+ closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return;
+ }
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';}
@@ -627,12 +931,30 @@ function paintOklchPlane(H){
if(T&&contrast(cell.hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
_planeCache={key,data:ctx.getImageData(0,0,w,h)};
}
+// --- safe-lightness guidance (spec Phase 5) ----------------------------------
+let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '')
+function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();}
+// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace
+// readable over its foreground set, with the L_max ceiling as the band's lower
+// edge. One marker computed via lMax at the current chroma, not a per-pixel mask.
+function paintSafeBand(C,H){
+ const el=document.getElementById('svsafe');if(!el)return;
+ if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;}
+ const fs=fgSetForFace(pkSafeFace);
+ if(fs.reason||!fs.set.length){el.style.display='none';return;}
+ const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET);
+ if(res.status==='all'){el.style.display='none';return;}
+ el.style.display='block';el.style.top='0px';
+ el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px';
+ el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')';
+}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;
const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;
if(pkModel==='oklch'){const [L,C,H]=readOklch();sv.style.background='#15120f';paintOklchPlane(H);
document.getElementById('svcur').style.left=(Math.min(1,C/OKLCH_CMAX)*w)+'px';
document.getElementById('svcur').style.top=((1-L)*h)+'px';
- document.getElementById('huecur').style.top=((H/360)*hh)+'px';return;}
+ document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;}
+ const sb=document.getElementById('svsafe');if(sb)sb.style.display='none';
sv.style.background=`linear-gradient(to top,#000,rgba(0,0,0,0)),linear-gradient(to right,#fff,rgba(255,255,255,0)),hsl(${pkH},100%,50%)`;
document.getElementById('svcur').style.left=(pkS*w)+'px';document.getElementById('svcur').style.top=((1-pkV)*h)+'px';document.getElementById('huecur').style.top=((pkH/360)*hh)+'px';drawMask();}
function pkReadout(h){const e=document.getElementById('pkhex');if(e)e.textContent=h;const c=document.getElementById('pkcon');if(c){const r=contrast(h,MAP['bg']);c.textContent=r.toFixed(1)+' '+rating(r);c.style.color=ratingColor(r);}
@@ -663,6 +985,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl
function pkOutside(e){if(!e.target.closest('#picker')&&!e.target.closest('#swatch'))closePicker();}
function pkDrag(el,fn){el.addEventListener('pointerdown',e=>{e.preventDefault();fn(e);const mv=ev=>fn(ev),up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);};document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);});}
function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;sw.style.background=curHex();sw.onclick=()=>pickerOn?closePicker():openPicker();
+ const sf=document.getElementById('safefor');if(sf&&sf.options.length<=1)COVERED_FACES.forEach(f=>{const o=document.createElement('option');o.value=f;o.textContent=f;sf.appendChild(o);});
pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();const fx=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
if(pkModel==='oklch'){setOklchInputs(1-fy,fx*OKLCH_CMAX,readOklch()[2]);pkOklchSet();}else{pkS=fx;pkV=1-fy;pkSet();}});
pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();const fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
@@ -676,7 +999,10 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s
function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim();
if(!name){notify('name the color before adding it',true);return;}
if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;}
- PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);}
+ PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker();
+ renderPalette();buildTable();buildUITable();
+ if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();}
+ notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);}
function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';}
function fileSlug(){return slugify(themeName());}
function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;}
@@ -690,7 +1016,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1);
try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]});
const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle();
}catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}}
-function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments);
+function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments);
BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true);
LOCKED=new Set(d.locks||[]);
if(d.ui)Object.assign(UIMAP,d.ui);
@@ -1146,8 +1472,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8
function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';}
function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();}
function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
+// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
+let WORST_TARGET=4.5;
+// The live v1 foreground set for a covered overlay face: the syntax-token colors
+// (every assignable category except the ground) plus the default foreground.
+function fgSetForFace(face){
+ const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])}));
+ return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']});
+}
+// The worst-case contrast cell for a covered face: the floor over its foreground
+// set against its effective background, naming the limiting foreground. Returns
+// null for an out-of-scope face so the caller keeps the single-pair readout.
+function worstCellHtml(face){
+ const r=fgSetForFace(face);
+ if(r.reason==='out-of-scope')return null;
+ if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>';
+ const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL';
+ const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict;
+ return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`;
+}
+// Repaint every covered overlay face (their floors depend on the syntax palette,
+// so a syntax-color edit has to refresh them even though it doesn't rebuild the table).
+function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});}
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box);
- const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}
+ const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -1324,4 +1673,145 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById('
const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2;
const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);}
+// Worst-case readout gate (open with #contrasttest): a covered overlay face shows
+// the floor over its foreground set and names the limiting foreground, an
+// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set".
+if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000';
+ UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false};
+ buildUITable();
+ const cell=document.getElementById('uicr-region');
+ A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent));
+ A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent));
+ A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict');
+ const fl=floor('#202830',fgSetForFace('region').set);
+ A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex);
+ A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg');
+ const ml=document.getElementById('uicr-mode-line');
+ A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)');
+ A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent));
+ MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable();
+ const empty=document.getElementById('uicr-region');
+ A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent));
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
+// lightness band for a selected covered face and hides it when no face is selected.
+if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP);
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['bg']='#000000';
+ document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
+ setSafeFace('region');
+ const band=document.getElementById('svsafe');
+ A(band&&band.style.display==='block','safe band shows for an in-scope face');
+ A(band&&parseFloat(band.style.height)>0,'safe band has a positive height: '+(band&&band.style.height));
+ setSafeFace('');
+ A(band&&band.style.display==='none','safe band hidden when no face is selected');
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);
+ setPkModel('hsv');closePicker();
+ document.title='SAFETEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Gone-rebind gate (open with #healtest): deleting a named color then recreating
+// the name re-points the assignments stranded on the old hex to the new color.
+if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx;
+ PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable();
+ const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue');
+ A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button');
+ if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click();
+ A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted');
+ A(lastGone['blue']==='#67809c','delete recorded the gone name->hex');
+ document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor();
+ A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']);
+ A(!('blue' in lastGone),'heal consumed the gone entry');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel;
+ renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
+ document.title='HEALTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Family-strip gate (open with #familytest): the palette renders as the pinned
+// ground strip plus hue families, chips keep their controls, and renaming a color
+// to anything leaves it in the same strip (grouping is by hex, not name).
+if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx;
+ MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette();
+ const strips=[...document.querySelectorAll('#pals .fstrip')];
+ A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first');
+ A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg');
+ A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length);
+ const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red');
+ A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls');
+ const redFamily=redChip&&redChip.closest('.fstrip').dataset.family;
+ const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette();
+ const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd');
+ A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette();
+ document.title='FAMILYTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Count-control gate (open with #counttest): the per-family count regenerates the
+// family — count up adds symmetric steps, count down drops the extremes, a
+// reference to a surviving step follows the new hex, a reference to a removed step
+// is left on its old (now-gone) hex.
+if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
+ MAP['bg']='#000000';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
+ regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change
+ const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down
+ UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false};
+ UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false};
+ selectedIdx=null;renderPalette();
+ setFamilyCount('#67809c',1);
+ const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down');
+ A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex');
+ const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex;
+ A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg);
+ setFamilyCount('#67809c',3);
+ const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase());
+ const have=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette');
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
+ document.title='COUNTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Base-edit + ground-edit gate (open with #baseedittest): editing a family base
+// recolors the whole family at the same count and references follow; editing a
+// ground swatch writes the bg/fg assignment.
+if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx;
+ MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';
+ PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']];
+ regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false};
+ renderPalette();buildUITable();
+ selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c');
+ document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal';
+ updateColor();
+ const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral);
+ A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex');
+ A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length));
+ A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette');
+ A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex');
+ // ground edit: select bg, change hex, MAP.bg follows
+ selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a');
+ document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground';
+ updateColor();
+ A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']);
+ PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
+ document.title='BASEEDITTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Round-trip gate (open with #roundtriptest): export stays a flat palette and
+// import needs no family reconstruction, so export → import → export is identical.
+if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const before=JSON.stringify(exportObj());
+ applyImported(before);
+ const after=JSON.stringify(exportObj());
+ A(before===after,'export → import → export is byte-identical');
+ const obj=JSON.parse(after);
+ A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list');
+ document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
</script> \ No newline at end of file