@monoes/monomindcli 2.16.1 → 2.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/.claude/helpers/handlers/edit-handler.cjs +14 -12
  2. package/.claude/helpers/handlers/route-handler.cjs +6 -0
  3. package/.claude/helpers/monograph-freshen.cjs +31 -113
  4. package/.claude/helpers/utils/monograph-resolve.cjs +497 -0
  5. package/.claude/helpers/utils/monograph.cjs +8 -51
  6. package/README.md +1 -1
  7. package/dist/src/commands/doctor-hook-monograph-checks.d.ts +8 -0
  8. package/dist/src/commands/doctor-hook-monograph-checks.d.ts.map +1 -0
  9. package/dist/src/commands/doctor-hook-monograph-checks.js +79 -0
  10. package/dist/src/commands/doctor-hook-monograph-checks.js.map +1 -0
  11. package/dist/src/commands/doctor.d.ts.map +1 -1
  12. package/dist/src/commands/doctor.js +4 -1
  13. package/dist/src/commands/doctor.js.map +1 -1
  14. package/dist/src/commands/monograph.d.ts.map +1 -1
  15. package/dist/src/commands/monograph.js +16 -1
  16. package/dist/src/commands/monograph.js.map +1 -1
  17. package/dist/src/orgrt/bash-timeout.d.ts +14 -0
  18. package/dist/src/orgrt/bash-timeout.d.ts.map +1 -0
  19. package/dist/src/orgrt/bash-timeout.js +18 -0
  20. package/dist/src/orgrt/bash-timeout.js.map +1 -0
  21. package/dist/src/orgrt/block-recheck.d.ts +30 -0
  22. package/dist/src/orgrt/block-recheck.d.ts.map +1 -0
  23. package/dist/src/orgrt/block-recheck.js +81 -0
  24. package/dist/src/orgrt/block-recheck.js.map +1 -0
  25. package/dist/src/orgrt/completion-gate.d.ts +4 -0
  26. package/dist/src/orgrt/completion-gate.d.ts.map +1 -1
  27. package/dist/src/orgrt/completion-gate.js +22 -2
  28. package/dist/src/orgrt/completion-gate.js.map +1 -1
  29. package/dist/src/orgrt/daemon.d.ts.map +1 -1
  30. package/dist/src/orgrt/daemon.js +6 -4
  31. package/dist/src/orgrt/daemon.js.map +1 -1
  32. package/dist/src/orgrt/decisions.d.ts +1 -1
  33. package/dist/src/orgrt/decisions.d.ts.map +1 -1
  34. package/dist/src/orgrt/decisions.js +14 -6
  35. package/dist/src/orgrt/decisions.js.map +1 -1
  36. package/dist/src/orgrt/mailbox.d.ts +14 -0
  37. package/dist/src/orgrt/mailbox.d.ts.map +1 -1
  38. package/dist/src/orgrt/mailbox.js +31 -1
  39. package/dist/src/orgrt/mailbox.js.map +1 -1
  40. package/dist/src/orgrt/sandbox-fault.d.ts +16 -6
  41. package/dist/src/orgrt/sandbox-fault.d.ts.map +1 -1
  42. package/dist/src/orgrt/sandbox-fault.js +78 -33
  43. package/dist/src/orgrt/sandbox-fault.js.map +1 -1
  44. package/dist/src/orgrt/session.d.ts +4 -3
  45. package/dist/src/orgrt/session.d.ts.map +1 -1
  46. package/dist/src/orgrt/session.js +24 -13
  47. package/dist/src/orgrt/session.js.map +1 -1
  48. package/dist/src/orgrt/task-dag.d.ts +8 -2
  49. package/dist/src/orgrt/task-dag.d.ts.map +1 -1
  50. package/dist/src/orgrt/task-dag.js +10 -4
  51. package/dist/src/orgrt/task-dag.js.map +1 -1
  52. package/dist/src/orgrt/types.d.ts +11 -0
  53. package/dist/src/orgrt/types.d.ts.map +1 -1
  54. package/dist/src/orgrt/types.js +18 -0
  55. package/dist/src/orgrt/types.js.map +1 -1
  56. package/dist/src/utils/resource-governor.d.ts.map +1 -1
  57. package/dist/src/utils/resource-governor.js +35 -2
  58. package/dist/src/utils/resource-governor.js.map +1 -1
  59. package/dist/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +6 -6
  61. package/.claude/skills/monodesign/SKILL.md +0 -257
  62. package/.claude/skills/monodesign/adapt.md +0 -53
  63. package/.claude/skills/monodesign/agents/monodesign-asset-producer.md +0 -100
  64. package/.claude/skills/monodesign/agents/monodesign-manual-edit-applier.md +0 -103
  65. package/.claude/skills/monodesign/animate.md +0 -65
  66. package/.claude/skills/monodesign/audit.md +0 -89
  67. package/.claude/skills/monodesign/bolder.md +0 -50
  68. package/.claude/skills/monodesign/clarify.md +0 -64
  69. package/.claude/skills/monodesign/colorize.md +0 -68
  70. package/.claude/skills/monodesign/craft.md +0 -51
  71. package/.claude/skills/monodesign/critique.md +0 -66
  72. package/.claude/skills/monodesign/delight.md +0 -47
  73. package/.claude/skills/monodesign/distill.md +0 -56
  74. package/.claude/skills/monodesign/document.md +0 -83
  75. package/.claude/skills/monodesign/extract.md +0 -74
  76. package/.claude/skills/monodesign/harden.md +0 -65
  77. package/.claude/skills/monodesign/init.md +0 -30
  78. package/.claude/skills/monodesign/layout.md +0 -36
  79. package/.claude/skills/monodesign/live.md +0 -34
  80. package/.claude/skills/monodesign/onboard.md +0 -50
  81. package/.claude/skills/monodesign/optimize.md +0 -64
  82. package/.claude/skills/monodesign/overdrive.md +0 -56
  83. package/.claude/skills/monodesign/polish.md +0 -68
  84. package/.claude/skills/monodesign/quieter.md +0 -57
  85. package/.claude/skills/monodesign/reference/adapt.md +0 -312
  86. package/.claude/skills/monodesign/reference/adapt.native.md +0 -58
  87. package/.claude/skills/monodesign/reference/android.md +0 -40
  88. package/.claude/skills/monodesign/reference/animate.md +0 -203
  89. package/.claude/skills/monodesign/reference/antipatterns-catalog.md +0 -410
  90. package/.claude/skills/monodesign/reference/audit.md +0 -135
  91. package/.claude/skills/monodesign/reference/audit.native.md +0 -139
  92. package/.claude/skills/monodesign/reference/bolder.md +0 -120
  93. package/.claude/skills/monodesign/reference/brand-workflow.md +0 -180
  94. package/.claude/skills/monodesign/reference/brand.md +0 -108
  95. package/.claude/skills/monodesign/reference/clarify.md +0 -288
  96. package/.claude/skills/monodesign/reference/codex.md +0 -105
  97. package/.claude/skills/monodesign/reference/cognitive-load.md +0 -106
  98. package/.claude/skills/monodesign/reference/color-and-contrast.md +0 -105
  99. package/.claude/skills/monodesign/reference/colorize.md +0 -257
  100. package/.claude/skills/monodesign/reference/component-specs.md +0 -260
  101. package/.claude/skills/monodesign/reference/component-states.md +0 -274
  102. package/.claude/skills/monodesign/reference/component-system.md +0 -358
  103. package/.claude/skills/monodesign/reference/copy-formulas.md +0 -160
  104. package/.claude/skills/monodesign/reference/craft.md +0 -123
  105. package/.claude/skills/monodesign/reference/critique.md +0 -780
  106. package/.claude/skills/monodesign/reference/delight.md +0 -302
  107. package/.claude/skills/monodesign/reference/design-principles.md +0 -246
  108. package/.claude/skills/monodesign/reference/distill.md +0 -111
  109. package/.claude/skills/monodesign/reference/document.md +0 -429
  110. package/.claude/skills/monodesign/reference/extract.md +0 -69
  111. package/.claude/skills/monodesign/reference/harden.md +0 -347
  112. package/.claude/skills/monodesign/reference/heuristics-scoring.md +0 -234
  113. package/.claude/skills/monodesign/reference/hooks.md +0 -92
  114. package/.claude/skills/monodesign/reference/image-prompts.md +0 -130
  115. package/.claude/skills/monodesign/reference/init.md +0 -221
  116. package/.claude/skills/monodesign/reference/interaction-design.md +0 -189
  117. package/.claude/skills/monodesign/reference/ios.md +0 -45
  118. package/.claude/skills/monodesign/reference/layout.md +0 -185
  119. package/.claude/skills/monodesign/reference/live.md +0 -710
  120. package/.claude/skills/monodesign/reference/motion-design.md +0 -109
  121. package/.claude/skills/monodesign/reference/onboard.md +0 -234
  122. package/.claude/skills/monodesign/reference/optimize.md +0 -258
  123. package/.claude/skills/monodesign/reference/overdrive.md +0 -130
  124. package/.claude/skills/monodesign/reference/personas.md +0 -179
  125. package/.claude/skills/monodesign/reference/polish.md +0 -241
  126. package/.claude/skills/monodesign/reference/pre-delivery-checklist.md +0 -108
  127. package/.claude/skills/monodesign/reference/product.md +0 -60
  128. package/.claude/skills/monodesign/reference/quieter.md +0 -99
  129. package/.claude/skills/monodesign/reference/responsive-design.md +0 -114
  130. package/.claude/skills/monodesign/reference/shape.md +0 -165
  131. package/.claude/skills/monodesign/reference/spatial-design.md +0 -100
  132. package/.claude/skills/monodesign/reference/teach.md +0 -10
  133. package/.claude/skills/monodesign/reference/token-architecture.md +0 -222
  134. package/.claude/skills/monodesign/reference/typeset.md +0 -301
  135. package/.claude/skills/monodesign/reference/typography.md +0 -159
  136. package/.claude/skills/monodesign/reference/ux-research.md +0 -143
  137. package/.claude/skills/monodesign/reference/ux-rules.md +0 -211
  138. package/.claude/skills/monodesign/reference/ux-writing.md +0 -107
  139. package/.claude/skills/monodesign/scripts/command-metadata.json +0 -94
  140. package/.claude/skills/monodesign/scripts/context-signals.mjs +0 -260
  141. package/.claude/skills/monodesign/scripts/context.mjs +0 -1065
  142. package/.claude/skills/monodesign/scripts/critique-storage.mjs +0 -416
  143. package/.claude/skills/monodesign/scripts/detect-csp.mjs +0 -193
  144. package/.claude/skills/monodesign/scripts/detect.mjs +0 -33
  145. package/.claude/skills/monodesign/scripts/detector/browser/injected/index.mjs +0 -1960
  146. package/.claude/skills/monodesign/scripts/detector/cli/main.mjs +0 -321
  147. package/.claude/skills/monodesign/scripts/detector/design-system.mjs +0 -814
  148. package/.claude/skills/monodesign/scripts/detector/detect-antipatterns-browser.js +0 -5628
  149. package/.claude/skills/monodesign/scripts/detector/detect-antipatterns.mjs +0 -50
  150. package/.claude/skills/monodesign/scripts/detector/engines/browser/detect-url.mjs +0 -259
  151. package/.claude/skills/monodesign/scripts/detector/engines/browser/drivers.mjs +0 -339
  152. package/.claude/skills/monodesign/scripts/detector/engines/regex/detect-text.mjs +0 -636
  153. package/.claude/skills/monodesign/scripts/detector/engines/static-html/css-cascade.mjs +0 -1016
  154. package/.claude/skills/monodesign/scripts/detector/engines/static-html/detect-html.mjs +0 -253
  155. package/.claude/skills/monodesign/scripts/detector/engines/visual/screenshot-contrast.mjs +0 -198
  156. package/.claude/skills/monodesign/scripts/detector/findings.mjs +0 -12
  157. package/.claude/skills/monodesign/scripts/detector/fix/css-regions.mjs +0 -181
  158. package/.claude/skills/monodesign/scripts/detector/fix/fixers.mjs +0 -373
  159. package/.claude/skills/monodesign/scripts/detector/fix/index.mjs +0 -426
  160. package/.claude/skills/monodesign/scripts/detector/node/file-system.mjs +0 -198
  161. package/.claude/skills/monodesign/scripts/detector/profile/profiler.mjs +0 -166
  162. package/.claude/skills/monodesign/scripts/detector/registry/antipatterns.mjs +0 -563
  163. package/.claude/skills/monodesign/scripts/detector/rules/checks.mjs +0 -3024
  164. package/.claude/skills/monodesign/scripts/detector/shared/color.mjs +0 -124
  165. package/.claude/skills/monodesign/scripts/detector/shared/constants.mjs +0 -101
  166. package/.claude/skills/monodesign/scripts/detector/shared/fonts.mjs +0 -30
  167. package/.claude/skills/monodesign/scripts/detector/shared/inline-ignores.mjs +0 -148
  168. package/.claude/skills/monodesign/scripts/detector/shared/page.mjs +0 -7
  169. package/.claude/skills/monodesign/scripts/hook-admin.mjs +0 -694
  170. package/.claude/skills/monodesign/scripts/hook-before-edit.mjs +0 -516
  171. package/.claude/skills/monodesign/scripts/hook-lib.mjs +0 -1785
  172. package/.claude/skills/monodesign/scripts/hook.mjs +0 -61
  173. package/.claude/skills/monodesign/scripts/lib/design-parser.mjs +0 -809
  174. package/.claude/skills/monodesign/scripts/lib/is-generated.mjs +0 -69
  175. package/.claude/skills/monodesign/scripts/lib/monodesign-config.mjs +0 -640
  176. package/.claude/skills/monodesign/scripts/lib/monodesign-paths.mjs +0 -129
  177. package/.claude/skills/monodesign/scripts/lib/provider.mjs +0 -4
  178. package/.claude/skills/monodesign/scripts/lib/target-args.mjs +0 -42
  179. package/.claude/skills/monodesign/scripts/live/browser-script-parts.mjs +0 -50
  180. package/.claude/skills/monodesign/scripts/live/completion.mjs +0 -19
  181. package/.claude/skills/monodesign/scripts/live/event-validation.mjs +0 -137
  182. package/.claude/skills/monodesign/scripts/live/insert-ui.mjs +0 -458
  183. package/.claude/skills/monodesign/scripts/live/manual-apply.mjs +0 -939
  184. package/.claude/skills/monodesign/scripts/live/manual-edit-routes.mjs +0 -357
  185. package/.claude/skills/monodesign/scripts/live/manual-edits-buffer.mjs +0 -152
  186. package/.claude/skills/monodesign/scripts/live/session-store.mjs +0 -293
  187. package/.claude/skills/monodesign/scripts/live/svelte-component.mjs +0 -826
  188. package/.claude/skills/monodesign/scripts/live/sveltekit-adapter.mjs +0 -279
  189. package/.claude/skills/monodesign/scripts/live/ui-core.mjs +0 -180
  190. package/.claude/skills/monodesign/scripts/live/vocabulary.mjs +0 -36
  191. package/.claude/skills/monodesign/scripts/live-accept.mjs +0 -812
  192. package/.claude/skills/monodesign/scripts/live-browser-dom.js +0 -147
  193. package/.claude/skills/monodesign/scripts/live-browser-session.js +0 -124
  194. package/.claude/skills/monodesign/scripts/live-browser.js +0 -11213
  195. package/.claude/skills/monodesign/scripts/live-commit-manual-edits.mjs +0 -1241
  196. package/.claude/skills/monodesign/scripts/live-complete.mjs +0 -75
  197. package/.claude/skills/monodesign/scripts/live-copy-edit-agent.mjs +0 -683
  198. package/.claude/skills/monodesign/scripts/live-discard-manual-edits.mjs +0 -51
  199. package/.claude/skills/monodesign/scripts/live-inject.mjs +0 -596
  200. package/.claude/skills/monodesign/scripts/live-insert.mjs +0 -272
  201. package/.claude/skills/monodesign/scripts/live-manual-edit-evidence.mjs +0 -363
  202. package/.claude/skills/monodesign/scripts/live-poll.mjs +0 -384
  203. package/.claude/skills/monodesign/scripts/live-resume.mjs +0 -94
  204. package/.claude/skills/monodesign/scripts/live-server.mjs +0 -1210
  205. package/.claude/skills/monodesign/scripts/live-status.mjs +0 -61
  206. package/.claude/skills/monodesign/scripts/live-target.mjs +0 -30
  207. package/.claude/skills/monodesign/scripts/live-wrap.mjs +0 -884
  208. package/.claude/skills/monodesign/scripts/live.mjs +0 -297
  209. package/.claude/skills/monodesign/scripts/modern-screenshot.umd.js +0 -14
  210. package/.claude/skills/monodesign/scripts/palette.mjs +0 -635
  211. package/.claude/skills/monodesign/scripts/pin.mjs +0 -221
  212. package/.claude/skills/monodesign/shape.md +0 -71
  213. package/.claude/skills/monodesign/teach.md +0 -18
  214. package/.claude/skills/monodesign/typeset.md +0 -59
@@ -1,1960 +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.monodesignExtension === 'true')
10
- || document.documentElement.dataset.monodesignExtension === '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 monodesign-reveal {
34
- from { opacity: 0; }
35
- to { opacity: 1; }
36
- }
37
- .monodesign-overlay:not(.monodesign-banner) {
38
- pointer-events: none;
39
- outline: 2px solid ${OUTLINE_COLOR};
40
- border-radius: 4px;
41
- transition: outline-color 0.15s ease;
42
- animation: monodesign-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
- .monodesign-overlay.monodesign-visible {
47
- animation-play-state: running;
48
- }
49
- .monodesign-overlay.monodesign-hover {
50
- outline-color: ${BRAND_COLOR_HOVER};
51
- z-index: 100001 !important;
52
- }
53
- .monodesign-overlay.monodesign-hover .monodesign-label {
54
- background: ${BRAND_COLOR_HOVER};
55
- }
56
- .monodesign-overlay.monodesign-spotlight {
57
- z-index: 100002 !important;
58
- }
59
- .monodesign-overlay.monodesign-spotlight-dimmed {
60
- opacity: 0.15 !important;
61
- animation: none !important;
62
- filter: blur(3px);
63
- }
64
- .monodesign-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
- .monodesign-spotlight-backdrop.monodesign-visible {
76
- opacity: 1;
77
- }
78
- .monodesign-hidden .monodesign-overlay${EXTENSION_MODE ? '' : ':not(.monodesign-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 = 'monodesign-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?.getBoundingClientRect) return;
116
- // Respect the spotlightBlur setting: if disabled, don't show the backdrop
117
- if (window.__MONODESIGN_CONFIG__?.spotlightBlur === false) {
118
- spotlightTarget = target;
119
- return;
120
- }
121
- spotlightTarget = target;
122
- const bd = getSpotlightBackdrop();
123
- updateSpotlightClipPath();
124
- bd.classList.add('monodesign-visible');
125
- }
126
-
127
- function hideSpotlight() {
128
- spotlightTarget = null;
129
- if (spotlightBackdrop) spotlightBackdrop.classList.remove('monodesign-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('monodesign-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._monodesignOverlay;
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('monodesign-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._monodesignOverlay === overlay) {
240
- visibilityObserver.unobserve(overlay._targetEl);
241
- delete overlay._targetEl._monodesignOverlay;
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('monodesign-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 = (el, findings) => {
262
- if (el._monodesignOverlay) detachOverlay(el._monodesignOverlay);
263
- const fixed = isInFixedContext(el);
264
- const rect = el.getBoundingClientRect();
265
- const outline = document.createElement('div');
266
- outline.className = 'monodesign-overlay';
267
- outline._targetEl = el;
268
- outline._isFixed = fixed;
269
- Object.assign(outline.style, {
270
- position: fixed ? 'fixed' : 'absolute',
271
- top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`,
272
- left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`,
273
- width: `${rect.width + 4}px`, height: `${rect.height + 4}px`,
274
- zIndex: '99999', boxSizing: 'border-box',
275
- });
276
-
277
- // Build per-finding label entries: ✦ prefix for slop
278
- const entries = findings.map(f => {
279
- const name = TYPE_LABELS[f.type || f.id] || f.type || f.id;
280
- const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : '';
281
- return { name: prefix + name, detail: f.detail || f.snippet };
282
- });
283
- const allText = entries.map(e => e.name).join(', ');
284
-
285
- const label = document.createElement('div');
286
- label.className = 'monodesign-label';
287
- Object.assign(label.style, {
288
- position: 'absolute', bottom: '100%', left: '-2px',
289
- display: 'flex', alignItems: 'center',
290
- whiteSpace: 'nowrap',
291
- fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em',
292
- color: LABEL_INK, lineHeight: '14px',
293
- background: LABEL_BG,
294
- fontFamily: 'system-ui, sans-serif',
295
- borderRadius: '4px 4px 0 0',
296
- });
297
-
298
- const textSpan = document.createElement('span');
299
- textSpan.style.padding = '3px 8px';
300
- textSpan.textContent = allText;
301
- label.appendChild(textSpan);
302
-
303
- // State for cycling mode
304
- let cycleMode = false;
305
- let cycleIndex = 0;
306
- let isHovered = false;
307
- let prevBtn, nextBtn;
308
-
309
- function updateCycleText() {
310
- const e = entries[cycleIndex];
311
- textSpan.textContent = isHovered ? e.detail : e.name;
312
- }
313
-
314
- function enableCycleMode() {
315
- if (cycleMode || entries.length < 2) return;
316
- cycleMode = true;
317
-
318
- const btnStyle = {
319
- background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)',
320
- fontSize: '11px', cursor: 'pointer', padding: '3px 4px',
321
- fontFamily: 'system-ui, sans-serif', lineHeight: '14px',
322
- pointerEvents: 'auto',
323
- };
324
-
325
- const navGroup = document.createElement('span');
326
- Object.assign(navGroup.style, {
327
- display: 'inline-flex', alignItems: 'center', flexShrink: '0',
328
- });
329
-
330
- prevBtn = document.createElement('button');
331
- prevBtn.textContent = '\u2039';
332
- Object.assign(prevBtn.style, btnStyle);
333
- prevBtn.style.paddingLeft = '6px';
334
- prevBtn.addEventListener('click', (e) => {
335
- e.stopPropagation();
336
- cycleIndex = (cycleIndex - 1 + entries.length) % entries.length;
337
- updateCycleText();
338
- });
339
-
340
- nextBtn = document.createElement('button');
341
- nextBtn.textContent = '\u203A';
342
- Object.assign(nextBtn.style, btnStyle);
343
- nextBtn.style.paddingRight = '2px';
344
- nextBtn.addEventListener('click', (e) => {
345
- e.stopPropagation();
346
- cycleIndex = (cycleIndex + 1) % entries.length;
347
- updateCycleText();
348
- });
349
-
350
- navGroup.appendChild(prevBtn);
351
- navGroup.appendChild(nextBtn);
352
- label.insertBefore(navGroup, textSpan);
353
- textSpan.style.padding = '3px 8px 3px 4px';
354
- updateCycleText();
355
- }
356
-
357
- outline.appendChild(label);
358
-
359
- // Start hidden; the IntersectionObserver will show it once the target is rendered
360
- outline.style.display = 'none';
361
- outline._staggerIndex = overlayIndex++;
362
- el._monodesignOverlay = outline;
363
- visibilityObserver.observe(el);
364
-
365
- // After first paint, check label width vs outline
366
- outline._checkLabel = () => {
367
- if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) {
368
- enableCycleMode();
369
- }
370
- };
371
-
372
- // Hover: show detail text, darken
373
- const onMouseEnter = () => {
374
- isHovered = true;
375
- outline.classList.add('monodesign-hover');
376
- outline.style.outlineColor = BRAND_COLOR_HOVER;
377
- label.style.background = BRAND_COLOR_HOVER;
378
- if (cycleMode) {
379
- updateCycleText();
380
- } else {
381
- textSpan.textContent = entries.map(e => e.detail).join(' | ');
382
- }
383
- };
384
- const onMouseLeave = () => {
385
- isHovered = false;
386
- outline.classList.remove('monodesign-hover');
387
- outline.style.outlineColor = '';
388
- label.style.background = LABEL_BG;
389
- if (cycleMode) {
390
- updateCycleText();
391
- } else {
392
- textSpan.textContent = allText;
393
- }
394
- };
395
- el.addEventListener('mouseenter', onMouseEnter);
396
- el.addEventListener('mouseleave', onMouseLeave);
397
- outline._cleanup = () => {
398
- el.removeEventListener('mouseenter', onMouseEnter);
399
- el.removeEventListener('mouseleave', onMouseLeave);
400
- };
401
-
402
- document.body.appendChild(outline);
403
- overlays.push(outline);
404
- };
405
-
406
- const showPageBanner = (findings) => {
407
- if (!findings.length) return;
408
- const banner = document.createElement('div');
409
- banner.className = 'monodesign-overlay monodesign-banner';
410
- Object.assign(banner.style, {
411
- position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000',
412
- background: LABEL_BG, color: LABEL_INK,
413
- fontFamily: 'system-ui, sans-serif', fontSize: '13px',
414
- display: 'flex', alignItems: 'center', pointerEvents: 'auto',
415
- height: '36px', overflow: 'hidden', maxWidth: '100vw',
416
- transform: 'translateY(-100%)',
417
- transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
418
- });
419
- requestAnimationFrame(() => requestAnimationFrame(() => {
420
- banner.style.transform = 'translateY(0)';
421
- }));
422
-
423
- // Scrollable findings area
424
- const scrollArea = document.createElement('div');
425
- Object.assign(scrollArea.style, {
426
- flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden',
427
- display: 'flex', gap: '8px', alignItems: 'center',
428
- padding: '0 12px', scrollSnapType: 'x mandatory',
429
- scrollbarWidth: 'none',
430
- });
431
- for (const f of findings) {
432
- const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : '';
433
- const tag = document.createElement('span');
434
- tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`;
435
- Object.assign(tag.style, {
436
- background: 'rgba(255,255,255,0.15)', padding: '2px 8px',
437
- borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace',
438
- whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start',
439
- });
440
- scrollArea.appendChild(tag);
441
- }
442
- banner.appendChild(scrollArea);
443
-
444
- // Controls area (only in standalone mode, not extension)
445
- if (!EXTENSION_MODE) {
446
- const controls = document.createElement('div');
447
- Object.assign(controls.style, {
448
- display: 'flex', alignItems: 'center', gap: '2px',
449
- padding: '0 8px', flexShrink: '0',
450
- });
451
-
452
- // Toggle visibility button
453
- const toggle = document.createElement('button');
454
- toggle.textContent = '\u25C9'; // circle with dot (visible state)
455
- toggle.title = 'Toggle overlay visibility';
456
- Object.assign(toggle.style, {
457
- background: 'none', border: 'none',
458
- color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px',
459
- opacity: '0.85', transition: 'opacity 0.15s',
460
- });
461
- let overlaysVisible = true;
462
- toggle.addEventListener('click', () => {
463
- overlaysVisible = !overlaysVisible;
464
- document.body.classList.toggle('monodesign-hidden', !overlaysVisible);
465
- toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle
466
- toggle.style.opacity = overlaysVisible ? '0.85' : '0.5';
467
- });
468
- controls.appendChild(toggle);
469
-
470
- // Close button
471
- const close = document.createElement('button');
472
- close.textContent = '\u00d7';
473
- close.title = 'Dismiss banner';
474
- Object.assign(close.style, {
475
- background: 'none', border: 'none',
476
- color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px',
477
- });
478
- close.addEventListener('click', () => banner.remove());
479
- controls.appendChild(close);
480
-
481
- banner.appendChild(controls);
482
- }
483
- document.body.appendChild(banner);
484
- overlays.push(banner);
485
- };
486
-
487
- // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_".
488
- // These change between builds and produce brittle, ugly selectors.
489
- function isLikelyHashedClass(c) {
490
- if (!c) return true;
491
- if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true;
492
- if (/^_[\w-]{5,}$/.test(c)) return true;
493
- if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true;
494
- return false;
495
- }
496
-
497
- function buildSelectorSegment(el) {
498
- const tag = el.tagName.toLowerCase();
499
- let sel = tag;
500
-
501
- if (el.classList && el.classList.length > 0) {
502
- const classes = [...el.classList]
503
- .filter(c => !c.startsWith('monodesign-') && !isLikelyHashedClass(c))
504
- .slice(0, 2);
505
- if (classes.length > 0) {
506
- sel += `.${classes.map(c => CSS.escape(c)).join('.')}`;
507
- }
508
- }
509
-
510
- // Disambiguate among siblings only if the parent has multiple matches
511
- const parent = el.parentElement;
512
- if (parent) {
513
- try {
514
- const matching = parent.querySelectorAll(`:scope > ${sel}`);
515
- if (matching.length > 1) {
516
- const sameType = [...parent.children].filter(c => c.tagName === el.tagName);
517
- const idx = sameType.indexOf(el) + 1;
518
- sel += `:nth-of-type(${idx})`;
519
- }
520
- } catch {
521
- const idx = [...parent.children].indexOf(el) + 1;
522
- sel = `${tag}:nth-child(${idx})`;
523
- }
524
- }
525
- return sel;
526
- }
527
-
528
- function generateSelector(el) {
529
- if (el === document.body) return 'body';
530
- if (el === document.documentElement) return 'html';
531
- if (el.id) return `#${CSS.escape(el.id)}`;
532
-
533
- const parts = [];
534
- let current = el;
535
- let depth = 0;
536
- const MAX_DEPTH = 10;
537
-
538
- while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) {
539
- parts.unshift(buildSelectorSegment(current));
540
-
541
- // Anchor on an ancestor's ID and stop walking up
542
- if (current.id) {
543
- parts[0] = `#${CSS.escape(current.id)}`;
544
- break;
545
- }
546
-
547
- // Stop as soon as the partial selector uniquely identifies the target
548
- const trySelector = parts.join(' > ');
549
- try {
550
- const matches = document.querySelectorAll(trySelector);
551
- if (matches.length === 1 && matches[0] === el) {
552
- return trySelector;
553
- }
554
- } catch { /* invalid selector — keep walking */ }
555
-
556
- current = current.parentElement;
557
- depth++;
558
- }
559
-
560
- return parts.join(' > ');
561
- }
562
-
563
- function getDirectText(el) {
564
- return [...el.childNodes]
565
- .filter(n => n.nodeType === 3)
566
- .map(n => n.textContent || '')
567
- .join('');
568
- }
569
-
570
- function getDirectTextRect(el) {
571
- const rects = [];
572
- for (const node of el.childNodes) {
573
- if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue;
574
- const range = document.createRange();
575
- range.selectNodeContents(node);
576
- for (const rect of range.getClientRects()) {
577
- if (rect.width >= 1 && rect.height >= 1) rects.push(rect);
578
- }
579
- range.detach?.();
580
- }
581
- if (rects.length === 0) return null;
582
- const left = Math.min(...rects.map(r => r.left));
583
- const top = Math.min(...rects.map(r => r.top));
584
- const right = Math.max(...rects.map(r => r.right));
585
- const bottom = Math.max(...rects.map(r => r.bottom));
586
- return {
587
- left,
588
- top,
589
- right,
590
- bottom,
591
- width: right - left,
592
- height: bottom - top,
593
- x: left,
594
- y: top,
595
- };
596
- }
597
-
598
- function collectVisualContrastReasons(el, style) {
599
- const reasons = new Set();
600
- const bgClip = style.webkitBackgroundClip || style.backgroundClip || '';
601
- const ownBgImage = style.backgroundImage || '';
602
- if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') {
603
- reasons.add('background-clip text');
604
- }
605
- if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow');
606
-
607
- let current = el;
608
- while (current && current.nodeType === 1) {
609
- const tag = current.tagName?.toLowerCase();
610
- const currentStyle = getComputedStyle(current);
611
- const bgImage = currentStyle.backgroundImage || '';
612
- const isDocumentSurface = tag === 'body' || tag === 'html';
613
-
614
- if (!isDocumentSurface && bgImage && bgImage !== 'none') {
615
- if (/url\s*\(/i.test(bgImage)) reasons.add('image background');
616
- if (/gradient/i.test(bgImage)) reasons.add('gradient background');
617
- }
618
- if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack');
619
- if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode');
620
- if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
621
- if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
622
-
623
- const solidBg = parseRgb(currentStyle.backgroundColor);
624
- if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
625
- current = current.parentElement;
626
- }
627
-
628
- const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect();
629
- if (sampleRect && document.elementsFromPoint) {
630
- const points = [
631
- [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2],
632
- [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2],
633
- [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2],
634
- ];
635
- for (const [x, y] of points) {
636
- if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue;
637
- const stack = document.elementsFromPoint(x, y);
638
- const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el));
639
- if (selfIndex < 0) continue;
640
- for (const node of stack.slice(selfIndex + 1)) {
641
- const nodeTag = node.tagName?.toLowerCase();
642
- if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') {
643
- reasons.add(`${nodeTag} underlay`);
644
- break;
645
- }
646
- }
647
- }
648
- }
649
-
650
- return [...reasons];
651
- }
652
-
653
- function collectVisualContrastCandidates(options = {}) {
654
- const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12;
655
- const candidates = [];
656
- for (const el of document.querySelectorAll('*')) {
657
- if (candidates.length >= maxCandidates) break;
658
- if (el.closest('.monodesign-overlay, .monodesign-label, .monodesign-banner, .monodesign-tooltip')) continue;
659
- if (el.closest('[id^="monodesign-live-"]')) continue;
660
- if (el === document.body || el === document.documentElement) continue;
661
- if (!isRenderedForBrowserRule(el)) continue;
662
-
663
- const tag = el.tagName.toLowerCase();
664
- const style = getComputedStyle(el);
665
- if (style.display === 'none' || style.visibility === 'hidden') continue;
666
- const directText = getDirectText(el);
667
- const hasDirectText = directText.trim().length > 0;
668
- if (!hasDirectText || isEmojiOnlyText(directText)) continue;
669
-
670
- const bgColor = readOwnBackgroundColor(el, style);
671
- const isStyledButton = (tag === 'a' || tag === 'button')
672
- && bgColor && bgColor.a > 0.5;
673
- if (SAFE_TAGS.has(tag) && !isStyledButton) continue;
674
-
675
- const rect = getDirectTextRect(el) || el.getBoundingClientRect();
676
- if (!rect || rect.width < 4 || rect.height < 4) continue;
677
-
678
- const reasons = collectVisualContrastReasons(el, style);
679
- if (reasons.length === 0) continue;
680
-
681
- const textColor = parseRgb(style.color);
682
- const fontSize = parseFloat(style.fontSize) || 16;
683
- const fontWeight = parseInt(style.fontWeight, 10) || 400;
684
- const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
685
- const threshold = isLargeText ? 3.0 : 4.5;
686
- const clip = {
687
- x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)),
688
- y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)),
689
- width: Math.max(1, Math.ceil(rect.width + 4)),
690
- height: Math.max(1, Math.ceil(rect.height + 4)),
691
- };
692
-
693
- candidates.push({
694
- selector: generateSelector(el),
695
- tagName: tag,
696
- text: directText.trim().replace(/\s+/g, ' ').slice(0, 80),
697
- threshold,
698
- reasons,
699
- clip,
700
- textColor,
701
- preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason =>
702
- reason === 'opacity stack' ||
703
- reason === 'blend mode' ||
704
- reason === 'filter' ||
705
- reason === 'backdrop filter' ||
706
- reason === 'background-clip text'
707
- ),
708
- backgroundClipText: reasons.includes('background-clip text'),
709
- });
710
- }
711
- return candidates;
712
- }
713
-
714
- const visualContrastImageCache = new Map();
715
- const visualContrastRasterCache = new WeakMap();
716
-
717
- function clampByte(value) {
718
- return Math.max(0, Math.min(255, Math.round(value)));
719
- }
720
-
721
- function blendRgba(fg, bg) {
722
- if (!fg) return bg || null;
723
- if (!bg || fg.a == null || fg.a >= 0.999) {
724
- return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a };
725
- }
726
- const alpha = Math.max(0, Math.min(1, fg.a));
727
- return {
728
- r: clampByte(fg.r * alpha + bg.r * (1 - alpha)),
729
- g: clampByte(fg.g * alpha + bg.g * (1 - alpha)),
730
- b: clampByte(fg.b * alpha + bg.b * (1 - alpha)),
731
- a: 1,
732
- };
733
- }
734
-
735
- function pickWorstContrastColor(textColor, colors) {
736
- const usable = (colors || []).filter(Boolean);
737
- if (!usable.length) return null;
738
- let worst = usable[0];
739
- let worstRatio = contrastRatio(textColor, worst);
740
- for (const color of usable.slice(1)) {
741
- const ratio = contrastRatio(textColor, color);
742
- if (ratio < worstRatio) {
743
- worst = color;
744
- worstRatio = ratio;
745
- }
746
- }
747
- return worst;
748
- }
749
-
750
- function firstCssUrl(value) {
751
- const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i);
752
- if (!match) return '';
753
- return (match[1] || match[2] || match[3] || '').trim();
754
- }
755
-
756
- function getLayerValue(value, index = 0) {
757
- return String(value || '').split(',')[index]?.trim() || '';
758
- }
759
-
760
- function parsePositionToken(token, container, painted) {
761
- if (!token || token === 'center') return (container - painted) / 2;
762
- if (token === 'left' || token === 'top') return 0;
763
- if (token === 'right' || token === 'bottom') return container - painted;
764
- if (/%$/.test(token)) {
765
- const pct = parseFloat(token) / 100;
766
- return (container - painted) * pct;
767
- }
768
- if (/px$/.test(token)) return parseFloat(token) || 0;
769
- return (container - painted) / 2;
770
- }
771
-
772
- function parsePositionPair(positionValue) {
773
- const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean);
774
- const first = tokens[0] || '50%';
775
- if (tokens.length < 2) {
776
- if (first === 'top' || first === 'bottom') return ['50%', first];
777
- return [first, '50%'];
778
- }
779
- return [first, tokens[1] || '50%'];
780
- }
781
-
782
- function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) {
783
- const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
784
- const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
785
- let paintedWidth = intrinsicWidth;
786
- let paintedHeight = intrinsicHeight;
787
- const size = String(sizeValue || 'auto').trim();
788
-
789
- if (size === 'cover' || size === 'contain') {
790
- const scale = size === 'cover'
791
- ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
792
- : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
793
- paintedWidth = intrinsicWidth * scale;
794
- paintedHeight = intrinsicHeight * scale;
795
- } else if (size && size !== 'auto') {
796
- const parts = size.split(/\s+/);
797
- const widthToken = parts[0];
798
- const heightToken = parts[1] || 'auto';
799
- if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100);
800
- else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth;
801
- if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth);
802
- else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100);
803
- else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight;
804
- }
805
-
806
- const [xToken, yToken] = parsePositionPair(positionValue);
807
- const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth);
808
- const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight);
809
- return {
810
- left: containerRect.left + positionX,
811
- top: containerRect.top + positionY,
812
- width: paintedWidth,
813
- height: paintedHeight,
814
- intrinsicWidth,
815
- intrinsicHeight,
816
- };
817
- }
818
-
819
- function parseObjectPosition(positionValue) {
820
- return parsePositionPair(positionValue);
821
- }
822
-
823
- function resolveObjectImageRect(containerRect, image, style) {
824
- const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1;
825
- const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1;
826
- const fit = style.objectFit || 'fill';
827
- let paintedWidth = containerRect.width;
828
- let paintedHeight = containerRect.height;
829
- if (fit === 'contain' || fit === 'cover') {
830
- const scale = fit === 'cover'
831
- ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight)
832
- : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight);
833
- paintedWidth = intrinsicWidth * scale;
834
- paintedHeight = intrinsicHeight * scale;
835
- } else if (fit === 'none') {
836
- paintedWidth = intrinsicWidth;
837
- paintedHeight = intrinsicHeight;
838
- } else if (fit === 'scale-down') {
839
- const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1);
840
- paintedWidth = intrinsicWidth * containScale;
841
- paintedHeight = intrinsicHeight * containScale;
842
- }
843
- const [xToken, yToken] = parseObjectPosition(style.objectPosition);
844
- return {
845
- left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth),
846
- top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight),
847
- width: paintedWidth,
848
- height: paintedHeight,
849
- intrinsicWidth,
850
- intrinsicHeight,
851
- };
852
- }
853
-
854
- function pointToImageSource(point, paintedRect) {
855
- if (
856
- point.x < paintedRect.left ||
857
- point.y < paintedRect.top ||
858
- point.x > paintedRect.left + paintedRect.width ||
859
- point.y > paintedRect.top + paintedRect.height
860
- ) {
861
- return null;
862
- }
863
- return {
864
- x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)),
865
- y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)),
866
- };
867
- }
868
-
869
- async function loadVisualContrastImage(src) {
870
- if (!src) return null;
871
- if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src);
872
- const promise = new Promise(resolve => {
873
- const img = new Image();
874
- let settled = false;
875
- const finish = value => {
876
- if (settled) return;
877
- settled = true;
878
- clearTimeout(timer);
879
- resolve(value);
880
- };
881
- const timer = setTimeout(() => finish(null), 800);
882
- try {
883
- const absolute = new URL(src, location.href);
884
- if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') {
885
- img.crossOrigin = 'anonymous';
886
- }
887
- } catch {
888
- // Let the browser resolve unusual URLs itself.
889
- }
890
- img.onload = () => finish(img);
891
- img.onerror = () => finish(null);
892
- img.src = src;
893
- });
894
- visualContrastImageCache.set(src, promise);
895
- return promise;
896
- }
897
-
898
- function sampleDrawablePixel(drawable, sourcePoint) {
899
- if (visualContrastRasterCache.has(drawable)) {
900
- const cached = visualContrastRasterCache.get(drawable);
901
- if (!cached?.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' };
902
- try {
903
- const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
904
- const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
905
- const data = cached.ctx.getImageData(x, y, 1, 1).data;
906
- return {
907
- status: 'sampled',
908
- color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
909
- };
910
- } catch (err) {
911
- return {
912
- status: 'unresolved',
913
- reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed',
914
- };
915
- }
916
- }
917
-
918
- const canvas = document.createElement('canvas');
919
- const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1;
920
- const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1;
921
- const maxRasterSide = 640;
922
- const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight));
923
- canvas.width = Math.max(1, Math.round(intrinsicWidth * scale));
924
- canvas.height = Math.max(1, Math.round(intrinsicHeight * scale));
925
- const ctx = canvas.getContext('2d', { willReadFrequently: true });
926
- if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' };
927
- try {
928
- ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height);
929
- const cached = {
930
- ctx,
931
- width: canvas.width,
932
- height: canvas.height,
933
- scaleX: canvas.width / intrinsicWidth,
934
- scaleY: canvas.height / intrinsicHeight,
935
- };
936
- visualContrastRasterCache.set(drawable, cached);
937
- const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX)));
938
- const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY)));
939
- const data = ctx.getImageData(x, y, 1, 1).data;
940
- return {
941
- status: 'sampled',
942
- color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 },
943
- };
944
- } catch (err) {
945
- const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed';
946
- visualContrastRasterCache.set(drawable, { ctx: null, reason });
947
- return {
948
- status: 'unresolved',
949
- reason,
950
- };
951
- }
952
- }
953
-
954
- async function sampleCssBackground(el, style, point, textColor) {
955
- const rect = el.getBoundingClientRect();
956
- const bgImage = style.backgroundImage || '';
957
- if (bgImage && bgImage !== 'none') {
958
- if (/gradient/i.test(bgImage)) {
959
- const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage));
960
- if (color) return { status: 'sampled', color, method: 'analytic-gradient' };
961
- }
962
- if (/url\s*\(/i.test(bgImage)) {
963
- const img = await loadVisualContrastImage(firstCssUrl(bgImage));
964
- if (!img) return { status: 'unresolved', reason: 'image unavailable' };
965
- const paintedRect = resolvePaintedImageRect(
966
- rect,
967
- img,
968
- getLayerValue(style.backgroundSize) || 'auto',
969
- getLayerValue(style.backgroundPosition) || '50% 50%',
970
- );
971
- const sourcePoint = pointToImageSource(point, paintedRect);
972
- if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' };
973
- const sample = sampleDrawablePixel(img, sourcePoint);
974
- if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' };
975
- return sample;
976
- }
977
- }
978
- const bg = parseRgb(style.backgroundColor);
979
- if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
980
- return { status: 'unresolved', reason: 'no readable background' };
981
- }
982
-
983
- async function sampleImageElement(img, point) {
984
- const rect = img.getBoundingClientRect();
985
- const style = getComputedStyle(img);
986
- const paintedRect = resolveObjectImageRect(rect, img, style);
987
- const sourcePoint = pointToImageSource(point, paintedRect);
988
- if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' };
989
- const sample = sampleDrawablePixel(img, sourcePoint);
990
- if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' };
991
-
992
- if (img.currentSrc || img.src) {
993
- const loaded = await loadVisualContrastImage(img.currentSrc || img.src);
994
- if (loaded) {
995
- const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight };
996
- const loadedPoint = pointToImageSource(point, loadedRect);
997
- if (loadedPoint) {
998
- const loadedSample = sampleDrawablePixel(loaded, loadedPoint);
999
- if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' };
1000
- }
1001
- }
1002
- }
1003
- return sample;
1004
- }
1005
-
1006
- function textSamplePoints(rect) {
1007
- const insetX = Math.min(12, Math.max(1, rect.width * 0.12));
1008
- const insetY = Math.min(8, Math.max(1, rect.height * 0.22));
1009
- const xs = rect.width < 28
1010
- ? [rect.left + rect.width / 2]
1011
- : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX];
1012
- const ys = rect.height < 22
1013
- ? [rect.top + rect.height / 2]
1014
- : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY];
1015
- const points = [];
1016
- for (const y of ys) {
1017
- for (const x of xs) {
1018
- if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y });
1019
- }
1020
- }
1021
- return points;
1022
- }
1023
-
1024
- async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) {
1025
- if (depth > 8) {
1026
- return { status: 'unresolved', reason: 'background stack too deep' };
1027
- }
1028
- const stack = typeof document.elementsFromPoint === 'function'
1029
- ? document.elementsFromPoint(point.x, point.y)
1030
- : [];
1031
- const selfIndex = stack.findIndex(node => node === el || el.contains(node));
1032
- const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack];
1033
- const unresolved = [];
1034
-
1035
- for (const node of nodes) {
1036
- if (node?.nodeType !== 1) continue;
1037
- if (node.closest?.('.monodesign-overlay, .monodesign-label, .monodesign-banner, .monodesign-tooltip')) continue;
1038
- const tag = node.tagName?.toLowerCase();
1039
- if (tag === 'img') {
1040
- const sample = await sampleImageElement(node, point);
1041
- if (sample.status === 'sampled') return sample;
1042
- unresolved.push(sample.reason);
1043
- continue;
1044
- }
1045
- if (tag === 'canvas' || tag === 'video') {
1046
- const rect = node.getBoundingClientRect();
1047
- const sourcePoint = pointToImageSource(point, {
1048
- left: rect.left,
1049
- top: rect.top,
1050
- width: rect.width,
1051
- height: rect.height,
1052
- intrinsicWidth: node.width || node.videoWidth || rect.width,
1053
- intrinsicHeight: node.height || node.videoHeight || rect.height,
1054
- });
1055
- if (sourcePoint) {
1056
- const sample = sampleDrawablePixel(node, sourcePoint);
1057
- if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` };
1058
- unresolved.push(sample.reason);
1059
- }
1060
- continue;
1061
- }
1062
- const style = getComputedStyle(node);
1063
- const sample = await sampleCssBackground(node, style, point, textColor);
1064
- if (sample.status === 'sampled') {
1065
- if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample;
1066
- const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1);
1067
- if (under.status === 'sampled') {
1068
- return {
1069
- status: 'sampled',
1070
- color: blendRgba(sample.color, under.color),
1071
- method: `${sample.method}+alpha`,
1072
- };
1073
- }
1074
- return sample;
1075
- }
1076
- unresolved.push(sample.reason);
1077
- }
1078
-
1079
- return {
1080
- status: 'unresolved',
1081
- reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background',
1082
- };
1083
- }
1084
-
1085
- async function analyzeVisualContrastCandidate(candidate) {
1086
- let el;
1087
- try {
1088
- el = document.querySelector(candidate.selector);
1089
- } catch {
1090
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
1091
- }
1092
- if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
1093
- if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
1094
-
1095
- const blockingReason = (candidate.reasons || []).find(reason =>
1096
- reason === 'background-clip text' ||
1097
- reason === 'blend mode' ||
1098
- reason === 'filter' ||
1099
- reason === 'backdrop filter' ||
1100
- reason === 'opacity stack' ||
1101
- reason === 'text shadow'
1102
- );
1103
- if (blockingReason) {
1104
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` };
1105
- }
1106
-
1107
- const style = getComputedStyle(el);
1108
- const textColor = parseRgb(style.color) || candidate.textColor;
1109
- if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
1110
-
1111
- const rect = getDirectTextRect(el) || el.getBoundingClientRect();
1112
- if (!rect || rect.width < 4 || rect.height < 4) {
1113
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' };
1114
- }
1115
-
1116
- const points = textSamplePoints(rect);
1117
- if (points.length === 0) {
1118
- return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' };
1119
- }
1120
-
1121
- const ratios = [];
1122
- const methods = new Set();
1123
- const unresolved = [];
1124
- for (const point of points) {
1125
- const sample = await sampleVisualBackgroundAtPoint(el, point, textColor);
1126
- if (sample.status !== 'sampled' || !sample.color) {
1127
- unresolved.push(sample.reason);
1128
- continue;
1129
- }
1130
- const fg = blendRgba(textColor, sample.color);
1131
- ratios.push(contrastRatio(fg, sample.color));
1132
- if (sample.method) methods.add(sample.method);
1133
- }
1134
-
1135
- if (ratios.length < Math.min(3, points.length)) {
1136
- return {
1137
- ...candidate,
1138
- status: 'unresolved',
1139
- confidence: 'none',
1140
- samples: ratios.length,
1141
- reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples',
1142
- };
1143
- }
1144
-
1145
- ratios.sort((a, b) => a - b);
1146
- const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
1147
- const measuredRatio = pick(10);
1148
- const medianRatio = pick(50);
1149
- const status = measuredRatio < candidate.threshold ? 'fail' : 'pass';
1150
- const method = [...methods].sort().join(', ') || 'browser-visual';
1151
- const textLabel = candidate.text ? ` "${candidate.text}"` : '';
1152
- const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`;
1153
- return {
1154
- ...candidate,
1155
- status,
1156
- confidence: method.includes('canvas-') ? 'high' : 'medium',
1157
- method,
1158
- ratio: measuredRatio,
1159
- medianRatio,
1160
- samples: ratios.length,
1161
- finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null,
1162
- };
1163
- }
1164
-
1165
- function waitForVisualPaint() {
1166
- return new Promise(resolve => {
1167
- requestAnimationFrame(() => requestAnimationFrame(resolve));
1168
- });
1169
- }
1170
-
1171
- async function analyzeVisualContrast(options = {}) {
1172
- const candidates = collectVisualContrastCandidates(options);
1173
- const results = [];
1174
- const shouldScrollOffscreen = options.scrollOffscreen === true;
1175
- const restoreScroll = { x: window.scrollX, y: window.scrollY };
1176
- for (const candidate of candidates) {
1177
- if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
1178
- window.scrollTo(restoreScroll.x, restoreScroll.y);
1179
- await waitForVisualPaint();
1180
- }
1181
- let result = await analyzeVisualContrastCandidate(candidate);
1182
- if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') {
1183
- let el = null;
1184
- try {
1185
- el = document.querySelector(candidate.selector);
1186
- } catch {
1187
- el = null;
1188
- }
1189
- if (el && typeof el.scrollIntoView === 'function') {
1190
- el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
1191
- await waitForVisualPaint();
1192
- result = await analyzeVisualContrastCandidate(candidate);
1193
- }
1194
- }
1195
- results.push(result);
1196
- }
1197
- if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) {
1198
- window.scrollTo(restoreScroll.x, restoreScroll.y);
1199
- }
1200
- return results;
1201
- }
1202
-
1203
- function isElementHidden(el) {
1204
- if (!el || el === document.body || el === document.documentElement) return false;
1205
- if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true });
1206
- // Fallback: zero size or no offsetParent (covers display:none and detached subtrees)
1207
- return el.offsetWidth === 0 && el.offsetHeight === 0;
1208
- }
1209
-
1210
- function serializeFindings(allFindings) {
1211
- return allFindings.map(({ el, findings }) => ({
1212
- selector: generateSelector(el),
1213
- tagName: el.tagName?.toLowerCase() || 'unknown',
1214
- rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect)
1215
- ? el.getBoundingClientRect().toJSON() : null,
1216
- isPageLevel: el === document.body || el === document.documentElement,
1217
- isHidden: isElementHidden(el),
1218
- findings: findings.map(f => {
1219
- const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id));
1220
- return {
1221
- type: f.type || f.id,
1222
- category: ap ? ap.category : 'quality',
1223
- severity: ap?.severity || 'warning',
1224
- detail: f.detail || f.snippet,
1225
- ignoreValue: f.ignoreValue || f.value || '',
1226
- name: ap ? ap.name : (f.type || f.id),
1227
- description: ap ? ap.description : '',
1228
- };
1229
- }),
1230
- }));
1231
- }
1232
-
1233
- const printSummary = (allFindings) => {
1234
- if (allFindings.length === 0) {
1235
- console.log('%c[monodesign] No anti-patterns found.', 'color: #22c55e; font-weight: bold');
1236
- return;
1237
- }
1238
- console.group(
1239
- `%c[monodesign] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`,
1240
- 'color: oklch(84% 0.19 80.46); font-weight: bold'
1241
- );
1242
- for (const { el, findings } of allFindings) {
1243
- for (const f of findings) {
1244
- console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`,
1245
- 'color: oklch(84% 0.19 80.46); font-weight: bold', 'color: inherit', el);
1246
- }
1247
- }
1248
- console.groupEnd();
1249
- };
1250
-
1251
- function addBrowserFindings(groupMap, el, findings) {
1252
- if (!findings || findings.length === 0) return;
1253
- const existing = groupMap.get(el);
1254
- if (existing) existing.push(...findings);
1255
- else groupMap.set(el, [...findings]);
1256
- }
1257
-
1258
- function browserFindingsFromMap(groupMap) {
1259
- return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
1260
- }
1261
-
1262
- const DESIGN_COLOR_TOLERANCE = 6;
1263
- const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
1264
- const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
1265
-
1266
- function normalizeBrowserFontName(value) {
1267
- return String(value || '')
1268
- .trim()
1269
- .replace(/^["']|["']$/g, '')
1270
- .replace(/\+/g, ' ')
1271
- .replace(/\s+/g, ' ')
1272
- .toLowerCase();
1273
- }
1274
-
1275
- function browserPrimaryFont(stack) {
1276
- if (!stack || /var\(/i.test(stack)) return '';
1277
- return String(stack || '')
1278
- .split(',')
1279
- .map(normalizeBrowserFontName)
1280
- .find(font => font && !GENERIC_FONTS.has(font)) || '';
1281
- }
1282
-
1283
- function browserDesignSystemConfig() {
1284
- const raw = window.__MONODESIGN_CONFIG__?.designSystem;
1285
- if (!raw?.present) return null;
1286
- const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
1287
- const allowedColors = (raw.allowedColors || [])
1288
- .filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
1289
- .map(color => ({ r: color.r, g: color.g, b: color.b }));
1290
- const allowedRadii = (raw.allowedRadii || [])
1291
- .map(Number)
1292
- .filter(px => Number.isFinite(px));
1293
- return {
1294
- present: true,
1295
- hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
1296
- allowedFonts,
1297
- hasColors: raw.hasColors === true && allowedColors.length > 0,
1298
- allowedColors,
1299
- hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
1300
- allowedRadii,
1301
- hasPillRadius: raw.hasPillRadius === true,
1302
- };
1303
- }
1304
-
1305
- function browserColorsClose(a, b) {
1306
- if (!a || !b) return false;
1307
- return Math.max(
1308
- Math.abs(a.r - b.r),
1309
- Math.abs(a.g - b.g),
1310
- Math.abs(a.b - b.b),
1311
- ) <= DESIGN_COLOR_TOLERANCE;
1312
- }
1313
-
1314
- function isBrowserDesignColorAllowed(raw, designSystem) {
1315
- if (!designSystem?.hasColors) return true;
1316
- const text = String(raw || '').trim().toLowerCase();
1317
- if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
1318
- if (text.includes('var(')) return true;
1319
- const parsed = parseAnyColor(text);
1320
- if (!parsed) return true;
1321
- if ((parsed.a ?? 1) <= 0.05) return true;
1322
- return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
1323
- }
1324
-
1325
- function isBrowserTransparentCss(value) {
1326
- const text = String(value || '').trim().toLowerCase();
1327
- if (!text || text === 'transparent') return true;
1328
- const parsed = parseAnyColor(text);
1329
- return parsed ? (parsed.a ?? 1) <= 0.05 : false;
1330
- }
1331
-
1332
- function isBrowserDesignRadiusAllowed(raw, designSystem) {
1333
- if (!designSystem?.hasRadii) return true;
1334
- const text = String(raw || '').trim().toLowerCase();
1335
- if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
1336
- if (text.includes('var(') || text.includes('%')) return true;
1337
- const px = resolveLengthPx(text, 16);
1338
- if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
1339
- if (designSystem.hasPillRadius && px >= 99) return true;
1340
- return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
1341
- }
1342
-
1343
- function browserRadiusTokens(value) {
1344
- return String(value || '')
1345
- .replace(/\s*\/\s*/g, ' ')
1346
- .split(/\s+/)
1347
- .map(token => token.trim())
1348
- .filter(Boolean);
1349
- }
1350
-
1351
- function browserHasDirectText(el) {
1352
- return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
1353
- }
1354
-
1355
- function browserSampleText(el) {
1356
- const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
1357
- return text ? ` "${text.slice(0, 40)}"` : '';
1358
- }
1359
-
1360
- function shouldSkipDesignElement(el) {
1361
- const tag = el.tagName?.toLowerCase?.() || '';
1362
- return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
1363
- }
1364
-
1365
- function checkElementDesignSystemDOM(el, designSystem, seen) {
1366
- if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
1367
- const findings = [];
1368
- const tag = el.tagName?.toLowerCase?.() || 'unknown';
1369
- const style = getComputedStyle(el);
1370
-
1371
- if (designSystem.hasFonts && browserHasDirectText(el)) {
1372
- const font = browserPrimaryFont(style.fontFamily || '');
1373
- if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
1374
- seen.fonts.add(font);
1375
- findings.push({
1376
- type: 'design-system-font',
1377
- detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
1378
- ignoreValue: font,
1379
- });
1380
- }
1381
- }
1382
-
1383
- if (designSystem.hasColors) {
1384
- const colorChecks = [];
1385
- if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
1386
- if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
1387
- for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
1388
- if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
1389
- colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
1390
- }
1391
- }
1392
- if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
1393
-
1394
- for (const [kind, raw] of colorChecks) {
1395
- const label = String(raw || '').trim().replace(/\s+/g, ' ');
1396
- if (isBrowserDesignColorAllowed(label, designSystem)) continue;
1397
- const key = `${kind}:${label}`;
1398
- if (seen.colors.has(key)) continue;
1399
- seen.colors.add(key);
1400
- findings.push({
1401
- type: 'design-system-color',
1402
- detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
1403
- ignoreValue: label,
1404
- });
1405
- }
1406
- }
1407
-
1408
- if (designSystem.hasRadii) {
1409
- for (const token of browserRadiusTokens(style.borderRadius || '')) {
1410
- if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
1411
- if (seen.radii.has(token)) continue;
1412
- seen.radii.add(token);
1413
- findings.push({
1414
- type: 'design-system-radius',
1415
- detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
1416
- ignoreValue: token,
1417
- });
1418
- }
1419
- }
1420
-
1421
- return findings;
1422
- }
1423
-
1424
- function decodeBrowserGoogleFamily(value) {
1425
- const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
1426
- try {
1427
- return decodeURIComponent(family);
1428
- } catch {
1429
- return family;
1430
- }
1431
- }
1432
-
1433
- function checkBrowserDesignSystemSources(designSystem, seen) {
1434
- if (!designSystem?.hasFonts) return [];
1435
- const findings = [];
1436
- for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
1437
- const href = link.getAttribute('href') || '';
1438
- for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
1439
- const display = decodeBrowserGoogleFamily(match[1]);
1440
- const font = normalizeBrowserFontName(display);
1441
- if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
1442
- seen.fonts.add(font);
1443
- findings.push({
1444
- type: 'design-system-font',
1445
- detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
1446
- ignoreValue: display,
1447
- });
1448
- }
1449
- }
1450
- return findings;
1451
- }
1452
-
1453
- function collectBrowserFindings() {
1454
- const groupMap = new Map();
1455
- const _disabled = EXTENSION_MODE ? (window.__MONODESIGN_CONFIG__?.disabledRules || []) : [];
1456
- const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
1457
- const designSystem = browserDesignSystemConfig();
1458
- const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
1459
- // Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
1460
- // real browser env (detector page, live overlay, extension) running every
1461
- // check is free, so we always surface them; the gating is purely a CLI
1462
- // output concern, applied in the Node engines' detect* return paths.
1463
-
1464
- for (const el of document.querySelectorAll('*')) {
1465
- // Skip monodesign's own elements and any descendants (overlays, labels, banner, nav buttons)
1466
- if (el.closest('.monodesign-overlay, .monodesign-label, .monodesign-banner, .monodesign-tooltip')) continue;
1467
- // Skip browser extension elements (Claude, etc.)
1468
- const elId = el.id || '';
1469
- if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue;
1470
- // Skip the monodesign live-mode overlay (highlight, tooltip, bar, picker, toast).
1471
- // These are inspector chrome, not part of the user's design.
1472
- if (el.closest('[id^="monodesign-live-"]')) continue;
1473
- // Skip html/body -- page-level findings go in the banner, not a full-page overlay
1474
- if (el === document.body || el === document.documentElement) continue;
1475
-
1476
- const findings = [
1477
- ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1478
- ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1479
- ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1480
- ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1481
- ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1482
- ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1483
- ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1484
- ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1485
- ...checkElementOversizedH1DOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1486
- ...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1487
- ...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1488
- ...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1489
- ...checkElementImageDimensionsDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1490
- ...checkElementSmallTouchTargetDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
1491
- ...checkElementDesignSystemDOM(el, designSystem, designSeen),
1492
- ].filter(f => _ruleOk(f.type));
1493
-
1494
- addBrowserFindings(groupMap, el, findings);
1495
-
1496
- // Hero eyebrow: the offending element is the eyebrow above the heading,
1497
- // not the heading itself — highlight the previous sibling instead.
1498
- const eyebrowFindings = checkElementHeroEyebrowDOM(el)
1499
- .map(f => ({ type: f.id, detail: f.snippet }))
1500
- .filter(f => _ruleOk(f.type));
1501
- if (eyebrowFindings.length > 0 && el.previousElementSibling) {
1502
- addBrowserFindings(groupMap, el.previousElementSibling, eyebrowFindings);
1503
- }
1504
- }
1505
-
1506
- const pageLevelFindings = [];
1507
-
1508
- const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
1509
- .filter(f => _ruleOk(f.type));
1510
- if (designSourceFindings.length > 0) {
1511
- pageLevelFindings.push(...designSourceFindings);
1512
- addBrowserFindings(groupMap, document.body, designSourceFindings);
1513
- }
1514
-
1515
- const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
1516
- if (typoFindings.length > 0) {
1517
- pageLevelFindings.push(...typoFindings);
1518
- addBrowserFindings(groupMap, document.body, typoFindings);
1519
- }
1520
-
1521
- const sectionKickerFindings = checkRepeatedSectionKickersDOM()
1522
- .map(f => ({ type: f.id, detail: f.snippet }))
1523
- .filter(f => _ruleOk(f.type));
1524
- if (sectionKickerFindings.length > 0) {
1525
- pageLevelFindings.push(...sectionKickerFindings);
1526
- addBrowserFindings(groupMap, document.body, sectionKickerFindings);
1527
- }
1528
-
1529
- const layoutFindings = checkLayout().filter(f => _ruleOk(f.type));
1530
- for (const f of layoutFindings) {
1531
- const el = f.el || document.body;
1532
- addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]);
1533
- }
1534
-
1535
- // Page-level quality checks (headings, etc.)
1536
- const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type));
1537
- if (qualityFindings.length > 0) {
1538
- pageLevelFindings.push(...qualityFindings);
1539
- addBrowserFindings(groupMap, document.body, qualityFindings);
1540
- }
1541
-
1542
- const creamFindings = checkCreamPalette(document)
1543
- .map(f => ({ type: f.id, detail: f.snippet }))
1544
- .filter(f => _ruleOk(f.type));
1545
- if (creamFindings.length > 0) {
1546
- pageLevelFindings.push(...creamFindings);
1547
- addBrowserFindings(groupMap, document.body, creamFindings);
1548
- }
1549
-
1550
- // Stylesheet-level accessibility rules (focus-visible, hover-only,
1551
- // dark-scheme). Serialize every same-origin stylesheet to CSS text and
1552
- // run the shared analyzers — cross-origin sheets throw on cssRules access
1553
- // and are skipped.
1554
- let stylesheetCssText = '';
1555
- for (const sheet of Array.from(document.styleSheets || [])) {
1556
- let rules;
1557
- try { rules = sheet.cssRules; } catch { continue; }
1558
- if (!rules) continue;
1559
- for (const rule of Array.from(rules)) {
1560
- try { stylesheetCssText += `${rule.cssText}\n`; } catch { /* ignore */ }
1561
- }
1562
- }
1563
- const stylesheetFindings = [
1564
- ...checkFocusVisible(stylesheetCssText),
1565
- ...checkHoverOnlyAffordance(stylesheetCssText),
1566
- ...checkDarkSchemeContrast(stylesheetCssText),
1567
- ].map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
1568
- if (stylesheetFindings.length > 0) {
1569
- pageLevelFindings.push(...stylesheetFindings);
1570
- addBrowserFindings(groupMap, document.body, stylesheetFindings);
1571
- }
1572
-
1573
- // Regex-on-HTML checks (shared with Node)
1574
- // Clone the document and strip monodesign-live overlay nodes before the
1575
- // regex scan, so the inspector's own inline styles (transitions on top/
1576
- // left/width/height, etc.) don't register as page anti-patterns.
1577
- const docClone = document.documentElement.cloneNode(true);
1578
- for (const node of docClone.querySelectorAll('[id^="monodesign-live-"]')) {
1579
- node.remove();
1580
- }
1581
- const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
1582
- if (htmlPatternFindings.length > 0) {
1583
- const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type));
1584
- pageLevelFindings.push(...mapped);
1585
- addBrowserFindings(groupMap, document.body, mapped);
1586
- }
1587
-
1588
- return {
1589
- groupMap,
1590
- allFindings: browserFindingsFromMap(groupMap),
1591
- pageLevelFindings,
1592
- };
1593
- }
1594
-
1595
- function shouldRunVisualContrast(options = {}) {
1596
- return options.visualContrast === true || window.__MONODESIGN_CONFIG__?.visualContrast === true;
1597
- }
1598
-
1599
- function visualContrastOptions(options = {}) {
1600
- const config = window.__MONODESIGN_CONFIG__ || {};
1601
- const scrollOffscreen = typeof options.scrollOffscreen === 'boolean'
1602
- ? options.scrollOffscreen
1603
- : typeof options.visualContrastScrollOffscreen === 'boolean'
1604
- ? options.visualContrastScrollOffscreen
1605
- : typeof config.visualContrastScrollOffscreen === 'boolean'
1606
- ? config.visualContrastScrollOffscreen
1607
- : false;
1608
- return {
1609
- ...options,
1610
- maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
1611
- ? options.visualContrastMaxCandidates
1612
- : Number.isFinite(options.maxCandidates)
1613
- ? options.maxCandidates
1614
- : Number.isFinite(config.visualContrastMaxCandidates)
1615
- ? config.visualContrastMaxCandidates
1616
- : undefined,
1617
- scrollOffscreen,
1618
- };
1619
- }
1620
-
1621
- let lastVisualContrastAnalyses = [];
1622
- let lazyVisualContrastObserver = null;
1623
- let lazyVisualContrastPending = new WeakMap();
1624
- const lazyVisualContrastResolving = new WeakSet();
1625
- let scanGeneration = 0;
1626
-
1627
- function rememberVisualContrastAnalysis(result) {
1628
- if (!result?.selector) {
1629
- lastVisualContrastAnalyses.push(result);
1630
- return;
1631
- }
1632
- const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector);
1633
- if (idx >= 0) lastVisualContrastAnalyses[idx] = result;
1634
- else lastVisualContrastAnalyses.push(result);
1635
- }
1636
-
1637
- function disconnectLazyVisualContrastObserver() {
1638
- if (lazyVisualContrastObserver) {
1639
- lazyVisualContrastObserver.disconnect();
1640
- lazyVisualContrastObserver = null;
1641
- }
1642
- lazyVisualContrastPending = new WeakMap();
1643
- }
1644
-
1645
- function addVisualContrastResult(groupMap, result, options = {}) {
1646
- if (result.status !== 'fail' || !result.finding || !result.selector) return false;
1647
- let el = null;
1648
- try {
1649
- el = document.querySelector(result.selector);
1650
- } catch {
1651
- el = null;
1652
- }
1653
- if (!el) return false;
1654
- const findingType = result.finding.type || result.finding.id || 'low-contrast';
1655
- const existing = groupMap.get(el) || [];
1656
- if (existing.some(f => (f.type || f.id) === findingType)) return false;
1657
- addBrowserFindings(groupMap, el, [{
1658
- type: findingType,
1659
- detail: result.finding.detail || result.finding.snippet,
1660
- }]);
1661
- if (options.decorate && el !== document.body && el !== document.documentElement) {
1662
- highlight(el, groupMap.get(el) || []);
1663
- }
1664
- return true;
1665
- }
1666
-
1667
- function scanResultMeta(options = {}) {
1668
- const scanId = options.scanId;
1669
- if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
1670
- return { scanId: String(scanId) };
1671
- }
1672
-
1673
- function postSerializedFindings(groupMap, options = {}) {
1674
- if (!EXTENSION_MODE) return;
1675
- const allFindings = browserFindingsFromMap(groupMap);
1676
- window.postMessage({
1677
- source: 'monodesign-results',
1678
- findings: serializeFindings(allFindings),
1679
- count: allFindings.length,
1680
- ...scanResultMeta(options),
1681
- }, '*');
1682
- }
1683
-
1684
- function postExtensionError(err) {
1685
- if (!EXTENSION_MODE) return;
1686
- window.postMessage({
1687
- source: 'monodesign-error',
1688
- message: err?.message || String(err),
1689
- }, '*');
1690
- }
1691
-
1692
- function reportVisualContrastError(err, detail = {}) {
1693
- window.dispatchEvent(new CustomEvent('monodesign-visual-contrast-error', {
1694
- detail: {
1695
- ...detail,
1696
- message: err?.message || String(err),
1697
- },
1698
- }));
1699
- if (EXTENSION_MODE) {
1700
- postExtensionError(err);
1701
- } else {
1702
- console.warn('[monodesign] visual contrast scan failed', err);
1703
- }
1704
- }
1705
-
1706
- function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) {
1707
- disconnectLazyVisualContrastObserver();
1708
- if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return;
1709
- if (typeof IntersectionObserver === 'undefined') return;
1710
- const unresolved = (analyses || []).filter(result =>
1711
- result?.status === 'unresolved' &&
1712
- result.reason === 'text outside viewport' &&
1713
- result.selector
1714
- );
1715
- if (unresolved.length === 0) return;
1716
- const generation = runtime.generation || scanGeneration;
1717
-
1718
- lazyVisualContrastObserver = new IntersectionObserver((entries) => {
1719
- for (const entry of entries) {
1720
- if (!entry.isIntersecting) continue;
1721
- const el = entry.target;
1722
- const candidate = lazyVisualContrastPending.get(el);
1723
- if (!candidate || lazyVisualContrastResolving.has(el)) continue;
1724
- lazyVisualContrastObserver?.unobserve(el);
1725
- lazyVisualContrastPending.delete(el);
1726
- lazyVisualContrastResolving.add(el);
1727
- waitForVisualPaint()
1728
- .then(() => analyzeVisualContrastCandidate(candidate))
1729
- .then(result => {
1730
- if (generation !== scanGeneration) return;
1731
- rememberVisualContrastAnalysis(result);
1732
- const added = addVisualContrastResult(groupMap, result, { decorate: true });
1733
- if (added) {
1734
- postSerializedFindings(groupMap, options);
1735
- window.dispatchEvent(new CustomEvent('monodesign-visual-contrast-resolved', {
1736
- detail: {
1737
- selector: result.selector,
1738
- status: result.status,
1739
- finding: result.finding || null,
1740
- },
1741
- }));
1742
- }
1743
- })
1744
- .catch(err => {
1745
- reportVisualContrastError(err, { selector: candidate.selector });
1746
- })
1747
- .finally(() => {
1748
- lazyVisualContrastResolving.delete(el);
1749
- });
1750
- }
1751
- }, { threshold: 0.5 });
1752
-
1753
- for (const candidate of unresolved) {
1754
- let el = null;
1755
- try {
1756
- el = document.querySelector(candidate.selector);
1757
- } catch {
1758
- el = null;
1759
- }
1760
- if (!el) continue;
1761
- lazyVisualContrastPending.set(el, candidate);
1762
- lazyVisualContrastObserver.observe(el);
1763
- }
1764
- }
1765
-
1766
- async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) {
1767
- if (!shouldRunVisualContrast(options)) {
1768
- lastVisualContrastAnalyses = [];
1769
- disconnectLazyVisualContrastObserver();
1770
- return [];
1771
- }
1772
- const resolvedOptions = visualContrastOptions(options);
1773
- const analyses = await analyzeVisualContrast(resolvedOptions);
1774
- if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
1775
- lastVisualContrastAnalyses = analyses;
1776
- for (const result of analyses) {
1777
- addVisualContrastResult(groupMap, result, { decorate: runtime.decorate });
1778
- }
1779
- if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime);
1780
- return analyses;
1781
- }
1782
-
1783
- async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
1784
- const collected = collectBrowserFindings();
1785
- await addVisualContrastFindings(collected.groupMap, options, runtime);
1786
- return {
1787
- ...collected,
1788
- allFindings: browserFindingsFromMap(collected.groupMap),
1789
- visualContrastAnalyses: lastVisualContrastAnalyses,
1790
- };
1791
- }
1792
-
1793
- function clearOverlays() {
1794
- scanGeneration += 1;
1795
- disconnectLazyVisualContrastObserver();
1796
- for (const o of [...overlays]) detachOverlay(o);
1797
- overlays.length = 0;
1798
- visibilityObserver.disconnect();
1799
- overlayIndex = 0;
1800
- }
1801
-
1802
- function renderBrowserFindings(collected, options = {}) {
1803
- const { allFindings, pageLevelFindings } = collected;
1804
-
1805
- for (const { el, findings } of allFindings) {
1806
- if (el === document.body || el === document.documentElement) continue;
1807
- highlight(el, findings);
1808
- }
1809
-
1810
- if (pageLevelFindings.length > 0) {
1811
- showPageBanner(pageLevelFindings);
1812
- }
1813
-
1814
- if (!EXTENSION_MODE) printSummary(allFindings);
1815
-
1816
- // In extension mode, post serialized results for the DevTools panel
1817
- if (EXTENSION_MODE) {
1818
- window.postMessage({
1819
- source: 'monodesign-results',
1820
- findings: serializeFindings(allFindings),
1821
- count: allFindings.length,
1822
- ...scanResultMeta(options),
1823
- }, '*');
1824
- }
1825
-
1826
- // After this scan completes, all subsequent reveals are instant (no stagger, no animation)
1827
- setTimeout(() => { firstScanDone = true; }, 1000);
1828
-
1829
- return allFindings;
1830
- }
1831
-
1832
- let firstScanDone = false;
1833
- const scan = (options = {}) => {
1834
- clearOverlays();
1835
- const generation = scanGeneration;
1836
- const collected = collectBrowserFindings();
1837
- const allFindings = renderBrowserFindings(collected, options);
1838
- if (shouldRunVisualContrast(options)) {
1839
- addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
1840
- .then(() => {
1841
- if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
1842
- })
1843
- .catch(err => {
1844
- reportVisualContrastError(err);
1845
- });
1846
- }
1847
- return allFindings;
1848
- };
1849
-
1850
- const scanAsync = async (options = {}) => {
1851
- clearOverlays();
1852
- const generation = scanGeneration;
1853
- if (shouldRunVisualContrast(options)) {
1854
- const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
1855
- if (generation !== scanGeneration) return [];
1856
- return renderBrowserFindings(collected, options);
1857
- }
1858
- lastVisualContrastAnalyses = [];
1859
- return renderBrowserFindings(collectBrowserFindings(), options);
1860
- };
1861
-
1862
- const detect = (options = {}) => {
1863
- lastVisualContrastAnalyses = [];
1864
- const { allFindings } = collectBrowserFindings();
1865
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
1866
- };
1867
-
1868
- const detectAsync = async (options = {}) => {
1869
- if (shouldRunVisualContrast(options)) {
1870
- const { allFindings } = await collectBrowserFindingsAsync(options);
1871
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
1872
- }
1873
- lastVisualContrastAnalyses = [];
1874
- const { allFindings } = collectBrowserFindings();
1875
- return options.serialize === false ? allFindings : serializeFindings(allFindings);
1876
- };
1877
-
1878
- if (EXTENSION_MODE) {
1879
- // Extension mode: listen for commands, don't auto-scan
1880
- window.addEventListener('message', (e) => {
1881
- if (e.source !== window || !e.data || e.data.source !== 'monodesign-command') return;
1882
- if (e.data.action === 'scan') {
1883
- if (e.data.config) window.__MONODESIGN_CONFIG__ = e.data.config;
1884
- try {
1885
- scan(e.data.config || {});
1886
- } catch (err) {
1887
- postExtensionError(err);
1888
- }
1889
- }
1890
- if (e.data.action === 'toggle-overlays') {
1891
- const visible = !document.body.classList.contains('monodesign-hidden');
1892
- document.body.classList.toggle('monodesign-hidden', visible);
1893
- window.postMessage({ source: 'monodesign-overlays-toggled', visible: !visible }, '*');
1894
- }
1895
- if (e.data.action === 'remove') {
1896
- clearOverlays();
1897
- styleEl.remove();
1898
- if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; }
1899
- document.body.classList.remove('monodesign-hidden');
1900
- }
1901
- if (e.data.action === 'highlight') {
1902
- try {
1903
- const target = e.data.selector ? document.querySelector(e.data.selector) : null;
1904
- if (target) {
1905
- // Scroll first so positionOverlay reads the post-scroll rect
1906
- if (!isInViewport(target) && target.scrollIntoView) {
1907
- target.scrollIntoView({ behavior: 'instant', block: 'center' });
1908
- }
1909
- for (const o of overlays) {
1910
- if (o.classList.contains('monodesign-banner')) continue;
1911
- const isMatch = o._targetEl === target;
1912
- o.classList.toggle('monodesign-spotlight', isMatch);
1913
- o.classList.toggle('monodesign-spotlight-dimmed', !isMatch);
1914
- if (isMatch) {
1915
- // Force the matching overlay visible immediately, don't wait for IntersectionObserver
1916
- o.style.display = '';
1917
- o.style.animation = 'none';
1918
- o.classList.add('monodesign-visible');
1919
- o._revealed = true;
1920
- positionOverlay(o);
1921
- }
1922
- }
1923
- showSpotlight(target);
1924
- }
1925
- } catch { /* invalid selector */ }
1926
- }
1927
- if (e.data.action === 'unhighlight') {
1928
- hideSpotlight();
1929
- for (const o of overlays) {
1930
- o.classList.remove('monodesign-spotlight');
1931
- o.classList.remove('monodesign-spotlight-dimmed');
1932
- }
1933
- }
1934
- });
1935
- window.postMessage({ source: 'monodesign-ready' }, '*');
1936
- } else {
1937
- if (window.__MONODESIGN_CONFIG__?.autoScan !== false) {
1938
- const runAutoScan = () => {
1939
- try {
1940
- scan();
1941
- } catch (err) {
1942
- console.warn('[monodesign] scan failed', err);
1943
- }
1944
- };
1945
- if (document.readyState === 'loading') {
1946
- document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100));
1947
- } else {
1948
- setTimeout(runAutoScan, 100);
1949
- }
1950
- }
1951
- }
1952
-
1953
- window.monodesignDetect = detect;
1954
- window.monodesignDetectAsync = detectAsync;
1955
- window.monodesignScan = scan;
1956
- window.monodesignScanAsync = scanAsync;
1957
- window.monodesignCollectVisualContrastCandidates = collectVisualContrastCandidates;
1958
- window.monodesignAnalyzeVisualContrast = analyzeVisualContrast;
1959
- window.monodesignGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice();
1960
- }