diff options
Diffstat (limited to 'scripts')
| -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 |
