From 111687b0ee35634d8b111c211f2600161d53e4af Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 10 Jun 2026 00:25:20 -0500 Subject: feat(theme-studio): render the palette as hue family strips The palette panel is now a stack of strips: the pinned ground strip (bg, fg) first, then hue-sorted family strips, each dark to light. Grouping comes from familiesFromPalette off the hex every render, so renaming a color never moves it. The flat PALETTE stays the editable truth and chips keep their per-chip remove / rename / select; the move-arrow and drag reordering are gone since the sort is deterministic now (moveColor and the drag state with them). Phase 3 of the color-families spec. A #familytest gate checks the ground strip pins first, families render, chips keep their controls, and a color renamed to anything stays in the same strip. Existing palette flows (delta, heal, ramp gates) stay green. --- scripts/theme-studio/app.js | 76 +++++++++++++++++++++--------- scripts/theme-studio/run-tests.sh | 2 +- scripts/theme-studio/styles.css | 8 ++-- scripts/theme-studio/theme-studio.html | 84 ++++++++++++++++++++++++---------- 4 files changed, 119 insertions(+), 51 deletions(-) (limited to 'scripts') diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index c3050548..10510591 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -138,7 +138,7 @@ 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. @@ -166,35 +166,47 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`
and ${overflow} more
`; 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?`🔒`:``; + d.innerHTML=`${rm}
${hex}
`; + 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?``:''; - const rgt=i›`:''; - const rm=locked?`🔒`:``; - d.innerHTML=`${rm}${lft}${rgt}
${hex}
`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; - if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; - if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; - d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; - 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{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=`
${g.hex}
`;gs.appendChild(sw);} + }); + sortFamilies(families).forEach(f=>{ + const s=strip('');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } 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;} @@ -1113,3 +1125,23 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);} diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index 1ef748f4..7ae24ed7 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 healtest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest healtest familytest" 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..b1962f5b 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -23,13 +23,15 @@ .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:column;gap:8px} + .fstrip{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fstrip .slabel{font:9pt monospace;color:#6f6a5e;min-width:30px;text-align:right;padding-right:4px} .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} diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 27df2a83..55c0a1ea 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -25,13 +25,15 @@ .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:column;gap:8px} + .fstrip{display:flex;gap:8px;flex-wrap:wrap;align-items:center;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fstrip .slabel{font:9pt monospace;color:#6f6a5e;min-width:30px;text-align:right;padding-right:4px} .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} @@ -769,7 +771,7 @@ 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. @@ -797,35 +799,47 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`
and ${overflow} more
`; 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?`🔒`:``; + d.innerHTML=`${rm}
${hex}
`; + 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?``:''; - const rgt=i›`:''; - const rm=locked?`🔒`:``; - d.innerHTML=`${rm}${lft}${rgt}
${hex}
`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; - if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; - if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; - d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; - 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{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=`
${g.hex}
`;gs.appendChild(sw);} + }); + sortFamilies(families).forEach(f=>{ + const s=strip('');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } 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;} @@ -1744,4 +1758,24 @@ if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c 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);} \ No newline at end of file -- cgit v1.2.3