@designesy/score 0.6.0 → 0.7.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/dist/engine.js CHANGED
@@ -1,13 +1,14 @@
1
1
  /**
2
- * @designesy/score — 42-check design-contract scoring engine.
2
+ * @designesy/score — 44-check design-contract scoring engine.
3
3
  *
4
4
  * Scores a URL against the Designesy design-system contract: fetches the page,
5
- * extracts CSS + :root tokens, runs 40 deterministic checks, computes a weighted
6
- * score (0-100), assigns a letter grade (A-F), and applies anti-slop deductions
7
- * + originality lifts.
5
+ * extracts CSS + :root tokens, runs 44 checks (41 scored, 3 that need a
6
+ * browser), computes a weighted score (0-100), assigns a letter grade (A-F),
7
+ * and applies anti-slop deductions + originality lifts.
8
8
  *
9
- * Zero dependencies. Uses node:https (not fetch/undici) to avoid the Windows
10
- * libuv crash during process exit.
9
+ * No required dependencies. Uses node:https (not fetch/undici) to avoid the
10
+ * Windows libuv crash during process exit. One optional dependency,
11
+ * @google/design.md, lints /DESIGN.md for v37; without it v37 is MANUAL.
11
12
  *
12
13
  * Extracted from apps/site/app/api/score/route.ts — same engine, same checks,
13
14
  * same scoring math. The API route wraps this module; the CLI calls it directly.
@@ -468,10 +469,12 @@ const REMEDIATION = {
468
469
  v29: 'Structure design tokens in layers: primitive (raw values like --color-blue-500: #3b82f6), semantic (aliases like --color-accent: var(--color-blue-500)), and component (references like --button-bg: var(--color-accent)). At minimum, alias some tokens via var() so a color change propagates through the system. Full 3-tier architecture is DSAF A1.1 maturity level.',
469
470
  v42: 'Name your color tokens by ROLE, not by hue. The contract names colors by what they mean: --ink (text), --paper (background), --surface (panels), --muted (secondary text), --signal (brand accent), --ok/--warn/--error (status). Hue names like --blue-500 or --slate-900 describe wavelength, not usage: when the brand palette shifts or dark mode lands, every hue-named reference must be hunted down and rewritten. Keep hue primitives in a separate tier and alias them to role tokens via var().',
470
471
  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
+ 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
+ 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.',
471
474
  v34: 'EU AI Act Article 50(1) requires AI chatbots/agents to disclose their AI nature at the first interaction, accessible to people with disabilities (effective 2026-08-02). Fix options (any one): (1) add visible "AI Assistant" or "Chatbot" text in the chatbot UI header, (2) add aria-label="AI assistant" to the chatbot container, (3) add <meta name="generator" content="AI-powered"> to the page head, (4) add C2PA Content Credentials to AI-generated images, (5) add a persistent AI-disclosure badge in footer/header. US parallels: California AB 2659, Colorado AI Act (Feb 2026).',
472
475
  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).',
473
476
  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.',
474
- 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 42-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.',
477
+ 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.',
475
478
  v38: 'Rewrite button labels to start with a verb or recognized command. NN/g: "Lead with verbs or verb phrases that clearly outline what will happen after the command is selected." Use "Save changes" not "Changes", "Delete file" not "File". Recognized commands: Save, Cancel, Delete, Edit, Share, Close, Back, Next, etc. This is a WARN (heuristic): review flagged buttons manually.',
476
479
  v39: 'Remove trailing periods from button text, labels, and tab text. Microsoft Fluent: "Don\'t end text for buttons, radio buttons, labels, or checkboxes with a period." Periods are for full sentences in tooltips, error messages, and dialog bodies only.',
477
480
  v40: 'Replace non-descriptive link text with destination-revealing text. WCAG 2.4.4 Link Purpose: link text should describe the destination. Use "Read the typography guide" not "Click here". Use "View the leaderboard" not "Learn more". NN/g: non-descriptive links force users to read surrounding context to understand the destination.',
@@ -1327,6 +1330,1622 @@ function checkSemanticStatusRoles(tokens) {
1327
1330
  }
1328
1331
  return { id: 'v43', item, category: 'semantic', status: 'WARN', detail: `${found.length}/4 status families present${found.length ? ` (${found.join(', ')})` : ''}; missing: ${missing.join(', ')}` };
1329
1332
  }
1333
+ function stripCssComments(css) {
1334
+ const out = [];
1335
+ const n = css.length;
1336
+ let last = 0;
1337
+ let i = 0;
1338
+ while (i < n) {
1339
+ const c = css.charCodeAt(i);
1340
+ if (c === 47 && css.charCodeAt(i + 1) === 42) {
1341
+ out.push(css.slice(last, i), ' ');
1342
+ const end = css.indexOf('*/', i + 2);
1343
+ i = end < 0 ? n : end + 2;
1344
+ last = i;
1345
+ continue;
1346
+ }
1347
+ if (c === 34 || c === 39) {
1348
+ i++;
1349
+ while (i < n) {
1350
+ const d = css.charCodeAt(i);
1351
+ if (d === 92) {
1352
+ i += 2;
1353
+ continue;
1354
+ }
1355
+ i++;
1356
+ if (d === c || d === 10)
1357
+ break;
1358
+ }
1359
+ continue;
1360
+ }
1361
+ i++;
1362
+ }
1363
+ out.push(css.slice(last));
1364
+ return out.join('');
1365
+ }
1366
+ /** Split at top-level separators, outside parentheses, brackets and quotes. */
1367
+ function splitCssTopLevel(s, sep) {
1368
+ const out = [];
1369
+ let depth = 0;
1370
+ let quote = '';
1371
+ let from = 0;
1372
+ const push = (to) => {
1373
+ const piece = s.slice(from, to).trim();
1374
+ if (piece)
1375
+ out.push(piece);
1376
+ };
1377
+ for (let i = 0; i < s.length; i++) {
1378
+ const ch = s[i];
1379
+ if (quote) {
1380
+ if (ch === '\\')
1381
+ i++;
1382
+ else if (ch === quote)
1383
+ quote = '';
1384
+ continue;
1385
+ }
1386
+ if (ch === '"' || ch === "'") {
1387
+ quote = ch;
1388
+ continue;
1389
+ }
1390
+ if (ch === '(' || ch === '[')
1391
+ depth++;
1392
+ else if ((ch === ')' || ch === ']') && depth > 0)
1393
+ depth--;
1394
+ else if (depth === 0 && (sep === ' ' ? ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r' || ch === '\f' : ch === sep)) {
1395
+ push(i);
1396
+ from = i + 1;
1397
+ }
1398
+ }
1399
+ push(s.length);
1400
+ return out;
1401
+ }
1402
+ /** A selector on one line, cut to 120 characters, for a finding. */
1403
+ function shortSelector(selector) {
1404
+ const one = selector.replace(/\s+/g, ' ').trim();
1405
+ return one.length > 120 ? `${one.slice(0, 117)}...` : one;
1406
+ }
1407
+ // v44 and v45 read the same sheet; the second parse of the same string is
1408
+ // served from here.
1409
+ let lastCssParse = null;
1410
+ function parseCssRules(css) {
1411
+ if (lastCssParse && lastCssParse.css === css)
1412
+ return lastCssParse.result;
1413
+ const result = parseCssRulesUncached(css);
1414
+ lastCssParse = { css, result };
1415
+ return result;
1416
+ }
1417
+ function parseCssRulesUncached(css) {
1418
+ const src = stripCssComments(css);
1419
+ const n = src.length;
1420
+ const rules = [];
1421
+ const keyframes = [];
1422
+ const stack = [];
1423
+ let order = 0;
1424
+ let start = 0;
1425
+ let paren = 0;
1426
+ const close = (f, end) => {
1427
+ f.parts.push(src.slice(f.segStart, end));
1428
+ const decls = f.parts.join(' ').trim();
1429
+ if (f.kind === 'frame' && f.kf)
1430
+ f.kf.frames.push({ selector: f.prelude.toLowerCase(), decls });
1431
+ else if ((f.kind === 'rule' || (f.kind === 'group' && f.selector)) && decls) {
1432
+ rules.push({ selector: f.selector, decls, at: f.at, order: order++ });
1433
+ }
1434
+ };
1435
+ let i = 0;
1436
+ while (i < n) {
1437
+ const c = src.charCodeAt(i);
1438
+ if (c === 34 || c === 39) {
1439
+ i++;
1440
+ while (i < n) {
1441
+ const d = src.charCodeAt(i);
1442
+ if (d === 92) {
1443
+ i += 2;
1444
+ continue;
1445
+ }
1446
+ i++;
1447
+ if (d === c || d === 10)
1448
+ break;
1449
+ }
1450
+ continue;
1451
+ }
1452
+ // An unquoted url(...) may hold braces and semicolons: skip it whole. A
1453
+ // quoted one is read as a string, because its text can hold `)`
1454
+ // (`url("data:...filter='url(%23n)'...")`).
1455
+ if ((c === 117 || c === 85) && src.slice(i, i + 4).toLowerCase() === 'url(') {
1456
+ let j = i + 4;
1457
+ while (j < n && (src[j] === ' ' || src[j] === '\t' || src[j] === '\n' || src[j] === '\r'))
1458
+ j++;
1459
+ if (src[j] !== '"' && src[j] !== "'") {
1460
+ const end = src.indexOf(')', j);
1461
+ i = end < 0 ? n : end + 1;
1462
+ continue;
1463
+ }
1464
+ }
1465
+ if (c === 40) {
1466
+ paren++;
1467
+ i++;
1468
+ continue;
1469
+ }
1470
+ if (c === 41) {
1471
+ if (paren > 0)
1472
+ paren--;
1473
+ i++;
1474
+ continue;
1475
+ }
1476
+ if (c === 59) {
1477
+ if (paren === 0)
1478
+ start = i + 1;
1479
+ i++;
1480
+ continue;
1481
+ }
1482
+ if (c === 123) {
1483
+ paren = 0;
1484
+ const prelude = src.slice(start, i).trim();
1485
+ const parent = stack.length ? stack[stack.length - 1] : null;
1486
+ if (parent)
1487
+ parent.parts.push(src.slice(parent.segStart, start));
1488
+ const at = parent ? parent.at : [];
1489
+ const ctx = parent && (parent.kind === 'rule' || parent.kind === 'group') ? parent.selector : '';
1490
+ let f;
1491
+ if (prelude.startsWith('@')) {
1492
+ const name = (/^@([\w-]+)/.exec(prelude)?.[1] ?? '').toLowerCase();
1493
+ if (/keyframes$/.test(name)) {
1494
+ const kf = { name: prelude.slice(name.length + 1).trim().replace(/^["']|["']$/g, ''), frames: [] };
1495
+ keyframes.push(kf);
1496
+ f = { kind: 'keyframes', prelude, selector: '', at, parts: [], segStart: i + 1, kf };
1497
+ }
1498
+ else if (/^(media|supports|layer|container|document|-moz-document|scope|starting-style)$/.test(name)) {
1499
+ f = { kind: 'group', prelude, selector: ctx, at: [...at, prelude.toLowerCase()], parts: [], segStart: i + 1, kf: null };
1500
+ }
1501
+ else {
1502
+ f = { kind: 'other', prelude, selector: '', at, parts: [], segStart: i + 1, kf: null };
1503
+ }
1504
+ }
1505
+ else if (parent && parent.kind === 'keyframes') {
1506
+ f = { kind: 'frame', prelude, selector: '', at, parts: [], segStart: i + 1, kf: parent.kf };
1507
+ }
1508
+ else if (parent && (parent.kind === 'other' || parent.kind === 'frame')) {
1509
+ f = { kind: 'other', prelude, selector: '', at, parts: [], segStart: i + 1, kf: null };
1510
+ }
1511
+ else {
1512
+ const selector = ctx
1513
+ ? splitCssTopLevel(prelude, ',').map((p) => (p.includes('&') ? p.replace(/&/g, `:is(${ctx})`) : `:is(${ctx}) ${p}`)).join(', ')
1514
+ : prelude;
1515
+ f = { kind: 'rule', prelude, selector, at, parts: [], segStart: i + 1, kf: null };
1516
+ }
1517
+ stack.push(f);
1518
+ start = i + 1;
1519
+ i++;
1520
+ continue;
1521
+ }
1522
+ if (c === 125) {
1523
+ paren = 0;
1524
+ const f = stack.pop();
1525
+ if (f) {
1526
+ close(f, i);
1527
+ const parent = stack.length ? stack[stack.length - 1] : null;
1528
+ if (parent)
1529
+ parent.segStart = i + 1;
1530
+ }
1531
+ start = i + 1;
1532
+ i++;
1533
+ continue;
1534
+ }
1535
+ i++;
1536
+ }
1537
+ while (stack.length) {
1538
+ const f = stack.pop();
1539
+ if (f)
1540
+ close(f, n);
1541
+ }
1542
+ return { rules, keyframes };
1543
+ }
1544
+ function parseCssDecls(decls) {
1545
+ const out = [];
1546
+ for (const raw of splitCssTopLevel(decls, ';')) {
1547
+ const colon = raw.indexOf(':');
1548
+ if (colon <= 0)
1549
+ continue;
1550
+ const name = raw.slice(0, colon).trim();
1551
+ if (!/^-{0,2}[a-zA-Z_][\w-]*$/.test(name))
1552
+ continue;
1553
+ let value = raw.slice(colon + 1).trim();
1554
+ const important = /!\s*important\s*$/i.test(value);
1555
+ if (important)
1556
+ value = value.replace(/\s*!\s*important\s*$/i, '').trim();
1557
+ out.push({ prop: name.startsWith('--') ? name : name.toLowerCase(), value, important });
1558
+ }
1559
+ return out;
1560
+ }
1561
+ /** Each var(--x[, fallback]) replaced by its value in the first scope that declares it. */
1562
+ function substituteCssVars(value, scope, depth = 0) {
1563
+ if (depth > 16)
1564
+ return null;
1565
+ let out = '';
1566
+ let i = 0;
1567
+ for (;;) {
1568
+ const k = value.indexOf('var(', i);
1569
+ if (k < 0)
1570
+ return out + value.slice(i);
1571
+ out += value.slice(i, k);
1572
+ let d = 0;
1573
+ let j = k + 3;
1574
+ for (; j < value.length; j++) {
1575
+ if (value[j] === '(')
1576
+ d++;
1577
+ else if (value[j] === ')' && --d === 0)
1578
+ break;
1579
+ }
1580
+ if (j >= value.length)
1581
+ return null;
1582
+ const inner = value.slice(k + 4, j);
1583
+ const parts = splitCssTopLevel(inner, ',');
1584
+ const name = (parts[0] ?? '').trim();
1585
+ const comma = inner.indexOf(',');
1586
+ const fallback = comma < 0 ? null : inner.slice(comma + 1).trim();
1587
+ let rep = null;
1588
+ for (const s of scope) {
1589
+ if (Object.prototype.hasOwnProperty.call(s, name)) {
1590
+ rep = s[name];
1591
+ break;
1592
+ }
1593
+ }
1594
+ if (rep === null)
1595
+ rep = fallback;
1596
+ if (rep === null)
1597
+ return null;
1598
+ const sub = substituteCssVars(rep, scope, depth + 1);
1599
+ if (sub === null)
1600
+ return null;
1601
+ out += sub;
1602
+ i = j + 1;
1603
+ }
1604
+ }
1605
+ const CSS_NAMED_COLORS = {
1606
+ transparent: [0, 0, 0, 0], white: [255, 255, 255, 1], black: [0, 0, 0, 1],
1607
+ red: [255, 0, 0, 1], green: [0, 128, 0, 1], blue: [0, 0, 255, 1], yellow: [255, 255, 0, 1],
1608
+ orange: [255, 165, 0, 1], gray: [128, 128, 128, 1], grey: [128, 128, 128, 1],
1609
+ silver: [192, 192, 192, 1], maroon: [128, 0, 0, 1], purple: [128, 0, 128, 1],
1610
+ navy: [0, 0, 128, 1], teal: [0, 128, 128, 1], olive: [128, 128, 0, 1], lime: [0, 255, 0, 1],
1611
+ };
1612
+ function srgbToOklab(rgb) {
1613
+ const lin = rgb.map((c) => {
1614
+ const s = c / 255;
1615
+ return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
1616
+ });
1617
+ const l = Math.cbrt(0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]);
1618
+ const m = Math.cbrt(0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]);
1619
+ const s = Math.cbrt(0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]);
1620
+ return [
1621
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1622
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
1623
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
1624
+ ];
1625
+ }
1626
+ /** Oklab to 0-255 sRGB, clipped per channel to the sRGB gamut. */
1627
+ function oklabToSrgb(lab) {
1628
+ const [L, a, b] = lab;
1629
+ const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
1630
+ const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
1631
+ const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
1632
+ const lin = [
1633
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
1634
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
1635
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
1636
+ ];
1637
+ return lin.map((c) => {
1638
+ const x = Math.min(1, Math.max(0, c));
1639
+ const g = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
1640
+ return Math.min(1, Math.max(0, g)) * 255;
1641
+ });
1642
+ }
1643
+ function cssNumber(token, percentScale) {
1644
+ const t = token.trim().toLowerCase();
1645
+ if (t === 'none')
1646
+ return 0;
1647
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%|deg|turn|rad|grad)?$/.exec(t);
1648
+ if (!m)
1649
+ return null;
1650
+ const x = parseFloat(m[1]);
1651
+ if (m[2] === '%')
1652
+ return (x / 100) * percentScale;
1653
+ if (m[2] === 'turn')
1654
+ return x * 360;
1655
+ if (m[2] === 'rad')
1656
+ return (x * 180) / Math.PI;
1657
+ if (m[2] === 'grad')
1658
+ return x * 0.9;
1659
+ return x;
1660
+ }
1661
+ function cssAlpha(token) {
1662
+ if (token === undefined)
1663
+ return 1;
1664
+ const a = cssNumber(token, 1);
1665
+ return a === null ? null : Math.min(1, Math.max(0, a));
1666
+ }
1667
+ /** Function arguments as [c1, c2, c3, alpha?], comma or space syntax. */
1668
+ function cssColorArgs(args) {
1669
+ const slash = splitCssTopLevel(args, '/');
1670
+ const head = slash[0] ?? '';
1671
+ const channels = head.includes(',') ? splitCssTopLevel(head, ',') : splitCssTopLevel(head, ' ');
1672
+ if (slash.length === 2)
1673
+ channels.push(slash[1]);
1674
+ if (slash.length > 2 || channels.length < 3 || channels.length > 4)
1675
+ return null;
1676
+ return channels;
1677
+ }
1678
+ function hslToRgb(h, s, l) {
1679
+ const hh = (((h % 360) + 360) % 360) / 360;
1680
+ const f = (n) => {
1681
+ const k = (n + hh * 12) % 12;
1682
+ return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
1683
+ };
1684
+ return [f(0) * 255, f(8) * 255, f(4) * 255];
1685
+ }
1686
+ /**
1687
+ * A CSS colour value, vars already substituted, to [r, g, b, alpha] in 0-255
1688
+ * sRGB. Hex, the common named colours, rgb(), hsl(), oklch(), oklab(),
1689
+ * color(srgb), light-dark(), color-mix() in srgb, oklab or oklch (premultiplied,
1690
+ * rounded to 8 bits as a browser paints it), and the identity form of relative
1691
+ * colour syntax (`rgb(from X r g b / a)`). Anything else returns null: an
1692
+ * unmeasurable colour is left out, never guessed.
1693
+ */
1694
+ function parseCssColor(input, dark, depth = 0) {
1695
+ if (depth > 8)
1696
+ return null;
1697
+ const v = input.trim().toLowerCase();
1698
+ if (!v)
1699
+ return null;
1700
+ if (v[0] === '#') {
1701
+ const hex = v.slice(1);
1702
+ if (!/^[0-9a-f]+$/.test(hex) || ![3, 4, 6, 8].includes(hex.length))
1703
+ return null;
1704
+ const full = hex.length <= 4 ? hex.split('').map((c) => c + c).join('') : hex;
1705
+ const ch = [0, 2, 4, 6].map((k) => (k < full.length ? parseInt(full.slice(k, k + 2), 16) : 255));
1706
+ return [ch[0], ch[1], ch[2], ch[3] / 255];
1707
+ }
1708
+ if (CSS_NAMED_COLORS[v])
1709
+ return [...CSS_NAMED_COLORS[v]];
1710
+ const fn = /^([a-z-]+)\(([\s\S]*)\)$/.exec(v);
1711
+ if (!fn)
1712
+ return null;
1713
+ const name = fn[1];
1714
+ const args = fn[2].trim();
1715
+ if (name === 'light-dark') {
1716
+ const pair = splitCssTopLevel(args, ',');
1717
+ return pair.length === 2 ? parseCssColor(pair[dark ? 1 : 0], dark, depth + 1) : null;
1718
+ }
1719
+ if (name === 'color-mix') {
1720
+ const parts = splitCssTopLevel(args, ',');
1721
+ if (parts.length !== 3)
1722
+ return null;
1723
+ const space = /^in\s+([a-z-]+)/.exec(parts[0])?.[1] ?? '';
1724
+ if (!['srgb', 'oklab', 'oklch'].includes(space))
1725
+ return null;
1726
+ const side = (p) => {
1727
+ const after = /^([\s\S]*?)\s+(\d*\.?\d+)%$/.exec(p);
1728
+ const before = /^(\d*\.?\d+)%\s+([\s\S]*)$/.exec(p);
1729
+ if (after)
1730
+ return { color: after[1], pct: parseFloat(after[2]) / 100 };
1731
+ if (before)
1732
+ return { color: before[2], pct: parseFloat(before[1]) / 100 };
1733
+ return { color: p, pct: null };
1734
+ };
1735
+ const a = side(parts[1]);
1736
+ const b = side(parts[2]);
1737
+ const ca = parseCssColor(a.color, dark, depth + 1);
1738
+ const cb = parseCssColor(b.color, dark, depth + 1);
1739
+ if (!ca || !cb)
1740
+ return null;
1741
+ let p1 = a.pct;
1742
+ let p2 = b.pct;
1743
+ if (p1 === null && p2 === null) {
1744
+ p1 = 0.5;
1745
+ p2 = 0.5;
1746
+ }
1747
+ else if (p1 === null)
1748
+ p1 = 1 - p2;
1749
+ else if (p2 === null)
1750
+ p2 = 1 - p1;
1751
+ const sum = p1 + p2;
1752
+ if (sum <= 0)
1753
+ return null;
1754
+ const w1 = p1 / sum;
1755
+ const w2 = p2 / sum;
1756
+ const alpha = ca[3] * w1 + cb[3] * w2;
1757
+ if (alpha <= 0)
1758
+ return [0, 0, 0, 0];
1759
+ let rgb;
1760
+ if (space === 'srgb') {
1761
+ rgb = [0, 1, 2].map((k) => (ca[k] * ca[3] * w1 + cb[k] * cb[3] * w2) / alpha);
1762
+ }
1763
+ else {
1764
+ const la = srgbToOklab([ca[0], ca[1], ca[2]]);
1765
+ const lb = srgbToOklab([cb[0], cb[1], cb[2]]);
1766
+ let lab;
1767
+ if (space === 'oklab') {
1768
+ lab = [0, 1, 2].map((k) => (la[k] * ca[3] * w1 + lb[k] * cb[3] * w2) / alpha);
1769
+ }
1770
+ else {
1771
+ const lch = (x) => [x[0], Math.hypot(x[1], x[2]), (Math.atan2(x[2], x[1]) * 180) / Math.PI];
1772
+ const A = lch(la);
1773
+ const B = lch(lb);
1774
+ let h1 = A[2];
1775
+ let h2 = B[2];
1776
+ if (A[1] < 1e-4)
1777
+ h1 = h2;
1778
+ if (B[1] < 1e-4)
1779
+ h2 = h1;
1780
+ if (h2 - h1 > 180)
1781
+ h1 += 360;
1782
+ else if (h1 - h2 > 180)
1783
+ h2 += 360;
1784
+ const L = (A[0] * ca[3] * w1 + B[0] * cb[3] * w2) / alpha;
1785
+ const C = (A[1] * ca[3] * w1 + B[1] * cb[3] * w2) / alpha;
1786
+ const H = ((h1 * w1 + h2 * w2) * Math.PI) / 180;
1787
+ lab = [L, C * Math.cos(H), C * Math.sin(H)];
1788
+ }
1789
+ rgb = oklabToSrgb(lab);
1790
+ }
1791
+ const mult = Math.min(1, sum);
1792
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2]), Math.round(alpha * mult * 255) / 255];
1793
+ }
1794
+ const rel = /^from\s+([\s\S]+?)\s+(r\s+g\s+b|h\s+s\s+l|l\s+c\s+h|l\s+a\s+b)\s*(?:\/\s*([\s\S]+))?$/.exec(args);
1795
+ if (rel) {
1796
+ const base = parseCssColor(rel[1], dark, depth + 1);
1797
+ if (!base)
1798
+ return null;
1799
+ const a = rel[3] === undefined ? base[3] : cssAlpha(rel[3]);
1800
+ return a === null ? null : [base[0], base[1], base[2], a];
1801
+ }
1802
+ if (name === 'color') {
1803
+ const m = /^srgb\s+([\s\S]+)$/.exec(args);
1804
+ const ch = m ? cssColorArgs(m[1]) : null;
1805
+ if (!ch)
1806
+ return null;
1807
+ const c = ch.slice(0, 3).map((t) => cssNumber(t, 1));
1808
+ const a = cssAlpha(ch[3]);
1809
+ if (c.some((x) => x === null) || a === null)
1810
+ return null;
1811
+ return [c[0] * 255, c[1] * 255, c[2] * 255, a];
1812
+ }
1813
+ const ch = cssColorArgs(args);
1814
+ if (!ch)
1815
+ return null;
1816
+ const alpha = cssAlpha(ch[3]);
1817
+ if (alpha === null)
1818
+ return null;
1819
+ if (name === 'rgb' || name === 'rgba') {
1820
+ const c = ch.slice(0, 3).map((t) => cssNumber(t, 255));
1821
+ if (c.some((x) => x === null))
1822
+ return null;
1823
+ return [...c.map((x) => Math.min(255, Math.max(0, x))), alpha];
1824
+ }
1825
+ if (name === 'hsl' || name === 'hsla') {
1826
+ const h = cssNumber(ch[0], 1);
1827
+ const s = cssNumber(ch[1].endsWith('%') ? ch[1] : `${ch[1]}%`, 1);
1828
+ const l = cssNumber(ch[2].endsWith('%') ? ch[2] : `${ch[2]}%`, 1);
1829
+ if (h === null || s === null || l === null)
1830
+ return null;
1831
+ return [...hslToRgb(h, Math.min(1, Math.max(0, s)), Math.min(1, Math.max(0, l))), alpha];
1832
+ }
1833
+ if (name === 'oklch' || name === 'oklab') {
1834
+ const L = cssNumber(ch[0], 1);
1835
+ const x = cssNumber(ch[1], 0.4);
1836
+ const y = cssNumber(ch[2], name === 'oklch' ? 1 : 0.4);
1837
+ if (L === null || x === null || y === null)
1838
+ return null;
1839
+ const lab = name === 'oklch'
1840
+ ? [L, x * Math.cos((y * Math.PI) / 180), x * Math.sin((y * Math.PI) / 180)]
1841
+ : [L, x, y];
1842
+ return [...oklabToSrgb(lab), alpha];
1843
+ }
1844
+ return null;
1845
+ }
1846
+ /** A token value resolved to a colour in a theme scope; bare HSL channels read as hsl(). */
1847
+ function resolveThemeColor(value, scope, dark) {
1848
+ const sub = substituteCssVars(value, scope);
1849
+ if (sub === null)
1850
+ return null;
1851
+ const direct = parseCssColor(sub, dark);
1852
+ if (direct)
1853
+ return direct;
1854
+ if (/^\s*-?[\d.]+(?:deg)?\s+[\d.]+%\s+[\d.]+%\s*$/.test(sub))
1855
+ return parseCssColor(`hsl(${sub})`, dark);
1856
+ return null;
1857
+ }
1858
+ function toHexColor(rgb) {
1859
+ return `#${[0, 1, 2].map((k) => Math.round(rgb[k]).toString(16).padStart(2, '0')).join('')}`;
1860
+ }
1861
+ /** Name segments of a custom property: camelCase and separators split, lowercased. */
1862
+ function tokenNameSegments(name) {
1863
+ return name.replace(/^--/, '').replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase().split(/[-_]+/).filter(Boolean);
1864
+ }
1865
+ // ── v44 Status colors used as text meet contrast in every declared theme ────
1866
+ // A colour picked to mark a state (a dot, a bar, a tint: 3:1 is enough for a
1867
+ // mark) gets reused to write the word for that state, and text needs 4.5:1.
1868
+ // Found on designesy.org itself: light --warn #b07d04 as text measured 3.51:1
1869
+ // on #fbfbfc; mixed toward the ink, color-mix(in oklab, var(--warn) 70%,
1870
+ // var(--ink)) paints #7f5e21 and measures 5.76:1.
1871
+ //
1872
+ // A status hue is a custom property named in the status vocabulary (v43's
1873
+ // families plus grade-a to grade-f), or one the stylesheet also paints as a
1874
+ // non-text mark (background, border, outline, fill, stroke) whose value has a
1875
+ // hue (Oklch chroma 0.06 or more, so white, black and greys are not hues). An
1876
+ // on-fill colour (--danger-foreground, --on-error, --x-contrast, --x-inverse)
1877
+ // is not a status hue: it is text for a filled surface the stylesheet does not
1878
+ // pair with it, and measuring it on the page would be wrong in both directions.
1879
+ // A text use is color or -webkit-text-fill-color reading a status hue
1880
+ // directly, through one alias, or at reduced alpha.
1881
+ //
1882
+ // Only a status-named colour can FAIL, and only where it resolves to a hue
1883
+ // (Oklch chroma 0.06 or more). A neutral value under a status name (white or
1884
+ // grey text for a counter or a hint on a danger button) WARNs at most: it is a
1885
+ // general text-contrast miss, not a status colour. A hue that qualifies only
1886
+ // because the sheet also paints it as a mark WARNs at most: across the
1887
+ // leaderboard cohort those were palette colours (utility classes,
1888
+ // illustrations, links) whose real surface is often an ancestor's fill the
1889
+ // static model cannot see. Disabled and
1890
+ // inactive states (:disabled, [disabled], [aria-disabled=true], .disabled) are
1891
+ // exempt, as WCAG 1.4.3 exempts inactive user interface components.
1892
+ //
1893
+ // Each text use is measured in every theme it applies to: the :root block and
1894
+ // every [data-theme]/.dark/.light block and prefers-color-scheme block, each
1895
+ // over the root. A theme named by fewer keys than a compound theme that
1896
+ // contains it ([data-color-mode=dark] inside [data-color-mode=dark]
1897
+ // [data-dark-theme=dark]) also reads that compound's tokens before the root's.
1898
+ //
1899
+ // The surface is attested, never assumed. Walking the selector from its
1900
+ // subject outward, the first rule that paints a background for that element
1901
+ // is its surface: the rule itself, another rule for the same selector, then
1902
+ // each ancestor compound (its full prefix, the prefix without its interactive
1903
+ // state, the compound alone, the compound without its state). A translucent
1904
+ // fill composites over the next surface out. With no fill in the selector, the
1905
+ // surface is the theme's page background (html/body, else
1906
+ // --paper/--bg/--background/--surface, else white or black by scheme), unless
1907
+ // an ancestor compound carries an interactive state (:hover, :active, :focus,
1908
+ // :checked, [aria-expanded], [aria-selected], [aria-pressed], [aria-current],
1909
+ // [open]): such a state usually changes a fill this reading cannot find, so
1910
+ // the pairing is unattested and WARNs at most. v44 never FAILs a pairing it
1911
+ // cannot attest.
1912
+ //
1913
+ // Text needs 4.5:1, large text (24px, or 18.66px at weight 700) 3:1. A
1914
+ // non-text use is a graphical object under WCAG 1.4.11 and needs 3:1: its
1915
+ // subject is an svg element, or a class whose role word (the last word, or a
1916
+ // leading icon- or octicon- prefix) is icon, octicon, glyph, spinner or
1917
+ // indicator, or a progress or meter bar; or the colour it reads is named for
1918
+ // one of those (--button-danger-iconColor); or the element paints its own
1919
+ // background with currentColor, so the colour is a fill measured against the
1920
+ // surface behind it.
1921
+ // Oklch chroma at or above which a colour has a hue; below it, white, black,
1922
+ // greys and near-greys are neutral.
1923
+ const V44_HUE_CHROMA = 0.06;
1924
+ const V44_STATUS_WORDS = ['ok', 'success', 'pass', 'positive', 'warn', 'warning', 'caution', 'error', 'danger', 'fail', 'negative', 'destructive', 'info'];
1925
+ const V44_ON_FILL_WORDS = ['on', 'foreground', 'contrast', 'inverse', 'inverted'];
1926
+ const V44_THEME_CLASS = /\.((?:theme-)?(?:dark|light)(?:-theme|-mode)?)(?![\w-])/gi;
1927
+ /** Theme keys a selector names: [data-theme=dark], [data-color-mode=light], .dark, .light. */
1928
+ function themeSelectorKeys(selector) {
1929
+ const keys = [];
1930
+ for (const m of selector.matchAll(/\[\s*([\w-]+)\s*=\s*["']?([\w-]+)["']?\s*\]/g)) {
1931
+ if (/(?:^|-)(?:theme|color-scheme|color-mode|mode|scheme|appearance)$/i.test(m[1]))
1932
+ keys.push(`[${m[1].toLowerCase()}=${m[2].toLowerCase()}]`);
1933
+ }
1934
+ for (const m of selector.matchAll(V44_THEME_CLASS))
1935
+ keys.push(`.${m[1].toLowerCase()}`);
1936
+ return keys;
1937
+ }
1938
+ /** A selector part with its theme attributes and classes removed. */
1939
+ function withoutThemeKeys(part) {
1940
+ return part
1941
+ .replace(/\[[^\]]*\]/g, (a) => (themeSelectorKeys(a).length ? '' : a))
1942
+ .replace(V44_THEME_CLASS, '')
1943
+ .trim();
1944
+ }
1945
+ /**
1946
+ * The token block a selector part declares: 'root' for :root, html, :host or
1947
+ * body; for one of those (or a bare theme selector) carrying theme attributes
1948
+ * or classes, the theme they name together, sorted
1949
+ * (`[data-color-mode=dark][data-dark-theme=dark]`); null for anything else.
1950
+ */
1951
+ function tokenBlockTheme(part) {
1952
+ const bare = part.replace(/:not\([^()]*\)/gi, '');
1953
+ const keys = [...new Set(themeSelectorKeys(bare))].sort();
1954
+ const rest = withoutThemeKeys(bare).replace(/\s+/g, '');
1955
+ if (!/^(?::root|html|:host|body)?$/i.test(rest))
1956
+ return null;
1957
+ if (keys.length === 0)
1958
+ return rest ? 'root' : null;
1959
+ return keys.join('');
1960
+ }
1961
+ /** A selector with its theme keys removed (and the html/:root/body that carried them), for matching a rule to its themed override. */
1962
+ function keylessSelector(selector) {
1963
+ return splitCssTopLevel(selector, ',')
1964
+ .map((p) => {
1965
+ const rest = withoutThemeKeys(p);
1966
+ return (themeSelectorKeys(p).length ? rest.replace(/^(?:html|:root|body)(?=\s|$)/i, '') : rest).replace(/\s+/g, ' ').trim();
1967
+ })
1968
+ .join(',');
1969
+ }
1970
+ function v44Scheme(at) {
1971
+ const media = at.filter((a) => a.startsWith('@media'));
1972
+ return { media: media.length > 0, scheme: media.map((a) => /prefers-color-scheme\s*:\s*(dark|light)/.exec(a)?.[1]).find(Boolean) };
1973
+ }
1974
+ function v44Composite(fg, bg) {
1975
+ const a = Math.round(fg[3] * 255) / 255;
1976
+ return [Math.round(fg[0] * a + bg[0] * (1 - a)), Math.round(fg[1] * a + bg[1] * (1 - a)), Math.round(fg[2] * a + bg[2] * (1 - a)), 1];
1977
+ }
1978
+ /** The colour a background value paints: 'none' when it paints nothing, null when it cannot be read. */
1979
+ function v44BackgroundColor(value, scope, dark) {
1980
+ if (/^(?:none|transparent|initial|inherit|unset|revert|revert-layer)$/i.test(value.trim()))
1981
+ return 'none';
1982
+ const layers = splitCssTopLevel(value, ',');
1983
+ for (const tok of splitCssTopLevel(layers[layers.length - 1] ?? '', ' ')) {
1984
+ if (/^(?:url|[a-z-]*gradient|image|image-set|element)\(/i.test(tok))
1985
+ continue;
1986
+ const c = resolveThemeColor(tok, scope, dark);
1987
+ if (c)
1988
+ return c[3] === 0 ? 'none' : c;
1989
+ }
1990
+ return null;
1991
+ }
1992
+ // Interactive states that usually change a fill.
1993
+ const V44_STATE = /^(?::(?:hover|active|focus|focus-visible|focus-within|checked|target)|\[(?:aria-(?:expanded|selected|pressed|current|checked)|open)(?:[~|^$*]?=[^\]]*)?\])$/i;
1994
+ const V44_NON_TEXT_TYPES = /^(?:svg|path|circle|rect|ellipse|line|polyline|polygon|use|g)$/i;
1995
+ const V44_NON_TEXT_WORDS = ['icon', 'octicon', 'glyph', 'spinner', 'indicator'];
1996
+ /** A selector part with its theme keys removed, and the html/:root/body that carried them. */
1997
+ function keylessPart(part) {
1998
+ const rest = withoutThemeKeys(part);
1999
+ return (themeSelectorKeys(part).length ? rest.replace(/^(?:html|:root|body)(?=\s|$)/i, '') : rest).trim();
2000
+ }
2001
+ /** A lookup key for a run of compounds: simple selectors, combinators kept. */
2002
+ function compoundsKey(comps, stateless) {
2003
+ return comps
2004
+ .map((c, i) => `${i === 0 ? '' : c.comb === ' ' ? ' ' : c.comb}${simpleSelectors(c.compound).filter((x) => !stateless || !V44_STATE.test(x)).join('')}`)
2005
+ .join('');
2006
+ }
2007
+ /**
2008
+ * Whether a selector's subject is a graphical object rather than text: every
2009
+ * alternative's subject is an svg element, or has a class whose role word (the
2010
+ * last word, after any CSS-module hash) is icon, octicon, glyph, spinner or
2011
+ * indicator, or that names an icon by prefix (icon-check, octicon-x), or is a
2012
+ * bar of a progress or meter component.
2013
+ */
2014
+ function nonTextSubject(part) {
2015
+ return expandIsWhere(part).every((alt) => {
2016
+ const comps = selectorCompounds(alt);
2017
+ const subject = simpleSelectors(comps[comps.length - 1]?.compound ?? '');
2018
+ if (subject.some((x) => V44_NON_TEXT_TYPES.test(x)))
2019
+ return true;
2020
+ return subject.some((x) => {
2021
+ if (!x.startsWith('.'))
2022
+ return false;
2023
+ const segs = x.slice(1).split(/[-_]+/).filter(Boolean);
2024
+ while (segs.length > 1 && /\d/.test(segs[segs.length - 1]))
2025
+ segs.pop();
2026
+ const words = segs.flatMap((w) => w.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().split(' '));
2027
+ const last = words[words.length - 1] ?? '';
2028
+ return V44_NON_TEXT_WORDS.includes(last) || ((words[0] === 'icon' || words[0] === 'octicon') && words.length > 1)
2029
+ || (last === 'bar' && words.some((w) => w === 'progress' || w === 'meter'));
2030
+ });
2031
+ });
2032
+ }
2033
+ function checkStatusTextContrast(css) {
2034
+ const ID = 'v44';
2035
+ const ITEM = 'Status colors used as text meet contrast in every declared theme';
2036
+ const CATEGORY = 'accessibility';
2037
+ const parsed = parseCssRules(css).rules.map((rule) => ({
2038
+ rule,
2039
+ decls: parseCssDecls(rule.decls),
2040
+ tokenBlock: splitCssTopLevel(rule.selector, ',').map(tokenBlockTheme),
2041
+ }));
2042
+ const themes = new Map();
2043
+ const ensure = (key, keys, media) => {
2044
+ let t = themes.get(key);
2045
+ if (!t) {
2046
+ const name = key === 'root' ? '' : key;
2047
+ // Dark by name: a *-theme attribute's value decides ([data-light-theme=dark]
2048
+ // puts the dark theme in the light slot); else any key that says dark
2049
+ // and none that says light. A color-scheme declaration overrides both.
2050
+ const themeValues = keys.map((k) => /^\[[\w-]*theme=([\w-]+)\]$/.exec(k)?.[1]).filter((v) => Boolean(v));
2051
+ const said = themeValues.length ? themeValues.join(' ') : name;
2052
+ t = { label: key === 'root' ? ':root' : key, dark: /dark/.test(said) && !/light/.test(said), tokens: {}, keys, media };
2053
+ themes.set(key, t);
2054
+ }
2055
+ return t;
2056
+ };
2057
+ const root = ensure('root', [], null);
2058
+ const whereOf = (rule) => {
2059
+ const { scheme } = v44Scheme(rule.at);
2060
+ return { keys: splitCssTopLevel(rule.selector, ',').map((p) => themeSelectorKeys(p)), media: scheme ? `@media (prefers-color-scheme: ${scheme})` : null };
2061
+ };
2062
+ const reaches = (t, w) => (w.media ? t.media === w.media : t.media === null || w.keys.some((k) => k.length === 0))
2063
+ && w.keys.some((k) => k.length === 0 || (t.keys.length > 0 && k.every((x) => t.keys.includes(x))));
2064
+ const themed = (w) => w.media !== null || w.keys.every((k) => k.length > 0);
2065
+ for (const { rule, decls, tokenBlock } of parsed) {
2066
+ const { media, scheme } = v44Scheme(rule.at);
2067
+ if (media && !scheme)
2068
+ continue;
2069
+ for (const t of tokenBlock) {
2070
+ if (!t)
2071
+ continue;
2072
+ const mediaKey = `@media (prefers-color-scheme: ${scheme})`;
2073
+ const theme = t === 'root'
2074
+ ? (scheme ? ensure(mediaKey, [], mediaKey) : root)
2075
+ : ensure(t, t.match(/\[[^\]]*\]|\.[\w-]+/g) ?? [], null);
2076
+ if (t === 'root' && scheme)
2077
+ theme.dark = scheme === 'dark';
2078
+ for (const d of decls) {
2079
+ if (d.prop.startsWith('--'))
2080
+ theme.tokens[d.prop] = d.value;
2081
+ else if (d.prop === 'color-scheme') {
2082
+ const v = d.value.trim().toLowerCase();
2083
+ if (/^(?:only\s+)?dark$/.test(v))
2084
+ theme.dark = true;
2085
+ else if (/^(?:only\s+)?light$/.test(v))
2086
+ theme.dark = false;
2087
+ }
2088
+ }
2089
+ }
2090
+ }
2091
+ // A theme's tokens; then those of every compound theme that contains it,
2092
+ // because a page that matches [data-color-mode=dark] also carries the theme
2093
+ // attribute those compounds add (primer.style declares --fgColor-danger only
2094
+ // under [data-color-mode=dark][data-dark-theme=dark]): nearest first, and
2095
+ // among those the one whose added keys repeat the theme's own value
2096
+ // ([data-dark-theme=dark] for [data-color-mode=dark]), then source order;
2097
+ // then those of every theme it includes (a [data-color-mode=dark] block
2098
+ // also applies under [data-color-mode=dark][data-dark-theme=dark_dimmed]),
2099
+ // most specific first; then the root's.
2100
+ const scopeCache = new Map();
2101
+ const scopeOf = (key) => {
2102
+ const hit = scopeCache.get(key);
2103
+ if (hit)
2104
+ return hit;
2105
+ const t = themes.get(key);
2106
+ const others = key === 'root' || t.media ? [] : [...themes.entries()].filter(([k, o]) => k !== key && k !== 'root' && !o.media);
2107
+ const valueOf = (k) => /=([\w-]+)\]$/.exec(k)?.[1] ?? k.replace(/^\./, '');
2108
+ const own = new Set(t.keys.map(valueOf));
2109
+ const repeats = (o) => o.keys.filter((x) => !t.keys.includes(x)).every((x) => own.has(valueOf(x)));
2110
+ const narrower = others
2111
+ .filter(([, o]) => o.keys.length > t.keys.length && t.keys.every((x) => o.keys.includes(x)))
2112
+ .sort((a, b) => a[1].keys.length - b[1].keys.length || Number(repeats(b[1])) - Number(repeats(a[1])))
2113
+ .map(([, o]) => o.tokens);
2114
+ const wider = others
2115
+ .filter(([, o]) => o.keys.length < t.keys.length && o.keys.every((x) => t.keys.includes(x)))
2116
+ .sort((a, b) => b[1].keys.length - a[1].keys.length)
2117
+ .map(([, o]) => o.tokens);
2118
+ const scope = key === 'root' ? [root.tokens] : [t.tokens, ...narrower, ...wider, root.tokens];
2119
+ scopeCache.set(key, scope);
2120
+ return scope;
2121
+ };
2122
+ // 2. Status hues, and the aliases that read one.
2123
+ const paintRefs = new Set();
2124
+ for (const { decls } of parsed) {
2125
+ for (const d of decls) {
2126
+ if (/^(?:background(?:-color)?|border(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?(?:-color)?|outline(?:-color)?|fill|stroke)$/.test(d.prop)) {
2127
+ for (const m of d.value.matchAll(/var\(\s*(--[\w-]+)/g))
2128
+ paintRefs.add(m[1]);
2129
+ }
2130
+ }
2131
+ }
2132
+ // 'name': a status colour by its name. 'paint': a hue the sheet also paints
2133
+ // as a mark. null: not a status hue.
2134
+ const hueCache = new Map();
2135
+ const hueKind = (name) => {
2136
+ const hit = hueCache.get(name);
2137
+ if (hit !== undefined)
2138
+ return hit;
2139
+ const segs = tokenNameSegments(name);
2140
+ let kind = null;
2141
+ if (!segs.some((s) => V44_ON_FILL_WORDS.includes(s))) {
2142
+ if (segs.some((s) => V44_STATUS_WORDS.includes(s)) || /(?:^|-)grade-[a-f](?:-|$)/.test(segs.join('-')))
2143
+ kind = 'name';
2144
+ else if (paintRefs.has(name)) {
2145
+ for (const [key, t] of themes) {
2146
+ const c = resolveThemeColor(`var(${name})`, scopeOf(key), t.dark);
2147
+ if (!c || c[3] <= 0)
2148
+ continue;
2149
+ const lab = srgbToOklab([c[0], c[1], c[2]]);
2150
+ if (Math.hypot(lab[1], lab[2]) >= V44_HUE_CHROMA)
2151
+ kind = 'paint';
2152
+ break;
2153
+ }
2154
+ }
2155
+ }
2156
+ hueCache.set(name, kind);
2157
+ return kind;
2158
+ };
2159
+ const isHue = (name) => hueKind(name) !== null;
2160
+ const huesIn = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]).filter(isHue);
2161
+ const aliasOf = new Map();
2162
+ const componentAlias = new Map();
2163
+ // Selectors whose text color a themed rule replaces: keyless selector -> where.
2164
+ const themedColor = new Map();
2165
+ for (const { rule, decls, tokenBlock } of parsed) {
2166
+ const where = whereOf(rule);
2167
+ if (themed(where) && decls.some((d) => d.prop === 'color' || d.prop === '-webkit-text-fill-color')) {
2168
+ const k = keylessSelector(rule.selector);
2169
+ themedColor.set(k, [...(themedColor.get(k) ?? []), where]);
2170
+ }
2171
+ for (const d of decls) {
2172
+ if (!d.prop.startsWith('--') || isHue(d.prop))
2173
+ continue;
2174
+ const hues = huesIn(d.value);
2175
+ if (hues.length && !aliasOf.has(d.prop))
2176
+ aliasOf.set(d.prop, hues[0]);
2177
+ // A component-scoped alias keeps every value it takes, hue or not, so a
2178
+ // themed rule that re-points it (to an ink mix, say) is measured too.
2179
+ if (!tokenBlock.some(Boolean) && /var\(\s*--/.test(d.value)) {
2180
+ const keyless = keylessSelector(rule.selector);
2181
+ const subjects = splitCssTopLevel(keyless, ',').map((p) => {
2182
+ const comps = selectorCompounds(p);
2183
+ return simpleSelectors(comps[comps.length - 1]?.compound ?? '');
2184
+ }).filter((x) => x.length > 0);
2185
+ componentAlias.set(d.prop, [...(componentAlias.get(d.prop) ?? []), { value: d.value, keyless, where, subjects }]);
2186
+ }
2187
+ }
2188
+ }
2189
+ // 3. The page background of each theme: body, then html, then :root, from
2190
+ // the theme's own rules first and the root's rules after; then the
2191
+ // surface tokens; then white, or black for a dark scheme. A theme other
2192
+ // than the root whose background is only that assumption is not
2193
+ // measured: nothing in it says what surface its text sits on.
2194
+ const pageBg = new Map();
2195
+ const assumedBg = new Set();
2196
+ const bgRules = [];
2197
+ for (let r = parsed.length - 1; r >= 0; r--) {
2198
+ const { rule, decls } = parsed[r];
2199
+ if (!/(?:^|[\s,>(])(?:html|body|:root)\b|\[|\.(?:theme-)?(?:dark|light)/i.test(rule.selector))
2200
+ continue;
2201
+ const bg = [...decls].reverse().find((d) => d.prop === 'background-color' || d.prop === 'background');
2202
+ if (!bg)
2203
+ continue;
2204
+ const { media, scheme } = v44Scheme(rule.at);
2205
+ if (media && !scheme)
2206
+ continue;
2207
+ const parts = splitCssTopLevel(rule.selector, ',').map((p) => {
2208
+ const keys = themeSelectorKeys(p);
2209
+ const comps = withoutThemeKeys(p).split(/\s+/).filter(Boolean);
2210
+ return { keys, subject: (comps[comps.length - 1] ?? (keys.length ? 'html' : '')).toLowerCase() };
2211
+ }).filter((p) => p.subject === 'body' || p.subject === 'html' || p.subject === ':root');
2212
+ if (parts.length)
2213
+ bgRules.push({ parts, media: scheme ? `@media (prefers-color-scheme: ${scheme})` : null, bg });
2214
+ }
2215
+ for (const [key, t] of themes) {
2216
+ const scope = scopeOf(key);
2217
+ const plain = t.dark ? [0, 0, 0, 1] : [255, 255, 255, 1];
2218
+ let found = null;
2219
+ for (const pass of key === 'root' ? ['root'] : ['own', 'root']) {
2220
+ for (const subject of ['body', 'html', ':root']) {
2221
+ for (const cand of bgRules) {
2222
+ if (found)
2223
+ break;
2224
+ const hit = cand.parts.some((p) => p.subject === subject && (pass === 'own'
2225
+ ? (t.media ? cand.media === t.media && p.keys.length === 0 : cand.media === null && p.keys.length > 0 && p.keys.every((x) => t.keys.includes(x)))
2226
+ : cand.media === null && p.keys.length === 0));
2227
+ if (!hit)
2228
+ continue;
2229
+ const c = v44BackgroundColor(cand.bg.value, scope, t.dark);
2230
+ if (c && c !== 'none')
2231
+ found = c[3] >= 1 ? c : v44Composite(c, plain);
2232
+ }
2233
+ if (found)
2234
+ break;
2235
+ }
2236
+ if (found)
2237
+ break;
2238
+ }
2239
+ if (!found) {
2240
+ for (const name of ['--paper', '--bg', '--background', '--surface']) {
2241
+ if (!scope.some((x) => Object.prototype.hasOwnProperty.call(x, name)))
2242
+ continue;
2243
+ const c = resolveThemeColor(`var(${name})`, scope, t.dark);
2244
+ if (c && c[3] > 0) {
2245
+ found = c[3] >= 1 ? c : v44Composite(c, plain);
2246
+ break;
2247
+ }
2248
+ }
2249
+ }
2250
+ // A theme whose scheme differs from the root's but whose page background
2251
+ // is the root's did not theme its surface: measuring its text there would
2252
+ // pair a dark palette with a light page, or the reverse.
2253
+ const rootBg = pageBg.get('root');
2254
+ if (key !== 'root' && (!found || (t.dark !== root.dark && rootBg && toHexColor(found) === toHexColor(rootBg))))
2255
+ assumedBg.add(key);
2256
+ pageBg.set(key, found ?? plain);
2257
+ }
2258
+ // Every background a rule paints, by selector (theme keys removed), for
2259
+ // attesting a text use's surface. Latest rule first.
2260
+ const bgIndex = new Map();
2261
+ for (let r = parsed.length - 1; r >= 0; r--) {
2262
+ const { rule, decls } = parsed[r];
2263
+ const bg = [...decls].reverse().find((d) => d.prop === 'background-color' || d.prop === 'background');
2264
+ if (!bg)
2265
+ continue;
2266
+ if (rule.at.some((a) => /^@media\b.*\b(?:print|forced-colors)\b/.test(a)))
2267
+ continue;
2268
+ const where = whereOf(rule);
2269
+ for (const part of splitCssTopLevel(rule.selector, ',')) {
2270
+ // Indexed as written: a :hover rule's fill is the hovered element's,
2271
+ // never the resting one's. The lookup side drops a state, not this side.
2272
+ const k = compoundsKey(selectorCompounds(keylessPart(part)), false);
2273
+ const list = bgIndex.get(k) ?? [];
2274
+ list.push({ where, value: bg.value });
2275
+ bgIndex.set(k, list);
2276
+ }
2277
+ }
2278
+ const findings = [];
2279
+ const seen = new Set();
2280
+ let uses = 0;
2281
+ let unresolved = 0;
2282
+ for (const { rule, decls } of parsed) {
2283
+ const texts = decls.filter((d) => d.prop === 'color' || d.prop === '-webkit-text-fill-color');
2284
+ if (!texts.length)
2285
+ continue;
2286
+ if (rule.at.some((a) => /^@media\b.*\b(?:print|forced-colors)\b/.test(a)))
2287
+ continue;
2288
+ const text = texts[texts.length - 1];
2289
+ const refs = [...text.value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
2290
+ const direct = refs.find(isHue);
2291
+ const alias = refs.find((r) => aliasOf.has(r));
2292
+ if (!direct && !alias)
2293
+ continue;
2294
+ // Inactive controls carry no contrast requirement (WCAG 1.4.3). A rule is
2295
+ // exempt when every selector in it names a disabled state; :not(:disabled)
2296
+ // names the opposite and does not count.
2297
+ const disabled = splitCssTopLevel(rule.selector, ',').every((p) => /:disabled\b|\[disabled\]|\[aria-disabled(?:=["']?true["']?)?\]|\.disabled(?![\w-])/i.test(p.replace(/:not\((?:[^()]|\([^()]*\))*\)/gi, '')));
2298
+ if (disabled)
2299
+ continue;
2300
+ uses++;
2301
+ const token = direct ?? `${alias} -> ${aliasOf.get(alias)}`;
2302
+ const byName = hueKind(direct ?? aliasOf.get(alias)) === 'name';
2303
+ const where = whereOf(rule);
2304
+ const replacedBy = themed(where) ? [] : themedColor.get(keylessSelector(rule.selector)) ?? [];
2305
+ const applies = [...themes.keys()].filter((k) => {
2306
+ const t = themes.get(k);
2307
+ return !assumedBg.has(k) && reaches(t, where) && !replacedBy.some((w) => reaches(t, w));
2308
+ });
2309
+ // Large text: the rule's own font-size (a clamp() reads its minimum) or
2310
+ // the size in a font shorthand, with its weight.
2311
+ let px = null;
2312
+ let weight = 400;
2313
+ for (const d of decls) {
2314
+ if (d.prop === 'font-size' || d.prop === 'font') {
2315
+ const v = d.value.toLowerCase();
2316
+ const kw = /(?:^|\s)(xxx-large|xx-large|x-large)(?:\s|$)/.exec(v);
2317
+ const m = /^clamp\(\s*([\d.]+)(px|rem|em)/.exec(v)
2318
+ ?? (d.prop === 'font-size' ? /^([\d.]+)(px|rem|em)$/.exec(v) : /(?:^|\s)([\d.]+)(px|rem|em)(?:\s*\/|\s|$)/.exec(v));
2319
+ if (kw)
2320
+ px = kw[1] === 'xxx-large' ? 48 : kw[1] === 'xx-large' ? 32 : 24;
2321
+ else if (m)
2322
+ px = parseFloat(m[1]) * (m[2] === 'px' ? 1 : 16);
2323
+ const w = d.prop === 'font' ? /(?:^|\s)(bold|bolder|[1-9]00)(?:\s|$)/.exec(v) : null;
2324
+ if (w)
2325
+ weight = /bold/.test(w[1]) ? 700 : parseInt(w[1], 10);
2326
+ }
2327
+ if (d.prop === 'font-weight') {
2328
+ const w = d.value.trim().toLowerCase();
2329
+ if (/^bold(?:er)?$/.test(w))
2330
+ weight = 700;
2331
+ else if (/^\d+$/.test(w))
2332
+ weight = parseInt(w, 10);
2333
+ }
2334
+ }
2335
+ // A colour named for a graphic (an icon colour) is not text.
2336
+ const graphicToken = [direct, alias, alias ? aliasOf.get(alias) : undefined].some((nm) => nm && tokenNameSegments(nm).some((w) => V44_NON_TEXT_WORDS.includes(w)));
2337
+ const need = graphicToken || (px !== null && (px >= 24 || (px >= 18.66 && weight >= 700))) ? 3 : 4.5;
2338
+ const ownBg = [...decls].reverse().find((d) => d.prop === 'background-color' || d.prop === 'background');
2339
+ const ownTokens = {};
2340
+ for (const d of decls)
2341
+ if (d.prop.startsWith('--'))
2342
+ ownTokens[d.prop] = d.value;
2343
+ // An alias set on a component reaches only text inside that component, so
2344
+ // a value is a variant here only when this rule's selector names the
2345
+ // component (`.card .label` for an alias set on `.card`). A themed rule
2346
+ // for the same component replaces the unthemed one.
2347
+ const compounds = splitCssTopLevel(rule.selector, ',').flatMap((p) => selectorCompounds(p).map((c) => simpleSelectors(c.compound)));
2348
+ const variantsIn = (key) => {
2349
+ if (direct || !alias || Object.prototype.hasOwnProperty.call(ownTokens, alias))
2350
+ return [{}];
2351
+ const t = themes.get(key);
2352
+ const decl = (componentAlias.get(alias) ?? []).filter((a) => reaches(t, a.where)
2353
+ && a.subjects.some((subject) => compounds.some((c) => compoundCovers(subject, c))));
2354
+ const forTheme = new Set(decl.filter((a) => themed(a.where)).map((a) => a.keyless));
2355
+ const values = decl.filter((a) => themed(a.where) || !forTheme.has(a.keyless)).map((a) => a.value);
2356
+ return [{}, ...[...new Set(values)].map((value) => ({ [alias]: value }))];
2357
+ };
2358
+ const sel = shortSelector(rule.selector);
2359
+ const parts = splitCssTopLevel(rule.selector, ',');
2360
+ for (const key of applies) {
2361
+ const t = themes.get(key);
2362
+ for (const variant of variantsIn(key)) {
2363
+ const scope = [ownTokens, variant, ...scopeOf(key)];
2364
+ const fg = resolveThemeColor(text.value, scope, t.dark);
2365
+ if (!fg) {
2366
+ unresolved++;
2367
+ continue;
2368
+ }
2369
+ if (fg[3] <= 0)
2370
+ continue;
2371
+ // One fill: its color in this theme; 'stale' when, in a theme whose
2372
+ // scheme differs from the root's, it resolves exactly as in the root
2373
+ // (the surface did not follow the theme, so it is not measured).
2374
+ const fill = (value) => {
2375
+ const c = v44BackgroundColor(value, scope, t.dark);
2376
+ if (c === null)
2377
+ return 'unreadable';
2378
+ if (c !== 'none' && key !== 'root' && t.dark !== root.dark) {
2379
+ const inRoot = v44BackgroundColor(value, [ownTokens, variant, root.tokens], root.dark);
2380
+ if (inRoot && inRoot !== 'none' && toHexColor(inRoot) === toHexColor(c) && inRoot[3] === c[3])
2381
+ return 'stale';
2382
+ }
2383
+ return c;
2384
+ };
2385
+ const indexed = (k) => {
2386
+ for (const e of bgIndex.get(k) ?? [])
2387
+ if (reaches(t, e.where))
2388
+ return e.value;
2389
+ return null;
2390
+ };
2391
+ const surfaces = parts.map((part) => {
2392
+ const comps = selectorCompounds(keylessPart(part));
2393
+ const n = comps.length - 1;
2394
+ let partNeed = nonTextSubject(keylessPart(part)) ? 3 : need;
2395
+ const layers = [];
2396
+ for (let k = n; k >= 0; k--) {
2397
+ let value = null;
2398
+ if (k === n && ownBg)
2399
+ value = ownBg.value;
2400
+ const prefix = comps.slice(0, k + 1);
2401
+ const lone = [{ comb: '', compound: comps[k].compound }];
2402
+ for (const cand of [compoundsKey(prefix, false), compoundsKey(prefix, true), compoundsKey(lone, false), compoundsKey(lone, true)]) {
2403
+ if (value !== null)
2404
+ break;
2405
+ value = indexed(cand);
2406
+ }
2407
+ if (value === null)
2408
+ continue;
2409
+ // An element that paints its own background with currentColor
2410
+ // (a progress bar, a dot) uses the colour as a fill: a graphic,
2411
+ // measured against the surface behind it.
2412
+ if (k === n && /^\s*currentcolor\s*$/i.test(value)) {
2413
+ partNeed = 3;
2414
+ continue;
2415
+ }
2416
+ const c = fill(value);
2417
+ if (c === 'stale')
2418
+ return { bg: null, attested: false, stale: true, need: partNeed };
2419
+ if (c === 'unreadable')
2420
+ return { bg: null, attested: true, stale: false, need: partNeed };
2421
+ if (c === 'none')
2422
+ continue;
2423
+ layers.push(c);
2424
+ if (c[3] >= 1)
2425
+ break;
2426
+ }
2427
+ const opaque = layers.length > 0 && layers[layers.length - 1][3] >= 1;
2428
+ const stateAbove = comps.slice(0, n).some((c) => simpleSelectors(c.compound).some((x) => V44_STATE.test(x)));
2429
+ let base = opaque ? layers.pop() : pageBg.get(key);
2430
+ for (let i = layers.length - 1; i >= 0; i--)
2431
+ base = v44Composite(layers[i], base);
2432
+ return { bg: base, attested: opaque || !stateAbove, stale: false, need: partNeed };
2433
+ });
2434
+ const solid = [Math.round(fg[0]), Math.round(fg[1]), Math.round(fg[2]), 1];
2435
+ const fgLab = srgbToOklab([solid[0], solid[1], solid[2]]);
2436
+ const neutral = Math.hypot(fgLab[1], fgLab[2]) < V44_HUE_CHROMA;
2437
+ const value = toHexColor(solid) + (fg[3] < 1 ? ` at ${Math.round(fg[3] * 100)}%` : '');
2438
+ const measured = [];
2439
+ for (const sf of surfaces) {
2440
+ if (sf.stale)
2441
+ continue;
2442
+ if (!sf.bg) {
2443
+ measured.push({ background: 'unresolved', ratio: null, need: sf.need, status: 'WARN', note: 'its background cannot be resolved' });
2444
+ continue;
2445
+ }
2446
+ const bg = sf.bg;
2447
+ const full = contrastRatio([solid[0], solid[1], solid[2]], [bg[0], bg[1], bg[2]]);
2448
+ const shown = fg[3] < 1 ? v44Composite(fg, bg) : solid;
2449
+ const painted = contrastRatio([shown[0], shown[1], shown[2]], [bg[0], bg[1], bg[2]]);
2450
+ let status = full < sf.need ? 'FAIL' : painted < sf.need ? 'WARN' : 'PASS';
2451
+ let note = status === 'WARN' ? `clears ${sf.need}:1 only at full alpha (${full.toFixed(2)}:1)` : '';
2452
+ if (status === 'FAIL' && !sf.attested) {
2453
+ status = 'WARN';
2454
+ note = 'is only a warning: the surface is unresolved, because an ancestor in an interactive state (hover, active, focus, expanded, open) usually changes a fill this check cannot find';
2455
+ }
2456
+ else if (status === 'FAIL' && !byName) {
2457
+ status = 'WARN';
2458
+ note = 'is only a warning: the color is not named as a status, it counts because the stylesheet also paints it as a mark, and its real surface may be a fill this check cannot see';
2459
+ }
2460
+ else if (status === 'FAIL' && neutral) {
2461
+ status = 'WARN';
2462
+ note = 'neutral text on a status fill: a general text-contrast miss, not a status color';
2463
+ }
2464
+ // Shown to two places; a miss that would round up to the bar is
2465
+ // rounded down instead, so 4.497 reads 4.49, never 4.50 against 4.5.
2466
+ const rounded = Math.round(painted * 100) / 100;
2467
+ const shownRatio = painted < sf.need && rounded >= sf.need ? Math.floor(painted * 100) / 100 : rounded;
2468
+ measured.push({ background: toHexColor(bg), ratio: shownRatio, need: sf.need, status, note });
2469
+ }
2470
+ if (!measured.length) {
2471
+ unresolved++;
2472
+ continue;
2473
+ }
2474
+ const order = { FAIL: 0, WARN: 1, PASS: 2 };
2475
+ measured.sort((a, b) => order[a.status] - order[b.status] || (a.ratio === null ? 0 : a.ratio / a.need) - (b.ratio === null ? 0 : b.ratio / b.need));
2476
+ const worst = measured[0];
2477
+ const dedupe = `${sel}|${token}|${value}|${worst.background}|${worst.need}|${worst.status}`;
2478
+ if (seen.has(dedupe))
2479
+ continue;
2480
+ seen.add(dedupe);
2481
+ findings.push({ selector: sel, token, theme: t.label, value, ...worst });
2482
+ }
2483
+ }
2484
+ }
2485
+ if (findings.length === 0) {
2486
+ return {
2487
+ id: ID, item: ITEM, category: CATEGORY, status: 'SKIP',
2488
+ detail: uses === 0
2489
+ ? 'not applicable: no status color is used as text'
2490
+ : `not applicable: ${uses} status-color text use(s), none resolvable to a measurable color`,
2491
+ };
2492
+ }
2493
+ const rank = { FAIL: 0, WARN: 1, PASS: 2 };
2494
+ const margin = (f) => (f.ratio === null ? 0 : f.ratio / f.need);
2495
+ findings.sort((a, b) => rank[a.status] - rank[b.status] || margin(a) - margin(b));
2496
+ const fails = findings.filter((f) => f.status === 'FAIL');
2497
+ const warns = findings.filter((f) => f.status === 'WARN');
2498
+ const status = fails.length ? 'FAIL' : warns.length ? 'WARN' : 'PASS';
2499
+ const say = (f) => (f.ratio === null
2500
+ ? `${f.selector} uses ${f.token} ${f.value} in ${f.theme}, and ${f.note}`
2501
+ : `${f.selector} uses ${f.token} ${f.value} on ${f.background} in ${f.theme} at ${f.ratio.toFixed(2)}:1 (needs ${f.need}:1)${f.note ? `; ${/^(?:is|clears) /.test(f.note) ? 'this ' : ''}${f.note}` : ''}`);
2502
+ const lead = status === 'FAIL' ? fails : warns;
2503
+ const detail = status === 'PASS'
2504
+ ? `${findings.length} status-color text use(s) clear contrast in ${themes.size} theme(s); the closest: ${say(findings[0])}`
2505
+ : `${lead.length} status-color text use(s) ${status === 'FAIL' ? 'under contrast' : 'to review'}: ${lead.slice(0, 2).map(say).join(' | ')}${lead.length > 2 ? ` | ${lead.length - 2} more` : ''}`;
2506
+ return {
2507
+ id: ID, item: ITEM, category: CATEGORY, status, detail,
2508
+ evidence: {
2509
+ findings: findings.slice(0, 20).map((f) => ({ selector: f.selector, token: f.token, theme: f.theme, value: f.value, background: f.background, ratio: f.ratio, need: f.need, status: f.status, note: f.note || null })),
2510
+ truncated: Math.max(0, findings.length - 20),
2511
+ },
2512
+ };
2513
+ }
2514
+ /** The compounds of one complex selector and the combinator before each. */
2515
+ function selectorCompounds(part) {
2516
+ const out = [];
2517
+ let depth = 0;
2518
+ let cur = '';
2519
+ let comb = '';
2520
+ for (let i = 0; i < part.length; i++) {
2521
+ const ch = part[i];
2522
+ if (ch === '(' || ch === '[')
2523
+ depth++;
2524
+ else if ((ch === ')' || ch === ']') && depth > 0)
2525
+ depth--;
2526
+ if (depth === 0 && (ch === ' ' || ch === '\n' || ch === '\t' || ch === '>' || ch === '+' || ch === '~')) {
2527
+ if (cur) {
2528
+ out.push({ comb, compound: cur });
2529
+ cur = '';
2530
+ comb = ' ';
2531
+ }
2532
+ if (ch !== ' ' && ch !== '\n' && ch !== '\t')
2533
+ comb = ch;
2534
+ continue;
2535
+ }
2536
+ cur += ch;
2537
+ }
2538
+ if (cur)
2539
+ out.push({ comb, compound: cur });
2540
+ return out;
2541
+ }
2542
+ /** The simple selectors of one compound, attribute quotes and spacing normalised. */
2543
+ function simpleSelectors(compound) {
2544
+ const out = [];
2545
+ let depth = 0;
2546
+ let cur = '';
2547
+ for (let i = 0; i < compound.length; i++) {
2548
+ const ch = compound[i];
2549
+ if (depth === 0 && (ch === '.' || ch === '#' || ch === '[' || (ch === ':' && compound[i - 1] !== ':')) && cur) {
2550
+ out.push(cur);
2551
+ cur = '';
2552
+ }
2553
+ if (ch === '(' || ch === '[')
2554
+ depth++;
2555
+ else if ((ch === ')' || ch === ']') && depth > 0)
2556
+ depth--;
2557
+ cur += ch;
2558
+ }
2559
+ if (cur)
2560
+ out.push(cur);
2561
+ return out.map((s) => (s.startsWith('[') ? s.replace(/["'\s]/g, '').toLowerCase() : s)).filter((s) => s !== '*');
2562
+ }
2563
+ /** Specificity [ids, classes, types] of one complex selector. */
2564
+ function selectorSpecificity(part) {
2565
+ const spec = [0, 0, 0];
2566
+ for (const { compound } of selectorCompounds(part)) {
2567
+ for (const s of simpleSelectors(compound)) {
2568
+ const fn = /^:(is|not|has|matches|where|nth-child|nth-last-child)\(([\s\S]*)\)$/i.exec(s);
2569
+ if (fn) {
2570
+ const name = fn[1].toLowerCase();
2571
+ if (name === 'where')
2572
+ continue;
2573
+ if (name.startsWith('nth')) {
2574
+ spec[1]++;
2575
+ continue;
2576
+ }
2577
+ let best = [0, 0, 0];
2578
+ for (const arg of splitCssTopLevel(fn[2], ',')) {
2579
+ const sp = selectorSpecificity(arg);
2580
+ if (sp[0] > best[0] || (sp[0] === best[0] && (sp[1] > best[1] || (sp[1] === best[1] && sp[2] > best[2]))))
2581
+ best = sp;
2582
+ }
2583
+ for (let k = 0; k < 3; k++)
2584
+ spec[k] += best[k];
2585
+ continue;
2586
+ }
2587
+ if (s.startsWith('#'))
2588
+ spec[0]++;
2589
+ else if (s.startsWith('::') || /^:(?:before|after|first-line|first-letter)$/i.test(s))
2590
+ spec[2]++;
2591
+ else if (s.startsWith('.') || s.startsWith('[') || s.startsWith(':'))
2592
+ spec[1]++;
2593
+ else
2594
+ spec[2]++;
2595
+ }
2596
+ }
2597
+ return spec;
2598
+ }
2599
+ function compareSpecificity(a, b) {
2600
+ for (let k = 0; k < 3; k++)
2601
+ if (a[k] !== b[k])
2602
+ return a[k] - b[k];
2603
+ return 0;
2604
+ }
2605
+ /** One time value in ms, null when it is not a literal time. */
2606
+ function cssTimeMs(token) {
2607
+ const m = /^(-?(?:\d+\.?\d*|\.\d+))(ms|s)$/i.exec(token.trim());
2608
+ return m ? parseFloat(m[1]) * (m[2].toLowerCase() === 's' ? 1000 : 1) : null;
2609
+ }
2610
+ /** :is() and :where() in a selector, expanded into their alternatives (bounded). */
2611
+ function expandIsWhere(part, budget = 64) {
2612
+ const m = /:(?:is|where|matches|-webkit-any)\(/i.exec(part);
2613
+ if (!m)
2614
+ return [part];
2615
+ const open = m.index + m[0].length - 1;
2616
+ let depth = 0;
2617
+ let close = -1;
2618
+ for (let i = open; i < part.length; i++) {
2619
+ if (part[i] === '(')
2620
+ depth++;
2621
+ else if (part[i] === ')' && --depth === 0) {
2622
+ close = i;
2623
+ break;
2624
+ }
2625
+ }
2626
+ if (close < 0)
2627
+ return [part];
2628
+ const out = [];
2629
+ for (const alt of splitCssTopLevel(part.slice(open + 1, close), ',')) {
2630
+ for (const x of expandIsWhere(part.slice(0, m.index) + alt + part.slice(close + 1), budget)) {
2631
+ if (out.length >= budget)
2632
+ return out;
2633
+ out.push(x);
2634
+ }
2635
+ }
2636
+ return out;
2637
+ }
2638
+ /** Whether every simple selector of `inner` holds for an element matching `outer` ([x] holds wherever [x=...] does). */
2639
+ function compoundCovers(inner, outer) {
2640
+ return inner.every((s) => outer.includes(s)
2641
+ || (/^\[[\w-]+\]$/.test(s) && outer.some((o) => o.startsWith(`${s.slice(0, -1)}=`) || o.startsWith(`${s.slice(0, -1)}~=`) || o.startsWith(`${s.slice(0, -1)}|=`))));
2642
+ }
2643
+ /**
2644
+ * Whether selector `r` matches every element selector `e` matches: r's
2645
+ * compounds, read from the subject outward, each hold for e's compound at the
2646
+ * same position, joined by the same combinator (a descendant combinator in r
2647
+ * also holds where e has a child combinator).
2648
+ */
2649
+ function selectorCovers(r, e) {
2650
+ const rc = selectorCompounds(r);
2651
+ const ec = selectorCompounds(e);
2652
+ if (!rc.length || rc.length > ec.length)
2653
+ return false;
2654
+ for (let k = 1; k <= rc.length; k++) {
2655
+ const a = rc[rc.length - k];
2656
+ const b = ec[ec.length - k];
2657
+ if (!compoundCovers(simpleSelectors(a.compound), simpleSelectors(b.compound)))
2658
+ return false;
2659
+ if (k < rc.length && a.comb !== b.comb && !(a.comb === ' ' && b.comb === '>'))
2660
+ return false;
2661
+ }
2662
+ return true;
2663
+ }
2664
+ const V45_USER_ACTION = /^:(?:hover|focus|focus-within|focus-visible|active|target|checked)$/i;
2665
+ // A document-level motion toggle: an attribute that names motion or animation
2666
+ // ([data-motion=paused], [data-reduced-motion]), or a class that is one
2667
+ // (.reduce-motion, .no-motion, .motion-paused, .animations-off). Component
2668
+ // classes that only end in "paused" (a marquee's .logobar--paused) are not.
2669
+ const V45_TOGGLE_ATTR = /^\[[\w-]*(?:motion|anim)[\w-]*(?:[~|^$*]?=[^\]]*)?\]$/i;
2670
+ const V45_TOGGLE_CLASS = /^\.(?:is-|has-|prefers-)?(?:reduced?-motion|no-motion|motion-(?:off|paused|reduced?|none)|paused-motion|(?:no-)?animations?(?:-(?:off|paused|disabled|none))?|(?:pause|stop)-animations?|still)$/i;
2671
+ /**
2672
+ * Whether a compound before `*` reaches the whole document: html, :root or
2673
+ * body with any qualifier, or a bare motion toggle (an attribute that names
2674
+ * motion or animation, or a class that is a motion toggle), never a
2675
+ * user-action state.
2676
+ */
2677
+ function isDocumentScope(compound) {
2678
+ const simple = simpleSelectors(compound);
2679
+ if (!simple.length)
2680
+ return false;
2681
+ if (simple.some((s) => V45_USER_ACTION.test(s)))
2682
+ return false;
2683
+ const type = simple.find((s) => /^[a-z]/i.test(s) || /^:root$/i.test(s));
2684
+ if (type)
2685
+ return /^(?:html|body|:root)$/i.test(type);
2686
+ return simple.every((s) => s.startsWith('.') || s.startsWith('['))
2687
+ && simple.some((s) => V45_TOGGLE_ATTR.test(s) || V45_TOGGLE_CLASS.test(s));
2688
+ }
2689
+ function checkPausedEntrances(css) {
2690
+ const ID = 'v45';
2691
+ const ITEM = 'Pausing motion keeps content visible';
2692
+ const CATEGORY = 'motion';
2693
+ const { rules, keyframes } = parseCssRules(css);
2694
+ const parsed = rules.map((rule) => ({ rule, decls: parseCssDecls(rule.decls) }));
2695
+ const isReduce = (at) => at.some((a) => a.startsWith('@media') && /prefers-reduced-motion(?!\s*:\s*no-preference)/.test(a));
2696
+ const isNoPreference = (at) => at.some((a) => a.startsWith('@media') && /prefers-reduced-motion\s*:\s*no-preference/.test(a));
2697
+ const rootTokens = {};
2698
+ for (const { rule, decls } of parsed) {
2699
+ if (rule.at.some((a) => a.startsWith('@media')))
2700
+ continue;
2701
+ if (!splitCssTopLevel(rule.selector, ',').some((p) => /^\s*(?::root|html)\s*$/i.test(p)))
2702
+ continue;
2703
+ for (const d of decls)
2704
+ if (d.prop.startsWith('--'))
2705
+ rootTokens[d.prop] = d.value;
2706
+ }
2707
+ // 1. Pause scopes.
2708
+ const scopes = [];
2709
+ for (const { rule, decls } of parsed) {
2710
+ const pause = [...decls].reverse().find((d) => d.prop === 'animation-play-state');
2711
+ if (!pause || !/^paused(?:\s*,\s*paused)*$/i.test(pause.value))
2712
+ continue;
2713
+ const media = isReduce(rule.at);
2714
+ for (const part of splitCssTopLevel(rule.selector, ',')) {
2715
+ const comps = selectorCompounds(part);
2716
+ const subject = comps[comps.length - 1];
2717
+ if (!subject || !/^\*(?:::?(?:before|after))?$/i.test(subject.compound))
2718
+ continue;
2719
+ let prefix = '';
2720
+ if (comps.length === 2 && comps[1].comb === ' ' && isDocumentScope(comps[0].compound))
2721
+ prefix = simpleSelectors(comps[0].compound).join('');
2722
+ else if (comps.length !== 1)
2723
+ continue;
2724
+ if (scopes.some((s) => s.prefix === prefix && s.media === media))
2725
+ continue;
2726
+ scopes.push({ prefix, media, selector: part, important: pause.important, spec: selectorSpecificity(part), order: rule.order, decls });
2727
+ }
2728
+ }
2729
+ if (!scopes.length) {
2730
+ return { id: ID, item: ITEM, category: CATEGORY, status: 'SKIP', detail: 'not applicable: no rule pauses every element (animation-play-state: paused on *)' };
2731
+ }
2732
+ // 2. Keyframes that start invisible (the last block of a name wins).
2733
+ const hiddenNames = new Set();
2734
+ const byName = new Map();
2735
+ for (const k of keyframes)
2736
+ byName.set(k.name, k);
2737
+ for (const [name, k] of byName) {
2738
+ // The first frame, and the last: an animation that also ends invisible
2739
+ // (a flash or a glow that fades out) hides nothing a pause would reveal.
2740
+ const frameHides = (edge) => {
2741
+ let opacity = null;
2742
+ let visibility = null;
2743
+ for (const f of k.frames) {
2744
+ if (!f.selector.split(',').some((x) => edge.test(x.trim())))
2745
+ continue;
2746
+ for (const d of parseCssDecls(f.decls)) {
2747
+ if (d.prop === 'opacity')
2748
+ opacity = d.value.trim();
2749
+ if (d.prop === 'visibility')
2750
+ visibility = d.value.trim().toLowerCase();
2751
+ }
2752
+ }
2753
+ return (opacity !== null && /\d/.test(opacity) && /^(?:0*\.?0*|0*(?:\.0+)?%)$/.test(opacity)) || visibility === 'hidden';
2754
+ };
2755
+ if (frameHides(/^(?:from|0*(?:\.0+)?%)$/) && !frameHides(/^(?:to|100(?:\.0+)?%)$/))
2756
+ hiddenNames.add(name);
2757
+ }
2758
+ const entrances = [];
2759
+ if (hiddenNames.size) {
2760
+ for (const { rule, decls } of parsed) {
2761
+ if (isReduce(rule.at))
2762
+ continue;
2763
+ const anim = [...decls].reverse().find((d) => d.prop === 'animation' || d.prop === 'animation-name');
2764
+ if (!anim)
2765
+ continue;
2766
+ const list = (prop) => {
2767
+ const d = [...decls].reverse().find((x) => x.prop === prop);
2768
+ return d ? splitCssTopLevel(substituteCssVars(d.value, [rootTokens]) ?? d.value, ',') : null;
2769
+ };
2770
+ const segments = splitCssTopLevel(anim.value, ',');
2771
+ segments.forEach((segment, idx) => {
2772
+ const resolved = substituteCssVars(segment, [rootTokens]) ?? segment;
2773
+ const tokens = splitCssTopLevel(resolved, ' ');
2774
+ const name = anim.prop === 'animation-name' ? segment.trim() : splitCssTopLevel(segment, ' ').find((t) => hiddenNames.has(t));
2775
+ if (!name || !hiddenNames.has(name))
2776
+ return;
2777
+ let infinite = anim.prop === 'animation' && tokens.some((t) => t.toLowerCase() === 'infinite');
2778
+ const times = anim.prop === 'animation' ? tokens.map(cssTimeMs).filter((x) => x !== null) : [];
2779
+ const unknownTime = anim.prop === 'animation' && tokens.some((t) => /^var\(/i.test(t));
2780
+ let durationMs = anim.prop === 'animation' ? (times.length ? times[0] : unknownTime ? null : 0) : null;
2781
+ let delayMs = anim.prop === 'animation' ? (times.length > 1 ? times[1] : unknownTime ? null : 0) : 0;
2782
+ let fill = anim.prop === 'animation' ? (tokens.find((t) => /^(?:none|forwards|backwards|both)$/i.test(t)) ?? 'none').toLowerCase() : 'none';
2783
+ const pick = (l) => (l && l.length ? l[idx % l.length] : null);
2784
+ const count = pick(list('animation-iteration-count'));
2785
+ if (count !== null)
2786
+ infinite = /infinite/i.test(count);
2787
+ const dur = pick(list('animation-duration'));
2788
+ if (dur !== null)
2789
+ durationMs = cssTimeMs(dur);
2790
+ const del = pick(list('animation-delay'));
2791
+ if (del !== null)
2792
+ delayMs = cssTimeMs(del);
2793
+ const fm = pick(list('animation-fill-mode'));
2794
+ if (fm !== null)
2795
+ fill = fm.toLowerCase();
2796
+ if (infinite || durationMs === 0)
2797
+ return;
2798
+ if (delayMs !== null && delayMs > 0 && (fill === 'none' || fill === 'forwards'))
2799
+ return;
2800
+ for (const part of splitCssTopLevel(rule.selector, ',')) {
2801
+ entrances.push({ name, part, spec: selectorSpecificity(part), order: rule.order, important: anim.important, shorthand: anim.prop === 'animation', durationMs, gated: isNoPreference(rule.at) });
2802
+ }
2803
+ });
2804
+ }
2805
+ }
2806
+ // 4. Overrides: rules that end or remove an animation, with what they reach.
2807
+ const neutralises = (decls, durationMs) => {
2808
+ let best = 'no';
2809
+ for (const d of decls) {
2810
+ const v = d.value.trim().toLowerCase();
2811
+ if ((d.prop === 'animation' || d.prop === 'animation-name') && /^none(?:\s*,\s*none)*$/.test(v))
2812
+ return 'yes';
2813
+ if (d.prop === 'animation-play-state' && /^running(?:\s*,\s*running)*$/.test(v))
2814
+ return 'yes';
2815
+ if (d.prop === 'animation-delay') {
2816
+ const ms = splitCssTopLevel(substituteCssVars(v, [rootTokens]) ?? v, ',').map(cssTimeMs);
2817
+ if (ms.length && ms.every((x) => x !== null && x < 0)) {
2818
+ const shortest = Math.min(...ms.map((x) => -x));
2819
+ if (durationMs !== null ? shortest >= durationMs : shortest >= 60000)
2820
+ return 'yes';
2821
+ if (durationMs === null)
2822
+ best = 'maybe';
2823
+ }
2824
+ }
2825
+ }
2826
+ return best;
2827
+ };
2828
+ const overrides = parsed
2829
+ .map(({ rule, decls }) => ({ rule, decls }))
2830
+ .filter(({ decls }) => decls.some((d) => /^animation(?:-name|-play-state|-delay)?$/.test(d.prop)));
2831
+ const verdicts = [];
2832
+ const seen = new Set();
2833
+ for (const scope of scopes) {
2834
+ for (const e of entrances) {
2835
+ if (scope.media && e.gated)
2836
+ continue;
2837
+ const reaches = scope.important || !e.shorthand || compareSpecificity(scope.spec, e.spec) > 0
2838
+ || (compareSpecificity(scope.spec, e.spec) === 0 && scope.order > e.order);
2839
+ if (!reaches)
2840
+ continue;
2841
+ const key = `${scope.prefix}|${scope.media}|${e.name}|${e.part}`;
2842
+ if (seen.has(key))
2843
+ continue;
2844
+ seen.add(key);
2845
+ const eComps = selectorCompounds(e.part);
2846
+ const eSubject = simpleSelectors(eComps[eComps.length - 1]?.compound ?? '');
2847
+ let status = 'FAIL';
2848
+ let override = null;
2849
+ const own = neutralises(scope.decls, e.durationMs);
2850
+ if (own === 'yes') {
2851
+ status = 'PASS';
2852
+ override = scope.selector;
2853
+ }
2854
+ for (const o of overrides) {
2855
+ if (status === 'PASS')
2856
+ break;
2857
+ for (const oPart of splitCssTopLevel(o.rule.selector, ',')) {
2858
+ if (status === 'PASS')
2859
+ break;
2860
+ const oComps = selectorCompounds(oPart);
2861
+ let rel = null;
2862
+ let otherScope = false;
2863
+ if (scope.media) {
2864
+ if (isReduce(o.rule.at))
2865
+ rel = oPart;
2866
+ else if (oComps.length > 1 && isDocumentScope(oComps[0].compound))
2867
+ otherScope = true;
2868
+ }
2869
+ else if (scope.prefix) {
2870
+ const first = simpleSelectors(oComps[0]?.compound ?? '').join('');
2871
+ if (oComps.length > 1 && oComps[1].comb === ' ' && first === scope.prefix && !isReduce(o.rule.at)) {
2872
+ rel = oPart.slice(oPart.indexOf(oComps[0].compound) + oComps[0].compound.length).trim();
2873
+ }
2874
+ else if (isReduce(o.rule.at) || (oComps.length > 1 && isDocumentScope(oComps[0].compound)))
2875
+ otherScope = true;
2876
+ }
2877
+ else {
2878
+ rel = oPart;
2879
+ }
2880
+ if (rel === null && !otherScope)
2881
+ continue;
2882
+ const effect = neutralises(o.decls, e.durationMs);
2883
+ if (effect === 'no')
2884
+ continue;
2885
+ // Under another scope: the selector after its document prefix, or
2886
+ // all of it inside a reduced-motion block.
2887
+ const target = rel ?? (isReduce(o.rule.at) && !(oComps.length > 1 && isDocumentScope(oComps[0].compound))
2888
+ ? oPart
2889
+ : oPart.slice(oPart.indexOf(oComps[0].compound) + oComps[0].compound.length).trim());
2890
+ let match = null;
2891
+ for (const alt of expandIsWhere(target)) {
2892
+ const aComps = selectorCompounds(alt);
2893
+ const aSubject = simpleSelectors(aComps[aComps.length - 1]?.compound ?? '');
2894
+ if (selectorCovers(alt, e.part)) {
2895
+ match = 'exact';
2896
+ break;
2897
+ }
2898
+ const named = (x) => x.startsWith('.') || x.startsWith('#') || x.startsWith('[');
2899
+ if (aSubject.some((x) => named(x) && compoundCovers([x], eSubject)))
2900
+ match = 'partial';
2901
+ }
2902
+ if (!match)
2903
+ continue;
2904
+ const oSpec = selectorSpecificity(oPart);
2905
+ const wins = o.decls.some((d) => d.important && /^animation(?:-name|-play-state|-delay)?$/.test(d.prop)) || (!e.important && (compareSpecificity(oSpec, e.spec) > 0
2906
+ || (compareSpecificity(oSpec, e.spec) === 0 && o.rule.order > e.order)));
2907
+ if (match === 'exact' && effect === 'yes' && wins && rel !== null) {
2908
+ status = 'PASS';
2909
+ override = oPart;
2910
+ }
2911
+ else if (status === 'FAIL') {
2912
+ status = 'WARN';
2913
+ override = oPart;
2914
+ }
2915
+ }
2916
+ }
2917
+ verdicts.push({ entrance: e, scope, status, override });
2918
+ }
2919
+ }
2920
+ const scopeNames = scopes.map((s) => (s.media ? `@media (prefers-reduced-motion: reduce) ${shortSelector(s.selector)}` : shortSelector(s.selector)));
2921
+ if (!verdicts.length) {
2922
+ return {
2923
+ id: ID, item: ITEM, category: CATEGORY, status: 'PASS',
2924
+ detail: `pause scope ${scopeNames.join(', ')}: no one-shot entrance it holds starts at opacity 0`,
2925
+ evidence: { findings: [], truncated: 0 },
2926
+ };
2927
+ }
2928
+ const rank = { FAIL: 0, WARN: 1, PASS: 2 };
2929
+ verdicts.sort((a, b) => rank[a.status] - rank[b.status]);
2930
+ const fails = verdicts.filter((v) => v.status === 'FAIL');
2931
+ const warns = verdicts.filter((v) => v.status === 'WARN');
2932
+ const status = fails.length ? 'FAIL' : warns.length ? 'WARN' : 'PASS';
2933
+ const pauseRule = (v) => (v.scope.media ? `@media (prefers-reduced-motion: reduce) ${shortSelector(v.scope.selector)}` : shortSelector(v.scope.selector));
2934
+ const say = (v) => `@keyframes ${v.entrance.name} on ${shortSelector(v.entrance.part)} under ${pauseRule(v)}${v.override ? ` (override: ${shortSelector(v.override)})` : ''}`;
2935
+ const lead = status === 'FAIL' ? fails : warns;
2936
+ const detail = status === 'PASS'
2937
+ ? `${verdicts.length} entrance(s) that start at opacity 0 end or are removed under the pause (${scopeNames.join(', ')})`
2938
+ : status === 'FAIL'
2939
+ ? `${fails.length} entrance(s) start at opacity 0 and stay held invisible under the pause: ${fails.slice(0, 2).map(say).join(' | ')}${fails.length > 2 ? ` | ${fails.length - 2} more` : ''}`
2940
+ : `${warns.length} entrance(s) with an override whose selector or scope does not clearly match: ${lead.slice(0, 2).map(say).join(' | ')}${warns.length > 2 ? ` | ${warns.length - 2} more` : ''}`;
2941
+ return {
2942
+ id: ID, item: ITEM, category: CATEGORY, status, detail,
2943
+ evidence: {
2944
+ findings: verdicts.slice(0, 20).map((v) => ({ keyframes: v.entrance.name, selector: shortSelector(v.entrance.part), pauseRule: pauseRule(v), override: v.override === null ? null : shortSelector(v.override), status: v.status })),
2945
+ truncated: Math.max(0, verdicts.length - 20),
2946
+ },
2947
+ };
2948
+ }
1330
2949
  // v34 — AI-Disclosure Readiness (EU AI Act Article 50, effective 2026-08-02).
1331
2950
  // Static-only v1: scans fetched HTML for AI-interactive surfaces (chatbots,
1332
2951
  // AI agents, AI-generated content) and disclosure signals (visible labels,
@@ -2117,7 +3736,7 @@ async function checkDesignMdSpec(targetUrl) {
2117
3736
  // package missing from THIS runtime, so the same page scored lower from the
2118
3737
  // npm CLI than from the API. Unverified-here is what MANUAL means (v02,
2119
3738
  // v04, v21): weight 0, resolved by a run that has the linter.
2120
- const msg = e instanceof Error ? e.message : 'unknown error';
3739
+ const msg = sanitizeErrorText(e instanceof Error ? e.message : 'unknown error');
2121
3740
  return { id: 'v37', item: ITEM, category: CATEGORY, status: 'MANUAL', detail: `/DESIGN.md fetched but linter unavailable: ${msg}. The @google/design.md package may not be installed in this runtime; run the full audit to resolve.` };
2122
3741
  }
2123
3742
  try {
@@ -2135,10 +3754,220 @@ async function checkDesignMdSpec(targetUrl) {
2135
3754
  // Distinct from the branch above: the linter LOADED and then threw, so the
2136
3755
  // file's format is unsupported rather than the runtime being incomplete.
2137
3756
  // Both WARN, but the messages point at different fixes.
2138
- const msg = e instanceof Error ? e.message : 'unknown error';
3757
+ const msg = sanitizeErrorText(e instanceof Error ? e.message : 'unknown error');
2139
3758
  return { id: 'v37', item: ITEM, category: CATEGORY, status: 'WARN', detail: `/DESIGN.md fetched but lint failed: ${msg}. The file may use a format version the linter doesn't support yet.` };
2140
3759
  }
2141
3760
  }
3761
+ // ── Error text in a result, shared by both engine copies ───────────────────
3762
+ // A caught error's message carries the file system of the machine that ran
3763
+ // the engine: Node's "Cannot find package '@google/design.md' imported from
3764
+ // <path>" gives the full path of the npm cache the CLI ran from, under the
3765
+ // Users folder of the account that ran it, and a result is read by people
3766
+ // other than that account. Every check detail passes through
3767
+ // sanitizeErrorText before it is returned. It replaces absolute paths (a
3768
+ // drive letter, a UNC share, a POSIX home, temp or system root), file:// URLs
3769
+ // and npm cache segments, and keeps the clause that says what failed
3770
+ // ("Cannot find package '@google/design.md'"). A path segment may hold spaces
3771
+ // (an account name often does); the last one may not, so the sentence after
3772
+ // a path survives, and trailing punctuation is kept.
3773
+ function sanitizeErrorText(text) {
3774
+ const keep = (label) => (m) => `${label}${/[.,;:!?)]+$/.exec(m)?.[0] ?? ''}`;
3775
+ return text
3776
+ .replace(/\bfile:\/\/[^\s'"<>`|]*/gi, keep('a local file'))
3777
+ .replace(/\\\\[^\\\s'"<>`|]+\\(?:[^\\\r\n'"<>`|*?]+\\)*[^\\\s'"<>`|*?]*/g, keep('a local path'))
3778
+ .replace(/(?<![\w\\/])[A-Za-z]:([\\/])(?:[^\\/\r\n'"<>`|*?]+\1)*[^\\/\s'"<>`|*?]*/g, keep('a local path'))
3779
+ .replace(/(^|[\s'"`(=,:[])\/(?:home|Users|root|tmp|var|private|opt|usr|srv|mnt|Volumes|Library|app|vercel|workspace|workspaces|github|runner|nix|snap)(?:\/[^/\r\n'"<>`|]+(?=\/))*\/[^/\s'"<>`|]+/g, (m, lead) => `${lead}${keep('a local path')(m.slice(lead.length))}`)
3780
+ .replace(/[^\s'"<>`|]*(?:npm-cache|[\\/]_npx[\\/]|[\\/]\.npm[\\/])[^\s'"<>`|]*/gi, keep('the npm cache'));
3781
+ }
3782
+ /** The checks with every detail passed through sanitizeErrorText. */
3783
+ function sanitizeCheckDetails(checks) {
3784
+ return checks.map((c) => (typeof c.detail === 'string' ? { ...c, detail: sanitizeErrorText(c.detail) } : c));
3785
+ }
3786
+ // ── Score arithmetic, shared by both engine copies ─────────────────────────
3787
+ // Everything between the check verdicts and the grade: category weights, the
3788
+ // weighted score, the slop deduction, the originality lift, the per-category
3789
+ // sub-scores, the accessibility floor and the hard-fail ceilings. It is one
3790
+ // function so the source-drift gate compares it as one unit. Until engine
3791
+ // 1.2.0 the arithmetic sat inline in each orchestrator, outside the gate, and
3792
+ // the copies disagreed on the floor: the site capped at 70 when the
3793
+ // accessibility category was under 60%, the npm engine on any accessibility
3794
+ // FAIL. The site's rule is the one documented on /methodology.
3795
+ function scoreArithmetic(checks, slopTotal, originalityPoints) {
3796
+ // ── Tier 2: per-category weighted scoring ──────────────────────────────────
3797
+ // Weights follow the contract's section emphasis (the contract IS the scoring
3798
+ // basis), with an accessibility floor so contract sections covering real-user
3799
+ // harm cannot be drowned out by cadence's 8 checks. SKIPs fall out of BOTH
3800
+ // numerator and denominator (Lighthouse precedent: manual/N/A audits excluded).
3801
+ //
3802
+ // Weight table (relative weights, sums to 117 — the scoring formula
3803
+ // normalizes via Σ(points)/Σ(total). Derived from AnySearch research against
3804
+ // Lighthouse axe user-impact, design-auditor category %, and DSAF 50/50):
3805
+ // cadence 18, accessibility 15, semantic 12, motion 10, tokens 9,
3806
+ // takt 8, poise 7, identity 6, interaction 6, performance 6, responsive 3
3807
+ // v0.4.0 additions: copywriting 8, security 5, spec 4
3808
+ const CATEGORY_WEIGHTS = {
3809
+ cadence: 18, accessibility: 15, semantic: 12, motion: 10, tokens: 9,
3810
+ takt: 8, poise: 7, identity: 6, interaction: 6, performance: 6, responsive: 3,
3811
+ security: 5, spec: 4, copywriting: 8,
3812
+ };
3813
+ // Per-check weight = category weight / number of checks in that category
3814
+ // (so each category contributes its full weight, split evenly among its checks).
3815
+ const categoryCounts = {};
3816
+ for (const c of checks) {
3817
+ if (c.status === 'SKIP' || c.status === 'MANUAL')
3818
+ continue;
3819
+ categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
3820
+ }
3821
+ let weightedPoints = 0;
3822
+ let weightedTotal = 0;
3823
+ for (const c of checks) {
3824
+ if (c.status === 'SKIP' || c.status === 'MANUAL')
3825
+ continue;
3826
+ const catWeight = CATEGORY_WEIGHTS[c.category] || 5;
3827
+ const checkWeight = catWeight / (categoryCounts[c.category] || 1);
3828
+ weightedTotal += checkWeight;
3829
+ if (c.status === 'PASS')
3830
+ weightedPoints += checkWeight;
3831
+ else if (c.status === 'WARN')
3832
+ weightedPoints += checkWeight * 0.5;
3833
+ // FAIL = 0 points
3834
+ }
3835
+ let score = weightedTotal === 0 ? 0 : Math.round((weightedPoints / weightedTotal) * 1000) / 10;
3836
+ // ── Anti-slop deduction ─────────────────────────────────────────────────────
3837
+ // Apply detected slop patterns as a direct subtraction from the weighted score.
3838
+ // The deduction is flat (not percentage-scaled) so it cannot be gamed by making
3839
+ // the site more minimal. Capped at 20 total.
3840
+ if (slopTotal > 0) {
3841
+ score = Math.max(0, score - slopTotal);
3842
+ }
3843
+ // ── Originality lift ─────────────────────────────────────────────────────────
3844
+ // Reward positive craft signals. Applied after the slop deduction so a generic
3845
+ // site with no distinctive signals stays at its (already-slop-deducted) score,
3846
+ // while a distinctive site is lifted above the compliant-but-generic baseline.
3847
+ // Capped at +8. Score clamped to ≤100 since this is a bonus on a 100-scale base.
3848
+ if (originalityPoints > 0) {
3849
+ score = Math.min(100, score + originalityPoints);
3850
+ }
3851
+ // One decimal, the precision scores are shown at: subtracting a fractional
3852
+ // slop total leaves float noise (72.6 - 20 + 4 is 56.599999999999994).
3853
+ score = Math.round(score * 10) / 10;
3854
+ // ── Per-category sub-scores (the constellation) ─────────────────────────
3855
+ // Each category gets its own 0-100 score using the same weighting rule as
3856
+ // the composite (PASS 1.0 / WARN 0.5 / FAIL 0, SKIP excluded). Categories
3857
+ // with zero scored checks report null so the client can render them as
3858
+ // "unscored" rather than fabricating a 0 or 100. This is the same math the
3859
+ // composite uses — one source of truth, no client-side re-derivation.
3860
+ const catAgg = {};
3861
+ for (const c of checks) {
3862
+ const agg = catAgg[c.category] || (catAgg[c.category] = { wp: 0, wt: 0, pass: 0, fail: 0, warn: 0, skip: 0, manual: 0 });
3863
+ if (c.status === 'SKIP') {
3864
+ agg.skip += 1;
3865
+ continue;
3866
+ }
3867
+ if (c.status === 'MANUAL') {
3868
+ agg.manual += 1;
3869
+ continue;
3870
+ }
3871
+ const checkWeight = (CATEGORY_WEIGHTS[c.category] || 5) / (categoryCounts[c.category] || 1);
3872
+ agg.wt += checkWeight;
3873
+ if (c.status === 'PASS') {
3874
+ agg.wp += checkWeight;
3875
+ agg.pass += 1;
3876
+ }
3877
+ else if (c.status === 'WARN') {
3878
+ agg.wp += checkWeight * 0.5;
3879
+ agg.warn += 1;
3880
+ }
3881
+ else
3882
+ agg.fail += 1; // FAIL
3883
+ }
3884
+ const categoryScores = {};
3885
+ for (const [cat, agg] of Object.entries(catAgg)) {
3886
+ categoryScores[cat] = {
3887
+ score: agg.wt === 0 ? null : Math.round((agg.wp / agg.wt) * 1000) / 10,
3888
+ weight: CATEGORY_WEIGHTS[cat] || 5,
3889
+ pass: agg.pass,
3890
+ fail: agg.fail,
3891
+ warn: agg.warn,
3892
+ skip: agg.skip,
3893
+ manual: agg.manual,
3894
+ };
3895
+ }
3896
+ // ── Tier 2: accessibility floor (DSAF enterprise-grade precedent) ──────────
3897
+ // DSAF enforces A8 Accessibility ≥75% — a system can score 90% combined and
3898
+ // still fail enterprise-grade if a11y is 73%. We apply a softer version: if
3899
+ // the accessibility category scores below 60%, cap the overall grade at C.
3900
+ // This prevents "perfect tokens, zero a11y = A" dishonesty.
3901
+ const a11yChecks = checks.filter((c) => c.category === 'accessibility' && c.status !== 'SKIP' && c.status !== 'MANUAL');
3902
+ const a11yPass = a11yChecks.filter((c) => c.status === 'PASS').length;
3903
+ const a11yWarn = a11yChecks.filter((c) => c.status === 'WARN').length;
3904
+ const a11yScored = a11yChecks.length;
3905
+ const a11yPct = a11yScored === 0 ? 100 : ((a11yPass + a11yWarn * 0.5) / a11yScored) * 100;
3906
+ let a11yFloorApplied = false;
3907
+ if (a11yScored > 0 && a11yPct < 60) {
3908
+ // Cap at C (70). If the weighted score is already below 70, leave it.
3909
+ if (score > 70) {
3910
+ score = 70;
3911
+ a11yFloorApplied = true;
3912
+ }
3913
+ }
3914
+ // ── Hard-fail ceilings (PixelJury precedent) ────────────────────────────────
3915
+ // Certain check FAILures are so severe they cap the score regardless of other
3916
+ // strengths. These are design-integrity failures — a site that FAILs on
3917
+ // contrast or horizontal overflow cannot be A-grade no matter how good its
3918
+ // tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
3919
+ const hardFailChecks = checks.filter((c) => c.status === 'FAIL');
3920
+ let hardFailCeilingApplied = false;
3921
+ let hardFailCeilingReason = null;
3922
+ for (const c of hardFailChecks) {
3923
+ let cap = null;
3924
+ let reason = null;
3925
+ // v06 Contrast readable — fundamental legibility failure
3926
+ if (c.id === 'v06') {
3927
+ cap = 65;
3928
+ reason = 'Contrast below WCAG minimum: text is unreadable for many users.';
3929
+ }
3930
+ // v22 Contrast signal — CTA text unreadable on brand color
3931
+ if (c.id === 'v22') {
3932
+ cap = 70;
3933
+ reason = 'Primary CTA contrast below WCAG AA: the most important interaction on the page is hard to read.';
3934
+ }
3935
+ // v02 Horizontal overflow — broken layout on mobile
3936
+ if (c.id === 'v02') {
3937
+ cap = 70;
3938
+ reason = 'Horizontal overflow detected: content is cut off or scrolls sideways on smaller viewports.';
3939
+ }
3940
+ // v24 Touch targets — interactive elements too small to use
3941
+ if (c.id === 'v24') {
3942
+ cap = 75;
3943
+ reason = 'Interactive elements below the 44px minimum touch target (WCAG 2.5.5 Enhanced): inaccessible on touch devices.';
3944
+ }
3945
+ // v25 Heading hierarchy — broken document outline
3946
+ if (c.id === 'v25') {
3947
+ cap = 75;
3948
+ reason = 'Multiple h1 elements or skipped heading levels: document outline is broken.';
3949
+ }
3950
+ // v16 Rem scale — root font-size under 16px (iOS zoom break)
3951
+ if (c.id === 'v16') {
3952
+ cap = 70;
3953
+ reason = 'Root font-size below 16px: triggers iOS Safari auto-zoom, breaks mobile UX.';
3954
+ }
3955
+ if (cap !== null && score > cap) {
3956
+ score = cap;
3957
+ hardFailCeilingApplied = true;
3958
+ hardFailCeilingReason = reason;
3959
+ }
3960
+ }
3961
+ return {
3962
+ score,
3963
+ categoryWeights: CATEGORY_WEIGHTS,
3964
+ categoryCounts,
3965
+ categoryScores,
3966
+ a11yFloorApplied,
3967
+ hardFailCeilingApplied,
3968
+ hardFailCeilingReason,
3969
+ };
3970
+ }
2142
3971
  function computeGrade(score) {
2143
3972
  if (score >= 90)
2144
3973
  return 'A';
@@ -2260,6 +4089,8 @@ export async function scoreFromParts(input) {
2260
4089
  checkNoTrailingPeriod(html),
2261
4090
  checkLinkTextDescriptive(html),
2262
4091
  checkNoAllCaps(html),
4092
+ checkStatusTextContrast(css),
4093
+ checkPausedEntrances(css),
2263
4094
  ];
2264
4095
  // v37 is async — added after the synchronous checks array
2265
4096
  //
@@ -2277,7 +4108,7 @@ export async function scoreFromParts(input) {
2277
4108
  detail: 'not probed: offline fixture run (no network); excluded from the score',
2278
4109
  }
2279
4110
  : await checkDesignMdSpec(targetUrl));
2280
- checks = applyScopeFilter(checks, effectiveScope);
4111
+ checks = sanitizeCheckDetails(applyScopeFilter(checks, effectiveScope));
2281
4112
  const pass = checks.filter((c) => c.status === 'PASS').length;
2282
4113
  const fail = checks.filter((c) => c.status === 'FAIL').length;
2283
4114
  const warn = checks.filter((c) => c.status === 'WARN').length;
@@ -2318,39 +4149,98 @@ export async function scoreFromParts(input) {
2318
4149
  if (usedFonts.size > 0)
2319
4150
  slopFindings.push({ id: 'S1', label: 'Overused font family', severity: 5, instances: usedFonts.size, evidence: [...usedFonts] });
2320
4151
  }
2321
- // S2. Full-page gradient background
4152
+ // S2. Full-page gradient background: a multi-stop linear gradient that
4153
+ // covers the viewport. Not: a gradient on a component (the glass score hero,
4154
+ // a window sheen, a cell fill), a hairline, or a grid.
4155
+ //
4156
+ // Viewport evidence is required: the gradient is painted on html, body or
4157
+ // :root, the rule is position: fixed and pinned to all four edges (or sized
4158
+ // to the viewport), or it is sized in viewport units.
4159
+ // Until engine 1.2.0 `inset: 0` counted as full-bleed, but inset: 0 fills
4160
+ // the nearest positioned ancestor, which is a component; designesy.org lost
4161
+ // 5 points to a 1px window sheen, a cell fill and a heatmap bar that all
4162
+ // matched on it. A viewport-sized rule with border-radius: inherit is still
4163
+ // a component. A gradient sized to 1px (height, width, border width, or a
4164
+ // background-size with a 1px dimension, as in `top / 100% 1px`) is a hairline.
2322
4165
  {
2323
4166
  // A repeating hairline pattern is a GRID, not a decorative gradient wash.
2324
4167
  //
2325
- // The previous pattern required the colour to precede the stop —
2326
- // `(transparent|rgba(...))\s+1px` — which only matches
2327
- // `linear-gradient(transparent 1px, ...)`. The form actually used on
2328
- // designesy.org is the reverse: `linear-gradient(color-mix(...) 1px,
2329
- // transparent 1px)`, so the filter never fired and a graph-paper grid
2330
- // overlay was reported as "Full-page gradient background" at severity 5 —
2331
- // the maximum — deducting the full 20-point slop budget on a page whose
2332
- // background is a 1px grid on near-black.
2333
- //
2334
- // Match a 1px stop in EITHER order, which is what "hairline" means
2335
- // regardless of how the colour and the stop are written.
4168
+ // An earlier pattern required the colour to precede the stop, which only
4169
+ // matches `linear-gradient(transparent 1px, ...)`; designesy.org writes the
4170
+ // reverse, `linear-gradient(color-mix(...) 1px, transparent 1px)`, and its
4171
+ // graph-paper grid was reported at the maximum severity. Match a 1px stop
4172
+ // in EITHER order, which is what "hairline" means however it is written.
2336
4173
  const isGridPattern = (text) => /(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\)|#[0-9a-fA-F]{3,8})\s+1px(?:\s*,)/.test(text)
2337
4174
  || /\s+1px\s*,\s*(?:transparent|rgba\([^)]+\)|color-mix\([^)]*\)|var\([^)]*\))/.test(text);
2338
- const bodyGrad = css.match(/(?:^|})\s*(?:body|html)(?!::)[^{]*\{[^}]*background(?:-image)?\s*:[^;{}]*linear-gradient\s*\([^)]*,\s*[^)]*\)/gi) || [];
2339
- const overlayGrad = [];
2340
- const gradBlockRe = /\{[^{}]{0,500}?linear-gradient\s*\(\s*[^)]*,\s*[^)]{4,}\)[^{}]{0,500}?\}/gi;
2341
- let block;
2342
- while ((block = gradBlockRe.exec(css)) !== null) {
2343
- const decl = block[0];
2344
- const fullBleed = /position\s*:\s*fixed/i.test(decl) || /inset\s*:\s*0\b/i.test(decl) || /width\s*:\s*100vw/i.test(decl) || /height\s*:\s*100vh/i.test(decl);
2345
- const hairline = /(?:height|width)\s*:\s*1px\b/.test(decl) || /border(?:-top|-bottom)?-width\s*:\s*1px\b/.test(decl);
2346
- if (fullBleed && !hairline)
2347
- overlayGrad.push(decl.slice(0, 160));
2348
- }
2349
- const bodyGradFiltered = bodyGrad.filter(m => !isGridPattern(m));
2350
- const overlayGradFiltered = overlayGrad.filter(m => !isGridPattern(m));
2351
- const instances = bodyGradFiltered.length + overlayGradFiltered.length;
2352
- if (instances > 0)
2353
- slopFindings.push({ id: 'S2', label: 'Full-page gradient background', severity: 5, instances, evidence: [...bodyGradFiltered, ...overlayGradFiltered].slice(0, 3) });
4175
+ // Two or more colour stops in some linear-gradient() of the value.
4176
+ const multiStop = (value) => {
4177
+ let from = 0;
4178
+ for (;;) {
4179
+ const at = value.toLowerCase().indexOf('linear-gradient(', from);
4180
+ if (at < 0)
4181
+ return false;
4182
+ let depth = 0;
4183
+ let end = -1;
4184
+ for (let i = at + 15; i < value.length; i++) {
4185
+ if (value[i] === '(')
4186
+ depth++;
4187
+ else if (value[i] === ')' && --depth === 0) {
4188
+ end = i;
4189
+ break;
4190
+ }
4191
+ }
4192
+ if (end < 0)
4193
+ return false;
4194
+ const args = splitCssTopLevel(value.slice(at + 16, end), ',');
4195
+ const stops = /^(?:to\s|[-+]?[\d.]+(?:deg|turn|rad|grad)\b)/i.test(args[0] ?? '') ? args.slice(1) : args;
4196
+ if (stops.length >= 2)
4197
+ return true;
4198
+ from = end;
4199
+ }
4200
+ };
4201
+ const pageGrad = [];
4202
+ for (const rule of parseCssRules(css).rules) {
4203
+ if (!/linear-gradient\s*\(/i.test(rule.decls))
4204
+ continue;
4205
+ const decls = parseCssDecls(rule.decls);
4206
+ if (!decls.some((d) => (d.prop === 'background' || d.prop === 'background-image') && multiStop(d.value)))
4207
+ continue;
4208
+ const text = `${rule.selector}{${rule.decls}}`;
4209
+ if (isGridPattern(text))
4210
+ continue;
4211
+ const last = (prop) => [...decls].reverse().find((d) => d.prop === prop)?.value.trim().toLowerCase() ?? '';
4212
+ const pageSelector = splitCssTopLevel(rule.selector, ',').some((p) => {
4213
+ const comps = selectorCompounds(p);
4214
+ const subject = comps[comps.length - 1]?.compound ?? '';
4215
+ return /^(?:html|body|:root)(?![\w-])/i.test(subject) && !/::?(?:before|after|marker|backdrop)/i.test(subject);
4216
+ });
4217
+ // A fixed element covers the viewport only when it is pinned to all four
4218
+ // edges or sized to it; a fixed drawer or toast is not a page background.
4219
+ const zero = (v) => /^0(?:px|rem|em|%)?$/.test(v);
4220
+ const inset = last('inset');
4221
+ const pinned = (inset !== '' && inset.split(/\s+/).every(zero))
4222
+ || ['top', 'right', 'bottom', 'left'].every((side) => zero(last(side)));
4223
+ const viewportSized = decls.some((d) => /^(?:width|height|min-width|min-height|inline-size|block-size|min-inline-size|min-block-size)$/.test(d.prop)
4224
+ && /(?:^|[\s(,])100(?:d|s|l)?v(?:w|h|i|b)\b/i.test(d.value));
4225
+ const component = last('border-radius') === 'inherit';
4226
+ const hairline = decls.some((d) => (/^(?:height|width|block-size|inline-size)$/.test(d.prop) && d.value.trim() === '1px')
4227
+ || (/^border(?:-top|-bottom)?-width$/.test(d.prop) && d.value.trim() === '1px')
4228
+ || (d.prop === 'background-size' && /(?:^|[\s,])1px(?:$|[\s,])/.test(d.value))
4229
+ || (d.prop === 'background' && /\/\s*[^,/]*(?:^|\s)1px(?:$|[\s,])/.test(d.value)));
4230
+ const fixedCover = last('position') === 'fixed' && (pinned || viewportSized);
4231
+ if ((pageSelector || fixedCover || (viewportSized && !component)) && !hairline)
4232
+ pageGrad.push(text.replace(/\s+/g, ' ').slice(0, 160));
4233
+ }
4234
+ const instances = pageGrad.length;
4235
+ if (instances > 0) {
4236
+ slopFindings.push({
4237
+ id: 'S2',
4238
+ label: 'Full-page gradient background',
4239
+ severity: 5,
4240
+ instances,
4241
+ evidence: pageGrad.slice(0, 3),
4242
+ });
4243
+ }
2354
4244
  }
2355
4245
  // S3. Purple/violet gradient
2356
4246
  {
@@ -2453,12 +4343,68 @@ export async function scoreFromParts(input) {
2453
4343
  if (emojiInButtons && emojiInButtons.length >= 2)
2454
4344
  slopFindings.push({ id: 'S7', label: 'Emoji as UI icons', severity: 4, instances: emojiInButtons.length, evidence: [`${emojiInButtons.length} emoji in button/CTA elements`] });
2455
4345
  }
2456
- // S8. AI-pill badges
4346
+ // S8. AI-pill badges: "AI-powered", "Generate", "Chat with AI" and the like
4347
+ // as the short text of a pill, badge, chip, tag or call to action.
4348
+ //
4349
+ // Until engine 1.2.0 this matched the whole HTML with no word boundary and
4350
+ // no element scope, so it read prose and the framework's <script> payload:
4351
+ // on designesy.org it counted three sentences arguing against AI sameness,
4352
+ // twice each, because "Generate" matched "generates" and "AI-generated".
4353
+ // Now <script>, <style>, <template> and comments are stripped, the phrases
4354
+ // match whole words only, and only a pill counts: an element whose class or
4355
+ // role names a badge, pill, chip, tag or CTA, a button, a link, or a span
4356
+ // with a border-radius of its own, holding 40 characters of text or fewer.
4357
+ // Skipped on a page that documents the rules (see isDocumentingSlopRules):
4358
+ // the anti-slop rule table on /methodology lists these phrases.
2457
4359
  {
2458
- const pillPattern = /(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)/gi;
2459
- const matches = html.match(pillPattern) || [];
2460
- if (matches.length > 0 && !isDocumentingSlopRules)
2461
- slopFindings.push({ id: 'S8', label: 'AI-pill badge text', severity: 3, instances: matches.length, evidence: [...new Set(matches.map(m => m.trim()))].slice(0, 3) });
4360
+ const visible = html
4361
+ .replace(/<script\b[\s\S]*?<\/script\s*>/gi, ' ')
4362
+ .replace(/<style\b[\s\S]*?<\/style\s*>/gi, ' ')
4363
+ .replace(/<template\b[\s\S]*?<\/template\s*>/gi, ' ')
4364
+ .replace(/<!--[\s\S]*?-->/g, ' ');
4365
+ const pillPhrase = /\b(?:AI-powered|Generate|Chat with AI|Powered by AI|Built with AI|AI-driven)\b/i;
4366
+ const openRe = /<(span|a|button|div|small|strong|b|em|mark|label|p|li)\b([^<>]*)>/gi;
4367
+ const tagRe = {};
4368
+ const pills = [];
4369
+ let open;
4370
+ while ((open = openRe.exec(visible)) !== null) {
4371
+ const tag = open[1].toLowerCase();
4372
+ const attrs = open[2];
4373
+ const named = [...attrs.matchAll(/\b(?:class|role)\s*=\s*["']([^"']*)["']/gi)].map((a) => a[1]).join(' ');
4374
+ const pillish = /(?:^|[\s_-])(?:badge|pill|chip|tag|cta)(?:$|[\s_-])/i.test(named);
4375
+ const rounded = tag === 'span' && /\bstyle\s*=\s*["'][^"']*border-radius\s*:/i.test(attrs);
4376
+ if (!pillish && !rounded && tag !== 'button' && tag !== 'a')
4377
+ continue;
4378
+ // The element's content, to its matching close tag, within 2,000 characters.
4379
+ const start = open.index + open[0].length;
4380
+ const windowText = visible.slice(start, start + 2000);
4381
+ const re = tagRe[tag] ?? (tagRe[tag] = new RegExp(`<(/?)${tag}\\b[^<>]*>`, 'gi'));
4382
+ re.lastIndex = 0;
4383
+ let depth = 1;
4384
+ let end = -1;
4385
+ let t;
4386
+ while ((t = re.exec(windowText)) !== null) {
4387
+ depth += t[1] ? -1 : 1;
4388
+ if (depth === 0) {
4389
+ end = t.index;
4390
+ break;
4391
+ }
4392
+ }
4393
+ if (end < 0)
4394
+ continue;
4395
+ const text = windowText.slice(0, end).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
4396
+ if (text && text.length <= 40 && pillPhrase.test(text))
4397
+ pills.push(text);
4398
+ }
4399
+ if (pills.length > 0 && !isDocumentingSlopRules) {
4400
+ slopFindings.push({
4401
+ id: 'S8',
4402
+ label: 'AI-pill badge text',
4403
+ severity: 3,
4404
+ instances: pills.length,
4405
+ evidence: [...new Set(pills)].slice(0, 3),
4406
+ });
4407
+ }
2462
4408
  }
2463
4409
  // S9. Lorem ipsum
2464
4410
  {
@@ -2615,116 +4561,8 @@ export async function scoreFromParts(input) {
2615
4561
  const originalitySummary = originalitySignals.length > 0
2616
4562
  ? `${originalitySignals.length} craft signal${originalitySignals.length !== 1 ? 's' : ''} (+${originalityPoints}pts${rawOriginality > ORIGINALITY_CAP ? `, capped from +${rawOriginality}` : ''}${slopGateApplied ? ', slop-gated ×0.5' : ''})`
2617
4563
  : null;
2618
- // ── Weighted scoring ─────────────────────────────────────────────────────
2619
- const CATEGORY_WEIGHTS = {
2620
- cadence: 18, accessibility: 15, semantic: 12, motion: 10, tokens: 9, takt: 8, poise: 7, identity: 6, interaction: 6, performance: 6, responsive: 3, security: 5, spec: 4, copywriting: 8,
2621
- };
2622
- const categoryCounts = {};
2623
- for (const c of checks) {
2624
- if (c.status === 'SKIP' || c.status === 'MANUAL')
2625
- continue;
2626
- categoryCounts[c.category] = (categoryCounts[c.category] || 0) + 1;
2627
- }
2628
- let weightedPoints = 0;
2629
- let weightedTotal = 0;
2630
- for (const c of checks) {
2631
- if (c.status === 'SKIP' || c.status === 'MANUAL')
2632
- continue;
2633
- const catWeight = CATEGORY_WEIGHTS[c.category] || 5;
2634
- const checkWeight = catWeight / (categoryCounts[c.category] || 1);
2635
- weightedTotal += checkWeight;
2636
- if (c.status === 'PASS')
2637
- weightedPoints += checkWeight;
2638
- else if (c.status === 'WARN')
2639
- weightedPoints += checkWeight * 0.5;
2640
- }
2641
- let score = weightedTotal === 0 ? 0 : Math.round((weightedPoints / weightedTotal) * 1000) / 10;
2642
- if (slopTotal > 0)
2643
- score = Math.max(0, score - slopTotal);
2644
- if (originalityPoints > 0)
2645
- score = Math.min(100, score + originalityPoints);
2646
- // Per-category sub-scores
2647
- const catAgg = {};
2648
- for (const c of checks) {
2649
- const agg = catAgg[c.category] || (catAgg[c.category] = { wp: 0, wt: 0, pass: 0, fail: 0, warn: 0, skip: 0, manual: 0 });
2650
- if (c.status === 'SKIP') {
2651
- agg.skip += 1;
2652
- continue;
2653
- }
2654
- if (c.status === 'MANUAL') {
2655
- agg.manual += 1;
2656
- continue;
2657
- }
2658
- const checkWeight = (CATEGORY_WEIGHTS[c.category] || 5) / (categoryCounts[c.category] || 1);
2659
- agg.wt += checkWeight;
2660
- if (c.status === 'PASS') {
2661
- agg.wp += checkWeight;
2662
- agg.pass += 1;
2663
- }
2664
- else if (c.status === 'WARN') {
2665
- agg.wp += checkWeight * 0.5;
2666
- agg.warn += 1;
2667
- }
2668
- else
2669
- agg.fail += 1;
2670
- }
2671
- const categoryScores = {};
2672
- for (const [cat, agg] of Object.entries(catAgg)) {
2673
- categoryScores[cat] = {
2674
- score: agg.wt === 0 ? null : Math.round((agg.wp / agg.wt) * 1000) / 10,
2675
- weight: CATEGORY_WEIGHTS[cat] || 5,
2676
- pass: agg.pass, fail: agg.fail, warn: agg.warn, skip: agg.skip, manual: agg.manual,
2677
- };
2678
- }
2679
- // A11y floor: cap score at C (70) when any accessibility FAIL exists
2680
- let a11yFloorApplied = false;
2681
- for (const c of checks) {
2682
- if (c.category === 'accessibility' && c.status === 'FAIL') {
2683
- if (score > 70) {
2684
- score = 70;
2685
- a11yFloorApplied = true;
2686
- }
2687
- break;
2688
- }
2689
- }
2690
- // Hard-fail ceilings for critical issues
2691
- let hardFailCeilingApplied = false;
2692
- let hardFailCeilingReason = null;
2693
- for (const c of checks) {
2694
- if (c.status !== 'FAIL')
2695
- continue;
2696
- let cap = null;
2697
- let reason = '';
2698
- if (c.id === 'v06') {
2699
- cap = 65;
2700
- reason = 'Contrast below WCAG minimum: text is unreadable for many users.';
2701
- }
2702
- if (c.id === 'v22') {
2703
- cap = 70;
2704
- reason = 'Primary CTA contrast below WCAG AA: the most important interaction on the page is hard to read.';
2705
- }
2706
- if (c.id === 'v02') {
2707
- cap = 70;
2708
- reason = 'Horizontal overflow detected: content is cut off or scrolls sideways on smaller viewports.';
2709
- }
2710
- if (c.id === 'v24') {
2711
- cap = 75;
2712
- reason = 'Interactive elements below the 44px minimum touch target: inaccessible on touch devices.';
2713
- }
2714
- if (c.id === 'v25') {
2715
- cap = 75;
2716
- reason = 'Multiple h1 elements or skipped heading levels: document outline is broken.';
2717
- }
2718
- if (c.id === 'v16') {
2719
- cap = 70;
2720
- reason = 'Root font-size below 16px: triggers iOS Safari auto-zoom, breaks mobile UX.';
2721
- }
2722
- if (cap !== null && score > cap) {
2723
- score = cap;
2724
- hardFailCeilingApplied = true;
2725
- hardFailCeilingReason = reason;
2726
- }
2727
- }
4564
+ // The arithmetic is shared with the other engine copy; see scoreArithmetic.
4565
+ const { score, categoryWeights: CATEGORY_WEIGHTS, categoryCounts, categoryScores, a11yFloorApplied, hardFailCeilingApplied, hardFailCeilingReason, } = scoreArithmetic(checks, slopTotal, originalityPoints);
2728
4566
  const grade = computeGrade(score);
2729
4567
  const checksWithRemediation = checks.map((c) => {
2730
4568
  const catWeight = CATEGORY_WEIGHTS[c.category] || 5;
@@ -2744,9 +4582,11 @@ export async function scoreFromParts(input) {
2744
4582
  /**
2745
4583
  * The contract revision these checks implement, reported by every emission.
2746
4584
  * The site derives its copy from the contract source; this standalone engine
2747
- * pins it here, once, and each release moves it.
4585
+ * pins it here, once, and each release moves it. 0.6.0 shipped v0.4.1 while
4586
+ * the site served v0.4.3; test/engine-1-2-0.test.mjs now reads the site's
4587
+ * contract source and fails when the two differ.
2748
4588
  */
2749
- export const CONTRACT_VERSION = 'v0.4.1';
4589
+ export const CONTRACT_VERSION = 'v0.4.3';
2750
4590
  /**
2751
4591
  * The one verdict rule, shared by every emission format.
2752
4592
  *