@designesy/score 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/engine.js +175 -77
  2. package/package.json +1 -1
package/dist/engine.js CHANGED
@@ -312,7 +312,9 @@ async function fetchPageResilient(targetUrl) {
312
312
  function extractRootTokens(css) {
313
313
  const stripped = css.replace(/@media[^{]*\{[^@]*?\}\s*\}/gi, '');
314
314
  const tokens = {};
315
- const rootRe = /:root\s*\{([^}]*)\}/g;
315
+ // `:root, :host { ... }` is the block Tailwind v4 writes its theme into;
316
+ // until engine 1.3.0 only `:root {` was read, so those tokens were missed.
317
+ const rootRe = /:root(?:\s*,\s*:host)?\s*\{([^}]*)\}/g;
316
318
  let m;
317
319
  while ((m = rootRe.exec(stripped)) !== null) {
318
320
  const block = m[1];
@@ -360,22 +362,6 @@ function inferTokensFromCss(css, tokens) {
360
362
  return result;
361
363
  }
362
364
  // ── Color utilities ──────────────────────────────────────────────────────────
363
- function hexToRgb(color) {
364
- color = color.trim();
365
- if (!color.startsWith('#'))
366
- return null;
367
- let hex = color.slice(1);
368
- if (hex.length === 3)
369
- hex = hex.split('').map((c) => c + c).join('');
370
- if (hex.length !== 6)
371
- return null;
372
- const r = parseInt(hex.slice(0, 2), 16);
373
- const g = parseInt(hex.slice(2, 4), 16);
374
- const b = parseInt(hex.slice(4, 6), 16);
375
- if (isNaN(r) || isNaN(g) || isNaN(b))
376
- return null;
377
- return [r, g, b];
378
- }
379
365
  function srgbChannelToLinear(c) {
380
366
  const s = c / 255;
381
367
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
@@ -388,23 +374,32 @@ function contrastRatio(a, b) {
388
374
  const lb = relativeLuminance(b);
389
375
  return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
390
376
  }
391
- function resolveColor(value, tokens) {
392
- const v = value.trim();
393
- if (!v)
377
+ /**
378
+ * A token value as the opaque sRGB colour v06 and v22 measure.
379
+ *
380
+ * Read through the CSS Color 4 parser v44 uses (resolveThemeColor): var()
381
+ * chains with fallbacks, hex of 3, 4, 6 or 8 digits, rgb() and hsl() in comma
382
+ * or space syntax, oklch(), oklab(), color(srgb), color-mix(), light-dark()
383
+ * (its light side: these two checks read one theme) and bare HSL channels.
384
+ * Until engine 1.3.0 they read only 3- and 6-digit hex and comma rgb(), with
385
+ * alpha ignored, so a stylesheet written in oklch() (Tailwind v4, shadcn)
386
+ * SKIPped both checks and a page with illegible text scored 100.
387
+ *
388
+ * A translucent colour is composited over `under` as a browser paints it;
389
+ * with nothing under it, it is not measured.
390
+ */
391
+ function resolveTokenColor(value, tokens, under) {
392
+ if (!value.trim())
394
393
  return null;
395
- if (v.startsWith('#'))
396
- return hexToRgb(v);
397
- const varMatch = v.match(/^var\(\s*(--[\w-]+)/);
398
- if (varMatch) {
399
- const ref = tokens[varMatch[1]];
400
- if (ref)
401
- return resolveColor(ref, tokens);
394
+ const c = resolveThemeColor(value, [tokens], false);
395
+ if (!c)
402
396
  return null;
403
- }
404
- const rgbMatch = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
405
- if (rgbMatch)
406
- return [parseInt(rgbMatch[1]), parseInt(rgbMatch[2]), parseInt(rgbMatch[3])];
407
- return null;
397
+ if (c[3] >= 1)
398
+ return [c[0], c[1], c[2]];
399
+ if (!under)
400
+ return null;
401
+ const painted = v44Composite(c, [under[0], under[1], under[2], 1]);
402
+ return [painted[0], painted[1], painted[2]];
408
403
  }
409
404
  // APCA-W3 0.0.98G-4g
410
405
  function sRGBtoY_APCA(rgb) {
@@ -471,7 +466,7 @@ const REMEDIATION = {
471
466
  v43: 'Express UI states as semantic color roles: --ok/--success (verification pass), --warn/--warning (caution), --error/--danger (failure), --info (notice). The contract ships --ok, --warn, and --error for exactly this. Status colors named by state let components consume meaning (a score badge, a form error, and a toast all read the same token) and stay legible when the palette evolves.',
472
467
  v44: 'Give each status hue a text token for words and keep the hue itself for marks. A color that marks a state (a dot, a bar, a tint) needs 3:1, while the word for that state needs 4.5:1 (3:1 at 24px, or 18.66px bold) in every theme the site declares. Mix the hue toward your ink for text, for example --warn-ink: color-mix(in oklab, var(--warn) 70%, var(--ink)), measure it on the page and on each surface it sits on in every theme, and paint status words with it.',
473
468
  v45: 'Let every entrance that starts invisible run to its end when motion is paused. Under the pause rule, give one-shot entrances whose first keyframe is opacity 0 a negative delay longer than the animation, for example html[data-motion="paused"] :is(.fade-up, .reveal) { animation-delay: -3600s !important; }, or remove them with animation: none. Loops can stay held. Test by pausing motion and reloading: every page should still show its content.',
474
- v34: '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).',
469
+ v34: 'If the page hosts an AI chatbot or agent, tell people they are talking to an AI no later than their first interaction, in a form assistive technology announces. EU AI Act Article 50(1), which applies from 2026-08-02, places this duty on the provider of the AI system (the organisation that develops it and places it on the market or puts it into service under its own name); it does not apply where the AI nature is obvious from the context, and Article 50(5) asks for the information to be clear and accessible. The check reads any one of: visible "AI assistant" or "Chatbot" text, aria-label="AI assistant" on the chat container, or a data-ai-disclosure attribute on the widget. A live chat answered by people needs no AI disclosure: if the check flagged one for review, confirm who answers. US laws differ in scope: California\'s bot-disclosure law (SB 1001, Bus. & Prof. Code §§17940-17943) covers bots that mislead people about being bots to drive a sale or influence a vote; Colorado SB 24-205 was repealed and re-enacted by SB 26-189 (signed 2026-05-14, obligations from 2027-01-01), which governs automated decision-making in consequential decisions. This is not legal advice.',
475
470
  v35: 'Add a forced-colors readiness block: @media (forced-colors: active) { ... } with forced-color-adjust: none on elements that must preserve brand identity (logos, charts, semantic-color indicators). Windows High Contrast Mode and Chrome forced-colors recolor the page: without this media query, critical UI becomes illegible. Also ensure borders/outlines use currentColor or system colors so they adapt. Test with Windows HCM (Settings > Accessibility > Contrast themes).',
476
471
  v36: 'Remove UTS #39 confusable characters from CSS identifiers and token names. Confusables are Unicode characters from different scripts (Cyrillic, Greek, fullwidth) that look identical to ASCII letters; Cyrillic а (U+0430), for example, looks like Latin a (U+0061). In token names they enable shadowing attacks (--соlor-bg with Cyrillic с vs --color-bg). Audit all custom property names, class names, and url() paths for non-ASCII characters using a Unicode confusable detector. Provenance: Unicode Technical Standard #39, Unicode 16.0.0. Designesy is the only design verification engine that checks this surface.',
477
472
  v37: 'Publish a DESIGN.md file at /DESIGN.md in your repo root and serve it publicly. Google\'s @google/design.md CLI (v0.4.0, Apache-2.0) validates the file format: 11 lint rules covering broken token refs, missing primary colors, WCAG contrast, orphaned tokens, section order, and more. Designesy integrates Google\'s linter as the spec layer and runs its own 44-check contract verification as the layer above. Install the CLI: npm install -g @google/design.md. Lint locally: npx @google/design.md lint DESIGN.md. Export to W3C DTCG: npx @google/design.md export --format dtcg DESIGN.md. Note: DESIGN.md uses sRGB hex only; for OKLCH/Display P3 color spaces, use the W3C DTCG JSON format directly.',
@@ -513,9 +508,11 @@ function checkContrastSignal(tokens) {
513
508
  // (designers choose the higher-contrast pairing).
514
509
  const actionVal = tokens['--action'];
515
510
  const onActionVal = tokens['--on-action'];
511
+ // The page under a translucent fill is --paper; a translucent label sits on the fill.
512
+ const pageRgb = resolveTokenColor(tokens['--paper'] || '', tokens, null);
516
513
  if (actionVal && onActionVal) {
517
- const fillRgb = resolveColor(actionVal, tokens);
518
- const labelRgb = resolveColor(onActionVal, tokens);
514
+ const fillRgb = resolveTokenColor(actionVal, tokens, pageRgb);
515
+ const labelRgb = fillRgb ? resolveTokenColor(onActionVal, tokens, fillRgb) : null;
519
516
  if (!fillRgb || !labelRgb)
520
517
  return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'SKIP', detail: `--action ${actionVal} or --on-action ${onActionVal} unresolvable to RGB` };
521
518
  const pairRatio = contrastRatio(labelRgb, fillRgb);
@@ -529,12 +526,12 @@ function checkContrastSignal(tokens) {
529
526
  const signalVal = tokens['--signal'];
530
527
  if (!signalVal)
531
528
  return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'WARN', detail: '--signal accent token not declared' };
532
- const signalRgb = resolveColor(signalVal, tokens);
529
+ const signalRgb = resolveTokenColor(signalVal, tokens, pageRgb);
533
530
  if (!signalRgb)
534
531
  return { id: 'v22', item: 'Primary button text passes WCAG AA contrast against --signal fill', category: 'accessibility', status: 'SKIP', detail: `--signal value ${signalVal} unresolvable to RGB` };
535
532
  const candidates = [
536
- { name: '--paper', rgb: resolveColor(tokens['--paper'] || '', tokens) },
537
- { name: '--ink', rgb: resolveColor(tokens['--ink'] || '', tokens) },
533
+ { name: '--paper', rgb: resolveTokenColor(tokens['--paper'] || '', tokens, signalRgb) },
534
+ { name: '--ink', rgb: resolveTokenColor(tokens['--ink'] || '', tokens, signalRgb) },
538
535
  ];
539
536
  let bestRatio = 0;
540
537
  let bestName = '';
@@ -560,14 +557,20 @@ function checkContrastSignal(tokens) {
560
557
  // Computes both WCAG 2.1 ratio (the scoring standard) AND APCA Lc (the
561
558
  // WCAG 3 candidate, polarity-aware). APCA is supplementary — it does not
562
559
  // change pass/fail, but surfaces dark-mode contrast issues WCAG 2.1 misses.
560
+ //
561
+ // Where a stylesheet declares --muted-foreground, that is the muted text
562
+ // measured: in the shadcn convention --muted is a surface and
563
+ // --muted-foreground the text on it. Engine 1.3.0 reads oklch() (see
564
+ // resolveTokenColor), so without this every default shadcn theme would
565
+ // measure its --muted surface as text, at about 1.1:1, and FAIL.
563
566
  function checkContrastReadable(tokens) {
564
567
  const paperVal = tokens['--paper'];
565
568
  if (!paperVal)
566
569
  return { id: 'v06', item: 'Contrast remains readable for ink, muted, and accent on paper', category: 'accessibility', status: 'SKIP', detail: '--paper not declared: cannot test contrast' };
567
- const paperRgb = resolveColor(paperVal, tokens);
570
+ const paperRgb = resolveTokenColor(paperVal, tokens, null);
568
571
  if (!paperRgb)
569
572
  return { id: 'v06', item: 'Contrast remains readable for ink, muted, and accent on paper', category: 'accessibility', status: 'SKIP', detail: `--paper value ${paperVal} unresolvable to RGB` };
570
- const textTokens = ['--ink', '--muted', '--muted-dim'];
573
+ const textTokens = ['--ink', tokens['--muted-foreground'] ? '--muted-foreground' : '--muted', '--muted-dim'];
571
574
  const results = [];
572
575
  let worst = { name: '', ratio: Infinity, status: 'PASS' };
573
576
  for (const name of textTokens) {
@@ -576,7 +579,7 @@ function checkContrastReadable(tokens) {
576
579
  results.push(`${name}: not declared`);
577
580
  continue;
578
581
  }
579
- const rgb = resolveColor(val, tokens);
582
+ const rgb = resolveTokenColor(val, tokens, paperRgb);
580
583
  if (!rgb) {
581
584
  results.push(`${name}: unresolvable`);
582
585
  continue;
@@ -1300,15 +1303,61 @@ function classifyColorToken(name) {
1300
1303
  return 'hue';
1301
1304
  return 'neutral';
1302
1305
  }
1306
+ /**
1307
+ * The color tokens v42 and v43 read, and the palette layer among them
1308
+ * (engine 1.3.0).
1309
+ *
1310
+ * A token is a color token when its value is written as a color, or when,
1311
+ * with var() aliases followed through the token set, it is one. Until 1.3.0 only a literal counted, so a
1312
+ * role token that aliases a primitive (--error: var(--red-700), the structure
1313
+ * v42's own remediation recommends) was dropped before classification and the
1314
+ * verdict inverted: a site that did what the check asks scored worse.
1315
+ *
1316
+ * The palette layer is every hue-named color token that a role-named token
1317
+ * reaches through its aliases. That is the two-tier structure the token
1318
+ * standards describe: DTCG 2025.10 §3.8 defines an alias as a token whose
1319
+ * value references another (its example is $color-text-primary aliasing
1320
+ * $color-palette-black); Spectrum's primitive tokens "are typically used
1321
+ * indirectly through semantic tokens as an alias"; Primer's base tokens are
1322
+ * "generally used as a reference for functional tokens"; Material's reference
1323
+ * tokens hold the concrete values that system tokens give roles to. A palette
1324
+ * used that way is not a hue vocabulary, so it leaves v42's denominator. A
1325
+ * hue-named token that no role token reaches is consumed directly, or not at
1326
+ * all, and still counts against the role share.
1327
+ */
1328
+ function colorTokenTiers(tokens) {
1329
+ const refs = (value) => [...value.matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
1330
+ // A value written as a color counts as one, as before 1.3.0, even when its
1331
+ // var() cannot be followed (hsl(var(--x)) whose --x refers back to itself);
1332
+ // anything else counts when its aliases resolve to a color.
1333
+ const color = Object.keys(tokens).filter((name) => {
1334
+ if (isColorValue(tokens[name]))
1335
+ return true;
1336
+ const resolved = substituteCssVars(tokens[name], [tokens]);
1337
+ return resolved !== null && isColorValue(resolved);
1338
+ });
1339
+ const reached = new Set();
1340
+ const queue = color.filter((name) => classifyColorToken(name) === 'role').flatMap((name) => refs(tokens[name]));
1341
+ while (queue.length > 0) {
1342
+ const name = queue.pop();
1343
+ if (reached.has(name) || !Object.prototype.hasOwnProperty.call(tokens, name))
1344
+ continue;
1345
+ reached.add(name);
1346
+ queue.push(...refs(tokens[name]));
1347
+ }
1348
+ return { color, palette: new Set(color.filter((name) => reached.has(name) && classifyColorToken(name) === 'hue')) };
1349
+ }
1303
1350
  function checkSemanticColorVocabulary(tokens) {
1304
1351
  const item = 'Semantic color vocabulary: tokens named by role rather than hue';
1305
- const colorTokens = Object.entries(tokens).filter(([, v]) => isColorValue(v));
1306
- if (colorTokens.length < 4) {
1307
- return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${colorTokens.length})` };
1352
+ const { color, palette } = colorTokenTiers(tokens);
1353
+ if (color.length < 4) {
1354
+ return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `too few color tokens to assess (${color.length})` };
1308
1355
  }
1309
1356
  let role = 0;
1310
1357
  let hue = 0;
1311
- for (const [name] of colorTokens) {
1358
+ for (const name of color) {
1359
+ if (palette.has(name))
1360
+ continue;
1312
1361
  const c = classifyColorToken(name);
1313
1362
  if (c === 'role')
1314
1363
  role++;
@@ -1317,22 +1366,26 @@ function checkSemanticColorVocabulary(tokens) {
1317
1366
  }
1318
1367
  const named = role + hue;
1319
1368
  if (named === 0) {
1320
- return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${colorTokens.length} color tokens, all neutrally named: vocabulary unclassifiable` };
1369
+ return { id: 'v42', item, category: 'semantic', status: 'SKIP', detail: `${color.length} color tokens, all neutrally named: vocabulary unclassifiable` };
1321
1370
  }
1322
1371
  const share = Math.round((role / named) * 100);
1372
+ const layer = palette.size > 0 ? `; ${palette.size} hue-named primitive(s) that role tokens alias are the palette layer, not counted` : '';
1323
1373
  if (role >= 3 && share >= 60) {
1324
- return { id: 'v42', item, category: 'semantic', status: 'PASS', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): colors are named by role` };
1374
+ return { id: 'v42', item, category: 'semantic', status: 'PASS', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): colors are named by role${layer}` };
1325
1375
  }
1326
- return { id: 'v42', item, category: 'semantic', status: 'WARN', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): color vocabulary leans on hue names` };
1376
+ return { id: 'v42', item, category: 'semantic', status: 'WARN', detail: `${role} role-named vs ${hue} hue-named color tokens (${share}% role share): color vocabulary leans on hue names${layer}` };
1327
1377
  }
1378
+ // v43 reads the same color tokens as v42, aliases followed, so --error:
1379
+ // var(--red-700) is an error family. The palette layer needs no exclusion
1380
+ // here: v43 counts families, and a hue-named primitive is never one.
1328
1381
  function checkSemanticStatusRoles(tokens) {
1329
1382
  const item = 'Semantic status colors: ok/warn/error/info state roles present';
1330
- const colorTokens = Object.entries(tokens).filter(([, v]) => isColorValue(v));
1331
- if (colorTokens.length === 0) {
1383
+ const { color } = colorTokenTiers(tokens);
1384
+ if (color.length === 0) {
1332
1385
  return { id: 'v43', item, category: 'semantic', status: 'SKIP', detail: 'no color tokens in :root' };
1333
1386
  }
1334
1387
  const families = { ok: false, warn: false, error: false, info: false };
1335
- for (const [name] of colorTokens) {
1388
+ for (const name of color) {
1336
1389
  const segments = name.toLowerCase().split(/[-_]/);
1337
1390
  if (segments.some((s) => ['ok', 'success', 'pass', 'positive'].includes(s)))
1338
1391
  families.ok = true;
@@ -1725,7 +1778,9 @@ function parseCssColor(input, dark, depth = 0) {
1725
1778
  const ch = [0, 2, 4, 6].map((k) => (k < full.length ? parseInt(full.slice(k, k + 2), 16) : 255));
1726
1779
  return [ch[0], ch[1], ch[2], ch[3] / 255];
1727
1780
  }
1728
- if (CSS_NAMED_COLORS[v])
1781
+ // Own keys only: 'constructor' or 'toString' as a value read Object.prototype
1782
+ // and threw, which failed the whole score (engine 1.3.0 reads token values here).
1783
+ if (Object.prototype.hasOwnProperty.call(CSS_NAMED_COLORS, v))
1729
1784
  return [...CSS_NAMED_COLORS[v]];
1730
1785
  const fn = /^([a-z-]+)\(([\s\S]*)\)$/.exec(v);
1731
1786
  if (!fn)
@@ -2966,17 +3021,25 @@ function checkPausedEntrances(css) {
2966
3021
  },
2967
3022
  };
2968
3023
  }
2969
- // v34 — AI-Disclosure Readiness (EU AI Act Article 50, effective 2026-08-02).
2970
- // Static-only v1: scans fetched HTML for AI-interactive surfaces (chatbots,
2971
- // AI agents, AI-generated content) and disclosure signals (visible labels,
2972
- // aria-label, C2PA meta, generator meta, JSON-LD, data-ai-disclosure).
2973
- // Four conditions:
2974
- // A — no AI surface detected → PASS (disclosure not required)
2975
- // B — AI surface + disclosure signal → PASS
2976
- // C — AI surface + NO disclosure → FAIL (Art 50 violation)
2977
- // D — uncertain (possible AI surface) → WARN (manual review)
2978
- // Green-field check: no competitor (Mozaika, Lighthouse, axe, WAVE, DESIGN.md)
2979
- // has an AI-disclosure check. Designesy can be first.
3024
+ // v34 — AI-Disclosure Readiness (EU AI Act Article 50, which applies from
3025
+ // 2026-08-02). Static, from the fetched HTML: it looks for an AI chat surface
3026
+ // and for a disclosure signal (visible text, aria-label, data-ai-disclosure,
3027
+ // generator meta, C2PA, JSON-LD).
3028
+ //
3029
+ // Article 50(1) puts the duty on the PROVIDER of an AI system that talks with
3030
+ // people: they must be told it is an AI, unless that is obvious from the
3031
+ // context, at the latest at the first interaction (50(5)). A site that embeds
3032
+ // a vendor's assistant may not be that provider, so the check says "may
3033
+ // require", never "requires", and it is not a legal finding.
3034
+ //
3035
+ // Only a positively identified AI chat surface with no disclosure FAILs: a
3036
+ // container or input that names itself a chatbot or an AI assistant, or a
3037
+ // script from a chatbot, AI-widget or Dialogflow path. Anything weaker WARNs
3038
+ // "possible AI surface: review": live-chat vendors (Intercom, Drift, Zendesk,
3039
+ // LiveChat, Tawk, Crisp), which are often answered by people; a generic chat
3040
+ // container or an "assistant" script; page text about AI, which is not a chat
3041
+ // surface. Until engine 1.3.0 those FAILed, and so did Google Publisher Tag,
3042
+ // an ad tag, because its gpt.js matched the word "gpt".
2980
3043
  function checkAiDisclosure(html) {
2981
3044
  const ITEM = 'AI-Disclosure Readiness (EU AI Act Art 50, effective 2026-08-02)';
2982
3045
  const CATEGORY = 'identity';
@@ -2989,20 +3052,31 @@ function checkAiDisclosure(html) {
2989
3052
  // false-positive as disclosure TEXT. Disclosure text must be human-readable
2990
3053
  // content between tags, not class/id/aria attribute values.
2991
3054
  const textOnly = visibleHtml.replace(/<[^>]+>/g, ' ');
2992
- // ── AI-surface detection ────────────────────────────────────────────────
2993
- const chatbotContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat|assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i;
2994
- const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|assistant|ai-widget|gpt|claude|gemini|dialogflow|rasa|intercom|drift|tawk|crisp|livechat|chatbot\.ai|conversational)\b/i;
2995
- const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
3055
+ // ── AI chat surfaces, positively identified ─────────────────────────────
3056
+ const aiContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:chatbot|chat-bot|ai-assistant|ai_chat)\b/i;
3057
+ const aiScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:chatbot|ai-widget|dialogflow)\b/i;
2996
3058
  const aiInputRe = /<(?:input|textarea|form)\b[^>]*(?:placeholder|aria-label)\s*=\s*["'][^"']*\b(?:ask\s+ai|message\s+ai|chat\s+with\s+ai|ai\s+assistant)\b/i;
3059
+ // ── Possible AI surfaces: a review item, never a FAIL ───────────────────
3060
+ const chatContainerRe = /<(?:iframe|div|section|aside)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:assistant-widget|intercom|drift|zendesk-chat|tawk(?:\.to)?|crisp\.chat|livechat|chat-widget|chat-container)\b/i;
3061
+ const chatScriptRe = /<script\b[^>]*src\s*=\s*["'][^"']*\b(?:assistant|claude|gemini|rasa|intercom|drift|tawk|crisp|livechat|conversational)\b/i;
3062
+ const aiTextRe = /\b(?:chat\s*with\s*(?:ai|our\s*bot|ai\s*assistant)|ask\s+ai|ai\s+assistant|talk\s+to\s+(?:our\s+)?bot|powered\s+by\s+ai|ai-generated|ai\s+chatbot|virtual\s+assistant)\b/i;
3063
+ const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
2997
3064
  const surfaceSignals = [];
2998
- if (chatbotContainerRe.test(html))
3065
+ if (aiContainerRe.test(html))
2999
3066
  surfaceSignals.push('chatbot container');
3000
3067
  if (aiScriptRe.test(html))
3001
3068
  surfaceSignals.push('AI widget script');
3002
- if (aiTextRe.test(textOnly))
3003
- surfaceSignals.push('AI prompt text');
3004
3069
  if (aiInputRe.test(html))
3005
3070
  surfaceSignals.push('AI input');
3071
+ const possibleSignals = [];
3072
+ if (chatContainerRe.test(html))
3073
+ possibleSignals.push('chat widget container');
3074
+ if (chatScriptRe.test(html))
3075
+ possibleSignals.push('chat or assistant script');
3076
+ if (aiTextRe.test(textOnly))
3077
+ possibleSignals.push('AI prompt text');
3078
+ if (ambiguousRe.test(html))
3079
+ possibleSignals.push('ambiguous widget');
3006
3080
  // ── Disclosure detection ────────────────────────────────────────────────
3007
3081
  const disclosureTextRe = /\b(?:ai\s+assistant|ai\s+chatbot|ai-powered|powered\s+by\s+ai|automated\s+assistant|virtual\s+assistant|chatbot)\b/i;
3008
3082
  const ariaDisclosureRe = /aria-(?:label|description)\s*=\s*["'][^"']*\b(?:ai|assistant|bot|automated|chatbot)\b/i;
@@ -3024,17 +3098,17 @@ function checkAiDisclosure(html) {
3024
3098
  if (dataAiRe.test(html))
3025
3099
  disclosureSignals.push('data-ai-disclosure');
3026
3100
  // ── Condition evaluation ────────────────────────────────────────────────
3027
- if (surfaceSignals.length === 0) {
3028
- const ambiguousRe = /<(?:iframe|div|section)\b[^>]*(?:class|id)\s*=\s*["'][^"']*\b(?:contact-widget|smart-search|virtual-agent|message-us|help-widget)\b/i;
3029
- if (ambiguousRe.test(html)) {
3030
- return { id: 'v34', item: ITEM, category: CATEGORY, status: 'WARN', detail: 'possible AI-interactive surface (ambiguous widget): manual review recommended for Art 50 compliance' };
3031
- }
3101
+ if (surfaceSignals.length === 0 && possibleSignals.length === 0) {
3032
3102
  return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: 'no AI-interactive surface detected: disclosure not required' };
3033
3103
  }
3034
3104
  if (disclosureSignals.length > 0) {
3035
- return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: `AI surface detected (${surfaceSignals.join(', ')}); disclosure present (${disclosureSignals.join(', ')})` };
3105
+ const found = surfaceSignals.length > 0 ? `AI surface detected (${surfaceSignals.join(', ')})` : `possible AI surface (${possibleSignals.join(', ')})`;
3106
+ return { id: 'v34', item: ITEM, category: CATEGORY, status: 'PASS', detail: `${found}; disclosure present (${disclosureSignals.join(', ')})` };
3107
+ }
3108
+ if (surfaceSignals.length > 0) {
3109
+ return { id: 'v34', item: ITEM, category: CATEGORY, status: 'FAIL', detail: `AI chat surface detected (${surfaceSignals.join(', ')}) and no AI disclosure found: EU AI Act Art. 50(1) may require telling people at their first interaction that they are talking to an AI (a duty on the AI system's provider, applying from 2026-08-02)` };
3036
3110
  }
3037
- return { id: 'v34', item: ITEM, category: CATEGORY, status: 'FAIL', detail: `AI-interactive surface detected (${surfaceSignals.join(', ')}) but no disclosure found: EU AI Act Art 50(1) requires disclosure at first interaction` };
3111
+ return { id: 'v34', item: ITEM, category: CATEGORY, status: 'WARN', detail: `possible AI surface (${possibleSignals.join(', ')}): review. If it is an AI system and no disclosure is shown, EU AI Act Art. 50(1) may require one (a duty on the AI system's provider); a chat answered by people needs none` };
3038
3112
  }
3039
3113
  // v35 — Forced-colors readiness (Windows High Contrast Mode / Chrome forced-colors).
3040
3114
  // Verifies the site has a @media (forced-colors: active) block, ideally with
@@ -3936,9 +4010,25 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
3936
4010
  // strengths. These are design-integrity failures — a site that FAILs on
3937
4011
  // contrast or horizontal overflow cannot be A-grade no matter how good its
3938
4012
  // tokens are. Caps are applied AFTER the a11y floor (floor wins over ceilings).
4013
+ //
4014
+ // No A while any check FAILs (engine 1.3.0, owner decision 2026-10-11). A
4015
+ // FAIL is the engine's own finding that the page breaks a rule, so any FAIL
4016
+ // caps the score at 89.9, a B at most. Until 1.3.0 a page could score 100, an
4017
+ // A, beside FAIL verdicts, including the engine's own WCAG contrast FAIL
4018
+ // (v44). The specific ceilings below are lower and win where they apply.
4019
+ // Every cap here runs after the originality lift, so the lift can carry a
4020
+ // score up to a cap but never past one.
3939
4021
  const hardFailChecks = checks.filter((c) => c.status === 'FAIL');
3940
4022
  let hardFailCeilingApplied = false;
3941
4023
  let hardFailCeilingReason = null;
4024
+ const ANY_FAIL_CAP = 89.9;
4025
+ if (hardFailChecks.length > 0 && score > ANY_FAIL_CAP) {
4026
+ const ids = hardFailChecks.map((c) => c.id);
4027
+ const named = ids.length === 1 ? `${ids[0]} fails` : `${ids.slice(0, -1).join(', ')} and ${ids[ids.length - 1]} fail`;
4028
+ score = ANY_FAIL_CAP;
4029
+ hardFailCeilingApplied = true;
4030
+ hardFailCeilingReason = `A FAIL caps the grade at B: ${named}, and the score cannot exceed 89.9 while any check fails.`;
4031
+ }
3942
4032
  for (const c of hardFailChecks) {
3943
4033
  let cap = null;
3944
4034
  let reason = null;
@@ -3947,6 +4037,14 @@ function scoreArithmetic(checks, slopTotal, originalityPoints) {
3947
4037
  cap = 65;
3948
4038
  reason = 'Contrast below WCAG minimum: text is unreadable for many users.';
3949
4039
  }
4040
+ // v44 Status text contrast — the words that report a state fail WCAG
4041
+ // 1.4.3 (or 1.4.11 for a graphic) on a surface the stylesheet attests.
4042
+ // Same cap as v06 (engine 1.3.0, owner decision 2026-10-11): both are
4043
+ // measured WCAG contrast failures on text.
4044
+ if (c.id === 'v44') {
4045
+ cap = 65;
4046
+ reason = 'Status text below WCAG contrast (4.5:1, or 3:1 for large text and graphics): the words that report errors, warnings and success are hard to read.';
4047
+ }
3950
4048
  // v22 Contrast signal — CTA text unreadable on brand color
3951
4049
  if (c.id === 'v22') {
3952
4050
  cap = 70;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@designesy/score",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Standalone 44-check design-contract scoring engine: fetches a URL, extracts CSS + tokens, and runs all checks locally. No required dependencies.",
5
5
  "license": "MIT",
6
6
  "author": "Designesy <hello@designesy.org>",