@vectoriox/iox-ui 5.25.0 → 5.26.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.
- package/design-core/index.d.ts +1153 -191
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +1680 -241
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/design-core/brand-neutrals.ts +236 -0
- package/src/design-core/brand.ts +24 -14
- package/src/design-core/colour-eval.ts +117 -0
- package/src/design-core/contrast-audit.ts +214 -0
- package/src/design-core/design-apply.ts +34 -14
- package/src/design-core/design-settings.ts +12 -3
- package/src/design-core/design-token.model.ts +26 -0
- package/src/design-core/flavor-fixtures.generated.ts +150 -0
- package/src/design-core/flavor-spec.generated.ts +1061 -189
- package/src/design-core/flavor-tokens.ts +36 -8
- package/src/design-core/public-api.ts +3 -0
- package/src/design-core/token-css.ts +48 -15
- package/styles/iox-design.css +16 -0
- package/styles/iox-forms.css +15 -9
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/design-core/brand.ts
CHANGED
|
@@ -57,10 +57,11 @@ export function oklabToRgb(c: OkLab): Rgb {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const
|
|
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:
|
|
238
|
-
bgDark?:
|
|
239
|
-
inverseBg?:
|
|
240
|
-
inverseBgDark?:
|
|
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:
|
|
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:
|
|
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:
|
|
304
|
-
const
|
|
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
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { toHexColor, wcagContrastRatio, WCAG_AA_TEXT } from './brand';
|
|
2
|
+
import { WCAG_AA_UI } from './brand-neutrals';
|
|
3
|
+
import { compositeOver, paintColours, parseCssColour, quantizeRgb, Rgba, ringColour } from './colour-eval';
|
|
4
|
+
import { ElementStyle, ELEMENT_STYLES } from './element-styles';
|
|
5
|
+
import { SPEC_CONTRAST_AUDIT, SPEC_INK_SCOPES, SPEC_SCHEMES } from './flavor-spec.generated';
|
|
6
|
+
import { DesignTokenLike, ResolvedMode, resolveSchemeTokenValues } from './token-css';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The contrast AUDIT (design-system DS9). The contract's token pairs were all AA and Doron still found a
|
|
10
|
+
* button whose icon was "almost hard to see": the failures live in the PAIRINGS element styles make — an
|
|
11
|
+
* Icon dropped into a Primary button painted `icon-color` (accent-text) on the accent fill, 1:1 in a Brand
|
|
12
|
+
* section. So this resolves what each element style actually paints, the way CSS resolves it:
|
|
13
|
+
*
|
|
14
|
+
* - in every section scheme (`resolveSchemeTokenValues`), directly on the section and inside every
|
|
15
|
+
* container style (Card, Panel, Navigation bar, Dialog);
|
|
16
|
+
* - `inherit` → the container's ink, a transparent fill → what is under it, translucent fills composited,
|
|
17
|
+
* a gradient ground (Glass's mesh, Midnight's glow) checked at its worst stop;
|
|
18
|
+
* - base and hover; a button's icon read through the contract's `inkScopes` (it takes the button's ink),
|
|
19
|
+
* its focus ring; the form parts the field baseline paints (`contrastAudit.formParts`).
|
|
20
|
+
*
|
|
21
|
+
* WCAG 2: 4.5:1 for text, 3:1 for glyphs, UI boundaries and focus indicators (1.4.11), judged on 8-bit
|
|
22
|
+
* colours. The same audit gates the contract (`colour.mjs auditContrast`, every flavor × tint × a seed
|
|
23
|
+
* sweep) and `contrast-audit.spec.ts` sweeps it here, so no pack or brand can regress.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface ContrastFinding {
|
|
27
|
+
scheme: string;
|
|
28
|
+
/** `section`, or the container style the element sits in. */
|
|
29
|
+
container: string;
|
|
30
|
+
element: string;
|
|
31
|
+
part: string;
|
|
32
|
+
fg: string;
|
|
33
|
+
bg: string;
|
|
34
|
+
ratio: number;
|
|
35
|
+
min: number;
|
|
36
|
+
pass: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface FormPart {
|
|
40
|
+
readonly part: string;
|
|
41
|
+
readonly fg: string | ReadonlyArray<string>;
|
|
42
|
+
readonly on?: string;
|
|
43
|
+
readonly any?: boolean;
|
|
44
|
+
readonly thumb?: string;
|
|
45
|
+
readonly min: number;
|
|
46
|
+
}
|
|
47
|
+
interface InkScope { readonly styles: ReadonlyArray<string>; readonly set: Readonly<Record<string, string>>; }
|
|
48
|
+
|
|
49
|
+
const FORM_PARTS: ReadonlyArray<FormPart> = SPEC_CONTRAST_AUDIT.formParts;
|
|
50
|
+
const INK_SCOPES: ReadonlyArray<InkScope> = SPEC_INK_SCOPES;
|
|
51
|
+
const SCHEME_IDS: ReadonlyArray<string> = Object.keys(SPEC_SCHEMES);
|
|
52
|
+
const CONTAINER_GROUPS: ReadonlySet<string> = new Set(['Layout', 'Overlays']);
|
|
53
|
+
const NOT_CONTENT: ReadonlySet<string> = new Set(['rule', 'media']);
|
|
54
|
+
const SECTION_STYLE = 'section';
|
|
55
|
+
const ICON_STYLE = 'icon';
|
|
56
|
+
const BUTTON_GROUP = 'Buttons';
|
|
57
|
+
const GLYPH_GROUP = 'Media';
|
|
58
|
+
const FORMS_GROUP = 'Forms';
|
|
59
|
+
const DEFAULT_SCHEME = 'default';
|
|
60
|
+
const SECTION = 'section';
|
|
61
|
+
const FIELD = 'field';
|
|
62
|
+
const WHITE: Rgba = { r: 1, g: 1, b: 1, a: 1 };
|
|
63
|
+
const SINGLE_REF = /^var\(--iox-([\w-]+)\)$/;
|
|
64
|
+
const TOKEN_REF = /var\(\s*--iox-([\w-]+)\s*(?:,([^()]*))?\)/g;
|
|
65
|
+
|
|
66
|
+
type Values = Readonly<Record<string, string>>;
|
|
67
|
+
type StyleMap = Readonly<Record<string, string>>;
|
|
68
|
+
|
|
69
|
+
/** A style value with its var() references resolved against `values` (already-resolved tokens). */
|
|
70
|
+
function resolveIn(values: Values, v: string | undefined): string {
|
|
71
|
+
const single = SINGLE_REF.exec(String(v ?? '').trim());
|
|
72
|
+
if (single) return values[single[1]] ?? (v as string);
|
|
73
|
+
return String(v ?? '').replace(TOKEN_REF, (ref, name: string, fallback?: string) =>
|
|
74
|
+
values[name] ?? (fallback !== undefined ? fallback.trim() : ref));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const overAll = (tops: Rgba[], unders: Rgba[]) => unders.flatMap(u => tops.map(t => compositeOver(t, u)));
|
|
78
|
+
const opaque = (paints: Rgba[]) => paints.some(c => c.a > 0);
|
|
79
|
+
|
|
80
|
+
const ownStyle = (e: ElementStyle) => (e.base ?? {}) as StyleMap;
|
|
81
|
+
|
|
82
|
+
function inkScoped(values: Values, styleId: string): Values {
|
|
83
|
+
const scope = INK_SCOPES.find(s => s.styles.includes(styleId));
|
|
84
|
+
return scope ? { ...values, ...scope.set } : values;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every pair a reader meets, for one set of token rows in one mode. */
|
|
88
|
+
export function auditDesignContrast(rows: ReadonlyArray<DesignTokenLike>, mode: ResolvedMode): ContrastFinding[] {
|
|
89
|
+
const findings: ContrastFinding[] = [];
|
|
90
|
+
const styles = ELEMENT_STYLES;
|
|
91
|
+
const byId = new Map(styles.map(e => [e.id, e]));
|
|
92
|
+
const containers: Array<ElementStyle | null> = [
|
|
93
|
+
null,
|
|
94
|
+
...styles.filter(e => CONTAINER_GROUPS.has(e.group) && ownStyle(e)['backgroundColor'] && e.id !== SECTION_STYLE),
|
|
95
|
+
];
|
|
96
|
+
const content = styles.filter(e => !CONTAINER_GROUPS.has(e.group) && (ownStyle(e)['color'] || ownStyle(e)['backgroundColor'])
|
|
97
|
+
&& e.group !== FORMS_GROUP && !NOT_CONTENT.has(e.id));
|
|
98
|
+
const notices = styles.filter(e => e.group === FORMS_GROUP && ownStyle(e)['color']);
|
|
99
|
+
const section = ownStyle(byId.get(SECTION_STYLE) as ElementStyle);
|
|
100
|
+
const iconColour = ownStyle(byId.get(ICON_STYLE) as ElementStyle)['color'];
|
|
101
|
+
|
|
102
|
+
for (const schemeId of SCHEME_IDS) {
|
|
103
|
+
const values = resolveSchemeTokenValues(rows, mode, schemeId);
|
|
104
|
+
const pageInk = parseCssColour(values['text']);
|
|
105
|
+
const page = paintColours(values['page-bg'], pageInk);
|
|
106
|
+
const sectionPaint = paintColours(values['section-bg'], pageInk);
|
|
107
|
+
const ground = opaque(sectionPaint)
|
|
108
|
+
? sectionPaint.map(c => (c.a < 1 ? compositeOver(c, page[0] ?? WHITE) : c))
|
|
109
|
+
: page;
|
|
110
|
+
const sectionInk = parseCssColour(resolveIn(values, section['color']), pageInk);
|
|
111
|
+
|
|
112
|
+
for (const box of containers) {
|
|
113
|
+
const boxStyle = box ? ownStyle(box) : null;
|
|
114
|
+
const bgs = boxStyle ? overAll(paintColours(resolveIn(values, boxStyle['backgroundColor']), sectionInk), ground) : ground;
|
|
115
|
+
const boxInk = boxStyle?.['color'] && boxStyle['color'] !== 'inherit'
|
|
116
|
+
? parseCssColour(resolveIn(values, boxStyle['color']), sectionInk) : sectionInk;
|
|
117
|
+
const where = { scheme: schemeId, container: box?.id ?? SECTION };
|
|
118
|
+
const record = (element: string, part: string, fg: string, bg: string, ratio: number, min: number) =>
|
|
119
|
+
findings.push({ ...where, element, part, fg, bg, ratio: Math.round(ratio * 100) / 100, min, pass: ratio >= min - 1e-9 });
|
|
120
|
+
const check = (element: string, part: string, fg: Rgba | null, under: Rgba[], min: number) => {
|
|
121
|
+
if (!fg) return;
|
|
122
|
+
for (const g of under) {
|
|
123
|
+
const bg = quantizeRgb(g);
|
|
124
|
+
const f = quantizeRgb(fg.a < 1 ? compositeOver(fg, bg) : fg);
|
|
125
|
+
record(element, part, toHexColor(f), toHexColor(bg), wcagContrastRatio(f, bg), min);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
check(box?.id ?? SECTION, 'text', boxInk, bgs, WCAG_AA_TEXT);
|
|
130
|
+
for (const e of content) {
|
|
131
|
+
const states: Array<[string, StyleMap]> = [['base', ownStyle(e)], ...(e.hover ? [['hover', { ...ownStyle(e), ...e.hover }] as [string, StyleMap]] : [])];
|
|
132
|
+
const glyph = e.group === GLYPH_GROUP;
|
|
133
|
+
for (const [state, s] of states) {
|
|
134
|
+
const fill = s['backgroundColor'] ? paintColours(resolveIn(values, s['backgroundColor']), boxInk) : [];
|
|
135
|
+
const under = fill.length && opaque(fill) ? overAll(fill, bgs) : bgs;
|
|
136
|
+
const ink = s['color'] ? parseCssColour(resolveIn(values, s['color']), boxInk) : boxInk;
|
|
137
|
+
check(e.id, state === 'base' ? (glyph ? 'glyph' : 'text') : `${state} text`, ink, under, glyph ? WCAG_AA_UI : WCAG_AA_TEXT);
|
|
138
|
+
if (state === 'base' && e.group === BUTTON_GROUP) {
|
|
139
|
+
check(e.id, 'icon', parseCssColour(resolveIn(inkScoped(values, e.id), iconColour), ink), under, WCAG_AA_UI);
|
|
140
|
+
const focus = (e.focus as StyleMap | undefined)?.['boxShadow'];
|
|
141
|
+
if (focus) check(e.id, 'focus ring', ringColour(resolveIn(values, focus), ink), bgs, WCAG_AA_UI);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const part of FORM_PARTS) {
|
|
146
|
+
const fieldBg = paintColours(resolveIn(values, part.on ?? 'transparent'), boxInk);
|
|
147
|
+
const under = part.on && opaque(fieldBg) ? overAll(fieldBg, bgs) : bgs;
|
|
148
|
+
if (part.any) {
|
|
149
|
+
for (const g of under) {
|
|
150
|
+
const bg = quantizeRgb(g);
|
|
151
|
+
const ratio = Math.max(...(part.fg as ReadonlyArray<string>).map(fg => {
|
|
152
|
+
const c = parseCssColour(resolveIn(values, fg), boxInk);
|
|
153
|
+
return c ? wcagContrastRatio(quantizeRgb(c.a < 1 ? compositeOver(c, bg) : c), bg) : 1;
|
|
154
|
+
}));
|
|
155
|
+
record(FIELD, part.part, '', toHexColor(bg), ratio, part.min);
|
|
156
|
+
}
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const paint = parseCssColour(resolveIn(values, part.fg as string), boxInk);
|
|
160
|
+
check(FIELD, part.part, paint, under, part.min);
|
|
161
|
+
if (part.thumb && paint) {
|
|
162
|
+
check(FIELD, `${part.part} thumb`, parseCssColour(resolveIn(values, part.thumb), boxInk), bgs.map(b => compositeOver(paint, b)), part.min);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (box === null && schemeId === DEFAULT_SCHEME) {
|
|
166
|
+
for (const n of notices) {
|
|
167
|
+
const fill = paintColours(resolveIn(values, ownStyle(n)['backgroundColor']), boxInk);
|
|
168
|
+
check(n.id, 'text', parseCssColour(resolveIn(values, ownStyle(n)['color']), boxInk), overAll(fill, bgs), WCAG_AA_TEXT);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return findings;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The pairs the Design ▸ Colour tab lists for the CURRENT brand (Board 6 + DS9): what a filled button
|
|
177
|
+
* shows, and the link on every section scheme — each the worst case on the section itself. */
|
|
178
|
+
export enum ColourCheckId {
|
|
179
|
+
ButtonLabel = 'button-label',
|
|
180
|
+
ButtonIcon = 'button-icon',
|
|
181
|
+
Link = 'link',
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface ColourCheck {
|
|
185
|
+
id: ColourCheckId;
|
|
186
|
+
/** The section scheme the pair sits in (`default` for the button). */
|
|
187
|
+
scheme: string;
|
|
188
|
+
fg: string;
|
|
189
|
+
bg: string;
|
|
190
|
+
ratio: number;
|
|
191
|
+
min: number;
|
|
192
|
+
passes: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const COLOUR_TAB_CHECKS: ReadonlyArray<{ id: ColourCheckId; element: string; part: string; schemes: ReadonlyArray<string> }> = [
|
|
196
|
+
{ id: ColourCheckId.ButtonLabel, element: 'button-primary', part: 'text', schemes: [DEFAULT_SCHEME] },
|
|
197
|
+
{ id: ColourCheckId.ButtonIcon, element: 'button-primary', part: 'icon', schemes: [DEFAULT_SCHEME] },
|
|
198
|
+
{ id: ColourCheckId.Link, element: 'link', part: 'text', schemes: SCHEME_IDS },
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
/** The Colour tab's brand rows, from one audit of the rows the canvas wears. */
|
|
202
|
+
export function colourTabChecks(rows: ReadonlyArray<DesignTokenLike>, mode: ResolvedMode): ColourCheck[] {
|
|
203
|
+
const findings = auditDesignContrast(rows, mode).filter(f => f.container === SECTION);
|
|
204
|
+
const out: ColourCheck[] = [];
|
|
205
|
+
for (const check of COLOUR_TAB_CHECKS) {
|
|
206
|
+
for (const scheme of check.schemes) {
|
|
207
|
+
const hits = findings.filter(f => f.scheme === scheme && f.element === check.element && f.part === check.part);
|
|
208
|
+
if (!hits.length) continue;
|
|
209
|
+
const worst = hits.reduce((a, b) => (b.ratio < a.ratio ? b : a));
|
|
210
|
+
out.push({ id: check.id, scheme, fg: worst.fg, bg: worst.bg, ratio: worst.ratio, min: worst.min, passes: worst.pass });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|