aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/theme-studio/app-core.js31
-rw-r--r--scripts/theme-studio/app.js35
-rw-r--r--scripts/theme-studio/test-app-core.mjs6
-rw-r--r--scripts/theme-studio/test-columns.mjs19
-rw-r--r--scripts/theme-studio/theme-studio.html64
5 files changed, 111 insertions, 44 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 5a7a14ab..3e9e93d8 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -135,10 +135,27 @@ function lMax(hue,chroma,fgSet,target){
// 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 columnStem(name){name=name||'color';if(/^color-\d+$/.test(name))return name;name=name.replace(/[+-]\d+$/,'');return name.replace(/\d+$/,'')||'color';}
function columnOffset(name){const m=(name||'').match(/([+-]\d+)$/);return m?parseInt(m[1],10):0;}
function columnIdOf(entry){return (entry&&entry[2])||columnStem(entry&&entry[1]);}
+function groundRoleOfEntry(entry,ground){
+ if(!entry)return null;
+ const [hex,name]=entry,col=entry[2],n=(name||'').toLowerCase(),h=(hex||'').toLowerCase();
+ const bg=(ground&&ground.bg||'').toLowerCase(),fg=(ground&&ground.fg||'').toLowerCase();
+ if(/^ground-\d+$/i.test(name||''))return 'step';
+ if(col==='ground'){
+ if(bg&&h===bg)return 'bg';
+ if(fg&&h===fg)return 'fg';
+ return 'step';
+ }
+ if(bg&&h===bg&&(n==='bg'||n==='ground'))return 'bg';
+ if(fg&&h===fg&&n==='fg')return 'fg';
+ return null;
+}
+function nameOfGroundRole(palette,ground,role){
+ const found=palette.find(entry=>groundRoleOfEntry(entry,ground)===role);
+ return found?found[1]:null;
+}
// Group a flat palette into the ground strip plus structural columns. ground is
// {bg,fg}; those endpoint hexes form the pinned ground column even when absent
@@ -147,15 +164,13 @@ function columnIdOf(entry){return (entry&&entry[2])||columnStem(entry&&entry[1])
// Legacy two-field entries fall back to their generated-name stem until edited.
function columnsFromPalette(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)});
+ if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfGroundRole(palette,ground,'bg')});
+ if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfGroundRole(palette,ground,'fg')});
const byColumn=new Map(),columns=[];
for(const entry of palette){
const [hex,name]=entry;
- if(gset.has(hex.toLowerCase()))continue;
- if(/^ground-\d+$/i.test(name||''))continue;
+ if(groundRoleOfEntry(entry,ground))continue;
const column=columnIdOf(entry);
if(!byColumn.has(column))byColumn.set(column,{column,members:[]});
byColumn.get(column).members.push({hex,name,offset:columnOffset(name),column});
@@ -217,10 +232,10 @@ function paletteOptionList(cur,palette,ground){
const add=(hex,name)=>{if(!hex)return;const key=hex.toLowerCase()+'|'+(name||'');if(seen.has(key))return;seen.add(key);out.push([hex,name||hex]);};
const grouped=columnsFromPalette(palette,ground||{});
const groundMembers=grouped.ground.map(g=>({hex:g.hex,name:g.name||g.role}))
- .concat(palette.filter(([,name])=>/^ground-\d+$/i.test(name||'')).map(([hex,name])=>({hex,name})));
+ .concat(palette.filter(entry=>groundRoleOfEntry(entry,ground)==='step').map(([hex,name])=>({hex,name})));
sortColumnMembers({base:(ground&&ground.bg)||'',members:groundMembers}).members.forEach(m=>add(m.hex,m.name));
sortColumns(grouped.columns).forEach(f=>f.members.forEach(m=>add(m.hex,m.name)));
return out;
}
-export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers };
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index f38df6bb..7aa6932f 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -165,12 +165,12 @@ function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);}
// endpoint, and generated ground-N steps live between them.
function groundColumnMembers(){
const members=[];
- for(const [hex,name] of PALETTE)if(hex.toLowerCase()===MAP['bg'].toLowerCase()||hex.toLowerCase()===MAP['p'].toLowerCase()||/^ground-\d+$/i.test(name||''))members.push({hex,name});
- if(!members.some(m=>m.hex.toLowerCase()===MAP['bg'].toLowerCase()))members.push({hex:MAP['bg'],name:'bg'});
- if(!members.some(m=>m.hex.toLowerCase()===MAP['p'].toLowerCase()))members.push({hex:MAP['p'],name:'fg'});
+ for(const entry of PALETTE)if(groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']}))members.push({hex:entry[0],name:entry[1]});
+ if(!PALETTE.some(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='bg'))members.push({hex:MAP['bg'],name:'bg'});
+ if(!PALETTE.some(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='fg'))members.push({hex:MAP['p'],name:'fg'});
return members.sort((a,b)=>oklab2oklch(srgb2oklab(a.hex)).L-oklab2oklch(srgb2oklab(b.hex)).L);
}
-function groundSpanCount(){return PALETTE.filter(([,name])=>/^ground-\d+$/i.test(name||'')).length;}
+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 title="number of ground colors between bg and fg">span <input type="number" min="0" max="8" value="${groundSpanCount()}"></span>`;
@@ -178,7 +178,7 @@ function groundSpanControl(){
return d;
}
function setGroundSpan(n){
- const old=PALETTE.filter(([,name])=>/^ground-\d+$/i.test(name||''));
+ 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=[];
for(let i=1;i<=n;i++){
@@ -190,8 +190,8 @@ function setGroundSpan(n){
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(/^ground-\d+$/i.test(PALETTE[i][1]||''))PALETTE.splice(i,1);
- let at=PALETTE.findIndex(([hex])=>hex.toLowerCase()===MAP['bg'].toLowerCase());
+ 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;renderPalette();buildTable();buildUITable();renderCode();applyGround();
@@ -212,10 +212,11 @@ function renderPaletteWarnings(warnings,overflow){
// 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 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.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}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
+ const rm=locked?`<span class="lock" title="${role==='bg'?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</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();};
@@ -232,9 +233,7 @@ function selectColumnBase(f){
if(i>=0)selectColor(i);
}
function isGroundEntry(entry){
- const [hex,name]=entry;
- const lower=(hex||'').toLowerCase();
- return lower===MAP['bg'].toLowerCase()||lower===MAP['p'].toLowerCase()||/^ground-\d+$/i.test(name||'');
+ return !!groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']});
}
function moveColumn(columnId,dir){
normalizePalette();
@@ -345,7 +344,7 @@ function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();}
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;}
- const i=selectedIdx,oldHex=PALETTE[i][0];
+ const i=selectedIdx,oldHex=PALETTE[i][0],oldRole=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']});
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;}
@@ -355,7 +354,9 @@ function updateColor(){
const count=column?Math.max(0,...rankByLightness(column.members.map(m=>m.hex),column.base).map(m=>Math.abs(m.offset))):0;
const columnId=PALETTE[i][2]||columnStem(PALETTE[i][1]);
PALETTE[i]=[newHex,newName,columnId];
- repointHex(oldHex,newHex);
+ const duplicateOldHex=PALETTE.some((p,j)=>j!==i&&p[0].toLowerCase()===oldHex.toLowerCase());
+ if(oldRole==='bg'||oldRole==='fg')repointHex(oldHex,newHex);
+ else if(!duplicateOldHex&&oldHex!==MAP['bg']&&oldHex!==MAP['p'])repointHex(oldHex,newHex);
if(column&&count>0){
const oldHexes=column.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
regenColumnInPlace(oldHexes,newHex,newName,count,column.column||columnId);
@@ -1298,6 +1299,12 @@ if(location.hash==='#columntest'||location.hash==='#familytest'){let ok=true;con
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.column===redColumn,'a renamed color stays in the same strip');
+ PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#0d0b0a','bg2'],['#0d0b0a','bg-alt']];MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';selectedIdx=null;renderPalette();
+ const bg2Chip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='bg2');
+ A(!!bg2Chip&&bg2Chip.closest('.fstrip').dataset.column==='bg'&&!!bg2Chip.querySelector('.rm')&&!bg2Chip.querySelector('.lock'),'same-hex bg2 remains a normal removable color column chip');
+ if(bg2Chip){bg2Chip.click();document.getElementById('newhexstr').value='#101820';document.getElementById('newname').value='bg2';updateColor();}
+ A(MAP['bg']==='#0d0b0a','editing same-hex bg2 does not repoint the real bg assignment');
+ A(PALETTE.some(p=>p[1]==='bg2'&&p[0]==='#101820'),'editing same-hex bg2 updates only that palette tile');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette();
document.title='COLUMNTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 0cdc37b5..69fe896e 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -64,6 +64,12 @@ test('paletteOptionList: Boundary — assignment-only ground colors are selectab
assert.ok(list.some(([hex, name]) => hex === '#f0fef0' && name === 'fg'));
});
+test('paletteOptionList: Boundary — bg-like imported colors remain selectable outside ground', () => {
+ const pal = [['#0d0b0a', 'bg2'], ['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']];
+ const list = paletteOptionList('', pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ assert.deepEqual(list.slice(0, 4), [['', '— default —'], ['#0d0b0a', 'bg'], ['#f0fef0', 'fg'], ['#0d0b0a', 'bg2']]);
+});
+
test('paletteOptionList: Error — a cur outside palette and ground is surfaced as gone', () => {
const list = paletteOptionList('#123456', PAL, { bg: '#0d0b0a', fg: '#f0fef0' });
assert.deepEqual(list[0], ['', '— default —']);
diff --git a/scripts/theme-studio/test-columns.mjs b/scripts/theme-studio/test-columns.mjs
index ab88cb73..abfab0a3 100644
--- a/scripts/theme-studio/test-columns.mjs
+++ b/scripts/theme-studio/test-columns.mjs
@@ -5,7 +5,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns } from './app-core.js';
+import { columnsFromPalette, regenColumn, groundRoleOfEntry, rankByLightness, stepRepointPlan, sortColumns } from './app-core.js';
const columnOf = (columns, name) => columns.find(f => f.members.some(m => m.name === name));
@@ -86,6 +86,23 @@ test('columnsFromPalette: Boundary - ground entries and ground-N steps stay out
assert.ok(!columns.some(f => f.members.some(m => m.name === 'bg' || m.name === 'ground-1')));
});
+test('columnsFromPalette: Boundary - imported bg-like names are not ground just because their hex matches bg', () => {
+ const pal = [['#0d0b0a', 'bg2'], ['#0d0b0a', 'bg-alt'], ['#0d0b0a', 'bg', 'ground'], ['#f0fef0', 'fg', 'ground']];
+ const { ground, columns } = columnsFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ assert.deepEqual(ground.map(g => [g.role, g.name]), [['bg', 'bg'], ['fg', 'fg']]);
+ assert.ok(columnOf(columns, 'bg2'), 'bg2 remains in a normal imported color column');
+ assert.ok(columnOf(columns, 'bg-alt'), 'bg-alt remains in a normal imported color column');
+});
+
+test('groundRoleOfEntry: Boundary - exact ground roles only, not bg-prefix names', () => {
+ const ground = { bg: '#0d0b0a', fg: '#f0fef0' };
+ assert.equal(groundRoleOfEntry(['#0d0b0a', 'bg'], ground), 'bg');
+ assert.equal(groundRoleOfEntry(['#0d0b0a', 'ground'], ground), 'bg');
+ assert.equal(groundRoleOfEntry(['#0d0b0a', 'bg2'], ground), null);
+ assert.equal(groundRoleOfEntry(['#0d0b0a', 'bg-alt'], ground), null);
+ assert.equal(groundRoleOfEntry(['#444444', 'ground-1'], ground), 'step');
+});
+
// --- regenColumn ------------------------------------------------------------
test('regenColumn: Normal - n steps each side plus the base, ordered by offset', () => {
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 9cdf58ed..9386ea82 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -551,10 +551,27 @@ function lMax(hue,chroma,fgSet,target){
// 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 columnStem(name){name=name||'color';if(/^color-\d+$/.test(name))return name;name=name.replace(/[+-]\d+$/,'');return name.replace(/\d+$/,'')||'color';}
function columnOffset(name){const m=(name||'').match(/([+-]\d+)$/);return m?parseInt(m[1],10):0;}
function columnIdOf(entry){return (entry&&entry[2])||columnStem(entry&&entry[1]);}
+function groundRoleOfEntry(entry,ground){
+ if(!entry)return null;
+ const [hex,name]=entry,col=entry[2],n=(name||'').toLowerCase(),h=(hex||'').toLowerCase();
+ const bg=(ground&&ground.bg||'').toLowerCase(),fg=(ground&&ground.fg||'').toLowerCase();
+ if(/^ground-\d+$/i.test(name||''))return 'step';
+ if(col==='ground'){
+ if(bg&&h===bg)return 'bg';
+ if(fg&&h===fg)return 'fg';
+ return 'step';
+ }
+ if(bg&&h===bg&&(n==='bg'||n==='ground'))return 'bg';
+ if(fg&&h===fg&&n==='fg')return 'fg';
+ return null;
+}
+function nameOfGroundRole(palette,ground,role){
+ const found=palette.find(entry=>groundRoleOfEntry(entry,ground)===role);
+ return found?found[1]:null;
+}
// Group a flat palette into the ground strip plus structural columns. ground is
// {bg,fg}; those endpoint hexes form the pinned ground column even when absent
@@ -563,15 +580,13 @@ function columnIdOf(entry){return (entry&&entry[2])||columnStem(entry&&entry[1])
// Legacy two-field entries fall back to their generated-name stem until edited.
function columnsFromPalette(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)});
+ if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfGroundRole(palette,ground,'bg')});
+ if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfGroundRole(palette,ground,'fg')});
const byColumn=new Map(),columns=[];
for(const entry of palette){
const [hex,name]=entry;
- if(gset.has(hex.toLowerCase()))continue;
- if(/^ground-\d+$/i.test(name||''))continue;
+ if(groundRoleOfEntry(entry,ground))continue;
const column=columnIdOf(entry);
if(!byColumn.has(column))byColumn.set(column,{column,members:[]});
byColumn.get(column).members.push({hex,name,offset:columnOffset(name),column});
@@ -633,7 +648,7 @@ function paletteOptionList(cur,palette,ground){
const add=(hex,name)=>{if(!hex)return;const key=hex.toLowerCase()+'|'+(name||'');if(seen.has(key))return;seen.add(key);out.push([hex,name||hex]);};
const grouped=columnsFromPalette(palette,ground||{});
const groundMembers=grouped.ground.map(g=>({hex:g.hex,name:g.name||g.role}))
- .concat(palette.filter(([,name])=>/^ground-\d+$/i.test(name||'')).map(([hex,name])=>({hex,name})));
+ .concat(palette.filter(entry=>groundRoleOfEntry(entry,ground)==='step').map(([hex,name])=>({hex,name})));
sortColumnMembers({base:(ground&&ground.bg)||'',members:groundMembers}).members.forEach(m=>add(m.hex,m.name));
sortColumns(grouped.columns).forEach(f=>f.members.forEach(m=>add(m.hex,m.name)));
return out;
@@ -803,12 +818,12 @@ function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);}
// endpoint, and generated ground-N steps live between them.
function groundColumnMembers(){
const members=[];
- for(const [hex,name] of PALETTE)if(hex.toLowerCase()===MAP['bg'].toLowerCase()||hex.toLowerCase()===MAP['p'].toLowerCase()||/^ground-\d+$/i.test(name||''))members.push({hex,name});
- if(!members.some(m=>m.hex.toLowerCase()===MAP['bg'].toLowerCase()))members.push({hex:MAP['bg'],name:'bg'});
- if(!members.some(m=>m.hex.toLowerCase()===MAP['p'].toLowerCase()))members.push({hex:MAP['p'],name:'fg'});
+ for(const entry of PALETTE)if(groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']}))members.push({hex:entry[0],name:entry[1]});
+ if(!PALETTE.some(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='bg'))members.push({hex:MAP['bg'],name:'bg'});
+ if(!PALETTE.some(entry=>groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']})==='fg'))members.push({hex:MAP['p'],name:'fg'});
return members.sort((a,b)=>oklab2oklch(srgb2oklab(a.hex)).L-oklab2oklch(srgb2oklab(b.hex)).L);
}
-function groundSpanCount(){return PALETTE.filter(([,name])=>/^ground-\d+$/i.test(name||'')).length;}
+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 title="number of ground colors between bg and fg">span <input type="number" min="0" max="8" value="${groundSpanCount()}"></span>`;
@@ -816,7 +831,7 @@ function groundSpanControl(){
return d;
}
function setGroundSpan(n){
- const old=PALETTE.filter(([,name])=>/^ground-\d+$/i.test(name||''));
+ 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=[];
for(let i=1;i<=n;i++){
@@ -828,8 +843,8 @@ function setGroundSpan(n){
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(/^ground-\d+$/i.test(PALETTE[i][1]||''))PALETTE.splice(i,1);
- let at=PALETTE.findIndex(([hex])=>hex.toLowerCase()===MAP['bg'].toLowerCase());
+ 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;renderPalette();buildTable();buildUITable();renderCode();applyGround();
@@ -850,10 +865,11 @@ function renderPaletteWarnings(warnings,overflow){
// 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 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.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}">&#128274;</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`;
+ const rm=locked?`<span class="lock" title="${role==='bg'?'background':'foreground'} — can't remove" style="color:${tc}">&#128274;</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();};
@@ -870,9 +886,7 @@ function selectColumnBase(f){
if(i>=0)selectColor(i);
}
function isGroundEntry(entry){
- const [hex,name]=entry;
- const lower=(hex||'').toLowerCase();
- return lower===MAP['bg'].toLowerCase()||lower===MAP['p'].toLowerCase()||/^ground-\d+$/i.test(name||'');
+ return !!groundRoleOfEntry(entry,{bg:MAP['bg'],fg:MAP['p']});
}
function moveColumn(columnId,dir){
normalizePalette();
@@ -983,7 +997,7 @@ function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();}
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;}
- const i=selectedIdx,oldHex=PALETTE[i][0];
+ const i=selectedIdx,oldHex=PALETTE[i][0],oldRole=groundRoleOfEntry(PALETTE[i],{bg:MAP['bg'],fg:MAP['p']});
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;}
@@ -993,7 +1007,9 @@ function updateColor(){
const count=column?Math.max(0,...rankByLightness(column.members.map(m=>m.hex),column.base).map(m=>Math.abs(m.offset))):0;
const columnId=PALETTE[i][2]||columnStem(PALETTE[i][1]);
PALETTE[i]=[newHex,newName,columnId];
- repointHex(oldHex,newHex);
+ const duplicateOldHex=PALETTE.some((p,j)=>j!==i&&p[0].toLowerCase()===oldHex.toLowerCase());
+ if(oldRole==='bg'||oldRole==='fg')repointHex(oldHex,newHex);
+ else if(!duplicateOldHex&&oldHex!==MAP['bg']&&oldHex!==MAP['p'])repointHex(oldHex,newHex);
if(column&&count>0){
const oldHexes=column.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
regenColumnInPlace(oldHexes,newHex,newName,count,column.column||columnId);
@@ -1936,6 +1952,12 @@ if(location.hash==='#columntest'||location.hash==='#familytest'){let ok=true;con
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.column===redColumn,'a renamed color stays in the same strip');
+ PALETTE=[['#0d0b0a','bg','ground'],['#f0fef0','fg','ground'],['#0d0b0a','bg2'],['#0d0b0a','bg-alt']];MAP['bg']='#0d0b0a';MAP['p']='#f0fef0';selectedIdx=null;renderPalette();
+ const bg2Chip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='bg2');
+ A(!!bg2Chip&&bg2Chip.closest('.fstrip').dataset.column==='bg'&&!!bg2Chip.querySelector('.rm')&&!bg2Chip.querySelector('.lock'),'same-hex bg2 remains a normal removable color column chip');
+ if(bg2Chip){bg2Chip.click();document.getElementById('newhexstr').value='#101820';document.getElementById('newname').value='bg2';updateColor();}
+ A(MAP['bg']==='#0d0b0a','editing same-hex bg2 does not repoint the real bg assignment');
+ A(PALETTE.some(p=>p[1]==='bg2'&&p[0]==='#101820'),'editing same-hex bg2 updates only that palette tile');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette();
document.title='COLUMNTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='columntest';d.textContent='COLUMNTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}