diff options
| -rw-r--r-- | scripts/theme-studio/colormath.js | 25 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 30 | ||||
| -rw-r--r-- | scripts/theme-studio/test-colormath.mjs | 72 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 53 |
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; |
