aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-families.mjs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-10 01:56:15 -0500
committerCraig Jennings <c@cjennings.net>2026-06-10 01:56:15 -0500
commit04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e (patch)
treec34f9d75c4d005f31df7e7b42fa2684c5a688531 /scripts/theme-studio/test-families.mjs
parent980cbe1a3a7a3930690b7780ea2c6aa674e9f2a0 (diff)
downloaddotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.tar.gz
dotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.zip
feat(theme-studio): group families by lightness-conditioned complete linkage
Replace the hue-anchor bucketing and the tent neutral threshold with the model two independent reviews of color-sorting.org converged on (Codex and Fable, with Fable's harness measuring pairwise F1 0.63 → 0.96 on the real palette). Chromatic colors now cluster by complete-linkage agglomeration on a lightness-conditioned hue distance: hue must match tightly at equal lightness and may drift across a lightness gap, because a tonal ramp drifts in hue with lightness by design. A low-chroma noise term widens the tolerance where hue is ill-defined, and a chroma clause keeps a vivid accent out of a soft same-hue family. Complete linkage makes single-linkage chaining structurally impossible. The neutral threshold is floored at both ends instead of tapering to zero, which fixes two real defects: pale warm grays (gray+1, gray+2) that leaked into a color column, and pure white (C=0 at L=1) that evaded a zero threshold. On the sterling/distinguished palette this separates the gold and olive ramps (the green/yellow complaint), keeps the red and blue ramps whole including drifted tints, isolates intense-red, and consolidates every gray and steel into the neutral column. The one residual — pale yellow+2 lands on the olive ramp — is geometrically irreducible from the hex (it sits on the olive trajectory by nearest-neighbor, ramp-line fit, and eye); only its name says gold. That needs the deferred per-hex family-hint override. New node tests cover the gold/olive split, blue pale-tint cohesion, gray/white neutrality, intense-red isolation, and palette-order independence. The count gate now asserts the count action adds all ramp colors to the palette rather than that they all display in one family, since a chroma-eased extreme can sit at the neutral boundary.
Diffstat (limited to 'scripts/theme-studio/test-families.mjs')
-rw-r--r--scripts/theme-studio/test-families.mjs68
1 files changed, 56 insertions, 12 deletions
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs
index 6d436394..c6602aeb 100644
--- a/scripts/theme-studio/test-families.mjs
+++ b/scripts/theme-studio/test-families.mjs
@@ -21,30 +21,24 @@ test('familiesFromPalette: Normal — separated hues split into one family each'
for (const f of families) assert.equal(f.members.length, 1);
});
-test('familiesFromPalette: Boundary — hues sharing an anchor stay one family', () => {
- const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; // both nearest the blue anchor
+test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => {
+ const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 1, 'a near hue-pair is one family');
assert.equal(families[0].members.length, 2);
});
-test('familiesFromPalette: Boundary — different anchors split (blue vs teal)', () => {
+test('familiesFromPalette: Boundary — well-separated hues split', () => {
const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 2);
});
-test('familiesFromPalette: Normal — yellow and green land in separate families', () => {
- const pal = [at(0.7, 0.12, 100, 'yellow'), at(0.6, 0.12, 145, 'green')];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2, 'yellow and green are separate anchors');
-});
-
-test('familiesFromPalette: Boundary — an intermediate chain does not merge yellow into green', () => {
- // gold, olive, yellow-green, green: anchor assignment buckets by nearest, no single-linkage chaining
+test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => {
+ // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing
const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2, 'two anchors (yellow, green), not one chained family');
+ assert.equal(families.length, 2, 'not one chained family');
});
test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => {
@@ -65,6 +59,56 @@ test('familiesFromPalette: Boundary — near-neutral colors form a separate fami
assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue')));
});
+// --- real-palette grouping (the hard cases the color-sorting reviews measured) ---
+
+// The contested region of the distinguished/sterling palette: the gold ramp and
+// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart.
+const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']];
+const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']];
+const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name));
+
+test('familiesFromPalette: Normal — the gold and olive ramps separate', () => {
+ const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' });
+ const gold = famOf(families, 'yellow'), olive = famOf(families, 'green');
+ assert.notEqual(gold, olive, 'gold and olive are different families');
+ assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens');
+ assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows');
+});
+
+test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => {
+ // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together
+ const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ const blue = famOf(families, 'blue');
+ assert.equal(blue.members.length, 3, 'all three blues in one family');
+});
+
+test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => {
+ const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch
+ const neutral = families.find(f => f.neutral);
+ for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral');
+ assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic');
+});
+
+test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => {
+ // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart
+ const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family');
+});
+
+test('familiesFromPalette: Boundary — grouping is independent of palette order', () => {
+ const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']];
+ const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families
+ .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | ');
+ const ref = key(base);
+ for (const seed of [1, 2, 3]) { // a few deterministic shuffles
+ const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]);
+ assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping');
+ }
+});
+
test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => {
const pal = [at(0.6, 0.1, 250, 'blue')];
const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });