diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/theme-studio/README.md | 45 | ||||
| -rw-r--r-- | scripts/theme-studio/app-core.js | 94 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 213 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 17 | ||||
| -rwxr-xr-x | scripts/theme-studio/run-tests.sh | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/styles.css | 13 | ||||
| -rw-r--r-- | scripts/theme-studio/test-app-core.mjs | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/test-contrast.mjs | 111 | ||||
| -rw-r--r-- | scripts/theme-studio/test-ramp.mjs | 105 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 333 |
10 files changed, 909 insertions, 26 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md index 044ccc2e..a2eb59b2 100644 --- a/scripts/theme-studio/README.md +++ b/scripts/theme-studio/README.md @@ -42,7 +42,8 @@ The runner regenerates the page, runs the Python templating tests (`test-colormath.mjs`, including the inline-integrity check), a syntax check of the spliced page script, and the browser hash gates in headless Chrome (`#selftest`, `#cursortest`, `#readouttest`, `#deltatest`, `#oklchtest`, -`#planetest`). It exits non-zero on any failure. The browser gates need a +`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#ramptest`, +`#contrasttest`, `#safetest`). It exits non-zero on any failure. The browser gates need a Chromium-family browser; without one they report SKIPPED rather than passing silently. The pure color math and the extracted picker logic (`planeCell`, `paletteWarnings`) live in `colormath.js` so they are unit-tested directly in @@ -92,6 +93,48 @@ Three tiers of faces, plus the palette: per face, shown in a live mock Emacs buffer. - **Package faces** — per-package face tables with a live preview (below). +## Ramps and background-contrast safety + +Two coupled features help build a harmonized palette and keep background tints +readable. Both work in OKLCH, where lightness, chroma, and hue move +independently. The pure math is in `app-core.js` (`ramp`, `fgSetFor`, `floor`, +`lMax`); the DOM is in `app.js`. + +**Ramps.** The "ramp" button on the palette controls generates a tonal ramp from +the current color: lighter and darker steps on a held hue, with the chroma easing +out toward the extremes. Three controls set the shape, with defaults that produce +a sensible first ramp: + +- `steps` — how many steps each direction (default 2, range 1-4). +- `stepL` — the OKLCH lightness delta per step (default 0.08, range 0.04-0.12). +- `chroma ease` — how much chroma drops at the farthest step (default 0.5, range + 0-1; 0 holds chroma flat, 1 fully desaturates the last step). + +Each previewed step is named after the source swatch (`blue` gives `blue+1`, +`blue-1`) and shows a clamp badge when it left the sRGB gamut. Click a step to +add it, or "add all"; steps insert next to the source in order. A name that +already exists is skipped (never overwritten); a generated hex that matches +another entry is added but flagged as a duplicate. + +**Worst-case contrast.** A background overlay sits behind many foregrounds at +once, so one fg/bg contrast pair is the wrong number. For the covered overlay +faces — `region`, `hl-line`, `highlight`, `lazy-highlight`, `isearch` — the +contrast cell shows the *worst-case floor*: the lowest contrast over the face's +foreground set (the syntax-token colors plus the default foreground), naming the +*limiting foreground* that sets it. A tint that clears the default text but fails +the darkest token reads FAIL, with that token named. Package faces and the other +UI rows keep their single-pair readout. + +The verdict is WCAG: AA (4.5) by default, AAA (7) selectable. APCA Lc stays a +picker-only diagnostic and does not drive PASS/FAIL. + +**Safe lightness.** In the OKLCH picker, the "safe for" selector picks one +covered face. The Chroma×Lightness plane then 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. If even pure black can't satisfy the target (a foreground is +too dark), the whole plane shades; that is a true finding about the foreground, +not a tool bug. + ## Package faces Pick an application from the dropdown to edit its faces. Each row has a diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 0e3cfe49..83f8402d 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -4,6 +4,12 @@ // the browser runs the same code the tests import. The app.js wrappers (pname, // seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the // live PALETTE / APPS / PKGMAP into these. +// +// The imports below are for the Node tests; generate.py strips them on inline, +// where normHex (app-util.js) and the colormath helpers are already present from +// the bodies inlined above this one. +import { normHex } from './app-util.js'; +import { oklch2hex, srgb2oklab, oklab2oklch, contrast } from './colormath.js'; // Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown. function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;} @@ -29,4 +35,90 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r // characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify }; +// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and +// +1..+n (the base itself is excluded — it already lives in the palette), +// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per +// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only +// the farthest step loses most of its color). Every step is gamut-clamped and +// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted} +// where adjusted names any knob clamped/rounded into range, or {steps:[], +// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown. +function ramp(baseHex,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {steps:[],error:'bad-hex'}; + const o=opts||{},adjusted=[]; + const knob=(name,def,lo,hi,isInt)=>{ + const v=o[name]; + if(typeof v!=='number'||!isFinite(v))return def; + const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r)); + if(c!==v)adjusted.push(name); + return c; + }; + const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false); + const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex)); + const steps=[]; + for(let off=-n;off<=n;off++){ + if(off===0)continue; + const L=Math.min(1,Math.max(0,L0+off*stepL)); + const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t); + const {hex:h,clamped}=oklch2hex(L,C,H0); + steps.push({hex:h,clamped,offset:off}); + } + return {steps,adjusted}; +} + +// --- background-contrast safety (palette-ramps spec, Phase 3) ---------------- +// An overlay background sits behind many foregrounds at once, so its real +// constraint is the worst-case contrast over the whole set, not one fg/bg pair. + +// The closed v1 set of code-overlay faces whose worst-case floor we compute. +// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added +// explicitly rather than by a heuristic. Shared by app.js and the tests. +const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch']; + +// A covered face's foreground set: the distinct syntax-token colors plus the +// default foreground, each labeled (syntax role preferred, else 'default'). +// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns +// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the +// face isn't in the covered set) or 'empty' (no syntax assignments constrain it). +function fgSetFor(face,state){ + const covered=(state&&state.covered)||COVERED_FACES; + if(!covered.includes(face))return {set:[],reason:'out-of-scope'}; + const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex); + if(!syn.length)return {set:[],reason:'empty'}; + const byHex=new Map(); + const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;}; + if(state&&state.defaultFg)add(state.defaultFg,'default',false); + for(const a of syn)add(a.hex,a.role||a.hex,true); + return {set:[...byHex.values()]}; +} + +// Worst-case (minimum) WCAG contrast of a background against a foreground set, +// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty +// set returns nulls so the caller can show the no-set readout instead of a floor. +function floor(bgHex,fgSet){ + if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null}; + let best=Infinity,lh=null,ll=null; + for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}} + return {ratio:best,limitingHex:lh,limitingLabel:ll}; +} + +// The lightest background at (hue, chroma) whose worst-case floor over fgSet still +// clears target (a WCAG ratio). Scans L up from black to bracket the first +// dark-side crossing, then binary-searches it to tol 0.001. status: +// 'ok' - a ceiling L was found +// 'none' - even pure black fails (a foreground is too dark for the target) +// 'all' - no foreground set to constrain (vacuously safe everywhere) +// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there) +function lMax(hue,chroma,fgSet,target){ + if(!fgSet||!fgSet.length)return {L:1,status:'all'}; + const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};}; + if(at(0).r<target)return {L:null,status:'none'}; + let loL=0,hiL=null; + for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;} + if(hiL===null)return {L:1,status:'all'}; + for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} + return {L:loL,status:at(loL).clamped?'clamp':'ok'}; +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index d0fed81c..7732914a 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -121,7 +121,7 @@ function buildTable(){ const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';} function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);} - const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}}); + const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();}); styleEx();styleCr(); const lkTd=mkLockCell(kind,[dd]); // style buttons @@ -139,6 +139,22 @@ function buildTable(){ tb.appendChild(tr);} } let dragFrom=null,selectedIdx=null; +// When a named palette color is deleted, remember its hex keyed by name so that +// recreating a color with the same name can re-bind the assignments still pointing +// at the old (now "(gone)") hex. Consumed once per name; cleared on import. +let lastGone={}; +// Re-point every assignment — syntax map, UI faces, package faces — from one hex +// to another. Used when a palette color's value is edited and when a deleted name +// is recreated. +function repointHex(oldHex,newHex){ + if(oldHex===newHex)return; + for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} + for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} + for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} +} +// On adding a color, if its name matches a recently-deleted one, re-bind the +// stranded assignments to the new hex. Returns true when a heal context existed. +function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;} // 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. // Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it. @@ -162,7 +178,7 @@ function renderPalette(){ const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">›</button>`:''; const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; @@ -187,9 +203,7 @@ function updateColor(){ const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1]; if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;} PALETTE[i]=[newHex,newName]; - for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} - for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} - for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} + repointHex(oldHex,newHex); closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -217,12 +231,30 @@ function paintOklchPlane(H){ 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)}; } +// --- safe-lightness guidance (spec Phase 5) ---------------------------------- +let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '') +function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();} +// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace +// readable over its foreground set, with the L_max ceiling as the band's lower +// edge. One marker computed via lMax at the current chroma, not a per-pixel mask. +function paintSafeBand(C,H){ + const el=document.getElementById('svsafe');if(!el)return; + if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;} + const fs=fgSetForFace(pkSafeFace); + if(fs.reason||!fs.set.length){el.style.display='none';return;} + const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET); + if(res.status==='all'){el.style.display='none';return;} + el.style.display='block';el.style.top='0px'; + el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px'; + el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')'; +} 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;} + document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;} + const sb=document.getElementById('svsafe');if(sb)sb.style.display='none'; 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);} @@ -253,6 +285,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl 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(); + 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)); @@ -266,7 +299,65 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim(); if(!name){notify('name the color before adding it',true);return;} if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} - PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} + PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); + renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();} + notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);} +// --- ramp generator UI (palette-ramps spec, Phase 2) ------------------------- +// Generate a tonal ramp from the current color, preview the steps, add the ones +// you want as named palette entries. The pure ramp() lives in app-core.js; this +// is the DOM around it. Names derive from the source swatch (blue -> blue+1). +let rampBase=null; // {hex,name} of the last previewed base (refreshed from the tile on preview) +// The base the ramp generates from is whatever sits on the color-selection tile +// right now: the selected palette color, or a typed hex and name. Reading it at +// preview time means selecting a new palette color then pressing preview just +// works, the same as reopening the panel. +function rampBaseFromTile(){const hex=curHex(),name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';return {hex,name};} +function openRamp(){document.getElementById('ramp').style.display='block';renderRamp();} +function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';} +function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};} +function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));} +function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';} +function rampNameTaken(nm){return PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase());} +function renderRamp(){ + rampBase=rampBaseFromTile(); + document.getElementById('rampname').textContent=rampBase.name+' '+rampBase.hex; + const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML=''; + if(r.error){rampNote('not a valid base color',true);return;} + const dups=[]; + r.steps.forEach(s=>{const nm=rampStepName(s.offset),taken=rampNameTaken(nm);if(taken)dups.push(nm); + const c=document.createElement('div');c.className='rchip'+(taken?' dup':'');c.style.background=s.hex;c.style.color=textOn(s.hex); + c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'')+(taken?' — a palette color is already named this; it will be skipped on add':''); + c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}${taken?'<span class="rdup" title="name already in the palette">⊘</span>':''}`; + c.onclick=()=>addRampStep(s);prev.appendChild(c);}); + const parts=[]; + if(r.adjusted.length)parts.push('adjusted: '+r.adjusted.join(', ')); + if(dups.length)parts.push('name already in palette, will be skipped on add: '+dups.join(', ')); + rampNote(parts.join(' | '),dups.length>0); +} +// Insert a step adjacent to the source swatch, keeping the ramp siblings in +// -n..+n order. A name collision is flagged and skipped (never overwrites); a +// hex that already exists under another name is added but flagged as a duplicate. +function rampInsertIndex(off){ + const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$'); + let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1; + let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;} + return idx; +} +function addRampStep(s){ + const nm=rampStepName(s.offset); + if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;} + const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase()); + PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);const healed=healGone(nm,s.hex);renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();} + rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true; +} +function addAllRampSteps(){ + if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts()); + if(r.error){rampNote('not a valid base color',true);return;} + let added=0;const skipped=[];r.steps.forEach(s=>{addRampStep(s)?added++:skipped.push(rampStepName(s.offset));}); + rampNote('added '+added+(skipped.length?(' | skipped (name already in palette): '+skipped.join(', ')):''),skipped.length>0); +} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} @@ -280,7 +371,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1); try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle(); }catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}} -function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); +function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true); LOCKED=new Set(d.locks||[]); if(d.ui)Object.assign(UIMAP,d.ui); @@ -736,8 +827,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8 function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';} function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();} function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} +// --- worst-case readout for the covered overlay faces (spec Phase 4) --------- +// Default WCAG target for the worst-case verdict (AA). AAA is selectable. +let WORST_TARGET=4.5; +// The live v1 foreground set for a covered overlay face: the syntax-token colors +// (every assignable category except the ground) plus the default foreground. +function fgSetForFace(face){ + const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])})); + return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']}); +} +// The worst-case contrast cell for a covered face: the floor over its foreground +// set against its effective background, naming the limiting foreground. Returns +// null for an out-of-scope face so the caller keeps the single-pair readout. +function worstCellHtml(face){ + const r=fgSetForFace(face); + if(r.reason==='out-of-scope')return null; + if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>'; + const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL'; + const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict; + return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`; +} +// Repaint every covered overlay face (their floors depend on the syntax palette, +// so a syntax-color edit has to refresh them even though it doesn't rebuild the table). +function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});} function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box); - const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}} + const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} function buildUITable(){ const tb=document.getElementById('uibody');tb.innerHTML=''; for(const [face,label,ex] of UI_FACES){ @@ -914,3 +1028,84 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById(' const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2; const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);} +// Ramp UI gate (open with #ramptest): generation count, ordered insertion after +// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step. +if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const save=PALETTE.slice(); + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette(); + selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue'; + openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp(); + A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length); + A(document.querySelectorAll('#rampprev .rchip .rhex').length===4,'each step tile shows its hex'); + addAllRampSteps(); + const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue'); + A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(',')); + const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names'); + A(/skipped \(name already in palette\): blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'add-all names the skipped collisions: '+document.getElementById('rampmsg').textContent); + renderRamp(); + A(document.querySelectorAll('#rampprev .rchip.dup').length===4,'re-preview marks the now-existing names as dup'); + A(/already in palette.*blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'preview names the colliding tiles: '+document.getElementById('rampmsg').textContent); + // preview re-reads the color-selection tile: change the tile, press preview, the base follows + document.getElementById('newhexstr').value='#2040e0';document.getElementById('newname').value='vivid';selectedIdx=null;document.getElementById('rampce').value='0';renderRamp(); + A(/^vivid #2040e0/.test(document.getElementById('rampname').textContent),'preview reads the tile: '+document.getElementById('rampname').textContent); + A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step'); + PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); + document.title='RAMPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Worst-case readout gate (open with #contrasttest): a covered overlay face shows +// the floor over its foreground set and names the limiting foreground, an +// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". +if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); + MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000'; + UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + buildUITable(); + const cell=document.getElementById('uicr-region'); + A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent)); + A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent)); + A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict'); + const fl=floor('#202830',fgSetForFace('region').set); + A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex); + A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg'); + const ml=document.getElementById('uicr-mode-line'); + A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)'); + A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent)); + MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable(); + const empty=document.getElementById('uicr-region'); + A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent)); + 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);} +// Gone-rebind gate (open with #healtest): deleting a named color then recreating +// the name re-points the assignments stranded on the old hex to the new color. +if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx; + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable(); + const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue'); + A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button'); + if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click(); + A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted'); + A(lastGone['blue']==='#67809c','delete recorded the gone name->hex'); + document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor(); + A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']); + A(!('blue' in lastGone),'heal consumed the gone entry'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel; + renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + document.title='HEALTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(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 9315faad..a8cda815 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -448,13 +448,28 @@ STYLES_CSS</style> <input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()"> <button onclick="addColor()">+ add color</button> <button onclick="updateColor()">↻ update selected</button> + <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">⛰ ramp</button> <span id="palmsg"></span> + <div id="ramp" class="ramp" style="display:none"> + <div class="ramprow"> + <label>ramp from <b id="rampname">—</b></label> + <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label> + <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label> + <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label> + <button onclick="renderRamp()">preview</button> + <button onclick="addAllRampSteps()">+ add all</button> + <button onclick="closeRamp()">close</button> + </div> + <div id="rampprev" class="rampprev"></div> + <div id="rampmsg"></div> + </div> <div id="picker" class="picker"> <div class="prow"> - <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div> + <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> <div id="hue" class="hue"><div id="huecur" class="huecur"></div></div> </div> <div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div> + <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div> <div class="oklchctl" id="oklchctl"> <div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div> <div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div> diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index e364d431..1ef748f4 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" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest healtest" 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 72541ca0..79a7efe2 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -55,13 +55,24 @@ .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} .pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad} .pinfo2 span{cursor:default} .pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer} + .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412} + .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2} + .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace} + .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} + .rchip{width:128px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace;line-height:1.3} + .rchip .rhex{font-weight:normal;font-size:8pt;opacity:.85} + .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} + .rchip.dup{outline:2px dashed #e8bd30;outline-offset:-2px} + .rchip .rdup{position:absolute;top:2px;left:4px;color:#e8bd30;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/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 16202525..39a44967 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -148,7 +148,7 @@ test('slugify: Error — an all-disallowed name falls back to "theme"', () => { // the page must carry app-core.js's body (sans exports) verbatim. Requires // `python3 generate.py` to have run first. const stripExports = (s) => - s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, ''); + s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))).join('\n').replace(/\s+$/, ''); test('inline-integrity: theme-studio.html contains the app-core.js body verbatim', () => { const body = stripExports(readFileSync(here + 'app-core.js', 'utf8')); diff --git a/scripts/theme-studio/test-contrast.mjs b/scripts/theme-studio/test-contrast.mjs new file mode 100644 index 00000000..9baf5bcc --- /dev/null +++ b/scripts/theme-studio/test-contrast.mjs @@ -0,0 +1,111 @@ +// Unit tests for the background-contrast safety core (app-core.js): fgSetFor, +// floor, and lMax. Phase 3 of the palette-ramps spec. A background overlay sits +// behind many foregrounds at once, so its real constraint is the worst-case +// (minimum) contrast over that foreground set, and the lightest background that +// keeps the floor above the target. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fgSetFor, floor, lMax, COVERED_FACES } from './app-core.js'; +import { contrast, oklch2hex } from './colormath.js'; + +const DEFAULT_FG = '#f0fef0'; +const stateWith = (syntaxAssignments) => ({ covered: COVERED_FACES, syntaxAssignments, defaultFg: DEFAULT_FG }); + +// --- fgSetFor --------------------------------------------------------------- + +test('fgSetFor: Normal — covered face gets default fg plus the distinct syntax colors', () => { + const r = fgSetFor('region', stateWith([ + { role: 'keyword', hex: '#67809c' }, + { role: 'string', hex: '#a3b18a' }, + ])); + assert.equal(r.reason, undefined); + assert.equal(r.set.length, 3); // default + 2 syntax + const hexes = r.set.map(e => e.hex); + assert.ok(hexes.includes('#f0fef0') && hexes.includes('#67809c') && hexes.includes('#a3b18a')); + assert.equal(r.set.find(e => e.hex === '#67809c').label, 'keyword'); + assert.equal(r.set.find(e => e.hex === '#f0fef0').label, 'default'); +}); + +test('fgSetFor: Boundary — a syntax hex equal to the default collapses, role label wins', () => { + const r = fgSetFor('region', { covered: COVERED_FACES, syntaxAssignments: [{ role: 'keyword', hex: '#f0fef0' }], defaultFg: '#f0fef0' }); + assert.equal(r.set.length, 1); + assert.equal(r.set[0].label, 'keyword'); // role preferred over 'default' +}); + +test('fgSetFor: Boundary — null/blank syntax hexes are dropped', () => { + const r = fgSetFor('isearch', stateWith([{ role: 'a', hex: null }, { role: 'b', hex: '#112233' }])); + assert.equal(r.set.length, 2); // default + the one real hex + assert.ok(r.set.some(e => e.hex === '#112233')); +}); + +test('fgSetFor: Error — a face outside the covered set is out-of-scope', () => { + const r = fgSetFor('mode-line', stateWith([{ role: 'keyword', hex: '#67809c' }])); + assert.deepEqual(r, { set: [], reason: 'out-of-scope' }); +}); + +test('fgSetFor: Error — a covered face with no syntax assignments is empty', () => { + const r = fgSetFor('hl-line', stateWith([])); + assert.deepEqual(r, { set: [], reason: 'empty' }); +}); + +test('fgSetFor: Normal — every covered face is in scope', () => { + for (const f of COVERED_FACES) { + const r = fgSetFor(f, stateWith([{ role: 'kw', hex: '#67809c' }])); + assert.equal(r.reason, undefined, `${f} should be covered`); + } +}); + +// --- floor ------------------------------------------------------------------ + +test('floor: Normal — the keyword-blue worst case sets the floor and is named', () => { + // sterling's keyword blue is the darkest foreground; against a lifted highlight + // background it is the limiting color while the light default still clears. + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const bg = '#202830'; + const r = floor(bg, fgSet); + assert.equal(r.limitingHex, '#67809c'); + assert.equal(r.limitingLabel, 'keyword'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', bg)) < 1e-9); + assert.ok(r.ratio < contrast('#f0fef0', bg), 'the floor is below the default-fg contrast'); +}); + +test('floor: Boundary — a single-entry set makes that entry the limit', () => { + const r = floor('#000000', [{ hex: '#67809c', label: 'keyword' }]); + assert.equal(r.limitingHex, '#67809c'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', '#000000')) < 1e-9); +}); + +test('floor: Error — an empty set returns nulls, not a bogus ratio', () => { + assert.deepEqual(floor('#000000', []), { ratio: null, limitingHex: null, limitingLabel: null }); +}); + +// --- lMax ------------------------------------------------------------------- + +const F = (L, chroma, hue, fgSet) => floor(oklch2hex(L, chroma, hue).hex, fgSet).ratio; + +test('lMax: Normal — finds the lightest safe background; the floor brackets the target', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(0, 0, fgSet, 4.5); + assert.equal(r.status, 'ok'); + assert.ok(r.L > 0 && r.L < 1); + assert.ok(F(r.L, 0, 0, fgSet) >= 4.5 - 0.05, 'floor at L_max clears the target'); + assert.ok(F(Math.min(1, r.L + 0.05), 0, 0, fgSet) < 4.5, 'just above L_max the floor fails'); +}); + +test('lMax: Boundary — no L satisfies the target when a foreground is too dark', () => { + const r = lMax(0, 0, [{ hex: '#1a1a1a', label: 'dim' }], 4.5); + assert.equal(r.status, 'none'); // even pure-black background can't lift #1a1a1a to AA +}); + +test('lMax: Boundary — an empty foreground set is vacuously safe everywhere', () => { + const r = lMax(0, 0, [], 4.5); + assert.deepEqual(r, { L: 1, status: 'all' }); +}); + +test('lMax: Boundary — requesting an unreachable chroma at the ceiling reports clamp', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(250, 0.3, fgSet, 4.5); // 0.3 chroma is out of gamut at the dark ceiling + assert.equal(r.status, 'clamp'); + assert.ok(r.L > 0 && r.L < 1); +}); diff --git a/scripts/theme-studio/test-ramp.mjs b/scripts/theme-studio/test-ramp.mjs new file mode 100644 index 00000000..0c447ff4 --- /dev/null +++ b/scripts/theme-studio/test-ramp.mjs @@ -0,0 +1,105 @@ +// Unit tests for the ramp generator (app-core.js `ramp`). Phase 1 of the +// palette-ramps spec: one base color -> a harmonized tonal ramp by stepping +// OKLCH lightness on a held hue, easing chroma toward the extremes, and +// gamut-clamping each step. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ramp } from './app-core.js'; +import { srgb2oklab, oklab2oklch, rl } from './colormath.js'; + +const HEXRE = /^#[0-9a-f]{6}$/; +const baseLCH = (hex) => oklab2oklch(srgb2oklab(hex)); + +test('ramp: Normal — default opts give 2n steps, darkest-to-lightest, base excluded', () => { + const r = ramp('#67809c'); // mid blue + assert.deepEqual(r.adjusted, []); + assert.equal(r.steps.length, 4); // n=2 -> -2,-1,+1,+2 + assert.deepEqual(r.steps.map(s => s.offset), [-2, -1, 1, 2]); + for (const s of r.steps) assert.match(s.hex, HEXRE, `${s.hex} is a 6-digit hex`); + // Lightness rises monotonically across the ordered steps. + const ls = r.steps.map(s => rl(s.hex)); + for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'each step lighter than the last'); + // Base sits between -1 and +1 in lightness. + const baseL = rl('#67809c'); + assert.ok(rl(r.steps[1].hex) < baseL && baseL < rl(r.steps[2].hex), 'base brackets the inner steps'); +}); + +test('ramp: Normal — holds the hue across in-gamut steps', () => { + const base = '#67809c'; + const H0 = baseLCH(base).H; + // chromaEase 0 keeps chroma up so the recovered hue is well-defined (near-gray + // steps have an ill-defined hue that 8-bit quantization can swing). + const r = ramp(base, { chromaEase: 0 }); + for (const s of r.steps) { + if (s.clamped) continue; // a clamped step may drift hue; only assert on clean ones + const dH = Math.abs(baseLCH(s.hex).H - H0); + assert.ok(Math.min(dH, 360 - dH) < 3.0, `step ${s.offset} holds hue (${dH.toFixed(2)} deg off)`); + } +}); + +test('ramp: Normal — chroma eases toward the extremes (outer step less chromatic than inner)', () => { + const base = '#67809c'; + const r = ramp(base, { n: 2, chromaEase: 0.8 }); + const inner = baseLCH(r.steps[1].hex).C; // offset -1 + const outer = baseLCH(r.steps[0].hex).C; // offset -2 + assert.ok(outer < inner, 'the farther step carries less chroma'); +}); + +test('ramp: Normal — chromaEase 0 holds chroma flat', () => { + const base = '#67809c'; + const C0 = baseLCH(base).C; + const r = ramp(base, { n: 1, stepL: 0.06, chromaEase: 0 }); + for (const s of r.steps) { + if (s.clamped) continue; + assert.ok(Math.abs(baseLCH(s.hex).C - C0) < 0.01, 'chroma held within tolerance'); + } +}); + +test('ramp: Boundary — near-white base clamps the lighter steps at L=1', () => { + const r = ramp('#f6f6f6', { n: 2, stepL: 0.08 }); + assert.equal(r.steps.length, 4); + const lightest = r.steps[r.steps.length - 1]; + assert.match(lightest.hex, HEXRE); + assert.ok(rl(lightest.hex) > 0.9, 'lightest step is near white'); +}); + +test('ramp: Boundary — near-black base clamps the darker steps at L=0', () => { + const r = ramp('#0b0b0b', { n: 2, stepL: 0.08 }); + assert.equal(r.steps.length, 4); + const darkest = r.steps[0]; + assert.match(darkest.hex, HEXRE); + assert.ok(rl(darkest.hex) < 0.05, 'darkest step is near black'); +}); + +test('ramp: Boundary — n clamps to [1,4] and reports the adjustment', () => { + const lo = ramp('#67809c', { n: 0 }); + assert.equal(lo.steps.length, 2); // clamped to n=1 + assert.ok(lo.adjusted.includes('n')); + const hi = ramp('#67809c', { n: 9 }); + assert.equal(hi.steps.length, 8); // clamped to n=4 + assert.ok(hi.adjusted.includes('n')); + const frac = ramp('#67809c', { n: 2.7 }); + assert.equal(frac.steps.length, 6); // rounded to 3, in range, still flagged as adjusted + assert.ok(frac.adjusted.includes('n')); +}); + +test('ramp: Boundary — stepL and chromaEase clamp to range and report', () => { + const r = ramp('#67809c', { stepL: 0.5, chromaEase: 2 }); + assert.ok(r.adjusted.includes('stepL')); + assert.ok(r.adjusted.includes('chromaEase')); + assert.equal(r.steps.length, 4); +}); + +test('ramp: Error — malformed hex returns a structured bad-hex, not a throw', () => { + for (const bad of ['nope', '#xyz', '#12', '12345g', null, undefined, '']) { + const r = ramp(bad); + assert.deepEqual(r, { steps: [], error: 'bad-hex' }, `${String(bad)} -> bad-hex`); + } +}); + +test('ramp: Boundary — a six-digit hex without the leading # is accepted', () => { + const r = ramp('67809c'); + assert.equal(r.steps.length, 4); + assert.ok(!r.error); +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 50e0a519..ab7d115c 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -57,13 +57,24 @@ .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} .pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad} .pinfo2 span{cursor:default} .pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer} + .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412} + .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2} + .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace} + .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} + .rchip{width:128px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace;line-height:1.3} + .rchip .rhex{font-weight:normal;font-size:8pt;opacity:.85} + .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} + .rchip.dup{outline:2px dashed #e8bd30;outline-offset:-2px} + .rchip .rdup{position:absolute;top:2px;left:4px;color:#e8bd30;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} @@ -100,13 +111,28 @@ <input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()"> <button onclick="addColor()">+ add color</button> <button onclick="updateColor()">↻ update selected</button> + <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">⛰ ramp</button> <span id="palmsg"></span> + <div id="ramp" class="ramp" style="display:none"> + <div class="ramprow"> + <label>ramp from <b id="rampname">—</b></label> + <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label> + <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label> + <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label> + <button onclick="renderRamp()">preview</button> + <button onclick="addAllRampSteps()">+ add all</button> + <button onclick="closeRamp()">close</button> + </div> + <div id="rampprev" class="rampprev"></div> + <div id="rampmsg"></div> + </div> <div id="picker" class="picker"> <div class="prow"> - <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svcur" class="svcur"></div></div> + <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> <div id="hue" class="hue"><div id="huecur" class="huecur"></div></div> </div> <div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div> + <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div> <div class="oklchctl" id="oklchctl"> <div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div> <div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div> @@ -386,6 +412,10 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) { // the browser runs the same code the tests import. The app.js wrappers (pname, // seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the // live PALETTE / APPS / PKGMAP into these. +// +// The imports below are for the Node tests; generate.py strips them on inline, +// where normHex (app-util.js) and the colormath helpers are already present from +// the bodies inlined above this one. // Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown. function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;} @@ -410,6 +440,92 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r // Turn a theme name into a safe filename slug: collapse runs of disallowed // characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} + +// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and +// +1..+n (the base itself is excluded — it already lives in the palette), +// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per +// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only +// the farthest step loses most of its color). Every step is gamut-clamped and +// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted} +// where adjusted names any knob clamped/rounded into range, or {steps:[], +// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown. +function ramp(baseHex,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {steps:[],error:'bad-hex'}; + const o=opts||{},adjusted=[]; + const knob=(name,def,lo,hi,isInt)=>{ + const v=o[name]; + if(typeof v!=='number'||!isFinite(v))return def; + const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r)); + if(c!==v)adjusted.push(name); + return c; + }; + const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false); + const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex)); + const steps=[]; + for(let off=-n;off<=n;off++){ + if(off===0)continue; + const L=Math.min(1,Math.max(0,L0+off*stepL)); + const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t); + const {hex:h,clamped}=oklch2hex(L,C,H0); + steps.push({hex:h,clamped,offset:off}); + } + return {steps,adjusted}; +} + +// --- background-contrast safety (palette-ramps spec, Phase 3) ---------------- +// An overlay background sits behind many foregrounds at once, so its real +// constraint is the worst-case contrast over the whole set, not one fg/bg pair. + +// The closed v1 set of code-overlay faces whose worst-case floor we compute. +// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added +// explicitly rather than by a heuristic. Shared by app.js and the tests. +const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch']; + +// A covered face's foreground set: the distinct syntax-token colors plus the +// default foreground, each labeled (syntax role preferred, else 'default'). +// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns +// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the +// face isn't in the covered set) or 'empty' (no syntax assignments constrain it). +function fgSetFor(face,state){ + const covered=(state&&state.covered)||COVERED_FACES; + if(!covered.includes(face))return {set:[],reason:'out-of-scope'}; + const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex); + if(!syn.length)return {set:[],reason:'empty'}; + const byHex=new Map(); + const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;}; + if(state&&state.defaultFg)add(state.defaultFg,'default',false); + for(const a of syn)add(a.hex,a.role||a.hex,true); + return {set:[...byHex.values()]}; +} + +// Worst-case (minimum) WCAG contrast of a background against a foreground set, +// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty +// set returns nulls so the caller can show the no-set readout instead of a floor. +function floor(bgHex,fgSet){ + if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null}; + let best=Infinity,lh=null,ll=null; + for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}} + return {ratio:best,limitingHex:lh,limitingLabel:ll}; +} + +// The lightest background at (hue, chroma) whose worst-case floor over fgSet still +// clears target (a WCAG ratio). Scans L up from black to bracket the first +// dark-side crossing, then binary-searches it to tol 0.001. status: +// 'ok' - a ceiling L was found +// 'none' - even pure black fails (a foreground is too dark for the target) +// 'all' - no foreground set to constrain (vacuously safe everywhere) +// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there) +function lMax(hue,chroma,fgSet,target){ + if(!fgSet||!fgSet.length)return {L:1,status:'all'}; + const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};}; + if(at(0).r<target)return {L:null,status:'none'}; + let loL=0,hiL=null; + for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;} + if(hiL===null)return {L:1,status:'all'}; + for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} + return {L:loL,status:at(loL).clamped?'clamp':'ok'}; +} // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. // Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status @@ -531,7 +647,7 @@ function buildTable(){ const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt'; function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';} function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);} - const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}}); + const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();}); styleEx();styleCr(); const lkTd=mkLockCell(kind,[dd]); // style buttons @@ -549,6 +665,22 @@ function buildTable(){ tb.appendChild(tr);} } let dragFrom=null,selectedIdx=null; +// When a named palette color is deleted, remember its hex keyed by name so that +// recreating a color with the same name can re-bind the assignments still pointing +// at the old (now "(gone)") hex. Consumed once per name; cleared on import. +let lastGone={}; +// Re-point every assignment — syntax map, UI faces, package faces — from one hex +// to another. Used when a palette color's value is edited and when a deleted name +// is recreated. +function repointHex(oldHex,newHex){ + if(oldHex===newHex)return; + for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} + for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} + for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} +} +// On adding a color, if its name matches a recently-deleted one, re-bind the +// stranded assignments to the new hex. Returns true when a heal context existed. +function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;} // 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. // Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it. @@ -572,7 +704,7 @@ function renderPalette(){ const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">›</button>`:''; const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; @@ -597,9 +729,7 @@ function updateColor(){ const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1]; if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;} PALETTE[i]=[newHex,newName]; - for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} - for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} - for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} + repointHex(oldHex,newHex); closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -627,12 +757,30 @@ function paintOklchPlane(H){ 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)}; } +// --- safe-lightness guidance (spec Phase 5) ---------------------------------- +let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '') +function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();} +// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace +// readable over its foreground set, with the L_max ceiling as the band's lower +// edge. One marker computed via lMax at the current chroma, not a per-pixel mask. +function paintSafeBand(C,H){ + const el=document.getElementById('svsafe');if(!el)return; + if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;} + const fs=fgSetForFace(pkSafeFace); + if(fs.reason||!fs.set.length){el.style.display='none';return;} + const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET); + if(res.status==='all'){el.style.display='none';return;} + el.style.display='block';el.style.top='0px'; + el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px'; + el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')'; +} 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;} + document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;} + const sb=document.getElementById('svsafe');if(sb)sb.style.display='none'; 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);} @@ -663,6 +811,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl 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(); + 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)); @@ -676,7 +825,65 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim(); if(!name){notify('name the color before adding it',true);return;} if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} - PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} + PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); + renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();} + notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);} +// --- ramp generator UI (palette-ramps spec, Phase 2) ------------------------- +// Generate a tonal ramp from the current color, preview the steps, add the ones +// you want as named palette entries. The pure ramp() lives in app-core.js; this +// is the DOM around it. Names derive from the source swatch (blue -> blue+1). +let rampBase=null; // {hex,name} of the last previewed base (refreshed from the tile on preview) +// The base the ramp generates from is whatever sits on the color-selection tile +// right now: the selected palette color, or a typed hex and name. Reading it at +// preview time means selecting a new palette color then pressing preview just +// works, the same as reopening the panel. +function rampBaseFromTile(){const hex=curHex(),name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';return {hex,name};} +function openRamp(){document.getElementById('ramp').style.display='block';renderRamp();} +function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';} +function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};} +function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));} +function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';} +function rampNameTaken(nm){return PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase());} +function renderRamp(){ + rampBase=rampBaseFromTile(); + document.getElementById('rampname').textContent=rampBase.name+' '+rampBase.hex; + const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML=''; + if(r.error){rampNote('not a valid base color',true);return;} + const dups=[]; + r.steps.forEach(s=>{const nm=rampStepName(s.offset),taken=rampNameTaken(nm);if(taken)dups.push(nm); + const c=document.createElement('div');c.className='rchip'+(taken?' dup':'');c.style.background=s.hex;c.style.color=textOn(s.hex); + c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'')+(taken?' — a palette color is already named this; it will be skipped on add':''); + c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}${taken?'<span class="rdup" title="name already in the palette">⊘</span>':''}`; + c.onclick=()=>addRampStep(s);prev.appendChild(c);}); + const parts=[]; + if(r.adjusted.length)parts.push('adjusted: '+r.adjusted.join(', ')); + if(dups.length)parts.push('name already in palette, will be skipped on add: '+dups.join(', ')); + rampNote(parts.join(' | '),dups.length>0); +} +// Insert a step adjacent to the source swatch, keeping the ramp siblings in +// -n..+n order. A name collision is flagged and skipped (never overwrites); a +// hex that already exists under another name is added but flagged as a duplicate. +function rampInsertIndex(off){ + const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$'); + let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1; + let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;} + return idx; +} +function addRampStep(s){ + const nm=rampStepName(s.offset); + if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;} + const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase()); + PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);const healed=healGone(nm,s.hex);renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();} + rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true; +} +function addAllRampSteps(){ + if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts()); + if(r.error){rampNote('not a valid base color',true);return;} + let added=0;const skipped=[];r.steps.forEach(s=>{addRampStep(s)?added++:skipped.push(rampStepName(s.offset));}); + rampNote('added '+added+(skipped.length?(' | skipped (name already in palette): '+skipped.join(', ')):''),skipped.length>0); +} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} @@ -690,7 +897,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1); try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle(); }catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}} -function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); +function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true); LOCKED=new Set(d.locks||[]); if(d.ui)Object.assign(UIMAP,d.ui); @@ -1146,8 +1353,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8 function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';} function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();} function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} +// --- worst-case readout for the covered overlay faces (spec Phase 4) --------- +// Default WCAG target for the worst-case verdict (AA). AAA is selectable. +let WORST_TARGET=4.5; +// The live v1 foreground set for a covered overlay face: the syntax-token colors +// (every assignable category except the ground) plus the default foreground. +function fgSetForFace(face){ + const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])})); + return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']}); +} +// The worst-case contrast cell for a covered face: the floor over its foreground +// set against its effective background, naming the limiting foreground. Returns +// null for an out-of-scope face so the caller keeps the single-pair readout. +function worstCellHtml(face){ + const r=fgSetForFace(face); + if(r.reason==='out-of-scope')return null; + if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>'; + const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL'; + const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict; + return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`; +} +// Repaint every covered overlay face (their floors depend on the syntax palette, +// so a syntax-color edit has to refresh them even though it doesn't rebuild the table). +function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});} function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box); - const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}} + const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} function buildUITable(){ const tb=document.getElementById('uibody');tb.innerHTML=''; for(const [face,label,ex] of UI_FACES){ @@ -1324,4 +1554,85 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById(' const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2; const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);} +// Ramp UI gate (open with #ramptest): generation count, ordered insertion after +// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step. +if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const save=PALETTE.slice(); + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette(); + selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue'; + openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp(); + A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length); + A(document.querySelectorAll('#rampprev .rchip .rhex').length===4,'each step tile shows its hex'); + addAllRampSteps(); + const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue'); + A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(',')); + const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names'); + A(/skipped \(name already in palette\): blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'add-all names the skipped collisions: '+document.getElementById('rampmsg').textContent); + renderRamp(); + A(document.querySelectorAll('#rampprev .rchip.dup').length===4,'re-preview marks the now-existing names as dup'); + A(/already in palette.*blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'preview names the colliding tiles: '+document.getElementById('rampmsg').textContent); + // preview re-reads the color-selection tile: change the tile, press preview, the base follows + document.getElementById('newhexstr').value='#2040e0';document.getElementById('newname').value='vivid';selectedIdx=null;document.getElementById('rampce').value='0';renderRamp(); + A(/^vivid #2040e0/.test(document.getElementById('rampname').textContent),'preview reads the tile: '+document.getElementById('rampname').textContent); + A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step'); + PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); + document.title='RAMPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Worst-case readout gate (open with #contrasttest): a covered overlay face shows +// the floor over its foreground set and names the limiting foreground, an +// out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". +if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP)); + MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000'; + UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false}; + buildUITable(); + const cell=document.getElementById('uicr-region'); + A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent)); + A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent)); + A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict'); + const fl=floor('#202830',fgSetForFace('region').set); + A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex); + A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg'); + const ml=document.getElementById('uicr-mode-line'); + A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)'); + A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent)); + MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable(); + const empty=document.getElementById('uicr-region'); + A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent)); + 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);} +// Gone-rebind gate (open with #healtest): deleting a named color then recreating +// the name re-points the assignments stranded on the old hex to the new color. +if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx; + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable(); + const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue'); + A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button'); + if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click(); + A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted'); + A(lastGone['blue']==='#67809c','delete recorded the gone name->hex'); + document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor(); + A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']); + A(!('blue' in lastGone),'heal consumed the gone entry'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel; + renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + document.title='HEALTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} </script>
\ No newline at end of file |
