function clearPalette(){ normalizePalette(); const plan=clearPalettePlan(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); plan.removed.forEach(({hex,name})=>rememberGone(hex,name)); PALETTE=plan.palette;selectedIdx=null; refreshPaletteState(); notify('cleared palette to bg and fg',false); } 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 faces, 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 repointFaceFields(face,oldHex,newHex){ if(face.fg===oldHex)face.fg=newHex; if(face.bg===oldHex)face.bg=newHex; if(face.box&&face.box.color===oldHex)face.box.color=newHex; } function repointHex(oldHex,newHex){ if(oldHex===newHex)return; for(const k in SYNTAX){repointFaceFields(SYNTAX[k],oldHex,newHex);syncSyntaxCache(k);} for(const f in UIMAP)repointFaceFields(UIMAP[f],oldHex,newHex); for(const ap in PKGMAP)for(const fc in PKGMAP[ap])repointFaceFields(PKGMAP[ap][fc],oldHex,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;} function rememberGone(hex,name){if(name)lastGone[name.toLowerCase()]=hex;} function normalizePaletteEntry(entry){ const hex=entry&&entry[0],name=(entry&&entry[1])||'color'; return [hex,name,(entry&&entry[2])||columnIdOf(entry)]; } function ensureGroundEndpoints(){ const ground={bg:MAP['bg'],fg:MAP['p']}; if(ground.bg&&!PALETTE.some(entry=>groundRoleOfEntry(entry,ground)==='bg'))PALETTE.unshift([ground.bg,'bg','ground']); if(ground.fg&&!PALETTE.some(entry=>groundRoleOfEntry(entry,ground)==='fg'))PALETTE.push([ground.fg,'fg','ground']); } function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);ensureGroundEndpoints();} // The ground column is explicit: bg pins the top endpoint, fg pins the bottom // endpoint, and generated ground+N steps live between them. function groundColumnMembers(){ return groundColumnMembersFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); } function groundSpanCount(){return PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step').length;} function groundSpanControl(){ const d=document.createElement('div');d.className='fcount'; d.innerHTML=`span `; d.querySelector('input').onchange=(e)=>setGroundSpan(Math.max(0,Math.min(8,parseInt(e.target.value,10)||0))); return d; } function setGroundSpan(n){ const old=PALETTE.filter(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='step'); const bg=srgb2oklab(MAP['bg']),fg=srgb2oklab(MAP['p']); const entries=[]; let step=1; for(let i=1;i<=n;i++){ const t=i/(n+1); const lab={L:bg.L+(fg.L-bg.L)*t,a:bg.a+(fg.a-bg.a)*t,b:bg.b+(fg.b-bg.b)*t}; const hex=lrgb2hex(oklab2lrgb(lab.L,lab.a,lab.b)); if(hex.toLowerCase()==='#ffffff'||hex.toLowerCase()==='#000000')continue; entries.push([hex,'ground+'+(step++),'ground']); } for(const [oldHex,oldName] of old){ const next=entries.find(([,name])=>name===oldName); if(next&&next[0].toLowerCase()!==oldHex.toLowerCase())repointHex(oldHex,next[0]); } for(let i=PALETTE.length-1;i>=0;i--)if(groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']})==='step')PALETTE.splice(i,1); let at=PALETTE.findIndex(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='bg'); if(at<0)at=0; else at+=1; PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); selectedIdx=null;refreshPaletteState(); notify('set ground span to '+n,false); } // 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. function renderPaletteWarnings(warnings,overflow){ const w=document.getElementById('palwarn');if(!w)return; if(!warnings.length){w.style.display='none';w.innerHTML='';return;} let html='
too-similar colors
'; html+=warnings.map(p=>`
${esc(p.aName+' / '+p.bName)} — \u0394E ${p.dE.toFixed(3)}, hard to distinguish
`).join(''); 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 role=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']}); const locked=(role==='bg'||role==='fg'); const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; d.dataset.paletteIndex=String(i); 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();rememberGone(hex,name);PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;refreshPaletteState({code:false,ground:false});}; const nm=d.querySelector('.nm'); const finishNameEdit=()=>{nm.readOnly=true;nm.classList.remove('editing');}; nm.onclick=(e)=>{e.preventDefault();e.stopPropagation();selectColor(i);}; nm.ondblclick=(e)=>{e.preventDefault();e.stopPropagation();nm.readOnly=false;nm.classList.add('editing');nm.focus();nm.setSelectionRange(0,0);}; nm.onblur=finishNameEdit; nm.onkeydown=(e)=>{if(e.key==='Enter'){e.preventDefault();nm.blur();}else if(e.key==='Escape'){e.preventDefault();nm.value=PALETTE[i][1];nm.blur();}}; nm.onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; d.onclick=(e)=>{if(e.target.closest('.rm'))return;selectColor(i);}; return d; } function setChipPreviewColor(i,hex){ const chip=document.querySelector('#pals .pchip[data-palette-index="'+i+'"]');if(!chip)return; const tc=textOn(hex);chip.style.background=hex; chip.querySelectorAll('.nm,.hx,.rm,.lock').forEach(e=>e.style.color=tc); } function previewSelectedChip(hex){if(selectedIdx===null)return;setChipPreviewColor(selectedIdx,hex);} function restoreSelectedChip(){if(selectedIdx===null||!PALETTE[selectedIdx])return;setChipPreviewColor(selectedIdx,PALETTE[selectedIdx][0]);} function paletteIndexByHexName(hex,name){ for(let i=0;im.hex.toLowerCase()===f.base.toLowerCase())||f.members[0]; const i=paletteIndexByHexName(baseMember.hex,baseMember.name); if(i>=0)selectColor(i); } function isGroundEntry(entry){ return !!groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']}); } function moveColumn(columnId,dir){ normalizePalette(); const columns=sortColumns(columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns); const pos=columns.findIndex(f=>f.column===columnId); const next=columns[pos+dir]; if(pos<0||!next)return; const moving=[],rest=[]; PALETTE.forEach(entry=>{ if(!isGroundEntry(entry)&&columnIdOf(entry)===columnId)moving.push(entry); else rest.push(entry); }); const nextPositions=[]; rest.forEach((entry,i)=>{if(!isGroundEntry(entry)&&columnIdOf(entry)===next.column)nextPositions.push(i);}); if(!nextPositions.length)return; const at=dir<0?nextPositions[0]:nextPositions[nextPositions.length-1]+1; PALETTE=rest.slice(0,at).concat(moving,rest.slice(at)); selectedIdx=null;refreshPaletteState(); notify('moved "'+columnId+'" '+(dir<0?'left':'right'),false); } function deleteColumn(columnId,label){ normalizePalette(); const plan=deletePaletteColumnPlan(PALETTE,{bg:MAP['bg'],fg:MAP['p']},columnId); if(!plan.removed.length){notify('nothing to delete in "'+(label||columnId)+'"',true);return;} const title=label||columnId; if(!confirm('Delete color column "'+title+'"?\n\nThis removes '+plan.removed.length+' palette color(s). Existing face assignments will stay on their old hex values and show as "(gone)".'))return; plan.removed.forEach(({hex,name})=>rememberGone(hex,name)); PALETTE=plan.palette;selectedIdx=null; refreshPaletteState(); notify('deleted column "'+title+'" — '+plan.removed.length+' color(s) now show "(gone)" where used',false); } function columnHeader(f,position,count){ const h=document.createElement('div');h.className='fhead'; const label=(f.members.find(m=>m.hex.toLowerCase()===f.base.toLowerCase())||{}).name||f.column||f.base; h.innerHTML=``; h.querySelector('.ctitle').textContent=label; h.querySelector('.ctitle').onclick=()=>selectColumnBase(f); h.querySelector('.left').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,-1);}; h.querySelector('.right').onclick=(e)=>{e.stopPropagation();moveColumn(f.column,1);}; h.querySelector('.cdel').onclick=(e)=>{e.stopPropagation();deleteColumn(f.column,label);}; return h; } // Render the palette as structural color columns: pinned ground column, then // first-seen palette columns. Grouping uses the stable column id stored on each // palette entry, so renaming a color never moves it. function renderPalette(){ normalizePalette(); const p=document.getElementById('pals');p.innerHTML=''; const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); const {ground,columns}=columnsFromPalette(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;}; if(ground.length){ const gs=strip(' ground');gs.dataset.column='ground'; const gh=document.createElement('div');gh.className='fhead';gh.textContent='ground';gs.appendChild(gh); gs.appendChild(groundSpanControl()); groundColumnMembers().forEach(m=>{ const i=idxOf(m.hex,m.name); if(i>=0)gs.appendChild(paletteChip(i,nearest)); else{const tc=textOn(m.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=m.hex;sw.title=(m.name||'ground')+' '+m.hex; sw.innerHTML=`
${m.hex}
`;gs.appendChild(sw);} }); } // The too-similar warning stays on the full flat palette, so large spans can // still expose genuinely hard-to-distinguish neighboring colors. const ordered=sortColumns(columns); ordered.forEach((f,pos)=>{ const s=strip('');s.dataset.column=f.column||f.base; s.appendChild(columnHeader(f,pos,ordered.length)); s.appendChild(columnCountControl(f)); 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(); } // The per-column count control under a chromatic strip. Its value is the column's // current per-side reach; setting N regenerates the column as base ±N. function columnCountControl(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 ± `; d.querySelector('input').onchange=(e)=>setColumnCount(f.base,Math.max(0,Math.min(8,parseInt(e.target.value,10)||0))); return d; } // Regenerate a column as a symmetric base ±N span, 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 regenColumnInPlace(oldHexes,baseHex,baseName,n,columnId){ const r=regenColumn(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=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); const col=columnId||columnStem(baseName); const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset)),col]); PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); for(const [o,nw] of plan.map)repointHex(o,nw); return plan.removed.length; } function setColumnCount(baseHex,n){ const {columns}=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); const column=columns.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); if(!column)return; const baseName=(column.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; const removed=regenColumnInPlace(column.members.map(m=>m.hex),baseHex,baseName,n,column.column); if(removed===null)return; selectedIdx=null;refreshPaletteState(); notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); }