@bastani/atomic 0.9.13 → 0.9.14-alpha.2

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 (762) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +3 -3
  3. package/dist/builtin/intercom/package.json +2 -2
  4. package/dist/builtin/mcp/package.json +3 -3
  5. package/dist/builtin/subagents/CHANGELOG.md +14 -0
  6. package/dist/builtin/subagents/README.md +6 -4
  7. package/dist/builtin/subagents/agents/code-simplifier.md +1 -1
  8. package/dist/builtin/subagents/agents/codebase-analyzer.md +1 -1
  9. package/dist/builtin/subagents/agents/codebase-locator.md +1 -1
  10. package/dist/builtin/subagents/agents/codebase-online-researcher.md +1 -1
  11. package/dist/builtin/subagents/agents/codebase-pattern-finder.md +1 -1
  12. package/dist/builtin/subagents/agents/codebase-research-analyzer.md +1 -1
  13. package/dist/builtin/subagents/agents/codebase-research-locator.md +1 -1
  14. package/dist/builtin/subagents/agents/debugger.md +1 -1
  15. package/dist/builtin/subagents/agents/worker.md +1 -1
  16. package/dist/builtin/subagents/package.json +6 -5
  17. package/dist/builtin/subagents/src/agents/agent-discovery.ts +17 -11
  18. package/dist/builtin/subagents/src/agents/agent-loaders.ts +115 -43
  19. package/dist/builtin/subagents/src/agents/agent-management-helpers.ts +10 -8
  20. package/dist/builtin/subagents/src/agents/agent-management.ts +8 -7
  21. package/dist/builtin/subagents/src/agents/agent-overrides.ts +2 -2
  22. package/dist/builtin/subagents/src/agents/agent-scope.ts +1 -1
  23. package/dist/builtin/subagents/src/agents/agent-selection.ts +1 -1
  24. package/dist/builtin/subagents/src/agents/agent-serializer.ts +24 -3
  25. package/dist/builtin/subagents/src/agents/agent-types.ts +3 -1
  26. package/dist/builtin/subagents/src/agents/agents.ts +7 -5
  27. package/dist/builtin/subagents/src/agents/frontmatter.ts +67 -22
  28. package/dist/builtin/subagents/src/agents/identity.ts +1 -1
  29. package/dist/builtin/subagents/src/agents/skills-paths.ts +1 -1
  30. package/dist/builtin/subagents/src/agents/skills.ts +64 -6
  31. package/dist/builtin/subagents/src/extension/config.ts +1 -1
  32. package/dist/builtin/subagents/src/extension/control-notices.ts +2 -2
  33. package/dist/builtin/subagents/src/extension/doctor.ts +15 -5
  34. package/dist/builtin/subagents/src/extension/fanout-child.ts +12 -12
  35. package/dist/builtin/subagents/src/extension/index.ts +23 -23
  36. package/dist/builtin/subagents/src/extension/notification-content.ts +1 -1
  37. package/dist/builtin/subagents/src/extension/schemas.ts +1 -1
  38. package/dist/builtin/subagents/src/extension/startup-maintenance.ts +3 -3
  39. package/dist/builtin/subagents/src/intercom/intercom-bridge.ts +3 -3
  40. package/dist/builtin/subagents/src/intercom/result-intercom.ts +1 -1
  41. package/dist/builtin/subagents/src/intercom/supervisor-authorization.ts +1 -1
  42. package/dist/builtin/subagents/src/runs/foreground/completion-notification.ts +2 -2
  43. package/dist/builtin/subagents/src/runs/foreground/execution-detach-reservations.ts +1 -1
  44. package/dist/builtin/subagents/src/runs/foreground/execution-intercom-detach.ts +3 -3
  45. package/dist/builtin/subagents/src/runs/foreground/execution-run-sync.ts +1 -1
  46. package/dist/builtin/subagents/src/runs/foreground/execution-updates.ts +2 -2
  47. package/dist/builtin/subagents/src/runs/foreground/execution-utils.ts +1 -1
  48. package/dist/builtin/subagents/src/runs/foreground/execution.ts +2 -2
  49. package/dist/builtin/subagents/src/runs/foreground/inprocess-run-sync.ts +27 -11
  50. package/dist/builtin/subagents/src/runs/foreground/notify.ts +3 -3
  51. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-context.ts +11 -11
  52. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-input.ts +3 -3
  53. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-parallel-task.ts +15 -14
  54. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-parallel.ts +19 -19
  55. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-runtime.ts +2 -2
  56. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-single.ts +14 -13
  57. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-status.ts +9 -9
  58. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-types.ts +7 -7
  59. package/dist/builtin/subagents/src/runs/foreground/subagent-executor-worktree.ts +6 -6
  60. package/dist/builtin/subagents/src/runs/foreground/subagent-executor.ts +24 -23
  61. package/dist/builtin/subagents/src/runs/inprocess/control-registry.ts +1 -1
  62. package/dist/builtin/subagents/src/runs/inprocess/control-status.ts +3 -3
  63. package/dist/builtin/subagents/src/runs/inprocess/index.ts +3 -3
  64. package/dist/builtin/subagents/src/runs/inprocess/prompt-behavior.ts +17 -8
  65. package/dist/builtin/subagents/src/runs/inprocess/runner.ts +60 -12
  66. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-api.ts +8 -8
  67. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-control.ts +3 -3
  68. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-core.ts +4 -4
  69. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-projection.ts +4 -4
  70. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-registry.ts +5 -5
  71. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-rendering.ts +3 -3
  72. package/dist/builtin/subagents/src/runs/inprocess/runtime-support/nested-sanitize.ts +3 -3
  73. package/dist/builtin/subagents/src/runs/inprocess.ts +1 -1
  74. package/dist/builtin/subagents/src/runs/shared/long-running-guard.ts +1 -1
  75. package/dist/builtin/subagents/src/runs/shared/mcp-direct-tool-allowlist.ts +1 -1
  76. package/dist/builtin/subagents/src/runs/shared/model-candidate-filter.ts +3 -3
  77. package/dist/builtin/subagents/src/runs/shared/model-fallback.ts +2 -2
  78. package/dist/builtin/subagents/src/runs/shared/parallel-utils.ts +1 -1
  79. package/dist/builtin/subagents/src/runs/shared/single-output.ts +1 -1
  80. package/dist/builtin/subagents/src/runs/shared/subagent-control.ts +1 -1
  81. package/dist/builtin/subagents/src/shared/artifacts.ts +2 -2
  82. package/dist/builtin/subagents/src/shared/event-jsonl-writer.ts +1 -1
  83. package/dist/builtin/subagents/src/shared/fast-mode.ts +1 -1
  84. package/dist/builtin/subagents/src/shared/formatters.ts +2 -2
  85. package/dist/builtin/subagents/src/shared/jsonl-writer.ts +1 -1
  86. package/dist/builtin/subagents/src/shared/model-resolution.ts +1 -1
  87. package/dist/builtin/subagents/src/shared/session-tokens.ts +1 -1
  88. package/dist/builtin/subagents/src/shared/settings.ts +4 -4
  89. package/dist/builtin/subagents/src/shared/status-format.ts +1 -1
  90. package/dist/builtin/subagents/src/shared/types-config.ts +16 -4
  91. package/dist/builtin/subagents/src/shared/types-depth.ts +1 -1
  92. package/dist/builtin/subagents/src/shared/types-nested.ts +1 -1
  93. package/dist/builtin/subagents/src/shared/types-output.ts +1 -1
  94. package/dist/builtin/subagents/src/shared/types-results.ts +1 -1
  95. package/dist/builtin/subagents/src/shared/types-runtime.ts +1 -1
  96. package/dist/builtin/subagents/src/shared/types.ts +6 -6
  97. package/dist/builtin/subagents/src/shared/utils.ts +3 -3
  98. package/dist/builtin/subagents/src/slash/prompt-template-bridge.ts +1 -1
  99. package/dist/builtin/subagents/src/slash/slash-bridge.ts +3 -3
  100. package/dist/builtin/subagents/src/slash/slash-commands.ts +6 -6
  101. package/dist/builtin/subagents/src/slash/slash-live-state.ts +3 -3
  102. package/dist/builtin/subagents/src/tui/render-event-formatting.ts +2 -2
  103. package/dist/builtin/subagents/src/tui/render-layout.ts +1 -1
  104. package/dist/builtin/subagents/src/tui/render-progress.ts +2 -2
  105. package/dist/builtin/subagents/src/tui/render-result-compact.ts +7 -7
  106. package/dist/builtin/subagents/src/tui/render-result.ts +10 -10
  107. package/dist/builtin/subagents/src/tui/render-stable-output.ts +1 -1
  108. package/dist/builtin/subagents/src/tui/render-status-progress.ts +5 -5
  109. package/dist/builtin/subagents/src/tui/render.ts +4 -4
  110. package/dist/builtin/web-access/package.json +2 -2
  111. package/dist/builtin/workflows/CHANGELOG.md +57 -0
  112. package/dist/builtin/workflows/README.md +39 -8
  113. package/dist/builtin/workflows/builtin/adversarial-verification-runner.ts +16 -5
  114. package/dist/builtin/workflows/builtin/adversarial-verification.ts +4 -0
  115. package/dist/builtin/workflows/builtin/classify-and-act.ts +4 -0
  116. package/dist/builtin/workflows/builtin/fan-out-and-synthesize.ts +4 -0
  117. package/dist/builtin/workflows/builtin/generate-and-filter-runner.ts +12 -4
  118. package/dist/builtin/workflows/builtin/generate-and-filter.ts +4 -0
  119. package/dist/builtin/workflows/builtin/goal-models.ts +8 -10
  120. package/dist/builtin/workflows/builtin/goal.ts +4 -0
  121. package/dist/builtin/workflows/builtin/index.d.ts +0 -4
  122. package/dist/builtin/workflows/builtin/loop-until-done-runner.ts +4 -2
  123. package/dist/builtin/workflows/builtin/loop-until-done.ts +4 -0
  124. package/dist/builtin/workflows/builtin/open-claude-design-live-protocol.ts +305 -0
  125. package/dist/builtin/workflows/builtin/open-claude-design-phases.ts +115 -171
  126. package/dist/builtin/workflows/builtin/open-claude-design-runner.ts +9 -25
  127. package/dist/builtin/workflows/builtin/open-claude-design-setup.ts +19 -86
  128. package/dist/builtin/workflows/builtin/open-claude-design-utils.ts +0 -6
  129. package/dist/builtin/workflows/builtin/open-claude-design.d.ts +0 -5
  130. package/dist/builtin/workflows/builtin/open-claude-design.ts +11 -15
  131. package/dist/builtin/workflows/builtin/ralph-models.ts +24 -29
  132. package/dist/builtin/workflows/builtin/ralph.ts +4 -0
  133. package/dist/builtin/workflows/builtin/tournament-runner.ts +6 -3
  134. package/dist/builtin/workflows/builtin/tournament.ts +4 -0
  135. package/dist/builtin/workflows/package.json +2 -2
  136. package/dist/builtin/workflows/skills/impeccable/SKILL.md +3 -3
  137. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_asset_producer.toml +25 -31
  138. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_documenter.toml +1 -0
  139. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_finish_reviewer.toml +16 -10
  140. package/dist/builtin/workflows/skills/impeccable/agents/impeccable_manual_edit_applier.toml +2 -2
  141. package/dist/builtin/workflows/skills/impeccable/reference/android.md +6 -0
  142. package/dist/builtin/workflows/skills/impeccable/reference/animate.md +3 -0
  143. package/dist/builtin/workflows/skills/impeccable/reference/bolder.md +3 -1
  144. package/dist/builtin/workflows/skills/impeccable/reference/craft-floor.md +6 -1
  145. package/dist/builtin/workflows/skills/impeccable/reference/critique.md +18 -2
  146. package/dist/builtin/workflows/skills/impeccable/reference/degraded/asset-producer.md +24 -30
  147. package/dist/builtin/workflows/skills/impeccable/reference/degraded/documenter.md +1 -0
  148. package/dist/builtin/workflows/skills/impeccable/reference/degraded/finish-reviewer.md +16 -10
  149. package/dist/builtin/workflows/skills/impeccable/reference/degraded/manual-edit-applier.md +2 -2
  150. package/dist/builtin/workflows/skills/impeccable/reference/distill.md +1 -1
  151. package/dist/builtin/workflows/skills/impeccable/reference/doctor.md +1 -0
  152. package/dist/builtin/workflows/skills/impeccable/reference/document.md +1 -1
  153. package/dist/builtin/workflows/skills/impeccable/reference/extract.md +1 -1
  154. package/dist/builtin/workflows/skills/impeccable/reference/hooks.md +12 -6
  155. package/dist/builtin/workflows/skills/impeccable/reference/init.md +12 -3
  156. package/dist/builtin/workflows/skills/impeccable/reference/ios.md +6 -0
  157. package/dist/builtin/workflows/skills/impeccable/reference/live-setup.md +102 -0
  158. package/dist/builtin/workflows/skills/impeccable/reference/live.md +113 -521
  159. package/dist/builtin/workflows/skills/impeccable/reference/new-work.md +39 -26
  160. package/dist/builtin/workflows/skills/impeccable/reference/overdrive.md +1 -1
  161. package/dist/builtin/workflows/skills/impeccable/reference/polish.md +3 -3
  162. package/dist/builtin/workflows/skills/impeccable/reference/quieter.md +1 -1
  163. package/dist/builtin/workflows/skills/impeccable/reference/visualize.md +25 -11
  164. package/dist/builtin/workflows/skills/impeccable/scripts/concept-seed.mjs +332 -221
  165. package/dist/builtin/workflows/skills/impeccable/scripts/context-signals.mjs +10 -19
  166. package/dist/builtin/workflows/skills/impeccable/scripts/context.mjs +91 -38
  167. package/dist/builtin/workflows/skills/impeccable/scripts/critique-storage.mjs +19 -10
  168. package/dist/builtin/workflows/skills/impeccable/scripts/detector/browser/injected/index.mjs +56 -6
  169. package/dist/builtin/workflows/skills/impeccable/scripts/detector/cli/main.mjs +10 -16
  170. package/dist/builtin/workflows/skills/impeccable/scripts/detector/design-system.mjs +140 -2
  171. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +1174 -694
  172. package/dist/builtin/workflows/skills/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  173. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +5 -1
  174. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +431 -45
  175. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +10 -1
  176. package/dist/builtin/workflows/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +47 -6
  177. package/dist/builtin/workflows/skills/impeccable/scripts/detector/node/file-system.mjs +23 -22
  178. package/dist/builtin/workflows/skills/impeccable/scripts/detector/registry/antipatterns.mjs +0 -10
  179. package/dist/builtin/workflows/skills/impeccable/scripts/detector/rules/checks.mjs +384 -384
  180. package/dist/builtin/workflows/skills/impeccable/scripts/detector/shared/color.mjs +466 -2
  181. package/dist/builtin/workflows/skills/impeccable/scripts/doctor.mjs +2 -0
  182. package/dist/builtin/workflows/skills/impeccable/scripts/embed-prompt.mjs +270 -0
  183. package/dist/builtin/workflows/skills/impeccable/scripts/generate-image.mjs +51 -6
  184. package/dist/builtin/workflows/skills/impeccable/scripts/hook-admin.mjs +70 -10
  185. package/dist/builtin/workflows/skills/impeccable/scripts/hook-lib.mjs +336 -90
  186. package/dist/builtin/workflows/skills/impeccable/scripts/lib/composition-catalog.mjs +38 -3
  187. package/dist/builtin/workflows/skills/impeccable/scripts/lib/concept-catalog.mjs +77 -11
  188. package/dist/builtin/workflows/skills/impeccable/scripts/lib/design-parser.mjs +102 -19
  189. package/dist/builtin/workflows/skills/impeccable/scripts/lib/impeccable-config.mjs +44 -59
  190. package/dist/builtin/workflows/skills/impeccable/scripts/lib/is-generated.mjs +3 -0
  191. package/dist/builtin/workflows/skills/impeccable/scripts/lib/open-system-browser.mjs +57 -0
  192. package/dist/builtin/workflows/skills/impeccable/scripts/lib/roll-selection.mjs +369 -0
  193. package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  194. package/dist/builtin/workflows/skills/impeccable/scripts/lib/staleness.mjs +73 -2
  195. package/dist/builtin/workflows/skills/impeccable/scripts/live/accept-css.mjs +617 -0
  196. package/dist/builtin/workflows/skills/impeccable/scripts/live/accept-verify.mjs +71 -0
  197. package/dist/builtin/workflows/skills/impeccable/scripts/live/browser-script-parts.mjs +29 -2
  198. package/dist/builtin/workflows/skills/impeccable/scripts/live/event-validation.mjs +54 -7
  199. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/astro.mjs +47 -0
  200. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/detect-utils.mjs +157 -0
  201. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/index.mjs +143 -0
  202. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/journal.mjs +205 -0
  203. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/nextjs.mjs +49 -0
  204. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/nuxt.mjs +170 -0
  205. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/script-src.mjs +26 -0
  206. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/static-html.mjs +26 -0
  207. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/sveltekit.mjs +71 -0
  208. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs +247 -0
  209. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs +70 -0
  210. package/dist/builtin/workflows/skills/impeccable/scripts/live/frameworks/vite-generic.mjs +42 -0
  211. package/dist/builtin/workflows/skills/impeccable/scripts/live/instructions.mjs +169 -0
  212. package/dist/builtin/workflows/skills/impeccable/scripts/live/roots.mjs +670 -0
  213. package/dist/builtin/workflows/skills/impeccable/scripts/live/session-store.mjs +232 -35
  214. package/dist/builtin/workflows/skills/impeccable/scripts/live/svelte-ast.mjs +969 -0
  215. package/dist/builtin/workflows/skills/impeccable/scripts/live/svelte-component.mjs +621 -74
  216. package/dist/builtin/workflows/skills/impeccable/scripts/live/sveltekit-adapter.mjs +66 -28
  217. package/dist/builtin/workflows/skills/impeccable/scripts/live/tanstack-adapter.mjs +12 -12
  218. package/dist/builtin/workflows/skills/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  219. package/dist/builtin/workflows/skills/impeccable/scripts/live/vocabulary.mjs +135 -0
  220. package/dist/builtin/workflows/skills/impeccable/scripts/live-accept.mjs +23 -37
  221. package/dist/builtin/workflows/skills/impeccable/scripts/live-browser.js +945 -115
  222. package/dist/builtin/workflows/skills/impeccable/scripts/live-complete.mjs +33 -1
  223. package/dist/builtin/workflows/skills/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  224. package/dist/builtin/workflows/skills/impeccable/scripts/live-inject.mjs +173 -425
  225. package/dist/builtin/workflows/skills/impeccable/scripts/live-insert.mjs +2 -0
  226. package/dist/builtin/workflows/skills/impeccable/scripts/live-poll.mjs +16 -3
  227. package/dist/builtin/workflows/skills/impeccable/scripts/live-resume.mjs +39 -10
  228. package/dist/builtin/workflows/skills/impeccable/scripts/live-server.mjs +194 -27
  229. package/dist/builtin/workflows/skills/impeccable/scripts/live-status.mjs +10 -4
  230. package/dist/builtin/workflows/skills/impeccable/scripts/live-wrap.mjs +50 -31
  231. package/dist/builtin/workflows/skills/impeccable/scripts/live.mjs +91 -28
  232. package/dist/builtin/workflows/skills/impeccable/scripts/pin.mjs +8 -5
  233. package/dist/builtin/workflows/skills/impeccable/scripts/serve-question.mjs +759 -109
  234. package/dist/builtin/workflows/src/authoring/workflow.ts +9 -0
  235. package/dist/builtin/workflows/src/authoring.d.ts +3 -0
  236. package/dist/builtin/workflows/src/durable/completed-catalog.ts +23 -2
  237. package/dist/builtin/workflows/src/durable/dbos-backend.ts +30 -17
  238. package/dist/builtin/workflows/src/durable/dbos-envelope.ts +14 -0
  239. package/dist/builtin/workflows/src/durable/dbos-lifecycle.ts +60 -57
  240. package/dist/builtin/workflows/src/durable/dbos-process-owner.ts +87 -0
  241. package/dist/builtin/workflows/src/durable/dbos-registration-diagnostics.ts +113 -0
  242. package/dist/builtin/workflows/src/durable/factory.ts +11 -8
  243. package/dist/builtin/workflows/src/durable/tool-failure-checkpoint.ts +5 -0
  244. package/dist/builtin/workflows/src/durable/tool-primitive.ts +186 -20
  245. package/dist/builtin/workflows/src/durable/types.ts +4 -0
  246. package/dist/builtin/workflows/src/durable/workflow-heartbeat-anchor.ts +153 -0
  247. package/dist/builtin/workflows/src/engine/run-durable-stage-session.ts +28 -0
  248. package/dist/builtin/workflows/src/engine/run-tool-control-registry.ts +9 -1
  249. package/dist/builtin/workflows/src/engine/run.ts +1 -0
  250. package/dist/builtin/workflows/src/extension/adopt-session-run-state.ts +20 -0
  251. package/dist/builtin/workflows/src/extension/background-ui-adapter.ts +9 -30
  252. package/dist/builtin/workflows/src/extension/extension-factory.ts +3 -0
  253. package/dist/builtin/workflows/src/extension/extension-lifecycle.ts +28 -10
  254. package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +138 -0
  255. package/dist/builtin/workflows/src/extension/index.bundle.mjs +3365 -1341
  256. package/dist/builtin/workflows/src/extension/workflow-heartbeat-delivery.ts +197 -0
  257. package/dist/builtin/workflows/src/extension/workflow-heartbeat-notice.ts +115 -0
  258. package/dist/builtin/workflows/src/extension/workflow-heartbeat-scheduler.ts +962 -0
  259. package/dist/builtin/workflows/src/extension/workflow-prompts.ts +4 -1
  260. package/dist/builtin/workflows/src/extension/workflow-schema.ts +29 -5
  261. package/dist/builtin/workflows/src/extension/workflow-tool-send.ts +32 -1
  262. package/dist/builtin/workflows/src/runs/background/cancellation-registry.ts +10 -1
  263. package/dist/builtin/workflows/src/runs/background/job-tracker.ts +10 -1
  264. package/dist/builtin/workflows/src/runs/foreground/executor-prompt-nodes.ts +16 -11
  265. package/dist/builtin/workflows/src/runs/foreground/stage-control-registry.ts +31 -1
  266. package/dist/builtin/workflows/src/runs/foreground/stage-runner-context.ts +41 -10
  267. package/dist/builtin/workflows/src/runs/foreground/stage-runner-messages.ts +48 -0
  268. package/dist/builtin/workflows/src/runs/foreground/stage-runner-output.ts +4 -1
  269. package/dist/builtin/workflows/src/runs/foreground/stage-runner-structured-output.ts +45 -12
  270. package/dist/builtin/workflows/src/sdk-surface.ts +9 -0
  271. package/dist/builtin/workflows/src/shared/authoring-contract-ui.d.ts +10 -0
  272. package/dist/builtin/workflows/src/shared/authoring-contract-ui.ts +10 -0
  273. package/dist/builtin/workflows/src/shared/graph-store-snapshot.ts +14 -2
  274. package/dist/builtin/workflows/src/shared/persistence-restore.ts +12 -0
  275. package/dist/builtin/workflows/src/shared/prompt-answer.ts +130 -0
  276. package/dist/builtin/workflows/src/shared/session-scoped-singleton.ts +98 -0
  277. package/dist/builtin/workflows/src/shared/stage-ui-broker.ts +9 -1
  278. package/dist/builtin/workflows/src/shared/store-factory.ts +9 -1
  279. package/dist/builtin/workflows/src/shared/store-public-types.ts +2 -1
  280. package/dist/builtin/workflows/src/shared/store-tool-node-methods.ts +7 -0
  281. package/dist/builtin/workflows/src/shared/store-types.ts +18 -1
  282. package/dist/builtin/workflows/src/shared/tool-payload-bounds.ts +576 -0
  283. package/dist/builtin/workflows/src/shared/workflow-authoring-types.d.ts +1 -0
  284. package/dist/builtin/workflows/src/shared/workflow-authoring-types.ts +1 -0
  285. package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.d.ts +21 -0
  286. package/dist/builtin/workflows/src/shared/workflow-heartbeat-contract.ts +25 -0
  287. package/dist/builtin/workflows/src/tui/graph-theme.ts +8 -1
  288. package/dist/builtin/workflows/src/tui/graph-view-constants.ts +11 -0
  289. package/dist/builtin/workflows/src/tui/graph-view-input.ts +100 -5
  290. package/dist/builtin/workflows/src/tui/graph-view-layout.ts +42 -11
  291. package/dist/builtin/workflows/src/tui/graph-view-render-helpers.ts +55 -6
  292. package/dist/builtin/workflows/src/tui/graph-view-render.ts +71 -6
  293. package/dist/builtin/workflows/src/tui/graph-view-state.ts +43 -0
  294. package/dist/builtin/workflows/src/tui/keybindings-adapter.ts +2 -0
  295. package/dist/builtin/workflows/src/tui/node-card.ts +5 -1
  296. package/dist/builtin/workflows/src/tui/stage-chat-view-archive-history.ts +11 -2
  297. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +36 -10
  298. package/dist/builtin/workflows/src/tui/switcher.ts +11 -4
  299. package/dist/builtin/workflows/src/tui/tool-detail.ts +348 -0
  300. package/dist/builtin/workflows/src/tui/workflow-status.ts +5 -2
  301. package/dist/cli/args.d.ts +1 -0
  302. package/dist/cli/args.d.ts.map +1 -1
  303. package/dist/cli/args.js +11 -0
  304. package/dist/cli/args.js.map +1 -1
  305. package/dist/cli/auth-check.d.ts +1 -1
  306. package/dist/cli/auth-check.js.map +1 -1
  307. package/dist/cli/credential-print.d.ts +1 -1
  308. package/dist/cli/credential-print.js.map +1 -1
  309. package/dist/cli/list-models.d.ts +1 -1
  310. package/dist/cli/list-models.js.map +1 -1
  311. package/dist/cli/startup-ui.d.ts +9 -0
  312. package/dist/cli/startup-ui.d.ts.map +1 -1
  313. package/dist/cli/startup-ui.js +7 -1
  314. package/dist/cli/startup-ui.js.map +1 -1
  315. package/dist/core/agent-session-custom-message-commit.js +2 -2
  316. package/dist/core/agent-session-custom-message-commit.js.map +1 -1
  317. package/dist/core/agent-session-export.d.ts +6 -1
  318. package/dist/core/agent-session-export.d.ts.map +1 -1
  319. package/dist/core/agent-session-export.js +6 -3
  320. package/dist/core/agent-session-export.js.map +1 -1
  321. package/dist/core/agent-session-extension-bindings.d.ts.map +1 -1
  322. package/dist/core/agent-session-extension-bindings.js +6 -4
  323. package/dist/core/agent-session-extension-bindings.js.map +1 -1
  324. package/dist/core/agent-session-methods.d.ts +5 -2
  325. package/dist/core/agent-session-methods.d.ts.map +1 -1
  326. package/dist/core/agent-session-methods.js.map +1 -1
  327. package/dist/core/agent-session-prompt.d.ts +12 -1
  328. package/dist/core/agent-session-prompt.d.ts.map +1 -1
  329. package/dist/core/agent-session-prompt.js +31 -10
  330. package/dist/core/agent-session-prompt.js.map +1 -1
  331. package/dist/core/agent-session-runtime-auth.js.map +1 -1
  332. package/dist/core/agent-session-services.d.ts +1 -1
  333. package/dist/core/agent-session-services.js.map +1 -1
  334. package/dist/core/agent-session-skill-block.d.ts +1 -0
  335. package/dist/core/agent-session-skill-block.d.ts.map +1 -1
  336. package/dist/core/agent-session-skill-block.js +18 -2
  337. package/dist/core/agent-session-skill-block.js.map +1 -1
  338. package/dist/core/agent-session-state.d.ts.map +1 -1
  339. package/dist/core/agent-session-state.js +2 -1
  340. package/dist/core/agent-session-state.js.map +1 -1
  341. package/dist/core/agent-session-types.d.ts +2 -1
  342. package/dist/core/agent-session-types.d.ts.map +1 -1
  343. package/dist/core/agent-session-types.js.map +1 -1
  344. package/dist/core/agent-session.d.ts +1 -1
  345. package/dist/core/agent-session.js.map +1 -1
  346. package/dist/core/atomic-guide-command.js +2 -2
  347. package/dist/core/atomic-guide-command.js.map +1 -1
  348. package/dist/core/builtin-packages.d.ts.map +1 -1
  349. package/dist/core/builtin-packages.js +0 -6
  350. package/dist/core/builtin-packages.js.map +1 -1
  351. package/dist/core/cloudflare-gateway-binding.d.ts +11 -0
  352. package/dist/core/cloudflare-gateway-binding.d.ts.map +1 -0
  353. package/dist/core/cloudflare-gateway-binding.js +11 -0
  354. package/dist/core/cloudflare-gateway-binding.js.map +1 -0
  355. package/dist/core/codex-fast-mode-transport.d.ts +12 -0
  356. package/dist/core/codex-fast-mode-transport.d.ts.map +1 -0
  357. package/dist/core/codex-fast-mode-transport.js +130 -0
  358. package/dist/core/codex-fast-mode-transport.js.map +1 -0
  359. package/dist/core/codex-fast-mode.d.ts +20 -6
  360. package/dist/core/codex-fast-mode.d.ts.map +1 -1
  361. package/dist/core/codex-fast-mode.js +111 -3
  362. package/dist/core/codex-fast-mode.js.map +1 -1
  363. package/dist/core/diagnostics.d.ts +4 -0
  364. package/dist/core/diagnostics.d.ts.map +1 -1
  365. package/dist/core/diagnostics.js.map +1 -1
  366. package/dist/core/event-bus.d.ts +12 -0
  367. package/dist/core/event-bus.d.ts.map +1 -1
  368. package/dist/core/event-bus.js +22 -0
  369. package/dist/core/event-bus.js.map +1 -1
  370. package/dist/core/experimental.d.ts +25 -0
  371. package/dist/core/experimental.d.ts.map +1 -1
  372. package/dist/core/experimental.js +24 -0
  373. package/dist/core/experimental.js.map +1 -1
  374. package/dist/core/export-html/template-js/tree-filter-render.js +4 -3
  375. package/dist/core/export-html/template.js +4 -3
  376. package/dist/core/extension-session-state.d.ts +28 -0
  377. package/dist/core/extension-session-state.d.ts.map +1 -0
  378. package/dist/core/extension-session-state.js +55 -0
  379. package/dist/core/extension-session-state.js.map +1 -0
  380. package/dist/core/extensions/api-types.d.ts +6 -0
  381. package/dist/core/extensions/api-types.d.ts.map +1 -1
  382. package/dist/core/extensions/api-types.js.map +1 -1
  383. package/dist/core/extensions/context-types.d.ts +4 -0
  384. package/dist/core/extensions/context-types.d.ts.map +1 -1
  385. package/dist/core/extensions/context-types.js.map +1 -1
  386. package/dist/core/extensions/index.d.ts +1 -0
  387. package/dist/core/extensions/index.d.ts.map +1 -1
  388. package/dist/core/extensions/index.js +1 -0
  389. package/dist/core/extensions/index.js.map +1 -1
  390. package/dist/core/extensions/loader-api.d.ts +1 -1
  391. package/dist/core/extensions/loader-api.d.ts.map +1 -1
  392. package/dist/core/extensions/loader-api.js +16 -10
  393. package/dist/core/extensions/loader-api.js.map +1 -1
  394. package/dist/core/extensions/loader-virtual-modules.d.ts.map +1 -1
  395. package/dist/core/extensions/loader-virtual-modules.js +12 -1
  396. package/dist/core/extensions/loader-virtual-modules.js.map +1 -1
  397. package/dist/core/extensions/runner-context.d.ts +2 -0
  398. package/dist/core/extensions/runner-context.d.ts.map +1 -1
  399. package/dist/core/extensions/runner-context.js +8 -0
  400. package/dist/core/extensions/runner-context.js.map +1 -1
  401. package/dist/core/extensions/runner.d.ts +1 -0
  402. package/dist/core/extensions/runner.d.ts.map +1 -1
  403. package/dist/core/extensions/runner.js +3 -0
  404. package/dist/core/extensions/runner.js.map +1 -1
  405. package/dist/core/extensions/runtime-types.d.ts +3 -0
  406. package/dist/core/extensions/runtime-types.d.ts.map +1 -1
  407. package/dist/core/extensions/runtime-types.js.map +1 -1
  408. package/dist/core/extensions/ui-types.d.ts +26 -0
  409. package/dist/core/extensions/ui-types.d.ts.map +1 -1
  410. package/dist/core/extensions/ui-types.js +17 -1
  411. package/dist/core/extensions/ui-types.js.map +1 -1
  412. package/dist/core/http-dispatcher.d.ts.map +1 -1
  413. package/dist/core/http-dispatcher.js +5 -0
  414. package/dist/core/http-dispatcher.js.map +1 -1
  415. package/dist/core/keybindings.d.ts +24 -0
  416. package/dist/core/keybindings.d.ts.map +1 -1
  417. package/dist/core/keybindings.js +6 -0
  418. package/dist/core/keybindings.js.map +1 -1
  419. package/dist/core/model-config.d.ts +5 -0
  420. package/dist/core/model-config.d.ts.map +1 -1
  421. package/dist/core/model-config.js +1 -0
  422. package/dist/core/model-config.js.map +1 -1
  423. package/dist/core/model-registry.d.ts +1 -1
  424. package/dist/core/model-registry.js.map +1 -1
  425. package/dist/core/model-resolver-cli.d.ts +1 -1
  426. package/dist/core/model-resolver-cli.js.map +1 -1
  427. package/dist/core/model-resolver-defaults.js +3 -3
  428. package/dist/core/model-resolver-defaults.js.map +1 -1
  429. package/dist/core/model-resolver-initial.d.ts +1 -1
  430. package/dist/core/model-resolver-initial.js.map +1 -1
  431. package/dist/core/model-resolver-scope.d.ts +1 -1
  432. package/dist/core/model-resolver-scope.js.map +1 -1
  433. package/dist/core/model-runtime.d.ts +54 -0
  434. package/dist/core/model-runtime.d.ts.map +1 -1
  435. package/dist/core/model-runtime.js +96 -13
  436. package/dist/core/model-runtime.js.map +1 -1
  437. package/dist/core/oauth-login.js.map +1 -1
  438. package/dist/core/project-trust.d.ts +10 -0
  439. package/dist/core/project-trust.d.ts.map +1 -1
  440. package/dist/core/project-trust.js +12 -0
  441. package/dist/core/project-trust.js.map +1 -1
  442. package/dist/core/resource-loader-assets.d.ts.map +1 -1
  443. package/dist/core/resource-loader-assets.js +14 -5
  444. package/dist/core/resource-loader-assets.js.map +1 -1
  445. package/dist/core/resource-loader-core.d.ts +3 -0
  446. package/dist/core/resource-loader-core.d.ts.map +1 -1
  447. package/dist/core/resource-loader-core.js +5 -0
  448. package/dist/core/resource-loader-core.js.map +1 -1
  449. package/dist/core/resource-loader-internals.d.ts +2 -0
  450. package/dist/core/resource-loader-internals.d.ts.map +1 -1
  451. package/dist/core/resource-loader-internals.js.map +1 -1
  452. package/dist/core/resource-loader-reload.d.ts.map +1 -1
  453. package/dist/core/resource-loader-reload.js +2 -0
  454. package/dist/core/resource-loader-reload.js.map +1 -1
  455. package/dist/core/resource-loader-types.d.ts +2 -0
  456. package/dist/core/resource-loader-types.d.ts.map +1 -1
  457. package/dist/core/resource-loader-types.js.map +1 -1
  458. package/dist/core/resource-loader.d.ts +2 -0
  459. package/dist/core/resource-loader.d.ts.map +1 -1
  460. package/dist/core/resource-loader.js +1 -0
  461. package/dist/core/resource-loader.js.map +1 -1
  462. package/dist/core/sdk-types.d.ts +7 -6
  463. package/dist/core/sdk-types.d.ts.map +1 -1
  464. package/dist/core/sdk-types.js.map +1 -1
  465. package/dist/core/sdk.d.ts.map +1 -1
  466. package/dist/core/sdk.js +17 -6
  467. package/dist/core/sdk.js.map +1 -1
  468. package/dist/core/session-manager-core.d.ts +7 -1
  469. package/dist/core/session-manager-core.d.ts.map +1 -1
  470. package/dist/core/session-manager-core.js +11 -2
  471. package/dist/core/session-manager-core.js.map +1 -1
  472. package/dist/core/session-manager-entries.d.ts +2 -2
  473. package/dist/core/session-manager-entries.d.ts.map +1 -1
  474. package/dist/core/session-manager-entries.js +6 -4
  475. package/dist/core/session-manager-entries.js.map +1 -1
  476. package/dist/core/session-manager-list.d.ts.map +1 -1
  477. package/dist/core/session-manager-list.js +5 -1
  478. package/dist/core/session-manager-list.js.map +1 -1
  479. package/dist/core/session-manager-types.d.ts +25 -2
  480. package/dist/core/session-manager-types.d.ts.map +1 -1
  481. package/dist/core/session-manager-types.js.map +1 -1
  482. package/dist/core/session-manager.d.ts +1 -1
  483. package/dist/core/session-manager.d.ts.map +1 -1
  484. package/dist/core/session-manager.js.map +1 -1
  485. package/dist/core/settings-manager-basic-accessors.d.ts +1 -0
  486. package/dist/core/settings-manager-basic-accessors.d.ts.map +1 -1
  487. package/dist/core/settings-manager-basic-accessors.js +8 -0
  488. package/dist/core/settings-manager-basic-accessors.js.map +1 -1
  489. package/dist/core/settings-manager-ui-accessors.d.ts +3 -1
  490. package/dist/core/settings-manager-ui-accessors.d.ts.map +1 -1
  491. package/dist/core/settings-manager-ui-accessors.js +9 -0
  492. package/dist/core/settings-manager-ui-accessors.js.map +1 -1
  493. package/dist/core/settings-manager.d.ts +1 -1
  494. package/dist/core/settings-manager.d.ts.map +1 -1
  495. package/dist/core/settings-manager.js.map +1 -1
  496. package/dist/core/settings-types.d.ts +4 -0
  497. package/dist/core/settings-types.d.ts.map +1 -1
  498. package/dist/core/settings-types.js.map +1 -1
  499. package/dist/core/skill-catalog.d.ts +35 -0
  500. package/dist/core/skill-catalog.d.ts.map +1 -0
  501. package/dist/core/skill-catalog.js +264 -0
  502. package/dist/core/skill-catalog.js.map +1 -0
  503. package/dist/core/skills-async.d.ts.map +1 -1
  504. package/dist/core/skills-async.js +12 -6
  505. package/dist/core/skills-async.js.map +1 -1
  506. package/dist/core/skills.d.ts +2 -0
  507. package/dist/core/skills.d.ts.map +1 -1
  508. package/dist/core/skills.js +8 -5
  509. package/dist/core/skills.js.map +1 -1
  510. package/dist/core/slash-commands.d.ts.map +1 -1
  511. package/dist/core/slash-commands.js +1 -5
  512. package/dist/core/slash-commands.js.map +1 -1
  513. package/dist/core/system-prompt.js +2 -2
  514. package/dist/core/system-prompt.js.map +1 -1
  515. package/dist/core/tools/ask-user-question/ask-user-question.d.ts +25 -2
  516. package/dist/core/tools/ask-user-question/ask-user-question.d.ts.map +1 -1
  517. package/dist/core/tools/ask-user-question/ask-user-question.js +47 -18
  518. package/dist/core/tools/ask-user-question/ask-user-question.js.map +1 -1
  519. package/dist/core/tools/ask-user-question/view/components/multi-select-view.d.ts.map +1 -1
  520. package/dist/core/tools/ask-user-question/view/components/multi-select-view.js +5 -2
  521. package/dist/core/tools/ask-user-question/view/components/multi-select-view.js.map +1 -1
  522. package/dist/core/tools/ask-user-question/view/components/submit-picker.d.ts.map +1 -1
  523. package/dist/core/tools/ask-user-question/view/components/submit-picker.js +3 -1
  524. package/dist/core/tools/ask-user-question/view/components/submit-picker.js.map +1 -1
  525. package/dist/core/tools/ask-user-question/view/components/wrapping-select.d.ts.map +1 -1
  526. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js +8 -1
  527. package/dist/core/tools/ask-user-question/view/components/wrapping-select.js.map +1 -1
  528. package/dist/core/tools/bash.d.ts.map +1 -1
  529. package/dist/core/tools/bash.js +2 -0
  530. package/dist/core/tools/bash.js.map +1 -1
  531. package/dist/core/tools/edit.d.ts.map +1 -1
  532. package/dist/core/tools/edit.js +2 -0
  533. package/dist/core/tools/edit.js.map +1 -1
  534. package/dist/core/tools/find.d.ts.map +1 -1
  535. package/dist/core/tools/find.js +3 -1
  536. package/dist/core/tools/find.js.map +1 -1
  537. package/dist/core/tools/grep.js +1 -1
  538. package/dist/core/tools/grep.js.map +1 -1
  539. package/dist/core/tools/hashline-engine/format.d.ts +2 -0
  540. package/dist/core/tools/hashline-engine/format.d.ts.map +1 -1
  541. package/dist/core/tools/hashline-engine/format.js +4 -2
  542. package/dist/core/tools/hashline-engine/format.js.map +1 -1
  543. package/dist/core/tools/hashline-engine/messages.d.ts +2 -0
  544. package/dist/core/tools/hashline-engine/messages.d.ts.map +1 -1
  545. package/dist/core/tools/hashline-engine/messages.js +4 -2
  546. package/dist/core/tools/hashline-engine/messages.js.map +1 -1
  547. package/dist/core/tools/hashline-engine/parser.d.ts.map +1 -1
  548. package/dist/core/tools/hashline-engine/parser.js +16 -4
  549. package/dist/core/tools/hashline-engine/parser.js.map +1 -1
  550. package/dist/core/tools/hashline-engine/tokenizer.d.ts.map +1 -1
  551. package/dist/core/tools/hashline-engine/tokenizer.js +37 -27
  552. package/dist/core/tools/hashline-engine/tokenizer.js.map +1 -1
  553. package/dist/core/tools/hashline.d.ts.map +1 -1
  554. package/dist/core/tools/hashline.js +8 -2
  555. package/dist/core/tools/hashline.js.map +1 -1
  556. package/dist/core/tools/ls.d.ts.map +1 -1
  557. package/dist/core/tools/ls.js +2 -0
  558. package/dist/core/tools/ls.js.map +1 -1
  559. package/dist/core/tools/read.d.ts.map +1 -1
  560. package/dist/core/tools/read.js +8 -1
  561. package/dist/core/tools/read.js.map +1 -1
  562. package/dist/core/tools/search.d.ts.map +1 -1
  563. package/dist/core/tools/search.js +2 -0
  564. package/dist/core/tools/search.js.map +1 -1
  565. package/dist/core/tools/todos.d.ts +3 -12
  566. package/dist/core/tools/todos.d.ts.map +1 -1
  567. package/dist/core/tools/todos.js +19 -4
  568. package/dist/core/tools/todos.js.map +1 -1
  569. package/dist/core/tools/write.d.ts.map +1 -1
  570. package/dist/core/tools/write.js +2 -0
  571. package/dist/core/tools/write.js.map +1 -1
  572. package/dist/index-extensions.d.ts +1 -1
  573. package/dist/index-extensions.d.ts.map +1 -1
  574. package/dist/index-extensions.js +1 -1
  575. package/dist/index-extensions.js.map +1 -1
  576. package/dist/index.d.ts +8 -5
  577. package/dist/index.d.ts.map +1 -1
  578. package/dist/index.js +6 -2
  579. package/dist/index.js.map +1 -1
  580. package/dist/main-runtime-api-key.d.ts +1 -1
  581. package/dist/main-runtime-api-key.js.map +1 -1
  582. package/dist/main-session-options.d.ts +1 -1
  583. package/dist/main-session-options.js.map +1 -1
  584. package/dist/main.d.ts.map +1 -1
  585. package/dist/main.js +10 -2
  586. package/dist/main.js.map +1 -1
  587. package/dist/modes/interactive/components/chat-session-host-rendering.js +1 -1
  588. package/dist/modes/interactive/components/chat-session-host-rendering.js.map +1 -1
  589. package/dist/modes/interactive/components/chat-session-host-state.d.ts +8 -0
  590. package/dist/modes/interactive/components/chat-session-host-state.d.ts.map +1 -1
  591. package/dist/modes/interactive/components/chat-session-host-state.js +8 -0
  592. package/dist/modes/interactive/components/chat-session-host-state.js.map +1 -1
  593. package/dist/modes/interactive/components/chat-session-host.d.ts +26 -0
  594. package/dist/modes/interactive/components/chat-session-host.d.ts.map +1 -1
  595. package/dist/modes/interactive/components/chat-session-host.js +38 -0
  596. package/dist/modes/interactive/components/chat-session-host.js.map +1 -1
  597. package/dist/modes/interactive/components/chat-transcript.d.ts +37 -0
  598. package/dist/modes/interactive/components/chat-transcript.d.ts.map +1 -1
  599. package/dist/modes/interactive/components/chat-transcript.js +54 -1
  600. package/dist/modes/interactive/components/chat-transcript.js.map +1 -1
  601. package/dist/modes/interactive/components/fast-mode-selector.js +1 -1
  602. package/dist/modes/interactive/components/fast-mode-selector.js.map +1 -1
  603. package/dist/modes/interactive/components/model-selector.d.ts +1 -1
  604. package/dist/modes/interactive/components/model-selector.d.ts.map +1 -1
  605. package/dist/modes/interactive/components/model-selector.js +2 -1
  606. package/dist/modes/interactive/components/model-selector.js.map +1 -1
  607. package/dist/modes/interactive/components/oauth-selector.d.ts +1 -1
  608. package/dist/modes/interactive/components/oauth-selector.js.map +1 -1
  609. package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts +95 -0
  610. package/dist/modes/interactive/components/reserved-bottom-overlay.d.ts.map +1 -0
  611. package/dist/modes/interactive/components/reserved-bottom-overlay.js +234 -0
  612. package/dist/modes/interactive/components/reserved-bottom-overlay.js.map +1 -0
  613. package/dist/modes/interactive/components/session-selector-search.d.ts +4 -0
  614. package/dist/modes/interactive/components/session-selector-search.d.ts.map +1 -1
  615. package/dist/modes/interactive/components/session-selector-search.js +4 -0
  616. package/dist/modes/interactive/components/session-selector-search.js.map +1 -1
  617. package/dist/modes/interactive/components/settings-selector-handlers.d.ts.map +1 -1
  618. package/dist/modes/interactive/components/settings-selector-handlers.js +3 -0
  619. package/dist/modes/interactive/components/settings-selector-handlers.js.map +1 -1
  620. package/dist/modes/interactive/components/settings-selector-items.d.ts.map +1 -1
  621. package/dist/modes/interactive/components/settings-selector-items.js +7 -0
  622. package/dist/modes/interactive/components/settings-selector-items.js.map +1 -1
  623. package/dist/modes/interactive/components/settings-selector-types.d.ts +3 -1
  624. package/dist/modes/interactive/components/settings-selector-types.d.ts.map +1 -1
  625. package/dist/modes/interactive/components/settings-selector-types.js.map +1 -1
  626. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  627. package/dist/modes/interactive/components/tool-execution.js +12 -1
  628. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  629. package/dist/modes/interactive/interactive-auth-login.js.map +1 -1
  630. package/dist/modes/interactive/interactive-auth-routing.js.map +1 -1
  631. package/dist/modes/interactive/interactive-autocomplete.js +5 -4
  632. package/dist/modes/interactive/interactive-autocomplete.js.map +1 -1
  633. package/dist/modes/interactive/interactive-bash-compact.d.ts.map +1 -1
  634. package/dist/modes/interactive/interactive-bash-compact.js +2 -2
  635. package/dist/modes/interactive/interactive-bash-compact.js.map +1 -1
  636. package/dist/modes/interactive/interactive-extension-custom-ui.js +94 -6
  637. package/dist/modes/interactive/interactive-extension-custom-ui.js.map +1 -1
  638. package/dist/modes/interactive/interactive-mode-base.d.ts +41 -3
  639. package/dist/modes/interactive/interactive-mode-base.d.ts.map +1 -1
  640. package/dist/modes/interactive/interactive-mode-base.js +80 -11
  641. package/dist/modes/interactive/interactive-mode-base.js.map +1 -1
  642. package/dist/modes/interactive/interactive-mode-surface.d.ts +9 -1
  643. package/dist/modes/interactive/interactive-mode-surface.d.ts.map +1 -1
  644. package/dist/modes/interactive/interactive-mode-surface.js.map +1 -1
  645. package/dist/modes/interactive/interactive-mode-types.d.ts +2 -0
  646. package/dist/modes/interactive/interactive-mode-types.d.ts.map +1 -1
  647. package/dist/modes/interactive/interactive-mode-types.js.map +1 -1
  648. package/dist/modes/interactive/interactive-model-catalog-startup.d.ts +8 -1
  649. package/dist/modes/interactive/interactive-model-catalog-startup.d.ts.map +1 -1
  650. package/dist/modes/interactive/interactive-model-catalog-startup.js +13 -3
  651. package/dist/modes/interactive/interactive-model-catalog-startup.js.map +1 -1
  652. package/dist/modes/interactive/interactive-model-routing.js +3 -4
  653. package/dist/modes/interactive/interactive-model-routing.js.map +1 -1
  654. package/dist/modes/interactive/interactive-render-chat.js +6 -0
  655. package/dist/modes/interactive/interactive-render-chat.js.map +1 -1
  656. package/dist/modes/interactive/interactive-resource-disclosure.js +2 -2
  657. package/dist/modes/interactive/interactive-resource-disclosure.js.map +1 -1
  658. package/dist/modes/interactive/interactive-selectors.js +7 -3
  659. package/dist/modes/interactive/interactive-selectors.js.map +1 -1
  660. package/dist/modes/interactive/interactive-session-runtime.js +7 -1
  661. package/dist/modes/interactive/interactive-session-runtime.js.map +1 -1
  662. package/dist/modes/interactive/interactive-slash-commands.js +2 -2
  663. package/dist/modes/interactive/interactive-slash-commands.js.map +1 -1
  664. package/dist/modes/interactive/interactive-startup.d.ts.map +1 -1
  665. package/dist/modes/interactive/interactive-startup.js +34 -9
  666. package/dist/modes/interactive/interactive-startup.js.map +1 -1
  667. package/dist/modes/interactive/interactive-tui.d.ts +21 -1
  668. package/dist/modes/interactive/interactive-tui.d.ts.map +1 -1
  669. package/dist/modes/interactive/interactive-tui.js +140 -38
  670. package/dist/modes/interactive/interactive-tui.js.map +1 -1
  671. package/dist/modes/interactive/model-catalog-refresh.d.ts +17 -0
  672. package/dist/modes/interactive/model-catalog-refresh.d.ts.map +1 -0
  673. package/dist/modes/interactive/model-catalog-refresh.js +84 -0
  674. package/dist/modes/interactive/model-catalog-refresh.js.map +1 -0
  675. package/dist/modes/interactive/theme/dark.json +2 -0
  676. package/dist/modes/interactive/theme/light.json +2 -0
  677. package/dist/modes/interactive/theme/theme-class.d.ts +12 -3
  678. package/dist/modes/interactive/theme/theme-class.d.ts.map +1 -1
  679. package/dist/modes/interactive/theme/theme-class.js +6 -1
  680. package/dist/modes/interactive/theme/theme-class.js.map +1 -1
  681. package/dist/modes/interactive/theme/theme-controller.d.ts +10 -2
  682. package/dist/modes/interactive/theme/theme-controller.d.ts.map +1 -1
  683. package/dist/modes/interactive/theme/theme-controller.js +23 -10
  684. package/dist/modes/interactive/theme/theme-controller.js.map +1 -1
  685. package/dist/modes/interactive/theme/theme-loading.d.ts.map +1 -1
  686. package/dist/modes/interactive/theme/theme-loading.js +1 -0
  687. package/dist/modes/interactive/theme/theme-loading.js.map +1 -1
  688. package/dist/modes/interactive/theme/theme-schema.d.ts +8 -0
  689. package/dist/modes/interactive/theme/theme-schema.d.ts.map +1 -1
  690. package/dist/modes/interactive/theme/theme-schema.js +3 -1
  691. package/dist/modes/interactive/theme/theme-schema.js.map +1 -1
  692. package/dist/modes/interactive/theme/theme-schema.json +9 -1
  693. package/dist/modes/interactive-engine/engine-custom-ui.d.ts +2 -0
  694. package/dist/modes/interactive-engine/engine-custom-ui.d.ts.map +1 -1
  695. package/dist/modes/interactive-engine/engine-custom-ui.js +1 -0
  696. package/dist/modes/interactive-engine/engine-custom-ui.js.map +1 -1
  697. package/dist/modes/interactive-engine/protocol.d.ts +1 -0
  698. package/dist/modes/interactive-engine/protocol.d.ts.map +1 -1
  699. package/dist/modes/interactive-engine/protocol.js +1 -0
  700. package/dist/modes/interactive-engine/protocol.js.map +1 -1
  701. package/dist/modes/interactive-engine/remote-component.d.ts +3 -1
  702. package/dist/modes/interactive-engine/remote-component.d.ts.map +1 -1
  703. package/dist/modes/interactive-engine/remote-component.js +3 -2
  704. package/dist/modes/interactive-engine/remote-component.js.map +1 -1
  705. package/dist/modes/json-event.d.ts +6 -1
  706. package/dist/modes/json-event.d.ts.map +1 -1
  707. package/dist/modes/json-event.js +17 -2
  708. package/dist/modes/json-event.js.map +1 -1
  709. package/dist/modes/rpc/rpc-client.js.map +1 -1
  710. package/dist/modes/rpc/rpc-command-handler.d.ts.map +1 -1
  711. package/dist/modes/rpc/rpc-command-handler.js +5 -4
  712. package/dist/modes/rpc/rpc-command-handler.js.map +1 -1
  713. package/dist/modes/rpc/rpc-provider-auth.js.map +1 -1
  714. package/dist/modes/rpc/rpc-responses.js.map +1 -1
  715. package/dist/modes/rpc/rpc-types.d.ts +1 -1
  716. package/dist/modes/rpc/rpc-types.js.map +1 -1
  717. package/dist/package-manager-cli.js.map +1 -1
  718. package/dist/server/create-harness.d.ts.map +1 -1
  719. package/dist/server/create-harness.js +11 -0
  720. package/dist/server/create-harness.js.map +1 -1
  721. package/dist/utils/abort.d.ts +9 -0
  722. package/dist/utils/abort.d.ts.map +1 -0
  723. package/dist/utils/abort.js +19 -0
  724. package/dist/utils/abort.js.map +1 -0
  725. package/dist/utils/tools-manager.d.ts +16 -1
  726. package/dist/utils/tools-manager.d.ts.map +1 -1
  727. package/dist/utils/tools-manager.js +19 -19
  728. package/dist/utils/tools-manager.js.map +1 -1
  729. package/docs/custom-provider.md +9 -8
  730. package/docs/development.md +0 -1
  731. package/docs/docs.json +0 -1
  732. package/docs/environment-variables.md +3 -0
  733. package/docs/extensions.md +100 -2
  734. package/docs/json.md +3 -1
  735. package/docs/keybindings.md +8 -3
  736. package/docs/models/artificial-analysis-index.md +2 -2
  737. package/docs/models/model-selection.md +32 -23
  738. package/docs/models/pareto-efficiency.md +25 -24
  739. package/docs/providers.md +95 -2
  740. package/docs/quickstart.md +4 -2
  741. package/docs/rpc.md +21 -7
  742. package/docs/sdk.md +26 -1
  743. package/docs/session-format.md +5 -2
  744. package/docs/settings.md +34 -3
  745. package/docs/skills.md +16 -1
  746. package/docs/terminal-setup.md +15 -2
  747. package/docs/themes.md +22 -4
  748. package/docs/tools.md +3 -1
  749. package/docs/tui.md +2 -0
  750. package/docs/usage.md +5 -4
  751. package/docs/workflows.md +96 -19
  752. package/npm-shrinkwrap.json +211 -232
  753. package/package.json +8 -8
  754. package/dist/builtin/i-have-adhd/CHANGELOG.md +0 -30
  755. package/dist/builtin/i-have-adhd/LICENSE +0 -21
  756. package/dist/builtin/i-have-adhd/README.md +0 -18
  757. package/dist/builtin/i-have-adhd/index.ts +0 -238
  758. package/dist/builtin/i-have-adhd/package.json +0 -56
  759. package/dist/builtin/i-have-adhd/skills/i-have-adhd/SKILL.md +0 -140
  760. package/dist/builtin/workflows/builtin/open-claude-design-feedback.ts +0 -359
  761. package/dist/builtin/workflows/skills/impeccable/scripts/live/ui-core.mjs +0 -180
  762. package/docs/i-have-adhd.md +0 -36
@@ -139,16 +139,6 @@ const ANTIPATTERNS = [
139
139
  skillSection: 'Typography',
140
140
  skillGuideline: 'overused fonts like Inter',
141
141
  },
142
- {
143
- id: 'single-font',
144
- category: 'slop',
145
- scopes: ['type'],
146
- name: 'Single font without hierarchy',
147
- description:
148
- 'Only one font family is used for the entire page. A single family can work when weight and size contrast carry the hierarchy; otherwise pair a distinctive display font with a refined body font.',
149
- skillSection: 'Typography',
150
- skillGuideline: 'only one font family for the entire page',
151
- },
152
142
  {
153
143
  id: 'flat-type-hierarchy',
154
144
  category: 'slop',
@@ -751,11 +741,43 @@ function contrastRatio(c1, c2) {
751
741
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
752
742
  }
753
743
 
744
+ // The CSS color functions worth pulling out of a longer declaration. The set
745
+ // is deliberately closed: `linear-gradient(` and `url(` also look like
746
+ // `name(` and must not be read as colors.
747
+ const COLOR_FUNCTION_NAMES = new Set([
748
+ 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
749
+ ]);
750
+
751
+ // Pull every color-function token out of a value, with balanced-paren capture
752
+ // so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
753
+ // whole. Returns the raw substrings in source order.
754
+ function extractColorFunctionTokens(value) {
755
+ const str = String(value || '');
756
+ const tokens = [];
757
+ const re = /([a-z][a-z-]*)\(/gi;
758
+ let m;
759
+ while ((m = re.exec(str)) !== null) {
760
+ if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
761
+ let depth = 0, end = -1;
762
+ for (let i = m.index + m[0].length - 1; i < str.length; i++) {
763
+ if (str[i] === '(') depth++;
764
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
765
+ }
766
+ if (end < 0) break;
767
+ tokens.push(str.slice(m.index, end + 1));
768
+ re.lastIndex = end + 1;
769
+ }
770
+ return tokens;
771
+ }
772
+
754
773
  function parseGradientColors(bgImage) {
755
774
  if (!bgImage || !bgImage.includes('gradient')) return [];
756
775
  const colors = [];
757
- for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
758
- const c = parseRgb(m[0]);
776
+ // Stops arrive in whatever syntax the author wrote and the browser kept.
777
+ // A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
778
+ // to read as a gradient with no stops at all.
779
+ for (const token of extractColorFunctionTokens(bgImage)) {
780
+ const c = parseAnyColor(token);
759
781
  if (c) colors.push(c);
760
782
  }
761
783
  for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
@@ -792,141 +814,587 @@ function colorToHex(c) {
792
814
  return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
793
815
  }
794
816
 
795
- // --- cli/engine/shared/fonts.mjs ---
796
- const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
817
+ // ─── Color-space conversions ────────────────────────────────────────────────
818
+ //
819
+ // Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
820
+ // and Firefox all keep the authored color space in getComputedStyle output
821
+ // (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
822
+ // so a detector that only reads rgb() is blind on any modern palette. The
823
+ // expected outputs are pinned in tests/detect-antipatterns.test.js against
824
+ // what Chrome itself paints for the same strings.
797
825
 
798
- function normalizeGoogleFontFamilyParam(value) {
799
- return String(value || '')
800
- .split('|')
801
- .map(part => part.split(':')[0].trim().toLowerCase())
802
- .filter(Boolean);
826
+ function clamp01(x) {
827
+ return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
803
828
  }
804
829
 
805
- function extractGoogleFontFamilies(text) {
806
- const families = [];
807
- if (!text) return families;
830
+ // Linear-light sRGB channel to the encoded 0-255 value.
831
+ function encodeSrgbChannel(x) {
832
+ const c = clamp01(x);
833
+ return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
834
+ }
808
835
 
809
- GOOGLE_FONTS_URL_RE.lastIndex = 0;
810
- let urlMatch;
811
- while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
812
- const url = urlMatch[0];
813
- const queryStart = url.indexOf('?');
814
- if (queryStart === -1) continue;
836
+ function decodeSrgbChannel(x) {
837
+ const c = Number.isFinite(x) ? x : 0;
838
+ const sign = c < 0 ? -1 : 1;
839
+ const abs = Math.abs(c);
840
+ return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
841
+ }
815
842
 
816
- const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
817
- for (const value of params.getAll('family')) {
818
- families.push(...normalizeGoogleFontFamilyParam(value));
819
- }
820
- }
843
+ function linearSrgbToColor(r, g, b, a = 1) {
844
+ return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
845
+ }
821
846
 
822
- return families;
847
+ // OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
848
+ function oklabToRgb(L, a, b) {
849
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
850
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
851
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
852
+ const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
853
+ return linearSrgbToColor(
854
+ 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
855
+ -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
856
+ -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
857
+ );
823
858
  }
824
859
 
825
- // --- cli/engine/rules/checks.mjs ---
826
- const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
860
+ // OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
861
+ // the sRGB gamut clamps per channel rather than producing NaN.
862
+ function oklchToRgb(L, C, H) {
863
+ const hRad = (H * Math.PI) / 180;
864
+ return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
865
+ }
827
866
 
828
- // ─── Section 3: Pure Detection ──────────────────────────────────────────────
867
+ // CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
868
+ // is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
869
+ function labToRgb(L, a, b) {
870
+ const kappa = 24389 / 27, epsilon = 216 / 24389;
871
+ const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
872
+ const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
873
+ const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
874
+ const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
875
+ const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
876
+ return linearSrgbToColor(
877
+ 3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
878
+ -0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
879
+ 0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
880
+ );
881
+ }
829
882
 
830
- function checkBorders(tag, widths, colors, radius, opts = {}) {
831
- // Badge-shaped <span>s (own visible background) are a real stripe target
832
- // for the top/bottom variant the inline-tag exemption exists to quiet
833
- // text-level borders, not chips. They skip the left/right arms below.
834
- const spanBadge = tag === 'span' && !!opts.badgeLike;
835
- if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
836
- // A live status/alert region wears a colored single-edge border as a
837
- // severity accent (toast, snackbar, callout), not as the side-tab tell.
838
- if (opts.statusContext) return [];
839
- const findings = [];
840
- const sides = ['Top', 'Right', 'Bottom', 'Left'];
883
+ function lchToRgb(L, C, H) {
884
+ const hRad = (H * Math.PI) / 180;
885
+ return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
886
+ }
887
+
888
+ // color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
889
+ // `srgb` is what Chrome serializes most color-mix() results into, routinely
890
+ // with channels outside 0..1. Spaces we do not model return null so callers
891
+ // abstain instead of measuring against a color we invented.
892
+ function colorFunctionToRgb(space, c1, c2, c3) {
893
+ switch (space) {
894
+ case 'srgb':
895
+ return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
896
+ case 'srgb-linear':
897
+ return linearSrgbToColor(c1, c2, c3);
898
+ case 'display-p3': {
899
+ const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
900
+ return linearSrgbToColor(
901
+ 1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
902
+ -0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
903
+ -0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
904
+ );
905
+ }
906
+ default:
907
+ return null;
908
+ }
909
+ }
841
910
 
842
- for (const side of sides) {
843
- const w = widths[side];
844
- if (w < 1 || isNeutralColor(colors[side])) continue;
911
+ function hslToRgb(h, s, l) {
912
+ h = ((h % 360) + 360) % 360;
913
+ const c = (1 - Math.abs(2 * l - 1)) * s;
914
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
915
+ const m0 = l - c / 2;
916
+ const [r, g, b] =
917
+ h < 60 ? [c, x, 0] :
918
+ h < 120 ? [x, c, 0] :
919
+ h < 180 ? [0, c, x] :
920
+ h < 240 ? [0, x, c] :
921
+ h < 300 ? [x, 0, c] : [c, 0, x];
922
+ return {
923
+ r: Math.round((r + m0) * 255),
924
+ g: Math.round((g + m0) * 255),
925
+ b: Math.round((b + m0) * 255),
926
+ a: 1,
927
+ };
928
+ }
845
929
 
846
- const otherSides = sides.filter(s => s !== side);
847
- const maxOther = Math.max(...otherSides.map(s => widths[s]));
848
- if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
930
+ function hwbToRgb(h, w, bl) {
931
+ if (w + bl >= 1) {
932
+ const g = Math.round((w / (w + bl)) * 255);
933
+ return { r: g, g, b: g, a: 1 };
934
+ }
935
+ const base = hslToRgb(h, 1, 0.5);
936
+ const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
937
+ return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
938
+ }
849
939
 
850
- const sn = side.toLowerCase();
851
- const isSide = side === 'Left' || side === 'Right';
940
+ // Common CSS named colors — the handful that actually show up in generated
941
+ // UIs, not the full 148-name spec list. Includes the achromatic names so a
942
+ // named gray parses (and correctly reads as no-chroma) instead of being
943
+ // treated as an unknown color.
944
+ const CSS_NAMED_COLORS = {
945
+ black: { r: 0, g: 0, b: 0 },
946
+ white: { r: 255, g: 255, b: 255 },
947
+ gray: { r: 128, g: 128, b: 128 },
948
+ grey: { r: 128, g: 128, b: 128 },
949
+ silver: { r: 192, g: 192, b: 192 },
950
+ dimgray: { r: 105, g: 105, b: 105 },
951
+ darkgray: { r: 169, g: 169, b: 169 },
952
+ lightgray: { r: 211, g: 211, b: 211 },
953
+ gainsboro: { r: 220, g: 220, b: 220 },
954
+ whitesmoke: { r: 245, g: 245, b: 245 },
955
+ red: { r: 255, g: 0, b: 0 },
956
+ crimson: { r: 220, g: 20, b: 60 },
957
+ tomato: { r: 255, g: 99, b: 71 },
958
+ coral: { r: 255, g: 127, b: 80 },
959
+ salmon: { r: 250, g: 128, b: 114 },
960
+ orange: { r: 255, g: 165, b: 0 },
961
+ gold: { r: 255, g: 215, b: 0 },
962
+ yellow: { r: 255, g: 255, b: 0 },
963
+ olive: { r: 128, g: 128, b: 0 },
964
+ lime: { r: 0, g: 255, b: 0 },
965
+ green: { r: 0, g: 128, b: 0 },
966
+ teal: { r: 0, g: 128, b: 128 },
967
+ turquoise: { r: 64, g: 224, b: 208 },
968
+ cyan: { r: 0, g: 255, b: 255 },
969
+ aqua: { r: 0, g: 255, b: 255 },
970
+ skyblue: { r: 135, g: 206, b: 235 },
971
+ dodgerblue: { r: 30, g: 144, b: 255 },
972
+ blue: { r: 0, g: 0, b: 255 },
973
+ navy: { r: 0, g: 0, b: 128 },
974
+ indigo: { r: 75, g: 0, b: 130 },
975
+ rebeccapurple: { r: 102, g: 51, b: 153 },
976
+ purple: { r: 128, g: 0, b: 128 },
977
+ violet: { r: 238, g: 130, b: 238 },
978
+ orchid: { r: 218, g: 112, b: 214 },
979
+ magenta: { r: 255, g: 0, b: 255 },
980
+ fuchsia: { r: 255, g: 0, b: 255 },
981
+ hotpink: { r: 255, g: 105, b: 180 },
982
+ pink: { r: 255, g: 192, b: 203 },
983
+ maroon: { r: 128, g: 0, b: 0 },
984
+ };
852
985
 
853
- if (isSide) {
854
- if (spanBadge) continue;
855
- if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
856
- else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
857
- } else {
858
- if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
859
- // Horizontal variant of the side-tab stripe: a thick chromatic accent
860
- // riding the top or bottom edge of a card/badge/container. Same
861
- // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
862
- // tab underlines are exempt via opts.tabContext (adapters look for
863
- // tablist/nav/tab ancestors and aria-selected); links, buttons,
864
- // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
865
- else if (!opts.tabContext && w >= 3 && w <= 12) {
866
- findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
867
- }
986
+ // Split a string on top-level commas (ignoring commas nested in parens).
987
+ function splitTopLevelCommas(str) {
988
+ const parts = [];
989
+ let depth = 0, start = 0;
990
+ for (let i = 0; i < str.length; i++) {
991
+ const ch = str[i];
992
+ if (ch === '(') depth++;
993
+ else if (ch === ')') depth = Math.max(0, depth - 1);
994
+ else if (ch === ',' && depth === 0) {
995
+ parts.push(str.slice(start, i).trim());
996
+ start = i + 1;
868
997
  }
869
998
  }
870
-
871
- return findings;
872
- }
873
-
874
- // Returns true if the given text is composed entirely of emoji characters
875
- // (plus whitespace / variation selectors). Emojis render as multicolor glyphs
876
- // regardless of CSS `color`, so contrast checks against the element's text
877
- // color are meaningless for these nodes.
878
- const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
879
- const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
880
- function isEmojiOnlyText(text) {
881
- if (!text) return false;
882
- if (!EMOJI_CHAR_RE.test(text)) return false;
883
- return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
999
+ const tail = str.slice(start).trim();
1000
+ if (tail) parts.push(tail);
1001
+ return parts;
884
1002
  }
885
1003
 
886
- function checkColors(opts) {
887
- const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
888
- if (SAFE_TAGS.has(tag)) {
889
- // Exception for elements styled as controls or chips. SAFE_TAGS exists to
890
- // suppress contrast noise on inline links and unstyled spans, where the
891
- // element has no own background and the contrast against the ancestor
892
- // surface is already the intended visual. When the element paints its own
893
- // opaque background under direct text, it is a styled button, chip, or
894
- // badge regardless of tag, and contrast on its own surface is a real,
895
- // frequent bug worth flagging. (The shipped miss: a <span> severity chip
896
- // whose white text lost a specificity fight and rendered muted-on-red at
897
- // 1.2:1; the old a/button-only exception never looked at it.) The 9px
898
- // font floor keeps sub-text decorations out.
899
- const isStyledControl = hasDirectText
900
- && ((bgColor && bgColor.a > 0.5)
901
- // A gradient painted on the element itself is an own surface the
902
- // same way a solid background is. Without this branch a nav CTA
903
- // built as `<a>` with `background: linear-gradient()` and a text
904
- // color that fails against every stop sails through on the
905
- // SAFE_TAGS suppression (the shipped escape).
906
- || (bgImage && /gradient/i.test(bgImage)))
907
- && fontSize >= 9;
908
- if (!isStyledControl) return [];
1004
+ // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
1005
+ // the expression can't be resolved (unresolved var(), unknown colors).
1006
+ //
1007
+ // Mixing is done with premultiplied alpha in sRGB regardless of the
1008
+ // declared interpolation space. That is exact for the dominant generated-UI
1009
+ // pattern `color-mix(in oklab, <color> N%, transparent)` where the
1010
+ // result is simply <color> at alpha N% in ANY rectangular space, and a
1011
+ // close-enough approximation for opaque-opaque mixes (the detector only
1012
+ // consumes these values for contrast/chroma thresholds, not for display).
1013
+ function parseColorMix(str) {
1014
+ const m = String(str).trim().match(/^color-mix\(/i);
1015
+ if (!m) return null;
1016
+ // Balanced-paren capture of the arguments.
1017
+ let depth = 0, end = -1;
1018
+ const open = str.indexOf('(');
1019
+ for (let i = open; i < str.length; i++) {
1020
+ if (str[i] === '(') depth++;
1021
+ else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
909
1022
  }
910
- const findings = [];
1023
+ if (end < 0) return null;
1024
+ const args = splitTopLevelCommas(str.slice(open + 1, end));
1025
+ if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
911
1026
 
912
- if (hasDirectText && textColor && !isEmojiOnly) {
913
- // Gradient-clipped text (`background-clip: text`, typically with a
914
- // transparent text-fill) paints its glyphs *with* the element's own
915
- // gradient. The `color` value the cascade still reports is never painted,
916
- // and the gradient is the fill, not a backdrop — so measuring `color`
917
- // against that gradient (which resolveGradientStops picks up as the
918
- // element's own background-image) is a guaranteed false positive
919
- // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
920
- // rule below still flags the pattern itself. Skipping a rule beats a false
921
- // positive here the true painted contrast can't be measured from `color`.
922
- const isGradientClippedText = bgClip === 'text';
923
- // Run background-dependent checks against either a solid bg or, if the
924
- // ancestor is a gradient, against every gradient stop (use the worst case).
925
- const bgs = isGradientClippedText
926
- ? null
927
- : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
928
- if (bgs) {
929
- // Gray on colored background — flag if every stop is chromatic
1027
+ const parseComponent = (component) => {
1028
+ // Percentage may lead or trail the color per spec.
1029
+ let pct = null;
1030
+ let colorStr = component;
1031
+ const trail = component.match(/\s+([\d.]+)%$/);
1032
+ const lead = component.match(/^([\d.]+)%\s+/);
1033
+ if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
1034
+ else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
1035
+ let color;
1036
+ if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
1037
+ else color = parseAnyColor(colorStr);
1038
+ if (!color) return null;
1039
+ return { color, pct };
1040
+ };
1041
+
1042
+ const c1 = parseComponent(args[1]);
1043
+ const c2 = parseComponent(args[2]);
1044
+ if (!c1 || !c2) return null;
1045
+ let p1 = c1.pct, p2 = c2.pct;
1046
+ if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
1047
+ else if (p1 == null) p1 = 100 - p2;
1048
+ else if (p2 == null) p2 = 100 - p1;
1049
+ const sum = p1 + p2;
1050
+ if (sum <= 0) return null;
1051
+ // Per spec: weights normalize to sum; when sum < 100 the result alpha is
1052
+ // additionally scaled by sum/100.
1053
+ const w1 = p1 / sum, w2 = p2 / sum;
1054
+ const alphaScale = sum < 100 ? sum / 100 : 1;
1055
+ const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
1056
+ const a = (a1 * w1 + a2 * w2) * alphaScale;
1057
+ if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
1058
+ const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
1059
+ return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
1060
+ }
1061
+
1062
+ // Composite a translucent color over an opaque(ish) base (simple
1063
+ // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
1064
+ function compositeColorOver(top, base) {
1065
+ const a = top.a ?? 1;
1066
+ return {
1067
+ r: Math.round(top.r * a + base.r * (1 - a)),
1068
+ g: Math.round(top.g * a + base.g * (1 - a)),
1069
+ b: Math.round(top.b * a + base.b * (1 - a)),
1070
+ a: 1,
1071
+ };
1072
+ }
1073
+
1074
+ // A color() / lab() / lch() component: a bare number, a percentage against
1075
+ // `scale`, or the `none` keyword (which resolves to zero for our purposes).
1076
+ function parseColorComponent(token, scale = 1) {
1077
+ if (token == null) return null;
1078
+ const t = String(token).trim();
1079
+ if (/^none$/i.test(t)) return 0;
1080
+ const num = parseFloat(t);
1081
+ if (!Number.isFinite(num)) return null;
1082
+ return t.endsWith('%') ? (num / 100) * scale : num;
1083
+ }
1084
+
1085
+ function parseAlphaToken(token) {
1086
+ if (token == null) return 1;
1087
+ const t = String(token).trim();
1088
+ if (/^none$/i.test(t)) return 1;
1089
+ const num = parseFloat(t);
1090
+ if (!Number.isFinite(num)) return 1;
1091
+ return t.endsWith('%') ? num / 100 : num;
1092
+ }
1093
+
1094
+ // Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
1095
+ // color-mix/common named colors. Returns null on no match. Use this when the
1096
+ // input might be any CSS color form; use plain parseRgb when you only expect
1097
+ // computed rgb() values from real browsers.
1098
+ function parseAnyColor(s) {
1099
+ if (!s || typeof s !== 'string') return null;
1100
+ const str = s.trim();
1101
+ if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
1102
+ if (/^color-mix\(/i.test(str)) return parseColorMix(str);
1103
+ let m;
1104
+ m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
1105
+ if (m) {
1106
+ const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
1107
+ if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
1108
+ return c;
1109
+ }
1110
+ m = str.match(/^#([0-9a-f]{3,8})$/i);
1111
+ if (m) {
1112
+ const h = m[1];
1113
+ if (h.length === 3 || h.length === 4) {
1114
+ return {
1115
+ r: parseInt(h[0] + h[0], 16),
1116
+ g: parseInt(h[1] + h[1], 16),
1117
+ b: parseInt(h[2] + h[2], 16),
1118
+ a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
1119
+ };
1120
+ }
1121
+ if (h.length === 6 || h.length === 8) {
1122
+ return {
1123
+ r: parseInt(h.slice(0, 2), 16),
1124
+ g: parseInt(h.slice(2, 4), 16),
1125
+ b: parseInt(h.slice(4, 6), 16),
1126
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
1127
+ };
1128
+ }
1129
+ }
1130
+ // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
1131
+ // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
1132
+ // Match L (with optional %), then C and H separated permissively.
1133
+ m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1134
+ if (m) {
1135
+ const Lnum = parseFloat(m[1]);
1136
+ const L = m[2] === '%' ? Lnum / 100 : Lnum;
1137
+ const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
1138
+ if (m[5] !== undefined) {
1139
+ const alpha = parseFloat(m[5]);
1140
+ rgb.a = m[6] === '%' ? alpha / 100 : alpha;
1141
+ }
1142
+ return rgb;
1143
+ }
1144
+ // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
1145
+ m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1146
+ if (m) {
1147
+ const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
1148
+ const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
1149
+ const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
1150
+ const rgb = oklabToRgb(L, a, b);
1151
+ if (m[7] !== undefined) {
1152
+ const alpha = parseFloat(m[7]);
1153
+ rgb.a = m[8] === '%' ? alpha / 100 : alpha;
1154
+ }
1155
+ return rgb;
1156
+ }
1157
+ // LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
1158
+ // `lch(20 5 60)`, so L arrives with or without its percent sign. In both
1159
+ // spaces L runs 0..100 and 100% means 100.
1160
+ m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1161
+ if (m) {
1162
+ const L = parseColorComponent(m[1], 100);
1163
+ const C = parseColorComponent(m[2], 150);
1164
+ const H = parseFloat(m[3]);
1165
+ if (L == null || C == null || !Number.isFinite(H)) return null;
1166
+ const rgb = lchToRgb(L, C, H);
1167
+ rgb.a = parseAlphaToken(m[4]);
1168
+ return rgb;
1169
+ }
1170
+ m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1171
+ if (m) {
1172
+ const L = parseColorComponent(m[1], 100);
1173
+ const a = parseColorComponent(m[2], 125);
1174
+ const b = parseColorComponent(m[3], 125);
1175
+ if (L == null || a == null || b == null) return null;
1176
+ const rgb = labToRgb(L, a, b);
1177
+ rgb.a = parseAlphaToken(m[4]);
1178
+ return rgb;
1179
+ }
1180
+ // color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
1181
+ // color-mix() results and for any wide-gamut color an author wrote.
1182
+ m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
1183
+ if (m) {
1184
+ const c1 = parseColorComponent(m[2]);
1185
+ const c2 = parseColorComponent(m[3]);
1186
+ const c3 = parseColorComponent(m[4]);
1187
+ if (c1 == null || c2 == null || c3 == null) return null;
1188
+ const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
1189
+ if (!rgb) return null;
1190
+ rgb.a = parseAlphaToken(m[5]);
1191
+ return rgb;
1192
+ }
1193
+ // HSL/HSLA — comma or space syntax, optional deg on hue.
1194
+ m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
1195
+ if (m) {
1196
+ const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
1197
+ if (m[4] !== undefined) {
1198
+ const alpha = parseFloat(m[4]);
1199
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
1200
+ }
1201
+ return rgb;
1202
+ }
1203
+ // HWB — hue whiteness% blackness%.
1204
+ m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
1205
+ if (m) {
1206
+ const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
1207
+ if (m[4] !== undefined) {
1208
+ const alpha = parseFloat(m[4]);
1209
+ rgb.a = m[5] === '%' ? alpha / 100 : alpha;
1210
+ }
1211
+ return rgb;
1212
+ }
1213
+ const named = CSS_NAMED_COLORS[str.toLowerCase()];
1214
+ if (named) return { ...named, a: 1 };
1215
+ return null;
1216
+ }
1217
+
1218
+ // True when a computed background-color string names no paint at all. Used to
1219
+ // tell "this layer is see-through" (walk on to the ancestor) apart from "this
1220
+ // layer has a color we could not read" (stop and abstain).
1221
+ //
1222
+ // `inherit` belongs here even though it is not literally see-through: it means
1223
+ // "paint with the parent's background-color", and walking on to the parent IS
1224
+ // that resolution. Real browsers resolve the keyword before getComputedStyle
1225
+ // output; only jsdom's partial cascade hands it through verbatim, and treating
1226
+ // it as unreadable would make the walk abstain on a surface it can know.
1227
+ // (`currentcolor` is NOT here — it is real paint in the element's own text
1228
+ // color; resolveBackgroundInfo substitutes the computed color for it.)
1229
+ function isNoPaintColorValue(value) {
1230
+ const v = String(value || '').trim().toLowerCase();
1231
+ if (!v) return true;
1232
+ return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
1233
+ }
1234
+
1235
+ // --- cli/engine/shared/fonts.mjs ---
1236
+ const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
1237
+
1238
+ function normalizeGoogleFontFamilyParam(value) {
1239
+ return String(value || '')
1240
+ .split('|')
1241
+ .map(part => part.split(':')[0].trim().toLowerCase())
1242
+ .filter(Boolean);
1243
+ }
1244
+
1245
+ function extractGoogleFontFamilies(text) {
1246
+ const families = [];
1247
+ if (!text) return families;
1248
+
1249
+ GOOGLE_FONTS_URL_RE.lastIndex = 0;
1250
+ let urlMatch;
1251
+ while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
1252
+ const url = urlMatch[0];
1253
+ const queryStart = url.indexOf('?');
1254
+ if (queryStart === -1) continue;
1255
+
1256
+ const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
1257
+ for (const value of params.getAll('family')) {
1258
+ families.push(...normalizeGoogleFontFamilyParam(value));
1259
+ }
1260
+ }
1261
+
1262
+ return families;
1263
+ }
1264
+
1265
+ // --- cli/engine/rules/checks.mjs ---
1266
+ const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
1267
+
1268
+ // ─── Section 3: Pure Detection ──────────────────────────────────────────────
1269
+
1270
+ function checkBorders(tag, widths, colors, radius, opts = {}) {
1271
+ // Badge-shaped <span>s (own visible background) are a real stripe target
1272
+ // for the top/bottom variant — the inline-tag exemption exists to quiet
1273
+ // text-level borders, not chips. They skip the left/right arms below.
1274
+ const spanBadge = tag === 'span' && !!opts.badgeLike;
1275
+ if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
1276
+ // A live status/alert region wears a colored single-edge border as a
1277
+ // severity accent (toast, snackbar, callout), not as the side-tab tell.
1278
+ if (opts.statusContext) return [];
1279
+ const findings = [];
1280
+ const sides = ['Top', 'Right', 'Bottom', 'Left'];
1281
+
1282
+ for (const side of sides) {
1283
+ const w = widths[side];
1284
+ if (w < 1 || isNeutralColor(colors[side])) continue;
1285
+
1286
+ const otherSides = sides.filter(s => s !== side);
1287
+ const maxOther = Math.max(...otherSides.map(s => widths[s]));
1288
+ if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
1289
+
1290
+ const sn = side.toLowerCase();
1291
+ const isSide = side === 'Left' || side === 'Right';
1292
+
1293
+ if (isSide) {
1294
+ if (spanBadge) continue;
1295
+ if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
1296
+ else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
1297
+ } else {
1298
+ if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
1299
+ // Horizontal variant of the side-tab stripe: a thick chromatic accent
1300
+ // riding the top or bottom edge of a card/badge/container. Same
1301
+ // dominant-edge + chroma gates as left/right, 3-12px band. Selected-
1302
+ // tab underlines are exempt via opts.tabContext (adapters look for
1303
+ // tablist/nav/tab ancestors and aria-selected); links, buttons,
1304
+ // table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
1305
+ else if (!opts.tabContext && w >= 3 && w <= 12) {
1306
+ findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
1307
+ }
1308
+ }
1309
+ }
1310
+
1311
+ return findings;
1312
+ }
1313
+
1314
+ // ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
1315
+ //
1316
+ // An element-scoped waiver that travels with the markup: any element carrying
1317
+ // `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
1318
+ // every rule) suppresses matching findings from itself and its entire subtree,
1319
+ // in every engine that walks elements — the browser overlay, the extension,
1320
+ // and the static scan. This is the DOM twin of the line-based
1321
+ // `impeccable-disable` comment directives, which the browser cannot apply (a
1322
+ // live DOM has no line numbers), and the generalization of the one-off
1323
+ // `data-impeccable-allow-kickers` opt-out.
1324
+ //
1325
+ // The intended use is curated exhibits: a page that documents anti-patterns by
1326
+ // example, or renders a deliberate "before" specimen, marks the container once
1327
+ // and every engine skips it while still scanning the page around it.
1328
+ function scopedIgnoreActive(el, ruleId) {
1329
+ const rule = String(ruleId || '').toLowerCase();
1330
+ let cur = el;
1331
+ while (cur && cur.nodeType === 1) {
1332
+ const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
1333
+ if (attr != null) {
1334
+ const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
1335
+ if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
1336
+ }
1337
+ cur = cur.parentElement;
1338
+ }
1339
+ return false;
1340
+ }
1341
+
1342
+ // Returns true if the given text is composed entirely of emoji characters
1343
+ // (plus whitespace / variation selectors). Emojis render as multicolor glyphs
1344
+ // regardless of CSS `color`, so contrast checks against the element's text
1345
+ // color are meaningless for these nodes.
1346
+ const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
1347
+ const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
1348
+ function isEmojiOnlyText(text) {
1349
+ if (!text) return false;
1350
+ if (!EMOJI_CHAR_RE.test(text)) return false;
1351
+ return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
1352
+ }
1353
+
1354
+ function checkColors(opts) {
1355
+ const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
1356
+ if (SAFE_TAGS.has(tag)) {
1357
+ // Exception for elements styled as controls or chips. SAFE_TAGS exists to
1358
+ // suppress contrast noise on inline links and unstyled spans, where the
1359
+ // element has no own background and the contrast against the ancestor
1360
+ // surface is already the intended visual. When the element paints its own
1361
+ // opaque background under direct text, it is a styled button, chip, or
1362
+ // badge regardless of tag, and contrast on its own surface is a real,
1363
+ // frequent bug worth flagging. (The shipped miss: a <span> severity chip
1364
+ // whose white text lost a specificity fight and rendered muted-on-red at
1365
+ // 1.2:1; the old a/button-only exception never looked at it.) The 9px
1366
+ // font floor keeps sub-text decorations out.
1367
+ const isStyledControl = hasDirectText
1368
+ && ((bgColor && bgColor.a > 0.5)
1369
+ // A gradient painted on the element itself is an own surface the
1370
+ // same way a solid background is. Without this branch a nav CTA
1371
+ // built as `<a>` with `background: linear-gradient(…)` and a text
1372
+ // color that fails against every stop sails through on the
1373
+ // SAFE_TAGS suppression (the shipped escape).
1374
+ || (bgImage && /gradient/i.test(bgImage)))
1375
+ && fontSize >= 9;
1376
+ if (!isStyledControl) return [];
1377
+ }
1378
+ const findings = [];
1379
+
1380
+ if (hasDirectText && textColor && !isEmojiOnly) {
1381
+ // Gradient-clipped text (`background-clip: text`, typically with a
1382
+ // transparent text-fill) paints its glyphs *with* the element's own
1383
+ // gradient. The `color` value the cascade still reports is never painted,
1384
+ // and the gradient is the fill, not a backdrop — so measuring `color`
1385
+ // against that gradient (which resolveGradientStops picks up as the
1386
+ // element's own background-image) is a guaranteed false positive
1387
+ // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
1388
+ // rule below still flags the pattern itself. Skipping a rule beats a false
1389
+ // positive here — the true painted contrast can't be measured from `color`.
1390
+ const isGradientClippedText = bgClip === 'text';
1391
+ // Run background-dependent checks against either a solid bg or, if the
1392
+ // ancestor is a gradient, against every gradient stop (use the worst case).
1393
+ const bgs = isGradientClippedText
1394
+ ? null
1395
+ : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
1396
+ if (bgs) {
1397
+ // Gray on colored background — flag if every stop is chromatic
930
1398
  const textLum = relativeLuminance(textColor);
931
1399
  const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
932
1400
  if (isGray && bgs.every(b => hasChroma(b, 40))) {
@@ -1438,6 +1906,26 @@ function cssTextHasDarkRootBg(content, customProps) {
1438
1906
  return false;
1439
1907
  }
1440
1908
 
1909
+ // Best-effort extraction of the CSS selector whose declaration block contains
1910
+ // the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
1911
+ // anchor, so the browser pass can resolve scoped ignores against the actual
1912
+ // element and drop patterns that render nowhere on the page. Returns null for
1913
+ // @-rule preludes, keyframe steps, nested blocks, and anything that does not
1914
+ // read as a selector; those findings stay page-level.
1915
+ function enclosingCssSelector(cssText, index) {
1916
+ if (!cssText || !Number.isFinite(index)) return null;
1917
+ const open = cssText.lastIndexOf('{', index);
1918
+ if (open === -1) return null;
1919
+ const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
1920
+ const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
1921
+ if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
1922
+ // Keyframe steps: percentage steps fail the digit test above, but `from`
1923
+ // and `to` would read as (never-matching) type selectors and get a valid
1924
+ // finding wrongly dropped by the zero-match rule downstream.
1925
+ if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
1926
+ return raw;
1927
+ }
1928
+
1441
1929
  function scanCssTextForGlow(content) {
1442
1930
  const customProps = collectCssCustomProps(content);
1443
1931
  const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -1749,6 +2237,7 @@ function scanCssTextForPseudoStripe(rawContent) {
1749
2237
  id: 'side-tab',
1750
2238
  snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
1751
2239
  index: selectorStart,
2240
+ selector,
1752
2241
  });
1753
2242
  }
1754
2243
  return findings;
@@ -1811,6 +2300,7 @@ function scanCssTextForInsetStripe(content) {
1811
2300
  findings.push({
1812
2301
  id: 'side-tab',
1813
2302
  snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
2303
+ selector,
1814
2304
  });
1815
2305
  break;
1816
2306
  }
@@ -1863,10 +2353,12 @@ function collectMarqueeKeyframes(content) {
1863
2353
  // bound to a keyframe loop that travels a large horizontal distance.
1864
2354
  // Rotation/opacity animations never qualify (no X travel); JS-driven
1865
2355
  // carousels with user controls have no infinite CSS X-loop to match.
1866
- function scanCssTextForMarquee(content) {
2356
+ // `content` is CSS-bearing text; `markup` (defaulting to the same string
2357
+ // for single-corpus callers) is where the <marquee> tag itself lives.
2358
+ function scanCssTextForMarquee(content, markup = content) {
1867
2359
  const findings = [];
1868
- if (/<marquee\b/i.test(content)) {
1869
- findings.push({ id: 'marquee', snippet: '<marquee> element' });
2360
+ if (/<marquee\b/i.test(markup)) {
2361
+ findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
1870
2362
  }
1871
2363
  const marqueeKeyframes = collectMarqueeKeyframes(content);
1872
2364
  if (marqueeKeyframes.size === 0) return findings;
@@ -1881,7 +2373,7 @@ function scanCssTextForMarquee(content) {
1881
2373
  const key = `${selector} ${name}`;
1882
2374
  if (seen.has(key)) continue;
1883
2375
  seen.add(key);
1884
- findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
2376
+ findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
1885
2377
  }
1886
2378
  }
1887
2379
  return findings;
@@ -2044,10 +2536,14 @@ function selectorHitsLandmark(content, selector, ranges) {
2044
2536
  // element sits inside a header/nav landmark is the hero liveness cliché
2045
2537
  // and is promoted to error severity; occurrences elsewhere keep the
2046
2538
  // registry default severity.
2047
- function scanCssTextForPulsingDot(content) {
2539
+ //
2540
+ // `content` is CSS-bearing text (rules and keyframes); `markup` — defaulting
2541
+ // to the same string for single-corpus callers like the regex source
2542
+ // engine — is where landmark ranges and Tailwind class attributes live.
2543
+ function scanCssTextForPulsingDot(content, markup = content) {
2048
2544
  const customProps = collectCssCustomProps(content);
2049
2545
  const keyframes = collectPulseKeyframes(content);
2050
- const heroRanges = landmarkSourceRanges(content);
2546
+ const heroRanges = landmarkSourceRanges(markup);
2051
2547
  const findings = [];
2052
2548
  const seen = new Set();
2053
2549
 
@@ -2096,7 +2592,7 @@ function scanCssTextForPulsingDot(content) {
2096
2592
 
2097
2593
  if (seen.has(selector)) continue;
2098
2594
  seen.add(selector);
2099
- const inLandmark = selectorHitsLandmark(content, selector, heroRanges);
2595
+ const inLandmark = selectorHitsLandmark(markup, selector, heroRanges);
2100
2596
  findings.push({
2101
2597
  id: 'pulsing-dot',
2102
2598
  snippet: `${selector} — ${w}x${h}px dot with infinite "${pulseName}" animation${inLandmark ? ' in header/nav' : ''}`,
@@ -2106,10 +2602,11 @@ function scanCssTextForPulsingDot(content) {
2106
2602
  }
2107
2603
 
2108
2604
  // Tailwind utilities: animate-ping / animate-pulse on a tiny rounded-full
2109
- // element declared entirely in the class attribute.
2605
+ // element declared entirely in the class attribute. Scanned in the markup
2606
+ // corpus so the match index lines up with the landmark ranges.
2110
2607
  const classRe = /class\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
2111
2608
  let cm;
2112
- while ((cm = classRe.exec(content)) !== null) {
2609
+ while ((cm = classRe.exec(markup)) !== null) {
2113
2610
  const cls = cm[1] || cm[2] || '';
2114
2611
  const anim = cls.match(/\banimate-(ping|pulse)\b/);
2115
2612
  if (!anim) continue;
@@ -2190,36 +2687,82 @@ function scanHtmlForShapeAssembledIllustration(html) {
2190
2687
  return findings;
2191
2688
  }
2192
2689
 
2690
+ // Scoped scan corpora for the page-level pattern checks. CSS-property
2691
+ // regexes run over the whole source string fire on documentation ABOUT
2692
+ // css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
2693
+ // comments — so the checks scan only the strings that actually style the
2694
+ // page:
2695
+ // styleText — <style> block contents plus style="…" attribute values.
2696
+ // Attribute values keep their `style="…"` form so block-scoped
2697
+ // scanners (grid background) keep treating each attribute as one
2698
+ // declaration block, exactly as they did against raw source. Engines
2699
+ // that already read more CSS (linked stylesheets) prepend it.
2700
+ // classText — class attribute values, for utility-class scans.
2701
+ // Markup-shaped checks (inline <svg> scenes, <img> tags, <marquee>,
2702
+ // landmark ranges) and rendered-text checks (theater phrases) keep the
2703
+ // full source. This extraction serves callers without a parsed document
2704
+ // (the browser bundle scanning outerHTML); attribute reads are tag-scoped
2705
+ // so escaped code samples (&lt;div style="…"&gt;) never contribute. The
2706
+ // static engine passes richer corpora built from its parsed document.
2707
+ // Bare CSS input (no markup at all) is its own style text, which keeps
2708
+ // direct checkHtmlPatterns(css) callers behaving as before.
2709
+ function buildHtmlPatternCorpora(html) {
2710
+ const source = String(html || '');
2711
+ if (!/<[a-zA-Z!/]/.test(source)) {
2712
+ return { styleText: source, classText: source };
2713
+ }
2714
+ const styleParts = [];
2715
+ const classParts = [];
2716
+ const styleBlockRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
2717
+ let m;
2718
+ while ((m = styleBlockRe.exec(source)) !== null) styleParts.push(m[1]);
2719
+ const tagRe = /<[a-zA-Z][^>]*>/g;
2720
+ while ((m = tagRe.exec(source)) !== null) {
2721
+ const tag = m[0];
2722
+ const sm = tag.match(/\bstyle\s*=\s*("[^"]*"|'[^']*')/i);
2723
+ if (sm) styleParts.push(`style=${sm[1]}`);
2724
+ const cm = tag.match(/\bclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
2725
+ if (cm) classParts.push(cm[1] ?? cm[2] ?? '');
2726
+ }
2727
+ return { styleText: styleParts.join('\n'), classText: classParts.join('\n') };
2728
+ }
2729
+
2193
2730
  /**
2194
2731
  * Regex-on-HTML checks shared between browser and Node page-level detection.
2195
- * These don't need DOM access, just the raw HTML string.
2732
+ * These don't need DOM access, just the raw HTML string. CSS-property and
2733
+ * utility-class patterns scan the scoped corpora (styleText / classText —
2734
+ * see buildHtmlPatternCorpora) so prose about css never flags; only the
2735
+ * markup-shaped and rendered-text checks read the full source.
2196
2736
  */
2197
- function checkHtmlPatterns(html) {
2737
+ function checkHtmlPatterns(html, corpora) {
2738
+ const { styleText, classText } = corpora || buildHtmlPatternCorpora(html);
2198
2739
  const findings = [];
2199
2740
 
2200
2741
  // --- Color ---
2201
2742
 
2202
2743
  // AI color palette: purple/violet
2203
2744
  const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
2204
- if (purpleHexRe.test(html)) {
2745
+ if (purpleHexRe.test(styleText)) {
2205
2746
  const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
2206
- if (purpleTextRe.test(html)) {
2207
- findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
2747
+ purpleTextRe.lastIndex = 0;
2748
+ const purpleMatch = purpleTextRe.exec(styleText);
2749
+ if (purpleMatch) {
2750
+ findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
2208
2751
  }
2209
2752
  }
2210
2753
 
2211
2754
  // Gradient text (background-clip: text + gradient)
2212
2755
  const gradientRe = /(?:-webkit-)?background-clip\s*:\s*text/gi;
2213
2756
  let gm;
2214
- while ((gm = gradientRe.exec(html)) !== null) {
2757
+ while ((gm = gradientRe.exec(styleText)) !== null) {
2215
2758
  const start = Math.max(0, gm.index - 200);
2216
- const context = html.substring(start, gm.index + gm[0].length + 200);
2759
+ const context = styleText.substring(start, gm.index + gm[0].length + 200);
2217
2760
  if (/gradient/i.test(context)) {
2218
- findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
2761
+ findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
2219
2762
  break;
2220
2763
  }
2221
2764
  }
2222
- if (/\bbg-clip-text\b/.test(html) && /\bbg-gradient-to-/.test(html)) {
2765
+ if (/\bbg-clip-text\b/.test(classText) && /\bbg-gradient-to-/.test(classText)) {
2223
2766
  findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
2224
2767
  }
2225
2768
 
@@ -2228,10 +2771,10 @@ function checkHtmlPatterns(html) {
2228
2771
  // Side-tab accent stripe drawn as an absolutely-positioned pseudo-element
2229
2772
  // (no border property involved, so the element-level border checks and
2230
2773
  // the border-left regexes never see it).
2231
- findings.push(...scanCssTextForPseudoStripe(html));
2774
+ findings.push(...scanCssTextForPseudoStripe(styleText));
2232
2775
 
2233
2776
  // Side-tab accent stripe drawn as a single-edge inset box-shadow.
2234
- findings.push(...scanCssTextForInsetStripe(html));
2777
+ findings.push(...scanCssTextForInsetStripe(styleText));
2235
2778
 
2236
2779
  // --- Layout ---
2237
2780
 
@@ -2239,20 +2782,20 @@ function checkHtmlPatterns(html) {
2239
2782
  const spacingValues = [];
2240
2783
  const spacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
2241
2784
  let sm;
2242
- while ((sm = spacingRe.exec(html)) !== null) {
2785
+ while ((sm = spacingRe.exec(styleText)) !== null) {
2243
2786
  const v = parseInt(sm[1], 10);
2244
2787
  if (v > 0 && v < 200) spacingValues.push(v);
2245
2788
  }
2246
2789
  const gapRe = /gap\s*:\s*(\d+)px/gi;
2247
- while ((sm = gapRe.exec(html)) !== null) {
2790
+ while ((sm = gapRe.exec(styleText)) !== null) {
2248
2791
  spacingValues.push(parseInt(sm[1], 10));
2249
2792
  }
2250
2793
  const twSpaceRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
2251
- while ((sm = twSpaceRe.exec(html)) !== null) {
2794
+ while ((sm = twSpaceRe.exec(classText)) !== null) {
2252
2795
  spacingValues.push(parseInt(sm[1], 10) * 4);
2253
2796
  }
2254
2797
  const remSpacingRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
2255
- while ((sm = remSpacingRe.exec(html)) !== null) {
2798
+ while ((sm = remSpacingRe.exec(styleText)) !== null) {
2256
2799
  const v = Math.round(parseFloat(sm[1]) * 16);
2257
2800
  if (v > 0 && v < 200) spacingValues.push(v);
2258
2801
  }
@@ -2276,21 +2819,21 @@ function checkHtmlPatterns(html) {
2276
2819
 
2277
2820
  // Bounce/elastic animation names
2278
2821
  const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
2279
- const bounceMatch = bounceRe.exec(html);
2822
+ const bounceMatch = bounceRe.exec(styleText);
2280
2823
  if (bounceMatch) {
2281
2824
  const animationToken = bounceMatch[1]
2282
2825
  .split(/[,\s]+/)
2283
2826
  .find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
2284
- findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
2827
+ findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
2285
2828
  }
2286
2829
 
2287
2830
  // Overshoot cubic-bezier
2288
2831
  const bezierRe = /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g;
2289
2832
  let bm;
2290
- while ((bm = bezierRe.exec(html)) !== null) {
2833
+ while ((bm = bezierRe.exec(styleText)) !== null) {
2291
2834
  const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
2292
2835
  if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
2293
- findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
2836
+ findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
2294
2837
  break;
2295
2838
  }
2296
2839
  }
@@ -2298,7 +2841,7 @@ function checkHtmlPatterns(html) {
2298
2841
  // Layout property transitions
2299
2842
  const transRe = /transition(?:-property)?\s*:\s*([^;{}]+)/gi;
2300
2843
  let tm;
2301
- while ((tm = transRe.exec(html)) !== null) {
2844
+ while ((tm = transRe.exec(styleText)) !== null) {
2302
2845
  const val = tm[1].toLowerCase();
2303
2846
  if (/\ball\b/.test(val)) continue;
2304
2847
  const found = val.match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
@@ -2308,31 +2851,36 @@ function checkHtmlPatterns(html) {
2308
2851
  }
2309
2852
  }
2310
2853
 
2311
- // Pulsing status dots (tiny circular elements on infinite pulse animations)
2312
- findings.push(...scanCssTextForPulsingDot(html));
2854
+ // Pulsing status dots (tiny circular elements on infinite pulse animations).
2855
+ // The CSS rules come from styleText; the markup carries the landmark
2856
+ // ranges and Tailwind class attributes.
2857
+ findings.push(...scanCssTextForPulsingDot(styleText, html));
2313
2858
 
2314
2859
  // Shape-assembled illustrations (large pictorial SVGs built from primitives)
2315
2860
  findings.push(...scanHtmlForShapeAssembledIllustration(html));
2316
2861
 
2317
2862
  // Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
2318
- findings.push(...scanCssTextForMarquee(html));
2863
+ findings.push(...scanCssTextForMarquee(styleText, html));
2319
2864
 
2320
2865
  // --- Dark glow / chromatic halo shadows ---
2321
2866
 
2322
- const glowHits = scanCssTextForGlow(html);
2867
+ const glowHits = scanCssTextForGlow(styleText);
2323
2868
  if (glowHits.length > 0) {
2324
- findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
2869
+ findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
2325
2870
  }
2326
2871
 
2327
2872
  // Radial-gradient background halo (gradient-drawn sibling of dark-glow)
2328
- const haloHits = scanCssTextForRadialHalo(html);
2873
+ const haloHits = scanCssTextForRadialHalo(styleText);
2329
2874
  if (haloHits.length > 0) {
2330
- findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
2875
+ findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
2331
2876
  }
2332
2877
 
2333
2878
  // --- Generated-UI tells: repeating-gradient stripes ---
2334
- if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(html)) {
2335
- findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
2879
+ {
2880
+ const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
2881
+ if (stripesMatch) {
2882
+ findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
2883
+ }
2336
2884
  }
2337
2885
 
2338
2886
  // --- Generated-UI tells: two-axis grid-line background ---
@@ -2348,9 +2896,9 @@ function checkHtmlPatterns(html) {
2348
2896
  // in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
2349
2897
  // nested parens, so match the hairline stop directly rather than parsing
2350
2898
  // whole gradient layers.
2351
- const gridHits = scanCssTextForGridBackground(html);
2899
+ const gridHits = scanCssTextForGridBackground(styleText);
2352
2900
  if (gridHits.length > 0) {
2353
- findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
2901
+ findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
2354
2902
  }
2355
2903
 
2356
2904
  function stripHtmlBlocks(content, tagNames) {
@@ -2386,8 +2934,11 @@ function checkHtmlPatterns(html) {
2386
2934
  // hover:rotate / hover:translate utility on an <img>. Each distinct
2387
2935
  // mechanism is its own finding.
2388
2936
  const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
2389
- if (imgHoverCss.test(html)) {
2390
- findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
2937
+ {
2938
+ const imgHoverMatch = imgHoverCss.exec(styleText);
2939
+ if (imgHoverMatch) {
2940
+ findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
2941
+ }
2391
2942
  }
2392
2943
  const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
2393
2944
  let im;
@@ -2434,7 +2985,46 @@ function readOwnBackgroundColor(el, computedStyle) {
2434
2985
  return bg;
2435
2986
  }
2436
2987
 
2437
- function resolveBackground(el, win, customPropMap) {
2988
+ // One element's background-color as the cascade walk sees it: computed style
2989
+ // first (with the modern-color fallback), then, in static mode only,
2990
+ // custom-prop resolution and the inline-shorthand peek. Shared by
2991
+ // resolveBackgroundInfo and resolveGradientStops so both walks read the same
2992
+ // surfaces.
2993
+ function readCascadeBackgroundColor(current, style, customPropMap) {
2994
+ let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
2995
+ if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
2996
+ // The static engine can return literal "var(--X)" / "oklch(...)" strings.
2997
+ // Resolve through customPropMap so Tailwind v4 color tokens become RGB.
2998
+ if (customPropMap) {
2999
+ bg = parseColorResolved(style.backgroundColor, customPropMap);
3000
+ }
3001
+ if (!bg || bg.a < 0.1) {
3002
+ // Inline-style fallback for colors the static cascade did not surface
3003
+ // on backgroundColor.
3004
+ const rawStyle = current.getAttribute?.('style') || '';
3005
+ const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
3006
+ const inlineBg = bgMatch ? bgMatch[1].trim() : '';
3007
+ if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
3008
+ bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
3009
+ }
3010
+ }
3011
+ }
3012
+ return bg;
3013
+ }
3014
+
3015
+ // Walk up for the surface the element's text is painted on.
3016
+ //
3017
+ // Returns { color, unresolved }:
3018
+ // • color set — the effective surface, overlays composited in.
3019
+ // • unresolved: true — a layer on the way up paints a color this parser
3020
+ // cannot read, so the surface is unknown. Callers
3021
+ // must SKIP their contrast checks. Guessing white
3022
+ // here is what flooded dark themes with false
3023
+ // "on #ffffff" findings: one abstention costs a
3024
+ // single finding, one wrong guess costs a hundred.
3025
+ // • both null/false — no solid color, but a gradient or image is in
3026
+ // play; callers fall back to its color stops.
3027
+ function resolveBackgroundInfo(el, win, customPropMap) {
2438
3028
  let current = el;
2439
3029
  // Translucent layers (0.1 < a < 1) found on the way down to an opaque
2440
3030
  // base. A browser composites these over the base; the old behavior
@@ -2462,67 +3052,114 @@ function resolveBackground(el, win, customPropMap) {
2462
3052
  // body backgrounds.
2463
3053
  // Real browsers serialize wide-gamut computed values as oklab()/oklch()
2464
3054
  // (e.g. any color-mix() result), which plain parseRgb misses.
2465
- let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
2466
- if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
2467
- // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
2468
- // through customPropMap so Tailwind v4 color tokens become RGB.
2469
- if (customPropMap) {
2470
- bg = parseColorResolved(style.backgroundColor, customPropMap);
2471
- }
2472
- if (!bg || bg.a < 0.1) {
2473
- // Inline-style fallback. jsdom doesn't decompose background
2474
- // shorthand, so colors set via inline style are otherwise invisible.
2475
- const rawStyle = current.getAttribute?.('style') || '';
2476
- const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
2477
- const inlineBg = bgMatch ? bgMatch[1].trim() : '';
2478
- if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
2479
- bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
2480
- }
2481
- }
3055
+ let bg = readCascadeBackgroundColor(current, style, customPropMap);
3056
+
3057
+ // `background-color: currentcolor` paints with the element's own text
3058
+ // color real paint whose value we know. Real browsers resolve the
3059
+ // keyword before getComputedStyle output; jsdom hands it through
3060
+ // verbatim, and without this substitution the layer would read as
3061
+ // unparseable and force a needless abstention.
3062
+ if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
3063
+ // The static cascade resolves var() text tokens before checks run, so
3064
+ // style.color is normally already an rgb string here; parseColorResolved
3065
+ // is defense in depth for any future caller that passes a live
3066
+ // customPropMap (it matches the text-color path in checkElementColors
3067
+ // and reduces to parseAnyColor when the map is null or absent).
3068
+ bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
2482
3069
  }
2483
3070
 
2484
3071
  if (bg && bg.a > 0.1) {
2485
- if (bg.a >= 0.99) return flatten(bg);
3072
+ if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
2486
3073
  overlays.push(bg);
2487
- }
2488
- // No solid bg-color at this level. If THIS level has a gradient/url
2489
- // with no underlying solid color we can read:
2490
- // on body/html: assume white. Body-level gradients are almost
2491
- // always decorative texture (paper grain, noise) on top of a
2492
- // solid bg-color the page set via `background: var(--paper)`
2493
- // shorthand which jsdom can't decompose into bg-color. The
2494
- // downstream gradient-stops fallback path produces catastrophic
2495
- // false positives in this case (gradient noise stops have
2496
- // accidental browns/blacks that look like card backgrounds).
2497
- // on other elements: bail to null and let the caller fall back
2498
- // to gradient stops (gradient buttons / hero sections are real
2499
- // bgs worth checking against).
3074
+ } else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
3075
+ // This layer names a color we could not parse (a color space we do not
3076
+ // model, an unresolved var(), a syntax newer than the parser). It may
3077
+ // well be opaque, which would make every ancestor below it invisible —
3078
+ // so the surface is unknown and the walk stops here rather than
3079
+ // reporting an ancestor the visitor never sees.
3080
+ return { color: null, unresolved: true };
3081
+ }
3082
+ // No solid bg-color at this level, but this level paints an image. CSS
3083
+ // stacks background-image layers first-on-top, so which layer leads
3084
+ // decides what the visitor sees:
3085
+ // gradient on top — the gradient is the surface. Hand the caller a
3086
+ // null color so it falls back to the gradient's own stops (body
3087
+ // grounds, gradient buttons, hero sections).
3088
+ // • url() on top — the surface is an image whose pixels this engine
3089
+ // cannot read, and it may fully cover every layer and ancestor
3090
+ // beneath it. Same contract as an unparseable color: abstain, so
3091
+ // the gradient-stop fallback never measures a gradient the image
3092
+ // hides (the shipped miss: `url(photo), linear-gradient(...)`
3093
+ // reported low-contrast against the invisible gradient's stops).
2500
3094
  if (hasGradientOrUrl) {
2501
- if (current.tagName === 'BODY' || current.tagName === 'HTML') {
2502
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
3095
+ const layers = splitTopLevelCommas(bgImage);
3096
+ const topPaintLayer = layers.find(
3097
+ (layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
3098
+ );
3099
+ const gradientOnTop = !!topPaintLayer
3100
+ && /gradient\s*\(/i.test(topPaintLayer)
3101
+ && !/^\s*url\s*\(/i.test(topPaintLayer);
3102
+ if (!gradientOnTop) return { color: null, unresolved: true };
3103
+ // Gradient on top of a url() layer: the image shows through wherever
3104
+ // the gradient is not fully opaque, so a translucent wash like
3105
+ // `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
3106
+ // a blend with pixels this engine cannot read. Only a gradient whose
3107
+ // every readable stop is opaque provably covers the image; otherwise
3108
+ // the surface is unknown — abstain rather than hand callers gradient
3109
+ // stops (or a stop average) the visitor never sees unmixed.
3110
+ const urlBeneath = layers.some(
3111
+ (layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
3112
+ );
3113
+ if (urlBeneath) {
3114
+ const topStops = parseGradientColors(topPaintLayer);
3115
+ const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
3116
+ if (!provablyOpaque) return { color: null, unresolved: true };
2503
3117
  }
2504
- return null;
3118
+ return { color: null, unresolved: false };
2505
3119
  }
2506
3120
  current = current.parentElement;
2507
3121
  }
2508
- return flatten({ r: 255, g: 255, b: 255, a: 1 });
3122
+ // Every layer up to the document root was genuinely see-through, so the
3123
+ // browser paints its default canvas. This is the ONLY case that earns the
3124
+ // white assumption.
3125
+ return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
3126
+ }
3127
+
3128
+ function resolveBackground(el, win, customPropMap) {
3129
+ return resolveBackgroundInfo(el, win, customPropMap).color;
2509
3130
  }
2510
3131
 
2511
3132
  // Walk parents looking for a gradient background and return its color stops.
2512
3133
  // Used as a fallback when resolveBackground() returns null because the
2513
3134
  // effective background is a gradient (no single solid color to compare against).
3135
+ // Translucent solid layers found between the element and the gradient (frosted
3136
+ // panels, glass washes) are composited over every stop, the same way
3137
+ // resolveBackground flattens them over a solid base — raw stops alone would
3138
+ // false-flag dark text on a light frosted wash over a dark gradient, and miss
3139
+ // the inverse.
2514
3140
  function resolveGradientStops(el, win, customPropMap) {
2515
3141
  let current = el;
3142
+ const overlays = [];
2516
3143
  while (current && current.nodeType === 1) {
2517
3144
  const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
2518
3145
  const bgImage = style.backgroundImage || '';
3146
+ // A url() layer anywhere in the stack — alone, or alongside a gradient in
3147
+ // the same declaration (a translucent wash over a texture photo) — paints
3148
+ // pixels the analytic walk cannot know. Measuring the gradient stops over
3149
+ // the wrong base flagged dark ink sitting on a bright gold-leaf image at
3150
+ // 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
3151
+ // image-backed text.
3152
+ if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
2519
3153
  let stops = null;
2520
3154
  if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
3155
+ // parseGradientColors (shared) reads modern-space stops too — oklch,
3156
+ // color-mix and friends via balanced-paren token capture — so browser
3157
+ // computed values that keep the authored syntax stay measurable.
2521
3158
  const parsed = parseGradientColors(bgImage);
2522
3159
  if (parsed.length > 0) stops = parsed;
2523
3160
  }
2524
3161
  if (!stops && !DETECTOR_IS_BROWSER) {
2525
- // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
3162
+ // Static mode: peek at the raw inline style for gradients the cascade did not surface
2526
3163
  const rawStyle = current.getAttribute?.('style') || '';
2527
3164
  const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
2528
3165
  if (bgMatch && /gradient/i.test(bgMatch[1])) {
@@ -2530,7 +3167,23 @@ function resolveGradientStops(el, win, customPropMap) {
2530
3167
  if (parsed.length > 0) stops = parsed;
2531
3168
  }
2532
3169
  }
2533
- if (stops) return compositeGradientStops(stops, current, win, customPropMap);
3170
+ if (stops) {
3171
+ const composited = compositeGradientStops(stops, current, win, customPropMap);
3172
+ if (!composited || overlays.length === 0) return composited;
3173
+ return composited.map(stop => {
3174
+ let acc = stop;
3175
+ for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
3176
+ return acc;
3177
+ });
3178
+ }
3179
+ const bg = readCascadeBackgroundColor(current, style, customPropMap);
3180
+ if (bg && bg.a > 0.1) {
3181
+ // An opaque surface above the gradient means the gradient never shows
3182
+ // through here; resolveBackground would have returned it, so reaching
3183
+ // this is defensive — bail rather than measure the wrong layer.
3184
+ if (bg.a >= 0.99) return null;
3185
+ overlays.push(bg);
3186
+ }
2534
3187
  current = current.parentElement;
2535
3188
  }
2536
3189
  return null;
@@ -2714,491 +3367,224 @@ function checkElementPseudoStripeDOM(el) {
2714
3367
 
2715
3368
  findings.push({
2716
3369
  id: 'side-tab',
2717
- snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
2718
- });
2719
- }
2720
- return findings;
2721
- }
2722
-
2723
- // Full-cover surface pseudo (browser): a ::before/::after positioned
2724
- // absolute/fixed whose box covers (nearly) the whole host and carries an
2725
- // opaque background. That pseudo is the element's visible surface even
2726
- // though the element's own background-color reads transparent — the nav-CTA
2727
- // construction that otherwise escapes every own-background contrast gate.
2728
- function readPseudoSurfaceDOM(el, rect) {
2729
- for (const which of ['::before', '::after']) {
2730
- let ps;
2731
- try { ps = getComputedStyle(el, which); } catch { continue; }
2732
- if (!ps || ps.content === 'none' || ps.content === '') continue;
2733
- if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
2734
- if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
2735
- const w = parseFloat(ps.width) || 0;
2736
- const h = parseFloat(ps.height) || 0;
2737
- if (w < rect.width - 4 || h < rect.height - 4) continue;
2738
- const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2739
- if (!bg || (bg.a ?? 1) < 0.9) continue;
2740
- return bg;
2741
- }
2742
- return null;
2743
- }
2744
-
2745
- function checkElementColorsDOM(el) {
2746
- const tag = el.tagName.toLowerCase();
2747
- // No early SAFE_TAGS bail here — checkColors() does its own gating that
2748
- // includes the styled-button exception for <a> / <button> with their own
2749
- // opaque background. Bailing here would prevent that exception from firing.
2750
- const rect = el.getBoundingClientRect();
2751
- if (rect.width < 10 || rect.height < 10) return [];
2752
- const style = getComputedStyle(el);
2753
- const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
2754
- const hasDirectText = directText.trim().length > 0;
2755
- let effectiveBg = resolveBackground(el);
2756
- let ownBg = readOwnBackgroundColor(el, style);
2757
- if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
2758
- const pseudoSurface = readPseudoSurfaceDOM(el, rect);
2759
- if (pseudoSurface) {
2760
- ownBg = pseudoSurface;
2761
- effectiveBg = pseudoSurface;
2762
- }
2763
- }
2764
- return checkColors({
2765
- tag,
2766
- // Chrome serializes computed colors specified in modern spaces as
2767
- // oklch()/oklab() strings; without the parseAnyColor fallback the text
2768
- // color comes back null and the low-contrast / gray-on-color checks
2769
- // silently never run (the shipped miss: a nav CTA whose text color was
2770
- // an oklch token near its own oklch background).
2771
- textColor: parseRgb(style.color) || parseAnyColor(style.color),
2772
- bgColor: ownBg,
2773
- effectiveBg,
2774
- effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
2775
- fontSize: parseFloat(style.fontSize) || 16,
2776
- fontWeight: parseInt(style.fontWeight) || 400,
2777
- hasDirectText,
2778
- isEmojiOnly: isEmojiOnlyText(directText),
2779
- bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
2780
- bgImage: style.backgroundImage || '',
2781
- classList: el.getAttribute('class') || '',
2782
- });
2783
- }
2784
-
2785
- function checkElementIconTileDOM(el) {
2786
- const tag = el.tagName.toLowerCase();
2787
- if (!HEADING_TAGS.has(tag)) return [];
2788
- const sibling = el.previousElementSibling;
2789
- if (!sibling) return [];
2790
-
2791
- const sibRect = sibling.getBoundingClientRect();
2792
- const headRect = el.getBoundingClientRect();
2793
- const sibStyle = getComputedStyle(sibling);
2794
-
2795
- // The tile may either contain an <svg>/<i> icon child, OR the tile itself
2796
- // may contain an emoji/symbol character directly as its only text content
2797
- // (the "card-icon" pattern from many AI-generated demos).
2798
- const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
2799
- const iconRect = iconChild?.getBoundingClientRect();
2800
- const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
2801
- const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
2802
-
2803
- return checkIconTile({
2804
- headingTag: tag,
2805
- headingText: el.textContent || '',
2806
- headingTop: headRect.top,
2807
- siblingTag: sibling.tagName.toLowerCase(),
2808
- siblingWidth: sibRect.width,
2809
- siblingHeight: sibRect.height,
2810
- siblingBottom: sibRect.bottom,
2811
- siblingBgColor: parseRgb(sibStyle.backgroundColor),
2812
- siblingBgImage: sibStyle.backgroundImage || '',
2813
- siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
2814
- siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
2815
- hasIconChild: !!iconChild || hasInlineEmojiIcon,
2816
- iconChildWidth: iconRect?.width || 0,
2817
- });
2818
- }
2819
-
2820
- function checkElementItalicSerifDOM(el) {
2821
- const tag = el.tagName.toLowerCase();
2822
- if (tag !== 'h1' && tag !== 'h2') return [];
2823
- const style = getComputedStyle(el);
2824
- return checkItalicSerif({
2825
- tag,
2826
- fontStyle: style.fontStyle || '',
2827
- fontFamily: style.fontFamily || '',
2828
- fontSize: parseFloat(style.fontSize) || 0,
2829
- headingText: el.textContent || '',
2830
- });
3370
+ snippet: `${classSelector(el)}${which} — absolute ${thickness}px pseudo-element stripe (${edge})`,
3371
+ });
3372
+ }
3373
+ return findings;
2831
3374
  }
2832
3375
 
2833
- function domAccentDashPseudo(el) {
3376
+ // Full-cover surface pseudo (browser): a ::before/::after positioned
3377
+ // absolute/fixed whose box covers (nearly) the whole host and carries an
3378
+ // opaque background. That pseudo is the element's visible surface even
3379
+ // though the element's own background-color reads transparent — the nav-CTA
3380
+ // construction that otherwise escapes every own-background contrast gate.
3381
+ function readPseudoSurfaceDOM(el, rect) {
2834
3382
  for (const which of ['::before', '::after']) {
2835
3383
  let ps;
2836
3384
  try { ps = getComputedStyle(el, which); } catch { continue; }
2837
3385
  if (!ps || ps.content === 'none' || ps.content === '') continue;
3386
+ if (ps.position !== 'absolute' && ps.position !== 'fixed') continue;
3387
+ if (ps.display === 'none' || (parseFloat(ps.opacity) || 1) < 0.9) continue;
2838
3388
  const w = parseFloat(ps.width) || 0;
2839
3389
  const h = parseFloat(ps.height) || 0;
2840
- if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
3390
+ if (w < rect.width - 4 || h < rect.height - 4) continue;
2841
3391
  const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
2842
- if (!bg || (bg.a ?? 1) < 0.1) continue;
2843
- if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
3392
+ if (!bg || (bg.a ?? 1) < 0.9) continue;
3393
+ return bg;
2844
3394
  }
2845
- return false;
3395
+ return null;
2846
3396
  }
2847
3397
 
2848
- function checkElementHeroEyebrowDOM(el) {
3398
+ function checkElementColorsDOM(el) {
2849
3399
  const tag = el.tagName.toLowerCase();
2850
- if (tag !== 'h1') return [];
2851
- const sibling = el.previousElementSibling;
2852
- if (!sibling) return [];
2853
- const headStyle = getComputedStyle(el);
2854
- const sibStyle = getComputedStyle(sibling);
2855
- return checkHeroEyebrow({
2856
- headingTag: tag,
2857
- headingText: el.textContent || '',
2858
- headingFontSize: parseFloat(headStyle.fontSize) || 0,
2859
- headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
2860
- siblingTag: sibling.tagName.toLowerCase(),
2861
- siblingText: sibling.textContent || '',
2862
- siblingTextTransform: sibStyle.textTransform || '',
2863
- siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
2864
- siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
2865
- siblingFontWeight: sibStyle.fontWeight || '',
2866
- siblingColor: sibStyle.color || '',
2867
- siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
2868
- });
2869
- }
2870
-
2871
- // Build a map of CSS custom properties declared on :root / :host / html.
2872
- // Used to resolve var(--X) refs that jsdom returns verbatim in
2873
- // getComputedStyle. Tailwind v4 routes every utility class through
2874
- // CSS vars (font-weight: var(--font-weight-bold), font-size:
2875
- // var(--text-xs), letter-spacing: var(--tracking-widest)), so without
2876
- // resolution every style-based check silently fails on Tailwind v4
2877
- // builds — the values come back as literal "var(--font-weight-bold)"
2878
- // strings and parseFloat returns NaN.
2879
- function buildCustomPropMap(document) {
2880
- const map = new Map();
2881
- let sheets;
2882
- try { sheets = Array.from(document.styleSheets || []); }
2883
- catch { return map; }
2884
- for (const sheet of sheets) {
2885
- let rules;
2886
- try { rules = Array.from(sheet.cssRules || []); }
2887
- catch { continue; }
2888
- for (const rule of rules) {
2889
- // Style rules only (type 1). Walk @media / @supports if present.
2890
- if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
2891
- try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
2892
- continue;
2893
- }
2894
- if (rule.type !== 1 /* STYLE_RULE */) continue;
2895
- const sel = rule.selectorText || '';
2896
- if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
2897
- const style = rule.style;
2898
- if (!style) continue;
2899
- for (let i = 0; i < style.length; i++) {
2900
- const prop = style[i];
2901
- if (!prop || !prop.startsWith('--')) continue;
2902
- const val = style.getPropertyValue(prop).trim();
2903
- if (val) map.set(prop, val);
2904
- }
3400
+ // No early SAFE_TAGS bail here — checkColors() does its own gating that
3401
+ // includes the styled-button exception for <a> / <button> with their own
3402
+ // opaque background. Bailing here would prevent that exception from firing.
3403
+ const rect = el.getBoundingClientRect();
3404
+ if (rect.width < 10 || rect.height < 10) return [];
3405
+ const style = getComputedStyle(el);
3406
+ // Invisible at rest: hidden scene variants (opacity-0 carousels, swap
3407
+ // decks) are not user-visible, and measuring their inherited colors against
3408
+ // whatever surface happens to sit behind the stack is noise, not audit.
3409
+ if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
3410
+ const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
3411
+ const hasDirectText = directText.trim().length > 0;
3412
+ const bgInfo = resolveBackgroundInfo(el);
3413
+ let effectiveBg = bgInfo.color;
3414
+ // An unreadable surface anywhere up the chain: skip the gradient-stop
3415
+ // fallback too, so nothing downstream measures against a ground we never
3416
+ // resolved.
3417
+ let surfaceUnresolved = bgInfo.unresolved;
3418
+ let ownBg = readOwnBackgroundColor(el, style);
3419
+ if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
3420
+ const pseudoSurface = readPseudoSurfaceDOM(el, rect);
3421
+ if (pseudoSurface) {
3422
+ ownBg = pseudoSurface;
3423
+ effectiveBg = pseudoSurface;
3424
+ surfaceUnresolved = false;
2905
3425
  }
2906
3426
  }
2907
- return map;
2908
- }
2909
-
2910
- // Resolve var(--X[, fallback]) refs in a computed-style value string.
2911
- // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
2912
- // the original string when no refs are present or the chain doesn't
2913
- // resolve. Safe to call on already-resolved values.
2914
- function resolveVarRefs(raw, customPropMap, depth = 0) {
2915
- if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
2916
- if (depth > 8) return raw;
2917
- return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
2918
- const v = customPropMap.get(name);
2919
- if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
2920
- return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
3427
+ return checkColors({
3428
+ tag,
3429
+ // Chrome serializes computed colors specified in modern spaces as
3430
+ // oklch()/oklab() strings; without the parseAnyColor fallback the text
3431
+ // color comes back null and the low-contrast / gray-on-color checks
3432
+ // silently never run (the shipped miss: a nav CTA whose text color was
3433
+ // an oklch token near its own oklch background).
3434
+ textColor: parseRgb(style.color) || parseAnyColor(style.color),
3435
+ bgColor: ownBg,
3436
+ effectiveBg: surfaceUnresolved ? null : effectiveBg,
3437
+ effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
3438
+ fontSize: parseFloat(style.fontSize) || 16,
3439
+ fontWeight: parseInt(style.fontWeight) || 400,
3440
+ hasDirectText,
3441
+ isEmojiOnly: isEmojiOnlyText(directText),
3442
+ bgClip: style.webkitBackgroundClip || style.backgroundClip || '',
3443
+ bgImage: style.backgroundImage || '',
3444
+ classList: el.getAttribute('class') || '',
2921
3445
  });
2922
3446
  }
2923
3447
 
2924
- // OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
2925
- // C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
2926
- // Needed because jsdom doesn't compute oklch() values — getComputedStyle
2927
- // returns the literal "oklch(...)" string. Without this, the entire
2928
- // Tailwind v4 color palette (which is OKLCH-based) is invisible to the
2929
- // detector's contrast / color checks.
2930
- function oklchToRgb(L, C, H) {
2931
- const hRad = (H * Math.PI) / 180;
2932
- return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
2933
- }
2934
-
2935
- function oklabToRgb(L, a, b) {
2936
- const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
2937
- const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
2938
- const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
2939
- const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
2940
- const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
2941
- const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
2942
- const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
2943
- const enc = (x) => {
2944
- const c = Math.max(0, Math.min(1, x));
2945
- return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
2946
- };
2947
- return {
2948
- r: Math.round(enc(rLin) * 255),
2949
- g: Math.round(enc(gLin) * 255),
2950
- b: Math.round(enc(bLin) * 255),
2951
- a: 1,
2952
- };
2953
- }
2954
-
2955
- function hslToRgb(h, s, l) {
2956
- h = ((h % 360) + 360) % 360;
2957
- const c = (1 - Math.abs(2 * l - 1)) * s;
2958
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
2959
- const m0 = l - c / 2;
2960
- const [r, g, b] =
2961
- h < 60 ? [c, x, 0] :
2962
- h < 120 ? [x, c, 0] :
2963
- h < 180 ? [0, c, x] :
2964
- h < 240 ? [0, x, c] :
2965
- h < 300 ? [x, 0, c] : [c, 0, x];
2966
- return {
2967
- r: Math.round((r + m0) * 255),
2968
- g: Math.round((g + m0) * 255),
2969
- b: Math.round((b + m0) * 255),
2970
- a: 1,
2971
- };
2972
- }
2973
-
2974
- function hwbToRgb(h, w, bl) {
2975
- if (w + bl >= 1) {
2976
- const g = Math.round((w / (w + bl)) * 255);
2977
- return { r: g, g, b: g, a: 1 };
2978
- }
2979
- const base = hslToRgb(h, 1, 0.5);
2980
- const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
2981
- return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
2982
- }
2983
-
2984
- // Common CSS named colors — the handful that actually show up in generated
2985
- // UIs, not the full 148-name spec list. Includes the achromatic names so a
2986
- // named gray parses (and correctly reads as no-chroma) instead of being
2987
- // treated as an unknown color.
2988
- const CSS_NAMED_COLORS = {
2989
- black: { r: 0, g: 0, b: 0 },
2990
- white: { r: 255, g: 255, b: 255 },
2991
- gray: { r: 128, g: 128, b: 128 },
2992
- grey: { r: 128, g: 128, b: 128 },
2993
- silver: { r: 192, g: 192, b: 192 },
2994
- dimgray: { r: 105, g: 105, b: 105 },
2995
- darkgray: { r: 169, g: 169, b: 169 },
2996
- lightgray: { r: 211, g: 211, b: 211 },
2997
- gainsboro: { r: 220, g: 220, b: 220 },
2998
- whitesmoke: { r: 245, g: 245, b: 245 },
2999
- red: { r: 255, g: 0, b: 0 },
3000
- crimson: { r: 220, g: 20, b: 60 },
3001
- tomato: { r: 255, g: 99, b: 71 },
3002
- coral: { r: 255, g: 127, b: 80 },
3003
- salmon: { r: 250, g: 128, b: 114 },
3004
- orange: { r: 255, g: 165, b: 0 },
3005
- gold: { r: 255, g: 215, b: 0 },
3006
- yellow: { r: 255, g: 255, b: 0 },
3007
- olive: { r: 128, g: 128, b: 0 },
3008
- lime: { r: 0, g: 255, b: 0 },
3009
- green: { r: 0, g: 128, b: 0 },
3010
- teal: { r: 0, g: 128, b: 128 },
3011
- turquoise: { r: 64, g: 224, b: 208 },
3012
- cyan: { r: 0, g: 255, b: 255 },
3013
- aqua: { r: 0, g: 255, b: 255 },
3014
- skyblue: { r: 135, g: 206, b: 235 },
3015
- dodgerblue: { r: 30, g: 144, b: 255 },
3016
- blue: { r: 0, g: 0, b: 255 },
3017
- navy: { r: 0, g: 0, b: 128 },
3018
- indigo: { r: 75, g: 0, b: 130 },
3019
- rebeccapurple: { r: 102, g: 51, b: 153 },
3020
- purple: { r: 128, g: 0, b: 128 },
3021
- violet: { r: 238, g: 130, b: 238 },
3022
- orchid: { r: 218, g: 112, b: 214 },
3023
- magenta: { r: 255, g: 0, b: 255 },
3024
- fuchsia: { r: 255, g: 0, b: 255 },
3025
- hotpink: { r: 255, g: 105, b: 180 },
3026
- pink: { r: 255, g: 192, b: 203 },
3027
- maroon: { r: 128, g: 0, b: 0 },
3028
- };
3029
-
3030
- // Split a string on top-level commas (ignoring commas nested in parens).
3031
- function splitTopLevelCommas(str) {
3032
- const parts = [];
3033
- let depth = 0, start = 0;
3034
- for (let i = 0; i < str.length; i++) {
3035
- const ch = str[i];
3036
- if (ch === '(') depth++;
3037
- else if (ch === ')') depth = Math.max(0, depth - 1);
3038
- else if (ch === ',' && depth === 0) {
3039
- parts.push(str.slice(start, i).trim());
3040
- start = i + 1;
3041
- }
3042
- }
3043
- const tail = str.slice(start).trim();
3044
- if (tail) parts.push(tail);
3045
- return parts;
3046
- }
3047
-
3048
- // Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
3049
- // the expression can't be resolved (unresolved var(), unknown colors).
3050
- //
3051
- // Mixing is done with premultiplied alpha in sRGB regardless of the
3052
- // declared interpolation space. That is exact for the dominant generated-UI
3053
- // pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
3054
- // result is simply <color> at alpha N% in ANY rectangular space, and a
3055
- // close-enough approximation for opaque-opaque mixes (the detector only
3056
- // consumes these values for contrast/chroma thresholds, not for display).
3057
- function parseColorMix(str) {
3058
- const m = String(str).trim().match(/^color-mix\(/i);
3059
- if (!m) return null;
3060
- // Balanced-paren capture of the arguments.
3061
- let depth = 0, end = -1;
3062
- const open = str.indexOf('(');
3063
- for (let i = open; i < str.length; i++) {
3064
- if (str[i] === '(') depth++;
3065
- else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
3066
- }
3067
- if (end < 0) return null;
3068
- const args = splitTopLevelCommas(str.slice(open + 1, end));
3069
- if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
3448
+ function checkElementIconTileDOM(el) {
3449
+ const tag = el.tagName.toLowerCase();
3450
+ if (!HEADING_TAGS.has(tag)) return [];
3451
+ const sibling = el.previousElementSibling;
3452
+ if (!sibling) return [];
3070
3453
 
3071
- const parseComponent = (component) => {
3072
- // Percentage may lead or trail the color per spec.
3073
- let pct = null;
3074
- let colorStr = component;
3075
- const trail = component.match(/\s+([\d.]+)%$/);
3076
- const lead = component.match(/^([\d.]+)%\s+/);
3077
- if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
3078
- else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
3079
- let color;
3080
- if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
3081
- else color = parseAnyColor(colorStr);
3082
- if (!color) return null;
3083
- return { color, pct };
3084
- };
3454
+ const sibRect = sibling.getBoundingClientRect();
3455
+ const headRect = el.getBoundingClientRect();
3456
+ const sibStyle = getComputedStyle(sibling);
3085
3457
 
3086
- const c1 = parseComponent(args[1]);
3087
- const c2 = parseComponent(args[2]);
3088
- if (!c1 || !c2) return null;
3089
- let p1 = c1.pct, p2 = c2.pct;
3090
- if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
3091
- else if (p1 == null) p1 = 100 - p2;
3092
- else if (p2 == null) p2 = 100 - p1;
3093
- const sum = p1 + p2;
3094
- if (sum <= 0) return null;
3095
- // Per spec: weights normalize to sum; when sum < 100 the result alpha is
3096
- // additionally scaled by sum/100.
3097
- const w1 = p1 / sum, w2 = p2 / sum;
3098
- const alphaScale = sum < 100 ? sum / 100 : 1;
3099
- const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
3100
- const a = (a1 * w1 + a2 * w2) * alphaScale;
3101
- if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
3102
- const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
3103
- return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
3458
+ // The tile may either contain an <svg>/<i> icon child, OR the tile itself
3459
+ // may contain an emoji/symbol character directly as its only text content
3460
+ // (the "card-icon" pattern from many AI-generated demos).
3461
+ const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
3462
+ const iconRect = iconChild?.getBoundingClientRect();
3463
+ const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
3464
+ const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
3465
+
3466
+ return checkIconTile({
3467
+ headingTag: tag,
3468
+ headingText: el.textContent || '',
3469
+ headingTop: headRect.top,
3470
+ siblingTag: sibling.tagName.toLowerCase(),
3471
+ siblingWidth: sibRect.width,
3472
+ siblingHeight: sibRect.height,
3473
+ siblingBottom: sibRect.bottom,
3474
+ siblingBgColor: parseRgb(sibStyle.backgroundColor),
3475
+ siblingBgImage: sibStyle.backgroundImage || '',
3476
+ siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
3477
+ siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
3478
+ hasIconChild: !!iconChild || hasInlineEmojiIcon,
3479
+ iconChildWidth: iconRect?.width || 0,
3480
+ });
3104
3481
  }
3105
3482
 
3106
- // Composite a translucent color over an opaque(ish) base (simple
3107
- // source-over in sRGB). Returns an opaque {r,g,b,a:1}.
3108
- function compositeColorOver(top, base) {
3109
- const a = top.a ?? 1;
3110
- return {
3111
- r: Math.round(top.r * a + base.r * (1 - a)),
3112
- g: Math.round(top.g * a + base.g * (1 - a)),
3113
- b: Math.round(top.b * a + base.b * (1 - a)),
3114
- a: 1,
3115
- };
3483
+ function checkElementItalicSerifDOM(el) {
3484
+ const tag = el.tagName.toLowerCase();
3485
+ if (tag !== 'h1' && tag !== 'h2') return [];
3486
+ const style = getComputedStyle(el);
3487
+ return checkItalicSerif({
3488
+ tag,
3489
+ fontStyle: style.fontStyle || '',
3490
+ fontFamily: style.fontFamily || '',
3491
+ fontSize: parseFloat(style.fontSize) || 0,
3492
+ headingText: el.textContent || '',
3493
+ });
3116
3494
  }
3117
3495
 
3118
- // Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
3119
- // named colors. Returns null on no match. Use this when the input might be
3120
- // any CSS color form; use plain parseRgb when you only expect computed rgb()
3121
- // values from real browsers.
3122
- function parseAnyColor(s) {
3123
- if (!s || typeof s !== 'string') return null;
3124
- const str = s.trim();
3125
- if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
3126
- if (/^color-mix\(/i.test(str)) return parseColorMix(str);
3127
- let m;
3128
- m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
3129
- 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 };
3130
- m = str.match(/^#([0-9a-f]{3,8})$/i);
3131
- if (m) {
3132
- const h = m[1];
3133
- if (h.length === 3 || h.length === 4) {
3134
- return {
3135
- r: parseInt(h[0] + h[0], 16),
3136
- g: parseInt(h[1] + h[1], 16),
3137
- b: parseInt(h[2] + h[2], 16),
3138
- a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
3139
- };
3140
- }
3141
- if (h.length === 6 || h.length === 8) {
3142
- return {
3143
- r: parseInt(h.slice(0, 2), 16),
3144
- g: parseInt(h.slice(2, 4), 16),
3145
- b: parseInt(h.slice(4, 6), 16),
3146
- a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
3147
- };
3148
- }
3149
- }
3150
- // OKLCH parser. Tailwind v4's CSS minifier squishes the space after
3151
- // `%` ("21.5%.02 50"), so the separator between L and C may be absent.
3152
- // Match L (with optional %), then C and H separated permissively.
3153
- m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3154
- if (m) {
3155
- const Lnum = parseFloat(m[1]);
3156
- const L = m[2] === '%' ? Lnum / 100 : Lnum;
3157
- const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
3158
- if (m[5] !== undefined) {
3159
- const alpha = parseFloat(m[5]);
3160
- rgb.a = m[6] === '%' ? alpha / 100 : alpha;
3161
- }
3162
- return rgb;
3163
- }
3164
- // OKLAB — a/b are signed axes; percentages map 100% → 0.4.
3165
- m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3166
- if (m) {
3167
- const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
3168
- const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
3169
- const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
3170
- const rgb = oklabToRgb(L, a, b);
3171
- if (m[7] !== undefined) {
3172
- const alpha = parseFloat(m[7]);
3173
- rgb.a = m[8] === '%' ? alpha / 100 : alpha;
3174
- }
3175
- return rgb;
3176
- }
3177
- // HSL/HSLA — comma or space syntax, optional deg on hue.
3178
- m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
3179
- if (m) {
3180
- const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3181
- if (m[4] !== undefined) {
3182
- const alpha = parseFloat(m[4]);
3183
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3184
- }
3185
- return rgb;
3496
+ function domAccentDashPseudo(el) {
3497
+ for (const which of ['::before', '::after']) {
3498
+ let ps;
3499
+ try { ps = getComputedStyle(el, which); } catch { continue; }
3500
+ if (!ps || ps.content === 'none' || ps.content === '') continue;
3501
+ const w = parseFloat(ps.width) || 0;
3502
+ const h = parseFloat(ps.height) || 0;
3503
+ if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
3504
+ const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
3505
+ if (!bg || (bg.a ?? 1) < 0.1) continue;
3506
+ if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
3186
3507
  }
3187
- // HWB — hue whiteness% blackness%.
3188
- m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
3189
- if (m) {
3190
- const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
3191
- if (m[4] !== undefined) {
3192
- const alpha = parseFloat(m[4]);
3193
- rgb.a = m[5] === '%' ? alpha / 100 : alpha;
3508
+ return false;
3509
+ }
3510
+
3511
+ function checkElementHeroEyebrowDOM(el) {
3512
+ const tag = el.tagName.toLowerCase();
3513
+ if (tag !== 'h1') return [];
3514
+ const sibling = el.previousElementSibling;
3515
+ if (!sibling) return [];
3516
+ const headStyle = getComputedStyle(el);
3517
+ const sibStyle = getComputedStyle(sibling);
3518
+ return checkHeroEyebrow({
3519
+ headingTag: tag,
3520
+ headingText: el.textContent || '',
3521
+ headingFontSize: parseFloat(headStyle.fontSize) || 0,
3522
+ headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
3523
+ siblingTag: sibling.tagName.toLowerCase(),
3524
+ siblingText: sibling.textContent || '',
3525
+ siblingTextTransform: sibStyle.textTransform || '',
3526
+ siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
3527
+ siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
3528
+ siblingFontWeight: sibStyle.fontWeight || '',
3529
+ siblingColor: sibStyle.color || '',
3530
+ siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
3531
+ });
3532
+ }
3533
+
3534
+ // Build a map of CSS custom properties declared on :root / :host / html.
3535
+ // Used to resolve var(--X) refs that jsdom returns verbatim in
3536
+ // getComputedStyle. Tailwind v4 routes every utility class through
3537
+ // CSS vars (font-weight: var(--font-weight-bold), font-size:
3538
+ // var(--text-xs), letter-spacing: var(--tracking-widest)), so without
3539
+ // resolution every style-based check silently fails on Tailwind v4
3540
+ // builds — the values come back as literal "var(--font-weight-bold)"
3541
+ // strings and parseFloat returns NaN.
3542
+ function buildCustomPropMap(document) {
3543
+ const map = new Map();
3544
+ let sheets;
3545
+ try { sheets = Array.from(document.styleSheets || []); }
3546
+ catch { return map; }
3547
+ for (const sheet of sheets) {
3548
+ let rules;
3549
+ try { rules = Array.from(sheet.cssRules || []); }
3550
+ catch { continue; }
3551
+ for (const rule of rules) {
3552
+ // Style rules only (type 1). Walk @media / @supports if present.
3553
+ if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
3554
+ try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
3555
+ continue;
3556
+ }
3557
+ if (rule.type !== 1 /* STYLE_RULE */) continue;
3558
+ const sel = rule.selectorText || '';
3559
+ if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
3560
+ const style = rule.style;
3561
+ if (!style) continue;
3562
+ for (let i = 0; i < style.length; i++) {
3563
+ const prop = style[i];
3564
+ if (!prop || !prop.startsWith('--')) continue;
3565
+ const val = style.getPropertyValue(prop).trim();
3566
+ if (val) map.set(prop, val);
3567
+ }
3194
3568
  }
3195
- return rgb;
3196
3569
  }
3197
- const named = CSS_NAMED_COLORS[str.toLowerCase()];
3198
- if (named) return { ...named, a: 1 };
3199
- return null;
3570
+ return map;
3571
+ }
3572
+
3573
+ // Resolve var(--X[, fallback]) refs in a computed-style value string.
3574
+ // Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
3575
+ // the original string when no refs are present or the chain doesn't
3576
+ // resolve. Safe to call on already-resolved values.
3577
+ function resolveVarRefs(raw, customPropMap, depth = 0) {
3578
+ if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
3579
+ if (depth > 8) return raw;
3580
+ return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
3581
+ const v = customPropMap.get(name);
3582
+ if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
3583
+ return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
3584
+ });
3200
3585
  }
3201
3586
 
3587
+
3202
3588
  // Resolve var() refs in a color string (via customPropMap), then parse.
3203
3589
  // Returns null on any failure. Used in jsdom-mode paths where
3204
3590
  // getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
@@ -3287,7 +3673,11 @@ function isKickerCandidate(opts) {
3287
3673
  || isSmallCaps;
3288
3674
  if (!isUppercased) return false;
3289
3675
  if (!(kickerFontSize > 0 && kickerFontSize <= 14)) return false;
3290
- const minTrackedSpacing = Math.max(1, kickerFontSize * 0.08);
3676
+ // Proportional only, no absolute floor: the wild's most common recipe is
3677
+ // 0.08em at a sub-13px size, which computes to under 1px and sailed past
3678
+ // the old Math.max(1, ...) floor (observed live: a page whose kickers were
3679
+ // literally class="kicker" produced zero findings).
3680
+ const minTrackedSpacing = kickerFontSize * 0.06;
3291
3681
  if (!(kickerLetterSpacing >= minTrackedSpacing)) return false;
3292
3682
  return true;
3293
3683
  }
@@ -3556,9 +3946,20 @@ function checkElementGlowDOM(el) {
3556
3946
  if (!boxShadow && !textShadow) return [];
3557
3947
  // Use parent's background — glow radiates outward, so the surrounding context matters
3558
3948
  // If resolveBackground returns null (gradient), try to infer from the gradient colors
3559
- let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
3560
- if (!parentBg) {
3561
- // Gradient background sample its colors to determine if it's dark
3949
+ const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
3950
+ // Unknown surface (an unreadable layer on the way up): skip only the
3951
+ // gradient hunt below, which would walk PAST that layer and score the
3952
+ // glow against a background the visitor never sees. checkGlow still runs
3953
+ // with a null surface: the zero-offset chromatic halo tell holds on ANY
3954
+ // background, and the static loop already passes the unresolved walk's
3955
+ // null color straight through (detect-html.mjs uses resolveBackground).
3956
+ let parentBg = parentBgInfo.color;
3957
+ if (!parentBg && !parentBgInfo.unresolved) {
3958
+ // Gradient background — sample its colors to determine if it's dark.
3959
+ // Modern-syntax parsing matters here: body-level gradients now reach this
3960
+ // fallback in browser mode, and their stops usually serialize as oklch —
3961
+ // which the shared parseGradientColors reads via its color-function
3962
+ // token capture.
3562
3963
  let cur = el.parentElement;
3563
3964
  while (cur && cur.nodeType === 1) {
3564
3965
  const bgImage = getComputedStyle(cur).backgroundImage || '';
@@ -3606,10 +4007,13 @@ function checkElementAIPaletteDOM(el) {
3606
4007
  const hue = getHue(textColor);
3607
4008
  const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
3608
4009
  if (isAIPalette) {
3609
- const parentBg = el.parentElement ? resolveBackground(el.parentElement) : null;
3610
- // Also check gradient parents
3611
- let effectiveBg = parentBg;
3612
- if (!effectiveBg) {
4010
+ const parentBgInfo = el.parentElement
4011
+ ? resolveBackgroundInfo(el.parentElement)
4012
+ : { color: null, unresolved: false };
4013
+ // Unknown surface: leave effectiveBg null (no finding) rather than
4014
+ // hunting gradient ancestors past a layer we could not read.
4015
+ let effectiveBg = parentBgInfo.color;
4016
+ if (!effectiveBg && !parentBgInfo.unresolved) {
3613
4017
  let cur = el.parentElement;
3614
4018
  while (cur && cur.nodeType === 1) {
3615
4019
  const gi = getComputedStyle(cur).backgroundImage || '';
@@ -4404,10 +4808,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
4404
4808
  }
4405
4809
 
4406
4810
  function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
4811
+ // Invisible at rest, static twin of the browser walk's skip: opacity does
4812
+ // not inherit, so walk ancestors multiplying declared opacity down.
4813
+ if (style.visibility === 'hidden') return [];
4814
+ let effOpacity = 1;
4815
+ for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
4816
+ effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
4817
+ }
4818
+ if (effOpacity <= 0.02) return [];
4407
4819
  const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
4408
4820
  const hasDirectText = directText.trim().length > 0;
4409
4821
 
4410
- const effectiveBg = resolveBackground(el, window, customPropMap);
4822
+ const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
4823
+ const effectiveBg = bgInfo.color;
4411
4824
  // jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
4412
4825
  // parseRgb misses Tailwind-tokenized text colors. Resolve through the
4413
4826
  // customPropMap first; fall back to parseRgb for vanilla rgb() pages.
@@ -4453,11 +4866,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
4453
4866
  // element itself has no usable own background, that pseudo is the real
4454
4867
  // surface for contrast purposes.
4455
4868
  let finalEffectiveBg = effectiveBg;
4869
+ let surfaceUnresolved = bgInfo.unresolved;
4456
4870
  if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
4457
4871
  const pseudoSurface = window.getPseudoSurface(el);
4458
4872
  if (pseudoSurface) {
4459
4873
  ownBg = pseudoSurface;
4460
4874
  finalEffectiveBg = pseudoSurface;
4875
+ surfaceUnresolved = false;
4461
4876
  }
4462
4877
  }
4463
4878
 
@@ -4465,8 +4880,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
4465
4880
  tag,
4466
4881
  textColor,
4467
4882
  bgColor: ownBg,
4468
- effectiveBg: finalEffectiveBg,
4469
- effectiveBgStops: finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
4883
+ // Unknown surface: hand the checks nothing rather than a guess.
4884
+ effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
4885
+ effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
4470
4886
  fontSize: parseFloat(style.fontSize) || 16,
4471
4887
  fontWeight: parseInt(style.fontWeight) || 400,
4472
4888
  hasDirectText,
@@ -4671,12 +5087,6 @@ function checkTypography() {
4671
5087
  if (isBrandFontOnOwnDomain(font)) continue;
4672
5088
  findings.push({ type: 'overused-font', detail: `Primary font: ${font} (${Math.round(share * 100)}% of text)` });
4673
5089
  }
4674
-
4675
- // Single-font check: only one distinct primary font across all text
4676
- if (fontUsage.size === 1) {
4677
- const only = [...fontUsage.keys()][0];
4678
- findings.push({ type: 'single-font', detail: `only font used is ${only}` });
4679
- }
4680
5090
  }
4681
5091
 
4682
5092
  const sizes = new Set();
@@ -4938,14 +5348,6 @@ function checkPageTypography(doc, win) {
4938
5348
  findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
4939
5349
  }
4940
5350
 
4941
- // Single font
4942
- if (fonts.size === 1) {
4943
- const els = doc.querySelectorAll('*');
4944
- if (els.length >= 20) {
4945
- findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
4946
- }
4947
- }
4948
-
4949
5351
  // Flat type hierarchy
4950
5352
  const sizes = new Set();
4951
5353
  const textEls = doc.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div');
@@ -5576,6 +5978,11 @@ function isRenderedForBrowserRule(el) {
5576
5978
  function checkElementTextOverflowDOM(el) {
5577
5979
  const tag = el.tagName.toLowerCase();
5578
5980
  if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
5981
+ // scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
5982
+ // returns arbitrary non-zero values for both (a <text> reported 78/48 while
5983
+ // its rendered length sat comfortably inside its box), so the delta is
5984
+ // noise, not overflow. SVG clips to its own viewport anyway.
5985
+ if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
5579
5986
  if (!isRenderedForBrowserRule(el)) return [];
5580
5987
  // Only the element that actually owns overflowing text — not its ancestors,
5581
5988
  // which inherit a wider scrollWidth from the spilling descendant.
@@ -5960,6 +6367,22 @@ function isPaintedForOcclusion(el) {
5960
6367
  // path is pure geometry and runs anywhere on the page.
5961
6368
  const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
5962
6369
 
6370
+ // An element whose effective opacity multiplies out to ~0 paints nothing at
6371
+ // rest: it is not user-visible, so visual findings on it (contrast, occlusion)
6372
+ // measure a state nobody sees. Browser-only — the walk needs live computed
6373
+ // styles. Cycling scenes that fade such elements in later are the screenshot
6374
+ // subsystem's territory, not the analytic walk's.
6375
+ function effectiveOpacityDOM(el) {
6376
+ let o = 1;
6377
+ // Walk all the way through body and html: `body { opacity: 0 }` page-fade
6378
+ // wrappers hide every descendant just as thoroughly as a local wrapper.
6379
+ for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
6380
+ o *= parseFloat(getComputedStyle(cur).opacity || '1');
6381
+ if (o <= 0.02) return 0;
6382
+ }
6383
+ return o;
6384
+ }
6385
+
5963
6386
  function checkTextOcclusionDOM() {
5964
6387
  const findings = [];
5965
6388
  const seenVictims = new Set();
@@ -5987,6 +6410,11 @@ function checkTextOcclusionDOM() {
5987
6410
  }
5988
6411
  return false;
5989
6412
  };
6413
+ // The classic occluder shape this rules out is an opacity-0 interaction
6414
+ // layer — a range scrubber stretched over a before/after comparison — which
6415
+ // elementFromPoint still returns and whose UA background-color otherwise
6416
+ // reads as an opaque box.
6417
+ const effectiveOpacity = effectiveOpacityDOM;
5990
6418
 
5991
6419
  // Collect renderable text owners in / near the first viewport for the
5992
6420
  // elementFromPoint probe. SVG <text> counts too.
@@ -5999,6 +6427,7 @@ function checkTextOcclusionDOM() {
5999
6427
  const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
6000
6428
  if (text.length < 2) continue;
6001
6429
  if (!isPaintedForOcclusion(el)) continue;
6430
+ if (effectiveOpacity(el) <= 0.02) continue;
6002
6431
  let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
6003
6432
  if (rect.width < 6 || rect.height < 6) continue;
6004
6433
  // Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -6032,6 +6461,7 @@ function checkTextOcclusionDOM() {
6032
6461
  if (top === el || el.contains(top) || top.contains(el)) continue;
6033
6462
  const topCs = getComputedStyle(top);
6034
6463
  if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
6464
+ if (effectiveOpacity(top) <= 0.02) continue;
6035
6465
  const topTag = top.tagName.toLowerCase();
6036
6466
  // Text sitting under a raw image/video is contrast territory (deduped
6037
6467
  // against the pixel low-contrast rule); leave those alone here.
@@ -6924,6 +7354,10 @@ if (IS_BROWSER) {
6924
7354
 
6925
7355
  const reasons = collectVisualContrastReasons(el, style);
6926
7356
  if (reasons.length === 0) continue;
7357
+ // Image-only mode filters here, inside the cap: gradient/opacity/filter
7358
+ // candidates earlier in DOM order must not consume the budget and
7359
+ // starve the url()-backed texts this mode exists to sample.
7360
+ if (options.imageOnly && !reasons.includes('image background')) continue;
6927
7361
 
6928
7362
  const textColor = parseRgb(style.color);
6929
7363
  const fontSize = parseFloat(style.fontSize) || 16;
@@ -7416,6 +7850,7 @@ if (IS_BROWSER) {
7416
7850
  }
7417
7851
 
7418
7852
  async function analyzeVisualContrast(options = {}) {
7853
+ // imageOnly is enforced inside the collector, before the candidate cap.
7419
7854
  const candidates = collectVisualContrastCandidates(options);
7420
7855
  const results = [];
7421
7856
  const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -7501,9 +7936,16 @@ if (IS_BROWSER) {
7501
7936
 
7502
7937
  function addBrowserFindings(groupMap, el, findings) {
7503
7938
  if (!findings || findings.length === 0) return;
7939
+ // Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
7940
+ // matching findings for its whole subtree. Applied at this choke point so
7941
+ // every per-element attribution (checks, layout, occlusion, rhythm)
7942
+ // honors it; page-level findings attributed to <body> pass through
7943
+ // untouched, since body has no ignoring ancestor.
7944
+ const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
7945
+ if (kept.length === 0) return;
7504
7946
  const existing = groupMap.get(el);
7505
- if (existing) existing.push(...findings);
7506
- else groupMap.set(el, [...findings]);
7947
+ if (existing) existing.push(...kept);
7948
+ else groupMap.set(el, [...kept]);
7507
7949
  }
7508
7950
 
7509
7951
  function browserFindingsFromMap(groupMap) {
@@ -7861,9 +8303,27 @@ if (IS_BROWSER) {
7861
8303
  for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
7862
8304
  node.remove();
7863
8305
  }
7864
- const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
7865
- if (htmlPatternFindings.length > 0) {
7866
- const mapped = htmlPatternFindings.map(f => {
8306
+ // Regex findings that name a live selector resolve against the real DOM:
8307
+ // pseudo-element/class segments are stripped (the host element is the
8308
+ // anchor), a selector that matches nothing on this page drops the finding
8309
+ // (the CSS ships here, but the pattern never renders — the live DOM is
8310
+ // ground truth in the browser), and a match under a data-impeccable-ignore
8311
+ // ancestor is waived. Selector-less findings stay page-level.
8312
+ const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
8313
+ if (!f.selector) return true;
8314
+ const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
8315
+ if (!query || /^[,\s]*$/.test(query)) return true;
8316
+ let matches;
8317
+ try {
8318
+ matches = document.querySelectorAll(query);
8319
+ } catch {
8320
+ return true;
8321
+ }
8322
+ if (matches.length === 0) return false;
8323
+ return [...matches].some(el => !scopedIgnoreActive(el, f.id));
8324
+ });
8325
+ if (scopedHtmlFindings.length > 0) {
8326
+ const mapped = scopedHtmlFindings.map(f => {
7867
8327
  const item = { type: f.id, detail: f.snippet };
7868
8328
  if (f.severity) {
7869
8329
  item.severity = f.severity;
@@ -7893,8 +8353,27 @@ if (IS_BROWSER) {
7893
8353
  };
7894
8354
  }
7895
8355
 
8356
+ // Visual contrast has three modes. Explicit true runs the full sampled
8357
+ // pass; explicit false disables it entirely (the deterministic-only mode
8358
+ // the test suites use). Unset — the default overlay run — samples ONLY
8359
+ // image-backed text: the one class the analytic walk deliberately skips,
8360
+ // because a url() layer's pixels are unknowable without looking. In-page
8361
+ // sampling draws the source image alone to a canvas (glyph ink never
8362
+ // pollutes it), and a cross-origin image without CORS reports unresolved
8363
+ // instead of guessing.
8364
+ function visualContrastMode(options = {}) {
8365
+ const explicit = typeof options.visualContrast === 'boolean'
8366
+ ? options.visualContrast
8367
+ : typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
8368
+ ? window.__IMPECCABLE_CONFIG__.visualContrast
8369
+ : null;
8370
+ if (explicit === true) return 'full';
8371
+ if (explicit === false) return false;
8372
+ return 'image-only';
8373
+ }
8374
+
7896
8375
  function shouldRunVisualContrast(options = {}) {
7897
- return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
8376
+ return visualContrastMode(options) !== false;
7898
8377
  }
7899
8378
 
7900
8379
  function visualContrastOptions(options = {}) {
@@ -8071,6 +8550,7 @@ if (IS_BROWSER) {
8071
8550
  return [];
8072
8551
  }
8073
8552
  const resolvedOptions = visualContrastOptions(options);
8553
+ if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
8074
8554
  const analyses = await analyzeVisualContrast(resolvedOptions);
8075
8555
  if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
8076
8556
  lastVisualContrastAnalyses = analyses;