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.
- package/LICENSE.md +135 -0
- package/README.md +99 -2
- package/action.yml +91 -0
- package/dist/everyhexcode.js +5397 -0
- package/package.json +40 -4
- package/src/cli.js +160 -0
- package/src/engine/apca.js +10 -0
- package/src/engine/check-input.js +540 -0
- package/src/engine/check.js +777 -0
- package/src/engine/color.js +364 -0
- package/src/engine/parse.js +208 -0
- package/src/engine/plain.js +124 -0
- package/src/files.js +97 -0
- package/src/no-apca.js +10 -0
- package/src/output.js +110 -0
- package/src/render.js +329 -0
|
@@ -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 };
|