@designesy/score 0.7.2 → 0.8.1
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 +1 -1
- package/dist/engine.d.ts +1 -1
- package/dist/engine.js +326 -97
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @designesy/score
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Design quality gate for AI-generated UI: score any URL on 44 deterministic checks, A–F grade. This is the standalone engine: it fetches a URL, extracts CSS + `:root` tokens, and runs every check that needs no browser locally, with **no Designesy server required**; the browser checks return MANUAL. No required dependencies.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
package/dist/engine.d.ts
CHANGED
|
@@ -159,7 +159,7 @@ export declare function scoreFromParts(input: ScorePartsInput): Promise<ScoreRes
|
|
|
159
159
|
* the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
|
|
160
160
|
* contract source and fails when the two differ.
|
|
161
161
|
*/
|
|
162
|
-
export declare const CONTRACT_VERSION = "v0.4.
|
|
162
|
+
export declare const CONTRACT_VERSION = "v0.4.5";
|
|
163
163
|
/**
|
|
164
164
|
* The one verdict rule, shared by every emission format.
|
|
165
165
|
*
|
package/dist/engine.js
CHANGED
|
@@ -209,7 +209,21 @@ const BROWSER_HEADERS = {
|
|
|
209
209
|
'Sec-Fetch-User': '?1',
|
|
210
210
|
'Upgrade-Insecure-Requests': '1',
|
|
211
211
|
};
|
|
212
|
-
|
|
212
|
+
/** Redirects httpsFetch follows before it gives up (engine 1.3.1). */
|
|
213
|
+
const MAX_REDIRECT_HOPS = 3;
|
|
214
|
+
/**
|
|
215
|
+
* GET a URL, following each 3xx with a Location by hand. Every redirect target
|
|
216
|
+
* is re-validated with isValidUrl first (the SSRF guard), and at most
|
|
217
|
+
* MAX_REDIRECT_HOPS redirects are followed. Until engine 1.3.1 the comment said
|
|
218
|
+
* "Follow up to 3 redirects" but nothing counted them, so a redirect loop
|
|
219
|
+
* recursed until the process gave out. A redirect past the limit fails closed,
|
|
220
|
+
* the same way a refused target does. A response reached through at least one
|
|
221
|
+
* redirect reports redirected: true and the final hop's URL as finalUrl; before
|
|
222
|
+
* 1.3.1 redirected was true only when the LAST response was itself a 3xx, so a
|
|
223
|
+
* followed redirect read redirected: false. `hop` counts the redirects already
|
|
224
|
+
* followed; callers leave it at 0.
|
|
225
|
+
*/
|
|
226
|
+
function httpsFetch(url, headers, timeoutMs = 8000, hop = 0) {
|
|
213
227
|
return new Promise((resolve) => {
|
|
214
228
|
let resolved = false;
|
|
215
229
|
const done = (r) => {
|
|
@@ -220,18 +234,19 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
|
|
|
220
234
|
};
|
|
221
235
|
try {
|
|
222
236
|
const req = httpsGet(url, { headers: { ...BROWSER_HEADERS, ...(headers || {}) }, timeout: timeoutMs, lookup: safeLookup }, (res) => {
|
|
223
|
-
// Follow
|
|
237
|
+
// Follow a redirect manually (3xx → Location)
|
|
224
238
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
225
239
|
try {
|
|
226
240
|
const next = new URL(res.headers.location, url).href;
|
|
227
241
|
res.resume();
|
|
228
|
-
// SSRF guard: re-validate every redirect target before following
|
|
229
|
-
|
|
242
|
+
// SSRF guard: re-validate every redirect target before following
|
|
243
|
+
// it. Hop limit: a chain longer than MAX_REDIRECT_HOPS (a loop
|
|
244
|
+
// among them) fails closed the same way.
|
|
245
|
+
if (!isValidUrl(next) || hop >= MAX_REDIRECT_HOPS) {
|
|
230
246
|
done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url });
|
|
231
247
|
return;
|
|
232
248
|
}
|
|
233
|
-
|
|
234
|
-
httpsFetch(next, headers, timeoutMs).then(done);
|
|
249
|
+
httpsFetch(next, headers, timeoutMs, hop + 1).then(done);
|
|
235
250
|
return;
|
|
236
251
|
}
|
|
237
252
|
catch { /* fall through */ }
|
|
@@ -240,7 +255,7 @@ function httpsFetch(url, headers, timeoutMs = 8000) {
|
|
|
240
255
|
res.setEncoding('utf-8');
|
|
241
256
|
res.on('data', (chunk) => { body += chunk; });
|
|
242
257
|
res.on('end', () => {
|
|
243
|
-
done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected:
|
|
258
|
+
done({ ok: (res.statusCode || 0) < 400, status: res.statusCode || 0, text: body, redirected: hop > 0, finalUrl: url });
|
|
244
259
|
});
|
|
245
260
|
res.on('error', () => done({ ok: false, status: 0, text: '', redirected: false, finalUrl: url }));
|
|
246
261
|
});
|
|
@@ -312,7 +327,9 @@ async function fetchPageResilient(targetUrl) {
|
|
|
312
327
|
function extractRootTokens(css) {
|
|
313
328
|
const stripped = css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '');
|
|
314
329
|
const tokens = {};
|
|
315
|
-
|
|
330
|
+
// `:root, :host { ... }` is the block Tailwind v4 writes its theme into;
|
|
331
|
+
// until engine 1.3.0 only `:root {` was read, so those tokens were missed.
|
|
332
|
+
const rootRe = /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g;
|
|
316
333
|
let m;
|
|
317
334
|
while ((m = rootRe.exec(stripped)) !== null) {
|
|
318
335
|
const block = m[1];
|
|
@@ -360,22 +377,6 @@ function inferTokensFromCss(css, tokens) {
|
|
|
360
377
|
return result;
|
|
361
378
|
}
|
|
362
379
|
// ── Color utilities ──────────────────────────────────────────────────────────
|
|
363
|
-
function hexToRgb(color) {
|
|
364
|
-
color = color.trim();
|
|
365
|
-
if (!color.startsWith('#'))
|
|
366
|
-
return null;
|
|
367
|
-
let hex = color.slice(1);
|
|
368
|
-
if (hex.length === 3)
|
|
369
|
-
hex = hex.split('').map((c) => c + c).join('');
|
|
370
|
-
if (hex.length !== 6)
|
|
371
|
-
return null;
|
|
372
|
-
const r = parseInt(hex.slice(0, 2), 16);
|
|
373
|
-
const g = parseInt(hex.slice(2, 4), 16);
|
|
374
|
-
const b = parseInt(hex.slice(4, 6), 16);
|
|
375
|
-
if (isNaN(r) || isNaN(g) || isNaN(b))
|
|
376
|
-
return null;
|
|
377
|
-
return [r, g, b];
|
|
378
|
-
}
|
|
379
380
|
function srgbChannelToLinear(c) {
|
|
380
381
|
const s = c / 255;
|
|
381
382
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
@@ -388,23 +389,32 @@ function contrastRatio(a, b) {
|
|
|
388
389
|
const lb = relativeLuminance(b);
|
|
389
390
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
390
391
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
/**
|
|
393
|
+
* A token value as the opaque sRGB colour v06 and v22 measure.
|
|
394
|
+
*
|
|
395
|
+
* Read through the CSS Color 4 parser v44 uses (resolveThemeColor): var()
|
|
396
|
+
* chains with fallbacks, hex of 3, 4, 6 or 8 digits, rgb() and hsl() in comma
|
|
397
|
+
* or space syntax, oklch(), oklab(), color(srgb), color-mix(), light-dark()
|
|
398
|
+
* (its light side: these two checks read one theme) and bare HSL channels.
|
|
399
|
+
* Until engine 1.3.0 they read only 3- and 6-digit hex and comma rgb(), with
|
|
400
|
+
* alpha ignored, so a stylesheet written in oklch() (Tailwind v4, shadcn)
|
|
401
|
+
* SKIPped both checks and a page with illegible text scored 100.
|
|
402
|
+
*
|
|
403
|
+
* A translucent colour is composited over `under` as a browser paints it;
|
|
404
|
+
* with nothing under it, it is not measured.
|
|
405
|
+
*/
|
|
406
|
+
function resolveTokenColor(value, tokens, under) {
|
|
407
|
+
if (!value.trim())
|
|
394
408
|
return null;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const varMatch = v.match(/^var\(\s*(--[\w-]+)/);
|
|
398
|
-
if (varMatch) {
|
|
399
|
-
const ref = tokens[varMatch[1]];
|
|
400
|
-
if (ref)
|
|
401
|
-
return resolveColor(ref, tokens);
|
|
409
|
+
const c = resolveThemeColor(value, [tokens], false);
|
|
410
|
+
if (!c)
|
|
402
411
|
return null;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
return
|
|
407
|
-
|
|
412
|
+
if (c[3] >= 1)
|
|
413
|
+
return [c[0], c[1], c[2]];
|
|
414
|
+
if (!under)
|
|
415
|
+
return null;
|
|
416
|
+
const painted = v44Composite(c, [under[0], under[1], under[2], 1]);
|
|
417
|
+
return [painted[0], painted[1], painted[2]];
|
|
408
418
|
}
|
|
409
419
|
// APCA-W3 0.0.98G-4g
|
|
410
420
|
function sRGBtoY_APCA(rgb) {
|
|
@@ -471,7 +481,7 @@ const REMEDIATION = {
|
|
|
471
481
|
v43: 'Express UI states as semantic color roles: --ok/--success (verification pass), --warn/--warning (caution), --error/--danger (failure), --info (notice). The contract ships --ok, --warn, and --error for exactly this. Status colors named by state let components consume meaning (a score badge, a form error, and a toast all read the same token) and stay legible when the palette evolves.',
|
|
472
482
|
v44: 'Give each status hue a text token for words and keep the hue itself for marks. A color that marks a state (a dot, a bar, a tint) needs 3:1, while the word for that state needs 4.5:1 (3:1 at 24px, or 18.66px bold) in every theme the site declares. Mix the hue toward your ink for text, for example --warn-ink: color-mix(in oklab, var(--warn) 70%, var(--ink)), measure it on the page and on each surface it sits on in every theme, and paint status words with it.',
|
|
473
483
|
v45: 'Let every entrance that starts invisible run to its end when motion is paused. Under the pause rule, give one-shot entrances whose first keyframe is opacity 0 a negative delay longer than the animation, for example html[data-motion="paused"] :is(.fade-up, .reveal) { animation-delay: -3600s !important; }, or remove them with animation: none. Loops can stay held. Test by pausing motion and reloading: every page should still show its content.',
|
|
474
|
-
v34: '
|
|
484
|
+
v34: 'If the page hosts an AI chatbot or agent, tell people they are talking to an AI no later than their first interaction, in a form assistive technology announces. EU AI Act Article 50(1), which applies from 2026-08-02, places this duty on the provider of the AI system (the organisation that develops it and places it on the market or puts it into service under its own name); it does not apply where the AI nature is obvious from the context, and Article 50(5) asks for the information to be clear and accessible. The check reads any one of: visible "AI assistant" or "Chatbot" text, aria-label="AI assistant" on the chat container, or a data-ai-disclosure attribute on the widget. A live chat answered by people needs no AI disclosure: if the check flagged one for review, confirm who answers. US laws differ in scope: California\'s bot-disclosure law (SB 1001, Bus. & Prof. Code §§17940-17943) covers bots that mislead people about being bots to drive a sale or influence a vote; Colorado SB 24-205 was repealed and re-enacted by SB 26-189 (signed 2026-05-14, obligations from 2027-01-01), which governs automated decision-making in consequential decisions. This is not legal advice.',
|
|
475
485
|
v35: 'Add a forced-colors readiness block: @media (forced-colors: active) { ... } with forced-color-adjust: none on elements that must preserve brand identity (logos, charts, semantic-color indicators). Windows High Contrast Mode and Chrome forced-colors recolor the page: without this media query, critical UI becomes illegible. Also ensure borders/outlines use currentColor or system colors so they adapt. Test with Windows HCM (Settings > Accessibility > Contrast themes).',
|
|
476
486
|
v36: 'Remove UTS #39 confusable characters from CSS identifiers and token names. Confusables are Unicode characters from different scripts (Cyrillic, Greek, fullwidth) that look identical to ASCII letters; Cyrillic а (U+0430), for example, looks like Latin a (U+0061). In token names they enable shadowing attacks (--соlor-bg with Cyrillic с vs --color-bg). Audit all custom property names, class names, and url() paths for non-ASCII characters using a Unicode confusable detector. Provenance: Unicode Technical Standard #39, Unicode 16.0.0. Designesy is the only design verification engine that checks this surface.',
|
|
477
487
|
v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly. Google\'s @google/design.md CLI (v0.4.0, Apache-2.0) validates the file format: 11 lint rules covering broken token refs, missing primary colors, WCAG contrast, orphaned tokens, section order, and more. Designesy integrates Google\'s linter as the spec layer and runs its own 44-check contract verification as the layer above. Install the CLI: npm install -g @google/design.md. Lint locally: npx @google/design.md lint DESIGN.md. Export to W3C DTCG: npx @google/design.md export --format dtcg DESIGN.md. Note: DESIGN.md uses sRGB hex only; for OKLCH/Display P3 color spaces, use the W3C DTCG JSON format directly.',
|
|
@@ -513,9 +523,11 @@ function checkContrastSignal(tokens) {
|
|
|
513
523
|
// (designers choose the higher-contrast pairing).
|
|
514
524
|
const actionVal = tokens['--action'];
|
|
515
525
|
const onActionVal = tokens['--on-action'];
|
|
526
|
+
// The page under a translucent fill is --paper; a translucent label sits on the fill.
|
|
527
|
+
const pageRgb = resolveTokenColor(tokens['--paper'] || '', tokens, null);
|
|
516
528
|
if (actionVal && onActionVal) {
|
|
517
|
-
const fillRgb =
|
|
518
|
-
const labelRgb =
|
|
529
|
+
const fillRgb = resolveTokenColor(actionVal, tokens, pageRgb);
|
|
530
|
+
const labelRgb = fillRgb ? resolveTokenColor(onActionVal, tokens, fillRgb) : null;
|
|
519
531
|
if (!fillRgb || !labelRgb)
|
|
520
532
|
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'SKIP', detail: `--action ${actionVal} or --on-action ${onActionVal} unresolvable to RGB` };
|
|
521
533
|
const pairRatio = contrastRatio(labelRgb, fillRgb);
|
|
@@ -529,12 +541,12 @@ function checkContrastSignal(tokens) {
|
|
|
529
541
|
const signalVal = tokens['--signal'];
|
|
530
542
|
if (!signalVal)
|
|
531
543
|
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'WARN', detail: '--signal accent token not declared' };
|
|
532
|
-
const signalRgb =
|
|
544
|
+
const signalRgb = resolveTokenColor(signalVal, tokens, pageRgb);
|
|
533
545
|
if (!signalRgb)
|
|
534
546
|
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'SKIP', detail: `--signal value ${signalVal} unresolvable to RGB` };
|
|
535
547
|
const candidates = [
|
|
536
|
-
{ name: '--paper', rgb:
|
|
537
|
-
{ name: '--ink', rgb:
|
|
548
|
+
{ name: '--paper', rgb: resolveTokenColor(tokens['--paper'] || '', tokens, signalRgb) },
|
|
549
|
+
{ name: '--ink', rgb: resolveTokenColor(tokens['--ink'] || '', tokens, signalRgb) },
|
|
538
550
|
];
|
|
539
551
|
let bestRatio = 0;
|
|
540
552
|
let bestName = '';
|
|
@@ -560,14 +572,20 @@ function checkContrastSignal(tokens) {
|
|
|
560
572
|
// Computes both WCAG 2.1 ratio (the scoring standard) AND APCA Lc (the
|
|
561
573
|
// WCAG 3 candidate, polarity-aware). APCA is supplementary — it does not
|
|
562
574
|
// change pass/fail, but surfaces dark-mode contrast issues WCAG 2.1 misses.
|
|
575
|
+
//
|
|
576
|
+
// Where a stylesheet declares --muted-foreground, that is the muted text
|
|
577
|
+
// measured: in the shadcn convention --muted is a surface and
|
|
578
|
+
// --muted-foreground the text on it. Engine 1.3.0 reads oklch() (see
|
|
579
|
+
// resolveTokenColor), so without this every default shadcn theme would
|
|
580
|
+
// measure its --muted surface as text, at about 1.1:1, and FAIL.
|
|
563
581
|
function checkContrastReadable(tokens) {
|
|
564
582
|
const paperVal = tokens['--paper'];
|
|
565
583
|
if (!paperVal)
|
|
566
584
|
return { id: 'v06', item: 'Contrast remains readable for ink, muted, and accent on paper', category: 'accessibility', status: 'SKIP', detail: '--paper not declared: cannot test contrast' };
|
|
567
|
-
const paperRgb =
|
|
585
|
+
const paperRgb = resolveTokenColor(paperVal, tokens, null);
|
|
568
586
|
if (!paperRgb)
|
|
569
587
|
return { id: 'v06', item: 'Contrast remains readable for ink, muted, and accent on paper', category: 'accessibility', status: 'SKIP', detail: `--paper value ${paperVal} unresolvable to RGB` };
|
|
570
|
-
const textTokens = ['--ink', '--muted', '--muted-dim'];
|
|
588
|
+
const textTokens = ['--ink', tokens['--muted-foreground'] ? '--muted-foreground' : '--muted', '--muted-dim'];
|
|
571
589
|
const results = [];
|
|
572
590
|
let worst = { name: '', ratio: Infinity, status: 'PASS' };
|
|
573
591
|
for (const name of textTokens) {
|
|
@@ -576,7 +594,7 @@ function checkContrastReadable(tokens) {
|
|
|
576
594
|
results.push(`${name}: not declared`);
|
|
577
595
|
continue;
|
|
578
596
|
}
|
|
579
|
-
const rgb =
|
|
597
|
+
const rgb = resolveTokenColor(val, tokens, paperRgb);
|
|
580
598
|
if (!rgb) {
|
|
581
599
|
results.push(`${name}: unresolvable`);
|
|
582
600
|
continue;
|
|
@@ -1083,7 +1101,13 @@ function checkFontFamilyCount(css, tokens) {
|
|
|
1083
1101
|
// the adjusted @font-face. Those are not additional typeface choices — they
|
|
1084
1102
|
// are the same family's fallback metrics — so they must not count as
|
|
1085
1103
|
// palette drift.
|
|
1086
|
-
|
|
1104
|
+
//
|
|
1105
|
+
// Engine 1.3.1: the WORD fallback anywhere in the name, not only as its
|
|
1106
|
+
// last word. A hand-tuned face can carry a suffix after it: designesy.org's
|
|
1107
|
+
// "Fraunces Fallback Tuned" is Times New Roman under size-adjust, drawn only
|
|
1108
|
+
// before Fraunces loads, and `\sfallback$` counted it as a fourth family
|
|
1109
|
+
// (v26 WARN, "4 families: fraunces fallback tuned, fraunces, ...").
|
|
1110
|
+
if (/\bfallback\b/.test(stack))
|
|
1087
1111
|
continue;
|
|
1088
1112
|
families.add(stack);
|
|
1089
1113
|
}
|
|
@@ -1095,6 +1119,57 @@ function checkFontFamilyCount(css, tokens) {
|
|
|
1095
1119
|
return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'WARN', detail: `${count} families (recommended ≤3): ${list}` };
|
|
1096
1120
|
return { id: 'v26', item: 'Font family count ≤3 (body + heading + mono)', category: 'cadence', status: 'FAIL', detail: `${count} families, palette drift (recommended ≤3): ${list}` };
|
|
1097
1121
|
}
|
|
1122
|
+
/**
|
|
1123
|
+
* v27 helper (engine 1.3.1): does this rule's selector style a form field
|
|
1124
|
+
* itself? True when the subject (the last compound) of any selector in the
|
|
1125
|
+
* list is an input, textarea or select element, or carries the exact class
|
|
1126
|
+
* .input or .field, and is not a pseudo-element (::placeholder does not set
|
|
1127
|
+
* the size iOS Safari reads). ".field-card-meta", ".input-hint", "::selection",
|
|
1128
|
+
* ".field label" and "user-select" are not fields.
|
|
1129
|
+
*/
|
|
1130
|
+
function isFieldSelector(selector) {
|
|
1131
|
+
const clean = selector.replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
1132
|
+
return splitCssTopLevel(clean, ',').some((part) => {
|
|
1133
|
+
const compounds = part.trim().split(/\s*[>+~]\s*|\s+/);
|
|
1134
|
+
const subject = compounds[compounds.length - 1] || '';
|
|
1135
|
+
if (subject.indexOf('::') >= 0)
|
|
1136
|
+
return false;
|
|
1137
|
+
return /(?:^|[^\w.#:-])(?:input|textarea|select)(?![\w-])/i.test(subject)
|
|
1138
|
+
|| /\.(?:input|field)(?![\w-])/i.test(subject);
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* v27 helper (engine 1.3.1): in each rule whose selector styles a field
|
|
1143
|
+
* (isFieldSelector), every `font-size: var(...)` declaration is rewritten to
|
|
1144
|
+
* its value from the :root token table, through substituteCssVars (var()
|
|
1145
|
+
* chains, and a var()'s own fallback where no token declares the name). A
|
|
1146
|
+
* value that does not resolve is left exactly as written, and so is every rule
|
|
1147
|
+
* that does not style a field: v27's literal scan matches its keywords
|
|
1148
|
+
* anywhere before a brace, so resolving var() in every rule would have read
|
|
1149
|
+
* designesy.org's `.field-card-meta { font-size: var(--text-label) }` (12.8px,
|
|
1150
|
+
* a card caption) as an input below 16px. Only values that start with var(
|
|
1151
|
+
* are touched; calc(), clamp() and min()/max() stay as written, as before.
|
|
1152
|
+
*
|
|
1153
|
+
* The scan is anchored on each `font-size: var(` and reads its rule's selector
|
|
1154
|
+
* backwards from the opening brace, so its cost is linear in the sheet. A
|
|
1155
|
+
* whole-sheet rule regex (`([^{}]*)\{([^{}]*)\}`) is quadratic on a long
|
|
1156
|
+
* @media prelude: measured at +4.5 s on five 43 KB preludes.
|
|
1157
|
+
*/
|
|
1158
|
+
function resolveFontSizeVars(css, tokens) {
|
|
1159
|
+
if (css.indexOf('var(') < 0)
|
|
1160
|
+
return css;
|
|
1161
|
+
return css.replace(/(font-size\s*:\s*)(var\([^;{}]*\))/gi, (decl, lead, value, offset) => {
|
|
1162
|
+
const open = css.lastIndexOf('{', offset);
|
|
1163
|
+
// Not inside a rule body (a stray declaration, or a brace closed between).
|
|
1164
|
+
if (open < 0 || css.lastIndexOf('}', offset) > open)
|
|
1165
|
+
return decl;
|
|
1166
|
+
const start = Math.max(css.lastIndexOf('}', open - 1), css.lastIndexOf('{', open - 1), css.lastIndexOf(';', open - 1)) + 1;
|
|
1167
|
+
if (!isFieldSelector(css.slice(start, open)))
|
|
1168
|
+
return decl;
|
|
1169
|
+
const sub = substituteCssVars(value, [tokens]);
|
|
1170
|
+
return sub === null ? decl : lead + sub.trim();
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1098
1173
|
/**
|
|
1099
1174
|
* v27 helper: does the delivered markup carry a field iOS Safari zooms into on
|
|
1100
1175
|
* focus? That is a <textarea>, a <select>, or an <input> of any type except the
|
|
@@ -1121,12 +1196,20 @@ function hasZoomableField(html) {
|
|
|
1121
1196
|
// unchanged: a sub-16px input rule still FAILs and a declared floor still PASSes,
|
|
1122
1197
|
// because both describe the stylesheet that fields rendered later by script
|
|
1123
1198
|
// would also inherit.
|
|
1124
|
-
|
|
1199
|
+
//
|
|
1200
|
+
// Since engine 1.3.1 a font-size written as var() is read through the :root
|
|
1201
|
+
// token table first (resolveFontSizeVars), so `input { font-size:
|
|
1202
|
+
// var(--text-body) }` with `--text-body: 1rem` is the 16px floor it renders as.
|
|
1203
|
+
// Both regexes below read literals only; before 1.3.1 that rule read WARN ("no
|
|
1204
|
+
// explicit input font-size ≥16px detected") on designesy.org. A var() the table
|
|
1205
|
+
// cannot resolve is left as written, so it reads as it did before.
|
|
1206
|
+
function checkInputFontFloor(css, html, tokens = {}) {
|
|
1207
|
+
const sheet = resolveFontSizeVars(css, tokens);
|
|
1125
1208
|
// Find input/textarea/select rules with font-size < 16px or < 1rem.
|
|
1126
1209
|
const inputRe = /(?:input|textarea|select|\.input|\.field)[^{]*\{[^}]*font-size\s*:\s*(\d+(?:\.\d+)?)(px|rem)/gi;
|
|
1127
1210
|
const below = [];
|
|
1128
1211
|
let m;
|
|
1129
|
-
while ((m = inputRe.exec(
|
|
1212
|
+
while ((m = inputRe.exec(sheet)) !== null) {
|
|
1130
1213
|
const val = parseFloat(m[1]);
|
|
1131
1214
|
const unit = m[2].toLowerCase();
|
|
1132
1215
|
const px = unit === 'rem' ? val * 16 : val;
|
|
@@ -1137,8 +1220,8 @@ function checkInputFontFloor(css, html) {
|
|
|
1137
1220
|
// Also check: is there a global input font-size ≥16px? (e.g. `input { font-size: 1rem }`)
|
|
1138
1221
|
// The strict form `input\s*\{` matches standalone rules; the grouped form
|
|
1139
1222
|
// `input,...,{` matches selectors merged by CSS minifiers (e.g. `input,textarea,select{font-size:1rem}`).
|
|
1140
|
-
const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(
|
|
1141
|
-
|| /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(
|
|
1223
|
+
const hasGlobalFloor = /input\s*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet)
|
|
1224
|
+
|| /input\s*[,][^{]*\{[^}]*font-size\s*:\s*(?:1rem|16px|1\.0(?:\d+)?rem|[2-9]\dpx)/i.test(sheet);
|
|
1142
1225
|
if (hasGlobalFloor)
|
|
1143
1226
|
return { id: 'v27', item: 'Input font-size ≥16px (prevents iOS Safari auto-zoom)', category: 'accessibility', status: 'PASS', detail: 'input font-size floor detected' };
|
|
1144
1227
|
if (!hasZoomableField(html))
|
|
@@ -1300,15 +1383,61 @@ function classifyColorToken(name) {
|
|
|
1300
1383
|
return 'hue';
|
|
1301
1384
|
return 'neutral';
|
|
1302
1385
|
}
|
|
1386
|
+
/**
|
|
1387
|
+
* The color tokens v42 and v43 read, and the palette layer among them
|
|
1388
|
+
* (engine 1.3.0).
|
|
1389
|
+
*
|
|
1390
|
+
* A token is a color token when its value is written as a color, or when,
|
|
1391
|
+
* with var() aliases followed through the token set, it is one. Until 1.3.0 only a literal counted, so a
|
|
1392
|
+
* role token that aliases a primitive (--error: var(--red-700), the structure
|
|
1393
|
+
* v42's own remediation recommends) was dropped before classification and the
|
|
1394
|
+
* verdict inverted: a site that did what the check asks scored worse.
|
|
1395
|
+
*
|
|
1396
|
+
* The palette layer is every hue-named color token that a role-named token
|
|
1397
|
+
* reaches through its aliases. That is the two-tier structure the token
|
|
1398
|
+
* standards describe: DTCG 2025.10 §3.8 defines an alias as a token whose
|
|
1399
|
+
* value references another (its example is $color-text-primary aliasing
|
|
1400
|
+
* $color-palette-black); Spectrum's primitive tokens "are typically used
|
|
1401
|
+
* indirectly through semantic tokens as an alias"; Primer's base tokens are
|
|
1402
|
+
* "generally used as a reference for functional tokens"; Material's reference
|
|
1403
|
+
* tokens hold the concrete values that system tokens give roles to. A palette
|
|
1404
|
+
* used that way is not a hue vocabulary, so it leaves v42's denominator. A
|
|
1405
|
+
* hue-named token that no role token reaches is consumed directly, or not at
|
|
1406
|
+
* all, and still counts against the role share.
|
|
1407
|
+
*/
|
|
1408
|
+
function colorTokenTiers(tokens) {
|
|
1409
|
+
const refs = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
|
|
1410
|
+
// A value written as a color counts as one, as before 1.3.0, even when its
|
|
1411
|
+
// var() cannot be followed (hsl(var(--x)) whose --x refers back to itself);
|
|
1412
|
+
// anything else counts when its aliases resolve to a color.
|
|
1413
|
+
const color = Object.keys(tokens).filter((name) => {
|
|
1414
|
+
if (isColorValue(tokens[name]))
|
|
1415
|
+
return true;
|
|
1416
|
+
const resolved = substituteCssVars(tokens[name], [tokens]);
|
|
1417
|
+
return resolved !== null && isColorValue(resolved);
|
|
1418
|
+
});
|
|
1419
|
+
const reached = new Set();
|
|
1420
|
+
const queue = color.filter((name) => classifyColorToken(name) === 'role').flatMap((name) => refs(tokens[name]));
|
|
1421
|
+
while (queue.length > 0) {
|
|
1422
|
+
const name = queue.pop();
|
|
1423
|
+
if (reached.has(name) || !Object.prototype.hasOwnProperty.call(tokens, name))
|
|
1424
|
+
continue;
|
|
1425
|
+
reached.add(name);
|
|
1426
|
+
queue.push(...refs(tokens[name]));
|
|
1427
|
+
}
|
|
1428
|
+
return { color, palette: new Set(color.filter((name) => reached.has(name) && classifyColorToken(name) === 'hue')) };
|
|
1429
|
+
}
|
|
1303
1430
|
function checkSemanticColorVocabulary(tokens) {
|
|
1304
1431
|
const item = 'Semantic color vocabulary: tokens named by role rather than hue';
|
|
1305
|
-
const
|
|
1306
|
-
if (
|
|
1307
|
-
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${
|
|
1432
|
+
const { color, palette } = colorTokenTiers(tokens);
|
|
1433
|
+
if (color.length < 4) {
|
|
1434
|
+
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${color.length})` };
|
|
1308
1435
|
}
|
|
1309
1436
|
let role = 0;
|
|
1310
1437
|
let hue = 0;
|
|
1311
|
-
for (const
|
|
1438
|
+
for (const name of color) {
|
|
1439
|
+
if (palette.has(name))
|
|
1440
|
+
continue;
|
|
1312
1441
|
const c = classifyColorToken(name);
|
|
1313
1442
|
if (c === 'role')
|
|
1314
1443
|
role++;
|
|
@@ -1317,28 +1446,33 @@ function checkSemanticColorVocabulary(tokens) {
|
|
|
1317
1446
|
}
|
|
1318
1447
|
const named = role + hue;
|
|
1319
1448
|
if (named === 0) {
|
|
1320
|
-
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${
|
|
1449
|
+
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${color.length} color tokens, all neutrally named: vocabulary unclassifiable` };
|
|
1321
1450
|
}
|
|
1322
1451
|
const share = Math.round((role / named) * 100);
|
|
1452
|
+
const layer = palette.size > 0 ? `; ${palette.size} hue-named primitive(s) that role tokens alias are the palette layer, not counted` : '';
|
|
1323
1453
|
if (role >= 3 && share >= 60) {
|
|
1324
|
-
return { id: 'v42', item, category: 'semantic', status: 'PASS', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): colors are named by role` };
|
|
1454
|
+
return { id: 'v42', item, category: 'semantic', status: 'PASS', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): colors are named by role${layer}` };
|
|
1325
1455
|
}
|
|
1326
|
-
return { id: 'v42', item, category: 'semantic', status: 'WARN', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): color vocabulary leans on hue names` };
|
|
1456
|
+
return { id: 'v42', item, category: 'semantic', status: 'WARN', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): color vocabulary leans on hue names${layer}` };
|
|
1327
1457
|
}
|
|
1458
|
+
// v43 reads the same color tokens as v42, aliases followed, so --error:
|
|
1459
|
+
// var(--red-700) is an error family. The palette layer needs no exclusion
|
|
1460
|
+
// here: v43 counts families, and a hue-named primitive is never one.
|
|
1328
1461
|
function checkSemanticStatusRoles(tokens) {
|
|
1329
1462
|
const item = 'Semantic status colors: ok/warn/error/info state roles present';
|
|
1330
|
-
const
|
|
1331
|
-
if (
|
|
1463
|
+
const { color } = colorTokenTiers(tokens);
|
|
1464
|
+
if (color.length === 0) {
|
|
1332
1465
|
return { id: 'v43', item, category: 'semantic', status: 'SKIP', detail: 'no color tokens in :root' };
|
|
1333
1466
|
}
|
|
1334
1467
|
const families = { ok: false, warn: false, error: false, info: false };
|
|
1335
|
-
for (const
|
|
1468
|
+
for (const name of color) {
|
|
1336
1469
|
const segments = name.toLowerCase().split(/[-_]/);
|
|
1337
1470
|
if (segments.some((s) => ['ok', 'success', 'pass', 'positive'].includes(s)))
|
|
1338
1471
|
families.ok = true;
|
|
1339
1472
|
if (segments.some((s) => ['warn', 'warning', 'caution'].includes(s)))
|
|
1340
1473
|
families.warn = true;
|
|
1341
|
-
|
|
1474
|
+
// 'destructive' is shadcn/ui's error role (--destructive); engine 1.3.1.
|
|
1475
|
+
if (segments.some((s) => ['error', 'danger', 'fail', 'critical', 'negative', 'destructive'].includes(s)))
|
|
1342
1476
|
families.error = true;
|
|
1343
1477
|
if (segments.some((s) => ['info', 'notice', 'informational'].includes(s)))
|
|
1344
1478
|
families.info = true;
|
|
@@ -1725,7 +1859,9 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1725
1859
|
const ch = [0, 2, 4, 6].map((k) => (k < full.length ? parseInt(full.slice(k, k + 2), 16) : 255));
|
|
1726
1860
|
return [ch[0], ch[1], ch[2], ch[3] / 255];
|
|
1727
1861
|
}
|
|
1728
|
-
|
|
1862
|
+
// Own keys only: 'constructor' or 'toString' as a value read Object.prototype
|
|
1863
|
+
// and threw, which failed the whole score (engine 1.3.0 reads token values here).
|
|
1864
|
+
if (Object.prototype.hasOwnProperty.call(CSS_NAMED_COLORS, v))
|
|
1729
1865
|
return [...CSS_NAMED_COLORS[v]];
|
|
1730
1866
|
const fn = /^([a-z-]+)\(([\s\S]*)\)$/.exec(v);
|
|
1731
1867
|
if (!fn)
|
|
@@ -2966,17 +3102,25 @@ function checkPausedEntrances(css) {
|
|
|
2966
3102
|
},
|
|
2967
3103
|
};
|
|
2968
3104
|
}
|
|
2969
|
-
// v34 — AI-Disclosure Readiness (EU AI Act Article 50,
|
|
2970
|
-
//
|
|
2971
|
-
//
|
|
2972
|
-
//
|
|
2973
|
-
//
|
|
2974
|
-
//
|
|
2975
|
-
//
|
|
2976
|
-
//
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
3105
|
+
// v34 — AI-Disclosure Readiness (EU AI Act Article 50, which applies from
|
|
3106
|
+
// 2026-08-02). Static, from the fetched HTML: it looks for an AI chat surface
|
|
3107
|
+
// and for a disclosure signal (visible text, aria-label, data-ai-disclosure,
|
|
3108
|
+
// generator meta, C2PA, JSON-LD).
|
|
3109
|
+
//
|
|
3110
|
+
// Article 50(1) puts the duty on the PROVIDER of an AI system that talks with
|
|
3111
|
+
// people: they must be told it is an AI, unless that is obvious from the
|
|
3112
|
+
// context, at the latest at the first interaction (50(5)). A site that embeds
|
|
3113
|
+
// a vendor's assistant may not be that provider, so the check says "may
|
|
3114
|
+
// require", never "requires", and it is not a legal finding.
|
|
3115
|
+
//
|
|
3116
|
+
// Only a positively identified AI chat surface with no disclosure FAILs: a
|
|
3117
|
+
// container or input that names itself a chatbot or an AI assistant, or a
|
|
3118
|
+
// script from a chatbot, AI-widget or Dialogflow path. Anything weaker WARNs
|
|
3119
|
+
// "possible AI surface: review": live-chat vendors (Intercom, Drift, Zendesk,
|
|
3120
|
+
// LiveChat, Tawk, Crisp), which are often answered by people; a generic chat
|
|
3121
|
+
// container or an "assistant" script; page text about AI, which is not a chat
|
|
3122
|
+
// surface. Until engine 1.3.0 those FAILed, and so did Google Publisher Tag,
|
|
3123
|
+
// an ad tag, because its gpt.js matched the word "gpt".
|
|
2980
3124
|
function checkAiDisclosure(html) {
|
|
2981
3125
|
const ITEM = 'AI-Disclosure Readiness (EU AI Act Art 50, effective 2026-08-02)';
|
|
2982
3126
|
const CATEGORY = 'identity';
|
|
@@ -2989,20 +3133,31 @@ function checkAiDisclosure(html) {
|
|
|
2989
3133
|
// false-positive as disclosure TEXT. Disclosure text must be human-readable
|
|
2990
3134
|
// content between tags, not class/id/aria attribute values.
|
|
2991
3135
|
const textOnly = visibleHtml.replace(/<[^>]+>/g, ' ');
|
|
2992
|
-
// ── AI
|
|
2993
|
-
const
|
|
2994
|
-
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|
|
|
2995
|
-
const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
|
|
3136
|
+
// ── AI chat surfaces, positively identified ─────────────────────────────
|
|
3137
|
+
const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
|
|
3138
|
+
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
|
|
2996
3139
|
const aiInputRe = /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i;
|
|
3140
|
+
// ── Possible AI surfaces: a review item, never a FAIL ───────────────────
|
|
3141
|
+
const chatContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i;
|
|
3142
|
+
const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
|
|
3143
|
+
const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
|
|
3144
|
+
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
2997
3145
|
const surfaceSignals = [];
|
|
2998
|
-
if (
|
|
3146
|
+
if (aiContainerRe.test(html))
|
|
2999
3147
|
surfaceSignals.push('chatbot container');
|
|
3000
3148
|
if (aiScriptRe.test(html))
|
|
3001
3149
|
surfaceSignals.push('AI widget script');
|
|
3002
|
-
if (aiTextRe.test(textOnly))
|
|
3003
|
-
surfaceSignals.push('AI prompt text');
|
|
3004
3150
|
if (aiInputRe.test(html))
|
|
3005
3151
|
surfaceSignals.push('AI input');
|
|
3152
|
+
const possibleSignals = [];
|
|
3153
|
+
if (chatContainerRe.test(html))
|
|
3154
|
+
possibleSignals.push('chat widget container');
|
|
3155
|
+
if (chatScriptRe.test(html))
|
|
3156
|
+
possibleSignals.push('chat or assistant script');
|
|
3157
|
+
if (aiTextRe.test(textOnly))
|
|
3158
|
+
possibleSignals.push('AI prompt text');
|
|
3159
|
+
if (ambiguousRe.test(html))
|
|
3160
|
+
possibleSignals.push('ambiguous widget');
|
|
3006
3161
|
// ── Disclosure detection ────────────────────────────────────────────────
|
|
3007
3162
|
const disclosureTextRe = /\b(?:ai\s+assistant|ai\s+chatbot|ai-powered|powered\s+by\s+ai|automated\s+assistant|virtual\s+assistant|chatbot)\b/i;
|
|
3008
3163
|
const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
|
|
@@ -3024,17 +3179,17 @@ function checkAiDisclosure(html) {
|
|
|
3024
3179
|
if (dataAiRe.test(html))
|
|
3025
3180
|
disclosureSignals.push('data-ai-disclosure');
|
|
3026
3181
|
// ── Condition evaluation ────────────────────────────────────────────────
|
|
3027
|
-
if (surfaceSignals.length === 0) {
|
|
3028
|
-
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
3029
|
-
if (ambiguousRe.test(html)) {
|
|
3030
|
-
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'WARN', detail: 'possible AI-interactive surface (ambiguous widget): manual review recommended for Art 50 compliance' };
|
|
3031
|
-
}
|
|
3182
|
+
if (surfaceSignals.length === 0 && possibleSignals.length === 0) {
|
|
3032
3183
|
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'no AI-interactive surface detected: disclosure not required' };
|
|
3033
3184
|
}
|
|
3034
3185
|
if (disclosureSignals.length > 0) {
|
|
3035
|
-
|
|
3186
|
+
const found = surfaceSignals.length > 0 ? `AI surface detected (${surfaceSignals.join(', ')})` : `possible AI surface (${possibleSignals.join(', ')})`;
|
|
3187
|
+
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: `${found}; disclosure present (${disclosureSignals.join(', ')})` };
|
|
3036
3188
|
}
|
|
3037
|
-
|
|
3189
|
+
if (surfaceSignals.length > 0) {
|
|
3190
|
+
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'FAIL', detail: `AI chat surface detected (${surfaceSignals.join(', ')}) and no AI disclosure found: EU AI Act Art. 50(1) may require telling people at their first interaction that they are talking to an AI (a duty on the AI system's provider, applying from 2026-08-02)` };
|
|
3191
|
+
}
|
|
3192
|
+
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'WARN', detail: `possible AI surface (${possibleSignals.join(', ')}): review. If it is an AI system and no disclosure is shown, EU AI Act Art. 50(1) may require one (a duty on the AI system's provider); a chat answered by people needs none` };
|
|
3038
3193
|
}
|
|
3039
3194
|
// v35 — Forced-colors readiness (Windows High Contrast Mode / Chrome forced-colors).
|
|
3040
3195
|
// Verifies the site has a @media (forced-colors: active) block, ideally with
|
|
@@ -3269,6 +3424,25 @@ const RECOGNIZED_BUTTON_COMMANDS = new Set([
|
|
|
3269
3424
|
'find', 'replace', 'navigate', 'run', 'score', 'review', 'verify',
|
|
3270
3425
|
'detect', 'assess', 'enforce', 'dismiss', 'analyze', 'inspect', 'check',
|
|
3271
3426
|
'evaluate', 'validate', 'test', 'monitor', 'track', 'measure', 'scan',
|
|
3427
|
+
// Engine 1.3.1. get, set and try: the comment in checkButtonTextVerb said
|
|
3428
|
+
// "Get X", "Set X" and "Try X" were allowed, but none of the three was in
|
|
3429
|
+
// this set, so "Get started" and "Try it free" read WARN on any site.
|
|
3430
|
+
'get', 'set', 'try',
|
|
3431
|
+
// Imperative CTA verbs that the suffix heuristic in isVerbLike misses (it
|
|
3432
|
+
// already accepts explore, collapse, toggle, choose and compare by their -e
|
|
3433
|
+
// ending). Each is read as a verb at the start of a button label, and none
|
|
3434
|
+
// names a thing a button would be labelled with on its own:
|
|
3435
|
+
// flip "Flip card", "Flip to the flame": a command, no noun label use
|
|
3436
|
+
// learn "Learn more": verb only
|
|
3437
|
+
// sign "Sign in", "Sign up", "Sign out": the noun reading does not
|
|
3438
|
+
// lead a button label
|
|
3439
|
+
// join "Join the waitlist": verb only
|
|
3440
|
+
// expand "Expand all", "Expand section": verb only
|
|
3441
|
+
// select "Select all", "Select plan": an imperative in button use
|
|
3442
|
+
// read "Read more", "Read the guide": an imperative in button use
|
|
3443
|
+
// Left out on purpose: switch and watch, which are common nouns ("Switch",
|
|
3444
|
+
// a toggle's name; "Watch", a product) as well as verbs.
|
|
3445
|
+
'flip', 'learn', 'sign', 'join', 'expand', 'select', 'read',
|
|
3272
3446
|
]);
|
|
3273
3447
|
function isVerbLike(word) {
|
|
3274
3448
|
const w = word.toLowerCase().trim();
|
|
@@ -3366,12 +3540,41 @@ function checkButtonTextVerb(html) {
|
|
|
3366
3540
|
// with no space between). A space separates a real word: "Alt text" is a
|
|
3367
3541
|
// label, not a shortcut, and must not be eaten.
|
|
3368
3542
|
const TEXT_SHORTCUT_RE = /[\s]*(?:Ctrl|Cmd|Shift|Alt|Option|Command)(?:\+[A-Za-z0-9+\-]*|[A-Z0-9]+)?$/;
|
|
3543
|
+
// Engine 1.3.1: a "+key" hint written in lower case ("Save ctrl+s",
|
|
3544
|
+
// "Find ctrl+k") is a hint too. The modifier is case-insensitive here but
|
|
3545
|
+
// must stand as its own word (the start of the label or after a space), so
|
|
3546
|
+
// "Halt+1" is untouched, and only the plus form is read: a modifier followed
|
|
3547
|
+
// by a space and a letter ("Shift A") is a label, as it was in 1.3.0.
|
|
3548
|
+
const PLUS_SHORTCUT_RE = /(?:^|\s)(?:ctrl|cmd|shift|alt|option|command)(?:\s*\+\s*[a-z0-9]+)+$/i;
|
|
3549
|
+
const stripShortcut = (s) => s.replace(SHORTCUT_RE, '').replace(TEXT_SHORTCUT_RE, '').replace(PLUS_SHORTCUT_RE, '').trim();
|
|
3550
|
+
// Engine 1.3.1: keycaps are read from the markup, not guessed from text.
|
|
3551
|
+
// A <kbd> element is keyboard input (HTML Living Standard, the kbd element),
|
|
3552
|
+
// a hint beside the label rather than the label, so its text is dropped
|
|
3553
|
+
// before the verb check, innermost first so nested keycaps
|
|
3554
|
+
// (<kbd><kbd>Ctrl</kbd>+<kbd>K</kbd></kbd>) go too. designesy.org's flip
|
|
3555
|
+
// button, <kbd>ctrl</kbd> <span aria-hidden="true">+</span> <kbd>i</kbd>
|
|
3556
|
+
// after a hidden lead, read as "Flip to the flame: ctrl i" in 1.3.0. A "+"
|
|
3557
|
+
// left between dropped keycaps is dropped with them. A button whose only
|
|
3558
|
+
// text is keycaps is then empty and is judged as an icon-only button: by its
|
|
3559
|
+
// aria-label when it has one, and not at all when it does not.
|
|
3560
|
+
const labelText = (inner) => {
|
|
3561
|
+
const visible = stripAriaHidden(inner);
|
|
3562
|
+
let out = visible;
|
|
3563
|
+
for (let i = 0; i < 4; i++) {
|
|
3564
|
+
const next = out.replace(/<kbd\b[^>]*>(?:(?!<kbd\b)[\s\S])*?<\/kbd>/gi, ' ');
|
|
3565
|
+
if (next === out)
|
|
3566
|
+
break;
|
|
3567
|
+
out = next;
|
|
3568
|
+
}
|
|
3569
|
+
const text = out.replace(/<[^>]*>/g, '').trim();
|
|
3570
|
+
return out === visible ? text : text.replace(/(?:\s*\+)+$/, '').trim();
|
|
3571
|
+
};
|
|
3369
3572
|
while ((m = buttonRe.exec(html)) !== null) {
|
|
3370
|
-
let text =
|
|
3573
|
+
let text = labelText(m[1]);
|
|
3371
3574
|
// Strip leading icon characters so "✕Close" → "Close"
|
|
3372
3575
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3373
3576
|
// Strip trailing keyboard shortcut hints so "Find⌘K" → "Find"
|
|
3374
|
-
text = text
|
|
3577
|
+
text = stripShortcut(text);
|
|
3375
3578
|
// Icon-only button with an accessible name: use the aria-label verb.
|
|
3376
3579
|
if (!text) {
|
|
3377
3580
|
const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
|
|
@@ -3392,9 +3595,9 @@ function checkButtonTextVerb(html) {
|
|
|
3392
3595
|
buttonTexts.push({ text, tag: m[0] });
|
|
3393
3596
|
}
|
|
3394
3597
|
while ((m = roleButtonRe.exec(html)) !== null) {
|
|
3395
|
-
let text =
|
|
3598
|
+
let text = labelText(m[1]);
|
|
3396
3599
|
text = text.replace(ICON_PREFIX_RE, '').trim();
|
|
3397
|
-
text = text
|
|
3600
|
+
text = stripShortcut(text);
|
|
3398
3601
|
if (!text) {
|
|
3399
3602
|
const aria = /aria-label=["']([^"']+)["']/i.exec(m[0]);
|
|
3400
3603
|
if (aria)
|
|
@@ -3456,7 +3659,9 @@ function checkButtonTextVerb(html) {
|
|
|
3456
3659
|
// Allow if first word is verb-like
|
|
3457
3660
|
if (isVerbLike(firstWord))
|
|
3458
3661
|
continue;
|
|
3459
|
-
//
|
|
3662
|
+
// "Get X", "Set X", "Try X" are allowed above: get, set and try are in
|
|
3663
|
+
// RECOGNIZED_BUTTON_COMMANDS (since engine 1.3.1; this comment claimed it
|
|
3664
|
+
// before they were).
|
|
3460
3665
|
// If we get here, the first word is likely a noun → potential violation
|
|
3461
3666
|
violations.push(`"${text}"`);
|
|
3462
3667
|
}
|
|
@@ -3936,9 +4141,25 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
3936
4141
|
// strengths. These are design-integrity failures — a site that FAILs on
|
|
3937
4142
|
// contrast or horizontal overflow cannot be A-grade no matter how good its
|
|
3938
4143
|
// tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
|
|
4144
|
+
//
|
|
4145
|
+
// No A while any check FAILs (engine 1.3.0, owner decision 2026-10-11). A
|
|
4146
|
+
// FAIL is the engine's own finding that the page breaks a rule, so any FAIL
|
|
4147
|
+
// caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
|
|
4148
|
+
// A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
|
|
4149
|
+
// (v44). The specific ceilings below are lower and win where they apply.
|
|
4150
|
+
// Every cap here runs after the originality lift, so the lift can carry a
|
|
4151
|
+
// score up to a cap but never past one.
|
|
3939
4152
|
const hardFailChecks = checks.filter((c) => c.status === 'FAIL');
|
|
3940
4153
|
let hardFailCeilingApplied = false;
|
|
3941
4154
|
let hardFailCeilingReason = null;
|
|
4155
|
+
const ANY_FAIL_CAP = 89.9;
|
|
4156
|
+
if (hardFailChecks.length > 0 && score > ANY_FAIL_CAP) {
|
|
4157
|
+
const ids = hardFailChecks.map((c) => c.id);
|
|
4158
|
+
const named = ids.length === 1 ? `${ids[0]} fails` : `${ids.slice(0, -1).join(', ')} and ${ids[ids.length - 1]} fail`;
|
|
4159
|
+
score = ANY_FAIL_CAP;
|
|
4160
|
+
hardFailCeilingApplied = true;
|
|
4161
|
+
hardFailCeilingReason = `A FAIL caps the grade at B: ${named}, and the score cannot exceed 89.9 while any check fails.`;
|
|
4162
|
+
}
|
|
3942
4163
|
for (const c of hardFailChecks) {
|
|
3943
4164
|
let cap = null;
|
|
3944
4165
|
let reason = null;
|
|
@@ -3947,6 +4168,14 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
3947
4168
|
cap = 65;
|
|
3948
4169
|
reason = 'Contrast below WCAG minimum: text is unreadable for many users.';
|
|
3949
4170
|
}
|
|
4171
|
+
// v44 Status text contrast — the words that report a state fail WCAG
|
|
4172
|
+
// 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
|
|
4173
|
+
// Same cap as v06 (engine 1.3.0, owner decision 2026-10-11): both are
|
|
4174
|
+
// measured WCAG contrast failures on text.
|
|
4175
|
+
if (c.id === 'v44') {
|
|
4176
|
+
cap = 65;
|
|
4177
|
+
reason = 'Status text below WCAG contrast (4.5:1, or 3:1 for large text and graphics): the words that report errors, warnings and success are hard to read.';
|
|
4178
|
+
}
|
|
3950
4179
|
// v22 Contrast signal — CTA text unreadable on brand color
|
|
3951
4180
|
if (c.id === 'v22') {
|
|
3952
4181
|
cap = 70;
|
|
@@ -4097,7 +4326,7 @@ export async function scoreFromParts(input) {
|
|
|
4097
4326
|
checkTouchTargets(css),
|
|
4098
4327
|
checkHeadingHierarchy(html),
|
|
4099
4328
|
checkFontFamilyCount(css, tokens),
|
|
4100
|
-
checkInputFontFloor(css, html),
|
|
4329
|
+
checkInputFontFloor(css, html, rawTokens),
|
|
4101
4330
|
checkReadingWidth(css),
|
|
4102
4331
|
checkTokenLayerDepth(tokens),
|
|
4103
4332
|
checkSemanticColorVocabulary(tokens),
|
|
@@ -4606,7 +4835,7 @@ export async function scoreFromParts(input) {
|
|
|
4606
4835
|
* the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
|
|
4607
4836
|
* contract source and fails when the two differ.
|
|
4608
4837
|
*/
|
|
4609
|
-
export const CONTRACT_VERSION = 'v0.4.
|
|
4838
|
+
export const CONTRACT_VERSION = 'v0.4.5';
|
|
4610
4839
|
/**
|
|
4611
4840
|
* The one verdict rule, shared by every emission format.
|
|
4612
4841
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@designesy/score",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.1",
|
|
4
|
+
"description": "Design quality gate for AI-generated UI: score any URL on 44 deterministic checks, A–F grade. Runs locally; browser checks return MANUAL. No required dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Designesy <hello@designesy.org>",
|
|
7
7
|
"homepage": "https://www.designesy.org/score",
|