diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 01:56:15 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 01:56:15 -0500 |
| commit | 04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e (patch) | |
| tree | c34f9d75c4d005f31df7e7b42fa2684c5a688531 /scripts/theme-studio/test-families.mjs | |
| parent | 980cbe1a3a7a3930690b7780ea2c6aa674e9f2a0 (diff) | |
| download | dotemacs-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.mjs | 68 |
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' }); |
