quiver-cli 0.8.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +110 -45
  2. package/bin/quiver-cli.mjs +3 -1
  3. package/dist/cli.js +546 -458
  4. package/package.json +2 -2
  5. package/template/.agents/AGENTS.md +4 -3
  6. package/template/.agents/config.json +7 -0
  7. package/template/.agents/plugins/opencode/rtk.ts +34 -0
  8. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  9. package/template/.agents/skills/apps/skybridge/SKILL.md +4 -0
  10. package/template/.agents/skills/design/impeccable/SKILL.md +36 -118
  11. package/template/.agents/skills/design/impeccable/reference/adapt.md +1 -0
  12. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +58 -0
  13. package/template/.agents/skills/design/impeccable/reference/android.md +40 -0
  14. package/template/.agents/skills/design/impeccable/reference/animate.md +73 -188
  15. package/template/.agents/skills/design/impeccable/reference/audit.md +12 -9
  16. package/template/.agents/skills/design/impeccable/reference/audit.native.md +139 -0
  17. package/template/.agents/skills/design/impeccable/reference/bolder.md +19 -101
  18. package/template/.agents/skills/design/impeccable/reference/clarify.md +59 -253
  19. package/template/.agents/skills/design/impeccable/reference/colorize.md +51 -222
  20. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +42 -0
  21. package/template/.agents/skills/design/impeccable/reference/craft.md +3 -121
  22. package/template/.agents/skills/design/impeccable/reference/critique.md +44 -23
  23. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +90 -0
  24. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +24 -0
  25. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +37 -0
  26. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  27. package/template/.agents/skills/design/impeccable/reference/delight.md +47 -279
  28. package/template/.agents/skills/design/impeccable/reference/distill.md +2 -2
  29. package/template/.agents/skills/design/impeccable/reference/doctor.md +53 -0
  30. package/template/.agents/skills/design/impeccable/reference/document.md +60 -73
  31. package/template/.agents/skills/design/impeccable/reference/harden.md +1 -12
  32. package/template/.agents/skills/design/impeccable/reference/hooks.md +20 -5
  33. package/template/.agents/skills/design/impeccable/reference/init.md +72 -119
  34. package/template/.agents/skills/design/impeccable/reference/ios.md +45 -0
  35. package/template/.agents/skills/design/impeccable/reference/layout.md +54 -131
  36. package/template/.agents/skills/design/impeccable/reference/live-setup.md +102 -0
  37. package/template/.agents/skills/design/impeccable/reference/live.md +116 -511
  38. package/template/.agents/skills/design/impeccable/reference/new-work.md +105 -0
  39. package/template/.agents/skills/design/impeccable/reference/{product.md → operate.md} +6 -5
  40. package/template/.agents/skills/design/impeccable/reference/optimize.md +4 -4
  41. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -4
  42. package/template/.agents/skills/design/impeccable/reference/polish.md +68 -212
  43. package/template/.agents/skills/design/impeccable/reference/quieter.md +3 -3
  44. package/template/.agents/skills/design/impeccable/reference/routing.md +18 -0
  45. package/template/.agents/skills/design/impeccable/reference/shape.md +38 -144
  46. package/template/.agents/skills/design/impeccable/reference/typeset.md +51 -250
  47. package/template/.agents/skills/design/impeccable/reference/visualize.md +47 -0
  48. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +1 -1
  49. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +558 -0
  50. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +119 -10
  51. package/template/.agents/skills/design/impeccable/scripts/context.mjs +534 -45
  52. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +18 -47
  53. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +96 -10
  54. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +174 -26
  55. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +233 -0
  56. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +3348 -203
  57. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +102 -7
  58. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +297 -97
  59. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +187 -16
  60. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +41 -11
  61. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +7 -1
  62. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +16 -2
  63. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +207 -38
  64. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +3082 -173
  65. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +11 -0
  66. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +30 -0
  67. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +336 -0
  68. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +133 -0
  69. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +240 -0
  70. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +98 -18
  71. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +46 -6
  72. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +518 -50
  73. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +25 -8
  74. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +93 -0
  75. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +200 -0
  76. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +357 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +27 -7
  78. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +17 -8
  79. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +5 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +362 -0
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +457 -0
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +169 -0
  83. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +457 -0
  84. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +151 -0
  85. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +33 -0
  86. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +146 -0
  87. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +617 -0
  88. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +60 -0
  89. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +7 -1
  90. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +10 -1
  91. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +67 -5
  92. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +73 -0
  94. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +143 -0
  95. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +197 -0
  96. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  97. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +161 -0
  98. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +17 -0
  99. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  100. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  101. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  102. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  103. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  104. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +149 -0
  105. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +142 -0
  106. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +14 -0
  107. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +508 -0
  108. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +324 -50
  109. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +105 -0
  110. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +105 -0
  111. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +961 -0
  112. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +588 -72
  113. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +59 -17
  114. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +280 -0
  115. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +135 -0
  116. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +210 -68
  117. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +1562 -223
  118. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +3 -0
  119. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +33 -1
  120. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +175 -255
  121. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +26 -6
  122. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +6 -1
  123. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +61 -16
  124. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +39 -10
  125. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +573 -47
  126. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +17 -7
  127. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +124 -91
  128. package/template/.agents/skills/design/impeccable/scripts/live.mjs +88 -26
  129. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +76 -81
  130. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +18 -11
  131. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +932 -0
  132. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +74 -0
  133. package/template/.agents/skills/design/shadcn/SKILL.md +46 -11
  134. package/template/.agents/skills/design/shadcn/cli.md +49 -16
  135. package/template/.agents/skills/design/shadcn/customization.md +14 -7
  136. package/template/.agents/skills/design/shadcn/evals/evals.json +30 -0
  137. package/template/.agents/skills/design/shadcn/mcp.md +27 -16
  138. package/template/.agents/skills/design/shadcn/registry.md +277 -0
  139. package/template/.agents/skills/design/shadcn/rules/chat.md +224 -0
  140. package/template/.agents/skills/design/shadcn/rules/composition.md +20 -2
  141. package/template/.agents/skills/design/shadcn/rules/styling.md +23 -0
  142. package/template/.agents/skills/find-skills/SKILL.md +2 -3
  143. package/template/.agents/skills/hono/SKILL.md +579 -0
  144. package/template/.agents/skills/integrations/langfuse/SKILL.md +10 -6
  145. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +41 -0
  146. package/template/.agents/skills/integrations/langfuse/references/cli.md +8 -0
  147. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +15 -27
  148. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +39 -52
  149. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +5 -2
  150. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +35 -0
  151. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +41 -196
  152. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +3 -0
  153. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +76 -0
  154. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -0
  155. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +73 -0
  156. package/template/.agents/skills/supabase/CHANGELOG.md +71 -0
  157. package/template/.agents/skills/supabase/SKILL.md +145 -0
  158. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +17 -0
  159. package/template/.agents/skills/supabase/references/skill-feedback.md +17 -0
  160. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +73 -0
  161. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +64 -0
  162. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +170 -0
  163. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +39 -0
  164. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +34 -0
  165. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +55 -0
  166. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +49 -0
  167. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +46 -0
  168. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +44 -0
  169. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +41 -0
  170. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +46 -0
  171. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +54 -0
  172. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +53 -0
  173. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +50 -0
  174. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +50 -0
  175. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +56 -0
  176. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +68 -0
  177. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +50 -0
  178. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +54 -0
  179. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +45 -0
  180. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +55 -0
  181. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +55 -0
  182. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +44 -0
  183. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +40 -0
  184. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +48 -0
  185. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +43 -0
  186. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +45 -0
  187. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +80 -0
  188. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +46 -0
  189. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +59 -0
  190. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +55 -0
  191. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +55 -0
  192. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +61 -0
  193. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +54 -0
  194. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +50 -0
  195. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +63 -0
  196. package/template/.agents/upstreams.json +34 -12
  197. package/template/.agents/skills/design/impeccable/reference/brand.md +0 -108
  198. package/template/.agents/skills/design/impeccable/reference/codex.md +0 -105
  199. package/template/.agents/skills/design/impeccable/reference/interaction-design.md +0 -189
  200. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -175
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  BORDER_SAFE_TAGS,
3
+ EM_DASH_CHARS_PER_DASH,
4
+ EM_DASH_FLOOR,
3
5
  GENERIC_FONTS,
4
6
  KNOWN_SERIF_FONTS,
5
7
  OVERUSED_FONTS,
@@ -18,13 +20,21 @@ import {
18
20
  parseRgb,
19
21
  relativeLuminance,
20
22
  } from '../shared/color.mjs';
23
+ import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
21
24
 
22
25
  const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
23
26
 
24
27
  // ─── Section 3: Pure Detection ──────────────────────────────────────────────
25
28
 
26
- function checkBorders(tag, widths, colors, radius) {
27
- if (BORDER_SAFE_TAGS.has(tag)) return [];
29
+ function checkBorders(tag, widths, colors, radius, opts = {}) {
30
+ // Badge-shaped <span>s (own visible background) are a real stripe target
31
+ // for the top/bottom variant — the inline-tag exemption exists to quiet
32
+ // text-level borders, not chips. They skip the left/right arms below.
33
+ const spanBadge = tag === 'span' && !!opts.badgeLike;
34
+ if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
35
+ // A live status/alert region wears a colored single-edge border as a
36
+ // severity accent (toast, snackbar, callout), not as the side-tab tell.
37
+ if (opts.statusContext) return [];
28
38
  const findings = [];
29
39
  const sides = ['Top', 'Right', 'Bottom', 'Left'];
30
40
 
@@ -40,10 +50,20 @@ function checkBorders(tag, widths, colors, radius) {
40
50
  const isSide = side === 'Left' || side === 'Right';
41
51
 
42
52
  if (isSide) {
53
+ if (spanBadge) continue;
43
54
  if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
44
55
  else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
45
56
  } else {
46
57
  if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
58
+ // Horizontal variant of the side-tab stripe: a thick chromatic accent
59
+ // riding the top or bottom edge of a card/badge/container. Same
60
+ // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
61
+ // tab underlines are exempt via opts.tabContext (adapters look for
62
+ // tablist/nav/tab ancestors and aria-selected); links, buttons,
63
+ // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
64
+ else if (!opts.tabContext && w >= 3 && w <= 12) {
65
+ findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
66
+ }
47
67
  }
48
68
  }
49
69
 
@@ -65,23 +85,45 @@ function isEmojiOnlyText(text) {
65
85
  function checkColors(opts) {
66
86
  const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
67
87
  if (SAFE_TAGS.has(tag)) {
68
- // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
69
- // exists to suppress contrast noise on inline links and unstyled controls,
70
- // where the element has no own background and the contrast against the
71
- // ancestor surface is already the intended visual. When the element has
72
- // its own opaque background and direct text, it is a styled button and
73
- // contrast on its own surface is a real, frequent bug worth flagging.
74
- const isStyledButton = (tag === 'a' || tag === 'button')
75
- && hasDirectText
76
- && bgColor && bgColor.a > 0.5;
77
- if (!isStyledButton) return [];
88
+ // Exception for elements styled as controls or chips. SAFE_TAGS exists to
89
+ // suppress contrast noise on inline links and unstyled spans, where the
90
+ // element has no own background and the contrast against the ancestor
91
+ // surface is already the intended visual. When the element paints its own
92
+ // opaque background under direct text, it is a styled button, chip, or
93
+ // badge regardless of tag, and contrast on its own surface is a real,
94
+ // frequent bug worth flagging. (The shipped miss: a <span> severity chip
95
+ // whose white text lost a specificity fight and rendered muted-on-red at
96
+ // 1.2:1; the old a/button-only exception never looked at it.) The 9px
97
+ // font floor keeps sub-text decorations out.
98
+ const isStyledControl = hasDirectText
99
+ && ((bgColor && bgColor.a > 0.5)
100
+ // A gradient painted on the element itself is an own surface the
101
+ // same way a solid background is. Without this branch a nav CTA
102
+ // built as `<a>` with `background: linear-gradient(…)` and a text
103
+ // color that fails against every stop sails through on the
104
+ // SAFE_TAGS suppression (the shipped escape).
105
+ || (bgImage && /gradient/i.test(bgImage)))
106
+ && fontSize >= 9;
107
+ if (!isStyledControl) return [];
78
108
  }
79
109
  const findings = [];
80
110
 
81
111
  if (hasDirectText && textColor && !isEmojiOnly) {
112
+ // Gradient-clipped text (`background-clip: text`, typically with a
113
+ // transparent text-fill) paints its glyphs *with* the element's own
114
+ // gradient. The `color` value the cascade still reports is never painted,
115
+ // and the gradient is the fill, not a backdrop — so measuring `color`
116
+ // against that gradient (which resolveGradientStops picks up as the
117
+ // element's own background-image) is a guaranteed false positive
118
+ // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
119
+ // rule below still flags the pattern itself. Skipping a rule beats a false
120
+ // positive here — the true painted contrast can't be measured from `color`.
121
+ const isGradientClippedText = bgClip === 'text';
82
122
  // Run background-dependent checks against either a solid bg or, if the
83
123
  // ancestor is a gradient, against every gradient stop (use the worst case).
84
- const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
124
+ const bgs = isGradientClippedText
125
+ ? null
126
+ : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
85
127
  if (bgs) {
86
128
  // Gray on colored background — flag if every stop is chromatic
87
129
  const textLum = relativeLuminance(textColor);
@@ -111,7 +153,11 @@ function checkColors(opts) {
111
153
  // like `text-paper/60` on `bg-ink` sections are the FP pattern.
112
154
  const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
113
155
  if (!isAlphaFallbackFP) {
114
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
156
+ // Near-threshold ratios (e.g. 4.497) would round to the threshold
157
+ // itself at one decimal and read as "4.5 needs 4.5" — show two
158
+ // decimals there so the finding stays legible.
159
+ const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
160
+ findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
115
161
  }
116
162
  }
117
163
  }
@@ -157,6 +203,27 @@ function checkColors(opts) {
157
203
  return findings;
158
204
  }
159
205
 
206
+ // WCAG contrast for the :hover state of an element whose hover rules change
207
+ // its text color and/or background. The classic miss: a nav CTA whose
208
+ // author-intended hover pair passes AA, but a broader selector (e.g.
209
+ // `.nav-links a:hover`) wins the specificity fight and swaps in a color
210
+ // that fails. Only fires on elements that present as styled controls —
211
+ // direct text plus an opaque-ish own background in either state — so plain
212
+ // inline links keep the same suppression they get in checkColors.
213
+ function checkHoverContrast(opts) {
214
+ const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
215
+ if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
216
+ if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
217
+ const ratio = contrastRatio(textColor, bg);
218
+ const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
219
+ const threshold = isLargeText ? 3.0 : 4.5;
220
+ if (ratio >= threshold) return [];
221
+ return [{
222
+ id: 'low-contrast',
223
+ snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
224
+ }];
225
+ }
226
+
160
227
  function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
161
228
  if (!hasShadow && !hasBorder) return false;
162
229
  return hasRadius || hasBg;
@@ -302,26 +369,57 @@ function isAccentColor(cssColor) {
302
369
  return false;
303
370
  }
304
371
 
372
+ function resolveHeroHeadingSizePx(value) {
373
+ const input = String(value || '').trim().toLowerCase();
374
+ if (!input) return 0;
375
+
376
+ const simpleLengthPx = (token) => {
377
+ const match = /^(-?\d*\.?\d+)\s*(px|rem|em|%)?$/.exec(String(token || '').trim());
378
+ if (!match) return null;
379
+ const amount = Number(match[1]);
380
+ if (!Number.isFinite(amount)) return null;
381
+ if (match[2] === 'rem' || match[2] === 'em') return amount * 16;
382
+ if (match[2] === '%') return amount * 0.16;
383
+ return amount;
384
+ };
385
+
386
+ const direct = simpleLengthPx(input);
387
+ if (direct !== null) return direct;
388
+
389
+ // Static CSS engines cannot resolve viewport units, but clamp's min/max
390
+ // bounds still tell us whether the heading can ever reach hero scale.
391
+ const clamp = /^clamp\((.*)\)$/.exec(input);
392
+ if (clamp) {
393
+ const parts = clamp[1].split(',');
394
+ if (parts.length === 3) {
395
+ const bounds = [simpleLengthPx(parts[0]), simpleLengthPx(parts[2])]
396
+ .filter((candidate) => candidate !== null);
397
+ if (bounds.length > 0) return Math.max(...bounds);
398
+ }
399
+ }
400
+
401
+ return 0;
402
+ }
403
+
305
404
  // Sibling-relationship rule. Anchor on a hero-scale h1, look at the
306
405
  // previousElementSibling, and gate on EITHER the classic tracked-
307
406
  // uppercase eyebrow OR the modern accent-colored bold eyebrow.
308
407
  function checkHeroEyebrow(opts) {
309
408
  const {
310
409
  headingTag, headingText, headingFontSize,
410
+ headingInApplicationContext,
311
411
  siblingTag, siblingText, siblingTextTransform,
312
412
  siblingFontSize, siblingLetterSpacing,
313
413
  siblingFontWeight, siblingColor,
414
+ siblingHasAccentDashPseudo,
314
415
  } = opts;
315
416
  if (headingTag !== 'h1') return [];
316
- // We previously gated on headingFontSize >= 48 to anchor "hero scale".
317
- // But modern hero h1s use clamp() / vw / var(--text-*), none of which
318
- // jsdom can resolve the computed value comes back as "2em" or
319
- // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
320
- // on virtually every Tailwind v4 / framework build. The other gates
321
- // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
322
- // tracked-caps) are tight enough to avoid false positives on non-
323
- // hero h1s — a tiny tan label directly above any h1 is the
324
- // antipattern regardless of how big the h1 ends up.
417
+ // This is specifically a marketing-hero cliché, not a ban on compact
418
+ // context labels in product UI (for example, a station name inside a tab
419
+ // panel). Browser-computed sizes are reliable; the static adapter also
420
+ // resolves ordinary px/rem/em and clamp() bounds before reaching here.
421
+ if (headingInApplicationContext) return [];
422
+ if (!(headingFontSize >= 48)) return [];
325
423
  if (!siblingTag) return [];
326
424
  // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
327
425
  // headings) — never an eyebrow.
@@ -342,23 +440,31 @@ function checkHeroEyebrow(opts) {
342
440
  const weight = Number(siblingFontWeight) || 400;
343
441
  const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
344
442
 
345
- if (!isClassicTracked && !isAccentBold) return [];
443
+ // Branch C: dash-prefix eyebrow — sentence case, low tracking, regular
444
+ // weight, but announced by a short chromatic ::before/::after bar
445
+ // (the kicker dash). Same label-above-headline pattern, third styling.
446
+ const isDashPrefixed = !!siblingHasAccentDashPseudo;
447
+
448
+ if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return [];
346
449
 
347
450
  const headingTextSnippet = (headingText || '').trim().slice(0, 60);
348
451
  const eyebrowSnippet = text.slice(0, 40);
349
- const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
452
+ const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix';
350
453
  return [{
351
454
  id: 'hero-eyebrow-chip',
352
455
  snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
353
456
  }];
354
457
  }
355
458
 
356
- function checkRepeatedSectionKickers(opts) {
357
- const { candidates, minCount = 3 } = opts;
358
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
459
+ // Outright ban: one kicker is one too many, so every collected candidate is
460
+ // a finding. The judgment lives in the candidate gate (isKickerCandidate) and
461
+ // the collector's context skips, not in a repetition count.
462
+ function checkKickerAboveHeading(opts) {
463
+ const { candidates } = opts;
464
+ if (!Array.isArray(candidates)) return [];
359
465
  return candidates.map(candidate => ({
360
- id: 'repeated-section-kickers',
361
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
466
+ id: 'kicker-above-heading',
467
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
362
468
  }));
363
469
  }
364
470
 
@@ -407,52 +513,947 @@ function checkMotion(opts) {
407
513
  return findings;
408
514
  }
409
515
 
516
+ // Locate the color token in a single shadow layer. Returns
517
+ // { color, start, end } where color is the parsed {r,g,b,a} (null when the
518
+ // token exists but can't be parsed — e.g. an unresolved var() or an exotic
519
+ // color space), or null when no color token is present at all. Handles both
520
+ // serialization orders: computed style puts the color first
521
+ // ("rgb(…) 0px 0px 20px"), authored CSS usually puts it last
522
+ // ("0 0 20px #3b82f6").
523
+ function findShadowColor(layer) {
524
+ const fn = layer.match(/(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)/i);
525
+ if (fn) return { color: parseAnyColor(fn[0]), start: fn.index, end: fn.index + fn[0].length };
526
+ const hex = layer.match(/#[0-9a-fA-F]{3,8}\b/);
527
+ if (hex) return { color: parseAnyColor(hex[0]), start: hex.index, end: hex.index + hex[0].length };
528
+ const wordRe = /[a-zA-Z][a-zA-Z]*/g;
529
+ let m;
530
+ while ((m = wordRe.exec(layer)) !== null) {
531
+ const named = CSS_NAMED_COLORS[m[0].toLowerCase()];
532
+ if (named) return { color: { ...named, a: 1 }, start: m.index, end: m.index + m[0].length };
533
+ }
534
+ return null;
535
+ }
536
+
537
+ // Extract the length values of a shadow layer in declaration order, with the
538
+ // color token removed so its components aren't misread as lengths. Handles
539
+ // computed-style px values AND authored unitless zeros ("0 0 20px"); rem/em
540
+ // approximate at 16px. Result order is offset-x, offset-y, blur, [spread].
541
+ function extractShadowLengths(layer, colorStart, colorEnd) {
542
+ const stripped = colorStart != null
543
+ ? layer.slice(0, colorStart) + ' ' + layer.slice(colorEnd)
544
+ : layer;
545
+ const vals = [];
546
+ const re = /(-?\d*\.?\d+)(px|rem|em)?/g;
547
+ let m;
548
+ while ((m = re.exec(stripped)) !== null) {
549
+ let v = parseFloat(m[1]);
550
+ if (m[2] === 'rem' || m[2] === 'em') v *= 16;
551
+ vals.push(v);
552
+ }
553
+ return vals;
554
+ }
555
+
410
556
  function checkGlow(opts) {
411
- const { boxShadow, effectiveBg } = opts;
412
- if (!boxShadow || boxShadow === 'none') return [];
413
- if (!effectiveBg) return [];
557
+ const { boxShadow, textShadow, effectiveBg } = opts;
558
+ const onDarkBg = effectiveBg ? relativeLuminance(effectiveBg) < 0.1 : false;
559
+
560
+ // Scan one shadow list. Two glow tells, in any color format:
561
+ // 1. Zero-offset chromatic halo (0 0 Npx <color>) — slop on ANY
562
+ // background; the light radiates evenly outward, which is never how
563
+ // real elevation shadows behave. Achromatic zero-offset shadows stay
564
+ // legal (soft ambient elevation), as do focus rings (blur 0).
565
+ // 2. Any chromatic shadow with real blur on a dark background — the
566
+ // classic dark-mode glow accent.
567
+ const scan = (value, prop) => {
568
+ if (!value || value === 'none') return null;
569
+ // Split multiple shadows (commas not inside parentheses)
570
+ for (const layer of value.split(/,(?![^(]*\))/)) {
571
+ const colorInfo = findShadowColor(layer);
572
+ // No color token, or one we can't resolve (unresolved var(), exotic
573
+ // color space): don't guess — skip rather than false-positive.
574
+ if (!colorInfo || !colorInfo.color) continue;
575
+ const color = colorInfo.color;
576
+ if (!hasChroma(color, 30)) continue;
577
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
578
+ // Third value is blur (offset-x, offset-y, blur, [spread])
579
+ if (vals.length < 3 || vals[2] <= 4) continue;
580
+ if (vals[0] === 0 && vals[1] === 0) {
581
+ return { id: 'dark-glow', snippet: `Zero-offset ${prop} glow (${colorToHex(color)})` };
582
+ }
583
+ if (onDarkBg) {
584
+ return { id: 'dark-glow', snippet: `Colored ${prop} glow (${colorToHex(color)}) on dark background` };
585
+ }
586
+ }
587
+ return null;
588
+ };
414
589
 
415
- // Only flag on dark backgrounds (luminance < 0.1)
416
- const bgLum = relativeLuminance(effectiveBg);
417
- if (bgLum >= 0.1) return [];
590
+ const found = scan(boxShadow, 'box-shadow') || scan(textShadow, 'text-shadow');
591
+ return found ? [found] : [];
592
+ }
418
593
 
419
- // Split multiple shadows (commas not inside parentheses)
420
- const parts = boxShadow.split(/,(?![^(]*\))/);
421
- for (const shadow of parts) {
422
- const colorMatch = shadow.match(/rgba?\([^)]+\)/);
423
- if (!colorMatch) continue;
424
- const color = parseRgb(colorMatch[0]);
425
- if (!color || !hasChroma(color, 30)) continue;
594
+ // Collect CSS custom property declarations from raw stylesheet/HTML text.
595
+ // First declaration wins (:root declarations usually come first); good
596
+ // enough for the single-level var() resolution the text engines need.
597
+ function collectCssCustomProps(content) {
598
+ const map = new Map();
599
+ const re = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
600
+ let m;
601
+ while ((m = re.exec(content)) !== null) {
602
+ if (!map.has(m[1])) map.set(m[1], m[2].trim());
603
+ }
604
+ return map;
605
+ }
606
+
607
+ // Text-level glow scan shared by the regex engine and the page-level HTML
608
+ // pattern pass. Resolves single-level var() refs against custom properties
609
+ // collected from the same text, then applies the same two glow tells as
610
+ // checkGlow: zero-offset chromatic halo (any background) and chromatic
611
+ // blurred shadow when the page has a dark background. Returns
612
+ // [{ index, snippet }] — index is the offset of the shadow declaration.
613
+ // Dark-page heuristic for raw CSS/HTML text: dark hex/rgb literals, Tailwind
614
+ // dark bg utilities, or a ROOT-scoped (body/html/:root or <body style>)
615
+ // background that resolves — via var() — to a dark color. The var/modern-
616
+ // color extension is deliberately root-scoped: a light page with one dark
617
+ // accent chip must not turn every tinted drop shadow into a "dark page"
618
+ // signal. Shared by the glow and radial-halo text scanners.
619
+ function cssTextHasDarkRootBg(content, customProps) {
620
+ const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/i;
621
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
622
+ if (darkBgRe.test(content) || twDarkBg.test(content)) return true;
623
+ const rootScopes = [];
624
+ const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi;
625
+ let sm;
626
+ while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]);
627
+ const inlineBody = content.match(/<body[^>]*\bstyle\s*=\s*"([^"]*)"/i);
628
+ if (inlineBody) rootScopes.push(inlineBody[1]);
629
+ for (const scope of rootScopes) {
630
+ const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi;
631
+ let bm;
632
+ while ((bm = bgRe.exec(scope)) !== null) {
633
+ const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps));
634
+ if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) return true;
635
+ }
636
+ }
637
+ return false;
638
+ }
426
639
 
427
- // Extract px values — in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
428
- const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
429
- const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
430
- const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
431
- .map(m => parseFloat(m[1]));
640
+ function scanCssTextForGlow(content) {
641
+ const customProps = collectCssCustomProps(content);
642
+ const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
432
643
 
433
- // Third value is blur (offset-x, offset-y, blur, [spread])
434
- if (pxVals.length >= 3 && pxVals[2] > 4) {
435
- return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
644
+ const results = [];
645
+ const shadowRe = /\b(box-shadow|text-shadow)\s*:\s*([^;{}]+)/gi;
646
+ let m;
647
+ while ((m = shadowRe.exec(content)) !== null) {
648
+ const prop = m[1].toLowerCase();
649
+ const value = resolveVarRefs(m[2].trim(), customProps);
650
+ for (const layer of value.split(/,(?![^(]*\))/)) {
651
+ const colorInfo = findShadowColor(layer);
652
+ if (!colorInfo || !colorInfo.color || !hasChroma(colorInfo.color, 30)) continue;
653
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
654
+ if (vals.length < 3 || vals[2] <= 4) continue;
655
+ const zeroOffset = vals[0] === 0 && vals[1] === 0;
656
+ if (!zeroOffset && !hasDarkBg) continue;
657
+ results.push({
658
+ index: m.index,
659
+ snippet: zeroOffset
660
+ ? `Zero-offset ${prop} glow (${colorToHex(colorInfo.color)})`
661
+ : `Colored ${prop} glow (${colorToHex(colorInfo.color)}) on dark page`,
662
+ });
663
+ break; // one finding per declaration
436
664
  }
437
665
  }
666
+ return results;
667
+ }
438
668
 
669
+ // Decorative grid or line-field backgrounds drawn with hairline
670
+ // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
671
+ // pattern pass and the regex source engine so standalone CSS, component
672
+ // styles, and inline styles receive the same coverage. Both signals must
673
+ // co-occur in one declaration block; unrelated rules must not add up across
674
+ // the file. Returns [{ index, snippet }], capped at one finding per source to
675
+ // match the page-level HTML check's existing behavior.
676
+ function scanCssTextForGridBackground(content) {
677
+ const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
678
+ const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
679
+ const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
680
+ const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
681
+ const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
682
+ const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
683
+ const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
684
+ const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
685
+ let blk;
686
+ while ((blk = blockRe.exec(content)) !== null) {
687
+ const block = blk[1] || blk[2] || blk[3] || '';
688
+ let hairlineCount = 0;
689
+ let bgJoined = '';
690
+ let bm;
691
+ bgDeclRe.lastIndex = 0;
692
+ while ((bm = bgDeclRe.exec(block)) !== null) {
693
+ hairlineCount += (bm[1].match(hairlineRe) || []).length;
694
+ hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
695
+ bgJoined += `${bm[1]};`;
696
+ }
697
+ if (hairlineCount === 0) continue;
698
+ const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
699
+ const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
700
+ if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
701
+ return [{
702
+ index: blk.index,
703
+ snippet: hairlineCount >= 2
704
+ ? 'two-axis grid-line gradient background'
705
+ : 'px-tiled hairline line-field background',
706
+ }];
707
+ }
708
+ }
439
709
  return [];
440
710
  }
441
711
 
712
+ // Decorative chromatic halo drawn as a radial-gradient background on a dark
713
+ // page: a saturated center stop dissolving to transparent. The gradient
714
+ // sibling of the dark-glow shadow tell. Mechanical gates, in order:
715
+ // * page has a dark root background (shared heuristic with the glow scan)
716
+ // * declaration has no url() layer (photographic imagery is exempt)
717
+ // * the gradient's first color stop is chromatic (RGB spread >= 24) and
718
+ // visible (alpha >= 0.7 — deliberately translucent light-scene washes
719
+ // composite with content instead of painting a flat halo, and stay legal)
720
+ // * the gradient's last stop is transparent / near-zero alpha
721
+ // * no small pixel-sized stop positions (<= 24px = dot/texture patterns)
722
+ // * not a repeating-* gradient
723
+ // Achromatic vignettes fail the chroma gate; panel sheens that fade to an
724
+ // opaque surface color fail the transparent-end gate.
725
+ function scanCssTextForRadialHalo(content) {
726
+ const customProps = collectCssCustomProps(content);
727
+ if (!cssTextHasDarkRootBg(content, customProps)) return [];
728
+
729
+ const findings = [];
730
+ const seen = new Set();
731
+ const declRe = /background(?:-image)?\s*:\s*([^;{}]+)/gi;
732
+ let m;
733
+ while ((m = declRe.exec(content)) !== null) {
734
+ const value = resolveVarRefs(m[1].trim(), customProps);
735
+ if (/url\s*\(/i.test(value)) continue;
736
+
737
+ const gradRe = /(repeating-)?radial-gradient\(/gi;
738
+ let g;
739
+ while ((g = gradRe.exec(value)) !== null) {
740
+ if (g[1]) continue; // repeating-* = pattern, not halo
741
+ // Balanced-paren capture of the gradient arguments.
742
+ let depth = 0, end = -1;
743
+ const open = value.indexOf('(', g.index);
744
+ for (let i = open; i < value.length; i++) {
745
+ if (value[i] === '(') depth++;
746
+ else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
747
+ }
748
+ if (end < 0) break;
749
+ const args = splitTopLevelCommas(value.slice(open + 1, end));
750
+ if (args.length < 2) continue;
751
+
752
+ // Optional prelude (shape / size / `at <pos>`) carries no color.
753
+ const colorTokenRe = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
754
+ const stops = args.filter(a => colorTokenRe.test(a));
755
+ if (stops.length < 2) continue;
756
+
757
+ // Dot/texture exemption: px-sized stop positions mean a repeating
758
+ // background-size pattern, not a page-scale halo.
759
+ const pxStop = stops.some(s => {
760
+ const pm = s.match(/(-?[\d.]+)px\b/);
761
+ return pm && Math.abs(parseFloat(pm[1])) <= 24;
762
+ });
763
+ if (pxStop) continue;
764
+
765
+ const first = stops[0].match(colorTokenRe);
766
+ const last = stops[stops.length - 1].match(colorTokenRe);
767
+ if (!first || !last) continue;
768
+
769
+ const lastColor = /^transparent$/i.test(last[0]) ? { r: 0, g: 0, b: 0, a: 0 } : parseAnyColor(last[0]);
770
+ if (!lastColor || (lastColor.a ?? 1) > 0.05) continue;
771
+
772
+ const firstColor = /^transparent$/i.test(first[0]) ? null : parseAnyColor(first[0]);
773
+ if (!firstColor) continue;
774
+ if ((firstColor.a ?? 1) < 0.7) continue;
775
+ const spread = Math.max(firstColor.r, firstColor.g, firstColor.b) - Math.min(firstColor.r, firstColor.g, firstColor.b);
776
+ if (spread < 24) continue;
777
+
778
+ const snippet = `radial-gradient halo (${colorToHex(firstColor)} → transparent) on dark page`;
779
+ if (seen.has(snippet)) continue;
780
+ seen.add(snippet);
781
+ findings.push({ index: m.index, snippet });
782
+ }
783
+ }
784
+ return findings;
785
+ }
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
789
+ // ---------------------------------------------------------------------------
790
+
791
+ // Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
792
+ // body excludes braces, so nested structures (@media, @keyframes) naturally
793
+ // yield their innermost rules with the innermost selector text. Callers
794
+ // create the regex locally — a shared /g instance is not re-entrant.
795
+ const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
796
+
797
+ // Parse a declaration block into a prop → value map (last declaration wins,
798
+ // approximating the cascade inside one block). Values keep their raw text
799
+ // with any !important suffix stripped.
800
+ function parseCssDeclBlock(block) {
801
+ const decls = new Map();
802
+ for (const part of String(block || '').split(';')) {
803
+ const idx = part.indexOf(':');
804
+ if (idx <= 0) continue;
805
+ const prop = part.slice(0, idx).trim().toLowerCase();
806
+ const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
807
+ if (prop && value) decls.set(prop, value);
808
+ }
809
+ return decls;
810
+ }
811
+
812
+ function cssLengthToPx(value) {
813
+ const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
814
+ if (!m) return null;
815
+ const n = parseFloat(m[1]);
816
+ return m[2].toLowerCase() === 'px' ? n : n * 16;
817
+ }
818
+
819
+ function isZeroOffset(value) {
820
+ return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
821
+ }
822
+
823
+ // Side-tab variant: the accent stripe drawn as an absolutely-positioned
824
+ // ::before/::after pseudo-element (narrow colored box hugging a vertical
825
+ // edge) instead of a border-left/right. The element-level border checks
826
+ // never see it — pseudo-elements aren't part of the DOM the cascade walks —
827
+ // so this scans stylesheet text directly, mirroring the border rule's
828
+ // gates: >= 3px thick, chromatic fill, full height against a side edge.
829
+ function scanCssTextForPseudoStripe(rawContent) {
830
+ // Blank comment bodies byte-for-byte so commented-out rules are not
831
+ // scanned as live CSS and every rule keeps its source offset (each
832
+ // finding carries `index` so line-based callers can attribute it and
833
+ // line-scoped inline ignores can match).
834
+ const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g,
835
+ (block) => block.replace(/[^\n]/g, ' '));
836
+ const customProps = collectCssCustomProps(content);
837
+ const findings = [];
838
+ const seen = new Set();
839
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
840
+ let m;
841
+ while ((m = ruleRe.exec(content)) !== null) {
842
+ const selector = m[1].trim();
843
+ if (!/::?(?:before|after)\b/i.test(selector)) continue;
844
+ // Keep the border rule's prose exemptions (blockquote bars etc.).
845
+ if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
846
+ const decls = parseCssDeclBlock(m[2]);
847
+ const position = decls.get('position');
848
+ if (position !== 'absolute' && position !== 'fixed') continue;
849
+
850
+ const widthPx = cssLengthToPx(resolveVarRefs(
851
+ decls.get('width') || decls.get('inline-size') || '', customProps));
852
+ const heightPx = cssLengthToPx(resolveVarRefs(
853
+ decls.get('height') || decls.get('block-size') || '', customProps));
854
+ const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12;
855
+ // Horizontal variant (top/bottom stripe) carries extra exemptions:
856
+ // link/button underline affordances, selected-state indicators
857
+ // (aria-selected="true", aria-current, active/current/selected class
858
+ // hints), and state-conditional (:hover/:focus/...) affordances are
859
+ // not stripes. Tab-strip membership alone ([role=tab], .tabs, bare
860
+ // [aria-selected]) is NOT exempt — a stripe on every tab in the
861
+ // group is decoration; only the selected item's underline stays.
862
+ const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
863
+ && !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
864
+ && !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)
865
+ && !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)
866
+ && !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
867
+ && !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector);
868
+ if (!verticalCandidate && !horizontalCandidate) continue;
869
+
870
+ // Resolve edge offsets, letting an `inset` shorthand fill the gaps.
871
+ const offsets = {
872
+ top: decls.get('top'), right: decls.get('right'),
873
+ bottom: decls.get('bottom'), left: decls.get('left'),
874
+ };
875
+ const inset = decls.get('inset');
876
+ if (inset) {
877
+ const p = inset.split(/\s+/);
878
+ const [t, r, b, l] =
879
+ p.length === 1 ? [p[0], p[0], p[0], p[0]]
880
+ : p.length === 2 ? [p[0], p[1], p[0], p[1]]
881
+ : p.length === 3 ? [p[0], p[1], p[2], p[1]]
882
+ : p;
883
+ if (offsets.top == null) offsets.top = t;
884
+ if (offsets.right == null) offsets.right = r;
885
+ if (offsets.bottom == null) offsets.bottom = b;
886
+ if (offsets.left == null) offsets.left = l;
887
+ }
888
+ if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
889
+ if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
890
+
891
+ const heightValue = String(resolveVarRefs(
892
+ decls.get('height') || decls.get('block-size') || '', customProps)).trim();
893
+ const widthValue = String(resolveVarRefs(
894
+ decls.get('width') || decls.get('inline-size') || '', customProps)).trim();
895
+
896
+ let edge = null;
897
+ let thicknessPx = null;
898
+ if (verticalCandidate) {
899
+ // Full-height stripes hug both corners; the "floating" variant backs
900
+ // off each end by a small inset (top/bottom a few px) so the bar
901
+ // clears the card's corners. Both read as the same side-tab accent —
902
+ // corner treatment is styling, not a different pattern.
903
+ const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps));
904
+ const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps));
905
+ const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
906
+ || /^100(?:\.0*)?%$/.test(heightValue)
907
+ || (topPx != null && bottomPx != null
908
+ && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20);
909
+ if (fullHeight) {
910
+ edge = isZeroOffset(offsets.left) ? 'left'
911
+ : isZeroOffset(offsets.right) ? 'right' : null;
912
+ thicknessPx = widthPx;
913
+ }
914
+ }
915
+ if (!edge && horizontalCandidate) {
916
+ const fullWidth = (isZeroOffset(offsets.left) && isZeroOffset(offsets.right))
917
+ || /^100(?:\.0*)?%$/.test(widthValue);
918
+ if (fullWidth) {
919
+ edge = isZeroOffset(offsets.top) ? 'top'
920
+ : isZeroOffset(offsets.bottom) ? 'bottom' : null;
921
+ thicknessPx = heightPx;
922
+ }
923
+ }
924
+ if (!edge) continue;
925
+
926
+ // Chromatic fill only — a neutral hairline divider is not an accent
927
+ // stripe. Unresolvable colors err toward detection, matching the
928
+ // border rule's unknown-format default.
929
+ const bg = String(resolveVarRefs(
930
+ decls.get('background-color') || decls.get('background') || '', customProps)).trim();
931
+ if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
932
+ const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
933
+ const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
934
+ if (parsed) {
935
+ if ((parsed.a ?? 1) < 0.1) continue;
936
+ const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
937
+ if (spread < 30) continue;
938
+ } else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
939
+ continue;
940
+ }
941
+
942
+ if (seen.has(selector)) continue;
943
+ seen.add(selector);
944
+ // The selector group absorbs whitespace trailing the previous rule;
945
+ // advance past it so `index` points at the selector itself.
946
+ const selectorStart = m.index + (m[1].length - m[1].trimStart().length);
947
+ findings.push({
948
+ id: 'side-tab',
949
+ snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
950
+ index: selectorStart,
951
+ });
952
+ }
953
+ return findings;
954
+ }
955
+
956
+ // Side-tab stripe drawn as a single-edge inset box-shadow
957
+ // (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color):
958
+ // paints a bar along one edge with no border property involved, so the
959
+ // element-level border checks never see it. Selection-state indicators
960
+ // are exempt — an inset stripe on [aria-current] / .active / [role=tab]
961
+ // marks the selected item; the same stripe unconditionally on every item
962
+ // is decoration and flags.
963
+ function scanCssTextForInsetStripe(content) {
964
+ const customProps = collectCssCustomProps(content);
965
+ const findings = [];
966
+ const seen = new Set();
967
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
968
+ let m;
969
+ while ((m = ruleRe.exec(content)) !== null) {
970
+ const selector = m[1].trim();
971
+ // Selection-state contexts: current-item markers and interaction
972
+ // states. Tab-strip membership alone ([role=tab], .tabs, bare
973
+ // [aria-selected]) is NOT exempt — a stripe on every tab in the
974
+ // group is decoration; only the selected item's indicator stays.
975
+ if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
976
+ if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
977
+ if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
978
+ if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
979
+ // Structural tags where a single-edge inset shadow is depth/quoting,
980
+ // not an accent stripe.
981
+ if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
982
+
983
+ const decls = parseCssDeclBlock(m[2]);
984
+ const shadow = decls.get('box-shadow');
985
+ if (!shadow || !/\binset\b/i.test(shadow)) continue;
986
+ // Narrow fixed-width elements (logo marks, icon glyphs) use inset
987
+ // fills as artwork, not edge stripes. Stripe targets — cards, badges,
988
+ // menu items — are wider or leave width to layout.
989
+ const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps));
990
+ if (declaredWidth != null && declaredWidth <= 40) continue;
991
+ const value = resolveVarRefs(shadow, customProps);
992
+ for (const layer of value.split(/,(?![^(]*\))/)) {
993
+ if (!/\binset\b/i.test(layer)) continue;
994
+ const colorInfo = findShadowColor(layer);
995
+ // Unresolvable colors (currentColor, external vars): don't guess.
996
+ if (!colorInfo || !colorInfo.color) continue;
997
+ const c = colorInfo.color;
998
+ if ((c.a ?? 1) < 0.1) continue;
999
+ const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
1000
+ if (chroma < 30) continue;
1001
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
1002
+ const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0;
1003
+ if (blur !== 0 || sp !== 0) continue;
1004
+ const ax = Math.abs(x), ay = Math.abs(y);
1005
+ const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0);
1006
+ if (!isStripe) continue;
1007
+ if (seen.has(selector)) break;
1008
+ seen.add(selector);
1009
+ const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
1010
+ findings.push({
1011
+ id: 'side-tab',
1012
+ snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
1013
+ });
1014
+ break;
1015
+ }
1016
+ }
1017
+ return findings;
1018
+ }
1019
+
1020
+ // Collect @keyframes names whose body travels horizontally — the marquee
1021
+ // loop. X travel is measured across every translateX/translate/translate3d
1022
+ // X component in the body: a centered element animating something else
1023
+ // keeps a constant -50% X (zero travel) and never qualifies, while a
1024
+ // ticker moves from its resting position to a large offset. Keyframes
1025
+ // with a single X sample that also vary scale/opacity read as pulses or
1026
+ // breathes, not marquees.
1027
+ function collectMarqueeKeyframes(content) {
1028
+ const names = new Set();
1029
+ const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
1030
+ let m;
1031
+ while ((m = re.exec(content)) !== null) {
1032
+ let depth = 1;
1033
+ let i = re.lastIndex;
1034
+ while (i < content.length && depth > 0) {
1035
+ const ch = content.charCodeAt(i);
1036
+ if (ch === 0x7b /* { */) depth++;
1037
+ else if (ch === 0x7d /* } */) depth--;
1038
+ i++;
1039
+ }
1040
+ const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
1041
+ re.lastIndex = i;
1042
+
1043
+ // Only percentage travel qualifies: a content marquee translates by a
1044
+ // fraction of its own (unknown) track width, so generated tickers use
1045
+ // -50% / -100%. Pixel-travel loops are bespoke product animations —
1046
+ // sweeping playheads, progress indicators — not marquees.
1047
+ const pct = [];
1048
+ const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi;
1049
+ let xm;
1050
+ while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1]));
1051
+ if (pct.length === 0) continue;
1052
+ if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue;
1053
+ // Implicit start: a lone declared X animates from the element's
1054
+ // resting position, so its magnitude is the travel.
1055
+ const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]);
1056
+ if (travelPct >= 20) names.add(m[1]);
1057
+ }
1058
+ return names;
1059
+ }
1060
+
1061
+ // Auto-scrolling marquee: a <marquee> element, or an infinite animation
1062
+ // bound to a keyframe loop that travels a large horizontal distance.
1063
+ // Rotation/opacity animations never qualify (no X travel); JS-driven
1064
+ // carousels with user controls have no infinite CSS X-loop to match.
1065
+ // `content` is CSS-bearing text; `markup` (defaulting to the same string
1066
+ // for single-corpus callers) is where the <marquee> tag itself lives.
1067
+ function scanCssTextForMarquee(content, markup = content) {
1068
+ const findings = [];
1069
+ if (/<marquee\b/i.test(markup)) {
1070
+ findings.push({ id: 'marquee', snippet: '<marquee> element' });
1071
+ }
1072
+ const marqueeKeyframes = collectMarqueeKeyframes(content);
1073
+ if (marqueeKeyframes.size === 0) return findings;
1074
+ const seen = new Set();
1075
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
1076
+ let m;
1077
+ while ((m = ruleRe.exec(content)) !== null) {
1078
+ const selector = m[1].trim();
1079
+ const decls = parseCssDeclBlock(m[2]);
1080
+ for (const name of infiniteAnimationNames(decls)) {
1081
+ if (!marqueeKeyframes.has(name)) continue;
1082
+ const key = `${selector} ${name}`;
1083
+ if (seen.has(key)) continue;
1084
+ seen.add(key);
1085
+ findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
1086
+ }
1087
+ }
1088
+ return findings;
1089
+ }
1090
+
1091
+ // Collect @keyframes names and whether each one reads as a "pulse" —
1092
+ // i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
1093
+ // (spinners) are explicitly not pulses.
1094
+ function collectPulseKeyframes(content) {
1095
+ const map = new Map();
1096
+ const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
1097
+ let m;
1098
+ while ((m = re.exec(content)) !== null) {
1099
+ let depth = 1;
1100
+ let i = re.lastIndex;
1101
+ while (i < content.length && depth > 0) {
1102
+ const ch = content.charCodeAt(i);
1103
+ if (ch === 0x7b /* { */) depth++;
1104
+ else if (ch === 0x7d /* } */) depth--;
1105
+ i++;
1106
+ }
1107
+ const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
1108
+ const pulses = /\bopacity\s*:/i.test(body)
1109
+ || /\bbox-shadow\s*:/i.test(body)
1110
+ || /\btransform\s*:[^;{}]*\bscale/i.test(body);
1111
+ if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
1112
+ re.lastIndex = i;
1113
+ }
1114
+ return map;
1115
+ }
1116
+
1117
+ const ANIMATION_VALUE_KEYWORDS = new Set([
1118
+ 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
1119
+ 'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
1120
+ 'none', 'forwards', 'backwards', 'both', 'running', 'paused',
1121
+ 'step-start', 'step-end', 'inherit', 'initial', 'unset',
1122
+ ]);
1123
+
1124
+ // Extract animation names that run with iteration-count: infinite from a
1125
+ // declaration block (shorthand layers or animation-name + iteration-count).
1126
+ function infiniteAnimationNames(decls) {
1127
+ const out = [];
1128
+ const shorthand = decls.get('animation');
1129
+ if (shorthand) {
1130
+ for (const layer of shorthand.split(/,(?![^(]*\))/)) {
1131
+ if (!/\binfinite\b/i.test(layer)) continue;
1132
+ const name = layer.split(/\s+/).find(t =>
1133
+ /^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
1134
+ if (name) out.push(name);
1135
+ }
1136
+ }
1137
+ const nameDecl = decls.get('animation-name');
1138
+ if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
1139
+ for (const raw of nameDecl.split(',')) {
1140
+ const t = raw.trim();
1141
+ if (t && t.toLowerCase() !== 'none') out.push(t);
1142
+ }
1143
+ }
1144
+ return out;
1145
+ }
1146
+
1147
+ function isRoundDotRadius(radiusValue, w, h) {
1148
+ if (!radiusValue) return false;
1149
+ const first = String(radiusValue).trim().split(/\s+/)[0];
1150
+ const pct = first.match(/^([\d.]+)%$/);
1151
+ if (pct) return parseFloat(pct[1]) >= 40;
1152
+ const px = cssLengthToPx(first);
1153
+ if (px == null) return false;
1154
+ return px >= 999 || px >= 0.4 * Math.min(w, h);
1155
+ }
1156
+
1157
+ // Remove @media blocks whose condition is prefers-reduced-motion: reduce.
1158
+ // Those blocks describe the accessibility fallback, not the default
1159
+ // experience that ships — an `animation: none` reset inside one must not
1160
+ // mask the resting-state animation the page plays for everyone else.
1161
+ function stripReducedMotionBlocks(content) {
1162
+ const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
1163
+ let out = '';
1164
+ let last = 0;
1165
+ let m;
1166
+ while ((m = re.exec(content)) !== null) {
1167
+ let depth = 1;
1168
+ let i = re.lastIndex;
1169
+ while (i < content.length && depth > 0) {
1170
+ const ch = content.charCodeAt(i);
1171
+ if (ch === 0x7b /* { */) depth++;
1172
+ else if (ch === 0x7d /* } */) depth--;
1173
+ i++;
1174
+ }
1175
+ out += content.slice(last, m.index);
1176
+ last = i;
1177
+ re.lastIndex = i;
1178
+ }
1179
+ return out + content.slice(last);
1180
+ }
1181
+
1182
+ // Source-index ranges of <header> and <nav> landmark elements in an HTML
1183
+ // string. Lets string-level scans decide whether a matched element sits in
1184
+ // the page chrome (the hero/nav region) without needing a DOM.
1185
+ function landmarkSourceRanges(content) {
1186
+ const ranges = [];
1187
+ for (const tag of ['header', 'nav']) {
1188
+ const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
1189
+ const stack = [];
1190
+ let m;
1191
+ while ((m = re.exec(content)) !== null) {
1192
+ if (m[0].charAt(1) === '/') {
1193
+ const start = stack.pop();
1194
+ if (start != null) ranges.push([start, m.index]);
1195
+ } else {
1196
+ stack.push(m.index);
1197
+ }
1198
+ }
1199
+ }
1200
+ return ranges;
1201
+ }
1202
+
1203
+ function indexInSourceRanges(index, ranges) {
1204
+ return ranges.some(([start, end]) => index >= start && index < end);
1205
+ }
1206
+
1207
+ // Does any element targeted by the final compound of `selector` appear
1208
+ // inside a header/nav landmark range of the HTML source? Resolves the last
1209
+ // .class or #id token of the selector against class/id attributes; a
1210
+ // tag-only compound is never resolvable this way and returns false
1211
+ // (conservative: no promotion without placement evidence).
1212
+ function selectorHitsLandmark(content, selector, ranges) {
1213
+ if (!ranges || ranges.length === 0) return false;
1214
+ const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
1215
+ const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
1216
+ const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
1217
+ let attrRe = null;
1218
+ if (idMatch) {
1219
+ const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1220
+ attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
1221
+ } else if (classMatch) {
1222
+ const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1223
+ attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
1224
+ }
1225
+ if (!attrRe) return false;
1226
+ let m;
1227
+ while ((m = attrRe.exec(content)) !== null) {
1228
+ if (indexInSourceRanges(m.index, ranges)) return true;
1229
+ }
1230
+ return false;
1231
+ }
1232
+
1233
+ // Small circular indicator bound to an infinite pulse animation — the
1234
+ // decorative "live" dot. Gates: tiny (<= 16px square-ish), round
1235
+ // (border-radius >= 40% or pill values), and an infinite animation whose
1236
+ // keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
1237
+ // the keyframes aren't in the scanned text). Rotation-only animations
1238
+ // (spinners) never flag.
1239
+ //
1240
+ // Declarations for one selector are merged across rule blocks before the
1241
+ // predicate runs: size in the base rule plus the animation added in a
1242
+ // second block (or inside a matching @media block) is the construction
1243
+ // that ships. prefers-reduced-motion: reduce overrides are stripped first
1244
+ // so their animation resets don't mask the default experience. A dot whose
1245
+ // element sits inside a header/nav landmark is the hero liveness cliché
1246
+ // and is promoted to error severity; occurrences elsewhere keep the
1247
+ // registry default severity.
1248
+ //
1249
+ // `content` is CSS-bearing text (rules and keyframes); `markup` — defaulting
1250
+ // to the same string for single-corpus callers like the regex source
1251
+ // engine — is where landmark ranges and Tailwind class attributes live.
1252
+ function scanCssTextForPulsingDot(content, markup = content) {
1253
+ const customProps = collectCssCustomProps(content);
1254
+ const keyframes = collectPulseKeyframes(content);
1255
+ const heroRanges = landmarkSourceRanges(markup);
1256
+ const findings = [];
1257
+ const seen = new Set();
1258
+
1259
+ // Merge declarations per selector across rule blocks, approximating the
1260
+ // cascade: later declarations for the same property win. Comma lists are
1261
+ // split so `.a, .b { … }` contributes to both selectors. Comments are
1262
+ // stripped first so they neither pollute selector keys nor smuggle a
1263
+ // comma into the selector-list split.
1264
+ const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
1265
+ const merged = new Map();
1266
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
1267
+ let m;
1268
+ while ((m = ruleRe.exec(scanText)) !== null) {
1269
+ const decls = parseCssDeclBlock(m[2]);
1270
+ if (decls.size === 0) continue;
1271
+ for (const rawSelector of m[1].split(',')) {
1272
+ const selector = rawSelector.trim();
1273
+ if (!selector || selector.startsWith('@')) continue;
1274
+ let acc = merged.get(selector);
1275
+ if (!acc) {
1276
+ acc = new Map();
1277
+ merged.set(selector, acc);
1278
+ }
1279
+ for (const [prop, value] of decls) acc.set(prop, value);
1280
+ }
1281
+ }
1282
+
1283
+ for (const [selector, decls] of merged) {
1284
+ const names = infiniteAnimationNames(decls);
1285
+ if (names.length === 0) continue;
1286
+ const pulseName = names.find(n => {
1287
+ const known = keyframes.get(n);
1288
+ if (known != null) return known;
1289
+ return /pulse|blink|ping/i.test(n);
1290
+ });
1291
+ if (!pulseName) continue;
1292
+
1293
+ const w = cssLengthToPx(resolveVarRefs(
1294
+ decls.get('width') || decls.get('inline-size') || '', customProps));
1295
+ const h = cssLengthToPx(resolveVarRefs(
1296
+ decls.get('height') || decls.get('block-size') || '', customProps));
1297
+ if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
1298
+
1299
+ const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
1300
+ if (!isRoundDotRadius(radius, w, h)) continue;
1301
+
1302
+ if (seen.has(selector)) continue;
1303
+ seen.add(selector);
1304
+ const inLandmark = selectorHitsLandmark(markup, selector, heroRanges);
1305
+ findings.push({
1306
+ id: 'pulsing-dot',
1307
+ snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
1308
+ selector,
1309
+ ...(inLandmark ? { severity: 'error' } : {}),
1310
+ });
1311
+ }
1312
+
1313
+ // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
1314
+ // element declared entirely in the class attribute. Scanned in the markup
1315
+ // corpus so the match index lines up with the landmark ranges.
1316
+ const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
1317
+ let cm;
1318
+ while ((cm = classRe.exec(markup)) !== null) {
1319
+ const cls = cm[1] || cm[2] || '';
1320
+ const anim = cls.match(/\banimate-(ping|pulse)\b/);
1321
+ if (!anim) continue;
1322
+ if (!/\brounded-full\b/.test(cls)) continue;
1323
+ if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
1324
+ const key = `tw:${cls}`;
1325
+ if (seen.has(key)) continue;
1326
+ seen.add(key);
1327
+ const inLandmark = indexInSourceRanges(cm.index, heroRanges);
1328
+ findings.push({
1329
+ id: 'pulsing-dot',
1330
+ snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
1331
+ ...(inLandmark ? { severity: 'error' } : {}),
1332
+ });
1333
+ }
1334
+
1335
+ return findings;
1336
+ }
1337
+
1338
+ // Shape-assembled illustration: a large inline SVG composing a pictorial
1339
+ // scene from many primitive shapes (rect / circle / ellipse / polygon) in
1340
+ // several fill colors — the clip-art hero mascot. Gates keep the legitimate
1341
+ // SVG population out:
1342
+ // • icons and logos: intrinsic size gate (>= 200px on both axes, from
1343
+ // width/height attributes or the viewBox when no explicit size is set)
1344
+ // • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
1345
+ // the graphic (axis labels, callouts)
1346
+ // • line drawings / technical diagrams: primitive count < 8 or fewer
1347
+ // than 3 distinct fills never qualifies (stroke-only art has no fills)
1348
+ // • tiling background textures: any <pattern> definition exempts
1349
+ function scanHtmlForShapeAssembledIllustration(html) {
1350
+ const findings = [];
1351
+ const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
1352
+ let m;
1353
+ while ((m = svgRe.exec(html)) !== null) {
1354
+ const block = m[0];
1355
+ const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
1356
+
1357
+ // Data-bearing or annotated graphics: axis labels and callout text
1358
+ // mark a chart or diagram, not a mascot.
1359
+ const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
1360
+ if (textCount > 2) continue;
1361
+ // Tiling texture definitions are decorative backgrounds, not scenes.
1362
+ if (/<pattern\b/i.test(block)) continue;
1363
+
1364
+ const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
1365
+ if (primitives < 8) continue;
1366
+
1367
+ // Intrinsic size: explicit width/height attributes win; fall back to
1368
+ // the viewBox box. Percentage or missing sizes stay unresolvable on
1369
+ // that axis and the viewBox speaks for them.
1370
+ const attrDim = (name) => {
1371
+ // (?<![-\w]) keeps compound attributes like stroke-width from
1372
+ // masquerading as the svg's own width.
1373
+ const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
1374
+ return am ? parseFloat(am[1]) : null;
1375
+ };
1376
+ const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
1377
+ const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
1378
+ const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
1379
+ if (w == null || h == null || w < 200 || h < 200) continue;
1380
+
1381
+ // Distinct fill paints (attributes and inline styles), excluding
1382
+ // non-paints. Multiple fills are what turn a shape pile into a scene.
1383
+ const fills = new Set();
1384
+ for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
1385
+ const paint = fm[1].trim().toLowerCase();
1386
+ if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
1387
+ fills.add(paint);
1388
+ }
1389
+ if (fills.size < 3) continue;
1390
+
1391
+ findings.push({
1392
+ id: 'shape-assembled-illustration',
1393
+ snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
1394
+ });
1395
+ }
1396
+ return findings;
1397
+ }
1398
+
1399
+ // Scoped scan corpora for the page-level pattern checks. CSS-property
1400
+ // regexes run over the whole source string fire on documentation ABOUT
1401
+ // css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
1402
+ // comments — so the checks scan only the strings that actually style the
1403
+ // page:
1404
+ // styleText — <style> block contents plus style="…" attribute values.
1405
+ // Attribute values keep their `style="…"` form so block-scoped
1406
+ // scanners (grid background) keep treating each attribute as one
1407
+ // declaration block, exactly as they did against raw source. Engines
1408
+ // that already read more CSS (linked stylesheets) prepend it.
1409
+ // classText — class attribute values, for utility-class scans.
1410
+ // Markup-shaped checks (inline <svg> scenes, <img> tags, <marquee>,
1411
+ // landmark ranges) and rendered-text checks (theater phrases) keep the
1412
+ // full source. This extraction serves callers without a parsed document
1413
+ // (the browser bundle scanning outerHTML); attribute reads are tag-scoped
1414
+ // so escaped code samples (&lt;div style="…"&gt;) never contribute. The
1415
+ // static engine passes richer corpora built from its parsed document.
1416
+ // Bare CSS input (no markup at all) is its own style text, which keeps
1417
+ // direct checkHtmlPatterns(css) callers behaving as before.
1418
+ function buildHtmlPatternCorpora(html) {
1419
+ const source = String(html || '');
1420
+ if (!/<[a-zA-Z!/]/.test(source)) {
1421
+ return { styleText: source, classText: source };
1422
+ }
1423
+ const styleParts = [];
1424
+ const classParts = [];
1425
+ const styleBlockRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
1426
+ let m;
1427
+ while ((m = styleBlockRe.exec(source)) !== null) styleParts.push(m[1]);
1428
+ const tagRe = /<[a-zA-Z][^>]*>/g;
1429
+ while ((m = tagRe.exec(source)) !== null) {
1430
+ const tag = m[0];
1431
+ const sm = tag.match(/\bstyle\s*=\s*("[^"]*"|'[^']*')/i);
1432
+ if (sm) styleParts.push(`style=${sm[1]}`);
1433
+ const cm = tag.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
1434
+ if (cm) classParts.push(cm[1] ?? cm[2] ?? '');
1435
+ }
1436
+ return { styleText: styleParts.join('\n'), classText: classParts.join('\n') };
1437
+ }
1438
+
442
1439
  /**
443
1440
  * Regex-on-HTML checks shared between browser and Node page-level detection.
444
- * These don't need DOM access, just the raw HTML string.
1441
+ * These don't need DOM access, just the raw HTML string. CSS-property and
1442
+ * utility-class patterns scan the scoped corpora (styleText / classText —
1443
+ * see buildHtmlPatternCorpora) so prose about css never flags; only the
1444
+ * markup-shaped and rendered-text checks read the full source.
445
1445
  */
446
- function checkHtmlPatterns(html) {
1446
+ function checkHtmlPatterns(html, corpora) {
1447
+ const { styleText, classText } = corpora || buildHtmlPatternCorpora(html);
447
1448
  const findings = [];
448
1449
 
449
1450
  // --- Color ---
450
1451
 
451
1452
  // AI color palette: purple/violet
452
1453
  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
453
- if (purpleHexRe.test(html)) {
1454
+ if (purpleHexRe.test(styleText)) {
454
1455
  const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
455
- if (purpleTextRe.test(html)) {
1456
+ if (purpleTextRe.test(styleText)) {
456
1457
  findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
457
1458
  }
458
1459
  }
@@ -460,38 +1461,48 @@ function checkHtmlPatterns(html) {
460
1461
  // Gradient text (background-clip: text + gradient)
461
1462
  const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
462
1463
  let gm;
463
- while ((gm = gradientRe.exec(html)) !== null) {
1464
+ while ((gm = gradientRe.exec(styleText)) !== null) {
464
1465
  const start = Math.max(0, gm.index - 200);
465
- const context = html.substring(start, gm.index + gm[0].length + 200);
1466
+ const context = styleText.substring(start, gm.index + gm[0].length + 200);
466
1467
  if (/gradient/i.test(context)) {
467
1468
  findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
468
1469
  break;
469
1470
  }
470
1471
  }
471
- if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
1472
+ if (/\bbg-clip-text\b/.test(classText) && /\bbg-gradient-to-/.test(classText)) {
472
1473
  findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
473
1474
  }
474
1475
 
1476
+ // --- Borders ---
1477
+
1478
+ // Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
1479
+ // (no border property involved, so the element-level border checks and
1480
+ // the border-left regexes never see it).
1481
+ findings.push(...scanCssTextForPseudoStripe(styleText));
1482
+
1483
+ // Side-tab accent stripe drawn as a single-edge inset box-shadow.
1484
+ findings.push(...scanCssTextForInsetStripe(styleText));
1485
+
475
1486
  // --- Layout ---
476
1487
 
477
1488
  // Monotonous spacing
478
1489
  const spacingValues = [];
479
1490
  const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
480
1491
  let sm;
481
- while ((sm = spacingRe.exec(html)) !== null) {
1492
+ while ((sm = spacingRe.exec(styleText)) !== null) {
482
1493
  const v = parseInt(sm[1], 10);
483
1494
  if (v > 0 && v < 200) spacingValues.push(v);
484
1495
  }
485
1496
  const gapRe = /gap\s*:\s*(\d+)px/gi;
486
- while ((sm = gapRe.exec(html)) !== null) {
1497
+ while ((sm = gapRe.exec(styleText)) !== null) {
487
1498
  spacingValues.push(parseInt(sm[1], 10));
488
1499
  }
489
1500
  const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
490
- while ((sm = twSpaceRe.exec(html)) !== null) {
1501
+ while ((sm = twSpaceRe.exec(classText)) !== null) {
491
1502
  spacingValues.push(parseInt(sm[1], 10) * 4);
492
1503
  }
493
1504
  const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
494
- while ((sm = remSpacingRe.exec(html)) !== null) {
1505
+ while ((sm = remSpacingRe.exec(styleText)) !== null) {
495
1506
  const v = Math.round(parseFloat(sm[1]) * 16);
496
1507
  if (v > 0 && v < 200) spacingValues.push(v);
497
1508
  }
@@ -515,7 +1526,7 @@ function checkHtmlPatterns(html) {
515
1526
 
516
1527
  // Bounce/elastic animation names
517
1528
  const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
518
- const bounceMatch = bounceRe.exec(html);
1529
+ const bounceMatch = bounceRe.exec(styleText);
519
1530
  if (bounceMatch) {
520
1531
  const animationToken = bounceMatch[1]
521
1532
  .split(/[,\s]+/)
@@ -526,7 +1537,7 @@ function checkHtmlPatterns(html) {
526
1537
  // Overshoot cubic-bezier
527
1538
  const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
528
1539
  let bm;
529
- while ((bm = bezierRe.exec(html)) !== null) {
1540
+ while ((bm = bezierRe.exec(styleText)) !== null) {
530
1541
  const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
531
1542
  if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
532
1543
  findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
@@ -537,7 +1548,7 @@ function checkHtmlPatterns(html) {
537
1548
  // Layout property transitions
538
1549
  const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
539
1550
  let tm;
540
- while ((tm = transRe.exec(html)) !== null) {
1551
+ while ((tm = transRe.exec(styleText)) !== null) {
541
1552
  const val = tm[1].toLowerCase();
542
1553
  if (/\ball\b/.test(val)) continue;
543
1554
  const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
@@ -547,33 +1558,54 @@ function checkHtmlPatterns(html) {
547
1558
  }
548
1559
  }
549
1560
 
550
- // --- Dark glow ---
1561
+ // Pulsing status dots (tiny circular elements on infinite pulse animations).
1562
+ // The CSS rules come from styleText; the markup carries the landmark
1563
+ // ranges and Tailwind class attributes.
1564
+ findings.push(...scanCssTextForPulsingDot(styleText, html));
551
1565
 
552
- const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
553
- const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
554
- if (darkBgRe.test(html) || twDarkBg.test(html)) {
555
- const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
556
- let shm;
557
- while ((shm = shadowRe.exec(html)) !== null) {
558
- const val = shm[1];
559
- const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
560
- if (!colorMatch) continue;
561
- const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
562
- if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
563
- const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
564
- if (pxVals.length >= 3 && pxVals[2] > 4) {
565
- findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
566
- break;
567
- }
568
- }
1566
+ // Shape-assembled illustrations (large pictorial SVGs built from primitives)
1567
+ findings.push(...scanHtmlForShapeAssembledIllustration(html));
1568
+
1569
+ // Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
1570
+ findings.push(...scanCssTextForMarquee(styleText, html));
1571
+
1572
+ // --- Dark glow / chromatic halo shadows ---
1573
+
1574
+ const glowHits = scanCssTextForGlow(styleText);
1575
+ if (glowHits.length > 0) {
1576
+ findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
1577
+ }
1578
+
1579
+ // Radial-gradient background halo (gradient-drawn sibling of dark-glow)
1580
+ const haloHits = scanCssTextForRadialHalo(styleText);
1581
+ if (haloHits.length > 0) {
1582
+ findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
569
1583
  }
570
1584
 
571
- // --- Provider tells (gated): repeating-gradient stripes (GPT) ---
572
- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
1585
+ // --- Generated-UI tells: repeating-gradient stripes ---
1586
+ if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
573
1587
  findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
574
1588
  }
575
1589
 
576
- // --- Provider tells (gated): "X theater" framing copy (GPT) ---
1590
+ // --- Generated-UI tells: two-axis grid-line background ---
1591
+ // The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
1592
+ // transparent 1px)` layers (one per axis) tiled by a repeating
1593
+ // `background-size` cell. Both signals must co-occur in the SAME style block
1594
+ // (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
1595
+ // tiling background-size is a fixed crosshair, not a grid, and a single
1596
+ // hairline is a legitimate ruled line. Scoping to one block also stops
1597
+ // unrelated single-axis rules on separate elements from adding up across the
1598
+ // page. Count hairlines only inside `background`/`background-image` values so
1599
+ // a hairline in an unrelated property (mask-image, border-image) can't stand
1600
+ // in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
1601
+ // nested parens, so match the hairline stop directly rather than parsing
1602
+ // whole gradient layers.
1603
+ const gridHits = scanCssTextForGridBackground(styleText);
1604
+ if (gridHits.length > 0) {
1605
+ findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
1606
+ }
1607
+
1608
+ // --- Generated-copy tells: "X theater" framing copy ---
577
1609
  // Lives here (regex-on-HTML) rather than in the text-content analyzers so it
578
1610
  // runs in the bundled browser path too, not just the CLI/static path.
579
1611
  {
@@ -585,12 +1617,12 @@ function checkHtmlPatterns(html) {
585
1617
  if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
586
1618
  }
587
1619
 
588
- // --- Provider tells (gated): image hover transform (Gemini) ---
1620
+ // --- Generated-UI tells: image hover transform ---
589
1621
  // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
590
1622
  // hover:rotate / hover:translate utility on an <img>. Each distinct
591
1623
  // mechanism is its own finding.
592
1624
  const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
593
- if (imgHoverCss.test(html)) {
1625
+ if (imgHoverCss.test(styleText)) {
594
1626
  findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
595
1627
  }
596
1628
  const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
@@ -614,7 +1646,11 @@ function checkHtmlPatterns(html) {
614
1646
  // `background: #abc`. Real browsers always decompose, so the fallback is
615
1647
  // a no-op there.
616
1648
  function readOwnBackgroundColor(el, computedStyle) {
617
- const bg = parseRgb(computedStyle.backgroundColor);
1649
+ // Real browsers keep wide-gamut/computed color functions (oklch(), oklab(),
1650
+ // color-mix() results) in getComputedStyle output, which plain parseRgb
1651
+ // misses — a flat oklch button background would silently skip every
1652
+ // contrast check without the parseAnyColor fallback.
1653
+ const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor);
618
1654
  if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
619
1655
  const rawStyle = el.getAttribute?.('style') || '';
620
1656
  const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
@@ -636,6 +1672,18 @@ function readOwnBackgroundColor(el, computedStyle) {
636
1672
 
637
1673
  function resolveBackground(el, win, customPropMap) {
638
1674
  let current = el;
1675
+ // Translucent layers (0.1 < a < 1) found on the way down to an opaque
1676
+ // base. A browser composites these over the base; the old behavior
1677
+ // either returned them as-if-opaque (browser mode) or skipped them
1678
+ // entirely (static mode), both of which misstate the effective surface
1679
+ // for contrast checks (e.g. `background: color-mix(in oklab, var(--hot)
1680
+ // 16%, transparent)` chips on dark pages).
1681
+ const overlays = [];
1682
+ const flatten = (base) => {
1683
+ let acc = base;
1684
+ for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
1685
+ return acc;
1686
+ };
639
1687
  while (current && current.nodeType === 1) {
640
1688
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
641
1689
  const bgImage = style.backgroundImage || '';
@@ -648,7 +1696,9 @@ function resolveBackground(el, win, customPropMap) {
648
1696
  // decorative. The old behavior bailed on any gradient ancestor, which
649
1697
  // caused massive false-positive contrast findings on grain-textured
650
1698
  // body backgrounds.
651
- let bg = parseRgb(style.backgroundColor);
1699
+ // Real browsers serialize wide-gamut computed values as oklab()/oklch()
1700
+ // (e.g. any color-mix() result), which plain parseRgb misses.
1701
+ let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
652
1702
  if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
653
1703
  // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
654
1704
  // through customPropMap so Tailwind v4 color tokens become RGB.
@@ -668,7 +1718,8 @@ function resolveBackground(el, win, customPropMap) {
668
1718
  }
669
1719
 
670
1720
  if (bg && bg.a > 0.1) {
671
- if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
1721
+ if (bg.a >= 0.99) return flatten(bg);
1722
+ overlays.push(bg);
672
1723
  }
673
1724
  // No solid bg-color at this level. If THIS level has a gradient/url
674
1725
  // with no underlying solid color we can read:
@@ -684,41 +1735,66 @@ function resolveBackground(el, win, customPropMap) {
684
1735
  // bgs worth checking against).
685
1736
  if (hasGradientOrUrl) {
686
1737
  if (current.tagName === 'BODY' || current.tagName === 'HTML') {
687
- return { r: 255, g: 255, b: 255, a: 1 };
1738
+ return flatten({ r: 255, g: 255, b: 255, a: 1 });
688
1739
  }
689
1740
  return null;
690
1741
  }
691
1742
  current = current.parentElement;
692
1743
  }
693
- return { r: 255, g: 255, b: 255 };
1744
+ return flatten({ r: 255, g: 255, b: 255, a: 1 });
694
1745
  }
695
1746
 
696
1747
  // Walk parents looking for a gradient background and return its color stops.
697
1748
  // Used as a fallback when resolveBackground() returns null because the
698
1749
  // effective background is a gradient (no single solid color to compare against).
699
- function resolveGradientStops(el, win) {
1750
+ function resolveGradientStops(el, win, customPropMap) {
700
1751
  let current = el;
701
1752
  while (current && current.nodeType === 1) {
702
1753
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
703
1754
  const bgImage = style.backgroundImage || '';
1755
+ let stops = null;
704
1756
  if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
705
- const stops = parseGradientColors(bgImage);
706
- if (stops.length > 0) return stops;
1757
+ const parsed = parseGradientColors(bgImage);
1758
+ if (parsed.length > 0) stops = parsed;
707
1759
  }
708
- if (!DETECTOR_IS_BROWSER) {
1760
+ if (!stops && !DETECTOR_IS_BROWSER) {
709
1761
  // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
710
1762
  const rawStyle = current.getAttribute?.('style') || '';
711
1763
  const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
712
1764
  if (bgMatch && /gradient/i.test(bgMatch[1])) {
713
- const stops = parseGradientColors(bgMatch[1]);
714
- if (stops.length > 0) return stops;
1765
+ const parsed = parseGradientColors(bgMatch[1]);
1766
+ if (parsed.length > 0) stops = parsed;
715
1767
  }
716
1768
  }
1769
+ if (stops) return compositeGradientStops(stops, current, win, customPropMap);
717
1770
  current = current.parentElement;
718
1771
  }
719
1772
  return null;
720
1773
  }
721
1774
 
1775
+ // A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent
1776
+ // glow) paints over whatever surface sits beneath the gradient — the browser
1777
+ // composites it, so its effective color is far closer to the base than to the
1778
+ // full-opacity accent. Treating the stop as opaque flags every text child of a
1779
+ // softly-glowing section as low-contrast (issue #409 Case B). Composite each
1780
+ // alpha stop over the resolved surface beneath the gradient element. When that
1781
+ // surface isn't resolvable (another gradient above, no opaque ancestor), drop
1782
+ // the translucent stop rather than guess: a dropped stop can't manufacture a
1783
+ // false finding, and skipping beats a wrong ratio.
1784
+ function compositeGradientStops(stops, gradientEl, win, customPropMap) {
1785
+ const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99);
1786
+ if (!hasAlpha) return stops;
1787
+ const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap);
1788
+ const out = [];
1789
+ for (const s of stops) {
1790
+ const a = s.a ?? 1;
1791
+ if (a >= 0.99) { out.push(s); continue; }
1792
+ if (base) out.push(compositeColorOver(s, base));
1793
+ // else: unresolvable base — drop the translucent stop (skip, don't guess).
1794
+ }
1795
+ return out.length ? out : null;
1796
+ }
1797
+
722
1798
  // Parse a single CSS length token to pixels. Accepts "12px", "50%", a
723
1799
  // shorthand like "12px 4px" (uses the first value), or empty / null.
724
1800
  // Returns the pixel value, or null when the input is unparseable.
@@ -752,6 +1828,43 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
752
1828
 
753
1829
  // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
754
1830
 
1831
+ // Selected-state context for accent stripes. Only an actual selection
1832
+ // marker exempts the stripe as the standard active-item indicator:
1833
+ // aria-selected="true", aria-current (any non-false value), or an
1834
+ // active/current/selected class hint. Tab-strip MEMBERSHIP alone
1835
+ // ([role=tablist]/[role=tab]/.tabs ancestry, aria-selected="false")
1836
+ // deliberately does not — a chromatic stripe repeated on every tab in
1837
+ // the group, or on every menu item, is decoration, not state; the
1838
+ // selected item's own underline stays legal.
1839
+ function isTabContextElement(el) {
1840
+ if (!el) return false;
1841
+ try {
1842
+ if (el.closest?.('[aria-selected="true"], [aria-current]:not([aria-current="false"])')) return true;
1843
+ } catch { /* selector engine differences — fall through to class scan */ }
1844
+ let cur = el, depth = 0;
1845
+ while (cur && cur.nodeType === 1 && depth < 6) {
1846
+ const cls = String(cur.getAttribute?.('class') || cur.className || '');
1847
+ if (/(?:^|[\s_-])(?:active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
1848
+ cur = cur.parentElement;
1849
+ depth++;
1850
+ }
1851
+ return false;
1852
+ }
1853
+
1854
+ // Status-surface context for accent borders. On a live status/alert region
1855
+ // (role=status|alert|alertdialog|log, or aria-live=polite|assertive) a colored
1856
+ // single-edge border is the established severity-accent convention — a toast,
1857
+ // snackbar, or callout bar — not the decorative side-tab tell. The element
1858
+ // itself or a wrapping live region qualifies. This never fires from the
1859
+ // CSS-only / regex scanners, which have no role information.
1860
+ function isStatusContextElement(el) {
1861
+ if (!el) return false;
1862
+ try {
1863
+ if (el.closest?.('[role="status"], [role="alert"], [role="alertdialog"], [role="log"], [aria-live="polite"], [aria-live="assertive"]')) return true;
1864
+ } catch { /* selector engine differences — fall through */ }
1865
+ return false;
1866
+ }
1867
+
755
1868
  function checkElementBordersDOM(el) {
756
1869
  const tag = el.tagName.toLowerCase();
757
1870
  if (BORDER_SAFE_TAGS.has(tag)) return [];
@@ -764,7 +1877,105 @@ function checkElementBordersDOM(el) {
764
1877
  widths[s] = parseFloat(style[`border${s}Width`]) || 0;
765
1878
  colors[s] = style[`border${s}Color`] || '';
766
1879
  }
767
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
1880
+ const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
1881
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, {
1882
+ tabContext: isTabContextElement(el),
1883
+ statusContext: isStatusContextElement(el),
1884
+ badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
1885
+ });
1886
+ }
1887
+
1888
+ // Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads
1889
+ // stylesheet source, so a stripe whose color only exists at runtime (an
1890
+ // inline per-card custom property, a JS-assigned var) or whose geometry
1891
+ // resolves in layout never matches it. In a real browser the pseudo-element's
1892
+ // computed style carries the actual used color and px geometry — check those
1893
+ // directly. Gates mirror the text scanner: 3-12px thick, chromatic fill,
1894
+ // spanning (nearly) the full edge; corner rounding on the host card is
1895
+ // irrelevant. Exemptions stay narrow: structural/prose tags, real selection
1896
+ // markers (isTabContextElement), and button/link affordances for the
1897
+ // horizontal variant.
1898
+ function checkElementPseudoStripeDOM(el) {
1899
+ const tag = el.tagName.toLowerCase();
1900
+ if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return [];
1901
+ if (el.closest?.('nav, blockquote, pre')) return [];
1902
+ if (!isRenderedForBrowserRule(el)) return [];
1903
+ const rect = el.getBoundingClientRect();
1904
+ if (rect.width < 40 || rect.height < 20) return [];
1905
+ if (isTabContextElement(el)) return [];
1906
+
1907
+ const findings = [];
1908
+ for (const which of ['::before', '::after']) {
1909
+ let ps;
1910
+ try { ps = getComputedStyle(el, which); } catch { continue; }
1911
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
1912
+ if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
1913
+ if ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue;
1914
+ const w = parseFloat(ps.width) || 0;
1915
+ const h = parseFloat(ps.height) || 0;
1916
+ if (!(w > 0 && h > 0)) continue;
1917
+
1918
+ // Used values: for absolutely-positioned boxes the browser resolves
1919
+ // both edge offsets after layout, so left/right (and top/bottom) are
1920
+ // real distances, never "auto".
1921
+ const left = parseFloat(ps.left);
1922
+ const right = parseFloat(ps.right);
1923
+ const top = parseFloat(ps.top);
1924
+ const bottom = parseFloat(ps.bottom);
1925
+ const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2;
1926
+
1927
+ let edge = null;
1928
+ let thickness = null;
1929
+ // Vertical stripe: narrow box spanning (nearly) the full height of the
1930
+ // host, hugging its left or right edge. "Nearly" tolerates the floating
1931
+ // variant that backs off each end by a small inset.
1932
+ if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) {
1933
+ edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null;
1934
+ thickness = w;
1935
+ }
1936
+ // Horizontal stripe riding the top or bottom edge. Button/link-styled
1937
+ // hosts keep their underline affordances.
1938
+ if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) {
1939
+ const cls = String(el.getAttribute?.('class') || el.className || '');
1940
+ if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) {
1941
+ edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null;
1942
+ thickness = h;
1943
+ }
1944
+ }
1945
+ if (!edge) continue;
1946
+
1947
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
1948
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
1949
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) < 30) continue;
1950
+
1951
+ findings.push({
1952
+ id: 'side-tab',
1953
+ snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
1954
+ });
1955
+ }
1956
+ return findings;
1957
+ }
1958
+
1959
+ // Full-cover surface pseudo (browser): a ::before/::after positioned
1960
+ // absolute/fixed whose box covers (nearly) the whole host and carries an
1961
+ // opaque background. That pseudo is the element's visible surface even
1962
+ // though the element's own background-color reads transparent — the nav-CTA
1963
+ // construction that otherwise escapes every own-background contrast gate.
1964
+ function readPseudoSurfaceDOM(el, rect) {
1965
+ for (const which of ['::before', '::after']) {
1966
+ let ps;
1967
+ try { ps = getComputedStyle(el, which); } catch { continue; }
1968
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
1969
+ if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
1970
+ if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
1971
+ const w = parseFloat(ps.width) || 0;
1972
+ const h = parseFloat(ps.height) || 0;
1973
+ if (w < rect.width - 4 || h < rect.height - 4) continue;
1974
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
1975
+ if (!bg || (bg.a ?? 1) < 0.9) continue;
1976
+ return bg;
1977
+ }
1978
+ return null;
768
1979
  }
769
1980
 
770
1981
  function checkElementColorsDOM(el) {
@@ -777,11 +1988,24 @@ function checkElementColorsDOM(el) {
777
1988
  const style = getComputedStyle(el);
778
1989
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
779
1990
  const hasDirectText = directText.trim().length > 0;
780
- const effectiveBg = resolveBackground(el);
1991
+ let effectiveBg = resolveBackground(el);
1992
+ let ownBg = readOwnBackgroundColor(el, style);
1993
+ if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
1994
+ const pseudoSurface = readPseudoSurfaceDOM(el, rect);
1995
+ if (pseudoSurface) {
1996
+ ownBg = pseudoSurface;
1997
+ effectiveBg = pseudoSurface;
1998
+ }
1999
+ }
781
2000
  return checkColors({
782
2001
  tag,
783
- textColor: parseRgb(style.color),
784
- bgColor: readOwnBackgroundColor(el, style),
2002
+ // Chrome serializes computed colors specified in modern spaces as
2003
+ // oklch()/oklab() strings; without the parseAnyColor fallback the text
2004
+ // color comes back null and the low-contrast / gray-on-color checks
2005
+ // silently never run (the shipped miss: a nav CTA whose text color was
2006
+ // an oklch token near its own oklch background).
2007
+ textColor: parseRgb(style.color) || parseAnyColor(style.color),
2008
+ bgColor: ownBg,
785
2009
  effectiveBg,
786
2010
  effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
787
2011
  fontSize: parseFloat(style.fontSize) || 16,
@@ -842,6 +2066,21 @@ function checkElementItalicSerifDOM(el) {
842
2066
  });
843
2067
  }
844
2068
 
2069
+ function domAccentDashPseudo(el) {
2070
+ for (const which of ['::before', '::after']) {
2071
+ let ps;
2072
+ try { ps = getComputedStyle(el, which); } catch { continue; }
2073
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
2074
+ const w = parseFloat(ps.width) || 0;
2075
+ const h = parseFloat(ps.height) || 0;
2076
+ if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
2077
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2078
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
2079
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
2080
+ }
2081
+ return false;
2082
+ }
2083
+
845
2084
  function checkElementHeroEyebrowDOM(el) {
846
2085
  const tag = el.tagName.toLowerCase();
847
2086
  if (tag !== 'h1') return [];
@@ -853,6 +2092,7 @@ function checkElementHeroEyebrowDOM(el) {
853
2092
  headingTag: tag,
854
2093
  headingText: el.textContent || '',
855
2094
  headingFontSize: parseFloat(headStyle.fontSize) || 0,
2095
+ headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
856
2096
  siblingTag: sibling.tagName.toLowerCase(),
857
2097
  siblingText: sibling.textContent || '',
858
2098
  siblingTextTransform: sibStyle.textTransform || '',
@@ -860,6 +2100,7 @@ function checkElementHeroEyebrowDOM(el) {
860
2100
  siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
861
2101
  siblingFontWeight: sibStyle.fontWeight || '',
862
2102
  siblingColor: sibStyle.color || '',
2103
+ siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
863
2104
  });
864
2105
  }
865
2106
 
@@ -924,8 +2165,10 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
924
2165
  // detector's contrast / color checks.
925
2166
  function oklchToRgb(L, C, H) {
926
2167
  const hRad = (H * Math.PI) / 180;
927
- const a = C * Math.cos(hRad);
928
- const b = C * Math.sin(hRad);
2168
+ return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
2169
+ }
2170
+
2171
+ function oklabToRgb(L, a, b) {
929
2172
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
930
2173
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
931
2174
  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
@@ -945,13 +2188,178 @@ function oklchToRgb(L, C, H) {
945
2188
  };
946
2189
  }
947
2190
 
948
- // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
949
- // Use this when the input might be any CSS color form; use plain parseRgb
950
- // when you only expect computed rgb() values from real browsers.
2191
+ function hslToRgb(h, s, l) {
2192
+ h = ((h % 360) + 360) % 360;
2193
+ const c = (1 - Math.abs(2 * l - 1)) * s;
2194
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
2195
+ const m0 = l - c / 2;
2196
+ const [r, g, b] =
2197
+ h < 60 ? [c, x, 0] :
2198
+ h < 120 ? [x, c, 0] :
2199
+ h < 180 ? [0, c, x] :
2200
+ h < 240 ? [0, x, c] :
2201
+ h < 300 ? [x, 0, c] : [c, 0, x];
2202
+ return {
2203
+ r: Math.round((r + m0) * 255),
2204
+ g: Math.round((g + m0) * 255),
2205
+ b: Math.round((b + m0) * 255),
2206
+ a: 1,
2207
+ };
2208
+ }
2209
+
2210
+ function hwbToRgb(h, w, bl) {
2211
+ if (w + bl >= 1) {
2212
+ const g = Math.round((w / (w + bl)) * 255);
2213
+ return { r: g, g, b: g, a: 1 };
2214
+ }
2215
+ const base = hslToRgb(h, 1, 0.5);
2216
+ const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
2217
+ return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
2218
+ }
2219
+
2220
+ // Common CSS named colors — the handful that actually show up in generated
2221
+ // UIs, not the full 148-name spec list. Includes the achromatic names so a
2222
+ // named gray parses (and correctly reads as no-chroma) instead of being
2223
+ // treated as an unknown color.
2224
+ const CSS_NAMED_COLORS = {
2225
+ black: { r: 0, g: 0, b: 0 },
2226
+ white: { r: 255, g: 255, b: 255 },
2227
+ gray: { r: 128, g: 128, b: 128 },
2228
+ grey: { r: 128, g: 128, b: 128 },
2229
+ silver: { r: 192, g: 192, b: 192 },
2230
+ dimgray: { r: 105, g: 105, b: 105 },
2231
+ darkgray: { r: 169, g: 169, b: 169 },
2232
+ lightgray: { r: 211, g: 211, b: 211 },
2233
+ gainsboro: { r: 220, g: 220, b: 220 },
2234
+ whitesmoke: { r: 245, g: 245, b: 245 },
2235
+ red: { r: 255, g: 0, b: 0 },
2236
+ crimson: { r: 220, g: 20, b: 60 },
2237
+ tomato: { r: 255, g: 99, b: 71 },
2238
+ coral: { r: 255, g: 127, b: 80 },
2239
+ salmon: { r: 250, g: 128, b: 114 },
2240
+ orange: { r: 255, g: 165, b: 0 },
2241
+ gold: { r: 255, g: 215, b: 0 },
2242
+ yellow: { r: 255, g: 255, b: 0 },
2243
+ olive: { r: 128, g: 128, b: 0 },
2244
+ lime: { r: 0, g: 255, b: 0 },
2245
+ green: { r: 0, g: 128, b: 0 },
2246
+ teal: { r: 0, g: 128, b: 128 },
2247
+ turquoise: { r: 64, g: 224, b: 208 },
2248
+ cyan: { r: 0, g: 255, b: 255 },
2249
+ aqua: { r: 0, g: 255, b: 255 },
2250
+ skyblue: { r: 135, g: 206, b: 235 },
2251
+ dodgerblue: { r: 30, g: 144, b: 255 },
2252
+ blue: { r: 0, g: 0, b: 255 },
2253
+ navy: { r: 0, g: 0, b: 128 },
2254
+ indigo: { r: 75, g: 0, b: 130 },
2255
+ rebeccapurple: { r: 102, g: 51, b: 153 },
2256
+ purple: { r: 128, g: 0, b: 128 },
2257
+ violet: { r: 238, g: 130, b: 238 },
2258
+ orchid: { r: 218, g: 112, b: 214 },
2259
+ magenta: { r: 255, g: 0, b: 255 },
2260
+ fuchsia: { r: 255, g: 0, b: 255 },
2261
+ hotpink: { r: 255, g: 105, b: 180 },
2262
+ pink: { r: 255, g: 192, b: 203 },
2263
+ maroon: { r: 128, g: 0, b: 0 },
2264
+ };
2265
+
2266
+ // Split a string on top-level commas (ignoring commas nested in parens).
2267
+ function splitTopLevelCommas(str) {
2268
+ const parts = [];
2269
+ let depth = 0, start = 0;
2270
+ for (let i = 0; i < str.length; i++) {
2271
+ const ch = str[i];
2272
+ if (ch === '(') depth++;
2273
+ else if (ch === ')') depth = Math.max(0, depth - 1);
2274
+ else if (ch === ',' && depth === 0) {
2275
+ parts.push(str.slice(start, i).trim());
2276
+ start = i + 1;
2277
+ }
2278
+ }
2279
+ const tail = str.slice(start).trim();
2280
+ if (tail) parts.push(tail);
2281
+ return parts;
2282
+ }
2283
+
2284
+ // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
2285
+ // the expression can't be resolved (unresolved var(), unknown colors).
2286
+ //
2287
+ // Mixing is done with premultiplied alpha in sRGB regardless of the
2288
+ // declared interpolation space. That is exact for the dominant generated-UI
2289
+ // pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
2290
+ // result is simply <color> at alpha N% in ANY rectangular space, and a
2291
+ // close-enough approximation for opaque-opaque mixes (the detector only
2292
+ // consumes these values for contrast/chroma thresholds, not for display).
2293
+ function parseColorMix(str) {
2294
+ const m = String(str).trim().match(/^color-mix\(/i);
2295
+ if (!m) return null;
2296
+ // Balanced-paren capture of the arguments.
2297
+ let depth = 0, end = -1;
2298
+ const open = str.indexOf('(');
2299
+ for (let i = open; i < str.length; i++) {
2300
+ if (str[i] === '(') depth++;
2301
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
2302
+ }
2303
+ if (end < 0) return null;
2304
+ const args = splitTopLevelCommas(str.slice(open + 1, end));
2305
+ if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
2306
+
2307
+ const parseComponent = (component) => {
2308
+ // Percentage may lead or trail the color per spec.
2309
+ let pct = null;
2310
+ let colorStr = component;
2311
+ const trail = component.match(/\s+([\d.]+)%$/);
2312
+ const lead = component.match(/^([\d.]+)%\s+/);
2313
+ if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
2314
+ else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
2315
+ let color;
2316
+ if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
2317
+ else color = parseAnyColor(colorStr);
2318
+ if (!color) return null;
2319
+ return { color, pct };
2320
+ };
2321
+
2322
+ const c1 = parseComponent(args[1]);
2323
+ const c2 = parseComponent(args[2]);
2324
+ if (!c1 || !c2) return null;
2325
+ let p1 = c1.pct, p2 = c2.pct;
2326
+ if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
2327
+ else if (p1 == null) p1 = 100 - p2;
2328
+ else if (p2 == null) p2 = 100 - p1;
2329
+ const sum = p1 + p2;
2330
+ if (sum <= 0) return null;
2331
+ // Per spec: weights normalize to sum; when sum < 100 the result alpha is
2332
+ // additionally scaled by sum/100.
2333
+ const w1 = p1 / sum, w2 = p2 / sum;
2334
+ const alphaScale = sum < 100 ? sum / 100 : 1;
2335
+ const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
2336
+ const a = (a1 * w1 + a2 * w2) * alphaScale;
2337
+ if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
2338
+ const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
2339
+ return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
2340
+ }
2341
+
2342
+ // Composite a translucent color over an opaque(ish) base (simple
2343
+ // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
2344
+ function compositeColorOver(top, base) {
2345
+ const a = top.a ?? 1;
2346
+ return {
2347
+ r: Math.round(top.r * a + base.r * (1 - a)),
2348
+ g: Math.round(top.g * a + base.g * (1 - a)),
2349
+ b: Math.round(top.b * a + base.b * (1 - a)),
2350
+ a: 1,
2351
+ };
2352
+ }
2353
+
2354
+ // Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
2355
+ // named colors. Returns null on no match. Use this when the input might be
2356
+ // any CSS color form; use plain parseRgb when you only expect computed rgb()
2357
+ // values from real browsers.
951
2358
  function parseAnyColor(s) {
952
2359
  if (!s || typeof s !== 'string') return null;
953
2360
  const str = s.trim();
954
2361
  if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
2362
+ if (/^color-mix\(/i.test(str)) return parseColorMix(str);
955
2363
  let m;
956
2364
  m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
957
2365
  if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
@@ -989,6 +2397,41 @@ function parseAnyColor(s) {
989
2397
  }
990
2398
  return rgb;
991
2399
  }
2400
+ // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
2401
+ m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
2402
+ if (m) {
2403
+ const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
2404
+ const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
2405
+ const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
2406
+ const rgb = oklabToRgb(L, a, b);
2407
+ if (m[7] !== undefined) {
2408
+ const alpha = parseFloat(m[7]);
2409
+ rgb.a = m[8] === '%' ? alpha / 100 : alpha;
2410
+ }
2411
+ return rgb;
2412
+ }
2413
+ // HSL/HSLA — comma or space syntax, optional deg on hue.
2414
+ m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
2415
+ if (m) {
2416
+ const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
2417
+ if (m[4] !== undefined) {
2418
+ const alpha = parseFloat(m[4]);
2419
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
2420
+ }
2421
+ return rgb;
2422
+ }
2423
+ // HWB — hue whiteness% blackness%.
2424
+ m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
2425
+ if (m) {
2426
+ const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
2427
+ if (m[4] !== undefined) {
2428
+ const alpha = parseFloat(m[4]);
2429
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
2430
+ }
2431
+ return rgb;
2432
+ }
2433
+ const named = CSS_NAMED_COLORS[str.toLowerCase()];
2434
+ if (named) return { ...named, a: 1 };
992
2435
  return null;
993
2436
  }
994
2437
 
@@ -1001,7 +2444,7 @@ function parseColorResolved(str, customPropMap) {
1001
2444
  return parseAnyColor(resolved);
1002
2445
  }
1003
2446
 
1004
- const REPEATED_KICKER_SKIP_SELECTOR = [
2447
+ const KICKER_SKIP_SELECTOR = [
1005
2448
  'nav',
1006
2449
  'form',
1007
2450
  'table',
@@ -1020,7 +2463,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
1020
2463
  '[data-impeccable-allow-kickers]',
1021
2464
  ].join(',');
1022
2465
 
1023
- const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
2466
+ const KICKER_CARD_CONTEXT_SELECTOR = [
1024
2467
  'article',
1025
2468
  'button',
1026
2469
  'a',
@@ -1038,23 +2481,32 @@ function cleanInlineText(el) {
1038
2481
  .trim();
1039
2482
  }
1040
2483
 
1041
- function isRepeatedKickerCardContext(heading, kicker) {
1042
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
2484
+ function isKickerCardContext(heading, kicker) {
2485
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
1043
2486
  return Boolean(item && (!item.contains || item.contains(kicker)));
1044
2487
  }
1045
2488
 
1046
- function isRepeatedKickerCandidate(opts) {
2489
+ // Meta lines above headlines join category and date (or path crumbs) with
2490
+ // separator glyphs, or carry a year. A kicker is one short phrase; metadata
2491
+ // keeps its markers.
2492
+ const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
2493
+ // Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
2494
+ // dotted decimal outlines. The label identifies the clause, so it stays.
2495
+ const KICKER_DOC_NUMBERING_RE = /^(§|\d+(\.\d+)+\b|(section|article|clause|appendix|exhibit|schedule|chapter|part|rule|title)\s+([\divxlc]+\b|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)\b)/i;
2496
+
2497
+ function isKickerCandidate(opts) {
1047
2498
  const {
1048
- headingTag,
2499
+ headingLevel,
1049
2500
  headingText,
1050
2501
  headingFontSize,
1051
2502
  kickerTag,
1052
2503
  kickerText,
1053
2504
  kickerTextTransform,
2505
+ kickerFontVariant,
1054
2506
  kickerFontSize,
1055
2507
  kickerLetterSpacing,
1056
2508
  } = opts;
1057
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
2509
+ if (!headingLevel || headingLevel > 4) return false;
1058
2510
  if (!headingText || headingText.length < 3) return false;
1059
2511
  if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
1060
2512
  if (!(headingFontSize >= 20)) return false;
@@ -1062,47 +2514,82 @@ function isRepeatedKickerCandidate(opts) {
1062
2514
  if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
1063
2515
  if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
1064
2516
  if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
2517
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
2518
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
1065
2519
 
2520
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
1066
2521
  const isUppercased = kickerTextTransform === 'uppercase'
1067
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
2522
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
2523
+ || isSmallCaps;
1068
2524
  if (!isUppercased) return false;
1069
2525
  if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
1070
- const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
2526
+ // Proportional only, no absolute floor: the wild's most common recipe is
2527
+ // 0.08em at a sub-13px size, which computes to under 1px and sailed past
2528
+ // the old Math.max(1, ...) floor (observed live: a page whose kickers were
2529
+ // literally class="kicker" produced zero findings).
2530
+ const minTrackedSpacing = kickerFontSize * 0.06;
1071
2531
  if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
1072
2532
  return true;
1073
2533
  }
1074
2534
 
1075
- function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
2535
+ // Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
2536
+ // (default 2) for role="heading" elements, 0 otherwise.
2537
+ function kickerHeadingLevel(heading) {
2538
+ const tag = heading.tagName.toLowerCase();
2539
+ const byTag = /^h([1-6])$/.exec(tag);
2540
+ if (byTag) return parseInt(byTag[1], 10);
2541
+ const role = heading.getAttribute?.('role') || '';
2542
+ if (role.toLowerCase() !== 'heading') return 0;
2543
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
2544
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
2545
+ }
2546
+
2547
+ function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
1076
2548
  const candidates = [];
1077
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
1078
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
2549
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
2550
+ const headingLevel = kickerHeadingLevel(heading);
2551
+ if (!headingLevel || headingLevel > 4) continue;
2552
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
2553
+ // Application contexts (tab panels, dialogs) use compact context labels
2554
+ // above headings to describe state, not to decorate. Same carve-out the
2555
+ // hero-eyebrow rule makes.
2556
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
1079
2557
  const kicker = heading.previousElementSibling;
1080
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1081
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
2558
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
2559
+ if (isKickerCardContext(heading, kicker)) continue;
1082
2560
 
1083
2561
  const headingStyle = getStyle(heading);
1084
2562
  const kickerStyle = getStyle(kicker);
2563
+ const headingTag = heading.tagName.toLowerCase();
1085
2564
  const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
1086
2565
  const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
1087
2566
  const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
1088
2567
  const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
1089
2568
  const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
1090
2569
 
1091
- if (!isRepeatedKickerCandidate({
1092
- headingTag: heading.tagName.toLowerCase(),
2570
+ if (!isKickerCandidate({
2571
+ headingLevel,
1093
2572
  headingText,
1094
2573
  headingFontSize,
1095
2574
  kickerTag: kicker.tagName.toLowerCase(),
1096
2575
  kickerText,
1097
2576
  kickerTextTransform: kickerStyle.textTransform || '',
2577
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
1098
2578
  kickerFontSize,
1099
2579
  kickerLetterSpacing,
1100
2580
  })) {
1101
2581
  continue;
1102
2582
  }
1103
2583
 
2584
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
2585
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
2586
+ // stylings there). Stand down so one element gets one finding.
2587
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
2588
+ continue;
2589
+ }
2590
+
1104
2591
  candidates.push({
1105
- headingTag: heading.tagName.toLowerCase(),
2592
+ headingTag,
1106
2593
  headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
1107
2594
  kickerText: kickerText.slice(0, 40),
1108
2595
  });
@@ -1110,13 +2597,177 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
1110
2597
  return candidates;
1111
2598
  }
1112
2599
 
1113
- function checkRepeatedSectionKickersDOM() {
1114
- const candidates = collectRepeatedSectionKickerCandidates(
2600
+ function checkKickerAboveHeadingDOM() {
2601
+ const candidates = collectKickerCandidates(
1115
2602
  document,
1116
2603
  (el) => getComputedStyle(el),
1117
2604
  (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1118
2605
  );
1119
- return checkRepeatedSectionKickers({ candidates });
2606
+ return checkKickerAboveHeading({ candidates });
2607
+ }
2608
+
2609
+ // ── Numbered section labels ─────────────────────────────────────────────────
2610
+ // Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
2611
+ // the section scaffold is a tiny numeric index riding beside each section
2612
+ // heading — bare and zero-padded, or an index joined to a short micro-label
2613
+ // by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
2614
+ // labels; this rule owns that shape.
2615
+
2616
+ const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']);
2617
+
2618
+ // Returns { index, text } when the trimmed text reads as a section index
2619
+ // label, else null. Two accepted shapes: a zero-padded/two-digit bare index,
2620
+ // or a 1-2 digit index followed by a non-word separator and a short label.
2621
+ function parseNumberedLabelText(rawText) {
2622
+ const text = (rawText || '').replace(/\s+/g, ' ').trim();
2623
+ if (!text || text.length > 40) return null;
2624
+ let m = /^(\d{2})$/.exec(text);
2625
+ if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text);
2626
+ if (!m) return null;
2627
+ const index = parseInt(m[1], 10);
2628
+ if (!Number.isFinite(index) || index > 40) return null;
2629
+ return { index, text };
2630
+ }
2631
+
2632
+ function isNumberedSectionLabelCandidate(opts) {
2633
+ const {
2634
+ headingTag, headingText, headingFontSize,
2635
+ labelTag, labelIndex, labelText,
2636
+ labelFontSize, labelLetterSpacing, labelFontWeight,
2637
+ labelFontFamily, labelTextTransform, labelColor,
2638
+ } = opts;
2639
+ if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
2640
+ if (!headingText || headingText.length < 3) return false;
2641
+ if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false;
2642
+ if (labelIndex == null || !labelText) return false;
2643
+ // Tiny rendered size is the tell — a display-scale section number is a
2644
+ // different (deliberate) device and stays legal.
2645
+ if (!(labelFontSize > 0 && labelFontSize <= 13)) return false;
2646
+ // The heading must be visibly larger where we can resolve its size.
2647
+ // clamp()/var() sizes come back unparseable (0) in the static engine —
2648
+ // the remaining gates carry the check there.
2649
+ if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false;
2650
+ // Deliberate micro-label styling separates the scaffold from incidental
2651
+ // small text: mono face, bold weight, tracking, uppercase, or accent color.
2652
+ const weight = Number(labelFontWeight) || 400;
2653
+ return /mono/i.test(labelFontFamily || '')
2654
+ || weight >= 600
2655
+ || (labelLetterSpacing || 0) >= 0.5
2656
+ || (labelTextTransform || '') === 'uppercase'
2657
+ || isAccentColor(labelColor || '');
2658
+ }
2659
+
2660
+ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) {
2661
+ const candidates = [];
2662
+ const seenLabels = new Set();
2663
+ for (const heading of doc.querySelectorAll('h2, h3, h4')) {
2664
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
2665
+ // The index sits either directly before the heading, or before the
2666
+ // wrapper the heading leads (label | <div><h2>…</h2>…</div>).
2667
+ let label = heading.previousElementSibling;
2668
+ if (!label) {
2669
+ const parent = heading.parentElement;
2670
+ const firstChild = parent?.children?.[0];
2671
+ if (firstChild === heading) label = parent.previousElementSibling;
2672
+ }
2673
+ if (!label || seenLabels.has(label)) continue;
2674
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
2675
+ if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
2676
+ if (isKickerCardContext(heading, label)) continue;
2677
+
2678
+ const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
2679
+ const parsed = parseNumberedLabelText(labelText);
2680
+ if (!parsed) continue;
2681
+
2682
+ const headingStyle = getStyle(heading);
2683
+ const labelStyle = getStyle(label);
2684
+ const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
2685
+ const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
2686
+ const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0;
2687
+
2688
+ if (!isNumberedSectionLabelCandidate({
2689
+ headingTag: heading.tagName.toLowerCase(),
2690
+ headingText,
2691
+ headingFontSize,
2692
+ labelTag: label.tagName.toLowerCase(),
2693
+ labelIndex: parsed.index,
2694
+ labelText: parsed.text,
2695
+ labelFontSize,
2696
+ labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize),
2697
+ labelFontWeight: labelStyle.fontWeight || '',
2698
+ labelFontFamily: labelStyle.fontFamily || '',
2699
+ labelTextTransform: labelStyle.textTransform || '',
2700
+ labelColor: labelStyle.color || '',
2701
+ })) {
2702
+ continue;
2703
+ }
2704
+
2705
+ seenLabels.add(label);
2706
+ candidates.push({
2707
+ index: parsed.index,
2708
+ labelText: parsed.text.slice(0, 24),
2709
+ headingTag: heading.tagName.toLowerCase(),
2710
+ headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
2711
+ });
2712
+ }
2713
+ return candidates;
2714
+ }
2715
+
2716
+ function checkNumberedSectionLabels(opts) {
2717
+ const { candidates, minCount = 2 } = opts;
2718
+ if (!Array.isArray(candidates) || candidates.length < minCount) return [];
2719
+ // A repeated identical number is some other device; the scaffold counts up.
2720
+ const distinctIndices = new Set(candidates.map(c => c.index));
2721
+ if (distinctIndices.size < 2) return [];
2722
+ return candidates.map(candidate => ({
2723
+ id: 'numbered-section-labels',
2724
+ snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
2725
+ }));
2726
+ }
2727
+
2728
+ function checkNumberedSectionLabelsFromDoc(doc, win) {
2729
+ const candidates = collectNumberedSectionLabelCandidates(
2730
+ doc,
2731
+ (el) => win.getComputedStyle(el),
2732
+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
2733
+ );
2734
+ return checkNumberedSectionLabels({ candidates });
2735
+ }
2736
+
2737
+ function checkNumberedSectionLabelsDOM() {
2738
+ const candidates = collectNumberedSectionLabelCandidates(
2739
+ document,
2740
+ (el) => getComputedStyle(el),
2741
+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
2742
+ );
2743
+ return checkNumberedSectionLabels({ candidates });
2744
+ }
2745
+
2746
+ // Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
2747
+ // Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
2748
+ // two gates (absolute floor + density) so a long article using a few dashes is
2749
+ // left alone while a short, dash-per-clause page is flagged. Operates on
2750
+ // already-rendered text, so no HTML-entity decoding is needed (the browser has
2751
+ // resolved `&mdash;` to the literal glyph). Exported for jsdom unit tests.
2752
+ function checkEmDashOveruse(text) {
2753
+ const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
2754
+ let count = 0;
2755
+ const re = /[—]|--(?=\S)/g;
2756
+ while (re.exec(body) !== null) count++;
2757
+ if (count < EM_DASH_FLOOR) return [];
2758
+ if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
2759
+ return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
2760
+ }
2761
+
2762
+ function checkEmDashOveruseDOM() {
2763
+ const body = document.body;
2764
+ if (!body) return [];
2765
+ // innerText reflects rendered, visible text; fall back to textContent for
2766
+ // engines (jsdom) that don't compute innerText.
2767
+ const text = typeof body.innerText === 'string' && body.innerText
2768
+ ? body.innerText
2769
+ : (body.textContent || '');
2770
+ return checkEmDashOveruse(text);
1120
2771
  }
1121
2772
 
1122
2773
  function checkElementMotionDOM(el) {
@@ -1135,7 +2786,14 @@ function checkElementMotionDOM(el) {
1135
2786
  function checkElementGlowDOM(el) {
1136
2787
  const tag = el.tagName.toLowerCase();
1137
2788
  const style = getComputedStyle(el);
1138
- if (!style.boxShadow || style.boxShadow === 'none') return [];
2789
+ const boxShadow = style.boxShadow && style.boxShadow !== 'none' ? style.boxShadow : '';
2790
+ // text-shadow inherits: only check the element that introduces it, so one
2791
+ // declaration doesn't produce a finding on every descendant.
2792
+ let textShadow = style.textShadow && style.textShadow !== 'none' ? style.textShadow : '';
2793
+ if (textShadow && el.parentElement && getComputedStyle(el.parentElement).textShadow === textShadow) {
2794
+ textShadow = '';
2795
+ }
2796
+ if (!boxShadow && !textShadow) return [];
1139
2797
  // Use parent's background — glow radiates outward, so the surrounding context matters
1140
2798
  // If resolveBackground returns null (gradient), try to infer from the gradient colors
1141
2799
  let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
@@ -1158,7 +2816,7 @@ function checkElementGlowDOM(el) {
1158
2816
  cur = cur.parentElement;
1159
2817
  }
1160
2818
  }
1161
- return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
2819
+ return checkGlow({ tag, boxShadow, textShadow, effectiveBg: parentBg });
1162
2820
  }
1163
2821
 
1164
2822
  function checkElementAIPaletteDOM(el) {
@@ -1215,7 +2873,132 @@ function checkElementAIPaletteDOM(el) {
1215
2873
  }
1216
2874
  }
1217
2875
 
1218
- return findings;
2876
+ return findings;
2877
+ }
2878
+
2879
+ // ─── Decorative radial spotlight glow ───────────────────────────────────────
2880
+ // A soft, low-opacity chromatic radial-gradient fading to transparent, painted
2881
+ // as a decorative wash behind a hero or section. The translucent sibling of the
2882
+ // `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on
2883
+ // a dark page; this catches the low-alpha "spotlight" the halo gate lets slip
2884
+ // (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26),
2885
+ // transparent 44%)`). The two alpha bands are disjoint, so they never
2886
+ // double-report the same declaration.
2887
+ const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
2888
+
2889
+ // Parse the FIRST non-repeating radial-gradient in a background value into its
2890
+ // ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }.
2891
+ // Returns null when there is no plain radial-gradient to read.
2892
+ function parseRadialGradientStops(value) {
2893
+ if (!value || !/radial-gradient/i.test(value)) return null;
2894
+ const gradRe = /(repeating-)?radial-gradient\(/gi;
2895
+ let g;
2896
+ while ((g = gradRe.exec(value)) !== null) {
2897
+ if (g[1]) continue; // repeating-* is a pattern, not a spotlight
2898
+ let depth = 0, end = -1;
2899
+ const open = value.indexOf('(', g.index);
2900
+ for (let i = open; i < value.length; i++) {
2901
+ if (value[i] === '(') depth++;
2902
+ else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
2903
+ }
2904
+ if (end < 0) return null;
2905
+ const args = splitTopLevelCommas(value.slice(open + 1, end));
2906
+ // The optional prelude (shape / size / `at <pos>`) carries no color token.
2907
+ const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a));
2908
+ if (stopArgs.length < 2) return null;
2909
+ return stopArgs.map(a => {
2910
+ const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE);
2911
+ if (!tok) return { color: null, transparent: false };
2912
+ if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true };
2913
+ const color = parseAnyColor(tok[0]);
2914
+ return { color, transparent: !!color && (color.a ?? 1) <= 0.05 };
2915
+ });
2916
+ }
2917
+ return null;
2918
+ }
2919
+
2920
+ // Pure gate. `label` is a stable identifier the fixture test keys on.
2921
+ function checkRadialSpotlight({ gradientValue, width, height, label }) {
2922
+ const stops = parseRadialGradientStops(gradientValue);
2923
+ if (!stops || stops.length < 2) return [];
2924
+
2925
+ // Must fade OUT: the last stop is transparent / near-zero alpha. A gradient
2926
+ // between two visible surfaces is a real background, not a floating glow.
2927
+ const last = stops[stops.length - 1];
2928
+ const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1);
2929
+ if (lastAlpha > 0.05) return [];
2930
+
2931
+ // The visible (non-transparent, parseable) color stops.
2932
+ const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05);
2933
+ if (colored.length === 0) return [];
2934
+ // One soft glow, not a multi-color composition: at most two visible stops.
2935
+ if (colored.length > 2) return [];
2936
+ // Every visible stop must be LOW opacity. Any opaque stop means a real fill
2937
+ // or a saturated halo (`radial-halo`'s job), not this translucent spotlight.
2938
+ if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return [];
2939
+ // At least one visible stop must be chromatic. A neutral (grayscale)
2940
+ // near-black / near-white vignette is a legitimate lighting move, exempt.
2941
+ const chromatic = colored.find(s => hasChroma(s.color, 24));
2942
+ if (!chromatic) return [];
2943
+
2944
+ // Decorative-scale gate. Badges, avatars, and actual small "lights" are
2945
+ // exempt; a spotlight glow only reads as slop when it washes a large surface.
2946
+ if (!(width >= 240 && height >= 160)) return [];
2947
+
2948
+ const alpha = (chromatic.color.a ?? 1).toFixed(2);
2949
+ const name = label || 'section';
2950
+ return [{
2951
+ id: 'radial-spotlight-glow',
2952
+ snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`,
2953
+ }];
2954
+ }
2955
+
2956
+ // Read the raw radial-gradient source off an element's computed style, with a
2957
+ // fallback to the `background` shorthand and the inline style attribute for
2958
+ // engines that don't decompose the shorthand into backgroundImage.
2959
+ function elementGradientValue(style, el) {
2960
+ const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : '';
2961
+ if (/radial-gradient/i.test(bgImage)) return bgImage;
2962
+ const bg = style.background || '';
2963
+ if (/radial-gradient/i.test(bg)) return bg;
2964
+ const rawStyle = el?.getAttribute?.('style') || '';
2965
+ const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
2966
+ if (m && /radial-gradient/i.test(m[1])) return m[1];
2967
+ return '';
2968
+ }
2969
+
2970
+ function spotlightLabel(el) {
2971
+ const dataName = el.getAttribute?.('data-name');
2972
+ if (dataName) return dataName;
2973
+ if (typeof el.id === 'string' && el.id) return el.id;
2974
+ const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
2975
+ if (cls) return cls;
2976
+ return el.tagName ? el.tagName.toLowerCase() : 'section';
2977
+ }
2978
+
2979
+ function checkElementRadialSpotlightDOM(el) {
2980
+ const style = getComputedStyle(el);
2981
+ const gradientValue = elementGradientValue(style, el);
2982
+ if (!gradientValue) return [];
2983
+ const rect = el.getBoundingClientRect();
2984
+ return checkRadialSpotlight({
2985
+ gradientValue,
2986
+ width: rect.width,
2987
+ height: rect.height,
2988
+ label: spotlightLabel(el),
2989
+ });
2990
+ }
2991
+
2992
+ function checkElementRadialSpotlight(el, style, tag, window) {
2993
+ const gradientValue = elementGradientValue(style, el);
2994
+ if (!gradientValue) return [];
2995
+ // Static engine does no layout — read explicit pixel dimensions from CSS.
2996
+ return checkRadialSpotlight({
2997
+ gradientValue,
2998
+ width: parseFloat(style.width) || 0,
2999
+ height: parseFloat(style.height) || 0,
3000
+ label: spotlightLabel(el),
3001
+ });
1219
3002
  }
1220
3003
 
1221
3004
  const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
@@ -1339,6 +3122,55 @@ function textDescendantsFlushSides(el, rect) {
1339
3122
  return flush;
1340
3123
  }
1341
3124
 
3125
+ // Screen-reader-only ("visually hidden") text is exempt from the tiny-text
3126
+ // floors: it is never rendered, so its size is irrelevant. Detect the two
3127
+ // standard idioms — a known sr-only class on the element or an ancestor, and
3128
+ // the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
3129
+ // browser (computed styles).
3130
+ const SR_ONLY_SELECTOR = '.sr-only, .visually-hidden, .visuallyhidden, .screen-reader, .screen-reader-only, .screenreader, .a11y-hidden, .hidden-visually, [class*="sr-only" i], [class*="visually-hidden" i], [class*="visuallyhidden" i], [class*="screen-reader" i], [class*="screenreader" i]';
3131
+ function isVisuallyHidden(el, style) {
3132
+ if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
3133
+ const pos = style.position || '';
3134
+ if (pos === 'absolute' || pos === 'fixed') {
3135
+ const clip = style.clip || '';
3136
+ const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
3137
+ if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
3138
+ const w = parseFloat(style.width);
3139
+ const h = parseFloat(style.height);
3140
+ const overflow = style.overflow || '';
3141
+ if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
3142
+ }
3143
+ return false;
3144
+ }
3145
+
3146
+ // Elements whose text is never painted: document metadata and script/style
3147
+ // payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on
3148
+ // sites that set `html { font-size: 62.5% }` their inherited computed size is
3149
+ // 10px — so the text-size floors flag them as tiny body copy even though
3150
+ // nothing renders (issue #408: dozens of phantom "10px body text" findings on
3151
+ // every Shopify page). Exclude them, plus anything the cascade resolves to
3152
+ // display:none / visibility:hidden. The jsdom path can't lay out, so the
3153
+ // tag/attribute-based exclusions carry the weight there; the display checks are
3154
+ // computed-style reads that resolve without layout in both adapters.
3155
+ const NON_RENDERED_TAGS = new Set([
3156
+ 'script', 'style', 'title', 'noscript', 'template', 'head',
3157
+ 'meta', 'link', 'base', 'param', 'source', 'track', 'datalist',
3158
+ 'col', 'colgroup', 'map', 'area',
3159
+ ]);
3160
+ function isNonRenderedText(el, tag, style) {
3161
+ const t = (tag || '').toLowerCase();
3162
+ if (NON_RENDERED_TAGS.has(t)) return true;
3163
+ // Descendants of <head> never render even when the tag itself would
3164
+ // (some sites nest <noscript>/<template> content there).
3165
+ if (el && el.closest && el.closest('head')) return true;
3166
+ if (style) {
3167
+ if (style.display === 'none') return true;
3168
+ const vis = style.visibility;
3169
+ if (vis === 'hidden' || vis === 'collapse') return true;
3170
+ }
3171
+ return false;
3172
+ }
3173
+
1342
3174
  // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1343
3175
  // jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1344
3176
  // element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1349,8 +3181,13 @@ function textDescendantsFlushSides(el, rect) {
1349
3181
  function checkQuality(opts) {
1350
3182
  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
1351
3183
  const findings = [];
1352
- // Skip browser extension injected elements
1353
- const elId = el.id || '';
3184
+ // Skip browser extension injected elements. Read the id via getAttribute
3185
+ // whenever `el.id` is not a string: on a <form> (and other
3186
+ // [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
3187
+ // shadows the builtin `id` getter and returns the control element, whose
3188
+ // `.startsWith` is undefined and throws (issue #407 — every Shopify product
3189
+ // form ships an <input name="id">).
3190
+ const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
1354
3191
  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1355
3192
 
1356
3193
  // --- Line length too long --- (browser-only: needs rect.width)
@@ -1618,11 +3455,67 @@ function checkQuality(opts) {
1618
3455
  const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
1619
3456
  const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
1620
3457
  const isUppercase = style.textTransform === 'uppercase';
1621
- if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
3458
+ if (!skipTags.includes(tag) && !inUIContext && !isUppercase && !isNonRenderedText(el, tag, style)) {
1622
3459
  findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
1623
3460
  }
1624
3461
  }
1625
3462
 
3463
+ // --- Undersized functional / UI text ---
3464
+ // Complements `tiny-text` above, which owns long body copy and deliberately
3465
+ // EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
3466
+ // uppercase micro-labels). This rule targets exactly that blind spot: the
3467
+ // interactive and short content-bearing text — nav items, buttons, labels,
3468
+ // table cells, meta rows, timecodes — shipped below an 11px floor.
3469
+ //
3470
+ // The live failure it closes: a build shipped its entire furniture layer at
3471
+ // 8px, and the design hook waved it through because 8px had been added to
3472
+ // the DESIGN.md size ramp. Being on the ramp is a token argument, not a
3473
+ // legibility one, so this rule ignores the design system entirely — a value
3474
+ // on the ramp is still flagged.
3475
+ //
3476
+ // Floors: 11px for anything functional. The floor holds inside a footer;
3477
+ // only NON-interactive legal smallprint gets the softer 10px floor. Exempts
3478
+ // sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
3479
+ // Uppercase letterspaced micro-labels are still functional — not exempt.
3480
+ {
3481
+ const directText = [...el.childNodes]
3482
+ .filter(n => n.nodeType === 3)
3483
+ .map(n => n.textContent || '')
3484
+ .join('')
3485
+ .replace(/\s+/g, ' ')
3486
+ .trim();
3487
+ const dtLen = directText.length;
3488
+ // `option` renders (in native select popups) so it stays a local skip;
3489
+ // script/style/title/noscript/head-descendants and display:none /
3490
+ // visibility:hidden are handled by isNonRenderedText (shared with tiny-text).
3491
+ const UI_SKIP_TAGS = new Set(['sub', 'sup', 'option']);
3492
+ // jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
3493
+ // text that computes at or above the floor never reaches here. The browser
3494
+ // adapter additionally catches values only resolvable with real layout
3495
+ // (e.g. viewport-relative units, cascade winners set in linked sheets).
3496
+ if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !isNonRenderedText(el, tag, style)) {
3497
+ const EXEMPT_CONTEXT = 'pre, code, kbd, samp, var, svg, [aria-hidden="true"], [class*="terminal" i], [class*="console" i], [class*="code" i], [class*="mock" i], [class*="editor" i], [class*="syntax" i], [class*="diff" i]';
3498
+ const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
3499
+ if (!isExemptContext && !isVisuallyHidden(el, style)) {
3500
+ const INTERACTIVE = 'a[href], button, summary, label, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [tabindex]';
3501
+ const FURNITURE = 'nav, [role="navigation"], td, th, [role="gridcell"], [role="cell"], caption, figcaption, dt, dd, footer, [class*="meta" i], [class*="label" i], [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="kicker" i], [class*="eyebrow" i], [class*="breadcrumb" i], [class*="timestamp" i], [class*="category" i], [class*="caption" i], [class*="nav" i]';
3502
+ const SMALLPRINT = 'small, footer, [class*="legal" i], [class*="copyright" i], [class*="fineprint" i], [class*="fine-print" i], [class*="smallprint" i], [class*="small-print" i], [class*="disclaimer" i], [class*="disclosure" i], [class*="footnote" i]';
3503
+ const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
3504
+ const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
3505
+ const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
3506
+ const floor = (!isInteractive && isSmallprint) ? 10 : 11;
3507
+ // Fire on functional text only: interactive, structural furniture, or
3508
+ // any short (<=20-char) run — the label / meta / timecode shape. Long
3509
+ // non-furniture body copy stays with `tiny-text`, so the two rules
3510
+ // never double-flag the same element.
3511
+ if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
3512
+ const excerpt = directText.slice(0, 40);
3513
+ findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
3514
+ }
3515
+ }
3516
+ }
3517
+ }
3518
+
1626
3519
  // --- All-caps body text ---
1627
3520
  if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
1628
3521
  if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
@@ -1715,7 +3608,7 @@ function checkElementQuality(el, style, tag, window) {
1715
3608
  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
1716
3609
  }
1717
3610
 
1718
- function checkElementBorders(tag, style, overrides, resolvedRadius) {
3611
+ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
1719
3612
  const sides = ['Top', 'Right', 'Bottom', 'Left'];
1720
3613
  const widths = {}, colors = {};
1721
3614
  for (const s of sides) {
@@ -1742,7 +3635,12 @@ function checkElementBorders(tag, style, overrides, resolvedRadius) {
1742
3635
  const radius = resolvedRadius != null
1743
3636
  ? resolvedRadius
1744
3637
  : (parseFloat(style.borderRadius) || 0);
1745
- return checkBorders(tag, widths, colors, radius);
3638
+ const ownBg = parseAnyColor(style.backgroundColor);
3639
+ return checkBorders(tag, widths, colors, radius, {
3640
+ tabContext: isTabContextElement(el),
3641
+ statusContext: isStatusContextElement(el),
3642
+ badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
3643
+ });
1746
3644
  }
1747
3645
 
1748
3646
  function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
@@ -1783,12 +3681,32 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
1783
3681
  }
1784
3682
  }
1785
3683
 
3684
+ // Own background: resolve var()/oklch() tokens through the custom-property
3685
+ // map first (mirrors the textColor path above). Without this a chip whose
3686
+ // background is `var(--sev)` reads as no-own-bg in the static engine and
3687
+ // the styled-control contrast exception never engages.
3688
+ let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
3689
+ || readOwnBackgroundColor(el, style);
3690
+
3691
+ // Full-cover surface pseudo (static): the cascade pass marks elements
3692
+ // whose ::before/::after paints an opaque covering surface. When the
3693
+ // element itself has no usable own background, that pseudo is the real
3694
+ // surface for contrast purposes.
3695
+ let finalEffectiveBg = effectiveBg;
3696
+ if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
3697
+ const pseudoSurface = window.getPseudoSurface(el);
3698
+ if (pseudoSurface) {
3699
+ ownBg = pseudoSurface;
3700
+ finalEffectiveBg = pseudoSurface;
3701
+ }
3702
+ }
3703
+
1786
3704
  return checkColors({
1787
3705
  tag,
1788
3706
  textColor,
1789
- bgColor: readOwnBackgroundColor(el, style),
1790
- effectiveBg,
1791
- effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
3707
+ bgColor: ownBg,
3708
+ effectiveBg: finalEffectiveBg,
3709
+ effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
1792
3710
  fontSize: parseFloat(style.fontSize) || 16,
1793
3711
  fontWeight: parseInt(style.fontWeight) || 400,
1794
3712
  hasDirectText,
@@ -1799,6 +3717,50 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
1799
3717
  });
1800
3718
  }
1801
3719
 
3720
+ // Static-engine adapter for hover-state contrast. Relies on the static
3721
+ // cascade's hover pass (css-cascade.mjs) exposing a per-element hover style
3722
+ // via window.getHoverStyle — present only when a :hover rule changed the
3723
+ // element's color or background-color relative to its resting state.
3724
+ function checkElementHoverContrast(el, style, tag, window) {
3725
+ if (typeof window.getHoverStyle !== 'function') return [];
3726
+ const hover = window.getHoverStyle(el);
3727
+ if (!hover) return [];
3728
+
3729
+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
3730
+ if (directText.trim().length === 0) return [];
3731
+
3732
+ const textColor = parseAnyColor(hover.color);
3733
+ if (!textColor || (textColor.a != null && textColor.a < 1)) return [];
3734
+
3735
+ const restingOwnBg = parseAnyColor(style.backgroundColor);
3736
+ const hoverOwnBg = parseAnyColor(hover.backgroundColor);
3737
+ const ownBg = hoverOwnBg || restingOwnBg;
3738
+
3739
+ // Effective hover background: the element's own hover bg composited over
3740
+ // whatever sits underneath. Bail when the surface can't be resolved to a
3741
+ // solid color — gradient ancestors are handled (as at rest) by the
3742
+ // resting-state check, not duplicated here.
3743
+ let bg = null;
3744
+ if (ownBg && ownBg.a >= 0.99) {
3745
+ bg = ownBg;
3746
+ } else {
3747
+ const under = resolveBackground(el.parentElement || el, window, null);
3748
+ if (!under) return [];
3749
+ bg = ownBg && ownBg.a > 0.1 ? compositeColorOver(ownBg, under) : under;
3750
+ }
3751
+
3752
+ return checkHoverContrast({
3753
+ tag,
3754
+ textColor,
3755
+ bg,
3756
+ ownBgAlpha: ownBg ? ownBg.a ?? 1 : null,
3757
+ fontSize: parseFloat(style.fontSize) || 16,
3758
+ fontWeight: parseInt(style.fontWeight) || 400,
3759
+ hasDirectText: true,
3760
+ isEmojiOnly: isEmojiOnlyText(directText),
3761
+ });
3762
+ }
3763
+
1802
3764
  function checkElementIconTile(el, tag, window) {
1803
3765
  if (!HEADING_TAGS.has(tag)) return [];
1804
3766
  const sibling = el.previousElementSibling;
@@ -1867,7 +3829,8 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
1867
3829
  return checkHeroEyebrow({
1868
3830
  headingTag: tag,
1869
3831
  headingText: el.textContent || '',
1870
- headingFontSize: parseFloat(headingFontSizeRaw) || 0,
3832
+ headingFontSize: resolveHeroHeadingSizePx(headingFontSizeRaw),
3833
+ headingInApplicationContext: !!el.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
1871
3834
  siblingTag: sibling.tagName.toLowerCase(),
1872
3835
  siblingText: sibling.textContent || '',
1873
3836
  siblingTextTransform: sibStyle.textTransform || '',
@@ -1875,16 +3838,21 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
1875
3838
  siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
1876
3839
  siblingFontWeight: fontWeightRaw || '',
1877
3840
  siblingColor: colorRaw || '',
3841
+ // Static cascade marks elements matched by a ::before/::after rule
3842
+ // whose geometry is a short chromatic dash (css-cascade.mjs).
3843
+ siblingHasAccentDashPseudo: typeof window.hasAccentDashPseudo === 'function'
3844
+ ? window.hasAccentDashPseudo(sibling)
3845
+ : false,
1878
3846
  });
1879
3847
  }
1880
3848
 
1881
- function checkRepeatedSectionKickersFromDoc(doc, win) {
1882
- const candidates = collectRepeatedSectionKickerCandidates(
3849
+ function checkKickerAboveHeadingFromDoc(doc, win) {
3850
+ const candidates = collectKickerCandidates(
1883
3851
  doc,
1884
3852
  (el) => win.getComputedStyle(el),
1885
3853
  (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1886
3854
  );
1887
- return checkRepeatedSectionKickers({ candidates });
3855
+ return checkKickerAboveHeading({ candidates });
1888
3856
  }
1889
3857
 
1890
3858
  function checkElementMotion(tag, style) {
@@ -1898,8 +3866,10 @@ function checkElementMotion(tag, style) {
1898
3866
  }
1899
3867
 
1900
3868
  function checkElementGlow(tag, style, effectiveBg) {
1901
- if (!style.boxShadow || style.boxShadow === 'none') return [];
1902
- return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
3869
+ const boxShadow = style.boxShadow && style.boxShadow !== 'none' ? style.boxShadow : '';
3870
+ const textShadow = style.textShadow && style.textShadow !== 'none' ? style.textShadow : '';
3871
+ if (!boxShadow && !textShadow) return [];
3872
+ return checkGlow({ tag, boxShadow, textShadow, effectiveBg });
1903
3873
  }
1904
3874
 
1905
3875
  // ─── Section 6: Page-Level Checks ───────────────────────────────────────────
@@ -1941,12 +3911,6 @@ function checkTypography() {
1941
3911
  if (isBrandFontOnOwnDomain(font)) continue;
1942
3912
  findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
1943
3913
  }
1944
-
1945
- // Single-font check: only one distinct primary font across all text
1946
- if (fontUsage.size === 1) {
1947
- const only = [...fontUsage.keys()][0];
1948
- findings.push({ type: 'single-font', detail: `only font used is ${only}` });
1949
- }
1950
3914
  }
1951
3915
 
1952
3916
  const sizes = new Set();
@@ -2009,6 +3973,157 @@ function checkLayout() {
2009
3973
  return findings;
2010
3974
  }
2011
3975
 
3976
+ // Heading rhythm (browser-only): a heading binds to the content it
3977
+ // introduces, so its rendered space above must exceed its space below.
3978
+ // Margins alone can't be trusted (collapsing, flex rows, section padding),
3979
+ // so this measures actual getBoundingClientRect gaps between the heading
3980
+ // and the nearest content genuinely above / below it. Fires only when two
3981
+ // or more headings violate the principle — a single occurrence is noise.
3982
+ function checkHeadingRhythmDOM() {
3983
+ const MIN_VIOLATIONS = 2;
3984
+ const CARD_EXEMPT_HEIGHT = 200;
3985
+ const MAX_BELOW_PX = 160; // beyond this the heading isn't binding to nearby content at all
3986
+ const MIN_DEFICIT_PX = 12;
3987
+
3988
+ function isVisibleFlow(el) {
3989
+ const style = getComputedStyle(el);
3990
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
3991
+ if (parseFloat(style.opacity || '1') <= 0.05) return false;
3992
+ if (style.position === 'absolute' || style.position === 'fixed' || style.position === 'sticky') return false;
3993
+ const rect = el.getBoundingClientRect();
3994
+ return rect.width >= 1 && rect.height >= 1;
3995
+ }
3996
+
3997
+ // Edges only count when they share the heading's column — grid layouts
3998
+ // put content beside a heading, and a far-away element in another column
3999
+ // says nothing about the heading's vertical rhythm.
4000
+ function overlapsX(sr, rect) {
4001
+ return Math.min(sr.right, rect.right) - Math.max(sr.left, rect.left) >= 8;
4002
+ }
4003
+
4004
+ // Does this container draw its own top boundary (background, top border,
4005
+ // shadow)? Crossing out of such a container means the container edge is
4006
+ // the separator above the heading, not raw whitespace — exempt.
4007
+ function hasOwnTopBoundary(el) {
4008
+ const style = getComputedStyle(el);
4009
+ const bg = parseAnyColor(style.backgroundColor || '');
4010
+ if (bg && (bg.a ?? 1) > 0.05) return true;
4011
+ if ((parseFloat(style.borderTopWidth) || 0) > 0) return true;
4012
+ if (style.boxShadow && style.boxShadow !== 'none') return true;
4013
+ return false;
4014
+ }
4015
+
4016
+ // Eyebrows, kickers, and index labels sitting directly on top of a
4017
+ // heading belong to the heading's own cluster — space above is measured
4018
+ // from the top of the cluster, not from the label to the heading.
4019
+ function clusterTop(h, rect) {
4020
+ const headingFontSize = parseFloat(getComputedStyle(h).fontSize) || 16;
4021
+ let topEl = h;
4022
+ let top = rect.top;
4023
+ for (let i = 0; i < 3; i++) {
4024
+ const sib = topEl.previousElementSibling;
4025
+ if (!sib || !isVisibleFlow(sib)) break;
4026
+ const sr = sib.getBoundingClientRect();
4027
+ if (!overlapsX(sr, rect)) break;
4028
+ const gap = top - sr.bottom;
4029
+ if (gap < 0 || gap >= 28 || sr.height > 60) break;
4030
+ const text = (sib.textContent || '').trim();
4031
+ const sibFontSize = parseFloat(getComputedStyle(sib).fontSize) || 16;
4032
+ const labelLike = sibFontSize < headingFontSize * 0.75 || text.length <= 40;
4033
+ if (!labelLike || text.length > 80) break;
4034
+ topEl = sib;
4035
+ top = sr.top;
4036
+ }
4037
+ return { topEl, top };
4038
+ }
4039
+
4040
+ // Nearest content edge strictly above the heading cluster. Walks
4041
+ // previous siblings, then out through ancestors. Skips elements that
4042
+ // vertically overlap (flex-row companions, sticky rails) or sit in
4043
+ // another column. Returns null when nothing qualifies — first content
4044
+ // on the page, or the top of a visually bounded container.
4045
+ function edgeAbove(startEl, top, rect) {
4046
+ let node = startEl;
4047
+ while (node && node !== document.body) {
4048
+ let sib = node.previousElementSibling;
4049
+ while (sib) {
4050
+ if (isVisibleFlow(sib)) {
4051
+ const sr = sib.getBoundingClientRect();
4052
+ if (sr.bottom <= top + 2 && overlapsX(sr, rect)) return sr.bottom;
4053
+ }
4054
+ sib = sib.previousElementSibling;
4055
+ }
4056
+ const parent = node.parentElement;
4057
+ if (!parent || parent === document.body) return null;
4058
+ // Leaving a container upward: if it draws its own top edge, that
4059
+ // edge separates the heading from whatever sits above.
4060
+ if (hasOwnTopBoundary(parent)) return null;
4061
+ node = parent;
4062
+ }
4063
+ return null;
4064
+ }
4065
+
4066
+ // Nearest content edge strictly below the heading — the block the
4067
+ // heading introduces. Crosses wrappers freely (headings often share a
4068
+ // row wrapper with an eyebrow or index label).
4069
+ function edgeBelow(h, rect) {
4070
+ let node = h;
4071
+ while (node && node !== document.body) {
4072
+ let sib = node.nextElementSibling;
4073
+ while (sib) {
4074
+ if (isVisibleFlow(sib)) {
4075
+ const sr = sib.getBoundingClientRect();
4076
+ if (sr.top >= rect.bottom - 2 && overlapsX(sr, rect)) return sr.top;
4077
+ }
4078
+ sib = sib.nextElementSibling;
4079
+ }
4080
+ node = node.parentElement;
4081
+ }
4082
+ return null;
4083
+ }
4084
+
4085
+ function insideSmallCard(h) {
4086
+ let cur = h.parentElement;
4087
+ while (cur && cur !== document.body) {
4088
+ if (isCardLikeDOM(cur)) {
4089
+ const cr = cur.getBoundingClientRect();
4090
+ if (cr.height < CARD_EXEMPT_HEIGHT) return true;
4091
+ }
4092
+ cur = cur.parentElement;
4093
+ }
4094
+ return false;
4095
+ }
4096
+
4097
+ const candidates = [];
4098
+ for (const h of document.querySelectorAll('h2, h3, h4')) {
4099
+ if (!isVisibleFlow(h)) continue;
4100
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ');
4101
+ if (text.length < 3) continue;
4102
+ const rect = h.getBoundingClientRect();
4103
+ const belowTop = edgeBelow(h, rect);
4104
+ if (belowTop == null) continue; // heading introduces nothing measurable
4105
+ const { topEl, top } = clusterTop(h, rect);
4106
+ const aboveBottom = edgeAbove(topEl, top, rect);
4107
+ if (aboveBottom == null) continue; // first content, or bounded container
4108
+ if (insideSmallCard(h)) continue;
4109
+ const above = Math.max(0, top - aboveBottom);
4110
+ const below = Math.max(0, belowTop - rect.bottom);
4111
+ if (below < 6 || below > MAX_BELOW_PX) continue;
4112
+ // Violation: the space above clearly fails to exceed the space below.
4113
+ // Near-equal gaps are ambiguous rather than inverted, so they pass.
4114
+ if (above < below * 0.75 && below - above >= MIN_DEFICIT_PX) {
4115
+ candidates.push({ el: h, tag: h.tagName.toLowerCase(), text: text.slice(0, 60), above, below });
4116
+ }
4117
+ }
4118
+
4119
+ if (candidates.length < MIN_VIOLATIONS) return [];
4120
+ return candidates.map(c => ({
4121
+ type: 'heading-rhythm',
4122
+ detail: `${c.tag} "${c.text}" has ${Math.round(c.above)}px above vs ${Math.round(c.below)}px below — it reads as bound to the block above (${candidates.length} headings on page)`,
4123
+ el: c.el,
4124
+ }));
4125
+ }
4126
+
2012
4127
  // Node page-level checks — take document/window as parameters
2013
4128
 
2014
4129
  function checkPageTypography(doc, win) {
@@ -2036,14 +4151,9 @@ function checkPageTypography(doc, win) {
2036
4151
 
2037
4152
  // Check Google Fonts links in HTML
2038
4153
  const html = doc.documentElement?.outerHTML || '';
2039
- const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
2040
- let m;
2041
- while ((m = gfRe.exec(html)) !== null) {
2042
- const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
2043
- for (const f of families) {
2044
- fonts.add(f);
2045
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2046
- }
4154
+ for (const f of extractGoogleFontFamilies(html)) {
4155
+ fonts.add(f);
4156
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2047
4157
  }
2048
4158
 
2049
4159
  // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -2062,14 +4172,6 @@ function checkPageTypography(doc, win) {
2062
4172
  findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
2063
4173
  }
2064
4174
 
2065
- // Single font
2066
- if (fonts.size === 1) {
2067
- const els = doc.querySelectorAll('*');
2068
- if (els.length >= 20) {
2069
- findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
2070
- }
2071
- }
2072
-
2073
4175
  // Flat type hierarchy
2074
4176
  const sizes = new Set();
2075
4177
  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
@@ -2156,6 +4258,135 @@ function checkPageLayout(doc, win) {
2156
4258
  return findings;
2157
4259
  }
2158
4260
 
4261
+ // ── Repeated text inside one container ──────────────────────────────────────
4262
+ // The same literal string rendered 3+ times in structurally different spots
4263
+ // inside one bordered/elevated container — typically a status word wired
4264
+ // into every slot of a card template. Legitimate repetition is structural:
4265
+ // table columns, calendar grids, nav/menu lists, and templated sibling rows
4266
+ // all repeat text in *parallel* positions, so occurrences whose element
4267
+ // paths inside the container are identical (or live in dedicated repetition
4268
+ // structures) never count. Only 3+ occurrences at 3+ distinct structural
4269
+ // positions flag.
4270
+
4271
+ const REPEATED_TEXT_SKIP_SELECTOR = [
4272
+ 'table',
4273
+ 'select',
4274
+ 'datalist',
4275
+ 'nav',
4276
+ 'menu',
4277
+ '[role="navigation"]',
4278
+ '[role="menu"]',
4279
+ '[role="menubar"]',
4280
+ '[role="listbox"]',
4281
+ '[role="grid"]',
4282
+ '[role="tablist"]',
4283
+ '[role="radiogroup"]',
4284
+ '[aria-hidden="true"]',
4285
+ ].join(',');
4286
+
4287
+ const REPEATED_TEXT_CONTAINER_TAGS = new Set([
4288
+ 'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li',
4289
+ ]);
4290
+
4291
+ // A container worth attributing text to: visibly bounded (border on most
4292
+ // sides or an elevation shadow) and surface-like (radius or own background).
4293
+ function isRepeatedTextContainer(style) {
4294
+ if (!style) return false;
4295
+ const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== '');
4296
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left']
4297
+ .filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length;
4298
+ const hasBorder = borderSides >= 3;
4299
+ const hasRadius = (parseFloat(style.borderRadius) || 0) > 0;
4300
+ const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
4301
+ const hasBg = !!(bg && (bg.a ?? 1) > 0.1);
4302
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
4303
+ }
4304
+
4305
+ function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) {
4306
+ const isVisible = opts.isVisible || (() => true);
4307
+ const findings = [];
4308
+
4309
+ const containers = [];
4310
+ const containerSet = new Set();
4311
+ for (const el of doc.querySelectorAll('*')) {
4312
+ if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue;
4313
+ if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
4314
+ if (!isRepeatedTextContainer(getStyle(el))) continue;
4315
+ containers.push(el);
4316
+ containerSet.add(el);
4317
+ }
4318
+
4319
+ for (const container of containers) {
4320
+ if (!isVisible(container)) continue;
4321
+ const descendants = container.querySelectorAll('*');
4322
+ // Page-scale wrappers that merely happen to carry a background are not
4323
+ // the "one card" this rule reasons about.
4324
+ if (descendants.length > 250) continue;
4325
+
4326
+ const groups = new Map();
4327
+ for (const d of descendants) {
4328
+ // Attribute text to the innermost container only.
4329
+ let anc = d.parentElement;
4330
+ let ownedByInner = false;
4331
+ while (anc && anc !== container) {
4332
+ if (containerSet.has(anc)) { ownedByInner = true; break; }
4333
+ anc = anc.parentElement;
4334
+ }
4335
+ if (ownedByInner) continue;
4336
+ if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
4337
+ // Icon-font glyph names read as text but render as symbols.
4338
+ if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue;
4339
+ if (!isVisible(d)) continue;
4340
+
4341
+ const direct = [...d.childNodes]
4342
+ .filter(n => n.nodeType === 3)
4343
+ .map(n => n.textContent)
4344
+ .join(' ')
4345
+ .replace(/\s+/g, ' ')
4346
+ .trim();
4347
+ if (direct.length < 4 || direct.length > 48) continue;
4348
+ if (!/[a-zA-Z]/.test(direct)) continue;
4349
+
4350
+ // Structural signature: the element path from the occurrence up to
4351
+ // the container. Parallel/templated repetition shares one signature.
4352
+ const sig = [];
4353
+ for (let cur = d; cur && cur !== container; cur = cur.parentElement) {
4354
+ const cls = String(cur.getAttribute?.('class') || '')
4355
+ .trim().split(/\s+/).filter(Boolean).sort().join('.');
4356
+ sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : ''));
4357
+ }
4358
+ if (!groups.has(direct)) groups.set(direct, []);
4359
+ groups.get(direct).push(sig.join('>'));
4360
+ }
4361
+
4362
+ for (const [text, sigs] of groups) {
4363
+ if (sigs.length < 3) continue;
4364
+ if (new Set(sigs).size < 3) continue;
4365
+ findings.push({
4366
+ id: 'repeated-container-text',
4367
+ snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`,
4368
+ });
4369
+ }
4370
+ }
4371
+ return findings;
4372
+ }
4373
+
4374
+ function checkRepeatedContainerTextFromDoc(doc, win) {
4375
+ return collectRepeatedContainerTextFindings(
4376
+ doc,
4377
+ (el) => win.getComputedStyle(el),
4378
+ { isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' },
4379
+ );
4380
+ }
4381
+
4382
+ function checkRepeatedContainerTextDOM() {
4383
+ return collectRepeatedContainerTextFindings(
4384
+ document,
4385
+ (el) => getComputedStyle(el),
4386
+ { isVisible: isRenderedForBrowserRule },
4387
+ );
4388
+ }
4389
+
2159
4390
  // ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
2160
4391
  // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
2161
4392
  // small warm tint (not white, not a strong color). The current reflex surface.
@@ -2269,7 +4500,7 @@ function checkElementOversizedH1DOM(el) {
2269
4500
  return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
2270
4501
  }
2271
4502
 
2272
- // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
4503
+ // ─── Generated-UI tell: hairline border + wide diffuse shadow ────────────────
2273
4504
  const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
2274
4505
 
2275
4506
  function shadowLayerAlpha(layer) {
@@ -2594,22 +4825,675 @@ function checkElementTextOverflowDOM(el) {
2594
4825
  if (el.clientWidth > 0 && delta >= 16) {
2595
4826
  return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
2596
4827
  }
4828
+
4829
+ // Inline text owners have no client geometry (clientWidth/scrollWidth are
4830
+ // both 0), so the scrollWidth path above never sees them. Their overflow
4831
+ // registers only on a block ancestor, and that ancestor has no direct text
4832
+ // so the ownership gate skips it. (The shipped miss: a nowrap inline
4833
+ // <span> spilling 45px past its fixed-width grid cell.) Measure the inline
4834
+ // box against the padding box of its nearest block container instead.
4835
+ if (el.clientWidth === 0 && rect && rect.width > 0) {
4836
+ let container = el.parentElement;
4837
+ while (container && container.clientWidth === 0) container = container.parentElement;
4838
+ if (!container) return [];
4839
+ // Transforms make rect comparisons lie; skip anything on that path.
4840
+ for (let p = el; p && p !== container.parentElement; p = p.parentElement) {
4841
+ const t = getComputedStyle(p).transform;
4842
+ if (t && t !== 'none') return [];
4843
+ }
4844
+ const cRect = container.getBoundingClientRect();
4845
+ const contentRight = cRect.left + container.clientLeft + container.clientWidth;
4846
+ const spill = rect.right - contentRight;
4847
+ if (spill >= 16) {
4848
+ return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its container by ${Math.round(spill)}px` }];
4849
+ }
4850
+ }
2597
4851
  return [];
2598
4852
  }
2599
4853
 
4854
+ // ---------------------------------------------------------------------------
4855
+ // Blinking cursor (browser-only)
4856
+ // ---------------------------------------------------------------------------
4857
+
4858
+ // Block / underscore glyphs commonly used as a fake text cursor.
4859
+ const CURSOR_GLYPH_RE = /^[_|▀-▟■▮❙❚|]$/;
4860
+
4861
+ // How far down the page still counts as the first-viewport / hero region.
4862
+ // Hero compositions regularly run past a literal viewport height, so the
4863
+ // gate is a landing-region budget, not an exact fold line.
4864
+ const CURSOR_FIRST_VIEWPORT_PX = 1200;
4865
+
4866
+ // Do the named @keyframes only toggle visibility (opacity dropping to ~0 or
4867
+ // visibility:hidden), i.e. a blink rather than a fade/move/spin? Walks the
4868
+ // live CSSOM; cross-origin sheets are skipped.
4869
+ function keyframesToggleVisibilityDOM(name) {
4870
+ if (!name) return false;
4871
+ for (const sheet of document.styleSheets) {
4872
+ let rules;
4873
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
4874
+ if (!rules) continue;
4875
+ const stack = [...rules];
4876
+ while (stack.length) {
4877
+ const rule = stack.shift();
4878
+ if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; }
4879
+ if (rule.type !== 7 || rule.name !== name) continue; // 7 = KEYFRAMES_RULE
4880
+ let togglesOut = false;
4881
+ for (const frame of rule.cssRules || []) {
4882
+ const fs = frame.style;
4883
+ if (!fs) continue;
4884
+ for (let i = 0; i < fs.length; i++) {
4885
+ const prop = fs[i];
4886
+ if (prop === 'opacity') {
4887
+ if ((parseFloat(fs.getPropertyValue('opacity')) || 0) <= 0.15) togglesOut = true;
4888
+ } else if (prop === 'visibility') {
4889
+ if (/hidden/i.test(fs.getPropertyValue('visibility'))) togglesOut = true;
4890
+ } else if (prop !== 'animation-timing-function') {
4891
+ return false; // keyframes animate something else — not a blink
4892
+ }
4893
+ }
4894
+ }
4895
+ return togglesOut;
4896
+ }
4897
+ }
4898
+ return false;
4899
+ }
4900
+
4901
+ // Decorative blinking cursor: a small block / underscore element bound to an
4902
+ // infinite blink animation, sitting in the first-viewport region of a page.
4903
+ // Real editable surfaces (inputs, textareas, contenteditable, role=textbox)
4904
+ // draw their own caret and are exempt. Round pulsing dots stay with the
4905
+ // pulsing-dot rule.
4906
+ function checkElementBlinkingCursorDOM(el) {
4907
+ const tag = el.tagName.toLowerCase();
4908
+ if (['input', 'textarea', 'select', 'img', 'svg', 'script', 'style'].includes(tag)) return [];
4909
+ const style = getComputedStyle(el);
4910
+
4911
+ const iterations = (style.animationIterationCount || '').split(',').map(s => s.trim());
4912
+ if (!iterations.includes('infinite')) return [];
4913
+ const names = (style.animationName || '').split(',').map(s => s.trim()).filter(n => n && n !== 'none');
4914
+ if (names.length === 0) return [];
4915
+ const blinkName = names.find(n => /blink|caret|cursor/i.test(n))
4916
+ || names.find(n => keyframesToggleVisibilityDOM(n));
4917
+ if (!blinkName) return [];
4918
+
4919
+ // Real caret contexts are exempt.
4920
+ if (el.isContentEditable || el.closest('[contenteditable=""], [contenteditable="true"], [role="textbox"]')) return [];
4921
+
4922
+ const rect = el.getBoundingClientRect();
4923
+ if (rect.width <= 0 || rect.height <= 0) return [];
4924
+
4925
+ // First-viewport gate: the hero cliché, not a footer terminal.
4926
+ const pageTop = rect.top + (window.scrollY || 0);
4927
+ if (pageTop > CURSOR_FIRST_VIEWPORT_PX) return [];
4928
+
4929
+ // Cursor shape: a lone block/underscore glyph, or an empty solid
4930
+ // rectangle sized like a text caret (block or underscore form).
4931
+ const text = (el.textContent || '').trim();
4932
+ const glyphCursor = text.length === 1 && CURSOR_GLYPH_RE.test(text);
4933
+ let blockCursor = false;
4934
+ if (!glyphCursor) {
4935
+ if (text.length > 0 || el.childElementCount > 0) return [];
4936
+ const bg = parseAnyColor(style.backgroundColor || '');
4937
+ const filled = bg && (bg.a ?? 1) > 0.2;
4938
+ const hasBorderFill = ['Left', 'Right', 'Bottom'].some(
4939
+ side => (parseFloat(style[`border${side}Width`]) || 0) >= 1,
4940
+ );
4941
+ if (!filled && !hasBorderFill) return [];
4942
+ const vertical = rect.width >= 1 && rect.width <= 24 && rect.height >= 6 && rect.height <= 48 && rect.height >= rect.width;
4943
+ const underscore = rect.height >= 1 && rect.height <= 6 && rect.width >= 4 && rect.width <= 24;
4944
+ if (!vertical && !underscore) return [];
4945
+ // Round dots are the pulsing-dot rule's territory.
4946
+ const radiusPx = parseFloat(style.borderRadius) || 0;
4947
+ if (radiusPx >= 0.4 * Math.min(rect.width, rect.height)) return [];
4948
+ blockCursor = true;
4949
+ }
4950
+ if (!glyphCursor && !blockCursor) return [];
4951
+
4952
+ // Hero-region promotion: a fake caret blinking in the first ~900px or
4953
+ // inside the page chrome is the shipped hero cliché, not an incidental
4954
+ // flourish. Promote those from the registry's advisory to warning;
4955
+ // lower first-viewport occurrences keep the default severity.
4956
+ const inHeroRegion = pageTop <= 900
4957
+ || !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
4958
+ return [{
4959
+ id: 'blinking-cursor',
4960
+ snippet: `${classSelector(el)} — ${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
4961
+ ...(inHeroRegion ? { severity: 'warning' } : {}),
4962
+ }];
4963
+ }
4964
+
4965
+ // ---------------------------------------------------------------------------
4966
+ // Content invisible at rest (browser-only, driven by the URL engine)
4967
+ // ---------------------------------------------------------------------------
4968
+
4969
+ // Tags whose text never renders, or whose hidden state is legitimate UI
4970
+ // (templates, dialogs, native select options). Text inside them stays out of
4971
+ // both the numerator and the denominator.
4972
+ const HIDDEN_TEXT_EXCLUDE_TAGS = new Set([
4973
+ 'script', 'style', 'noscript', 'template', 'title', 'head', 'meta', 'link',
4974
+ 'option', 'optgroup', 'select', 'datalist', 'dialog',
4975
+ ]);
4976
+
4977
+ // Measure how many text characters currently render invisible (computed
4978
+ // opacity ~0 or visibility hidden anywhere on the ancestor chain) versus
4979
+ // visible. display:none / [hidden] / aria-hidden subtrees are legitimately
4980
+ // hidden UI (menus, tab panels, templates): they are excluded from the
4981
+ // denominator entirely rather than counted as invisible.
4982
+ function measureHiddenTextDOM() {
4983
+ const cache = new Map();
4984
+ function stateOf(el) {
4985
+ if (!el || el.nodeType !== 1 || el === document.documentElement) return 'visible';
4986
+ const cached = cache.get(el);
4987
+ if (cached) return cached;
4988
+ let state;
4989
+ const tag = el.tagName.toLowerCase();
4990
+ if (HIDDEN_TEXT_EXCLUDE_TAGS.has(tag)) {
4991
+ state = 'excluded';
4992
+ } else {
4993
+ const parentState = stateOf(el.parentElement);
4994
+ if (parentState === 'excluded') {
4995
+ state = 'excluded';
4996
+ } else {
4997
+ const style = getComputedStyle(el);
4998
+ if (style.display === 'none' || el.hidden || el.getAttribute('aria-hidden') === 'true'
4999
+ || String(style.contentVisibility || '').toLowerCase() === 'hidden') {
5000
+ state = 'excluded';
5001
+ } else if (parentState === 'invisible'
5002
+ || (parseFloat(style.opacity) || 0) <= 0.02
5003
+ || /^(hidden|collapse)$/.test(style.visibility)) {
5004
+ state = 'invisible';
5005
+ } else {
5006
+ state = 'visible';
5007
+ }
5008
+ }
5009
+ }
5010
+ cache.set(el, state);
5011
+ return state;
5012
+ }
5013
+
5014
+ let totalChars = 0;
5015
+ let hiddenChars = 0;
5016
+ const hiddenSamples = [];
5017
+ for (const el of document.querySelectorAll('body *')) {
5018
+ let len = 0;
5019
+ for (const node of el.childNodes) {
5020
+ if (node.nodeType === 3) len += node.textContent.replace(/\s+/g, ' ').trim().length;
5021
+ }
5022
+ if (!len) continue;
5023
+ const state = stateOf(el);
5024
+ if (state === 'excluded') continue;
5025
+ totalChars += len;
5026
+ if (state === 'invisible') {
5027
+ hiddenChars += len;
5028
+ if (hiddenSamples.length < 3) {
5029
+ const text = String(el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
5030
+ if (text) hiddenSamples.push(text);
5031
+ }
5032
+ }
5033
+ }
5034
+ return { totalChars, hiddenChars, hiddenSamples };
5035
+ }
5036
+
5037
+ // Pure threshold check over a measureHiddenTextDOM() result. The URL engine
5038
+ // calls it AFTER a reveal sweep (scroll through the document so every
5039
+ // IntersectionObserver / scroll reveal had its chance to fire, then back to
5040
+ // the top): a healthy reveal-on-scroll page drops to ~0 invisible text after
5041
+ // the sweep, while a page whose reveal script died keeps most of its text at
5042
+ // opacity 0 forever. Fires only when the invisible share stays above 30%
5043
+ // with a real amount of text behind it.
5044
+ function checkContentHiddenAtRest({ totalChars = 0, hiddenChars = 0, hiddenSamples = [] } = {}) {
5045
+ if (totalChars < 200 || hiddenChars < 150) return [];
5046
+ const share = hiddenChars / totalChars;
5047
+ if (share <= 0.3) return [];
5048
+ const sample = hiddenSamples.length ? ` (e.g. "${hiddenSamples[0]}")` : '';
5049
+ return [{
5050
+ id: 'content-hidden-at-rest',
5051
+ snippet: `${Math.round(share * 100)}% of page text (${hiddenChars} of ${totalChars} chars) stays at opacity 0 / visibility hidden after reveal handlers ran${sample}`,
5052
+ }];
5053
+ }
5054
+
5055
+ // ---------------------------------------------------------------------------
5056
+ // Edge-flush cards in horizontal scrollers (browser-only)
5057
+ // ---------------------------------------------------------------------------
5058
+
5059
+ // A visually-defined card (own opaque background, or borders on 2+ sides)
5060
+ // inside a horizontal scroller, sitting flush against one edge of the
5061
+ // scroller's clip box at rest while keeping a clear gutter on the other
5062
+ // side. The canonical bug: the first snap panel is sized wider than the
5063
+ // scroller, so its cards end exactly at the clip edge with their rounded
5064
+ // corners cut, while every sibling panel keeps its inset. Cards that extend
5065
+ // far past the edge are deliberate peeks and stay exempt.
5066
+ function checkEdgeFlushCardsDOM() {
5067
+ const findings = [];
5068
+ const vh = window.innerHeight || 800;
5069
+ const isScroller = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
5070
+
5071
+ for (const scroller of document.querySelectorAll('*')) {
5072
+ const style = getComputedStyle(scroller);
5073
+ if (!isScroller(style)) continue;
5074
+ if (scroller.scrollWidth <= scroller.clientWidth + 8) continue;
5075
+ // At rest only: a user-scrolled or snapped-forward scroller legitimately
5076
+ // shows cut cards at both edges.
5077
+ if (scroller.scrollLeft > 4) continue;
5078
+ const scRect = scroller.getBoundingClientRect();
5079
+ if (scRect.width < 120 || scRect.height < 60) continue;
5080
+ // Landing-region gate: the defect matters where the page opens.
5081
+ if (scRect.top + (window.scrollY || 0) > 2 * vh) continue;
5082
+ const contentLeft = scRect.left + scroller.clientLeft;
5083
+ const contentRight = contentLeft + scroller.clientWidth;
5084
+
5085
+ const flush = [];
5086
+ for (const card of scroller.querySelectorAll('*')) {
5087
+ if (!isRenderedForBrowserRule(card)) continue;
5088
+ // Attribute cards to their nearest scroller only (nested scrollers).
5089
+ let owner = card.parentElement;
5090
+ while (owner && owner !== scroller && !isScroller(getComputedStyle(owner))) owner = owner.parentElement;
5091
+ if (owner !== scroller) continue;
5092
+ const cs = getComputedStyle(card);
5093
+ const rect = card.getBoundingClientRect();
5094
+ if (rect.width < 80 || rect.height < 40) continue;
5095
+ const bg = parseAnyColor(cs.backgroundColor || '');
5096
+ const hasBg = !!(bg && (bg.a ?? 1) > 0.5);
5097
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left']
5098
+ .filter(side => (parseFloat(cs[`border${side}Width`]) || 0) > 0).length;
5099
+ if (!hasBg && borderSides < 2) continue;
5100
+ const leftGutter = rect.left - contentLeft;
5101
+ const rightGap = contentRight - rect.right;
5102
+ // Flush right with a left gutter, or the mirror. The -24 floor keeps
5103
+ // deliberately peeking next-cards (cut mid-card) exempt.
5104
+ const flushRight = leftGutter >= 6 && rightGap < 8 && rightGap > -24;
5105
+ const flushLeft = rightGap >= 6 && leftGutter < 8 && leftGutter > -24;
5106
+ if (!flushRight && !flushLeft) continue;
5107
+ flush.push({ card, edge: flushRight ? 'right' : 'left', gap: Math.round(flushRight ? rightGap : leftGutter) });
5108
+ }
5109
+ if (flush.length === 0) continue;
5110
+ const worst = flush.reduce((a, b) => (b.gap < a.gap ? b : a));
5111
+ findings.push({
5112
+ el: scroller,
5113
+ type: 'edge-flush-cards',
5114
+ detail: `${flush.length} card${flush.length === 1 ? '' : 's'} flush against the ${worst.edge} edge of ${classSelector(scroller)} at rest (${worst.gap}px gap, e.g. ${classSelector(worst.card)})`,
5115
+ });
5116
+ }
5117
+ return findings;
5118
+ }
5119
+
5120
+ // ---------------------------------------------------------------------------
5121
+ // Text occlusion / element overlap (browser-only)
5122
+ // ---------------------------------------------------------------------------
5123
+
5124
+ // An opaque decorated box: a near-solid background fill or two-plus visible
5125
+ // borders make it hide whatever sits behind it. Gradient / image fills are
5126
+ // deliberately excluded — a scrim gradient over hero imagery is a contrast
5127
+ // layer, not an occluder, and belongs to the pixel low-contrast rule.
5128
+ function isOpaqueDecoratedBox(cs) {
5129
+ if (!cs) return false;
5130
+ const bg = parseAnyColor(cs.backgroundColor || '');
5131
+ if (bg && (bg.a ?? 1) > 0.6) return true;
5132
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left'].filter((side) => {
5133
+ if ((parseFloat(cs[`border${side}Width`]) || 0) <= 0) return false;
5134
+ const bc = parseAnyColor(cs[`border${side}Color`] || '');
5135
+ return bc && (bc.a ?? 1) > 0.3;
5136
+ }).length;
5137
+ return borderSides >= 2;
5138
+ }
5139
+
5140
+ // Is this element lifted out of normal flow into a layer that can cover
5141
+ // siblings? Two normal-flow blocks stacked vertically cannot truly hide each
5142
+ // other's ink — an overlap between their rects is line-box bleed from tight
5143
+ // leading (a display headline reaching up over the line before it), not
5144
+ // occlusion. Only out-of-flow positioning (absolute / fixed / sticky) moves an
5145
+ // element off its own row onto the pixels of another; an in-place transform or
5146
+ // relative nudge on a display headline does not.
5147
+ function isLayeredElement(el) {
5148
+ for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) {
5149
+ const pos = String(getComputedStyle(cur).position || 'static');
5150
+ if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return true;
5151
+ }
5152
+ return false;
5153
+ }
5154
+
5155
+ function elementDirectText(el) {
5156
+ let t = '';
5157
+ for (const node of el.childNodes || []) {
5158
+ if (node.nodeType === 3) t += node.textContent;
5159
+ }
5160
+ return t.trim();
5161
+ }
5162
+
5163
+ // Rendered gate that, unlike isRenderedForBrowserRule, does NOT exempt
5164
+ // aria-hidden subtrees: a decorative aria-hidden box still paints on screen
5165
+ // and can still visually cover real text.
5166
+ function isPaintedForOcclusion(el) {
5167
+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
5168
+ const style = getComputedStyle(cur);
5169
+ const visibility = String(style.visibility || '').toLowerCase();
5170
+ if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
5171
+ if ((parseFloat(style.opacity) || 0) <= 0.05) return false;
5172
+ if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
5173
+ }
5174
+ return true;
5175
+ }
5176
+
5177
+ // Detects text that is actually painted UNDER an opaque box or another text
5178
+ // run (the reader can't read it), plus two structural overlap tells the
5179
+ // elementFromPoint probe can't reach: a large headline whose edge tucks behind
5180
+ // an opaque card, and an inline element whose leaked padding-box (a common
5181
+ // class-name-collision bug) covers a sibling.
5182
+ //
5183
+ // The occlusion probe is viewport-bound: elementFromPoint only answers for the
5184
+ // scan's current viewport (scroll 0), so the ground-truth paths cover the
5185
+ // first-viewport composition where collisions matter most. The inline-leak
5186
+ // path is pure geometry and runs anywhere on the page.
5187
+ const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
5188
+
5189
+ function checkTextOcclusionDOM() {
5190
+ const findings = [];
5191
+ const seenVictims = new Set();
5192
+ const vw = window.innerWidth || 1280;
5193
+ const vh = window.innerHeight || 800;
5194
+
5195
+ const isFloated = (cs) => {
5196
+ const f = String(cs.cssFloat || cs.float || 'none').toLowerCase();
5197
+ return f === 'left' || f === 'right';
5198
+ };
5199
+ const isMarqueeish = (el, cs) => {
5200
+ if (el.tagName === 'MARQUEE') return true;
5201
+ const ident = `${el.getAttribute?.('class') || ''} ${el.getAttribute?.('id') || ''}`;
5202
+ if (/\b(marquee|ticker|scroller|carousel|conveyor)\b/i.test(ident)) return true;
5203
+ const anim = String(cs.animationName || '').toLowerCase();
5204
+ return /marquee|ticker|scroll/.test(anim);
5205
+ };
5206
+ // A fixed or sticky overlay (status bar, toolbar, sticky header) floats above
5207
+ // scrolling content by design — whatever sits under it at rest scrolls clear,
5208
+ // so it is not occluding the page.
5209
+ const isPinnedOverlay = (el) => {
5210
+ for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) {
5211
+ const pos = String(getComputedStyle(cur).position || 'static');
5212
+ if (pos === 'fixed' || pos === 'sticky') return true;
5213
+ }
5214
+ return false;
5215
+ };
5216
+
5217
+ // Collect renderable text owners in / near the first viewport for the
5218
+ // elementFromPoint probe. SVG <text> counts too.
5219
+ const textEls = [];
5220
+ for (const el of document.querySelectorAll('body *')) {
5221
+ const tag = el.tagName.toLowerCase();
5222
+ if (OCCLUSION_TEXT_SKIP_TAGS.has(tag)) continue;
5223
+ const inSvg = !!el.closest('svg');
5224
+ if (inSvg && tag !== 'text') continue;
5225
+ const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
5226
+ if (text.length < 2) continue;
5227
+ if (!isPaintedForOcclusion(el)) continue;
5228
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
5229
+ if (rect.width < 6 || rect.height < 6) continue;
5230
+ // Viewport-bound probe: keep text whose box overlaps the live viewport.
5231
+ if (rect.bottom <= 0 || rect.top >= vh) continue;
5232
+ textEls.push({ el, rect, text, inSvg });
5233
+ }
5234
+
5235
+ for (const victim of textEls) {
5236
+ const { el, rect, text } = victim;
5237
+ if (seenVictims.has(el)) continue;
5238
+ const style = getComputedStyle(el);
5239
+ if (isScreenReaderOnlyTextStyle(style, { width: rect.width, height: rect.height, clientWidth: el.clientWidth, clientHeight: el.clientHeight })) continue;
5240
+
5241
+ const cols = Math.max(6, Math.min(30, Math.round(rect.width / 12)));
5242
+ const rows = Math.max(1, Math.min(4, Math.round(rect.height / 14)));
5243
+ let total = 0;
5244
+ let occluded = 0;
5245
+ let occluderEl = null;
5246
+ let occluderKind = '';
5247
+ for (let i = 0; i < cols; i++) {
5248
+ const x = rect.left + rect.width * ((i + 0.5) / cols);
5249
+ if (x < 1 || x > vw - 1) continue;
5250
+ for (let j = 0; j < rows; j++) {
5251
+ const y = rect.top + rect.height * ((j + 0.5) / rows);
5252
+ if (y < 1 || y > vh - 1) continue;
5253
+ total++;
5254
+ const top = document.elementFromPoint(x, y);
5255
+ if (!top) continue;
5256
+ // Text visible here: the probe returns the text itself, a descendant,
5257
+ // or one of its ancestors (the text's own container / background).
5258
+ if (top === el || el.contains(top) || top.contains(el)) continue;
5259
+ const topCs = getComputedStyle(top);
5260
+ if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
5261
+ const topTag = top.tagName.toLowerCase();
5262
+ // Text sitting under a raw image/video is contrast territory (deduped
5263
+ // against the pixel low-contrast rule); leave those alone here.
5264
+ if (['img', 'video', 'canvas', 'picture'].includes(topTag)) continue;
5265
+ const topHasText = elementDirectText(top).length > 0 || !!top.closest('svg');
5266
+ if (isOpaqueDecoratedBox(topCs)) {
5267
+ occluded++;
5268
+ if (!occluderEl) { occluderEl = top; occluderKind = 'box'; }
5269
+ } else if (topHasText) {
5270
+ occluded++;
5271
+ if (!occluderEl) { occluderEl = top; occluderKind = 'text'; }
5272
+ }
5273
+ }
5274
+ }
5275
+ if (total === 0 || !occluderEl) continue;
5276
+ const occFrac = occluded / total;
5277
+ // A solid box's paint fills its rect, so box coverage is real at a lower
5278
+ // bar. Text coverage rides on elementFromPoint returning the occluder's box
5279
+ // (line box / container), which can exceed its actual glyph ink, so the
5280
+ // text bar is higher — partial overlaps below it are crowding, not burial.
5281
+ if (occFrac < (occluderKind === 'text' ? 0.45 : 0.3)) continue;
5282
+
5283
+ // (i) Substantial occlusion: a real slab of the text is behind something.
5284
+ if (occluderKind === 'text') {
5285
+ // Two SVG texts inside the same emblem (concentric arcs, monogram) are one
5286
+ // decorative unit, not a collision.
5287
+ const victimSvg = el.closest('svg');
5288
+ const occSvg = occluderEl.closest('svg');
5289
+ if (victimSvg && occSvg && victimSvg === occSvg) continue;
5290
+ // Both sides in plain flow: the overlap is line-box bleed from tight
5291
+ // leading (a big headline reaching up over its own eyebrow), not one text
5292
+ // run painted over another.
5293
+ if (!isLayeredElement(el) && !isLayeredElement(occluderEl)) continue;
5294
+ }
5295
+ seenVictims.add(el);
5296
+ findings.push({
5297
+ el,
5298
+ type: 'text-occlusion',
5299
+ detail: `${classSelector(el)} "${text.slice(0, 24)}" is ${Math.round(occFrac * 100)}% covered by ${occluderKind === 'text' ? 'overlapping text' : 'an opaque element'} (${classSelector(occluderEl)})`,
5300
+ });
5301
+ }
5302
+
5303
+ // (ii) Headline overhanging an opaque card: a display-scale line whose bulk
5304
+ // sits outside a bounded content card but whose edge clips into it. The text
5305
+ // may still paint on top and stay readable, but the two layers were dropped
5306
+ // on the same pixels — a placement collision, not a composition.
5307
+ const cards = [];
5308
+ for (const el of document.querySelectorAll('body *')) {
5309
+ if (el.closest('svg')) continue;
5310
+ if (!isPaintedForOcclusion(el)) continue;
5311
+ const cs = getComputedStyle(el);
5312
+ const bg = parseAnyColor(cs.backgroundColor || '');
5313
+ const bgImg = cs.backgroundImage || '';
5314
+ if (!bg || (bg.a ?? 1) <= 0.7) continue;
5315
+ if (bgImg && bgImg !== 'none' && /(gradient|url)\(/i.test(bgImg)) continue;
5316
+ const hasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((s) => (parseFloat(cs[`border${s}Width`]) || 0) > 0);
5317
+ const hasShadow = cs.boxShadow && cs.boxShadow !== 'none';
5318
+ if (!hasBorder && !hasShadow) continue;
5319
+ if (isPinnedOverlay(el)) continue;
5320
+ let cr; try { cr = el.getBoundingClientRect(); } catch { continue; }
5321
+ if (cr.width < 100 || cr.width > 0.8 * vw || cr.height < 60) continue;
5322
+ cards.push({ el, rect: cr });
5323
+ }
5324
+ for (const victim of textEls) {
5325
+ const { el, rect, text } = victim;
5326
+ if (seenVictims.has(el)) continue;
5327
+ const style = getComputedStyle(el);
5328
+ if ((parseFloat(style.fontSize) || 16) < 40) continue;
5329
+ let lineHeight = parseFloat(style.lineHeight);
5330
+ if (!Number.isFinite(lineHeight)) lineHeight = (parseFloat(style.fontSize) || 16) * 1.2;
5331
+ const centerX = rect.left + rect.width / 2;
5332
+ for (const card of cards) {
5333
+ if (card.el === el || el.contains(card.el) || card.el.contains(el)) continue;
5334
+ const ix = Math.max(0, Math.min(rect.right, card.rect.right) - Math.max(rect.left, card.rect.left));
5335
+ const iy = Math.max(0, Math.min(rect.bottom, card.rect.bottom) - Math.max(rect.top, card.rect.top));
5336
+ if (ix < 8 || iy < 0.5 * lineHeight) continue;
5337
+ // The headline's bulk must sit outside the card — only its edge clips in.
5338
+ if (centerX >= card.rect.left && centerX <= card.rect.right) continue;
5339
+ if (ix > 0.5 * rect.width) continue;
5340
+ seenVictims.add(el);
5341
+ findings.push({
5342
+ el,
5343
+ type: 'text-occlusion',
5344
+ detail: `${classSelector(el)} "${text.slice(0, 24)}" overhangs ${classSelector(card.el)} by ${Math.round(ix)}px — the headline and the card collide`,
5345
+ });
5346
+ break;
5347
+ }
5348
+ }
5349
+
5350
+ // (iii) Inline padding leak: an inline element with an opaque background and
5351
+ // large vertical padding paints a filled block whose padding-box overflows
5352
+ // its line (inline padding reserves no vertical space), so the fill lands on
5353
+ // the content above and below instead of enclosing its own text. The
5354
+ // canonical bug is a class-name collision that hands a decorative marker a
5355
+ // payoff card's padding. The tell is a rendered height several times the line
5356
+ // height, which distinguishes the leak from a padded inline highlight.
5357
+ for (const el of document.querySelectorAll('body *')) {
5358
+ if (el.closest('svg')) continue;
5359
+ if (!isPaintedForOcclusion(el)) continue;
5360
+ const cs = getComputedStyle(el);
5361
+ if (cs.display !== 'inline') continue;
5362
+ const bg = parseAnyColor(cs.backgroundColor || '');
5363
+ if (!bg || (bg.a ?? 1) <= 0.6) continue;
5364
+ const padTop = parseFloat(cs.paddingTop) || 0;
5365
+ const padBottom = parseFloat(cs.paddingBottom) || 0;
5366
+ if (padTop + padBottom < 24) continue;
5367
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
5368
+ if (rect.width < 12 || rect.height < 24) continue;
5369
+ const fontSize = parseFloat(cs.fontSize) || 16;
5370
+ let lineHeight = parseFloat(cs.lineHeight);
5371
+ if (!Number.isFinite(lineHeight)) lineHeight = fontSize * 1.4;
5372
+ // The padding box has to overflow the line by a clear margin — a padded
5373
+ // inline highlight sits at roughly one line height, the leak at several.
5374
+ if (rect.height < 2.2 * lineHeight) continue;
5375
+ if (seenVictims.has(el)) continue;
5376
+ // Name a neighbour the fill lands on, if one is nearby (paint state aside,
5377
+ // reveal-on-scroll siblings still occupy the space it covers).
5378
+ let overlaps = null;
5379
+ for (const other of el.parentElement ? el.parentElement.children : []) {
5380
+ if (other === el || el.contains(other) || other.contains(el)) continue;
5381
+ if (getComputedStyle(other).display === 'none') continue;
5382
+ const oRect = other.getBoundingClientRect();
5383
+ const ix = Math.max(0, Math.min(rect.right, oRect.right) - Math.max(rect.left, oRect.left));
5384
+ const iy = Math.max(0, Math.min(rect.bottom, oRect.bottom) - Math.max(rect.top, oRect.top));
5385
+ if (ix > 4 && iy > 4 && (other.textContent || '').trim().length > 0) { overlaps = other; break; }
5386
+ }
5387
+ seenVictims.add(el);
5388
+ findings.push({
5389
+ el,
5390
+ type: 'text-occlusion',
5391
+ detail: `${classSelector(el)} is an inline element whose opaque fill leaks ${Math.round(rect.height)}px past its line${overlaps ? ` onto ${classSelector(overlaps)}` : ''}`,
5392
+ });
5393
+ }
5394
+
5395
+ return findings;
5396
+ }
5397
+
5398
+ // ---------------------------------------------------------------------------
5399
+ // First-viewport column overflow — the stretched-hero signature (browser-only)
5400
+ // ---------------------------------------------------------------------------
5401
+
5402
+ // A multi-column composition that opens the page (grid/flex with two or more
5403
+ // side-by-side columns, each a real share of the width) where one column's
5404
+ // content runs far past the fold while its sibling fits inside a single
5405
+ // viewport. The row stretches to the tall column, so the short one floats in a
5406
+ // screen-and-a-half of dead space and the fold falls deep inside a single
5407
+ // section. Single-column pages and full-page heroes (no sibling column) are
5408
+ // exempt because there is no fitting sibling to contrast against.
5409
+ function checkFirstViewportColumnOverflowDOM() {
5410
+ const findings = [];
5411
+ const vw = window.innerWidth || 1280;
5412
+ const vh = window.innerHeight || 800;
5413
+ const isMultiCol = (s) => /(^|inline-)(grid|flex)$/.test(String(s.display || ''));
5414
+
5415
+ for (const el of document.querySelectorAll('body *')) {
5416
+ const style = getComputedStyle(el);
5417
+ if (!isMultiCol(style)) continue;
5418
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
5419
+ if (rect.width < 0.5 * vw) continue;
5420
+ const pageTop = rect.top + (window.scrollY || 0);
5421
+ const pageBottom = pageTop + rect.height;
5422
+ // The fold must fall inside this container: it opens within the first
5423
+ // viewport and runs past it.
5424
+ if (pageTop >= vh * 0.9 || pageBottom <= vh) continue;
5425
+
5426
+ // Direct children that read as side-by-side columns: a real width share,
5427
+ // not full-bleed (stacked single column), sharing the container's top row.
5428
+ const cols = [];
5429
+ for (const child of el.children) {
5430
+ const cs = getComputedStyle(child);
5431
+ if (cs.display === 'none') continue;
5432
+ if (String(cs.position || '') === 'absolute' || String(cs.position || '') === 'fixed') continue;
5433
+ let cr; try { cr = child.getBoundingClientRect(); } catch { continue; }
5434
+ const wShare = cr.width / rect.width;
5435
+ if (wShare < 0.25 || wShare > 0.9) continue;
5436
+ if (cr.height < 40) continue;
5437
+ // Content extent: how far the child's own content actually reaches,
5438
+ // independent of a stretched row height.
5439
+ let contentBottom = cr.top;
5440
+ for (const d of child.querySelectorAll('*')) {
5441
+ const ds = getComputedStyle(d);
5442
+ if (ds.position === 'absolute' || ds.position === 'fixed') continue;
5443
+ if (ds.display === 'none' || ds.visibility === 'hidden') continue;
5444
+ let dr; try { dr = d.getBoundingClientRect(); } catch { continue; }
5445
+ if (dr.width > 0 && dr.height > 0) contentBottom = Math.max(contentBottom, dr.bottom);
5446
+ }
5447
+ cols.push({ child, top: cr.top, contentH: contentBottom - cr.top });
5448
+ }
5449
+ if (cols.length < 2) continue;
5450
+ // Side-by-side: the two candidate columns must share the top row.
5451
+ cols.sort((a, b) => b.contentH - a.contentH);
5452
+ const tall = cols[0];
5453
+ const shortest = cols[cols.length - 1];
5454
+ if (Math.abs(tall.top - shortest.top) > 0.25 * vh) continue;
5455
+ if (tall.contentH <= vh * 1.4) continue;
5456
+ if (shortest.contentH > vh) continue;
5457
+
5458
+ findings.push({
5459
+ el,
5460
+ type: 'first-viewport-column-overflow',
5461
+ detail: `${classSelector(el)} opens the page with one column running ${Math.round(tall.contentH / vh * 100)}% of the viewport tall while a sibling fits in ${Math.round(shortest.contentH / vh * 100)}% — the fold falls deep inside the section`,
5462
+ });
5463
+ }
5464
+ return findings;
5465
+ }
5466
+
2600
5467
  export {
5468
+ CSS_NAMED_COLORS,
2601
5469
  checkBorders,
2602
5470
  isEmojiOnlyText,
2603
5471
  checkColors,
5472
+ checkHoverContrast,
5473
+ checkElementHoverContrast,
5474
+ parseColorMix,
5475
+ compositeColorOver,
2604
5476
  isCardLikeFromProps,
2605
5477
  checkIconTile,
2606
5478
  resolveSerif,
2607
5479
  checkItalicSerif,
2608
5480
  isAccentColor,
2609
5481
  checkHeroEyebrow,
2610
- checkRepeatedSectionKickers,
5482
+ checkKickerAboveHeading,
2611
5483
  checkMotion,
2612
5484
  checkGlow,
5485
+ scanCssTextForGlow,
5486
+ scanCssTextForGridBackground,
5487
+ scanCssTextForRadialHalo,
5488
+ scanCssTextForPseudoStripe,
5489
+ scanCssTextForInsetStripe,
5490
+ scanCssTextForMarquee,
5491
+ collectMarqueeKeyframes,
5492
+ collectCssCustomProps,
5493
+ cssLengthToPx,
5494
+ scanCssTextForPulsingDot,
5495
+ scanHtmlForShapeAssembledIllustration,
5496
+ buildHtmlPatternCorpora,
2613
5497
  checkHtmlPatterns,
2614
5498
  readOwnBackgroundColor,
2615
5499
  resolveBackground,
@@ -2627,12 +5511,28 @@ export {
2627
5511
  parseAnyColor,
2628
5512
  parseColorResolved,
2629
5513
  cleanInlineText,
2630
- isRepeatedKickerCandidate,
2631
- collectRepeatedSectionKickerCandidates,
2632
- checkRepeatedSectionKickersDOM,
5514
+ isKickerCandidate,
5515
+ collectKickerCandidates,
5516
+ checkKickerAboveHeadingDOM,
5517
+ parseNumberedLabelText,
5518
+ isNumberedSectionLabelCandidate,
5519
+ collectNumberedSectionLabelCandidates,
5520
+ checkNumberedSectionLabels,
5521
+ checkNumberedSectionLabelsFromDoc,
5522
+ checkNumberedSectionLabelsDOM,
5523
+ checkEmDashOveruse,
5524
+ checkEmDashOveruseDOM,
5525
+ isRepeatedTextContainer,
5526
+ collectRepeatedContainerTextFindings,
5527
+ checkRepeatedContainerTextFromDoc,
5528
+ checkRepeatedContainerTextDOM,
5529
+ checkElementPseudoStripeDOM,
2633
5530
  checkElementMotionDOM,
2634
5531
  checkElementGlowDOM,
2635
5532
  checkElementAIPaletteDOM,
5533
+ checkElementRadialSpotlightDOM,
5534
+ checkElementRadialSpotlight,
5535
+ checkRadialSpotlight,
2636
5536
  resolveFontSizePx,
2637
5537
  resolveLengthPx,
2638
5538
  checkQuality,
@@ -2645,7 +5545,7 @@ export {
2645
5545
  checkElementIconTile,
2646
5546
  checkElementItalicSerif,
2647
5547
  checkElementHeroEyebrow,
2648
- checkRepeatedSectionKickersFromDoc,
5548
+ checkKickerAboveHeadingFromDoc,
2649
5549
  checkElementMotion,
2650
5550
  checkElementGlow,
2651
5551
  checkTypography,
@@ -2668,4 +5568,13 @@ export {
2668
5568
  checkElementClippedOverflowDOM,
2669
5569
  isScreenReaderOnlyTextStyle,
2670
5570
  checkElementTextOverflowDOM,
5571
+ checkHeadingRhythmDOM,
5572
+ checkElementBlinkingCursorDOM,
5573
+ measureHiddenTextDOM,
5574
+ checkContentHiddenAtRest,
5575
+ checkEdgeFlushCardsDOM,
5576
+ isOpaqueDecoratedBox,
5577
+ isLayeredElement,
5578
+ checkTextOcclusionDOM,
5579
+ checkFirstViewportColumnOverflowDOM,
2671
5580
  };