quiver-cli 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/dist/cli.js +237 -10
  2. package/package.json +1 -1
  3. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  4. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  5. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  6. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  7. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  8. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  9. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  10. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  12. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  13. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  14. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  15. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  16. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  17. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  18. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  19. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  21. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  22. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  23. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  24. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  25. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  26. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  28. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  29. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  30. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  31. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  32. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  33. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  34. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  36. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  37. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  39. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  40. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  41. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  42. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  43. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  45. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  47. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  48. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  49. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  51. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  56. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  57. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  58. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  60. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  61. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  62. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  63. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  64. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  65. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  66. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  67. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  68. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  69. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  70. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  76. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  77. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  85. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  86. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  89. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  90. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  91. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  92. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  95. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  96. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  97. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  98. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  99. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  100. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  101. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  102. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  103. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  104. package/template/.agents/skills/hono/SKILL.md +17 -3
  105. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  106. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  107. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  108. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  109. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  110. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  111. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  112. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  113. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  114. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  115. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  116. package/template/.agents/skills/supabase/SKILL.md +5 -1
  117. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  118. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  119. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  120. package/template/.agents/upstreams.json +21 -22
  121. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -9,16 +9,24 @@ import {
9
9
  WCAG_LARGE_BOLD_TEXT_PX,
10
10
  WCAG_LARGE_TEXT_PX,
11
11
  isBrandFontOnOwnDomain,
12
+ primaryFontFace,
12
13
  } from '../shared/constants.mjs';
13
14
  import {
15
+ CSS_NAMED_COLORS,
14
16
  colorToHex,
17
+ compositeColorOver,
15
18
  contrastRatio,
16
19
  getHue,
17
20
  hasChroma,
18
21
  isNeutralColor,
22
+ isNoPaintColorValue,
23
+ oklchToRgb,
24
+ parseAnyColor,
25
+ parseColorMix,
19
26
  parseGradientColors,
20
27
  parseRgb,
21
28
  relativeLuminance,
29
+ splitTopLevelCommas,
22
30
  } from '../shared/color.mjs';
23
31
  import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
24
32
 
@@ -70,6 +78,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
70
78
  return findings;
71
79
  }
72
80
 
81
+ // ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
82
+ //
83
+ // An element-scoped waiver that travels with the markup: any element carrying
84
+ // `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
85
+ // every rule) suppresses matching findings from itself and its entire subtree,
86
+ // in every engine that walks elements — the browser overlay, the extension,
87
+ // and the static scan. This is the DOM twin of the line-based
88
+ // `impeccable-disable` comment directives, which the browser cannot apply (a
89
+ // live DOM has no line numbers), and the generalization of the one-off
90
+ // `data-impeccable-allow-kickers` opt-out.
91
+ //
92
+ // The intended use is curated exhibits: a page that documents anti-patterns by
93
+ // example, or renders a deliberate "before" specimen, marks the container once
94
+ // and every engine skips it while still scanning the page around it.
95
+ function scopedIgnoreActive(el, ruleId) {
96
+ const rule = String(ruleId || '').toLowerCase();
97
+ let cur = el;
98
+ while (cur && cur.nodeType === 1) {
99
+ const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
100
+ if (attr != null) {
101
+ const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
102
+ if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
103
+ }
104
+ cur = cur.parentElement;
105
+ }
106
+ return false;
107
+ }
108
+
73
109
  // Returns true if the given text is composed entirely of emoji characters
74
110
  // (plus whitespace / variation selectors). Emojis render as multicolor glyphs
75
111
  // regardless of CSS `color`, so contrast checks against the element's text
@@ -296,7 +332,7 @@ function checkIconTile(opts) {
296
332
  function resolveSerif(fontFamily) {
297
333
  if (!fontFamily) return { primary: null, isSerif: false };
298
334
  const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
299
- const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
335
+ const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
300
336
  if (!primary) return { primary: null, isSerif: false };
301
337
  if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
302
338
  if (tokens.includes('serif')) return { primary, isSerif: true };
@@ -637,6 +673,31 @@ function cssTextHasDarkRootBg(content, customProps) {
637
673
  return false;
638
674
  }
639
675
 
676
+ // Best-effort extraction of the CSS selector whose declaration block contains
677
+ // the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
678
+ // anchor, so the browser pass can resolve scoped ignores against the actual
679
+ // element and drop patterns that render nowhere on the page. Returns null for
680
+ // @-rule preludes, keyframe steps, nested blocks, and anything that does not
681
+ // read as a selector; those findings stay page-level.
682
+ function enclosingCssSelector(cssText, index) {
683
+ if (!cssText || !Number.isFinite(index)) return null;
684
+ const open = cssText.lastIndexOf('{', index);
685
+ if (open === -1) return null;
686
+ // A match inside an inline style fragment (`style="…"` appended to the
687
+ // corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
688
+ // `{` belongs to some other selector.
689
+ const closeBeforeIndex = cssText.lastIndexOf('}', index);
690
+ if (closeBeforeIndex > open) return null;
691
+ const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
692
+ const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
693
+ if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
694
+ // Keyframe steps: percentage steps fail the digit test above, but `from`
695
+ // and `to` would read as (never-matching) type selectors and get a valid
696
+ // finding wrongly dropped by the zero-match rule downstream.
697
+ if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
698
+ return raw;
699
+ }
700
+
640
701
  function scanCssTextForGlow(content) {
641
702
  const customProps = collectCssCustomProps(content);
642
703
  const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -666,20 +727,19 @@ function scanCssTextForGlow(content) {
666
727
  return results;
667
728
  }
668
729
 
669
- // Decorative grid or line-field backgrounds drawn with hairline
730
+ // Decorative two-axis grid backgrounds drawn with hairline
670
731
  // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
671
732
  // pattern pass and the regex source engine so standalone CSS, component
672
733
  // styles, and inline styles receive the same coverage. Both signals must
673
734
  // co-occur in one declaration block; unrelated rules must not add up across
674
- // the file. Returns [{ index, snippet }], capped at one finding per source to
675
- // match the page-level HTML check's existing behavior.
735
+ // the file. A single hairline is a line, divider, or rail, not a grid, even
736
+ // when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
737
+ // finding per source to match the page-level HTML check's existing behavior.
676
738
  function scanCssTextForGridBackground(content) {
677
739
  const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
678
740
  const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
679
741
  const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
680
- const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
681
742
  const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
682
- const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
683
743
  const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
684
744
  const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
685
745
  let blk;
@@ -696,13 +756,10 @@ function scanCssTextForGridBackground(content) {
696
756
  }
697
757
  if (hairlineCount === 0) continue;
698
758
  const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
699
- const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
700
- if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
759
+ if (hairlineCount >= 2 && hasPxCell) {
701
760
  return [{
702
761
  index: blk.index,
703
- snippet: hairlineCount >= 2
704
- ? 'two-axis grid-line gradient background'
705
- : 'px-tiled hairline line-field background',
762
+ snippet: 'two-axis grid-line gradient background',
706
763
  }];
707
764
  }
708
765
  }
@@ -948,6 +1005,7 @@ function scanCssTextForPseudoStripe(rawContent) {
948
1005
  id: 'side-tab',
949
1006
  snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
950
1007
  index: selectorStart,
1008
+ selector,
951
1009
  });
952
1010
  }
953
1011
  return findings;
@@ -1010,6 +1068,7 @@ function scanCssTextForInsetStripe(content) {
1010
1068
  findings.push({
1011
1069
  id: 'side-tab',
1012
1070
  snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
1071
+ selector,
1013
1072
  });
1014
1073
  break;
1015
1074
  }
@@ -1067,7 +1126,7 @@ function collectMarqueeKeyframes(content) {
1067
1126
  function scanCssTextForMarquee(content, markup = content) {
1068
1127
  const findings = [];
1069
1128
  if (/<marquee\b/i.test(markup)) {
1070
- findings.push({ id: 'marquee', snippet: '<marquee> element' });
1129
+ findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
1071
1130
  }
1072
1131
  const marqueeKeyframes = collectMarqueeKeyframes(content);
1073
1132
  if (marqueeKeyframes.size === 0) return findings;
@@ -1082,7 +1141,7 @@ function scanCssTextForMarquee(content, markup = content) {
1082
1141
  const key = `${selector} ${name}`;
1083
1142
  if (seen.has(key)) continue;
1084
1143
  seen.add(key);
1085
- findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
1144
+ findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
1086
1145
  }
1087
1146
  }
1088
1147
  return findings;
@@ -1396,6 +1455,95 @@ function scanHtmlForShapeAssembledIllustration(html) {
1396
1455
  return findings;
1397
1456
  }
1398
1457
 
1458
+
1459
+ // --- Organic clip-path polygons ----------------------------------------------
1460
+ // A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
1461
+ // with curves, is CSS approximating an organic contour: a torn edge, a blob,
1462
+ // a silhouette. The approximation reads as the cheap version of the effect
1463
+ // (the craft floor's geometric-occlusion-mask ban), and it is the signature
1464
+ // of a comp's produced material being replaced with code. Geometric clips
1465
+ // (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
1466
+ // the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
1467
+ // from an alpha matte passes.
1468
+ const ORGANIC_POLYGON_MIN_VERTICES = 10;
1469
+ function scanCssTextForOrganicClipPath(styleText) {
1470
+ const findings = [];
1471
+ const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
1472
+ let m;
1473
+ while ((m = re.exec(styleText)) !== null) {
1474
+ const kind = m[1].toLowerCase();
1475
+ const body = m[2];
1476
+ if (kind === 'path') {
1477
+ // curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
1478
+ // rectilinear M/L/Z outline; letters in path data are only commands
1479
+ const curves = (body.match(/[CSQTA]/gi) || []).length;
1480
+ if (curves < 3) continue;
1481
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
1482
+ continue;
1483
+ }
1484
+ const points = body.split(',').map((p) => p.trim()).filter(Boolean);
1485
+ if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
1486
+ // Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
1487
+ // contour has arbitrary values.
1488
+ let offGrid = 0;
1489
+ for (const p of points) {
1490
+ const nums = p.match(/-?[\d.]+/g) || [];
1491
+ for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
1492
+ }
1493
+ if (offGrid < points.length) continue;
1494
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
1495
+ }
1496
+ return findings;
1497
+ }
1498
+
1499
+ // --- Buried raster ------------------------------------------------------------
1500
+ // A raster (background-image url or <img>) that never reaches the screen:
1501
+ // under a near-opaque gradient wash in the same background stack, or on an
1502
+ // element at near-zero opacity. It is how a produced texture "ships" while
1503
+ // the page shows flat color, and the finish reviewer cannot see it either.
1504
+ // A tint under 0.9 alpha passes (hero darkening); a blend mode passes
1505
+ // (multiply/overlay keep the material visible); opacity >= 0.15 passes.
1506
+ function scanCssTextForBuriedRaster(styleText) {
1507
+ const findings = [];
1508
+ // background stacks: split declarations, look for url() + a gradient whose
1509
+ // stops all carry alpha >= 0.9 (or opaque hex/named colors)
1510
+ const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
1511
+ let m;
1512
+ while ((m = declRe.exec(styleText)) !== null) {
1513
+ const value = m[1];
1514
+ if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
1515
+ // a blend mode declared in the same rule keeps the raster visible
1516
+ const ruleStart = styleText.lastIndexOf('{', m.index);
1517
+ const ruleEnd = styleText.indexOf('}', m.index);
1518
+ const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
1519
+ if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
1520
+ // Layers are painted first-on-top: only a wash listed BEFORE the url()
1521
+ // covers it. An image on top of a gradient is not buried.
1522
+ const firstUrl = value.search(/url\(/i);
1523
+ const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
1524
+ let opaqueWash = false;
1525
+ // an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
1526
+ const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
1527
+ for (const g of gradients) {
1528
+ const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
1529
+ const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
1530
+ // hex stops: 4- and 8-digit forms carry their own alpha
1531
+ for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
1532
+ const hex = h[1];
1533
+ if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
1534
+ else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
1535
+ else alphas.push(1);
1536
+ }
1537
+ const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
1538
+ if (named) alphas.push(1);
1539
+ if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
1540
+ }
1541
+ if (!opaqueWash) continue;
1542
+ findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
1543
+ }
1544
+ return findings;
1545
+ }
1546
+
1399
1547
  // Scoped scan corpora for the page-level pattern checks. CSS-property
1400
1548
  // regexes run over the whole source string fire on documentation ABOUT
1401
1549
  // css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
@@ -1453,8 +1601,10 @@ function checkHtmlPatterns(html, corpora) {
1453
1601
  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
1454
1602
  if (purpleHexRe.test(styleText)) {
1455
1603
  const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
1456
- if (purpleTextRe.test(styleText)) {
1457
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
1604
+ purpleTextRe.lastIndex = 0;
1605
+ const purpleMatch = purpleTextRe.exec(styleText);
1606
+ if (purpleMatch) {
1607
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
1458
1608
  }
1459
1609
  }
1460
1610
 
@@ -1465,7 +1615,7 @@ function checkHtmlPatterns(html, corpora) {
1465
1615
  const start = Math.max(0, gm.index - 200);
1466
1616
  const context = styleText.substring(start, gm.index + gm[0].length + 200);
1467
1617
  if (/gradient/i.test(context)) {
1468
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
1618
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
1469
1619
  break;
1470
1620
  }
1471
1621
  }
@@ -1531,7 +1681,7 @@ function checkHtmlPatterns(html, corpora) {
1531
1681
  const animationToken = bounceMatch[1]
1532
1682
  .split(/[,\s]+/)
1533
1683
  .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
1534
- findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
1684
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
1535
1685
  }
1536
1686
 
1537
1687
  // Overshoot cubic-bezier
@@ -1540,7 +1690,7 @@ function checkHtmlPatterns(html, corpora) {
1540
1690
  while ((bm = bezierRe.exec(styleText)) !== null) {
1541
1691
  const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
1542
1692
  if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
1543
- findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
1693
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
1544
1694
  break;
1545
1695
  }
1546
1696
  }
@@ -1566,6 +1716,10 @@ function checkHtmlPatterns(html, corpora) {
1566
1716
  // Shape-assembled illustrations (large pictorial SVGs built from primitives)
1567
1717
  findings.push(...scanHtmlForShapeAssembledIllustration(html));
1568
1718
 
1719
+ // Organic clip-path contours and rasters buried under washes or opacity
1720
+ findings.push(...scanCssTextForOrganicClipPath(styleText));
1721
+ findings.push(...scanCssTextForBuriedRaster(styleText));
1722
+
1569
1723
  // Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
1570
1724
  findings.push(...scanCssTextForMarquee(styleText, html));
1571
1725
 
@@ -1573,18 +1727,21 @@ function checkHtmlPatterns(html, corpora) {
1573
1727
 
1574
1728
  const glowHits = scanCssTextForGlow(styleText);
1575
1729
  if (glowHits.length > 0) {
1576
- findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
1730
+ findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
1577
1731
  }
1578
1732
 
1579
1733
  // Radial-gradient background halo (gradient-drawn sibling of dark-glow)
1580
1734
  const haloHits = scanCssTextForRadialHalo(styleText);
1581
1735
  if (haloHits.length > 0) {
1582
- findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
1736
+ findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
1583
1737
  }
1584
1738
 
1585
1739
  // --- Generated-UI tells: repeating-gradient stripes ---
1586
- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
1587
- findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
1740
+ {
1741
+ const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
1742
+ if (stripesMatch) {
1743
+ findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
1744
+ }
1588
1745
  }
1589
1746
 
1590
1747
  // --- Generated-UI tells: two-axis grid-line background ---
@@ -1602,7 +1759,7 @@ function checkHtmlPatterns(html, corpora) {
1602
1759
  // whole gradient layers.
1603
1760
  const gridHits = scanCssTextForGridBackground(styleText);
1604
1761
  if (gridHits.length > 0) {
1605
- findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
1762
+ findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
1606
1763
  }
1607
1764
 
1608
1765
  // --- Generated-copy tells: "X theater" framing copy ---
@@ -1622,8 +1779,11 @@ function checkHtmlPatterns(html, corpora) {
1622
1779
  // hover:rotate / hover:translate utility on an <img>. Each distinct
1623
1780
  // mechanism is its own finding.
1624
1781
  const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
1625
- if (imgHoverCss.test(styleText)) {
1626
- findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
1782
+ {
1783
+ const imgHoverMatch = imgHoverCss.exec(styleText);
1784
+ if (imgHoverMatch) {
1785
+ findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
1786
+ }
1627
1787
  }
1628
1788
  const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
1629
1789
  let im;
@@ -1670,7 +1830,46 @@ function readOwnBackgroundColor(el, computedStyle) {
1670
1830
  return bg;
1671
1831
  }
1672
1832
 
1673
- function resolveBackground(el, win, customPropMap) {
1833
+ // One element's background-color as the cascade walk sees it: computed style
1834
+ // first (with the modern-color fallback), then, in static mode only,
1835
+ // custom-prop resolution and the inline-shorthand peek. Shared by
1836
+ // resolveBackgroundInfo and resolveGradientStops so both walks read the same
1837
+ // surfaces.
1838
+ function readCascadeBackgroundColor(current, style, customPropMap) {
1839
+ let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
1840
+ if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
1841
+ // The static engine can return literal "var(--X)" / "oklch(...)" strings.
1842
+ // Resolve through customPropMap so Tailwind v4 color tokens become RGB.
1843
+ if (customPropMap) {
1844
+ bg = parseColorResolved(style.backgroundColor, customPropMap);
1845
+ }
1846
+ if (!bg || bg.a < 0.1) {
1847
+ // Inline-style fallback for colors the static cascade did not surface
1848
+ // on backgroundColor.
1849
+ const rawStyle = current.getAttribute?.('style') || '';
1850
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
1851
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
1852
+ if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
1853
+ bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
1854
+ }
1855
+ }
1856
+ }
1857
+ return bg;
1858
+ }
1859
+
1860
+ // Walk up for the surface the element's text is painted on.
1861
+ //
1862
+ // Returns { color, unresolved }:
1863
+ // • color set — the effective surface, overlays composited in.
1864
+ // • unresolved: true — a layer on the way up paints a color this parser
1865
+ // cannot read, so the surface is unknown. Callers
1866
+ // must SKIP their contrast checks. Guessing white
1867
+ // here is what flooded dark themes with false
1868
+ // "on #ffffff" findings: one abstention costs a
1869
+ // single finding, one wrong guess costs a hundred.
1870
+ // • both null/false — no solid color, but a gradient or image is in
1871
+ // play; callers fall back to its color stops.
1872
+ function resolveBackgroundInfo(el, win, customPropMap) {
1674
1873
  let current = el;
1675
1874
  // Translucent layers (0.1 < a < 1) found on the way down to an opaque
1676
1875
  // base. A browser composites these over the base; the old behavior
@@ -1698,67 +1897,114 @@ function resolveBackground(el, win, customPropMap) {
1698
1897
  // body backgrounds.
1699
1898
  // Real browsers serialize wide-gamut computed values as oklab()/oklch()
1700
1899
  // (e.g. any color-mix() result), which plain parseRgb misses.
1701
- let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
1702
- if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
1703
- // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
1704
- // through customPropMap so Tailwind v4 color tokens become RGB.
1705
- if (customPropMap) {
1706
- bg = parseColorResolved(style.backgroundColor, customPropMap);
1707
- }
1708
- if (!bg || bg.a < 0.1) {
1709
- // Inline-style fallback. jsdom doesn't decompose background
1710
- // shorthand, so colors set via inline style are otherwise invisible.
1711
- const rawStyle = current.getAttribute?.('style') || '';
1712
- const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
1713
- const inlineBg = bgMatch ? bgMatch[1].trim() : '';
1714
- if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
1715
- bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
1716
- }
1717
- }
1900
+ let bg = readCascadeBackgroundColor(current, style, customPropMap);
1901
+
1902
+ // `background-color: currentcolor` paints with the element's own text
1903
+ // color real paint whose value we know. Real browsers resolve the
1904
+ // keyword before getComputedStyle output; jsdom hands it through
1905
+ // verbatim, and without this substitution the layer would read as
1906
+ // unparseable and force a needless abstention.
1907
+ if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
1908
+ // The static cascade resolves var() text tokens before checks run, so
1909
+ // style.color is normally already an rgb string here; parseColorResolved
1910
+ // is defense in depth for any future caller that passes a live
1911
+ // customPropMap (it matches the text-color path in checkElementColors
1912
+ // and reduces to parseAnyColor when the map is null or absent).
1913
+ bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
1718
1914
  }
1719
1915
 
1720
1916
  if (bg && bg.a > 0.1) {
1721
- if (bg.a >= 0.99) return flatten(bg);
1917
+ if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
1722
1918
  overlays.push(bg);
1919
+ } else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
1920
+ // This layer names a color we could not parse (a color space we do not
1921
+ // model, an unresolved var(), a syntax newer than the parser). It may
1922
+ // well be opaque, which would make every ancestor below it invisible —
1923
+ // so the surface is unknown and the walk stops here rather than
1924
+ // reporting an ancestor the visitor never sees.
1925
+ return { color: null, unresolved: true };
1723
1926
  }
1724
- // No solid bg-color at this level. If THIS level has a gradient/url
1725
- // with no underlying solid color we can read:
1726
- // on body/html: assume white. Body-level gradients are almost
1727
- // always decorative texture (paper grain, noise) on top of a
1728
- // solid bg-color the page set via `background: var(--paper)`
1729
- // shorthand which jsdom can't decompose into bg-color. The
1730
- // downstream gradient-stops fallback path produces catastrophic
1731
- // false positives in this case (gradient noise stops have
1732
- // accidental browns/blacks that look like card backgrounds).
1733
- // on other elements: bail to null and let the caller fall back
1734
- // to gradient stops (gradient buttons / hero sections are real
1735
- // bgs worth checking against).
1927
+ // No solid bg-color at this level, but this level paints an image. CSS
1928
+ // stacks background-image layers first-on-top, so which layer leads
1929
+ // decides what the visitor sees:
1930
+ // gradient on top the gradient is the surface. Hand the caller a
1931
+ // null color so it falls back to the gradient's own stops (body
1932
+ // grounds, gradient buttons, hero sections).
1933
+ // url() on top the surface is an image whose pixels this engine
1934
+ // cannot read, and it may fully cover every layer and ancestor
1935
+ // beneath it. Same contract as an unparseable color: abstain, so
1936
+ // the gradient-stop fallback never measures a gradient the image
1937
+ // hides (the shipped miss: `url(photo), linear-gradient(...)`
1938
+ // reported low-contrast against the invisible gradient's stops).
1736
1939
  if (hasGradientOrUrl) {
1737
- if (current.tagName === 'BODY' || current.tagName === 'HTML') {
1738
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
1940
+ const layers = splitTopLevelCommas(bgImage);
1941
+ const topPaintLayer = layers.find(
1942
+ (layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
1943
+ );
1944
+ const gradientOnTop = !!topPaintLayer
1945
+ && /gradient\s*\(/i.test(topPaintLayer)
1946
+ && !/^\s*url\s*\(/i.test(topPaintLayer);
1947
+ if (!gradientOnTop) return { color: null, unresolved: true };
1948
+ // Gradient on top of a url() layer: the image shows through wherever
1949
+ // the gradient is not fully opaque, so a translucent wash like
1950
+ // `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
1951
+ // a blend with pixels this engine cannot read. Only a gradient whose
1952
+ // every readable stop is opaque provably covers the image; otherwise
1953
+ // the surface is unknown — abstain rather than hand callers gradient
1954
+ // stops (or a stop average) the visitor never sees unmixed.
1955
+ const urlBeneath = layers.some(
1956
+ (layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
1957
+ );
1958
+ if (urlBeneath) {
1959
+ const topStops = parseGradientColors(topPaintLayer);
1960
+ const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
1961
+ if (!provablyOpaque) return { color: null, unresolved: true };
1739
1962
  }
1740
- return null;
1963
+ return { color: null, unresolved: false };
1741
1964
  }
1742
1965
  current = current.parentElement;
1743
1966
  }
1744
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
1967
+ // Every layer up to the document root was genuinely see-through, so the
1968
+ // browser paints its default canvas. This is the ONLY case that earns the
1969
+ // white assumption.
1970
+ return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
1971
+ }
1972
+
1973
+ function resolveBackground(el, win, customPropMap) {
1974
+ return resolveBackgroundInfo(el, win, customPropMap).color;
1745
1975
  }
1746
1976
 
1747
1977
  // Walk parents looking for a gradient background and return its color stops.
1748
1978
  // Used as a fallback when resolveBackground() returns null because the
1749
1979
  // effective background is a gradient (no single solid color to compare against).
1980
+ // Translucent solid layers found between the element and the gradient (frosted
1981
+ // panels, glass washes) are composited over every stop, the same way
1982
+ // resolveBackground flattens them over a solid base — raw stops alone would
1983
+ // false-flag dark text on a light frosted wash over a dark gradient, and miss
1984
+ // the inverse.
1750
1985
  function resolveGradientStops(el, win, customPropMap) {
1751
1986
  let current = el;
1987
+ const overlays = [];
1752
1988
  while (current && current.nodeType === 1) {
1753
1989
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1754
1990
  const bgImage = style.backgroundImage || '';
1991
+ // A url() layer anywhere in the stack — alone, or alongside a gradient in
1992
+ // the same declaration (a translucent wash over a texture photo) — paints
1993
+ // pixels the analytic walk cannot know. Measuring the gradient stops over
1994
+ // the wrong base flagged dark ink sitting on a bright gold-leaf image at
1995
+ // 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
1996
+ // image-backed text.
1997
+ if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
1755
1998
  let stops = null;
1756
1999
  if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
2000
+ // parseGradientColors (shared) reads modern-space stops too — oklch,
2001
+ // color-mix and friends via balanced-paren token capture — so browser
2002
+ // computed values that keep the authored syntax stay measurable.
1757
2003
  const parsed = parseGradientColors(bgImage);
1758
2004
  if (parsed.length > 0) stops = parsed;
1759
2005
  }
1760
2006
  if (!stops && !DETECTOR_IS_BROWSER) {
1761
- // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
2007
+ // Static mode: peek at the raw inline style for gradients the cascade did not surface
1762
2008
  const rawStyle = current.getAttribute?.('style') || '';
1763
2009
  const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
1764
2010
  if (bgMatch && /gradient/i.test(bgMatch[1])) {
@@ -1766,7 +2012,23 @@ function resolveGradientStops(el, win, customPropMap) {
1766
2012
  if (parsed.length > 0) stops = parsed;
1767
2013
  }
1768
2014
  }
1769
- if (stops) return compositeGradientStops(stops, current, win, customPropMap);
2015
+ if (stops) {
2016
+ const composited = compositeGradientStops(stops, current, win, customPropMap);
2017
+ if (!composited || overlays.length === 0) return composited;
2018
+ return composited.map(stop => {
2019
+ let acc = stop;
2020
+ for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
2021
+ return acc;
2022
+ });
2023
+ }
2024
+ const bg = readCascadeBackgroundColor(current, style, customPropMap);
2025
+ if (bg && bg.a > 0.1) {
2026
+ // An opaque surface above the gradient means the gradient never shows
2027
+ // through here; resolveBackground would have returned it, so reaching
2028
+ // this is defensive — bail rather than measure the wrong layer.
2029
+ if (bg.a >= 0.99) return null;
2030
+ overlays.push(bg);
2031
+ }
1770
2032
  current = current.parentElement;
1771
2033
  }
1772
2034
  return null;
@@ -1986,15 +2248,25 @@ function checkElementColorsDOM(el) {
1986
2248
  const rect = el.getBoundingClientRect();
1987
2249
  if (rect.width < 10 || rect.height < 10) return [];
1988
2250
  const style = getComputedStyle(el);
2251
+ // Invisible at rest: hidden scene variants (opacity-0 carousels, swap
2252
+ // decks) are not user-visible, and measuring their inherited colors against
2253
+ // whatever surface happens to sit behind the stack is noise, not audit.
2254
+ if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
1989
2255
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1990
2256
  const hasDirectText = directText.trim().length > 0;
1991
- let effectiveBg = resolveBackground(el);
2257
+ const bgInfo = resolveBackgroundInfo(el);
2258
+ let effectiveBg = bgInfo.color;
2259
+ // An unreadable surface anywhere up the chain: skip the gradient-stop
2260
+ // fallback too, so nothing downstream measures against a ground we never
2261
+ // resolved.
2262
+ let surfaceUnresolved = bgInfo.unresolved;
1992
2263
  let ownBg = readOwnBackgroundColor(el, style);
1993
2264
  if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
1994
2265
  const pseudoSurface = readPseudoSurfaceDOM(el, rect);
1995
2266
  if (pseudoSurface) {
1996
2267
  ownBg = pseudoSurface;
1997
2268
  effectiveBg = pseudoSurface;
2269
+ surfaceUnresolved = false;
1998
2270
  }
1999
2271
  }
2000
2272
  return checkColors({
@@ -2006,8 +2278,8 @@ function checkElementColorsDOM(el) {
2006
2278
  // an oklch token near its own oklch background).
2007
2279
  textColor: parseRgb(style.color) || parseAnyColor(style.color),
2008
2280
  bgColor: ownBg,
2009
- effectiveBg,
2010
- effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
2281
+ effectiveBg: surfaceUnresolved ? null : effectiveBg,
2282
+ effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
2011
2283
  fontSize: parseFloat(style.fontSize) || 16,
2012
2284
  fontWeight: parseInt(style.fontWeight) || 400,
2013
2285
  hasDirectText,
@@ -2157,283 +2429,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
2157
2429
  });
2158
2430
  }
2159
2431
 
2160
- // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
2161
- // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
2162
- // Needed because jsdom doesn't compute oklch() values — getComputedStyle
2163
- // returns the literal "oklch(...)" string. Without this, the entire
2164
- // Tailwind v4 color palette (which is OKLCH-based) is invisible to the
2165
- // detector's contrast / color checks.
2166
- function oklchToRgb(L, C, H) {
2167
- const hRad = (H * Math.PI) / 180;
2168
- return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
2169
- }
2170
-
2171
- function oklabToRgb(L, a, b) {
2172
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
2173
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
2174
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
2175
- const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
2176
- const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
2177
- const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
2178
- const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
2179
- const enc = (x) => {
2180
- const c = Math.max(0, Math.min(1, x));
2181
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
2182
- };
2183
- return {
2184
- r: Math.round(enc(rLin) * 255),
2185
- g: Math.round(enc(gLin) * 255),
2186
- b: Math.round(enc(bLin) * 255),
2187
- a: 1,
2188
- };
2189
- }
2190
-
2191
- function hslToRgb(h, s, l) {
2192
- h = ((h % 360) + 360) % 360;
2193
- const c = (1 - Math.abs(2 * l - 1)) * s;
2194
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
2195
- const m0 = l - c / 2;
2196
- const [r, g, b] =
2197
- h < 60 ? [c, x, 0] :
2198
- h < 120 ? [x, c, 0] :
2199
- h < 180 ? [0, c, x] :
2200
- h < 240 ? [0, x, c] :
2201
- h < 300 ? [x, 0, c] : [c, 0, x];
2202
- return {
2203
- r: Math.round((r + m0) * 255),
2204
- g: Math.round((g + m0) * 255),
2205
- b: Math.round((b + m0) * 255),
2206
- a: 1,
2207
- };
2208
- }
2209
-
2210
- function hwbToRgb(h, w, bl) {
2211
- if (w + bl >= 1) {
2212
- const g = Math.round((w / (w + bl)) * 255);
2213
- return { r: g, g, b: g, a: 1 };
2214
- }
2215
- const base = hslToRgb(h, 1, 0.5);
2216
- const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
2217
- return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
2218
- }
2219
-
2220
- // Common CSS named colors — the handful that actually show up in generated
2221
- // UIs, not the full 148-name spec list. Includes the achromatic names so a
2222
- // named gray parses (and correctly reads as no-chroma) instead of being
2223
- // treated as an unknown color.
2224
- const CSS_NAMED_COLORS = {
2225
- black: { r: 0, g: 0, b: 0 },
2226
- white: { r: 255, g: 255, b: 255 },
2227
- gray: { r: 128, g: 128, b: 128 },
2228
- grey: { r: 128, g: 128, b: 128 },
2229
- silver: { r: 192, g: 192, b: 192 },
2230
- dimgray: { r: 105, g: 105, b: 105 },
2231
- darkgray: { r: 169, g: 169, b: 169 },
2232
- lightgray: { r: 211, g: 211, b: 211 },
2233
- gainsboro: { r: 220, g: 220, b: 220 },
2234
- whitesmoke: { r: 245, g: 245, b: 245 },
2235
- red: { r: 255, g: 0, b: 0 },
2236
- crimson: { r: 220, g: 20, b: 60 },
2237
- tomato: { r: 255, g: 99, b: 71 },
2238
- coral: { r: 255, g: 127, b: 80 },
2239
- salmon: { r: 250, g: 128, b: 114 },
2240
- orange: { r: 255, g: 165, b: 0 },
2241
- gold: { r: 255, g: 215, b: 0 },
2242
- yellow: { r: 255, g: 255, b: 0 },
2243
- olive: { r: 128, g: 128, b: 0 },
2244
- lime: { r: 0, g: 255, b: 0 },
2245
- green: { r: 0, g: 128, b: 0 },
2246
- teal: { r: 0, g: 128, b: 128 },
2247
- turquoise: { r: 64, g: 224, b: 208 },
2248
- cyan: { r: 0, g: 255, b: 255 },
2249
- aqua: { r: 0, g: 255, b: 255 },
2250
- skyblue: { r: 135, g: 206, b: 235 },
2251
- dodgerblue: { r: 30, g: 144, b: 255 },
2252
- blue: { r: 0, g: 0, b: 255 },
2253
- navy: { r: 0, g: 0, b: 128 },
2254
- indigo: { r: 75, g: 0, b: 130 },
2255
- rebeccapurple: { r: 102, g: 51, b: 153 },
2256
- purple: { r: 128, g: 0, b: 128 },
2257
- violet: { r: 238, g: 130, b: 238 },
2258
- orchid: { r: 218, g: 112, b: 214 },
2259
- magenta: { r: 255, g: 0, b: 255 },
2260
- fuchsia: { r: 255, g: 0, b: 255 },
2261
- hotpink: { r: 255, g: 105, b: 180 },
2262
- pink: { r: 255, g: 192, b: 203 },
2263
- maroon: { r: 128, g: 0, b: 0 },
2264
- };
2265
-
2266
- // Split a string on top-level commas (ignoring commas nested in parens).
2267
- function splitTopLevelCommas(str) {
2268
- const parts = [];
2269
- let depth = 0, start = 0;
2270
- for (let i = 0; i < str.length; i++) {
2271
- const ch = str[i];
2272
- if (ch === '(') depth++;
2273
- else if (ch === ')') depth = Math.max(0, depth - 1);
2274
- else if (ch === ',' && depth === 0) {
2275
- parts.push(str.slice(start, i).trim());
2276
- start = i + 1;
2277
- }
2278
- }
2279
- const tail = str.slice(start).trim();
2280
- if (tail) parts.push(tail);
2281
- return parts;
2282
- }
2283
-
2284
- // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
2285
- // the expression can't be resolved (unresolved var(), unknown colors).
2286
- //
2287
- // Mixing is done with premultiplied alpha in sRGB regardless of the
2288
- // declared interpolation space. That is exact for the dominant generated-UI
2289
- // pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
2290
- // result is simply <color> at alpha N% in ANY rectangular space, and a
2291
- // close-enough approximation for opaque-opaque mixes (the detector only
2292
- // consumes these values for contrast/chroma thresholds, not for display).
2293
- function parseColorMix(str) {
2294
- const m = String(str).trim().match(/^color-mix\(/i);
2295
- if (!m) return null;
2296
- // Balanced-paren capture of the arguments.
2297
- let depth = 0, end = -1;
2298
- const open = str.indexOf('(');
2299
- for (let i = open; i < str.length; i++) {
2300
- if (str[i] === '(') depth++;
2301
- else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
2302
- }
2303
- if (end < 0) return null;
2304
- const args = splitTopLevelCommas(str.slice(open + 1, end));
2305
- if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
2306
-
2307
- const parseComponent = (component) => {
2308
- // Percentage may lead or trail the color per spec.
2309
- let pct = null;
2310
- let colorStr = component;
2311
- const trail = component.match(/\s+([\d.]+)%$/);
2312
- const lead = component.match(/^([\d.]+)%\s+/);
2313
- if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
2314
- else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
2315
- let color;
2316
- if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
2317
- else color = parseAnyColor(colorStr);
2318
- if (!color) return null;
2319
- return { color, pct };
2320
- };
2321
-
2322
- const c1 = parseComponent(args[1]);
2323
- const c2 = parseComponent(args[2]);
2324
- if (!c1 || !c2) return null;
2325
- let p1 = c1.pct, p2 = c2.pct;
2326
- if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
2327
- else if (p1 == null) p1 = 100 - p2;
2328
- else if (p2 == null) p2 = 100 - p1;
2329
- const sum = p1 + p2;
2330
- if (sum <= 0) return null;
2331
- // Per spec: weights normalize to sum; when sum < 100 the result alpha is
2332
- // additionally scaled by sum/100.
2333
- const w1 = p1 / sum, w2 = p2 / sum;
2334
- const alphaScale = sum < 100 ? sum / 100 : 1;
2335
- const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
2336
- const a = (a1 * w1 + a2 * w2) * alphaScale;
2337
- if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
2338
- const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
2339
- return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
2340
- }
2341
-
2342
- // Composite a translucent color over an opaque(ish) base (simple
2343
- // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
2344
- function compositeColorOver(top, base) {
2345
- const a = top.a ?? 1;
2346
- return {
2347
- r: Math.round(top.r * a + base.r * (1 - a)),
2348
- g: Math.round(top.g * a + base.g * (1 - a)),
2349
- b: Math.round(top.b * a + base.b * (1 - a)),
2350
- a: 1,
2351
- };
2352
- }
2353
-
2354
- // Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
2355
- // named colors. Returns null on no match. Use this when the input might be
2356
- // any CSS color form; use plain parseRgb when you only expect computed rgb()
2357
- // values from real browsers.
2358
- function parseAnyColor(s) {
2359
- if (!s || typeof s !== 'string') return null;
2360
- const str = s.trim();
2361
- if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
2362
- if (/^color-mix\(/i.test(str)) return parseColorMix(str);
2363
- let m;
2364
- m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
2365
- if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
2366
- m = str.match(/^#([0-9a-f]{3,8})$/i);
2367
- if (m) {
2368
- const h = m[1];
2369
- if (h.length === 3 || h.length === 4) {
2370
- return {
2371
- r: parseInt(h[0] + h[0], 16),
2372
- g: parseInt(h[1] + h[1], 16),
2373
- b: parseInt(h[2] + h[2], 16),
2374
- a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
2375
- };
2376
- }
2377
- if (h.length === 6 || h.length === 8) {
2378
- return {
2379
- r: parseInt(h.slice(0, 2), 16),
2380
- g: parseInt(h.slice(2, 4), 16),
2381
- b: parseInt(h.slice(4, 6), 16),
2382
- a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
2383
- };
2384
- }
2385
- }
2386
- // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
2387
- // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
2388
- // Match L (with optional %), then C and H separated permissively.
2389
- m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
2390
- if (m) {
2391
- const Lnum = parseFloat(m[1]);
2392
- const L = m[2] === '%' ? Lnum / 100 : Lnum;
2393
- const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
2394
- if (m[5] !== undefined) {
2395
- const alpha = parseFloat(m[5]);
2396
- rgb.a = m[6] === '%' ? alpha / 100 : alpha;
2397
- }
2398
- return rgb;
2399
- }
2400
- // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
2401
- m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
2402
- if (m) {
2403
- const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
2404
- const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
2405
- const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
2406
- const rgb = oklabToRgb(L, a, b);
2407
- if (m[7] !== undefined) {
2408
- const alpha = parseFloat(m[7]);
2409
- rgb.a = m[8] === '%' ? alpha / 100 : alpha;
2410
- }
2411
- return rgb;
2412
- }
2413
- // HSL/HSLA — comma or space syntax, optional deg on hue.
2414
- m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
2415
- if (m) {
2416
- const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
2417
- if (m[4] !== undefined) {
2418
- const alpha = parseFloat(m[4]);
2419
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
2420
- }
2421
- return rgb;
2422
- }
2423
- // HWB — hue whiteness% blackness%.
2424
- m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
2425
- if (m) {
2426
- const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
2427
- if (m[4] !== undefined) {
2428
- const alpha = parseFloat(m[4]);
2429
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
2430
- }
2431
- return rgb;
2432
- }
2433
- const named = CSS_NAMED_COLORS[str.toLowerCase()];
2434
- if (named) return { ...named, a: 1 };
2435
- return null;
2436
- }
2437
2432
 
2438
2433
  // Resolve var() refs in a color string (via customPropMap), then parse.
2439
2434
  // Returns null on any failure. Used in jsdom-mode paths where
@@ -2796,9 +2791,20 @@ function checkElementGlowDOM(el) {
2796
2791
  if (!boxShadow && !textShadow) return [];
2797
2792
  // Use parent's background — glow radiates outward, so the surrounding context matters
2798
2793
  // If resolveBackground returns null (gradient), try to infer from the gradient colors
2799
- let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
2800
- if (!parentBg) {
2801
- // Gradient background sample its colors to determine if it's dark
2794
+ const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
2795
+ // Unknown surface (an unreadable layer on the way up): skip only the
2796
+ // gradient hunt below, which would walk PAST that layer and score the
2797
+ // glow against a background the visitor never sees. checkGlow still runs
2798
+ // with a null surface: the zero-offset chromatic halo tell holds on ANY
2799
+ // background, and the static loop already passes the unresolved walk's
2800
+ // null color straight through (detect-html.mjs uses resolveBackground).
2801
+ let parentBg = parentBgInfo.color;
2802
+ if (!parentBg && !parentBgInfo.unresolved) {
2803
+ // Gradient background — sample its colors to determine if it's dark.
2804
+ // Modern-syntax parsing matters here: body-level gradients now reach this
2805
+ // fallback in browser mode, and their stops usually serialize as oklch —
2806
+ // which the shared parseGradientColors reads via its color-function
2807
+ // token capture.
2802
2808
  let cur = el.parentElement;
2803
2809
  while (cur && cur.nodeType === 1) {
2804
2810
  const bgImage = getComputedStyle(cur).backgroundImage || '';
@@ -2841,15 +2847,18 @@ function checkElementAIPaletteDOM(el) {
2841
2847
  }
2842
2848
 
2843
2849
  // Check for neon text (vivid cyan/purple color on dark background)
2844
- const textColor = parseRgb(style.color);
2850
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color);
2845
2851
  if (textColor && hasChroma(textColor, 80)) {
2846
2852
  const hue = getHue(textColor);
2847
2853
  const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
2848
2854
  if (isAIPalette) {
2849
- const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
2850
- // Also check gradient parents
2851
- let effectiveBg = parentBg;
2852
- if (!effectiveBg) {
2855
+ const parentBgInfo = el.parentElement
2856
+ ? resolveBackgroundInfo(el.parentElement)
2857
+ : { color: null, unresolved: false };
2858
+ // Unknown surface: leave effectiveBg null (no finding) rather than
2859
+ // hunting gradient ancestors past a layer we could not read.
2860
+ let effectiveBg = parentBgInfo.color;
2861
+ if (!effectiveBg && !parentBgInfo.unresolved) {
2853
2862
  let cur = el.parentElement;
2854
2863
  while (cur && cur.nodeType === 1) {
2855
2864
  const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3181,14 +3190,30 @@ function isNonRenderedText(el, tag, style) {
3181
3190
  function checkQuality(opts) {
3182
3191
  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
3183
3192
  const findings = [];
3184
- // Skip browser extension injected elements. Read the id via getAttribute
3185
- // whenever `el.id` is not a string: on a <form> (and other
3186
- // [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
3187
- // shadows the builtin `id` getter and returns the control element, whose
3188
- // `.startsWith` is undefined and throws (issue #407 every Shopify product
3189
- // form ships an <input name="id">).
3193
+ // A raster (<img>, or an element with a background url) at near-zero
3194
+ // opacity never reaches the screen: the produced material ships as a
3195
+ // compliance token. The CSS-text scan catches the stylesheet form; this
3196
+ // catches computed opacity on the element itself (both engines).
3197
+ // Skip browser extension injected elements BEFORE any finding is pushed
3198
+ // (a low-opacity raster those hosts inject used to be recorded and then
3199
+ // returned by this very skip). Read the id via getAttribute whenever
3200
+ // `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
3201
+ // hosts) a named control like <input name="id"> shadows the builtin `id`
3202
+ // getter and returns the control element, whose `.startsWith` is undefined
3203
+ // and throws (issue #407 — every Shopify product form ships an
3204
+ // <input name="id">).
3190
3205
  const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
3191
3206
  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
3207
+ {
3208
+ const op = parseFloat(style.opacity);
3209
+ if (Number.isFinite(op) && op < 0.15 && op >= 0) {
3210
+ const bg = String(style.backgroundImage || '');
3211
+ if (tag === 'img' || /url\(/i.test(bg)) {
3212
+ const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
3213
+ findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
3214
+ }
3215
+ }
3216
+ }
3192
3217
 
3193
3218
  // --- Line length too long --- (browser-only: needs rect.width)
3194
3219
  if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
@@ -3644,10 +3669,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
3644
3669
  }
3645
3670
 
3646
3671
  function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
3672
+ // Invisible at rest, static twin of the browser walk's skip: opacity does
3673
+ // not inherit, so walk ancestors multiplying declared opacity down.
3674
+ if (style.visibility === 'hidden') return [];
3675
+ let effOpacity = 1;
3676
+ for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
3677
+ effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
3678
+ }
3679
+ if (effOpacity <= 0.02) return [];
3647
3680
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
3648
3681
  const hasDirectText = directText.trim().length > 0;
3649
3682
 
3650
- const effectiveBg = resolveBackground(el, window, customPropMap);
3683
+ const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
3684
+ const effectiveBg = bgInfo.color;
3651
3685
  // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
3652
3686
  // parseRgb misses Tailwind-tokenized text colors. Resolve through the
3653
3687
  // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -3693,11 +3727,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
3693
3727
  // element itself has no usable own background, that pseudo is the real
3694
3728
  // surface for contrast purposes.
3695
3729
  let finalEffectiveBg = effectiveBg;
3730
+ let surfaceUnresolved = bgInfo.unresolved;
3696
3731
  if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
3697
3732
  const pseudoSurface = window.getPseudoSurface(el);
3698
3733
  if (pseudoSurface) {
3699
3734
  ownBg = pseudoSurface;
3700
3735
  finalEffectiveBg = pseudoSurface;
3736
+ surfaceUnresolved = false;
3701
3737
  }
3702
3738
  }
3703
3739
 
@@ -3705,8 +3741,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
3705
3741
  tag,
3706
3742
  textColor,
3707
3743
  bgColor: ownBg,
3708
- effectiveBg: finalEffectiveBg,
3709
- effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
3744
+ // Unknown surface: hand the checks nothing rather than a guess.
3745
+ effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
3746
+ effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
3710
3747
  fontSize: parseFloat(style.fontSize) || 16,
3711
3748
  fontWeight: parseInt(style.fontWeight) || 400,
3712
3749
  hasDirectText,
@@ -3894,8 +3931,7 @@ function checkTypography() {
3894
3931
  const style = getComputedStyle(el);
3895
3932
  const ff = style.fontFamily;
3896
3933
  if (!ff) continue;
3897
- const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
3898
- const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
3934
+ const primary = primaryFontFace(ff);
3899
3935
  if (!primary) continue;
3900
3936
  fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
3901
3937
  totalTextElements++;
@@ -4140,8 +4176,7 @@ function checkPageTypography(doc, win) {
4140
4176
  if (rule.type !== 1) continue;
4141
4177
  const ff = rule.style?.fontFamily;
4142
4178
  if (!ff) continue;
4143
- const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
4144
- const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
4179
+ const primary = primaryFontFace(ff);
4145
4180
  if (primary) {
4146
4181
  fonts.add(primary);
4147
4182
  if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
@@ -4160,11 +4195,10 @@ function checkPageTypography(doc, win) {
4160
4195
  const ffRe = /font-family\s*:\s*([^;}]+)/gi;
4161
4196
  let fm;
4162
4197
  while ((fm = ffRe.exec(html)) !== null) {
4163
- for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
4164
- if (f && !GENERIC_FONTS.has(f)) {
4165
- fonts.add(f);
4166
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
4167
- }
4198
+ const primary = primaryFontFace(fm[1]);
4199
+ if (primary) {
4200
+ fonts.add(primary);
4201
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
4168
4202
  }
4169
4203
  }
4170
4204
 
@@ -4802,6 +4836,11 @@ function isRenderedForBrowserRule(el) {
4802
4836
  function checkElementTextOverflowDOM(el) {
4803
4837
  const tag = el.tagName.toLowerCase();
4804
4838
  if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
4839
+ // scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
4840
+ // returns arbitrary non-zero values for both (a <text> reported 78/48 while
4841
+ // its rendered length sat comfortably inside its box), so the delta is
4842
+ // noise, not overflow. SVG clips to its own viewport anyway.
4843
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
4805
4844
  if (!isRenderedForBrowserRule(el)) return [];
4806
4845
  // Only the element that actually owns overflowing text — not its ancestors,
4807
4846
  // which inherit a wider scrollWidth from the spilling descendant.
@@ -5186,6 +5225,22 @@ function isPaintedForOcclusion(el) {
5186
5225
  // path is pure geometry and runs anywhere on the page.
5187
5226
  const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
5188
5227
 
5228
+ // An element whose effective opacity multiplies out to ~0 paints nothing at
5229
+ // rest: it is not user-visible, so visual findings on it (contrast, occlusion)
5230
+ // measure a state nobody sees. Browser-only — the walk needs live computed
5231
+ // styles. Cycling scenes that fade such elements in later are the screenshot
5232
+ // subsystem's territory, not the analytic walk's.
5233
+ function effectiveOpacityDOM(el) {
5234
+ let o = 1;
5235
+ // Walk all the way through body and html: `body { opacity: 0 }` page-fade
5236
+ // wrappers hide every descendant just as thoroughly as a local wrapper.
5237
+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
5238
+ o *= parseFloat(getComputedStyle(cur).opacity || '1');
5239
+ if (o <= 0.02) return 0;
5240
+ }
5241
+ return o;
5242
+ }
5243
+
5189
5244
  function checkTextOcclusionDOM() {
5190
5245
  const findings = [];
5191
5246
  const seenVictims = new Set();
@@ -5213,6 +5268,41 @@ function checkTextOcclusionDOM() {
5213
5268
  }
5214
5269
  return false;
5215
5270
  };
5271
+ // The classic occluder shape this rules out is an opacity-0 interaction
5272
+ // layer — a range scrubber stretched over a before/after comparison — which
5273
+ // elementFromPoint still returns and whose UA background-color otherwise
5274
+ // reads as an opaque box.
5275
+ const effectiveOpacity = effectiveOpacityDOM;
5276
+
5277
+ // The part of an element that is actually painted, after every scrolling or
5278
+ // clipping ancestor has had its say.
5279
+ //
5280
+ // getBoundingClientRect reports where a box would be if nothing cut it off,
5281
+ // so a paragraph half scrolled out of a panel still reports its full height,
5282
+ // and the half that is clipped away lands wherever the page continues below
5283
+ // the panel. The elementFromPoint probe then samples coordinates the text is
5284
+ // not painted at, finds whatever genuinely is painted there, and reports the
5285
+ // text as buried under it. Any sticky footer or toolbar beneath a scroll
5286
+ // region produces this, and it is the shape most likely to be waved off as
5287
+ // noise, which costs the rule its credibility on the findings that are real.
5288
+ //
5289
+ // Border box rather than padding box on purpose: it errs toward probing, and
5290
+ // giving up a scrollbar gutter's width would drop true findings at the right
5291
+ // edge of a scroller.
5292
+ const paintedRect = (el, rect) => {
5293
+ let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
5294
+ for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
5295
+ let cs; try { cs = getComputedStyle(cur); } catch { continue; }
5296
+ const clipsX = String(cs.overflowX || 'visible') !== 'visible';
5297
+ const clipsY = String(cs.overflowY || 'visible') !== 'visible';
5298
+ if (!clipsX && !clipsY) continue;
5299
+ let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
5300
+ if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
5301
+ if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
5302
+ if (right - left < 1 || bottom - top < 1) return null;
5303
+ }
5304
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
5305
+ };
5216
5306
 
5217
5307
  // Collect renderable text owners in / near the first viewport for the
5218
5308
  // elementFromPoint probe. SVG <text> counts too.
@@ -5225,8 +5315,14 @@ function checkTextOcclusionDOM() {
5225
5315
  const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
5226
5316
  if (text.length < 2) continue;
5227
5317
  if (!isPaintedForOcclusion(el)) continue;
5228
- let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
5229
- if (rect.width < 6 || rect.height < 6) continue;
5318
+ if (effectiveOpacity(el) <= 0.02) continue;
5319
+ let full; try { full = el.getBoundingClientRect(); } catch { continue; }
5320
+ if (full.width < 6 || full.height < 6) continue;
5321
+ // Probe only where the text is on screen. A run clipped down to a sliver is
5322
+ // dropped rather than sampled: a few pixels of visible text cannot support
5323
+ // a coverage fraction worth reporting either way.
5324
+ const rect = paintedRect(el, full);
5325
+ if (!rect || rect.width < 6 || rect.height < 6) continue;
5230
5326
  // Viewport-bound probe: keep text whose box overlaps the live viewport.
5231
5327
  if (rect.bottom <= 0 || rect.top >= vh) continue;
5232
5328
  textEls.push({ el, rect, text, inSvg });
@@ -5258,6 +5354,7 @@ function checkTextOcclusionDOM() {
5258
5354
  if (top === el || el.contains(top) || top.contains(el)) continue;
5259
5355
  const topCs = getComputedStyle(top);
5260
5356
  if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
5357
+ if (effectiveOpacity(top) <= 0.02) continue;
5261
5358
  const topTag = top.tagName.toLowerCase();
5262
5359
  // Text sitting under a raw image/video is contrast territory (deduped
5263
5360
  // against the pixel low-contrast rule); leave those alone here.
@@ -5468,6 +5565,7 @@ export {
5468
5565
  CSS_NAMED_COLORS,
5469
5566
  checkBorders,
5470
5567
  isEmojiOnlyText,
5568
+ scopedIgnoreActive,
5471
5569
  checkColors,
5472
5570
  checkHoverContrast,
5473
5571
  checkElementHoverContrast,
@@ -5493,10 +5591,13 @@ export {
5493
5591
  cssLengthToPx,
5494
5592
  scanCssTextForPulsingDot,
5495
5593
  scanHtmlForShapeAssembledIllustration,
5594
+ scanCssTextForOrganicClipPath,
5595
+ scanCssTextForBuriedRaster,
5496
5596
  buildHtmlPatternCorpora,
5497
5597
  checkHtmlPatterns,
5498
5598
  readOwnBackgroundColor,
5499
5599
  resolveBackground,
5600
+ resolveBackgroundInfo,
5500
5601
  resolveGradientStops,
5501
5602
  parseRadiusToPx,
5502
5603
  resolveBorderRadiusPx,