@vectoriox/iox-ui 5.25.0 → 5.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/design-core/index.d.ts +1153 -191
  2. package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
  3. package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
  4. package/fesm2022/vectoriox-iox-ui-generation-core.mjs +7018 -0
  5. package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -0
  6. package/generation-core/index.d.ts +1397 -0
  7. package/generation-core/package.json +3 -0
  8. package/package.json +5 -1
  9. package/src/design-core/brand-neutrals.ts +236 -0
  10. package/src/design-core/brand.ts +24 -14
  11. package/src/design-core/colour-eval.ts +117 -0
  12. package/src/design-core/contrast-audit.ts +214 -0
  13. package/src/design-core/design-apply.ts +34 -14
  14. package/src/design-core/design-settings.ts +12 -3
  15. package/src/design-core/design-token.model.ts +26 -0
  16. package/src/design-core/flavor-fixtures.generated.ts +150 -0
  17. package/src/design-core/flavor-spec.generated.ts +1061 -189
  18. package/src/design-core/flavor-tokens.ts +36 -8
  19. package/src/design-core/public-api.ts +3 -0
  20. package/src/design-core/token-css.ts +48 -15
  21. package/src/generation-core/compose-site.ts +474 -0
  22. package/src/generation-core/node-baseline.model.ts +10 -0
  23. package/src/generation-core/node-baselines.generated.ts +4841 -0
  24. package/src/generation-core/patterns.ts +125 -0
  25. package/src/generation-core/public-api.ts +20 -0
  26. package/src/generation-core/serialized-node.ts +91 -0
  27. package/src/generation-core/site-plan.model.ts +136 -0
  28. package/src/generation-core/site-spec.generated.ts +1111 -0
  29. package/src/generation-core/templates.ts +60 -0
  30. package/src/generation-core/validate-site-plan.ts +413 -0
  31. package/styles/iox-design.css +16 -0
  32. package/styles/iox-forms.css +15 -9
@@ -0,0 +1,3 @@
1
+ {
2
+ "module": "../fesm2022/vectoriox-iox-ui-generation-core.mjs"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.25.0",
3
+ "version": "5.27.0",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -35,6 +35,10 @@
35
35
  "types": "./forms-core/index.d.ts",
36
36
  "default": "./fesm2022/vectoriox-iox-ui-forms-core.mjs"
37
37
  },
38
+ "./generation-core": {
39
+ "types": "./generation-core/index.d.ts",
40
+ "default": "./fesm2022/vectoriox-iox-ui-generation-core.mjs"
41
+ },
38
42
  "./seo-core": {
39
43
  "types": "./seo-core/index.d.ts",
40
44
  "default": "./fesm2022/vectoriox-iox-ui-seo-core.mjs"
@@ -0,0 +1,236 @@
1
+ import {
2
+ AccentGround, atLightness, deriveAccentTokens, inSrgbGamut, oklabToRgb, parseHexColor, rgbToOklab, Rgb, toHexColor, wcagContrastRatio, WCAG_AA_TEXT,
3
+ } from './brand';
4
+ import { compositeOver, paintColours, quantizeRgb } from './colour-eval';
5
+ import { SPEC_AXES } from './flavor-spec.generated';
6
+ import { FlavorPalette, FlavorTintConfig, PRE_TINT_DEFAULT, TintId } from './design-token.model';
7
+ import { resolveTokenValues } from './token-css';
8
+
9
+ /**
10
+ * The whole colour system follows the brand (design-system DS9 — Doron, 2026-10-19: "I would imagine
11
+ * that once I change the colour of the brand, the whole colour system changes, but it doesn't").
12
+ *
13
+ * One brand colour in → every colour token of the look out, light and dark:
14
+ * - **Background tint** (`DesignChoices.tint`): Neutral is the look's own palette, untouched; Subtle and
15
+ * Rich give every neutral role the flavor lists the BRAND's hue — the role keeps its own OKLab
16
+ * lightness (so the look's light/dark structure and contrast survive) at the level's chroma (or its
17
+ * own, when the flavor coloured it more), scaled by how chromatic the brand is: a grey brand tints
18
+ * nothing. Material 3's neutral tonal palettes and Radix's tinted greys, as one table in the contract.
19
+ * A pure-white page is capped just under white (the level's `groundMaxL`) so it can carry the tint; the flavor's paints rotate
20
+ * with the brand (Glass's mesh); Midnight's glow already reads the accent.
21
+ * - **Accents**: the flavor's own quartet at its own seed, else derived from the brand ramp — and
22
+ * `accent-text` is the first step that reads on EVERY ground (bg, surface, surface-2, the page's paint).
23
+ * - **Held pairs**: every text and line role is pushed until it clears its pair again on its grounds.
24
+ * A no-op for the hand-tuned palette at its own seed — the generator's gate proves it.
25
+ *
26
+ * Port of iox-ai-guidance `design-system/colour.mjs` `flavorColours`; held equal to it by
27
+ * `flavor-fixtures.v1.json` (brand-neutrals.spec.ts). Change the maths THERE first.
28
+ */
29
+
30
+ /** A seed below this OKLCH chroma tints at a proportionally lower strength. */
31
+ export const TINT_FULL_CHROMA = 0.08;
32
+ /** A neutral at least this chromatic was coloured on purpose: it keeps its hue offset from the flavor's seed. */
33
+ export const TINT_OWN_HUE_CHROMA = 0.01;
34
+ /** A brand below this chroma has no reliable hue: paints and coloured neutrals are not turned toward it. */
35
+ export const TINT_TURN_CHROMA = 0.02;
36
+ /** WCAG 1.4.11 — non-text contrast: UI boundaries, glyphs, focus indicators. */
37
+ export const WCAG_AA_UI = 3;
38
+
39
+ const PAGE = 'page';
40
+ const GROUND_ROLES: ReadonlySet<string> = new Set(['bg']);
41
+ const TEXT_GROUNDS: ReadonlyArray<string> = ['bg', 'surface', 'surface-2', PAGE];
42
+ const INVERSE_GROUNDS: ReadonlyArray<string> = ['inverse-bg', 'inverse-surface'];
43
+ const MODES = ['light', 'dark'] as const;
44
+
45
+ /** The roles a derivation holds to their pair, on the grounds named (`page` = the page paint's colours). */
46
+ export const HELD_CONTRAST: ReadonlyArray<readonly [string, ReadonlyArray<string>, number]> = [
47
+ ['text', TEXT_GROUNDS, WCAG_AA_TEXT],
48
+ ['text-muted', TEXT_GROUNDS, WCAG_AA_TEXT],
49
+ ['line-strong', TEXT_GROUNDS, WCAG_AA_UI],
50
+ ['danger', TEXT_GROUNDS, WCAG_AA_TEXT],
51
+ ['success', TEXT_GROUNDS, WCAG_AA_TEXT],
52
+ ['inverse-text', INVERSE_GROUNDS, WCAG_AA_TEXT],
53
+ ['inverse-muted', INVERSE_GROUNDS, WCAG_AA_TEXT],
54
+ ];
55
+
56
+ /** One tint level of the contract (`axes.tints`). */
57
+ export interface TintLevel {
58
+ readonly label: string;
59
+ readonly chroma?: Readonly<Record<string, number>>;
60
+ readonly ownScale?: number;
61
+ readonly paintChroma?: number;
62
+ /** A pure-white page is capped just under white so it can carry any chroma (at L 1 only white exists). */
63
+ readonly groundMaxL?: number;
64
+ }
65
+ export const TINT_LEVELS: Readonly<Record<TintId, TintLevel>> = SPEC_AXES.tints;
66
+
67
+ /** What the colour derivation reads of a flavor. */
68
+ export interface ColourFlavor {
69
+ readonly seed: string;
70
+ readonly palette: FlavorPalette;
71
+ readonly tint: FlavorTintConfig;
72
+ }
73
+
74
+ /** Every colour token of a look: `[light, dark]` per name. */
75
+ export type FlavorColours = Record<string, [string, string]>;
76
+
77
+ const upper = (hex: string) => hex.toUpperCase();
78
+ const passesAll = (fg: string, bgs: ReadonlyArray<string>, min: number) => bgs.every(bg => wcagContrastRatio(fg, bg) >= min);
79
+
80
+ /** The lightest/darkest version of `fg` (its own hue and chroma) that clears `min` on every ground. */
81
+ export function ensureContrast(fg: string, bgs: ReadonlyArray<string>, min: number): string {
82
+ if (!parseHexColor(fg) || bgs.some(b => !parseHexColor(b)) || passesAll(fg, bgs, min)) return fg;
83
+ const lab = rgbToOklab(parseHexColor(fg) as Rgb);
84
+ const meanBgL = bgs.reduce((sum, b) => sum + rgbToOklab(parseHexColor(b) as Rgb).L, 0) / bgs.length;
85
+ const darker = lab.L <= meanBgL;
86
+ const chroma = Math.hypot(lab.a, lab.b);
87
+ const ua = chroma > 1e-6 ? lab.a : 0;
88
+ const ub = chroma > 1e-6 ? lab.b : 1e-6;
89
+ let lo = darker ? 0 : lab.L;
90
+ let hi = darker ? lab.L : 1;
91
+ let best = darker ? '#000000' : '#ffffff';
92
+ for (let i = 0; i < 30; i++) {
93
+ const mid = (lo + hi) / 2;
94
+ const candidate = toHexColor(atLightness(mid, ua, ub, chroma));
95
+ if (passesAll(candidate, bgs, min)) {
96
+ best = candidate;
97
+ if (darker) lo = mid; else hi = mid;
98
+ } else if (darker) hi = mid; else lo = mid;
99
+ }
100
+ return best;
101
+ }
102
+
103
+ const RGBA = /^rgba\(\s*(\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\s*\)$/i;
104
+ const ALPHA_STEP = 0.01;
105
+
106
+ /** A translucent `fg` (rgba) made just opaque enough to clear `min` on every ground, in 0.01 steps. */
107
+ export function ensureAlpha(fg: string, bgs: ReadonlyArray<string>, min: number): string {
108
+ const m = RGBA.exec(String(fg ?? '').trim());
109
+ if (!m) return fg;
110
+ const [r, g, b] = [m[1], m[2], m[3]].map(Number);
111
+ const passes = (a: number) => bgs.every(bg => {
112
+ const ground = parseHexColor(bg) as Rgb;
113
+ return wcagContrastRatio(quantizeRgb(compositeOver({ r: r / 255, g: g / 255, b: b / 255, a }, ground)), ground) >= min;
114
+ });
115
+ let alpha = Number(m[4]);
116
+ if (passes(alpha)) return fg;
117
+ while (alpha < 1 && !passes(alpha)) alpha = Math.min(1, Math.round((alpha + ALPHA_STEP) * 100) / 100);
118
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
119
+ }
120
+
121
+ /** The colour at (L, hue angle) with the most chroma ≤ `chroma` that fits sRGB (a tint never overshoots its
122
+ * level, and a stronger level never lands under a weaker one — `atLightness`'s ×0.82 steps can). */
123
+ export function atLightnessMaxChroma(L: number, hue: number, chroma: number): Rgb {
124
+ const ua = Math.cos(hue);
125
+ const ub = Math.sin(hue);
126
+ const at = (c: number) => oklabToRgb({ L, a: ua * c, b: ub * c });
127
+ if (inSrgbGamut(at(chroma))) return at(chroma);
128
+ let lo = 0;
129
+ let hi = chroma;
130
+ for (let i = 0; i < 24; i++) {
131
+ const mid = (lo + hi) / 2;
132
+ if (inSrgbGamut(at(mid))) lo = mid; else hi = mid;
133
+ }
134
+ return at(lo);
135
+ }
136
+
137
+ /** The seed's OKLab hue angle and chroma — a grey seed keeps a stable (arbitrary) axis. */
138
+ function seedHue(seed: string): { hue: number; chroma: number } {
139
+ const lab = rgbToOklab(parseHexColor(seed) as Rgb);
140
+ const chroma = Math.hypot(lab.a, lab.b);
141
+ const ua = chroma > 1e-6 ? lab.a : 0.0;
142
+ const ub = chroma > 1e-6 ? lab.b : 1e-6;
143
+ return { hue: Math.atan2(ub, ua), chroma };
144
+ }
145
+
146
+ const HEX_IN = /#[0-9a-f]{6}\b/gi;
147
+
148
+ /** Every hex colour inside a paint (a gradient's stops) turned by `angle` radians of OKLab hue. */
149
+ function rotatePaint(paint: string, angle: number, chromaScale: number): string {
150
+ return String(paint).replace(HEX_IN, hex => {
151
+ const lab = rgbToOklab(parseHexColor(hex) as Rgb);
152
+ const a = lab.a * Math.cos(angle) - lab.b * Math.sin(angle);
153
+ const b = lab.a * Math.sin(angle) + lab.b * Math.cos(angle);
154
+ return upper(toHexColor(atLightness(lab.L, a, b, Math.hypot(a, b) * chromaScale)));
155
+ });
156
+ }
157
+
158
+ /** The opaque colours a palette's named grounds show; `page` = every colour of the page's own paint. */
159
+ function groundsOf(p: Readonly<Record<string, string>>, names: ReadonlyArray<string>): string[] {
160
+ const out: string[] = [];
161
+ for (const name of names) {
162
+ if (name === PAGE) {
163
+ const resolved = resolveTokenValues(Object.entries(p).map(([n, value]) => ({ name: n, value })))['page-bg'];
164
+ for (const c of paintColours(resolved)) if (c.a >= 1) out.push(toHexColor(c));
165
+ } else if (parseHexColor(p[name])) out.push(p[name]);
166
+ }
167
+ return [...new Set(out.map(upper))];
168
+ }
169
+
170
+ function holdContrast(p: Record<string, string>): void {
171
+ for (const [fg, names, min] of HELD_CONTRAST) {
172
+ if (parseHexColor(p[fg])) p[fg] = upper(ensureContrast(p[fg], groundsOf(p, names), min));
173
+ else if (RGBA.test(String(p[fg] ?? '').trim())) p[fg] = ensureAlpha(p[fg], groundsOf(p, names), min);
174
+ }
175
+ }
176
+
177
+ function tintPalette(p: Record<string, string>, flavor: ColourFlavor, level: TintLevel, seed: string): void {
178
+ const { hue: brandHue, chroma } = seedHue(seed);
179
+ const strength = Math.min(1, chroma / TINT_FULL_CHROMA);
180
+ const turn = chroma >= TINT_TURN_CHROMA ? brandHue - seedHue(flavor.seed).hue : 0;
181
+ for (const role of flavor.tint.roles) {
182
+ const rgb = parseHexColor(p[role]);
183
+ if (!rgb) continue;
184
+ const lab = rgbToOklab(rgb);
185
+ const L = GROUND_ROLES.has(role) ? Math.min(lab.L, level.groundMaxL ?? 1) : lab.L;
186
+ const own = Math.hypot(lab.a, lab.b);
187
+ const hue = own >= TINT_OWN_HUE_CHROMA ? Math.atan2(lab.b, lab.a) + turn : brandHue;
188
+ const c = Math.max(own * (level.ownScale ?? 1), (level.chroma?.[role] ?? 0) * flavor.tint.scale) * strength;
189
+ p[role] = upper(toHexColor(atLightnessMaxChroma(L, hue, c)));
190
+ }
191
+ for (const name of flavor.tint.paints ?? []) p[name] = rotatePaint(p[name], turn, level.paintChroma ?? 1);
192
+ }
193
+
194
+ /**
195
+ * The colour tokens of a flavor for a brand `seed` and a background `tint`, light and dark.
196
+ * Neutral at the flavor's own seed returns its palette exactly.
197
+ */
198
+ export function flavorColours(flavor: ColourFlavor, choices: { seed?: string | null; tint?: TintId | null }): FlavorColours {
199
+ const seed = choices.seed || flavor.seed;
200
+ const level = TINT_LEVELS[choices.tint ?? PRE_TINT_DEFAULT];
201
+ const palette = {
202
+ light: { ...flavor.palette.light } as Record<string, string>,
203
+ dark: { ...flavor.palette.light, ...flavor.palette.dark } as Record<string, string>,
204
+ };
205
+ if (level?.chroma) for (const mode of MODES) tintPalette(palette[mode], flavor, level, seed);
206
+
207
+ if (seed.toLowerCase() !== flavor.seed.toLowerCase()) {
208
+ // Twice: the page paint may read the accent (Midnight's glow), so its grounds are known only once
209
+ // the fill is — and the fill never depends on the grounds.
210
+ for (let pass = 0; pass < 2; pass++) {
211
+ const derived = deriveAccentTokens(seed, {
212
+ bg: groundsOf(palette.light, TEXT_GROUNDS), bgDark: groundsOf(palette.dark, TEXT_GROUNDS),
213
+ inverseBg: groundsOf(palette.light, INVERSE_GROUNDS), inverseBgDark: groundsOf(palette.dark, INVERSE_GROUNDS),
214
+ });
215
+ for (const t of derived) {
216
+ palette.light[t.name] = upper(t.value);
217
+ if (t.values?.dark) palette.dark[t.name] = upper(t.values.dark);
218
+ }
219
+ }
220
+ }
221
+ for (const mode of MODES) {
222
+ const p = palette[mode];
223
+ p['accent-text'] = upper(ensureContrast(p['accent-text'], groundsOf(p, TEXT_GROUNDS), WCAG_AA_TEXT));
224
+ p['inverse-accent'] = upper(ensureContrast(p['inverse-accent'], groundsOf(p, INVERSE_GROUNDS), WCAG_AA_TEXT));
225
+ holdContrast(p);
226
+ }
227
+ const out: FlavorColours = {};
228
+ for (const name of Object.keys(flavor.palette.light)) out[name] = [palette.light[name], palette.dark[name]];
229
+ return out;
230
+ }
231
+
232
+ /** The grounds the PRE-DS9 accent derivation read: `bg` alone (light/dark), `inverse-bg` alone. */
233
+ export function preTintAccentGrounds(palette: FlavorPalette): { bg: AccentGround; bgDark?: AccentGround; inverseBg?: AccentGround; inverseBgDark?: AccentGround } {
234
+ const dark = { ...palette.light, ...palette.dark };
235
+ return { bg: palette.light['bg'] ?? '', bgDark: dark['bg'], inverseBg: palette.light['inverse-bg'], inverseBgDark: dark['inverse-bg'] };
236
+ }
@@ -57,10 +57,11 @@ export function oklabToRgb(c: OkLab): Rgb {
57
57
  };
58
58
  }
59
59
 
60
- const inGamut = (c: Rgb) => [c.r, c.g, c.b].every(v => v >= -1e-6 && v <= 1 + 1e-6);
60
+ export const inSrgbGamut = (c: Rgb) => [c.r, c.g, c.b].every(v => v >= -1e-6 && v <= 1 + 1e-6);
61
+ const inGamut = inSrgbGamut;
61
62
 
62
63
  /** The OKLab colour at (L, hue, chroma), chroma reduced until it fits sRGB. */
63
- function atLightness(L: number, hueA: number, hueB: number, chroma: number): Rgb {
64
+ export function atLightness(L: number, hueA: number, hueB: number, chroma: number): Rgb {
64
65
  const len = Math.hypot(hueA, hueB) || 1;
65
66
  const ua = hueA / len, ub = hueB / len;
66
67
  let c = chroma;
@@ -231,19 +232,27 @@ export const ACCENT_TOKEN_NAMES = [
231
232
  ] as const;
232
233
  export type AccentTokenName = (typeof ACCENT_TOKEN_NAMES)[number];
233
234
 
235
+ /** One ground, or every ground the accent must read on (DS9: the bg, the surfaces a scheme or a
236
+ * container puts under the same text, the page's own paint) — the FIRST decides direction. */
237
+ export type AccentGround = string | ReadonlyArray<string>;
238
+
234
239
  /** The backgrounds the accent must read on, light and (optionally) dark. Without an inverse
235
240
  * background the inverse pair is not derived — the pack's own stays. */
236
241
  export interface AccentBackgrounds {
237
- bg: string;
238
- bgDark?: string;
239
- inverseBg?: string;
240
- inverseBgDark?: string;
242
+ bg: AccentGround;
243
+ bgDark?: AccentGround;
244
+ inverseBg?: AccentGround;
245
+ inverseBgDark?: AccentGround;
241
246
  }
242
247
 
248
+ const groundList = (g: AccentGround | null | undefined): string[] =>
249
+ (g === undefined || g === null ? [] : typeof g === 'string' ? [g] : [...g]);
250
+ const hasGround = (g: AccentGround | null | undefined) => groundList(g).length > 0;
251
+
243
252
  const RAMP_ORDER = RAMP_STEPS.map(s => s.step);
244
253
  const WHITE_HEX = toHexColor(WHITE);
245
254
  const BLACK_HEX = toHexColor(BLACK);
246
- const passesText = (fg: string, bg: string) => wcagContrastRatio(fg, bg) >= WCAG_AA_TEXT;
255
+ const passesText = (fg: string, bg: AccentGround) => groundList(bg).every(g => wcagContrastRatio(fg, g) >= WCAG_AA_TEXT);
247
256
  const bestInkHex = (bg: string) =>
248
257
  (wcagContrastRatio(WHITE_HEX, bg) >= wcagContrastRatio(BLACK_HEX, bg) ? WHITE_HEX : BLACK_HEX);
249
258
 
@@ -279,7 +288,7 @@ export function deriveAccentTokens(
279
288
  const candidate = RAMP_ORDER[i] !== undefined ? step(RAMP_ORDER[i]) : null;
280
289
  return candidate && passesText(ink, candidate) ? candidate : step(accentStep);
281
290
  };
282
- const firstOn = (steps: number[], bg: string) => step(steps.find(n => passesText(step(n), bg)) ?? steps[steps.length - 1]);
291
+ const firstOn = (steps: number[], bg: AccentGround) => step(steps.find(n => passesText(step(n), bg)) ?? steps[steps.length - 1]);
283
292
 
284
293
  const ink = hexOf('brandInk');
285
294
  const light = {
@@ -289,19 +298,20 @@ export function deriveAccentTokens(
289
298
  text: firstOn([500, 600, 700, 800, 900], against.bg),
290
299
  };
291
300
 
292
- const dark = against.bgDark ? (() => {
301
+ const dark = hasGround(against.bgDark) ? (() => {
293
302
  const darkStep = [300, 200, 400].find(n => passesText(bestInkHex(step(n)), step(n))) ?? 300;
294
303
  const darkInk = bestInkHex(step(darkStep));
295
304
  return {
296
305
  accent: step(darkStep),
297
306
  ink: darkInk,
298
307
  hover: hoverFor(darkStep, darkInk),
299
- text: firstOn([300, 200, 100, 50], against.bgDark),
308
+ text: firstOn([300, 200, 100, 50], against.bgDark as AccentGround),
300
309
  };
301
310
  })() : null;
302
311
 
303
- const inverseOn = (inverseBg: string) => {
304
- const toward = wcagContrastRatio(step(RAMP_ORDER[0]), inverseBg) >= wcagContrastRatio(step(RAMP_ORDER[RAMP_ORDER.length - 1]), inverseBg)
312
+ const inverseOn = (inverseBg: AccentGround) => {
313
+ const ground = groundList(inverseBg)[0];
314
+ const toward = wcagContrastRatio(step(RAMP_ORDER[0]), ground) >= wcagContrastRatio(step(RAMP_ORDER[RAMP_ORDER.length - 1]), ground)
305
315
  ? -1 : 1;
306
316
  const walk = RAMP_ORDER.filter(n => (toward < 0 ? n <= 500 : n >= 500));
307
317
  if (toward < 0) walk.reverse();
@@ -315,8 +325,8 @@ export function deriveAccentTokens(
315
325
  const fill = step(walk[walk.length - 1]);
316
326
  return { accent: fill, ink: passesText(inkOn(fill), fill) ? inkOn(fill) : bestInkHex(fill) };
317
327
  };
318
- const inverse = against.inverseBg ? inverseOn(against.inverseBg) : null;
319
- const inverseDark = against.inverseBg && against.inverseBgDark ? inverseOn(against.inverseBgDark) : null;
328
+ const inverse = hasGround(against.inverseBg) ? inverseOn(against.inverseBg as AccentGround) : null;
329
+ const inverseDark = hasGround(against.inverseBg) && hasGround(against.inverseBgDark) ? inverseOn(against.inverseBgDark as AccentGround) : null;
320
330
 
321
331
  const row = (name: AccentTokenName, value: string, darkValue?: string): FlavorTokenDef => ({
322
332
  name, value, type: 'color', group: 'colour', tier: 'semantic',
@@ -0,0 +1,117 @@
1
+ import { parseHexColor, Rgb, toHexColor } from './brand';
2
+
3
+ /**
4
+ * What a token VALUE paints, as colours (design-system DS9): the evaluator the contrast audit and the
5
+ * brand-tinted neutrals read a resolved CSS value with. It understands exactly the colour syntax the
6
+ * contract writes — `#rgb`/`#rrggbb`, `rgb()`/`rgba()`, `transparent`, `currentColor`/`inherit`,
7
+ * `color-mix(in srgb, A p%, B)` (premultiplied, as CSS mixes), and a gradient layer list (each stop
8
+ * over the final plain layer: the page's paint shows every one of those to a reader somewhere).
9
+ * Port of iox-ai-guidance `design-system/colour.mjs` — held equal by the derivation fixtures.
10
+ */
11
+
12
+ export interface Rgba extends Rgb { a: number; }
13
+
14
+ const TRANSPARENT: Rgba = { r: 0, g: 0, b: 0, a: 0 };
15
+ const NAMED: Readonly<Record<string, Rgba>> = {
16
+ transparent: TRANSPARENT,
17
+ white: { r: 1, g: 1, b: 1, a: 1 },
18
+ black: { r: 0, g: 0, b: 0, a: 1 },
19
+ };
20
+ const CURRENT_COLOR = new Set(['currentcolor', 'inherit']);
21
+ const RGB_FN = /^rgba?\(([^)]*)\)$/i;
22
+ const COLOR_MIX = /^color-mix\(\s*in srgb\s*,(.*)\)$/i;
23
+ const MIX_STOP = /^(.*?)(?:\s+(\d+(?:\.\d+)?)%)?$/;
24
+ const GRADIENT = /^(?:radial|linear|conic)-gradient\((.*)\)$/i;
25
+ const STOP_POSITION = /\s+-?\d+(?:\.\d+)?%(\s+-?\d+(?:\.\d+)?%)?$/;
26
+ const SHADOW_LAYER = /^(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(-?[\d.]+px|0)\s+(.+)$/;
27
+
28
+ /** Split on top-level commas (never inside parentheses). */
29
+ export function splitTopLevel(value: string, separator = ','): string[] {
30
+ const out: string[] = [];
31
+ let depth = 0;
32
+ let current = '';
33
+ for (const ch of String(value ?? '')) {
34
+ if (ch === '(') depth++;
35
+ if (ch === ')') depth--;
36
+ if (ch === separator && depth === 0) { out.push(current.trim()); current = ''; continue; }
37
+ current += ch;
38
+ }
39
+ if (current.trim()) out.push(current.trim());
40
+ return out;
41
+ }
42
+
43
+ /** `top` composited over an opaque `under`. */
44
+ export function compositeOver(top: Rgba, under: Rgb): Rgba {
45
+ const a = top.a;
46
+ return { r: top.r * a + under.r * (1 - a), g: top.g * a + under.g * (1 - a), b: top.b * a + under.b * (1 - a), a: 1 };
47
+ }
48
+
49
+ /** The 8-bit colour a screen shows — contrast is judged on WHAT SHIPS, as the derivation's grounds are. */
50
+ export const quantizeRgb = (c: Rgb): Rgb => parseHexColor(toHexColor(c)) as Rgb;
51
+
52
+ /** One CSS colour → RGBA, or null when it is not a colour this evaluator reads. `ink` = currentColor. */
53
+ export function parseCssColour(value: string | null | undefined, ink?: Rgba | null): Rgba | null {
54
+ const v = String(value ?? '').trim();
55
+ const low = v.toLowerCase();
56
+ if (low in NAMED) return NAMED[low];
57
+ if (CURRENT_COLOR.has(low)) return ink ?? null;
58
+ const hex = parseHexColor(v);
59
+ if (hex) return { ...hex, a: 1 };
60
+ const rgb = RGB_FN.exec(v);
61
+ if (rgb) {
62
+ const p = rgb[1].split(/[\s,/]+/).filter(Boolean).map(Number);
63
+ return { r: p[0] / 255, g: p[1] / 255, b: p[2] / 255, a: p[3] ?? 1 };
64
+ }
65
+ const mix = COLOR_MIX.exec(v);
66
+ if (mix) return mixColours(splitTopLevel(mix[1]), ink);
67
+ return null;
68
+ }
69
+
70
+ function mixColours(parts: string[], ink: Rgba | null | undefined): Rgba | null {
71
+ const stop = (s: string) => {
72
+ const m = MIX_STOP.exec(s.trim()) as RegExpExecArray;
73
+ return { c: parseCssColour(m[1], ink), p: m[2] !== undefined ? Number(m[2]) / 100 : null };
74
+ };
75
+ const [x, y] = parts.map(stop);
76
+ if (!x?.c || !y?.c) return null;
77
+ const px = x.p ?? (y.p !== null ? 1 - y.p : 0.5);
78
+ const py = y.p ?? 1 - px;
79
+ const a = x.c.a * px + y.c.a * py;
80
+ if (a <= 0) return TRANSPARENT;
81
+ const channel = (k: keyof Rgb) => ((x.c as Rgba)[k] * (x.c as Rgba).a * px + (y.c as Rgba)[k] * (y.c as Rgba).a * py) / a;
82
+ return { r: channel('r'), g: channel('g'), b: channel('b'), a };
83
+ }
84
+
85
+ /**
86
+ * A paint → every colour a reader can meet ON it: a plain colour is one; a gradient layer list is each
87
+ * stop over the final plain layer, and that layer itself. The worst case is the one a check reads.
88
+ */
89
+ export function paintColours(value: string | null | undefined, ink?: Rgba | null): Rgba[] {
90
+ const layers = splitTopLevel(String(value ?? ''));
91
+ if (!layers.length) return [];
92
+ const last = parseCssColour(layers[layers.length - 1], ink);
93
+ if (layers.length === 1) return last ? [last] : [];
94
+ if (!last) return [];
95
+ const ground: Rgba = { ...last, a: 1 };
96
+ const out: Rgba[] = [ground];
97
+ for (const layer of layers.slice(0, -1)) {
98
+ const g = GRADIENT.exec(layer);
99
+ if (!g) continue;
100
+ for (const part of splitTopLevel(g[1])) {
101
+ const colour = parseCssColour(part.replace(STOP_POSITION, ''), ink);
102
+ if (colour) out.push(compositeOver(colour, ground));
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** The outermost ring of a box-shadow list (`0 0 0 4px <colour>`) — a focus indicator's colour. */
109
+ export function ringColour(shadow: string | null | undefined, ink?: Rgba | null): Rgba | null {
110
+ const layers = splitTopLevel(String(shadow ?? ''))
111
+ .map(l => SHADOW_LAYER.exec(l.trim()))
112
+ .filter((m): m is RegExpExecArray => !!m)
113
+ .map(m => ({ spread: parseFloat(m[4]) || 0, colour: m[5] }));
114
+ if (!layers.length) return null;
115
+ const outer = layers.reduce((a, b) => (b.spread >= a.spread ? b : a));
116
+ return parseCssColour(outer.colour, ink);
117
+ }