aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/README.md7
-rw-r--r--scripts/theme-studio/app-core.js27
-rw-r--r--scripts/theme-studio/browser-gates.js4
-rw-r--r--scripts/theme-studio/palette-actions.js11
-rw-r--r--scripts/theme-studio/test-columns.mjs18
-rw-r--r--scripts/theme-studio/theme-studio.html40
6 files changed, 72 insertions, 35 deletions
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index 6678bf59..2b4acc6c 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -139,9 +139,10 @@ derived from hue, chroma, lightness, or the visible color name.
the start of the name.
- **The count control** under each non-ground column sets how many steps sit on
each side of the column's base. Setting N regenerates the column as a symmetric
- base ±N tonal ramp via `ramp()` — lighter and darker steps on the base's hue
- with chroma easing toward the extremes — *replacing* the column's current
- colors. N=0 collapses to the base alone.
+ base ±N span: N interior OKLab steps from black to the base and N interior
+ OKLab steps from the base to white. Pure black/white endpoint duplicates and
+ rounded base duplicates are skipped. The current UI caps N at 8; N=0 collapses
+ to the base alone.
- **Editing a base** recolors the whole column: change a base color and the column
regenerates from it at the same count.
- **References follow.** When a regenerate changes a step's hex, any face assigned
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 0c48babf..4f1eee16 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -9,7 +9,7 @@
// where normHex (app-util.js) and the colormath helpers are already present from
// the bodies inlined above this one.
import { normHex } from './app-util.js';
-import { oklch2hex, srgb2oklab, oklab2oklch, contrast } from './colormath.js';
+import { oklch2hex, srgb2oklab, oklab2oklch, oklab2lrgb, lrgb2hex, inGamut, contrast } from './colormath.js';
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
@@ -137,6 +137,11 @@ 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 interpOklabHex(a,b,t,offset){
+ const lab={L:a.L+(b.L-a.L)*t,a:a.a+(b.a-a.a)*t,b:a.b+(b.b-a.b)*t};
+ const lrgb=oklab2lrgb(lab.L,lab.a,lab.b);
+ return {hex:lrgb2hex(lrgb),offset,clamped:!inGamut(lrgb)};
+}
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);}
@@ -234,17 +239,23 @@ function columnsFromPalette(palette,ground){
}
return {ground:groundStrip,columns};
}
-// 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'}.
+// Regenerate a column's members as a symmetric span around the base: n=0 is the
+// base alone, n>=1 divides the OKLab intervals black..base and base..white into
+// n interior steps per side. Pure black/white endpoint duplicates and rounded
+// base duplicates are skipped. {members:[{hex,offset,clamped}]} or
+// {members:[],error:'bad-hex'}.
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)));
+ const k=Math.min(8,Math.max(0,Math.round(n||0)));
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.filter(s=>!isPureEndpointHex(s.hex)),{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ const base=srgb2oklab(hex),black=srgb2oklab('#000000'),white=srgb2oklab('#ffffff'),steps=[];
+ for(let i=1;i<=k;i++){
+ const dark=interpOklabHex(black,base,i/(k+1),i-k-1);
+ const light=interpOklabHex(base,white,i/(k+1),i);
+ steps.push(dark,light);
+ }
+ const members=[...steps.filter(s=>!isPureEndpointHex(s.hex)&&s.hex.toLowerCase()!==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 e60efb7b..54f591e9 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -409,6 +409,8 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
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();
+ const blueSpanInput=document.querySelector('#pals .fstrip[data-column="blue"] .fcount input');
+ A(blueSpanInput&&blueSpanInput.max==='8','normal column span control allows up to 8 per side');
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');
@@ -418,7 +420,7 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
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');
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 span 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);}
diff --git a/scripts/theme-studio/palette-actions.js b/scripts/theme-studio/palette-actions.js
index 7fbac701..db91ed0a 100644
--- a/scripts/theme-studio/palette-actions.js
+++ b/scripts/theme-studio/palette-actions.js
@@ -176,9 +176,8 @@ function renderPalette(){
sw.innerHTML=`<input class="nm" value="${m.name||'ground'}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${m.hex}</div>`;gs.appendChild(sw);}
});
}
- // 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.
+ // The too-similar warning stays on the full flat palette, so large spans can
+ // still expose genuinely hard-to-distinguish neighboring colors.
const ordered=sortColumns(columns);
ordered.forEach((f,pos)=>{
const s=strip('');s.dataset.column=f.column||f.base;
@@ -194,11 +193,11 @@ function renderPalette(){
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 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)));
+ 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="8" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setColumnCount(f.base,Math.max(0,Math.min(8,parseInt(e.target.value,10)||0)));
return d;
}
-// Regenerate a column as a symmetric base ±N ramp, replacing its current members.
+// Regenerate a column as a symmetric base ±N span, replacing its current members.
// References to a surviving position (matched by signed lightness rank) follow the
// new hex; references to a position removed by lowering N leave their old hex,
// which is no longer in the palette and so renders as "(gone)".
diff --git a/scripts/theme-studio/test-columns.mjs b/scripts/theme-studio/test-columns.mjs
index ae4cfcce..13986036 100644
--- a/scripts/theme-studio/test-columns.mjs
+++ b/scripts/theme-studio/test-columns.mjs
@@ -121,18 +121,30 @@ test('regenColumn: Normal - n steps each side plus the base, ordered by offset',
assert.equal(r.members.find(m => m.offset === 0).hex, '#67809c');
});
-test('regenColumn: Boundary - n=0 is the base alone, no ramp() clamp to 1', () => {
+test('regenColumn: Boundary - n=0 is the base alone', () => {
const r = regenColumn('#67809c', 0);
assert.deepEqual(r.members, [{ hex: '#67809c', offset: 0, clamped: false }]);
});
+test('regenColumn: Boundary - span count is capped at eight per side', () => {
+ const r = regenColumn('#67809c', 10);
+ assert.equal(r.members.length, 17);
+ assert.deepEqual(r.members.map(m => m.offset), [-8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8]);
+});
+
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'));
+ assert.ok(!regenColumn('#fefefe', 8).members.some(m => m.offset !== 0 && m.hex === '#ffffff'));
+ assert.ok(!regenColumn('#010101', 8).members.some(m => m.offset !== 0 && m.hex === '#000000'));
+});
+
+test('regenColumn: Normal - changing span count redistributes steps between endpoint and base', () => {
+ const one = regenColumn('#67809c', 1).members.find(m => m.offset === 1).hex;
+ const two = regenColumn('#67809c', 2).members.find(m => m.offset === 1).hex;
+ assert.notEqual(one, two);
});
// --- rankByLightness --------------------------------------------------------
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 8ef83cd2..ac8c285f 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -557,6 +557,11 @@ 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 interpOklabHex(a,b,t,offset){
+ const lab={L:a.L+(b.L-a.L)*t,a:a.a+(b.a-a.a)*t,b:a.b+(b.b-a.b)*t};
+ const lrgb=oklab2lrgb(lab.L,lab.a,lab.b);
+ return {hex:lrgb2hex(lrgb),offset,clamped:!inGamut(lrgb)};
+}
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);}
@@ -654,17 +659,23 @@ function columnsFromPalette(palette,ground){
}
return {ground:groundStrip,columns};
}
-// 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'}.
+// Regenerate a column's members as a symmetric span around the base: n=0 is the
+// base alone, n>=1 divides the OKLab intervals black..base and base..white into
+// n interior steps per side. Pure black/white endpoint duplicates and rounded
+// base duplicates are skipped. {members:[{hex,offset,clamped}]} or
+// {members:[],error:'bad-hex'}.
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)));
+ const k=Math.min(8,Math.max(0,Math.round(n||0)));
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.filter(s=>!isPureEndpointHex(s.hex)),{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset);
+ const base=srgb2oklab(hex),black=srgb2oklab('#000000'),white=srgb2oklab('#ffffff'),steps=[];
+ for(let i=1;i<=k;i++){
+ const dark=interpOklabHex(black,base,i/(k+1),i-k-1);
+ const light=interpOklabHex(base,white,i/(k+1),i);
+ steps.push(dark,light);
+ }
+ const members=[...steps.filter(s=>!isPureEndpointHex(s.hex)&&s.hex.toLowerCase()!==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
@@ -1046,9 +1057,8 @@ function renderPalette(){
sw.innerHTML=`<input class="nm" value="${m.name||'ground'}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${m.hex}</div>`;gs.appendChild(sw);}
});
}
- // 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.
+ // The too-similar warning stays on the full flat palette, so large spans can
+ // still expose genuinely hard-to-distinguish neighboring colors.
const ordered=sortColumns(columns);
ordered.forEach((f,pos)=>{
const s=strip('');s.dataset.column=f.column||f.base;
@@ -1064,11 +1074,11 @@ function renderPalette(){
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 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)));
+ 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="8" value="${per}"></span>`;
+ d.querySelector('input').onchange=(e)=>setColumnCount(f.base,Math.max(0,Math.min(8,parseInt(e.target.value,10)||0)));
return d;
}
-// Regenerate a column as a symmetric base ±N ramp, replacing its current members.
+// Regenerate a column as a symmetric base ±N span, replacing its current members.
// References to a surviving position (matched by signed lightness rank) follow the
// new hex; references to a position removed by lowering N leave their old hex,
// which is no longer in the palette and so renders as "(gone)".
@@ -2162,6 +2172,8 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
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();
+ const blueSpanInput=document.querySelector('#pals .fstrip[data-column="blue"] .fcount input');
+ A(blueSpanInput&&blueSpanInput.max==='8','normal column span control allows up to 8 per side');
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');
@@ -2171,7 +2183,7 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
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');
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 span 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);}