everyhexcode 0.0.0-stage → 0.1.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.
@@ -0,0 +1,777 @@
1
+ // The colour checker: from the tokens shared/check-input.js reads, it works out what each colour is for (text,
2
+ // background, border, accent), which colours really meet, and judges each pair by WCAG 2 (the standard), with APCA as an
3
+ // advisory value when the caller passes it in (shared/apca.js; the command-line build leaves it out). It simulates colour
4
+ // vision and glare, finds colours that must be told apart but look alike, reads the palette's feel, and fixes the whole
5
+ // palette at once, written back as a diff of the text that was pasted. All of it is computed; nothing is stored.
6
+ import { converter, differenceEuclidean, filterDeficiencyDeuter, filterDeficiencyProt, filterDeficiencyTrit, formatHex, parse as cparse, toGamut, wcagContrast, wcagLuminance } from 'culori/fn';
7
+ import { contrast, cvd, info, moods, textOn } from './color.js';
8
+ import { readInput } from './check-input.js';
9
+ import { explain, plainSummary } from './plain.js';
10
+
11
+ const okDist = differenceEuclidean('oklab');
12
+ const toOklch = converter('oklch');
13
+ const toRgb = converter('rgb');
14
+ const toSrgb = toGamut('rgb', 'oklch');
15
+ const col = (hex) => cparse('#' + hex);
16
+ const r1 = (n, d = 0) => { const f = 10 ** d; const v = Math.round(n * f) / f; return Object.is(v, -0) ? 0 : v; };
17
+ const fromOklch = (l, c, h) => formatHex(toSrgb({ mode: 'oklch', l: Math.max(0, Math.min(1, l)), c, h })).slice(1);
18
+ const dE = (a, b) => r1(okDist(col(a), col(b)) * 100, 1);
19
+
20
+ export const VERSION = '1.0.0';
21
+ export const MAX_PAIRS = 400;
22
+
23
+ // ---------------------------------------------------------------------------------------------------------------- roles
24
+
25
+ const BG_WORDS = ['background', 'bg', 'surface', 'canvas', 'card', 'popover', 'paper', 'page', 'backdrop', 'layer', 'container', 'sheet', 'panel', 'well', 'sidebar', 'modal', 'dialog', 'overlay', 'fill', 'highlight', 'selection', 'hover', 'code'];
26
+ const FG_WORDS = ['foreground', 'fg', 'text', 'ink', 'on', 'label', 'heading', 'title', 'copy', 'content', 'caption', 'placeholder', 'icon', 'font', 'type'];
27
+ const BORDER_WORDS = ['border', 'outline', 'ring', 'divider', 'stroke', 'separator', 'input', 'focus', 'hairline', 'rule'];
28
+ const ACCENT_WORDS = ['primary', 'secondary', 'tertiary', 'accent', 'brand', 'link', 'info', 'success', 'warning', 'warn', 'danger', 'error', 'err', 'destructive', 'positive', 'negative', 'critical', 'caution', 'notice', 'attention', 'neutral', 'muted', 'subtle', 'ok', 'alert'];
29
+ const STATUS = ['info', 'success', 'warning', 'warn', 'danger', 'error', 'err', 'destructive', 'positive', 'negative', 'critical', 'caution', 'notice', 'ok', 'alert', 'invalid', 'valid'];
30
+ // a utility class (Tailwind, Bootstrap) that sets one colour says nothing about where it is used: only rules that set
31
+ // text and background together are taken from those
32
+ const UTILITY = /^\.(?:[\w-]+\\:)*(?:text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|placeholder|caret|accent|divide|shadow|link|btn-outline|bg-gradient)-[\w\\/.\[\]#-]+(?::[\w-]+)*$/;
33
+ const SCALE_STEP = /^(50|[1-9]00|950|[1-9]|1[0-2]|[1-9]0|a?[1-9]00)$/i;
34
+
35
+ /** "--md-sys-color-on-primary-container" → ["on", "primary", "container"]; camelCase and dots split too. */
36
+ export function words(name) {
37
+ return String(name).replace(/^(--|\$|@)/, '').replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
38
+ .replace(/([a-z])(\d)/g, '$1-$2').split(/[-_./\s]+/).filter((w) => w && !['md', 'sys', 'ref', 'color', 'colors', 'colour', 'theme', 'tw', 'semantic', 'global', 'default', 'token', 'tokens', 'value', 'chakra', 'mantine', 'bs', 'ui', 'sl'].includes(w));
39
+ }
40
+
41
+ /**
42
+ * What a token is for, from its name: background, text (and on what), border, accent, chart, or scale step. A name
43
+ * without a role word gets one from its lightness later (bare palettes).
44
+ */
45
+ // a design system's raw scales (color.base.gray-900, primitives.blue.500, palette.red): values other tokens point to,
46
+ // never a pair of their own
47
+ const PRIMITIVE = /(^|[.\-_/])(base|primitives?|palette|core|ref|ramps?|scales?|raw|swatch(es)?)[.\-_/]./i;
48
+
49
+ export function roleOf(name) {
50
+ const w = words(name);
51
+ if (!w.length) return { role: null };
52
+ // disabled text and controls are exempt from contrast (WCAG 1.4.3, 1.4.11)
53
+ if (w.includes('disabled') || w.includes('inactive')) return { role: 'ignore', why: 'disabled: exempt from contrast' };
54
+ if (PRIMITIVE.test(String(name).replace(/^(--|\$|@)/, '')) && !w.some((x) => ['on', 'foreground', 'fg', 'text', 'background', 'bg', 'surface', 'border'].includes(x))) return { role: 'scale', primitive: true };
55
+ const has = (list) => w.some((x) => list.includes(x));
56
+ const last = w[w.length - 1];
57
+ // chart-1, series-3, data-2, categorical
58
+ if (/^(chart|series|data|categorical|viz|graph|plot)$/.test(w[0]) || w.includes('chart')) return { role: 'chart' };
59
+ // on-primary, on-surface-variant (Material), primary-foreground (shadcn), text-on-accent
60
+ const onIdx = w.indexOf('on');
61
+ if (onIdx >= 0 && onIdx < w.length - 1) return { role: 'text', on: w.slice(onIdx + 1).join('-'), why: 'named on-…' };
62
+ if (w.length > 1 && ['foreground', 'fg', 'contrast', 'text', 'content', 'ink', 'icon'].includes(last) && !FG_WORDS.includes(w[0])) {
63
+ return { role: 'text', on: w.slice(0, -1).join('-'), why: `named …-${last}` };
64
+ }
65
+ if (has(BORDER_WORDS)) return { role: has(['input', 'ring', 'focus', 'outline', 'field']) ? 'control' : 'border' };
66
+ if (FG_WORDS.includes(w[0]) && w.length > 1 && !has(BG_WORDS)) return { role: 'text', sub: w.slice(1).join('-') };
67
+ if (has(FG_WORDS) && !has(BG_WORDS)) return { role: 'text' };
68
+ if (has(BG_WORDS)) return { role: 'bg', accent: has(ACCENT_WORDS) };
69
+ if (SCALE_STEP.test(last) && w.length > 1) return { role: 'scale', stem: w.slice(0, -1).join('-') };
70
+ if (has(STATUS)) return { role: 'accent', status: true };
71
+ if (has(ACCENT_WORDS)) return { role: 'accent', muted: has(['muted', 'subtle', 'neutral']) };
72
+ return { role: null };
73
+ }
74
+
75
+ const L = (hex) => toOklch(col(hex)).l;
76
+ const C = (hex) => toOklch(col(hex)).c || 0;
77
+ const H = (hex) => toOklch(col(hex)).h || 0;
78
+
79
+ /** A colour with alpha, laid over its background, is the colour the eye gets. */
80
+ export function over(hex, alpha, bg) {
81
+ if (alpha == null || alpha >= 1) return hex;
82
+ const a = toRgb(col(hex)), b = toRgb(col(bg));
83
+ const m = (x, y) => x * alpha + y * (1 - alpha);
84
+ return formatHex({ mode: 'rgb', r: m(a.r, b.r), g: m(a.g, b.g), b: m(a.b, b.b) }).slice(1);
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------------------------------------------- targets
88
+
89
+ /** What each kind of pair needs. WCAG 2 AA and AAA are the standard; "APCA" is advisory only. */
90
+ export const TARGETS = {
91
+ AA: { text: 4.5, large: 3, ui: 3, label: 'WCAG 2 AA' },
92
+ AAA: { text: 7, large: 4.5, ui: 3, label: 'WCAG 2 AAA' },
93
+ };
94
+ const KIND_LABEL = { text: 'text', large: 'large text', ui: 'UI part', decor: 'decoration' };
95
+ export const kindLabel = (k) => KIND_LABEL[k] || k;
96
+ /** Whether text of this size and weight is "large" in WCAG 2: 24px, or 18.66px bold. */
97
+ export const isLarge = (px, weight = 400) => px >= 24 || (weight >= 700 && px >= 18.66);
98
+ const needOf = (kind, target) => (kind === 'decor' ? 0 : (TARGETS[target] || TARGETS.AA)[kind] ?? 4.5);
99
+
100
+ // ---------------------------------------------------------------------------------------------------------------- vision
101
+
102
+ export const VISION = [
103
+ { id: 'protanopia', label: 'Protanopia', note: 'no red cones, about 1% of men', f: cvd(filterDeficiencyProt, 1) },
104
+ { id: 'deuteranopia', label: 'Deuteranopia', note: 'no green cones, about 1% of men', f: cvd(filterDeficiencyDeuter, 1) },
105
+ { id: 'tritanopia', label: 'Tritanopia', note: 'no blue cones, rare', f: cvd(filterDeficiencyTrit, 1) },
106
+ { id: 'protanomaly', label: 'Protanomaly', note: 'weak red cones, about 1% of men', f: cvd(filterDeficiencyProt, 0.6) },
107
+ { id: 'deuteranomaly', label: 'Deuteranomaly', note: 'weak green cones, about 5% of men, the most common', f: cvd(filterDeficiencyDeuter, 0.6) },
108
+ { id: 'tritanomaly', label: 'Tritanomaly', note: 'weak blue cones, rare', f: cvd(filterDeficiencyTrit, 0.6) },
109
+ // no colour at all: the grey of the same luminance (so contrast is what it is)
110
+ { id: 'achromatopsia', label: 'Achromatopsia', note: 'no color at all, very rare', f: (c) => { const y = wcagLuminance(c); return { mode: 'lrgb', r: y, g: y, b: y }; } },
111
+ ];
112
+ const simCache = new Map();
113
+ /** How a colour looks with one kind of colour vision. */
114
+ export function seeAs(hex, id) {
115
+ const k = `${hex}|${id}`;
116
+ let v = simCache.get(k);
117
+ if (!v) {
118
+ const t = VISION.find((x) => x.id === id);
119
+ v = formatHex(toSrgb(t.f(col(hex)))).slice(1);
120
+ if (simCache.size > 20000) simCache.clear();
121
+ simCache.set(k, v);
122
+ }
123
+ return v;
124
+ }
125
+
126
+ /** Contrast in bright light: a veil of reflected light (a share of white) added to both colours. */
127
+ export function glareRatio(a, b, veil = 0.1) {
128
+ const la = wcagLuminance(col(a)) + veil, lb = wcagLuminance(col(b)) + veil;
129
+ return r1((Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05), 2);
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------------------------------------------- pairs
133
+
134
+ /**
135
+ * The tokens with their roles, per mode, and the pairs to judge: declared in CSS rules, implied by names
136
+ * (primary-foreground on primary, text on background), or chosen by lightness for a bare palette. `opts.roles` sets
137
+ * a token's role by name; `opts.pairs` adds pairs [text, background, kind].
138
+ */
139
+ export function plan(read, opts = {}) {
140
+ const tokens = read.tokens.map((t, i) => ({ ...t, id: i, ...roleOf(t.name) }));
141
+ // a dark mode that overrides only some names keeps the light value of the rest (CSS cascade): those come along,
142
+ // marked inherited, and are never moved by a fix (moving one would change the light mode too)
143
+ if (tokens.some((t) => t.mode === 'dark') && tokens.some((t) => t.mode === 'light')) {
144
+ const dark = new Set(tokens.filter((t) => t.mode === 'dark').map((t) => t.name));
145
+ for (const t of tokens.filter((x) => x.mode === 'light' && !dark.has(x.name))) tokens.push({ ...t, id: tokens.length, mode: 'dark', inherited: true, span: null });
146
+ }
147
+ for (const t of tokens) {
148
+ const r = opts.roles?.[t.name] || opts.roles?.['#' + t.hex] || opts.roles?.[t.hex];
149
+ if (r) { Object.assign(t, { role: r === 'ignore' ? 'ignore' : r, why: 'set by you', on: undefined, sub: undefined }); }
150
+ }
151
+ // "muted" and "subtle" alone are a pale surface in some systems (shadcn's --muted) and grey text in others: the light
152
+ // mode's lightness decides, and the role holds in every mode (dark --muted is still a surface)
153
+ // a colour another is named to sit on (muted under muted-foreground, primary under on-primary) is a surface
154
+ const surfaces = new Set(tokens.filter((t) => t.role === 'text' && t.on).map((t) => t.on));
155
+ for (const t of tokens) if (surfaces.has(words(t.name).join('-')) && t.why !== 'set by you' && (t.role === 'text' || t.role == null || (t.role === 'accent' && t.muted))) { t.role = 'bg'; t.why = 'has its own text color'; }
156
+ const mutedRole = new Map();
157
+ for (const t of [...tokens].sort((a, b) => (a.mode === 'light' ? -1 : 1) - (b.mode === 'light' ? -1 : 1))) {
158
+ if (t.role !== 'accent' || !t.muted || t.why === 'set by you') continue;
159
+ if (!mutedRole.has(t.name)) mutedRole.set(t.name, (t.mode === 'dark' ? L(t.hex) < 0.3 : L(t.hex) > 0.85) ? 'bg' : 'text');
160
+ t.role = mutedRole.get(t.name); t.why = t.role === 'bg' ? 'muted: a surface' : 'muted: text';
161
+ }
162
+ // Bootstrap's --bs-body-color, --bs-secondary-color: "<x>-color" beside "<x>-bg" is the text colour on that background
163
+ for (const t of tokens) {
164
+ const m = String(t.name).match(/^(.*?)[-_]colou?r$/);
165
+ if (!m || t.why === 'set by you' || t.role === 'text' || /(^|[-_])(bg|background|border|outline|ring|shadow|fill)([-_]|$)/i.test(m[1])) continue;
166
+ // the nearest "<prefix>-bg" up the name: $dropdown-dark-link-color sits on $dropdown-dark-bg
167
+ let stem = m[1], bgTok = null;
168
+ while (stem && !bgTok) {
169
+ bgTok = tokens.find((x) => x.mode === t.mode && [`${stem}-bg`, `${stem}-bg-color`, `${stem}-background`, `${stem}_bg`].includes(x.name));
170
+ if (!bgTok) { const i = Math.max(stem.lastIndexOf('-'), stem.lastIndexOf('_')); stem = i > 0 ? stem.slice(0, i) : ''; }
171
+ }
172
+ if (bgTok) { t.role = 'text'; t.on = words(bgTok.name).join('-'); t.why = `beside ${bgTok.name}`; }
173
+ }
174
+ // Tailwind v4's --color-background: var(--background) is the same colour under a second name: one of them is judged
175
+ const key = (t) => `${words(t.name).join('-')}|${t.mode}`;
176
+ const byKey = new Map();
177
+ for (const t of tokens) if (!t.via) byKey.set(key(t), t);
178
+ for (const t of tokens) if (t.via && byKey.has(key(t)) && byKey.get(key(t)) !== t && byKey.get(key(t)).hex === t.hex) { t.role = 'ignore'; t.why = `the same as ${byKey.get(key(t)).name}`; }
179
+ const modes = [...new Set([...tokens.map((t) => t.mode), ...(read.rules || []).map((r) => r.mode)])].sort((a, b) => (a === 'light' ? -1 : b === 'light' ? 1 : 0));
180
+ const pairs = [];
181
+ const seen = new Map();
182
+ const add = (fg, bg, kind, why, extra = {}) => {
183
+ if (!fg || !bg || fg === bg || fg.role === 'ignore' || bg.role === 'ignore') return;
184
+ // a fully transparent colour paints nothing; a token that points to the other one is the same colour, not a pair
185
+ if (fg.alpha === 0 || bg.alpha === 0 || fg.via === bg.name || bg.via === fg.name) return;
186
+ // an alias (--color-background: var(--background), {color.base.gray-900}) is judged and fixed as its source
187
+ // on a scanned page, a pair guessed from names alone is not seen on the page: listed, not judged
188
+ if (opts.page && !extra.selector && why !== 'declared by you') extra = { ...extra, optional: true, unconfirmed: true };
189
+ // a dark theme switched by a class or attribute (not by the visitor's system setting) is not on unless the page turns it on
190
+ if (opts.page && fg.mode === 'dark' && !read.darkMedia) extra = { ...extra, optional: true, darkSwitch: true };
191
+ const asFg = fg, asBg = bg;
192
+ fg = canon(fg); bg = canon(bg);
193
+ if (fg === bg) return;
194
+ const last = (x) => words(x.name).at(-1);
195
+ if (fg.hex === bg.hex && COLOR_WORDS.test(last(fg) || '') && last(fg) === last(bg)) return; // --fg-black on --bg-black: utility pairs, not a design's text
196
+ if (asFg !== fg) extra = { ...extra, asFg: asFg.name };
197
+ if (asBg !== bg) extra = { ...extra, asBg: asBg.name };
198
+ // the same colour on itself is never a design's pair (hidden text, a border that is the fill): only a pair you
199
+ // declare is judged then
200
+ if (fg.hex === bg.hex && (fg.alpha ?? 1) === (bg.alpha ?? 1) && (extra.guess || extra.selector)) return;
201
+ const key = `${fg.id}>${bg.id}`;
202
+ if (seen.has(key)) { const p = seen.get(key); if (p && rank(kind) > rank(p.kind)) Object.assign(p, { kind, why }); return; }
203
+ const p = { key, fg, bg, kind, why, mode: fg.mode, ...extra };
204
+ seen.set(key, p);
205
+ pairs.push(p);
206
+ };
207
+ const rank = (k) => ({ text: 3, large: 2, ui: 1, decor: 0 })[k] ?? 0;
208
+ const pairsLater = [];
209
+ const pageSkip = new Set(), pageImage = new Set(), pageEmpty = new Set(), pageBgs = {};
210
+ const byName = new Map(tokens.map((t) => [`${t.name}|${t.mode}`, t]));
211
+ const canon = (t) => { let x = t, n = 0; while (x.via && n++ < 8) { const y = byName.get(`${x.via}|${x.mode}`) || byName.get(`${x.via}|light`); if (!y || y === x || y.role === 'ignore' || isPalette(y)) break; x = y; } return x; };
212
+
213
+ for (const mode of modes) {
214
+ const all = tokens.filter((t) => t.mode === mode && t.role !== 'ignore');
215
+ const named = new Map(all.map((t) => [words(t.name).join('-'), t]));
216
+ // no roles to go on (a bare list, or a small set of raw colours such as a token file's core palette): lightness decides
217
+ const unnamed = all.every((t) => t.unnamed || t.role == null) || (all.length <= 12 && all.every((t) => (t.role === 'scale' && !/\d$/.test(t.name)) || t.role == null));
218
+ // a bare palette: roles by lightness (the lightest is the background, the darkest the text)
219
+ if (unnamed && !read.rules?.length) {
220
+ const by = [...all].sort((a, b) => L(b.hex) - L(a.hex));
221
+ const light = by[0], dark = by[by.length - 1];
222
+ for (const t of all) {
223
+ if (t.why === 'set by you') continue;
224
+ t.role = t === light ? 'bg' : t === dark ? 'text' : C(t.hex) > 0.04 ? 'accent' : L(t.hex) > 0.8 ? 'bg' : L(t.hex) < 0.35 ? 'text' : 'accent';
225
+ t.why = t === light ? 'lightest' : t === dark ? 'darkest' : 'by lightness';
226
+ }
227
+ }
228
+ const bgs = all.filter((t) => t.role === 'bg' && !t.accent);
229
+ // disabled controls are exempt from contrast (WCAG 1.4.3 and 1.4.11), so their rules are not judged
230
+ const modeRules = (read.rules || []).filter((x) => (x.mode === mode || (mode === 'dark' && x.mode === 'light' && !x.scoped)) && !DISABLED.test(x.selector));
231
+ // the page's own background, when a stylesheet sets it on html, body or :root and no token names one
232
+ const pageRule = [...modeRules].reverse().find((x) => PAGE_SEL.test(x.selector) && x.bg && !x.bg.image && x.bg.alpha !== 0);
233
+ let main = bgs.length ? pickMain(bgs, mode) : null;
234
+ if (!main && pageRule) main = pageRule.bg.token ? all.find((t) => t.name === pageRule.bg.token) : synth(tokens, pageRule.bg, mode, pageRule.selector);
235
+ if (!main) main = pickMain(all, mode);
236
+ // CSS that sets text colours but no background at all: the browser's white page is what they sit on
237
+ if (!main && mode === 'light' && modeRules.some((x) => x.fg)) { main = synth(tokens, { hex: 'ffffff', alpha: 1 }, mode, 'the browser default'); main.name = 'page background (white, the browser default)'; main.span = null; }
238
+ // a page background with no text colour set on the page: the browser's default text is what sits on it
239
+ if (pageRule && main && !modeRules.some((x) => PAGE_SEL.test(x.selector) && x.fg)) {
240
+ const dflt = synth(tokens, { hex: mode === 'dark' ? 'ffffff' : '000000', alpha: 1 }, mode, 'the browser default');
241
+ dflt.name = mode === 'dark' ? 'default text (white)' : 'default text (black)'; dflt.span = null;
242
+ pairsLater.push([dflt, main]);
243
+ }
244
+ if (main) pageBgs[mode] = main.hex;
245
+ const texts = all.filter((t) => t.role === 'text');
246
+ // 1. CSS rules: what a selector really sets
247
+ for (const [a, b] of pairsLater.splice(0)) add(a, b, 'text', "the browser's default text on the page background", { selector: 'body' });
248
+ for (const r of modeRules.filter((x) => !(UTILITY.test(x.selector) && !(x.fg && x.bg)) && !(mode === 'dark' && x.mode === 'light' && ![x.fg, x.bg, x.border, x.outline].some((v) => v?.token)))) {
249
+ // a website scan knows the page's HTML: a rule for elements the page does not have is not judged
250
+ if (opts.page && !PAGE_SEL.test(r.selector)) { const n = opts.page.count(r.selector); if (n === 0) { pageSkip.add(r.selector); continue; } }
251
+ const tok = (v) => {
252
+ if (!v || v.image) return null;
253
+ if (v.token) {
254
+ const own = tokens.find((t) => t.name === v.token && t.mode === mode);
255
+ // an alias set aside as a duplicate (--color-x: var(--x)) stands for its source in this mode
256
+ const real = own && own.role === 'ignore' ? all.find((t) => key(t) === key(own) && t.role !== 'ignore') : own;
257
+ if (real && real.role !== 'ignore') return real;
258
+ }
259
+ return synth(tokens, v, mode, r.selector);
260
+ };
261
+ // a text colour alone is checked on the page background, unless the selector says it sits on something else
262
+ if (!r.bg && r.fg && (ELSEWHERE.test(r.selector) || TEXT_UTILITY.test(r.selector))) continue;
263
+ // a rule that sets only the text colour of a state (.button:hover) takes the background its base rule sets
264
+ const base = !r.bg ? (modeRules.find((x) => x !== r && x.bg && !x.bg.image && baseSel(x.selector) === baseSel(r.selector) && states(x.selector) === states(r.selector))
265
+ || modeRules.find((x) => x !== r && x.bg && !x.bg.image && baseSel(x.selector) === baseSel(r.selector) && !states(x.selector))) : null;
266
+ // on a scanned page, the background is the one the nearest ancestor (or the element) gets from the stylesheets
267
+ let pageBg = null;
268
+ // a translucent background of its own shows what is behind it: an image there makes the pair unknowable
269
+ if (opts.page && r.bg && !r.bg.image && (r.bg.alpha ?? 1) < 1) { const under = opts.page.bgFor(r.selector, modeRules.filter((x) => x.bg && !states(x.selector)), true); if (under === 'image') { pageImage.add(r.selector); continue; } }
270
+ if (opts.page && !r.bg) {
271
+ const found = opts.page.bgFor(r.selector, modeRules.filter((x) => x.bg && !states(x.selector)));
272
+ if (found === 'image') { pageImage.add(r.selector); continue; }
273
+ if (found) pageBg = found.bg;
274
+ }
275
+ const bg = r.bg && !r.bg.image ? tok(r.bg) : pageBg ? tok(pageBg) : base ? tok(base.bg) : main;
276
+ const large = r.fontSize ? isLarge(r.fontSize, r.fontWeight) : false;
277
+ // a text colour on elements that hold no text on this page (an image thumbnail) paints nothing
278
+ const noText = opts.page && r.fg && !PAGE_SEL.test(r.selector) && opts.page.hasText?.(r.selector) === false;
279
+ if (noText) pageEmpty.add(r.selector);
280
+ if (r.fg && !noText) add(tok(r.fg), bg, large ? 'large' : 'text', `${r.selector} sets ${r.bg ? 'color and background' : 'color'}${pageBg ? ' (background from the element it sits in)' : ''}`, { selector: r.selector, image: r.bg?.image, guess: !r.bg && !base && !pageBg, under: main?.hex });
281
+ for (const k of ['border', 'outline']) if (r[k] && bg) add(tok(r[k]), bg, /input|select|textarea|button|focus|checkbox|radio/i.test(r.selector) || k === 'outline' ? 'ui' : 'decor', `${r.selector} sets ${k}`, { selector: r.selector, under: main?.hex });
282
+ }
283
+ // links no colour rule reaches are drawn in the browser's own blue, which no stylesheet shows
284
+ if (opts.page?.plainLinks && mode === 'light') {
285
+ const pl = opts.page.plainLinks(modeRules.filter((x) => x.bg && !states(x.selector)));
286
+ if (pl && pl.bg === 'image') pageImage.add(pl.where);
287
+ else if (pl) {
288
+ const link = synth(tokens, { hex: '0000ee', alpha: 1 }, mode, 'the browser default'); link.name = 'default link blue (#0000ee, the browser default)'; link.span = null;
289
+ const v = pl.bg?.bg, bg = !v ? main : v.token ? (all.find((t) => t.name === v.token) || main) : synth(tokens, v, mode, pl.bg.selector);
290
+ if (bg) add(link, bg, 'text', `${pl.count} link${pl.count > 1 ? 's' : ''} no CSS rule colours, drawn in the browser's default blue`, { selector: pl.where });
291
+ }
292
+ }
293
+ // 2. names: X-foreground on X, on-X on X
294
+ for (const t of texts) {
295
+ if (t.on) {
296
+ const base = named.get(t.on) || named.get(t.on.replace(/-?(default|base)$/, '')) || ['bg', 'background', 'surface', 'fill', 'container'].map((x) => named.get(`${t.on}-${x}`)).find(Boolean)
297
+ || [...named.entries()].find(([k, v]) => v !== t && v.role !== 'text' && (k.endsWith(t.on) || t.on.endsWith(k)))?.[1];
298
+ if (base && base !== t) add(t, base, 'text', `${t.name} is named for ${base.name}`);
299
+ // muted-foreground and secondary text are used on the page background too
300
+ if (main && base !== main && (!base || /muted|subtle|secondary|surface|card|popover|background/.test(t.on))) add(t, main, 'text', `${t.name} on the page background`, { guess: !base });
301
+ } else if (main) {
302
+ add(t, main, 'text', 'text on the page background', { guess: !!(main.literal || /lightness/.test(t.why || '')), generic: !/^(foreground|fg|text|ink|body|body-text|default|text-default|fg-default|text-primary)$/.test(words(t.name).join('-')) });
303
+ if (bgs.length <= 6) for (const b of bgs) if (b !== main && !hasOwnText(b, texts)) add(t, b, 'text', `text on ${b.name}`);
304
+ }
305
+ }
306
+ // 3. accents and status colours: as text or icons on the background (links, error text), and as backgrounds of their own text
307
+ for (const t of all.filter((x) => x.role === 'accent')) {
308
+ if (!main || t === main) continue;
309
+ const own = texts.find((x) => x.on && (named.get(x.on) === t || words(t.name).join('-') === x.on));
310
+ // a filled colour with its own text colour: rule 2 covers it; a status colour is also used as text (error messages)
311
+ if (own) { if (t.status) add(t, main, 'text', 'if used as text (error and warning messages)', { optional: true }); continue; }
312
+ add(t, main, words(t.name).includes('link') || t.status ? 'text' : 'ui', words(t.name).includes('link') ? 'links are text' : t.status ? 'status text and icons' : 'buttons, icons and accents on the background', { guess: true, ...(t.status && !words(t.name).includes('link') ? { optional: true } : {}) });
313
+ if (unnamed) for (const x of texts) add(x, t, 'text', 'text on an accent', { optional: true });
314
+ }
315
+ // 4. borders and controls: input borders and focus rings need 3:1, plain dividers are decoration
316
+ for (const t of all.filter((x) => x.role === 'border' || x.role === 'control')) if (main) add(t, main, t.role === 'control' ? 'ui' : 'decor', t.role === 'control' ? 'input borders and focus rings (WCAG 1.4.11)' : 'dividers and card edges (decoration)');
317
+ // 5. chart colours on the background
318
+ for (const t of all.filter((x) => x.role === 'chart')) if (main) add(t, main, 'ui', 'chart colors on the background (WCAG 1.4.11)');
319
+ // 6. a palette without names: dark text on every lighter colour, light text on every darker colour
320
+ if (unnamed) for (const x of all) if (x.role === 'text' && main) add(x, main, 'text', 'darkest on lightest');
321
+ // 7. pairs the caller declares
322
+ for (const [a, b, kind] of opts.pairs || []) {
323
+ const fa = findTok(all, a), fb = findTok(all, b);
324
+ if (fa && fb) add(fa, fb, kind || 'text', 'declared by you');
325
+ }
326
+ }
327
+ // groups that must be told apart: status colours, chart colours, the accents of a bare palette
328
+ const groups = [];
329
+ for (const mode of modes) {
330
+ const all = tokens.filter((t) => t.mode === mode && t.role !== 'ignore');
331
+ const status = all.filter((t) => t.status && !t.on);
332
+ const chart = all.filter((t) => t.role === 'chart');
333
+ const accents = all.filter((t) => t.role === 'accent' && !t.status && C(t.hex) > 0.04);
334
+ if (status.length >= 2) groups.push({ id: `status-${mode}`, label: 'Status colors', mode, members: status });
335
+ if (chart.length >= 2) groups.push({ id: `chart-${mode}`, label: 'Chart colors', mode, members: chart });
336
+ if (accents.length >= 2 && !chart.length) groups.push({ id: `accents-${mode}`, label: 'Accent colors', mode, members: accents });
337
+ }
338
+ // colours with a name but no role and in no pair: listed, so nothing is skipped without saying so
339
+ const inPair = new Set(pairs.flatMap((p) => [p.fg.name, p.bg.name, p.asFg, p.asBg]));
340
+ const unchecked = [...new Set(tokens.filter((t) => !t.unnamed && !t.literal && !t.inherited && t.role !== 'ignore' && t.role !== 'scale' && !inPair.has(t.name) && !tokens.some((x) => x.via === t.name)).map((t) => t.name))];
341
+ return { tokens, pairs: pairs.slice(0, MAX_PAIRS), groups, modes, tooMany: pairs.length > MAX_PAIRS, unchecked, pageSkip: pageSkip.size, pageImage: pageImage.size, pageEmpty: pageEmpty.size, pageBgs };
342
+ }
343
+
344
+ // a text-colour utility somewhere in a selector (Tailwind's .hover\:text-red-500, .text-neutral-400\!, variants)
345
+ const TEXT_UTILITY = /(^|[\s.:(*,>])\.?[\w\\:\[\]\.*-]*text-[\w\\/.!\[\]#-]+/;
346
+ /** The first selector of a list, without its pseudo-classes: ".btn:hover, .btn:focus" → ".btn". */
347
+ const baseSel = (sel) => String(sel).split(',')[0].trim().replace(/::?[\w-]+(\([^)]*\))?/g, '').trim();
348
+ const DISABLED = /:disabled|\[disabled\]|\.disabled\b|aria-disabled=["']?true|is-disabled|--disabled/i;
349
+ /** The interaction states a selector is for (:hover, :focus…; :link and :visited are the normal state). */
350
+ const states = (sel) => [...new Set((String(sel).split(',')[0].match(/:(hover|focus-visible|focus-within|focus|active|checked|selected|current)\b/g) || []))].sort().join('');
351
+ const PAGE_SEL = /^(html|body|:root)(\s*,\s*(html|body|:root))*$/i;
352
+ const ELSEWHERE = /invers|revers|on-dark|on-light|knockout|\.dark\b|dark-mode|-dark\b|::selection|disabled/i;
353
+
354
+ function pickMain(list, mode) {
355
+ const exact = list.find((t) => /^(background|bg|page|canvas|body|surface|base|background-default|bg-default|surface-default)$/.test(words(t.name).join('-')));
356
+ if (exact) return exact;
357
+ return [...list].sort((a, b) => (mode === 'dark' ? L(a.hex) - L(b.hex) : L(b.hex) - L(a.hex)))[0] || null;
358
+ }
359
+ const hasOwnText = (b, texts) => {
360
+ const w = words(b.name).join('-'), stem = w.replace(/-(bg|background|surface|fill|container)$/, '');
361
+ return texts.some((x) => x.on && (w.endsWith(x.on) || stem === x.on));
362
+ };
363
+ function findTok(all, ref) {
364
+ const s = String(ref).replace(/^#/, '').toLowerCase();
365
+ return all.find((t) => t.name === ref) || all.find((t) => t.name.toLowerCase() === String(ref).toLowerCase()) || all.find((t) => t.hex === s);
366
+ }
367
+ /** A colour a CSS rule writes out literally becomes a token of its own, so it can be judged and fixed. */
368
+ function synth(tokens, v, mode, selector) {
369
+ // one token per colour, opacity and mode: #000 at 0% (transparent) is not #000
370
+ let t = tokens.find((x) => x.literal && x.hex === v.hex && (x.alpha ?? 1) === (v.alpha ?? 1) && x.mode === mode);
371
+ if (!t) { t = { name: (v.alpha ?? 1) < 1 ? (v.raw || `#${v.hex}${Math.round(v.alpha * 255).toString(16).padStart(2, '0')}`) : `#${v.hex}`, hex: v.hex, alpha: v.alpha, mode, id: tokens.length, role: 'literal', literal: true, raw: v.raw || null, span: v.span || null, format: v.format, where: selector }; tokens.push(t); }
372
+ return t;
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------------------------------------------- judging
376
+
377
+ /** One pair judged: WCAG 2 (with alpha laid over the background), APCA if given, glare, colour vision, low vision. */
378
+ export function judge(p, target = 'AA', apca) {
379
+ // a translucent background shows the page under it
380
+ const bgHex = p.bg.alpha < 1 ? over(p.bg.hex, p.bg.alpha, p.under || (p.mode === 'dark' ? '000000' : 'ffffff')) : p.bg.hex;
381
+ const fgHex = over(p.fg.hex, p.fg.alpha, bgHex);
382
+ const ratio = contrast(fgHex, bgHex);
383
+ const need = needOf(p.kind, target);
384
+ const out = {
385
+ // shown under the name the design uses (an alias), fixed at its source
386
+ fg: { name: p.asFg || p.fg.name, hex: p.fg.hex, alpha: p.fg.alpha ?? 1, shown: fgHex, ...(p.asFg ? { source: p.fg.name } : {}) },
387
+ bg: { name: p.asBg || p.bg.name, hex: p.bg.hex, alpha: p.bg.alpha ?? 1, shown: bgHex, ...(p.asBg ? { source: p.bg.name } : {}) },
388
+ mode: p.mode, kind: p.kind, why: p.why, ...(p.selector ? { selector: p.selector } : {}), ratio, need, pass: ratio >= need,
389
+ level: p.kind === 'ui' ? (ratio >= 3 ? 'meets 3:1' : 'under 3:1') : ratio >= 7 ? 'AAA' : ratio >= 4.5 ? 'AA' : ratio >= 3 ? 'AA large' : 'fail',
390
+ margin: r1(ratio - need, 2),
391
+ };
392
+ // text on an image or gradient: the colour behind it is not one colour, so it is listed, not judged
393
+ if (p.image) { out.image = true; out.optional = true; out.pass = null; out.unknownBg = true; out.why = `${p.why}; the background is an image or gradient, so contrast can only be measured on the drawn page`; }
394
+ // a combination only a bare palette suggests is reported, not judged: pass is null
395
+ if (p.optional) { out.optional = true; out.pass = null; }
396
+ // text set without a background and nearly the page's own colour must sit on something the stylesheets do not show
397
+ // (a dark header, a coloured badge): its background is unknown, so it is listed, not judged
398
+ if ((p.guess || p.generic) && ratio < 1.2) { out.optional = true; out.pass = null; out.unknownBg = true; out.why = `${p.why}; nearly the page color, so it sits on a background the stylesheets do not show`; }
399
+ if (apca) out.lc = apca(fgHex, bgHex);
400
+ out.glare = glareRatio(fgHex, bgHex);
401
+ // the worst a colour-vision deficiency does to this pair (red text on dark goes darker for protans)
402
+ let worst = null;
403
+ for (const v of VISION) {
404
+ const r = contrast(seeAs(fgHex, v.id), seeAs(bgHex, v.id));
405
+ if (!worst || r < worst.ratio) worst = { id: v.id, label: v.label, ratio: r };
406
+ }
407
+ out.vision = worst;
408
+ out.lowVision = ratio >= 7;
409
+ return out;
410
+ }
411
+
412
+ /** Readability of a text pair at real sizes: WCAG 2 per size (large from 24px, or 18.66px bold), and APCA if given. */
413
+ export function readability(fg, bg, apca) {
414
+ const ratio = contrast(fg, bg), lc = apca ? Math.abs(apca(fg, bg)) : null;
415
+ const rows = [];
416
+ for (const px of [12, 14, 16, 18, 20, 24, 32, 48]) for (const weight of [400, 700]) {
417
+ const large = isLarge(px, weight);
418
+ const need = large ? 3 : 4.5;
419
+ rows.push({ px, weight, large, wcag: ratio >= need, aaa: ratio >= (large ? 4.5 : 7) });
420
+ }
421
+ return { ratio, lc, rows };
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------------------------------------------- tell apart
425
+
426
+ /** Colours of a group that look alike, with normal vision or with a colour-vision deficiency (ΔE-OK under 10 is hard, under 5 the same). */
427
+ export function collisions(groups) {
428
+ const out = [];
429
+ for (const g of groups) {
430
+ const m = g.members.slice(0, 24);
431
+ for (let i = 0; i < m.length; i++) for (let j = i + 1; j < m.length; j++) {
432
+ const a = m[i], b = m[j];
433
+ if (a.hex === b.hex) continue; // the same colour under two names is one colour, not a look-alike
434
+ const normal = dE(a.hex, b.hex);
435
+ const under = VISION.map((v) => ({ id: v.id, label: v.label, d: dE(seeAs(a.hex, v.id), seeAs(b.hex, v.id)) }));
436
+ // name the deficiency that matters most: red-green (about 1 in 12 men) before the rare ones, when it merges them
437
+ const low = (l) => l.reduce((x, y) => (y.d < x.d ? y : x));
438
+ const common = under.filter((x) => /^(protan|deuteran)/.test(x.id));
439
+ const worst = low(common).d < 10 ? low(common) : low(under);
440
+ if (normal < 10 || worst.d < 10) {
441
+ out.push({ group: g.label, mode: g.mode, a: { name: a.name, hex: a.hex }, b: { name: b.name, hex: b.hex }, normal, worst,
442
+ hard: under.filter((x) => x.d < 10).map((x) => x.label), common: under.some((x) => x.d < 10 && /^(protan|deuteran)/.test(x.id)), severity: normal < 5 || worst.d < 5 ? 'same' : 'close' });
443
+ }
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------------------------------------------- feel
450
+
451
+ const hueName = (h) => (h < 40 || h >= 355 ? 'red' : h < 78 ? 'orange' : h < 110 ? 'yellow' : h < 165 ? 'green' : h < 200 ? 'teal' : h < 275 ? 'blue' : h < 330 ? 'purple' : 'pink');
452
+ const angle = (a, b) => { const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; };
453
+
454
+ /** The palette's feel, measured: harmony, temperature, saturation balance, lightness steps, vibration, gamut, mood. */
455
+ export function feel(hexes) {
456
+ const list = [...new Set(hexes)];
457
+ if (!list.length) return null;
458
+ const o = list.map((h) => ({ hex: h, ...toOklch(col(h)) })).map((x) => ({ ...x, c: x.c || 0, h: x.h || 0 }));
459
+ const chromatic = o.filter((x) => x.c > 0.04);
460
+ const notes = [];
461
+ // harmony: cluster the chromatic hues (within 25°), then name the relation between the clusters
462
+ const clusters = [];
463
+ for (const x of [...chromatic].sort((a, b) => b.c - a.c)) {
464
+ const k = clusters.find((cl) => angle(cl.h, x.h) <= 25);
465
+ if (k) k.members.push(x); else clusters.push({ h: x.h, members: [x] });
466
+ }
467
+ const hs = clusters.map((c) => c.h);
468
+ let harmony = 'neutral';
469
+ if (hs.length === 1) harmony = 'monochromatic';
470
+ else if (hs.length === 2) { const a = angle(hs[0], hs[1]); harmony = a >= 150 ? 'complementary' : a <= 60 ? 'analogous' : 'contrasting'; }
471
+ else if (hs.length >= 3) {
472
+ const spread = Math.max(...hs.flatMap((a) => hs.map((b) => angle(a, b))));
473
+ const sorted = [...hs].sort((a, b) => a - b), gaps = sorted.map((h, i) => (sorted[(i + 1) % sorted.length] - h + 360) % 360 || 360);
474
+ if (spread <= 90) harmony = 'analogous';
475
+ else if (hs.length === 3 && gaps.every((g) => g >= 90 && g <= 150)) harmony = 'triadic';
476
+ else if (hs.length === 3 && Math.max(...gaps) >= 150 && Math.min(...gaps) <= 60) harmony = 'split complementary';
477
+ else if (hs.length === 4 && gaps.every((g) => g >= 60 && g <= 120)) harmony = 'tetradic';
478
+ else harmony = 'free (no single rule)';
479
+ }
480
+ // temperature, weighted by chroma
481
+ let warm = 0, cool = 0;
482
+ for (const x of chromatic) { if (x.h < 100 || x.h > 330) warm += x.c; else if (x.h > 160 && x.h < 300) cool += x.c; }
483
+ const temperature = !chromatic.length ? 'neutral' : warm > cool * 1.5 ? 'warm' : cool > warm * 1.5 ? 'cool' : 'mixed';
484
+ // saturation balance: one colour much louder than the rest
485
+ const cs = o.map((x) => x.c).sort((a, b) => a - b), med = cs[Math.floor(cs.length / 2)];
486
+ const loud = o.filter((x) => x.c > 0.15 && x.c > Math.max(med, 0.03) * 2.5);
487
+ if (loud.length === 1 && o.length >= 3) notes.push({ kind: 'loud', text: `#${loud[0].hex} is far more saturated than the rest; it will pull the eye. Good for one call to action, tiring as a large area.`, hex: loud[0].hex });
488
+ const chromaSpread = r1((cs[cs.length - 1] - cs[0]), 3);
489
+ // lightness steps: a set needs a clear dark and a clear light to carry text
490
+ const ls = o.map((x) => x.l).sort((a, b) => a - b);
491
+ const lightRange = r1(ls[ls.length - 1] - ls[0], 2);
492
+ const steps = ls.filter((l, i) => !i || l - ls[i - 1] >= 0.08).length;
493
+ const best = o.length >= 2 ? contrast(o.reduce((a, b) => (a.l > b.l ? a : b)).hex, o.reduce((a, b) => (a.l < b.l ? a : b)).hex) : 21;
494
+ if (lightRange < 0.5 && o.length >= 2 && best < 4.5) notes.push({ kind: 'flat', text: `The lightest and darkest colors are only ${Math.round(lightRange * 100)} points apart in lightness, so no pair will carry body text. Add a near-black or near-white.` });
495
+ else if (steps < Math.min(3, o.length)) notes.push({ kind: 'steps', text: 'Few distinct lightness steps: headings, text and surfaces will be hard to tell apart. Spread the lightness more.' });
496
+ // vibration: saturated opposite hues at similar lightness flicker where they meet
497
+ const vib = [];
498
+ for (let i = 0; i < chromatic.length; i++) for (let j = i + 1; j < chromatic.length; j++) {
499
+ const a = chromatic[i], b = chromatic[j];
500
+ if (a.c > 0.12 && b.c > 0.12 && angle(a.h, b.h) >= 120 && Math.abs(a.l - b.l) < 0.12) vib.push([a.hex, b.hex]);
501
+ }
502
+ if (vib.length) notes.push({ kind: 'vibrate', text: `${vib.map(([a, b]) => `#${a} and #${b}`).join(', ')} are strong opposite hues at about the same lightness: as text on each other they vibrate. Use one as text only on a neutral.`, pairs: vib });
503
+ // gamut: colours P3 screens could show far more vivid
504
+ const edge = o.filter((x) => { const v = toGamut('p3', 'oklch')({ mode: 'oklch', l: x.l, c: 0.4, h: x.h }); return x.c > 0.1 && toOklch(v).c > x.c * 1.3; });
505
+ const muddy = o.filter((x) => x.l > 0.35 && x.l < 0.6 && x.c > 0.03 && x.c < 0.07 && x.h > 40 && x.h < 120);
506
+ if (muddy.length && muddy.length < o.length) notes.push({ kind: 'muddy', text: `${muddy.map((x) => '#' + x.hex).join(', ')} ${muddy.length === 1 ? 'sits' : 'sit'} in the dull olive-brown band (mid lightness, low chroma, warm hue), which can read as dirty next to clean colors.` });
507
+ const neutrals = o.filter((x) => x.c <= 0.04).length;
508
+ return {
509
+ harmony, hues: clusters.map((c) => ({ h: r1(c.h), name: hueName(c.h), count: c.members.length })),
510
+ temperature, chroma: { max: r1(cs[cs.length - 1], 3), median: r1(med, 3), spread: chromaSpread },
511
+ lightness: { min: r1(ls[0], 2), max: r1(ls[ls.length - 1], 2), range: lightRange, steps },
512
+ neutrals, vivid: chromatic.length, moods: moods(list), p3: edge.map((x) => x.hex), notes,
513
+ };
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------------------------------------------- fixing
517
+
518
+ const WEIGHT = { text: 1, literal: 1.1, control: 1.2, border: 1.2, chart: 1.6, bg: 1.8, scale: 1.5, accent: 2.2 };
519
+
520
+ /** The nearest lightness (hue and chroma kept, chroma lowered only to fit sRGB) at which a test passes, or null. */
521
+ function nearestL(hex, test) {
522
+ const o = toOklch(col(hex)), c = o.c || 0, h = o.h || 0;
523
+ const at = (l) => fromOklch(l, c, h);
524
+ // coarse steps to find the first passing lightness each way, then fine steps back towards the start
525
+ let best = null;
526
+ for (const s of [1, -1]) {
527
+ let hit = null;
528
+ for (let d = 0.01; d <= 1.001; d += 0.01) { const l = o.l + s * d; if (l < -0.001 || l > 1.001) break; if (test(at(Math.min(1, Math.max(0, l))))) { hit = d; break; } }
529
+ if (hit == null) continue;
530
+ let d = hit;
531
+ for (let f = hit - 0.001; f > hit - 0.01 && f > 0; f -= 0.001) { if (test(at(Math.min(1, Math.max(0, o.l + s * f))))) d = f; else break; }
532
+ if (!best || d < best.d) best = { d, x: at(Math.min(1, Math.max(0, o.l + s * d))) };
533
+ }
534
+ return best && test(best.x) ? best.x : null;
535
+ }
536
+
537
+ // a design system's raw palette (gray-400, $yellow, color.base.blue): other tokens point to it, so it is never moved;
538
+ // the token that points to it is re-pointed to another step of the same scale, or changed itself
539
+ const COLOR_WORDS = /^(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|gray|grey|slate|zinc|neutral|stone|white|black|brown|navy|maroon|olive|silver|gold|magenta|lavender|coral|beige)$/i;
540
+ export const isPalette = (t) => t.role === 'scale' || !!t.primitive || COLOR_WORDS.test(words(t.name).at(-1) || '');
541
+
542
+ /**
543
+ * Fix a whole palette: each round moves the one colour whose nearest passing lightness fixes the most failing pairs for
544
+ * the smallest visible change (ΔE-OK, weighted so text moves before backgrounds and brand colours last). A move never
545
+ * breaks a pair that passes, in either mode (a light value a dark mode inherits moves with its copy), never turns a pair
546
+ * around (dark text stays darker than its background), and lands a margin past the line. Locked names and raw palette
547
+ * steps never move; a token that points to a palette step is re-pointed to a step that passes when the scale has one.
548
+ */
549
+ export function solve(planned, target = 'AA', lock = [], opts = {}) {
550
+ const margin = opts.margin ?? 0.1;
551
+ const value = new Map(planned.tokens.map((t) => [t.id, t.hex]));
552
+ const locked = new Set(lock.map(String));
553
+ const pairs = planned.pairs.filter((p) => p.kind !== 'decor' && !p.optional && !p.image && !((p.guess || p.generic) && contrast(p.fg.hex, p.bg.hex) < 1.2));
554
+ const shownBg = (p, v) => (p.bg.alpha < 1 ? over(v.get(p.bg.id), p.bg.alpha, p.under || (p.mode === 'dark' ? '000000' : 'ffffff')) : v.get(p.bg.id));
555
+ const ratioOf = (p, v = value) => { const bg = shownBg(p, v); return wcagContrast(col(over(v.get(p.fg.id), p.fg.alpha, bg)), col(bg)); };
556
+ const ok = (p, v = value, extra = 0) => { const r = ratioOf(p, v); return r >= needOf(p.kind, target) + extra && contrast(over(v.get(p.fg.id), p.fg.alpha, shownBg(p, v)), shownBg(p, v)) >= needOf(p.kind, target); };
557
+ // which way round each pair is now (text lighter or darker than its background): a fix keeps it so
558
+ const lum = (h) => wcagLuminance(col(h));
559
+ const side = new Map(pairs.map((p) => [p, Math.sign(lum(value.get(p.fg.id)) - lum(value.get(p.bg.id)))]));
560
+ const sameSide = (p) => { const sg = side.get(p); return !sg || Math.sign(lum(value.get(p.fg.id)) - lum(value.get(p.bg.id))) === sg; };
561
+ const byId = new Map(planned.tokens.map((t) => [t.id, t]));
562
+ // a light value that dark mode inherits is one value: the copies move with it
563
+ const copies = new Map();
564
+ for (const t of planned.tokens) if (t.inherited) { const src = planned.tokens.find((x) => x.name === t.name && x.mode === 'light' && !x.inherited); if (src) copies.set(src.id, [...(copies.get(src.id) || []), t.id]); }
565
+ const ids = (t) => [t.id, ...(copies.get(t.id) || [])];
566
+ const setAll = (t, x) => { for (const i of ids(t)) value.set(i, x); };
567
+ const siblings = (t) => {
568
+ const src = t.via && planned.tokens.find((x) => x.name === t.via && x.mode === t.mode) || planned.tokens.find((x) => x.name === t.via);
569
+ if (!src || !isPalette(src)) return null;
570
+ const stem = src.name.replace(/[-_.]?\d+$/, '');
571
+ // a named palette colour ($cyan): the other palette colours near it are the steps to choose from
572
+ if (stem === src.name) return planned.tokens.filter((x) => x !== src && x.mode === src.mode && isPalette(x) && !x.via && dE(src.hex, x.hex) <= 25).map((x) => ({ x, src }));
573
+ return planned.tokens.filter((x) => x !== src && x.mode === src.mode && x.name !== src.name && x.name.replace(/[-_.]?\d+$/, '') === stem && /\d+$/.test(x.name)).map((x) => ({ x, src }));
574
+ };
575
+ const movable = (t) => !t.inherited && !locked.has(t.name) && !locked.has(t.hex) && !locked.has('#' + t.hex) && !(isPalette(t) && planned.tokens.some((x) => x.via === t.name));
576
+ const moved = new Map(), repoint = new Map(), flipped = new Set();
577
+ for (let round = 0; round < 60; round++) {
578
+ const failing = pairs.filter((p) => !ok(p));
579
+ if (!failing.length) break;
580
+ let best = null;
581
+ const cands = new Map();
582
+ for (const p of failing.slice(0, 40)) for (const t of [p.fg, p.bg]) { const tk = byId.get(t.id) || t; if (movable(tk)) cands.set(tk.id, tk); }
583
+ for (const t of cands.values()) {
584
+ const all = ids(t);
585
+ const mine = pairs.filter((p) => all.includes(p.fg.id) || all.includes(p.bg.id));
586
+ const prev = value.get(t.id);
587
+ const test = (x) => { setAll(t, x); const r = mine.every((p) => ok(p, value, failing.includes(p) ? margin : 0) && sameSide(p)); setAll(t, prev); return r; };
588
+ // a step of the same scale first (re-pointing keeps the design system whole), else the nearest lightness
589
+ let to = null, via = null;
590
+ const sib = siblings(t);
591
+ // a step far away (gray-400 to gray-900) would merge two roles: then a new step is suggested instead
592
+ if (sib) { const hit = sib.filter(({ x }) => test(x.hex) && dE(prev, x.hex) <= 20).sort((a, b2) => dE(prev, a.x.hex) - dE(prev, b2.x.hex))[0]; if (hit) { to = hit.x.hex; via = { from: hit.src.name, to: hit.x.name }; } }
593
+ if (!to) to = nearestL(prev, test);
594
+ if (!to) continue;
595
+ const fixes = failing.filter((p) => all.includes(p.fg.id) || all.includes(p.bg.id)).length;
596
+ const cost = (dE(prev, to) * (WEIGHT[t.role] || 1.5) * (via ? 0.8 : 1)) / fixes;
597
+ if (!best || cost < best.cost) best = { t, to, cost, via };
598
+ }
599
+ if (!best) break;
600
+ setAll(best.t, best.to);
601
+ moved.set(best.t.id, best.t);
602
+ if (best.via) repoint.set(best.t.id, best.via); else repoint.delete(best.t.id);
603
+ }
604
+ // a text colour made for one background (white on a locked brand colour) may turn dark if that is the only way to pass
605
+ for (const p of pairs.filter((x) => !ok(x))) {
606
+ const f = byId.get(p.fg.id), b = byId.get(p.bg.id);
607
+ if (!f || !b || movable(b) || !movable(f) || !f.on) continue;
608
+ const all = ids(f), mine = pairs.filter((x) => all.includes(x.fg.id) || all.includes(x.bg.id));
609
+ const prev = value.get(f.id);
610
+ const best = ['000000', 'ffffff', prev].map((seed) => nearestL(seed, (x) => { setAll(f, x); const r = mine.every((q) => ok(q, value, q === p ? margin : 0)); setAll(f, prev); return r; })).filter(Boolean).sort((a, c) => dE(prev, a) - dE(prev, c))[0];
611
+ if (best) { setAll(f, best); moved.set(f.id, f); flipped.add(f.id); }
612
+ }
613
+ // a reference (var(--gray-400), {color.base.gray.400}, $cyan) is never overwritten with a value: the new value is
614
+ // given as a step to add to the palette and point to
615
+ const isRef = (raw) => /^\s*(var\(|\{|[$@][\w-]+\s*$)/.test(String(raw || ''));
616
+ const changes = [...moved.values()].map((t) => ({ name: t.name, mode: t.mode, role: t.role, from: t.hex, to: value.get(t.id), deltaE: dE(t.hex, value.get(t.id)), span: !repoint.get(t.id) && isRef(t.raw) ? null : t.span, raw: t.raw, format: t.format, alpha: t.alpha, ...(!repoint.get(t.id) && isRef(t.raw) ? { newStep: true } : {}),
617
+ ...(repoint.get(t.id) ? { repoint: repoint.get(t.id) } : {}), ...(copies.get(t.id) ? { alsoDark: true } : {}), ...(flipped.has(t.id) ? { flipped: true } : {}) }))
618
+ .filter((c) => c.from !== c.to);
619
+ const why = (p) => {
620
+ const f = byId.get(p.fg.id), b = byId.get(p.bg.id);
621
+ const stuck = [f, b].filter((t) => !movable(t));
622
+ if (stuck.length === 2) return `${stuck.map((t) => t.name).join(' and ')} cannot change (${stuck.map((t) => (locked.has(t.name) || locked.has('#' + t.hex) ? 'locked' : t.inherited ? 'inherited from light mode' : 'a palette step other tokens use')).join(', ')})`;
623
+ const lockedOne = [f, b].find((t) => locked.has(t.name) || locked.has('#' + t.hex));
624
+ if (lockedOne) return `${lockedOne.name} is locked, and the other color cannot move far enough without turning the pair around`;
625
+ const shared = [f, b].find((t) => copies.get(t.id) || (t.inherited));
626
+ if (shared) return `${shared.name} is the same value in light and dark mode, and no single value passes on both backgrounds: give it its own dark value`;
627
+ return 'no single color can move without breaking another pair or turning the pair around';
628
+ };
629
+ const remaining = pairs.filter((p) => !ok(p)).map((p) => {
630
+ const keep = (id, x) => { const v = new Map(value).set(id, x); return ok(p, v, margin) && Math.sign(lum(v.get(p.fg.id)) - lum(v.get(p.bg.id))) === side.get(p); };
631
+ return { fg: p.fg.name, bg: p.bg.name, mode: p.mode, kind: p.kind, reason: why(p),
632
+ suggest: { fg: movable(byId.get(p.fg.id)) ? nearestL(value.get(p.fg.id), (x) => keep(p.fg.id, x)) : null, bg: movable(byId.get(p.bg.id)) ? nearestL(value.get(p.bg.id), (x) => keep(p.bg.id, x)) : null } };
633
+ }).slice(0, 60);
634
+ return { target, changes, remaining, values: value };
635
+ }
636
+
637
+ /** A colour written the way the original was: hex stays hex (and its case), hsl stays hsl, bare shadcn numbers stay bare. */
638
+ export function writeLike(hex, raw, format, alpha = 1) {
639
+ const v = info(hex), a = alpha < 1 ? alpha : null;
640
+ const pct = (x) => `${r1(x, 1)}%`;
641
+ switch (format) {
642
+ case 'HEX': return '#' + hex.toUpperCase() + (a != null ? Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase() : '');
643
+ case 'hex': case 'hex8': case 'name': return '#' + hex + (a != null ? Math.round(a * 255).toString(16).padStart(2, '0') : '');
644
+ case 'hsl-bare': return `${v.hsl.h} ${pct(v.hsl.s)} ${pct(v.hsl.l)}${a != null ? ` / ${a}` : ''}`;
645
+ case 'hsl': return `hsl(${v.hsl.h} ${pct(v.hsl.s)} ${pct(v.hsl.l)}${a != null ? ` / ${a}` : ''})`;
646
+ case 'hsl-legacy': return a != null ? `hsla(${v.hsl.h}, ${pct(v.hsl.s)}, ${pct(v.hsl.l)}, ${a})` : `hsl(${v.hsl.h}, ${pct(v.hsl.s)}, ${pct(v.hsl.l)})`;
647
+ case 'rgb': return `rgb(${v.rgb.r} ${v.rgb.g} ${v.rgb.b}${a != null ? ` / ${a}` : ''})`;
648
+ case 'rgb-legacy': return a != null ? `rgba(${v.rgb.r}, ${v.rgb.g}, ${v.rgb.b}, ${a})` : `rgb(${v.rgb.r}, ${v.rgb.g}, ${v.rgb.b})`;
649
+ case 'oklch': {
650
+ const decimal = raw && /oklch\(\s*0?\.\d/i.test(raw);
651
+ const o = v.oklch;
652
+ return `oklch(${decimal ? r1(o.l / 100, 4) : `${o.l}%`} ${o.c} ${o.h}${a != null ? ` / ${a}` : ''})`;
653
+ }
654
+ default: return '#' + hex;
655
+ }
656
+ }
657
+
658
+ /** The fixed text, and a unified diff of the lines that changed. */
659
+ export function applyFix(text, changes, label = 'colors') {
660
+ const edits = changes.filter((c) => c.span).map((c) => ({ ...c, out: c.repoint && c.raw && c.raw.includes(c.repoint.from) ? c.raw.replace(c.repoint.from, c.repoint.to) : writeLike(c.to, c.raw, c.format, c.alpha) })).sort((a, b) => b.span[0] - a.span[0]);
661
+ let out = text;
662
+ for (const e of edits) out = out.slice(0, e.span[0]) + e.out + out.slice(e.span[1]);
663
+ const A = text.split('\n'), B = out.split('\n');
664
+ // a value written over several lines changes the line count: then the fixed text is given without a line diff
665
+ if (A.length !== B.length) return { text: out, diff: '', unwritten: changes.filter((c) => !c.span).map((c) => c.name) };
666
+ const changed = A.map((l, i) => l !== B[i]);
667
+ const hunks = [];
668
+ for (let i = 0; i < A.length; i++) {
669
+ if (!changed[i]) continue;
670
+ const s = Math.max(0, i - 2);
671
+ let e = i;
672
+ while (e + 1 < A.length && (changed[e + 1] || changed.slice(e + 1, e + 4).some(Boolean))) e++;
673
+ e = Math.min(A.length - 1, e + 2);
674
+ const lines = [];
675
+ // a minified stylesheet is one long line: show the part around each change, not the whole file
676
+ const cut = (a, b) => {
677
+ if (a.length <= 300) return [a, b];
678
+ let i = 0; while (i < a.length && a[i] === b[i]) i++;
679
+ let j = 0; while (j < a.length - i && a[a.length - 1 - j] === b[b.length - 1 - j]) j++;
680
+ const from = Math.max(0, i - 80);
681
+ return [`${from ? '…' : ''}${a.slice(from, Math.min(a.length, a.length - j + 80))}${a.length - j + 80 < a.length ? '…' : ''}`, `${from ? '…' : ''}${b.slice(from, Math.min(b.length, b.length - j + 80))}${b.length - j + 80 < b.length ? '…' : ''}`];
682
+ };
683
+ for (let k = s; k <= e; k++) { if (changed[k]) { const [x, y] = cut(A[k], B[k]); lines.push('-' + x, '+' + y); } else lines.push(' ' + (A[k].length > 300 ? A[k].slice(0, 120) + '…' : A[k])); }
684
+ const len = e - s + 1;
685
+ hunks.push(`@@ -${s + 1},${len} +${s + 1},${len} @@\n${lines.join('\n')}`);
686
+ i = e;
687
+ }
688
+ const diff = hunks.length ? `--- a/${label}\n+++ b/${label}\n${hunks.join('\n')}\n` : '';
689
+ return { text: out, diff, unwritten: changes.filter((c) => !c.span).map((c) => c.name) };
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------------------------------------------- report
693
+
694
+ /**
695
+ * The whole check. `input` is pasted text, or an array of colours. Options: target ('AA' | 'AAA'), roles, pairs, lock
696
+ * (names that must not move), apca (the APCA function from shared/apca.js, or nothing), label (the file name in diffs).
697
+ */
698
+ export function check(input, opts = {}) {
699
+ const text = Array.isArray(input) ? input.join('\n') : String(input ?? '');
700
+ const read = readInput(text, opts.max || 200000);
701
+ const target = opts.target === 'AAA' ? 'AAA' : 'AA';
702
+ const planned = plan(read, opts);
703
+ const judged = planned.pairs.map((p) => judge(p, target, opts.apca));
704
+ const clash = collisions(planned.groups);
705
+ const fix = solve(planned, target, opts.lock || [], { margin: opts.margin });
706
+ const applied = applyFix(text, fix.changes, opts.label);
707
+ const byMode = {};
708
+ for (const m of planned.modes) byMode[m] = feel(planned.tokens.filter((t) => t.mode === m && t.role !== 'ignore').map((t) => t.hex));
709
+ const failing = judged.filter((p) => !p.pass && p.kind !== 'decor' && !p.optional);
710
+ const report = {
711
+ version: VERSION,
712
+ input: { page: opts.page ? { rulesNotOnPage: planned.pageSkip, overImages: planned.pageImage, noText: planned.pageEmpty } : undefined, kind: read.kind, colors: new Set(planned.tokens.filter((t) => !t.inherited).map((t) => `${t.name}|${t.mode}`)).size, rules: read.rules?.length || 0, modes: planned.modes, warnings: [...read.warnings, ...(planned.tooMany ? [`Only the first ${MAX_PAIRS} pairs are checked.`] : []), ...(planned.pageSkip ? [`${planned.pageSkip} CSS rules are for elements this page's HTML does not have or hides on load (other pages, or shown by scripts): not judged.`] : []), ...(planned.pageEmpty ? [`${planned.pageEmpty} text colors are set on elements that hold no text on this page (only images or icons): not judged.`] : []), ...(planned.pageImage ? [`${planned.pageImage} text colors sit on an image or gradient: their contrast can only be measured on the drawn page (the command-line scan does that).`] : []), ...(opts.page ? ['Pairs guessed from color names alone are listed apart: a stylesheet does not say they meet on this page.'] : []), ...(opts.page && planned.pairs.some((p) => p.darkSwitch) ? ["This site's dark mode is switched by a class or attribute, not the visitor's system setting: dark-mode pairs are listed apart, not judged."] : [])] },
713
+ target, standard: TARGETS[target].label,
714
+ tokens: planned.tokens.filter((t) => !t.literal || judged.some((p) => p.fg.name === t.name || p.bg.name === t.name)).map((t) => ({ name: t.name, hex: t.hex, alpha: t.alpha ?? 1, mode: t.mode, role: t.role || 'other', why: t.why || null, at: t.span ? t.span[0] : null, ...(t.where ? { where: t.where } : {}), ...(t.inherited ? { inherited: true } : {}) })),
715
+ pairs: judged,
716
+ collisions: clash.slice(0, 60),
717
+ unchecked: planned.unchecked,
718
+ feel: byMode,
719
+ fix: { changes: fix.changes.map((c) => ({ ...c, span: c.span || null, raw: c.raw ?? null })), remaining: fix.remaining, text: fix.changes.length ? applied.text : null, diff: applied.diff, unwritten: applied.unwritten },
720
+ };
721
+ // each pair in plain words, with the nearest passing colour for it
722
+ for (const p of judged) {
723
+ p.pageBg = planned.pageBgs?.[p.mode] || null;
724
+ p.plain = explain(p, p.pass === false && !p.optional && p.kind !== 'decor' ? pairFix(p, report.fix) : null);
725
+ }
726
+ report.summary = summarize(report);
727
+ report.summary.plain = plainSummary(report);
728
+ return report;
729
+ }
730
+
731
+ /** The nearest passing colour for one failing pair: the whole-palette fix where it moves one of the two, else the pair's own. */
732
+ function pairFix(p, fix) {
733
+ const fgName = p.fg.source || p.fg.name, bgName = p.bg.source || p.bg.name;
734
+ const ratioWith = (fg, bg) => contrast(over(fg, p.fg.alpha, bg), bg);
735
+ const c = fix.changes.find((x) => x.mode === p.mode && x.name === fgName) || fix.changes.find((x) => x.mode === p.mode && x.name === bgName);
736
+ if (c) {
737
+ const side = c.name === fgName ? 'fg' : 'bg';
738
+ const r = side === 'fg' ? ratioWith(c.to, p.bg.shown) : ratioWith(p.fg.hex, c.to);
739
+ if (r >= p.need) return { side, name: c.name, from: c.from, to: c.to, ratio: r };
740
+ }
741
+ const to = nearestL(p.fg.hex, (x) => ratioWith(x, p.bg.shown) >= p.need + 0.1);
742
+ if (to) return { side: 'fg', name: fgName, from: p.fg.hex, to, ratio: ratioWith(to, p.bg.shown) };
743
+ const tb = nearestL(p.bg.shown, (x) => ratioWith(p.fg.hex, x) >= p.need + 0.1);
744
+ return tb ? { side: 'bg', name: bgName, from: p.bg.shown, to: tb, ratio: ratioWith(p.fg.hex, tb) } : null;
745
+ }
746
+
747
+ /** The plain-language top of a report, filled in from the numbers (a template, the same words every time). */
748
+ export function summarize(r) {
749
+ const judged = r.pairs.filter((p) => p.kind !== 'decor' && !p.optional);
750
+ const fail = judged.filter((p) => !p.pass);
751
+ const pass = judged.length - fail.length;
752
+ const lines = [];
753
+ if (!r.input.colors) return { pass: 0, fail: 0, total: 0, issues: 0, text: r.input.rules ? `${r.input.rules} CSS rules read, but none sets a color that can be paired (colors set by scripts or images are not seen).` : 'No colors found. Paste hex codes, CSS, Tailwind config or design tokens.', lines: [] };
754
+ if (!judged.length) lines.push(`${r.input.colors} ${r.input.colors === 1 ? 'color' : 'colors'} read, but no pairs to judge: tell the checker which colors are text and which are backgrounds.`);
755
+ else lines.push(`${judged.length} ${judged.length === 1 ? 'pair' : 'pairs'} checked against ${r.standard}: ${pass} pass, ${fail.length} fail.`);
756
+ if (fail.length) {
757
+ const kinds = {};
758
+ for (const p of fail) kinds[p.kind] = (kinds[p.kind] || 0) + 1;
759
+ lines.push(`Failing: ${Object.entries(kinds).map(([k, n]) => `${n} ${kindLabel(k)}${n === 1 ? '' : k === 'text' || k === 'large' ? '' : 's'}`).join(', ')}. The worst is ${fail.sort((a, b) => a.ratio / a.need - b.ratio / b.need)[0].fg.name} on ${fail[0].bg.name} at ${fail[0].ratio}:1 (needs ${fail[0].need}:1).`);
760
+ }
761
+ if (r.fix.changes.length) {
762
+ const ch = r.fix.changes;
763
+ lines.push(`Changing ${ch.length} ${ch.length === 1 ? 'color' : 'colors'} fixes ${r.fix.remaining.length ? 'most of it' : 'every failing pair'}: ${ch.slice(0, 4).map((c) => `${c.name === '#' + c.from ? '' : c.name + ' '}${r.input.modes.length > 1 ? `(${c.mode}) ` : ''}#${c.from} → #${c.to} (ΔE ${c.deltaE})`).join(', ')}${ch.length > 4 ? ', …' : ''}.`);
764
+ }
765
+ if (r.fix.remaining.length) lines.push(`${r.fix.remaining.length} ${r.fix.remaining.length === 1 ? 'pair needs' : 'pairs need'} both colors to move; see the suggestions.`);
766
+ const cvd = r.pairs.filter((p) => p.pass && p.vision && p.vision.ratio < p.need);
767
+ if (cvd.length) lines.push(`${cvd.length} passing ${cvd.length === 1 ? 'pair drops' : 'pairs drop'} below ${r.standard} for some color vision (${[...new Set(cvd.map((p) => p.vision.label))].join(', ')}).`);
768
+ const same = r.collisions.filter((c) => c.normal >= 10 && c.common), rare = r.collisions.filter((c) => c.normal >= 10 && !c.common);
769
+ if (same.length) lines.push(`${same.length} ${same.length === 1 ? 'pair' : 'pairs'} of ${same[0].group.toLowerCase()} look alike with red-green color blindness${rare.length ? ` (${rare.length} more only with rarer types)` : ''}: don't let color alone tell them apart.`);
770
+ else if (rare.length) lines.push(`${rare.length} ${rare.length === 1 ? 'pair looks' : 'pairs look'} alike only with rarer color blindness (${[...new Set(rare.map((c) => c.worst.label))].join(', ')}).`);
771
+ if (r.unchecked?.length) lines.push(`${r.unchecked.length} ${r.unchecked.length === 1 ? 'color has' : 'colors have'} no role and ${r.unchecked.length === 1 ? 'was' : 'were'} not checked: ${r.unchecked.slice(0, 6).join(', ')}${r.unchecked.length > 6 ? ', …' : ''}. Give ${r.unchecked.length === 1 ? 'it' : 'them'} a role to check ${r.unchecked.length === 1 ? 'it' : 'them'}.`);
772
+ const narrow = judged.filter((p) => p.pass && p.kind === 'text' && p.ratio < 5);
773
+ if (narrow.length) lines.push(`${narrow.length} text ${narrow.length === 1 ? 'pair passes' : 'pairs pass'} by less than 0.5; glare and low vision will lose ${narrow.length === 1 ? 'it' : 'them'}.`);
774
+ return { pass, fail: fail.length, total: judged.length, issues: fail.length + same.length, text: lines.join(' '), lines };
775
+ }
776
+
777
+ export { textOn };