quiver-cli 1.3.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (407) hide show
  1. package/README.md +197 -239
  2. package/dist/cli.js +3163 -2040
  3. package/package.json +10 -11
  4. package/template/.agents/AGENTS.md +0 -26
  5. package/template/.agents/commands/cp.md +0 -108
  6. package/template/.agents/commands/next-setup.md +0 -1064
  7. package/template/.agents/commands/review.md +0 -68
  8. package/template/.agents/commands/terraform-readme.md +0 -38
  9. package/template/.agents/config.json +0 -80
  10. package/template/.agents/plugins/opencode/rtk.ts +0 -34
  11. package/template/.agents/skills/agent-browser/SKILL.md +0 -52
  12. package/template/.agents/skills/apps/skybridge/SKILL.md +0 -52
  13. package/template/.agents/skills/code/cleanup/SKILL.md +0 -26
  14. package/template/.agents/skills/code/improve/SKILL.md +0 -122
  15. package/template/.agents/skills/code/improve/references/audit-playbook.md +0 -130
  16. package/template/.agents/skills/code/improve/references/closing-the-loop.md +0 -96
  17. package/template/.agents/skills/code/improve/references/plan-template.md +0 -197
  18. package/template/.agents/skills/code/vercel-react-best-practices/AGENTS.md +0 -3810
  19. package/template/.agents/skills/code/vercel-react-best-practices/README.md +0 -123
  20. package/template/.agents/skills/code/vercel-react-best-practices/SKILL.md +0 -149
  21. package/template/.agents/skills/code/vercel-react-best-practices/metadata.json +0 -15
  22. package/template/.agents/skills/code/vercel-react-best-practices/rules/_sections.md +0 -46
  23. package/template/.agents/skills/code/vercel-react-best-practices/rules/_template.md +0 -28
  24. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-effect-event-deps.md +0 -56
  25. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-event-handler-refs.md +0 -55
  26. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-init-once.md +0 -42
  27. package/template/.agents/skills/code/vercel-react-best-practices/rules/advanced-use-latest.md +0 -39
  28. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-api-routes.md +0 -38
  29. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-cheap-condition-before-await.md +0 -37
  30. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-defer-await.md +0 -82
  31. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-dependencies.md +0 -51
  32. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-parallel.md +0 -28
  33. package/template/.agents/skills/code/vercel-react-best-practices/rules/async-suspense-boundaries.md +0 -99
  34. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-analyzable-paths.md +0 -63
  35. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-barrel-imports.md +0 -60
  36. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-conditional.md +0 -31
  37. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-defer-third-party.md +0 -49
  38. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-dynamic-imports.md +0 -35
  39. package/template/.agents/skills/code/vercel-react-best-practices/rules/bundle-preload.md +0 -50
  40. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-event-listeners.md +0 -74
  41. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-localstorage-schema.md +0 -71
  42. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-passive-event-listeners.md +0 -48
  43. package/template/.agents/skills/code/vercel-react-best-practices/rules/client-swr-dedup.md +0 -56
  44. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-batch-dom-css.md +0 -107
  45. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-function-results.md +0 -80
  46. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-property-access.md +0 -28
  47. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-cache-storage.md +0 -70
  48. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-combine-iterations.md +0 -32
  49. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-early-exit.md +0 -50
  50. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-flatmap-filter.md +0 -60
  51. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-hoist-regexp.md +0 -45
  52. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-index-maps.md +0 -37
  53. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-length-check-first.md +0 -49
  54. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-min-max-loop.md +0 -82
  55. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-request-idle-callback.md +0 -105
  56. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-set-map-lookups.md +0 -24
  57. package/template/.agents/skills/code/vercel-react-best-practices/rules/js-tosorted-immutable.md +0 -57
  58. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-activity.md +0 -26
  59. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +0 -47
  60. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-conditional-render.md +0 -40
  61. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-content-visibility.md +0 -38
  62. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hoist-jsx.md +0 -46
  63. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +0 -82
  64. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +0 -30
  65. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-resource-hints.md +0 -85
  66. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-script-defer-async.md +0 -68
  67. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-svg-precision.md +0 -28
  68. package/template/.agents/skills/code/vercel-react-best-practices/rules/rendering-usetransition-loading.md +0 -75
  69. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-defer-reads.md +0 -39
  70. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-dependencies.md +0 -45
  71. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +0 -40
  72. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-derived-state.md +0 -29
  73. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-functional-setstate.md +0 -74
  74. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-lazy-state-init.md +0 -58
  75. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +0 -38
  76. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-memo.md +0 -44
  77. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +0 -45
  78. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-no-inline-components.md +0 -82
  79. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +0 -35
  80. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-split-combined-hooks.md +0 -64
  81. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-transitions.md +0 -40
  82. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-deferred-value.md +0 -59
  83. package/template/.agents/skills/code/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +0 -73
  84. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-after-nonblocking.md +0 -73
  85. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-auth-actions.md +0 -96
  86. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-lru.md +0 -41
  87. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-cache-react.md +0 -76
  88. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-dedup-props.md +0 -65
  89. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-hoist-static-io.md +0 -149
  90. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-no-shared-module-state.md +0 -50
  91. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-fetching.md +0 -83
  92. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-parallel-nested-fetching.md +0 -34
  93. package/template/.agents/skills/code/vercel-react-best-practices/rules/server-serialization.md +0 -38
  94. package/template/.agents/skills/data/prisma-cli/SKILL.md +0 -265
  95. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +0 -27
  96. package/template/.agents/skills/data/prisma-cli/references/complete.md +0 -22
  97. package/template/.agents/skills/data/prisma-cli/references/db-execute.md +0 -78
  98. package/template/.agents/skills/data/prisma-cli/references/db-pull.md +0 -185
  99. package/template/.agents/skills/data/prisma-cli/references/db-push.md +0 -150
  100. package/template/.agents/skills/data/prisma-cli/references/db-seed.md +0 -188
  101. package/template/.agents/skills/data/prisma-cli/references/debug.md +0 -46
  102. package/template/.agents/skills/data/prisma-cli/references/dev.md +0 -157
  103. package/template/.agents/skills/data/prisma-cli/references/format.md +0 -48
  104. package/template/.agents/skills/data/prisma-cli/references/generate.md +0 -173
  105. package/template/.agents/skills/data/prisma-cli/references/init.md +0 -139
  106. package/template/.agents/skills/data/prisma-cli/references/mcp.md +0 -39
  107. package/template/.agents/skills/data/prisma-cli/references/migrate-deploy.md +0 -127
  108. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +0 -145
  109. package/template/.agents/skills/data/prisma-cli/references/migrate-diff.md +0 -89
  110. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +0 -80
  111. package/template/.agents/skills/data/prisma-cli/references/migrate-resolve.md +0 -57
  112. package/template/.agents/skills/data/prisma-cli/references/migrate-status.md +0 -65
  113. package/template/.agents/skills/data/prisma-cli/references/studio.md +0 -137
  114. package/template/.agents/skills/data/prisma-cli/references/validate.md +0 -53
  115. package/template/.agents/skills/data/prisma-client-api/SKILL.md +0 -216
  116. package/template/.agents/skills/data/prisma-client-api/references/client-methods.md +0 -223
  117. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +0 -221
  118. package/template/.agents/skills/data/prisma-client-api/references/filters.md +0 -256
  119. package/template/.agents/skills/data/prisma-client-api/references/model-queries.md +0 -281
  120. package/template/.agents/skills/data/prisma-client-api/references/query-options.md +0 -276
  121. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +0 -198
  122. package/template/.agents/skills/data/prisma-client-api/references/relations.md +0 -308
  123. package/template/.agents/skills/data/prisma-client-api/references/transactions.md +0 -184
  124. package/template/.agents/skills/design/impeccable/SKILL.md +0 -84
  125. package/template/.agents/skills/design/impeccable/reference/adapt.md +0 -312
  126. package/template/.agents/skills/design/impeccable/reference/adapt.native.md +0 -58
  127. package/template/.agents/skills/design/impeccable/reference/android.md +0 -46
  128. package/template/.agents/skills/design/impeccable/reference/animate.md +0 -89
  129. package/template/.agents/skills/design/impeccable/reference/audit.md +0 -136
  130. package/template/.agents/skills/design/impeccable/reference/audit.native.md +0 -139
  131. package/template/.agents/skills/design/impeccable/reference/bolder.md +0 -33
  132. package/template/.agents/skills/design/impeccable/reference/clarify.md +0 -94
  133. package/template/.agents/skills/design/impeccable/reference/colorize.md +0 -86
  134. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +0 -44
  135. package/template/.agents/skills/design/impeccable/reference/craft.md +0 -5
  136. package/template/.agents/skills/design/impeccable/reference/critique.md +0 -806
  137. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +0 -37
  138. package/template/.agents/skills/design/impeccable/reference/degraded/documenter.md +0 -24
  139. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +0 -38
  140. package/template/.agents/skills/design/impeccable/reference/degraded/manual-edit-applier.md +0 -92
  141. package/template/.agents/skills/design/impeccable/reference/delight.md +0 -70
  142. package/template/.agents/skills/design/impeccable/reference/distill.md +0 -111
  143. package/template/.agents/skills/design/impeccable/reference/doctor.md +0 -54
  144. package/template/.agents/skills/design/impeccable/reference/document.md +0 -416
  145. package/template/.agents/skills/design/impeccable/reference/extract.md +0 -69
  146. package/template/.agents/skills/design/impeccable/reference/harden.md +0 -336
  147. package/template/.agents/skills/design/impeccable/reference/hooks.md +0 -111
  148. package/template/.agents/skills/design/impeccable/reference/init.md +0 -131
  149. package/template/.agents/skills/design/impeccable/reference/ios.md +0 -51
  150. package/template/.agents/skills/design/impeccable/reference/layout.md +0 -84
  151. package/template/.agents/skills/design/impeccable/reference/live-setup.md +0 -102
  152. package/template/.agents/skills/design/impeccable/reference/live.md +0 -323
  153. package/template/.agents/skills/design/impeccable/reference/new-work.md +0 -145
  154. package/template/.agents/skills/design/impeccable/reference/onboard.md +0 -234
  155. package/template/.agents/skills/design/impeccable/reference/operate.md +0 -61
  156. package/template/.agents/skills/design/impeccable/reference/optimize.md +0 -258
  157. package/template/.agents/skills/design/impeccable/reference/overdrive.md +0 -127
  158. package/template/.agents/skills/design/impeccable/reference/polish.md +0 -105
  159. package/template/.agents/skills/design/impeccable/reference/quieter.md +0 -99
  160. package/template/.agents/skills/design/impeccable/reference/routing.md +0 -18
  161. package/template/.agents/skills/design/impeccable/reference/shape.md +0 -59
  162. package/template/.agents/skills/design/impeccable/reference/typeset.md +0 -80
  163. package/template/.agents/skills/design/impeccable/reference/visualize.md +0 -46
  164. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +0 -1022
  165. package/template/.agents/skills/design/impeccable/scripts/command-metadata.json +0 -94
  166. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +0 -391
  167. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +0 -513
  168. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +0 -814
  169. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +0 -325
  170. package/template/.agents/skills/design/impeccable/scripts/context.mjs +0 -1565
  171. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +0 -473
  172. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +0 -121
  173. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +0 -1
  174. package/template/.agents/skills/design/impeccable/scripts/detect-csp.mjs +0 -198
  175. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +0 -30
  176. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +0 -2204
  177. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +0 -432
  178. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +0 -1311
  179. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +0 -9043
  180. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +0 -51
  181. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +0 -434
  182. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +0 -1319
  183. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +0 -1241
  184. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +0 -288
  185. package/template/.agents/skills/design/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -189
  186. package/template/.agents/skills/design/impeccable/scripts/detector/findings.mjs +0 -18
  187. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +0 -213
  188. package/template/.agents/skills/design/impeccable/scripts/detector/profile/profiler.mjs +0 -166
  189. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +0 -635
  190. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +0 -5681
  191. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +0 -596
  192. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +0 -127
  193. package/template/.agents/skills/design/impeccable/scripts/detector/shared/fonts.mjs +0 -30
  194. package/template/.agents/skills/design/impeccable/scripts/detector/shared/inline-ignores.mjs +0 -148
  195. package/template/.agents/skills/design/impeccable/scripts/detector/shared/page.mjs +0 -7
  196. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +0 -329
  197. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +0 -166
  198. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +0 -457
  199. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +0 -447
  200. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +0 -819
  201. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +0 -538
  202. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +0 -2490
  203. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +0 -79
  204. package/template/.agents/skills/design/impeccable/scripts/lib/artifact-schema.mjs +0 -93
  205. package/template/.agents/skills/design/impeccable/scripts/lib/composition-catalog.mjs +0 -200
  206. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +0 -396
  207. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +0 -880
  208. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +0 -564
  209. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +0 -130
  210. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +0 -246
  211. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +0 -306
  212. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +0 -640
  213. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-paths.mjs +0 -137
  214. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +0 -72
  215. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +0 -37
  216. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +0 -26
  217. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +0 -281
  218. package/template/.agents/skills/design/impeccable/scripts/lib/provider.mjs +0 -5
  219. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +0 -194
  220. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +0 -369
  221. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +0 -485
  222. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-notice.mjs +0 -169
  223. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +0 -533
  224. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +0 -149
  225. package/template/.agents/skills/design/impeccable/scripts/lib/target-args.mjs +0 -42
  226. package/template/.agents/skills/design/impeccable/scripts/lib/target-slug.mjs +0 -33
  227. package/template/.agents/skills/design/impeccable/scripts/lib/template-extensions.mjs +0 -146
  228. package/template/.agents/skills/design/impeccable/scripts/live/accept-css.mjs +0 -617
  229. package/template/.agents/skills/design/impeccable/scripts/live/accept-verify.mjs +0 -60
  230. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +0 -84
  231. package/template/.agents/skills/design/impeccable/scripts/live/completion.mjs +0 -28
  232. package/template/.agents/skills/design/impeccable/scripts/live/event-validation.mjs +0 -199
  233. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/astro.mjs +0 -47
  234. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/detect-utils.mjs +0 -73
  235. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/index.mjs +0 -143
  236. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/journal.mjs +0 -197
  237. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nextjs.mjs +0 -49
  238. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/nuxt.mjs +0 -161
  239. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/script-src.mjs +0 -17
  240. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/static-html.mjs +0 -26
  241. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/sveltekit.mjs +0 -71
  242. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tag-strategy.mjs +0 -247
  243. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/tanstack-start.mjs +0 -70
  244. package/template/.agents/skills/design/impeccable/scripts/live/frameworks/vite-generic.mjs +0 -42
  245. package/template/.agents/skills/design/impeccable/scripts/live/generation-preflight.mjs +0 -149
  246. package/template/.agents/skills/design/impeccable/scripts/live/insert-ui.mjs +0 -458
  247. package/template/.agents/skills/design/impeccable/scripts/live/instructions.mjs +0 -142
  248. package/template/.agents/skills/design/impeccable/scripts/live/manual-apply.mjs +0 -939
  249. package/template/.agents/skills/design/impeccable/scripts/live/manual-edit-routes.mjs +0 -357
  250. package/template/.agents/skills/design/impeccable/scripts/live/manual-edits-buffer.mjs +0 -152
  251. package/template/.agents/skills/design/impeccable/scripts/live/poll-lanes.mjs +0 -14
  252. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +0 -139
  253. package/template/.agents/skills/design/impeccable/scripts/live/roots.mjs +0 -508
  254. package/template/.agents/skills/design/impeccable/scripts/live/session-store.mjs +0 -563
  255. package/template/.agents/skills/design/impeccable/scripts/live/source-lock.mjs +0 -105
  256. package/template/.agents/skills/design/impeccable/scripts/live/source-search.mjs +0 -105
  257. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +0 -969
  258. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +0 -1366
  259. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +0 -304
  260. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +0 -259
  261. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +0 -75
  262. package/template/.agents/skills/design/impeccable/scripts/live/vocabulary.mjs +0 -171
  263. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +0 -938
  264. package/template/.agents/skills/design/impeccable/scripts/live-browser-dom.js +0 -146
  265. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +0 -242
  266. package/template/.agents/skills/design/impeccable/scripts/live-browser-session.js +0 -123
  267. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +0 -12551
  268. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +0 -1200
  269. package/template/.agents/skills/design/impeccable/scripts/live-complete.mjs +0 -107
  270. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +0 -800
  271. package/template/.agents/skills/design/impeccable/scripts/live-discard-manual-edits.mjs +0 -51
  272. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +0 -463
  273. package/template/.agents/skills/design/impeccable/scripts/live-insert.mjs +0 -292
  274. package/template/.agents/skills/design/impeccable/scripts/live-manual-edit-evidence.mjs +0 -368
  275. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +0 -430
  276. package/template/.agents/skills/design/impeccable/scripts/live-resume.mjs +0 -123
  277. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +0 -1698
  278. package/template/.agents/skills/design/impeccable/scripts/live-status.mjs +0 -71
  279. package/template/.agents/skills/design/impeccable/scripts/live-target.mjs +0 -30
  280. package/template/.agents/skills/design/impeccable/scripts/live-wrap.mjs +0 -927
  281. package/template/.agents/skills/design/impeccable/scripts/live.mjs +0 -334
  282. package/template/.agents/skills/design/impeccable/scripts/modern-screenshot.umd.js +0 -14
  283. package/template/.agents/skills/design/impeccable/scripts/palette.mjs +0 -628
  284. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +0 -224
  285. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +0 -1783
  286. package/template/.agents/skills/design/impeccable/scripts/surface-brief.mjs +0 -74
  287. package/template/.agents/skills/design/shadcn/SKILL.md +0 -277
  288. package/template/.agents/skills/design/shadcn/agents/openai.yml +0 -5
  289. package/template/.agents/skills/design/shadcn/assets/shadcn-small.png +0 -0
  290. package/template/.agents/skills/design/shadcn/assets/shadcn.png +0 -0
  291. package/template/.agents/skills/design/shadcn/cli.md +0 -290
  292. package/template/.agents/skills/design/shadcn/customization.md +0 -209
  293. package/template/.agents/skills/design/shadcn/evals/evals.json +0 -77
  294. package/template/.agents/skills/design/shadcn/mcp.md +0 -105
  295. package/template/.agents/skills/design/shadcn/registry.md +0 -277
  296. package/template/.agents/skills/design/shadcn/rules/base-vs-radix.md +0 -306
  297. package/template/.agents/skills/design/shadcn/rules/chat.md +0 -250
  298. package/template/.agents/skills/design/shadcn/rules/composition.md +0 -213
  299. package/template/.agents/skills/design/shadcn/rules/forms.md +0 -192
  300. package/template/.agents/skills/design/shadcn/rules/icons.md +0 -101
  301. package/template/.agents/skills/design/shadcn/rules/styling.md +0 -185
  302. package/template/.agents/skills/find-skills/SKILL.md +0 -141
  303. package/template/.agents/skills/hono/SKILL.md +0 -593
  304. package/template/.agents/skills/integrations/langfuse/SKILL.md +0 -150
  305. package/template/.agents/skills/integrations/langfuse/references/ci-cd.md +0 -41
  306. package/template/.agents/skills/integrations/langfuse/references/cli.md +0 -60
  307. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +0 -35
  308. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +0 -87
  309. package/template/.agents/skills/integrations/langfuse/references/instrumentation.md +0 -121
  310. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +0 -293
  311. package/template/.agents/skills/integrations/langfuse/references/prompt-engineering.md +0 -35
  312. package/template/.agents/skills/integrations/langfuse/references/prompt-migration.md +0 -79
  313. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +0 -34
  314. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +0 -65
  315. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +0 -35
  316. package/template/.agents/skills/integrations/langfuse/references/trace-evaluator-upgrade.md +0 -76
  317. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +0 -66
  318. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +0 -75
  319. package/template/.agents/skills/integrations/posthog/SKILL.md +0 -102
  320. package/template/.agents/skills/integrations/posthog/references/error-tracking-alerts.md +0 -63
  321. package/template/.agents/skills/integrations/posthog/references/error-tracking-assigning-issues.md +0 -77
  322. package/template/.agents/skills/integrations/posthog/references/error-tracking-fingerprints.md +0 -57
  323. package/template/.agents/skills/integrations/posthog/references/error-tracking-monitoring.md +0 -140
  324. package/template/.agents/skills/integrations/posthog/references/error-tracking-nextjs.md +0 -490
  325. package/template/.agents/skills/integrations/posthog/references/error-tracking-source-maps.md +0 -45
  326. package/template/.agents/skills/integrations/posthog/references/feature-flags-best-practices.md +0 -139
  327. package/template/.agents/skills/integrations/posthog/references/feature-flags-react.md +0 -302
  328. package/template/.agents/skills/integrations/posthog/references/identify-users.md +0 -202
  329. package/template/.agents/skills/integrations/posthog/references/integration-example.md +0 -706
  330. package/template/.agents/skills/integrations/posthog/references/integration-nextjs.md +0 -385
  331. package/template/.agents/skills/integrations/posthog/references/integration-step-1-begin.md +0 -43
  332. package/template/.agents/skills/integrations/posthog/references/integration-step-2-edit.md +0 -37
  333. package/template/.agents/skills/integrations/posthog/references/integration-step-3-revise.md +0 -22
  334. package/template/.agents/skills/integrations/posthog/references/integration-step-4-conclude.md +0 -38
  335. package/template/.agents/skills/integrations/posthog/references/llm-analytics-anthropic.md +0 -200
  336. package/template/.agents/skills/integrations/posthog/references/llm-analytics-basics.md +0 -62
  337. package/template/.agents/skills/integrations/posthog/references/llm-analytics-costs.md +0 -197
  338. package/template/.agents/skills/integrations/posthog/references/llm-analytics-manual-capture.md +0 -397
  339. package/template/.agents/skills/integrations/posthog/references/llm-analytics-traces.md +0 -98
  340. package/template/.agents/skills/integrations/posthog/references/llm-analytics-vercel-ai.md +0 -120
  341. package/template/.agents/skills/repo/repo-ci/SKILL.md +0 -265
  342. package/template/.agents/skills/repo/repo-init-next-js/SKILL.md +0 -129
  343. package/template/.agents/skills/repo/repo-init-next-js/references/file-contents.md +0 -800
  344. package/template/.agents/skills/repo/repo-init-next-js/scripts/setup.sh +0 -47
  345. package/template/.agents/skills/repo/repo-init-node/SKILL.md +0 -196
  346. package/template/.agents/skills/skill-creator/LICENSE.txt +0 -202
  347. package/template/.agents/skills/skill-creator/SKILL.md +0 -485
  348. package/template/.agents/skills/skill-creator/agents/analyzer.md +0 -274
  349. package/template/.agents/skills/skill-creator/agents/comparator.md +0 -202
  350. package/template/.agents/skills/skill-creator/agents/grader.md +0 -223
  351. package/template/.agents/skills/skill-creator/assets/eval_review.html +0 -146
  352. package/template/.agents/skills/skill-creator/eval-viewer/generate_review.py +0 -471
  353. package/template/.agents/skills/skill-creator/eval-viewer/viewer.html +0 -1325
  354. package/template/.agents/skills/skill-creator/references/schemas.md +0 -430
  355. package/template/.agents/skills/skill-creator/scripts/__init__.py +0 -0
  356. package/template/.agents/skills/skill-creator/scripts/aggregate_benchmark.py +0 -401
  357. package/template/.agents/skills/skill-creator/scripts/generate_report.py +0 -326
  358. package/template/.agents/skills/skill-creator/scripts/improve_description.py +0 -247
  359. package/template/.agents/skills/skill-creator/scripts/package_skill.py +0 -136
  360. package/template/.agents/skills/skill-creator/scripts/quick_validate.py +0 -103
  361. package/template/.agents/skills/skill-creator/scripts/run_eval.py +0 -310
  362. package/template/.agents/skills/skill-creator/scripts/run_loop.py +0 -328
  363. package/template/.agents/skills/skill-creator/scripts/utils.py +0 -47
  364. package/template/.agents/skills/supabase/CHANGELOG.md +0 -78
  365. package/template/.agents/skills/supabase/SKILL.md +0 -149
  366. package/template/.agents/skills/supabase/assets/feedback-issue-template.md +0 -17
  367. package/template/.agents/skills/supabase/references/skill-feedback.md +0 -17
  368. package/template/.agents/skills/supabase-postgres-best-practices/CHANGELOG.md +0 -73
  369. package/template/.agents/skills/supabase-postgres-best-practices/SKILL.md +0 -64
  370. package/template/.agents/skills/supabase-postgres-best-practices/references/_contributing.md +0 -170
  371. package/template/.agents/skills/supabase-postgres-best-practices/references/_sections.md +0 -39
  372. package/template/.agents/skills/supabase-postgres-best-practices/references/_template.md +0 -34
  373. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-full-text-search.md +0 -55
  374. package/template/.agents/skills/supabase-postgres-best-practices/references/advanced-jsonb-indexing.md +0 -49
  375. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-idle-timeout.md +0 -46
  376. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-limits.md +0 -44
  377. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-pooling.md +0 -41
  378. package/template/.agents/skills/supabase-postgres-best-practices/references/conn-prepared-statements.md +0 -46
  379. package/template/.agents/skills/supabase-postgres-best-practices/references/data-batch-inserts.md +0 -54
  380. package/template/.agents/skills/supabase-postgres-best-practices/references/data-n-plus-one.md +0 -53
  381. package/template/.agents/skills/supabase-postgres-best-practices/references/data-pagination.md +0 -50
  382. package/template/.agents/skills/supabase-postgres-best-practices/references/data-upsert.md +0 -50
  383. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-advisory.md +0 -56
  384. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-deadlock-prevention.md +0 -68
  385. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-short-transactions.md +0 -50
  386. package/template/.agents/skills/supabase-postgres-best-practices/references/lock-skip-locked.md +0 -54
  387. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-explain-analyze.md +0 -45
  388. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-pg-stat-statements.md +0 -55
  389. package/template/.agents/skills/supabase-postgres-best-practices/references/monitor-vacuum-analyze.md +0 -55
  390. package/template/.agents/skills/supabase-postgres-best-practices/references/query-composite-indexes.md +0 -44
  391. package/template/.agents/skills/supabase-postgres-best-practices/references/query-covering-indexes.md +0 -40
  392. package/template/.agents/skills/supabase-postgres-best-practices/references/query-index-types.md +0 -48
  393. package/template/.agents/skills/supabase-postgres-best-practices/references/query-missing-indexes.md +0 -43
  394. package/template/.agents/skills/supabase-postgres-best-practices/references/query-partial-indexes.md +0 -45
  395. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-constraints.md +0 -80
  396. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-data-types.md +0 -46
  397. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-foreign-key-indexes.md +0 -59
  398. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-lowercase-identifiers.md +0 -55
  399. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-partitioning.md +0 -55
  400. package/template/.agents/skills/supabase-postgres-best-practices/references/schema-primary-keys.md +0 -61
  401. package/template/.agents/skills/supabase-postgres-best-practices/references/security-privileges.md +0 -54
  402. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-basics.md +0 -50
  403. package/template/.agents/skills/supabase-postgres-best-practices/references/security-rls-performance.md +0 -63
  404. package/template/.agents/skills/writing/humanizer/LICENSE +0 -21
  405. package/template/.agents/skills/writing/humanizer/README.md +0 -209
  406. package/template/.agents/skills/writing/humanizer/SKILL.md +0 -456
  407. package/template/.agents/upstreams.json +0 -115
@@ -1,2204 +0,0 @@
1
- const IS_BROWSER = typeof window !== 'undefined';
2
-
3
- // ─── Section 7: Browser UI (IS_BROWSER only) ────────────────────────────────
4
-
5
- if (IS_BROWSER) {
6
- // Detect extension mode via the script tag's data attribute or the document element fallback.
7
- // currentScript is reliable for synchronously-executing scripts (which our IIFE is).
8
- const _myScript = document.currentScript;
9
- const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true')
10
- || document.documentElement.dataset.impeccableExtension === 'true';
11
-
12
- // Kinpaku gold — pinned to the site's brand token (see
13
- // site/styles/kinpaku-tokens.css --ks-kinpaku). Keep this in sync with
14
- // the picker's C.brand in skill/scripts/live-browser.js and the kit's
15
- // picker section in site/styles/kinpaku-kit.css.
16
- //
17
- // One color across both light and dark host pages. The outline is a
18
- // 2px gesture pointing at an element + a labeled tag — it's a marker,
19
- // not body text, so it doesn't need WCAG AA against the page. The
20
- // label text inside the gold tag is dark (LABEL_INK) which has ~16:1
21
- // against the leaf gold, so reading the rule name is solid in both
22
- // modes. Hover deepens the gold (preserves chroma — never drops it,
23
- // dropping chroma washes the gold into a sand/olive tone).
24
- const BRAND_COLOR = 'oklch(84% 0.19 80.46)';
25
- const BRAND_COLOR_HOVER = 'oklch(74% 0.18 80)';
26
- const LABEL_INK = 'oklch(4% 0.004 95)';
27
- const LABEL_BG = BRAND_COLOR;
28
- const OUTLINE_COLOR = BRAND_COLOR;
29
-
30
- // Inject hover styles via CSS (more reliable than JS event listeners)
31
- const styleEl = document.createElement('style');
32
- styleEl.textContent = `
33
- @keyframes impeccable-reveal {
34
- from { opacity: 0; }
35
- to { opacity: 1; }
36
- }
37
- .impeccable-overlay:not(.impeccable-banner) {
38
- pointer-events: none;
39
- outline: 2px solid ${OUTLINE_COLOR};
40
- border-radius: 4px;
41
- transition: outline-color 0.15s ease;
42
- animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
43
- animation-play-state: paused;
44
- border-top-left-radius: 0;
45
- }
46
- .impeccable-overlay.impeccable-visible {
47
- animation-play-state: running;
48
- }
49
- .impeccable-overlay.impeccable-hover {
50
- outline-color: ${BRAND_COLOR_HOVER};
51
- z-index: 100001 !important;
52
- }
53
- .impeccable-overlay.impeccable-hover .impeccable-label {
54
- background: ${BRAND_COLOR_HOVER};
55
- }
56
- .impeccable-overlay.impeccable-spotlight {
57
- z-index: 100002 !important;
58
- }
59
- .impeccable-overlay.impeccable-spotlight-dimmed {
60
- opacity: 0.15 !important;
61
- animation: none !important;
62
- filter: blur(3px);
63
- }
64
- .impeccable-spotlight-backdrop {
65
- position: fixed;
66
- top: 0; left: 0; right: 0; bottom: 0;
67
- backdrop-filter: blur(3px) brightness(0.6);
68
- -webkit-backdrop-filter: blur(3px) brightness(0.6);
69
- pointer-events: none;
70
- z-index: 99998;
71
- opacity: 0;
72
- outline: none !important;
73
- animation: none !important;
74
- }
75
- .impeccable-spotlight-backdrop.impeccable-visible {
76
- opacity: 1;
77
- }
78
- .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} {
79
- display: none !important;
80
- }
81
- `;
82
- (document.head || document.documentElement).appendChild(styleEl);
83
-
84
- // Spotlight backdrop element (created lazily on first use)
85
- let spotlightBackdrop = null;
86
- let spotlightTarget = null;
87
-
88
- function getSpotlightBackdrop() {
89
- if (!spotlightBackdrop) {
90
- spotlightBackdrop = document.createElement('div');
91
- spotlightBackdrop.className = 'impeccable-spotlight-backdrop';
92
- document.body.appendChild(spotlightBackdrop);
93
- }
94
- return spotlightBackdrop;
95
- }
96
-
97
- function updateSpotlightClipPath() {
98
- if (!spotlightBackdrop || !spotlightTarget) return;
99
- const r = spotlightTarget.getBoundingClientRect();
100
- // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width)
101
- const inset = 4;
102
- const radius = 6; // outline border-radius (4) + outline width (2)
103
- const x1 = r.left - inset;
104
- const y1 = r.top - inset;
105
- const x2 = r.right + inset;
106
- const y2 = r.bottom + inset;
107
- const vw = window.innerWidth;
108
- const vh = window.innerHeight;
109
- // Outer rect + rounded inner rect (evenodd creates a hole)
110
- const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`;
111
- spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`;
112
- }
113
-
114
- function showSpotlight(target) {
115
- if (!target || !target.getBoundingClientRect) return;
116
- // Respect the spotlightBlur setting: if disabled, don't show the backdrop
117
- if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) {
118
- spotlightTarget = target;
119
- return;
120
- }
121
- spotlightTarget = target;
122
- const bd = getSpotlightBackdrop();
123
- updateSpotlightClipPath();
124
- bd.classList.add('impeccable-visible');
125
- }
126
-
127
- function hideSpotlight() {
128
- spotlightTarget = null;
129
- if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible');
130
- }
131
-
132
- function isInViewport(el) {
133
- const r = el.getBoundingClientRect();
134
- return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth;
135
- }
136
-
137
- // Reposition spotlight on scroll/resize
138
- window.addEventListener('scroll', () => {
139
- if (spotlightTarget) updateSpotlightClipPath();
140
- }, { passive: true });
141
- window.addEventListener('resize', () => {
142
- if (spotlightTarget) updateSpotlightClipPath();
143
- });
144
-
145
- const overlays = [];
146
- const TYPE_LABELS = {};
147
- const RULE_CATEGORY = {};
148
- for (const ap of ANTIPATTERNS) {
149
- TYPE_LABELS[ap.id] = ap.name.toLowerCase();
150
- RULE_CATEGORY[ap.id] = ap.category || 'quality';
151
- }
152
-
153
- function isInFixedContext(el) {
154
- let p = el;
155
- while (p && p !== document.body) {
156
- if (getComputedStyle(p).position === 'fixed') return true;
157
- p = p.parentElement;
158
- }
159
- return false;
160
- }
161
-
162
- function positionOverlay(overlay) {
163
- const el = overlay._targetEl;
164
- if (!el) return;
165
- const rect = el.getBoundingClientRect();
166
- if (overlay._isFixed) {
167
- // Viewport-relative coords for fixed targets
168
- overlay.style.top = `${rect.top - 2}px`;
169
- overlay.style.left = `${rect.left - 2}px`;
170
- } else {
171
- // Document-relative coords for normal targets
172
- overlay.style.top = `${rect.top + scrollY - 2}px`;
173
- overlay.style.left = `${rect.left + scrollX - 2}px`;
174
- }
175
- overlay.style.width = `${rect.width + 4}px`;
176
- overlay.style.height = `${rect.height + 4}px`;
177
- }
178
-
179
- function repositionOverlays() {
180
- for (const o of overlays) {
181
- if (!o._targetEl || o.classList.contains('impeccable-banner')) continue;
182
- // Skip overlays whose target is currently hidden (display: none on the overlay)
183
- if (o.style.display === 'none') continue;
184
- positionOverlay(o);
185
- }
186
- }
187
-
188
- let resizeRAF;
189
- const onResize = () => {
190
- cancelAnimationFrame(resizeRAF);
191
- resizeRAF = requestAnimationFrame(repositionOverlays);
192
- };
193
- window.addEventListener('resize', onResize);
194
- // Reposition on scroll too -- catches sticky/parallax shifts
195
- window.addEventListener('scroll', onResize, { passive: true });
196
- // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading)
197
- if (typeof ResizeObserver !== 'undefined') {
198
- const bodyResizeObserver = new ResizeObserver(onResize);
199
- bodyResizeObserver.observe(document.body);
200
- }
201
-
202
- // Track target element visibility via IntersectionObserver.
203
- // Uses a huge rootMargin so all *rendered* elements count as intersecting,
204
- // while display:none / closed <details> / hidden modals etc. do not.
205
- // This is event-driven -- no polling needed.
206
- let overlayIndex = 0;
207
- const visibilityObserver = new IntersectionObserver((entries) => {
208
- for (const entry of entries) {
209
- const overlay = entry.target._impeccableOverlay;
210
- if (!overlay) continue;
211
- if (entry.isIntersecting) {
212
- overlay.style.display = '';
213
- positionOverlay(overlay);
214
- if (!overlay._revealed) {
215
- overlay._revealed = true;
216
- if (firstScanDone) {
217
- // Subsequent reveals (re-scans, scroll-into-view): instant, no animation
218
- overlay.style.animation = 'none';
219
- } else {
220
- // Initial scan: staggered cascade reveal
221
- overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`;
222
- }
223
- requestAnimationFrame(() => {
224
- overlay.classList.add('impeccable-visible');
225
- if (overlay._checkLabel) overlay._checkLabel();
226
- });
227
- }
228
- } else {
229
- overlay.style.display = 'none';
230
- }
231
- }
232
- }, { rootMargin: '99999px' });
233
-
234
- function detachOverlay(overlay) {
235
- if (!overlay) return;
236
- if (typeof overlay._cleanup === 'function') {
237
- try { overlay._cleanup(); } catch { /* best effort overlay teardown */ }
238
- }
239
- if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) {
240
- visibilityObserver.unobserve(overlay._targetEl);
241
- delete overlay._targetEl._impeccableOverlay;
242
- }
243
- const idx = overlays.indexOf(overlay);
244
- if (idx >= 0) overlays.splice(idx, 1);
245
- overlay.remove();
246
- }
247
-
248
- // Reposition overlays after CSS transitions end (e.g. reveal animations).
249
- // Listens at document level so it catches transitions on ancestor elements
250
- // (the transform may be on a parent, not the flagged element itself).
251
- document.addEventListener('transitionend', (e) => {
252
- if (e.propertyName !== 'transform') return;
253
- for (const o of overlays) {
254
- if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue;
255
- if (e.target === o._targetEl || e.target.contains(o._targetEl)) {
256
- positionOverlay(o);
257
- }
258
- }
259
- });
260
-
261
- const highlight = function(el, findings) {
262
- if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay);
263
- const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop');
264
-
265
- const fixed = isInFixedContext(el);
266
- const rect = el.getBoundingClientRect();
267
- const outline = document.createElement('div');
268
- outline.className = 'impeccable-overlay';
269
- outline._targetEl = el;
270
- outline._isFixed = fixed;
271
- Object.assign(outline.style, {
272
- position: fixed ? 'fixed' : 'absolute',
273
- top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
274
- left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
275
- width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
276
- zIndex: '99999', boxSizing: 'border-box',
277
- });
278
-
279
- // Build per-finding label entries: ✦ prefix for slop
280
- const entries = findings.map(f => {
281
- const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
282
- const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
283
- return { name: prefix + name, detail: f.detail || f.snippet };
284
- });
285
- const allText = entries.map(e => e.name).join(', ');
286
-
287
- const label = document.createElement('div');
288
- label.className = 'impeccable-label';
289
- Object.assign(label.style, {
290
- position: 'absolute', bottom: '100%', left: '-2px',
291
- display: 'flex', alignItems: 'center',
292
- whiteSpace: 'nowrap',
293
- fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
294
- color: LABEL_INK, lineHeight: '14px',
295
- background: LABEL_BG,
296
- fontFamily: 'system-ui, sans-serif',
297
- borderRadius: '4px 4px 0 0',
298
- });
299
-
300
- const textSpan = document.createElement('span');
301
- textSpan.style.padding = '3px 8px';
302
- textSpan.textContent = allText;
303
- label.appendChild(textSpan);
304
-
305
- // State for cycling mode
306
- let cycleMode = false;
307
- let cycleIndex = 0;
308
- let isHovered = false;
309
- let prevBtn, nextBtn;
310
-
311
- function updateCycleText() {
312
- const e = entries[cycleIndex];
313
- textSpan.textContent = isHovered ? e.detail : e.name;
314
- }
315
-
316
- function enableCycleMode() {
317
- if (cycleMode || entries.length < 2) return;
318
- cycleMode = true;
319
-
320
- const btnStyle = {
321
- background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
322
- fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
323
- fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
324
- pointerEvents: 'auto',
325
- };
326
-
327
- const navGroup = document.createElement('span');
328
- Object.assign(navGroup.style, {
329
- display: 'inline-flex', alignItems: 'center', flexShrink: '0',
330
- });
331
-
332
- prevBtn = document.createElement('button');
333
- prevBtn.textContent = '\u2039';
334
- Object.assign(prevBtn.style, btnStyle);
335
- prevBtn.style.paddingLeft = '6px';
336
- prevBtn.addEventListener('click', (e) => {
337
- e.stopPropagation();
338
- cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
339
- updateCycleText();
340
- });
341
-
342
- nextBtn = document.createElement('button');
343
- nextBtn.textContent = '\u203A';
344
- Object.assign(nextBtn.style, btnStyle);
345
- nextBtn.style.paddingRight = '2px';
346
- nextBtn.addEventListener('click', (e) => {
347
- e.stopPropagation();
348
- cycleIndex = (cycleIndex + 1) % entries.length;
349
- updateCycleText();
350
- });
351
-
352
- navGroup.appendChild(prevBtn);
353
- navGroup.appendChild(nextBtn);
354
- label.insertBefore(navGroup, textSpan);
355
- textSpan.style.padding = '3px 8px 3px 4px';
356
- updateCycleText();
357
- }
358
-
359
- outline.appendChild(label);
360
-
361
- // Start hidden; the IntersectionObserver will show it once the target is rendered
362
- outline.style.display = 'none';
363
- outline._staggerIndex = overlayIndex++;
364
- el._impeccableOverlay = outline;
365
- visibilityObserver.observe(el);
366
-
367
- // After first paint, check label width vs outline
368
- outline._checkLabel = () => {
369
- if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
370
- enableCycleMode();
371
- }
372
- };
373
-
374
- // Hover: show detail text, darken
375
- const onMouseEnter = () => {
376
- isHovered = true;
377
- outline.classList.add('impeccable-hover');
378
- outline.style.outlineColor = BRAND_COLOR_HOVER;
379
- label.style.background = BRAND_COLOR_HOVER;
380
- if (cycleMode) {
381
- updateCycleText();
382
- } else {
383
- textSpan.textContent = entries.map(e => e.detail).join(' | ');
384
- }
385
- };
386
- const onMouseLeave = () => {
387
- isHovered = false;
388
- outline.classList.remove('impeccable-hover');
389
- outline.style.outlineColor = '';
390
- label.style.background = LABEL_BG;
391
- if (cycleMode) {
392
- updateCycleText();
393
- } else {
394
- textSpan.textContent = allText;
395
- }
396
- };
397
- el.addEventListener('mouseenter', onMouseEnter);
398
- el.addEventListener('mouseleave', onMouseLeave);
399
- outline._cleanup = () => {
400
- el.removeEventListener('mouseenter', onMouseEnter);
401
- el.removeEventListener('mouseleave', onMouseLeave);
402
- };
403
-
404
- document.body.appendChild(outline);
405
- overlays.push(outline);
406
- };
407
-
408
- const showPageBanner = function(findings) {
409
- if (!findings.length) return;
410
- const banner = document.createElement('div');
411
- banner.className = 'impeccable-overlay impeccable-banner';
412
- Object.assign(banner.style, {
413
- position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
414
- background: LABEL_BG, color: LABEL_INK,
415
- fontFamily: 'system-ui, sans-serif', fontSize: '13px',
416
- display: 'flex', alignItems: 'center', pointerEvents: 'auto',
417
- height: '36px', overflow: 'hidden', maxWidth: '100vw',
418
- transform: 'translateY(-100%)',
419
- transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
420
- });
421
- requestAnimationFrame(() => requestAnimationFrame(() => {
422
- banner.style.transform = 'translateY(0)';
423
- }));
424
-
425
- // Scrollable findings area
426
- const scrollArea = document.createElement('div');
427
- Object.assign(scrollArea.style, {
428
- flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
429
- display: 'flex', gap: '8px', alignItems: 'center',
430
- padding: '0 12px', scrollSnapType: 'x mandatory',
431
- scrollbarWidth: 'none',
432
- });
433
- for (const f of findings) {
434
- const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
435
- const tag = document.createElement('span');
436
- tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
437
- Object.assign(tag.style, {
438
- background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
439
- borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
440
- whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
441
- });
442
- scrollArea.appendChild(tag);
443
- }
444
- banner.appendChild(scrollArea);
445
-
446
- // Controls area (only in standalone mode, not extension)
447
- if (!EXTENSION_MODE) {
448
- const controls = document.createElement('div');
449
- Object.assign(controls.style, {
450
- display: 'flex', alignItems: 'center', gap: '2px',
451
- padding: '0 8px', flexShrink: '0',
452
- });
453
-
454
- // Toggle visibility button
455
- const toggle = document.createElement('button');
456
- toggle.textContent = '\u25C9'; // circle with dot (visible state)
457
- toggle.title = 'Toggle overlay visibility';
458
- Object.assign(toggle.style, {
459
- background: 'none', border: 'none',
460
- color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
461
- opacity: '0.85', transition: 'opacity 0.15s',
462
- });
463
- let overlaysVisible = true;
464
- toggle.addEventListener('click', () => {
465
- overlaysVisible = !overlaysVisible;
466
- document.body.classList.toggle('impeccable-hidden', !overlaysVisible);
467
- toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
468
- toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
469
- });
470
- controls.appendChild(toggle);
471
-
472
- // Close button
473
- const close = document.createElement('button');
474
- close.textContent = '\u00d7';
475
- close.title = 'Dismiss banner';
476
- Object.assign(close.style, {
477
- background: 'none', border: 'none',
478
- color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
479
- });
480
- close.addEventListener('click', () => banner.remove());
481
- controls.appendChild(close);
482
-
483
- banner.appendChild(controls);
484
- }
485
- document.body.appendChild(banner);
486
- overlays.push(banner);
487
- };
488
-
489
- // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
490
- // These change between builds and produce brittle, ugly selectors.
491
- function isLikelyHashedClass(c) {
492
- if (!c) return true;
493
- if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
494
- if (/^_[\w-]{5,}$/.test(c)) return true;
495
- if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
496
- return false;
497
- }
498
-
499
- function buildSelectorSegment(el) {
500
- const tag = el.tagName.toLowerCase();
501
- let sel = tag;
502
-
503
- if (el.classList && el.classList.length > 0) {
504
- const classes = [...el.classList]
505
- .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c))
506
- .slice(0, 2);
507
- if (classes.length > 0) {
508
- sel += '.' + classes.map(c => CSS.escape(c)).join('.');
509
- }
510
- }
511
-
512
- // Disambiguate among siblings only if the parent has multiple matches
513
- const parent = el.parentElement;
514
- if (parent) {
515
- try {
516
- const matching = parent.querySelectorAll(':scope > ' + sel);
517
- if (matching.length > 1) {
518
- const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
519
- const idx = sameType.indexOf(el) + 1;
520
- sel += `:nth-of-type(${idx})`;
521
- }
522
- } catch {
523
- const idx = [...parent.children].indexOf(el) + 1;
524
- sel = `${tag}:nth-child(${idx})`;
525
- }
526
- }
527
- return sel;
528
- }
529
-
530
- function generateSelector(el) {
531
- if (el === document.body) return 'body';
532
- if (el === document.documentElement) return 'html';
533
- // Read via getAttribute when `el.id` is not a string — a <form> with a
534
- // named control (e.g. <input name="id">) shadows the builtin getter and
535
- // returns the element, producing a garbage `#[object …]` selector (#407).
536
- const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
537
- if (elId) return '#' + CSS.escape(elId);
538
-
539
- const parts = [];
540
- let current = el;
541
- let depth = 0;
542
- const MAX_DEPTH = 10;
543
-
544
- while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
545
- parts.unshift(buildSelectorSegment(current));
546
-
547
- // Anchor on an ancestor's ID and stop walking up
548
- if (current.id) {
549
- parts[0] = '#' + CSS.escape(current.id);
550
- break;
551
- }
552
-
553
- // Stop as soon as the partial selector uniquely identifies the target
554
- const trySelector = parts.join(' > ');
555
- try {
556
- const matches = document.querySelectorAll(trySelector);
557
- if (matches.length === 1 && matches[0] === el) {
558
- return trySelector;
559
- }
560
- } catch { /* invalid selector — keep walking */ }
561
-
562
- current = current.parentElement;
563
- depth++;
564
- }
565
-
566
- return parts.join(' > ');
567
- }
568
-
569
- function getDirectText(el) {
570
- return [...el.childNodes]
571
- .filter(n => n.nodeType === 3)
572
- .map(n => n.textContent || '')
573
- .join('');
574
- }
575
-
576
- function getDirectTextRect(el) {
577
- const rects = [];
578
- for (const node of el.childNodes) {
579
- if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;
580
- const range = document.createRange();
581
- range.selectNodeContents(node);
582
- for (const rect of range.getClientRects()) {
583
- if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
584
- }
585
- range.detach?.();
586
- }
587
- if (rects.length === 0) return null;
588
- const left = Math.min(...rects.map(r => r.left));
589
- const top = Math.min(...rects.map(r => r.top));
590
- const right = Math.max(...rects.map(r => r.right));
591
- const bottom = Math.max(...rects.map(r => r.bottom));
592
- return {
593
- left,
594
- top,
595
- right,
596
- bottom,
597
- width: right - left,
598
- height: bottom - top,
599
- x: left,
600
- y: top,
601
- };
602
- }
603
-
604
- function collectVisualContrastReasons(el, style) {
605
- const reasons = new Set();
606
- const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
607
- const ownBgImage = style.backgroundImage || '';
608
- if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {
609
- reasons.add('background-clip text');
610
- }
611
- if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');
612
-
613
- let current = el;
614
- while (current && current.nodeType === 1) {
615
- const tag = current.tagName?.toLowerCase();
616
- const currentStyle = getComputedStyle(current);
617
- const bgImage = currentStyle.backgroundImage || '';
618
- const isDocumentSurface = tag === 'body' || tag === 'html';
619
-
620
- if (!isDocumentSurface && bgImage && bgImage !== 'none') {
621
- if (/url\s*\(/i.test(bgImage)) reasons.add('image background');
622
- if (/gradient/i.test(bgImage)) reasons.add('gradient background');
623
- }
624
- if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');
625
- if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');
626
- if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
627
- if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
628
-
629
- const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
630
- if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
631
- current = current.parentElement;
632
- }
633
-
634
- const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();
635
- if (sampleRect && document.elementsFromPoint) {
636
- const points = [
637
- [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],
638
- [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],
639
- [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],
640
- ];
641
- for (const [x, y] of points) {
642
- if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
643
- const stack = document.elementsFromPoint(x, y);
644
- const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));
645
- if (selfIndex < 0) continue;
646
- for (const node of stack.slice(selfIndex + 1)) {
647
- const nodeTag = node.tagName?.toLowerCase();
648
- if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {
649
- reasons.add(`${nodeTag} underlay`);
650
- break;
651
- }
652
- }
653
- }
654
- }
655
-
656
- return [...reasons];
657
- }
658
-
659
- function collectVisualContrastCandidates(options = {}) {
660
- const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;
661
- const candidates = [];
662
- for (const el of document.querySelectorAll('*')) {
663
- if (candidates.length >= maxCandidates) break;
664
- if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
665
- if (el.closest('[id^="impeccable-live-"]')) continue;
666
- if (el === document.body || el === document.documentElement) continue;
667
- if (!isRenderedForBrowserRule(el)) continue;
668
-
669
- const tag = el.tagName.toLowerCase();
670
- const style = getComputedStyle(el);
671
- if (style.display === 'none' || style.visibility === 'hidden') continue;
672
- const directText = getDirectText(el);
673
- const hasDirectText = directText.trim().length > 0;
674
- if (!hasDirectText || isEmojiOnlyText(directText)) continue;
675
-
676
- const bgColor = readOwnBackgroundColor(el, style);
677
- const isStyledButton = (tag === 'a' || tag === 'button')
678
- && bgColor && bgColor.a > 0.5;
679
- if (SAFE_TAGS.has(tag) && !isStyledButton) continue;
680
-
681
- const rect = getDirectTextRect(el) || el.getBoundingClientRect();
682
- if (!rect || rect.width < 4 || rect.height < 4) continue;
683
-
684
- const reasons = collectVisualContrastReasons(el, style);
685
- if (reasons.length === 0) continue;
686
- // Image-only mode filters here, inside the cap: gradient/opacity/filter
687
- // candidates earlier in DOM order must not consume the budget and
688
- // starve the url()-backed texts this mode exists to sample.
689
- if (options.imageOnly && !reasons.includes('image background')) continue;
690
-
691
- const textColor = parseRgb(style.color) || parseAnyColor(style.color);
692
- const fontSize = parseFloat(style.fontSize) || 16;
693
- const fontWeight = parseInt(style.fontWeight) || 400;
694
- const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
695
- const threshold = isLargeText ? 3.0 : 4.5;
696
- const clip = {
697
- x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),
698
- y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),
699
- width: Math.max(1, Math.ceil(rect.width + 4)),
700
- height: Math.max(1, Math.ceil(rect.height + 4)),
701
- };
702
-
703
- candidates.push({
704
- selector: generateSelector(el),
705
- tagName: tag,
706
- text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),
707
- threshold,
708
- reasons,
709
- clip,
710
- textColor,
711
- preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>
712
- reason === 'opacity stack' ||
713
- reason === 'blend mode' ||
714
- reason === 'filter' ||
715
- reason === 'backdrop filter' ||
716
- reason === 'background-clip text'
717
- ),
718
- backgroundClipText: reasons.includes('background-clip text'),
719
- });
720
- }
721
- return candidates;
722
- }
723
-
724
- const visualContrastImageCache = new Map();
725
- const visualContrastRasterCache = new WeakMap();
726
-
727
- function clampByte(value) {
728
- return Math.max(0, Math.min(255, Math.round(value)));
729
- }
730
-
731
- function blendRgba(fg, bg) {
732
- if (!fg) return bg || null;
733
- if (!bg || fg.a == null || fg.a >= 0.999) {
734
- return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };
735
- }
736
- const alpha = Math.max(0, Math.min(1, fg.a));
737
- return {
738
- r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),
739
- g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),
740
- b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),
741
- a: 1,
742
- };
743
- }
744
-
745
- function pickWorstContrastColor(textColor, colors) {
746
- const usable = (colors || []).filter(Boolean);
747
- if (!usable.length) return null;
748
- let worst = usable[0];
749
- let worstRatio = contrastRatio(textColor, worst);
750
- for (const color of usable.slice(1)) {
751
- const ratio = contrastRatio(textColor, color);
752
- if (ratio < worstRatio) {
753
- worst = color;
754
- worstRatio = ratio;
755
- }
756
- }
757
- return worst;
758
- }
759
-
760
- function firstCssUrl(value) {
761
- const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);
762
- if (!match) return '';
763
- return (match[1] || match[2] || match[3] || '').trim();
764
- }
765
-
766
- function getLayerValue(value, index = 0) {
767
- return String(value || '').split(',')[index]?.trim() || '';
768
- }
769
-
770
- function parsePositionToken(token, container, painted) {
771
- if (!token || token === 'center') return (container - painted) / 2;
772
- if (token === 'left' || token === 'top') return 0;
773
- if (token === 'right' || token === 'bottom') return container - painted;
774
- if (/%$/.test(token)) {
775
- const pct = parseFloat(token) / 100;
776
- return (container - painted) * pct;
777
- }
778
- if (/px$/.test(token)) return parseFloat(token) || 0;
779
- return (container - painted) / 2;
780
- }
781
-
782
- function parsePositionPair(positionValue) {
783
- const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);
784
- const first = tokens[0] || '50%';
785
- if (tokens.length < 2) {
786
- if (first === 'top' || first === 'bottom') return ['50%', first];
787
- return [first, '50%'];
788
- }
789
- return [first, tokens[1] || '50%'];
790
- }
791
-
792
- function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {
793
- const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
794
- const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
795
- let paintedWidth = intrinsicWidth;
796
- let paintedHeight = intrinsicHeight;
797
- const size = String(sizeValue || 'auto').trim();
798
-
799
- if (size === 'cover' || size === 'contain') {
800
- const scale = size === 'cover'
801
- ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
802
- : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
803
- paintedWidth = intrinsicWidth * scale;
804
- paintedHeight = intrinsicHeight * scale;
805
- } else if (size && size !== 'auto') {
806
- const parts = size.split(/\s+/);
807
- const widthToken = parts[0];
808
- const heightToken = parts[1] || 'auto';
809
- if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);
810
- else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;
811
- if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);
812
- else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);
813
- else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;
814
- }
815
-
816
- const [xToken, yToken] = parsePositionPair(positionValue);
817
- const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);
818
- const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);
819
- return {
820
- left: containerRect.left + positionX,
821
- top: containerRect.top + positionY,
822
- width: paintedWidth,
823
- height: paintedHeight,
824
- intrinsicWidth,
825
- intrinsicHeight,
826
- };
827
- }
828
-
829
- function parseObjectPosition(positionValue) {
830
- return parsePositionPair(positionValue);
831
- }
832
-
833
- function resolveObjectImageRect(containerRect, image, style) {
834
- const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
835
- const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
836
- const fit = style.objectFit || 'fill';
837
- let paintedWidth = containerRect.width;
838
- let paintedHeight = containerRect.height;
839
- if (fit === 'contain' || fit === 'cover') {
840
- const scale = fit === 'cover'
841
- ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
842
- : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
843
- paintedWidth = intrinsicWidth * scale;
844
- paintedHeight = intrinsicHeight * scale;
845
- } else if (fit === 'none') {
846
- paintedWidth = intrinsicWidth;
847
- paintedHeight = intrinsicHeight;
848
- } else if (fit === 'scale-down') {
849
- const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);
850
- paintedWidth = intrinsicWidth * containScale;
851
- paintedHeight = intrinsicHeight * containScale;
852
- }
853
- const [xToken, yToken] = parseObjectPosition(style.objectPosition);
854
- return {
855
- left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),
856
- top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),
857
- width: paintedWidth,
858
- height: paintedHeight,
859
- intrinsicWidth,
860
- intrinsicHeight,
861
- };
862
- }
863
-
864
- function pointToImageSource(point, paintedRect) {
865
- if (
866
- point.x < paintedRect.left ||
867
- point.y < paintedRect.top ||
868
- point.x > paintedRect.left + paintedRect.width ||
869
- point.y > paintedRect.top + paintedRect.height
870
- ) {
871
- return null;
872
- }
873
- return {
874
- x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),
875
- y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),
876
- };
877
- }
878
-
879
- async function loadVisualContrastImage(src) {
880
- if (!src) return null;
881
- if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);
882
- const promise = new Promise(resolve => {
883
- const img = new Image();
884
- let settled = false;
885
- const finish = value => {
886
- if (settled) return;
887
- settled = true;
888
- clearTimeout(timer);
889
- resolve(value);
890
- };
891
- const timer = setTimeout(() => finish(null), 800);
892
- try {
893
- const absolute = new URL(src, location.href);
894
- if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {
895
- img.crossOrigin = 'anonymous';
896
- }
897
- } catch {
898
- // Let the browser resolve unusual URLs itself.
899
- }
900
- img.onload = () => finish(img);
901
- img.onerror = () => finish(null);
902
- img.src = src;
903
- });
904
- visualContrastImageCache.set(src, promise);
905
- return promise;
906
- }
907
-
908
- function sampleDrawablePixel(drawable, sourcePoint) {
909
- if (visualContrastRasterCache.has(drawable)) {
910
- const cached = visualContrastRasterCache.get(drawable);
911
- if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };
912
- try {
913
- const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
914
- const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
915
- const data = cached.ctx.getImageData(x, y, 1, 1).data;
916
- return {
917
- status: 'sampled',
918
- color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
919
- };
920
- } catch (err) {
921
- return {
922
- status: 'unresolved',
923
- reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',
924
- };
925
- }
926
- }
927
-
928
- const canvas = document.createElement('canvas');
929
- const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;
930
- const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;
931
- const maxRasterSide = 640;
932
- const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));
933
- canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));
934
- canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));
935
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
936
- if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };
937
- try {
938
- ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);
939
- const cached = {
940
- ctx,
941
- width: canvas.width,
942
- height: canvas.height,
943
- scaleX: canvas.width / intrinsicWidth,
944
- scaleY: canvas.height / intrinsicHeight,
945
- };
946
- visualContrastRasterCache.set(drawable, cached);
947
- const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
948
- const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
949
- const data = ctx.getImageData(x, y, 1, 1).data;
950
- return {
951
- status: 'sampled',
952
- color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
953
- };
954
- } catch (err) {
955
- const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';
956
- visualContrastRasterCache.set(drawable, { ctx: null, reason });
957
- return {
958
- status: 'unresolved',
959
- reason,
960
- };
961
- }
962
- }
963
-
964
- async function sampleCssBackground(el, style, point, textColor) {
965
- const rect = el.getBoundingClientRect();
966
- const bgImage = style.backgroundImage || '';
967
- if (bgImage && bgImage !== 'none') {
968
- if (/gradient/i.test(bgImage)) {
969
- const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));
970
- if (color) return { status: 'sampled', color, method: 'analytic-gradient' };
971
- }
972
- if (/url\s*\(/i.test(bgImage)) {
973
- const img = await loadVisualContrastImage(firstCssUrl(bgImage));
974
- if (!img) return { status: 'unresolved', reason: 'image unavailable' };
975
- const paintedRect = resolvePaintedImageRect(
976
- rect,
977
- img,
978
- getLayerValue(style.backgroundSize) || 'auto',
979
- getLayerValue(style.backgroundPosition) || '50% 50%',
980
- );
981
- const sourcePoint = pointToImageSource(point, paintedRect);
982
- if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };
983
- const sample = sampleDrawablePixel(img, sourcePoint);
984
- if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };
985
- return sample;
986
- }
987
- }
988
- const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
989
- if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
990
- return { status: 'unresolved', reason: 'no readable background' };
991
- }
992
-
993
- async function sampleImageElement(img, point) {
994
- const rect = img.getBoundingClientRect();
995
- const style = getComputedStyle(img);
996
- const paintedRect = resolveObjectImageRect(rect, img, style);
997
- const sourcePoint = pointToImageSource(point, paintedRect);
998
- if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };
999
- const sample = sampleDrawablePixel(img, sourcePoint);
1000
- if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };
1001
-
1002
- if (img.currentSrc || img.src) {
1003
- const loaded = await loadVisualContrastImage(img.currentSrc || img.src);
1004
- if (loaded) {
1005
- const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };
1006
- const loadedPoint = pointToImageSource(point, loadedRect);
1007
- if (loadedPoint) {
1008
- const loadedSample = sampleDrawablePixel(loaded, loadedPoint);
1009
- if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };
1010
- }
1011
- }
1012
- }
1013
- return sample;
1014
- }
1015
-
1016
- function textSamplePoints(rect) {
1017
- const insetX = Math.min(12, Math.max(1, rect.width * 0.12));
1018
- const insetY = Math.min(8, Math.max(1, rect.height * 0.22));
1019
- const xs = rect.width < 28
1020
- ? [rect.left + rect.width / 2]
1021
- : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];
1022
- const ys = rect.height < 22
1023
- ? [rect.top + rect.height / 2]
1024
- : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];
1025
- const points = [];
1026
- for (const y of ys) {
1027
- for (const x of xs) {
1028
- if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });
1029
- }
1030
- }
1031
- return points;
1032
- }
1033
-
1034
- async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {
1035
- if (depth > 8) {
1036
- return { status: 'unresolved', reason: 'background stack too deep' };
1037
- }
1038
- const stack = typeof document.elementsFromPoint === 'function'
1039
- ? document.elementsFromPoint(point.x, point.y)
1040
- : [];
1041
- const selfIndex = stack.findIndex(node => node === el || el.contains(node));
1042
- const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];
1043
- const unresolved = [];
1044
-
1045
- for (const node of nodes) {
1046
- if (!node || node.nodeType !== 1) continue;
1047
- if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1048
- const tag = node.tagName?.toLowerCase();
1049
- if (tag === 'img') {
1050
- const sample = await sampleImageElement(node, point);
1051
- if (sample.status === 'sampled') return sample;
1052
- unresolved.push(sample.reason);
1053
- continue;
1054
- }
1055
- if (tag === 'canvas' || tag === 'video') {
1056
- const rect = node.getBoundingClientRect();
1057
- const sourcePoint = pointToImageSource(point, {
1058
- left: rect.left,
1059
- top: rect.top,
1060
- width: rect.width,
1061
- height: rect.height,
1062
- intrinsicWidth: node.width || node.videoWidth || rect.width,
1063
- intrinsicHeight: node.height || node.videoHeight || rect.height,
1064
- });
1065
- if (sourcePoint) {
1066
- const sample = sampleDrawablePixel(node, sourcePoint);
1067
- if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };
1068
- unresolved.push(sample.reason);
1069
- }
1070
- continue;
1071
- }
1072
- const style = getComputedStyle(node);
1073
- const sample = await sampleCssBackground(node, style, point, textColor);
1074
- if (sample.status === 'sampled') {
1075
- if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;
1076
- const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);
1077
- if (under.status === 'sampled') {
1078
- return {
1079
- status: 'sampled',
1080
- color: blendRgba(sample.color, under.color),
1081
- method: `${sample.method}+alpha`,
1082
- };
1083
- }
1084
- return sample;
1085
- }
1086
- unresolved.push(sample.reason);
1087
- }
1088
-
1089
- return {
1090
- status: 'unresolved',
1091
- reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',
1092
- };
1093
- }
1094
-
1095
- async function analyzeVisualContrastCandidate(candidate) {
1096
- let el;
1097
- try {
1098
- el = document.querySelector(candidate.selector);
1099
- } catch {
1100
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
1101
- }
1102
- if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
1103
- if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
1104
-
1105
- const blockingReason = (candidate.reasons || []).find(reason =>
1106
- reason === 'background-clip text' ||
1107
- reason === 'blend mode' ||
1108
- reason === 'filter' ||
1109
- reason === 'backdrop filter' ||
1110
- reason === 'opacity stack' ||
1111
- reason === 'text shadow'
1112
- );
1113
- if (blockingReason) {
1114
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };
1115
- }
1116
-
1117
- const style = getComputedStyle(el);
1118
- const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
1119
- if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
1120
-
1121
- const rect = getDirectTextRect(el) || el.getBoundingClientRect();
1122
- if (!rect || rect.width < 4 || rect.height < 4) {
1123
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };
1124
- }
1125
-
1126
- const points = textSamplePoints(rect);
1127
- if (points.length === 0) {
1128
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };
1129
- }
1130
-
1131
- const ratios = [];
1132
- const methods = new Set();
1133
- const unresolved = [];
1134
- for (const point of points) {
1135
- const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);
1136
- if (sample.status !== 'sampled' || !sample.color) {
1137
- unresolved.push(sample.reason);
1138
- continue;
1139
- }
1140
- const fg = blendRgba(textColor, sample.color);
1141
- ratios.push(contrastRatio(fg, sample.color));
1142
- if (sample.method) methods.add(sample.method);
1143
- }
1144
-
1145
- if (ratios.length < Math.min(3, points.length)) {
1146
- return {
1147
- ...candidate,
1148
- status: 'unresolved',
1149
- confidence: 'none',
1150
- samples: ratios.length,
1151
- reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',
1152
- };
1153
- }
1154
-
1155
- ratios.sort((a, b) => a - b);
1156
- const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
1157
- const measuredRatio = pick(10);
1158
- const medianRatio = pick(50);
1159
- const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';
1160
- const method = [...methods].sort().join(', ') || 'browser-visual';
1161
- const textLabel = candidate.text ? ` "${candidate.text}"` : '';
1162
- const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;
1163
- return {
1164
- ...candidate,
1165
- status,
1166
- confidence: method.includes('canvas-') ? 'high' : 'medium',
1167
- method,
1168
- ratio: measuredRatio,
1169
- medianRatio,
1170
- samples: ratios.length,
1171
- finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,
1172
- };
1173
- }
1174
-
1175
- function waitForVisualPaint() {
1176
- return new Promise(resolve => {
1177
- requestAnimationFrame(() => requestAnimationFrame(resolve));
1178
- });
1179
- }
1180
-
1181
- async function analyzeVisualContrast(options = {}) {
1182
- // imageOnly is enforced inside the collector, before the candidate cap.
1183
- const candidates = collectVisualContrastCandidates(options);
1184
- const results = [];
1185
- const shouldScrollOffscreen = options.scrollOffscreen === true;
1186
- const restoreScroll = { x: window.scrollX, y: window.scrollY };
1187
- for (const candidate of candidates) {
1188
- if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
1189
- window.scrollTo(restoreScroll.x, restoreScroll.y);
1190
- await waitForVisualPaint();
1191
- }
1192
- let result = await analyzeVisualContrastCandidate(candidate);
1193
- if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {
1194
- let el = null;
1195
- try {
1196
- el = document.querySelector(candidate.selector);
1197
- } catch {
1198
- el = null;
1199
- }
1200
- if (el && typeof el.scrollIntoView === 'function') {
1201
- el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
1202
- await waitForVisualPaint();
1203
- result = await analyzeVisualContrastCandidate(candidate);
1204
- }
1205
- }
1206
- results.push(result);
1207
- }
1208
- if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
1209
- window.scrollTo(restoreScroll.x, restoreScroll.y);
1210
- }
1211
- return results;
1212
- }
1213
-
1214
- function isElementHidden(el) {
1215
- if (!el || el === document.body || el === document.documentElement) return false;
1216
- if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
1217
- // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
1218
- return el.offsetWidth === 0 && el.offsetHeight === 0;
1219
- }
1220
-
1221
- function serializeFindings(allFindings) {
1222
- return allFindings.map(({ el, findings }) => ({
1223
- selector: generateSelector(el),
1224
- tagName: el.tagName?.toLowerCase() || 'unknown',
1225
- rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
1226
- ? el.getBoundingClientRect().toJSON() : null,
1227
- isPageLevel: el === document.body || el === document.documentElement,
1228
- isHidden: isElementHidden(el),
1229
- findings: findings.map(f => {
1230
- const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
1231
- return {
1232
- type: f.type || f.id,
1233
- category: ap ? ap.category : 'quality',
1234
- severity: f.severity || ap?.severity || 'warning',
1235
- // Advisory findings (em-dash overuse, etc.) are surfaced but never
1236
- // treated as failures; carry the flag so the overlay/extension can
1237
- // render them with the mildest affordance and consumers can filter.
1238
- advisory: (ap && ap.advisory === true) || f.advisory === true,
1239
- detail: f.detail || f.snippet,
1240
- ignoreValue: f.ignoreValue || f.value || '',
1241
- name: ap ? ap.name : (f.type || f.id),
1242
- description: ap ? ap.description : '',
1243
- };
1244
- }),
1245
- }));
1246
- }
1247
-
1248
- const printSummary = function(allFindings) {
1249
- if (allFindings.length === 0) {
1250
- console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
1251
- return;
1252
- }
1253
- console.group(
1254
- `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
1255
- 'color: oklch(84% 0.19 80.46); font-weight: bold'
1256
- );
1257
- for (const { el, findings } of allFindings) {
1258
- for (const f of findings) {
1259
- console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
1260
- 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);
1261
- }
1262
- }
1263
- console.groupEnd();
1264
- };
1265
-
1266
- function addBrowserFindings(groupMap, el, findings) {
1267
- if (!findings || findings.length === 0) return;
1268
- // Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
1269
- // matching findings for its whole subtree. Applied at this choke point so
1270
- // every per-element attribution (checks, layout, occlusion, rhythm)
1271
- // honors it; page-level findings attributed to <body> pass through
1272
- // untouched, since body has no ignoring ancestor.
1273
- const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
1274
- if (kept.length === 0) return;
1275
- const existing = groupMap.get(el);
1276
- if (existing) existing.push(...kept);
1277
- else groupMap.set(el, [...kept]);
1278
- }
1279
-
1280
- function browserFindingsFromMap(groupMap) {
1281
- return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
1282
- }
1283
-
1284
- const DESIGN_COLOR_TOLERANCE = 6;
1285
- const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
1286
- const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
1287
-
1288
- function normalizeBrowserFontName(value) {
1289
- return String(value || '')
1290
- .trim()
1291
- .replace(/^["']|["']$/g, '')
1292
- .replace(/\+/g, ' ')
1293
- .replace(/\s+/g, ' ')
1294
- .toLowerCase();
1295
- }
1296
-
1297
- function browserPrimaryFont(stack) {
1298
- if (!stack || /var\(/i.test(stack)) return '';
1299
- return String(stack || '')
1300
- .split(',')
1301
- .map(normalizeBrowserFontName)
1302
- .find(font => font && !GENERIC_FONTS.has(font)) || '';
1303
- }
1304
-
1305
- function browserDesignSystemConfig() {
1306
- const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
1307
- if (!raw?.present) return null;
1308
- const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
1309
- const allowedColors = (raw.allowedColors || [])
1310
- .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
1311
- .map(color => ({ r: color.r, g: color.g, b: color.b }));
1312
- const allowedRadii = (raw.allowedRadii || [])
1313
- .map(Number)
1314
- .filter(px => Number.isFinite(px));
1315
- return {
1316
- present: true,
1317
- hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
1318
- allowedFonts,
1319
- hasColors: raw.hasColors === true && allowedColors.length > 0,
1320
- allowedColors,
1321
- hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
1322
- allowedRadii,
1323
- hasPillRadius: raw.hasPillRadius === true,
1324
- };
1325
- }
1326
-
1327
- function browserColorsClose(a, b) {
1328
- if (!a || !b) return false;
1329
- return Math.max(
1330
- Math.abs(a.r - b.r),
1331
- Math.abs(a.g - b.g),
1332
- Math.abs(a.b - b.b),
1333
- ) <= DESIGN_COLOR_TOLERANCE;
1334
- }
1335
-
1336
- function isBrowserDesignColorAllowed(raw, designSystem) {
1337
- if (!designSystem?.hasColors) return true;
1338
- const text = String(raw || '').trim().toLowerCase();
1339
- if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
1340
- if (text.includes('var(')) return true;
1341
- const parsed = parseAnyColor(text);
1342
- if (!parsed) return true;
1343
- if ((parsed.a ?? 1) <= 0.05) return true;
1344
- return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
1345
- }
1346
-
1347
- function isBrowserTransparentCss(value) {
1348
- const text = String(value || '').trim().toLowerCase();
1349
- if (!text || text === 'transparent') return true;
1350
- const parsed = parseAnyColor(text);
1351
- return parsed ? (parsed.a ?? 1) <= 0.05 : false;
1352
- }
1353
-
1354
- function isBrowserDesignRadiusAllowed(raw, designSystem) {
1355
- if (!designSystem?.hasRadii) return true;
1356
- const text = String(raw || '').trim().toLowerCase();
1357
- if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
1358
- if (text.includes('var(') || text.includes('%')) return true;
1359
- const px = resolveLengthPx(text, 16);
1360
- if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
1361
- if (designSystem.hasPillRadius && px >= 99) return true;
1362
- return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
1363
- }
1364
-
1365
- function browserRadiusTokens(value) {
1366
- return String(value || '')
1367
- .replace(/\s*\/\s*/g, ' ')
1368
- .split(/\s+/)
1369
- .map(token => token.trim())
1370
- .filter(Boolean);
1371
- }
1372
-
1373
- function browserHasDirectText(el) {
1374
- return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
1375
- }
1376
-
1377
- function browserSampleText(el) {
1378
- const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
1379
- return text ? ` "${text.slice(0, 40)}"` : '';
1380
- }
1381
-
1382
- function shouldSkipDesignElement(el) {
1383
- const tag = el.tagName?.toLowerCase?.() || '';
1384
- return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
1385
- }
1386
-
1387
- function checkElementDesignSystemDOM(el, designSystem, seen) {
1388
- if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
1389
- const findings = [];
1390
- const tag = el.tagName?.toLowerCase?.() || 'unknown';
1391
- const style = getComputedStyle(el);
1392
-
1393
- if (designSystem.hasFonts && browserHasDirectText(el)) {
1394
- const font = browserPrimaryFont(style.fontFamily || '');
1395
- if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
1396
- seen.fonts.add(font);
1397
- findings.push({
1398
- type: 'design-system-font',
1399
- detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
1400
- ignoreValue: font,
1401
- });
1402
- }
1403
- }
1404
-
1405
- if (designSystem.hasColors) {
1406
- const colorChecks = [];
1407
- if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
1408
- if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
1409
- for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
1410
- if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
1411
- colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
1412
- }
1413
- }
1414
- if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
1415
-
1416
- for (const [kind, raw] of colorChecks) {
1417
- const label = String(raw || '').trim().replace(/\s+/g, ' ');
1418
- if (isBrowserDesignColorAllowed(label, designSystem)) continue;
1419
- const key = `${kind}:${label}`;
1420
- if (seen.colors.has(key)) continue;
1421
- seen.colors.add(key);
1422
- findings.push({
1423
- type: 'design-system-color',
1424
- detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
1425
- ignoreValue: label,
1426
- });
1427
- }
1428
- }
1429
-
1430
- if (designSystem.hasRadii) {
1431
- for (const token of browserRadiusTokens(style.borderRadius || '')) {
1432
- if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
1433
- if (seen.radii.has(token)) continue;
1434
- seen.radii.add(token);
1435
- findings.push({
1436
- type: 'design-system-radius',
1437
- detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
1438
- ignoreValue: token,
1439
- });
1440
- }
1441
- }
1442
-
1443
- return findings;
1444
- }
1445
-
1446
- function decodeBrowserGoogleFamily(value) {
1447
- const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
1448
- try {
1449
- return decodeURIComponent(family);
1450
- } catch {
1451
- return family;
1452
- }
1453
- }
1454
-
1455
- function checkBrowserDesignSystemSources(designSystem, seen) {
1456
- if (!designSystem?.hasFonts) return [];
1457
- const findings = [];
1458
- for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
1459
- const href = link.getAttribute('href') || '';
1460
- for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
1461
- const display = decodeBrowserGoogleFamily(match[1]);
1462
- const font = normalizeBrowserFontName(display);
1463
- if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
1464
- seen.fonts.add(font);
1465
- findings.push({
1466
- type: 'design-system-font',
1467
- detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
1468
- ignoreValue: display,
1469
- });
1470
- }
1471
- }
1472
- return findings;
1473
- }
1474
-
1475
- // A page matched by detector.ignoreFiles is waived wholesale: every scan
1476
- // stage answers empty so the badge and toast read zero. Mirrors
1477
- // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
1478
- // overlay resolves the globs per page (live-browser-ignores.js) and
1479
- // forwards the verdict as config.skipScan.
1480
- function skipScanActive() {
1481
- return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
1482
- }
1483
-
1484
- function collectBrowserFindings() {
1485
- if (skipScanActive()) {
1486
- return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
1487
- }
1488
- const groupMap = new Map();
1489
- const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
1490
- const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
1491
- const designSystem = browserDesignSystemConfig();
1492
- const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
1493
- // All deterministic rules run in the browser and extension path.
1494
-
1495
- for (const el of document.querySelectorAll('*')) {
1496
- // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons)
1497
- if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
1498
- // Skip browser extension elements (Claude, etc.). Use getAttribute when
1499
- // `el.id` is not a string: a <form> with a named control like
1500
- // <input name="id"> shadows the builtin `id` getter and returns the
1501
- // element, whose `.startsWith` throws (issue #407).
1502
- const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || '');
1503
- if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
1504
- // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast).
1505
- // These are inspector chrome, not part of the user's design.
1506
- if (el.closest('[id^="impeccable-live-"]')) continue;
1507
- // Skip html/body -- page-level findings go in the banner, not a full-page overlay
1508
- if (el === document.body || el === document.documentElement) continue;
1509
-
1510
- const findings = [
1511
- ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1512
- ...checkElementPseudoStripeDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1513
- ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1514
- ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1515
- ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1516
- ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1517
- ...checkElementRadialSpotlightDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1518
- ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1519
- ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1520
- ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1521
- ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1522
- ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1523
- ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1524
- ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1525
- ...checkElementBlinkingCursorDOM(el).map(f => ({ type: f.id, detail: f.snippet, ...(f.severity ? { severity: f.severity } : {}) })),
1526
- ...checkElementDesignSystemDOM(el, designSystem, designSeen),
1527
- ].filter(f => _ruleOk(f.type));
1528
-
1529
- addBrowserFindings(groupMap, el, findings);
1530
-
1531
- // Hero eyebrow: the offending element is the eyebrow above the heading,
1532
- // not the heading itself — highlight the previous sibling instead.
1533
- const eyebrowFindings = checkElementHeroEyebrowDOM(el)
1534
- .map(f => ({ type: f.id, detail: f.snippet }))
1535
- .filter(f => _ruleOk(f.type));
1536
- if (eyebrowFindings.length > 0 && el.previousElementSibling) {
1537
- addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings);
1538
- }
1539
- }
1540
-
1541
- const pageLevelFindings = [];
1542
-
1543
- const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
1544
- .filter(f => _ruleOk(f.type));
1545
- if (designSourceFindings.length > 0) {
1546
- pageLevelFindings.push(...designSourceFindings);
1547
- addBrowserFindings(groupMap, document.body, designSourceFindings);
1548
- }
1549
-
1550
- const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
1551
- if (typoFindings.length > 0) {
1552
- pageLevelFindings.push(...typoFindings);
1553
- addBrowserFindings(groupMap, document.body, typoFindings);
1554
- }
1555
-
1556
- const sectionKickerFindings = checkKickerAboveHeadingDOM()
1557
- .map(f => ({ type: f.id, detail: f.snippet }))
1558
- .filter(f => _ruleOk(f.type));
1559
- if (sectionKickerFindings.length > 0) {
1560
- pageLevelFindings.push(...sectionKickerFindings);
1561
- addBrowserFindings(groupMap, document.body, sectionKickerFindings);
1562
- }
1563
-
1564
- const numberedLabelFindings = checkNumberedSectionLabelsDOM()
1565
- .map(f => ({ type: f.id, detail: f.snippet }))
1566
- .filter(f => _ruleOk(f.type));
1567
- if (numberedLabelFindings.length > 0) {
1568
- pageLevelFindings.push(...numberedLabelFindings);
1569
- addBrowserFindings(groupMap, document.body, numberedLabelFindings);
1570
- }
1571
-
1572
- const repeatedTextFindings = checkRepeatedContainerTextDOM()
1573
- .map(f => ({ type: f.id, detail: f.snippet }))
1574
- .filter(f => _ruleOk(f.type));
1575
- if (repeatedTextFindings.length > 0) {
1576
- pageLevelFindings.push(...repeatedTextFindings);
1577
- addBrowserFindings(groupMap, document.body, repeatedTextFindings);
1578
- }
1579
-
1580
- // Em-dash overuse (advisory): browser parity with the static/regex path.
1581
- // Reads rendered body text so it catches dashes written as HTML entities.
1582
- // serializeFindings stamps the advisory flag from the registry.
1583
- const emDashFindings = checkEmDashOveruseDOM()
1584
- .map(f => ({ type: f.id, detail: f.snippet }))
1585
- .filter(f => _ruleOk(f.type));
1586
- if (emDashFindings.length > 0) {
1587
- pageLevelFindings.push(...emDashFindings);
1588
- addBrowserFindings(groupMap, document.body, emDashFindings);
1589
- }
1590
-
1591
- const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
1592
- for (const f of layoutFindings) {
1593
- const el = f.el || document.body;
1594
- addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
1595
- }
1596
-
1597
- // Heading rhythm (browser-only: needs real layout for the gap math)
1598
- const headingRhythmFindings = checkHeadingRhythmDOM().filter(f => _ruleOk(f.type));
1599
- for (const f of headingRhythmFindings) {
1600
- addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
1601
- }
1602
-
1603
- // Edge-flush cards in horizontal scrollers (browser-only: needs real
1604
- // layout for the scroller clip box vs card rect math)
1605
- const edgeFlushFindings = checkEdgeFlushCardsDOM().filter(f => _ruleOk(f.type));
1606
- for (const f of edgeFlushFindings) {
1607
- addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
1608
- }
1609
-
1610
- // Text occlusion / element overlap (browser-only: needs real layout +
1611
- // elementFromPoint to confirm what actually paints on top)
1612
- const occlusionFindings = checkTextOcclusionDOM().filter(f => _ruleOk(f.type));
1613
- for (const f of occlusionFindings) {
1614
- addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
1615
- }
1616
-
1617
- // First-viewport column overflow — the stretched-hero signature
1618
- // (browser-only: needs real layout for the content-extent math)
1619
- const colOverflowFindings = checkFirstViewportColumnOverflowDOM().filter(f => _ruleOk(f.type));
1620
- for (const f of colOverflowFindings) {
1621
- addBrowserFindings(groupMap, f.el || document.body, [{ type: f.type, detail: f.detail }]);
1622
- }
1623
-
1624
- // Page-level quality checks (headings, etc.)
1625
- const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
1626
- if (qualityFindings.length > 0) {
1627
- pageLevelFindings.push(...qualityFindings);
1628
- addBrowserFindings(groupMap, document.body, qualityFindings);
1629
- }
1630
-
1631
- const creamFindings = checkCreamPalette(document)
1632
- .map(f => ({ type: f.id, detail: f.snippet }))
1633
- .filter(f => _ruleOk(f.type));
1634
- if (creamFindings.length > 0) {
1635
- pageLevelFindings.push(...creamFindings);
1636
- addBrowserFindings(groupMap, document.body, creamFindings);
1637
- }
1638
-
1639
- // Regex-on-HTML checks (shared with Node)
1640
- // Clone the document and strip impeccable-live overlay nodes before the
1641
- // regex scan, so the inspector's own inline styles (transitions on top/
1642
- // left/width/height, etc.) don't register as page anti-patterns.
1643
- const docClone = document.documentElement.cloneNode(true);
1644
- for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
1645
- node.remove();
1646
- }
1647
- // Regex findings that name a live selector resolve against the real DOM:
1648
- // pseudo-element/class segments are stripped (the host element is the
1649
- // anchor), a selector that matches nothing on this page drops the finding
1650
- // (the CSS ships here, but the pattern never renders — the live DOM is
1651
- // ground truth in the browser), and a match under a data-impeccable-ignore
1652
- // ancestor is waived. Selector-less findings stay page-level.
1653
- const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
1654
- if (!f.selector) return true;
1655
- const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
1656
- if (!query || /^[,\s]*$/.test(query)) return true;
1657
- let matches;
1658
- try {
1659
- matches = document.querySelectorAll(query);
1660
- } catch {
1661
- return true;
1662
- }
1663
- if (matches.length === 0) return false;
1664
- return [...matches].some(el => !scopedIgnoreActive(el, f.id));
1665
- });
1666
- if (scopedHtmlFindings.length > 0) {
1667
- const mapped = scopedHtmlFindings.map(f => {
1668
- const item = { type: f.id, detail: f.snippet };
1669
- if (f.severity) {
1670
- item.severity = f.severity;
1671
- } else if (f.id === 'pulsing-dot' && f.selector) {
1672
- // The string scan promotes header/nav dots on its own; with a live
1673
- // layout also promote dots resting in the first ~900px of the page
1674
- // (the hero region), which the source scan cannot measure.
1675
- try {
1676
- const dotEl = document.querySelector(f.selector);
1677
- if (dotEl) {
1678
- const rect = dotEl.getBoundingClientRect();
1679
- const pageTop = rect.top + (window.scrollY || 0);
1680
- if (pageTop <= 900) item.severity = 'error';
1681
- }
1682
- } catch { /* unresolvable selector: keep registry severity */ }
1683
- }
1684
- return item;
1685
- }).filter(f => _ruleOk(f.type));
1686
- pageLevelFindings.push(...mapped);
1687
- addBrowserFindings(groupMap, document.body, mapped);
1688
- }
1689
-
1690
- // Value-level suppression (issue #639). `disabledRules` above handles
1691
- // whole rules; this applies the config's remaining ignoreValues entries,
1692
- // which the CLI filters through isIgnoredFindingValue in
1693
- // cli/lib/impeccable-config.mjs, so a project waiver like
1694
- // overused-font = "geist mono" reaches the overlay and extension too.
1695
- const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
1696
- .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
1697
- const _disabledValues = EXTENSION_MODE
1698
- ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
1699
- .filter(e => e && typeof e === 'object' && e.rule && e.value)
1700
- .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
1701
- : [];
1702
- if (_disabledValues.length > 0) {
1703
- // The six rules whose findings carry a matchable value; keep in step
1704
- // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
1705
- // Everything else is suppressed by rule or by file scope, both already
1706
- // resolved into disabledRules before the scan message was sent.
1707
- const _directValueRules = new Set([
1708
- 'overused-font',
1709
- 'bounce-easing',
1710
- 'design-system-font',
1711
- 'design-system-color',
1712
- 'design-system-radius',
1713
- 'design-system-font-size',
1714
- ]);
1715
- // The design-system checks set `ignoreValue` on their findings; the
1716
- // detail fallbacks catch overused-font, whose value lives in its
1717
- // sentence. One CLI matcher is not mirrored here: the motion extractor
1718
- // (a value-scoped bounce-easing waiver only matches when the finding
1719
- // carries ignoreValue directly). The CLI's [?&]family= URL fallback is
1720
- // also omitted on purpose: browser findings for these rules always
1721
- // carry ignoreValue or a "Primary font:" / "Google Fonts:" /
1722
- // font-family sentence, so it is unreachable here.
1723
- const _findingValue = (f) => {
1724
- if (!f || !_directValueRules.has(f.type || f.id)) return '';
1725
- const direct = f.ignoreValue || f.value;
1726
- if (direct) return _normValue(direct);
1727
- // The CLI routes bounce-easing through extractMotionIgnoreValue and
1728
- // never the font regexes; without a direct ignoreValue there is no
1729
- // value to match, so do not invent one from unrelated CSS text.
1730
- if ((f.type || f.id) === 'bounce-easing') return '';
1731
- for (const text of [f.detail, f.snippet]) {
1732
- if (typeof text !== 'string' || !text) continue;
1733
- const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
1734
- if (primary) return _normValue(primary[1]);
1735
- const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
1736
- if (google) return _normValue(google[1]);
1737
- const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
1738
- if (family) return _normValue(family[1]);
1739
- }
1740
- return '';
1741
- };
1742
- // design-system-color compares by color value, not by spelling: the
1743
- // browser reports computed rgb(...) strings while waivers are usually
1744
- // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
1745
- // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
1746
- // hsl stays CLI-only.
1747
- const _colorKey = (value) => {
1748
- const text = String(value || '').trim().toLowerCase();
1749
- const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
1750
- if (hex) {
1751
- const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
1752
- const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
1753
- return `${r},${g},${b},${a}`;
1754
- }
1755
- const rgb = text.match(/^rgba?\((.*)\)$/);
1756
- if (!rgb) return '';
1757
- const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
1758
- let parts;
1759
- if (body.includes(',')) {
1760
- parts = body.split(',').map(p => p.trim()).filter(Boolean);
1761
- const last = parts[parts.length - 1];
1762
- if (last && last.includes('/')) {
1763
- parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
1764
- }
1765
- } else {
1766
- parts = body.split(/\s+/).filter(p => p && p !== '/');
1767
- }
1768
- if (parts.length < 3 || parts.length > 4) return '';
1769
- const channel = (raw, isAlpha) => {
1770
- const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
1771
- if (!m) return null;
1772
- let v = parseFloat(m[1]);
1773
- if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
1774
- const max = isAlpha ? 1 : 255;
1775
- if (!Number.isFinite(v) || v < 0 || v > max) return null;
1776
- return isAlpha ? v : Math.round(v);
1777
- };
1778
- const r = channel(parts[0], false);
1779
- const g = channel(parts[1], false);
1780
- const b = channel(parts[2], false);
1781
- const a = parts[3] === undefined ? 1 : channel(parts[3], true);
1782
- if ([r, g, b, a].some(v => v === null)) return '';
1783
- return `${r},${g},${b},${Math.round(a * 255)}`;
1784
- };
1785
- const _valueIgnored = (f) => {
1786
- const value = _findingValue(f);
1787
- if (!value) return false;
1788
- const rule = f.type || f.id;
1789
- return _disabledValues.some(e => e.rule === rule && (e.value === value
1790
- || (rule === 'design-system-color'
1791
- && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
1792
- };
1793
- for (const [el, list] of [...groupMap.entries()]) {
1794
- const kept = list.filter(f => !_valueIgnored(f));
1795
- if (kept.length > 0) groupMap.set(el, kept);
1796
- else groupMap.delete(el);
1797
- }
1798
- for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
1799
- if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
1800
- }
1801
- }
1802
-
1803
- return {
1804
- groupMap,
1805
- allFindings: browserFindingsFromMap(groupMap),
1806
- pageLevelFindings,
1807
- };
1808
- }
1809
-
1810
- // Visual contrast has three modes. Explicit true runs the full sampled
1811
- // pass; explicit false disables it entirely (the deterministic-only mode
1812
- // the test suites use). Unset — the default overlay run — samples ONLY
1813
- // image-backed text: the one class the analytic walk deliberately skips,
1814
- // because a url() layer's pixels are unknowable without looking. In-page
1815
- // sampling draws the source image alone to a canvas (glyph ink never
1816
- // pollutes it), and a cross-origin image without CORS reports unresolved
1817
- // instead of guessing.
1818
- function visualContrastMode(options = {}) {
1819
- const explicit = typeof options.visualContrast === 'boolean'
1820
- ? options.visualContrast
1821
- : typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
1822
- ? window.__IMPECCABLE_CONFIG__.visualContrast
1823
- : null;
1824
- if (explicit === true) return 'full';
1825
- if (explicit === false) return false;
1826
- return 'image-only';
1827
- }
1828
-
1829
- function shouldRunVisualContrast(options = {}) {
1830
- return visualContrastMode(options) !== false;
1831
- }
1832
-
1833
- function visualContrastOptions(options = {}) {
1834
- const config = window.__IMPECCABLE_CONFIG__ || {};
1835
- const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'
1836
- ? options.scrollOffscreen
1837
- : typeof options.visualContrastScrollOffscreen === 'boolean'
1838
- ? options.visualContrastScrollOffscreen
1839
- : typeof config.visualContrastScrollOffscreen === 'boolean'
1840
- ? config.visualContrastScrollOffscreen
1841
- : false;
1842
- return {
1843
- ...options,
1844
- maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
1845
- ? options.visualContrastMaxCandidates
1846
- : Number.isFinite(options.maxCandidates)
1847
- ? options.maxCandidates
1848
- : Number.isFinite(config.visualContrastMaxCandidates)
1849
- ? config.visualContrastMaxCandidates
1850
- : undefined,
1851
- scrollOffscreen,
1852
- };
1853
- }
1854
-
1855
- let lastVisualContrastAnalyses = [];
1856
- let lazyVisualContrastObserver = null;
1857
- let lazyVisualContrastPending = new WeakMap();
1858
- const lazyVisualContrastResolving = new WeakSet();
1859
- let scanGeneration = 0;
1860
-
1861
- function rememberVisualContrastAnalysis(result) {
1862
- if (!result?.selector) {
1863
- lastVisualContrastAnalyses.push(result);
1864
- return;
1865
- }
1866
- const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
1867
- if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
1868
- else lastVisualContrastAnalyses.push(result);
1869
- }
1870
-
1871
- function disconnectLazyVisualContrastObserver() {
1872
- if (lazyVisualContrastObserver) {
1873
- lazyVisualContrastObserver.disconnect();
1874
- lazyVisualContrastObserver = null;
1875
- }
1876
- lazyVisualContrastPending = new WeakMap();
1877
- }
1878
-
1879
- function addVisualContrastResult(groupMap, result, options = {}) {
1880
- if (result.status !== 'fail' || !result.finding || !result.selector) return false;
1881
- let el = null;
1882
- try {
1883
- el = document.querySelector(result.selector);
1884
- } catch {
1885
- el = null;
1886
- }
1887
- if (!el) return false;
1888
- const findingType = result.finding.type || result.finding.id || 'low-contrast';
1889
- const existing = groupMap.get(el) || [];
1890
- if (existing.some(f => (f.type || f.id) === findingType)) return false;
1891
- addBrowserFindings(groupMap, el, [{
1892
- type: findingType,
1893
- detail: result.finding.detail || result.finding.snippet,
1894
- }]);
1895
- if (options.decorate && el !== document.body && el !== document.documentElement) {
1896
- highlight(el, groupMap.get(el) || []);
1897
- }
1898
- return true;
1899
- }
1900
-
1901
- function scanResultMeta(options = {}) {
1902
- const scanId = options.scanId;
1903
- if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
1904
- return { scanId: String(scanId) };
1905
- }
1906
-
1907
- function postSerializedFindings(groupMap, options = {}) {
1908
- if (!EXTENSION_MODE) return;
1909
- const allFindings = browserFindingsFromMap(groupMap);
1910
- window.postMessage({
1911
- source: 'impeccable-results',
1912
- findings: serializeFindings(allFindings),
1913
- count: allFindings.length,
1914
- ...scanResultMeta(options),
1915
- }, '*');
1916
- }
1917
-
1918
- function postExtensionError(err) {
1919
- if (!EXTENSION_MODE) return;
1920
- window.postMessage({
1921
- source: 'impeccable-error',
1922
- message: err?.message || String(err),
1923
- }, '*');
1924
- }
1925
-
1926
- function reportVisualContrastError(err, detail = {}) {
1927
- window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', {
1928
- detail: {
1929
- ...detail,
1930
- message: err?.message || String(err),
1931
- },
1932
- }));
1933
- if (EXTENSION_MODE) {
1934
- postExtensionError(err);
1935
- } else {
1936
- console.warn('[impeccable] visual contrast scan failed', err);
1937
- }
1938
- }
1939
-
1940
- function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
1941
- disconnectLazyVisualContrastObserver();
1942
- if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
1943
- if (typeof IntersectionObserver === 'undefined') return;
1944
- const unresolved = (analyses || []).filter(result =>
1945
- result?.status === 'unresolved' &&
1946
- result.reason === 'text outside viewport' &&
1947
- result.selector
1948
- );
1949
- if (unresolved.length === 0) return;
1950
- const generation = runtime.generation || scanGeneration;
1951
-
1952
- lazyVisualContrastObserver = new IntersectionObserver((entries) => {
1953
- for (const entry of entries) {
1954
- if (!entry.isIntersecting) continue;
1955
- const el = entry.target;
1956
- const candidate = lazyVisualContrastPending.get(el);
1957
- if (!candidate || lazyVisualContrastResolving.has(el)) continue;
1958
- lazyVisualContrastObserver?.unobserve(el);
1959
- lazyVisualContrastPending.delete(el);
1960
- lazyVisualContrastResolving.add(el);
1961
- waitForVisualPaint()
1962
- .then(() => analyzeVisualContrastCandidate(candidate))
1963
- .then(result => {
1964
- if (generation !== scanGeneration) return;
1965
- rememberVisualContrastAnalysis(result);
1966
- const added = addVisualContrastResult(groupMap, result, { decorate: true });
1967
- if (added) {
1968
- postSerializedFindings(groupMap, options);
1969
- window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
1970
- detail: {
1971
- selector: result.selector,
1972
- status: result.status,
1973
- finding: result.finding || null,
1974
- },
1975
- }));
1976
- }
1977
- })
1978
- .catch(err => {
1979
- reportVisualContrastError(err, { selector: candidate.selector });
1980
- })
1981
- .finally(() => {
1982
- lazyVisualContrastResolving.delete(el);
1983
- });
1984
- }
1985
- }, { threshold: 0.5 });
1986
-
1987
- for (const candidate of unresolved) {
1988
- let el = null;
1989
- try {
1990
- el = document.querySelector(candidate.selector);
1991
- } catch {
1992
- el = null;
1993
- }
1994
- if (!el) continue;
1995
- lazyVisualContrastPending.set(el, candidate);
1996
- lazyVisualContrastObserver.observe(el);
1997
- }
1998
- }
1999
-
2000
- async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
2001
- if (!shouldRunVisualContrast(options)) {
2002
- lastVisualContrastAnalyses = [];
2003
- disconnectLazyVisualContrastObserver();
2004
- return [];
2005
- }
2006
- const resolvedOptions = visualContrastOptions(options);
2007
- if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
2008
- const analyses = await analyzeVisualContrast(resolvedOptions);
2009
- if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
2010
- lastVisualContrastAnalyses = analyses;
2011
- for (const result of analyses) {
2012
- addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
2013
- }
2014
- if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
2015
- return analyses;
2016
- }
2017
-
2018
- async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
2019
- const collected = collectBrowserFindings();
2020
- // The visual pass walks the DOM on its own; on a skipScan page it would
2021
- // repopulate the emptied scan, so it is skipped with everything else.
2022
- if (skipScanActive()) {
2023
- lastVisualContrastAnalyses = [];
2024
- return { ...collected, allFindings: [], visualContrastAnalyses: [] };
2025
- }
2026
- await addVisualContrastFindings(collected.groupMap, options, runtime);
2027
- return {
2028
- ...collected,
2029
- allFindings: browserFindingsFromMap(collected.groupMap),
2030
- visualContrastAnalyses: lastVisualContrastAnalyses,
2031
- };
2032
- }
2033
-
2034
- function clearOverlays() {
2035
- scanGeneration += 1;
2036
- disconnectLazyVisualContrastObserver();
2037
- for (const o of [...overlays]) detachOverlay(o);
2038
- overlays.length = 0;
2039
- visibilityObserver.disconnect();
2040
- overlayIndex = 0;
2041
- }
2042
-
2043
- function renderBrowserFindings(collected, options = {}) {
2044
- const { allFindings, pageLevelFindings } = collected;
2045
-
2046
- for (const { el, findings } of allFindings) {
2047
- if (el === document.body || el === document.documentElement) continue;
2048
- highlight(el, findings);
2049
- }
2050
-
2051
- if (pageLevelFindings.length > 0) {
2052
- showPageBanner(pageLevelFindings);
2053
- }
2054
-
2055
- if (!EXTENSION_MODE) printSummary(allFindings);
2056
-
2057
- // In extension mode, post serialized results for the DevTools panel
2058
- if (EXTENSION_MODE) {
2059
- window.postMessage({
2060
- source: 'impeccable-results',
2061
- findings: serializeFindings(allFindings),
2062
- count: allFindings.length,
2063
- ...scanResultMeta(options),
2064
- }, '*');
2065
- }
2066
-
2067
- // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
2068
- setTimeout(() => { firstScanDone = true; }, 1000);
2069
-
2070
- return allFindings;
2071
- }
2072
-
2073
- let firstScanDone = false;
2074
- const scan = function(options = {}) {
2075
- clearOverlays();
2076
- const generation = scanGeneration;
2077
- const collected = collectBrowserFindings();
2078
- const allFindings = renderBrowserFindings(collected, options);
2079
- if (!skipScanActive() && shouldRunVisualContrast(options)) {
2080
- addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
2081
- .then(() => {
2082
- if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
2083
- })
2084
- .catch(err => {
2085
- reportVisualContrastError(err);
2086
- });
2087
- }
2088
- return allFindings;
2089
- };
2090
-
2091
- const scanAsync = async function(options = {}) {
2092
- clearOverlays();
2093
- const generation = scanGeneration;
2094
- if (shouldRunVisualContrast(options)) {
2095
- const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
2096
- if (generation !== scanGeneration) return [];
2097
- return renderBrowserFindings(collected, options);
2098
- }
2099
- lastVisualContrastAnalyses = [];
2100
- return renderBrowserFindings(collectBrowserFindings(), options);
2101
- };
2102
-
2103
- const detect = function(options = {}) {
2104
- lastVisualContrastAnalyses = [];
2105
- const { allFindings } = collectBrowserFindings();
2106
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
2107
- };
2108
-
2109
- const detectAsync = async function(options = {}) {
2110
- if (shouldRunVisualContrast(options)) {
2111
- const { allFindings } = await collectBrowserFindingsAsync(options);
2112
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
2113
- }
2114
- lastVisualContrastAnalyses = [];
2115
- const { allFindings } = collectBrowserFindings();
2116
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
2117
- };
2118
-
2119
- if (EXTENSION_MODE) {
2120
- // Extension mode: listen for commands, don't auto-scan
2121
- window.addEventListener('message', (e) => {
2122
- if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return;
2123
- if (e.data.action === 'scan') {
2124
- if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config;
2125
- try {
2126
- scan(e.data.config || {});
2127
- } catch (err) {
2128
- postExtensionError(err);
2129
- }
2130
- }
2131
- if (e.data.action === 'toggle-overlays') {
2132
- const visible = !document.body.classList.contains('impeccable-hidden');
2133
- document.body.classList.toggle('impeccable-hidden', visible);
2134
- window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*');
2135
- }
2136
- if (e.data.action === 'remove') {
2137
- clearOverlays();
2138
- styleEl.remove();
2139
- if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
2140
- document.body.classList.remove('impeccable-hidden');
2141
- }
2142
- if (e.data.action === 'highlight') {
2143
- try {
2144
- const target = e.data.selector ? document.querySelector(e.data.selector) : null;
2145
- if (target) {
2146
- // Scroll first so positionOverlay reads the post-scroll rect
2147
- if (!isInViewport(target) && target.scrollIntoView) {
2148
- target.scrollIntoView({ behavior: 'instant', block: 'center' });
2149
- }
2150
- for (const o of overlays) {
2151
- if (o.classList.contains('impeccable-banner')) continue;
2152
- const isMatch = o._targetEl === target;
2153
- o.classList.toggle('impeccable-spotlight', isMatch);
2154
- o.classList.toggle('impeccable-spotlight-dimmed', !isMatch);
2155
- if (isMatch) {
2156
- // Force the matching overlay visible immediately, don't wait for IntersectionObserver
2157
- o.style.display = '';
2158
- o.style.animation = 'none';
2159
- o.classList.add('impeccable-visible');
2160
- o._revealed = true;
2161
- positionOverlay(o);
2162
- }
2163
- }
2164
- showSpotlight(target);
2165
- }
2166
- } catch { /* invalid selector */ }
2167
- }
2168
- if (e.data.action === 'unhighlight') {
2169
- hideSpotlight();
2170
- for (const o of overlays) {
2171
- o.classList.remove('impeccable-spotlight');
2172
- o.classList.remove('impeccable-spotlight-dimmed');
2173
- }
2174
- }
2175
- });
2176
- window.postMessage({ source: 'impeccable-ready' }, '*');
2177
- } else {
2178
- if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) {
2179
- const runAutoScan = () => {
2180
- try {
2181
- scan();
2182
- } catch (err) {
2183
- console.warn('[impeccable] scan failed', err);
2184
- }
2185
- };
2186
- if (document.readyState === 'loading') {
2187
- document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
2188
- } else {
2189
- setTimeout(runAutoScan, 100);
2190
- }
2191
- }
2192
- }
2193
-
2194
- window.impeccableDetect = detect;
2195
- window.impeccableDetectAsync = detectAsync;
2196
- window.impeccableScan = scan;
2197
- window.impeccableScanAsync = scanAsync;
2198
- // Raw measurement for the URL engine's content-hidden-at-rest pass: it
2199
- // drives a reveal sweep from Node and thresholds the result itself.
2200
- window.impeccableMeasureHiddenText = measureHiddenTextDOM;
2201
- window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates;
2202
- window.impeccableAnalyzeVisualContrast = analyzeVisualContrast;
2203
- window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
2204
- }