@designesy/score 0.5.1 → 0.7.0

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