quiver-cli 0.7.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 +733 -499
  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
@@ -82,6 +82,15 @@ const GENERIC_FONTS = new Set([
82
82
  const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
83
83
  const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
84
84
 
85
+ // Em-dash overuse (advisory) thresholds, shared by the regex/static-HTML
86
+ // analyzer and the browser DOM check so both fire on the same saturation
87
+ // pattern. Two gates must hold: an absolute floor of EM_DASH_FLOOR dashes, and
88
+ // a density of at least one dash per EM_DASH_CHARS_PER_DASH characters of body
89
+ // text. A long article that uses a few em-dashes is left alone; a short,
90
+ // dash-per-clause page is not.
91
+ const EM_DASH_FLOOR = 8;
92
+ const EM_DASH_CHARS_PER_DASH = 500;
93
+
85
94
  // Serif faces that show up in italic-display heroes. The rule also fires when
86
95
  // the primary face is unknown but the stack ends in the generic `serif` token,
87
96
  // which catches custom/private faces with a serif fallback.
@@ -123,24 +132,17 @@ const ANTIPATTERNS = [
123
132
  {
124
133
  id: 'overused-font',
125
134
  category: 'slop',
135
+ scopes: ['type'],
126
136
  name: 'Overused font',
127
137
  description:
128
138
  'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
129
139
  skillSection: 'Typography',
130
140
  skillGuideline: 'overused fonts like Inter',
131
141
  },
132
- {
133
- id: 'single-font',
134
- category: 'slop',
135
- name: 'Single font for everything',
136
- description:
137
- 'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
138
- skillSection: 'Typography',
139
- skillGuideline: 'only one font family for the entire page',
140
- },
141
142
  {
142
143
  id: 'flat-type-hierarchy',
143
144
  category: 'slop',
145
+ scopes: ['type'],
144
146
  name: 'Flat type hierarchy',
145
147
  description:
146
148
  'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
@@ -177,6 +179,7 @@ const ANTIPATTERNS = [
177
179
  {
178
180
  id: 'nested-cards',
179
181
  category: 'slop',
182
+ scopes: ['layout'],
180
183
  name: 'Nested cards',
181
184
  description:
182
185
  'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
@@ -186,6 +189,7 @@ const ANTIPATTERNS = [
186
189
  {
187
190
  id: 'monotonous-spacing',
188
191
  category: 'slop',
192
+ scopes: ['layout'],
189
193
  name: 'Monotonous spacing',
190
194
  description:
191
195
  'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
@@ -201,18 +205,73 @@ const ANTIPATTERNS = [
201
205
  skillSection: 'Motion',
202
206
  skillGuideline: 'bounce or elastic easing',
203
207
  },
208
+ {
209
+ id: 'pulsing-dot',
210
+ category: 'slop',
211
+ name: 'Pulsing status dot',
212
+ description:
213
+ 'Small pulsing status dots simulate liveness decoratively. Reserve pulse animation for indicators tied to genuinely live, changing data; a static indicator with clear labeling is honest and calmer.',
214
+ skillSection: 'Motion',
215
+ skillGuideline: 'decorative pulsing status dot',
216
+ },
217
+ {
218
+ id: 'blinking-cursor',
219
+ category: 'slop',
220
+ severity: 'advisory',
221
+ name: 'Decorative blinking cursor',
222
+ description:
223
+ 'A blinking text cursor animated into a hero or landing section simulates typing where no input exists. It borrows the dev-tool aesthetic as decoration. Real editable fields draw their own caret; anywhere else, let the composition hold attention without a fake prompt.',
224
+ skillSection: 'Motion',
225
+ },
226
+ {
227
+ id: 'shape-assembled-illustration',
228
+ category: 'slop',
229
+ severity: 'advisory',
230
+ name: 'Shape-assembled illustration',
231
+ description:
232
+ 'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
233
+ skillSection: 'Imagery',
234
+ },
204
235
  {
205
236
  id: 'dark-glow',
206
237
  category: 'slop',
207
- name: 'Dark mode with glowing accents',
238
+ name: 'Glowing shadow accents',
208
239
  description:
209
- 'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
240
+ 'Colored glow shadows a zero-offset chromatic halo (box- or text-shadow) on any background, or any colored blurred shadow on a dark background — are the default "cool" look of AI-generated UIs. Use neutral elevation shadows and subtle, purposeful lighting instead.',
210
241
  skillSection: 'Color & Contrast',
211
242
  skillGuideline: 'dark mode with glowing accents',
212
243
  },
244
+ {
245
+ id: 'radial-halo',
246
+ category: 'slop',
247
+ name: 'Radial-gradient background halo',
248
+ description:
249
+ 'A chromatic radial-gradient wash — saturated at the center, fading to transparent — used as a decorative background glow on a dark page. Same tell as glowing shadows, drawn with a gradient instead of a shadow. Ground the surface with a solid or subtly shifted background instead.',
250
+ skillSection: 'Color & Contrast',
251
+ skillGuideline: 'dark mode with glowing accents',
252
+ },
253
+ {
254
+ id: 'radial-spotlight-glow',
255
+ category: 'slop',
256
+ name: 'Decorative radial spotlight glow',
257
+ description:
258
+ 'A soft, low-opacity accent-colored radial gradient fading to transparent, dropped behind a hero or section as a "spotlight." It is a reflex AI decoration — the translucent cousin of the saturated radial halo. Let the surface stand on its own, or light the composition with a deliberate material accent rather than a floating colored haze.',
259
+ skillSection: 'Color & Contrast',
260
+ skillGuideline: 'dark mode with glowing accents',
261
+ },
262
+ {
263
+ id: 'marquee',
264
+ category: 'slop',
265
+ name: 'Auto-scrolling marquee',
266
+ description:
267
+ 'Continuously auto-scrolling content demands attention it has not earned and hides half its content at any moment. Reserve motion for content that changes; let readers move at their own pace.',
268
+ skillSection: 'Motion',
269
+ skillGuideline: 'auto-scrolling marquee',
270
+ },
213
271
  {
214
272
  id: 'icon-tile-stack',
215
273
  category: 'slop',
274
+ scopes: ['layout'],
216
275
  name: 'Icon tile stacked above heading',
217
276
  description:
218
277
  'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
@@ -222,6 +281,7 @@ const ANTIPATTERNS = [
222
281
  {
223
282
  id: 'italic-serif-display',
224
283
  category: 'slop',
284
+ scopes: ['type'],
225
285
  name: 'Italic serif display headline',
226
286
  description:
227
287
  'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
@@ -231,6 +291,7 @@ const ANTIPATTERNS = [
231
291
  {
232
292
  id: 'hero-eyebrow-chip',
233
293
  category: 'slop',
294
+ scopes: ['type'],
234
295
  name: 'Hero eyebrow / pill chip',
235
296
  description:
236
297
  'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
@@ -238,31 +299,37 @@ const ANTIPATTERNS = [
238
299
  skillGuideline: 'tiny uppercase tracked label above the hero headline',
239
300
  },
240
301
  {
241
- id: 'repeated-section-kickers',
302
+ id: 'kicker-above-heading',
242
303
  category: 'slop',
243
- severity: 'advisory',
244
- name: 'Repeated section kicker labels',
304
+ scopes: ['type'],
305
+ name: 'Kicker / eyebrow label above heading',
245
306
  description:
246
- 'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
307
+ 'A tiny tracked uppercase or small-caps label sitting as its own block directly above a heading is banned outright, repeated or not. Generated kickers never earn their place: the heading carries its own weight. Delete the label and let the heading speak; if the words matter, work them into the heading or the body.',
247
308
  skillSection: 'Typography',
248
- skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
309
+ skillGuideline: 'kicker or eyebrow labels above headings',
249
310
  },
250
311
  {
251
- id: 'numbered-section-markers',
312
+ id: 'numbered-section-labels',
252
313
  category: 'slop',
314
+ scopes: ['type'],
253
315
  severity: 'advisory',
254
- name: 'Numbered section markers (01 / 02 / 03)',
316
+ name: 'Tiny numbered section labels',
255
317
  description:
256
- 'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
318
+ 'Small numeric index labels riding next to section headings, repeated section after section, are AI editorial scaffolding a page numbering its own chapters instead of earning structure. Let hierarchy, content, and rhythm carry the sequence.',
257
319
  skillSection: 'Layout & Space',
258
320
  skillGuideline: 'numbered section markers',
259
321
  },
260
322
  {
261
323
  id: 'em-dash-overuse',
262
324
  category: 'slop',
325
+ // Advisory: humans use em-dashes legitimately, so this rule is opt-in noise
326
+ // rather than a failure. It fires only on the AI saturation pattern, not on
327
+ // ordinary prose. Advisory findings are surfaced separately, never counted
328
+ // as failures, and skipped by the design hook unless a project opts in.
329
+ advisory: true,
263
330
  name: 'Em-dash overuse',
264
331
  description:
265
- 'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
332
+ 'Em-dash saturation in body copy is an AI cadence tell. Advisory only: humans use em-dashes legitimately, so this fires only on saturation — at least 8 em-dashes (— or --) at a density near one per 500 characters of body text never on a long article that uses a few. Prefer commas, colons, periods, or parentheses.',
266
333
  skillSection: 'Copy',
267
334
  skillGuideline: 'no em dashes',
268
335
  },
@@ -287,6 +354,7 @@ const ANTIPATTERNS = [
287
354
  {
288
355
  id: 'oversized-h1',
289
356
  category: 'slop',
357
+ scopes: ['type'],
290
358
  name: 'Oversized hero headline',
291
359
  description:
292
360
  'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
@@ -296,6 +364,7 @@ const ANTIPATTERNS = [
296
364
  {
297
365
  id: 'extreme-negative-tracking',
298
366
  category: 'slop',
367
+ scopes: ['type'],
299
368
  name: 'Crushed letter spacing',
300
369
  description:
301
370
  'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
@@ -313,6 +382,49 @@ const ANTIPATTERNS = [
313
382
  },
314
383
 
315
384
  // ── Quality: general design and accessibility issues ──
385
+ {
386
+ id: 'script-error',
387
+ category: 'quality',
388
+ severity: 'error',
389
+ name: 'Uncaught script error on load',
390
+ description:
391
+ 'A script threw an uncaught exception or failed to parse while the page loaded. Broken JavaScript silently kills reveals, interactions, and dynamic content, and can leave most of a page invisible. Fix the error before judging anything else.',
392
+ },
393
+ {
394
+ id: 'content-hidden-at-rest',
395
+ category: 'quality',
396
+ severity: 'error',
397
+ scopes: ['layout'],
398
+ name: 'Content invisible at rest',
399
+ description:
400
+ 'A large share of the page text sits at opacity 0 or visibility hidden even after every reveal handler had a chance to run. This is the failed-reveal signature: the content shipped but never becomes visible. Make content visible by default and let JavaScript enhance its entrance instead of gating its existence.',
401
+ },
402
+ {
403
+ id: 'edge-flush-cards',
404
+ category: 'quality',
405
+ scopes: ['layout'],
406
+ name: 'Cards flush against the scroller edge',
407
+ description:
408
+ 'Cards inside a horizontal scroller or tab panel sit flush against the container edge at rest while keeping a gutter on the other side, so their edges and rounded corners get cut off. Usually the panel is sized wider than its clip box. Keep a consistent inset on both sides.',
409
+ },
410
+ {
411
+ id: 'text-occlusion',
412
+ category: 'quality',
413
+ scopes: ['layout'],
414
+ name: 'Text occluded by an overlapping element',
415
+ description:
416
+ 'Text is painted under an opaque element or a second text run, so part of it cannot be read. A decorative box, a stacked layer, or an inline element with leaked padding lands on the words instead of beside them. Give overlapping layers room, or move the text out from under the layer above it.',
417
+ skillSection: 'Layout & Space',
418
+ },
419
+ {
420
+ id: 'first-viewport-column-overflow',
421
+ category: 'quality',
422
+ scopes: ['layout'],
423
+ name: 'One column stretches the first viewport',
424
+ description:
425
+ 'A multi-column opening section lets one column run far past the fold while its sibling fits in a single viewport, so the short column floats in dead space and the fold falls deep inside one section. Balance the columns, cap the tall one, or let the long content flow below the opening row.',
426
+ skillSection: 'Layout & Space',
427
+ },
316
428
  {
317
429
  id: 'gray-on-color',
318
430
  category: 'quality',
@@ -341,6 +453,7 @@ const ANTIPATTERNS = [
341
453
  {
342
454
  id: 'line-length',
343
455
  category: 'quality',
456
+ scopes: ['type', 'layout'],
344
457
  name: 'Line length too long',
345
458
  description:
346
459
  'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
@@ -350,6 +463,7 @@ const ANTIPATTERNS = [
350
463
  {
351
464
  id: 'cramped-padding',
352
465
  category: 'quality',
466
+ scopes: ['layout'],
353
467
  name: 'Cramped padding',
354
468
  description:
355
469
  'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 12–16px) of padding inside bordered, outlined, or colored containers.',
@@ -359,6 +473,7 @@ const ANTIPATTERNS = [
359
473
  {
360
474
  id: 'body-text-viewport-edge',
361
475
  category: 'quality',
476
+ scopes: ['layout'],
362
477
  name: 'Body text touching viewport edge',
363
478
  description:
364
479
  'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
@@ -366,6 +481,7 @@ const ANTIPATTERNS = [
366
481
  {
367
482
  id: 'tight-leading',
368
483
  category: 'quality',
484
+ scopes: ['type'],
369
485
  name: 'Tight line height',
370
486
  description:
371
487
  'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
@@ -373,13 +489,24 @@ const ANTIPATTERNS = [
373
489
  {
374
490
  id: 'skipped-heading',
375
491
  category: 'quality',
492
+ scopes: ['type'],
376
493
  name: 'Skipped heading level',
377
494
  description:
378
495
  'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
379
496
  },
497
+ {
498
+ id: 'heading-rhythm',
499
+ category: 'quality',
500
+ scopes: ['layout', 'type'],
501
+ name: 'Heading crowded against the previous block',
502
+ description:
503
+ 'A heading binds to the content it introduces, so the rendered space above it should exceed the space below it. When headings across a page sit as close or closer to the block above than to their own content, every section reads as if it captions the previous one. Open up the space above each heading.',
504
+ skillSection: 'Layout & Space',
505
+ },
380
506
  {
381
507
  id: 'justified-text',
382
508
  category: 'quality',
509
+ scopes: ['type'],
383
510
  name: 'Justified text',
384
511
  description:
385
512
  'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
@@ -387,13 +514,23 @@ const ANTIPATTERNS = [
387
514
  {
388
515
  id: 'tiny-text',
389
516
  category: 'quality',
517
+ scopes: ['type'],
390
518
  name: 'Tiny body text',
391
519
  description:
392
520
  'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
393
521
  },
522
+ {
523
+ id: 'undersized-ui-text',
524
+ category: 'quality',
525
+ scopes: ['type'],
526
+ name: 'Undersized functional text',
527
+ description:
528
+ 'Interactive and content-bearing UI text (links, buttons, nav items, labels, table cells, meta rows, timecodes) below 11px is a legibility failure, not a style choice. WCAG sets no absolute pixel floor, but functional text under 11px is a defensible quality bar: it fails on high-DPI and small viewports and it degrades tap and read targets. The 11px floor holds even inside a footer; only non-interactive legal smallprint gets the softer 10px floor. Being ON the DESIGN.md size ramp does not exempt a value here: adding 8px to the ramp launders the token but not the legibility problem, and that is exactly the escape hatch this rule closes. Exempts sup/sub, visually-hidden (sr-only) text, and code/terminal contexts. Decorative letterspaced micro-labels are still functional and stay in scope.',
529
+ },
394
530
  {
395
531
  id: 'all-caps-body',
396
532
  category: 'quality',
533
+ scopes: ['type'],
397
534
  name: 'All-caps body text',
398
535
  description:
399
536
  'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
@@ -403,6 +540,7 @@ const ANTIPATTERNS = [
403
540
  {
404
541
  id: 'wide-tracking',
405
542
  category: 'quality',
543
+ scopes: ['type'],
406
544
  name: 'Wide letter spacing on body text',
407
545
  description:
408
546
  'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
@@ -410,15 +548,24 @@ const ANTIPATTERNS = [
410
548
  {
411
549
  id: 'text-overflow',
412
550
  category: 'quality',
551
+ scopes: ['layout'],
413
552
  name: 'Content overflowing its container',
414
553
  description:
415
554
  'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
416
555
  skillSection: 'Layout & Space',
417
556
  skillGuideline: 'content wider than its container',
418
557
  },
558
+ {
559
+ id: 'repeated-container-text',
560
+ category: 'quality',
561
+ name: 'Same text repeated inside one container',
562
+ description:
563
+ 'The same literal text rendered three or more times in structurally different spots inside a single card or panel is redundant messaging — usually a status or label wired into every slot of a template. Say it once, in the slot where it matters most.',
564
+ },
419
565
  {
420
566
  id: 'clipped-overflow-container',
421
567
  category: 'quality',
568
+ scopes: ['layout'],
422
569
  name: 'Positioned child clipped by overflow container',
423
570
  description:
424
571
  'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
@@ -428,6 +575,7 @@ const ANTIPATTERNS = [
428
575
  {
429
576
  id: 'design-system-font',
430
577
  category: 'quality',
578
+ scopes: ['type'],
431
579
  name: 'Font outside DESIGN.md',
432
580
  description:
433
581
  'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
@@ -454,13 +602,23 @@ const ANTIPATTERNS = [
454
602
  skillSection: 'Visual Details',
455
603
  skillGuideline: 'border radius outside the project design system',
456
604
  },
605
+ {
606
+ id: 'design-system-font-size',
607
+ category: 'quality',
608
+ severity: 'advisory',
609
+ scopes: ['type'],
610
+ name: 'Font size outside DESIGN.md',
611
+ description:
612
+ 'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
613
+ skillSection: 'Typography',
614
+ skillGuideline: 'font size outside the project design system',
615
+ },
457
616
 
458
- // ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
617
+ // ── Common generated-UI tells ───────────────────────────────────────────
459
618
  {
460
619
  id: 'gpt-thin-border-wide-shadow',
461
620
  category: 'slop',
462
621
  severity: 'advisory',
463
- gated: 'gpt',
464
622
  name: 'Hairline border with wide shadow',
465
623
  description:
466
624
  'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
@@ -471,18 +629,26 @@ const ANTIPATTERNS = [
471
629
  id: 'repeating-stripes-gradient',
472
630
  category: 'slop',
473
631
  severity: 'advisory',
474
- gated: 'gpt',
475
632
  name: 'Repeating-gradient stripes',
476
633
  description:
477
634
  'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
478
635
  skillSection: 'Visual Details',
479
636
  skillGuideline: 'repeating-gradient decorative stripes',
480
637
  },
638
+ {
639
+ id: 'codex-grid-background',
640
+ category: 'slop',
641
+ severity: 'advisory',
642
+ name: 'Decorative grid-line background',
643
+ description:
644
+ 'A decorative grid or line-field background drawn with hairline linear-gradient layers tiled by a fixed pixel cell is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
645
+ skillSection: 'Visual Details',
646
+ skillGuideline: 'two-axis grid-line gradient background',
647
+ },
481
648
  {
482
649
  id: 'theater-slop-phrase',
483
650
  category: 'slop',
484
651
  severity: 'advisory',
485
- gated: 'gpt',
486
652
  name: 'Theater framing copy',
487
653
  description:
488
654
  'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
@@ -493,7 +659,6 @@ const ANTIPATTERNS = [
493
659
  id: 'image-hover-transform',
494
660
  category: 'slop',
495
661
  severity: 'advisory',
496
- gated: 'gemini',
497
662
  name: 'Image hover transform',
498
663
  description:
499
664
  'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
@@ -617,13 +782,50 @@ function colorToHex(c) {
617
782
  return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
618
783
  }
619
784
 
785
+ // --- cli/engine/shared/fonts.mjs ---
786
+ const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
787
+
788
+ function normalizeGoogleFontFamilyParam(value) {
789
+ return String(value || '')
790
+ .split('|')
791
+ .map(part => part.split(':')[0].trim().toLowerCase())
792
+ .filter(Boolean);
793
+ }
794
+
795
+ function extractGoogleFontFamilies(text) {
796
+ const families = [];
797
+ if (!text) return families;
798
+
799
+ GOOGLE_FONTS_URL_RE.lastIndex = 0;
800
+ let urlMatch;
801
+ while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
802
+ const url = urlMatch[0];
803
+ const queryStart = url.indexOf('?');
804
+ if (queryStart === -1) continue;
805
+
806
+ const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
807
+ for (const value of params.getAll('family')) {
808
+ families.push(...normalizeGoogleFontFamilyParam(value));
809
+ }
810
+ }
811
+
812
+ return families;
813
+ }
814
+
620
815
  // --- cli/engine/rules/checks.mjs ---
621
816
  const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
622
817
 
623
818
  // ─── Section 3: Pure Detection ──────────────────────────────────────────────
624
819
 
625
- function checkBorders(tag, widths, colors, radius) {
626
- if (BORDER_SAFE_TAGS.has(tag)) return [];
820
+ function checkBorders(tag, widths, colors, radius, opts = {}) {
821
+ // Badge-shaped <span>s (own visible background) are a real stripe target
822
+ // for the top/bottom variant — the inline-tag exemption exists to quiet
823
+ // text-level borders, not chips. They skip the left/right arms below.
824
+ const spanBadge = tag === 'span' && !!opts.badgeLike;
825
+ if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
826
+ // A live status/alert region wears a colored single-edge border as a
827
+ // severity accent (toast, snackbar, callout), not as the side-tab tell.
828
+ if (opts.statusContext) return [];
627
829
  const findings = [];
628
830
  const sides = ['Top', 'Right', 'Bottom', 'Left'];
629
831
 
@@ -639,10 +841,20 @@ function checkBorders(tag, widths, colors, radius) {
639
841
  const isSide = side === 'Left' || side === 'Right';
640
842
 
641
843
  if (isSide) {
844
+ if (spanBadge) continue;
642
845
  if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
643
846
  else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
644
847
  } else {
645
848
  if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
849
+ // Horizontal variant of the side-tab stripe: a thick chromatic accent
850
+ // riding the top or bottom edge of a card/badge/container. Same
851
+ // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
852
+ // tab underlines are exempt via opts.tabContext (adapters look for
853
+ // tablist/nav/tab ancestors and aria-selected); links, buttons,
854
+ // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
855
+ else if (!opts.tabContext && w >= 3 && w <= 12) {
856
+ findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
857
+ }
646
858
  }
647
859
  }
648
860
 
@@ -664,23 +876,45 @@ function isEmojiOnlyText(text) {
664
876
  function checkColors(opts) {
665
877
  const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
666
878
  if (SAFE_TAGS.has(tag)) {
667
- // Exception for <a> and <button> elements styled as buttons. SAFE_TAGS
668
- // exists to suppress contrast noise on inline links and unstyled controls,
669
- // where the element has no own background and the contrast against the
670
- // ancestor surface is already the intended visual. When the element has
671
- // its own opaque background and direct text, it is a styled button and
672
- // contrast on its own surface is a real, frequent bug worth flagging.
673
- const isStyledButton = (tag === 'a' || tag === 'button')
674
- && hasDirectText
675
- && bgColor && bgColor.a > 0.5;
676
- if (!isStyledButton) return [];
879
+ // Exception for elements styled as controls or chips. SAFE_TAGS exists to
880
+ // suppress contrast noise on inline links and unstyled spans, where the
881
+ // element has no own background and the contrast against the ancestor
882
+ // surface is already the intended visual. When the element paints its own
883
+ // opaque background under direct text, it is a styled button, chip, or
884
+ // badge regardless of tag, and contrast on its own surface is a real,
885
+ // frequent bug worth flagging. (The shipped miss: a <span> severity chip
886
+ // whose white text lost a specificity fight and rendered muted-on-red at
887
+ // 1.2:1; the old a/button-only exception never looked at it.) The 9px
888
+ // font floor keeps sub-text decorations out.
889
+ const isStyledControl = hasDirectText
890
+ && ((bgColor && bgColor.a > 0.5)
891
+ // A gradient painted on the element itself is an own surface the
892
+ // same way a solid background is. Without this branch a nav CTA
893
+ // built as `<a>` with `background: linear-gradient(…)` and a text
894
+ // color that fails against every stop sails through on the
895
+ // SAFE_TAGS suppression (the shipped escape).
896
+ || (bgImage && /gradient/i.test(bgImage)))
897
+ && fontSize >= 9;
898
+ if (!isStyledControl) return [];
677
899
  }
678
900
  const findings = [];
679
901
 
680
902
  if (hasDirectText && textColor && !isEmojiOnly) {
903
+ // Gradient-clipped text (`background-clip: text`, typically with a
904
+ // transparent text-fill) paints its glyphs *with* the element's own
905
+ // gradient. The `color` value the cascade still reports is never painted,
906
+ // and the gradient is the fill, not a backdrop — so measuring `color`
907
+ // against that gradient (which resolveGradientStops picks up as the
908
+ // element's own background-image) is a guaranteed false positive
909
+ // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
910
+ // rule below still flags the pattern itself. Skipping a rule beats a false
911
+ // positive here — the true painted contrast can't be measured from `color`.
912
+ const isGradientClippedText = bgClip === 'text';
681
913
  // Run background-dependent checks against either a solid bg or, if the
682
914
  // ancestor is a gradient, against every gradient stop (use the worst case).
683
- const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null);
915
+ const bgs = isGradientClippedText
916
+ ? null
917
+ : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
684
918
  if (bgs) {
685
919
  // Gray on colored background — flag if every stop is chromatic
686
920
  const textLum = relativeLuminance(textColor);
@@ -710,7 +944,11 @@ function checkColors(opts) {
710
944
  // like `text-paper/60` on `bg-ink` sections are the FP pattern.
711
945
  const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
712
946
  if (!isAlphaFallbackFP) {
713
- findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
947
+ // Near-threshold ratios (e.g. 4.497) would round to the threshold
948
+ // itself at one decimal and read as "4.5 needs 4.5" — show two
949
+ // decimals there so the finding stays legible.
950
+ const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
951
+ findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
714
952
  }
715
953
  }
716
954
  }
@@ -756,6 +994,27 @@ function checkColors(opts) {
756
994
  return findings;
757
995
  }
758
996
 
997
+ // WCAG contrast for the :hover state of an element whose hover rules change
998
+ // its text color and/or background. The classic miss: a nav CTA whose
999
+ // author-intended hover pair passes AA, but a broader selector (e.g.
1000
+ // `.nav-links a:hover`) wins the specificity fight and swaps in a color
1001
+ // that fails. Only fires on elements that present as styled controls —
1002
+ // direct text plus an opaque-ish own background in either state — so plain
1003
+ // inline links keep the same suppression they get in checkColors.
1004
+ function checkHoverContrast(opts) {
1005
+ const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
1006
+ if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
1007
+ if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
1008
+ const ratio = contrastRatio(textColor, bg);
1009
+ const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
1010
+ const threshold = isLargeText ? 3.0 : 4.5;
1011
+ if (ratio >= threshold) return [];
1012
+ return [{
1013
+ id: 'low-contrast',
1014
+ snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
1015
+ }];
1016
+ }
1017
+
759
1018
  function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
760
1019
  if (!hasShadow && !hasBorder) return false;
761
1020
  return hasRadius || hasBg;
@@ -901,26 +1160,57 @@ function isAccentColor(cssColor) {
901
1160
  return false;
902
1161
  }
903
1162
 
1163
+ function resolveHeroHeadingSizePx(value) {
1164
+ const input = String(value || '').trim().toLowerCase();
1165
+ if (!input) return 0;
1166
+
1167
+ const simpleLengthPx = (token) => {
1168
+ const match = /^(-?\d*\.?\d+)\s*(px|rem|em|%)?$/.exec(String(token || '').trim());
1169
+ if (!match) return null;
1170
+ const amount = Number(match[1]);
1171
+ if (!Number.isFinite(amount)) return null;
1172
+ if (match[2] === 'rem' || match[2] === 'em') return amount * 16;
1173
+ if (match[2] === '%') return amount * 0.16;
1174
+ return amount;
1175
+ };
1176
+
1177
+ const direct = simpleLengthPx(input);
1178
+ if (direct !== null) return direct;
1179
+
1180
+ // Static CSS engines cannot resolve viewport units, but clamp's min/max
1181
+ // bounds still tell us whether the heading can ever reach hero scale.
1182
+ const clamp = /^clamp\((.*)\)$/.exec(input);
1183
+ if (clamp) {
1184
+ const parts = clamp[1].split(',');
1185
+ if (parts.length === 3) {
1186
+ const bounds = [simpleLengthPx(parts[0]), simpleLengthPx(parts[2])]
1187
+ .filter((candidate) => candidate !== null);
1188
+ if (bounds.length > 0) return Math.max(...bounds);
1189
+ }
1190
+ }
1191
+
1192
+ return 0;
1193
+ }
1194
+
904
1195
  // Sibling-relationship rule. Anchor on a hero-scale h1, look at the
905
1196
  // previousElementSibling, and gate on EITHER the classic tracked-
906
1197
  // uppercase eyebrow OR the modern accent-colored bold eyebrow.
907
1198
  function checkHeroEyebrow(opts) {
908
1199
  const {
909
1200
  headingTag, headingText, headingFontSize,
1201
+ headingInApplicationContext,
910
1202
  siblingTag, siblingText, siblingTextTransform,
911
1203
  siblingFontSize, siblingLetterSpacing,
912
1204
  siblingFontWeight, siblingColor,
1205
+ siblingHasAccentDashPseudo,
913
1206
  } = opts;
914
1207
  if (headingTag !== 'h1') return [];
915
- // We previously gated on headingFontSize >= 48 to anchor "hero scale".
916
- // But modern hero h1s use clamp() / vw / var(--text-*), none of which
917
- // jsdom can resolve the computed value comes back as "2em" or
918
- // "var(--text-9xl)" and parseFloat returns 2 or NaN. The gate fails
919
- // on virtually every Tailwind v4 / framework build. The other gates
920
- // (sibling text 2-60 chars, font-size ≤ 14px, accent-bold OR
921
- // tracked-caps) are tight enough to avoid false positives on non-
922
- // hero h1s — a tiny tan label directly above any h1 is the
923
- // antipattern regardless of how big the h1 ends up.
1208
+ // This is specifically a marketing-hero cliché, not a ban on compact
1209
+ // context labels in product UI (for example, a station name inside a tab
1210
+ // panel). Browser-computed sizes are reliable; the static adapter also
1211
+ // resolves ordinary px/rem/em and clamp() bounds before reaching here.
1212
+ if (headingInApplicationContext) return [];
1213
+ if (!(headingFontSize >= 48)) return [];
924
1214
  if (!siblingTag) return [];
925
1215
  // An h2 above an h1 is a different anti-pattern (heading hierarchy / dual
926
1216
  // headings) — never an eyebrow.
@@ -941,23 +1231,31 @@ function checkHeroEyebrow(opts) {
941
1231
  const weight = Number(siblingFontWeight) || 400;
942
1232
  const isAccentBold = weight >= 700 && isAccentColor(siblingColor || '');
943
1233
 
944
- if (!isClassicTracked && !isAccentBold) return [];
1234
+ // Branch C: dash-prefix eyebrow — sentence case, low tracking, regular
1235
+ // weight, but announced by a short chromatic ::before/::after bar
1236
+ // (the kicker dash). Same label-above-headline pattern, third styling.
1237
+ const isDashPrefixed = !!siblingHasAccentDashPseudo;
1238
+
1239
+ if (!isClassicTracked && !isAccentBold && !isDashPrefixed) return [];
945
1240
 
946
1241
  const headingTextSnippet = (headingText || '').trim().slice(0, 60);
947
1242
  const eyebrowSnippet = text.slice(0, 40);
948
- const style = isClassicTracked ? 'tracked-caps' : 'accent-bold';
1243
+ const style = isClassicTracked ? 'tracked-caps' : isAccentBold ? 'accent-bold' : 'dash-prefix';
949
1244
  return [{
950
1245
  id: 'hero-eyebrow-chip',
951
1246
  snippet: `eyebrow chip (${style}) "${eyebrowSnippet}" above ${headingTag} "${headingTextSnippet}"`,
952
1247
  }];
953
1248
  }
954
1249
 
955
- function checkRepeatedSectionKickers(opts) {
956
- const { candidates, minCount = 3 } = opts;
957
- if (!Array.isArray(candidates) || candidates.length < minCount) return [];
1250
+ // Outright ban: one kicker is one too many, so every collected candidate is
1251
+ // a finding. The judgment lives in the candidate gate (isKickerCandidate) and
1252
+ // the collector's context skips, not in a repetition count.
1253
+ function checkKickerAboveHeading(opts) {
1254
+ const { candidates } = opts;
1255
+ if (!Array.isArray(candidates)) return [];
958
1256
  return candidates.map(candidate => ({
959
- id: 'repeated-section-kickers',
960
- snippet: `repeated section kicker "${candidate.kickerText}" before ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
1257
+ id: 'kicker-above-heading',
1258
+ snippet: `kicker "${candidate.kickerText}" above ${candidate.headingTag} "${candidate.headingText}"`,
961
1259
  }));
962
1260
  }
963
1261
 
@@ -1006,52 +1304,947 @@ function checkMotion(opts) {
1006
1304
  return findings;
1007
1305
  }
1008
1306
 
1307
+ // Locate the color token in a single shadow layer. Returns
1308
+ // { color, start, end } where color is the parsed {r,g,b,a} (null when the
1309
+ // token exists but can't be parsed — e.g. an unresolved var() or an exotic
1310
+ // color space), or null when no color token is present at all. Handles both
1311
+ // serialization orders: computed style puts the color first
1312
+ // ("rgb(…) 0px 0px 20px"), authored CSS usually puts it last
1313
+ // ("0 0 20px #3b82f6").
1314
+ function findShadowColor(layer) {
1315
+ const fn = layer.match(/(?:rgba?|hsla?|hwb|oklch|oklab|lch|lab|color)\([^)]*\)/i);
1316
+ if (fn) return { color: parseAnyColor(fn[0]), start: fn.index, end: fn.index + fn[0].length };
1317
+ const hex = layer.match(/#[0-9a-fA-F]{3,8}\b/);
1318
+ if (hex) return { color: parseAnyColor(hex[0]), start: hex.index, end: hex.index + hex[0].length };
1319
+ const wordRe = /[a-zA-Z][a-zA-Z]*/g;
1320
+ let m;
1321
+ while ((m = wordRe.exec(layer)) !== null) {
1322
+ const named = CSS_NAMED_COLORS[m[0].toLowerCase()];
1323
+ if (named) return { color: { ...named, a: 1 }, start: m.index, end: m.index + m[0].length };
1324
+ }
1325
+ return null;
1326
+ }
1327
+
1328
+ // Extract the length values of a shadow layer in declaration order, with the
1329
+ // color token removed so its components aren't misread as lengths. Handles
1330
+ // computed-style px values AND authored unitless zeros ("0 0 20px"); rem/em
1331
+ // approximate at 16px. Result order is offset-x, offset-y, blur, [spread].
1332
+ function extractShadowLengths(layer, colorStart, colorEnd) {
1333
+ const stripped = colorStart != null
1334
+ ? layer.slice(0, colorStart) + ' ' + layer.slice(colorEnd)
1335
+ : layer;
1336
+ const vals = [];
1337
+ const re = /(-?\d*\.?\d+)(px|rem|em)?/g;
1338
+ let m;
1339
+ while ((m = re.exec(stripped)) !== null) {
1340
+ let v = parseFloat(m[1]);
1341
+ if (m[2] === 'rem' || m[2] === 'em') v *= 16;
1342
+ vals.push(v);
1343
+ }
1344
+ return vals;
1345
+ }
1346
+
1009
1347
  function checkGlow(opts) {
1010
- const { boxShadow, effectiveBg } = opts;
1011
- if (!boxShadow || boxShadow === 'none') return [];
1012
- if (!effectiveBg) return [];
1348
+ const { boxShadow, textShadow, effectiveBg } = opts;
1349
+ const onDarkBg = effectiveBg ? relativeLuminance(effectiveBg) < 0.1 : false;
1350
+
1351
+ // Scan one shadow list. Two glow tells, in any color format:
1352
+ // 1. Zero-offset chromatic halo (0 0 Npx <color>) — slop on ANY
1353
+ // background; the light radiates evenly outward, which is never how
1354
+ // real elevation shadows behave. Achromatic zero-offset shadows stay
1355
+ // legal (soft ambient elevation), as do focus rings (blur 0).
1356
+ // 2. Any chromatic shadow with real blur on a dark background — the
1357
+ // classic dark-mode glow accent.
1358
+ const scan = (value, prop) => {
1359
+ if (!value || value === 'none') return null;
1360
+ // Split multiple shadows (commas not inside parentheses)
1361
+ for (const layer of value.split(/,(?![^(]*\))/)) {
1362
+ const colorInfo = findShadowColor(layer);
1363
+ // No color token, or one we can't resolve (unresolved var(), exotic
1364
+ // color space): don't guess — skip rather than false-positive.
1365
+ if (!colorInfo || !colorInfo.color) continue;
1366
+ const color = colorInfo.color;
1367
+ if (!hasChroma(color, 30)) continue;
1368
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
1369
+ // Third value is blur (offset-x, offset-y, blur, [spread])
1370
+ if (vals.length < 3 || vals[2] <= 4) continue;
1371
+ if (vals[0] === 0 && vals[1] === 0) {
1372
+ return { id: 'dark-glow', snippet: `Zero-offset ${prop} glow (${colorToHex(color)})` };
1373
+ }
1374
+ if (onDarkBg) {
1375
+ return { id: 'dark-glow', snippet: `Colored ${prop} glow (${colorToHex(color)}) on dark background` };
1376
+ }
1377
+ }
1378
+ return null;
1379
+ };
1013
1380
 
1014
- // Only flag on dark backgrounds (luminance < 0.1)
1015
- const bgLum = relativeLuminance(effectiveBg);
1016
- if (bgLum >= 0.1) return [];
1381
+ const found = scan(boxShadow, 'box-shadow') || scan(textShadow, 'text-shadow');
1382
+ return found ? [found] : [];
1383
+ }
1017
1384
 
1018
- // Split multiple shadows (commas not inside parentheses)
1019
- const parts = boxShadow.split(/,(?![^(]*\))/);
1020
- for (const shadow of parts) {
1021
- const colorMatch = shadow.match(/rgba?\([^)]+\)/);
1022
- if (!colorMatch) continue;
1023
- const color = parseRgb(colorMatch[0]);
1024
- if (!color || !hasChroma(color, 30)) continue;
1385
+ // Collect CSS custom property declarations from raw stylesheet/HTML text.
1386
+ // First declaration wins (:root declarations usually come first); good
1387
+ // enough for the single-level var() resolution the text engines need.
1388
+ function collectCssCustomProps(content) {
1389
+ const map = new Map();
1390
+ const re = /(--[\w-]+)\s*:\s*([^;{}]+)/g;
1391
+ let m;
1392
+ while ((m = re.exec(content)) !== null) {
1393
+ if (!map.has(m[1])) map.set(m[1], m[2].trim());
1394
+ }
1395
+ return map;
1396
+ }
1025
1397
 
1026
- // Extract px values in computed style: "color Xpx Ypx BLURpx [SPREADpx]"
1027
- const afterColor = shadow.substring(shadow.indexOf(colorMatch[0]) + colorMatch[0].length);
1028
- const beforeColor = shadow.substring(0, shadow.indexOf(colorMatch[0]));
1029
- const pxVals = [...beforeColor.matchAll(/([\d.]+)px/g), ...afterColor.matchAll(/([\d.]+)px/g)]
1030
- .map(m => parseFloat(m[1]));
1398
+ // Text-level glow scan shared by the regex engine and the page-level HTML
1399
+ // pattern pass. Resolves single-level var() refs against custom properties
1400
+ // collected from the same text, then applies the same two glow tells as
1401
+ // checkGlow: zero-offset chromatic halo (any background) and chromatic
1402
+ // blurred shadow when the page has a dark background. Returns
1403
+ // [{ index, snippet }] — index is the offset of the shadow declaration.
1404
+ // Dark-page heuristic for raw CSS/HTML text: dark hex/rgb literals, Tailwind
1405
+ // dark bg utilities, or a ROOT-scoped (body/html/:root or <body style>)
1406
+ // background that resolves — via var() — to a dark color. The var/modern-
1407
+ // color extension is deliberately root-scoped: a light page with one dark
1408
+ // accent chip must not turn every tinted drop shadow into a "dark page"
1409
+ // signal. Shared by the glow and radial-halo text scanners.
1410
+ function cssTextHasDarkRootBg(content, customProps) {
1411
+ 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;
1412
+ const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
1413
+ if (darkBgRe.test(content) || twDarkBg.test(content)) return true;
1414
+ const rootScopes = [];
1415
+ const blockRe = /(?:^|[}\s,;>])(?:body|html|:root)\s*(?:,[^{]*)?\{([^}]*)\}/gi;
1416
+ let sm;
1417
+ while ((sm = blockRe.exec(content)) !== null) rootScopes.push(sm[1]);
1418
+ const inlineBody = content.match(/<body[^>]*\bstyle\s*=\s*"([^"]*)"/i);
1419
+ if (inlineBody) rootScopes.push(inlineBody[1]);
1420
+ for (const scope of rootScopes) {
1421
+ const bgRe = /background(?:-color)?\s*:\s*([^;{}]+)/gi;
1422
+ let bm;
1423
+ while ((bm = bgRe.exec(scope)) !== null) {
1424
+ const c = parseAnyColor(resolveVarRefs(bm[1].trim(), customProps));
1425
+ if (c && (c.a ?? 1) > 0.5 && relativeLuminance(c) < 0.1) return true;
1426
+ }
1427
+ }
1428
+ return false;
1429
+ }
1430
+
1431
+ function scanCssTextForGlow(content) {
1432
+ const customProps = collectCssCustomProps(content);
1433
+ const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
1031
1434
 
1032
- // Third value is blur (offset-x, offset-y, blur, [spread])
1033
- if (pxVals.length >= 3 && pxVals[2] > 4) {
1034
- return [{ id: 'dark-glow', snippet: `Colored glow (${colorToHex(color)}) on dark background` }];
1435
+ const results = [];
1436
+ const shadowRe = /\b(box-shadow|text-shadow)\s*:\s*([^;{}]+)/gi;
1437
+ let m;
1438
+ while ((m = shadowRe.exec(content)) !== null) {
1439
+ const prop = m[1].toLowerCase();
1440
+ const value = resolveVarRefs(m[2].trim(), customProps);
1441
+ for (const layer of value.split(/,(?![^(]*\))/)) {
1442
+ const colorInfo = findShadowColor(layer);
1443
+ if (!colorInfo || !colorInfo.color || !hasChroma(colorInfo.color, 30)) continue;
1444
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
1445
+ if (vals.length < 3 || vals[2] <= 4) continue;
1446
+ const zeroOffset = vals[0] === 0 && vals[1] === 0;
1447
+ if (!zeroOffset && !hasDarkBg) continue;
1448
+ results.push({
1449
+ index: m.index,
1450
+ snippet: zeroOffset
1451
+ ? `Zero-offset ${prop} glow (${colorToHex(colorInfo.color)})`
1452
+ : `Colored ${prop} glow (${colorToHex(colorInfo.color)}) on dark page`,
1453
+ });
1454
+ break; // one finding per declaration
1035
1455
  }
1036
1456
  }
1457
+ return results;
1458
+ }
1037
1459
 
1460
+ // Decorative grid or line-field backgrounds drawn with hairline
1461
+ // linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
1462
+ // pattern pass and the regex source engine so standalone CSS, component
1463
+ // styles, and inline styles receive the same coverage. Both signals must
1464
+ // co-occur in one declaration block; unrelated rules must not add up across
1465
+ // the file. Returns [{ index, snippet }], capped at one finding per source to
1466
+ // match the page-level HTML check's existing behavior.
1467
+ function scanCssTextForGridBackground(content) {
1468
+ const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
1469
+ const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
1470
+ const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
1471
+ const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
1472
+ const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
1473
+ const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
1474
+ const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
1475
+ const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
1476
+ let blk;
1477
+ while ((blk = blockRe.exec(content)) !== null) {
1478
+ const block = blk[1] || blk[2] || blk[3] || '';
1479
+ let hairlineCount = 0;
1480
+ let bgJoined = '';
1481
+ let bm;
1482
+ bgDeclRe.lastIndex = 0;
1483
+ while ((bm = bgDeclRe.exec(block)) !== null) {
1484
+ hairlineCount += (bm[1].match(hairlineRe) || []).length;
1485
+ hairlineCount += (bm[1].match(invertedHairlineRe) || []).length;
1486
+ bgJoined += `${bm[1]};`;
1487
+ }
1488
+ if (hairlineCount === 0) continue;
1489
+ const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
1490
+ const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
1491
+ if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
1492
+ return [{
1493
+ index: blk.index,
1494
+ snippet: hairlineCount >= 2
1495
+ ? 'two-axis grid-line gradient background'
1496
+ : 'px-tiled hairline line-field background',
1497
+ }];
1498
+ }
1499
+ }
1038
1500
  return [];
1039
1501
  }
1040
1502
 
1503
+ // Decorative chromatic halo drawn as a radial-gradient background on a dark
1504
+ // page: a saturated center stop dissolving to transparent. The gradient
1505
+ // sibling of the dark-glow shadow tell. Mechanical gates, in order:
1506
+ // * page has a dark root background (shared heuristic with the glow scan)
1507
+ // * declaration has no url() layer (photographic imagery is exempt)
1508
+ // * the gradient's first color stop is chromatic (RGB spread >= 24) and
1509
+ // visible (alpha >= 0.7 — deliberately translucent light-scene washes
1510
+ // composite with content instead of painting a flat halo, and stay legal)
1511
+ // * the gradient's last stop is transparent / near-zero alpha
1512
+ // * no small pixel-sized stop positions (<= 24px = dot/texture patterns)
1513
+ // * not a repeating-* gradient
1514
+ // Achromatic vignettes fail the chroma gate; panel sheens that fade to an
1515
+ // opaque surface color fail the transparent-end gate.
1516
+ function scanCssTextForRadialHalo(content) {
1517
+ const customProps = collectCssCustomProps(content);
1518
+ if (!cssTextHasDarkRootBg(content, customProps)) return [];
1519
+
1520
+ const findings = [];
1521
+ const seen = new Set();
1522
+ const declRe = /background(?:-image)?\s*:\s*([^;{}]+)/gi;
1523
+ let m;
1524
+ while ((m = declRe.exec(content)) !== null) {
1525
+ const value = resolveVarRefs(m[1].trim(), customProps);
1526
+ if (/url\s*\(/i.test(value)) continue;
1527
+
1528
+ const gradRe = /(repeating-)?radial-gradient\(/gi;
1529
+ let g;
1530
+ while ((g = gradRe.exec(value)) !== null) {
1531
+ if (g[1]) continue; // repeating-* = pattern, not halo
1532
+ // Balanced-paren capture of the gradient arguments.
1533
+ let depth = 0, end = -1;
1534
+ const open = value.indexOf('(', g.index);
1535
+ for (let i = open; i < value.length; i++) {
1536
+ if (value[i] === '(') depth++;
1537
+ else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
1538
+ }
1539
+ if (end < 0) break;
1540
+ const args = splitTopLevelCommas(value.slice(open + 1, end));
1541
+ if (args.length < 2) continue;
1542
+
1543
+ // Optional prelude (shape / size / `at <pos>`) carries no color.
1544
+ const colorTokenRe = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
1545
+ const stops = args.filter(a => colorTokenRe.test(a));
1546
+ if (stops.length < 2) continue;
1547
+
1548
+ // Dot/texture exemption: px-sized stop positions mean a repeating
1549
+ // background-size pattern, not a page-scale halo.
1550
+ const pxStop = stops.some(s => {
1551
+ const pm = s.match(/(-?[\d.]+)px\b/);
1552
+ return pm && Math.abs(parseFloat(pm[1])) <= 24;
1553
+ });
1554
+ if (pxStop) continue;
1555
+
1556
+ const first = stops[0].match(colorTokenRe);
1557
+ const last = stops[stops.length - 1].match(colorTokenRe);
1558
+ if (!first || !last) continue;
1559
+
1560
+ const lastColor = /^transparent$/i.test(last[0]) ? { r: 0, g: 0, b: 0, a: 0 } : parseAnyColor(last[0]);
1561
+ if (!lastColor || (lastColor.a ?? 1) > 0.05) continue;
1562
+
1563
+ const firstColor = /^transparent$/i.test(first[0]) ? null : parseAnyColor(first[0]);
1564
+ if (!firstColor) continue;
1565
+ if ((firstColor.a ?? 1) < 0.7) continue;
1566
+ const spread = Math.max(firstColor.r, firstColor.g, firstColor.b) - Math.min(firstColor.r, firstColor.g, firstColor.b);
1567
+ if (spread < 24) continue;
1568
+
1569
+ const snippet = `radial-gradient halo (${colorToHex(firstColor)} → transparent) on dark page`;
1570
+ if (seen.has(snippet)) continue;
1571
+ seen.add(snippet);
1572
+ findings.push({ index: m.index, snippet });
1573
+ }
1574
+ }
1575
+ return findings;
1576
+ }
1577
+
1578
+ // ---------------------------------------------------------------------------
1579
+ // Text-level CSS rule-block scanners (pseudo-element stripes, pulsing dots)
1580
+ // ---------------------------------------------------------------------------
1581
+
1582
+ // Iterate `selector { declarations }` pairs in raw CSS/HTML text. The block
1583
+ // body excludes braces, so nested structures (@media, @keyframes) naturally
1584
+ // yield their innermost rules with the innermost selector text. Callers
1585
+ // create the regex locally — a shared /g instance is not re-entrant.
1586
+ const CSS_RULE_BLOCK_SOURCE = String.raw`([^{};]+)\{([^{}]*)\}`;
1587
+
1588
+ // Parse a declaration block into a prop → value map (last declaration wins,
1589
+ // approximating the cascade inside one block). Values keep their raw text
1590
+ // with any !important suffix stripped.
1591
+ function parseCssDeclBlock(block) {
1592
+ const decls = new Map();
1593
+ for (const part of String(block || '').split(';')) {
1594
+ const idx = part.indexOf(':');
1595
+ if (idx <= 0) continue;
1596
+ const prop = part.slice(0, idx).trim().toLowerCase();
1597
+ const value = part.slice(idx + 1).replace(/\s*!important\s*$/i, '').trim();
1598
+ if (prop && value) decls.set(prop, value);
1599
+ }
1600
+ return decls;
1601
+ }
1602
+
1603
+ function cssLengthToPx(value) {
1604
+ const m = String(value || '').trim().match(/^(-?[\d.]+)(px|rem|em)$/i);
1605
+ if (!m) return null;
1606
+ const n = parseFloat(m[1]);
1607
+ return m[2].toLowerCase() === 'px' ? n : n * 16;
1608
+ }
1609
+
1610
+ function isZeroOffset(value) {
1611
+ return value != null && /^-?0(?:px|%|rem|em)?$/.test(String(value).trim());
1612
+ }
1613
+
1614
+ // Side-tab variant: the accent stripe drawn as an absolutely-positioned
1615
+ // ::before/::after pseudo-element (narrow colored box hugging a vertical
1616
+ // edge) instead of a border-left/right. The element-level border checks
1617
+ // never see it — pseudo-elements aren't part of the DOM the cascade walks —
1618
+ // so this scans stylesheet text directly, mirroring the border rule's
1619
+ // gates: >= 3px thick, chromatic fill, full height against a side edge.
1620
+ function scanCssTextForPseudoStripe(rawContent) {
1621
+ // Blank comment bodies byte-for-byte so commented-out rules are not
1622
+ // scanned as live CSS and every rule keeps its source offset (each
1623
+ // finding carries `index` so line-based callers can attribute it and
1624
+ // line-scoped inline ignores can match).
1625
+ const content = String(rawContent || '').replace(/\/\*[\s\S]*?\*\//g,
1626
+ (block) => block.replace(/[^\n]/g, ' '));
1627
+ const customProps = collectCssCustomProps(content);
1628
+ const findings = [];
1629
+ const seen = new Set();
1630
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
1631
+ let m;
1632
+ while ((m = ruleRe.exec(content)) !== null) {
1633
+ const selector = m[1].trim();
1634
+ if (!/::?(?:before|after)\b/i.test(selector)) continue;
1635
+ // Keep the border rule's prose exemptions (blockquote bars etc.).
1636
+ if (/\b(?:blockquote|pre|code|nav|hr)\b/i.test(selector)) continue;
1637
+ const decls = parseCssDeclBlock(m[2]);
1638
+ const position = decls.get('position');
1639
+ if (position !== 'absolute' && position !== 'fixed') continue;
1640
+
1641
+ const widthPx = cssLengthToPx(resolveVarRefs(
1642
+ decls.get('width') || decls.get('inline-size') || '', customProps));
1643
+ const heightPx = cssLengthToPx(resolveVarRefs(
1644
+ decls.get('height') || decls.get('block-size') || '', customProps));
1645
+ const verticalCandidate = widthPx != null && widthPx >= 3 && widthPx <= 12;
1646
+ // Horizontal variant (top/bottom stripe) carries extra exemptions:
1647
+ // link/button underline affordances, selected-state indicators
1648
+ // (aria-selected="true", aria-current, active/current/selected class
1649
+ // hints), and state-conditional (:hover/:focus/...) affordances are
1650
+ // not stripes. Tab-strip membership alone ([role=tab], .tabs, bare
1651
+ // [aria-selected]) is NOT exempt — a stripe on every tab in the
1652
+ // group is decoration; only the selected item's underline stays.
1653
+ const horizontalCandidate = heightPx != null && heightPx >= 3 && heightPx <= 12
1654
+ && !/(?:^|[\s>+~,(])(?:a|button|summary|tr|td|th|table|li)(?![\w-])/i.test(selector)
1655
+ && !/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)
1656
+ && !/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)
1657
+ && !/(?:^|[\s._[-])(?:active|current|selected|btn[\w-]*|button[\w-]*|link[\w-]*)(?![\w])/i.test(selector)
1658
+ && !/:(?:hover|focus|focus-visible|focus-within|active|checked)\b/i.test(selector);
1659
+ if (!verticalCandidate && !horizontalCandidate) continue;
1660
+
1661
+ // Resolve edge offsets, letting an `inset` shorthand fill the gaps.
1662
+ const offsets = {
1663
+ top: decls.get('top'), right: decls.get('right'),
1664
+ bottom: decls.get('bottom'), left: decls.get('left'),
1665
+ };
1666
+ const inset = decls.get('inset');
1667
+ if (inset) {
1668
+ const p = inset.split(/\s+/);
1669
+ const [t, r, b, l] =
1670
+ p.length === 1 ? [p[0], p[0], p[0], p[0]]
1671
+ : p.length === 2 ? [p[0], p[1], p[0], p[1]]
1672
+ : p.length === 3 ? [p[0], p[1], p[2], p[1]]
1673
+ : p;
1674
+ if (offsets.top == null) offsets.top = t;
1675
+ if (offsets.right == null) offsets.right = r;
1676
+ if (offsets.bottom == null) offsets.bottom = b;
1677
+ if (offsets.left == null) offsets.left = l;
1678
+ }
1679
+ if (offsets.left == null) offsets.left = decls.get('inset-inline-start');
1680
+ if (offsets.right == null) offsets.right = decls.get('inset-inline-end');
1681
+
1682
+ const heightValue = String(resolveVarRefs(
1683
+ decls.get('height') || decls.get('block-size') || '', customProps)).trim();
1684
+ const widthValue = String(resolveVarRefs(
1685
+ decls.get('width') || decls.get('inline-size') || '', customProps)).trim();
1686
+
1687
+ let edge = null;
1688
+ let thicknessPx = null;
1689
+ if (verticalCandidate) {
1690
+ // Full-height stripes hug both corners; the "floating" variant backs
1691
+ // off each end by a small inset (top/bottom a few px) so the bar
1692
+ // clears the card's corners. Both read as the same side-tab accent —
1693
+ // corner treatment is styling, not a different pattern.
1694
+ const topPx = cssLengthToPx(resolveVarRefs(String(offsets.top ?? ''), customProps));
1695
+ const bottomPx = cssLengthToPx(resolveVarRefs(String(offsets.bottom ?? ''), customProps));
1696
+ const fullHeight = (isZeroOffset(offsets.top) && isZeroOffset(offsets.bottom))
1697
+ || /^100(?:\.0*)?%$/.test(heightValue)
1698
+ || (topPx != null && bottomPx != null
1699
+ && topPx >= 0 && topPx <= 20 && bottomPx >= 0 && bottomPx <= 20);
1700
+ if (fullHeight) {
1701
+ edge = isZeroOffset(offsets.left) ? 'left'
1702
+ : isZeroOffset(offsets.right) ? 'right' : null;
1703
+ thicknessPx = widthPx;
1704
+ }
1705
+ }
1706
+ if (!edge && horizontalCandidate) {
1707
+ const fullWidth = (isZeroOffset(offsets.left) && isZeroOffset(offsets.right))
1708
+ || /^100(?:\.0*)?%$/.test(widthValue);
1709
+ if (fullWidth) {
1710
+ edge = isZeroOffset(offsets.top) ? 'top'
1711
+ : isZeroOffset(offsets.bottom) ? 'bottom' : null;
1712
+ thicknessPx = heightPx;
1713
+ }
1714
+ }
1715
+ if (!edge) continue;
1716
+
1717
+ // Chromatic fill only — a neutral hairline divider is not an accent
1718
+ // stripe. Unresolvable colors err toward detection, matching the
1719
+ // border rule's unknown-format default.
1720
+ const bg = String(resolveVarRefs(
1721
+ decls.get('background-color') || decls.get('background') || '', customProps)).trim();
1722
+ if (!bg || /^(?:none|transparent|inherit|initial|unset|currentcolor)$/i.test(bg)) continue;
1723
+ const colorToken = bg.match(/(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\([^)]*\)|#[0-9a-f]{3,8}\b/i);
1724
+ const parsed = parseAnyColor(colorToken ? colorToken[0] : bg);
1725
+ if (parsed) {
1726
+ if ((parsed.a ?? 1) < 0.1) continue;
1727
+ const spread = Math.max(parsed.r, parsed.g, parsed.b) - Math.min(parsed.r, parsed.g, parsed.b);
1728
+ if (spread < 30) continue;
1729
+ } else if (/^(?:white|black|gray|grey|silver)$/i.test(bg)) {
1730
+ continue;
1731
+ }
1732
+
1733
+ if (seen.has(selector)) continue;
1734
+ seen.add(selector);
1735
+ // The selector group absorbs whitespace trailing the previous rule;
1736
+ // advance past it so `index` points at the selector itself.
1737
+ const selectorStart = m.index + (m[1].length - m[1].trimStart().length);
1738
+ findings.push({
1739
+ id: 'side-tab',
1740
+ snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
1741
+ index: selectorStart,
1742
+ });
1743
+ }
1744
+ return findings;
1745
+ }
1746
+
1747
+ // Side-tab stripe drawn as a single-edge inset box-shadow
1748
+ // (x or y offset 3-12px, other axis 0, no blur/spread, chromatic color):
1749
+ // paints a bar along one edge with no border property involved, so the
1750
+ // element-level border checks never see it. Selection-state indicators
1751
+ // are exempt — an inset stripe on [aria-current] / .active / [role=tab]
1752
+ // marks the selected item; the same stripe unconditionally on every item
1753
+ // is decoration and flags.
1754
+ function scanCssTextForInsetStripe(content) {
1755
+ const customProps = collectCssCustomProps(content);
1756
+ const findings = [];
1757
+ const seen = new Set();
1758
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
1759
+ let m;
1760
+ while ((m = ruleRe.exec(content)) !== null) {
1761
+ const selector = m[1].trim();
1762
+ // Selection-state contexts: current-item markers and interaction
1763
+ // states. Tab-strip membership alone ([role=tab], .tabs, bare
1764
+ // [aria-selected]) is NOT exempt — a stripe on every tab in the
1765
+ // group is decoration; only the selected item's indicator stays.
1766
+ if (/:(?:hover|focus|focus-visible|focus-within|active|checked|target)\b/i.test(selector)) continue;
1767
+ if (/\[aria-selected\s*[*^$|~]?=\s*["']?true/i.test(selector)) continue;
1768
+ if (/\[aria-current(?!\s*[*^$|~]?=\s*["']?false)/i.test(selector)) continue;
1769
+ if (/(?:^|[\s._[-])(?:active|current|selected)(?![\w])/i.test(selector)) continue;
1770
+ // Structural tags where a single-edge inset shadow is depth/quoting,
1771
+ // not an accent stripe.
1772
+ if (/(?:^|[\s>+~,(])(?:button|hr|tr|td|th|table|blockquote|pre|code)(?![\w-])/i.test(selector)) continue;
1773
+
1774
+ const decls = parseCssDeclBlock(m[2]);
1775
+ const shadow = decls.get('box-shadow');
1776
+ if (!shadow || !/\binset\b/i.test(shadow)) continue;
1777
+ // Narrow fixed-width elements (logo marks, icon glyphs) use inset
1778
+ // fills as artwork, not edge stripes. Stripe targets — cards, badges,
1779
+ // menu items — are wider or leave width to layout.
1780
+ const declaredWidth = cssLengthToPx(resolveVarRefs(decls.get('width') || decls.get('inline-size') || '', customProps));
1781
+ if (declaredWidth != null && declaredWidth <= 40) continue;
1782
+ const value = resolveVarRefs(shadow, customProps);
1783
+ for (const layer of value.split(/,(?![^(]*\))/)) {
1784
+ if (!/\binset\b/i.test(layer)) continue;
1785
+ const colorInfo = findShadowColor(layer);
1786
+ // Unresolvable colors (currentColor, external vars): don't guess.
1787
+ if (!colorInfo || !colorInfo.color) continue;
1788
+ const c = colorInfo.color;
1789
+ if ((c.a ?? 1) < 0.1) continue;
1790
+ const chroma = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
1791
+ if (chroma < 30) continue;
1792
+ const vals = extractShadowLengths(layer, colorInfo.start, colorInfo.end);
1793
+ const x = vals[0] || 0, y = vals[1] || 0, blur = vals[2] || 0, sp = vals[3] || 0;
1794
+ if (blur !== 0 || sp !== 0) continue;
1795
+ const ax = Math.abs(x), ay = Math.abs(y);
1796
+ const isStripe = (ax >= 3 && ax <= 12 && ay === 0) || (ay >= 3 && ay <= 12 && ax === 0);
1797
+ if (!isStripe) continue;
1798
+ if (seen.has(selector)) break;
1799
+ seen.add(selector);
1800
+ const edge = ay === 0 ? (x > 0 ? 'left' : 'right') : (y > 0 ? 'top' : 'bottom');
1801
+ findings.push({
1802
+ id: 'side-tab',
1803
+ snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
1804
+ });
1805
+ break;
1806
+ }
1807
+ }
1808
+ return findings;
1809
+ }
1810
+
1811
+ // Collect @keyframes names whose body travels horizontally — the marquee
1812
+ // loop. X travel is measured across every translateX/translate/translate3d
1813
+ // X component in the body: a centered element animating something else
1814
+ // keeps a constant -50% X (zero travel) and never qualifies, while a
1815
+ // ticker moves from its resting position to a large offset. Keyframes
1816
+ // with a single X sample that also vary scale/opacity read as pulses or
1817
+ // breathes, not marquees.
1818
+ function collectMarqueeKeyframes(content) {
1819
+ const names = new Set();
1820
+ const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
1821
+ let m;
1822
+ while ((m = re.exec(content)) !== null) {
1823
+ let depth = 1;
1824
+ let i = re.lastIndex;
1825
+ while (i < content.length && depth > 0) {
1826
+ const ch = content.charCodeAt(i);
1827
+ if (ch === 0x7b /* { */) depth++;
1828
+ else if (ch === 0x7d /* } */) depth--;
1829
+ i++;
1830
+ }
1831
+ const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
1832
+ re.lastIndex = i;
1833
+
1834
+ // Only percentage travel qualifies: a content marquee translates by a
1835
+ // fraction of its own (unknown) track width, so generated tickers use
1836
+ // -50% / -100%. Pixel-travel loops are bespoke product animations —
1837
+ // sweeping playheads, progress indicators — not marquees.
1838
+ const pct = [];
1839
+ const xRe = /\btranslate(?:X|3d)?\(\s*(-?[\d.]+)%/gi;
1840
+ let xm;
1841
+ while ((xm = xRe.exec(body)) !== null) pct.push(parseFloat(xm[1]));
1842
+ if (pct.length === 0) continue;
1843
+ if (pct.length === 1 && /\bscale\(|\bopacity\s*:/i.test(body)) continue;
1844
+ // Implicit start: a lone declared X animates from the element's
1845
+ // resting position, so its magnitude is the travel.
1846
+ const travelPct = pct.length > 1 ? Math.max(...pct) - Math.min(...pct) : Math.abs(pct[0]);
1847
+ if (travelPct >= 20) names.add(m[1]);
1848
+ }
1849
+ return names;
1850
+ }
1851
+
1852
+ // Auto-scrolling marquee: a <marquee> element, or an infinite animation
1853
+ // bound to a keyframe loop that travels a large horizontal distance.
1854
+ // Rotation/opacity animations never qualify (no X travel); JS-driven
1855
+ // carousels with user controls have no infinite CSS X-loop to match.
1856
+ // `content` is CSS-bearing text; `markup` (defaulting to the same string
1857
+ // for single-corpus callers) is where the <marquee> tag itself lives.
1858
+ function scanCssTextForMarquee(content, markup = content) {
1859
+ const findings = [];
1860
+ if (/<marquee\b/i.test(markup)) {
1861
+ findings.push({ id: 'marquee', snippet: '<marquee> element' });
1862
+ }
1863
+ const marqueeKeyframes = collectMarqueeKeyframes(content);
1864
+ if (marqueeKeyframes.size === 0) return findings;
1865
+ const seen = new Set();
1866
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
1867
+ let m;
1868
+ while ((m = ruleRe.exec(content)) !== null) {
1869
+ const selector = m[1].trim();
1870
+ const decls = parseCssDeclBlock(m[2]);
1871
+ for (const name of infiniteAnimationNames(decls)) {
1872
+ if (!marqueeKeyframes.has(name)) continue;
1873
+ const key = `${selector} ${name}`;
1874
+ if (seen.has(key)) continue;
1875
+ seen.add(key);
1876
+ findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
1877
+ }
1878
+ }
1879
+ return findings;
1880
+ }
1881
+
1882
+ // Collect @keyframes names and whether each one reads as a "pulse" —
1883
+ // i.e. it varies opacity, scale, or box-shadow. Rotation-only keyframes
1884
+ // (spinners) are explicitly not pulses.
1885
+ function collectPulseKeyframes(content) {
1886
+ const map = new Map();
1887
+ const re = /@(?:-webkit-)?keyframes\s+([\w-]+)\s*\{/g;
1888
+ let m;
1889
+ while ((m = re.exec(content)) !== null) {
1890
+ let depth = 1;
1891
+ let i = re.lastIndex;
1892
+ while (i < content.length && depth > 0) {
1893
+ const ch = content.charCodeAt(i);
1894
+ if (ch === 0x7b /* { */) depth++;
1895
+ else if (ch === 0x7d /* } */) depth--;
1896
+ i++;
1897
+ }
1898
+ const body = content.slice(re.lastIndex, Math.max(re.lastIndex, i - 1));
1899
+ const pulses = /\bopacity\s*:/i.test(body)
1900
+ || /\bbox-shadow\s*:/i.test(body)
1901
+ || /\btransform\s*:[^;{}]*\bscale/i.test(body);
1902
+ if (!map.has(m[1]) || pulses) map.set(m[1], pulses);
1903
+ re.lastIndex = i;
1904
+ }
1905
+ return map;
1906
+ }
1907
+
1908
+ const ANIMATION_VALUE_KEYWORDS = new Set([
1909
+ 'ease', 'ease-in', 'ease-out', 'ease-in-out', 'linear',
1910
+ 'infinite', 'alternate', 'alternate-reverse', 'normal', 'reverse',
1911
+ 'none', 'forwards', 'backwards', 'both', 'running', 'paused',
1912
+ 'step-start', 'step-end', 'inherit', 'initial', 'unset',
1913
+ ]);
1914
+
1915
+ // Extract animation names that run with iteration-count: infinite from a
1916
+ // declaration block (shorthand layers or animation-name + iteration-count).
1917
+ function infiniteAnimationNames(decls) {
1918
+ const out = [];
1919
+ const shorthand = decls.get('animation');
1920
+ if (shorthand) {
1921
+ for (const layer of shorthand.split(/,(?![^(]*\))/)) {
1922
+ if (!/\binfinite\b/i.test(layer)) continue;
1923
+ const name = layer.split(/\s+/).find(t =>
1924
+ /^[a-zA-Z_-][\w-]*$/.test(t) && !ANIMATION_VALUE_KEYWORDS.has(t.toLowerCase()));
1925
+ if (name) out.push(name);
1926
+ }
1927
+ }
1928
+ const nameDecl = decls.get('animation-name');
1929
+ if (nameDecl && /\binfinite\b/i.test(decls.get('animation-iteration-count') || '')) {
1930
+ for (const raw of nameDecl.split(',')) {
1931
+ const t = raw.trim();
1932
+ if (t && t.toLowerCase() !== 'none') out.push(t);
1933
+ }
1934
+ }
1935
+ return out;
1936
+ }
1937
+
1938
+ function isRoundDotRadius(radiusValue, w, h) {
1939
+ if (!radiusValue) return false;
1940
+ const first = String(radiusValue).trim().split(/\s+/)[0];
1941
+ const pct = first.match(/^([\d.]+)%$/);
1942
+ if (pct) return parseFloat(pct[1]) >= 40;
1943
+ const px = cssLengthToPx(first);
1944
+ if (px == null) return false;
1945
+ return px >= 999 || px >= 0.4 * Math.min(w, h);
1946
+ }
1947
+
1948
+ // Remove @media blocks whose condition is prefers-reduced-motion: reduce.
1949
+ // Those blocks describe the accessibility fallback, not the default
1950
+ // experience that ships — an `animation: none` reset inside one must not
1951
+ // mask the resting-state animation the page plays for everyone else.
1952
+ function stripReducedMotionBlocks(content) {
1953
+ const re = /@media[^{]*prefers-reduced-motion\s*:\s*reduce[^{]*\{/gi;
1954
+ let out = '';
1955
+ let last = 0;
1956
+ let m;
1957
+ while ((m = re.exec(content)) !== null) {
1958
+ let depth = 1;
1959
+ let i = re.lastIndex;
1960
+ while (i < content.length && depth > 0) {
1961
+ const ch = content.charCodeAt(i);
1962
+ if (ch === 0x7b /* { */) depth++;
1963
+ else if (ch === 0x7d /* } */) depth--;
1964
+ i++;
1965
+ }
1966
+ out += content.slice(last, m.index);
1967
+ last = i;
1968
+ re.lastIndex = i;
1969
+ }
1970
+ return out + content.slice(last);
1971
+ }
1972
+
1973
+ // Source-index ranges of <header> and <nav> landmark elements in an HTML
1974
+ // string. Lets string-level scans decide whether a matched element sits in
1975
+ // the page chrome (the hero/nav region) without needing a DOM.
1976
+ function landmarkSourceRanges(content) {
1977
+ const ranges = [];
1978
+ for (const tag of ['header', 'nav']) {
1979
+ const re = new RegExp(`<${tag}\\b|</${tag}\\s*>`, 'gi');
1980
+ const stack = [];
1981
+ let m;
1982
+ while ((m = re.exec(content)) !== null) {
1983
+ if (m[0].charAt(1) === '/') {
1984
+ const start = stack.pop();
1985
+ if (start != null) ranges.push([start, m.index]);
1986
+ } else {
1987
+ stack.push(m.index);
1988
+ }
1989
+ }
1990
+ }
1991
+ return ranges;
1992
+ }
1993
+
1994
+ function indexInSourceRanges(index, ranges) {
1995
+ return ranges.some(([start, end]) => index >= start && index < end);
1996
+ }
1997
+
1998
+ // Does any element targeted by the final compound of `selector` appear
1999
+ // inside a header/nav landmark range of the HTML source? Resolves the last
2000
+ // .class or #id token of the selector against class/id attributes; a
2001
+ // tag-only compound is never resolvable this way and returns false
2002
+ // (conservative: no promotion without placement evidence).
2003
+ function selectorHitsLandmark(content, selector, ranges) {
2004
+ if (!ranges || ranges.length === 0) return false;
2005
+ const last = selector.split(/[\s>+~]+/).filter(Boolean).pop() || '';
2006
+ const idMatch = last.match(/#([A-Za-z_][\w-]*)/);
2007
+ const classMatch = last.match(/\.([A-Za-z_][\w-]*)/);
2008
+ let attrRe = null;
2009
+ if (idMatch) {
2010
+ const id = idMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2011
+ attrRe = new RegExp(`<[a-zA-Z][^>]*\\bid\\s*=\\s*["']${id}["']`, 'gi');
2012
+ } else if (classMatch) {
2013
+ const cls = classMatch[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2014
+ attrRe = new RegExp(`<[a-zA-Z][^>]*\\bclass\\s*=\\s*["'][^"']*(?<![\\w-])${cls}(?![\\w-])[^"']*["']`, 'gi');
2015
+ }
2016
+ if (!attrRe) return false;
2017
+ let m;
2018
+ while ((m = attrRe.exec(content)) !== null) {
2019
+ if (indexInSourceRanges(m.index, ranges)) return true;
2020
+ }
2021
+ return false;
2022
+ }
2023
+
2024
+ // Small circular indicator bound to an infinite pulse animation — the
2025
+ // decorative "live" dot. Gates: tiny (<= 16px square-ish), round
2026
+ // (border-radius >= 40% or pill values), and an infinite animation whose
2027
+ // keyframes vary opacity/scale/box-shadow (or a pulse/blink/ping name when
2028
+ // the keyframes aren't in the scanned text). Rotation-only animations
2029
+ // (spinners) never flag.
2030
+ //
2031
+ // Declarations for one selector are merged across rule blocks before the
2032
+ // predicate runs: size in the base rule plus the animation added in a
2033
+ // second block (or inside a matching @media block) is the construction
2034
+ // that ships. prefers-reduced-motion: reduce overrides are stripped first
2035
+ // so their animation resets don't mask the default experience. A dot whose
2036
+ // element sits inside a header/nav landmark is the hero liveness cliché
2037
+ // and is promoted to error severity; occurrences elsewhere keep the
2038
+ // registry default severity.
2039
+ //
2040
+ // `content` is CSS-bearing text (rules and keyframes); `markup` — defaulting
2041
+ // to the same string for single-corpus callers like the regex source
2042
+ // engine — is where landmark ranges and Tailwind class attributes live.
2043
+ function scanCssTextForPulsingDot(content, markup = content) {
2044
+ const customProps = collectCssCustomProps(content);
2045
+ const keyframes = collectPulseKeyframes(content);
2046
+ const heroRanges = landmarkSourceRanges(markup);
2047
+ const findings = [];
2048
+ const seen = new Set();
2049
+
2050
+ // Merge declarations per selector across rule blocks, approximating the
2051
+ // cascade: later declarations for the same property win. Comma lists are
2052
+ // split so `.a, .b { … }` contributes to both selectors. Comments are
2053
+ // stripped first so they neither pollute selector keys nor smuggle a
2054
+ // comma into the selector-list split.
2055
+ const scanText = stripReducedMotionBlocks(content).replace(/\/\*[\s\S]*?\*\//g, ' ');
2056
+ const merged = new Map();
2057
+ const ruleRe = new RegExp(CSS_RULE_BLOCK_SOURCE, 'g');
2058
+ let m;
2059
+ while ((m = ruleRe.exec(scanText)) !== null) {
2060
+ const decls = parseCssDeclBlock(m[2]);
2061
+ if (decls.size === 0) continue;
2062
+ for (const rawSelector of m[1].split(',')) {
2063
+ const selector = rawSelector.trim();
2064
+ if (!selector || selector.startsWith('@')) continue;
2065
+ let acc = merged.get(selector);
2066
+ if (!acc) {
2067
+ acc = new Map();
2068
+ merged.set(selector, acc);
2069
+ }
2070
+ for (const [prop, value] of decls) acc.set(prop, value);
2071
+ }
2072
+ }
2073
+
2074
+ for (const [selector, decls] of merged) {
2075
+ const names = infiniteAnimationNames(decls);
2076
+ if (names.length === 0) continue;
2077
+ const pulseName = names.find(n => {
2078
+ const known = keyframes.get(n);
2079
+ if (known != null) return known;
2080
+ return /pulse|blink|ping/i.test(n);
2081
+ });
2082
+ if (!pulseName) continue;
2083
+
2084
+ const w = cssLengthToPx(resolveVarRefs(
2085
+ decls.get('width') || decls.get('inline-size') || '', customProps));
2086
+ const h = cssLengthToPx(resolveVarRefs(
2087
+ decls.get('height') || decls.get('block-size') || '', customProps));
2088
+ if (w == null || h == null || w < 2 || h < 2 || w > 16 || h > 16) continue;
2089
+
2090
+ const radius = resolveVarRefs(decls.get('border-radius') || '', customProps);
2091
+ if (!isRoundDotRadius(radius, w, h)) continue;
2092
+
2093
+ if (seen.has(selector)) continue;
2094
+ seen.add(selector);
2095
+ const inLandmark = selectorHitsLandmark(markup, selector, heroRanges);
2096
+ findings.push({
2097
+ id: 'pulsing-dot',
2098
+ snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
2099
+ selector,
2100
+ ...(inLandmark ? { severity: 'error' } : {}),
2101
+ });
2102
+ }
2103
+
2104
+ // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
2105
+ // element declared entirely in the class attribute. Scanned in the markup
2106
+ // corpus so the match index lines up with the landmark ranges.
2107
+ const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
2108
+ let cm;
2109
+ while ((cm = classRe.exec(markup)) !== null) {
2110
+ const cls = cm[1] || cm[2] || '';
2111
+ const anim = cls.match(/\banimate-(ping|pulse)\b/);
2112
+ if (!anim) continue;
2113
+ if (!/\brounded-full\b/.test(cls)) continue;
2114
+ if (!/\b(?:w|h|size)-(?:1|1\.5|2|2\.5|3|3\.5|4)\b/.test(cls)) continue;
2115
+ const key = `tw:${cls}`;
2116
+ if (seen.has(key)) continue;
2117
+ seen.add(key);
2118
+ const inLandmark = indexInSourceRanges(cm.index, heroRanges);
2119
+ findings.push({
2120
+ id: 'pulsing-dot',
2121
+ snippet: `animate-${anim[1]} on tiny rounded-full element${inLandmark ? ' in header/nav' : ''}`,
2122
+ ...(inLandmark ? { severity: 'error' } : {}),
2123
+ });
2124
+ }
2125
+
2126
+ return findings;
2127
+ }
2128
+
2129
+ // Shape-assembled illustration: a large inline SVG composing a pictorial
2130
+ // scene from many primitive shapes (rect / circle / ellipse / polygon) in
2131
+ // several fill colors — the clip-art hero mascot. Gates keep the legitimate
2132
+ // SVG population out:
2133
+ // • icons and logos: intrinsic size gate (>= 200px on both axes, from
2134
+ // width/height attributes or the viewBox when no explicit size is set)
2135
+ // • charts / labeled diagrams: more than two <text>/<tspan> nodes exempts
2136
+ // the graphic (axis labels, callouts)
2137
+ // • line drawings / technical diagrams: primitive count < 8 or fewer
2138
+ // than 3 distinct fills never qualifies (stroke-only art has no fills)
2139
+ // • tiling background textures: any <pattern> definition exempts
2140
+ function scanHtmlForShapeAssembledIllustration(html) {
2141
+ const findings = [];
2142
+ const svgRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
2143
+ let m;
2144
+ while ((m = svgRe.exec(html)) !== null) {
2145
+ const block = m[0];
2146
+ const openTag = (block.match(/^<svg\b[^>]*>/i) || [''])[0];
2147
+
2148
+ // Data-bearing or annotated graphics: axis labels and callout text
2149
+ // mark a chart or diagram, not a mascot.
2150
+ const textCount = (block.match(/<(?:text|tspan)\b/gi) || []).length;
2151
+ if (textCount > 2) continue;
2152
+ // Tiling texture definitions are decorative backgrounds, not scenes.
2153
+ if (/<pattern\b/i.test(block)) continue;
2154
+
2155
+ const primitives = (block.match(/<(?:rect|circle|ellipse|polygon)\b/gi) || []).length;
2156
+ if (primitives < 8) continue;
2157
+
2158
+ // Intrinsic size: explicit width/height attributes win; fall back to
2159
+ // the viewBox box. Percentage or missing sizes stay unresolvable on
2160
+ // that axis and the viewBox speaks for them.
2161
+ const attrDim = (name) => {
2162
+ // (?<![-\w]) keeps compound attributes like stroke-width from
2163
+ // masquerading as the svg's own width.
2164
+ const am = openTag.match(new RegExp(`(?<![-\\w])${name}\\s*=\\s*["']\\s*([\\d.]+)(?:px)?\\s*["']`, 'i'));
2165
+ return am ? parseFloat(am[1]) : null;
2166
+ };
2167
+ const vb = openTag.match(/\bviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i);
2168
+ const w = attrDim('width') ?? (vb ? parseFloat(vb[1]) : null);
2169
+ const h = attrDim('height') ?? (vb ? parseFloat(vb[2]) : null);
2170
+ if (w == null || h == null || w < 200 || h < 200) continue;
2171
+
2172
+ // Distinct fill paints (attributes and inline styles), excluding
2173
+ // non-paints. Multiple fills are what turn a shape pile into a scene.
2174
+ const fills = new Set();
2175
+ for (const fm of block.matchAll(/\bfill\s*[:=]\s*["']?\s*([^"';>}\s]+)/gi)) {
2176
+ const paint = fm[1].trim().toLowerCase();
2177
+ if (!paint || ['none', 'transparent', 'currentcolor', 'inherit'].includes(paint)) continue;
2178
+ fills.add(paint);
2179
+ }
2180
+ if (fills.size < 3) continue;
2181
+
2182
+ findings.push({
2183
+ id: 'shape-assembled-illustration',
2184
+ snippet: `inline <svg> scene: ${primitives} primitive shapes, ~${Math.round(w)}x${Math.round(h)}px, ${fills.size} fill colors`,
2185
+ });
2186
+ }
2187
+ return findings;
2188
+ }
2189
+
2190
+ // Scoped scan corpora for the page-level pattern checks. CSS-property
2191
+ // regexes run over the whole source string fire on documentation ABOUT
2192
+ // css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
2193
+ // comments — so the checks scan only the strings that actually style the
2194
+ // page:
2195
+ // styleText — <style> block contents plus style="…" attribute values.
2196
+ // Attribute values keep their `style="…"` form so block-scoped
2197
+ // scanners (grid background) keep treating each attribute as one
2198
+ // declaration block, exactly as they did against raw source. Engines
2199
+ // that already read more CSS (linked stylesheets) prepend it.
2200
+ // classText — class attribute values, for utility-class scans.
2201
+ // Markup-shaped checks (inline <svg> scenes, <img> tags, <marquee>,
2202
+ // landmark ranges) and rendered-text checks (theater phrases) keep the
2203
+ // full source. This extraction serves callers without a parsed document
2204
+ // (the browser bundle scanning outerHTML); attribute reads are tag-scoped
2205
+ // so escaped code samples (&lt;div style="…"&gt;) never contribute. The
2206
+ // static engine passes richer corpora built from its parsed document.
2207
+ // Bare CSS input (no markup at all) is its own style text, which keeps
2208
+ // direct checkHtmlPatterns(css) callers behaving as before.
2209
+ function buildHtmlPatternCorpora(html) {
2210
+ const source = String(html || '');
2211
+ if (!/<[a-zA-Z!/]/.test(source)) {
2212
+ return { styleText: source, classText: source };
2213
+ }
2214
+ const styleParts = [];
2215
+ const classParts = [];
2216
+ const styleBlockRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
2217
+ let m;
2218
+ while ((m = styleBlockRe.exec(source)) !== null) styleParts.push(m[1]);
2219
+ const tagRe = /<[a-zA-Z][^>]*>/g;
2220
+ while ((m = tagRe.exec(source)) !== null) {
2221
+ const tag = m[0];
2222
+ const sm = tag.match(/\bstyle\s*=\s*("[^"]*"|'[^']*')/i);
2223
+ if (sm) styleParts.push(`style=${sm[1]}`);
2224
+ const cm = tag.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2225
+ if (cm) classParts.push(cm[1] ?? cm[2] ?? '');
2226
+ }
2227
+ return { styleText: styleParts.join('\n'), classText: classParts.join('\n') };
2228
+ }
2229
+
1041
2230
  /**
1042
2231
  * Regex-on-HTML checks shared between browser and Node page-level detection.
1043
- * These don't need DOM access, just the raw HTML string.
2232
+ * These don't need DOM access, just the raw HTML string. CSS-property and
2233
+ * utility-class patterns scan the scoped corpora (styleText / classText —
2234
+ * see buildHtmlPatternCorpora) so prose about css never flags; only the
2235
+ * markup-shaped and rendered-text checks read the full source.
1044
2236
  */
1045
- function checkHtmlPatterns(html) {
2237
+ function checkHtmlPatterns(html, corpora) {
2238
+ const { styleText, classText } = corpora || buildHtmlPatternCorpora(html);
1046
2239
  const findings = [];
1047
2240
 
1048
2241
  // --- Color ---
1049
2242
 
1050
2243
  // AI color palette: purple/violet
1051
2244
  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
1052
- if (purpleHexRe.test(html)) {
2245
+ if (purpleHexRe.test(styleText)) {
1053
2246
  const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
1054
- if (purpleTextRe.test(html)) {
2247
+ if (purpleTextRe.test(styleText)) {
1055
2248
  findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
1056
2249
  }
1057
2250
  }
@@ -1059,38 +2252,48 @@ function checkHtmlPatterns(html) {
1059
2252
  // Gradient text (background-clip: text + gradient)
1060
2253
  const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
1061
2254
  let gm;
1062
- while ((gm = gradientRe.exec(html)) !== null) {
2255
+ while ((gm = gradientRe.exec(styleText)) !== null) {
1063
2256
  const start = Math.max(0, gm.index - 200);
1064
- const context = html.substring(start, gm.index + gm[0].length + 200);
2257
+ const context = styleText.substring(start, gm.index + gm[0].length + 200);
1065
2258
  if (/gradient/i.test(context)) {
1066
2259
  findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
1067
2260
  break;
1068
2261
  }
1069
2262
  }
1070
- if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
2263
+ if (/\bbg-clip-text\b/.test(classText) && /\bbg-gradient-to-/.test(classText)) {
1071
2264
  findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
1072
2265
  }
1073
2266
 
2267
+ // --- Borders ---
2268
+
2269
+ // Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
2270
+ // (no border property involved, so the element-level border checks and
2271
+ // the border-left regexes never see it).
2272
+ findings.push(...scanCssTextForPseudoStripe(styleText));
2273
+
2274
+ // Side-tab accent stripe drawn as a single-edge inset box-shadow.
2275
+ findings.push(...scanCssTextForInsetStripe(styleText));
2276
+
1074
2277
  // --- Layout ---
1075
2278
 
1076
2279
  // Monotonous spacing
1077
2280
  const spacingValues = [];
1078
2281
  const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
1079
2282
  let sm;
1080
- while ((sm = spacingRe.exec(html)) !== null) {
2283
+ while ((sm = spacingRe.exec(styleText)) !== null) {
1081
2284
  const v = parseInt(sm[1], 10);
1082
2285
  if (v > 0 && v < 200) spacingValues.push(v);
1083
2286
  }
1084
2287
  const gapRe = /gap\s*:\s*(\d+)px/gi;
1085
- while ((sm = gapRe.exec(html)) !== null) {
2288
+ while ((sm = gapRe.exec(styleText)) !== null) {
1086
2289
  spacingValues.push(parseInt(sm[1], 10));
1087
2290
  }
1088
2291
  const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
1089
- while ((sm = twSpaceRe.exec(html)) !== null) {
2292
+ while ((sm = twSpaceRe.exec(classText)) !== null) {
1090
2293
  spacingValues.push(parseInt(sm[1], 10) * 4);
1091
2294
  }
1092
2295
  const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
1093
- while ((sm = remSpacingRe.exec(html)) !== null) {
2296
+ while ((sm = remSpacingRe.exec(styleText)) !== null) {
1094
2297
  const v = Math.round(parseFloat(sm[1]) * 16);
1095
2298
  if (v > 0 && v < 200) spacingValues.push(v);
1096
2299
  }
@@ -1114,7 +2317,7 @@ function checkHtmlPatterns(html) {
1114
2317
 
1115
2318
  // Bounce/elastic animation names
1116
2319
  const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
1117
- const bounceMatch = bounceRe.exec(html);
2320
+ const bounceMatch = bounceRe.exec(styleText);
1118
2321
  if (bounceMatch) {
1119
2322
  const animationToken = bounceMatch[1]
1120
2323
  .split(/[,\s]+/)
@@ -1125,7 +2328,7 @@ function checkHtmlPatterns(html) {
1125
2328
  // Overshoot cubic-bezier
1126
2329
  const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
1127
2330
  let bm;
1128
- while ((bm = bezierRe.exec(html)) !== null) {
2331
+ while ((bm = bezierRe.exec(styleText)) !== null) {
1129
2332
  const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
1130
2333
  if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
1131
2334
  findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
@@ -1136,7 +2339,7 @@ function checkHtmlPatterns(html) {
1136
2339
  // Layout property transitions
1137
2340
  const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
1138
2341
  let tm;
1139
- while ((tm = transRe.exec(html)) !== null) {
2342
+ while ((tm = transRe.exec(styleText)) !== null) {
1140
2343
  const val = tm[1].toLowerCase();
1141
2344
  if (/\ball\b/.test(val)) continue;
1142
2345
  const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
@@ -1146,33 +2349,54 @@ function checkHtmlPatterns(html) {
1146
2349
  }
1147
2350
  }
1148
2351
 
1149
- // --- Dark glow ---
2352
+ // Pulsing status dots (tiny circular elements on infinite pulse animations).
2353
+ // The CSS rules come from styleText; the markup carries the landmark
2354
+ // ranges and Tailwind class attributes.
2355
+ findings.push(...scanCssTextForPulsingDot(styleText, html));
1150
2356
 
1151
- 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;
1152
- const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
1153
- if (darkBgRe.test(html) || twDarkBg.test(html)) {
1154
- const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
1155
- let shm;
1156
- while ((shm = shadowRe.exec(html)) !== null) {
1157
- const val = shm[1];
1158
- const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
1159
- if (!colorMatch) continue;
1160
- const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
1161
- if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue;
1162
- const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
1163
- if (pxVals.length >= 3 && pxVals[2] > 4) {
1164
- findings.push({ id: 'dark-glow', snippet: `Colored glow (rgb(${r},${g},${b})) on dark page` });
1165
- break;
1166
- }
1167
- }
2357
+ // Shape-assembled illustrations (large pictorial SVGs built from primitives)
2358
+ findings.push(...scanHtmlForShapeAssembledIllustration(html));
2359
+
2360
+ // Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
2361
+ findings.push(...scanCssTextForMarquee(styleText, html));
2362
+
2363
+ // --- Dark glow / chromatic halo shadows ---
2364
+
2365
+ const glowHits = scanCssTextForGlow(styleText);
2366
+ if (glowHits.length > 0) {
2367
+ findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
1168
2368
  }
1169
2369
 
1170
- // --- Provider tells (gated): repeating-gradient stripes (GPT) ---
1171
- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
2370
+ // Radial-gradient background halo (gradient-drawn sibling of dark-glow)
2371
+ const haloHits = scanCssTextForRadialHalo(styleText);
2372
+ if (haloHits.length > 0) {
2373
+ findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
2374
+ }
2375
+
2376
+ // --- Generated-UI tells: repeating-gradient stripes ---
2377
+ if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
1172
2378
  findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
1173
2379
  }
1174
2380
 
1175
- // --- Provider tells (gated): "X theater" framing copy (GPT) ---
2381
+ // --- Generated-UI tells: two-axis grid-line background ---
2382
+ // The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
2383
+ // transparent 1px)` layers (one per axis) tiled by a repeating
2384
+ // `background-size` cell. Both signals must co-occur in the SAME style block
2385
+ // (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
2386
+ // tiling background-size is a fixed crosshair, not a grid, and a single
2387
+ // hairline is a legitimate ruled line. Scoping to one block also stops
2388
+ // unrelated single-axis rules on separate elements from adding up across the
2389
+ // page. Count hairlines only inside `background`/`background-image` values so
2390
+ // a hairline in an unrelated property (mask-image, border-image) can't stand
2391
+ // in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
2392
+ // nested parens, so match the hairline stop directly rather than parsing
2393
+ // whole gradient layers.
2394
+ const gridHits = scanCssTextForGridBackground(styleText);
2395
+ if (gridHits.length > 0) {
2396
+ findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
2397
+ }
2398
+
2399
+ // --- Generated-copy tells: "X theater" framing copy ---
1176
2400
  // Lives here (regex-on-HTML) rather than in the text-content analyzers so it
1177
2401
  // runs in the bundled browser path too, not just the CLI/static path.
1178
2402
  {
@@ -1184,12 +2408,12 @@ function checkHtmlPatterns(html) {
1184
2408
  if (tm) findings.push({ id: 'theater-slop-phrase', snippet: `"${tm[0].trim()}"` });
1185
2409
  }
1186
2410
 
1187
- // --- Provider tells (gated): image hover transform (Gemini) ---
2411
+ // --- Generated-UI tells: image hover transform ---
1188
2412
  // A CSS `img...:hover { transform: ... }` rule, or a Tailwind hover:scale /
1189
2413
  // hover:rotate / hover:translate utility on an <img>. Each distinct
1190
2414
  // mechanism is its own finding.
1191
2415
  const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
1192
- if (imgHoverCss.test(html)) {
2416
+ if (imgHoverCss.test(styleText)) {
1193
2417
  findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
1194
2418
  }
1195
2419
  const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
@@ -1213,7 +2437,11 @@ function checkHtmlPatterns(html) {
1213
2437
  // `background: #abc`. Real browsers always decompose, so the fallback is
1214
2438
  // a no-op there.
1215
2439
  function readOwnBackgroundColor(el, computedStyle) {
1216
- const bg = parseRgb(computedStyle.backgroundColor);
2440
+ // Real browsers keep wide-gamut/computed color functions (oklch(), oklab(),
2441
+ // color-mix() results) in getComputedStyle output, which plain parseRgb
2442
+ // misses — a flat oklch button background would silently skip every
2443
+ // contrast check without the parseAnyColor fallback.
2444
+ const bg = parseRgb(computedStyle.backgroundColor) || parseAnyColor(computedStyle.backgroundColor);
1217
2445
  if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg;
1218
2446
  const rawStyle = el.getAttribute?.('style') || '';
1219
2447
  const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
@@ -1235,6 +2463,18 @@ function readOwnBackgroundColor(el, computedStyle) {
1235
2463
 
1236
2464
  function resolveBackground(el, win, customPropMap) {
1237
2465
  let current = el;
2466
+ // Translucent layers (0.1 < a < 1) found on the way down to an opaque
2467
+ // base. A browser composites these over the base; the old behavior
2468
+ // either returned them as-if-opaque (browser mode) or skipped them
2469
+ // entirely (static mode), both of which misstate the effective surface
2470
+ // for contrast checks (e.g. `background: color-mix(in oklab, var(--hot)
2471
+ // 16%, transparent)` chips on dark pages).
2472
+ const overlays = [];
2473
+ const flatten = (base) => {
2474
+ let acc = base;
2475
+ for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
2476
+ return acc;
2477
+ };
1238
2478
  while (current && current.nodeType === 1) {
1239
2479
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1240
2480
  const bgImage = style.backgroundImage || '';
@@ -1247,7 +2487,9 @@ function resolveBackground(el, win, customPropMap) {
1247
2487
  // decorative. The old behavior bailed on any gradient ancestor, which
1248
2488
  // caused massive false-positive contrast findings on grain-textured
1249
2489
  // body backgrounds.
1250
- let bg = parseRgb(style.backgroundColor);
2490
+ // Real browsers serialize wide-gamut computed values as oklab()/oklch()
2491
+ // (e.g. any color-mix() result), which plain parseRgb misses.
2492
+ let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
1251
2493
  if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
1252
2494
  // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
1253
2495
  // through customPropMap so Tailwind v4 color tokens become RGB.
@@ -1267,7 +2509,8 @@ function resolveBackground(el, win, customPropMap) {
1267
2509
  }
1268
2510
 
1269
2511
  if (bg && bg.a > 0.1) {
1270
- if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg;
2512
+ if (bg.a >= 0.99) return flatten(bg);
2513
+ overlays.push(bg);
1271
2514
  }
1272
2515
  // No solid bg-color at this level. If THIS level has a gradient/url
1273
2516
  // with no underlying solid color we can read:
@@ -1283,41 +2526,66 @@ function resolveBackground(el, win, customPropMap) {
1283
2526
  // bgs worth checking against).
1284
2527
  if (hasGradientOrUrl) {
1285
2528
  if (current.tagName === 'BODY' || current.tagName === 'HTML') {
1286
- return { r: 255, g: 255, b: 255, a: 1 };
2529
+ return flatten({ r: 255, g: 255, b: 255, a: 1 });
1287
2530
  }
1288
2531
  return null;
1289
2532
  }
1290
2533
  current = current.parentElement;
1291
2534
  }
1292
- return { r: 255, g: 255, b: 255 };
2535
+ return flatten({ r: 255, g: 255, b: 255, a: 1 });
1293
2536
  }
1294
2537
 
1295
2538
  // Walk parents looking for a gradient background and return its color stops.
1296
2539
  // Used as a fallback when resolveBackground() returns null because the
1297
2540
  // effective background is a gradient (no single solid color to compare against).
1298
- function resolveGradientStops(el, win) {
2541
+ function resolveGradientStops(el, win, customPropMap) {
1299
2542
  let current = el;
1300
2543
  while (current && current.nodeType === 1) {
1301
2544
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
1302
2545
  const bgImage = style.backgroundImage || '';
2546
+ let stops = null;
1303
2547
  if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
1304
- const stops = parseGradientColors(bgImage);
1305
- if (stops.length > 0) return stops;
2548
+ const parsed = parseGradientColors(bgImage);
2549
+ if (parsed.length > 0) stops = parsed;
1306
2550
  }
1307
- if (!DETECTOR_IS_BROWSER) {
2551
+ if (!stops && !DETECTOR_IS_BROWSER) {
1308
2552
  // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
1309
2553
  const rawStyle = current.getAttribute?.('style') || '';
1310
2554
  const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
1311
2555
  if (bgMatch && /gradient/i.test(bgMatch[1])) {
1312
- const stops = parseGradientColors(bgMatch[1]);
1313
- if (stops.length > 0) return stops;
2556
+ const parsed = parseGradientColors(bgMatch[1]);
2557
+ if (parsed.length > 0) stops = parsed;
1314
2558
  }
1315
2559
  }
2560
+ if (stops) return compositeGradientStops(stops, current, win, customPropMap);
1316
2561
  current = current.parentElement;
1317
2562
  }
1318
2563
  return null;
1319
2564
  }
1320
2565
 
2566
+ // A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent
2567
+ // glow) paints over whatever surface sits beneath the gradient — the browser
2568
+ // composites it, so its effective color is far closer to the base than to the
2569
+ // full-opacity accent. Treating the stop as opaque flags every text child of a
2570
+ // softly-glowing section as low-contrast (issue #409 Case B). Composite each
2571
+ // alpha stop over the resolved surface beneath the gradient element. When that
2572
+ // surface isn't resolvable (another gradient above, no opaque ancestor), drop
2573
+ // the translucent stop rather than guess: a dropped stop can't manufacture a
2574
+ // false finding, and skipping beats a wrong ratio.
2575
+ function compositeGradientStops(stops, gradientEl, win, customPropMap) {
2576
+ const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99);
2577
+ if (!hasAlpha) return stops;
2578
+ const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap);
2579
+ const out = [];
2580
+ for (const s of stops) {
2581
+ const a = s.a ?? 1;
2582
+ if (a >= 0.99) { out.push(s); continue; }
2583
+ if (base) out.push(compositeColorOver(s, base));
2584
+ // else: unresolvable base — drop the translucent stop (skip, don't guess).
2585
+ }
2586
+ return out.length ? out : null;
2587
+ }
2588
+
1321
2589
  // Parse a single CSS length token to pixels. Accepts "12px", "50%", a
1322
2590
  // shorthand like "12px 4px" (uses the first value), or empty / null.
1323
2591
  // Returns the pixel value, or null when the input is unparseable.
@@ -1351,6 +2619,43 @@ function resolveBorderRadiusPx(el, style, widthPx, win) {
1351
2619
 
1352
2620
  // Browser adapters — call getComputedStyle/getBoundingClientRect on live DOM
1353
2621
 
2622
+ // Selected-state context for accent stripes. Only an actual selection
2623
+ // marker exempts the stripe as the standard active-item indicator:
2624
+ // aria-selected="true", aria-current (any non-false value), or an
2625
+ // active/current/selected class hint. Tab-strip MEMBERSHIP alone
2626
+ // ([role=tablist]/[role=tab]/.tabs ancestry, aria-selected="false")
2627
+ // deliberately does not — a chromatic stripe repeated on every tab in
2628
+ // the group, or on every menu item, is decoration, not state; the
2629
+ // selected item's own underline stays legal.
2630
+ function isTabContextElement(el) {
2631
+ if (!el) return false;
2632
+ try {
2633
+ if (el.closest?.('[aria-selected="true"], [aria-current]:not([aria-current="false"])')) return true;
2634
+ } catch { /* selector engine differences — fall through to class scan */ }
2635
+ let cur = el, depth = 0;
2636
+ while (cur && cur.nodeType === 1 && depth < 6) {
2637
+ const cls = String(cur.getAttribute?.('class') || cur.className || '');
2638
+ if (/(?:^|[\s_-])(?:active|current|selected)(?:$|[\s_-])/i.test(cls)) return true;
2639
+ cur = cur.parentElement;
2640
+ depth++;
2641
+ }
2642
+ return false;
2643
+ }
2644
+
2645
+ // Status-surface context for accent borders. On a live status/alert region
2646
+ // (role=status|alert|alertdialog|log, or aria-live=polite|assertive) a colored
2647
+ // single-edge border is the established severity-accent convention — a toast,
2648
+ // snackbar, or callout bar — not the decorative side-tab tell. The element
2649
+ // itself or a wrapping live region qualifies. This never fires from the
2650
+ // CSS-only / regex scanners, which have no role information.
2651
+ function isStatusContextElement(el) {
2652
+ if (!el) return false;
2653
+ try {
2654
+ if (el.closest?.('[role="status"], [role="alert"], [role="alertdialog"], [role="log"], [aria-live="polite"], [aria-live="assertive"]')) return true;
2655
+ } catch { /* selector engine differences — fall through */ }
2656
+ return false;
2657
+ }
2658
+
1354
2659
  function checkElementBordersDOM(el) {
1355
2660
  const tag = el.tagName.toLowerCase();
1356
2661
  if (BORDER_SAFE_TAGS.has(tag)) return [];
@@ -1363,7 +2668,105 @@ function checkElementBordersDOM(el) {
1363
2668
  widths[s] = parseFloat(style[`border${s}Width`]) || 0;
1364
2669
  colors[s] = style[`border${s}Color`] || '';
1365
2670
  }
1366
- return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0);
2671
+ const ownBg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
2672
+ return checkBorders(tag, widths, colors, parseFloat(style.borderRadius) || 0, {
2673
+ tabContext: isTabContextElement(el),
2674
+ statusContext: isStatusContextElement(el),
2675
+ badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
2676
+ });
2677
+ }
2678
+
2679
+ // Browser-side twin of scanCssTextForPseudoStripe. The text scanner reads
2680
+ // stylesheet source, so a stripe whose color only exists at runtime (an
2681
+ // inline per-card custom property, a JS-assigned var) or whose geometry
2682
+ // resolves in layout never matches it. In a real browser the pseudo-element's
2683
+ // computed style carries the actual used color and px geometry — check those
2684
+ // directly. Gates mirror the text scanner: 3-12px thick, chromatic fill,
2685
+ // spanning (nearly) the full edge; corner rounding on the host card is
2686
+ // irrelevant. Exemptions stay narrow: structural/prose tags, real selection
2687
+ // markers (isTabContextElement), and button/link affordances for the
2688
+ // horizontal variant.
2689
+ function checkElementPseudoStripeDOM(el) {
2690
+ const tag = el.tagName.toLowerCase();
2691
+ if (BORDER_SAFE_TAGS.has(tag) || tag === 'summary') return [];
2692
+ if (el.closest?.('nav, blockquote, pre')) return [];
2693
+ if (!isRenderedForBrowserRule(el)) return [];
2694
+ const rect = el.getBoundingClientRect();
2695
+ if (rect.width < 40 || rect.height < 20) return [];
2696
+ if (isTabContextElement(el)) return [];
2697
+
2698
+ const findings = [];
2699
+ for (const which of ['::before', '::after']) {
2700
+ let ps;
2701
+ try { ps = getComputedStyle(el, which); } catch { continue; }
2702
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
2703
+ if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
2704
+ if ((parseFloat(ps.opacity) || 0) <= 0.01 || ps.display === 'none') continue;
2705
+ const w = parseFloat(ps.width) || 0;
2706
+ const h = parseFloat(ps.height) || 0;
2707
+ if (!(w > 0 && h > 0)) continue;
2708
+
2709
+ // Used values: for absolutely-positioned boxes the browser resolves
2710
+ // both edge offsets after layout, so left/right (and top/bottom) are
2711
+ // real distances, never "auto".
2712
+ const left = parseFloat(ps.left);
2713
+ const right = parseFloat(ps.right);
2714
+ const top = parseFloat(ps.top);
2715
+ const bottom = parseFloat(ps.bottom);
2716
+ const hugs = (v) => Number.isFinite(v) && v >= -2 && v <= 2;
2717
+
2718
+ let edge = null;
2719
+ let thickness = null;
2720
+ // Vertical stripe: narrow box spanning (nearly) the full height of the
2721
+ // host, hugging its left or right edge. "Nearly" tolerates the floating
2722
+ // variant that backs off each end by a small inset.
2723
+ if (w >= 3 && w <= 12 && h >= rect.height - 44 && h >= rect.height * 0.5) {
2724
+ edge = hugs(left) ? 'left' : hugs(right) ? 'right' : null;
2725
+ thickness = w;
2726
+ }
2727
+ // Horizontal stripe riding the top or bottom edge. Button/link-styled
2728
+ // hosts keep their underline affordances.
2729
+ if (!edge && h >= 3 && h <= 12 && w >= rect.width - 44 && w >= rect.width * 0.5) {
2730
+ const cls = String(el.getAttribute?.('class') || el.className || '');
2731
+ if (!/(?:^|[\s_-])(?:btn|button|link)(?:$|[\s\w_-])/i.test(cls)) {
2732
+ edge = hugs(top) ? 'top' : hugs(bottom) ? 'bottom' : null;
2733
+ thickness = h;
2734
+ }
2735
+ }
2736
+ if (!edge) continue;
2737
+
2738
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2739
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
2740
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) < 30) continue;
2741
+
2742
+ findings.push({
2743
+ id: 'side-tab',
2744
+ snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
2745
+ });
2746
+ }
2747
+ return findings;
2748
+ }
2749
+
2750
+ // Full-cover surface pseudo (browser): a ::before/::after positioned
2751
+ // absolute/fixed whose box covers (nearly) the whole host and carries an
2752
+ // opaque background. That pseudo is the element's visible surface even
2753
+ // though the element's own background-color reads transparent — the nav-CTA
2754
+ // construction that otherwise escapes every own-background contrast gate.
2755
+ function readPseudoSurfaceDOM(el, rect) {
2756
+ for (const which of ['::before', '::after']) {
2757
+ let ps;
2758
+ try { ps = getComputedStyle(el, which); } catch { continue; }
2759
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
2760
+ if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
2761
+ if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
2762
+ const w = parseFloat(ps.width) || 0;
2763
+ const h = parseFloat(ps.height) || 0;
2764
+ if (w < rect.width - 4 || h < rect.height - 4) continue;
2765
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2766
+ if (!bg || (bg.a ?? 1) < 0.9) continue;
2767
+ return bg;
2768
+ }
2769
+ return null;
1367
2770
  }
1368
2771
 
1369
2772
  function checkElementColorsDOM(el) {
@@ -1376,11 +2779,24 @@ function checkElementColorsDOM(el) {
1376
2779
  const style = getComputedStyle(el);
1377
2780
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
1378
2781
  const hasDirectText = directText.trim().length > 0;
1379
- const effectiveBg = resolveBackground(el);
2782
+ let effectiveBg = resolveBackground(el);
2783
+ let ownBg = readOwnBackgroundColor(el, style);
2784
+ if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
2785
+ const pseudoSurface = readPseudoSurfaceDOM(el, rect);
2786
+ if (pseudoSurface) {
2787
+ ownBg = pseudoSurface;
2788
+ effectiveBg = pseudoSurface;
2789
+ }
2790
+ }
1380
2791
  return checkColors({
1381
2792
  tag,
1382
- textColor: parseRgb(style.color),
1383
- bgColor: readOwnBackgroundColor(el, style),
2793
+ // Chrome serializes computed colors specified in modern spaces as
2794
+ // oklch()/oklab() strings; without the parseAnyColor fallback the text
2795
+ // color comes back null and the low-contrast / gray-on-color checks
2796
+ // silently never run (the shipped miss: a nav CTA whose text color was
2797
+ // an oklch token near its own oklch background).
2798
+ textColor: parseRgb(style.color) || parseAnyColor(style.color),
2799
+ bgColor: ownBg,
1384
2800
  effectiveBg,
1385
2801
  effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
1386
2802
  fontSize: parseFloat(style.fontSize) || 16,
@@ -1441,6 +2857,21 @@ function checkElementItalicSerifDOM(el) {
1441
2857
  });
1442
2858
  }
1443
2859
 
2860
+ function domAccentDashPseudo(el) {
2861
+ for (const which of ['::before', '::after']) {
2862
+ let ps;
2863
+ try { ps = getComputedStyle(el, which); } catch { continue; }
2864
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
2865
+ const w = parseFloat(ps.width) || 0;
2866
+ const h = parseFloat(ps.height) || 0;
2867
+ if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
2868
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2869
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
2870
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
2871
+ }
2872
+ return false;
2873
+ }
2874
+
1444
2875
  function checkElementHeroEyebrowDOM(el) {
1445
2876
  const tag = el.tagName.toLowerCase();
1446
2877
  if (tag !== 'h1') return [];
@@ -1452,6 +2883,7 @@ function checkElementHeroEyebrowDOM(el) {
1452
2883
  headingTag: tag,
1453
2884
  headingText: el.textContent || '',
1454
2885
  headingFontSize: parseFloat(headStyle.fontSize) || 0,
2886
+ headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
1455
2887
  siblingTag: sibling.tagName.toLowerCase(),
1456
2888
  siblingText: sibling.textContent || '',
1457
2889
  siblingTextTransform: sibStyle.textTransform || '',
@@ -1459,6 +2891,7 @@ function checkElementHeroEyebrowDOM(el) {
1459
2891
  siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
1460
2892
  siblingFontWeight: sibStyle.fontWeight || '',
1461
2893
  siblingColor: sibStyle.color || '',
2894
+ siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
1462
2895
  });
1463
2896
  }
1464
2897
 
@@ -1523,8 +2956,10 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
1523
2956
  // detector's contrast / color checks.
1524
2957
  function oklchToRgb(L, C, H) {
1525
2958
  const hRad = (H * Math.PI) / 180;
1526
- const a = C * Math.cos(hRad);
1527
- const b = C * Math.sin(hRad);
2959
+ return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
2960
+ }
2961
+
2962
+ function oklabToRgb(L, a, b) {
1528
2963
  const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
1529
2964
  const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
1530
2965
  const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
@@ -1544,13 +2979,178 @@ function oklchToRgb(L, C, H) {
1544
2979
  };
1545
2980
  }
1546
2981
 
1547
- // Extended color parser: rgb/rgba/hex/oklch. Returns null on no match.
1548
- // Use this when the input might be any CSS color form; use plain parseRgb
1549
- // when you only expect computed rgb() values from real browsers.
2982
+ function hslToRgb(h, s, l) {
2983
+ h = ((h % 360) + 360) % 360;
2984
+ const c = (1 - Math.abs(2 * l - 1)) * s;
2985
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
2986
+ const m0 = l - c / 2;
2987
+ const [r, g, b] =
2988
+ h < 60 ? [c, x, 0] :
2989
+ h < 120 ? [x, c, 0] :
2990
+ h < 180 ? [0, c, x] :
2991
+ h < 240 ? [0, x, c] :
2992
+ h < 300 ? [x, 0, c] : [c, 0, x];
2993
+ return {
2994
+ r: Math.round((r + m0) * 255),
2995
+ g: Math.round((g + m0) * 255),
2996
+ b: Math.round((b + m0) * 255),
2997
+ a: 1,
2998
+ };
2999
+ }
3000
+
3001
+ function hwbToRgb(h, w, bl) {
3002
+ if (w + bl >= 1) {
3003
+ const g = Math.round((w / (w + bl)) * 255);
3004
+ return { r: g, g, b: g, a: 1 };
3005
+ }
3006
+ const base = hslToRgb(h, 1, 0.5);
3007
+ const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
3008
+ return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
3009
+ }
3010
+
3011
+ // Common CSS named colors — the handful that actually show up in generated
3012
+ // UIs, not the full 148-name spec list. Includes the achromatic names so a
3013
+ // named gray parses (and correctly reads as no-chroma) instead of being
3014
+ // treated as an unknown color.
3015
+ const CSS_NAMED_COLORS = {
3016
+ black: { r: 0, g: 0, b: 0 },
3017
+ white: { r: 255, g: 255, b: 255 },
3018
+ gray: { r: 128, g: 128, b: 128 },
3019
+ grey: { r: 128, g: 128, b: 128 },
3020
+ silver: { r: 192, g: 192, b: 192 },
3021
+ dimgray: { r: 105, g: 105, b: 105 },
3022
+ darkgray: { r: 169, g: 169, b: 169 },
3023
+ lightgray: { r: 211, g: 211, b: 211 },
3024
+ gainsboro: { r: 220, g: 220, b: 220 },
3025
+ whitesmoke: { r: 245, g: 245, b: 245 },
3026
+ red: { r: 255, g: 0, b: 0 },
3027
+ crimson: { r: 220, g: 20, b: 60 },
3028
+ tomato: { r: 255, g: 99, b: 71 },
3029
+ coral: { r: 255, g: 127, b: 80 },
3030
+ salmon: { r: 250, g: 128, b: 114 },
3031
+ orange: { r: 255, g: 165, b: 0 },
3032
+ gold: { r: 255, g: 215, b: 0 },
3033
+ yellow: { r: 255, g: 255, b: 0 },
3034
+ olive: { r: 128, g: 128, b: 0 },
3035
+ lime: { r: 0, g: 255, b: 0 },
3036
+ green: { r: 0, g: 128, b: 0 },
3037
+ teal: { r: 0, g: 128, b: 128 },
3038
+ turquoise: { r: 64, g: 224, b: 208 },
3039
+ cyan: { r: 0, g: 255, b: 255 },
3040
+ aqua: { r: 0, g: 255, b: 255 },
3041
+ skyblue: { r: 135, g: 206, b: 235 },
3042
+ dodgerblue: { r: 30, g: 144, b: 255 },
3043
+ blue: { r: 0, g: 0, b: 255 },
3044
+ navy: { r: 0, g: 0, b: 128 },
3045
+ indigo: { r: 75, g: 0, b: 130 },
3046
+ rebeccapurple: { r: 102, g: 51, b: 153 },
3047
+ purple: { r: 128, g: 0, b: 128 },
3048
+ violet: { r: 238, g: 130, b: 238 },
3049
+ orchid: { r: 218, g: 112, b: 214 },
3050
+ magenta: { r: 255, g: 0, b: 255 },
3051
+ fuchsia: { r: 255, g: 0, b: 255 },
3052
+ hotpink: { r: 255, g: 105, b: 180 },
3053
+ pink: { r: 255, g: 192, b: 203 },
3054
+ maroon: { r: 128, g: 0, b: 0 },
3055
+ };
3056
+
3057
+ // Split a string on top-level commas (ignoring commas nested in parens).
3058
+ function splitTopLevelCommas(str) {
3059
+ const parts = [];
3060
+ let depth = 0, start = 0;
3061
+ for (let i = 0; i < str.length; i++) {
3062
+ const ch = str[i];
3063
+ if (ch === '(') depth++;
3064
+ else if (ch === ')') depth = Math.max(0, depth - 1);
3065
+ else if (ch === ',' && depth === 0) {
3066
+ parts.push(str.slice(start, i).trim());
3067
+ start = i + 1;
3068
+ }
3069
+ }
3070
+ const tail = str.slice(start).trim();
3071
+ if (tail) parts.push(tail);
3072
+ return parts;
3073
+ }
3074
+
3075
+ // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
3076
+ // the expression can't be resolved (unresolved var(), unknown colors).
3077
+ //
3078
+ // Mixing is done with premultiplied alpha in sRGB regardless of the
3079
+ // declared interpolation space. That is exact for the dominant generated-UI
3080
+ // pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
3081
+ // result is simply <color> at alpha N% in ANY rectangular space, and a
3082
+ // close-enough approximation for opaque-opaque mixes (the detector only
3083
+ // consumes these values for contrast/chroma thresholds, not for display).
3084
+ function parseColorMix(str) {
3085
+ const m = String(str).trim().match(/^color-mix\(/i);
3086
+ if (!m) return null;
3087
+ // Balanced-paren capture of the arguments.
3088
+ let depth = 0, end = -1;
3089
+ const open = str.indexOf('(');
3090
+ for (let i = open; i < str.length; i++) {
3091
+ if (str[i] === '(') depth++;
3092
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
3093
+ }
3094
+ if (end < 0) return null;
3095
+ const args = splitTopLevelCommas(str.slice(open + 1, end));
3096
+ if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
3097
+
3098
+ const parseComponent = (component) => {
3099
+ // Percentage may lead or trail the color per spec.
3100
+ let pct = null;
3101
+ let colorStr = component;
3102
+ const trail = component.match(/\s+([\d.]+)%$/);
3103
+ const lead = component.match(/^([\d.]+)%\s+/);
3104
+ if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
3105
+ else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
3106
+ let color;
3107
+ if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
3108
+ else color = parseAnyColor(colorStr);
3109
+ if (!color) return null;
3110
+ return { color, pct };
3111
+ };
3112
+
3113
+ const c1 = parseComponent(args[1]);
3114
+ const c2 = parseComponent(args[2]);
3115
+ if (!c1 || !c2) return null;
3116
+ let p1 = c1.pct, p2 = c2.pct;
3117
+ if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
3118
+ else if (p1 == null) p1 = 100 - p2;
3119
+ else if (p2 == null) p2 = 100 - p1;
3120
+ const sum = p1 + p2;
3121
+ if (sum <= 0) return null;
3122
+ // Per spec: weights normalize to sum; when sum < 100 the result alpha is
3123
+ // additionally scaled by sum/100.
3124
+ const w1 = p1 / sum, w2 = p2 / sum;
3125
+ const alphaScale = sum < 100 ? sum / 100 : 1;
3126
+ const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
3127
+ const a = (a1 * w1 + a2 * w2) * alphaScale;
3128
+ if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
3129
+ const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
3130
+ return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
3131
+ }
3132
+
3133
+ // Composite a translucent color over an opaque(ish) base (simple
3134
+ // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
3135
+ function compositeColorOver(top, base) {
3136
+ const a = top.a ?? 1;
3137
+ return {
3138
+ r: Math.round(top.r * a + base.r * (1 - a)),
3139
+ g: Math.round(top.g * a + base.g * (1 - a)),
3140
+ b: Math.round(top.b * a + base.b * (1 - a)),
3141
+ a: 1,
3142
+ };
3143
+ }
3144
+
3145
+ // Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
3146
+ // named colors. Returns null on no match. Use this when the input might be
3147
+ // any CSS color form; use plain parseRgb when you only expect computed rgb()
3148
+ // values from real browsers.
1550
3149
  function parseAnyColor(s) {
1551
3150
  if (!s || typeof s !== 'string') return null;
1552
3151
  const str = s.trim();
1553
3152
  if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
3153
+ if (/^color-mix\(/i.test(str)) return parseColorMix(str);
1554
3154
  let m;
1555
3155
  m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
1556
3156
  if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
@@ -1588,6 +3188,41 @@ function parseAnyColor(s) {
1588
3188
  }
1589
3189
  return rgb;
1590
3190
  }
3191
+ // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
3192
+ m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3193
+ if (m) {
3194
+ const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
3195
+ const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
3196
+ const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
3197
+ const rgb = oklabToRgb(L, a, b);
3198
+ if (m[7] !== undefined) {
3199
+ const alpha = parseFloat(m[7]);
3200
+ rgb.a = m[8] === '%' ? alpha / 100 : alpha;
3201
+ }
3202
+ return rgb;
3203
+ }
3204
+ // HSL/HSLA — comma or space syntax, optional deg on hue.
3205
+ m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
3206
+ if (m) {
3207
+ const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3208
+ if (m[4] !== undefined) {
3209
+ const alpha = parseFloat(m[4]);
3210
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3211
+ }
3212
+ return rgb;
3213
+ }
3214
+ // HWB — hue whiteness% blackness%.
3215
+ m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3216
+ if (m) {
3217
+ const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3218
+ if (m[4] !== undefined) {
3219
+ const alpha = parseFloat(m[4]);
3220
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3221
+ }
3222
+ return rgb;
3223
+ }
3224
+ const named = CSS_NAMED_COLORS[str.toLowerCase()];
3225
+ if (named) return { ...named, a: 1 };
1591
3226
  return null;
1592
3227
  }
1593
3228
 
@@ -1600,7 +3235,7 @@ function parseColorResolved(str, customPropMap) {
1600
3235
  return parseAnyColor(resolved);
1601
3236
  }
1602
3237
 
1603
- const REPEATED_KICKER_SKIP_SELECTOR = [
3238
+ const KICKER_SKIP_SELECTOR = [
1604
3239
  'nav',
1605
3240
  'form',
1606
3241
  'table',
@@ -1619,7 +3254,7 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
1619
3254
  '[data-impeccable-allow-kickers]',
1620
3255
  ].join(',');
1621
3256
 
1622
- const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
3257
+ const KICKER_CARD_CONTEXT_SELECTOR = [
1623
3258
  'article',
1624
3259
  'button',
1625
3260
  'a',
@@ -1637,23 +3272,32 @@ function cleanInlineText(el) {
1637
3272
  .trim();
1638
3273
  }
1639
3274
 
1640
- function isRepeatedKickerCardContext(heading, kicker) {
1641
- const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
3275
+ function isKickerCardContext(heading, kicker) {
3276
+ const item = heading.closest?.(KICKER_CARD_CONTEXT_SELECTOR);
1642
3277
  return Boolean(item && (!item.contains || item.contains(kicker)));
1643
3278
  }
1644
3279
 
1645
- function isRepeatedKickerCandidate(opts) {
3280
+ // Meta lines above headlines join category and date (or path crumbs) with
3281
+ // separator glyphs, or carry a year. A kicker is one short phrase; metadata
3282
+ // keeps its markers.
3283
+ const KICKER_META_TEXT_RE = /[·•|]|\s[\/›»>]\s|\b(19|20)\d{2}\b/;
3284
+ // Legal and document numbering: "Section 4.2", "Article IX", "§ 12.3",
3285
+ // dotted decimal outlines. The label identifies the clause, so it stays.
3286
+ 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;
3287
+
3288
+ function isKickerCandidate(opts) {
1646
3289
  const {
1647
- headingTag,
3290
+ headingLevel,
1648
3291
  headingText,
1649
3292
  headingFontSize,
1650
3293
  kickerTag,
1651
3294
  kickerText,
1652
3295
  kickerTextTransform,
3296
+ kickerFontVariant,
1653
3297
  kickerFontSize,
1654
3298
  kickerLetterSpacing,
1655
3299
  } = opts;
1656
- if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
3300
+ if (!headingLevel || headingLevel > 4) return false;
1657
3301
  if (!headingText || headingText.length < 3) return false;
1658
3302
  if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
1659
3303
  if (!(headingFontSize >= 20)) return false;
@@ -1661,47 +3305,82 @@ function isRepeatedKickerCandidate(opts) {
1661
3305
  if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
1662
3306
  if (!kickerText || kickerText.length < 2 || kickerText.length > 34) return false;
1663
3307
  if (/^step\s*\d+/i.test(kickerText) || /^\d{1,2}$/.test(kickerText)) return false;
3308
+ if (KICKER_META_TEXT_RE.test(kickerText)) return false;
3309
+ if (KICKER_DOC_NUMBERING_RE.test(kickerText)) return false;
1664
3310
 
3311
+ const isSmallCaps = /small-caps/.test(kickerFontVariant || '');
1665
3312
  const isUppercased = kickerTextTransform === 'uppercase'
1666
- || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText));
3313
+ || (/[A-Z]/.test(kickerText) && !/[a-z]/.test(kickerText))
3314
+ || isSmallCaps;
1667
3315
  if (!isUppercased) return false;
1668
3316
  if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
1669
- const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
3317
+ // Proportional only, no absolute floor: the wild's most common recipe is
3318
+ // 0.08em at a sub-13px size, which computes to under 1px and sailed past
3319
+ // the old Math.max(1, ...) floor (observed live: a page whose kickers were
3320
+ // literally class="kicker" produced zero findings).
3321
+ const minTrackedSpacing = kickerFontSize * 0.06;
1670
3322
  if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
1671
3323
  return true;
1672
3324
  }
1673
3325
 
1674
- function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpacing) {
3326
+ // Resolve a heading level for the anchor element: 1-4 for h1-h4, aria-level
3327
+ // (default 2) for role="heading" elements, 0 otherwise.
3328
+ function kickerHeadingLevel(heading) {
3329
+ const tag = heading.tagName.toLowerCase();
3330
+ const byTag = /^h([1-6])$/.exec(tag);
3331
+ if (byTag) return parseInt(byTag[1], 10);
3332
+ const role = heading.getAttribute?.('role') || '';
3333
+ if (role.toLowerCase() !== 'heading') return 0;
3334
+ const ariaLevel = parseInt(heading.getAttribute?.('aria-level') || '', 10);
3335
+ return Number.isFinite(ariaLevel) && ariaLevel >= 1 ? ariaLevel : 2;
3336
+ }
3337
+
3338
+ function collectKickerCandidates(doc, getStyle, resolveLetterSpacing) {
1675
3339
  const candidates = [];
1676
- for (const heading of doc.querySelectorAll('h2, h3, h4')) {
1677
- if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
3340
+ for (const heading of doc.querySelectorAll('h1, h2, h3, h4, [role="heading"]')) {
3341
+ const headingLevel = kickerHeadingLevel(heading);
3342
+ if (!headingLevel || headingLevel > 4) continue;
3343
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
3344
+ // Application contexts (tab panels, dialogs) use compact context labels
3345
+ // above headings to describe state, not to decorate. Same carve-out the
3346
+ // hero-eyebrow rule makes.
3347
+ if (heading.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog')) continue;
1678
3348
  const kicker = heading.previousElementSibling;
1679
- if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
1680
- if (isRepeatedKickerCardContext(heading, kicker)) continue;
3349
+ if (!kicker || kicker.closest?.(KICKER_SKIP_SELECTOR)) continue;
3350
+ if (isKickerCardContext(heading, kicker)) continue;
1681
3351
 
1682
3352
  const headingStyle = getStyle(heading);
1683
3353
  const kickerStyle = getStyle(kicker);
3354
+ const headingTag = heading.tagName.toLowerCase();
1684
3355
  const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
1685
3356
  const kickerText = cleanInlineText(kicker) || (kicker.textContent || '').replace(/\s+/g, ' ').trim();
1686
3357
  const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
1687
3358
  const kickerFontSize = resolveLetterSpacing(kickerStyle.fontSize || '', 16) || parseFloat(kickerStyle.fontSize) || 0;
1688
3359
  const kickerLetterSpacing = resolveLetterSpacing(kickerStyle.letterSpacing || '', kickerFontSize);
1689
3360
 
1690
- if (!isRepeatedKickerCandidate({
1691
- headingTag: heading.tagName.toLowerCase(),
3361
+ if (!isKickerCandidate({
3362
+ headingLevel,
1692
3363
  headingText,
1693
3364
  headingFontSize,
1694
3365
  kickerTag: kicker.tagName.toLowerCase(),
1695
3366
  kickerText,
1696
3367
  kickerTextTransform: kickerStyle.textTransform || '',
3368
+ kickerFontVariant: `${kickerStyle.fontVariant || ''} ${kickerStyle.fontVariantCaps || ''}`,
1697
3369
  kickerFontSize,
1698
3370
  kickerLetterSpacing,
1699
3371
  })) {
1700
3372
  continue;
1701
3373
  }
1702
3374
 
3375
+ // A tracked-caps eyebrow above a hero-scale h1 belongs to
3376
+ // hero-eyebrow-chip (which also covers the accent-bold and dash-prefix
3377
+ // stylings there). Stand down so one element gets one finding.
3378
+ if (headingTag === 'h1' && headingFontSize >= 48 && kickerLetterSpacing >= 1.6) {
3379
+ continue;
3380
+ }
3381
+
1703
3382
  candidates.push({
1704
- headingTag: heading.tagName.toLowerCase(),
3383
+ headingTag,
1705
3384
  headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
1706
3385
  kickerText: kickerText.slice(0, 40),
1707
3386
  });
@@ -1709,13 +3388,177 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
1709
3388
  return candidates;
1710
3389
  }
1711
3390
 
1712
- function checkRepeatedSectionKickersDOM() {
1713
- const candidates = collectRepeatedSectionKickerCandidates(
3391
+ function checkKickerAboveHeadingDOM() {
3392
+ const candidates = collectKickerCandidates(
1714
3393
  document,
1715
3394
  (el) => getComputedStyle(el),
1716
3395
  (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
1717
3396
  );
1718
- return checkRepeatedSectionKickers({ candidates });
3397
+ return checkKickerAboveHeading({ candidates });
3398
+ }
3399
+
3400
+ // ── Numbered section labels ─────────────────────────────────────────────────
3401
+ // Sibling of the kicker-above-heading rule: instead of a tracked uppercase word,
3402
+ // the section scaffold is a tiny numeric index riding beside each section
3403
+ // heading — bare and zero-padded, or an index joined to a short micro-label
3404
+ // by a separator glyph. The kicker rule deliberately excludes bare 1-2 digit
3405
+ // labels; this rule owns that shape.
3406
+
3407
+ const NUMBERED_LABEL_TAGS = new Set(['span', 'p', 'div', 'small', 'em', 'strong', 'b']);
3408
+
3409
+ // Returns { index, text } when the trimmed text reads as a section index
3410
+ // label, else null. Two accepted shapes: a zero-padded/two-digit bare index,
3411
+ // or a 1-2 digit index followed by a non-word separator and a short label.
3412
+ function parseNumberedLabelText(rawText) {
3413
+ const text = (rawText || '').replace(/\s+/g, ' ').trim();
3414
+ if (!text || text.length > 40) return null;
3415
+ let m = /^(\d{2})$/.exec(text);
3416
+ if (!m) m = /^(\d{1,2})\s*[^\w\s]\s*\S/.exec(text);
3417
+ if (!m) return null;
3418
+ const index = parseInt(m[1], 10);
3419
+ if (!Number.isFinite(index) || index > 40) return null;
3420
+ return { index, text };
3421
+ }
3422
+
3423
+ function isNumberedSectionLabelCandidate(opts) {
3424
+ const {
3425
+ headingTag, headingText, headingFontSize,
3426
+ labelTag, labelIndex, labelText,
3427
+ labelFontSize, labelLetterSpacing, labelFontWeight,
3428
+ labelFontFamily, labelTextTransform, labelColor,
3429
+ } = opts;
3430
+ if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
3431
+ if (!headingText || headingText.length < 3) return false;
3432
+ if (!labelTag || !NUMBERED_LABEL_TAGS.has(labelTag)) return false;
3433
+ if (labelIndex == null || !labelText) return false;
3434
+ // Tiny rendered size is the tell — a display-scale section number is a
3435
+ // different (deliberate) device and stays legal.
3436
+ if (!(labelFontSize > 0 && labelFontSize <= 13)) return false;
3437
+ // The heading must be visibly larger where we can resolve its size.
3438
+ // clamp()/var() sizes come back unparseable (0) in the static engine —
3439
+ // the remaining gates carry the check there.
3440
+ if (headingFontSize > 0 && headingFontSize < labelFontSize * 1.3) return false;
3441
+ // Deliberate micro-label styling separates the scaffold from incidental
3442
+ // small text: mono face, bold weight, tracking, uppercase, or accent color.
3443
+ const weight = Number(labelFontWeight) || 400;
3444
+ return /mono/i.test(labelFontFamily || '')
3445
+ || weight >= 600
3446
+ || (labelLetterSpacing || 0) >= 0.5
3447
+ || (labelTextTransform || '') === 'uppercase'
3448
+ || isAccentColor(labelColor || '');
3449
+ }
3450
+
3451
+ function collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing) {
3452
+ const candidates = [];
3453
+ const seenLabels = new Set();
3454
+ for (const heading of doc.querySelectorAll('h2, h3, h4')) {
3455
+ if (heading.closest?.(KICKER_SKIP_SELECTOR)) continue;
3456
+ // The index sits either directly before the heading, or before the
3457
+ // wrapper the heading leads (label | <div><h2>…</h2>…</div>).
3458
+ let label = heading.previousElementSibling;
3459
+ if (!label) {
3460
+ const parent = heading.parentElement;
3461
+ const firstChild = parent?.children?.[0];
3462
+ if (firstChild === heading) label = parent.previousElementSibling;
3463
+ }
3464
+ if (!label || seenLabels.has(label)) continue;
3465
+ if (label.closest?.(KICKER_SKIP_SELECTOR)) continue;
3466
+ if (HEADING_TAGS.has(label.tagName.toLowerCase())) continue;
3467
+ if (isKickerCardContext(heading, label)) continue;
3468
+
3469
+ const labelText = cleanInlineText(label) || (label.textContent || '').replace(/\s+/g, ' ').trim();
3470
+ const parsed = parseNumberedLabelText(labelText);
3471
+ if (!parsed) continue;
3472
+
3473
+ const headingStyle = getStyle(heading);
3474
+ const labelStyle = getStyle(label);
3475
+ const headingText = (heading.textContent || '').replace(/\s+/g, ' ').trim();
3476
+ const headingFontSize = resolveLetterSpacing(headingStyle.fontSize || '', 16) || parseFloat(headingStyle.fontSize) || 0;
3477
+ const labelFontSize = resolveLetterSpacing(labelStyle.fontSize || '', 16) || parseFloat(labelStyle.fontSize) || 0;
3478
+
3479
+ if (!isNumberedSectionLabelCandidate({
3480
+ headingTag: heading.tagName.toLowerCase(),
3481
+ headingText,
3482
+ headingFontSize,
3483
+ labelTag: label.tagName.toLowerCase(),
3484
+ labelIndex: parsed.index,
3485
+ labelText: parsed.text,
3486
+ labelFontSize,
3487
+ labelLetterSpacing: resolveLetterSpacing(labelStyle.letterSpacing || '', labelFontSize),
3488
+ labelFontWeight: labelStyle.fontWeight || '',
3489
+ labelFontFamily: labelStyle.fontFamily || '',
3490
+ labelTextTransform: labelStyle.textTransform || '',
3491
+ labelColor: labelStyle.color || '',
3492
+ })) {
3493
+ continue;
3494
+ }
3495
+
3496
+ seenLabels.add(label);
3497
+ candidates.push({
3498
+ index: parsed.index,
3499
+ labelText: parsed.text.slice(0, 24),
3500
+ headingTag: heading.tagName.toLowerCase(),
3501
+ headingText: headingText.replace(/^"|"$/g, '').slice(0, 60),
3502
+ });
3503
+ }
3504
+ return candidates;
3505
+ }
3506
+
3507
+ function checkNumberedSectionLabels(opts) {
3508
+ const { candidates, minCount = 2 } = opts;
3509
+ if (!Array.isArray(candidates) || candidates.length < minCount) return [];
3510
+ // A repeated identical number is some other device; the scaffold counts up.
3511
+ const distinctIndices = new Set(candidates.map(c => c.index));
3512
+ if (distinctIndices.size < 2) return [];
3513
+ return candidates.map(candidate => ({
3514
+ id: 'numbered-section-labels',
3515
+ snippet: `tiny numbered label "${candidate.labelText}" beside ${candidate.headingTag} "${candidate.headingText}" (${candidates.length} on page)`,
3516
+ }));
3517
+ }
3518
+
3519
+ function checkNumberedSectionLabelsFromDoc(doc, win) {
3520
+ const candidates = collectNumberedSectionLabelCandidates(
3521
+ doc,
3522
+ (el) => win.getComputedStyle(el),
3523
+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
3524
+ );
3525
+ return checkNumberedSectionLabels({ candidates });
3526
+ }
3527
+
3528
+ function checkNumberedSectionLabelsDOM() {
3529
+ const candidates = collectNumberedSectionLabelCandidates(
3530
+ document,
3531
+ (el) => getComputedStyle(el),
3532
+ (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
3533
+ );
3534
+ return checkNumberedSectionLabels({ candidates });
3535
+ }
3536
+
3537
+ // Em-dash overuse (ADVISORY) — pure logic shared by the browser DOM check.
3538
+ // Mirrors the regex/static-HTML analyzer in engines/regex/detect-text.mjs:
3539
+ // two gates (absolute floor + density) so a long article using a few dashes is
3540
+ // left alone while a short, dash-per-clause page is flagged. Operates on
3541
+ // already-rendered text, so no HTML-entity decoding is needed (the browser has
3542
+ // resolved `&mdash;` to the literal glyph). Exported for jsdom unit tests.
3543
+ function checkEmDashOveruse(text) {
3544
+ const body = typeof text === 'string' ? text.replace(/\s+/g, ' ') : '';
3545
+ let count = 0;
3546
+ const re = /[—]|--(?=\S)/g;
3547
+ while (re.exec(body) !== null) count++;
3548
+ if (count < EM_DASH_FLOOR) return [];
3549
+ if (body.length > count * EM_DASH_CHARS_PER_DASH) return [];
3550
+ return [{ id: 'em-dash-overuse', snippet: `${count} em-dashes in body text` }];
3551
+ }
3552
+
3553
+ function checkEmDashOveruseDOM() {
3554
+ const body = document.body;
3555
+ if (!body) return [];
3556
+ // innerText reflects rendered, visible text; fall back to textContent for
3557
+ // engines (jsdom) that don't compute innerText.
3558
+ const text = typeof body.innerText === 'string' && body.innerText
3559
+ ? body.innerText
3560
+ : (body.textContent || '');
3561
+ return checkEmDashOveruse(text);
1719
3562
  }
1720
3563
 
1721
3564
  function checkElementMotionDOM(el) {
@@ -1734,7 +3577,14 @@ function checkElementMotionDOM(el) {
1734
3577
  function checkElementGlowDOM(el) {
1735
3578
  const tag = el.tagName.toLowerCase();
1736
3579
  const style = getComputedStyle(el);
1737
- if (!style.boxShadow || style.boxShadow === 'none') return [];
3580
+ const boxShadow = style.boxShadow && style.boxShadow !== 'none' ? style.boxShadow : '';
3581
+ // text-shadow inherits: only check the element that introduces it, so one
3582
+ // declaration doesn't produce a finding on every descendant.
3583
+ let textShadow = style.textShadow && style.textShadow !== 'none' ? style.textShadow : '';
3584
+ if (textShadow && el.parentElement && getComputedStyle(el.parentElement).textShadow === textShadow) {
3585
+ textShadow = '';
3586
+ }
3587
+ if (!boxShadow && !textShadow) return [];
1738
3588
  // Use parent's background — glow radiates outward, so the surrounding context matters
1739
3589
  // If resolveBackground returns null (gradient), try to infer from the gradient colors
1740
3590
  let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
@@ -1757,7 +3607,7 @@ function checkElementGlowDOM(el) {
1757
3607
  cur = cur.parentElement;
1758
3608
  }
1759
3609
  }
1760
- return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg: parentBg });
3610
+ return checkGlow({ tag, boxShadow, textShadow, effectiveBg: parentBg });
1761
3611
  }
1762
3612
 
1763
3613
  function checkElementAIPaletteDOM(el) {
@@ -1814,7 +3664,132 @@ function checkElementAIPaletteDOM(el) {
1814
3664
  }
1815
3665
  }
1816
3666
 
1817
- return findings;
3667
+ return findings;
3668
+ }
3669
+
3670
+ // ─── Decorative radial spotlight glow ───────────────────────────────────────
3671
+ // A soft, low-opacity chromatic radial-gradient fading to transparent, painted
3672
+ // as a decorative wash behind a hero or section. The translucent sibling of the
3673
+ // `radial-halo` tell: `radial-halo` requires a saturated, near-opaque center on
3674
+ // a dark page; this catches the low-alpha "spotlight" the halo gate lets slip
3675
+ // (e.g. `radial-gradient(circle at 52% 38%, rgba(80,111,255,0.26),
3676
+ // transparent 44%)`). The two alpha bands are disjoint, so they never
3677
+ // double-report the same declaration.
3678
+ const SPOTLIGHT_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}\b|\btransparent\b/i;
3679
+
3680
+ // Parse the FIRST non-repeating radial-gradient in a background value into its
3681
+ // ordered color stops. Each stop is { color: {r,g,b,a} | null, transparent }.
3682
+ // Returns null when there is no plain radial-gradient to read.
3683
+ function parseRadialGradientStops(value) {
3684
+ if (!value || !/radial-gradient/i.test(value)) return null;
3685
+ const gradRe = /(repeating-)?radial-gradient\(/gi;
3686
+ let g;
3687
+ while ((g = gradRe.exec(value)) !== null) {
3688
+ if (g[1]) continue; // repeating-* is a pattern, not a spotlight
3689
+ let depth = 0, end = -1;
3690
+ const open = value.indexOf('(', g.index);
3691
+ for (let i = open; i < value.length; i++) {
3692
+ if (value[i] === '(') depth++;
3693
+ else if (value[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
3694
+ }
3695
+ if (end < 0) return null;
3696
+ const args = splitTopLevelCommas(value.slice(open + 1, end));
3697
+ // The optional prelude (shape / size / `at <pos>`) carries no color token.
3698
+ const stopArgs = args.filter(a => SPOTLIGHT_COLOR_TOKEN_RE.test(a));
3699
+ if (stopArgs.length < 2) return null;
3700
+ return stopArgs.map(a => {
3701
+ const tok = a.match(SPOTLIGHT_COLOR_TOKEN_RE);
3702
+ if (!tok) return { color: null, transparent: false };
3703
+ if (/^transparent$/i.test(tok[0])) return { color: null, transparent: true };
3704
+ const color = parseAnyColor(tok[0]);
3705
+ return { color, transparent: !!color && (color.a ?? 1) <= 0.05 };
3706
+ });
3707
+ }
3708
+ return null;
3709
+ }
3710
+
3711
+ // Pure gate. `label` is a stable identifier the fixture test keys on.
3712
+ function checkRadialSpotlight({ gradientValue, width, height, label }) {
3713
+ const stops = parseRadialGradientStops(gradientValue);
3714
+ if (!stops || stops.length < 2) return [];
3715
+
3716
+ // Must fade OUT: the last stop is transparent / near-zero alpha. A gradient
3717
+ // between two visible surfaces is a real background, not a floating glow.
3718
+ const last = stops[stops.length - 1];
3719
+ const lastAlpha = last.transparent ? 0 : (last.color ? (last.color.a ?? 1) : 1);
3720
+ if (lastAlpha > 0.05) return [];
3721
+
3722
+ // The visible (non-transparent, parseable) color stops.
3723
+ const colored = stops.filter(s => !s.transparent && s.color && (s.color.a ?? 1) > 0.05);
3724
+ if (colored.length === 0) return [];
3725
+ // One soft glow, not a multi-color composition: at most two visible stops.
3726
+ if (colored.length > 2) return [];
3727
+ // Every visible stop must be LOW opacity. Any opaque stop means a real fill
3728
+ // or a saturated halo (`radial-halo`'s job), not this translucent spotlight.
3729
+ if (colored.some(s => (s.color.a ?? 1) >= 0.45)) return [];
3730
+ // At least one visible stop must be chromatic. A neutral (grayscale)
3731
+ // near-black / near-white vignette is a legitimate lighting move, exempt.
3732
+ const chromatic = colored.find(s => hasChroma(s.color, 24));
3733
+ if (!chromatic) return [];
3734
+
3735
+ // Decorative-scale gate. Badges, avatars, and actual small "lights" are
3736
+ // exempt; a spotlight glow only reads as slop when it washes a large surface.
3737
+ if (!(width >= 240 && height >= 160)) return [];
3738
+
3739
+ const alpha = (chromatic.color.a ?? 1).toFixed(2);
3740
+ const name = label || 'section';
3741
+ return [{
3742
+ id: 'radial-spotlight-glow',
3743
+ snippet: `radial-gradient spotlight glow "${name}" (${colorToHex(chromatic.color)} a${alpha} → transparent) on ${Math.round(width)}x${Math.round(height)} surface`,
3744
+ }];
3745
+ }
3746
+
3747
+ // Read the raw radial-gradient source off an element's computed style, with a
3748
+ // fallback to the `background` shorthand and the inline style attribute for
3749
+ // engines that don't decompose the shorthand into backgroundImage.
3750
+ function elementGradientValue(style, el) {
3751
+ const bgImage = style.backgroundImage && style.backgroundImage !== 'none' ? style.backgroundImage : '';
3752
+ if (/radial-gradient/i.test(bgImage)) return bgImage;
3753
+ const bg = style.background || '';
3754
+ if (/radial-gradient/i.test(bg)) return bg;
3755
+ const rawStyle = el?.getAttribute?.('style') || '';
3756
+ const m = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
3757
+ if (m && /radial-gradient/i.test(m[1])) return m[1];
3758
+ return '';
3759
+ }
3760
+
3761
+ function spotlightLabel(el) {
3762
+ const dataName = el.getAttribute?.('data-name');
3763
+ if (dataName) return dataName;
3764
+ if (typeof el.id === 'string' && el.id) return el.id;
3765
+ const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
3766
+ if (cls) return cls;
3767
+ return el.tagName ? el.tagName.toLowerCase() : 'section';
3768
+ }
3769
+
3770
+ function checkElementRadialSpotlightDOM(el) {
3771
+ const style = getComputedStyle(el);
3772
+ const gradientValue = elementGradientValue(style, el);
3773
+ if (!gradientValue) return [];
3774
+ const rect = el.getBoundingClientRect();
3775
+ return checkRadialSpotlight({
3776
+ gradientValue,
3777
+ width: rect.width,
3778
+ height: rect.height,
3779
+ label: spotlightLabel(el),
3780
+ });
3781
+ }
3782
+
3783
+ function checkElementRadialSpotlight(el, style, tag, window) {
3784
+ const gradientValue = elementGradientValue(style, el);
3785
+ if (!gradientValue) return [];
3786
+ // Static engine does no layout — read explicit pixel dimensions from CSS.
3787
+ return checkRadialSpotlight({
3788
+ gradientValue,
3789
+ width: parseFloat(style.width) || 0,
3790
+ height: parseFloat(style.height) || 0,
3791
+ label: spotlightLabel(el),
3792
+ });
1818
3793
  }
1819
3794
 
1820
3795
  const QUALITY_TEXT_TAGS = new Set(['p', 'li', 'td', 'th', 'dd', 'blockquote', 'figcaption']);
@@ -1938,6 +3913,55 @@ function textDescendantsFlushSides(el, rect) {
1938
3913
  return flush;
1939
3914
  }
1940
3915
 
3916
+ // Screen-reader-only ("visually hidden") text is exempt from the tiny-text
3917
+ // floors: it is never rendered, so its size is irrelevant. Detect the two
3918
+ // standard idioms — a known sr-only class on the element or an ancestor, and
3919
+ // the clip / 1px-box pattern. Works in both jsdom (declared styles) and the
3920
+ // browser (computed styles).
3921
+ 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]';
3922
+ function isVisuallyHidden(el, style) {
3923
+ if ((el.matches && el.matches(SR_ONLY_SELECTOR)) || (el.closest && el.closest(SR_ONLY_SELECTOR))) return true;
3924
+ const pos = style.position || '';
3925
+ if (pos === 'absolute' || pos === 'fixed') {
3926
+ const clip = style.clip || '';
3927
+ const clipPath = style.clipPath || style.webkitClipPath || style['clip-path'] || '';
3928
+ if (/rect\(\s*0/.test(clip) || /inset\(\s*(?:50%|99|100%)/.test(clipPath)) return true;
3929
+ const w = parseFloat(style.width);
3930
+ const h = parseFloat(style.height);
3931
+ const overflow = style.overflow || '';
3932
+ if ((w === 1 || h === 1) && (overflow === 'hidden' || overflow === 'clip')) return true;
3933
+ }
3934
+ return false;
3935
+ }
3936
+
3937
+ // Elements whose text is never painted: document metadata and script/style
3938
+ // payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on
3939
+ // sites that set `html { font-size: 62.5% }` their inherited computed size is
3940
+ // 10px — so the text-size floors flag them as tiny body copy even though
3941
+ // nothing renders (issue #408: dozens of phantom "10px body text" findings on
3942
+ // every Shopify page). Exclude them, plus anything the cascade resolves to
3943
+ // display:none / visibility:hidden. The jsdom path can't lay out, so the
3944
+ // tag/attribute-based exclusions carry the weight there; the display checks are
3945
+ // computed-style reads that resolve without layout in both adapters.
3946
+ const NON_RENDERED_TAGS = new Set([
3947
+ 'script', 'style', 'title', 'noscript', 'template', 'head',
3948
+ 'meta', 'link', 'base', 'param', 'source', 'track', 'datalist',
3949
+ 'col', 'colgroup', 'map', 'area',
3950
+ ]);
3951
+ function isNonRenderedText(el, tag, style) {
3952
+ const t = (tag || '').toLowerCase();
3953
+ if (NON_RENDERED_TAGS.has(t)) return true;
3954
+ // Descendants of <head> never render even when the tag itself would
3955
+ // (some sites nest <noscript>/<template> content there).
3956
+ if (el && el.closest && el.closest('head')) return true;
3957
+ if (style) {
3958
+ if (style.display === 'none') return true;
3959
+ const vis = style.visibility;
3960
+ if (vis === 'hidden' || vis === 'collapse') return true;
3961
+ }
3962
+ return false;
3963
+ }
3964
+
1941
3965
  // Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
1942
3966
  // jsdom and the browser). Two checks (line-length, cramped-padding) gate on
1943
3967
  // element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1948,8 +3972,13 @@ function textDescendantsFlushSides(el, rect) {
1948
3972
  function checkQuality(opts) {
1949
3973
  const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
1950
3974
  const findings = [];
1951
- // Skip browser extension injected elements
1952
- const elId = el.id || '';
3975
+ // Skip browser extension injected elements. Read the id via getAttribute
3976
+ // whenever `el.id` is not a string: on a <form> (and other
3977
+ // [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
3978
+ // shadows the builtin `id` getter and returns the control element, whose
3979
+ // `.startsWith` is undefined and throws (issue #407 — every Shopify product
3980
+ // form ships an <input name="id">).
3981
+ const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
1953
3982
  if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
1954
3983
 
1955
3984
  // --- Line length too long --- (browser-only: needs rect.width)
@@ -2217,11 +4246,67 @@ function checkQuality(opts) {
2217
4246
  const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
2218
4247
  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]');
2219
4248
  const isUppercase = style.textTransform === 'uppercase';
2220
- if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
4249
+ if (!skipTags.includes(tag) && !inUIContext && !isUppercase && !isNonRenderedText(el, tag, style)) {
2221
4250
  findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
2222
4251
  }
2223
4252
  }
2224
4253
 
4254
+ // --- Undersized functional / UI text ---
4255
+ // Complements `tiny-text` above, which owns long body copy and deliberately
4256
+ // EXEMPTS the UI furniture layer (nav, footer, links, buttons, labels,
4257
+ // uppercase micro-labels). This rule targets exactly that blind spot: the
4258
+ // interactive and short content-bearing text — nav items, buttons, labels,
4259
+ // table cells, meta rows, timecodes — shipped below an 11px floor.
4260
+ //
4261
+ // The live failure it closes: a build shipped its entire furniture layer at
4262
+ // 8px, and the design hook waved it through because 8px had been added to
4263
+ // the DESIGN.md size ramp. Being on the ramp is a token argument, not a
4264
+ // legibility one, so this rule ignores the design system entirely — a value
4265
+ // on the ramp is still flagged.
4266
+ //
4267
+ // Floors: 11px for anything functional. The floor holds inside a footer;
4268
+ // only NON-interactive legal smallprint gets the softer 10px floor. Exempts
4269
+ // sup/sub, visually-hidden (sr-only) text, and code/terminal contexts.
4270
+ // Uppercase letterspaced micro-labels are still functional — not exempt.
4271
+ {
4272
+ const directText = [...el.childNodes]
4273
+ .filter(n => n.nodeType === 3)
4274
+ .map(n => n.textContent || '')
4275
+ .join('')
4276
+ .replace(/\s+/g, ' ')
4277
+ .trim();
4278
+ const dtLen = directText.length;
4279
+ // `option` renders (in native select popups) so it stays a local skip;
4280
+ // script/style/title/noscript/head-descendants and display:none /
4281
+ // visibility:hidden are handled by isNonRenderedText (shared with tiny-text).
4282
+ const UI_SKIP_TAGS = new Set(['sub', 'sup', 'option']);
4283
+ // jsdom resolves the parent chain in resolveFontSizePx, so em/rem/%-sized
4284
+ // text that computes at or above the floor never reaches here. The browser
4285
+ // adapter additionally catches values only resolvable with real layout
4286
+ // (e.g. viewport-relative units, cascade winners set in linked sheets).
4287
+ if (fontSize > 0 && fontSize < 11 && dtLen >= 2 && !UI_SKIP_TAGS.has(tag) && !isNonRenderedText(el, tag, style)) {
4288
+ 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]';
4289
+ const isExemptContext = (el.matches && el.matches(EXEMPT_CONTEXT)) || (el.closest && el.closest(EXEMPT_CONTEXT));
4290
+ if (!isExemptContext && !isVisuallyHidden(el, style)) {
4291
+ 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]';
4292
+ 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]';
4293
+ 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]';
4294
+ const isInteractive = (el.matches && el.matches(INTERACTIVE)) || (el.closest && el.closest(INTERACTIVE));
4295
+ const isFurniture = (el.matches && el.matches(FURNITURE)) || (el.closest && el.closest(FURNITURE));
4296
+ const isSmallprint = (el.matches && el.matches(SMALLPRINT)) || (el.closest && el.closest(SMALLPRINT));
4297
+ const floor = (!isInteractive && isSmallprint) ? 10 : 11;
4298
+ // Fire on functional text only: interactive, structural furniture, or
4299
+ // any short (<=20-char) run — the label / meta / timecode shape. Long
4300
+ // non-furniture body copy stays with `tiny-text`, so the two rules
4301
+ // never double-flag the same element.
4302
+ if (fontSize < floor && (isInteractive || isFurniture || dtLen <= 20)) {
4303
+ const excerpt = directText.slice(0, 40);
4304
+ findings.push({ id: 'undersized-ui-text', snippet: `${fontSize}px functional text "${excerpt}" (below ${floor}px floor)` });
4305
+ }
4306
+ }
4307
+ }
4308
+ }
4309
+
2225
4310
  // --- All-caps body text ---
2226
4311
  if (hasDirectText && textLen > 30 && style.textTransform === 'uppercase') {
2227
4312
  if (!['h1','h2','h3','h4','h5','h6'].includes(tag)) {
@@ -2314,7 +4399,7 @@ function checkElementQuality(el, style, tag, window) {
2314
4399
  return checkQuality({ el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect: null, win: window });
2315
4400
  }
2316
4401
 
2317
- function checkElementBorders(tag, style, overrides, resolvedRadius) {
4402
+ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
2318
4403
  const sides = ['Top', 'Right', 'Bottom', 'Left'];
2319
4404
  const widths = {}, colors = {};
2320
4405
  for (const s of sides) {
@@ -2341,7 +4426,12 @@ function checkElementBorders(tag, style, overrides, resolvedRadius) {
2341
4426
  const radius = resolvedRadius != null
2342
4427
  ? resolvedRadius
2343
4428
  : (parseFloat(style.borderRadius) || 0);
2344
- return checkBorders(tag, widths, colors, radius);
4429
+ const ownBg = parseAnyColor(style.backgroundColor);
4430
+ return checkBorders(tag, widths, colors, radius, {
4431
+ tabContext: isTabContextElement(el),
4432
+ statusContext: isStatusContextElement(el),
4433
+ badgeLike: !!(ownBg && (ownBg.a ?? 1) > 0.1),
4434
+ });
2345
4435
  }
2346
4436
 
2347
4437
  function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
@@ -2382,12 +4472,32 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
2382
4472
  }
2383
4473
  }
2384
4474
 
4475
+ // Own background: resolve var()/oklch() tokens through the custom-property
4476
+ // map first (mirrors the textColor path above). Without this a chip whose
4477
+ // background is `var(--sev)` reads as no-own-bg in the static engine and
4478
+ // the styled-control contrast exception never engages.
4479
+ let ownBg = (customPropMap ? parseColorResolved(style.backgroundColor, customPropMap) : null)
4480
+ || readOwnBackgroundColor(el, style);
4481
+
4482
+ // Full-cover surface pseudo (static): the cascade pass marks elements
4483
+ // whose ::before/::after paints an opaque covering surface. When the
4484
+ // element itself has no usable own background, that pseudo is the real
4485
+ // surface for contrast purposes.
4486
+ let finalEffectiveBg = effectiveBg;
4487
+ if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
4488
+ const pseudoSurface = window.getPseudoSurface(el);
4489
+ if (pseudoSurface) {
4490
+ ownBg = pseudoSurface;
4491
+ finalEffectiveBg = pseudoSurface;
4492
+ }
4493
+ }
4494
+
2385
4495
  return checkColors({
2386
4496
  tag,
2387
4497
  textColor,
2388
- bgColor: readOwnBackgroundColor(el, style),
2389
- effectiveBg,
2390
- effectiveBgStops: effectiveBg ? null : resolveGradientStops(el, window),
4498
+ bgColor: ownBg,
4499
+ effectiveBg: finalEffectiveBg,
4500
+ effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
2391
4501
  fontSize: parseFloat(style.fontSize) || 16,
2392
4502
  fontWeight: parseInt(style.fontWeight) || 400,
2393
4503
  hasDirectText,
@@ -2398,6 +4508,50 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
2398
4508
  });
2399
4509
  }
2400
4510
 
4511
+ // Static-engine adapter for hover-state contrast. Relies on the static
4512
+ // cascade's hover pass (css-cascade.mjs) exposing a per-element hover style
4513
+ // via window.getHoverStyle — present only when a :hover rule changed the
4514
+ // element's color or background-color relative to its resting state.
4515
+ function checkElementHoverContrast(el, style, tag, window) {
4516
+ if (typeof window.getHoverStyle !== 'function') return [];
4517
+ const hover = window.getHoverStyle(el);
4518
+ if (!hover) return [];
4519
+
4520
+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
4521
+ if (directText.trim().length === 0) return [];
4522
+
4523
+ const textColor = parseAnyColor(hover.color);
4524
+ if (!textColor || (textColor.a != null && textColor.a < 1)) return [];
4525
+
4526
+ const restingOwnBg = parseAnyColor(style.backgroundColor);
4527
+ const hoverOwnBg = parseAnyColor(hover.backgroundColor);
4528
+ const ownBg = hoverOwnBg || restingOwnBg;
4529
+
4530
+ // Effective hover background: the element's own hover bg composited over
4531
+ // whatever sits underneath. Bail when the surface can't be resolved to a
4532
+ // solid color — gradient ancestors are handled (as at rest) by the
4533
+ // resting-state check, not duplicated here.
4534
+ let bg = null;
4535
+ if (ownBg && ownBg.a >= 0.99) {
4536
+ bg = ownBg;
4537
+ } else {
4538
+ const under = resolveBackground(el.parentElement || el, window, null);
4539
+ if (!under) return [];
4540
+ bg = ownBg && ownBg.a > 0.1 ? compositeColorOver(ownBg, under) : under;
4541
+ }
4542
+
4543
+ return checkHoverContrast({
4544
+ tag,
4545
+ textColor,
4546
+ bg,
4547
+ ownBgAlpha: ownBg ? ownBg.a ?? 1 : null,
4548
+ fontSize: parseFloat(style.fontSize) || 16,
4549
+ fontWeight: parseInt(style.fontWeight) || 400,
4550
+ hasDirectText: true,
4551
+ isEmojiOnly: isEmojiOnlyText(directText),
4552
+ });
4553
+ }
4554
+
2401
4555
  function checkElementIconTile(el, tag, window) {
2402
4556
  if (!HEADING_TAGS.has(tag)) return [];
2403
4557
  const sibling = el.previousElementSibling;
@@ -2466,7 +4620,8 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
2466
4620
  return checkHeroEyebrow({
2467
4621
  headingTag: tag,
2468
4622
  headingText: el.textContent || '',
2469
- headingFontSize: parseFloat(headingFontSizeRaw) || 0,
4623
+ headingFontSize: resolveHeroHeadingSizePx(headingFontSizeRaw),
4624
+ headingInApplicationContext: !!el.closest?.('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
2470
4625
  siblingTag: sibling.tagName.toLowerCase(),
2471
4626
  siblingText: sibling.textContent || '',
2472
4627
  siblingTextTransform: sibStyle.textTransform || '',
@@ -2474,16 +4629,21 @@ function checkElementHeroEyebrow(el, style, tag, window, customPropMap) {
2474
4629
  siblingLetterSpacing: resolveLengthPx(letterSpacingRaw, siblingFontSize) || 0,
2475
4630
  siblingFontWeight: fontWeightRaw || '',
2476
4631
  siblingColor: colorRaw || '',
4632
+ // Static cascade marks elements matched by a ::before/::after rule
4633
+ // whose geometry is a short chromatic dash (css-cascade.mjs).
4634
+ siblingHasAccentDashPseudo: typeof window.hasAccentDashPseudo === 'function'
4635
+ ? window.hasAccentDashPseudo(sibling)
4636
+ : false,
2477
4637
  });
2478
4638
  }
2479
4639
 
2480
- function checkRepeatedSectionKickersFromDoc(doc, win) {
2481
- const candidates = collectRepeatedSectionKickerCandidates(
4640
+ function checkKickerAboveHeadingFromDoc(doc, win) {
4641
+ const candidates = collectKickerCandidates(
2482
4642
  doc,
2483
4643
  (el) => win.getComputedStyle(el),
2484
4644
  (value, fontSize) => resolveLengthPx(value, fontSize) || 0,
2485
4645
  );
2486
- return checkRepeatedSectionKickers({ candidates });
4646
+ return checkKickerAboveHeading({ candidates });
2487
4647
  }
2488
4648
 
2489
4649
  function checkElementMotion(tag, style) {
@@ -2497,8 +4657,10 @@ function checkElementMotion(tag, style) {
2497
4657
  }
2498
4658
 
2499
4659
  function checkElementGlow(tag, style, effectiveBg) {
2500
- if (!style.boxShadow || style.boxShadow === 'none') return [];
2501
- return checkGlow({ tag, boxShadow: style.boxShadow, effectiveBg });
4660
+ const boxShadow = style.boxShadow && style.boxShadow !== 'none' ? style.boxShadow : '';
4661
+ const textShadow = style.textShadow && style.textShadow !== 'none' ? style.textShadow : '';
4662
+ if (!boxShadow && !textShadow) return [];
4663
+ return checkGlow({ tag, boxShadow, textShadow, effectiveBg });
2502
4664
  }
2503
4665
 
2504
4666
  // ─── Section 6: Page-Level Checks ───────────────────────────────────────────
@@ -2540,12 +4702,6 @@ function checkTypography() {
2540
4702
  if (isBrandFontOnOwnDomain(font)) continue;
2541
4703
  findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
2542
4704
  }
2543
-
2544
- // Single-font check: only one distinct primary font across all text
2545
- if (fontUsage.size === 1) {
2546
- const only = [...fontUsage.keys()][0];
2547
- findings.push({ type: 'single-font', detail: `only font used is ${only}` });
2548
- }
2549
4705
  }
2550
4706
 
2551
4707
  const sizes = new Set();
@@ -2608,6 +4764,157 @@ function checkLayout() {
2608
4764
  return findings;
2609
4765
  }
2610
4766
 
4767
+ // Heading rhythm (browser-only): a heading binds to the content it
4768
+ // introduces, so its rendered space above must exceed its space below.
4769
+ // Margins alone can't be trusted (collapsing, flex rows, section padding),
4770
+ // so this measures actual getBoundingClientRect gaps between the heading
4771
+ // and the nearest content genuinely above / below it. Fires only when two
4772
+ // or more headings violate the principle — a single occurrence is noise.
4773
+ function checkHeadingRhythmDOM() {
4774
+ const MIN_VIOLATIONS = 2;
4775
+ const CARD_EXEMPT_HEIGHT = 200;
4776
+ const MAX_BELOW_PX = 160; // beyond this the heading isn't binding to nearby content at all
4777
+ const MIN_DEFICIT_PX = 12;
4778
+
4779
+ function isVisibleFlow(el) {
4780
+ const style = getComputedStyle(el);
4781
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
4782
+ if (parseFloat(style.opacity || '1') <= 0.05) return false;
4783
+ if (style.position === 'absolute' || style.position === 'fixed' || style.position === 'sticky') return false;
4784
+ const rect = el.getBoundingClientRect();
4785
+ return rect.width >= 1 && rect.height >= 1;
4786
+ }
4787
+
4788
+ // Edges only count when they share the heading's column — grid layouts
4789
+ // put content beside a heading, and a far-away element in another column
4790
+ // says nothing about the heading's vertical rhythm.
4791
+ function overlapsX(sr, rect) {
4792
+ return Math.min(sr.right, rect.right) - Math.max(sr.left, rect.left) >= 8;
4793
+ }
4794
+
4795
+ // Does this container draw its own top boundary (background, top border,
4796
+ // shadow)? Crossing out of such a container means the container edge is
4797
+ // the separator above the heading, not raw whitespace — exempt.
4798
+ function hasOwnTopBoundary(el) {
4799
+ const style = getComputedStyle(el);
4800
+ const bg = parseAnyColor(style.backgroundColor || '');
4801
+ if (bg && (bg.a ?? 1) > 0.05) return true;
4802
+ if ((parseFloat(style.borderTopWidth) || 0) > 0) return true;
4803
+ if (style.boxShadow && style.boxShadow !== 'none') return true;
4804
+ return false;
4805
+ }
4806
+
4807
+ // Eyebrows, kickers, and index labels sitting directly on top of a
4808
+ // heading belong to the heading's own cluster — space above is measured
4809
+ // from the top of the cluster, not from the label to the heading.
4810
+ function clusterTop(h, rect) {
4811
+ const headingFontSize = parseFloat(getComputedStyle(h).fontSize) || 16;
4812
+ let topEl = h;
4813
+ let top = rect.top;
4814
+ for (let i = 0; i < 3; i++) {
4815
+ const sib = topEl.previousElementSibling;
4816
+ if (!sib || !isVisibleFlow(sib)) break;
4817
+ const sr = sib.getBoundingClientRect();
4818
+ if (!overlapsX(sr, rect)) break;
4819
+ const gap = top - sr.bottom;
4820
+ if (gap < 0 || gap >= 28 || sr.height > 60) break;
4821
+ const text = (sib.textContent || '').trim();
4822
+ const sibFontSize = parseFloat(getComputedStyle(sib).fontSize) || 16;
4823
+ const labelLike = sibFontSize < headingFontSize * 0.75 || text.length <= 40;
4824
+ if (!labelLike || text.length > 80) break;
4825
+ topEl = sib;
4826
+ top = sr.top;
4827
+ }
4828
+ return { topEl, top };
4829
+ }
4830
+
4831
+ // Nearest content edge strictly above the heading cluster. Walks
4832
+ // previous siblings, then out through ancestors. Skips elements that
4833
+ // vertically overlap (flex-row companions, sticky rails) or sit in
4834
+ // another column. Returns null when nothing qualifies — first content
4835
+ // on the page, or the top of a visually bounded container.
4836
+ function edgeAbove(startEl, top, rect) {
4837
+ let node = startEl;
4838
+ while (node && node !== document.body) {
4839
+ let sib = node.previousElementSibling;
4840
+ while (sib) {
4841
+ if (isVisibleFlow(sib)) {
4842
+ const sr = sib.getBoundingClientRect();
4843
+ if (sr.bottom <= top + 2 && overlapsX(sr, rect)) return sr.bottom;
4844
+ }
4845
+ sib = sib.previousElementSibling;
4846
+ }
4847
+ const parent = node.parentElement;
4848
+ if (!parent || parent === document.body) return null;
4849
+ // Leaving a container upward: if it draws its own top edge, that
4850
+ // edge separates the heading from whatever sits above.
4851
+ if (hasOwnTopBoundary(parent)) return null;
4852
+ node = parent;
4853
+ }
4854
+ return null;
4855
+ }
4856
+
4857
+ // Nearest content edge strictly below the heading — the block the
4858
+ // heading introduces. Crosses wrappers freely (headings often share a
4859
+ // row wrapper with an eyebrow or index label).
4860
+ function edgeBelow(h, rect) {
4861
+ let node = h;
4862
+ while (node && node !== document.body) {
4863
+ let sib = node.nextElementSibling;
4864
+ while (sib) {
4865
+ if (isVisibleFlow(sib)) {
4866
+ const sr = sib.getBoundingClientRect();
4867
+ if (sr.top >= rect.bottom - 2 && overlapsX(sr, rect)) return sr.top;
4868
+ }
4869
+ sib = sib.nextElementSibling;
4870
+ }
4871
+ node = node.parentElement;
4872
+ }
4873
+ return null;
4874
+ }
4875
+
4876
+ function insideSmallCard(h) {
4877
+ let cur = h.parentElement;
4878
+ while (cur && cur !== document.body) {
4879
+ if (isCardLikeDOM(cur)) {
4880
+ const cr = cur.getBoundingClientRect();
4881
+ if (cr.height < CARD_EXEMPT_HEIGHT) return true;
4882
+ }
4883
+ cur = cur.parentElement;
4884
+ }
4885
+ return false;
4886
+ }
4887
+
4888
+ const candidates = [];
4889
+ for (const h of document.querySelectorAll('h2, h3, h4')) {
4890
+ if (!isVisibleFlow(h)) continue;
4891
+ const text = (h.textContent || '').trim().replace(/\s+/g, ' ');
4892
+ if (text.length < 3) continue;
4893
+ const rect = h.getBoundingClientRect();
4894
+ const belowTop = edgeBelow(h, rect);
4895
+ if (belowTop == null) continue; // heading introduces nothing measurable
4896
+ const { topEl, top } = clusterTop(h, rect);
4897
+ const aboveBottom = edgeAbove(topEl, top, rect);
4898
+ if (aboveBottom == null) continue; // first content, or bounded container
4899
+ if (insideSmallCard(h)) continue;
4900
+ const above = Math.max(0, top - aboveBottom);
4901
+ const below = Math.max(0, belowTop - rect.bottom);
4902
+ if (below < 6 || below > MAX_BELOW_PX) continue;
4903
+ // Violation: the space above clearly fails to exceed the space below.
4904
+ // Near-equal gaps are ambiguous rather than inverted, so they pass.
4905
+ if (above < below * 0.75 && below - above >= MIN_DEFICIT_PX) {
4906
+ candidates.push({ el: h, tag: h.tagName.toLowerCase(), text: text.slice(0, 60), above, below });
4907
+ }
4908
+ }
4909
+
4910
+ if (candidates.length < MIN_VIOLATIONS) return [];
4911
+ return candidates.map(c => ({
4912
+ type: 'heading-rhythm',
4913
+ 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)`,
4914
+ el: c.el,
4915
+ }));
4916
+ }
4917
+
2611
4918
  // Node page-level checks — take document/window as parameters
2612
4919
 
2613
4920
  function checkPageTypography(doc, win) {
@@ -2635,14 +4942,9 @@ function checkPageTypography(doc, win) {
2635
4942
 
2636
4943
  // Check Google Fonts links in HTML
2637
4944
  const html = doc.documentElement?.outerHTML || '';
2638
- const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
2639
- let m;
2640
- while ((m = gfRe.exec(html)) !== null) {
2641
- const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
2642
- for (const f of families) {
2643
- fonts.add(f);
2644
- if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2645
- }
4945
+ for (const f of extractGoogleFontFamilies(html)) {
4946
+ fonts.add(f);
4947
+ if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
2646
4948
  }
2647
4949
 
2648
4950
  // Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -2661,14 +4963,6 @@ function checkPageTypography(doc, win) {
2661
4963
  findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
2662
4964
  }
2663
4965
 
2664
- // Single font
2665
- if (fonts.size === 1) {
2666
- const els = doc.querySelectorAll('*');
2667
- if (els.length >= 20) {
2668
- findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
2669
- }
2670
- }
2671
-
2672
4966
  // Flat type hierarchy
2673
4967
  const sizes = new Set();
2674
4968
  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
@@ -2755,6 +5049,135 @@ function checkPageLayout(doc, win) {
2755
5049
  return findings;
2756
5050
  }
2757
5051
 
5052
+ // ── Repeated text inside one container ──────────────────────────────────────
5053
+ // The same literal string rendered 3+ times in structurally different spots
5054
+ // inside one bordered/elevated container — typically a status word wired
5055
+ // into every slot of a card template. Legitimate repetition is structural:
5056
+ // table columns, calendar grids, nav/menu lists, and templated sibling rows
5057
+ // all repeat text in *parallel* positions, so occurrences whose element
5058
+ // paths inside the container are identical (or live in dedicated repetition
5059
+ // structures) never count. Only 3+ occurrences at 3+ distinct structural
5060
+ // positions flag.
5061
+
5062
+ const REPEATED_TEXT_SKIP_SELECTOR = [
5063
+ 'table',
5064
+ 'select',
5065
+ 'datalist',
5066
+ 'nav',
5067
+ 'menu',
5068
+ '[role="navigation"]',
5069
+ '[role="menu"]',
5070
+ '[role="menubar"]',
5071
+ '[role="listbox"]',
5072
+ '[role="grid"]',
5073
+ '[role="tablist"]',
5074
+ '[role="radiogroup"]',
5075
+ '[aria-hidden="true"]',
5076
+ ].join(',');
5077
+
5078
+ const REPEATED_TEXT_CONTAINER_TAGS = new Set([
5079
+ 'div', 'section', 'article', 'aside', 'main', 'figure', 'form', 'fieldset', 'details', 'li',
5080
+ ]);
5081
+
5082
+ // A container worth attributing text to: visibly bounded (border on most
5083
+ // sides or an elevation shadow) and surface-like (radius or own background).
5084
+ function isRepeatedTextContainer(style) {
5085
+ if (!style) return false;
5086
+ const hasShadow = !!(style.boxShadow && style.boxShadow !== 'none' && style.boxShadow !== '');
5087
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left']
5088
+ .filter(side => (parseFloat(style[`border${side}Width`]) || 0) >= 1).length;
5089
+ const hasBorder = borderSides >= 3;
5090
+ const hasRadius = (parseFloat(style.borderRadius) || 0) > 0;
5091
+ const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
5092
+ const hasBg = !!(bg && (bg.a ?? 1) > 0.1);
5093
+ return isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg);
5094
+ }
5095
+
5096
+ function collectRepeatedContainerTextFindings(doc, getStyle, opts = {}) {
5097
+ const isVisible = opts.isVisible || (() => true);
5098
+ const findings = [];
5099
+
5100
+ const containers = [];
5101
+ const containerSet = new Set();
5102
+ for (const el of doc.querySelectorAll('*')) {
5103
+ if (!REPEATED_TEXT_CONTAINER_TAGS.has(el.tagName.toLowerCase())) continue;
5104
+ if (el.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
5105
+ if (!isRepeatedTextContainer(getStyle(el))) continue;
5106
+ containers.push(el);
5107
+ containerSet.add(el);
5108
+ }
5109
+
5110
+ for (const container of containers) {
5111
+ if (!isVisible(container)) continue;
5112
+ const descendants = container.querySelectorAll('*');
5113
+ // Page-scale wrappers that merely happen to carry a background are not
5114
+ // the "one card" this rule reasons about.
5115
+ if (descendants.length > 250) continue;
5116
+
5117
+ const groups = new Map();
5118
+ for (const d of descendants) {
5119
+ // Attribute text to the innermost container only.
5120
+ let anc = d.parentElement;
5121
+ let ownedByInner = false;
5122
+ while (anc && anc !== container) {
5123
+ if (containerSet.has(anc)) { ownedByInner = true; break; }
5124
+ anc = anc.parentElement;
5125
+ }
5126
+ if (ownedByInner) continue;
5127
+ if (d.closest?.(REPEATED_TEXT_SKIP_SELECTOR)) continue;
5128
+ // Icon-font glyph names read as text but render as symbols.
5129
+ if (/icon|material-symbols|(?:^|\s)fa[srlbd]?(?:\s|-|$)/i.test(String(d.getAttribute?.('class') || ''))) continue;
5130
+ if (!isVisible(d)) continue;
5131
+
5132
+ const direct = [...d.childNodes]
5133
+ .filter(n => n.nodeType === 3)
5134
+ .map(n => n.textContent)
5135
+ .join(' ')
5136
+ .replace(/\s+/g, ' ')
5137
+ .trim();
5138
+ if (direct.length < 4 || direct.length > 48) continue;
5139
+ if (!/[a-zA-Z]/.test(direct)) continue;
5140
+
5141
+ // Structural signature: the element path from the occurrence up to
5142
+ // the container. Parallel/templated repetition shares one signature.
5143
+ const sig = [];
5144
+ for (let cur = d; cur && cur !== container; cur = cur.parentElement) {
5145
+ const cls = String(cur.getAttribute?.('class') || '')
5146
+ .trim().split(/\s+/).filter(Boolean).sort().join('.');
5147
+ sig.push(cur.tagName.toLowerCase() + (cls ? `.${cls}` : ''));
5148
+ }
5149
+ if (!groups.has(direct)) groups.set(direct, []);
5150
+ groups.get(direct).push(sig.join('>'));
5151
+ }
5152
+
5153
+ for (const [text, sigs] of groups) {
5154
+ if (sigs.length < 3) continue;
5155
+ if (new Set(sigs).size < 3) continue;
5156
+ findings.push({
5157
+ id: 'repeated-container-text',
5158
+ snippet: `"${text.slice(0, 40)}" rendered ${sigs.length}× in distinct spots inside ${classSelector(container)}`,
5159
+ });
5160
+ }
5161
+ }
5162
+ return findings;
5163
+ }
5164
+
5165
+ function checkRepeatedContainerTextFromDoc(doc, win) {
5166
+ return collectRepeatedContainerTextFindings(
5167
+ doc,
5168
+ (el) => win.getComputedStyle(el),
5169
+ { isVisible: (el) => String(win.getComputedStyle(el).display || '') !== 'none' },
5170
+ );
5171
+ }
5172
+
5173
+ function checkRepeatedContainerTextDOM() {
5174
+ return collectRepeatedContainerTextFindings(
5175
+ document,
5176
+ (el) => getComputedStyle(el),
5177
+ { isVisible: isRenderedForBrowserRule },
5178
+ );
5179
+ }
5180
+
2758
5181
  // ─── Cream / beige palette (the default "tasteful" AI surface) ────────────────
2759
5182
  // A warm, lightly-tinted off-white page background — light, with R≥G≥B and a
2760
5183
  // small warm tint (not white, not a strong color). The current reflex surface.
@@ -2868,7 +5291,7 @@ function checkElementOversizedH1DOM(el) {
2868
5291
  return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
2869
5292
  }
2870
5293
 
2871
- // ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
5294
+ // ─── Generated-UI tell: hairline border + wide diffuse shadow ────────────────
2872
5295
  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;
2873
5296
 
2874
5297
  function shadowLayerAlpha(layer) {
@@ -3193,9 +5616,645 @@ function checkElementTextOverflowDOM(el) {
3193
5616
  if (el.clientWidth > 0 && delta >= 16) {
3194
5617
  return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its box by ${Math.round(delta)}px` }];
3195
5618
  }
5619
+
5620
+ // Inline text owners have no client geometry (clientWidth/scrollWidth are
5621
+ // both 0), so the scrollWidth path above never sees them. Their overflow
5622
+ // registers only on a block ancestor, and that ancestor has no direct text
5623
+ // so the ownership gate skips it. (The shipped miss: a nowrap inline
5624
+ // <span> spilling 45px past its fixed-width grid cell.) Measure the inline
5625
+ // box against the padding box of its nearest block container instead.
5626
+ if (el.clientWidth === 0 && rect && rect.width > 0) {
5627
+ let container = el.parentElement;
5628
+ while (container && container.clientWidth === 0) container = container.parentElement;
5629
+ if (!container) return [];
5630
+ // Transforms make rect comparisons lie; skip anything on that path.
5631
+ for (let p = el; p && p !== container.parentElement; p = p.parentElement) {
5632
+ const t = getComputedStyle(p).transform;
5633
+ if (t && t !== 'none') return [];
5634
+ }
5635
+ const cRect = container.getBoundingClientRect();
5636
+ const contentRight = cRect.left + container.clientLeft + container.clientWidth;
5637
+ const spill = rect.right - contentRight;
5638
+ if (spill >= 16) {
5639
+ return [{ id: 'text-overflow', snippet: `${classSelector(el)} overflows its container by ${Math.round(spill)}px` }];
5640
+ }
5641
+ }
3196
5642
  return [];
3197
5643
  }
3198
5644
 
5645
+ // ---------------------------------------------------------------------------
5646
+ // Blinking cursor (browser-only)
5647
+ // ---------------------------------------------------------------------------
5648
+
5649
+ // Block / underscore glyphs commonly used as a fake text cursor.
5650
+ const CURSOR_GLYPH_RE = /^[_|▀-▟■▮❙❚|]$/;
5651
+
5652
+ // How far down the page still counts as the first-viewport / hero region.
5653
+ // Hero compositions regularly run past a literal viewport height, so the
5654
+ // gate is a landing-region budget, not an exact fold line.
5655
+ const CURSOR_FIRST_VIEWPORT_PX = 1200;
5656
+
5657
+ // Do the named @keyframes only toggle visibility (opacity dropping to ~0 or
5658
+ // visibility:hidden), i.e. a blink rather than a fade/move/spin? Walks the
5659
+ // live CSSOM; cross-origin sheets are skipped.
5660
+ function keyframesToggleVisibilityDOM(name) {
5661
+ if (!name) return false;
5662
+ for (const sheet of document.styleSheets) {
5663
+ let rules;
5664
+ try { rules = sheet.cssRules || sheet.rules; } catch { continue; }
5665
+ if (!rules) continue;
5666
+ const stack = [...rules];
5667
+ while (stack.length) {
5668
+ const rule = stack.shift();
5669
+ if (rule.cssRules && rule.type !== 7) { stack.push(...rule.cssRules); continue; }
5670
+ if (rule.type !== 7 || rule.name !== name) continue; // 7 = KEYFRAMES_RULE
5671
+ let togglesOut = false;
5672
+ for (const frame of rule.cssRules || []) {
5673
+ const fs = frame.style;
5674
+ if (!fs) continue;
5675
+ for (let i = 0; i < fs.length; i++) {
5676
+ const prop = fs[i];
5677
+ if (prop === 'opacity') {
5678
+ if ((parseFloat(fs.getPropertyValue('opacity')) || 0) <= 0.15) togglesOut = true;
5679
+ } else if (prop === 'visibility') {
5680
+ if (/hidden/i.test(fs.getPropertyValue('visibility'))) togglesOut = true;
5681
+ } else if (prop !== 'animation-timing-function') {
5682
+ return false; // keyframes animate something else — not a blink
5683
+ }
5684
+ }
5685
+ }
5686
+ return togglesOut;
5687
+ }
5688
+ }
5689
+ return false;
5690
+ }
5691
+
5692
+ // Decorative blinking cursor: a small block / underscore element bound to an
5693
+ // infinite blink animation, sitting in the first-viewport region of a page.
5694
+ // Real editable surfaces (inputs, textareas, contenteditable, role=textbox)
5695
+ // draw their own caret and are exempt. Round pulsing dots stay with the
5696
+ // pulsing-dot rule.
5697
+ function checkElementBlinkingCursorDOM(el) {
5698
+ const tag = el.tagName.toLowerCase();
5699
+ if (['input', 'textarea', 'select', 'img', 'svg', 'script', 'style'].includes(tag)) return [];
5700
+ const style = getComputedStyle(el);
5701
+
5702
+ const iterations = (style.animationIterationCount || '').split(',').map(s => s.trim());
5703
+ if (!iterations.includes('infinite')) return [];
5704
+ const names = (style.animationName || '').split(',').map(s => s.trim()).filter(n => n && n !== 'none');
5705
+ if (names.length === 0) return [];
5706
+ const blinkName = names.find(n => /blink|caret|cursor/i.test(n))
5707
+ || names.find(n => keyframesToggleVisibilityDOM(n));
5708
+ if (!blinkName) return [];
5709
+
5710
+ // Real caret contexts are exempt.
5711
+ if (el.isContentEditable || el.closest('[contenteditable=""], [contenteditable="true"], [role="textbox"]')) return [];
5712
+
5713
+ const rect = el.getBoundingClientRect();
5714
+ if (rect.width <= 0 || rect.height <= 0) return [];
5715
+
5716
+ // First-viewport gate: the hero cliché, not a footer terminal.
5717
+ const pageTop = rect.top + (window.scrollY || 0);
5718
+ if (pageTop > CURSOR_FIRST_VIEWPORT_PX) return [];
5719
+
5720
+ // Cursor shape: a lone block/underscore glyph, or an empty solid
5721
+ // rectangle sized like a text caret (block or underscore form).
5722
+ const text = (el.textContent || '').trim();
5723
+ const glyphCursor = text.length === 1 && CURSOR_GLYPH_RE.test(text);
5724
+ let blockCursor = false;
5725
+ if (!glyphCursor) {
5726
+ if (text.length > 0 || el.childElementCount > 0) return [];
5727
+ const bg = parseAnyColor(style.backgroundColor || '');
5728
+ const filled = bg && (bg.a ?? 1) > 0.2;
5729
+ const hasBorderFill = ['Left', 'Right', 'Bottom'].some(
5730
+ side => (parseFloat(style[`border${side}Width`]) || 0) >= 1,
5731
+ );
5732
+ if (!filled && !hasBorderFill) return [];
5733
+ const vertical = rect.width >= 1 && rect.width <= 24 && rect.height >= 6 && rect.height <= 48 && rect.height >= rect.width;
5734
+ const underscore = rect.height >= 1 && rect.height <= 6 && rect.width >= 4 && rect.width <= 24;
5735
+ if (!vertical && !underscore) return [];
5736
+ // Round dots are the pulsing-dot rule's territory.
5737
+ const radiusPx = parseFloat(style.borderRadius) || 0;
5738
+ if (radiusPx >= 0.4 * Math.min(rect.width, rect.height)) return [];
5739
+ blockCursor = true;
5740
+ }
5741
+ if (!glyphCursor && !blockCursor) return [];
5742
+
5743
+ // Hero-region promotion: a fake caret blinking in the first ~900px or
5744
+ // inside the page chrome is the shipped hero cliché, not an incidental
5745
+ // flourish. Promote those from the registry's advisory to warning;
5746
+ // lower first-viewport occurrences keep the default severity.
5747
+ const inHeroRegion = pageTop <= 900
5748
+ || !!(el.closest && el.closest('header, nav, [role="banner"], [role="navigation"]'));
5749
+ return [{
5750
+ id: 'blinking-cursor',
5751
+ snippet: `${classSelector(el)} — ${Math.round(rect.width)}x${Math.round(rect.height)}px blinking cursor (animation "${blinkName}") in the first viewport`,
5752
+ ...(inHeroRegion ? { severity: 'warning' } : {}),
5753
+ }];
5754
+ }
5755
+
5756
+ // ---------------------------------------------------------------------------
5757
+ // Content invisible at rest (browser-only, driven by the URL engine)
5758
+ // ---------------------------------------------------------------------------
5759
+
5760
+ // Tags whose text never renders, or whose hidden state is legitimate UI
5761
+ // (templates, dialogs, native select options). Text inside them stays out of
5762
+ // both the numerator and the denominator.
5763
+ const HIDDEN_TEXT_EXCLUDE_TAGS = new Set([
5764
+ 'script', 'style', 'noscript', 'template', 'title', 'head', 'meta', 'link',
5765
+ 'option', 'optgroup', 'select', 'datalist', 'dialog',
5766
+ ]);
5767
+
5768
+ // Measure how many text characters currently render invisible (computed
5769
+ // opacity ~0 or visibility hidden anywhere on the ancestor chain) versus
5770
+ // visible. display:none / [hidden] / aria-hidden subtrees are legitimately
5771
+ // hidden UI (menus, tab panels, templates): they are excluded from the
5772
+ // denominator entirely rather than counted as invisible.
5773
+ function measureHiddenTextDOM() {
5774
+ const cache = new Map();
5775
+ function stateOf(el) {
5776
+ if (!el || el.nodeType !== 1 || el === document.documentElement) return 'visible';
5777
+ const cached = cache.get(el);
5778
+ if (cached) return cached;
5779
+ let state;
5780
+ const tag = el.tagName.toLowerCase();
5781
+ if (HIDDEN_TEXT_EXCLUDE_TAGS.has(tag)) {
5782
+ state = 'excluded';
5783
+ } else {
5784
+ const parentState = stateOf(el.parentElement);
5785
+ if (parentState === 'excluded') {
5786
+ state = 'excluded';
5787
+ } else {
5788
+ const style = getComputedStyle(el);
5789
+ if (style.display === 'none' || el.hidden || el.getAttribute('aria-hidden') === 'true'
5790
+ || String(style.contentVisibility || '').toLowerCase() === 'hidden') {
5791
+ state = 'excluded';
5792
+ } else if (parentState === 'invisible'
5793
+ || (parseFloat(style.opacity) || 0) <= 0.02
5794
+ || /^(hidden|collapse)$/.test(style.visibility)) {
5795
+ state = 'invisible';
5796
+ } else {
5797
+ state = 'visible';
5798
+ }
5799
+ }
5800
+ }
5801
+ cache.set(el, state);
5802
+ return state;
5803
+ }
5804
+
5805
+ let totalChars = 0;
5806
+ let hiddenChars = 0;
5807
+ const hiddenSamples = [];
5808
+ for (const el of document.querySelectorAll('body *')) {
5809
+ let len = 0;
5810
+ for (const node of el.childNodes) {
5811
+ if (node.nodeType === 3) len += node.textContent.replace(/\s+/g, ' ').trim().length;
5812
+ }
5813
+ if (!len) continue;
5814
+ const state = stateOf(el);
5815
+ if (state === 'excluded') continue;
5816
+ totalChars += len;
5817
+ if (state === 'invisible') {
5818
+ hiddenChars += len;
5819
+ if (hiddenSamples.length < 3) {
5820
+ const text = String(el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 40);
5821
+ if (text) hiddenSamples.push(text);
5822
+ }
5823
+ }
5824
+ }
5825
+ return { totalChars, hiddenChars, hiddenSamples };
5826
+ }
5827
+
5828
+ // Pure threshold check over a measureHiddenTextDOM() result. The URL engine
5829
+ // calls it AFTER a reveal sweep (scroll through the document so every
5830
+ // IntersectionObserver / scroll reveal had its chance to fire, then back to
5831
+ // the top): a healthy reveal-on-scroll page drops to ~0 invisible text after
5832
+ // the sweep, while a page whose reveal script died keeps most of its text at
5833
+ // opacity 0 forever. Fires only when the invisible share stays above 30%
5834
+ // with a real amount of text behind it.
5835
+ function checkContentHiddenAtRest({ totalChars = 0, hiddenChars = 0, hiddenSamples = [] } = {}) {
5836
+ if (totalChars < 200 || hiddenChars < 150) return [];
5837
+ const share = hiddenChars / totalChars;
5838
+ if (share <= 0.3) return [];
5839
+ const sample = hiddenSamples.length ? ` (e.g. "${hiddenSamples[0]}")` : '';
5840
+ return [{
5841
+ id: 'content-hidden-at-rest',
5842
+ snippet: `${Math.round(share * 100)}% of page text (${hiddenChars} of ${totalChars} chars) stays at opacity 0 / visibility hidden after reveal handlers ran${sample}`,
5843
+ }];
5844
+ }
5845
+
5846
+ // ---------------------------------------------------------------------------
5847
+ // Edge-flush cards in horizontal scrollers (browser-only)
5848
+ // ---------------------------------------------------------------------------
5849
+
5850
+ // A visually-defined card (own opaque background, or borders on 2+ sides)
5851
+ // inside a horizontal scroller, sitting flush against one edge of the
5852
+ // scroller's clip box at rest while keeping a clear gutter on the other
5853
+ // side. The canonical bug: the first snap panel is sized wider than the
5854
+ // scroller, so its cards end exactly at the clip edge with their rounded
5855
+ // corners cut, while every sibling panel keeps its inset. Cards that extend
5856
+ // far past the edge are deliberate peeks and stay exempt.
5857
+ function checkEdgeFlushCardsDOM() {
5858
+ const findings = [];
5859
+ const vh = window.innerHeight || 800;
5860
+ const isScroller = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
5861
+
5862
+ for (const scroller of document.querySelectorAll('*')) {
5863
+ const style = getComputedStyle(scroller);
5864
+ if (!isScroller(style)) continue;
5865
+ if (scroller.scrollWidth <= scroller.clientWidth + 8) continue;
5866
+ // At rest only: a user-scrolled or snapped-forward scroller legitimately
5867
+ // shows cut cards at both edges.
5868
+ if (scroller.scrollLeft > 4) continue;
5869
+ const scRect = scroller.getBoundingClientRect();
5870
+ if (scRect.width < 120 || scRect.height < 60) continue;
5871
+ // Landing-region gate: the defect matters where the page opens.
5872
+ if (scRect.top + (window.scrollY || 0) > 2 * vh) continue;
5873
+ const contentLeft = scRect.left + scroller.clientLeft;
5874
+ const contentRight = contentLeft + scroller.clientWidth;
5875
+
5876
+ const flush = [];
5877
+ for (const card of scroller.querySelectorAll('*')) {
5878
+ if (!isRenderedForBrowserRule(card)) continue;
5879
+ // Attribute cards to their nearest scroller only (nested scrollers).
5880
+ let owner = card.parentElement;
5881
+ while (owner && owner !== scroller && !isScroller(getComputedStyle(owner))) owner = owner.parentElement;
5882
+ if (owner !== scroller) continue;
5883
+ const cs = getComputedStyle(card);
5884
+ const rect = card.getBoundingClientRect();
5885
+ if (rect.width < 80 || rect.height < 40) continue;
5886
+ const bg = parseAnyColor(cs.backgroundColor || '');
5887
+ const hasBg = !!(bg && (bg.a ?? 1) > 0.5);
5888
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left']
5889
+ .filter(side => (parseFloat(cs[`border${side}Width`]) || 0) > 0).length;
5890
+ if (!hasBg && borderSides < 2) continue;
5891
+ const leftGutter = rect.left - contentLeft;
5892
+ const rightGap = contentRight - rect.right;
5893
+ // Flush right with a left gutter, or the mirror. The -24 floor keeps
5894
+ // deliberately peeking next-cards (cut mid-card) exempt.
5895
+ const flushRight = leftGutter >= 6 && rightGap < 8 && rightGap > -24;
5896
+ const flushLeft = rightGap >= 6 && leftGutter < 8 && leftGutter > -24;
5897
+ if (!flushRight && !flushLeft) continue;
5898
+ flush.push({ card, edge: flushRight ? 'right' : 'left', gap: Math.round(flushRight ? rightGap : leftGutter) });
5899
+ }
5900
+ if (flush.length === 0) continue;
5901
+ const worst = flush.reduce((a, b) => (b.gap < a.gap ? b : a));
5902
+ findings.push({
5903
+ el: scroller,
5904
+ type: 'edge-flush-cards',
5905
+ 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)})`,
5906
+ });
5907
+ }
5908
+ return findings;
5909
+ }
5910
+
5911
+ // ---------------------------------------------------------------------------
5912
+ // Text occlusion / element overlap (browser-only)
5913
+ // ---------------------------------------------------------------------------
5914
+
5915
+ // An opaque decorated box: a near-solid background fill or two-plus visible
5916
+ // borders make it hide whatever sits behind it. Gradient / image fills are
5917
+ // deliberately excluded — a scrim gradient over hero imagery is a contrast
5918
+ // layer, not an occluder, and belongs to the pixel low-contrast rule.
5919
+ function isOpaqueDecoratedBox(cs) {
5920
+ if (!cs) return false;
5921
+ const bg = parseAnyColor(cs.backgroundColor || '');
5922
+ if (bg && (bg.a ?? 1) > 0.6) return true;
5923
+ const borderSides = ['Top', 'Right', 'Bottom', 'Left'].filter((side) => {
5924
+ if ((parseFloat(cs[`border${side}Width`]) || 0) <= 0) return false;
5925
+ const bc = parseAnyColor(cs[`border${side}Color`] || '');
5926
+ return bc && (bc.a ?? 1) > 0.3;
5927
+ }).length;
5928
+ return borderSides >= 2;
5929
+ }
5930
+
5931
+ // Is this element lifted out of normal flow into a layer that can cover
5932
+ // siblings? Two normal-flow blocks stacked vertically cannot truly hide each
5933
+ // other's ink — an overlap between their rects is line-box bleed from tight
5934
+ // leading (a display headline reaching up over the line before it), not
5935
+ // occlusion. Only out-of-flow positioning (absolute / fixed / sticky) moves an
5936
+ // element off its own row onto the pixels of another; an in-place transform or
5937
+ // relative nudge on a display headline does not.
5938
+ function isLayeredElement(el) {
5939
+ for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) {
5940
+ const pos = String(getComputedStyle(cur).position || 'static');
5941
+ if (pos === 'absolute' || pos === 'fixed' || pos === 'sticky') return true;
5942
+ }
5943
+ return false;
5944
+ }
5945
+
5946
+ function elementDirectText(el) {
5947
+ let t = '';
5948
+ for (const node of el.childNodes || []) {
5949
+ if (node.nodeType === 3) t += node.textContent;
5950
+ }
5951
+ return t.trim();
5952
+ }
5953
+
5954
+ // Rendered gate that, unlike isRenderedForBrowserRule, does NOT exempt
5955
+ // aria-hidden subtrees: a decorative aria-hidden box still paints on screen
5956
+ // and can still visually cover real text.
5957
+ function isPaintedForOcclusion(el) {
5958
+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
5959
+ const style = getComputedStyle(cur);
5960
+ const visibility = String(style.visibility || '').toLowerCase();
5961
+ if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
5962
+ if ((parseFloat(style.opacity) || 0) <= 0.05) return false;
5963
+ if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
5964
+ }
5965
+ return true;
5966
+ }
5967
+
5968
+ // Detects text that is actually painted UNDER an opaque box or another text
5969
+ // run (the reader can't read it), plus two structural overlap tells the
5970
+ // elementFromPoint probe can't reach: a large headline whose edge tucks behind
5971
+ // an opaque card, and an inline element whose leaked padding-box (a common
5972
+ // class-name-collision bug) covers a sibling.
5973
+ //
5974
+ // The occlusion probe is viewport-bound: elementFromPoint only answers for the
5975
+ // scan's current viewport (scroll 0), so the ground-truth paths cover the
5976
+ // first-viewport composition where collisions matter most. The inline-leak
5977
+ // path is pure geometry and runs anywhere on the page.
5978
+ const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
5979
+
5980
+ function checkTextOcclusionDOM() {
5981
+ const findings = [];
5982
+ const seenVictims = new Set();
5983
+ const vw = window.innerWidth || 1280;
5984
+ const vh = window.innerHeight || 800;
5985
+
5986
+ const isFloated = (cs) => {
5987
+ const f = String(cs.cssFloat || cs.float || 'none').toLowerCase();
5988
+ return f === 'left' || f === 'right';
5989
+ };
5990
+ const isMarqueeish = (el, cs) => {
5991
+ if (el.tagName === 'MARQUEE') return true;
5992
+ const ident = `${el.getAttribute?.('class') || ''} ${el.getAttribute?.('id') || ''}`;
5993
+ if (/\b(marquee|ticker|scroller|carousel|conveyor)\b/i.test(ident)) return true;
5994
+ const anim = String(cs.animationName || '').toLowerCase();
5995
+ return /marquee|ticker|scroll/.test(anim);
5996
+ };
5997
+ // A fixed or sticky overlay (status bar, toolbar, sticky header) floats above
5998
+ // scrolling content by design — whatever sits under it at rest scrolls clear,
5999
+ // so it is not occluding the page.
6000
+ const isPinnedOverlay = (el) => {
6001
+ for (let cur = el; cur && cur.nodeType === 1 && cur !== document.body; cur = cur.parentElement) {
6002
+ const pos = String(getComputedStyle(cur).position || 'static');
6003
+ if (pos === 'fixed' || pos === 'sticky') return true;
6004
+ }
6005
+ return false;
6006
+ };
6007
+
6008
+ // Collect renderable text owners in / near the first viewport for the
6009
+ // elementFromPoint probe. SVG <text> counts too.
6010
+ const textEls = [];
6011
+ for (const el of document.querySelectorAll('body *')) {
6012
+ const tag = el.tagName.toLowerCase();
6013
+ if (OCCLUSION_TEXT_SKIP_TAGS.has(tag)) continue;
6014
+ const inSvg = !!el.closest('svg');
6015
+ if (inSvg && tag !== 'text') continue;
6016
+ const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
6017
+ if (text.length < 2) continue;
6018
+ if (!isPaintedForOcclusion(el)) continue;
6019
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
6020
+ if (rect.width < 6 || rect.height < 6) continue;
6021
+ // Viewport-bound probe: keep text whose box overlaps the live viewport.
6022
+ if (rect.bottom <= 0 || rect.top >= vh) continue;
6023
+ textEls.push({ el, rect, text, inSvg });
6024
+ }
6025
+
6026
+ for (const victim of textEls) {
6027
+ const { el, rect, text } = victim;
6028
+ if (seenVictims.has(el)) continue;
6029
+ const style = getComputedStyle(el);
6030
+ if (isScreenReaderOnlyTextStyle(style, { width: rect.width, height: rect.height, clientWidth: el.clientWidth, clientHeight: el.clientHeight })) continue;
6031
+
6032
+ const cols = Math.max(6, Math.min(30, Math.round(rect.width / 12)));
6033
+ const rows = Math.max(1, Math.min(4, Math.round(rect.height / 14)));
6034
+ let total = 0;
6035
+ let occluded = 0;
6036
+ let occluderEl = null;
6037
+ let occluderKind = '';
6038
+ for (let i = 0; i < cols; i++) {
6039
+ const x = rect.left + rect.width * ((i + 0.5) / cols);
6040
+ if (x < 1 || x > vw - 1) continue;
6041
+ for (let j = 0; j < rows; j++) {
6042
+ const y = rect.top + rect.height * ((j + 0.5) / rows);
6043
+ if (y < 1 || y > vh - 1) continue;
6044
+ total++;
6045
+ const top = document.elementFromPoint(x, y);
6046
+ if (!top) continue;
6047
+ // Text visible here: the probe returns the text itself, a descendant,
6048
+ // or one of its ancestors (the text's own container / background).
6049
+ if (top === el || el.contains(top) || top.contains(el)) continue;
6050
+ const topCs = getComputedStyle(top);
6051
+ if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
6052
+ const topTag = top.tagName.toLowerCase();
6053
+ // Text sitting under a raw image/video is contrast territory (deduped
6054
+ // against the pixel low-contrast rule); leave those alone here.
6055
+ if (['img', 'video', 'canvas', 'picture'].includes(topTag)) continue;
6056
+ const topHasText = elementDirectText(top).length > 0 || !!top.closest('svg');
6057
+ if (isOpaqueDecoratedBox(topCs)) {
6058
+ occluded++;
6059
+ if (!occluderEl) { occluderEl = top; occluderKind = 'box'; }
6060
+ } else if (topHasText) {
6061
+ occluded++;
6062
+ if (!occluderEl) { occluderEl = top; occluderKind = 'text'; }
6063
+ }
6064
+ }
6065
+ }
6066
+ if (total === 0 || !occluderEl) continue;
6067
+ const occFrac = occluded / total;
6068
+ // A solid box's paint fills its rect, so box coverage is real at a lower
6069
+ // bar. Text coverage rides on elementFromPoint returning the occluder's box
6070
+ // (line box / container), which can exceed its actual glyph ink, so the
6071
+ // text bar is higher — partial overlaps below it are crowding, not burial.
6072
+ if (occFrac < (occluderKind === 'text' ? 0.45 : 0.3)) continue;
6073
+
6074
+ // (i) Substantial occlusion: a real slab of the text is behind something.
6075
+ if (occluderKind === 'text') {
6076
+ // Two SVG texts inside the same emblem (concentric arcs, monogram) are one
6077
+ // decorative unit, not a collision.
6078
+ const victimSvg = el.closest('svg');
6079
+ const occSvg = occluderEl.closest('svg');
6080
+ if (victimSvg && occSvg && victimSvg === occSvg) continue;
6081
+ // Both sides in plain flow: the overlap is line-box bleed from tight
6082
+ // leading (a big headline reaching up over its own eyebrow), not one text
6083
+ // run painted over another.
6084
+ if (!isLayeredElement(el) && !isLayeredElement(occluderEl)) continue;
6085
+ }
6086
+ seenVictims.add(el);
6087
+ findings.push({
6088
+ el,
6089
+ type: 'text-occlusion',
6090
+ detail: `${classSelector(el)} "${text.slice(0, 24)}" is ${Math.round(occFrac * 100)}% covered by ${occluderKind === 'text' ? 'overlapping text' : 'an opaque element'} (${classSelector(occluderEl)})`,
6091
+ });
6092
+ }
6093
+
6094
+ // (ii) Headline overhanging an opaque card: a display-scale line whose bulk
6095
+ // sits outside a bounded content card but whose edge clips into it. The text
6096
+ // may still paint on top and stay readable, but the two layers were dropped
6097
+ // on the same pixels — a placement collision, not a composition.
6098
+ const cards = [];
6099
+ for (const el of document.querySelectorAll('body *')) {
6100
+ if (el.closest('svg')) continue;
6101
+ if (!isPaintedForOcclusion(el)) continue;
6102
+ const cs = getComputedStyle(el);
6103
+ const bg = parseAnyColor(cs.backgroundColor || '');
6104
+ const bgImg = cs.backgroundImage || '';
6105
+ if (!bg || (bg.a ?? 1) <= 0.7) continue;
6106
+ if (bgImg && bgImg !== 'none' && /(gradient|url)\(/i.test(bgImg)) continue;
6107
+ const hasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((s) => (parseFloat(cs[`border${s}Width`]) || 0) > 0);
6108
+ const hasShadow = cs.boxShadow && cs.boxShadow !== 'none';
6109
+ if (!hasBorder && !hasShadow) continue;
6110
+ if (isPinnedOverlay(el)) continue;
6111
+ let cr; try { cr = el.getBoundingClientRect(); } catch { continue; }
6112
+ if (cr.width < 100 || cr.width > 0.8 * vw || cr.height < 60) continue;
6113
+ cards.push({ el, rect: cr });
6114
+ }
6115
+ for (const victim of textEls) {
6116
+ const { el, rect, text } = victim;
6117
+ if (seenVictims.has(el)) continue;
6118
+ const style = getComputedStyle(el);
6119
+ if ((parseFloat(style.fontSize) || 16) < 40) continue;
6120
+ let lineHeight = parseFloat(style.lineHeight);
6121
+ if (!Number.isFinite(lineHeight)) lineHeight = (parseFloat(style.fontSize) || 16) * 1.2;
6122
+ const centerX = rect.left + rect.width / 2;
6123
+ for (const card of cards) {
6124
+ if (card.el === el || el.contains(card.el) || card.el.contains(el)) continue;
6125
+ const ix = Math.max(0, Math.min(rect.right, card.rect.right) - Math.max(rect.left, card.rect.left));
6126
+ const iy = Math.max(0, Math.min(rect.bottom, card.rect.bottom) - Math.max(rect.top, card.rect.top));
6127
+ if (ix < 8 || iy < 0.5 * lineHeight) continue;
6128
+ // The headline's bulk must sit outside the card — only its edge clips in.
6129
+ if (centerX >= card.rect.left && centerX <= card.rect.right) continue;
6130
+ if (ix > 0.5 * rect.width) continue;
6131
+ seenVictims.add(el);
6132
+ findings.push({
6133
+ el,
6134
+ type: 'text-occlusion',
6135
+ detail: `${classSelector(el)} "${text.slice(0, 24)}" overhangs ${classSelector(card.el)} by ${Math.round(ix)}px — the headline and the card collide`,
6136
+ });
6137
+ break;
6138
+ }
6139
+ }
6140
+
6141
+ // (iii) Inline padding leak: an inline element with an opaque background and
6142
+ // large vertical padding paints a filled block whose padding-box overflows
6143
+ // its line (inline padding reserves no vertical space), so the fill lands on
6144
+ // the content above and below instead of enclosing its own text. The
6145
+ // canonical bug is a class-name collision that hands a decorative marker a
6146
+ // payoff card's padding. The tell is a rendered height several times the line
6147
+ // height, which distinguishes the leak from a padded inline highlight.
6148
+ for (const el of document.querySelectorAll('body *')) {
6149
+ if (el.closest('svg')) continue;
6150
+ if (!isPaintedForOcclusion(el)) continue;
6151
+ const cs = getComputedStyle(el);
6152
+ if (cs.display !== 'inline') continue;
6153
+ const bg = parseAnyColor(cs.backgroundColor || '');
6154
+ if (!bg || (bg.a ?? 1) <= 0.6) continue;
6155
+ const padTop = parseFloat(cs.paddingTop) || 0;
6156
+ const padBottom = parseFloat(cs.paddingBottom) || 0;
6157
+ if (padTop + padBottom < 24) continue;
6158
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
6159
+ if (rect.width < 12 || rect.height < 24) continue;
6160
+ const fontSize = parseFloat(cs.fontSize) || 16;
6161
+ let lineHeight = parseFloat(cs.lineHeight);
6162
+ if (!Number.isFinite(lineHeight)) lineHeight = fontSize * 1.4;
6163
+ // The padding box has to overflow the line by a clear margin — a padded
6164
+ // inline highlight sits at roughly one line height, the leak at several.
6165
+ if (rect.height < 2.2 * lineHeight) continue;
6166
+ if (seenVictims.has(el)) continue;
6167
+ // Name a neighbour the fill lands on, if one is nearby (paint state aside,
6168
+ // reveal-on-scroll siblings still occupy the space it covers).
6169
+ let overlaps = null;
6170
+ for (const other of el.parentElement ? el.parentElement.children : []) {
6171
+ if (other === el || el.contains(other) || other.contains(el)) continue;
6172
+ if (getComputedStyle(other).display === 'none') continue;
6173
+ const oRect = other.getBoundingClientRect();
6174
+ const ix = Math.max(0, Math.min(rect.right, oRect.right) - Math.max(rect.left, oRect.left));
6175
+ const iy = Math.max(0, Math.min(rect.bottom, oRect.bottom) - Math.max(rect.top, oRect.top));
6176
+ if (ix > 4 && iy > 4 && (other.textContent || '').trim().length > 0) { overlaps = other; break; }
6177
+ }
6178
+ seenVictims.add(el);
6179
+ findings.push({
6180
+ el,
6181
+ type: 'text-occlusion',
6182
+ detail: `${classSelector(el)} is an inline element whose opaque fill leaks ${Math.round(rect.height)}px past its line${overlaps ? ` onto ${classSelector(overlaps)}` : ''}`,
6183
+ });
6184
+ }
6185
+
6186
+ return findings;
6187
+ }
6188
+
6189
+ // ---------------------------------------------------------------------------
6190
+ // First-viewport column overflow — the stretched-hero signature (browser-only)
6191
+ // ---------------------------------------------------------------------------
6192
+
6193
+ // A multi-column composition that opens the page (grid/flex with two or more
6194
+ // side-by-side columns, each a real share of the width) where one column's
6195
+ // content runs far past the fold while its sibling fits inside a single
6196
+ // viewport. The row stretches to the tall column, so the short one floats in a
6197
+ // screen-and-a-half of dead space and the fold falls deep inside a single
6198
+ // section. Single-column pages and full-page heroes (no sibling column) are
6199
+ // exempt because there is no fitting sibling to contrast against.
6200
+ function checkFirstViewportColumnOverflowDOM() {
6201
+ const findings = [];
6202
+ const vw = window.innerWidth || 1280;
6203
+ const vh = window.innerHeight || 800;
6204
+ const isMultiCol = (s) => /(^|inline-)(grid|flex)$/.test(String(s.display || ''));
6205
+
6206
+ for (const el of document.querySelectorAll('body *')) {
6207
+ const style = getComputedStyle(el);
6208
+ if (!isMultiCol(style)) continue;
6209
+ let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
6210
+ if (rect.width < 0.5 * vw) continue;
6211
+ const pageTop = rect.top + (window.scrollY || 0);
6212
+ const pageBottom = pageTop + rect.height;
6213
+ // The fold must fall inside this container: it opens within the first
6214
+ // viewport and runs past it.
6215
+ if (pageTop >= vh * 0.9 || pageBottom <= vh) continue;
6216
+
6217
+ // Direct children that read as side-by-side columns: a real width share,
6218
+ // not full-bleed (stacked single column), sharing the container's top row.
6219
+ const cols = [];
6220
+ for (const child of el.children) {
6221
+ const cs = getComputedStyle(child);
6222
+ if (cs.display === 'none') continue;
6223
+ if (String(cs.position || '') === 'absolute' || String(cs.position || '') === 'fixed') continue;
6224
+ let cr; try { cr = child.getBoundingClientRect(); } catch { continue; }
6225
+ const wShare = cr.width / rect.width;
6226
+ if (wShare < 0.25 || wShare > 0.9) continue;
6227
+ if (cr.height < 40) continue;
6228
+ // Content extent: how far the child's own content actually reaches,
6229
+ // independent of a stretched row height.
6230
+ let contentBottom = cr.top;
6231
+ for (const d of child.querySelectorAll('*')) {
6232
+ const ds = getComputedStyle(d);
6233
+ if (ds.position === 'absolute' || ds.position === 'fixed') continue;
6234
+ if (ds.display === 'none' || ds.visibility === 'hidden') continue;
6235
+ let dr; try { dr = d.getBoundingClientRect(); } catch { continue; }
6236
+ if (dr.width > 0 && dr.height > 0) contentBottom = Math.max(contentBottom, dr.bottom);
6237
+ }
6238
+ cols.push({ child, top: cr.top, contentH: contentBottom - cr.top });
6239
+ }
6240
+ if (cols.length < 2) continue;
6241
+ // Side-by-side: the two candidate columns must share the top row.
6242
+ cols.sort((a, b) => b.contentH - a.contentH);
6243
+ const tall = cols[0];
6244
+ const shortest = cols[cols.length - 1];
6245
+ if (Math.abs(tall.top - shortest.top) > 0.25 * vh) continue;
6246
+ if (tall.contentH <= vh * 1.4) continue;
6247
+ if (shortest.contentH > vh) continue;
6248
+
6249
+ findings.push({
6250
+ el,
6251
+ type: 'first-viewport-column-overflow',
6252
+ 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`,
6253
+ });
6254
+ }
6255
+ return findings;
6256
+ }
6257
+
3199
6258
  // --- cli/engine/browser/injected/index.mjs ---
3200
6259
  const IS_BROWSER = typeof window !== 'undefined';
3201
6260
 
@@ -3729,7 +6788,11 @@ if (IS_BROWSER) {
3729
6788
  function generateSelector(el) {
3730
6789
  if (el === document.body) return 'body';
3731
6790
  if (el === document.documentElement) return 'html';
3732
- if (el.id) return '#' + CSS.escape(el.id);
6791
+ // Read via getAttribute when `el.id` is not a string — a <form> with a
6792
+ // named control (e.g. <input name="id">) shadows the builtin getter and
6793
+ // returns the element, producing a garbage `#[object …]` selector (#407).
6794
+ const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
6795
+ if (elId) return '#' + CSS.escape(elId);
3733
6796
 
3734
6797
  const parts = [];
3735
6798
  let current = el;
@@ -4421,7 +7484,11 @@ if (IS_BROWSER) {
4421
7484
  return {
4422
7485
  type: f.type || f.id,
4423
7486
  category: ap ? ap.category : 'quality',
4424
- severity: ap?.severity || 'warning',
7487
+ severity: f.severity || ap?.severity || 'warning',
7488
+ // Advisory findings (em-dash overuse, etc.) are surfaced but never
7489
+ // treated as failures; carry the flag so the overlay/extension can
7490
+ // render them with the mildest affordance and consumers can filter.
7491
+ advisory: (ap && ap.advisory === true) || f.advisory === true,
4425
7492
  detail: f.detail || f.snippet,
4426
7493
  ignoreValue: f.ignoreValue || f.value || '',
4427
7494
  name: ap ? ap.name : (f.type || f.id),
@@ -4657,16 +7724,16 @@ if (IS_BROWSER) {
4657
7724
  const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
4658
7725
  const designSystem = browserDesignSystemConfig();
4659
7726
  const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
4660
- // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
4661
- // real browser env (detector page, live overlay, extension) running every
4662
- // check is free, so we always surface them; the gating is purely a CLI
4663
- // output concern, applied in the Node engines' detect* return paths.
7727
+ // All deterministic rules run in the browser and extension path.
4664
7728
 
4665
7729
  for (const el of document.querySelectorAll('*')) {
4666
7730
  // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
4667
7731
  if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
4668
- // Skip browser extension elements (Claude, etc.)
4669
- const elId = el.id || '';
7732
+ // Skip browser extension elements (Claude, etc.). Use getAttribute when
7733
+ // `el.id` is not a string: a <form> with a named control like
7734
+ // <input name="id"> shadows the builtin `id` getter and returns the
7735
+ // element, whose `.startsWith` throws (issue #407).
7736
+ const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
4670
7737
  if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
4671
7738
  // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
4672
7739
  // These are inspector chrome, not part of the user's design.
@@ -4676,10 +7743,12 @@ if (IS_BROWSER) {
4676
7743
 
4677
7744
  const findings = [
4678
7745
  ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
7746
+ ...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4679
7747
  ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4680
7748
  ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4681
7749
  ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4682
7750
  ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
7751
+ ...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4683
7752
  ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4684
7753
  ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4685
7754
  ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
@@ -4687,6 +7756,7 @@ if (IS_BROWSER) {
4687
7756
  ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4688
7757
  ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
4689
7758
  ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
7759
+ ...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
4690
7760
  ...checkElementDesignSystemDOM(el, designSystem, designSeen),
4691
7761
  ].filter(f => _ruleOk(f.type));
4692
7762
 
@@ -4717,7 +7787,7 @@ if (IS_BROWSER) {
4717
7787
  addBrowserFindings(groupMap, document.body, typoFindings);
4718
7788
  }
4719
7789
 
4720
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
7790
+ const sectionKickerFindings = checkKickerAboveHeadingDOM()
4721
7791
  .map(f => ({ type: f.id, detail: f.snippet }))
4722
7792
  .filter(f => _ruleOk(f.type));
4723
7793
  if (sectionKickerFindings.length > 0) {
@@ -4725,12 +7795,66 @@ if (IS_BROWSER) {
4725
7795
  addBrowserFindings(groupMap, document.body, sectionKickerFindings);
4726
7796
  }
4727
7797
 
7798
+ const numberedLabelFindings = checkNumberedSectionLabelsDOM()
7799
+ .map(f => ({ type: f.id, detail: f.snippet }))
7800
+ .filter(f => _ruleOk(f.type));
7801
+ if (numberedLabelFindings.length > 0) {
7802
+ pageLevelFindings.push(...numberedLabelFindings);
7803
+ addBrowserFindings(groupMap, document.body, numberedLabelFindings);
7804
+ }
7805
+
7806
+ const repeatedTextFindings = checkRepeatedContainerTextDOM()
7807
+ .map(f => ({ type: f.id, detail: f.snippet }))
7808
+ .filter(f => _ruleOk(f.type));
7809
+ if (repeatedTextFindings.length > 0) {
7810
+ pageLevelFindings.push(...repeatedTextFindings);
7811
+ addBrowserFindings(groupMap, document.body, repeatedTextFindings);
7812
+ }
7813
+
7814
+ // Em-dash overuse (advisory): browser parity with the static/regex path.
7815
+ // Reads rendered body text so it catches dashes written as HTML entities.
7816
+ // serializeFindings stamps the advisory flag from the registry.
7817
+ const emDashFindings = checkEmDashOveruseDOM()
7818
+ .map(f => ({ type: f.id, detail: f.snippet }))
7819
+ .filter(f => _ruleOk(f.type));
7820
+ if (emDashFindings.length > 0) {
7821
+ pageLevelFindings.push(...emDashFindings);
7822
+ addBrowserFindings(groupMap, document.body, emDashFindings);
7823
+ }
7824
+
4728
7825
  const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
4729
7826
  for (const f of layoutFindings) {
4730
7827
  const el = f.el || document.body;
4731
7828
  addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
4732
7829
  }
4733
7830
 
7831
+ // Heading rhythm (browser-only: needs real layout for the gap math)
7832
+ const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type));
7833
+ for (const f of headingRhythmFindings) {
7834
+ addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
7835
+ }
7836
+
7837
+ // Edge-flush cards in horizontal scrollers (browser-only: needs real
7838
+ // layout for the scroller clip box vs card rect math)
7839
+ const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
7840
+ for (const f of edgeFlushFindings) {
7841
+ addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
7842
+ }
7843
+
7844
+ // Text occlusion / element overlap (browser-only: needs real layout +
7845
+ // elementFromPoint to confirm what actually paints on top)
7846
+ const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type));
7847
+ for (const f of occlusionFindings) {
7848
+ addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
7849
+ }
7850
+
7851
+ // First-viewport column overflow — the stretched-hero signature
7852
+ // (browser-only: needs real layout for the content-extent math)
7853
+ const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type));
7854
+ for (const f of colOverflowFindings) {
7855
+ addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
7856
+ }
7857
+
4734
7858
  // Page-level quality checks (headings, etc.)
4735
7859
  const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
4736
7860
  if (qualityFindings.length > 0) {
@@ -4756,7 +7880,25 @@ if (IS_BROWSER) {
4756
7880
  }
4757
7881
  const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
4758
7882
  if (htmlPatternFindings.length > 0) {
4759
- const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
7883
+ const mapped = htmlPatternFindings.map(f => {
7884
+ const item = { type: f.id, detail: f.snippet };
7885
+ if (f.severity) {
7886
+ item.severity = f.severity;
7887
+ } else if (f.id === 'pulsing-dot' && f.selector) {
7888
+ // The string scan promotes header/nav dots on its own; with a live
7889
+ // layout also promote dots resting in the first ~900px of the page
7890
+ // (the hero region), which the source scan cannot measure.
7891
+ try {
7892
+ const dotEl = document.querySelector(f.selector);
7893
+ if (dotEl) {
7894
+ const rect = dotEl.getBoundingClientRect();
7895
+ const pageTop = rect.top + (window.scrollY || 0);
7896
+ if (pageTop <= 900) item.severity = 'error';
7897
+ }
7898
+ } catch { /* unresolvable selector: keep registry severity */ }
7899
+ }
7900
+ return item;
7901
+ }).filter(f => _ruleOk(f.type));
4760
7902
  pageLevelFindings.push(...mapped);
4761
7903
  addBrowserFindings(groupMap, document.body, mapped);
4762
7904
  }
@@ -5130,6 +8272,9 @@ if (IS_BROWSER) {
5130
8272
  window.impeccableDetectAsync = detectAsync;
5131
8273
  window.impeccableScan = scan;
5132
8274
  window.impeccableScanAsync = scanAsync;
8275
+ // Raw measurement for the URL engine's content-hidden-at-rest pass: it
8276
+ // drives a reveal sweep from Node and thresholds the result itself.
8277
+ window.impeccableMeasureHiddenText = measureHiddenTextDOM;
5133
8278
  window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
5134
8279
  window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
5135
8280
  window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();