diff options
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/README.md | 7 | ||||
| -rw-r--r-- | scripts/theme-studio/colormath.js | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 53 | ||||
| -rw-r--r-- | scripts/theme-studio/test-colormath.mjs | 23 | ||||
| -rw-r--r-- | 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<w;x+=step){const S=x/w;for(let y=0;y<h;y+=step){const V=1-y/h,[r,g,b]=hsv2rgb(pkH,S,V);if(contrast(rgb2hex(r,g,b),MAP['bg'])<T)ctx.fillRect(x,y,step,step);}}} -function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;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%)`;const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;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();} +// Phase 4b: the SV box becomes a Chroma×Lightness plane in OKLCH mode. Per cell +// the in-gamut test is forward-only (oklch→oklab→linear-rgb + range check), never +// the binary search — that is reserved for committing a color. The rendered +// bitmap is cached on (hue, dims, mask, bg) so dragging C/L (fixed hue) reuses it. +let _planeCache={key:null,data:null}; +function paintOklchPlane(H){ + 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'); + const T=pkThresh(),key=Math.round(H)+'|'+w+'|'+h+'|'+pkMode+'|'+MAP['bg']; + 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);}}} + _planeCache={key,data:ctx.getImageData(0,0,w,h)}; +} +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;} + 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);} const o=document.getElementById('pkoklch');if(o){const lch=oklab2oklch(srgb2oklab(h));o.textContent='OKLCH '+lch.L.toFixed(3)+' '+lch.C.toFixed(3)+' '+Math.round(lch.H)+'\\u00b0';} const a=document.getElementById('pkapca');if(a){const lc=apca(h,MAP['bg']);a.textContent='APCA Lc '+lc.toFixed(0);a.title='APCA Lc '+lc.toFixed(1)+' (APCA-W3 0.1.9), text on the ground color. Positive = dark text on a light background, negative = light text on a dark background.';}} @@ -689,14 +715,16 @@ function setPkModel(m){pkModel=m;document.querySelectorAll('.pmodel button').for const oc=document.getElementById('oklchctl');if(oc)oc.classList.toggle('show',m==='oklch'); if(m==='oklch')oklchInputsFromHex(curHex());else pkClampStatus(false);} function buildPkChips(){const c=document.getElementById('pkchips');if(!c)return;c.innerHTML='';const T=pkThresh();PALETTE.forEach(([hex,name])=>{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<w;x+=step){const S=x/w;for(let y=0;y<h;y+=step){const V=1-y/h,[r,g,b]=hsv2rgb(pkH,S,V);if(contrast(rgb2hex(r,g,b),MAP['bg'])<T)ctx.fillRect(x,y,step,step);}}} -function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;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%)`;const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;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();} +// Phase 4b: the SV box becomes a Chroma×Lightness plane in OKLCH mode. Per cell +// the in-gamut test is forward-only (oklch→oklab→linear-rgb + range check), never +// the binary search — that is reserved for committing a color. The rendered +// bitmap is cached on (hue, dims, mask, bg) so dragging C/L (fixed hue) reuses it. +let _planeCache={key:null,data:null}; +function paintOklchPlane(H){ + 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'); + const T=pkThresh(),key=Math.round(H)+'|'+w+'|'+h+'|'+pkMode+'|'+MAP['bg']; + 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);}}} + _planeCache={key,data:ctx.getImageData(0,0,w,h)}; +} +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;} + 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);} const o=document.getElementById('pkoklch');if(o){const lch=oklab2oklch(srgb2oklab(h));o.textContent='OKLCH '+lch.L.toFixed(3)+' '+lch.C.toFixed(3)+' '+Math.round(lch.H)+'\u00b0';} const a=document.getElementById('pkapca');if(a){const lc=apca(h,MAP['bg']);a.textContent='APCA Lc '+lc.toFixed(0);a.title='APCA Lc '+lc.toFixed(1)+' (APCA-W3 0.1.9), text on the ground color. Positive = dark text on a light background, negative = light text on a dark background.';}} @@ -477,14 +503,16 @@ function setPkModel(m){pkModel=m;document.querySelectorAll('.pmodel button').for const oc=document.getElementById('oklchctl');if(oc)oc.classList.toggle('show',m==='oklch'); if(m==='oklch')oklchInputsFromHex(curHex());else pkClampStatus(false);} function buildPkChips(){const c=document.getElementById('pkchips');if(!c)return;c.innerHTML='';const T=pkThresh();PALETTE.forEach(([hex,name])=>{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; |
