@designesy/score 0.7.1 → 0.8.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/dist/engine.d.ts +1 -1
- package/dist/engine.js +200 -82
- package/package.json +1 -1
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.4";
|
|
163
163
|
/**
|
|
164
164
|
* The one verdict rule, shared by every emission format.
|
|
165
165
|
*
|
package/dist/engine.js
CHANGED
|
@@ -312,7 +312,9 @@ async function fetchPageResilient(targetUrl) {
|
|
|
312
312
|
function extractRootTokens(css) {
|
|
313
313
|
const stripped = css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '');
|
|
314
314
|
const tokens = {};
|
|
315
|
-
|
|
315
|
+
// `:root, :host { ... }` is the block Tailwind v4 writes its theme into;
|
|
316
|
+
// until engine 1.3.0 only `:root {` was read, so those tokens were missed.
|
|
317
|
+
const rootRe = /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g;
|
|
316
318
|
let m;
|
|
317
319
|
while ((m = rootRe.exec(stripped)) !== null) {
|
|
318
320
|
const block = m[1];
|
|
@@ -360,22 +362,6 @@ function inferTokensFromCss(css, tokens) {
|
|
|
360
362
|
return result;
|
|
361
363
|
}
|
|
362
364
|
// ── 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
365
|
function srgbChannelToLinear(c) {
|
|
380
366
|
const s = c / 255;
|
|
381
367
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
@@ -388,23 +374,32 @@ function contrastRatio(a, b) {
|
|
|
388
374
|
const lb = relativeLuminance(b);
|
|
389
375
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
|
390
376
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
377
|
+
/**
|
|
378
|
+
* A token value as the opaque sRGB colour v06 and v22 measure.
|
|
379
|
+
*
|
|
380
|
+
* Read through the CSS Color 4 parser v44 uses (resolveThemeColor): var()
|
|
381
|
+
* chains with fallbacks, hex of 3, 4, 6 or 8 digits, rgb() and hsl() in comma
|
|
382
|
+
* or space syntax, oklch(), oklab(), color(srgb), color-mix(), light-dark()
|
|
383
|
+
* (its light side: these two checks read one theme) and bare HSL channels.
|
|
384
|
+
* Until engine 1.3.0 they read only 3- and 6-digit hex and comma rgb(), with
|
|
385
|
+
* alpha ignored, so a stylesheet written in oklch() (Tailwind v4, shadcn)
|
|
386
|
+
* SKIPped both checks and a page with illegible text scored 100.
|
|
387
|
+
*
|
|
388
|
+
* A translucent colour is composited over `under` as a browser paints it;
|
|
389
|
+
* with nothing under it, it is not measured.
|
|
390
|
+
*/
|
|
391
|
+
function resolveTokenColor(value, tokens, under) {
|
|
392
|
+
if (!value.trim())
|
|
394
393
|
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);
|
|
394
|
+
const c = resolveThemeColor(value, [tokens], false);
|
|
395
|
+
if (!c)
|
|
402
396
|
return null;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
return
|
|
407
|
-
|
|
397
|
+
if (c[3] >= 1)
|
|
398
|
+
return [c[0], c[1], c[2]];
|
|
399
|
+
if (!under)
|
|
400
|
+
return null;
|
|
401
|
+
const painted = v44Composite(c, [under[0], under[1], under[2], 1]);
|
|
402
|
+
return [painted[0], painted[1], painted[2]];
|
|
408
403
|
}
|
|
409
404
|
// APCA-W3 0.0.98G-4g
|
|
410
405
|
function sRGBtoY_APCA(rgb) {
|
|
@@ -471,7 +466,7 @@ const REMEDIATION = {
|
|
|
471
466
|
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
467
|
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
468
|
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: '
|
|
469
|
+
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
470
|
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
471
|
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
472
|
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.',
|
|
@@ -500,21 +495,43 @@ function checkPaperToken(tokens) {
|
|
|
500
495
|
return { id: 'v01', item: 'Token values match live site :root foundation', category: 'tokens', status: 'FAIL', detail: '--paper background token not declared in :root' };
|
|
501
496
|
}
|
|
502
497
|
function checkContrastSignal(tokens) {
|
|
503
|
-
// v22 — REAL WCAG 2.1 contrast between
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
// (
|
|
507
|
-
//
|
|
508
|
-
//
|
|
498
|
+
// v22 — REAL WCAG 2.1 contrast between the primary button's fill and its
|
|
499
|
+
// label. AA threshold: 4.5:1 for body text, 3:1 for large/UI — buttons are
|
|
500
|
+
// large text, but we hold the 4.5:1 bar because button labels are often
|
|
501
|
+
// small (12-14px).
|
|
502
|
+
//
|
|
503
|
+
// A contract that names its primary action pair (--action, the fill, and
|
|
504
|
+
// --on-action, the label) is measured on exactly that pair: it is what the
|
|
505
|
+
// button paints, so a better-contrast token elsewhere cannot stand in for a
|
|
506
|
+
// label that fails. Without both tokens the check reads the convention most
|
|
507
|
+
// contracts use: --paper or --ink text on --signal, the better ratio winning
|
|
508
|
+
// (designers choose the higher-contrast pairing).
|
|
509
|
+
const actionVal = tokens['--action'];
|
|
510
|
+
const onActionVal = tokens['--on-action'];
|
|
511
|
+
// The page under a translucent fill is --paper; a translucent label sits on the fill.
|
|
512
|
+
const pageRgb = resolveTokenColor(tokens['--paper'] || '', tokens, null);
|
|
513
|
+
if (actionVal && onActionVal) {
|
|
514
|
+
const fillRgb = resolveTokenColor(actionVal, tokens, pageRgb);
|
|
515
|
+
const labelRgb = fillRgb ? resolveTokenColor(onActionVal, tokens, fillRgb) : null;
|
|
516
|
+
if (!fillRgb || !labelRgb)
|
|
517
|
+
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` };
|
|
518
|
+
const pairRatio = contrastRatio(labelRgb, fillRgb);
|
|
519
|
+
const pairStr = `${pairRatio.toFixed(2)}:1`;
|
|
520
|
+
if (pairRatio >= 4.5)
|
|
521
|
+
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'PASS', detail: `--on-action on --action = ${pairStr} (≥ 4.5:1 AA)` };
|
|
522
|
+
if (pairRatio >= 3)
|
|
523
|
+
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'WARN', detail: `--on-action on --action = ${pairStr} (passes 3:1 large-text, fails 4.5:1 body)` };
|
|
524
|
+
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'FAIL', detail: `--on-action on --action = ${pairStr} (below 3:1, illegible)` };
|
|
525
|
+
}
|
|
509
526
|
const signalVal = tokens['--signal'];
|
|
510
527
|
if (!signalVal)
|
|
511
528
|
return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'WARN', detail: '--signal accent token not declared' };
|
|
512
|
-
const signalRgb =
|
|
529
|
+
const signalRgb = resolveTokenColor(signalVal, tokens, pageRgb);
|
|
513
530
|
if (!signalRgb)
|
|
514
531
|
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` };
|
|
515
532
|
const candidates = [
|
|
516
|
-
{ name: '--paper', rgb:
|
|
517
|
-
{ name: '--ink', rgb:
|
|
533
|
+
{ name: '--paper', rgb: resolveTokenColor(tokens['--paper'] || '', tokens, signalRgb) },
|
|
534
|
+
{ name: '--ink', rgb: resolveTokenColor(tokens['--ink'] || '', tokens, signalRgb) },
|
|
518
535
|
];
|
|
519
536
|
let bestRatio = 0;
|
|
520
537
|
let bestName = '';
|
|
@@ -540,14 +557,20 @@ function checkContrastSignal(tokens) {
|
|
|
540
557
|
// Computes both WCAG 2.1 ratio (the scoring standard) AND APCA Lc (the
|
|
541
558
|
// WCAG 3 candidate, polarity-aware). APCA is supplementary — it does not
|
|
542
559
|
// change pass/fail, but surfaces dark-mode contrast issues WCAG 2.1 misses.
|
|
560
|
+
//
|
|
561
|
+
// Where a stylesheet declares --muted-foreground, that is the muted text
|
|
562
|
+
// measured: in the shadcn convention --muted is a surface and
|
|
563
|
+
// --muted-foreground the text on it. Engine 1.3.0 reads oklch() (see
|
|
564
|
+
// resolveTokenColor), so without this every default shadcn theme would
|
|
565
|
+
// measure its --muted surface as text, at about 1.1:1, and FAIL.
|
|
543
566
|
function checkContrastReadable(tokens) {
|
|
544
567
|
const paperVal = tokens['--paper'];
|
|
545
568
|
if (!paperVal)
|
|
546
569
|
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' };
|
|
547
|
-
const paperRgb =
|
|
570
|
+
const paperRgb = resolveTokenColor(paperVal, tokens, null);
|
|
548
571
|
if (!paperRgb)
|
|
549
572
|
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` };
|
|
550
|
-
const textTokens = ['--ink', '--muted', '--muted-dim'];
|
|
573
|
+
const textTokens = ['--ink', tokens['--muted-foreground'] ? '--muted-foreground' : '--muted', '--muted-dim'];
|
|
551
574
|
const results = [];
|
|
552
575
|
let worst = { name: '', ratio: Infinity, status: 'PASS' };
|
|
553
576
|
for (const name of textTokens) {
|
|
@@ -556,7 +579,7 @@ function checkContrastReadable(tokens) {
|
|
|
556
579
|
results.push(`${name}: not declared`);
|
|
557
580
|
continue;
|
|
558
581
|
}
|
|
559
|
-
const rgb =
|
|
582
|
+
const rgb = resolveTokenColor(val, tokens, paperRgb);
|
|
560
583
|
if (!rgb) {
|
|
561
584
|
results.push(`${name}: unresolvable`);
|
|
562
585
|
continue;
|
|
@@ -1280,15 +1303,61 @@ function classifyColorToken(name) {
|
|
|
1280
1303
|
return 'hue';
|
|
1281
1304
|
return 'neutral';
|
|
1282
1305
|
}
|
|
1306
|
+
/**
|
|
1307
|
+
* The color tokens v42 and v43 read, and the palette layer among them
|
|
1308
|
+
* (engine 1.3.0).
|
|
1309
|
+
*
|
|
1310
|
+
* A token is a color token when its value is written as a color, or when,
|
|
1311
|
+
* with var() aliases followed through the token set, it is one. Until 1.3.0 only a literal counted, so a
|
|
1312
|
+
* role token that aliases a primitive (--error: var(--red-700), the structure
|
|
1313
|
+
* v42's own remediation recommends) was dropped before classification and the
|
|
1314
|
+
* verdict inverted: a site that did what the check asks scored worse.
|
|
1315
|
+
*
|
|
1316
|
+
* The palette layer is every hue-named color token that a role-named token
|
|
1317
|
+
* reaches through its aliases. That is the two-tier structure the token
|
|
1318
|
+
* standards describe: DTCG 2025.10 §3.8 defines an alias as a token whose
|
|
1319
|
+
* value references another (its example is $color-text-primary aliasing
|
|
1320
|
+
* $color-palette-black); Spectrum's primitive tokens "are typically used
|
|
1321
|
+
* indirectly through semantic tokens as an alias"; Primer's base tokens are
|
|
1322
|
+
* "generally used as a reference for functional tokens"; Material's reference
|
|
1323
|
+
* tokens hold the concrete values that system tokens give roles to. A palette
|
|
1324
|
+
* used that way is not a hue vocabulary, so it leaves v42's denominator. A
|
|
1325
|
+
* hue-named token that no role token reaches is consumed directly, or not at
|
|
1326
|
+
* all, and still counts against the role share.
|
|
1327
|
+
*/
|
|
1328
|
+
function colorTokenTiers(tokens) {
|
|
1329
|
+
const refs = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
|
|
1330
|
+
// A value written as a color counts as one, as before 1.3.0, even when its
|
|
1331
|
+
// var() cannot be followed (hsl(var(--x)) whose --x refers back to itself);
|
|
1332
|
+
// anything else counts when its aliases resolve to a color.
|
|
1333
|
+
const color = Object.keys(tokens).filter((name) => {
|
|
1334
|
+
if (isColorValue(tokens[name]))
|
|
1335
|
+
return true;
|
|
1336
|
+
const resolved = substituteCssVars(tokens[name], [tokens]);
|
|
1337
|
+
return resolved !== null && isColorValue(resolved);
|
|
1338
|
+
});
|
|
1339
|
+
const reached = new Set();
|
|
1340
|
+
const queue = color.filter((name) => classifyColorToken(name) === 'role').flatMap((name) => refs(tokens[name]));
|
|
1341
|
+
while (queue.length > 0) {
|
|
1342
|
+
const name = queue.pop();
|
|
1343
|
+
if (reached.has(name) || !Object.prototype.hasOwnProperty.call(tokens, name))
|
|
1344
|
+
continue;
|
|
1345
|
+
reached.add(name);
|
|
1346
|
+
queue.push(...refs(tokens[name]));
|
|
1347
|
+
}
|
|
1348
|
+
return { color, palette: new Set(color.filter((name) => reached.has(name) && classifyColorToken(name) === 'hue')) };
|
|
1349
|
+
}
|
|
1283
1350
|
function checkSemanticColorVocabulary(tokens) {
|
|
1284
1351
|
const item = 'Semantic color vocabulary: tokens named by role rather than hue';
|
|
1285
|
-
const
|
|
1286
|
-
if (
|
|
1287
|
-
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${
|
|
1352
|
+
const { color, palette } = colorTokenTiers(tokens);
|
|
1353
|
+
if (color.length < 4) {
|
|
1354
|
+
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${color.length})` };
|
|
1288
1355
|
}
|
|
1289
1356
|
let role = 0;
|
|
1290
1357
|
let hue = 0;
|
|
1291
|
-
for (const
|
|
1358
|
+
for (const name of color) {
|
|
1359
|
+
if (palette.has(name))
|
|
1360
|
+
continue;
|
|
1292
1361
|
const c = classifyColorToken(name);
|
|
1293
1362
|
if (c === 'role')
|
|
1294
1363
|
role++;
|
|
@@ -1297,22 +1366,26 @@ function checkSemanticColorVocabulary(tokens) {
|
|
|
1297
1366
|
}
|
|
1298
1367
|
const named = role + hue;
|
|
1299
1368
|
if (named === 0) {
|
|
1300
|
-
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${
|
|
1369
|
+
return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${color.length} color tokens, all neutrally named: vocabulary unclassifiable` };
|
|
1301
1370
|
}
|
|
1302
1371
|
const share = Math.round((role / named) * 100);
|
|
1372
|
+
const layer = palette.size > 0 ? `; ${palette.size} hue-named primitive(s) that role tokens alias are the palette layer, not counted` : '';
|
|
1303
1373
|
if (role >= 3 && share >= 60) {
|
|
1304
|
-
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` };
|
|
1374
|
+
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}` };
|
|
1305
1375
|
}
|
|
1306
|
-
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` };
|
|
1376
|
+
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}` };
|
|
1307
1377
|
}
|
|
1378
|
+
// v43 reads the same color tokens as v42, aliases followed, so --error:
|
|
1379
|
+
// var(--red-700) is an error family. The palette layer needs no exclusion
|
|
1380
|
+
// here: v43 counts families, and a hue-named primitive is never one.
|
|
1308
1381
|
function checkSemanticStatusRoles(tokens) {
|
|
1309
1382
|
const item = 'Semantic status colors: ok/warn/error/info state roles present';
|
|
1310
|
-
const
|
|
1311
|
-
if (
|
|
1383
|
+
const { color } = colorTokenTiers(tokens);
|
|
1384
|
+
if (color.length === 0) {
|
|
1312
1385
|
return { id: 'v43', item, category: 'semantic', status: 'SKIP', detail: 'no color tokens in :root' };
|
|
1313
1386
|
}
|
|
1314
1387
|
const families = { ok: false, warn: false, error: false, info: false };
|
|
1315
|
-
for (const
|
|
1388
|
+
for (const name of color) {
|
|
1316
1389
|
const segments = name.toLowerCase().split(/[-_]/);
|
|
1317
1390
|
if (segments.some((s) => ['ok', 'success', 'pass', 'positive'].includes(s)))
|
|
1318
1391
|
families.ok = true;
|
|
@@ -1705,7 +1778,9 @@ function parseCssColor(input, dark, depth = 0) {
|
|
|
1705
1778
|
const ch = [0, 2, 4, 6].map((k) => (k < full.length ? parseInt(full.slice(k, k + 2), 16) : 255));
|
|
1706
1779
|
return [ch[0], ch[1], ch[2], ch[3] / 255];
|
|
1707
1780
|
}
|
|
1708
|
-
|
|
1781
|
+
// Own keys only: 'constructor' or 'toString' as a value read Object.prototype
|
|
1782
|
+
// and threw, which failed the whole score (engine 1.3.0 reads token values here).
|
|
1783
|
+
if (Object.prototype.hasOwnProperty.call(CSS_NAMED_COLORS, v))
|
|
1709
1784
|
return [...CSS_NAMED_COLORS[v]];
|
|
1710
1785
|
const fn = /^([a-z-]+)\(([\s\S]*)\)$/.exec(v);
|
|
1711
1786
|
if (!fn)
|
|
@@ -2946,17 +3021,25 @@ function checkPausedEntrances(css) {
|
|
|
2946
3021
|
},
|
|
2947
3022
|
};
|
|
2948
3023
|
}
|
|
2949
|
-
// v34 — AI-Disclosure Readiness (EU AI Act Article 50,
|
|
2950
|
-
//
|
|
2951
|
-
//
|
|
2952
|
-
//
|
|
2953
|
-
//
|
|
2954
|
-
//
|
|
2955
|
-
//
|
|
2956
|
-
//
|
|
2957
|
-
//
|
|
2958
|
-
//
|
|
2959
|
-
//
|
|
3024
|
+
// v34 — AI-Disclosure Readiness (EU AI Act Article 50, which applies from
|
|
3025
|
+
// 2026-08-02). Static, from the fetched HTML: it looks for an AI chat surface
|
|
3026
|
+
// and for a disclosure signal (visible text, aria-label, data-ai-disclosure,
|
|
3027
|
+
// generator meta, C2PA, JSON-LD).
|
|
3028
|
+
//
|
|
3029
|
+
// Article 50(1) puts the duty on the PROVIDER of an AI system that talks with
|
|
3030
|
+
// people: they must be told it is an AI, unless that is obvious from the
|
|
3031
|
+
// context, at the latest at the first interaction (50(5)). A site that embeds
|
|
3032
|
+
// a vendor's assistant may not be that provider, so the check says "may
|
|
3033
|
+
// require", never "requires", and it is not a legal finding.
|
|
3034
|
+
//
|
|
3035
|
+
// Only a positively identified AI chat surface with no disclosure FAILs: a
|
|
3036
|
+
// container or input that names itself a chatbot or an AI assistant, or a
|
|
3037
|
+
// script from a chatbot, AI-widget or Dialogflow path. Anything weaker WARNs
|
|
3038
|
+
// "possible AI surface: review": live-chat vendors (Intercom, Drift, Zendesk,
|
|
3039
|
+
// LiveChat, Tawk, Crisp), which are often answered by people; a generic chat
|
|
3040
|
+
// container or an "assistant" script; page text about AI, which is not a chat
|
|
3041
|
+
// surface. Until engine 1.3.0 those FAILed, and so did Google Publisher Tag,
|
|
3042
|
+
// an ad tag, because its gpt.js matched the word "gpt".
|
|
2960
3043
|
function checkAiDisclosure(html) {
|
|
2961
3044
|
const ITEM = 'AI-Disclosure Readiness (EU AI Act Art 50, effective 2026-08-02)';
|
|
2962
3045
|
const CATEGORY = 'identity';
|
|
@@ -2969,20 +3052,31 @@ function checkAiDisclosure(html) {
|
|
|
2969
3052
|
// false-positive as disclosure TEXT. Disclosure text must be human-readable
|
|
2970
3053
|
// content between tags, not class/id/aria attribute values.
|
|
2971
3054
|
const textOnly = visibleHtml.replace(/<[^>]+>/g, ' ');
|
|
2972
|
-
// ── AI
|
|
2973
|
-
const
|
|
2974
|
-
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|
|
|
2975
|
-
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;
|
|
3055
|
+
// ── AI chat surfaces, positively identified ─────────────────────────────
|
|
3056
|
+
const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
|
|
3057
|
+
const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
|
|
2976
3058
|
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;
|
|
3059
|
+
// ── Possible AI surfaces: a review item, never a FAIL ───────────────────
|
|
3060
|
+
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;
|
|
3061
|
+
const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
|
|
3062
|
+
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;
|
|
3063
|
+
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
2977
3064
|
const surfaceSignals = [];
|
|
2978
|
-
if (
|
|
3065
|
+
if (aiContainerRe.test(html))
|
|
2979
3066
|
surfaceSignals.push('chatbot container');
|
|
2980
3067
|
if (aiScriptRe.test(html))
|
|
2981
3068
|
surfaceSignals.push('AI widget script');
|
|
2982
|
-
if (aiTextRe.test(textOnly))
|
|
2983
|
-
surfaceSignals.push('AI prompt text');
|
|
2984
3069
|
if (aiInputRe.test(html))
|
|
2985
3070
|
surfaceSignals.push('AI input');
|
|
3071
|
+
const possibleSignals = [];
|
|
3072
|
+
if (chatContainerRe.test(html))
|
|
3073
|
+
possibleSignals.push('chat widget container');
|
|
3074
|
+
if (chatScriptRe.test(html))
|
|
3075
|
+
possibleSignals.push('chat or assistant script');
|
|
3076
|
+
if (aiTextRe.test(textOnly))
|
|
3077
|
+
possibleSignals.push('AI prompt text');
|
|
3078
|
+
if (ambiguousRe.test(html))
|
|
3079
|
+
possibleSignals.push('ambiguous widget');
|
|
2986
3080
|
// ── Disclosure detection ────────────────────────────────────────────────
|
|
2987
3081
|
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;
|
|
2988
3082
|
const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
|
|
@@ -3004,17 +3098,17 @@ function checkAiDisclosure(html) {
|
|
|
3004
3098
|
if (dataAiRe.test(html))
|
|
3005
3099
|
disclosureSignals.push('data-ai-disclosure');
|
|
3006
3100
|
// ── Condition evaluation ────────────────────────────────────────────────
|
|
3007
|
-
if (surfaceSignals.length === 0) {
|
|
3008
|
-
const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
|
|
3009
|
-
if (ambiguousRe.test(html)) {
|
|
3010
|
-
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'WARN', detail: 'possible AI-interactive surface (ambiguous widget): manual review recommended for Art 50 compliance' };
|
|
3011
|
-
}
|
|
3101
|
+
if (surfaceSignals.length === 0 && possibleSignals.length === 0) {
|
|
3012
3102
|
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'no AI-interactive surface detected: disclosure not required' };
|
|
3013
3103
|
}
|
|
3014
3104
|
if (disclosureSignals.length > 0) {
|
|
3015
|
-
|
|
3105
|
+
const found = surfaceSignals.length > 0 ? `AI surface detected (${surfaceSignals.join(', ')})` : `possible AI surface (${possibleSignals.join(', ')})`;
|
|
3106
|
+
return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: `${found}; disclosure present (${disclosureSignals.join(', ')})` };
|
|
3107
|
+
}
|
|
3108
|
+
if (surfaceSignals.length > 0) {
|
|
3109
|
+
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)` };
|
|
3016
3110
|
}
|
|
3017
|
-
return { id: 'v34', item: ITEM, category: CATEGORY, status: '
|
|
3111
|
+
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` };
|
|
3018
3112
|
}
|
|
3019
3113
|
// v35 — Forced-colors readiness (Windows High Contrast Mode / Chrome forced-colors).
|
|
3020
3114
|
// Verifies the site has a @media (forced-colors: active) block, ideally with
|
|
@@ -3916,9 +4010,25 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
3916
4010
|
// strengths. These are design-integrity failures — a site that FAILs on
|
|
3917
4011
|
// contrast or horizontal overflow cannot be A-grade no matter how good its
|
|
3918
4012
|
// tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
|
|
4013
|
+
//
|
|
4014
|
+
// No A while any check FAILs (engine 1.3.0, owner decision 2026-10-11). A
|
|
4015
|
+
// FAIL is the engine's own finding that the page breaks a rule, so any FAIL
|
|
4016
|
+
// caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
|
|
4017
|
+
// A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
|
|
4018
|
+
// (v44). The specific ceilings below are lower and win where they apply.
|
|
4019
|
+
// Every cap here runs after the originality lift, so the lift can carry a
|
|
4020
|
+
// score up to a cap but never past one.
|
|
3919
4021
|
const hardFailChecks = checks.filter((c) => c.status === 'FAIL');
|
|
3920
4022
|
let hardFailCeilingApplied = false;
|
|
3921
4023
|
let hardFailCeilingReason = null;
|
|
4024
|
+
const ANY_FAIL_CAP = 89.9;
|
|
4025
|
+
if (hardFailChecks.length > 0 && score > ANY_FAIL_CAP) {
|
|
4026
|
+
const ids = hardFailChecks.map((c) => c.id);
|
|
4027
|
+
const named = ids.length === 1 ? `${ids[0]} fails` : `${ids.slice(0, -1).join(', ')} and ${ids[ids.length - 1]} fail`;
|
|
4028
|
+
score = ANY_FAIL_CAP;
|
|
4029
|
+
hardFailCeilingApplied = true;
|
|
4030
|
+
hardFailCeilingReason = `A FAIL caps the grade at B: ${named}, and the score cannot exceed 89.9 while any check fails.`;
|
|
4031
|
+
}
|
|
3922
4032
|
for (const c of hardFailChecks) {
|
|
3923
4033
|
let cap = null;
|
|
3924
4034
|
let reason = null;
|
|
@@ -3927,6 +4037,14 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
|
|
|
3927
4037
|
cap = 65;
|
|
3928
4038
|
reason = 'Contrast below WCAG minimum: text is unreadable for many users.';
|
|
3929
4039
|
}
|
|
4040
|
+
// v44 Status text contrast — the words that report a state fail WCAG
|
|
4041
|
+
// 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
|
|
4042
|
+
// Same cap as v06 (engine 1.3.0, owner decision 2026-10-11): both are
|
|
4043
|
+
// measured WCAG contrast failures on text.
|
|
4044
|
+
if (c.id === 'v44') {
|
|
4045
|
+
cap = 65;
|
|
4046
|
+
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.';
|
|
4047
|
+
}
|
|
3930
4048
|
// v22 Contrast signal — CTA text unreadable on brand color
|
|
3931
4049
|
if (c.id === 'v22') {
|
|
3932
4050
|
cap = 70;
|
|
@@ -4586,7 +4704,7 @@ export async function scoreFromParts(input) {
|
|
|
4586
4704
|
* the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
|
|
4587
4705
|
* contract source and fails when the two differ.
|
|
4588
4706
|
*/
|
|
4589
|
-
export const CONTRACT_VERSION = 'v0.4.
|
|
4707
|
+
export const CONTRACT_VERSION = 'v0.4.4';
|
|
4590
4708
|
/**
|
|
4591
4709
|
* The one verdict rule, shared by every emission format.
|
|
4592
4710
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@designesy/score",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Standalone 44-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. No required dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Designesy <hello@designesy.org>",
|