design-constraint-validator 2.1.0 → 2.2.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/README.md +17 -6
- package/cli/commands/build.d.ts.map +1 -1
- package/cli/commands/build.js +32 -24
- package/cli/commands/build.ts +26 -17
- package/cli/commands/graph.d.ts.map +1 -1
- package/cli/commands/graph.js +33 -16
- package/cli/commands/graph.ts +28 -15
- package/cli/commands/patch-apply.d.ts.map +1 -1
- package/cli/commands/patch-apply.js +4 -1
- package/cli/commands/patch-apply.ts +4 -1
- package/cli/commands/set.d.ts.map +1 -1
- package/cli/commands/set.js +18 -19
- package/cli/commands/set.ts +19 -19
- package/cli/commands/utils.d.ts +1 -0
- package/cli/commands/utils.d.ts.map +1 -1
- package/cli/commands/utils.js +20 -1
- package/cli/commands/utils.ts +23 -1
- package/cli/commands/validate.d.ts.map +1 -1
- package/cli/commands/validate.js +5 -17
- package/cli/commands/validate.ts +10 -18
- package/cli/commands/why.d.ts.map +1 -1
- package/cli/commands/why.js +22 -10
- package/cli/commands/why.ts +20 -9
- package/cli/config-schema.d.ts +144 -178
- package/cli/config-schema.d.ts.map +1 -1
- package/cli/config-schema.js +25 -5
- package/cli/config-schema.ts +27 -5
- package/cli/constraint-registry.d.ts.map +1 -1
- package/cli/constraint-registry.js +53 -15
- package/cli/constraint-registry.ts +53 -18
- package/cli/cross-axis-loader.d.ts +62 -0
- package/cli/cross-axis-loader.d.ts.map +1 -1
- package/cli/cross-axis-loader.js +186 -31
- package/cli/cross-axis-loader.ts +199 -24
- package/cli/dcv.js +23 -1
- package/cli/dcv.ts +23 -1
- package/cli/types.d.ts +19 -9
- package/cli/types.d.ts.map +1 -1
- package/cli/types.ts +23 -10
- package/cli/validate-api.d.ts.map +1 -1
- package/cli/validate-api.js +6 -1
- package/cli/validate-api.ts +6 -1
- package/core/constraints/cross-axis.d.ts.map +1 -1
- package/core/constraints/cross-axis.js +37 -9
- package/core/constraints/cross-axis.ts +37 -9
- package/core/constraints/monotonic.d.ts.map +1 -1
- package/core/constraints/monotonic.js +32 -8
- package/core/constraints/monotonic.ts +29 -8
- package/core/constraints/threshold.d.ts.map +1 -1
- package/core/constraints/threshold.js +24 -4
- package/core/constraints/threshold.ts +23 -4
- package/core/constraints/wcag.js +1 -1
- package/core/constraints/wcag.ts +1 -1
- package/core/flatten.d.ts.map +1 -1
- package/core/flatten.js +8 -0
- package/core/flatten.ts +9 -0
- package/core/poset.d.ts +6 -1
- package/core/poset.d.ts.map +1 -1
- package/core/poset.js +7 -2
- package/core/poset.ts +7 -2
- package/mcp/contracts.d.ts +1456 -13
- package/mcp/contracts.d.ts.map +1 -1
- package/mcp/contracts.js +45 -1
- package/mcp/contracts.ts +55 -1
- package/mcp/index.d.ts +6 -4
- package/mcp/index.d.ts.map +1 -1
- package/mcp/index.js +6 -3
- package/mcp/index.ts +28 -1
- package/mcp/insights.d.ts +94 -0
- package/mcp/insights.d.ts.map +1 -0
- package/mcp/insights.js +445 -0
- package/mcp/insights.ts +541 -0
- package/mcp/tools.d.ts +14 -3
- package/mcp/tools.d.ts.map +1 -1
- package/mcp/tools.js +133 -6
- package/mcp/tools.ts +188 -11
- package/package.json +1 -6
- package/server.json +2 -2
package/mcp/insights.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure derivation logic for the read-only insight tools
|
|
3
|
+
* (`list-constraints`, `explain`, `suggest-fix`).
|
|
4
|
+
*
|
|
5
|
+
* Everything here is side-effect free: no filesystem, no engine construction, no
|
|
6
|
+
* writes. Callers pass in a value resolver and the already-discovered constraint
|
|
7
|
+
* sources; these functions turn raw violations into explanations and verified
|
|
8
|
+
* suggestions. The WCAG math reuses core/color so suggestions are checked against
|
|
9
|
+
* the same contrast pipeline the validator uses.
|
|
10
|
+
*/
|
|
11
|
+
import { DEFAULT_THRESHOLDS, DEFAULT_WCAG_PAIRS, } from '../cli/constraint-registry.js';
|
|
12
|
+
import { parseSize } from '../core/constraints/monotonic.js';
|
|
13
|
+
import { parseLightness } from '../core/constraints/monotonic-lightness.js';
|
|
14
|
+
import { parseCssColor, compositeOver, relativeLuminance, contrastRatio, } from '../core/color.js';
|
|
15
|
+
/** Thrown for caller-facing problems (bad input, unsupported rule). The MCP
|
|
16
|
+
* handler maps `.code` onto a structured tool error. */
|
|
17
|
+
export class InsightError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Flatten discovered constraint sources into a stable, agent-friendly list. */
|
|
25
|
+
export function describeConstraints(sources) {
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const s of sources) {
|
|
28
|
+
switch (s.type) {
|
|
29
|
+
case 'builtin-wcag':
|
|
30
|
+
if (s.enabled) {
|
|
31
|
+
for (const p of DEFAULT_WCAG_PAIRS) {
|
|
32
|
+
out.push({ kind: 'wcag', source: 'builtin', foreground: p.fg, background: p.bg, minRatio: p.min, ...(p.backdrop ? { backdrop: p.backdrop } : {}), ...(p.where ? { where: p.where } : {}) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
break;
|
|
36
|
+
case 'builtin-threshold':
|
|
37
|
+
if (s.enabled) {
|
|
38
|
+
for (const t of DEFAULT_THRESHOLDS) {
|
|
39
|
+
out.push({ kind: 'threshold', source: 'builtin', tokenId: t.id, op: t.op, valuePx: t.valuePx, ...(t.where ? { where: t.where } : {}) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case 'config-wcag':
|
|
44
|
+
for (const r of s.rules) {
|
|
45
|
+
out.push({ kind: 'wcag', source: 'config', foreground: r.fg, background: r.bg, minRatio: r.min, ...(r.backdrop ? { backdrop: r.backdrop } : {}), ...(r.where ? { where: r.where } : {}) });
|
|
46
|
+
}
|
|
47
|
+
break;
|
|
48
|
+
case 'custom-threshold':
|
|
49
|
+
for (const r of s.rules) {
|
|
50
|
+
out.push({ kind: 'threshold', source: 'config', tokenId: r.id, op: r.op, valuePx: r.valuePx, ...(r.where ? { where: r.where } : {}) });
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
case 'order-file':
|
|
54
|
+
out.push({ kind: 'order', source: 'file', axis: s.axis, path: s.path, orders: s.orders });
|
|
55
|
+
break;
|
|
56
|
+
case 'lightness-file':
|
|
57
|
+
out.push({ kind: 'lightness', source: 'file', path: s.path, orders: s.orders });
|
|
58
|
+
break;
|
|
59
|
+
case 'cross-axis-file':
|
|
60
|
+
out.push({ kind: 'cross-axis', source: 'file', path: s.path, ...(s.bp ? { breakpoint: s.bp } : {}) });
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function classifyRule(ruleId) {
|
|
67
|
+
switch (ruleId) {
|
|
68
|
+
case 'wcag-contrast':
|
|
69
|
+
return 'wcag';
|
|
70
|
+
case 'threshold':
|
|
71
|
+
case 'custom-threshold':
|
|
72
|
+
return 'threshold';
|
|
73
|
+
case 'monotonic':
|
|
74
|
+
return 'monotonic';
|
|
75
|
+
case 'monotonic-lightness':
|
|
76
|
+
return 'monotonic-lightness';
|
|
77
|
+
default:
|
|
78
|
+
throw new InsightError('unsupported_rule', `explain/suggest-fix does not support rule "${ruleId}" (supported: wcag-contrast, threshold, custom-threshold, monotonic, monotonic-lightness).`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** A monotonic issue id is `a|b`; a caller may also pass [a, b] directly. */
|
|
82
|
+
function orderPair(nodes) {
|
|
83
|
+
if (nodes.length === 1 && nodes[0].includes('|')) {
|
|
84
|
+
const parts = nodes[0].split('|');
|
|
85
|
+
if (parts.length !== 2) {
|
|
86
|
+
throw new InsightError('invalid_input', `A monotonic node id must be "a|b" (got ${parts.length} segments in "${nodes[0]}").`);
|
|
87
|
+
}
|
|
88
|
+
return [parts[0], parts[1]];
|
|
89
|
+
}
|
|
90
|
+
if (nodes.length === 2)
|
|
91
|
+
return [nodes[0], nodes[1]];
|
|
92
|
+
throw new InsightError('invalid_input', 'A monotonic violation needs exactly two token ids (nodes: [a, b] or ["a|b"]).');
|
|
93
|
+
}
|
|
94
|
+
function findOrderOp(descriptors, kind, a, b) {
|
|
95
|
+
for (const d of descriptors) {
|
|
96
|
+
if (d.kind !== kind)
|
|
97
|
+
continue;
|
|
98
|
+
for (const [x, op, y] of d.orders) {
|
|
99
|
+
if (x === a && y === b)
|
|
100
|
+
return op;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
// Mirrors the px-default parser in core/constraints/threshold.ts (incl. `em` and
|
|
106
|
+
// the real-number guard) so `explain` agrees with the validator (TASK-034).
|
|
107
|
+
function parseThresholdPx(v) {
|
|
108
|
+
const m = v.trim().match(/^(\d*\.?\d+)\s*(px|rem|em)?$/i);
|
|
109
|
+
if (!m)
|
|
110
|
+
return null;
|
|
111
|
+
const n = parseFloat(m[1]);
|
|
112
|
+
if (!Number.isFinite(n))
|
|
113
|
+
return null;
|
|
114
|
+
const unit = (m[2] || 'px').toLowerCase();
|
|
115
|
+
return unit === 'rem' || unit === 'em' ? n * 16 : n;
|
|
116
|
+
}
|
|
117
|
+
function round2(n) {
|
|
118
|
+
return Number(n.toFixed(2));
|
|
119
|
+
}
|
|
120
|
+
function toHex(c) {
|
|
121
|
+
const h = (n) => Math.round(n).toString(16).padStart(2, '0');
|
|
122
|
+
return `#${h(c.r)}${h(c.g)}${h(c.b)}`;
|
|
123
|
+
}
|
|
124
|
+
/** Effective (alpha-composited) contrast for a fg/bg/backdrop triplet, or null
|
|
125
|
+
* when any color is unparseable. Same pipeline as WcagContrastPlugin. */
|
|
126
|
+
function effectiveContrast(fgVal, bgVal, backdropVal) {
|
|
127
|
+
const fg = parseCssColor(fgVal);
|
|
128
|
+
const bg = parseCssColor(bgVal);
|
|
129
|
+
const backdrop = parseCssColor(backdropVal || '#ffffff');
|
|
130
|
+
if (!fg || !bg || !backdrop)
|
|
131
|
+
return null;
|
|
132
|
+
const effBg = bg.a < 1 ? compositeOver(bg, backdrop) : bg;
|
|
133
|
+
const effFg = fg.a < 1 ? compositeOver(fg, effBg) : fg;
|
|
134
|
+
return { ratio: contrastRatio(relativeLuminance(effFg), relativeLuminance(effBg)), effFg, effBg };
|
|
135
|
+
}
|
|
136
|
+
export function explain(req) {
|
|
137
|
+
const kind = classifyRule(req.ruleId);
|
|
138
|
+
const { getValue, descriptors, context } = req;
|
|
139
|
+
const whereSuffix = (where) => (where ? ` (${where})` : '');
|
|
140
|
+
if (kind === 'wcag') {
|
|
141
|
+
if (req.nodes.length < 2) {
|
|
142
|
+
throw new InsightError('invalid_input', 'A WCAG violation needs nodes: [foreground, background].');
|
|
143
|
+
}
|
|
144
|
+
const [fg, bg] = req.nodes;
|
|
145
|
+
const rule = descriptors.find((d) => d.kind === 'wcag' && d.foreground === fg && d.background === bg);
|
|
146
|
+
const required = rule?.minRatio ?? (typeof context?.required === 'number' ? context.required : undefined);
|
|
147
|
+
if (required === undefined) {
|
|
148
|
+
throw new InsightError('invalid_input', `No active WCAG rule found for ${fg} on ${bg}, and no required ratio in context.`);
|
|
149
|
+
}
|
|
150
|
+
const fgVal = getValue(fg);
|
|
151
|
+
const bgVal = getValue(bg);
|
|
152
|
+
const backdropVal = getValue(rule?.backdrop ?? '#ffffff');
|
|
153
|
+
const computed = effectiveContrast(fgVal, bgVal, backdropVal);
|
|
154
|
+
const where = rule?.where;
|
|
155
|
+
const actual = computed ? round2(computed.ratio) : null;
|
|
156
|
+
const facts = {
|
|
157
|
+
foreground: fg,
|
|
158
|
+
background: bg,
|
|
159
|
+
foregroundValue: fgVal,
|
|
160
|
+
backgroundValue: bgVal,
|
|
161
|
+
backdrop: rule?.backdrop ?? '#ffffff',
|
|
162
|
+
requiredRatio: required,
|
|
163
|
+
actualRatio: actual,
|
|
164
|
+
parseable: computed !== null,
|
|
165
|
+
...(where ? { where } : {}),
|
|
166
|
+
};
|
|
167
|
+
const explanation = computed
|
|
168
|
+
? `Contrast of ${fg} (${fgVal}) on ${bg} (${bgVal}) is ${actual}:1, ${computed.ratio < required ? 'below' : 'at or above'} the required ${required}:1${whereSuffix(where)}.`
|
|
169
|
+
: `Could not compute contrast for ${fg} (${fgVal}) on ${bg} (${bgVal}) — one or more colors are unparseable.`;
|
|
170
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [fg, bg], facts, explanation };
|
|
171
|
+
}
|
|
172
|
+
if (kind === 'threshold') {
|
|
173
|
+
const id = req.nodes[0];
|
|
174
|
+
if (!id)
|
|
175
|
+
throw new InsightError('invalid_input', 'A threshold violation needs nodes: [tokenId].');
|
|
176
|
+
const rule = descriptors.find((d) => d.kind === 'threshold' && d.tokenId === id);
|
|
177
|
+
const op = rule?.op ?? context?.op;
|
|
178
|
+
const valuePx = rule?.valuePx ?? (typeof context?.threshold === 'number' ? context.threshold : undefined);
|
|
179
|
+
if (op === undefined || valuePx === undefined) {
|
|
180
|
+
throw new InsightError('invalid_input', `No active threshold rule found for ${id}, and no op/threshold in context.`);
|
|
181
|
+
}
|
|
182
|
+
const val = getValue(id);
|
|
183
|
+
const actualPx = parseThresholdPx(val);
|
|
184
|
+
const satisfied = actualPx === null ? null : op === '>=' ? actualPx >= valuePx : actualPx <= valuePx;
|
|
185
|
+
const where = rule?.where;
|
|
186
|
+
const facts = { tokenId: id, value: val, actualPx, op, requiredPx: valuePx, satisfied, ...(where ? { where } : {}) };
|
|
187
|
+
const explanation = actualPx === null
|
|
188
|
+
? `${id} is "${val}", which is not a parseable px/rem size; it must be ${op} ${valuePx}px${whereSuffix(where)}.`
|
|
189
|
+
: `${id} is ${actualPx}px, but must be ${op} ${valuePx}px${whereSuffix(where)}.`;
|
|
190
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [id], facts, explanation };
|
|
191
|
+
}
|
|
192
|
+
// monotonic (size) or monotonic-lightness (relative luminance)
|
|
193
|
+
const [a, b] = orderPair(req.nodes);
|
|
194
|
+
const isLightness = kind === 'monotonic-lightness';
|
|
195
|
+
const op = findOrderOp(descriptors, isLightness ? 'lightness' : 'order', a, b);
|
|
196
|
+
if (op === undefined) {
|
|
197
|
+
throw new InsightError('invalid_input', `No active ${isLightness ? 'lightness' : 'order'} constraint found for ${a} ${'<=/>='} ${b}.`);
|
|
198
|
+
}
|
|
199
|
+
const aVal = getValue(a);
|
|
200
|
+
const bVal = getValue(b);
|
|
201
|
+
const parse = isLightness ? parseLightness : parseSize;
|
|
202
|
+
const na = parse(aVal);
|
|
203
|
+
const nb = parse(bVal);
|
|
204
|
+
const unit = isLightness ? 'luminance' : 'px';
|
|
205
|
+
// Actually compare — explain accepts a loose {ruleId, nodes} pair, so the order
|
|
206
|
+
// may hold; don't unconditionally claim a violation (TASK-032).
|
|
207
|
+
const comparable = na !== null && nb !== null;
|
|
208
|
+
const satisfied = comparable ? (op === '>=' ? na >= nb : na <= nb) : null;
|
|
209
|
+
const facts = {
|
|
210
|
+
left: a,
|
|
211
|
+
right: b,
|
|
212
|
+
leftValue: aVal,
|
|
213
|
+
rightValue: bVal,
|
|
214
|
+
op,
|
|
215
|
+
unit,
|
|
216
|
+
leftMeasure: na,
|
|
217
|
+
rightMeasure: nb,
|
|
218
|
+
satisfied,
|
|
219
|
+
};
|
|
220
|
+
const explanation = !comparable
|
|
221
|
+
? `${a} (${aVal}) ${op} ${b} (${bVal}) can't be evaluated — one or both values aren't parseable as ${unit}.`
|
|
222
|
+
: satisfied
|
|
223
|
+
? `${a} (${aVal}) ${op} ${b} (${bVal}) by ${unit} — the order holds.`
|
|
224
|
+
: `${a} (${aVal}) must be ${op} ${b} (${bVal}) by ${unit}, but the order is violated — the scale is out of order.`;
|
|
225
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [a, b], facts, explanation };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Find the smallest change to `color` (toward white or black) whose contrast
|
|
229
|
+
* against a fixed luminance reaches `min`. Returns an opaque, integer-channel
|
|
230
|
+
* RGBA already verified to clear `min`, or null when neither endpoint reaches it.
|
|
231
|
+
*
|
|
232
|
+
* We scan in fine steps rather than binary-search because contrast is NOT
|
|
233
|
+
* monotonic along the blend when `otherLum` falls between the start and the
|
|
234
|
+
* target (it dips to 1:1 as the colors cross). Scanning from the original
|
|
235
|
+
* outward returns the first — i.e. minimal — integer color that genuinely
|
|
236
|
+
* clears the ratio; we keep the closer of the two directions.
|
|
237
|
+
*/
|
|
238
|
+
function pushToContrast(color, otherLum, min) {
|
|
239
|
+
const white = { r: 255, g: 255, b: 255, a: 1 };
|
|
240
|
+
const black = { r: 0, g: 0, b: 0, a: 1 };
|
|
241
|
+
const STEPS = 256;
|
|
242
|
+
const blendSnap = (target, t) => ({
|
|
243
|
+
r: Math.round(color.r + (target.r - color.r) * t),
|
|
244
|
+
g: Math.round(color.g + (target.g - color.g) * t),
|
|
245
|
+
b: Math.round(color.b + (target.b - color.b) * t),
|
|
246
|
+
a: 1,
|
|
247
|
+
});
|
|
248
|
+
const ratioAt = (cand) => contrastRatio(relativeLuminance(cand), otherLum);
|
|
249
|
+
const dist = (c) => (c.r - color.r) ** 2 + (c.g - color.g) ** 2 + (c.b - color.b) ** 2;
|
|
250
|
+
let best = null;
|
|
251
|
+
let bestDist = Infinity;
|
|
252
|
+
for (const target of [white, black]) {
|
|
253
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
254
|
+
const cand = blendSnap(target, i / STEPS);
|
|
255
|
+
if (ratioAt(cand) >= min) {
|
|
256
|
+
const d = dist(cand);
|
|
257
|
+
if (d < bestDist) {
|
|
258
|
+
bestDist = d;
|
|
259
|
+
best = cand;
|
|
260
|
+
}
|
|
261
|
+
break; // first satisfying step in a direction is that direction's minimal change
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return best;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Find the smallest change to an (opaque) background that clears `min` against
|
|
269
|
+
* `fg`. Unlike a foreground tweak, changing the background also changes the
|
|
270
|
+
* EFFECTIVE foreground when `fg` is semi-transparent (it composites over the new
|
|
271
|
+
* background), so the ratio is recomputed through the full pipeline for every
|
|
272
|
+
* candidate — never against a stale precomputed foreground. Returns a verified
|
|
273
|
+
* opaque RGBA, or null when no candidate reaches `min`.
|
|
274
|
+
*/
|
|
275
|
+
function pushBackgroundToContrast(fg, bgStart, min) {
|
|
276
|
+
const white = { r: 255, g: 255, b: 255, a: 1 };
|
|
277
|
+
const black = { r: 0, g: 0, b: 0, a: 1 };
|
|
278
|
+
const STEPS = 256;
|
|
279
|
+
const blendSnap = (target, t) => ({
|
|
280
|
+
r: Math.round(bgStart.r + (target.r - bgStart.r) * t),
|
|
281
|
+
g: Math.round(bgStart.g + (target.g - bgStart.g) * t),
|
|
282
|
+
b: Math.round(bgStart.b + (target.b - bgStart.b) * t),
|
|
283
|
+
a: 1,
|
|
284
|
+
});
|
|
285
|
+
const trueRatio = (bgCand) => {
|
|
286
|
+
const effFg = fg.a < 1 ? compositeOver(fg, bgCand) : fg;
|
|
287
|
+
return contrastRatio(relativeLuminance(effFg), relativeLuminance(bgCand));
|
|
288
|
+
};
|
|
289
|
+
const dist = (c) => (c.r - bgStart.r) ** 2 + (c.g - bgStart.g) ** 2 + (c.b - bgStart.b) ** 2;
|
|
290
|
+
let best = null;
|
|
291
|
+
let bestDist = Infinity;
|
|
292
|
+
for (const target of [white, black]) {
|
|
293
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
294
|
+
const cand = blendSnap(target, i / STEPS);
|
|
295
|
+
if (trueRatio(cand) >= min) {
|
|
296
|
+
const d = dist(cand);
|
|
297
|
+
if (d < bestDist) {
|
|
298
|
+
bestDist = d;
|
|
299
|
+
best = cand;
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return best;
|
|
306
|
+
}
|
|
307
|
+
export function suggestFix(req) {
|
|
308
|
+
const kind = classifyRule(req.ruleId);
|
|
309
|
+
const { getValue, descriptors, context } = req;
|
|
310
|
+
if (kind === 'wcag') {
|
|
311
|
+
if (req.nodes.length < 2) {
|
|
312
|
+
throw new InsightError('invalid_input', 'A WCAG violation needs nodes: [foreground, background].');
|
|
313
|
+
}
|
|
314
|
+
const [fg, bg] = req.nodes;
|
|
315
|
+
const rule = descriptors.find((d) => d.kind === 'wcag' && d.foreground === fg && d.background === bg);
|
|
316
|
+
const min = rule?.minRatio ?? (typeof context?.required === 'number' ? context.required : undefined);
|
|
317
|
+
if (min === undefined) {
|
|
318
|
+
throw new InsightError('invalid_input', `No active WCAG rule found for ${fg} on ${bg}, and no required ratio in context.`);
|
|
319
|
+
}
|
|
320
|
+
const fgVal = getValue(fg);
|
|
321
|
+
const bgVal = getValue(bg);
|
|
322
|
+
const backdropVal = getValue(rule?.backdrop ?? '#ffffff');
|
|
323
|
+
const fgColor = parseCssColor(fgVal);
|
|
324
|
+
const bgColor = parseCssColor(bgVal);
|
|
325
|
+
const backdrop = parseCssColor(backdropVal || '#ffffff');
|
|
326
|
+
if (!fgColor || !bgColor || !backdrop) {
|
|
327
|
+
throw new InsightError('invalid_input', `Cannot suggest a fix: unparseable color(s) — foreground "${fgVal}", background "${bgVal}".`);
|
|
328
|
+
}
|
|
329
|
+
const effBg = bgColor.a < 1 ? compositeOver(bgColor, backdrop) : bgColor;
|
|
330
|
+
const effFg = fgColor.a < 1 ? compositeOver(fgColor, effBg) : fgColor;
|
|
331
|
+
const sides = req.target ? [req.target] : ['foreground', 'background'];
|
|
332
|
+
const suggestions = [];
|
|
333
|
+
for (const side of sides) {
|
|
334
|
+
if (side === 'foreground') {
|
|
335
|
+
const adj = pushToContrast(effFg, relativeLuminance(effBg), min);
|
|
336
|
+
if (adj) {
|
|
337
|
+
const ratio = contrastRatio(relativeLuminance(adj), relativeLuminance(effBg));
|
|
338
|
+
if (ratio >= min) {
|
|
339
|
+
suggestions.push({
|
|
340
|
+
tokenId: fg,
|
|
341
|
+
role: 'foreground',
|
|
342
|
+
currentValue: fgVal,
|
|
343
|
+
suggestedValue: toHex(adj),
|
|
344
|
+
resultingValue: round2(ratio),
|
|
345
|
+
satisfies: `wcag-contrast >= ${min}:1`,
|
|
346
|
+
why: 'Foreground lightness adjusted (opaque) until contrast clears the ratio; verified with WCAG contrast math.',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
// Recompute the effective foreground over the candidate background: when
|
|
353
|
+
// the foreground has alpha, changing the background changes the composited
|
|
354
|
+
// foreground too, so a fixed precomputed effFg would over-report contrast.
|
|
355
|
+
const adj = pushBackgroundToContrast(fgColor, effBg, min);
|
|
356
|
+
if (adj) {
|
|
357
|
+
const effFgOverAdj = fgColor.a < 1 ? compositeOver(fgColor, adj) : fgColor;
|
|
358
|
+
const ratio = contrastRatio(relativeLuminance(effFgOverAdj), relativeLuminance(adj));
|
|
359
|
+
if (ratio >= min) {
|
|
360
|
+
suggestions.push({
|
|
361
|
+
tokenId: bg,
|
|
362
|
+
role: 'background',
|
|
363
|
+
currentValue: bgVal,
|
|
364
|
+
suggestedValue: toHex(adj),
|
|
365
|
+
resultingValue: round2(ratio),
|
|
366
|
+
satisfies: `wcag-contrast >= ${min}:1`,
|
|
367
|
+
why: 'Background lightness adjusted (opaque) until contrast clears the ratio; verified against the recomposited foreground.',
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const note = suggestions.length === 0
|
|
374
|
+
? `No sRGB ${req.target ?? 'foreground or background'} color reaches ${min}:1 against the other color; widen the lightness separation or change the backdrop.`
|
|
375
|
+
: undefined;
|
|
376
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [fg, bg], suggestions, ...(note ? { note } : {}) };
|
|
377
|
+
}
|
|
378
|
+
if (kind === 'threshold') {
|
|
379
|
+
const id = req.nodes[0];
|
|
380
|
+
if (!id)
|
|
381
|
+
throw new InsightError('invalid_input', 'A threshold violation needs nodes: [tokenId].');
|
|
382
|
+
const rule = descriptors.find((d) => d.kind === 'threshold' && d.tokenId === id);
|
|
383
|
+
const op = rule?.op ?? context?.op;
|
|
384
|
+
const valuePx = rule?.valuePx ?? (typeof context?.threshold === 'number' ? context.threshold : undefined);
|
|
385
|
+
if (op === undefined || valuePx === undefined) {
|
|
386
|
+
throw new InsightError('invalid_input', `No active threshold rule found for ${id}, and no op/threshold in context.`);
|
|
387
|
+
}
|
|
388
|
+
const current = getValue(id);
|
|
389
|
+
const suggestion = {
|
|
390
|
+
tokenId: id,
|
|
391
|
+
role: op === '>=' ? 'raise-to-min' : 'lower-to-max',
|
|
392
|
+
currentValue: current,
|
|
393
|
+
suggestedValue: `${valuePx}px`,
|
|
394
|
+
resultingValue: valuePx,
|
|
395
|
+
satisfies: `${id} ${op} ${valuePx}px`,
|
|
396
|
+
why: op === '>=' ? `Set to the ${valuePx}px minimum (the boundary that satisfies ${op}).` : `Set to the ${valuePx}px maximum (the boundary that satisfies ${op}).`,
|
|
397
|
+
};
|
|
398
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [id], suggestions: [suggestion] };
|
|
399
|
+
}
|
|
400
|
+
if (kind === 'monotonic-lightness') {
|
|
401
|
+
throw new InsightError('unsupported_rule', 'suggest-fix does not synthesize colors for lightness ordering; adjust the out-of-order token\'s lightness manually (use explain to see the luminance gap).');
|
|
402
|
+
}
|
|
403
|
+
// monotonic (size): a op b violated → raise the low side or lower the high side
|
|
404
|
+
// to the boundary. Both candidates satisfy the order at equality.
|
|
405
|
+
const [a, b] = orderPair(req.nodes);
|
|
406
|
+
const op = findOrderOp(descriptors, 'order', a, b);
|
|
407
|
+
if (op === undefined) {
|
|
408
|
+
throw new InsightError('invalid_input', `No active order constraint found for ${a} .. ${b}.`);
|
|
409
|
+
}
|
|
410
|
+
const aVal = getValue(a);
|
|
411
|
+
const bVal = getValue(b);
|
|
412
|
+
const na = parseSize(aVal);
|
|
413
|
+
const nb = parseSize(bVal);
|
|
414
|
+
if (na === null || nb === null) {
|
|
415
|
+
throw new InsightError('invalid_input', `Cannot compute a numeric boundary: ${a}="${aVal}" or ${b}="${bVal}" is not a parseable size.`);
|
|
416
|
+
}
|
|
417
|
+
// The order holds at equality regardless of `op`, so the boundary is the same
|
|
418
|
+
// either way: move `a` to b's value, or move `b` to a's value. (The earlier
|
|
419
|
+
// op-conditional made the `<=` case suggest each token's own value — a no-op.)
|
|
420
|
+
// Role reflects the real direction of the move for the reported value.
|
|
421
|
+
const aTarget = round2(nb);
|
|
422
|
+
const bTarget = round2(na);
|
|
423
|
+
const direction = (from, to) => (to > from ? 'raise' : to < from ? 'lower' : 'keep');
|
|
424
|
+
const suggestions = [
|
|
425
|
+
{
|
|
426
|
+
tokenId: a,
|
|
427
|
+
role: direction(na, aTarget),
|
|
428
|
+
currentValue: aVal,
|
|
429
|
+
suggestedValue: `${aTarget}px`,
|
|
430
|
+
resultingValue: aTarget,
|
|
431
|
+
satisfies: `${a} ${op} ${b}`,
|
|
432
|
+
why: `Set ${a} to ${b}'s value (${aTarget}px) so the order holds at equality.`,
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
tokenId: b,
|
|
436
|
+
role: direction(nb, bTarget),
|
|
437
|
+
currentValue: bVal,
|
|
438
|
+
suggestedValue: `${bTarget}px`,
|
|
439
|
+
resultingValue: bTarget,
|
|
440
|
+
satisfies: `${a} ${op} ${b}`,
|
|
441
|
+
why: `Set ${b} to ${a}'s value (${bTarget}px) so the order holds at equality.`,
|
|
442
|
+
},
|
|
443
|
+
];
|
|
444
|
+
return { ok: true, ruleId: req.ruleId, kind, nodes: [a, b], suggestions };
|
|
445
|
+
}
|