|
|
/**
|
|
|
* 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);
|
|
|
});
|
|
|
});
|