You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/palette-invariant.test.ts

131 lines
6.0 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* Palette confusability invariant (THEMING §25.2, guard G4).
*
* The three-gate rule for admitting a family ends in "not confusable with an
* existing one". That guarantee is only worth the WEAKEST generator, so it is
* verified on OUTPUT, not input — every generator's palette must clear a floor:
*
* - no two chromatic families are near-duplicates (ΔE Oklab > IDENTICAL at steps
* 3/9/11 — tints and text collide worse than the solid, so we check beyond step 9);
* - the authored palette stays at/above the SHIPPED floor (grandfathering the
* Radix-canonical tight pairs it already ships — teal/jade et al.);
* - `generatePalette` never degenerates across the builder's CLAMPED character
* range (beyond it, bright families whiten to the gamut ceiling and merge);
* - `deriveScheme` (M3) keeps primary/secondary/tertiary distinct — EXCEPT
* `monochrome`, whose collapse to one ink is intentional and documented.
*
* Thresholds are calibrated to the SHIPPED palette, not an abstract ideal: the
* authored set's own floor is ~0.023 at the solid. The point is to catch a NEW
* family (authored or generated) that is tighter than what already ships.
*/
import { describe, expect, it } from 'vitest';
import {
parseColor,
oklchToOklab,
scaleToTemplate,
deriveScheme,
type Oklch,
type ScaleSteps
} from '$color';
import { COLOR_SCALE_STEPS } from './config-types';
import { THEME_BASE_LIGHT_COLOR_SCALES } from './themes/base';
import { generatePalette, type HueAnchor } from './generate-palette';
/**
* Two floors, because tints and solids are not comparable. The SOLID (step 9) is
* the accent components actually use, so it carries the real confusability floor
* (the shipped set's tightest solid, teal↔jade, is ~0.0235). Tints (step 3) and
* text (step 11) are inherently compressed toward the axis — the shipped palette
* itself has green↔jade at ~0.003 in the tints — so there the bar is only "not
* literally the same colour". `IDENTICAL` catches a generator that MERGES two
* families (e.g. `generatePalette` at extreme tone: yellow == lime, ΔE 0.0).
*/
const SHIPPED_FLOOR = 0.02;
const IDENTICAL = 0.002;
const LIGHT = THEME_BASE_LIGHT_COLOR_SCALES;
const NAMES = Object.keys(LIGHT);
const CHROMATIC = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] > 0.04);
const dE = (a: readonly number[], b: readonly number[]) =>
Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
const labOf = (c: Oklch | string) => oklchToOklab(typeof c === 'string' ? parseColor(c) : c);
/** min pairwise ΔE across a list of {name, lab}, with the winning pair. */
function minPair(fam: { n: string; lab: readonly number[] }[]): { a: string; b: string; d: number } {
let best = { a: '', b: '', d: Infinity };
for (let i = 0; i < fam.length; i++)
for (let j = i + 1; j < fam.length; j++) {
const d = dE(fam[i].lab, fam[j].lab);
if (d < best.d) best = { a: fam[i].n, b: fam[j].n, d };
}
return best;
}
describe('palette confusability invariant (G4)', () => {
it('authored palette — no chromatic family is a near-duplicate at step 3 / 9 / 11', () => {
for (const step of ['3', '9', '11'] as const) {
const fam = CHROMATIC.map((n) => ({ n, lab: labOf(LIGHT[n][step]) }));
const m = minPair(fam);
expect(m.d, `step ${step}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL);
}
});
it('authored palette — solid floor holds (grandfathers the Radix-canonical tight pairs)', () => {
const fam = CHROMATIC.map((n) => ({ n, lab: labOf(LIGHT[n]['9']) }));
const m = minPair(fam);
// Documents the known tightest pair (teal↔jade ≈ 0.0235); fails if a future
// authored family lands below the shipped floor.
expect(m.d, `tightest solid pair: ${m.a}↔${m.b} = ${m.d.toFixed(4)}`).toBeGreaterThan(SHIPPED_FLOOR);
});
// ── generatePalette (parametric) ──
const anchors: HueAnchor[] = NAMES.map((n) => {
const [, c, h] = parseColor(LIGHT[n]['9']);
return { name: n, hue: h, chroma: c };
});
const grays = NAMES.filter((n) => parseColor(LIGHT[n]['9'])[1] < 0.03);
const donors: Record<string, ScaleSteps> = Object.fromEntries(
CHROMATIC.map((n) => [n, scaleToTemplate(COLOR_SCALE_STEPS.map((s) => LIGHT[n][s]))])
);
const genMin = (vivacity: number, toneShift: number) => {
const pal = generatePalette(anchors, donors, { vivacity, toneShift, neutralHue: 55, neutralNames: grays });
const fam = pal.filter((p) => !grays.includes(p.name)).map((p) => ({ n: p.name, lab: labOf(p.hex[8]) }));
return minPair(fam);
};
it('generatePalette — default character reproduces a palette at the shipped floor', () => {
expect(genMin(1, 0).d).toBeGreaterThan(SHIPPED_FLOOR);
});
it('generatePalette — never degenerates across the builder clamped range (viv 0.4–1.9 · tone −0.15…+0.08)', () => {
for (const viv of [0.4, 0.7, 1, 1.4, 1.9])
for (const tone of [-0.15, -0.08, 0, 0.05, 0.08]) {
const m = genMin(viv, tone);
expect(m.d, `viv=${viv} tone=${tone}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL);
}
});
// ── deriveScheme (M3 runtime) ──
const SEEDS = ['#6366f1', '#12a594', '#e5484d', '#8b8b8b', '#0a7ea4'];
const schemeMin = (hex: string, variant: 'tonal' | 'vibrant' | 'monochrome') => {
const s = deriveScheme(parseColor(hex), variant);
const roles = (['primary', 'secondary', 'tertiary'] as const).map((r) => ({ n: r, lab: labOf(s[r] as Oklch) }));
return minPair(roles);
};
it('deriveScheme — tonal/vibrant keep primary/secondary/tertiary distinct', () => {
for (const variant of ['tonal', 'vibrant'] as const)
for (const hex of SEEDS) {
const m = schemeMin(hex, variant);
expect(m.d, `${variant} ${hex}: ${m.a}↔${m.b}`).toBeGreaterThan(IDENTICAL);
}
});
it('deriveScheme — monochrome collapse is intentional (roles coincide by design)', () => {
// The exemption is real, not an oversight: monochrome MUST fold the hierarchy
// to one ink. Assert it does — differentiation is by tone + emphasis, not hue.
for (const hex of SEEDS) expect(schemeMin(hex, 'monochrome').d).toBeLessThan(IDENTICAL);
});
});

Powered by TurnKey Linux.