diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-09 18:53:05 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-09 18:53:05 -0500 |
| commit | e7021bfe47072d8d9cb0fa6ec8d240d877f13cf0 (patch) | |
| tree | 94d204fbef670f8a180cf63679419c03525a8119 | |
| parent | 47cf3552bf1151aed99477653dacd14993f3c736 (diff) | |
| download | dotemacs-e7021bfe47072d8d9cb0fa6ec8d240d877f13cf0.tar.gz dotemacs-e7021bfe47072d8d9cb0fa6ec8d240d877f13cf0.zip | |
feat(theme-studio): add the background-contrast safety core
A background overlay sits behind many foregrounds at once, so its real constraint is the worst-case contrast over the whole set, not the single fg/bg pair the contrast cell shows today. Phase 3 adds three pure functions in app-core.js for that.
fgSetFor(face, state) builds a covered face's foreground set: the distinct syntax-token colors plus the default foreground, each labeled by syntax role. It returns a structured reason ('out-of-scope' or 'empty') rather than a bogus set when the face isn't covered or has no syntax assignments. floor(bgHex, fgSet) returns the minimum WCAG contrast over that set with the limiting foreground's hex and label. lMax(hue, chroma, fgSet, target) finds the lightest background that still clears the target, scanning L up from black to bracket the dark-side crossing then binary-searching it, and reports status ok/none/all/clamp.
state is passed explicitly (covered set, syntax assignments, default fg) so the functions read no globals and the Node tests stay direct. The closed five-face covered set lives here as COVERED_FACES, shared with app.js. Tests include the sterling keyword-blue worst case as a fixture, plus lMax's none/all/clamp branches.
| -rw-r--r-- | scripts/theme-studio/app-core.js | 58 | ||||
| -rw-r--r-- | scripts/theme-studio/test-contrast.mjs | 111 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 54 |
3 files changed, 221 insertions, 2 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 7c3bcc45..83f8402d 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 } from './colormath.js'; +import { oklch2hex, srgb2oklab, oklab2oklch, 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;} @@ -67,4 +67,58 @@ function ramp(baseHex,opts){ return {steps,adjusted}; } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp }; +// --- background-contrast safety (palette-ramps spec, Phase 3) ---------------- +// An overlay background sits behind many foregrounds at once, so its real +// constraint is the worst-case contrast over the whole set, not one fg/bg pair. + +// The closed v1 set of code-overlay faces whose worst-case floor we compute. +// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added +// explicitly rather than by a heuristic. Shared by app.js and the tests. +const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch']; + +// A covered face's foreground set: the distinct syntax-token colors plus the +// default foreground, each labeled (syntax role preferred, else 'default'). +// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns +// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the +// face isn't in the covered set) or 'empty' (no syntax assignments constrain it). +function fgSetFor(face,state){ + const covered=(state&&state.covered)||COVERED_FACES; + if(!covered.includes(face))return {set:[],reason:'out-of-scope'}; + const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex); + if(!syn.length)return {set:[],reason:'empty'}; + const byHex=new Map(); + const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;}; + if(state&&state.defaultFg)add(state.defaultFg,'default',false); + for(const a of syn)add(a.hex,a.role||a.hex,true); + return {set:[...byHex.values()]}; +} + +// Worst-case (minimum) WCAG contrast of a background against a foreground set, +// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty +// set returns nulls so the caller can show the no-set readout instead of a floor. +function floor(bgHex,fgSet){ + if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null}; + let best=Infinity,lh=null,ll=null; + for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}} + return {ratio:best,limitingHex:lh,limitingLabel:ll}; +} + +// The lightest background at (hue, chroma) whose worst-case floor over fgSet still +// clears target (a WCAG ratio). Scans L up from black to bracket the first +// dark-side crossing, then binary-searches it to tol 0.001. status: +// 'ok' - a ceiling L was found +// 'none' - even pure black fails (a foreground is too dark for the target) +// 'all' - no foreground set to constrain (vacuously safe everywhere) +// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there) +function lMax(hue,chroma,fgSet,target){ + if(!fgSet||!fgSet.length)return {L:1,status:'all'}; + const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};}; + if(at(0).r<target)return {L:null,status:'none'}; + let loL=0,hiL=null; + for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;} + if(hiL===null)return {L:1,status:'all'}; + for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} + return {L:loL,status:at(loL).clamped?'clamp':'ok'}; +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES }; diff --git a/scripts/theme-studio/test-contrast.mjs b/scripts/theme-studio/test-contrast.mjs new file mode 100644 index 00000000..9baf5bcc --- /dev/null +++ b/scripts/theme-studio/test-contrast.mjs @@ -0,0 +1,111 @@ +// Unit tests for the background-contrast safety core (app-core.js): fgSetFor, +// floor, and lMax. Phase 3 of the palette-ramps spec. A background overlay sits +// behind many foregrounds at once, so its real constraint is the worst-case +// (minimum) contrast over that foreground set, and the lightest background that +// keeps the floor above the target. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fgSetFor, floor, lMax, COVERED_FACES } from './app-core.js'; +import { contrast, oklch2hex } from './colormath.js'; + +const DEFAULT_FG = '#f0fef0'; +const stateWith = (syntaxAssignments) => ({ covered: COVERED_FACES, syntaxAssignments, defaultFg: DEFAULT_FG }); + +// --- fgSetFor --------------------------------------------------------------- + +test('fgSetFor: Normal — covered face gets default fg plus the distinct syntax colors', () => { + const r = fgSetFor('region', stateWith([ + { role: 'keyword', hex: '#67809c' }, + { role: 'string', hex: '#a3b18a' }, + ])); + assert.equal(r.reason, undefined); + assert.equal(r.set.length, 3); // default + 2 syntax + const hexes = r.set.map(e => e.hex); + assert.ok(hexes.includes('#f0fef0') && hexes.includes('#67809c') && hexes.includes('#a3b18a')); + assert.equal(r.set.find(e => e.hex === '#67809c').label, 'keyword'); + assert.equal(r.set.find(e => e.hex === '#f0fef0').label, 'default'); +}); + +test('fgSetFor: Boundary — a syntax hex equal to the default collapses, role label wins', () => { + const r = fgSetFor('region', { covered: COVERED_FACES, syntaxAssignments: [{ role: 'keyword', hex: '#f0fef0' }], defaultFg: '#f0fef0' }); + assert.equal(r.set.length, 1); + assert.equal(r.set[0].label, 'keyword'); // role preferred over 'default' +}); + +test('fgSetFor: Boundary — null/blank syntax hexes are dropped', () => { + const r = fgSetFor('isearch', stateWith([{ role: 'a', hex: null }, { role: 'b', hex: '#112233' }])); + assert.equal(r.set.length, 2); // default + the one real hex + assert.ok(r.set.some(e => e.hex === '#112233')); +}); + +test('fgSetFor: Error — a face outside the covered set is out-of-scope', () => { + const r = fgSetFor('mode-line', stateWith([{ role: 'keyword', hex: '#67809c' }])); + assert.deepEqual(r, { set: [], reason: 'out-of-scope' }); +}); + +test('fgSetFor: Error — a covered face with no syntax assignments is empty', () => { + const r = fgSetFor('hl-line', stateWith([])); + assert.deepEqual(r, { set: [], reason: 'empty' }); +}); + +test('fgSetFor: Normal — every covered face is in scope', () => { + for (const f of COVERED_FACES) { + const r = fgSetFor(f, stateWith([{ role: 'kw', hex: '#67809c' }])); + assert.equal(r.reason, undefined, `${f} should be covered`); + } +}); + +// --- floor ------------------------------------------------------------------ + +test('floor: Normal — the keyword-blue worst case sets the floor and is named', () => { + // sterling's keyword blue is the darkest foreground; against a lifted highlight + // background it is the limiting color while the light default still clears. + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const bg = '#202830'; + const r = floor(bg, fgSet); + assert.equal(r.limitingHex, '#67809c'); + assert.equal(r.limitingLabel, 'keyword'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', bg)) < 1e-9); + assert.ok(r.ratio < contrast('#f0fef0', bg), 'the floor is below the default-fg contrast'); +}); + +test('floor: Boundary — a single-entry set makes that entry the limit', () => { + const r = floor('#000000', [{ hex: '#67809c', label: 'keyword' }]); + assert.equal(r.limitingHex, '#67809c'); + assert.ok(Math.abs(r.ratio - contrast('#67809c', '#000000')) < 1e-9); +}); + +test('floor: Error — an empty set returns nulls, not a bogus ratio', () => { + assert.deepEqual(floor('#000000', []), { ratio: null, limitingHex: null, limitingLabel: null }); +}); + +// --- lMax ------------------------------------------------------------------- + +const F = (L, chroma, hue, fgSet) => floor(oklch2hex(L, chroma, hue).hex, fgSet).ratio; + +test('lMax: Normal — finds the lightest safe background; the floor brackets the target', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(0, 0, fgSet, 4.5); + assert.equal(r.status, 'ok'); + assert.ok(r.L > 0 && r.L < 1); + assert.ok(F(r.L, 0, 0, fgSet) >= 4.5 - 0.05, 'floor at L_max clears the target'); + assert.ok(F(Math.min(1, r.L + 0.05), 0, 0, fgSet) < 4.5, 'just above L_max the floor fails'); +}); + +test('lMax: Boundary — no L satisfies the target when a foreground is too dark', () => { + const r = lMax(0, 0, [{ hex: '#1a1a1a', label: 'dim' }], 4.5); + assert.equal(r.status, 'none'); // even pure-black background can't lift #1a1a1a to AA +}); + +test('lMax: Boundary — an empty foreground set is vacuously safe everywhere', () => { + const r = lMax(0, 0, [], 4.5); + assert.deepEqual(r, { L: 1, status: 'all' }); +}); + +test('lMax: Boundary — requesting an unreachable chroma at the ceiling reports clamp', () => { + const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }]; + const r = lMax(250, 0.3, fgSet, 4.5); // 0.3 chroma is out of gamut at the dark ceiling + assert.equal(r.status, 'clamp'); + assert.ok(r.L > 0 && r.L < 1); +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index e81fb59c..3fc8bdb1 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -446,6 +446,60 @@ function ramp(baseHex,opts){ } return {steps,adjusted}; } + +// --- background-contrast safety (palette-ramps spec, Phase 3) ---------------- +// An overlay background sits behind many foregrounds at once, so its real +// constraint is the worst-case contrast over the whole set, not one fg/bg pair. + +// The closed v1 set of code-overlay faces whose worst-case floor we compute. +// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added +// explicitly rather than by a heuristic. Shared by app.js and the tests. +const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch']; + +// A covered face's foreground set: the distinct syntax-token colors plus the +// default foreground, each labeled (syntax role preferred, else 'default'). +// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns +// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the +// face isn't in the covered set) or 'empty' (no syntax assignments constrain it). +function fgSetFor(face,state){ + const covered=(state&&state.covered)||COVERED_FACES; + if(!covered.includes(face))return {set:[],reason:'out-of-scope'}; + const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex); + if(!syn.length)return {set:[],reason:'empty'}; + const byHex=new Map(); + const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;}; + if(state&&state.defaultFg)add(state.defaultFg,'default',false); + for(const a of syn)add(a.hex,a.role||a.hex,true); + return {set:[...byHex.values()]}; +} + +// Worst-case (minimum) WCAG contrast of a background against a foreground set, +// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty +// set returns nulls so the caller can show the no-set readout instead of a floor. +function floor(bgHex,fgSet){ + if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null}; + let best=Infinity,lh=null,ll=null; + for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}} + return {ratio:best,limitingHex:lh,limitingLabel:ll}; +} + +// The lightest background at (hue, chroma) whose worst-case floor over fgSet still +// clears target (a WCAG ratio). Scans L up from black to bracket the first +// dark-side crossing, then binary-searches it to tol 0.001. status: +// 'ok' - a ceiling L was found +// 'none' - even pure black fails (a foreground is too dark for the target) +// 'all' - no foreground set to constrain (vacuously safe everywhere) +// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there) +function lMax(hue,chroma,fgSet,target){ + if(!fgSet||!fgSet.length)return {L:1,status:'all'}; + const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};}; + if(at(0).r<target)return {L:null,status:'none'}; + let loL=0,hiL=null; + for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;} + if(hiL===null)return {L:1,status:'all'}; + for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} + return {L:loL,status:at(loL).clamped?'clamp':'ok'}; +} // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. // Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status |
