From 582d8a6a5d228bcb49a6ca3092b61418a348f37f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 8 Jun 2026 21:41:38 -0500 Subject: feat(theme-studio): render a Chroma×Lightness plane in OKLCH mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perceptual-metrics Phase 4b, the last piece of the OKLCH editor. In OKLCH mode the picker's square becomes a Chroma (x) by Lightness (y) plane at the current hue. The crosshair maps to (C, L) and the hue strip selects H. The unreachable region is greyed out so the sRGB gamut boundary at that hue is visible, and the AA/AAA contrast mask overlays on top of the reachable colors. The per-cell in-gamut test is forward-only: oklch to oklab to linear-rgb plus a channel-range check, never the binary search, which stays in oklch2hex for committing a chosen color. colormath.js now exports oklab2lrgb, inGamut, and lrgb2hex (with direct Node tests, including one that pins inGamut to oklch2hex's clamped flag so the plane and the commit path agree on the boundary). The rendered bitmap caches on hue, dimensions, mask, and background, so dragging C and L at a fixed hue reuses it. HSV stays untouched: the square keeps its saturation/value gradient and the existing contrast mask. A #planetest headless guard asserts the crosshair lands at the color's (C, L), an out-of-gamut cell renders as the grey fill, and an in-gamut cell renders as a real color. --- scripts/theme-studio/README.md | 7 +++++ scripts/theme-studio/colormath.js | 2 +- scripts/theme-studio/generate.py | 53 +++++++++++++++++++++++++++++---- scripts/theme-studio/test-colormath.mjs | 23 +++++++++++++- scripts/theme-studio/theme-studio.html | 53 +++++++++++++++++++++++++++++---- 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md index 58ddbf81..68d74815 100644 --- a/scripts/theme-studio/README.md +++ b/scripts/theme-studio/README.md @@ -58,6 +58,13 @@ Three tiers of faces, plus the palette: background, negative means light text on a dark background — so a light color on dupre's dark ground reads as a negative Lc. WCAG stays the rating used in the syntax/UI/package tables; APCA and OKLCH are picker-only diagnostics. + + An edit-model toggle switches the picker between HSV and OKLCH, independent of + the contrast mask. In OKLCH mode the L/C/H dials drive the color and the square + becomes a Chroma×Lightness plane at the current hue, with the out-of-gamut + region greyed out; the hue strip selects the hue. Pushing chroma past sRGB + snaps to the reachable color and shows a clamp note. The palette also warns + when two colors fall below a perceptual ΔE threshold, hard to tell apart. - **Syntax** — every font-lock / tree-sitter category (keyword, string, function, type, comment, and the rest), each with normal/bold/italic and a contrast rating. Click a category to flash its tokens in the code; click a diff --git a/scripts/theme-studio/colormath.js b/scripts/theme-studio/colormath.js index 367b6abe..70b8f5e4 100644 --- a/scripts/theme-studio/colormath.js +++ b/scripts/theme-studio/colormath.js @@ -167,4 +167,4 @@ 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 }; +export { srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, lin, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, oklab2lrgb, inGamut, lrgb2hex }; diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 8e3e8098..b53b3f88 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -663,9 +663,35 @@ function curHex(){return normHex(document.getElementById('newhexstr').value)||'# let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) let pkModel='hsv'; // color model for editing: hsv / oklch (orthogonal to pkMode) +const OKLCH_CMAX=0.4; // chroma axis range for the C×L plane (and the C dial); past sRGB at most hues, so the gamut grey shows the reachable region function pkThresh(){return pkMode==='aa'?4.5:pkMode==='aaa'?7:0;} function drawMask(){const cv=document.getElementById('svmask');if(!cv)return;const sv=document.getElementById('sv'),w=cv.width=sv.clientWidth,h=cv.height=sv.clientHeight,ctx=cv.getContext('2d');ctx.clearRect(0,0,w,h);const T=pkThresh();if(!T)return;ctx.fillStyle='rgba(8,7,6,0.66)';const step=4;for(let x=0;x{const s=document.createElement('div');s.className='pc';s.style.background=hex;s.title=name+' '+hex;const ok=!T||contrast(hex,MAP['bg'])>=T;if(!ok){s.style.opacity='0.22';s.title+=' (below '+pkMode.toUpperCase()+')';}s.onclick=()=>{if(ok)setHex(hex);};c.appendChild(s);});} -function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';paintPicker();pkReadout(curHex());setPkModel(pkModel);setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} +function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';setPkModel(pkModel);paintPicker();pkReadout(curHex());setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getElementById('picker');if(p)p.style.display='none';document.removeEventListener('pointerdown',pkOutside);} 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(); - pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();pkS=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width));pkV=1-Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));pkSet();}); - pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();pkH=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))*360;pkSet();}); - document.querySelectorAll('.pmode button').forEach(b=>b.onclick=()=>{pkMode=b.dataset.m;document.querySelectorAll('.pmode button').forEach(x=>x.classList.toggle('on',x===b));drawMask();buildPkChips();}); + 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)); + if(pkModel==='oklch'){const [L,C]=readOklch();setOklchInputs(L,C,fy*360);pkOklchSet();}else{pkH=fy*360;pkSet();}}); + document.querySelectorAll('.pmode button').forEach(b=>b.onclick=()=>{pkMode=b.dataset.m;document.querySelectorAll('.pmode button').forEach(x=>x.classList.toggle('on',x===b));paintPicker();buildPkChips();}); document.querySelectorAll('.pmodel button').forEach(b=>b.onclick=()=>setPkModel(b.dataset.pm)); [['okL','okLn',3],['okC','okCn',3],['okH','okHn',0]].forEach(([r,n,dp])=>{ const re=document.getElementById(r),ne=document.getElementById(n); @@ -1187,6 +1215,21 @@ if(location.hash==='#selftest')pkgSelftest(); if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}} if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);} if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}} +if(location.hash==='#planetest'){let ok=true;const notes=[]; + document.getElementById('newhexstr').value='#67809c';openPicker();setPkModel('oklch');paintPicker(); + const sv=document.getElementById('sv'),cv=document.getElementById('svmask'),ctx=cv.getContext('2d'); + const [L,C,H]=readOklch(); + const expLeft=Math.min(1,C/OKLCH_CMAX)*sv.clientWidth,expTop=(1-L)*sv.clientHeight; + const gotLeft=parseFloat(document.getElementById('svcur').style.left),gotTop=parseFloat(document.getElementById('svcur').style.top); + if(Math.abs(gotLeft-expLeft)>2||Math.abs(gotTop-expTop)>2){ok=false;notes.push('crosshair off got '+gotLeft.toFixed(1)+','+gotTop.toFixed(1)+' exp '+expLeft.toFixed(1)+','+expTop.toFixed(1));} + const Coog=0.38,Loog=0.5,labO=oklch2oklab(Loog,Coog,H),oog=!inGamut(oklab2lrgb(labO.L,labO.a,labO.b)); + const oogX=Math.min(cv.width-2,Math.round((Coog/OKLCH_CMAX)*cv.width)),oogY=Math.round((1-Loog)*cv.height); + const dO=ctx.getImageData(oogX,oogY,1,1).data,greyO=Math.abs(dO[0]-0x15)<10&&Math.abs(dO[1]-0x12)<10&&Math.abs(dO[2]-0x0f)<10; + if(oog&&!greyO){ok=false;notes.push('OOG cell not masked rgb '+dO[0]+','+dO[1]+','+dO[2]);} + const inX=Math.round((0.03/OKLCH_CMAX)*cv.width),inY=Math.round(0.5*cv.height); + const dI=ctx.getImageData(inX,inY,1,1).data,greyI=Math.abs(dI[0]-0x15)<10&&Math.abs(dI[1]-0x12)<10&&Math.abs(dI[2]-0x0f)<10; + if(greyI){ok=false;notes.push('in-gamut cell rendered as OOG grey rgb '+dI[0]+','+dI[1]+','+dI[2]);} + document.title='PLANETEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='planetest';d.textContent='PLANETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} if(location.hash==='#oklchtest'){let ok=true;const notes=[]; document.getElementById('newhexstr').value='#67809c';openPicker(); const before=document.getElementById('newhexstr').value; diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs index 2e929b7b..2a58ad61 100644 --- a/scripts/theme-studio/test-colormath.mjs +++ b/scripts/theme-studio/test-colormath.mjs @@ -10,8 +10,9 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE, + srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE, hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex, + oklab2lrgb, inGamut, lrgb2hex, } from './colormath.js'; const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps; @@ -139,6 +140,26 @@ test('rgb2hex formats and clamps out-of-range channels', () => { assert.equal(rgb2hex(-5, 300, 128), '#00ff80'); // clamps below 0 and above 255 }); +test('oklab2lrgb / lrgb2hex round-trip through known sRGB colors', () => { + for (const h of ['#000000', '#ffffff', '#67809c', '#e8bd30']) { + const lab = srgb2oklab(h); + assert.equal(lrgb2hex(oklab2lrgb(lab.L, lab.a, lab.b)), h, `round-trip ${h}`); + } +}); + +test('inGamut flags reachable vs unreachable OKLCH (forward-only gamut test)', () => { + // dupre-blue is a real sRGB color -> in gamut. + const ok = oklch2oklab(0.591, 0.052, 251.6); + assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), true, 'reachable'); + // very high chroma at mid lightness -> outside sRGB. + const bad = oklch2oklab(0.7, 0.4, 140); + assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), false, 'unreachable'); + // the in-gamut verdict must agree with oklch2hex's clamped flag (the plane and + // the commit path share one gamut boundary). + assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), !oklch2hex(0.591, 0.052, 251.6).clamped); + assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), !oklch2hex(0.7, 0.4, 140).clamped); +}); + // 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 22e31353..99349a38 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -451,9 +451,35 @@ function curHex(){return normHex(document.getElementById('newhexstr').value)||'# let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) let pkModel='hsv'; // color model for editing: hsv / oklch (orthogonal to pkMode) +const OKLCH_CMAX=0.4; // chroma axis range for the C×L plane (and the C dial); past sRGB at most hues, so the gamut grey shows the reachable region function pkThresh(){return pkMode==='aa'?4.5:pkMode==='aaa'?7:0;} function drawMask(){const cv=document.getElementById('svmask');if(!cv)return;const sv=document.getElementById('sv'),w=cv.width=sv.clientWidth,h=cv.height=sv.clientHeight,ctx=cv.getContext('2d');ctx.clearRect(0,0,w,h);const T=pkThresh();if(!T)return;ctx.fillStyle='rgba(8,7,6,0.66)';const step=4;for(let x=0;x{const s=document.createElement('div');s.className='pc';s.style.background=hex;s.title=name+' '+hex;const ok=!T||contrast(hex,MAP['bg'])>=T;if(!ok){s.style.opacity='0.22';s.title+=' (below '+pkMode.toUpperCase()+')';}s.onclick=()=>{if(ok)setHex(hex);};c.appendChild(s);});} -function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';paintPicker();pkReadout(curHex());setPkModel(pkModel);setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} +function openPicker(){pickerOn=true;[pkH,pkS,pkV]=rgb2hsv(...hex2rgb(curHex()));buildPkChips();document.getElementById('picker').style.display='block';setPkModel(pkModel);paintPicker();pkReadout(curHex());setTimeout(()=>document.addEventListener('pointerdown',pkOutside),0);} function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getElementById('picker');if(p)p.style.display='none';document.removeEventListener('pointerdown',pkOutside);} 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(); - pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();pkS=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width));pkV=1-Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));pkSet();}); - pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();pkH=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))*360;pkSet();}); - document.querySelectorAll('.pmode button').forEach(b=>b.onclick=()=>{pkMode=b.dataset.m;document.querySelectorAll('.pmode button').forEach(x=>x.classList.toggle('on',x===b));drawMask();buildPkChips();}); + 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)); + if(pkModel==='oklch'){const [L,C]=readOklch();setOklchInputs(L,C,fy*360);pkOklchSet();}else{pkH=fy*360;pkSet();}}); + document.querySelectorAll('.pmode button').forEach(b=>b.onclick=()=>{pkMode=b.dataset.m;document.querySelectorAll('.pmode button').forEach(x=>x.classList.toggle('on',x===b));paintPicker();buildPkChips();}); document.querySelectorAll('.pmodel button').forEach(b=>b.onclick=()=>setPkModel(b.dataset.pm)); [['okL','okLn',3],['okC','okCn',3],['okH','okHn',0]].forEach(([r,n,dp])=>{ const re=document.getElementById(r),ne=document.getElementById(n); @@ -975,6 +1003,21 @@ if(location.hash==='#selftest')pkgSelftest(); if(location.hash.startsWith('#pick')){openPicker();const m=location.hash.slice(5);if(m){const b=document.querySelector('.pmode button[data-m="'+m+'"]');if(b)b.click();}} if(location.hash==='#cursortest'){document.getElementById('newhexstr').value='#67809c';openPicker();const sc=document.getElementById('svcur'),hc=document.getElementById('huecur');const L=parseFloat(sc.style.left||'0'),T=parseFloat(sc.style.top||'0'),H=parseFloat(hc.style.top||'0');const ok=L>1&&T>1&&H>1;document.title='CURSORTEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='cursortest';d.textContent='CURSORTEST '+(ok?'PASS':'FAIL')+' left='+sc.style.left+' top='+sc.style.top+' hue='+hc.style.top;document.body.appendChild(d);} if(location.hash.startsWith('#app')){const ap=location.hash.slice(4),s=document.getElementById('appsel');if(s&&ap){s.value=ap;pkgChanged();}} +if(location.hash==='#planetest'){let ok=true;const notes=[]; + document.getElementById('newhexstr').value='#67809c';openPicker();setPkModel('oklch');paintPicker(); + const sv=document.getElementById('sv'),cv=document.getElementById('svmask'),ctx=cv.getContext('2d'); + const [L,C,H]=readOklch(); + const expLeft=Math.min(1,C/OKLCH_CMAX)*sv.clientWidth,expTop=(1-L)*sv.clientHeight; + const gotLeft=parseFloat(document.getElementById('svcur').style.left),gotTop=parseFloat(document.getElementById('svcur').style.top); + if(Math.abs(gotLeft-expLeft)>2||Math.abs(gotTop-expTop)>2){ok=false;notes.push('crosshair off got '+gotLeft.toFixed(1)+','+gotTop.toFixed(1)+' exp '+expLeft.toFixed(1)+','+expTop.toFixed(1));} + const Coog=0.38,Loog=0.5,labO=oklch2oklab(Loog,Coog,H),oog=!inGamut(oklab2lrgb(labO.L,labO.a,labO.b)); + const oogX=Math.min(cv.width-2,Math.round((Coog/OKLCH_CMAX)*cv.width)),oogY=Math.round((1-Loog)*cv.height); + const dO=ctx.getImageData(oogX,oogY,1,1).data,greyO=Math.abs(dO[0]-0x15)<10&&Math.abs(dO[1]-0x12)<10&&Math.abs(dO[2]-0x0f)<10; + if(oog&&!greyO){ok=false;notes.push('OOG cell not masked rgb '+dO[0]+','+dO[1]+','+dO[2]);} + const inX=Math.round((0.03/OKLCH_CMAX)*cv.width),inY=Math.round(0.5*cv.height); + const dI=ctx.getImageData(inX,inY,1,1).data,greyI=Math.abs(dI[0]-0x15)<10&&Math.abs(dI[1]-0x12)<10&&Math.abs(dI[2]-0x0f)<10; + if(greyI){ok=false;notes.push('in-gamut cell rendered as OOG grey rgb '+dI[0]+','+dI[1]+','+dI[2]);} + document.title='PLANETEST '+(ok?'PASS':'FAIL');const d=document.createElement('div');d.id='planetest';d.textContent='PLANETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} if(location.hash==='#oklchtest'){let ok=true;const notes=[]; document.getElementById('newhexstr').value='#67809c';openPicker(); const before=document.getElementById('newhexstr').value; -- cgit v1.2.3