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
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
70
70
  return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
71
71
  }
72
72
 
73
- const GENERIC_FONTS = new Set([
73
+ // Overused-font primary selection skips only CSS generics so a system stack
74
+ // keeps the system face as primary; GENERIC_FONTS still includes platform
75
+ // faces for design-system/serif resolution.
76
+ const CSS_GENERIC_FONTS = new Set([
74
77
  'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
78
+ 'inherit', 'initial', 'unset', 'revert',
79
+ ]);
80
+
81
+ const GENERIC_FONTS = new Set([
82
+ ...CSS_GENERIC_FONTS,
75
83
  'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
76
84
  '-apple-system', 'blinkmacsystemfont', 'segoe ui',
77
- 'inherit', 'initial', 'unset', 'revert',
78
85
  ]);
79
86
 
87
+ function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
88
+ return String(fontFamily || '')
89
+ .split(',')
90
+ .map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
91
+ .find(f => f && !skip.has(f)) || null;
92
+ }
93
+
80
94
  // WCAG large text thresholds are defined in points: 18pt normal text and
81
95
  // 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
82
96
  const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
@@ -232,6 +246,24 @@ const ANTIPATTERNS = [
232
246
  'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
233
247
  skillSection: 'Imagery',
234
248
  },
249
+ {
250
+ id: 'organic-clip-path',
251
+ category: 'quality',
252
+ name: 'Organic contour drawn as clip-path',
253
+ description:
254
+ 'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
255
+ skillSection: 'Imagery',
256
+ skillGuideline: 'geometric masks standing in for organic contours',
257
+ },
258
+ {
259
+ id: 'buried-raster',
260
+ category: 'quality',
261
+ name: 'Raster buried under a wash or opacity',
262
+ description:
263
+ 'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
264
+ skillSection: 'Imagery',
265
+ skillGuideline: 'a produced material must survive to the screen',
266
+ },
235
267
  {
236
268
  id: 'dark-glow',
237
269
  category: 'slop',
@@ -741,14 +773,54 @@ function contrastRatio(c1, c2) {
741
773
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
742
774
  }
743
775
 
776
+ // The CSS color functions worth pulling out of a longer declaration. The set
777
+ // is deliberately closed: `linear-gradient(` and `url(` also look like
778
+ // `name(` and must not be read as colors.
779
+ const COLOR_FUNCTION_NAMES = new Set([
780
+ 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
781
+ ]);
782
+
783
+ // Pull every color-function token out of a value, with balanced-paren capture
784
+ // so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
785
+ // whole. Returns the raw substrings in source order.
786
+ function extractColorFunctionTokens(value) {
787
+ const str = String(value || '');
788
+ const tokens = [];
789
+ const re = /([a-z][a-z-]*)\(/gi;
790
+ let m;
791
+ while ((m = re.exec(str)) !== null) {
792
+ if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
793
+ let depth = 0, end = -1;
794
+ for (let i = m.index + m[0].length - 1; i < str.length; i++) {
795
+ if (str[i] === '(') depth++;
796
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
797
+ }
798
+ if (end < 0) break;
799
+ tokens.push(str.slice(m.index, end + 1));
800
+ re.lastIndex = end + 1;
801
+ }
802
+ return tokens;
803
+ }
804
+
744
805
  function parseGradientColors(bgImage) {
745
806
  if (!bgImage || !bgImage.includes('gradient')) return [];
746
807
  const colors = [];
747
- for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
748
- const c = parseRgb(m[0]);
808
+ const tokenSpans = [];
809
+ let from = 0;
810
+ // Stops arrive in whatever syntax the author wrote and the browser kept.
811
+ // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
812
+ // to read as a gradient with no stops at all.
813
+ for (const token of extractColorFunctionTokens(bgImage)) {
814
+ const start = bgImage.indexOf(token, from);
815
+ if (start < 0) break;
816
+ tokenSpans.push({ start, end: start + token.length });
817
+ from = start + token.length;
818
+ const c = parseAnyColor(token);
749
819
  if (c) colors.push(c);
750
820
  }
751
821
  for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
822
+ // Nested hex inside color-mix is an ingredient, not a stop (issue #578).
823
+ if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
752
824
  const h = m[1];
753
825
  if (h.length === 6) {
754
826
  colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -782,262 +854,708 @@ function colorToHex(c) {
782
854
  return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
783
855
  }
784
856
 
785
- // --- cli/engine/shared/fonts.mjs ---
786
- const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
857
+ // ─── Color-space conversions ────────────────────────────────────────────────
858
+ //
859
+ // Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
860
+ // and Firefox all keep the authored color space in getComputedStyle output
861
+ // (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
862
+ // so a detector that only reads rgb() is blind on any modern palette. The
863
+ // expected outputs are pinned in tests/detect-antipatterns.test.js against
864
+ // what Chrome itself paints for the same strings.
787
865
 
788
- function normalizeGoogleFontFamilyParam(value) {
789
- return String(value || '')
790
- .split('|')
791
- .map(part => part.split(':')[0].trim().toLowerCase())
792
- .filter(Boolean);
866
+ function clamp01(x) {
867
+ return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
793
868
  }
794
869
 
795
- function extractGoogleFontFamilies(text) {
796
- const families = [];
797
- if (!text) return families;
870
+ // Linear-light sRGB channel to the encoded 0-255 value.
871
+ function encodeSrgbChannel(x) {
872
+ const c = clamp01(x);
873
+ return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
874
+ }
798
875
 
799
- GOOGLE_FONTS_URL_RE.lastIndex = 0;
800
- let urlMatch;
801
- while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
802
- const url = urlMatch[0];
803
- const queryStart = url.indexOf('?');
804
- if (queryStart === -1) continue;
876
+ function decodeSrgbChannel(x) {
877
+ const c = Number.isFinite(x) ? x : 0;
878
+ const sign = c < 0 ? -1 : 1;
879
+ const abs = Math.abs(c);
880
+ return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
881
+ }
805
882
 
806
- const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
807
- for (const value of params.getAll('family')) {
808
- families.push(...normalizeGoogleFontFamilyParam(value));
809
- }
810
- }
883
+ function linearSrgbToColor(r, g, b, a = 1) {
884
+ return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
885
+ }
811
886
 
812
- return families;
887
+ // OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
888
+ function oklabToRgb(L, a, b) {
889
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
890
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
891
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
892
+ const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
893
+ return linearSrgbToColor(
894
+ 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
895
+ -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
896
+ -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
897
+ );
813
898
  }
814
899
 
815
- // --- cli/engine/rules/checks.mjs ---
816
- const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
900
+ // OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
901
+ // the sRGB gamut clamps per channel rather than producing NaN.
902
+ function oklchToRgb(L, C, H) {
903
+ const hRad = (H * Math.PI) / 180;
904
+ return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
905
+ }
817
906
 
818
- // ─── Section 3: Pure Detection ──────────────────────────────────────────────
907
+ // CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
908
+ // is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
909
+ function labToRgb(L, a, b) {
910
+ const kappa = 24389 / 27, epsilon = 216 / 24389;
911
+ const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
912
+ const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
913
+ const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
914
+ const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
915
+ const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
916
+ return linearSrgbToColor(
917
+ 3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
918
+ -0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
919
+ 0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
920
+ );
921
+ }
819
922
 
820
- function checkBorders(tag, widths, colors, radius, opts = {}) {
821
- // Badge-shaped <span>s (own visible background) are a real stripe target
822
- // for the top/bottom variant the inline-tag exemption exists to quiet
823
- // text-level borders, not chips. They skip the left/right arms below.
824
- const spanBadge = tag === 'span' && !!opts.badgeLike;
825
- if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
826
- // A live status/alert region wears a colored single-edge border as a
827
- // severity accent (toast, snackbar, callout), not as the side-tab tell.
828
- if (opts.statusContext) return [];
829
- const findings = [];
830
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
923
+ function lchToRgb(L, C, H) {
924
+ const hRad = (H * Math.PI) / 180;
925
+ return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
926
+ }
927
+
928
+ // color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
929
+ // `srgb` is what Chrome serializes most color-mix() results into, routinely
930
+ // with channels outside 0..1. Spaces we do not model return null so callers
931
+ // abstain instead of measuring against a color we invented.
932
+ function colorFunctionToRgb(space, c1, c2, c3) {
933
+ switch (space) {
934
+ case 'srgb':
935
+ return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
936
+ case 'srgb-linear':
937
+ return linearSrgbToColor(c1, c2, c3);
938
+ case 'display-p3': {
939
+ const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
940
+ return linearSrgbToColor(
941
+ 1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
942
+ -0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
943
+ -0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
944
+ );
945
+ }
946
+ default:
947
+ return null;
948
+ }
949
+ }
831
950
 
832
- for (const side of sides) {
833
- const w = widths[side];
834
- if (w < 1 || isNeutralColor(colors[side])) continue;
951
+ function hslToRgb(h, s, l) {
952
+ h = ((h % 360) + 360) % 360;
953
+ const c = (1 - Math.abs(2 * l - 1)) * s;
954
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
955
+ const m0 = l - c / 2;
956
+ const [r, g, b] =
957
+ h < 60 ? [c, x, 0] :
958
+ h < 120 ? [x, c, 0] :
959
+ h < 180 ? [0, c, x] :
960
+ h < 240 ? [0, x, c] :
961
+ h < 300 ? [x, 0, c] : [c, 0, x];
962
+ return {
963
+ r: Math.round((r + m0) * 255),
964
+ g: Math.round((g + m0) * 255),
965
+ b: Math.round((b + m0) * 255),
966
+ a: 1,
967
+ };
968
+ }
835
969
 
836
- const otherSides = sides.filter(s => s !== side);
837
- const maxOther = Math.max(...otherSides.map(s => widths[s]));
838
- if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
970
+ function hwbToRgb(h, w, bl) {
971
+ if (w + bl >= 1) {
972
+ const g = Math.round((w / (w + bl)) * 255);
973
+ return { r: g, g, b: g, a: 1 };
974
+ }
975
+ const base = hslToRgb(h, 1, 0.5);
976
+ const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
977
+ return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
978
+ }
839
979
 
840
- const sn = side.toLowerCase();
841
- const isSide = side === 'Left' || side === 'Right';
980
+ // Common CSS named colors — the handful that actually show up in generated
981
+ // UIs, not the full 148-name spec list. Includes the achromatic names so a
982
+ // named gray parses (and correctly reads as no-chroma) instead of being
983
+ // treated as an unknown color.
984
+ const CSS_NAMED_COLORS = {
985
+ black: { r: 0, g: 0, b: 0 },
986
+ white: { r: 255, g: 255, b: 255 },
987
+ gray: { r: 128, g: 128, b: 128 },
988
+ grey: { r: 128, g: 128, b: 128 },
989
+ silver: { r: 192, g: 192, b: 192 },
990
+ dimgray: { r: 105, g: 105, b: 105 },
991
+ darkgray: { r: 169, g: 169, b: 169 },
992
+ lightgray: { r: 211, g: 211, b: 211 },
993
+ gainsboro: { r: 220, g: 220, b: 220 },
994
+ whitesmoke: { r: 245, g: 245, b: 245 },
995
+ red: { r: 255, g: 0, b: 0 },
996
+ crimson: { r: 220, g: 20, b: 60 },
997
+ tomato: { r: 255, g: 99, b: 71 },
998
+ coral: { r: 255, g: 127, b: 80 },
999
+ salmon: { r: 250, g: 128, b: 114 },
1000
+ orange: { r: 255, g: 165, b: 0 },
1001
+ gold: { r: 255, g: 215, b: 0 },
1002
+ yellow: { r: 255, g: 255, b: 0 },
1003
+ olive: { r: 128, g: 128, b: 0 },
1004
+ lime: { r: 0, g: 255, b: 0 },
1005
+ green: { r: 0, g: 128, b: 0 },
1006
+ teal: { r: 0, g: 128, b: 128 },
1007
+ turquoise: { r: 64, g: 224, b: 208 },
1008
+ cyan: { r: 0, g: 255, b: 255 },
1009
+ aqua: { r: 0, g: 255, b: 255 },
1010
+ skyblue: { r: 135, g: 206, b: 235 },
1011
+ dodgerblue: { r: 30, g: 144, b: 255 },
1012
+ blue: { r: 0, g: 0, b: 255 },
1013
+ navy: { r: 0, g: 0, b: 128 },
1014
+ indigo: { r: 75, g: 0, b: 130 },
1015
+ rebeccapurple: { r: 102, g: 51, b: 153 },
1016
+ purple: { r: 128, g: 0, b: 128 },
1017
+ violet: { r: 238, g: 130, b: 238 },
1018
+ orchid: { r: 218, g: 112, b: 214 },
1019
+ magenta: { r: 255, g: 0, b: 255 },
1020
+ fuchsia: { r: 255, g: 0, b: 255 },
1021
+ hotpink: { r: 255, g: 105, b: 180 },
1022
+ pink: { r: 255, g: 192, b: 203 },
1023
+ maroon: { r: 128, g: 0, b: 0 },
1024
+ };
842
1025
 
843
- if (isSide) {
844
- if (spanBadge) continue;
845
- if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
846
- else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
847
- } else {
848
- if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
849
- // Horizontal variant of the side-tab stripe: a thick chromatic accent
850
- // riding the top or bottom edge of a card/badge/container. Same
851
- // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
852
- // tab underlines are exempt via opts.tabContext (adapters look for
853
- // tablist/nav/tab ancestors and aria-selected); links, buttons,
854
- // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
855
- else if (!opts.tabContext && w >= 3 && w <= 12) {
856
- findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
857
- }
1026
+ // Split a string on top-level commas (ignoring commas nested in parens).
1027
+ function splitTopLevelCommas(str) {
1028
+ const parts = [];
1029
+ let depth = 0, start = 0;
1030
+ for (let i = 0; i < str.length; i++) {
1031
+ const ch = str[i];
1032
+ if (ch === '(') depth++;
1033
+ else if (ch === ')') depth = Math.max(0, depth - 1);
1034
+ else if (ch === ',' && depth === 0) {
1035
+ parts.push(str.slice(start, i).trim());
1036
+ start = i + 1;
858
1037
  }
859
1038
  }
860
-
861
- return findings;
862
- }
863
-
864
- // Returns true if the given text is composed entirely of emoji characters
865
- // (plus whitespace / variation selectors). Emojis render as multicolor glyphs
866
- // regardless of CSS `color`, so contrast checks against the element's text
867
- // color are meaningless for these nodes.
868
- const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
869
- const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
870
- function isEmojiOnlyText(text) {
871
- if (!text) return false;
872
- if (!EMOJI_CHAR_RE.test(text)) return false;
873
- return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
1039
+ const tail = str.slice(start).trim();
1040
+ if (tail) parts.push(tail);
1041
+ return parts;
874
1042
  }
875
1043
 
876
- function checkColors(opts) {
877
- const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
878
- if (SAFE_TAGS.has(tag)) {
879
- // Exception for elements styled as controls or chips. SAFE_TAGS exists to
880
- // suppress contrast noise on inline links and unstyled spans, where the
881
- // element has no own background and the contrast against the ancestor
882
- // surface is already the intended visual. When the element paints its own
883
- // opaque background under direct text, it is a styled button, chip, or
884
- // badge regardless of tag, and contrast on its own surface is a real,
885
- // frequent bug worth flagging. (The shipped miss: a <span> severity chip
886
- // whose white text lost a specificity fight and rendered muted-on-red at
887
- // 1.2:1; the old a/button-only exception never looked at it.) The 9px
888
- // font floor keeps sub-text decorations out.
889
- const isStyledControl = hasDirectText
890
- && ((bgColor && bgColor.a > 0.5)
891
- // A gradient painted on the element itself is an own surface the
892
- // same way a solid background is. Without this branch a nav CTA
893
- // built as `<a>` with `background: linear-gradient()` and a text
894
- // color that fails against every stop sails through on the
895
- // SAFE_TAGS suppression (the shipped escape).
896
- || (bgImage && /gradient/i.test(bgImage)))
897
- && fontSize >= 9;
898
- if (!isStyledControl) return [];
1044
+ // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
1045
+ // the expression can't be resolved (unresolved var(), unknown colors).
1046
+ //
1047
+ // Mixing is done with premultiplied alpha in sRGB regardless of the
1048
+ // declared interpolation space. That is exact for the dominant generated-UI
1049
+ // pattern `color-mix(in oklab, <color> N%, transparent)` where the
1050
+ // result is simply <color> at alpha N% in ANY rectangular space, and a
1051
+ // close-enough approximation for opaque-opaque mixes (the detector only
1052
+ // consumes these values for contrast/chroma thresholds, not for display).
1053
+ function parseColorMix(str) {
1054
+ const m = String(str).trim().match(/^color-mix\(/i);
1055
+ if (!m) return null;
1056
+ // Balanced-paren capture of the arguments.
1057
+ let depth = 0, end = -1;
1058
+ const open = str.indexOf('(');
1059
+ for (let i = open; i < str.length; i++) {
1060
+ if (str[i] === '(') depth++;
1061
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
899
1062
  }
900
- const findings = [];
1063
+ if (end < 0) return null;
1064
+ const args = splitTopLevelCommas(str.slice(open + 1, end));
1065
+ if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
901
1066
 
902
- if (hasDirectText && textColor && !isEmojiOnly) {
903
- // Gradient-clipped text (`background-clip: text`, typically with a
904
- // transparent text-fill) paints its glyphs *with* the element's own
905
- // gradient. The `color` value the cascade still reports is never painted,
906
- // and the gradient is the fill, not a backdrop — so measuring `color`
907
- // against that gradient (which resolveGradientStops picks up as the
908
- // element's own background-image) is a guaranteed false positive
909
- // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
910
- // rule below still flags the pattern itself. Skipping a rule beats a false
911
- // positive here the true painted contrast can't be measured from `color`.
912
- const isGradientClippedText = bgClip === 'text';
913
- // Run background-dependent checks against either a solid bg or, if the
914
- // ancestor is a gradient, against every gradient stop (use the worst case).
915
- const bgs = isGradientClippedText
916
- ? null
917
- : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
918
- if (bgs) {
919
- // Gray on colored background — flag if every stop is chromatic
920
- const textLum = relativeLuminance(textColor);
921
- const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
922
- if (isGray && bgs.every(b => hasChroma(b, 40))) {
923
- const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
924
- findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
925
- }
1067
+ const parseComponent = (component) => {
1068
+ // Percentage may lead or trail the color per spec.
1069
+ let pct = null;
1070
+ let colorStr = component;
1071
+ const trail = component.match(/\s+([\d.]+)%$/);
1072
+ const lead = component.match(/^([\d.]+)%\s+/);
1073
+ if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
1074
+ else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
1075
+ let color;
1076
+ if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
1077
+ else color = parseAnyColor(colorStr);
1078
+ if (!color) return null;
1079
+ return { color, pct };
1080
+ };
926
1081
 
927
- // Low contrast (WCAG AA) — worst case across all bg stops
928
- const ratios = bgs.map(b => contrastRatio(textColor, b));
929
- let worstIdx = 0;
930
- for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
931
- const ratio = ratios[worstIdx];
932
- const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
933
- const threshold = isLargeText ? 3.0 : 4.5;
934
- if (ratio < threshold) {
935
- // Skip the false-positive class where text has alpha < 1 AND we
936
- // couldn't find an opaque ancestor (effectiveBg is null, we're
937
- // comparing against gradient-stop fallback). In jsdom mode the
938
- // detector can't resolve `var(--X)` color tokens, so a dark
939
- // section sitting between the text and the body's decorative
940
- // gradient is invisible to us we end up measuring contrast
941
- // against the body's paper-grain noise instead of the real
942
- // local bg. Real low-contrast bugs use alpha=1 and have a
943
- // resolvable opaque ancestor; semi-transparent Tailwind tokens
944
- // like `text-paper/60` on `bg-ink` sections are the FP pattern.
945
- const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
946
- if (!isAlphaFallbackFP) {
947
- // Near-threshold ratios (e.g. 4.497) would round to the threshold
948
- // itself at one decimal and read as "4.5 needs 4.5" — show two
949
- // decimals there so the finding stays legible.
950
- const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
951
- findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
952
- }
953
- }
954
- }
1082
+ const c1 = parseComponent(args[1]);
1083
+ const c2 = parseComponent(args[2]);
1084
+ if (!c1 || !c2) return null;
1085
+ let p1 = c1.pct, p2 = c2.pct;
1086
+ if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
1087
+ else if (p1 == null) p1 = 100 - p2;
1088
+ else if (p2 == null) p2 = 100 - p1;
1089
+ const sum = p1 + p2;
1090
+ if (sum <= 0) return null;
1091
+ // Per spec: weights normalize to sum; when sum < 100 the result alpha is
1092
+ // additionally scaled by sum/100.
1093
+ const w1 = p1 / sum, w2 = p2 / sum;
1094
+ const alphaScale = sum < 100 ? sum / 100 : 1;
1095
+ const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
1096
+ const a = (a1 * w1 + a2 * w2) * alphaScale;
1097
+ if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
1098
+ const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
1099
+ return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
1100
+ }
955
1101
 
956
- // AI palette: purple/violet on headings
957
- if (hasChroma(textColor, 50)) {
958
- const hue = getHue(textColor);
959
- if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
960
- findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
961
- }
962
- }
963
- }
1102
+ // Composite a translucent color over an opaque(ish) base (simple
1103
+ // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
1104
+ function compositeColorOver(top, base) {
1105
+ const a = top.a ?? 1;
1106
+ return {
1107
+ r: Math.round(top.r * a + base.r * (1 - a)),
1108
+ g: Math.round(top.g * a + base.g * (1 - a)),
1109
+ b: Math.round(top.b * a + base.b * (1 - a)),
1110
+ a: 1,
1111
+ };
1112
+ }
964
1113
 
965
- // Gradient text
966
- if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
967
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
968
- }
1114
+ // A color() / lab() / lch() component: a bare number, a percentage against
1115
+ // `scale`, or the `none` keyword (which resolves to zero for our purposes).
1116
+ function parseColorComponent(token, scale = 1) {
1117
+ if (token == null) return null;
1118
+ const t = String(token).trim();
1119
+ if (/^none$/i.test(t)) return 0;
1120
+ const num = parseFloat(t);
1121
+ if (!Number.isFinite(num)) return null;
1122
+ return t.endsWith('%') ? (num / 100) * scale : num;
1123
+ }
969
1124
 
970
- // Tailwind class checks
971
- if (classList) {
972
- const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
1125
+ function parseAlphaToken(token) {
1126
+ if (token == null) return 1;
1127
+ const t = String(token).trim();
1128
+ if (/^none$/i.test(t)) return 1;
1129
+ const num = parseFloat(t);
1130
+ if (!Number.isFinite(num)) return 1;
1131
+ return t.endsWith('%') ? num / 100 : num;
1132
+ }
973
1133
 
974
- const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
975
- const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
976
- if (grayMatch && colorBgMatch) {
977
- findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
1134
+ // Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
1135
+ // color-mix/common named colors. Returns null on no match. Use this when the
1136
+ // input might be any CSS color form; use plain parseRgb when you only expect
1137
+ // computed rgb() values from real browsers.
1138
+ function parseAnyColor(s) {
1139
+ if (!s || typeof s !== 'string') return null;
1140
+ const str = s.trim();
1141
+ if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
1142
+ if (/^color-mix\(/i.test(str)) return parseColorMix(str);
1143
+ let m;
1144
+ m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
1145
+ if (m) {
1146
+ const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
1147
+ if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
1148
+ return c;
1149
+ }
1150
+ m = str.match(/^#([0-9a-f]{3,8})$/i);
1151
+ if (m) {
1152
+ const h = m[1];
1153
+ if (h.length === 3 || h.length === 4) {
1154
+ return {
1155
+ r: parseInt(h[0] + h[0], 16),
1156
+ g: parseInt(h[1] + h[1], 16),
1157
+ b: parseInt(h[2] + h[2], 16),
1158
+ a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
1159
+ };
978
1160
  }
979
-
980
- if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
981
- findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
1161
+ if (h.length === 6 || h.length === 8) {
1162
+ return {
1163
+ r: parseInt(h.slice(0, 2), 16),
1164
+ g: parseInt(h.slice(2, 4), 16),
1165
+ b: parseInt(h.slice(4, 6), 16),
1166
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
1167
+ };
982
1168
  }
983
-
984
- const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
985
- if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
986
- findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
1169
+ }
1170
+ // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
1171
+ // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
1172
+ // Match L (with optional %), then C and H separated permissively.
1173
+ m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1174
+ if (m) {
1175
+ const Lnum = parseFloat(m[1]);
1176
+ const L = m[2] === '%' ? Lnum / 100 : Lnum;
1177
+ const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
1178
+ if (m[5] !== undefined) {
1179
+ const alpha = parseFloat(m[5]);
1180
+ rgb.a = m[6] === '%' ? alpha / 100 : alpha;
987
1181
  }
988
-
989
- if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
990
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
1182
+ return rgb;
1183
+ }
1184
+ // OKLAB a/b are signed axes; percentages map 100% → 0.4.
1185
+ m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1186
+ if (m) {
1187
+ const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
1188
+ const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
1189
+ const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
1190
+ const rgb = oklabToRgb(L, a, b);
1191
+ if (m[7] !== undefined) {
1192
+ const alpha = parseFloat(m[7]);
1193
+ rgb.a = m[8] === '%' ? alpha / 100 : alpha;
991
1194
  }
1195
+ return rgb;
992
1196
  }
993
-
994
- return findings;
1197
+ // LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
1198
+ // `lch(20 5 60)`, so L arrives with or without its percent sign. In both
1199
+ // spaces L runs 0..100 and 100% means 100.
1200
+ m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1201
+ if (m) {
1202
+ const L = parseColorComponent(m[1], 100);
1203
+ const C = parseColorComponent(m[2], 150);
1204
+ const H = parseFloat(m[3]);
1205
+ if (L == null || C == null || !Number.isFinite(H)) return null;
1206
+ const rgb = lchToRgb(L, C, H);
1207
+ rgb.a = parseAlphaToken(m[4]);
1208
+ return rgb;
1209
+ }
1210
+ m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1211
+ if (m) {
1212
+ const L = parseColorComponent(m[1], 100);
1213
+ const a = parseColorComponent(m[2], 125);
1214
+ const b = parseColorComponent(m[3], 125);
1215
+ if (L == null || a == null || b == null) return null;
1216
+ const rgb = labToRgb(L, a, b);
1217
+ rgb.a = parseAlphaToken(m[4]);
1218
+ return rgb;
1219
+ }
1220
+ // color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
1221
+ // color-mix() results and for any wide-gamut color an author wrote.
1222
+ m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1223
+ if (m) {
1224
+ const c1 = parseColorComponent(m[2]);
1225
+ const c2 = parseColorComponent(m[3]);
1226
+ const c3 = parseColorComponent(m[4]);
1227
+ if (c1 == null || c2 == null || c3 == null) return null;
1228
+ const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
1229
+ if (!rgb) return null;
1230
+ rgb.a = parseAlphaToken(m[5]);
1231
+ return rgb;
1232
+ }
1233
+ // HSL/HSLA — comma or space syntax, optional deg on hue.
1234
+ m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
1235
+ if (m) {
1236
+ const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
1237
+ if (m[4] !== undefined) {
1238
+ const alpha = parseFloat(m[4]);
1239
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
1240
+ }
1241
+ return rgb;
1242
+ }
1243
+ // HWB — hue whiteness% blackness%.
1244
+ m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1245
+ if (m) {
1246
+ const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
1247
+ if (m[4] !== undefined) {
1248
+ const alpha = parseFloat(m[4]);
1249
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
1250
+ }
1251
+ return rgb;
1252
+ }
1253
+ const named = CSS_NAMED_COLORS[str.toLowerCase()];
1254
+ if (named) return { ...named, a: 1 };
1255
+ return null;
995
1256
  }
996
1257
 
997
- // WCAG contrast for the :hover state of an element whose hover rules change
998
- // its text color and/or background. The classic miss: a nav CTA whose
999
- // author-intended hover pair passes AA, but a broader selector (e.g.
1000
- // `.nav-links a:hover`) wins the specificity fight and swaps in a color
1001
- // that fails. Only fires on elements that present as styled controls
1002
- // direct text plus an opaque-ish own background in either state so plain
1003
- // inline links keep the same suppression they get in checkColors.
1004
- function checkHoverContrast(opts) {
1005
- const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
1006
- if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
1007
- if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
1008
- const ratio = contrastRatio(textColor, bg);
1009
- const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
1010
- const threshold = isLargeText ? 3.0 : 4.5;
1011
- if (ratio >= threshold) return [];
1012
- return [{
1013
- id: 'low-contrast',
1014
- snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
1015
- }];
1258
+ // True when a computed background-color string names no paint at all. Used to
1259
+ // tell "this layer is see-through" (walk on to the ancestor) apart from "this
1260
+ // layer has a color we could not read" (stop and abstain).
1261
+ //
1262
+ // `inherit` belongs here even though it is not literally see-through: it means
1263
+ // "paint with the parent's background-color", and walking on to the parent IS
1264
+ // that resolution. Real browsers resolve the keyword before getComputedStyle
1265
+ // output; only jsdom's partial cascade hands it through verbatim, and treating
1266
+ // it as unreadable would make the walk abstain on a surface it can know.
1267
+ // (`currentcolor` is NOT here it is real paint in the element's own text
1268
+ // color; resolveBackgroundInfo substitutes the computed color for it.)
1269
+ function isNoPaintColorValue(value) {
1270
+ const v = String(value || '').trim().toLowerCase();
1271
+ if (!v) return true;
1272
+ return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
1016
1273
  }
1017
1274
 
1018
- function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
1019
- if (!hasShadow && !hasBorder) return false;
1020
- return hasRadius || hasBg;
1275
+ // --- cli/engine/shared/fonts.mjs ---
1276
+ const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
1277
+
1278
+ function normalizeGoogleFontFamilyParam(value) {
1279
+ return String(value || '')
1280
+ .split('|')
1281
+ .map(part => part.split(':')[0].trim().toLowerCase())
1282
+ .filter(Boolean);
1021
1283
  }
1022
1284
 
1023
- const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
1285
+ function extractGoogleFontFamilies(text) {
1286
+ const families = [];
1287
+ if (!text) return families;
1024
1288
 
1025
- // Pure check: given a heading and metrics about its previousElementSibling,
1026
- // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
1027
- //
1028
- // Triggers when ALL of the following hold for the sibling:
1029
- // • size 32–128px on both axes (not too small, not a hero image)
1030
- // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
1031
- // • has a non-transparent background-color, background-image, OR a visible border
1032
- // (covers solid colors, white-with-border, gradients anything that visually
1033
- // defines a tile)
1034
- // • border-radius < width/2 (excludes round avatars; rounded squares pass)
1035
- // • contains an <svg> or icon-class <i> element that's smaller than the tile
1036
- // • the tile sits above the heading (its bottom is above the heading's top)
1037
- function checkIconTile(opts) {
1038
- const { headingTag, headingText, headingTop,
1039
- siblingTag, siblingWidth, siblingHeight, siblingBottom,
1040
- siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
1289
+ GOOGLE_FONTS_URL_RE.lastIndex = 0;
1290
+ let urlMatch;
1291
+ while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
1292
+ const url = urlMatch[0];
1293
+ const queryStart = url.indexOf('?');
1294
+ if (queryStart === -1) continue;
1295
+
1296
+ const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
1297
+ for (const value of params.getAll('family')) {
1298
+ families.push(...normalizeGoogleFontFamilyParam(value));
1299
+ }
1300
+ }
1301
+
1302
+ return families;
1303
+ }
1304
+
1305
+ // --- cli/engine/rules/checks.mjs ---
1306
+ const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
1307
+
1308
+ // ─── Section 3: Pure Detection ──────────────────────────────────────────────
1309
+
1310
+ function checkBorders(tag, widths, colors, radius, opts = {}) {
1311
+ // Badge-shaped <span>s (own visible background) are a real stripe target
1312
+ // for the top/bottom variant — the inline-tag exemption exists to quiet
1313
+ // text-level borders, not chips. They skip the left/right arms below.
1314
+ const spanBadge = tag === 'span' && !!opts.badgeLike;
1315
+ if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
1316
+ // A live status/alert region wears a colored single-edge border as a
1317
+ // severity accent (toast, snackbar, callout), not as the side-tab tell.
1318
+ if (opts.statusContext) return [];
1319
+ const findings = [];
1320
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
1321
+
1322
+ for (const side of sides) {
1323
+ const w = widths[side];
1324
+ if (w < 1 || isNeutralColor(colors[side])) continue;
1325
+
1326
+ const otherSides = sides.filter(s => s !== side);
1327
+ const maxOther = Math.max(...otherSides.map(s => widths[s]));
1328
+ if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
1329
+
1330
+ const sn = side.toLowerCase();
1331
+ const isSide = side === 'Left' || side === 'Right';
1332
+
1333
+ if (isSide) {
1334
+ if (spanBadge) continue;
1335
+ if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
1336
+ else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
1337
+ } else {
1338
+ if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
1339
+ // Horizontal variant of the side-tab stripe: a thick chromatic accent
1340
+ // riding the top or bottom edge of a card/badge/container. Same
1341
+ // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
1342
+ // tab underlines are exempt via opts.tabContext (adapters look for
1343
+ // tablist/nav/tab ancestors and aria-selected); links, buttons,
1344
+ // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
1345
+ else if (!opts.tabContext && w >= 3 && w <= 12) {
1346
+ findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
1347
+ }
1348
+ }
1349
+ }
1350
+
1351
+ return findings;
1352
+ }
1353
+
1354
+ // ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
1355
+ //
1356
+ // An element-scoped waiver that travels with the markup: any element carrying
1357
+ // `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
1358
+ // every rule) suppresses matching findings from itself and its entire subtree,
1359
+ // in every engine that walks elements — the browser overlay, the extension,
1360
+ // and the static scan. This is the DOM twin of the line-based
1361
+ // `impeccable-disable` comment directives, which the browser cannot apply (a
1362
+ // live DOM has no line numbers), and the generalization of the one-off
1363
+ // `data-impeccable-allow-kickers` opt-out.
1364
+ //
1365
+ // The intended use is curated exhibits: a page that documents anti-patterns by
1366
+ // example, or renders a deliberate "before" specimen, marks the container once
1367
+ // and every engine skips it while still scanning the page around it.
1368
+ function scopedIgnoreActive(el, ruleId) {
1369
+ const rule = String(ruleId || '').toLowerCase();
1370
+ let cur = el;
1371
+ while (cur && cur.nodeType === 1) {
1372
+ const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
1373
+ if (attr != null) {
1374
+ const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
1375
+ if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
1376
+ }
1377
+ cur = cur.parentElement;
1378
+ }
1379
+ return false;
1380
+ }
1381
+
1382
+ // Returns true if the given text is composed entirely of emoji characters
1383
+ // (plus whitespace / variation selectors). Emojis render as multicolor glyphs
1384
+ // regardless of CSS `color`, so contrast checks against the element's text
1385
+ // color are meaningless for these nodes.
1386
+ const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
1387
+ const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
1388
+ function isEmojiOnlyText(text) {
1389
+ if (!text) return false;
1390
+ if (!EMOJI_CHAR_RE.test(text)) return false;
1391
+ return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
1392
+ }
1393
+
1394
+ function checkColors(opts) {
1395
+ const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
1396
+ if (SAFE_TAGS.has(tag)) {
1397
+ // Exception for elements styled as controls or chips. SAFE_TAGS exists to
1398
+ // suppress contrast noise on inline links and unstyled spans, where the
1399
+ // element has no own background and the contrast against the ancestor
1400
+ // surface is already the intended visual. When the element paints its own
1401
+ // opaque background under direct text, it is a styled button, chip, or
1402
+ // badge regardless of tag, and contrast on its own surface is a real,
1403
+ // frequent bug worth flagging. (The shipped miss: a <span> severity chip
1404
+ // whose white text lost a specificity fight and rendered muted-on-red at
1405
+ // 1.2:1; the old a/button-only exception never looked at it.) The 9px
1406
+ // font floor keeps sub-text decorations out.
1407
+ const isStyledControl = hasDirectText
1408
+ && ((bgColor && bgColor.a > 0.5)
1409
+ // A gradient painted on the element itself is an own surface the
1410
+ // same way a solid background is. Without this branch a nav CTA
1411
+ // built as `<a>` with `background: linear-gradient(…)` and a text
1412
+ // color that fails against every stop sails through on the
1413
+ // SAFE_TAGS suppression (the shipped escape).
1414
+ || (bgImage && /gradient/i.test(bgImage)))
1415
+ && fontSize >= 9;
1416
+ if (!isStyledControl) return [];
1417
+ }
1418
+ const findings = [];
1419
+
1420
+ if (hasDirectText && textColor && !isEmojiOnly) {
1421
+ // Gradient-clipped text (`background-clip: text`, typically with a
1422
+ // transparent text-fill) paints its glyphs *with* the element's own
1423
+ // gradient. The `color` value the cascade still reports is never painted,
1424
+ // and the gradient is the fill, not a backdrop — so measuring `color`
1425
+ // against that gradient (which resolveGradientStops picks up as the
1426
+ // element's own background-image) is a guaranteed false positive
1427
+ // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
1428
+ // rule below still flags the pattern itself. Skipping a rule beats a false
1429
+ // positive here — the true painted contrast can't be measured from `color`.
1430
+ const isGradientClippedText = bgClip === 'text';
1431
+ // Run background-dependent checks against either a solid bg or, if the
1432
+ // ancestor is a gradient, against every gradient stop (use the worst case).
1433
+ const bgs = isGradientClippedText
1434
+ ? null
1435
+ : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
1436
+ if (bgs) {
1437
+ // Gray on colored background — flag if every stop is chromatic
1438
+ const textLum = relativeLuminance(textColor);
1439
+ const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
1440
+ if (isGray && bgs.every(b => hasChroma(b, 40))) {
1441
+ const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
1442
+ findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
1443
+ }
1444
+
1445
+ // Low contrast (WCAG AA) — worst case across all bg stops
1446
+ const ratios = bgs.map(b => contrastRatio(textColor, b));
1447
+ let worstIdx = 0;
1448
+ for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
1449
+ const ratio = ratios[worstIdx];
1450
+ const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
1451
+ const threshold = isLargeText ? 3.0 : 4.5;
1452
+ if (ratio < threshold) {
1453
+ // Skip the false-positive class where text has alpha < 1 AND we
1454
+ // couldn't find an opaque ancestor (effectiveBg is null, we're
1455
+ // comparing against gradient-stop fallback). In jsdom mode the
1456
+ // detector can't resolve `var(--X)` color tokens, so a dark
1457
+ // section sitting between the text and the body's decorative
1458
+ // gradient is invisible to us — we end up measuring contrast
1459
+ // against the body's paper-grain noise instead of the real
1460
+ // local bg. Real low-contrast bugs use alpha=1 and have a
1461
+ // resolvable opaque ancestor; semi-transparent Tailwind tokens
1462
+ // like `text-paper/60` on `bg-ink` sections are the FP pattern.
1463
+ const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
1464
+ if (!isAlphaFallbackFP) {
1465
+ // Near-threshold ratios (e.g. 4.497) would round to the threshold
1466
+ // itself at one decimal and read as "4.5 needs 4.5" — show two
1467
+ // decimals there so the finding stays legible.
1468
+ const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
1469
+ findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ // AI palette: purple/violet on headings
1475
+ if (hasChroma(textColor, 50)) {
1476
+ const hue = getHue(textColor);
1477
+ if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
1478
+ findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
1479
+ }
1480
+ }
1481
+ }
1482
+
1483
+ // Gradient text
1484
+ if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
1485
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
1486
+ }
1487
+
1488
+ // Tailwind class checks
1489
+ if (classList) {
1490
+ const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
1491
+
1492
+ const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
1493
+ const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
1494
+ if (grayMatch && colorBgMatch) {
1495
+ findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
1496
+ }
1497
+
1498
+ if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
1499
+ findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
1500
+ }
1501
+
1502
+ const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
1503
+ if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
1504
+ findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
1505
+ }
1506
+
1507
+ if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
1508
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
1509
+ }
1510
+ }
1511
+
1512
+ return findings;
1513
+ }
1514
+
1515
+ // WCAG contrast for the :hover state of an element whose hover rules change
1516
+ // its text color and/or background. The classic miss: a nav CTA whose
1517
+ // author-intended hover pair passes AA, but a broader selector (e.g.
1518
+ // `.nav-links a:hover`) wins the specificity fight and swaps in a color
1519
+ // that fails. Only fires on elements that present as styled controls —
1520
+ // direct text plus an opaque-ish own background in either state — so plain
1521
+ // inline links keep the same suppression they get in checkColors.
1522
+ function checkHoverContrast(opts) {
1523
+ const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
1524
+ if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
1525
+ if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
1526
+ const ratio = contrastRatio(textColor, bg);
1527
+ const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
1528
+ const threshold = isLargeText ? 3.0 : 4.5;
1529
+ if (ratio >= threshold) return [];
1530
+ return [{
1531
+ id: 'low-contrast',
1532
+ snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
1533
+ }];
1534
+ }
1535
+
1536
+ function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
1537
+ if (!hasShadow && !hasBorder) return false;
1538
+ return hasRadius || hasBg;
1539
+ }
1540
+
1541
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
1542
+
1543
+ // Pure check: given a heading and metrics about its previousElementSibling,
1544
+ // decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
1545
+ //
1546
+ // Triggers when ALL of the following hold for the sibling:
1547
+ // • size 32–128px on both axes (not too small, not a hero image)
1548
+ // • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
1549
+ // • has a non-transparent background-color, background-image, OR a visible border
1550
+ // (covers solid colors, white-with-border, gradients — anything that visually
1551
+ // defines a tile)
1552
+ // • border-radius < width/2 (excludes round avatars; rounded squares pass)
1553
+ // • contains an <svg> or icon-class <i> element that's smaller than the tile
1554
+ // • the tile sits above the heading (its bottom is above the heading's top)
1555
+ function checkIconTile(opts) {
1556
+ const { headingTag, headingText, headingTop,
1557
+ siblingTag, siblingWidth, siblingHeight, siblingBottom,
1558
+ siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
1041
1559
  hasIconChild, iconChildWidth } = opts;
1042
1560
  if (!HEADING_TAGS.has(headingTag)) return [];
1043
1561
  if (!siblingTag) return [];
@@ -1087,7 +1605,7 @@ function checkIconTile(opts) {
1087
1605
  function resolveSerif(fontFamily) {
1088
1606
  if (!fontFamily) return { primary: null, isSerif: false };
1089
1607
  const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
1090
- const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
1608
+ const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
1091
1609
  if (!primary) return { primary: null, isSerif: false };
1092
1610
  if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
1093
1611
  if (tokens.includes('serif')) return { primary, isSerif: true };
@@ -1428,6 +1946,31 @@ function cssTextHasDarkRootBg(content, customProps) {
1428
1946
  return false;
1429
1947
  }
1430
1948
 
1949
+ // Best-effort extraction of the CSS selector whose declaration block contains
1950
+ // the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
1951
+ // anchor, so the browser pass can resolve scoped ignores against the actual
1952
+ // element and drop patterns that render nowhere on the page. Returns null for
1953
+ // @-rule preludes, keyframe steps, nested blocks, and anything that does not
1954
+ // read as a selector; those findings stay page-level.
1955
+ function enclosingCssSelector(cssText, index) {
1956
+ if (!cssText || !Number.isFinite(index)) return null;
1957
+ const open = cssText.lastIndexOf('{', index);
1958
+ if (open === -1) return null;
1959
+ // A match inside an inline style fragment (`style="…"` appended to the
1960
+ // corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
1961
+ // `{` belongs to some other selector.
1962
+ const closeBeforeIndex = cssText.lastIndexOf('}', index);
1963
+ if (closeBeforeIndex > open) return null;
1964
+ const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
1965
+ const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
1966
+ if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
1967
+ // Keyframe steps: percentage steps fail the digit test above, but `from`
1968
+ // and `to` would read as (never-matching) type selectors and get a valid
1969
+ // finding wrongly dropped by the zero-match rule downstream.
1970
+ if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
1971
+ return raw;
1972
+ }
1973
+
1431
1974
  function scanCssTextForGlow(content) {
1432
1975
  const customProps = collectCssCustomProps(content);
1433
1976
  const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -1457,20 +2000,19 @@ function scanCssTextForGlow(content) {
1457
2000
  return results;
1458
2001
  }
1459
2002
 
1460
- // Decorative grid or line-field backgrounds drawn with hairline
2003
+ // Decorative two-axis grid backgrounds drawn with hairline
1461
2004
  // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
1462
2005
  // pattern pass and the regex source engine so standalone CSS, component
1463
2006
  // styles, and inline styles receive the same coverage. Both signals must
1464
2007
  // co-occur in one declaration block; unrelated rules must not add up across
1465
- // the file. Returns [{ index, snippet }], capped at one finding per source to
1466
- // match the page-level HTML check's existing behavior.
2008
+ // the file. A single hairline is a line, divider, or rail, not a grid, even
2009
+ // when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
2010
+ // finding per source to match the page-level HTML check's existing behavior.
1467
2011
  function scanCssTextForGridBackground(content) {
1468
2012
  const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
1469
2013
  const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
1470
2014
  const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
1471
- const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
1472
2015
  const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
1473
- const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
1474
2016
  const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
1475
2017
  const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
1476
2018
  let blk;
@@ -1487,13 +2029,10 @@ function scanCssTextForGridBackground(content) {
1487
2029
  }
1488
2030
  if (hairlineCount === 0) continue;
1489
2031
  const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
1490
- const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
1491
- if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
2032
+ if (hairlineCount >= 2 && hasPxCell) {
1492
2033
  return [{
1493
2034
  index: blk.index,
1494
- snippet: hairlineCount >= 2
1495
- ? 'two-axis grid-line gradient background'
1496
- : 'px-tiled hairline line-field background',
2035
+ snippet: 'two-axis grid-line gradient background',
1497
2036
  }];
1498
2037
  }
1499
2038
  }
@@ -1739,6 +2278,7 @@ function scanCssTextForPseudoStripe(rawContent) {
1739
2278
  id: 'side-tab',
1740
2279
  snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
1741
2280
  index: selectorStart,
2281
+ selector,
1742
2282
  });
1743
2283
  }
1744
2284
  return findings;
@@ -1801,6 +2341,7 @@ function scanCssTextForInsetStripe(content) {
1801
2341
  findings.push({
1802
2342
  id: 'side-tab',
1803
2343
  snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
2344
+ selector,
1804
2345
  });
1805
2346
  break;
1806
2347
  }
@@ -1858,7 +2399,7 @@ function collectMarqueeKeyframes(content) {
1858
2399
  function scanCssTextForMarquee(content, markup = content) {
1859
2400
  const findings = [];
1860
2401
  if (/<marquee\b/i.test(markup)) {
1861
- findings.push({ id: 'marquee', snippet: '<marquee> element' });
2402
+ findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
1862
2403
  }
1863
2404
  const marqueeKeyframes = collectMarqueeKeyframes(content);
1864
2405
  if (marqueeKeyframes.size === 0) return findings;
@@ -1873,7 +2414,7 @@ function scanCssTextForMarquee(content, markup = content) {
1873
2414
  const key = `${selector} ${name}`;
1874
2415
  if (seen.has(key)) continue;
1875
2416
  seen.add(key);
1876
- findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
2417
+ findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
1877
2418
  }
1878
2419
  }
1879
2420
  return findings;
@@ -2187,7 +2728,96 @@ function scanHtmlForShapeAssembledIllustration(html) {
2187
2728
  return findings;
2188
2729
  }
2189
2730
 
2190
- // Scoped scan corpora for the page-level pattern checks. CSS-property
2731
+
2732
+ // --- Organic clip-path polygons ----------------------------------------------
2733
+ // A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
2734
+ // with curves, is CSS approximating an organic contour: a torn edge, a blob,
2735
+ // a silhouette. The approximation reads as the cheap version of the effect
2736
+ // (the craft floor's geometric-occlusion-mask ban), and it is the signature
2737
+ // of a comp's produced material being replaced with code. Geometric clips
2738
+ // (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
2739
+ // the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
2740
+ // from an alpha matte passes.
2741
+ const ORGANIC_POLYGON_MIN_VERTICES = 10;
2742
+ function scanCssTextForOrganicClipPath(styleText) {
2743
+ const findings = [];
2744
+ const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
2745
+ let m;
2746
+ while ((m = re.exec(styleText)) !== null) {
2747
+ const kind = m[1].toLowerCase();
2748
+ const body = m[2];
2749
+ if (kind === 'path') {
2750
+ // curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
2751
+ // rectilinear M/L/Z outline; letters in path data are only commands
2752
+ const curves = (body.match(/[CSQTA]/gi) || []).length;
2753
+ if (curves < 3) continue;
2754
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
2755
+ continue;
2756
+ }
2757
+ const points = body.split(',').map((p) => p.trim()).filter(Boolean);
2758
+ if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
2759
+ // Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
2760
+ // contour has arbitrary values.
2761
+ let offGrid = 0;
2762
+ for (const p of points) {
2763
+ const nums = p.match(/-?[\d.]+/g) || [];
2764
+ for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
2765
+ }
2766
+ if (offGrid < points.length) continue;
2767
+ findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
2768
+ }
2769
+ return findings;
2770
+ }
2771
+
2772
+ // --- Buried raster ------------------------------------------------------------
2773
+ // A raster (background-image url or <img>) that never reaches the screen:
2774
+ // under a near-opaque gradient wash in the same background stack, or on an
2775
+ // element at near-zero opacity. It is how a produced texture "ships" while
2776
+ // the page shows flat color, and the finish reviewer cannot see it either.
2777
+ // A tint under 0.9 alpha passes (hero darkening); a blend mode passes
2778
+ // (multiply/overlay keep the material visible); opacity >= 0.15 passes.
2779
+ function scanCssTextForBuriedRaster(styleText) {
2780
+ const findings = [];
2781
+ // background stacks: split declarations, look for url() + a gradient whose
2782
+ // stops all carry alpha >= 0.9 (or opaque hex/named colors)
2783
+ const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
2784
+ let m;
2785
+ while ((m = declRe.exec(styleText)) !== null) {
2786
+ const value = m[1];
2787
+ if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
2788
+ // a blend mode declared in the same rule keeps the raster visible
2789
+ const ruleStart = styleText.lastIndexOf('{', m.index);
2790
+ const ruleEnd = styleText.indexOf('}', m.index);
2791
+ const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
2792
+ if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
2793
+ // Layers are painted first-on-top: only a wash listed BEFORE the url()
2794
+ // covers it. An image on top of a gradient is not buried.
2795
+ const firstUrl = value.search(/url\(/i);
2796
+ const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
2797
+ let opaqueWash = false;
2798
+ // an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
2799
+ const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
2800
+ for (const g of gradients) {
2801
+ 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]));
2802
+ const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
2803
+ // hex stops: 4- and 8-digit forms carry their own alpha
2804
+ for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
2805
+ const hex = h[1];
2806
+ if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
2807
+ else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
2808
+ else alphas.push(1);
2809
+ }
2810
+ const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
2811
+ if (named) alphas.push(1);
2812
+ if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
2813
+ }
2814
+ if (!opaqueWash) continue;
2815
+ findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
2816
+ }
2817
+ return findings;
2818
+ }
2819
+
2820
+ // Scoped scan corpora for the page-level pattern checks. CSS-property
2191
2821
  // regexes run over the whole source string fire on documentation ABOUT
2192
2822
  // css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
2193
2823
  // comments — so the checks scan only the strings that actually style the
@@ -2244,8 +2874,10 @@ function checkHtmlPatterns(html, corpora) {
2244
2874
  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
2245
2875
  if (purpleHexRe.test(styleText)) {
2246
2876
  const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
2247
- if (purpleTextRe.test(styleText)) {
2248
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
2877
+ purpleTextRe.lastIndex = 0;
2878
+ const purpleMatch = purpleTextRe.exec(styleText);
2879
+ if (purpleMatch) {
2880
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
2249
2881
  }
2250
2882
  }
2251
2883
 
@@ -2256,7 +2888,7 @@ function checkHtmlPatterns(html, corpora) {
2256
2888
  const start = Math.max(0, gm.index - 200);
2257
2889
  const context = styleText.substring(start, gm.index + gm[0].length + 200);
2258
2890
  if (/gradient/i.test(context)) {
2259
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
2891
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
2260
2892
  break;
2261
2893
  }
2262
2894
  }
@@ -2322,7 +2954,7 @@ function checkHtmlPatterns(html, corpora) {
2322
2954
  const animationToken = bounceMatch[1]
2323
2955
  .split(/[,\s]+/)
2324
2956
  .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
2325
- findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
2957
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
2326
2958
  }
2327
2959
 
2328
2960
  // Overshoot cubic-bezier
@@ -2331,7 +2963,7 @@ function checkHtmlPatterns(html, corpora) {
2331
2963
  while ((bm = bezierRe.exec(styleText)) !== null) {
2332
2964
  const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
2333
2965
  if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
2334
- findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
2966
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
2335
2967
  break;
2336
2968
  }
2337
2969
  }
@@ -2357,6 +2989,10 @@ function checkHtmlPatterns(html, corpora) {
2357
2989
  // Shape-assembled illustrations (large pictorial SVGs built from primitives)
2358
2990
  findings.push(...scanHtmlForShapeAssembledIllustration(html));
2359
2991
 
2992
+ // Organic clip-path contours and rasters buried under washes or opacity
2993
+ findings.push(...scanCssTextForOrganicClipPath(styleText));
2994
+ findings.push(...scanCssTextForBuriedRaster(styleText));
2995
+
2360
2996
  // Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
2361
2997
  findings.push(...scanCssTextForMarquee(styleText, html));
2362
2998
 
@@ -2364,18 +3000,21 @@ function checkHtmlPatterns(html, corpora) {
2364
3000
 
2365
3001
  const glowHits = scanCssTextForGlow(styleText);
2366
3002
  if (glowHits.length > 0) {
2367
- findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
3003
+ findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
2368
3004
  }
2369
3005
 
2370
3006
  // Radial-gradient background halo (gradient-drawn sibling of dark-glow)
2371
3007
  const haloHits = scanCssTextForRadialHalo(styleText);
2372
3008
  if (haloHits.length > 0) {
2373
- findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
3009
+ findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
2374
3010
  }
2375
3011
 
2376
3012
  // --- Generated-UI tells: repeating-gradient stripes ---
2377
- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
2378
- findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
3013
+ {
3014
+ const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
3015
+ if (stripesMatch) {
3016
+ findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
3017
+ }
2379
3018
  }
2380
3019
 
2381
3020
  // --- Generated-UI tells: two-axis grid-line background ---
@@ -2393,7 +3032,7 @@ function checkHtmlPatterns(html, corpora) {
2393
3032
  // whole gradient layers.
2394
3033
  const gridHits = scanCssTextForGridBackground(styleText);
2395
3034
  if (gridHits.length > 0) {
2396
- findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
3035
+ findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
2397
3036
  }
2398
3037
 
2399
3038
  // --- Generated-copy tells: "X theater" framing copy ---
@@ -2413,8 +3052,11 @@ function checkHtmlPatterns(html, corpora) {
2413
3052
  // hover:rotate / hover:translate utility on an <img>. Each distinct
2414
3053
  // mechanism is its own finding.
2415
3054
  const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
2416
- if (imgHoverCss.test(styleText)) {
2417
- findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
3055
+ {
3056
+ const imgHoverMatch = imgHoverCss.exec(styleText);
3057
+ if (imgHoverMatch) {
3058
+ findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
3059
+ }
2418
3060
  }
2419
3061
  const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
2420
3062
  let im;
@@ -2461,7 +3103,46 @@ function readOwnBackgroundColor(el, computedStyle) {
2461
3103
  return bg;
2462
3104
  }
2463
3105
 
2464
- function resolveBackground(el, win, customPropMap) {
3106
+ // One element's background-color as the cascade walk sees it: computed style
3107
+ // first (with the modern-color fallback), then, in static mode only,
3108
+ // custom-prop resolution and the inline-shorthand peek. Shared by
3109
+ // resolveBackgroundInfo and resolveGradientStops so both walks read the same
3110
+ // surfaces.
3111
+ function readCascadeBackgroundColor(current, style, customPropMap) {
3112
+ let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
3113
+ if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
3114
+ // The static engine can return literal "var(--X)" / "oklch(...)" strings.
3115
+ // Resolve through customPropMap so Tailwind v4 color tokens become RGB.
3116
+ if (customPropMap) {
3117
+ bg = parseColorResolved(style.backgroundColor, customPropMap);
3118
+ }
3119
+ if (!bg || bg.a < 0.1) {
3120
+ // Inline-style fallback for colors the static cascade did not surface
3121
+ // on backgroundColor.
3122
+ const rawStyle = current.getAttribute?.('style') || '';
3123
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
3124
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
3125
+ if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
3126
+ bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
3127
+ }
3128
+ }
3129
+ }
3130
+ return bg;
3131
+ }
3132
+
3133
+ // Walk up for the surface the element's text is painted on.
3134
+ //
3135
+ // Returns { color, unresolved }:
3136
+ // • color set — the effective surface, overlays composited in.
3137
+ // • unresolved: true — a layer on the way up paints a color this parser
3138
+ // cannot read, so the surface is unknown. Callers
3139
+ // must SKIP their contrast checks. Guessing white
3140
+ // here is what flooded dark themes with false
3141
+ // "on #ffffff" findings: one abstention costs a
3142
+ // single finding, one wrong guess costs a hundred.
3143
+ // • both null/false — no solid color, but a gradient or image is in
3144
+ // play; callers fall back to its color stops.
3145
+ function resolveBackgroundInfo(el, win, customPropMap) {
2465
3146
  let current = el;
2466
3147
  // Translucent layers (0.1 < a < 1) found on the way down to an opaque
2467
3148
  // base. A browser composites these over the base; the old behavior
@@ -2489,67 +3170,114 @@ function resolveBackground(el, win, customPropMap) {
2489
3170
  // body backgrounds.
2490
3171
  // Real browsers serialize wide-gamut computed values as oklab()/oklch()
2491
3172
  // (e.g. any color-mix() result), which plain parseRgb misses.
2492
- let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
2493
- if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
2494
- // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
2495
- // through customPropMap so Tailwind v4 color tokens become RGB.
2496
- if (customPropMap) {
2497
- bg = parseColorResolved(style.backgroundColor, customPropMap);
2498
- }
2499
- if (!bg || bg.a < 0.1) {
2500
- // Inline-style fallback. jsdom doesn't decompose background
2501
- // shorthand, so colors set via inline style are otherwise invisible.
2502
- const rawStyle = current.getAttribute?.('style') || '';
2503
- const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
2504
- const inlineBg = bgMatch ? bgMatch[1].trim() : '';
2505
- if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
2506
- bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
2507
- }
2508
- }
3173
+ let bg = readCascadeBackgroundColor(current, style, customPropMap);
3174
+
3175
+ // `background-color: currentcolor` paints with the element's own text
3176
+ // color real paint whose value we know. Real browsers resolve the
3177
+ // keyword before getComputedStyle output; jsdom hands it through
3178
+ // verbatim, and without this substitution the layer would read as
3179
+ // unparseable and force a needless abstention.
3180
+ if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
3181
+ // The static cascade resolves var() text tokens before checks run, so
3182
+ // style.color is normally already an rgb string here; parseColorResolved
3183
+ // is defense in depth for any future caller that passes a live
3184
+ // customPropMap (it matches the text-color path in checkElementColors
3185
+ // and reduces to parseAnyColor when the map is null or absent).
3186
+ bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
2509
3187
  }
2510
3188
 
2511
3189
  if (bg && bg.a > 0.1) {
2512
- if (bg.a >= 0.99) return flatten(bg);
3190
+ if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
2513
3191
  overlays.push(bg);
2514
- }
2515
- // No solid bg-color at this level. If THIS level has a gradient/url
2516
- // with no underlying solid color we can read:
2517
- // on body/html: assume white. Body-level gradients are almost
2518
- // always decorative texture (paper grain, noise) on top of a
2519
- // solid bg-color the page set via `background: var(--paper)`
2520
- // shorthand which jsdom can't decompose into bg-color. The
2521
- // downstream gradient-stops fallback path produces catastrophic
2522
- // false positives in this case (gradient noise stops have
2523
- // accidental browns/blacks that look like card backgrounds).
2524
- // on other elements: bail to null and let the caller fall back
2525
- // to gradient stops (gradient buttons / hero sections are real
2526
- // bgs worth checking against).
3192
+ } else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
3193
+ // This layer names a color we could not parse (a color space we do not
3194
+ // model, an unresolved var(), a syntax newer than the parser). It may
3195
+ // well be opaque, which would make every ancestor below it invisible —
3196
+ // so the surface is unknown and the walk stops here rather than
3197
+ // reporting an ancestor the visitor never sees.
3198
+ return { color: null, unresolved: true };
3199
+ }
3200
+ // No solid bg-color at this level, but this level paints an image. CSS
3201
+ // stacks background-image layers first-on-top, so which layer leads
3202
+ // decides what the visitor sees:
3203
+ // gradient on top — the gradient is the surface. Hand the caller a
3204
+ // null color so it falls back to the gradient's own stops (body
3205
+ // grounds, gradient buttons, hero sections).
3206
+ // • url() on top — the surface is an image whose pixels this engine
3207
+ // cannot read, and it may fully cover every layer and ancestor
3208
+ // beneath it. Same contract as an unparseable color: abstain, so
3209
+ // the gradient-stop fallback never measures a gradient the image
3210
+ // hides (the shipped miss: `url(photo), linear-gradient(...)`
3211
+ // reported low-contrast against the invisible gradient's stops).
2527
3212
  if (hasGradientOrUrl) {
2528
- if (current.tagName === 'BODY' || current.tagName === 'HTML') {
2529
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
3213
+ const layers = splitTopLevelCommas(bgImage);
3214
+ const topPaintLayer = layers.find(
3215
+ (layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
3216
+ );
3217
+ const gradientOnTop = !!topPaintLayer
3218
+ && /gradient\s*\(/i.test(topPaintLayer)
3219
+ && !/^\s*url\s*\(/i.test(topPaintLayer);
3220
+ if (!gradientOnTop) return { color: null, unresolved: true };
3221
+ // Gradient on top of a url() layer: the image shows through wherever
3222
+ // the gradient is not fully opaque, so a translucent wash like
3223
+ // `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
3224
+ // a blend with pixels this engine cannot read. Only a gradient whose
3225
+ // every readable stop is opaque provably covers the image; otherwise
3226
+ // the surface is unknown — abstain rather than hand callers gradient
3227
+ // stops (or a stop average) the visitor never sees unmixed.
3228
+ const urlBeneath = layers.some(
3229
+ (layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
3230
+ );
3231
+ if (urlBeneath) {
3232
+ const topStops = parseGradientColors(topPaintLayer);
3233
+ const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
3234
+ if (!provablyOpaque) return { color: null, unresolved: true };
2530
3235
  }
2531
- return null;
3236
+ return { color: null, unresolved: false };
2532
3237
  }
2533
3238
  current = current.parentElement;
2534
3239
  }
2535
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
3240
+ // Every layer up to the document root was genuinely see-through, so the
3241
+ // browser paints its default canvas. This is the ONLY case that earns the
3242
+ // white assumption.
3243
+ return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
3244
+ }
3245
+
3246
+ function resolveBackground(el, win, customPropMap) {
3247
+ return resolveBackgroundInfo(el, win, customPropMap).color;
2536
3248
  }
2537
3249
 
2538
3250
  // Walk parents looking for a gradient background and return its color stops.
2539
3251
  // Used as a fallback when resolveBackground() returns null because the
2540
3252
  // effective background is a gradient (no single solid color to compare against).
3253
+ // Translucent solid layers found between the element and the gradient (frosted
3254
+ // panels, glass washes) are composited over every stop, the same way
3255
+ // resolveBackground flattens them over a solid base — raw stops alone would
3256
+ // false-flag dark text on a light frosted wash over a dark gradient, and miss
3257
+ // the inverse.
2541
3258
  function resolveGradientStops(el, win, customPropMap) {
2542
3259
  let current = el;
3260
+ const overlays = [];
2543
3261
  while (current && current.nodeType === 1) {
2544
3262
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
2545
3263
  const bgImage = style.backgroundImage || '';
3264
+ // A url() layer anywhere in the stack — alone, or alongside a gradient in
3265
+ // the same declaration (a translucent wash over a texture photo) — paints
3266
+ // pixels the analytic walk cannot know. Measuring the gradient stops over
3267
+ // the wrong base flagged dark ink sitting on a bright gold-leaf image at
3268
+ // 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
3269
+ // image-backed text.
3270
+ if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
2546
3271
  let stops = null;
2547
3272
  if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
3273
+ // parseGradientColors (shared) reads modern-space stops too — oklch,
3274
+ // color-mix and friends via balanced-paren token capture — so browser
3275
+ // computed values that keep the authored syntax stay measurable.
2548
3276
  const parsed = parseGradientColors(bgImage);
2549
3277
  if (parsed.length > 0) stops = parsed;
2550
3278
  }
2551
3279
  if (!stops && !DETECTOR_IS_BROWSER) {
2552
- // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
3280
+ // Static mode: peek at the raw inline style for gradients the cascade did not surface
2553
3281
  const rawStyle = current.getAttribute?.('style') || '';
2554
3282
  const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
2555
3283
  if (bgMatch && /gradient/i.test(bgMatch[1])) {
@@ -2557,7 +3285,23 @@ function resolveGradientStops(el, win, customPropMap) {
2557
3285
  if (parsed.length > 0) stops = parsed;
2558
3286
  }
2559
3287
  }
2560
- if (stops) return compositeGradientStops(stops, current, win, customPropMap);
3288
+ if (stops) {
3289
+ const composited = compositeGradientStops(stops, current, win, customPropMap);
3290
+ if (!composited || overlays.length === 0) return composited;
3291
+ return composited.map(stop => {
3292
+ let acc = stop;
3293
+ for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
3294
+ return acc;
3295
+ });
3296
+ }
3297
+ const bg = readCascadeBackgroundColor(current, style, customPropMap);
3298
+ if (bg && bg.a > 0.1) {
3299
+ // An opaque surface above the gradient means the gradient never shows
3300
+ // through here; resolveBackground would have returned it, so reaching
3301
+ // this is defensive — bail rather than measure the wrong layer.
3302
+ if (bg.a >= 0.99) return null;
3303
+ overlays.push(bg);
3304
+ }
2561
3305
  current = current.parentElement;
2562
3306
  }
2563
3307
  return null;
@@ -2777,15 +3521,25 @@ function checkElementColorsDOM(el) {
2777
3521
  const rect = el.getBoundingClientRect();
2778
3522
  if (rect.width < 10 || rect.height < 10) return [];
2779
3523
  const style = getComputedStyle(el);
3524
+ // Invisible at rest: hidden scene variants (opacity-0 carousels, swap
3525
+ // decks) are not user-visible, and measuring their inherited colors against
3526
+ // whatever surface happens to sit behind the stack is noise, not audit.
3527
+ if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
2780
3528
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
2781
3529
  const hasDirectText = directText.trim().length > 0;
2782
- let effectiveBg = resolveBackground(el);
3530
+ const bgInfo = resolveBackgroundInfo(el);
3531
+ let effectiveBg = bgInfo.color;
3532
+ // An unreadable surface anywhere up the chain: skip the gradient-stop
3533
+ // fallback too, so nothing downstream measures against a ground we never
3534
+ // resolved.
3535
+ let surfaceUnresolved = bgInfo.unresolved;
2783
3536
  let ownBg = readOwnBackgroundColor(el, style);
2784
3537
  if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
2785
3538
  const pseudoSurface = readPseudoSurfaceDOM(el, rect);
2786
3539
  if (pseudoSurface) {
2787
3540
  ownBg = pseudoSurface;
2788
3541
  effectiveBg = pseudoSurface;
3542
+ surfaceUnresolved = false;
2789
3543
  }
2790
3544
  }
2791
3545
  return checkColors({
@@ -2797,8 +3551,8 @@ function checkElementColorsDOM(el) {
2797
3551
  // an oklch token near its own oklch background).
2798
3552
  textColor: parseRgb(style.color) || parseAnyColor(style.color),
2799
3553
  bgColor: ownBg,
2800
- effectiveBg,
2801
- effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
3554
+ effectiveBg: surfaceUnresolved ? null : effectiveBg,
3555
+ effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
2802
3556
  fontSize: parseFloat(style.fontSize) || 16,
2803
3557
  fontWeight: parseInt(style.fontWeight) || 400,
2804
3558
  hasDirectText,
@@ -2823,409 +3577,132 @@ function checkElementIconTileDOM(el) {
2823
3577
  // may contain an emoji/symbol character directly as its only text content
2824
3578
  // (the "card-icon" pattern from many AI-generated demos).
2825
3579
  const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
2826
- const iconRect = iconChild?.getBoundingClientRect();
2827
- const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
2828
- const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
2829
-
2830
- return checkIconTile({
2831
- headingTag: tag,
2832
- headingText: el.textContent || '',
2833
- headingTop: headRect.top,
2834
- siblingTag: sibling.tagName.toLowerCase(),
2835
- siblingWidth: sibRect.width,
2836
- siblingHeight: sibRect.height,
2837
- siblingBottom: sibRect.bottom,
2838
- siblingBgColor: parseRgb(sibStyle.backgroundColor),
2839
- siblingBgImage: sibStyle.backgroundImage || '',
2840
- siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
2841
- siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
2842
- hasIconChild: !!iconChild || hasInlineEmojiIcon,
2843
- iconChildWidth: iconRect?.width || 0,
2844
- });
2845
- }
2846
-
2847
- function checkElementItalicSerifDOM(el) {
2848
- const tag = el.tagName.toLowerCase();
2849
- if (tag !== 'h1' && tag !== 'h2') return [];
2850
- const style = getComputedStyle(el);
2851
- return checkItalicSerif({
2852
- tag,
2853
- fontStyle: style.fontStyle || '',
2854
- fontFamily: style.fontFamily || '',
2855
- fontSize: parseFloat(style.fontSize) || 0,
2856
- headingText: el.textContent || '',
2857
- });
2858
- }
2859
-
2860
- function domAccentDashPseudo(el) {
2861
- for (const which of ['::before', '::after']) {
2862
- let ps;
2863
- try { ps = getComputedStyle(el, which); } catch { continue; }
2864
- if (!ps || ps.content === 'none' || ps.content === '') continue;
2865
- const w = parseFloat(ps.width) || 0;
2866
- const h = parseFloat(ps.height) || 0;
2867
- if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
2868
- const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2869
- if (!bg || (bg.a ?? 1) < 0.1) continue;
2870
- if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
2871
- }
2872
- return false;
2873
- }
2874
-
2875
- function checkElementHeroEyebrowDOM(el) {
2876
- const tag = el.tagName.toLowerCase();
2877
- if (tag !== 'h1') return [];
2878
- const sibling = el.previousElementSibling;
2879
- if (!sibling) return [];
2880
- const headStyle = getComputedStyle(el);
2881
- const sibStyle = getComputedStyle(sibling);
2882
- return checkHeroEyebrow({
2883
- headingTag: tag,
2884
- headingText: el.textContent || '',
2885
- headingFontSize: parseFloat(headStyle.fontSize) || 0,
2886
- headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
2887
- siblingTag: sibling.tagName.toLowerCase(),
2888
- siblingText: sibling.textContent || '',
2889
- siblingTextTransform: sibStyle.textTransform || '',
2890
- siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
2891
- siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
2892
- siblingFontWeight: sibStyle.fontWeight || '',
2893
- siblingColor: sibStyle.color || '',
2894
- siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
2895
- });
2896
- }
2897
-
2898
- // Build a map of CSS custom properties declared on :root / :host / html.
2899
- // Used to resolve var(--X) refs that jsdom returns verbatim in
2900
- // getComputedStyle. Tailwind v4 routes every utility class through
2901
- // CSS vars (font-weight: var(--font-weight-bold), font-size:
2902
- // var(--text-xs), letter-spacing: var(--tracking-widest)), so without
2903
- // resolution every style-based check silently fails on Tailwind v4
2904
- // builds — the values come back as literal "var(--font-weight-bold)"
2905
- // strings and parseFloat returns NaN.
2906
- function buildCustomPropMap(document) {
2907
- const map = new Map();
2908
- let sheets;
2909
- try { sheets = Array.from(document.styleSheets || []); }
2910
- catch { return map; }
2911
- for (const sheet of sheets) {
2912
- let rules;
2913
- try { rules = Array.from(sheet.cssRules || []); }
2914
- catch { continue; }
2915
- for (const rule of rules) {
2916
- // Style rules only (type 1). Walk @media / @supports if present.
2917
- if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
2918
- try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
2919
- continue;
2920
- }
2921
- if (rule.type !== 1 /* STYLE_RULE */) continue;
2922
- const sel = rule.selectorText || '';
2923
- if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
2924
- const style = rule.style;
2925
- if (!style) continue;
2926
- for (let i = 0; i < style.length; i++) {
2927
- const prop = style[i];
2928
- if (!prop || !prop.startsWith('--')) continue;
2929
- const val = style.getPropertyValue(prop).trim();
2930
- if (val) map.set(prop, val);
2931
- }
2932
- }
2933
- }
2934
- return map;
2935
- }
2936
-
2937
- // Resolve var(--X[, fallback]) refs in a computed-style value string.
2938
- // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
2939
- // the original string when no refs are present or the chain doesn't
2940
- // resolve. Safe to call on already-resolved values.
2941
- function resolveVarRefs(raw, customPropMap, depth = 0) {
2942
- if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
2943
- if (depth > 8) return raw;
2944
- return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
2945
- const v = customPropMap.get(name);
2946
- if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
2947
- return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
2948
- });
2949
- }
2950
-
2951
- // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
2952
- // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
2953
- // Needed because jsdom doesn't compute oklch() values — getComputedStyle
2954
- // returns the literal "oklch(...)" string. Without this, the entire
2955
- // Tailwind v4 color palette (which is OKLCH-based) is invisible to the
2956
- // detector's contrast / color checks.
2957
- function oklchToRgb(L, C, H) {
2958
- const hRad = (H * Math.PI) / 180;
2959
- return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
2960
- }
2961
-
2962
- function oklabToRgb(L, a, b) {
2963
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
2964
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
2965
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
2966
- const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
2967
- const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
2968
- const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
2969
- const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
2970
- const enc = (x) => {
2971
- const c = Math.max(0, Math.min(1, x));
2972
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
2973
- };
2974
- return {
2975
- r: Math.round(enc(rLin) * 255),
2976
- g: Math.round(enc(gLin) * 255),
2977
- b: Math.round(enc(bLin) * 255),
2978
- a: 1,
2979
- };
2980
- }
2981
-
2982
- function hslToRgb(h, s, l) {
2983
- h = ((h % 360) + 360) % 360;
2984
- const c = (1 - Math.abs(2 * l - 1)) * s;
2985
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
2986
- const m0 = l - c / 2;
2987
- const [r, g, b] =
2988
- h < 60 ? [c, x, 0] :
2989
- h < 120 ? [x, c, 0] :
2990
- h < 180 ? [0, c, x] :
2991
- h < 240 ? [0, x, c] :
2992
- h < 300 ? [x, 0, c] : [c, 0, x];
2993
- return {
2994
- r: Math.round((r + m0) * 255),
2995
- g: Math.round((g + m0) * 255),
2996
- b: Math.round((b + m0) * 255),
2997
- a: 1,
2998
- };
2999
- }
3000
-
3001
- function hwbToRgb(h, w, bl) {
3002
- if (w + bl >= 1) {
3003
- const g = Math.round((w / (w + bl)) * 255);
3004
- return { r: g, g, b: g, a: 1 };
3005
- }
3006
- const base = hslToRgb(h, 1, 0.5);
3007
- const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
3008
- return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
3009
- }
3010
-
3011
- // Common CSS named colors — the handful that actually show up in generated
3012
- // UIs, not the full 148-name spec list. Includes the achromatic names so a
3013
- // named gray parses (and correctly reads as no-chroma) instead of being
3014
- // treated as an unknown color.
3015
- const CSS_NAMED_COLORS = {
3016
- black: { r: 0, g: 0, b: 0 },
3017
- white: { r: 255, g: 255, b: 255 },
3018
- gray: { r: 128, g: 128, b: 128 },
3019
- grey: { r: 128, g: 128, b: 128 },
3020
- silver: { r: 192, g: 192, b: 192 },
3021
- dimgray: { r: 105, g: 105, b: 105 },
3022
- darkgray: { r: 169, g: 169, b: 169 },
3023
- lightgray: { r: 211, g: 211, b: 211 },
3024
- gainsboro: { r: 220, g: 220, b: 220 },
3025
- whitesmoke: { r: 245, g: 245, b: 245 },
3026
- red: { r: 255, g: 0, b: 0 },
3027
- crimson: { r: 220, g: 20, b: 60 },
3028
- tomato: { r: 255, g: 99, b: 71 },
3029
- coral: { r: 255, g: 127, b: 80 },
3030
- salmon: { r: 250, g: 128, b: 114 },
3031
- orange: { r: 255, g: 165, b: 0 },
3032
- gold: { r: 255, g: 215, b: 0 },
3033
- yellow: { r: 255, g: 255, b: 0 },
3034
- olive: { r: 128, g: 128, b: 0 },
3035
- lime: { r: 0, g: 255, b: 0 },
3036
- green: { r: 0, g: 128, b: 0 },
3037
- teal: { r: 0, g: 128, b: 128 },
3038
- turquoise: { r: 64, g: 224, b: 208 },
3039
- cyan: { r: 0, g: 255, b: 255 },
3040
- aqua: { r: 0, g: 255, b: 255 },
3041
- skyblue: { r: 135, g: 206, b: 235 },
3042
- dodgerblue: { r: 30, g: 144, b: 255 },
3043
- blue: { r: 0, g: 0, b: 255 },
3044
- navy: { r: 0, g: 0, b: 128 },
3045
- indigo: { r: 75, g: 0, b: 130 },
3046
- rebeccapurple: { r: 102, g: 51, b: 153 },
3047
- purple: { r: 128, g: 0, b: 128 },
3048
- violet: { r: 238, g: 130, b: 238 },
3049
- orchid: { r: 218, g: 112, b: 214 },
3050
- magenta: { r: 255, g: 0, b: 255 },
3051
- fuchsia: { r: 255, g: 0, b: 255 },
3052
- hotpink: { r: 255, g: 105, b: 180 },
3053
- pink: { r: 255, g: 192, b: 203 },
3054
- maroon: { r: 128, g: 0, b: 0 },
3055
- };
3056
-
3057
- // Split a string on top-level commas (ignoring commas nested in parens).
3058
- function splitTopLevelCommas(str) {
3059
- const parts = [];
3060
- let depth = 0, start = 0;
3061
- for (let i = 0; i < str.length; i++) {
3062
- const ch = str[i];
3063
- if (ch === '(') depth++;
3064
- else if (ch === ')') depth = Math.max(0, depth - 1);
3065
- else if (ch === ',' && depth === 0) {
3066
- parts.push(str.slice(start, i).trim());
3067
- start = i + 1;
3068
- }
3069
- }
3070
- const tail = str.slice(start).trim();
3071
- if (tail) parts.push(tail);
3072
- return parts;
3073
- }
3074
-
3075
- // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
3076
- // the expression can't be resolved (unresolved var(), unknown colors).
3077
- //
3078
- // Mixing is done with premultiplied alpha in sRGB regardless of the
3079
- // declared interpolation space. That is exact for the dominant generated-UI
3080
- // pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
3081
- // result is simply <color> at alpha N% in ANY rectangular space, and a
3082
- // close-enough approximation for opaque-opaque mixes (the detector only
3083
- // consumes these values for contrast/chroma thresholds, not for display).
3084
- function parseColorMix(str) {
3085
- const m = String(str).trim().match(/^color-mix\(/i);
3086
- if (!m) return null;
3087
- // Balanced-paren capture of the arguments.
3088
- let depth = 0, end = -1;
3089
- const open = str.indexOf('(');
3090
- for (let i = open; i < str.length; i++) {
3091
- if (str[i] === '(') depth++;
3092
- else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
3093
- }
3094
- if (end < 0) return null;
3095
- const args = splitTopLevelCommas(str.slice(open + 1, end));
3096
- if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
3097
-
3098
- const parseComponent = (component) => {
3099
- // Percentage may lead or trail the color per spec.
3100
- let pct = null;
3101
- let colorStr = component;
3102
- const trail = component.match(/\s+([\d.]+)%$/);
3103
- const lead = component.match(/^([\d.]+)%\s+/);
3104
- if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
3105
- else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
3106
- let color;
3107
- if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
3108
- else color = parseAnyColor(colorStr);
3109
- if (!color) return null;
3110
- return { color, pct };
3111
- };
3580
+ const iconRect = iconChild?.getBoundingClientRect();
3581
+ const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
3582
+ const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
3112
3583
 
3113
- const c1 = parseComponent(args[1]);
3114
- const c2 = parseComponent(args[2]);
3115
- if (!c1 || !c2) return null;
3116
- let p1 = c1.pct, p2 = c2.pct;
3117
- if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
3118
- else if (p1 == null) p1 = 100 - p2;
3119
- else if (p2 == null) p2 = 100 - p1;
3120
- const sum = p1 + p2;
3121
- if (sum <= 0) return null;
3122
- // Per spec: weights normalize to sum; when sum < 100 the result alpha is
3123
- // additionally scaled by sum/100.
3124
- const w1 = p1 / sum, w2 = p2 / sum;
3125
- const alphaScale = sum < 100 ? sum / 100 : 1;
3126
- const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
3127
- const a = (a1 * w1 + a2 * w2) * alphaScale;
3128
- if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
3129
- const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
3130
- return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
3584
+ return checkIconTile({
3585
+ headingTag: tag,
3586
+ headingText: el.textContent || '',
3587
+ headingTop: headRect.top,
3588
+ siblingTag: sibling.tagName.toLowerCase(),
3589
+ siblingWidth: sibRect.width,
3590
+ siblingHeight: sibRect.height,
3591
+ siblingBottom: sibRect.bottom,
3592
+ siblingBgColor: parseRgb(sibStyle.backgroundColor),
3593
+ siblingBgImage: sibStyle.backgroundImage || '',
3594
+ siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
3595
+ siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
3596
+ hasIconChild: !!iconChild || hasInlineEmojiIcon,
3597
+ iconChildWidth: iconRect?.width || 0,
3598
+ });
3131
3599
  }
3132
3600
 
3133
- // Composite a translucent color over an opaque(ish) base (simple
3134
- // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
3135
- function compositeColorOver(top, base) {
3136
- const a = top.a ?? 1;
3137
- return {
3138
- r: Math.round(top.r * a + base.r * (1 - a)),
3139
- g: Math.round(top.g * a + base.g * (1 - a)),
3140
- b: Math.round(top.b * a + base.b * (1 - a)),
3141
- a: 1,
3142
- };
3601
+ function checkElementItalicSerifDOM(el) {
3602
+ const tag = el.tagName.toLowerCase();
3603
+ if (tag !== 'h1' && tag !== 'h2') return [];
3604
+ const style = getComputedStyle(el);
3605
+ return checkItalicSerif({
3606
+ tag,
3607
+ fontStyle: style.fontStyle || '',
3608
+ fontFamily: style.fontFamily || '',
3609
+ fontSize: parseFloat(style.fontSize) || 0,
3610
+ headingText: el.textContent || '',
3611
+ });
3143
3612
  }
3144
3613
 
3145
- // Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
3146
- // named colors. Returns null on no match. Use this when the input might be
3147
- // any CSS color form; use plain parseRgb when you only expect computed rgb()
3148
- // values from real browsers.
3149
- function parseAnyColor(s) {
3150
- if (!s || typeof s !== 'string') return null;
3151
- const str = s.trim();
3152
- if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
3153
- if (/^color-mix\(/i.test(str)) return parseColorMix(str);
3154
- let m;
3155
- m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
3156
- 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 };
3157
- m = str.match(/^#([0-9a-f]{3,8})$/i);
3158
- if (m) {
3159
- const h = m[1];
3160
- if (h.length === 3 || h.length === 4) {
3161
- return {
3162
- r: parseInt(h[0] + h[0], 16),
3163
- g: parseInt(h[1] + h[1], 16),
3164
- b: parseInt(h[2] + h[2], 16),
3165
- a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
3166
- };
3167
- }
3168
- if (h.length === 6 || h.length === 8) {
3169
- return {
3170
- r: parseInt(h.slice(0, 2), 16),
3171
- g: parseInt(h.slice(2, 4), 16),
3172
- b: parseInt(h.slice(4, 6), 16),
3173
- a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
3174
- };
3175
- }
3176
- }
3177
- // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
3178
- // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
3179
- // Match L (with optional %), then C and H separated permissively.
3180
- m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3181
- if (m) {
3182
- const Lnum = parseFloat(m[1]);
3183
- const L = m[2] === '%' ? Lnum / 100 : Lnum;
3184
- const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
3185
- if (m[5] !== undefined) {
3186
- const alpha = parseFloat(m[5]);
3187
- rgb.a = m[6] === '%' ? alpha / 100 : alpha;
3188
- }
3189
- return rgb;
3190
- }
3191
- // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
3192
- m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3193
- if (m) {
3194
- const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
3195
- const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
3196
- const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
3197
- const rgb = oklabToRgb(L, a, b);
3198
- if (m[7] !== undefined) {
3199
- const alpha = parseFloat(m[7]);
3200
- rgb.a = m[8] === '%' ? alpha / 100 : alpha;
3201
- }
3202
- return rgb;
3203
- }
3204
- // HSL/HSLA — comma or space syntax, optional deg on hue.
3205
- m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
3206
- if (m) {
3207
- const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3208
- if (m[4] !== undefined) {
3209
- const alpha = parseFloat(m[4]);
3210
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3211
- }
3212
- return rgb;
3614
+ function domAccentDashPseudo(el) {
3615
+ for (const which of ['::before', '::after']) {
3616
+ let ps;
3617
+ try { ps = getComputedStyle(el, which); } catch { continue; }
3618
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
3619
+ const w = parseFloat(ps.width) || 0;
3620
+ const h = parseFloat(ps.height) || 0;
3621
+ if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
3622
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
3623
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
3624
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
3213
3625
  }
3214
- // HWB — hue whiteness% blackness%.
3215
- m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3216
- if (m) {
3217
- const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3218
- if (m[4] !== undefined) {
3219
- const alpha = parseFloat(m[4]);
3220
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3626
+ return false;
3627
+ }
3628
+
3629
+ function checkElementHeroEyebrowDOM(el) {
3630
+ const tag = el.tagName.toLowerCase();
3631
+ if (tag !== 'h1') return [];
3632
+ const sibling = el.previousElementSibling;
3633
+ if (!sibling) return [];
3634
+ const headStyle = getComputedStyle(el);
3635
+ const sibStyle = getComputedStyle(sibling);
3636
+ return checkHeroEyebrow({
3637
+ headingTag: tag,
3638
+ headingText: el.textContent || '',
3639
+ headingFontSize: parseFloat(headStyle.fontSize) || 0,
3640
+ headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
3641
+ siblingTag: sibling.tagName.toLowerCase(),
3642
+ siblingText: sibling.textContent || '',
3643
+ siblingTextTransform: sibStyle.textTransform || '',
3644
+ siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
3645
+ siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
3646
+ siblingFontWeight: sibStyle.fontWeight || '',
3647
+ siblingColor: sibStyle.color || '',
3648
+ siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
3649
+ });
3650
+ }
3651
+
3652
+ // Build a map of CSS custom properties declared on :root / :host / html.
3653
+ // Used to resolve var(--X) refs that jsdom returns verbatim in
3654
+ // getComputedStyle. Tailwind v4 routes every utility class through
3655
+ // CSS vars (font-weight: var(--font-weight-bold), font-size:
3656
+ // var(--text-xs), letter-spacing: var(--tracking-widest)), so without
3657
+ // resolution every style-based check silently fails on Tailwind v4
3658
+ // builds — the values come back as literal "var(--font-weight-bold)"
3659
+ // strings and parseFloat returns NaN.
3660
+ function buildCustomPropMap(document) {
3661
+ const map = new Map();
3662
+ let sheets;
3663
+ try { sheets = Array.from(document.styleSheets || []); }
3664
+ catch { return map; }
3665
+ for (const sheet of sheets) {
3666
+ let rules;
3667
+ try { rules = Array.from(sheet.cssRules || []); }
3668
+ catch { continue; }
3669
+ for (const rule of rules) {
3670
+ // Style rules only (type 1). Walk @media / @supports if present.
3671
+ if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
3672
+ try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
3673
+ continue;
3674
+ }
3675
+ if (rule.type !== 1 /* STYLE_RULE */) continue;
3676
+ const sel = rule.selectorText || '';
3677
+ if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
3678
+ const style = rule.style;
3679
+ if (!style) continue;
3680
+ for (let i = 0; i < style.length; i++) {
3681
+ const prop = style[i];
3682
+ if (!prop || !prop.startsWith('--')) continue;
3683
+ const val = style.getPropertyValue(prop).trim();
3684
+ if (val) map.set(prop, val);
3685
+ }
3221
3686
  }
3222
- return rgb;
3223
3687
  }
3224
- const named = CSS_NAMED_COLORS[str.toLowerCase()];
3225
- if (named) return { ...named, a: 1 };
3226
- return null;
3688
+ return map;
3689
+ }
3690
+
3691
+ // Resolve var(--X[, fallback]) refs in a computed-style value string.
3692
+ // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
3693
+ // the original string when no refs are present or the chain doesn't
3694
+ // resolve. Safe to call on already-resolved values.
3695
+ function resolveVarRefs(raw, customPropMap, depth = 0) {
3696
+ if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
3697
+ if (depth > 8) return raw;
3698
+ return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
3699
+ const v = customPropMap.get(name);
3700
+ if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
3701
+ return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
3702
+ });
3227
3703
  }
3228
3704
 
3705
+
3229
3706
  // Resolve var() refs in a color string (via customPropMap), then parse.
3230
3707
  // Returns null on any failure. Used in jsdom-mode paths where
3231
3708
  // getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
@@ -3587,9 +4064,20 @@ function checkElementGlowDOM(el) {
3587
4064
  if (!boxShadow && !textShadow) return [];
3588
4065
  // Use parent's background — glow radiates outward, so the surrounding context matters
3589
4066
  // If resolveBackground returns null (gradient), try to infer from the gradient colors
3590
- let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
3591
- if (!parentBg) {
3592
- // Gradient background sample its colors to determine if it's dark
4067
+ const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
4068
+ // Unknown surface (an unreadable layer on the way up): skip only the
4069
+ // gradient hunt below, which would walk PAST that layer and score the
4070
+ // glow against a background the visitor never sees. checkGlow still runs
4071
+ // with a null surface: the zero-offset chromatic halo tell holds on ANY
4072
+ // background, and the static loop already passes the unresolved walk's
4073
+ // null color straight through (detect-html.mjs uses resolveBackground).
4074
+ let parentBg = parentBgInfo.color;
4075
+ if (!parentBg && !parentBgInfo.unresolved) {
4076
+ // Gradient background — sample its colors to determine if it's dark.
4077
+ // Modern-syntax parsing matters here: body-level gradients now reach this
4078
+ // fallback in browser mode, and their stops usually serialize as oklch —
4079
+ // which the shared parseGradientColors reads via its color-function
4080
+ // token capture.
3593
4081
  let cur = el.parentElement;
3594
4082
  while (cur && cur.nodeType === 1) {
3595
4083
  const bgImage = getComputedStyle(cur).backgroundImage || '';
@@ -3632,15 +4120,18 @@ function checkElementAIPaletteDOM(el) {
3632
4120
  }
3633
4121
 
3634
4122
  // Check for neon text (vivid cyan/purple color on dark background)
3635
- const textColor = parseRgb(style.color);
4123
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color);
3636
4124
  if (textColor && hasChroma(textColor, 80)) {
3637
4125
  const hue = getHue(textColor);
3638
4126
  const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
3639
4127
  if (isAIPalette) {
3640
- const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
3641
- // Also check gradient parents
3642
- let effectiveBg = parentBg;
3643
- if (!effectiveBg) {
4128
+ const parentBgInfo = el.parentElement
4129
+ ? resolveBackgroundInfo(el.parentElement)
4130
+ : { color: null, unresolved: false };
4131
+ // Unknown surface: leave effectiveBg null (no finding) rather than
4132
+ // hunting gradient ancestors past a layer we could not read.
4133
+ let effectiveBg = parentBgInfo.color;
4134
+ if (!effectiveBg && !parentBgInfo.unresolved) {
3644
4135
  let cur = el.parentElement;
3645
4136
  while (cur && cur.nodeType === 1) {
3646
4137
  const gi = getComputedStyle(cur).backgroundImage || '';
@@ -3972,14 +4463,30 @@ function isNonRenderedText(el, tag, style) {
3972
4463
  function checkQuality(opts) {
3973
4464
  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
3974
4465
  const findings = [];
3975
- // Skip browser extension injected elements. Read the id via getAttribute
3976
- // whenever `el.id` is not a string: on a <form> (and other
3977
- // [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
3978
- // shadows the builtin `id` getter and returns the control element, whose
3979
- // `.startsWith` is undefined and throws (issue #407 every Shopify product
3980
- // form ships an <input name="id">).
4466
+ // A raster (<img>, or an element with a background url) at near-zero
4467
+ // opacity never reaches the screen: the produced material ships as a
4468
+ // compliance token. The CSS-text scan catches the stylesheet form; this
4469
+ // catches computed opacity on the element itself (both engines).
4470
+ // Skip browser extension injected elements BEFORE any finding is pushed
4471
+ // (a low-opacity raster those hosts inject used to be recorded and then
4472
+ // returned by this very skip). Read the id via getAttribute whenever
4473
+ // `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
4474
+ // hosts) a named control like <input name="id"> shadows the builtin `id`
4475
+ // getter and returns the control element, whose `.startsWith` is undefined
4476
+ // and throws (issue #407 — every Shopify product form ships an
4477
+ // <input name="id">).
3981
4478
  const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
3982
4479
  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
4480
+ {
4481
+ const op = parseFloat(style.opacity);
4482
+ if (Number.isFinite(op) && op < 0.15 && op >= 0) {
4483
+ const bg = String(style.backgroundImage || '');
4484
+ if (tag === 'img' || /url\(/i.test(bg)) {
4485
+ const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
4486
+ findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
4487
+ }
4488
+ }
4489
+ }
3983
4490
 
3984
4491
  // --- Line length too long --- (browser-only: needs rect.width)
3985
4492
  if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
@@ -4435,10 +4942,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
4435
4942
  }
4436
4943
 
4437
4944
  function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
4945
+ // Invisible at rest, static twin of the browser walk's skip: opacity does
4946
+ // not inherit, so walk ancestors multiplying declared opacity down.
4947
+ if (style.visibility === 'hidden') return [];
4948
+ let effOpacity = 1;
4949
+ for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
4950
+ effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
4951
+ }
4952
+ if (effOpacity <= 0.02) return [];
4438
4953
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
4439
4954
  const hasDirectText = directText.trim().length > 0;
4440
4955
 
4441
- const effectiveBg = resolveBackground(el, window, customPropMap);
4956
+ const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
4957
+ const effectiveBg = bgInfo.color;
4442
4958
  // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
4443
4959
  // parseRgb misses Tailwind-tokenized text colors. Resolve through the
4444
4960
  // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -4484,11 +5000,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
4484
5000
  // element itself has no usable own background, that pseudo is the real
4485
5001
  // surface for contrast purposes.
4486
5002
  let finalEffectiveBg = effectiveBg;
5003
+ let surfaceUnresolved = bgInfo.unresolved;
4487
5004
  if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
4488
5005
  const pseudoSurface = window.getPseudoSurface(el);
4489
5006
  if (pseudoSurface) {
4490
5007
  ownBg = pseudoSurface;
4491
5008
  finalEffectiveBg = pseudoSurface;
5009
+ surfaceUnresolved = false;
4492
5010
  }
4493
5011
  }
4494
5012
 
@@ -4496,8 +5014,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
4496
5014
  tag,
4497
5015
  textColor,
4498
5016
  bgColor: ownBg,
4499
- effectiveBg: finalEffectiveBg,
4500
- effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
5017
+ // Unknown surface: hand the checks nothing rather than a guess.
5018
+ effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
5019
+ effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
4501
5020
  fontSize: parseFloat(style.fontSize) || 16,
4502
5021
  fontWeight: parseInt(style.fontWeight) || 400,
4503
5022
  hasDirectText,
@@ -4685,8 +5204,7 @@ function checkTypography() {
4685
5204
  const style = getComputedStyle(el);
4686
5205
  const ff = style.fontFamily;
4687
5206
  if (!ff) continue;
4688
- const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
4689
- const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
5207
+ const primary = primaryFontFace(ff);
4690
5208
  if (!primary) continue;
4691
5209
  fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
4692
5210
  totalTextElements++;
@@ -4931,8 +5449,7 @@ function checkPageTypography(doc, win) {
4931
5449
  if (rule.type !== 1) continue;
4932
5450
  const ff = rule.style?.fontFamily;
4933
5451
  if (!ff) continue;
4934
- const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
4935
- const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
5452
+ const primary = primaryFontFace(ff);
4936
5453
  if (primary) {
4937
5454
  fonts.add(primary);
4938
5455
  if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
@@ -4951,11 +5468,10 @@ function checkPageTypography(doc, win) {
4951
5468
  const ffRe = /font-family\s*:\s*([^;}]+)/gi;
4952
5469
  let fm;
4953
5470
  while ((fm = ffRe.exec(html)) !== null) {
4954
- for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
4955
- if (f && !GENERIC_FONTS.has(f)) {
4956
- fonts.add(f);
4957
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
4958
- }
5471
+ const primary = primaryFontFace(fm[1]);
5472
+ if (primary) {
5473
+ fonts.add(primary);
5474
+ if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
4959
5475
  }
4960
5476
  }
4961
5477
 
@@ -5593,6 +6109,11 @@ function isRenderedForBrowserRule(el) {
5593
6109
  function checkElementTextOverflowDOM(el) {
5594
6110
  const tag = el.tagName.toLowerCase();
5595
6111
  if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
6112
+ // scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
6113
+ // returns arbitrary non-zero values for both (a <text> reported 78/48 while
6114
+ // its rendered length sat comfortably inside its box), so the delta is
6115
+ // noise, not overflow. SVG clips to its own viewport anyway.
6116
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
5596
6117
  if (!isRenderedForBrowserRule(el)) return [];
5597
6118
  // Only the element that actually owns overflowing text — not its ancestors,
5598
6119
  // which inherit a wider scrollWidth from the spilling descendant.
@@ -5977,6 +6498,22 @@ function isPaintedForOcclusion(el) {
5977
6498
  // path is pure geometry and runs anywhere on the page.
5978
6499
  const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
5979
6500
 
6501
+ // An element whose effective opacity multiplies out to ~0 paints nothing at
6502
+ // rest: it is not user-visible, so visual findings on it (contrast, occlusion)
6503
+ // measure a state nobody sees. Browser-only — the walk needs live computed
6504
+ // styles. Cycling scenes that fade such elements in later are the screenshot
6505
+ // subsystem's territory, not the analytic walk's.
6506
+ function effectiveOpacityDOM(el) {
6507
+ let o = 1;
6508
+ // Walk all the way through body and html: `body { opacity: 0 }` page-fade
6509
+ // wrappers hide every descendant just as thoroughly as a local wrapper.
6510
+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
6511
+ o *= parseFloat(getComputedStyle(cur).opacity || '1');
6512
+ if (o <= 0.02) return 0;
6513
+ }
6514
+ return o;
6515
+ }
6516
+
5980
6517
  function checkTextOcclusionDOM() {
5981
6518
  const findings = [];
5982
6519
  const seenVictims = new Set();
@@ -6004,6 +6541,41 @@ function checkTextOcclusionDOM() {
6004
6541
  }
6005
6542
  return false;
6006
6543
  };
6544
+ // The classic occluder shape this rules out is an opacity-0 interaction
6545
+ // layer — a range scrubber stretched over a before/after comparison — which
6546
+ // elementFromPoint still returns and whose UA background-color otherwise
6547
+ // reads as an opaque box.
6548
+ const effectiveOpacity = effectiveOpacityDOM;
6549
+
6550
+ // The part of an element that is actually painted, after every scrolling or
6551
+ // clipping ancestor has had its say.
6552
+ //
6553
+ // getBoundingClientRect reports where a box would be if nothing cut it off,
6554
+ // so a paragraph half scrolled out of a panel still reports its full height,
6555
+ // and the half that is clipped away lands wherever the page continues below
6556
+ // the panel. The elementFromPoint probe then samples coordinates the text is
6557
+ // not painted at, finds whatever genuinely is painted there, and reports the
6558
+ // text as buried under it. Any sticky footer or toolbar beneath a scroll
6559
+ // region produces this, and it is the shape most likely to be waved off as
6560
+ // noise, which costs the rule its credibility on the findings that are real.
6561
+ //
6562
+ // Border box rather than padding box on purpose: it errs toward probing, and
6563
+ // giving up a scrollbar gutter's width would drop true findings at the right
6564
+ // edge of a scroller.
6565
+ const paintedRect = (el, rect) => {
6566
+ let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
6567
+ for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
6568
+ let cs; try { cs = getComputedStyle(cur); } catch { continue; }
6569
+ const clipsX = String(cs.overflowX || 'visible') !== 'visible';
6570
+ const clipsY = String(cs.overflowY || 'visible') !== 'visible';
6571
+ if (!clipsX && !clipsY) continue;
6572
+ let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
6573
+ if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
6574
+ if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
6575
+ if (right - left < 1 || bottom - top < 1) return null;
6576
+ }
6577
+ return { left, top, right, bottom, width: right - left, height: bottom - top };
6578
+ };
6007
6579
 
6008
6580
  // Collect renderable text owners in / near the first viewport for the
6009
6581
  // elementFromPoint probe. SVG <text> counts too.
@@ -6016,8 +6588,14 @@ function checkTextOcclusionDOM() {
6016
6588
  const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
6017
6589
  if (text.length < 2) continue;
6018
6590
  if (!isPaintedForOcclusion(el)) continue;
6019
- let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
6020
- if (rect.width < 6 || rect.height < 6) continue;
6591
+ if (effectiveOpacity(el) <= 0.02) continue;
6592
+ let full; try { full = el.getBoundingClientRect(); } catch { continue; }
6593
+ if (full.width < 6 || full.height < 6) continue;
6594
+ // Probe only where the text is on screen. A run clipped down to a sliver is
6595
+ // dropped rather than sampled: a few pixels of visible text cannot support
6596
+ // a coverage fraction worth reporting either way.
6597
+ const rect = paintedRect(el, full);
6598
+ if (!rect || rect.width < 6 || rect.height < 6) continue;
6021
6599
  // Viewport-bound probe: keep text whose box overlaps the live viewport.
6022
6600
  if (rect.bottom <= 0 || rect.top >= vh) continue;
6023
6601
  textEls.push({ el, rect, text, inSvg });
@@ -6049,6 +6627,7 @@ function checkTextOcclusionDOM() {
6049
6627
  if (top === el || el.contains(top) || top.contains(el)) continue;
6050
6628
  const topCs = getComputedStyle(top);
6051
6629
  if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
6630
+ if (effectiveOpacity(top) <= 0.02) continue;
6052
6631
  const topTag = top.tagName.toLowerCase();
6053
6632
  // Text sitting under a raw image/video is contrast territory (deduped
6054
6633
  // against the pixel low-contrast rule); leave those alone here.
@@ -6884,7 +7463,7 @@ if (IS_BROWSER) {
6884
7463
  if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
6885
7464
  if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
6886
7465
 
6887
- const solidBg = parseRgb(currentStyle.backgroundColor);
7466
+ const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
6888
7467
  if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
6889
7468
  current = current.parentElement;
6890
7469
  }
@@ -6941,8 +7520,12 @@ if (IS_BROWSER) {
6941
7520
 
6942
7521
  const reasons = collectVisualContrastReasons(el, style);
6943
7522
  if (reasons.length === 0) continue;
7523
+ // Image-only mode filters here, inside the cap: gradient/opacity/filter
7524
+ // candidates earlier in DOM order must not consume the budget and
7525
+ // starve the url()-backed texts this mode exists to sample.
7526
+ if (options.imageOnly && !reasons.includes('image background')) continue;
6944
7527
 
6945
- const textColor = parseRgb(style.color);
7528
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color);
6946
7529
  const fontSize = parseFloat(style.fontSize) || 16;
6947
7530
  const fontWeight = parseInt(style.fontWeight) || 400;
6948
7531
  const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7239,7 +7822,7 @@ if (IS_BROWSER) {
7239
7822
  return sample;
7240
7823
  }
7241
7824
  }
7242
- const bg = parseRgb(style.backgroundColor);
7825
+ const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
7243
7826
  if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
7244
7827
  return { status: 'unresolved', reason: 'no readable background' };
7245
7828
  }
@@ -7369,7 +7952,7 @@ if (IS_BROWSER) {
7369
7952
  }
7370
7953
 
7371
7954
  const style = getComputedStyle(el);
7372
- const textColor = parseRgb(style.color) || candidate.textColor;
7955
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
7373
7956
  if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
7374
7957
 
7375
7958
  const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -7433,6 +8016,7 @@ if (IS_BROWSER) {
7433
8016
  }
7434
8017
 
7435
8018
  async function analyzeVisualContrast(options = {}) {
8019
+ // imageOnly is enforced inside the collector, before the candidate cap.
7436
8020
  const candidates = collectVisualContrastCandidates(options);
7437
8021
  const results = [];
7438
8022
  const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -7518,9 +8102,16 @@ if (IS_BROWSER) {
7518
8102
 
7519
8103
  function addBrowserFindings(groupMap, el, findings) {
7520
8104
  if (!findings || findings.length === 0) return;
8105
+ // Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
8106
+ // matching findings for its whole subtree. Applied at this choke point so
8107
+ // every per-element attribution (checks, layout, occlusion, rhythm)
8108
+ // honors it; page-level findings attributed to <body> pass through
8109
+ // untouched, since body has no ignoring ancestor.
8110
+ const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
8111
+ if (kept.length === 0) return;
7521
8112
  const existing = groupMap.get(el);
7522
- if (existing) existing.push(...findings);
7523
- else groupMap.set(el, [...findings]);
8113
+ if (existing) existing.push(...kept);
8114
+ else groupMap.set(el, [...kept]);
7524
8115
  }
7525
8116
 
7526
8117
  function browserFindingsFromMap(groupMap) {
@@ -7718,7 +8309,19 @@ if (IS_BROWSER) {
7718
8309
  return findings;
7719
8310
  }
7720
8311
 
8312
+ // A page matched by detector.ignoreFiles is waived wholesale: every scan
8313
+ // stage answers empty so the badge and toast read zero. Mirrors
8314
+ // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
8315
+ // overlay resolves the globs per page (live-browser-ignores.js) and
8316
+ // forwards the verdict as config.skipScan.
8317
+ function skipScanActive() {
8318
+ return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
8319
+ }
8320
+
7721
8321
  function collectBrowserFindings() {
8322
+ if (skipScanActive()) {
8323
+ return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
8324
+ }
7722
8325
  const groupMap = new Map();
7723
8326
  const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
7724
8327
  const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
@@ -7878,9 +8481,27 @@ if (IS_BROWSER) {
7878
8481
  for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
7879
8482
  node.remove();
7880
8483
  }
7881
- const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
7882
- if (htmlPatternFindings.length > 0) {
7883
- const mapped = htmlPatternFindings.map(f => {
8484
+ // Regex findings that name a live selector resolve against the real DOM:
8485
+ // pseudo-element/class segments are stripped (the host element is the
8486
+ // anchor), a selector that matches nothing on this page drops the finding
8487
+ // (the CSS ships here, but the pattern never renders — the live DOM is
8488
+ // ground truth in the browser), and a match under a data-impeccable-ignore
8489
+ // ancestor is waived. Selector-less findings stay page-level.
8490
+ const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
8491
+ if (!f.selector) return true;
8492
+ const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
8493
+ if (!query || /^[,\s]*$/.test(query)) return true;
8494
+ let matches;
8495
+ try {
8496
+ matches = document.querySelectorAll(query);
8497
+ } catch {
8498
+ return true;
8499
+ }
8500
+ if (matches.length === 0) return false;
8501
+ return [...matches].some(el => !scopedIgnoreActive(el, f.id));
8502
+ });
8503
+ if (scopedHtmlFindings.length > 0) {
8504
+ const mapped = scopedHtmlFindings.map(f => {
7884
8505
  const item = { type: f.id, detail: f.snippet };
7885
8506
  if (f.severity) {
7886
8507
  item.severity = f.severity;
@@ -7903,6 +8524,119 @@ if (IS_BROWSER) {
7903
8524
  addBrowserFindings(groupMap, document.body, mapped);
7904
8525
  }
7905
8526
 
8527
+ // Value-level suppression (issue #639). `disabledRules` above handles
8528
+ // whole rules; this applies the config's remaining ignoreValues entries,
8529
+ // which the CLI filters through isIgnoredFindingValue in
8530
+ // cli/lib/impeccable-config.mjs, so a project waiver like
8531
+ // overused-font = "geist mono" reaches the overlay and extension too.
8532
+ const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
8533
+ .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
8534
+ const _disabledValues = EXTENSION_MODE
8535
+ ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
8536
+ .filter(e => e && typeof e === 'object' && e.rule && e.value)
8537
+ .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
8538
+ : [];
8539
+ if (_disabledValues.length > 0) {
8540
+ // The six rules whose findings carry a matchable value; keep in step
8541
+ // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
8542
+ // Everything else is suppressed by rule or by file scope, both already
8543
+ // resolved into disabledRules before the scan message was sent.
8544
+ const _directValueRules = new Set([
8545
+ 'overused-font',
8546
+ 'bounce-easing',
8547
+ 'design-system-font',
8548
+ 'design-system-color',
8549
+ 'design-system-radius',
8550
+ 'design-system-font-size',
8551
+ ]);
8552
+ // The design-system checks set `ignoreValue` on their findings; the
8553
+ // detail fallbacks catch overused-font, whose value lives in its
8554
+ // sentence. One CLI matcher is not mirrored here: the motion extractor
8555
+ // (a value-scoped bounce-easing waiver only matches when the finding
8556
+ // carries ignoreValue directly). The CLI's [?&]family= URL fallback is
8557
+ // also omitted on purpose: browser findings for these rules always
8558
+ // carry ignoreValue or a "Primary font:" / "Google Fonts:" /
8559
+ // font-family sentence, so it is unreachable here.
8560
+ const _findingValue = (f) => {
8561
+ if (!f || !_directValueRules.has(f.type || f.id)) return '';
8562
+ const direct = f.ignoreValue || f.value;
8563
+ if (direct) return _normValue(direct);
8564
+ // The CLI routes bounce-easing through extractMotionIgnoreValue and
8565
+ // never the font regexes; without a direct ignoreValue there is no
8566
+ // value to match, so do not invent one from unrelated CSS text.
8567
+ if ((f.type || f.id) === 'bounce-easing') return '';
8568
+ for (const text of [f.detail, f.snippet]) {
8569
+ if (typeof text !== 'string' || !text) continue;
8570
+ const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
8571
+ if (primary) return _normValue(primary[1]);
8572
+ const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
8573
+ if (google) return _normValue(google[1]);
8574
+ const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
8575
+ if (family) return _normValue(family[1]);
8576
+ }
8577
+ return '';
8578
+ };
8579
+ // design-system-color compares by color value, not by spelling: the
8580
+ // browser reports computed rgb(...) strings while waivers are usually
8581
+ // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
8582
+ // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
8583
+ // hsl stays CLI-only.
8584
+ const _colorKey = (value) => {
8585
+ const text = String(value || '').trim().toLowerCase();
8586
+ const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
8587
+ if (hex) {
8588
+ const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
8589
+ const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
8590
+ return `${r},${g},${b},${a}`;
8591
+ }
8592
+ const rgb = text.match(/^rgba?\((.*)\)$/);
8593
+ if (!rgb) return '';
8594
+ const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
8595
+ let parts;
8596
+ if (body.includes(',')) {
8597
+ parts = body.split(',').map(p => p.trim()).filter(Boolean);
8598
+ const last = parts[parts.length - 1];
8599
+ if (last && last.includes('/')) {
8600
+ parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
8601
+ }
8602
+ } else {
8603
+ parts = body.split(/\s+/).filter(p => p && p !== '/');
8604
+ }
8605
+ if (parts.length < 3 || parts.length > 4) return '';
8606
+ const channel = (raw, isAlpha) => {
8607
+ const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
8608
+ if (!m) return null;
8609
+ let v = parseFloat(m[1]);
8610
+ if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
8611
+ const max = isAlpha ? 1 : 255;
8612
+ if (!Number.isFinite(v) || v < 0 || v > max) return null;
8613
+ return isAlpha ? v : Math.round(v);
8614
+ };
8615
+ const r = channel(parts[0], false);
8616
+ const g = channel(parts[1], false);
8617
+ const b = channel(parts[2], false);
8618
+ const a = parts[3] === undefined ? 1 : channel(parts[3], true);
8619
+ if ([r, g, b, a].some(v => v === null)) return '';
8620
+ return `${r},${g},${b},${Math.round(a * 255)}`;
8621
+ };
8622
+ const _valueIgnored = (f) => {
8623
+ const value = _findingValue(f);
8624
+ if (!value) return false;
8625
+ const rule = f.type || f.id;
8626
+ return _disabledValues.some(e => e.rule === rule && (e.value === value
8627
+ || (rule === 'design-system-color'
8628
+ && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
8629
+ };
8630
+ for (const [el, list] of [...groupMap.entries()]) {
8631
+ const kept = list.filter(f => !_valueIgnored(f));
8632
+ if (kept.length > 0) groupMap.set(el, kept);
8633
+ else groupMap.delete(el);
8634
+ }
8635
+ for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
8636
+ if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
8637
+ }
8638
+ }
8639
+
7906
8640
  return {
7907
8641
  groupMap,
7908
8642
  allFindings: browserFindingsFromMap(groupMap),
@@ -7910,8 +8644,27 @@ if (IS_BROWSER) {
7910
8644
  };
7911
8645
  }
7912
8646
 
8647
+ // Visual contrast has three modes. Explicit true runs the full sampled
8648
+ // pass; explicit false disables it entirely (the deterministic-only mode
8649
+ // the test suites use). Unset — the default overlay run — samples ONLY
8650
+ // image-backed text: the one class the analytic walk deliberately skips,
8651
+ // because a url() layer's pixels are unknowable without looking. In-page
8652
+ // sampling draws the source image alone to a canvas (glyph ink never
8653
+ // pollutes it), and a cross-origin image without CORS reports unresolved
8654
+ // instead of guessing.
8655
+ function visualContrastMode(options = {}) {
8656
+ const explicit = typeof options.visualContrast === 'boolean'
8657
+ ? options.visualContrast
8658
+ : typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
8659
+ ? window.__IMPECCABLE_CONFIG__.visualContrast
8660
+ : null;
8661
+ if (explicit === true) return 'full';
8662
+ if (explicit === false) return false;
8663
+ return 'image-only';
8664
+ }
8665
+
7913
8666
  function shouldRunVisualContrast(options = {}) {
7914
- return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
8667
+ return visualContrastMode(options) !== false;
7915
8668
  }
7916
8669
 
7917
8670
  function visualContrastOptions(options = {}) {
@@ -8088,6 +8841,7 @@ if (IS_BROWSER) {
8088
8841
  return [];
8089
8842
  }
8090
8843
  const resolvedOptions = visualContrastOptions(options);
8844
+ if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
8091
8845
  const analyses = await analyzeVisualContrast(resolvedOptions);
8092
8846
  if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
8093
8847
  lastVisualContrastAnalyses = analyses;
@@ -8100,6 +8854,12 @@ if (IS_BROWSER) {
8100
8854
 
8101
8855
  async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
8102
8856
  const collected = collectBrowserFindings();
8857
+ // The visual pass walks the DOM on its own; on a skipScan page it would
8858
+ // repopulate the emptied scan, so it is skipped with everything else.
8859
+ if (skipScanActive()) {
8860
+ lastVisualContrastAnalyses = [];
8861
+ return { ...collected, allFindings: [], visualContrastAnalyses: [] };
8862
+ }
8103
8863
  await addVisualContrastFindings(collected.groupMap, options, runtime);
8104
8864
  return {
8105
8865
  ...collected,
@@ -8153,7 +8913,7 @@ if (IS_BROWSER) {
8153
8913
  const generation = scanGeneration;
8154
8914
  const collected = collectBrowserFindings();
8155
8915
  const allFindings = renderBrowserFindings(collected, options);
8156
- if (shouldRunVisualContrast(options)) {
8916
+ if (!skipScanActive() && shouldRunVisualContrast(options)) {
8157
8917
  addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
8158
8918
  .then(() => {
8159
8919
  if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);