aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-13 18:19:25 -0500
committerCraig Jennings <c@cjennings.net>2026-06-13 18:19:25 -0500
commit749cb0885872571b36d9b3174067911a47fd5e3b (patch)
tree4253b675c96dcdf78639611e4b57f66813929b7a /scripts/theme-studio
parentd0cf30bfa37864db12009c2f561c87f96bd66989 (diff)
downloaddotemacs-749cb0885872571b36d9b3174067911a47fd5e3b.tar.gz
dotemacs-749cb0885872571b36d9b3174067911a47fd5e3b.zip
Fix theme studio span endpoint tiles
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/README.md2
-rw-r--r--scripts/theme-studio/app-core.js9
-rw-r--r--scripts/theme-studio/browser-gates.js14
-rw-r--r--scripts/theme-studio/palette-actions.js7
-rw-r--r--scripts/theme-studio/test-app-core.mjs12
-rw-r--r--scripts/theme-studio/test-columns.mjs14
-rw-r--r--scripts/theme-studio/theme-studio.html30
7 files changed, 57 insertions, 31 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index 32eb2f56..e20e8c7d 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -115,7 +115,7 @@ Three tiers of faces, plus the palette:
## Color columns
The palette is displayed as **columns**. The ground column is pinned first: `bg`
-at one end, `fg` at the other, with optional `ground-N` span colors between them.
+at one end, `fg` at the other, with optional `ground+N` span colors between them.
Every other color stays in the column where it was created. Columns are not
derived from hue, chroma, lightness, or the visible color name.
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 3c53e6a9..0c48babf 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -136,6 +136,7 @@ function lMax(hue,chroma,fgSet,target){
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function isReservedGroundLikeName(name){return /^(bg|fg)(?:[-_+].+|\d.*)$/i.test(name||'');}
+function isPureEndpointHex(hex){const h=(hex||'').toLowerCase();return h==='#ffffff'||h==='#000000';}
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 legacyColumnStem(name){return isReservedGroundLikeName(name)?name:columnStem(name);}
@@ -145,7 +146,7 @@ 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(/^ground[+-]\d+$/i.test(name||''))return 'step';
if(col==='ground'){
if(bg&&h===bg)return 'bg';
if(fg&&h===fg)return 'fg';
@@ -172,7 +173,7 @@ function groundColumnMembersFromPalette(palette,ground){
if(role==='bg'||role==='fg')byRole[role]={hex:entry[0],name:entry[1]};
else if(role==='step')byRole.steps.push({hex:entry[0],name:entry[1]});
}
- const stepIndex=m=>{const x=(m.name||'').match(/^ground-(\d+)$/i);return x?parseInt(x[1],10):Infinity;};
+ const stepIndex=m=>{const x=(m.name||'').match(/^ground[+-](\d+)$/i);return x?parseInt(x[1],10):Infinity;};
byRole.steps.sort((a,b)=>stepIndex(a)-stepIndex(b));
return [byRole.bg||{hex:ground&&ground.bg,name:'bg'},...byRole.steps,byRole.fg||{hex:ground&&ground.fg,name:'fg'}].filter(m=>m.hex);
}
@@ -211,7 +212,7 @@ function toggleLockSet(keys,locked){
// 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
+// 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 columnsFromPalette(palette,ground){
@@ -243,7 +244,7 @@ function regenColumn(baseHex,n,opts){
if(k===0)return {members:[{hex,offset:0,clamped:false}]};
const r=ramp(hex,Object.assign({},opts,{n:k}));
if(r.error)return {members:[],error:r.error};
- const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ const members=[...r.steps.filter(s=>!isPureEndpointHex(s.hex)),{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
return {members};
}
// Rank a column's current member hexes by lightness and give each a signed offset
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index a82514d7..e60efb7b 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -386,16 +386,22 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
PALETTE=[['#204060','bg'],['#f0fef0','fg']];
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(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-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
MAP['bg']='#ffffff';MAP['p']='#000000';
- PALETTE=[['#ffffff','bg'],['#bbbbbb','ground-1','ground'],['#777777','ground-2','ground'],['#000000','fg']];
+ PALETTE=[['#ffffff','bg'],['#bbbbbb','ground+1','ground'],['#777777','ground+2','ground'],['#000000','fg']];
renderPalette();
const groundNames=[...document.querySelectorAll('#pals .fstrip[data-column="ground"] .pchip .nm')].map(e=>e.value);
- A(groundNames.join('|')==='bg|ground-1|ground-2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|'));
+ A(groundNames.join('|')==='bg|ground+1|ground+2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|'));
MAP['bg']='#204060';MAP['p']='#f0fef0';
setGroundSpan(1);
- A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps');
+ A(!PALETTE.some(p=>p[1]==='ground+2'),'lowering ground span removes dropped interior steps');
+ PALETTE=[['#204060','bg'],['#f0fef0','fg'],['#e0e0e0','near-white','near-white']];
+ setColumnCount('#e0e0e0',4);
+ A(!PALETTE.some(p=>p[0].toLowerCase()==='#ffffff'&&p[1]!=='fg'),'spanning a near-white base skips generated pure-white tiles');
+ PALETTE=[['#204060','bg'],['#f0fef0','fg'],['#101010','near-black','near-black']];
+ setColumnCount('#101010',4);
+ A(!PALETTE.some(p=>p[0].toLowerCase()==='#000000'&&p[1]!=='bg'),'spanning a near-black base skips generated pure-black tiles');
PALETTE=[['#204060','bg'],['#f0fef0','fg']];
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
diff --git a/scripts/theme-studio/palette-actions.js b/scripts/theme-studio/palette-actions.js
index 11f37d2f..7fbac701 100644
--- a/scripts/theme-studio/palette-actions.js
+++ b/scripts/theme-studio/palette-actions.js
@@ -35,7 +35,7 @@ function ensureGroundEndpoints(){
}
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.
+// endpoint, and generated ground+N steps live between them.
function groundColumnMembers(){
return groundColumnMembersFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
}
@@ -50,10 +50,13 @@ 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};
- entries.push([lrgb2hex(oklab2lrgb(lab.L,lab.a,lab.b)),'ground-'+i,'ground']);
+ 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);
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 44474d43..48318305 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -143,21 +143,21 @@ test('deletePaletteColumnPlan: Normal — removes one stable column and keeps gr
test('deletePaletteColumnPlan: Boundary — never deletes ground entries', () => {
const plan = deletePaletteColumnPlan([
['#0d0b0a', 'bg', 'ground'],
- ['#555555', 'ground-1', 'ground'],
+ ['#555555', 'ground+1', 'ground'],
['#f0fef0', 'fg', 'ground'],
], { bg: '#0d0b0a', fg: '#f0fef0' }, 'ground');
- assert.deepEqual(plan.palette.map(p => p[1]), ['bg', 'ground-1', 'fg']);
+ assert.deepEqual(plan.palette.map(p => p[1]), ['bg', 'ground+1', 'fg']);
assert.deepEqual(plan.removed, []);
});
-test('groundColumnMembersFromPalette: Normal — sorts bg, ground-N steps, then fg', () => {
+test('groundColumnMembersFromPalette: Normal — sorts bg, ground+N steps, then fg', () => {
const members = groundColumnMembersFromPalette([
['#ffffff', 'bg', 'ground'],
- ['#333333', 'ground-2', 'ground'],
- ['#bbbbbb', 'ground-1', 'ground'],
+ ['#333333', 'ground+2', 'ground'],
+ ['#bbbbbb', 'ground+1', 'ground'],
['#000000', 'fg', 'ground'],
], { bg: '#ffffff', fg: '#000000' });
- assert.deepEqual(members.map(m => m.name), ['bg', 'ground-1', 'ground-2', 'fg']);
+ assert.deepEqual(members.map(m => m.name), ['bg', 'ground+1', 'ground+2', 'fg']);
});
test('lock helpers: Normal — label and toggle operate on the full key set', () => {
diff --git a/scripts/theme-studio/test-columns.mjs b/scripts/theme-studio/test-columns.mjs
index 4f5ae6a0..ae4cfcce 100644
--- a/scripts/theme-studio/test-columns.mjs
+++ b/scripts/theme-studio/test-columns.mjs
@@ -79,11 +79,11 @@ test('columnsFromPalette: Boundary - ground hex absent from the palette still fo
assert.ok(ground.some(g => g.role === 'fg'));
});
-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']];
+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, columns } = columnsFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a'));
- assert.ok(!columns.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')));
});
test('columnsFromPalette: Boundary - imported bg-like names are not ground just because their hex matches bg', () => {
@@ -108,7 +108,8 @@ test('groundRoleOfEntry: Boundary - exact ground roles only, not bg-prefix names
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');
+ assert.equal(groundRoleOfEntry(['#444444', 'ground+1'], ground), 'step');
+ assert.equal(groundRoleOfEntry(['#555555', 'ground-1'], ground), 'step');
});
// --- regenColumn ------------------------------------------------------------
@@ -129,6 +130,11 @@ test('regenColumn: Error - a malformed base returns a structured bad-hex', () =>
assert.deepEqual(regenColumn('nope', 2), { members: [], error: 'bad-hex' });
});
+test('regenColumn: Boundary - generated pure white and black endpoint steps are skipped', () => {
+ assert.ok(!regenColumn('#e0e0e0', 4).members.some(m => m.offset !== 0 && m.hex === '#ffffff'));
+ assert.ok(!regenColumn('#101010', 4).members.some(m => m.offset !== 0 && m.hex === '#000000'));
+});
+
// --- rankByLightness --------------------------------------------------------
test('rankByLightness: Normal - offsets are signed distance from the base by lightness', () => {
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 70726afa..72cb2022 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -557,6 +557,7 @@ function lMax(hue,chroma,fgSet,target){
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function isReservedGroundLikeName(name){return /^(bg|fg)(?:[-_+].+|\d.*)$/i.test(name||'');}
+function isPureEndpointHex(hex){const h=(hex||'').toLowerCase();return h==='#ffffff'||h==='#000000';}
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 legacyColumnStem(name){return isReservedGroundLikeName(name)?name:columnStem(name);}
@@ -566,7 +567,7 @@ 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(/^ground[+-]\d+$/i.test(name||''))return 'step';
if(col==='ground'){
if(bg&&h===bg)return 'bg';
if(fg&&h===fg)return 'fg';
@@ -593,7 +594,7 @@ function groundColumnMembersFromPalette(palette,ground){
if(role==='bg'||role==='fg')byRole[role]={hex:entry[0],name:entry[1]};
else if(role==='step')byRole.steps.push({hex:entry[0],name:entry[1]});
}
- const stepIndex=m=>{const x=(m.name||'').match(/^ground-(\d+)$/i);return x?parseInt(x[1],10):Infinity;};
+ const stepIndex=m=>{const x=(m.name||'').match(/^ground[+-](\d+)$/i);return x?parseInt(x[1],10):Infinity;};
byRole.steps.sort((a,b)=>stepIndex(a)-stepIndex(b));
return [byRole.bg||{hex:ground&&ground.bg,name:'bg'},...byRole.steps,byRole.fg||{hex:ground&&ground.fg,name:'fg'}].filter(m=>m.hex);
}
@@ -632,7 +633,7 @@ function toggleLockSet(keys,locked){
// 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
+// 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 columnsFromPalette(palette,ground){
@@ -664,7 +665,7 @@ function regenColumn(baseHex,n,opts){
if(k===0)return {members:[{hex,offset:0,clamped:false}]};
const r=ramp(hex,Object.assign({},opts,{n:k}));
if(r.error)return {members:[],error:r.error};
- const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ const members=[...r.steps.filter(s=>!isPureEndpointHex(s.hex)),{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
return {members};
}
// Rank a column's current member hexes by lightness and give each a signed offset
@@ -905,7 +906,7 @@ function ensureGroundEndpoints(){
}
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.
+// endpoint, and generated ground+N steps live between them.
function groundColumnMembers(){
return groundColumnMembersFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']});
}
@@ -920,10 +921,13 @@ 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};
- entries.push([lrgb2hex(oklab2lrgb(lab.L,lab.a,lab.b)),'ground-'+i,'ground']);
+ 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);
@@ -2144,16 +2148,22 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
PALETTE=[['#204060','bg'],['#f0fef0','fg']];
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(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-column="ground"] .fhead + .fcount + .pchip'),'ground span control renders before tiles');
MAP['bg']='#ffffff';MAP['p']='#000000';
- PALETTE=[['#ffffff','bg'],['#bbbbbb','ground-1','ground'],['#777777','ground-2','ground'],['#000000','fg']];
+ PALETTE=[['#ffffff','bg'],['#bbbbbb','ground+1','ground'],['#777777','ground+2','ground'],['#000000','fg']];
renderPalette();
const groundNames=[...document.querySelectorAll('#pals .fstrip[data-column="ground"] .pchip .nm')].map(e=>e.value);
- A(groundNames.join('|')==='bg|ground-1|ground-2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|'));
+ A(groundNames.join('|')==='bg|ground+1|ground+2|fg','ground column order is bg, ground steps, fg even when bg is lighter: '+groundNames.join('|'));
MAP['bg']='#204060';MAP['p']='#f0fef0';
setGroundSpan(1);
- A(!PALETTE.some(p=>p[1]==='ground-2'),'lowering ground span removes dropped interior steps');
+ A(!PALETTE.some(p=>p[1]==='ground+2'),'lowering ground span removes dropped interior steps');
+ PALETTE=[['#204060','bg'],['#f0fef0','fg'],['#e0e0e0','near-white','near-white']];
+ setColumnCount('#e0e0e0',4);
+ A(!PALETTE.some(p=>p[0].toLowerCase()==='#ffffff'&&p[1]!=='fg'),'spanning a near-white base skips generated pure-white tiles');
+ PALETTE=[['#204060','bg'],['#f0fef0','fg'],['#101010','near-black','near-black']];
+ setColumnCount('#101010',4);
+ A(!PALETTE.some(p=>p[0].toLowerCase()==='#000000'&&p[1]!=='bg'),'spanning a near-black base skips generated pure-black tiles');
PALETTE=[['#204060','bg'],['#f0fef0','fg']];
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