aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-13 15:09:16 -0500
committerCraig Jennings <c@cjennings.net>2026-06-13 15:09:16 -0500
commit90b1eeb938212121e9dc52170f21586bda4d30fc (patch)
tree48d4adabd19126be4b61cfe3a43d59d65a066999 /scripts
parent99fd107f7aea766e869120151bb1be0811a0d5cb (diff)
downloaddotemacs-90b1eeb938212121e9dc52170f21586bda4d30fc.tar.gz
dotemacs-90b1eeb938212121e9dc52170f21586bda4d30fc.zip
Rename theme studio color model to columns
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/README.md4
-rw-r--r--scripts/theme-studio/app-core.js36
-rw-r--r--scripts/theme-studio/app.js106
-rw-r--r--scripts/theme-studio/test-columns.mjs (renamed from scripts/theme-studio/test-families.mjs)88
-rw-r--r--scripts/theme-studio/theme-studio.html140
5 files changed, 187 insertions, 187 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index 844d036d..69dad3b6 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -67,8 +67,8 @@ Node; the DOM glue is covered by the browser hash gates.
Three tiers of faces, plus the palette:
-- **Palette** — named colors, shown grouped into hue *families* (see Color
- families below). Add by hex or with the in-page color picker
+- **Palette** — named colors, shown grouped into stable structural columns. 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 and rename per chip;
the colors serving as background and foreground are locked.
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 90376d51..ff87d53f 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -121,7 +121,7 @@ function lMax(hue,chroma,fgSet,target){
return {L:loL,status:at(loL).clamped?'clamp':'ok'};
}
-// --- color columns (color-families spec, current UI model) -------------------
+// --- color columns -----------------------------------------------------------
// Columns are structural, not inferred by color. Generated ramp entries are named
// base-1/base/base+1 and remain in that base column regardless of their hex. A
// manually-added color starts as its own singleton column. The flat palette stays
@@ -130,40 +130,40 @@ function lMax(hue,chroma,fgSet,target){
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 familyStem(name){return (name||'color').replace(/[+-]\d+$/,'');}
-function familyOffset(name){const m=(name||'').match(/([+-]\d+)$/);return m?parseInt(m[1],10):0;}
-function columnIdOf(entry){return (entry&&entry[2])||familyStem(entry&&entry[1]);}
+function columnStem(name){return (name||'color').replace(/[+-]\d+$/,'');}
+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]);}
// 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
// from the palette, and ground-N entries are reserved for that column. Everything
// else groups by its stable column id, not by OKLCH hue/chroma or display name.
// Legacy two-field entries fall back to their generated-name stem until edited.
-function familiesFromPalette(palette,ground){
+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)});
- const byColumn=new Map(),families=[];
+ 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;
const column=columnIdOf(entry);
if(!byColumn.has(column))byColumn.set(column,{column,members:[]});
- byColumn.get(column).members.push({hex,name,offset:familyOffset(name),column});
+ byColumn.get(column).members.push({hex,name,offset:columnOffset(name),column});
}
for(const f of byColumn.values()){
const base=(f.members.find(m=>m.offset===0)||f.members[0]).hex;
- families.push({base,column:f.column,stem:f.column,members:f.members.map(m=>({hex:m.hex,name:m.name,column:m.column}))});
+ columns.push({base,column:f.column,stem:f.column,members:f.members.map(m=>({hex:m.hex,name:m.name,column:m.column}))});
}
- return {ground:groundStrip,families};
+ return {ground:groundStrip,columns};
}
-// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
+// Regenerate a column'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){
+function regenColumn(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)));
@@ -173,7 +173,7 @@ function regenFamily(baseHex,n,opts){
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
+// Rank a column'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){
@@ -198,8 +198,8 @@ function stepRepointPlan(oldRanked,newMembers){
// Preserve structural order. Generated ramps are inserted in offset order, and
// columns are emitted in first-seen palette order. No color sorting happens here.
-function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members]});}
-function sortFamilies(families){return families.map(sortFamilyMembers);}
+function sortColumnMembers(column){return Object.assign({},column,{members:[...column.members]});}
+function sortColumns(columns){return columns.map(sortColumnMembers);}
// Dropdown order for color selection mirrors the visual palette organization:
// ground first, then structural columns in display order. Stored palette order
@@ -209,12 +209,12 @@ function paletteOptionList(cur,palette,ground){
const out=[['','— default —']],seen=new Set();
if(!have)out.push([cur,'(gone) '+cur]);
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=familiesFromPalette(palette,ground||{});
+ 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})));
- sortFamilyMembers({base:(ground&&ground.bg)||'',members:groundMembers}).members.forEach(m=>add(m.hex,m.name));
- sortFamilies(grouped.families).forEach(f=>f.members.forEach(m=>add(m.hex,m.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, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers };
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, paletteOptionList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index af33f607..e7e983f1 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -67,7 +67,7 @@ function mkColorDropdown(options,cur,onPick){
t.setValue=h=>{cur=h;paint();};
return t;}
// Standard option list for a swatch dropdown: a "default" entry, then the
-// palette in the same ground/family order as the palette panel. If cur is set
+// palette in the same ground/column order as the palette panel. If cur is set
// but no longer in the palette, surface it as a "(gone)" entry so the row still
// shows what it points at. Shared by all three tiers.
function ddList(cur){return paletteOptionList(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']});}
@@ -158,7 +158,7 @@ function repointHex(oldHex,newHex){
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 normalizePaletteEntry(entry){
const hex=entry&&entry[0],name=(entry&&entry[1])||'color';
- return [hex,name,(entry&&entry[2])||familyStem(name)];
+ return [hex,name,(entry&&entry[2])||columnStem(name)];
}
function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);}
// The ground column is explicit: bg pins the dark endpoint, fg pins the light
@@ -229,12 +229,12 @@ function renderPalette(){
normalizePalette();
const p=document.getElementById('pals');p.innerHTML='';
const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5);
- const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const {ground,columns}=columnsFromPalette(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;};
if(ground.length){
- const gs=strip(' ground');gs.dataset.family='ground';
+ 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=>{
@@ -247,53 +247,53 @@ function renderPalette(){
// 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('');s.dataset.family=f.column||f.base;
+ sortColumns(columns).forEach(f=>{
+ const s=strip('');s.dataset.column=f.column||f.base;
const h=document.createElement('div');h.className='fhead';
h.textContent=(f.members.find(m=>m.hex.toLowerCase()===f.base.toLowerCase())||{}).name||f.column||f.base;
s.appendChild(h);
- s.appendChild(familyCountControl(f));
+ 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-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){
+// 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 title="set the family span: N generated steps on each side of the base — this replaces the column">span &#177; <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)));
+ d.innerHTML=`<span title="set the column span: N generated steps on each side of the base — this replaces the column">span &#177; <input type="number" min="0" max="4" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setColumnCount(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.
+// Regenerate a column 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,columnId){
- const r=regenFamily(baseHex,n,{});
+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<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 col=columnId||familyStem(baseName);
+ 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 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,fam.column);
+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;renderPalette();buildTable();buildUITable();renderCode();applyGround();
notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false);
@@ -307,17 +307,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.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;
- const columnId=PALETTE[i][2]||familyStem(PALETTE[i][1]);
+ // If the edited color is a column base with a ramp, recolor the whole column: regenerate from the new base at the same count.
+ const columns=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns;
+ const column=columns.find(f=>f.base.toLowerCase()===oldHex.toLowerCase());
+ 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);
- if(fam&&count>0){
- const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
- regenFamilyInPlace(oldHexes,newHex,newName,count,fam.column||columnId);
- closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return;
+ 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);
+ closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" column from the new base',false);return;
}
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
@@ -414,7 +414,7 @@ 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,familyStem(name)]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker();
+ PALETTE.push([h,name,columnStem(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);}
@@ -1209,7 +1209,7 @@ 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 a pinned
+// Column-strip gate (open with #familytest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
@@ -1217,21 +1217,21 @@ if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(
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].dataset.family==='ground','ground column is pinned first');
+ A(strips.length&&strips[0].dataset.column==='ground','ground column is pinned first');
A(strips[0].querySelectorAll('.pchip').length===2,'ground column carries bg + fg endpoints');
A(!!strips[0].querySelector('.fhead + .fcount + .pchip'),'span control sits between header and tiles for ground');
A(strips.length>=4,'ground + red + blue + gray columns, 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;
+ A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a column chip keeps remove + rename controls');
+ const redColumn=redChip&&redChip.closest('.fstrip').dataset.column;
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');
+ A(!!renamed&&renamed.closest('.fstrip').dataset.column===redColumn,'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
+// Count-control gate (open with #counttest): the per-column count regenerates the
+// column — 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);}};
@@ -1241,44 +1241,44 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
setGroundSpan(2);
A(MAP['bg']==='#204060'&&MAP['p']==='#f0fef0','spanning ground keeps bg/fg assignments on endpoints');
A(PALETTE.some(p=>p[1]==='ground-1')&&PALETTE.some(p=>p[1]==='ground-2'),'spanning ground adds interior ground-N entries');
- A(document.querySelector('#pals .fstrip[data-family="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
+ A(document.querySelector('#pals .fstrip[data-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
setGroundSpan(1);
A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps');
PALETTE=[['#204060','bg'],['#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
+ regenColumn('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ const innerOld=regenColumn('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change
+ const outerOld=regenColumn('#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);
+ setColumnCount('#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;
+ const newInner=regenColumn('#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());
+ setColumnCount('#67809c',3);
+ const want3=regenColumn('#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
+// Base-edit + ground-edit gate (open with #baseedittest): editing a column base
+// recolors the whole column 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)]));
+ regenColumn('#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[0];
- A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex');
+ const column=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns[0];
+ A(column&&column.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'column 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');
@@ -1291,7 +1291,7 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
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 with
-// stable column ids, and import does not need color-derived family reconstruction.
+// stable column ids, and import does not need color-derived column reconstruction.
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);
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-columns.mjs
index 7d59e281..50a208a3 100644
--- a/scripts/theme-studio/test-families.mjs
+++ b/scripts/theme-studio/test-columns.mjs
@@ -5,86 +5,86 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
-import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies } from './app-core.js';
+import { columnsFromPalette, regenColumn, rankByLightness, stepRepointPlan, sortColumns } from './app-core.js';
-const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name));
+const columnOf = (columns, name) => columns.find(f => f.members.some(m => m.name === name));
-// --- familiesFromPalette ----------------------------------------------------
+// --- columnsFromPalette ----------------------------------------------------
-test('familiesFromPalette: Normal - generated names group by column stem', () => {
+test('columnsFromPalette: Normal - generated names group by column stem', () => {
const pal = [['#223344', 'blue-1'], ['#67809c', 'blue'], ['#b2c3cc', 'blue+1']];
- const { ground, families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ const { ground, columns } = columnsFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(ground.length, 2, 'ground strip carries bg and fg');
- assert.equal(families.length, 1);
- assert.deepEqual(families[0].members.map(m => m.name), ['blue-1', 'blue', 'blue+1']);
+ assert.equal(columns.length, 1);
+ assert.deepEqual(columns[0].members.map(m => m.name), ['blue-1', 'blue', 'blue+1']);
});
-test('familiesFromPalette: Normal - different stems stay in different columns even with similar colors', () => {
+test('columnsFromPalette: Normal - different stems stay in different columns even with similar colors', () => {
const pal = [['#67809c', 'blue'], ['#6a829e', 'steel']];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2);
- assert.notEqual(famOf(families, 'blue'), famOf(families, 'steel'));
+ const { columns } = columnsFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(columns.length, 2);
+ assert.notEqual(columnOf(columns, 'blue'), columnOf(columns, 'steel'));
});
-test('familiesFromPalette: Boundary - a stable third-field column id survives rename', () => {
+test('columnsFromPalette: Boundary - a stable third-field column id survives rename', () => {
const pal = [['#223344', 'blue-1', 'blue'], ['#67809c', 'renamed-base', 'blue'], ['#b2c3cc', 'wild-card', 'blue']];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 1);
- assert.equal(families[0].column, 'blue');
- assert.deepEqual(families[0].members.map(m => m.name), ['blue-1', 'renamed-base', 'wild-card']);
+ const { columns } = columnsFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(columns.length, 1);
+ assert.equal(columns[0].column, 'blue');
+ assert.deepEqual(columns[0].members.map(m => m.name), ['blue-1', 'renamed-base', 'wild-card']);
});
-test('familiesFromPalette: Boundary - legacy two-field entries fall back to name stem', () => {
+test('columnsFromPalette: Boundary - legacy two-field entries fall back to name stem', () => {
const pal = [['#875f00', 'yellow-1'], ['#d7af5f', 'yellow'], ['#646d14', 'green-1'], ['#a4ac64', 'green']];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2);
- assert.ok(famOf(families, 'yellow'));
- assert.ok(famOf(families, 'green'));
+ const { columns } = columnsFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.equal(columns.length, 2);
+ assert.ok(columnOf(columns, 'yellow'));
+ assert.ok(columnOf(columns, 'green'));
});
-test('familiesFromPalette: Normal - palette order controls column order', () => {
+test('columnsFromPalette: Normal - palette order controls column order', () => {
const pal = [['#67809c', 'blue'], ['#e8bd30', 'gold'], ['#5d9b86', 'green']];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.deepEqual(families.map(f => f.members[0].name), ['blue', 'gold', 'green']);
+ const { columns } = columnsFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.deepEqual(columns.map(f => f.members[0].name), ['blue', 'gold', 'green']);
});
-test('familiesFromPalette: Boundary - ground hex absent from the palette still forms the strip', () => {
+test('columnsFromPalette: Boundary - ground hex absent from the palette still forms the strip', () => {
const pal = [['#67809c', 'blue']];
- const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ const { ground } = columnsFromPalette(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 - ground entries and ground-N steps stay out of normal columns', () => {
+test('columnsFromPalette: Boundary - ground entries and ground-N steps stay out of normal columns', () => {
const pal = [['#0d0b0a', 'bg', 'ground'], ['#444444', 'ground-1', 'ground'], ['#67809c', 'blue']];
- const { ground, families } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
+ const { ground, columns } = columnsFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a'));
- assert.ok(!families.some(f => f.members.some(m => m.name === 'bg' || m.name === 'ground-1')));
+ assert.ok(!columns.some(f => f.members.some(m => m.name === 'bg' || m.name === 'ground-1')));
});
-// --- regenFamily ------------------------------------------------------------
+// --- regenColumn ------------------------------------------------------------
-test('regenFamily: Normal - n steps each side plus the base, ordered by offset', () => {
- const r = regenFamily('#67809c', 2);
+test('regenColumn: Normal - n steps each side plus the base, ordered by offset', () => {
+ const r = regenColumn('#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);
+test('regenColumn: Boundary - n=0 is the base alone, no ramp() clamp to 1', () => {
+ const r = regenColumn('#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' });
+test('regenColumn: Error - a malformed base returns a structured bad-hex', () => {
+ assert.deepEqual(regenColumn('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 members = regenColumn('#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);
@@ -117,16 +117,16 @@ test('stepRepointPlan: Boundary - an offset with no new counterpart is removed,
assert.deepEqual(removed, ['#000033']);
});
-// --- sortFamilies -----------------------------------------------------------
+// --- sortColumns -----------------------------------------------------------
-const fam = (label, members) => ({ base: members[0], column: label, members: members.map((h, i) => ({ hex: h, name: label + i })) });
+const column = (label, members) => ({ base: members[0], column: label, members: members.map((h, i) => ({ hex: h, name: label + i })) });
-test('sortFamilies: Normal - preserves first-seen column order', () => {
- const fams = [fam('blue', ['#67809c']), fam('gold', ['#e8bd30']), fam('green', ['#5d9b86'])];
- assert.deepEqual(sortFamilies(fams).map(f => f.column), ['blue', 'gold', 'green']);
+test('sortColumns: Normal - preserves first-seen column order', () => {
+ const columns = [column('blue', ['#67809c']), column('gold', ['#e8bd30']), column('green', ['#5d9b86'])];
+ assert.deepEqual(sortColumns(columns).map(f => f.column), ['blue', 'gold', 'green']);
});
-test('sortFamilies: Normal - preserves member order inside a column', () => {
+test('sortColumns: Normal - preserves member order inside a column', () => {
const members = ['#dddddd', '#222222', '#888888'];
- assert.deepEqual(sortFamilies([fam('gray', members)])[0].members.map(m => m.hex), members);
+ assert.deepEqual(sortColumns([column('gray', members)])[0].members.map(m => m.hex), members);
});
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 552aa028..7a3aecf9 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -532,7 +532,7 @@ function lMax(hue,chroma,fgSet,target){
return {L:loL,status:at(loL).clamped?'clamp':'ok'};
}
-// --- color columns (color-families spec, current UI model) -------------------
+// --- color columns -----------------------------------------------------------
// Columns are structural, not inferred by color. Generated ramp entries are named
// base-1/base/base+1 and remain in that base column regardless of their hex. A
// manually-added color starts as its own singleton column. The flat palette stays
@@ -541,40 +541,40 @@ function lMax(hue,chroma,fgSet,target){
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 familyStem(name){return (name||'color').replace(/[+-]\d+$/,'');}
-function familyOffset(name){const m=(name||'').match(/([+-]\d+)$/);return m?parseInt(m[1],10):0;}
-function columnIdOf(entry){return (entry&&entry[2])||familyStem(entry&&entry[1]);}
+function columnStem(name){return (name||'color').replace(/[+-]\d+$/,'');}
+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]);}
// 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
// from the palette, and ground-N entries are reserved for that column. Everything
// else groups by its stable column id, not by OKLCH hue/chroma or display name.
// Legacy two-field entries fall back to their generated-name stem until edited.
-function familiesFromPalette(palette,ground){
+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)});
- const byColumn=new Map(),families=[];
+ 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;
const column=columnIdOf(entry);
if(!byColumn.has(column))byColumn.set(column,{column,members:[]});
- byColumn.get(column).members.push({hex,name,offset:familyOffset(name),column});
+ byColumn.get(column).members.push({hex,name,offset:columnOffset(name),column});
}
for(const f of byColumn.values()){
const base=(f.members.find(m=>m.offset===0)||f.members[0]).hex;
- families.push({base,column:f.column,stem:f.column,members:f.members.map(m=>({hex:m.hex,name:m.name,column:m.column}))});
+ columns.push({base,column:f.column,stem:f.column,members:f.members.map(m=>({hex:m.hex,name:m.name,column:m.column}))});
}
- return {ground:groundStrip,families};
+ return {ground:groundStrip,columns};
}
-// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
+// Regenerate a column'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){
+function regenColumn(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)));
@@ -584,7 +584,7 @@ function regenFamily(baseHex,n,opts){
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
+// Rank a column'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){
@@ -609,8 +609,8 @@ function stepRepointPlan(oldRanked,newMembers){
// Preserve structural order. Generated ramps are inserted in offset order, and
// columns are emitted in first-seen palette order. No color sorting happens here.
-function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members]});}
-function sortFamilies(families){return families.map(sortFamilyMembers);}
+function sortColumnMembers(column){return Object.assign({},column,{members:[...column.members]});}
+function sortColumns(columns){return columns.map(sortColumnMembers);}
// Dropdown order for color selection mirrors the visual palette organization:
// ground first, then structural columns in display order. Stored palette order
@@ -620,11 +620,11 @@ function paletteOptionList(cur,palette,ground){
const out=[['','— default —']],seen=new Set();
if(!have)out.push([cur,'(gone) '+cur]);
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=familiesFromPalette(palette,ground||{});
+ 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})));
- sortFamilyMembers({base:(ground&&ground.bg)||'',members:groundMembers}).members.forEach(m=>add(m.hex,m.name));
- sortFamilies(grouped.families).forEach(f=>f.members.forEach(m=>add(m.hex,m.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;
}
// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from
@@ -694,7 +694,7 @@ function mkColorDropdown(options,cur,onPick){
t.setValue=h=>{cur=h;paint();};
return t;}
// Standard option list for a swatch dropdown: a "default" entry, then the
-// palette in the same ground/family order as the palette panel. If cur is set
+// palette in the same ground/column order as the palette panel. If cur is set
// but no longer in the palette, surface it as a "(gone)" entry so the row still
// shows what it points at. Shared by all three tiers.
function ddList(cur){return paletteOptionList(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']});}
@@ -785,7 +785,7 @@ function repointHex(oldHex,newHex){
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 normalizePaletteEntry(entry){
const hex=entry&&entry[0],name=(entry&&entry[1])||'color';
- return [hex,name,(entry&&entry[2])||familyStem(name)];
+ return [hex,name,(entry&&entry[2])||columnStem(name)];
}
function normalizePalette(){PALETTE=PALETTE.map(normalizePaletteEntry);}
// The ground column is explicit: bg pins the dark endpoint, fg pins the light
@@ -856,12 +856,12 @@ function renderPalette(){
normalizePalette();
const p=document.getElementById('pals');p.innerHTML='';
const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5);
- const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const {ground,columns}=columnsFromPalette(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;};
if(ground.length){
- const gs=strip(' ground');gs.dataset.family='ground';
+ 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=>{
@@ -874,53 +874,53 @@ function renderPalette(){
// 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('');s.dataset.family=f.column||f.base;
+ sortColumns(columns).forEach(f=>{
+ const s=strip('');s.dataset.column=f.column||f.base;
const h=document.createElement('div');h.className='fhead';
h.textContent=(f.members.find(m=>m.hex.toLowerCase()===f.base.toLowerCase())||{}).name||f.column||f.base;
s.appendChild(h);
- s.appendChild(familyCountControl(f));
+ 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-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){
+// 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 title="set the family span: N generated steps on each side of the base — this replaces the column">span &#177; <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)));
+ d.innerHTML=`<span title="set the column span: N generated steps on each side of the base — this replaces the column">span &#177; <input type="number" min="0" max="4" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setColumnCount(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.
+// Regenerate a column 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,columnId){
- const r=regenFamily(baseHex,n,{});
+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<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 col=columnId||familyStem(baseName);
+ 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 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,fam.column);
+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;renderPalette();buildTable();buildUITable();renderCode();applyGround();
notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false);
@@ -934,17 +934,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.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;
- const columnId=PALETTE[i][2]||familyStem(PALETTE[i][1]);
+ // If the edited color is a column base with a ramp, recolor the whole column: regenerate from the new base at the same count.
+ const columns=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns;
+ const column=columns.find(f=>f.base.toLowerCase()===oldHex.toLowerCase());
+ 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);
- if(fam&&count>0){
- const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex);
- regenFamilyInPlace(oldHexes,newHex,newName,count,fam.column||columnId);
- closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return;
+ 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);
+ closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" column from the new base',false);return;
}
closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false);
}
@@ -1041,7 +1041,7 @@ 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,familyStem(name)]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker();
+ PALETTE.push([h,name,columnStem(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);}
@@ -1836,7 +1836,7 @@ 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 a pinned
+// Column-strip gate (open with #familytest): the palette renders as a pinned
// ground column plus structural columns, chips keep their controls, and renaming
// a color leaves it in the same strip because the column id is stable.
if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
@@ -1844,21 +1844,21 @@ if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(
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].dataset.family==='ground','ground column is pinned first');
+ A(strips.length&&strips[0].dataset.column==='ground','ground column is pinned first');
A(strips[0].querySelectorAll('.pchip').length===2,'ground column carries bg + fg endpoints');
A(!!strips[0].querySelector('.fhead + .fcount + .pchip'),'span control sits between header and tiles for ground');
A(strips.length>=4,'ground + red + blue + gray columns, 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;
+ A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a column chip keeps remove + rename controls');
+ const redColumn=redChip&&redChip.closest('.fstrip').dataset.column;
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');
+ A(!!renamed&&renamed.closest('.fstrip').dataset.column===redColumn,'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
+// Count-control gate (open with #counttest): the per-column count regenerates the
+// column — 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);}};
@@ -1868,44 +1868,44 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
setGroundSpan(2);
A(MAP['bg']==='#204060'&&MAP['p']==='#f0fef0','spanning ground keeps bg/fg assignments on endpoints');
A(PALETTE.some(p=>p[1]==='ground-1')&&PALETTE.some(p=>p[1]==='ground-2'),'spanning ground adds interior ground-N entries');
- A(document.querySelector('#pals .fstrip[data-family="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
+ A(document.querySelector('#pals .fstrip[data-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
setGroundSpan(1);
A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps');
PALETTE=[['#204060','bg'],['#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
+ regenColumn('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)]));
+ const innerOld=regenColumn('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change
+ const outerOld=regenColumn('#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);
+ setColumnCount('#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;
+ const newInner=regenColumn('#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());
+ setColumnCount('#67809c',3);
+ const want3=regenColumn('#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
+// Base-edit + ground-edit gate (open with #baseedittest): editing a column base
+// recolors the whole column 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)]));
+ regenColumn('#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[0];
- A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex');
+ const column=columnsFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).columns[0];
+ A(column&&column.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'column 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');
@@ -1918,7 +1918,7 @@ if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{i
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 with
-// stable column ids, and import does not need color-derived family reconstruction.
+// stable column ids, and import does not need color-derived column reconstruction.
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);