aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-08 21:58:34 -0500
committerCraig Jennings <c@cjennings.net>2026-06-08 21:58:34 -0500
commit78269dae7dd445425e5ec0863b65ca768a4f76a3 (patch)
tree99fa2ab53cf5cdac2515e71d662926b3bfd7cc05
parent0f7088233bbfac2eb6b19d6ddcdc05c66f22e026 (diff)
downloaddotemacs-78269dae7dd445425e5ec0863b65ca768a4f76a3.tar.gz
dotemacs-78269dae7dd445425e5ec0863b65ca768a4f76a3.zip
refactor(theme-studio): extract plane and palette-ΔE logic into the tested core
The picker's two heaviest pieces of pure logic lived as strings inside generate.py, reachable only through the single-scenario browser hash tests. I moved them into colormath.js, where they get the same direct Node testing the color math has: planeCell(L,C,H) returns a C×L plane cell's color or flags it out of gamut, and paletteWarnings(palette, threshold, cap) does the pairwise ΔE analysis and returns the too-close pairs, the overflow count, and each color's nearest neighbor. The page now calls both. The inline copies are gone. The new Node tests cover what the hash tests never could: empty, single, and identical-color palettes; the strict threshold boundary; the cap and overflow count; closest-first ordering; the C=0 achromatic case; and a plane cell pinned to oklch2hex's clamped flag so the plane and the commit path agree on the gamut edge. The refactor preserves behavior: the page renders identically, guarded by the existing #deltatest and #planetest characterization gates.
-rw-r--r--scripts/theme-studio/colormath.js25
-rw-r--r--scripts/theme-studio/generate.py30
-rw-r--r--scripts/theme-studio/test-colormath.mjs72
-rw-r--r--scripts/theme-studio/theme-studio.html53
4 files changed, 140 insertions, 40 deletions
diff --git a/scripts/theme-studio/colormath.js b/scripts/theme-studio/colormath.js
index 70b8f5e4..167a5ea1 100644
--- a/scripts/theme-studio/colormath.js
+++ b/scripts/theme-studio/colormath.js
@@ -167,4 +167,27 @@ function rgb2hex(r, g, b) {
return '#' + [r, g, b].map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('');
}
-export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex };
+// One Chroma×Lightness plane cell at a fixed hue: the sRGB color if the (L,C,H)
+// is reachable, else flagged out of gamut. Forward-only (one conversion + a
+// range check) — the binary-search clamp is reserved for committing a color.
+function planeCell(L, C, H) {
+ const lab = oklch2oklab(L, C, H), lrgb = oklab2lrgb(lab.L, lab.a, lab.b);
+ return inGamut(lrgb) ? { inGamut: true, hex: lrgb2hex(lrgb) } : { inGamut: false, hex: null };
+}
+
+// Pairwise palette analysis. palette is [[hex, name], ...]. Returns the pairs
+// closer than threshold (OKLab ΔE), closest-first and capped, the overflow count
+// beyond the cap, and each color's nearest-neighbor distance for its chip title.
+function paletteWarnings(palette, threshold = 0.02, cap = 5) {
+ const n = palette.length, nearest = new Array(n).fill(Infinity), pairs = [];
+ for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
+ const d = deltaE(palette[i][0], palette[j][0]);
+ if (d < nearest[i]) nearest[i] = d;
+ if (d < nearest[j]) nearest[j] = d;
+ if (d < threshold) pairs.push({ i, j, aName: palette[i][1], bName: palette[j][1], dE: d });
+ }
+ pairs.sort((a, b) => a.dE - b.dE);
+ return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest };
+}
+
+export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings };
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index b53b3f88..e2d72acd 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -599,26 +599,18 @@ function buildTable(){
let dragFrom=null,selectedIdx=null;
// 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.
-function paletteDeltas(){
- const n=PALETTE.length,nearest=new Array(n).fill(Infinity),pairs=[];
- for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){const d=deltaE(PALETTE[i][0],PALETTE[j][0]);
- if(d<nearest[i])nearest[i]=d;if(d<nearest[j])nearest[j]=d;
- if(d<DELTAE_MIN)pairs.push({i,j,d});}
- pairs.sort((a,b)=>a.d-b.d);
- return {pairs,nearest};
-}
-function renderPaletteWarnings(pairs){
+// Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it.
+function renderPaletteWarnings(warnings,overflow){
const w=document.getElementById('palwarn');if(!w)return;
- if(!pairs.length){w.style.display='none';w.innerHTML='';return;}
- const cap=5,shown=pairs.slice(0,cap);
+ if(!warnings.length){w.style.display='none';w.innerHTML='';return;}
let html='<div class="pwh">too-similar colors</div>';
- html+=shown.map(p=>`<div class="pwl">${esc(PALETTE[p.i][1]+' / '+PALETTE[p.j][1])} — \\u0394E ${p.d.toFixed(3)}, hard to distinguish</div>`).join('');
- if(pairs.length>cap)html+=`<div class="pwl">and ${pairs.length-cap} more</div>`;
+ html+=warnings.map(p=>`<div class="pwl">${esc(p.aName+' / '+p.bName)} — \\u0394E ${p.dE.toFixed(3)}, hard to distinguish</div>`).join('');
+ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`;
w.innerHTML=html;w.style.display='block';
}
function renderPalette(){
const p=document.getElementById('pals');p.innerHTML='';
- const {pairs,nearest}=paletteDeltas();
+ 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']);
@@ -639,7 +631,7 @@ function renderPalette(){
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);});
- renderPaletteWarnings(pairs);
+ renderPaletteWarnings(warnings,overflow);
buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
}
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);}
@@ -678,10 +670,10 @@ function paintOklchPlane(H){
if(_planeCache.key===key&&_planeCache.data){ctx.putImageData(_planeCache.data,0,0);return;}
const step=4;
for(let x=0;x<w;x+=step){const C=(x/w)*OKLCH_CMAX;
- for(let y=0;y<h;y+=step){const L=1-y/h,lab=oklch2oklab(L,C,H),lrgb=oklab2lrgb(lab.L,lab.a,lab.b);
- if(!inGamut(lrgb)){ctx.fillStyle='#15120f';ctx.fillRect(x,y,step,step);continue;}
- const hex=lrgb2hex(lrgb);ctx.fillStyle=hex;ctx.fillRect(x,y,step,step);
- if(T&&contrast(hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
+ for(let y=0;y<h;y+=step){const L=1-y/h,cell=planeCell(L,C,H);
+ if(!cell.inGamut){ctx.fillStyle='#15120f';ctx.fillRect(x,y,step,step);continue;}
+ ctx.fillStyle=cell.hex;ctx.fillRect(x,y,step,step);
+ 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)};
}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
index 2a58ad61..58ce7829 100644
--- a/scripts/theme-studio/test-colormath.mjs
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url';
import {
srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE,
hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex,
- oklab2lrgb, inGamut, lrgb2hex,
+ oklab2lrgb, inGamut, lrgb2hex, planeCell, paletteWarnings,
} from './colormath.js';
const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
@@ -160,6 +160,76 @@ test('inGamut flags reachable vs unreachable OKLCH (forward-only gamut test)', (
assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), !oklch2hex(0.7, 0.4, 140).clamped);
});
+test('planeCell: reachable cell returns its exact hex, agrees with oklch2hex', () => {
+ // Normal: a low-chroma blue is reachable; the hex matches the commit path.
+ const cell = planeCell(0.591, 0.052, 251.6);
+ assert.equal(cell.inGamut, true);
+ assert.equal(cell.hex, oklch2hex(0.591, 0.052, 251.6).hex);
+});
+
+test('planeCell: C=0 is the achromatic grey for its lightness', () => {
+ // Boundary: zero chroma -> a neutral grey, always in gamut, hue irrelevant.
+ const a = planeCell(0.5, 0, 0), b = planeCell(0.5, 0, 251.6);
+ assert.equal(a.inGamut, true);
+ assert.equal(a.hex, b.hex, 'hue must not matter at C=0');
+ assert.equal(a.hex[1], a.hex[3]); // r==g==b nibble: grey
+});
+
+test('planeCell: out-of-gamut chroma is flagged, no hex', () => {
+ // Error/boundary: chroma past sRGB at this L/H.
+ const cell = planeCell(0.7, 0.4, 140);
+ assert.equal(cell.inGamut, false);
+ assert.equal(cell.hex, null);
+ assert.equal(cell.inGamut, !oklch2hex(0.7, 0.4, 140).clamped); // shares the boundary
+});
+
+test('paletteWarnings: a near-identical pair warns, named, with its ΔE', () => {
+ const { warnings, overflow, nearest } = paletteWarnings(
+ [['#0d0b0a', 'ground'], ['#cdced1', 'fg'], ['#67809c', 'blue'], ['#69829e', 'blue2']]);
+ assert.equal(warnings.length, 1);
+ assert.equal(overflow, 0);
+ const w = warnings[0];
+ assert.deepEqual([w.aName, w.bName], ['blue', 'blue2']);
+ assert.ok(w.dE > 0 && w.dE < 0.02, `dE ${w.dE}`);
+ assert.equal(nearest.length, 4);
+ assert.ok(nearest[2] < 0.02 && nearest[3] < 0.02, 'blue/blue2 are each other’s nearest');
+});
+
+test('paletteWarnings: a well-spread palette warns about nothing', () => {
+ const { warnings, overflow } = paletteWarnings(
+ [['#0d0b0a', 'ground'], ['#cdced1', 'fg'], ['#67809c', 'blue'], ['#e8bd30', 'gold'], ['#cb6b4d', 'terra']]);
+ assert.equal(warnings.length, 0);
+ assert.equal(overflow, 0);
+});
+
+test('paletteWarnings: boundary cases — empty, single, identical', () => {
+ assert.deepEqual(paletteWarnings([]), { warnings: [], overflow: 0, nearest: [] });
+ const one = paletteWarnings([['#67809c', 'blue']]);
+ assert.deepEqual(one.warnings, []);
+ assert.deepEqual(one.nearest, [Infinity]); // no neighbor
+ const dup = paletteWarnings([['#67809c', 'a'], ['#67809c', 'b']]);
+ assert.equal(dup.warnings.length, 1);
+ assert.equal(dup.warnings[0].dE, 0); // identical colors -> ΔE 0
+});
+
+test('paletteWarnings: closest-first ordering and cap with overflow', () => {
+ // Seven near-identical colors -> C(7,2)=21 sub-threshold pairs.
+ const pal = [['#0d0b0a', 'ground'], ['#cdced1', 'fg']];
+ for (let k = 0; k < 7; k++) pal.push(['#' + (0x67 + k).toString(16).padStart(2, '0') + '809c', 'c' + k]);
+ const { warnings, overflow } = paletteWarnings(pal, 0.02, 5);
+ assert.equal(warnings.length, 5, 'capped at 5');
+ assert.equal(overflow, 16, '21 pairs - 5 shown');
+ for (let i = 1; i < warnings.length; i++)
+ assert.ok(warnings[i].dE >= warnings[i - 1].dE, 'ascending by ΔE');
+});
+
+test('paletteWarnings: threshold is inclusive-exclusive at the boundary', () => {
+ // A custom threshold lets a pair fall just inside or just outside.
+ const pal = [['#67809c', 'a'], ['#69829e', 'b']]; // dE ~0.0067
+ assert.equal(paletteWarnings(pal, 0.0067).warnings.length, 0, 'd < threshold is strict');
+ assert.equal(paletteWarnings(pal, 0.007).warnings.length, 1, 'just above the pair distance');
+});
+
// Guards the one-source-of-truth contract: the page must carry colormath.js's
// body (sans exports) verbatim, so the inlined copy and the tested module cannot
// drift. Requires `python3 generate.py` to have run first.
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 99349a38..7d3e4fe5 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -339,6 +339,29 @@ function rgb2hsv(r, g, b) {
function rgb2hex(r, g, b) {
return '#' + [r, g, b].map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('');
}
+
+// One Chroma×Lightness plane cell at a fixed hue: the sRGB color if the (L,C,H)
+// is reachable, else flagged out of gamut. Forward-only (one conversion + a
+// range check) — the binary-search clamp is reserved for committing a color.
+function planeCell(L, C, H) {
+ const lab = oklch2oklab(L, C, H), lrgb = oklab2lrgb(lab.L, lab.a, lab.b);
+ return inGamut(lrgb) ? { inGamut: true, hex: lrgb2hex(lrgb) } : { inGamut: false, hex: null };
+}
+
+// Pairwise palette analysis. palette is [[hex, name], ...]. Returns the pairs
+// closer than threshold (OKLab ΔE), closest-first and capped, the overflow count
+// beyond the cap, and each color's nearest-neighbor distance for its chip title.
+function paletteWarnings(palette, threshold = 0.02, cap = 5) {
+ const n = palette.length, nearest = new Array(n).fill(Infinity), pairs = [];
+ for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
+ const d = deltaE(palette[i][0], palette[j][0]);
+ if (d < nearest[i]) nearest[i] = d;
+ if (d < nearest[j]) nearest[j] = d;
+ if (d < threshold) pairs.push({ i, j, aName: palette[i][1], bName: palette[j][1], dE: d });
+ }
+ pairs.sort((a, b) => a.dE - b.dE);
+ return { warnings: pairs.slice(0, cap), overflow: Math.max(0, pairs.length - cap), nearest };
+}
function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';}
function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';}
function cid(l){return l.replace(/\W/g,'');}
@@ -387,26 +410,18 @@ function buildTable(){
let dragFrom=null,selectedIdx=null;
// 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.
-function paletteDeltas(){
- const n=PALETTE.length,nearest=new Array(n).fill(Infinity),pairs=[];
- for(let i=0;i<n;i++)for(let j=i+1;j<n;j++){const d=deltaE(PALETTE[i][0],PALETTE[j][0]);
- if(d<nearest[i])nearest[i]=d;if(d<nearest[j])nearest[j]=d;
- if(d<DELTAE_MIN)pairs.push({i,j,d});}
- pairs.sort((a,b)=>a.d-b.d);
- return {pairs,nearest};
-}
-function renderPaletteWarnings(pairs){
+// Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it.
+function renderPaletteWarnings(warnings,overflow){
const w=document.getElementById('palwarn');if(!w)return;
- if(!pairs.length){w.style.display='none';w.innerHTML='';return;}
- const cap=5,shown=pairs.slice(0,cap);
+ if(!warnings.length){w.style.display='none';w.innerHTML='';return;}
let html='<div class="pwh">too-similar colors</div>';
- html+=shown.map(p=>`<div class="pwl">${esc(PALETTE[p.i][1]+' / '+PALETTE[p.j][1])} — \u0394E ${p.d.toFixed(3)}, hard to distinguish</div>`).join('');
- if(pairs.length>cap)html+=`<div class="pwl">and ${pairs.length-cap} more</div>`;
+ html+=warnings.map(p=>`<div class="pwl">${esc(p.aName+' / '+p.bName)} — \u0394E ${p.dE.toFixed(3)}, hard to distinguish</div>`).join('');
+ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`;
w.innerHTML=html;w.style.display='block';
}
function renderPalette(){
const p=document.getElementById('pals');p.innerHTML='';
- const {pairs,nearest}=paletteDeltas();
+ 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']);
@@ -427,7 +442,7 @@ function renderPalette(){
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);});
- renderPaletteWarnings(pairs);
+ renderPaletteWarnings(warnings,overflow);
buildUITable();if(document.getElementById('pkgbody'))buildPkgTable();
}
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);}
@@ -466,10 +481,10 @@ function paintOklchPlane(H){
if(_planeCache.key===key&&_planeCache.data){ctx.putImageData(_planeCache.data,0,0);return;}
const step=4;
for(let x=0;x<w;x+=step){const C=(x/w)*OKLCH_CMAX;
- for(let y=0;y<h;y+=step){const L=1-y/h,lab=oklch2oklab(L,C,H),lrgb=oklab2lrgb(lab.L,lab.a,lab.b);
- if(!inGamut(lrgb)){ctx.fillStyle='#15120f';ctx.fillRect(x,y,step,step);continue;}
- const hex=lrgb2hex(lrgb);ctx.fillStyle=hex;ctx.fillRect(x,y,step,step);
- if(T&&contrast(hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
+ for(let y=0;y<h;y+=step){const L=1-y/h,cell=planeCell(L,C,H);
+ if(!cell.inGamut){ctx.fillStyle='#15120f';ctx.fillRect(x,y,step,step);continue;}
+ ctx.fillStyle=cell.hex;ctx.fillRect(x,y,step,step);
+ 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)};
}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;