From 843bbf0848adff40714e3d7f01376dfe2f14e1dc Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 9 Jun 2026 19:05:13 -0500 Subject: feat(theme-studio): mark safe lightness in the OKLCH picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OKLCH picker gets a "safe for" selector listing the covered overlay faces. Pick one and the C×L plane 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. The ceiling is one marker computed via lMax at the current chroma, not a per-pixel foreground-set mask over the plane, so the existing AA/AAA mask stays single-foreground. When no foreground is dark enough to fail, the band hides; when even black can't satisfy the target, the whole plane shades. The band only shows in OKLCH mode and clears in HSV. The cursor moved above the band so it stays visible through the shade. Phase 5 of the palette-ramps spec, the last build phase. A #safetest browser gate pins that the band appears for a selected covered face with a positive height and hides when none is selected. --- scripts/theme-studio/app.js | 37 ++++++++++++++++++++++++++++- scripts/theme-studio/generate.py | 3 ++- scripts/theme-studio/run-tests.sh | 2 +- scripts/theme-studio/styles.css | 3 ++- scripts/theme-studio/theme-studio.html | 43 +++++++++++++++++++++++++++++++--- 5 files changed, 81 insertions(+), 7 deletions(-) (limited to 'scripts/theme-studio') diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 9fb509ac..4b57c425 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -217,12 +217,30 @@ function paintOklchPlane(H){ if(T&&contrast(cell.hex,MAP['bg']){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)); @@ -1020,3 +1039,19 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i 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);} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 60520b41..a8cda815 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -465,10 +465,11 @@ STYLES_CSS
-
+
edit
+
safe for
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index cd466f4b..4cdcd383 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 ramptest contrasttest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest" 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 6e860059..a4aa7c04 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -55,7 +55,7 @@ .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} @@ -69,6 +69,7 @@ .rchip{width:74px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace} .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496} + .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/theme-studio.html b/scripts/theme-studio/theme-studio.html index 21a38268..831e14a5 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -57,7 +57,7 @@ .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} @@ -71,6 +71,7 @@ .rchip{width:74px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace} .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496} + .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} @@ -124,10 +125,11 @@
-
+
edit
+
safe for
@@ -738,12 +740,30 @@ function paintOklchPlane(H){ if(T&&contrast(cell.hex,MAP['bg']){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)); @@ -1541,4 +1562,20 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i 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);} \ No newline at end of file -- cgit v1.2.3