diff options
Diffstat (limited to 'scripts')
| -rw-r--r-- | scripts/theme-studio/README.md | 70 | ||||
| -rw-r--r-- | scripts/theme-studio/app-core.js | 133 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 314 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 14 | ||||
| -rwxr-xr-x | scripts/theme-studio/run-tests.sh | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/styles.css | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 213 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 478 |
8 files changed, 950 insertions, 293 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md index a2eb59b2..caee7b24 100644 --- a/scripts/theme-studio/README.md +++ b/scripts/theme-studio/README.md @@ -42,8 +42,9 @@ 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`, `#locktest`, `#sorttest`, `#mocktest`, `#ramptest`, -`#contrasttest`, `#safetest`). It exits non-zero on any failure. The browser gates need a +`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#contrasttest`, +`#safetest`, `#healtest`, `#familytest`, `#counttest`, `#baseedittest`, +`#roundtriptest`). 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 @@ -66,10 +67,11 @@ Node; the DOM glue is covered by the browser hash gates. Three tiers of faces, plus the palette: -- **Palette** — named colors. Add by hex or with the in-page color picker +- **Palette** — named colors, shown grouped into hue *families* (see Color + families below). Add by hex or with the in-page color picker (saturation/value square, hue slider, palette reuse chips, live contrast - readout, and an any / AA+ / AAA legibility mask). Remove, rename, reorder with - arrows or drag. The colors serving as background and foreground are locked. + readout, and an any / AA+ / AAA legibility mask). Remove and rename per chip; + the colors serving as background and foreground are locked. The picker also shows perceptual readouts beside the WCAG ratio: the OKLCH coordinates (lightness, chroma, hue°) and the APCA Lc contrast against the @@ -93,28 +95,42 @@ 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. +## Color families + +The palette is displayed as **families**: colors grouped into vertical columns by +their actual color, dark at the top and light at the bottom, columns arranged left +to right. Grouping is derived from the hex on every render — never from the name — +so renaming a color to anything never moves it between columns. The flat palette +underneath is unchanged (export stays a flat `[hex, name]` list); families are a +view over it, and the per-chip rename/remove still work. + +- **Grouping.** Chromatic colors bucket by their nearest perceptual hue (red, + orange, yellow, green, teal, blue, purple, pink). Near-neutrals — grays, the + background and foreground ramps — collapse into one neutral column ordered by + lightness, using a lightness-scaled chroma threshold so a faint pale tint keeps + its hue while a faint mid gray reads as neutral. Columns sort by hue; the ground + strip (the `bg` and `fg` assignments) pins first, neutrals next. (Hue-adjacent + warm colors like olive-greens and golds can still share a column — a known + limitation, since by hue they really are adjacent.) +- **The count control** under each chromatic column sets how many steps sit on + each side of the family's base (its most-saturated color). Setting N regenerates + the family as a symmetric base ±N tonal ramp via `ramp()` — lighter and darker + steps on the base's hue with chroma easing toward the extremes — *replacing* the + column's current colors. N=0 collapses to the base alone. +- **Editing a base** recolors the whole family: change a base color and the family + regenerates from it at the same count. +- **References follow.** When a regenerate changes a step's hex, any face assigned + to that step is re-pointed to the new hex. A step *removed* by lowering the count + leaves its references showing "(gone)" — visible and recoverable, never a silent + jump to a different color. + +The standalone ramp generator is gone; fanning a color into a ramp is now "add the +color, then raise its column's count." + +## Background-contrast safety + +Keep background tints readable. Works in OKLCH; the pure math is in `app-core.js` +(`fgSetFor`, `floor`, `lMax`), the DOM in `app.js`. **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 diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 83f8402d..60ee1410 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -121,4 +121,135 @@ function lMax(hue,chroma,fgSet,target){ return {L:loL,status:at(loL).clamped?'clamp':'ok'}; } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES }; +// --- color families (color-families spec, Phase 1) --------------------------- +// Families are a display grouping derived from the hex every render — never from +// names — so renaming a color can't move it. The flat palette stays the editable +// truth; these pure functions group it, regenerate a family's ramp, and plan the +// assignment re-point across a regenerate. + +function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));} +function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} + +// A color reads as neutral below this chroma. Lightness-scaled (the Munsell +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} +// A family from its members: base is the most-saturated member (tie toward +// mid-lightness), the anchor for a generated ramp. +function makeFamily(ms,neutral){ + let base=ms[0]; + for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m; + return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))}; +} +// Group a flat palette into the ground strip plus families. ground is {bg,fg}: +// those two hexes form the pinned ground strip even when absent from the palette, +// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals +// (chroma below the lightness-scaled threshold) form one neutral family; the rest +// cluster by lightness-conditioned complete linkage (clusterChromatic). +function familiesFromPalette(palette,ground){ + const bg=ground&&ground.bg,fg=ground&&ground.fg; + const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase())); + const groundStrip=[]; + if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)}); + if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)}); + const neutrals=[],chromatic=[]; + for(const [hex,name] of palette){ + if(gset.has(hex.toLowerCase()))continue; + const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H}; + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); + } + const families=[]; + if(neutrals.length)families.push(makeFamily(neutrals,true)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false)); + return {ground:groundStrip,families}; +} +// Regenerate a family's members as a symmetric ramp around the base: n=0 is the +// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted +// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}. +function regenFamily(baseHex,n,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {members:[],error:'bad-hex'}; + const k=Math.min(4,Math.max(0,Math.round(n||0))); + if(k===0)return {members:[{hex,offset:0,clamped:false}]}; + const r=ramp(hex,Object.assign({},opts,{n:k})); + if(r.error)return {members:[],error:r.error}; + const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset); + return {members}; +} +// Rank a family's current member hexes by lightness and give each a signed offset +// from the base (the matching hex, or the nearest by lightness if the base isn't +// present). Lets a regenerate match old positions to new ramp offsets. +function rankByLightness(memberHexes,baseHex){ + const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L); + let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase()); + if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});} + return items.map((m,i)=>({hex:m.hex,offset:i-bi})); +} +// Plan the assignment re-point for a regenerate: for each old ranked member, the +// new member at the same offset is the same position. {map:[[old,new]]} for +// positions whose hex changed; {removed:[hex]} for positions with no new +// counterpart (the caller leaves their references a visible "(gone)"). +function stepRepointPlan(oldRanked,newMembers){ + const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[]; + for(const o of oldRanked){ + const nh=byOff.get(o.offset); + if(nh===undefined)removed.push(o.hex); + else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]); + } + return {map,removed}; +} + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 44a2ee74..0b6663ee 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -138,7 +138,23 @@ function buildTable(){ tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);} } -let dragFrom=null,selectedIdx=null; +let 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. @@ -150,35 +166,90 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`; w.innerHTML=html;w.style.display='block'; } +// One palette chip for PALETTE[i], with its remove / rename / select handlers. +// Families sort deterministically, so the old move-arrow / drag reordering is gone. +function paletteChip(i,nearest){ + const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i]; + const locked=(hex===MAP['bg']||hex===MAP['p']); + const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; + d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); + 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}<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();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; + d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);}; + return d; +} +// Render the palette as hue families: the pinned ground strip, then hue-sorted +// family strips, each dark to light. Grouping is derived from the hex by +// familiesFromPalette every render, so renaming a color never moves it. The flat +// PALETTE stays the editable truth; chips keep their per-chip controls. function renderPalette(){ const p=document.getElementById('pals');p.innerHTML=''; const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex); - const nde=nearest[i]; - const locked=(hex===MAP['bg']||hex===MAP['p']); - const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true; - d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3)); - const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">‹</button>`:''; - 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(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();}; - d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);}; - d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');}; - d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));}; - d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');}; - d.ondragleave=()=>d.classList.remove('over'); - d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();}; - p.appendChild(d);}); + const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const used=new Set(); + const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;}; + const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; + const gs=strip(' ground');gs.dataset.family='ground'; + ground.forEach(g=>{ + const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex); + if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));} + else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex; + sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);} + }); + // The too-similar warning stays on the full flat palette: a generated ramp's + // steps are a stepL apart (well above the warning's ΔE threshold), so they never + // trigger it, and any pair that does is a genuine near-duplicate worth flagging. + sortFamilies(families).forEach(f=>{ + const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + if(!f.neutral)s.appendChild(familyCountControl(f)); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } +// The per-family count control under a chromatic strip. Its value is the family's +// current per-side reach; setting N regenerates the family as base ±N. +function familyCountControl(f){ + const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset))); + const d=document.createElement('div');d.className='fcount'; + d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">± <input type="number" min="0" max="4" value="${per}"></span>`; + d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); + return d; +} +// Regenerate a family as a symmetric base ±N ramp, replacing its current members. +// References to a surviving position (matched by signed lightness rank) follow the +// new hex; references to a position removed by lowering N leave their old hex, +// which is no longer in the palette and so renders as "(gone)". +// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving +// references and leaving removed ones on their now-gone hex. Returns the removed +// count, or null on a bad base. Shared by the count control and the base edit. +function regenFamilyInPlace(oldHexes,baseHex,baseName,n){ + const r=regenFamily(baseHex,n,{}); + if(r.error){notify('cannot regenerate from '+baseHex,true);return null;} + const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members); + const oldSet=new Set(oldHexes.map(h=>h.toLowerCase())); + let at=PALETTE.length; + for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;} + for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); + const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]); + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + for(const [o,nw] of plan.map)repointHex(o,nw); + return plan.removed.length; +} +function setFamilyCount(baseHex,n){ + const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); + if(!fam)return; + const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; + const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n); + if(removed===null)return; + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); +} function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);} function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();} -function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();} function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);} function updateColor(){ if(selectedIdx===null){notify('click a palette color to select it first',true);return;} @@ -186,10 +257,17 @@ function updateColor(){ const newHex=curHex(); 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;} + // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count. + const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families; + const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase()); + const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0; 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); + if(fam&&count>0){ + const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); + regenFamilyInPlace(oldHexes,newHex,newName,count); + closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return; + } closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -285,61 +363,10 @@ 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);} -// --- 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]);renderPalette();buildTable();buildUITable(); - 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); -} + 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);} 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;} @@ -353,7 +380,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); @@ -1010,30 +1037,6 @@ 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". @@ -1074,3 +1077,104 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);} +// Family-strip gate (open with #familytest): the palette renders as the pinned +// ground strip plus hue families, chips keep their controls, and renaming a color +// to anything leaves it in the same strip (grouping is by hex, not name). +if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette(); + const strips=[...document.querySelectorAll('#pals .fstrip')]; + A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first'); + A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg'); + A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length); + const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red'); + A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls'); + const redFamily=redChip&&redChip.closest('.fstrip').dataset.family; + const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette(); + const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd'); + A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette(); + document.title='FAMILYTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Count-control gate (open with #counttest): the per-family count regenerates the +// family — count up adds symmetric steps, count down drops the extremes, a +// reference to a surviving step follows the new hex, a reference to a removed step +// is left on its old (now-gone) hex. +if(location.hash==='#counttest'){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)),saveSel=selectedIdx; + MAP['bg']='#000000';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change + const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down + UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; + UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; + selectedIdx=null;renderPalette(); + setFamilyCount('#67809c',1); + const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down'); + A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex'); + const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; + A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); + setFamilyCount('#67809c',3); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); + 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);selectedIdx=saveSel;renderPalette(); + document.title='COUNTTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Base-edit + ground-edit gate (open with #baseedittest): editing a family base +// recolors the whole family at the same count and references follow; editing a +// ground swatch writes the bg/fg assignment. +if(location.hash==='#baseedittest'){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)),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette();buildUITable(); + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); + document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; + updateColor(); + const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral); + A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex'); + A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length)); + A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette'); + A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex'); + // ground edit: select bg, change hex, MAP.bg follows + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a'); + document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground'; + updateColor(); + A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']); + 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);selectedIdx=saveSel;renderPalette(); + document.title='BASEEDITTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Round-trip gate (open with #roundtriptest): export stays a flat palette and +// import needs no family reconstruction, so export → import → export is identical. +if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const before=JSON.stringify(exportObj()); + applyImported(before); + const after=JSON.stringify(exportObj()); + A(before===after,'export → import → export is byte-identical'); + const obj=JSON.parse(after); + A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list'); + document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(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 a8cda815..b6e2fc73 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -448,21 +448,7 @@ 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="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index 4cdcd383..2f46602c 100755 --- a/scripts/theme-studio/run-tests.sh +++ b/scripts/theme-studio/run-tests.sh @@ -53,7 +53,7 @@ CHROME="" for c in google-chrome-stable google-chrome chromium chromium-browser; do if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi done -HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest" 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 79a7efe2..cc074dac 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -23,13 +23,16 @@ .cat{color:#b4b1a2} .ex{font-size:17px} .sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0} .sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496} - .pals{display:flex;gap:8px;flex-wrap:wrap} + .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start} + .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center} + .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center} .palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d} .palwarn .pwh{font-weight:bold;margin-bottom:2px} .palwarn .pwl{opacity:.92} .pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab} - .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} - .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0} + .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} .pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7} .pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6} .palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} @@ -62,16 +65,6 @@ .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} diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs new file mode 100644 index 00000000..c6602aeb --- /dev/null +++ b/scripts/theme-studio/test-families.mjs @@ -0,0 +1,213 @@ +// Unit tests for the color-families model (app-core.js): grouping a flat palette +// into hue families, regenerating a family's ramp, ranking members by lightness, +// and planning the assignment re-point across a regenerate. Phase 1 of the +// color-families spec. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies } from './app-core.js'; +import { oklch2hex, srgb2oklab, oklab2oklch } from './colormath.js'; + +// Build a palette entry at a controlled OKLCH hue so clustering is deterministic. +const at = (L, C, H, name) => [oklch2hex(L, C, H).hex, name || ('c' + H)]; + +// --- familiesFromPalette ---------------------------------------------------- + +test('familiesFromPalette: Normal — separated hues split into one family each', () => { + const pal = [at(0.6, 0.1, 30, 'red'), at(0.6, 0.1, 150, 'green'), at(0.6, 0.1, 270, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 3, 'three separated hues -> three families'); + assert.equal(ground.length, 2, 'ground strip carries bg and fg'); + for (const f of families) assert.equal(f.members.length, 1); +}); + +test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => { + const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 1, 'a near hue-pair is one family'); + assert.equal(families[0].members.length, 2); +}); + +test('familiesFromPalette: Boundary — well-separated hues split', () => { + const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 2); +}); + +test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => { + // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing + const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 2, 'not one chained family'); +}); + +test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => { + const paleBlue = oklch2hex(0.9, 0.03, 255).hex; // light, faint -> still blue + const midGray = oklch2hex(0.6, 0.025, 100).hex; // mid, faint -> reads neutral + const { families } = familiesFromPalette([[paleBlue, 'paleblue'], [midGray, 'graytone']], { bg: '#000000', fg: '#ffffff' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral && neutral.members.some(m => m.name === 'graytone'), 'mid faint color is neutral'); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'paleblue')), 'pale tint stays chromatic'); +}); + +test('familiesFromPalette: Boundary — near-neutral colors form a separate family', () => { + const pal = [at(0.6, 0.1, 250, 'blue'), at(0.5, 0.004, 250, 'gray')]; // gray below the chroma threshold + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral, 'a neutral family exists'); + assert.ok(neutral.members.some(m => m.name === 'gray')); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue'))); +}); + +// --- real-palette grouping (the hard cases the color-sorting reviews measured) --- + +// The contested region of the distinguished/sterling palette: the gold ramp and +// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart. +const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']]; +const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']]; +const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name)); + +test('familiesFromPalette: Normal — the gold and olive ramps separate', () => { + const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' }); + const gold = famOf(families, 'yellow'), olive = famOf(families, 'green'); + assert.notEqual(gold, olive, 'gold and olive are different families'); + assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens'); + assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows'); +}); + +test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => { + // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together + const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + const blue = famOf(families, 'blue'); + assert.equal(blue.members.length, 3, 'all three blues in one family'); +}); + +test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => { + const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch + const neutral = families.find(f => f.neutral); + for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral'); + assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic'); +}); + +test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => { + // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart + const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family'); +}); + +test('familiesFromPalette: Boundary — grouping is independent of palette order', () => { + const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']]; + const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families + .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | '); + const ref = key(base); + for (const seed of [1, 2, 3]) { // a few deterministic shuffles + const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]); + assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping'); + } +}); + +test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => { + const pal = [at(0.6, 0.1, 250, 'blue')]; + const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.equal(ground.length, 2); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a' && g.role === 'bg')); + assert.ok(ground.some(g => g.role === 'fg')); +}); + +test('familiesFromPalette: Boundary — a chip at a ground hex is not duplicated into a family', () => { + const pal = [['#0d0b0a', 'ground'], at(0.6, 0.1, 250, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a')); + assert.ok(!families.some(f => f.members.some(m => m.hex.toLowerCase() === '#0d0b0a')), 'ground chip stays out of families'); +}); + +// --- regenFamily ------------------------------------------------------------ + +test('regenFamily: Normal — n steps each side plus the base, ordered by offset', () => { + const r = regenFamily('#67809c', 2); + assert.equal(r.members.length, 5); + assert.deepEqual(r.members.map(m => m.offset), [-2, -1, 0, 1, 2]); + assert.equal(r.members.find(m => m.offset === 0).hex, '#67809c'); +}); + +test('regenFamily: Boundary — n=0 is the base alone, no ramp() clamp to 1', () => { + const r = regenFamily('#67809c', 0); + assert.deepEqual(r.members, [{ hex: '#67809c', offset: 0, clamped: false }]); +}); + +test('regenFamily: Error — a malformed base returns a structured bad-hex', () => { + assert.deepEqual(regenFamily('nope', 2), { members: [], error: 'bad-hex' }); +}); + +// --- rankByLightness -------------------------------------------------------- + +test('rankByLightness: Normal — offsets are signed distance from the base by lightness', () => { + const members = regenFamily('#67809c', 2).members.map(m => m.hex); + const ranked = rankByLightness(members, '#67809c'); + const base = ranked.find(m => m.hex === '#67809c'); + assert.equal(base.offset, 0); + const sorted = [...ranked].sort((a, b) => a.offset - b.offset); + assert.deepEqual(sorted.map(m => m.offset), [-2, -1, 0, 1, 2]); +}); + +test('rankByLightness: Boundary — a base not among the members ranks by nearest lightness', () => { + const members = ['#222222', '#888888', '#dddddd']; + const ranked = rankByLightness(members, '#8a8a8a'); // near the mid member + const mid = ranked.find(m => m.hex === '#888888'); + assert.equal(mid.offset, 0, 'nearest-lightness member is the base rank'); +}); + +// --- stepRepointPlan -------------------------------------------------------- + +test('stepRepointPlan: Normal — surviving offsets map old->new, changed hex only', () => { + const oldR = [{ hex: '#111111', offset: -1 }, { hex: '#222222', offset: 0 }, { hex: '#333333', offset: 1 }]; + const neu = [{ hex: '#111111', offset: -1 }, { hex: '#aaaaaa', offset: 0 }, { hex: '#444444', offset: 1 }]; + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(removed, []); + assert.deepEqual(map, [['#222222', '#aaaaaa'], ['#333333', '#444444']]); // -1 unchanged, skipped +}); + +test('stepRepointPlan: Boundary — an offset with no new counterpart is removed, not repointed', () => { + const oldR = [{ hex: '#000033', offset: -3 }, { hex: '#222222', offset: 0 }]; + const neu = [{ hex: '#222222', offset: 0 }]; // count dropped, -3 gone + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(map, []); + assert.deepEqual(removed, ['#000033']); +}); + +// --- sortFamilies ----------------------------------------------------------- + +const fam = (baseHex, neutral, members) => ({ base: baseHex, neutral: !!neutral, members: (members || [baseHex]).map(h => ({ hex: h, name: h })) }); + +test('sortFamilies: Normal — chromatic families order by base hue', () => { + const fams = [fam(oklch2hex(0.6, 0.1, 270).hex), fam(oklch2hex(0.6, 0.1, 30).hex), fam(oklch2hex(0.6, 0.1, 150).hex)]; + const sorted = sortFamilies(fams); + const hues = sorted.map(f => Math.round(oklab2oklch(srgb2oklab(f.base)).H)); + for (let i = 1; i < hues.length; i++) assert.ok(hues[i] > hues[i - 1], 'ascending hue: ' + hues.join(',')); +}); + +test('sortFamilies: Boundary — neutral families pin ahead of chromatic ones', () => { + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false), fam('#808080', true)]); + assert.equal(sorted[0].neutral, true, 'neutral first'); + assert.equal(sorted[1].neutral, false); +}); + +test('sortFamilies: Normal — members within a family sort dark to light', () => { + const members = ['#dddddd', '#222222', '#888888']; + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false, members)]); + const ls = sorted[0].members.map(m => oklab2oklch(srgb2oklab(m.hex)).L); + for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'ascending lightness'); +}); + +test('sortFamilies: Boundary — order is (hue, then lightness); a hue tie falls to lightness', () => { + const bases = [oklch2hex(0.6, 0.1, 200).hex, oklch2hex(0.5, 0.1, 200).hex, oklch2hex(0.6, 0.1, 40).hex]; + const sorted = sortFamilies(bases.map(b => fam(b, false))); + const key = h => { const c = oklab2oklch(srgb2oklab(h)); return [Math.round(c.H), c.L]; }; + for (let i = 1; i < sorted.length; i++) { + const [h0, l0] = key(sorted[i - 1].base), [h1, l1] = key(sorted[i].base); + assert.ok(h0 < h1 || (h0 === h1 && l0 <= l1), `order at ${i}: hue ${h0}/${h1} L ${l0.toFixed(3)}/${l1.toFixed(3)}`); + } +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index a9b030f9..33358704 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -25,13 +25,16 @@ .cat{color:#b4b1a2} .ex{font-size:17px} .sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0} .sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496} - .pals{display:flex;gap:8px;flex-wrap:wrap} + .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start} + .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center} + .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center} .palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d} .palwarn .pwh{font-weight:bold;margin-bottom:2px} .palwarn .pwl{opacity:.92} .pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab} - .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} - .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0} + .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} .pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7} .pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6} .palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} @@ -64,16 +67,6 @@ .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} @@ -111,21 +104,7 @@ <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="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> @@ -526,6 +505,137 @@ function lMax(hue,chroma,fgSet,target){ 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'}; } + +// --- color families (color-families spec, Phase 1) --------------------------- +// Families are a display grouping derived from the hex every render — never from +// names — so renaming a color can't move it. The flat palette stays the editable +// truth; these pure functions group it, regenerate a family's ramp, and plan the +// assignment re-point across a regenerate. + +function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));} +function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} + +// A color reads as neutral below this chroma. Lightness-scaled (the Munsell +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} +// A family from its members: base is the most-saturated member (tie toward +// mid-lightness), the anchor for a generated ramp. +function makeFamily(ms,neutral){ + let base=ms[0]; + for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m; + return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))}; +} +// Group a flat palette into the ground strip plus families. ground is {bg,fg}: +// those two hexes form the pinned ground strip even when absent from the palette, +// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals +// (chroma below the lightness-scaled threshold) form one neutral family; the rest +// cluster by lightness-conditioned complete linkage (clusterChromatic). +function familiesFromPalette(palette,ground){ + const bg=ground&&ground.bg,fg=ground&&ground.fg; + const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase())); + const groundStrip=[]; + if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)}); + if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)}); + const neutrals=[],chromatic=[]; + for(const [hex,name] of palette){ + if(gset.has(hex.toLowerCase()))continue; + const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H}; + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); + } + const families=[]; + if(neutrals.length)families.push(makeFamily(neutrals,true)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false)); + return {ground:groundStrip,families}; +} +// Regenerate a family's members as a symmetric ramp around the base: n=0 is the +// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted +// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}. +function regenFamily(baseHex,n,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {members:[],error:'bad-hex'}; + const k=Math.min(4,Math.max(0,Math.round(n||0))); + if(k===0)return {members:[{hex,offset:0,clamped:false}]}; + const r=ramp(hex,Object.assign({},opts,{n:k})); + if(r.error)return {members:[],error:r.error}; + const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset); + return {members}; +} +// Rank a family's current member hexes by lightness and give each a signed offset +// from the base (the matching hex, or the nearest by lightness if the base isn't +// present). Lets a regenerate match old positions to new ramp offsets. +function rankByLightness(memberHexes,baseHex){ + const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L); + let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase()); + if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});} + return items.map((m,i)=>({hex:m.hex,offset:i-bi})); +} +// Plan the assignment re-point for a regenerate: for each old ranked member, the +// new member at the same offset is the same position. {map:[[old,new]]} for +// positions whose hex changed; {removed:[hex]} for positions with no new +// counterpart (the caller leaves their references a visible "(gone)"). +function stepRepointPlan(oldRanked,newMembers){ + const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[]; + for(const o of oldRanked){ + const nh=byOff.get(o.offset); + if(nh===undefined)removed.push(o.hex); + else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]); + } + return {map,removed}; +} + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} // 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 @@ -664,7 +774,23 @@ function buildTable(){ tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);} } -let dragFrom=null,selectedIdx=null; +let 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. @@ -676,35 +802,90 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`; w.innerHTML=html;w.style.display='block'; } +// One palette chip for PALETTE[i], with its remove / rename / select handlers. +// Families sort deterministically, so the old move-arrow / drag reordering is gone. +function paletteChip(i,nearest){ + const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i]; + const locked=(hex===MAP['bg']||hex===MAP['p']); + const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; + d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); + 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}<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();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; + d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);}; + return d; +} +// Render the palette as hue families: the pinned ground strip, then hue-sorted +// family strips, each dark to light. Grouping is derived from the hex by +// familiesFromPalette every render, so renaming a color never moves it. The flat +// PALETTE stays the editable truth; chips keep their per-chip controls. function renderPalette(){ const p=document.getElementById('pals');p.innerHTML=''; const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex); - const nde=nearest[i]; - const locked=(hex===MAP['bg']||hex===MAP['p']); - const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true; - d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3)); - const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">‹</button>`:''; - 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(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();}; - d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);}; - d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');}; - d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));}; - d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');}; - d.ondragleave=()=>d.classList.remove('over'); - d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();}; - p.appendChild(d);}); + const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const used=new Set(); + const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;}; + const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; + const gs=strip(' ground');gs.dataset.family='ground'; + ground.forEach(g=>{ + const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex); + if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));} + else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex; + sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);} + }); + // The too-similar warning stays on the full flat palette: a generated ramp's + // steps are a stepL apart (well above the warning's ΔE threshold), so they never + // trigger it, and any pair that does is a genuine near-duplicate worth flagging. + sortFamilies(families).forEach(f=>{ + const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + if(!f.neutral)s.appendChild(familyCountControl(f)); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } +// The per-family count control under a chromatic strip. Its value is the family's +// current per-side reach; setting N regenerates the family as base ±N. +function familyCountControl(f){ + const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset))); + const d=document.createElement('div');d.className='fcount'; + d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">± <input type="number" min="0" max="4" value="${per}"></span>`; + d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); + return d; +} +// Regenerate a family as a symmetric base ±N ramp, replacing its current members. +// References to a surviving position (matched by signed lightness rank) follow the +// new hex; references to a position removed by lowering N leave their old hex, +// which is no longer in the palette and so renders as "(gone)". +// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving +// references and leaving removed ones on their now-gone hex. Returns the removed +// count, or null on a bad base. Shared by the count control and the base edit. +function regenFamilyInPlace(oldHexes,baseHex,baseName,n){ + const r=regenFamily(baseHex,n,{}); + if(r.error){notify('cannot regenerate from '+baseHex,true);return null;} + const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members); + const oldSet=new Set(oldHexes.map(h=>h.toLowerCase())); + let at=PALETTE.length; + for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;} + for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); + const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]); + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + for(const [o,nw] of plan.map)repointHex(o,nw); + return plan.removed.length; +} +function setFamilyCount(baseHex,n){ + const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); + if(!fam)return; + const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; + const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n); + if(removed===null)return; + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); +} function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);} function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();} -function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();} function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);} function updateColor(){ if(selectedIdx===null){notify('click a palette color to select it first',true);return;} @@ -712,10 +893,17 @@ function updateColor(){ const newHex=curHex(); 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;} + // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count. + const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families; + const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase()); + const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0; 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); + if(fam&&count>0){ + const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); + regenFamilyInPlace(oldHexes,newHex,newName,count); + closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return; + } closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -811,61 +999,10 @@ 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);} -// --- 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]);renderPalette();buildTable();buildUITable(); - 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); -} + 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);} 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;} @@ -879,7 +1016,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); @@ -1536,30 +1673,6 @@ 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". @@ -1600,4 +1713,105 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);} +// Family-strip gate (open with #familytest): the palette renders as the pinned +// ground strip plus hue families, chips keep their controls, and renaming a color +// to anything leaves it in the same strip (grouping is by hex, not name). +if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette(); + const strips=[...document.querySelectorAll('#pals .fstrip')]; + A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first'); + A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg'); + A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length); + const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red'); + A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls'); + const redFamily=redChip&&redChip.closest('.fstrip').dataset.family; + const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette(); + const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd'); + A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette(); + document.title='FAMILYTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Count-control gate (open with #counttest): the per-family count regenerates the +// family — count up adds symmetric steps, count down drops the extremes, a +// reference to a surviving step follows the new hex, a reference to a removed step +// is left on its old (now-gone) hex. +if(location.hash==='#counttest'){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)),saveSel=selectedIdx; + MAP['bg']='#000000';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change + const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down + UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; + UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; + selectedIdx=null;renderPalette(); + setFamilyCount('#67809c',1); + const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down'); + A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex'); + const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; + A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); + setFamilyCount('#67809c',3); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); + 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);selectedIdx=saveSel;renderPalette(); + document.title='COUNTTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Base-edit + ground-edit gate (open with #baseedittest): editing a family base +// recolors the whole family at the same count and references follow; editing a +// ground swatch writes the bg/fg assignment. +if(location.hash==='#baseedittest'){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)),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette();buildUITable(); + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); + document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; + updateColor(); + const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral); + A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex'); + A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length)); + A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette'); + A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex'); + // ground edit: select bg, change hex, MAP.bg follows + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a'); + document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground'; + updateColor(); + A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']); + 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);selectedIdx=saveSel;renderPalette(); + document.title='BASEEDITTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Round-trip gate (open with #roundtriptest): export stays a flat palette and +// import needs no family reconstruction, so export → import → export is identical. +if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const before=JSON.stringify(exportObj()); + applyImported(before); + const after=JSON.stringify(exportObj()); + A(before===after,'export → import → export is byte-identical'); + const obj=JSON.parse(after); + A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list'); + document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} </script>
\ No newline at end of file |
