diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-10 01:18:20 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-10 01:18:20 -0500 |
| commit | 77783126c8e35d5880a3e16a0014fc727f59b00a (patch) | |
| tree | 2cf11ad85a2ac99c3bf30d2ebd7c7b1d5482fadd /scripts/theme-studio/test-families.mjs | |
| parent | e7ae18c4731d5576747679814befd56eadc2d461 (diff) | |
| download | dotemacs-77783126c8e35d5880a3e16a0014fc727f59b00a.tar.gz dotemacs-77783126c8e35d5880a3e16a0014fc727f59b00a.zip | |
feat(theme-studio): group families by hue anchor with a lightness-scaled neutral cut
Replace gap-based hue clustering and the flat neutral threshold. Chromatic colors now bucket by nearest perceptual hue anchor (red, orange, yellow, green, teal, blue, purple, pink), so adjacent categories stay separate by construction and there's no single-linkage chaining merging them through intermediate tones. The neutral cut is lightness-scaled rather than flat: a color reads as neutral below a chroma that's highest in the mid-tones and tapers toward the light end, so a faint mid gray goes neutral while an equally-faint pale tint keeps its hue.
This fixes the two concrete problems: the grays and steels consolidate into one neutral column, and pale tints (light blues) stay with their hue instead of falling into the grays. What it doesn't fix is hue-adjacent warm colors: this palette's olive-greens sit on top of the golds in OKLCH hue, so they still group together, and a ramp that drifts in hue can split across an anchor boundary. That's a real property of the colors, not a bug, and it's filed for research (a writeup of the problem and the four approaches tried lives outside the repo; the task points to it).
20 family node tests including the yellow/green split and the no-chaining case; suite green.
Diffstat (limited to 'scripts/theme-studio/test-families.mjs')
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 30 |
1 files changed, 26 insertions, 4 deletions
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs index 14e80c9b..6d436394 100644 --- a/scripts/theme-studio/test-families.mjs +++ b/scripts/theme-studio/test-families.mjs @@ -21,19 +21,41 @@ 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 within the gap stay one family', () => { - const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; // 6° apart < 25° +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 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 — hues past the gap split', () => { - const pal = [at(0.6, 0.1, 250, 'b'), at(0.6, 0.1, 200, 'c')]; // 50° apart > 25° +test('familiesFromPalette: Boundary — different anchors split (blue vs teal)', () => { + 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 + 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'); +}); + +test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => { + const paleBlue = oklch2hex(0.9, 0.03, 255).hex; // light, faint -> still blue + const midGray = oklch2hex(0.6, 0.025, 100).hex; // mid, faint -> reads neutral + const { families } = familiesFromPalette([[paleBlue, 'paleblue'], [midGray, 'graytone']], { bg: '#000000', fg: '#ffffff' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral && neutral.members.some(m => m.name === 'graytone'), 'mid faint color is neutral'); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'paleblue')), 'pale tint stays chromatic'); +}); + test('familiesFromPalette: Boundary — near-neutral colors form a separate family', () => { const pal = [at(0.6, 0.1, 250, 'blue'), at(0.5, 0.004, 250, 'gray')]; // gray below the chroma threshold const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); |
