diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 00:19:34 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 00:19:34 -0500 |
| commit | 74db9a526503c9cc0c273f4523ad7ef76b61fb64 (patch) | |
| tree | a95aef60f22ca6ee5cd13d2e906c541c268dae41 /scripts/theme-studio | |
| parent | ebe18d51ad99fe0a5916516c47d5dda3315e9add (diff) | |
| download | dotemacs-74db9a526503c9cc0c273f4523ad7ef76b61fb64.tar.gz dotemacs-74db9a526503c9cc0c273f4523ad7ef76b61fb64.zip | |
feat(theme-studio): add color-family sort
sortFamilies orders the strips for display: neutrals first by lightness, then chromatic families by base hue, ties broken by base lightness then base hex. Each family's members come back sorted dark to light. Hue is compared rounded so a sub-degree hue hair from gamut quantization doesn't outrank lightness. Sorting is display-only; the stored palette order is untouched.
Phase 2 of the color-families spec, pure logic. Four node tests cover the hue order, the neutral pin, within-family lightness order, and the (hue, then lightness) ordering invariant. Suite 91 to 95 green.
Diffstat (limited to 'scripts/theme-studio')
| -rw-r--r-- | scripts/theme-studio/app-core.js | 20 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 38 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 18 |
3 files changed, 73 insertions, 3 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 70054255..e6d90039 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -208,4 +208,22 @@ function stepRepointPlan(oldRanked,newMembers){ return {map,removed}; } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan }; +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers }; diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs index 071c8307..14e80c9b 100644 --- a/scripts/theme-studio/test-families.mjs +++ b/scripts/theme-studio/test-families.mjs @@ -5,8 +5,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan } from './app-core.js'; -import { oklch2hex } from './colormath.js'; +import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies } from './app-core.js'; +import { oklch2hex, srgb2oklab, oklab2oklch } from './colormath.js'; // Build a palette entry at a controlled OKLCH hue so clustering is deterministic. const at = (L, C, H, name) => [oklch2hex(L, C, H).hex, name || ('c' + H)]; @@ -111,3 +111,37 @@ test('stepRepointPlan: Boundary — an offset with no new counterpart is removed assert.deepEqual(map, []); assert.deepEqual(removed, ['#000033']); }); + +// --- sortFamilies ----------------------------------------------------------- + +const fam = (baseHex, neutral, members) => ({ base: baseHex, neutral: !!neutral, members: (members || [baseHex]).map(h => ({ hex: h, name: h })) }); + +test('sortFamilies: Normal — chromatic families order by base hue', () => { + const fams = [fam(oklch2hex(0.6, 0.1, 270).hex), fam(oklch2hex(0.6, 0.1, 30).hex), fam(oklch2hex(0.6, 0.1, 150).hex)]; + const sorted = sortFamilies(fams); + const hues = sorted.map(f => Math.round(oklab2oklch(srgb2oklab(f.base)).H)); + for (let i = 1; i < hues.length; i++) assert.ok(hues[i] > hues[i - 1], 'ascending hue: ' + hues.join(',')); +}); + +test('sortFamilies: Boundary — neutral families pin ahead of chromatic ones', () => { + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false), fam('#808080', true)]); + assert.equal(sorted[0].neutral, true, 'neutral first'); + assert.equal(sorted[1].neutral, false); +}); + +test('sortFamilies: Normal — members within a family sort dark to light', () => { + const members = ['#dddddd', '#222222', '#888888']; + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false, members)]); + const ls = sorted[0].members.map(m => oklab2oklch(srgb2oklab(m.hex)).L); + for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'ascending lightness'); +}); + +test('sortFamilies: Boundary — order is (hue, then lightness); a hue tie falls to lightness', () => { + const bases = [oklch2hex(0.6, 0.1, 200).hex, oklch2hex(0.5, 0.1, 200).hex, oklch2hex(0.6, 0.1, 40).hex]; + const sorted = sortFamilies(bases.map(b => fam(b, false))); + const key = h => { const c = oklab2oklch(srgb2oklab(h)); return [Math.round(c.H), c.L]; }; + for (let i = 1; i < sorted.length; i++) { + const [h0, l0] = key(sorted[i - 1].base), [h1, l1] = key(sorted[i].base); + assert.ok(h0 < h1 || (h0 === h1 && l0 <= l1), `order at ${i}: hue ${h0}/${h1} L ${l0.toFixed(3)}/${l1.toFixed(3)}`); + } +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 1716db9b..27df2a83 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -613,6 +613,24 @@ function stepRepointPlan(oldRanked,newMembers){ } return {map,removed}; } + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} // 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 |
