aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--scripts/theme-studio/app-core.js20
-rw-r--r--scripts/theme-studio/test-families.mjs38
-rw-r--r--scripts/theme-studio/theme-studio.html18
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