@hexclave/next 1.0.13 → 1.0.15

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 (189) hide show
  1. package/dist/clickmap/clickmap-core.d.ts +15 -0
  2. package/dist/clickmap/clickmap-core.d.ts.map +1 -0
  3. package/dist/clickmap/clickmap-core.js +1527 -0
  4. package/dist/clickmap/clickmap-core.js.map +1 -0
  5. package/dist/clickmap/clickmap-styles.d.ts +5 -0
  6. package/dist/clickmap/clickmap-styles.d.ts.map +1 -0
  7. package/dist/clickmap/clickmap-styles.js +1095 -0
  8. package/dist/clickmap/clickmap-styles.js.map +1 -0
  9. package/dist/clickmap/index.d.ts +16 -0
  10. package/dist/clickmap/index.d.ts.map +1 -0
  11. package/dist/clickmap/index.js +74 -0
  12. package/dist/clickmap/index.js.map +1 -0
  13. package/dist/components/api-key-dialogs.js +2 -2
  14. package/dist/components/credential-sign-in.js +1 -1
  15. package/dist/components/credential-sign-up.js +1 -1
  16. package/dist/components/magic-link-sign-in.js +1 -1
  17. package/dist/components/message-cards/known-error-message-card.d.ts +1 -1
  18. package/dist/components/team-switcher.js +1 -1
  19. package/dist/components-page/account-settings/active-sessions/active-sessions-page.js +1 -1
  20. package/dist/components-page/account-settings/email-and-auth/emails-section.js +1 -1
  21. package/dist/components-page/account-settings/email-and-auth/mfa-section.js +1 -1
  22. package/dist/components-page/account-settings/email-and-auth/password-section.js +1 -1
  23. package/dist/components-page/account-settings/teams/team-creation-page.js +1 -1
  24. package/dist/components-page/account-settings/teams/team-member-invitation-section.js +1 -1
  25. package/dist/components-page/auth-page.js +1 -1
  26. package/dist/components-page/cli-auth-confirm.js +1 -1
  27. package/dist/components-page/cli-auth-confirm.test.js +1 -1
  28. package/dist/components-page/forgot-password.d.ts.map +1 -1
  29. package/dist/components-page/forgot-password.js +2 -3
  30. package/dist/components-page/forgot-password.js.map +1 -1
  31. package/dist/components-page/hexclave-handler-client.d.ts +1 -1
  32. package/dist/components-page/mfa.js +4 -19
  33. package/dist/components-page/mfa.js.map +1 -1
  34. package/dist/components-page/oauth-callback.js +1 -1
  35. package/dist/components-page/onboarding.js +1 -1
  36. package/dist/components-page/password-reset.d.ts.map +1 -1
  37. package/dist/components-page/password-reset.js +5 -7
  38. package/dist/components-page/password-reset.js.map +1 -1
  39. package/dist/components-page/team-creation.js +1 -1
  40. package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
  41. package/dist/dev-tool/dev-tool-core.js +258 -262
  42. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  43. package/dist/dev-tool/dev-tool-styles.d.ts +1 -1
  44. package/dist/dev-tool/dev-tool-styles.d.ts.map +1 -1
  45. package/dist/dev-tool/dev-tool-styles.js +13 -143
  46. package/dist/dev-tool/dev-tool-styles.js.map +1 -1
  47. package/dist/dev-tool/index.d.ts.map +1 -1
  48. package/dist/dev-tool/index.js +5 -12
  49. package/dist/dev-tool/index.js.map +1 -1
  50. package/dist/esm/clickmap/clickmap-core.d.ts +15 -0
  51. package/dist/esm/clickmap/clickmap-core.d.ts.map +1 -0
  52. package/dist/esm/clickmap/clickmap-core.js +1525 -0
  53. package/dist/esm/clickmap/clickmap-core.js.map +1 -0
  54. package/dist/esm/clickmap/clickmap-styles.d.ts +5 -0
  55. package/dist/esm/clickmap/clickmap-styles.d.ts.map +1 -0
  56. package/dist/esm/clickmap/clickmap-styles.js +1093 -0
  57. package/dist/esm/clickmap/clickmap-styles.js.map +1 -0
  58. package/dist/esm/clickmap/index.d.ts +16 -0
  59. package/dist/esm/clickmap/index.d.ts.map +1 -0
  60. package/dist/esm/clickmap/index.js +72 -0
  61. package/dist/esm/clickmap/index.js.map +1 -0
  62. package/dist/esm/components/api-key-dialogs.js +2 -2
  63. package/dist/esm/components/credential-sign-in.js +1 -1
  64. package/dist/esm/components/credential-sign-up.js +1 -1
  65. package/dist/esm/components/magic-link-sign-in.js +1 -1
  66. package/dist/esm/components/team-switcher.js +1 -1
  67. package/dist/esm/components-page/account-settings/active-sessions/active-sessions-page.js +1 -1
  68. package/dist/esm/components-page/account-settings/email-and-auth/emails-section.js +1 -1
  69. package/dist/esm/components-page/account-settings/email-and-auth/mfa-section.js +1 -1
  70. package/dist/esm/components-page/account-settings/email-and-auth/password-section.js +1 -1
  71. package/dist/esm/components-page/account-settings/teams/team-creation-page.js +1 -1
  72. package/dist/esm/components-page/account-settings/teams/team-member-invitation-section.js +1 -1
  73. package/dist/esm/components-page/auth-page.js +1 -1
  74. package/dist/esm/components-page/cli-auth-confirm.js +1 -1
  75. package/dist/esm/components-page/cli-auth-confirm.test.js +1 -1
  76. package/dist/esm/components-page/forgot-password.d.ts.map +1 -1
  77. package/dist/esm/components-page/forgot-password.js +2 -3
  78. package/dist/esm/components-page/forgot-password.js.map +1 -1
  79. package/dist/esm/components-page/hexclave-handler-client.d.ts +1 -1
  80. package/dist/esm/components-page/mfa.js +4 -19
  81. package/dist/esm/components-page/mfa.js.map +1 -1
  82. package/dist/esm/components-page/oauth-callback.js +1 -1
  83. package/dist/esm/components-page/onboarding.js +1 -1
  84. package/dist/esm/components-page/password-reset.d.ts.map +1 -1
  85. package/dist/esm/components-page/password-reset.js +5 -7
  86. package/dist/esm/components-page/password-reset.js.map +1 -1
  87. package/dist/esm/components-page/team-creation.js +1 -1
  88. package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
  89. package/dist/esm/dev-tool/dev-tool-core.js +35 -39
  90. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  91. package/dist/esm/dev-tool/dev-tool-styles.d.ts +1 -1
  92. package/dist/esm/dev-tool/dev-tool-styles.d.ts.map +1 -1
  93. package/dist/esm/dev-tool/dev-tool-styles.js +13 -143
  94. package/dist/esm/dev-tool/dev-tool-styles.js.map +1 -1
  95. package/dist/esm/dev-tool/index.d.ts.map +1 -1
  96. package/dist/esm/dev-tool/index.js +2 -9
  97. package/dist/esm/dev-tool/index.js.map +1 -1
  98. package/dist/esm/generated/global-css.d.ts +1 -1
  99. package/dist/esm/generated/global-css.js +1 -1
  100. package/dist/esm/generated/global-css.js.map +1 -1
  101. package/dist/esm/generated/quetzal-translations.d.ts +2 -2
  102. package/dist/esm/in-page-ui/base-styles.d.ts +5 -0
  103. package/dist/esm/in-page-ui/base-styles.d.ts.map +1 -0
  104. package/dist/esm/in-page-ui/base-styles.js +166 -0
  105. package/dist/esm/in-page-ui/base-styles.js.map +1 -0
  106. package/dist/esm/in-page-ui/dom.d.ts +15 -0
  107. package/dist/esm/in-page-ui/dom.d.ts.map +1 -0
  108. package/dist/esm/in-page-ui/dom.js +44 -0
  109. package/dist/esm/in-page-ui/dom.js.map +1 -0
  110. package/dist/esm/lib/auth.js +1 -1
  111. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +5 -1
  112. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  113. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js +20 -0
  114. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  115. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  116. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +4 -2
  117. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  118. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
  119. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts +13 -0
  120. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
  121. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js +146 -14
  122. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
  123. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js +221 -0
  124. package/dist/esm/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
  125. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  126. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  127. package/dist/esm/lib/hexclave-app/apps/implementations/session-replay.js +1 -1
  128. package/dist/esm/lib/hexclave-app/apps/interfaces/admin-app.d.ts +5 -0
  129. package/dist/esm/lib/hexclave-app/apps/interfaces/admin-app.d.ts.map +1 -1
  130. package/dist/esm/lib/hexclave-app/apps/interfaces/admin-app.js.map +1 -1
  131. package/dist/esm/providers/theme-provider.js +1 -1
  132. package/dist/generated/global-css.d.ts +1 -1
  133. package/dist/generated/global-css.js +1 -1
  134. package/dist/generated/global-css.js.map +1 -1
  135. package/dist/generated/quetzal-translations.d.ts +2 -2
  136. package/dist/in-page-ui/base-styles.d.ts +5 -0
  137. package/dist/in-page-ui/base-styles.d.ts.map +1 -0
  138. package/dist/in-page-ui/base-styles.js +168 -0
  139. package/dist/in-page-ui/base-styles.js.map +1 -0
  140. package/dist/in-page-ui/dom.d.ts +15 -0
  141. package/dist/in-page-ui/dom.d.ts.map +1 -0
  142. package/dist/in-page-ui/dom.js +51 -0
  143. package/dist/in-page-ui/dom.js.map +1 -0
  144. package/dist/index.d.ts +1 -1
  145. package/dist/integrations/convex/component/convex.config.d.ts +1 -1
  146. package/dist/lib/auth.js +1 -1
  147. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +5 -1
  148. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  149. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js +20 -0
  150. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
  151. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  152. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +4 -2
  153. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  154. package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
  155. package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts +13 -0
  156. package/dist/lib/hexclave-app/apps/implementations/event-tracker.d.ts.map +1 -1
  157. package/dist/lib/hexclave-app/apps/implementations/event-tracker.js +146 -14
  158. package/dist/lib/hexclave-app/apps/implementations/event-tracker.js.map +1 -1
  159. package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js +221 -0
  160. package/dist/lib/hexclave-app/apps/implementations/event-tracker.test.js.map +1 -1
  161. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  162. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  163. package/dist/lib/hexclave-app/apps/implementations/session-replay.js +1 -1
  164. package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +5 -0
  165. package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts.map +1 -1
  166. package/dist/lib/hexclave-app/apps/interfaces/admin-app.js.map +1 -1
  167. package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
  168. package/dist/lib/hexclave-app/common.d.ts +1 -1
  169. package/dist/providers/hexclave-provider-client.d.ts +1 -1
  170. package/dist/providers/theme-provider.js +1 -1
  171. package/dist/{storage-CKzvsBxG.d.ts → storage-ksajV_p6.d.ts} +1 -1
  172. package/dist/{storage-CKzvsBxG.d.ts.map → storage-ksajV_p6.d.ts.map} +1 -1
  173. package/package.json +4 -4
  174. package/src/clickmap/clickmap-core.ts +1997 -0
  175. package/src/clickmap/clickmap-styles.ts +1102 -0
  176. package/src/clickmap/index.ts +95 -0
  177. package/src/components-page/forgot-password.tsx +1 -2
  178. package/src/components-page/mfa.tsx +12 -21
  179. package/src/components-page/password-reset.tsx +4 -6
  180. package/src/dev-tool/dev-tool-core.ts +38 -65
  181. package/src/dev-tool/dev-tool-styles.ts +13 -142
  182. package/src/dev-tool/index.ts +1 -14
  183. package/src/in-page-ui/base-styles.ts +171 -0
  184. package/src/in-page-ui/dom.ts +80 -0
  185. package/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +23 -1
  186. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +7 -0
  187. package/src/lib/hexclave-app/apps/implementations/event-tracker.test.ts +287 -0
  188. package/src/lib/hexclave-app/apps/implementations/event-tracker.ts +226 -16
  189. package/src/lib/hexclave-app/apps/interfaces/admin-app.ts +3 -0
@@ -0,0 +1,1997 @@
1
+
2
+ //===========================================
3
+ // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
+ //===========================================
5
+
6
+ // Standalone clickmap overlay. This module is fully independent from the dev
7
+ // tool (packages/template/src/dev-tool): it has its own DOM root, its own
8
+ // stylesheet, and its own mount lifecycle, so the dev tool can be changed or
9
+ // removed without affecting clickmaps. It's opened via a dashboard-minted
10
+ // token (the CLICKMAP_OVERLAY_TOKEN_UPDATED event / resume flow) — see
11
+ // ./index.ts for the lazy-loading entry point.
12
+
13
+ import { AnalyticsClickmapResponseBodySchema, type AnalyticsClickmapResponse } from "@hexclave/shared/dist/interface/admin-metrics";
14
+ import {
15
+ CLICKMAP_OVERLAY_RESUME_STORAGE_KEY,
16
+ CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY,
17
+ CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT,
18
+ } from "@hexclave/shared/dist/utils/analytics-clickmap-overlay";
19
+ import { CLICKMAP_ROOT_ID, DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool";
20
+ import { cssEscapeIdent } from "@hexclave/shared/dist/utils/dom";
21
+ import { buildElementsChain, parseElementsChain, type ElementsChainSegment } from "@hexclave/shared/dist/utils/elements-chain";
22
+ import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
23
+ import { stringCompare } from "@hexclave/shared/dist/utils/strings";
24
+ import { getGlobalUiInstance, h, hasAppendChild, setGlobalUiInstance, setHtml, type UiGlobalInstance } from "../in-page-ui/dom";
25
+ import type { StackClientApp } from "../lib/hexclave-app";
26
+ import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
27
+ import { clickmapCSS } from "./clickmap-styles";
28
+
29
+ type ClickmapPanelResult = { element: HTMLElement, cleanup?: () => void };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Clickmap panel
33
+ // ---------------------------------------------------------------------------
34
+
35
+ type ClickmapClickGroup = {
36
+ selector: string;
37
+ label: string;
38
+ count: number;
39
+ // Clicks on this element that produced no observable effect (is_dead rows).
40
+ deadCount: number;
41
+ element: Element | null;
42
+ rect: DOMRect | null;
43
+ };
44
+
45
+ type ClickmapGroupOverlayElement = {
46
+ marker: HTMLElement;
47
+ outline: HTMLElement;
48
+ };
49
+
50
+ type ClickmapListRowElement = {
51
+ row: HTMLElement;
52
+ count: HTMLElement;
53
+ check: HTMLButtonElement;
54
+ eye: HTMLButtonElement;
55
+ label: HTMLElement;
56
+ dead: HTMLElement;
57
+ selector: HTMLElement;
58
+ group: ClickmapClickGroup | null;
59
+ renderedEyeIcon: string;
60
+ renderedCheckIcon: string;
61
+ };
62
+
63
+ const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters';
64
+
65
+ type ClickmapRangeKey = '24h' | '7d' | '30d';
66
+ type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv';
67
+
68
+ type ClickmapFilters = {
69
+ range: ClickmapRangeKey,
70
+ device: ClickmapDeviceKey,
71
+ urlPattern: string,
72
+ elementSearch: string,
73
+ // Reveal dead clicks in the overlay. Off by default: every displayed count
74
+ // is alive clicks only, dead chips are hidden, and elements whose clicks
75
+ // were all dead are dropped. Pure client-side filter — the server response
76
+ // always carries both clicks (total) and dead_clicks per element, so
77
+ // toggling never refetches.
78
+ showDead: boolean,
79
+ };
80
+
81
+ type ClickmapViewportBucket = {
82
+ min: number,
83
+ max: number | null,
84
+ };
85
+
86
+ const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = {
87
+ range: '7d',
88
+ device: 'all',
89
+ urlPattern: '',
90
+ elementSearch: '',
91
+ showDead: false,
92
+ };
93
+
94
+ const CLICKMAP_RANGE_MS: Record<ClickmapRangeKey, number> = {
95
+ '24h': 24 * 60 * 60 * 1000,
96
+ '7d': 7 * 24 * 60 * 60 * 1000,
97
+ '30d': 30 * 24 * 60 * 60 * 1000,
98
+ };
99
+
100
+ const CLICKMAP_VIEWPORT_BUCKETS: Record<Exclude<ClickmapDeviceKey, 'all'>, ClickmapViewportBucket> = {
101
+ mobile: { min: 0, max: 767 },
102
+ tablet: { min: 768, max: 1023 },
103
+ laptop: { min: 1024, max: 1199 },
104
+ desktop: { min: 1200, max: 1439 },
105
+ widescreen: { min: 1440, max: 1919 },
106
+ tv: { min: 1920, max: null },
107
+ };
108
+
109
+ function getClickmapViewportBucket(device: ClickmapDeviceKey): ClickmapViewportBucket | null {
110
+ if (device === 'all') return null;
111
+ return CLICKMAP_VIEWPORT_BUCKETS[device];
112
+ }
113
+
114
+ function isClickmapViewportWidthInBucket(width: number, bucket: ClickmapViewportBucket): boolean {
115
+ return width >= bucket.min && (bucket.max == null || width <= bucket.max);
116
+ }
117
+
118
+ function getClickmapRecommendedViewportWidth(bucket: ClickmapViewportBucket): number {
119
+ if (bucket.max == null) return bucket.min;
120
+ return Math.round((bucket.min + bucket.max) / 2);
121
+ }
122
+
123
+ function formatClickmapViewportBucket(bucket: ClickmapViewportBucket): string {
124
+ if (bucket.max == null) return `${bucket.min}px+`;
125
+ return `${bucket.min}-${bucket.max}px`;
126
+ }
127
+
128
+ function isClickmapRangeKey(value: unknown): value is ClickmapRangeKey {
129
+ return value === '24h' || value === '7d' || value === '30d';
130
+ }
131
+ function isClickmapDeviceKey(value: unknown): value is ClickmapDeviceKey {
132
+ return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv';
133
+ }
134
+ const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250;
135
+
136
+ type ServerClickmapSelector = {
137
+ selector: string;
138
+ clicks: number;
139
+ };
140
+
141
+ type ServerClickmapElement = {
142
+ elementsChain: string;
143
+ elementsText: string;
144
+ tagName: string;
145
+ href: string | null;
146
+ clicks: number;
147
+ deadClicks: number;
148
+ };
149
+
150
+ type ServerClickmap = {
151
+ path: string;
152
+ // True aggregate click total returned for the active filter (summed across
153
+ // every matching route), independent of how many elements can be drawn on the
154
+ // current page's DOM. The overlay can only render elements that exist on the
155
+ // page you're viewing, but this count reflects the full pattern.
156
+ totalClicks: number;
157
+ selectors: ServerClickmapSelector[];
158
+ elements: ServerClickmapElement[];
159
+ };
160
+
161
+ function cssEscapeAttrValue(value: string): string {
162
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
163
+ }
164
+
165
+ function readChainAttr(segment: ElementsChainSegment, attr: string): string {
166
+ if (!Object.prototype.hasOwnProperty.call(segment.attrs, attr)) return '';
167
+ const value = segment.attrs[attr];
168
+ return typeof value === 'string' ? value : '';
169
+ }
170
+
171
+ // Compact, human-readable counts for tight UI surfaces (markers, chips,
172
+ // stats): 999 → "999", 1234 → "1.2k", 1_250_000 → "1.3m", 2e9 → "2b". One
173
+ // decimal place (trailing .0 drops out of the arithmetic), and rounding
174
+ // cascades into the next unit so 999_950+ reads "1m" rather than "1000k".
175
+ function formatClickmapCount(value: number): string {
176
+ let scaled = value;
177
+ let suffix = '';
178
+ for (const nextSuffix of ['k', 'm', 'b']) {
179
+ // 999.95 is the smallest value that would display as "1000" at one
180
+ // decimal place, so it already belongs to the next unit up.
181
+ if (scaled < 999.95) break;
182
+ scaled /= 1000;
183
+ suffix = nextSuffix;
184
+ }
185
+ if (suffix === '') return String(Math.round(scaled));
186
+ return `${Math.round(scaled * 10) / 10}${suffix}`;
187
+ }
188
+
189
+ function getClickmapHue(count: number, maxCount: number): number {
190
+ if (maxCount <= 1) return 185;
191
+ const intensity = Math.min(1, count / maxCount);
192
+ return 185 - Math.round(intensity * 155);
193
+ }
194
+
195
+
196
+ function getReadableElementLabel(element: Element): string {
197
+ const ariaLabel = element.getAttribute('aria-label');
198
+ if (ariaLabel != null && ariaLabel.trim() !== '') {
199
+ return ariaLabel.trim().slice(0, 80);
200
+ }
201
+ const title = element.getAttribute('title');
202
+ if (title != null && title.trim() !== '') {
203
+ return title.trim().slice(0, 80);
204
+ }
205
+ const text = element.textContent.trim().replace(/\s+/g, ' ');
206
+ if (text !== '') {
207
+ return text.slice(0, 80);
208
+ }
209
+ return element.tagName.toLowerCase();
210
+ }
211
+
212
+ function isElementVisibleForClickmap(element: Element): boolean {
213
+ // Never treat our own UI (or the dev tool's, if it happens to be mounted
214
+ // alongside) as a clickmap candidate.
215
+ if (element.closest(`#${cssEscapeIdent(CLICKMAP_ROOT_ID)}, #${cssEscapeIdent(DEV_TOOL_ROOT_ID)}`) != null) {
216
+ return false;
217
+ }
218
+ if (element.closest('[hidden], [aria-hidden="true"], [inert]') != null) {
219
+ return false;
220
+ }
221
+ const rect = element.getBoundingClientRect();
222
+ if (rect.width <= 0 || rect.height <= 0) {
223
+ return false;
224
+ }
225
+ const style = window.getComputedStyle(element);
226
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+
232
+ function getElementFromSelector(selector: string): Element | null {
233
+ try {
234
+ const elements = Array.from(document.querySelectorAll(selector));
235
+ return elements.find(isElementVisibleForClickmap) ?? null;
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ function getSessionStorageString(key: string): string | null {
242
+ try {
243
+ const value = sessionStorage.getItem(key);
244
+ return value == null || value.trim() === '' ? null : value;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function removeSessionStorageItem(key: string): void {
251
+ try {
252
+ sessionStorage.removeItem(key);
253
+ } catch {
254
+ // Storage can be blocked in private or embedded contexts; the toolbar keeps
255
+ // rendering the actionable error state in that case.
256
+ }
257
+ }
258
+
259
+ // Read a string claim out of a JWT payload without verifying the signature. The
260
+ // clickmap token is self-describing — it carries the `project_id` and `origin`
261
+ // it was minted for — so the overlay derives both from the token itself instead
262
+ // of needing them handed over alongside. The server still verifies the token on
263
+ // every request; this is only used to scope/label the token client-side.
264
+ function getJwtPayloadClaim(token: string, claim: string): string | null {
265
+ const tokenParts = token.split('.');
266
+ if (tokenParts.length < 2 || tokenParts[1] === '') {
267
+ return null;
268
+ }
269
+ try {
270
+ const payloadPart = tokenParts[1];
271
+ const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/');
272
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
273
+ const payload: unknown = JSON.parse(atob(padded));
274
+ if (typeof payload !== 'object' || payload === null) {
275
+ return null;
276
+ }
277
+ const value = Reflect.get(payload, claim);
278
+ return typeof value === 'string' ? value : null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ function getClickmapTokenFromStorage(): string | null {
285
+ return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
286
+ }
287
+
288
+ function getClickmapOriginFromStorage(): string | null {
289
+ const token = getClickmapTokenFromStorage();
290
+ return token == null ? null : getJwtPayloadClaim(token, 'origin');
291
+ }
292
+
293
+ function clearClickmapTokenStorage(): void {
294
+ removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY);
295
+ }
296
+
297
+ function parseServerClickmapResponse(value: unknown, path: string): ServerClickmap {
298
+ let parsed: AnalyticsClickmapResponse;
299
+ try {
300
+ // Validate against the canonical response contract instead of hand-walking
301
+ // `unknown`. Anything that doesn't match is treated as "no data" so the
302
+ // overlay stays alive rather than crashing on shape drift.
303
+ parsed = AnalyticsClickmapResponseBodySchema.validateSync(value);
304
+ } catch {
305
+ return { path, totalClicks: 0, selectors: [], elements: [] };
306
+ }
307
+ return {
308
+ path,
309
+ // True aggregate across every matching route, independent of what the
310
+ // current DOM can render.
311
+ totalClicks: parsed.routes.reduce((sum, route) => sum + route.clicks, 0),
312
+ selectors: parsed.selectors.map((selector) => ({ selector: selector.selector, clicks: selector.clicks })),
313
+ elements: parsed.elements.map((element) => ({
314
+ elementsChain: element.elements_chain,
315
+ elementsText: element.elements_text,
316
+ tagName: element.tag_name,
317
+ href: element.href,
318
+ clicks: element.clicks,
319
+ deadClicks: element.dead_clicks,
320
+ })),
321
+ };
322
+ }
323
+
324
+ // Heuristic: does this path segment look like an opaque per-entity id (a UUID,
325
+ // numeric id, Mongo ObjectId, ULID, etc.) rather than a human-readable slug?
326
+ // Used to auto-wildcard slug routes so a single clickmap pattern aggregates
327
+ // across every user/team instead of just the one currently in the URL.
328
+ function isDynamicPathSegment(segment: string): boolean {
329
+ if (segment === '') return false;
330
+ let decoded = segment;
331
+ try {
332
+ decoded = decodeURIComponent(segment);
333
+ } catch {
334
+ // keep the raw segment if it isn't valid percent-encoding
335
+ }
336
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded)) return true; // UUID
337
+ if (/^[0-9a-f]{32}$/i.test(decoded)) return true; // UUID without dashes / md5
338
+ if (/^[0-9a-f]{24}$/i.test(decoded)) return true; // Mongo ObjectId
339
+ if (/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(decoded)) return true; // ULID
340
+ if (/^\d+$/.test(decoded)) return true; // numeric id
341
+ return false;
342
+ }
343
+
344
+ // Turn the current pathname into a clickmap URL pattern by replacing id-like
345
+ // segments with `*` (PostHog-style wildcards). Stable slugs are preserved so
346
+ // e.g. `/teams/<uuid>/settings` becomes `/teams/*/settings`.
347
+ function wildcardizePathname(pathname: string): string {
348
+ const trailingSlash = pathname.length > 1 && pathname.endsWith('/');
349
+ const segments = pathname.split('/').map((segment) => (isDynamicPathSegment(segment) ? '*' : segment));
350
+ const joined = segments.join('/');
351
+ return trailingSlash ? `${joined}/` : joined;
352
+ }
353
+
354
+ // Translate a PostHog-style glob (where `*` is the only wildcard) into an
355
+ // anchored regex source mirroring the backend's SQL LIKE semantics.
356
+ function globToRegexSource(glob: string): string {
357
+ return glob
358
+ .split('*')
359
+ .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
360
+ .join('.*');
361
+ }
362
+
363
+ // Does `path` match the active URL pattern? Used to tell the user when the page
364
+ // they're on isn't covered by the pattern, so the overlay can't be drawn here
365
+ // even though aggregate data exists. Glob matching mirrors the backend's
366
+ // anchored `LIKE`.
367
+ function patternMatchesPath(pattern: string, path: string): boolean {
368
+ if (pattern === '') return true;
369
+ try {
370
+ return new RegExp(`^${globToRegexSource(pattern)}$`).test(path);
371
+ } catch {
372
+ return false;
373
+ }
374
+ }
375
+
376
+ function createClickmapPanel(app: StackClientApp<true>, onClose: () => void): ClickmapPanelResult {
377
+ const container = h('div', { className: 'sdt-hm' });
378
+ const overlayHighlight = h('div', { className: 'sdt-hm-highlight' });
379
+ const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }, overlayHighlight);
380
+ const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0');
381
+ const selectorCount = h('div', { className: 'sdt-hm-stat-value' }, '0');
382
+ const viewportValue = h('div', { className: 'sdt-hm-stat-value' }, `${window.innerWidth}x${window.innerHeight}`);
383
+ const list = h('div', { className: 'sdt-hm-list' });
384
+ const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a clickmap token from the dashboard to load aggregated element clicks for this page.');
385
+ const status = h('div', { className: 'sdt-hm-token-status' });
386
+ const viewportWarningTitle = h('div', { className: 'sdt-hm-viewport-warning-title' });
387
+ const viewportWarningBody = h('div', { className: 'sdt-hm-viewport-warning-body' });
388
+ const viewportWarningWidthValue = h('code', { className: 'sdt-hm-viewport-warning-code' });
389
+ const viewportWarningHeightValue = h('code', { className: 'sdt-hm-viewport-warning-code' });
390
+ const viewportWarningWidthCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' });
391
+ const viewportWarningHeightCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' });
392
+ const viewportWarning = h('div', { className: 'sdt-hm-viewport-warning', role: 'status' },
393
+ viewportWarningTitle,
394
+ viewportWarningBody,
395
+ h('div', { className: 'sdt-hm-viewport-warning-actions' },
396
+ h('span', { className: 'sdt-hm-viewport-warning-action' },
397
+ h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Width'),
398
+ viewportWarningWidthValue,
399
+ viewportWarningWidthCopy,
400
+ ),
401
+ h('span', { className: 'sdt-hm-viewport-warning-action' },
402
+ h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Height'),
403
+ viewportWarningHeightValue,
404
+ viewportWarningHeightCopy,
405
+ ),
406
+ ),
407
+ );
408
+ const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide');
409
+ const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', 'data-sdt-tip': 'Expand clickmap options' });
410
+ const closeButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Close clickmap', 'data-sdt-tip': 'Close clickmap' });
411
+ const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0');
412
+ const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0');
413
+
414
+ function readStoredFilters(): ClickmapFilters {
415
+ try {
416
+ const raw = sessionStorage.getItem(CLICKMAP_FILTERS_STORAGE_KEY);
417
+ if (raw == null) return { ...CLICKMAP_DEFAULT_FILTERS };
418
+ const parsed: unknown = JSON.parse(raw);
419
+ if (parsed == null || typeof parsed !== 'object') return { ...CLICKMAP_DEFAULT_FILTERS };
420
+ const obj = parsed as Record<string, unknown>;
421
+ return {
422
+ range: isClickmapRangeKey(obj.range) ? obj.range : CLICKMAP_DEFAULT_FILTERS.range,
423
+ device: isClickmapDeviceKey(obj.device) ? obj.device : CLICKMAP_DEFAULT_FILTERS.device,
424
+ urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : CLICKMAP_DEFAULT_FILTERS.urlPattern,
425
+ elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch,
426
+ showDead: typeof obj.showDead === 'boolean' ? obj.showDead : CLICKMAP_DEFAULT_FILTERS.showDead,
427
+ };
428
+ } catch {
429
+ return { ...CLICKMAP_DEFAULT_FILTERS };
430
+ }
431
+ }
432
+ function persistFilters(next: ClickmapFilters) {
433
+ try {
434
+ sessionStorage.setItem(CLICKMAP_FILTERS_STORAGE_KEY, JSON.stringify(next));
435
+ } catch {
436
+ // ignore storage errors
437
+ }
438
+ }
439
+
440
+ let currentPath = window.location.pathname;
441
+ let serverClickmap: ServerClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
442
+ let loadingServerClickmap = false;
443
+ let serverClickmapError: string | null = null;
444
+ let serverClickmapRequestId = 0;
445
+ let overlayVisible = true;
446
+ let expanded = false;
447
+ let renderFrame = 0;
448
+ let overlayMode: 'hidden' | 'elements' = 'hidden';
449
+ let highlightedGroupSelector: string | null = null;
450
+ let highlightRenderedSelector: string | null = null;
451
+ let highlightSettleTimer: number | null = null;
452
+ // Hovering a count marker tints its outline. The marker button is the only
453
+ // pointer-interactive part of the overlay (outlines are pointer-events:none
454
+ // so the page stays usable), so it owns the hover.
455
+ let hoveredGroupSelector: string | null = null;
456
+ const mutedGroupSelectors = new Set<string>();
457
+ // Datagrid-style row selection, keyed by the same selector ids as muting but
458
+ // independent of it: it drives the page highlight and scopes the list
459
+ // header's bulk show/hide actions. The anchor remembers the last plainly
460
+ // clicked row so shift+click can extend a contiguous range in list order.
461
+ const selectedGroupSelectors = new Set<string>();
462
+ let selectionAnchorSelector: string | null = null;
463
+ // Snapshot of the groups from the last render, in list order. Range
464
+ // selection and the header's bulk actions operate on this.
465
+ let latestGroups: ClickmapClickGroup[] = [];
466
+ const groupOverlayElements = new Map<string, ClickmapGroupOverlayElement>();
467
+ const listRowElements = new Map<string, ClickmapListRowElement>();
468
+
469
+ function resetCopyButton(button: HTMLElement, label: string) {
470
+ button.textContent = label;
471
+ }
472
+
473
+ function copyClickmapViewportValue(button: HTMLElement, value: string, label: string) {
474
+ runAsynchronously(async () => {
475
+ try {
476
+ await navigator.clipboard.writeText(value);
477
+ button.textContent = 'Copied';
478
+ window.setTimeout(() => resetCopyButton(button, label), 1200);
479
+ } catch {
480
+ button.textContent = 'Copy failed';
481
+ window.setTimeout(() => resetCopyButton(button, label), 1600);
482
+ }
483
+ });
484
+ }
485
+
486
+ // DOM-index cache for fast element-chain inference.
487
+ const domIndex = new Map<string, Element[]>();
488
+ let domIndexDirty = true;
489
+ let domIndexDebounce = 0;
490
+ function rebuildDomIndex() {
491
+ domIndex.clear();
492
+ trimTargetCache = new WeakMap();
493
+ const all = document.querySelectorAll('*');
494
+ for (const el of all) {
495
+ if (!isElementVisibleForClickmap(el)) continue;
496
+ const tag = el.tagName.toLowerCase();
497
+ const bucket = domIndex.get(tag) ?? [];
498
+ bucket.push(el);
499
+ domIndex.set(tag, bucket);
500
+ }
501
+ domIndexDirty = false;
502
+ }
503
+
504
+ // Attribute clicks to the logical control, not the fragment the browser
505
+ // reported (PostHog's trimElement). A click on a <span> or <svg> inside a
506
+ // button records a span/svg-leaf chain, so the matched element walks up to
507
+ // the nearest clickable ancestor: a real control (semantic selector) or the
508
+ // element where cursor:pointer begins (computed pointer while the parent's
509
+ // isn't — catches div-as-button components with zero hardcoded tags). No
510
+ // hit within the cap returns the element unchanged. Resolution happens here
511
+ // at render time, never at capture: stored chains stay raw, so these rules
512
+ // can evolve and historical clicks regroup for free.
513
+ const CLICKMAP_TRIM_TARGET_SELECTOR = 'a[href], button, input, select, textarea, summary, label, [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="tab"], [role="checkbox"], [role="radio"], [role="switch"], [role="option"], [contenteditable="true"]';
514
+ const CLICKMAP_TRIM_MAX_HOPS = 10;
515
+ // getComputedStyle on every hop is too hot for the per-render group loop;
516
+ // resolutions are cached per matched element and dropped together with the
517
+ // dom index (same trigger: the page's DOM changed).
518
+ let trimTargetCache = new WeakMap<Element, { target: Element, key: string }>();
519
+ function resolveClickTarget(start: Element): { target: Element, key: string } {
520
+ const cached = trimTargetCache.get(start);
521
+ if (cached != null) return cached;
522
+ let target = start;
523
+ let current: Element | null = start;
524
+ for (let hops = 0; current != null && current !== document.body && current !== document.documentElement && hops < CLICKMAP_TRIM_MAX_HOPS; hops++) {
525
+ if (current.matches(CLICKMAP_TRIM_TARGET_SELECTOR)) {
526
+ target = current;
527
+ break;
528
+ }
529
+ const parent: Element | null = current.parentElement;
530
+ if (window.getComputedStyle(current).cursor === 'pointer' && (parent == null || window.getComputedStyle(parent).cursor !== 'pointer')) {
531
+ target = current;
532
+ break;
533
+ }
534
+ current = parent;
535
+ }
536
+ const resolved = { target, key: buildElementsChain(target) };
537
+ trimTargetCache.set(start, resolved);
538
+ return resolved;
539
+ }
540
+ function ensureDomIndex() {
541
+ if (domIndexDirty) rebuildDomIndex();
542
+ }
543
+ function invalidateDomIndex() {
544
+ domIndexDirty = true;
545
+ }
546
+ function scheduleDomIndexInvalidation() {
547
+ if (domIndexDebounce !== 0) {
548
+ window.clearTimeout(domIndexDebounce);
549
+ }
550
+ domIndexDebounce = window.setTimeout(() => {
551
+ domIndexDebounce = 0;
552
+ invalidateDomIndex();
553
+ scheduleRender();
554
+ }, CLICKMAP_DOM_INDEX_DEBOUNCE_MS);
555
+ }
556
+
557
+ function isElementChainCandidateUnique(matches: Element[]): Element | null {
558
+ const visible = matches.filter(isElementVisibleForClickmap);
559
+ return visible.length === 1 ? visible[0] : null;
560
+ }
561
+
562
+ function queryUniqueBySelector(selector: string): Element | null {
563
+ try {
564
+ const all = Array.from(document.querySelectorAll(selector));
565
+ return isElementChainCandidateUnique(all);
566
+ } catch {
567
+ return null;
568
+ }
569
+ }
570
+
571
+ function elementMatchesSegment(element: Element, segment: ElementsChainSegment, useClasses: boolean): boolean {
572
+ if (element.tagName.toLowerCase() !== segment.tag) return false;
573
+ if (useClasses) {
574
+ for (const cls of segment.classes) {
575
+ if (!element.classList.contains(cls)) return false;
576
+ }
577
+ }
578
+ return true;
579
+ }
580
+
581
+ function ancestorMatchesChain(leaf: Element, chain: ElementsChainSegment[], useClasses: boolean, useNthOfType: boolean, useNthChild: boolean): boolean {
582
+ let cursor: Element | null = leaf;
583
+ for (let i = 0; i < chain.length; i++) {
584
+ if (cursor == null) return false;
585
+ const segment = chain[i];
586
+ if (!elementMatchesSegment(cursor, segment, useClasses)) return false;
587
+ if (useNthOfType && segment.nthOfType != null) {
588
+ if (computeNthOfType(cursor) !== segment.nthOfType) return false;
589
+ }
590
+ if (useNthChild && segment.nthChild != null) {
591
+ if (computeNthChild(cursor) !== segment.nthChild) return false;
592
+ }
593
+ cursor = cursor.parentElement;
594
+ }
595
+ return true;
596
+ }
597
+
598
+ function computeNthOfType(el: Element): number {
599
+ let n = 1;
600
+ let sib = el.previousElementSibling;
601
+ const tag = el.tagName;
602
+ while (sib != null) {
603
+ if (sib.tagName === tag) n += 1;
604
+ sib = sib.previousElementSibling;
605
+ }
606
+ return n;
607
+ }
608
+
609
+ function computeNthChild(el: Element): number {
610
+ let n = 1;
611
+ let sib = el.previousElementSibling;
612
+ while (sib != null) {
613
+ n += 1;
614
+ sib = sib.previousElementSibling;
615
+ }
616
+ return n;
617
+ }
618
+
619
+ function inferElementFromChain(chain: ElementsChainSegment[]): Element | null {
620
+ if (chain.length === 0) return null;
621
+ const leaf = chain[0];
622
+
623
+ // 1. Stable attribute selectors on leaf (no tag).
624
+ const stableAttrOrder: Array<{ attr: string, prefix?: string }> = [
625
+ { attr: 'data-hexclave-id' },
626
+ { attr: 'data-testid' },
627
+ { attr: 'data-test-id' },
628
+ { attr: 'name' },
629
+ ];
630
+ for (const { attr } of stableAttrOrder) {
631
+ const value = readChainAttr(leaf, attr);
632
+ if (value === '') continue;
633
+ const sel = `[${attr}="${cssEscapeAttrValue(value)}"]`;
634
+ const match: Element | null = queryUniqueBySelector(sel);
635
+ if (match) return match;
636
+ }
637
+ const id = readChainAttr(leaf, 'id');
638
+ if (id !== '') {
639
+ const match: Element | null = queryUniqueBySelector(`#${cssEscapeIdent(id)}`);
640
+ if (match) return match;
641
+ }
642
+ if (leaf.href != null && leaf.href !== '' && leaf.tag === 'a') {
643
+ const match: Element | null = queryUniqueBySelector(`a[href="${cssEscapeAttrValue(leaf.href)}"]`);
644
+ if (match) return match;
645
+ }
646
+
647
+ // 2. Tag + stable attribute on the leaf.
648
+ const otherStableAttrs = ['aria-label', 'role', 'placeholder', 'title', 'type'];
649
+ for (const attr of otherStableAttrs) {
650
+ const value = readChainAttr(leaf, attr);
651
+ if (value === '') continue;
652
+ const sel = `${leaf.tag}[${attr}="${cssEscapeAttrValue(value)}"]`;
653
+ const match: Element | null = queryUniqueBySelector(sel);
654
+ if (match) return match;
655
+ }
656
+
657
+ // 3, 4, 5: walk the DOM index by leaf tag, score the chain.
658
+ ensureDomIndex();
659
+ const candidates = domIndex.get(leaf.tag) ?? [];
660
+ if (candidates.length === 0) return null;
661
+
662
+ // Variant 3: tag.classes across the chain, no nth.
663
+ const v3: Element[] = [];
664
+ for (const candidate of candidates) {
665
+ if (ancestorMatchesChain(candidate, chain, true, false, false)) v3.push(candidate);
666
+ }
667
+ const u3 = isElementChainCandidateUnique(v3);
668
+ if (u3 != null) return u3;
669
+
670
+ // Variant 4: tag.classes + nth-of-type.
671
+ const v4: Element[] = [];
672
+ for (const candidate of candidates) {
673
+ if (ancestorMatchesChain(candidate, chain, true, true, false)) v4.push(candidate);
674
+ }
675
+ const u4 = isElementChainCandidateUnique(v4);
676
+ if (u4 != null) return u4;
677
+
678
+ // Variant 5: tag.classes + nth-child.
679
+ const v5: Element[] = [];
680
+ for (const candidate of candidates) {
681
+ if (ancestorMatchesChain(candidate, chain, true, true, true)) v5.push(candidate);
682
+ }
683
+ const u5 = isElementChainCandidateUnique(v5);
684
+ if (u5 != null) return u5;
685
+
686
+ return null;
687
+ }
688
+
689
+ setHtml(closeButton, '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>');
690
+ const chevronUpSvg = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>';
691
+ const chevronDownSvg = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>';
692
+ const clicksIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4.1 12 6"/><path d="m5.1 8-2.9-.8"/><path d="m6 12-1.9 2"/><path d="M7.2 2.2 8 5.1"/><path d="M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z"/></svg>';
693
+ const elementsIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>';
694
+ const eyeIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>';
695
+ const eyeOffIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"/><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"/><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"/><path d="m2 2 20 20"/></svg>';
696
+ // Only swap the chevron when the expanded state actually changes. render()
697
+ // runs constantly (route poll, scroll, body mutations), and rewriting the
698
+ // button's SVG on every pass detaches the element under the user's pointer
699
+ // mid-press, which makes the browser drop the click entirely — the button
700
+ // appeared to have dead spots wherever the icon sat.
701
+ let renderedExpandIcon = '';
702
+ function syncExpandIcon() {
703
+ const icon = expanded ? chevronDownSvg : chevronUpSvg;
704
+ if (renderedExpandIcon === icon) return;
705
+ renderedExpandIcon = icon;
706
+ setHtml(expandButton, icon);
707
+ }
708
+ syncExpandIcon();
709
+ resetCopyButton(viewportWarningWidthCopy, 'Copy width');
710
+ resetCopyButton(viewportWarningHeightCopy, 'Copy height');
711
+ viewportWarningWidthCopy.addEventListener('click', () => {
712
+ copyClickmapViewportValue(viewportWarningWidthCopy, viewportWarningWidthValue.textContent, 'Copy width');
713
+ });
714
+ viewportWarningHeightCopy.addEventListener('click', () => {
715
+ copyClickmapViewportValue(viewportWarningHeightCopy, viewportWarningHeightValue.textContent, 'Copy height');
716
+ });
717
+
718
+ const stats = h('div', { className: 'sdt-hm-stats' },
719
+ h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount),
720
+ h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Elements'), selectorCount),
721
+ h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Viewport'), viewportValue),
722
+ );
723
+
724
+ let filters: ClickmapFilters = readStoredFilters();
725
+ let filterReloadDebounce = 0;
726
+ // When the user hasn't typed a custom pattern, the URL pattern field mirrors
727
+ // the current route with id-like segments auto-wildcarded (`/teams/*/settings`)
728
+ // so the clickmap aggregates across all entities. A stored non-empty pattern
729
+ // means the user took manual control, so we leave it alone.
730
+ let urlPatternUserEdited = filters.urlPattern.trim() !== '';
731
+
732
+ function getEffectiveUrlPattern(): string {
733
+ if (urlPatternUserEdited) return filters.urlPattern.trim();
734
+ return wildcardizePathname(window.location.pathname);
735
+ }
736
+ // Reflect the current route into the field while in auto mode. No-op once the
737
+ // user has typed their own pattern.
738
+ function syncAutoUrlPattern() {
739
+ if (urlPatternUserEdited) return;
740
+ const auto = wildcardizePathname(window.location.pathname);
741
+ if (urlPatternInput.value !== auto) {
742
+ urlPatternInput.value = auto;
743
+ }
744
+ }
745
+
746
+ function makeFilterSelect(options: Array<[string, string]>, value: string): HTMLSelectElement {
747
+ const el = h('select', { className: 'sdt-hm-filter-input' }) as HTMLSelectElement;
748
+ for (const [optValue, label] of options) {
749
+ const opt = h('option', { value: optValue }, label) as HTMLOptionElement;
750
+ el.appendChild(opt);
751
+ }
752
+ el.value = value;
753
+ return el;
754
+ }
755
+
756
+ const rangeSelect = makeFilterSelect([
757
+ ['24h', 'Last 24h'],
758
+ ['7d', 'Last 7 days'],
759
+ ['30d', 'Last 30 days'],
760
+ ], filters.range);
761
+ // Viewport filter as a segmented switcher: equal-weight options with a single
762
+ // pill that slides to the active mode, instead of a hidden-until-opened native
763
+ // <select>. The thumb is an absolutely-positioned element measured from the
764
+ // active button so labels of different widths still track precisely.
765
+ const deviceOptions: [ClickmapDeviceKey, string][] = [
766
+ ['all', 'All'],
767
+ ['mobile', 'Mobile'],
768
+ ['tablet', 'Tablet'],
769
+ ['laptop', 'Laptop'],
770
+ ['desktop', 'Desktop'],
771
+ ['widescreen', 'Wide'],
772
+ ['tv', 'TV'],
773
+ ];
774
+ const deviceThumb = h('span', { className: 'sdt-hm-seg-thumb', 'aria-hidden': 'true' });
775
+ const deviceSwitcher = h('div', {
776
+ className: 'sdt-hm-seg',
777
+ role: 'radiogroup',
778
+ 'aria-label': 'Viewport',
779
+ }, deviceThumb);
780
+ const deviceButtons = new Map<ClickmapDeviceKey, HTMLButtonElement>();
781
+ // Skip the animated slide the very first time the thumb is placed (panel open),
782
+ // so it appears already parked on the active option rather than sweeping in.
783
+ let deviceThumbPlaced = false;
784
+ function positionDeviceThumb() {
785
+ const active = deviceButtons.get(filters.device);
786
+ // While the panel is collapsed the switcher isn't laid out (offsetWidth 0);
787
+ // defer until it has real geometry so the thumb lands in the right place.
788
+ if (active == null || active.offsetWidth === 0) return;
789
+ if (!deviceThumbPlaced) {
790
+ deviceThumb.style.transition = 'none';
791
+ }
792
+ deviceThumb.style.transform = `translateX(${active.offsetLeft}px)`;
793
+ deviceThumb.style.width = `${active.offsetWidth}px`;
794
+ if (!deviceThumbPlaced) {
795
+ // Force a reflow so the no-transition placement commits before we hand
796
+ // animation back, otherwise the first real move wouldn't tween.
797
+ void deviceThumb.offsetWidth;
798
+ deviceThumb.style.transition = '';
799
+ deviceThumbPlaced = true;
800
+ }
801
+ }
802
+ for (const [key, label] of deviceOptions) {
803
+ const btn = h('button', {
804
+ className: 'sdt-hm-seg-btn',
805
+ type: 'button',
806
+ role: 'radio',
807
+ }, label) as HTMLButtonElement;
808
+ btn.setAttribute('aria-checked', String(key === filters.device));
809
+ btn.addEventListener('click', () => {
810
+ if (filters.device === key) return;
811
+ updateFilters({ device: key });
812
+ for (const [k, b] of deviceButtons) {
813
+ b.setAttribute('aria-checked', String(k === key));
814
+ }
815
+ positionDeviceThumb();
816
+ });
817
+ deviceButtons.set(key, btn);
818
+ deviceSwitcher.appendChild(btn);
819
+ }
820
+ const urlPatternInput = h('input', {
821
+ className: 'sdt-hm-filter-input',
822
+ type: 'text',
823
+ placeholder: '/products/*',
824
+ spellcheck: 'false',
825
+ autocomplete: 'off',
826
+ autocapitalize: 'off',
827
+ }) as HTMLInputElement;
828
+ urlPatternInput.value = getEffectiveUrlPattern();
829
+ // Reverts the field back to the auto-wildcarded current route. Shown whenever
830
+ // the field holds a custom pattern (i.e. reverting would change something).
831
+ const urlPatternReset = h('button', {
832
+ className: 'sdt-hm-filter-reset',
833
+ type: 'button',
834
+ 'aria-label': 'Revert the URL pattern to the current page',
835
+ 'data-sdt-tip': 'Revert to the current page',
836
+ }) as HTMLButtonElement;
837
+ setHtml(urlPatternReset, '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>');
838
+
839
+ // The revert button appears the moment the field diverges from the
840
+ // auto-wildcarded current route. Toggled directly from the input handlers (on
841
+ // top of render()) so it shows up immediately on edit, independent of the
842
+ // panel's expanded state and of whether the pattern still covers this page.
843
+ function syncUrlPatternResetVisibility() {
844
+ const auto = wildcardizePathname(window.location.pathname);
845
+ const showReset = urlPatternUserEdited && filters.urlPattern.trim() !== auto;
846
+ urlPatternReset.classList.toggle('sdt-hm-filter-reset-visible', showReset);
847
+ }
848
+
849
+ // Info button + popover explaining the URL pattern syntax. The backend
850
+ // translates `*` into a SQL LIKE `%`, so `*` is the only wildcard — every
851
+ // other character is matched literally against the page's pathname.
852
+ const urlPatternInfo = h('button', {
853
+ className: 'sdt-hm-filter-info',
854
+ type: 'button',
855
+ 'aria-label': 'URL pattern help',
856
+ 'aria-expanded': 'false',
857
+ 'data-sdt-tip': 'How URL patterns work',
858
+ }) as HTMLButtonElement;
859
+ setHtml(urlPatternInfo, '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>');
860
+
861
+ function makeUrlHelpRow(pattern: string, description: string): HTMLElement {
862
+ return h('div', { className: 'sdt-hm-url-help-row' },
863
+ h('code', { className: 'sdt-hm-url-help-code' }, pattern),
864
+ h('span', { className: 'sdt-hm-url-help-desc' }, description),
865
+ );
866
+ }
867
+ function makeCode(text: string): HTMLElement {
868
+ return h('code', { className: 'sdt-hm-url-help-code' }, text);
869
+ }
870
+ const urlHelpTitle = h('div', { className: 'sdt-hm-url-help-title' });
871
+ const urlHelpBody = h('div', { className: 'sdt-hm-url-help-body' });
872
+ const urlHelpRows = h('div', { className: 'sdt-hm-url-help-rows' });
873
+ function renderUrlHelp() {
874
+ urlHelpTitle.textContent = 'URL pattern · glob';
875
+ urlHelpBody.replaceChildren(
876
+ 'Limits the clickmap to pages whose path matches. Matched against the pathname only — no domain, hash, or query string. ',
877
+ makeCode('*'),
878
+ ' is the only wildcard and stands in for any characters (including ',
879
+ makeCode('/'),
880
+ '). Everything else is matched literally.',
881
+ );
882
+ urlHelpRows.replaceChildren(
883
+ makeUrlHelpRow('/pricing', 'That exact page'),
884
+ makeUrlHelpRow('/products/*', 'Any path under /products/'),
885
+ makeUrlHelpRow('/teams/*/members', 'A wildcard segment in the middle'),
886
+ makeUrlHelpRow('*/settings', 'Any path ending in /settings'),
887
+ makeUrlHelpRow('*', 'Every page'),
888
+ makeUrlHelpRow('(empty)', 'Auto-tracks the page you are viewing'),
889
+ );
890
+ }
891
+ const urlPatternHelp = h('div', { className: 'sdt-hm-url-help', role: 'dialog', 'aria-label': 'URL pattern help' },
892
+ urlHelpTitle,
893
+ urlHelpBody,
894
+ urlHelpRows,
895
+ );
896
+
897
+ let urlHelpOpen = false;
898
+ function setUrlHelpOpen(open: boolean) {
899
+ urlHelpOpen = open;
900
+ urlPatternHelp.classList.toggle('sdt-hm-url-help-open', open);
901
+ urlPatternInfo.setAttribute('aria-expanded', String(open));
902
+ }
903
+ urlPatternInfo.addEventListener('click', (event) => {
904
+ event.stopPropagation();
905
+ setUrlHelpOpen(!urlHelpOpen);
906
+ });
907
+ urlPatternHelp.addEventListener('click', (event) => {
908
+ event.stopPropagation();
909
+ });
910
+ renderUrlHelp();
911
+
912
+ const elementSearchInput = h('input', {
913
+ className: 'sdt-hm-filter-input',
914
+ type: 'text',
915
+ placeholder: 'Search element text or tag',
916
+ 'aria-label': 'Search element text or tag',
917
+ spellcheck: 'false',
918
+ autocomplete: 'off',
919
+ autocapitalize: 'off',
920
+ }) as HTMLInputElement;
921
+ elementSearchInput.value = filters.elementSearch;
922
+
923
+ function wrapFilterField(label: string, input: HTMLElement, action?: HTMLElement): HTMLElement {
924
+ const labelRow = h('span', { className: 'sdt-hm-filter-label-row' },
925
+ h('span', { className: 'sdt-hm-filter-label' }, label),
926
+ );
927
+ if (action != null) {
928
+ labelRow.appendChild(action);
929
+ }
930
+ return h('label', { className: 'sdt-hm-filter-field' },
931
+ labelRow,
932
+ input,
933
+ );
934
+ }
935
+
936
+ // The range and URL-pattern controls live in the always-visible toolbar so
937
+ // they're reachable whether or not the panel is expanded (the previous pill
938
+ // only showed a static title + a single clicks badge and felt empty). The
939
+ // remaining filters stay in the expanded body.
940
+ const clicksIcon = h('span', { className: 'sdt-hm-toolbar-metric-icon' });
941
+ const elementsIcon = h('span', { className: 'sdt-hm-toolbar-metric-icon' });
942
+ setHtml(clicksIcon, clicksIconSvg);
943
+ setHtml(elementsIcon, elementsIconSvg);
944
+
945
+ // "Show dead clicks" toggle. Two synced controls — a compact icon toggle in
946
+ // the always-visible toolbar and a labeled button in the expanded actions
947
+ // row — flip the same filters.showDead flag. Off (default) the overlay
948
+ // pretends dead clicks don't exist: counts are alive-only and all-dead
949
+ // elements are hidden; on, totals include dead clicks and the per-element
950
+ // "% dead" chips appear. Icons are written once at creation (see
951
+ // syncExpandIcon for the dead-spot hazard of rewriting SVG on every
952
+ // render); state sync only toggles classes/attrs.
953
+ const showDeadIconSvg = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 10h.01"/><path d="M15 10h.01"/><path d="M12 2a8 8 0 0 0-8 8v12l3-3 2.5 2.5L12 19l2.5 2.5L17 19l3 3V10a8 8 0 0 0-8-8z"/></svg>';
954
+ const showDeadMiniToggle = h('button', {
955
+ className: 'sdt-hm-icon-btn sdt-hm-dead-toggle',
956
+ type: 'button',
957
+ 'aria-pressed': 'false',
958
+ 'aria-label': 'Show dead clicks',
959
+ 'data-sdt-tip': 'Show dead clicks',
960
+ }) as HTMLButtonElement;
961
+ setHtml(showDeadMiniToggle, showDeadIconSvg);
962
+ const showDeadToggleIcon = h('span', { className: 'sdt-hm-dead-toggle-icon' });
963
+ setHtml(showDeadToggleIcon, showDeadIconSvg);
964
+ const showDeadToggle = h('button', {
965
+ className: 'sdt-hm-btn sdt-hm-dead-toggle',
966
+ type: 'button',
967
+ 'aria-pressed': 'false',
968
+ 'data-sdt-tip': 'Include clicks that had no effect',
969
+ }, showDeadToggleIcon, 'Dead clicks') as HTMLButtonElement;
970
+
971
+ function syncShowDeadToggles() {
972
+ for (const button of [showDeadMiniToggle, showDeadToggle]) {
973
+ button.setAttribute('aria-pressed', String(filters.showDead));
974
+ button.classList.toggle('sdt-hm-dead-toggle-active', filters.showDead);
975
+ }
976
+ }
977
+ function setShowDead(next: boolean) {
978
+ if (filters.showDead === next) return;
979
+ filters = { ...filters, showDead: next };
980
+ persistFilters(filters);
981
+ syncShowDeadToggles();
982
+ scheduleRender();
983
+ }
984
+ showDeadMiniToggle.addEventListener('click', () => setShowDead(!filters.showDead));
985
+ showDeadToggle.addEventListener('click', () => setShowDead(!filters.showDead));
986
+ syncShowDeadToggles();
987
+
988
+ // Overlay visibility toggle in the always-visible toolbar, twinned with the
989
+ // expanded panel's labeled button (both flip overlayVisible). Eye = shown,
990
+ // eye-off = hidden; the icon is only rewritten on state flips (see
991
+ // syncExpandIcon for the dead-spot hazard of rewriting SVG every render).
992
+ const overlayMiniToggle = h('button', {
993
+ className: 'sdt-hm-icon-btn',
994
+ type: 'button',
995
+ 'aria-pressed': 'false',
996
+ 'aria-label': 'Hide overlay',
997
+ 'data-sdt-tip': 'Hide overlay',
998
+ }) as HTMLButtonElement;
999
+ let renderedOverlayMiniIcon = '';
1000
+ function syncOverlayMiniToggle() {
1001
+ overlayMiniToggle.setAttribute('aria-pressed', String(!overlayVisible));
1002
+ const label = overlayVisible ? 'Hide overlay' : 'Show overlay';
1003
+ overlayMiniToggle.setAttribute('aria-label', label);
1004
+ overlayMiniToggle.setAttribute('data-sdt-tip', label);
1005
+ overlayMiniToggle.classList.toggle('sdt-hm-overlay-mini-off', !overlayVisible);
1006
+ const icon = overlayVisible ? eyeIconSvg : eyeOffIconSvg;
1007
+ if (renderedOverlayMiniIcon !== icon) {
1008
+ renderedOverlayMiniIcon = icon;
1009
+ setHtml(overlayMiniToggle, icon);
1010
+ }
1011
+ }
1012
+ overlayMiniToggle.addEventListener('click', () => {
1013
+ overlayVisible = !overlayVisible;
1014
+ render();
1015
+ });
1016
+ syncOverlayMiniToggle();
1017
+ const toolbar = h('div', { className: 'sdt-hm-toolbar' },
1018
+ closeButton,
1019
+ h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'),
1020
+ h('div', { className: 'sdt-hm-toolbar-filters' },
1021
+ rangeSelect,
1022
+ h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternInfo, urlPatternHelp),
1023
+ ),
1024
+ h('div', { className: 'sdt-hm-toolbar-metrics' },
1025
+ h('span', { className: 'sdt-hm-toolbar-metric', 'data-sdt-tip': 'Aggregate clicks' }, miniClicks, clicksIcon),
1026
+ h('span', { className: 'sdt-hm-toolbar-metric', 'data-sdt-tip': 'Mapped elements' }, miniElements, elementsIcon),
1027
+ ),
1028
+ showDeadMiniToggle,
1029
+ overlayMiniToggle,
1030
+ expandButton,
1031
+ );
1032
+
1033
+ // The mismatch warning is anchored directly under the viewport switcher it
1034
+ // describes (not in the scrollable body, where it sat below the status line
1035
+ // and could scroll out of view while the filter that triggered it stayed
1036
+ // visible). It can't live inside the field's <label>: a label click
1037
+ // activates its first labelable descendant, so warning text clicks would
1038
+ // press the first segment button.
1039
+ const filterRow = h('div', { className: 'sdt-hm-filters' },
1040
+ wrapFilterField('Viewport', deviceSwitcher),
1041
+ viewportWarning,
1042
+ );
1043
+
1044
+ function scheduleFilterReload() {
1045
+ if (filterReloadDebounce !== 0) {
1046
+ window.clearTimeout(filterReloadDebounce);
1047
+ }
1048
+ filterReloadDebounce = window.setTimeout(() => {
1049
+ filterReloadDebounce = 0;
1050
+ runAsynchronously(loadServerClickmap());
1051
+ }, 250);
1052
+ }
1053
+
1054
+ function updateFilters(next: Partial<ClickmapFilters>) {
1055
+ filters = { ...filters, ...next };
1056
+ persistFilters(filters);
1057
+ scheduleFilterReload();
1058
+ }
1059
+
1060
+ let elementSearchDebounce = 0;
1061
+ function updateElementSearch(value: string) {
1062
+ filters = { ...filters, elementSearch: value };
1063
+ persistFilters(filters);
1064
+ if (elementSearchDebounce !== 0) {
1065
+ window.clearTimeout(elementSearchDebounce);
1066
+ }
1067
+ elementSearchDebounce = window.setTimeout(() => {
1068
+ elementSearchDebounce = 0;
1069
+ scheduleRender();
1070
+ }, 120);
1071
+ }
1072
+
1073
+ rangeSelect.addEventListener('change', () => {
1074
+ if (isClickmapRangeKey(rangeSelect.value)) updateFilters({ range: rangeSelect.value });
1075
+ });
1076
+ urlPatternInput.addEventListener('input', () => {
1077
+ const value = urlPatternInput.value;
1078
+ // Clearing the field hands control back to auto mode (reflect the route).
1079
+ urlPatternUserEdited = value.trim() !== '';
1080
+ updateFilters({ urlPattern: value });
1081
+ syncUrlPatternResetVisibility();
1082
+ });
1083
+ urlPatternReset.addEventListener('click', () => {
1084
+ // Hand control back to auto mode and reflect the current route immediately,
1085
+ // so the pattern covers the page the overlay is bound to.
1086
+ urlPatternUserEdited = false;
1087
+ urlPatternInput.value = wildcardizePathname(window.location.pathname);
1088
+ updateFilters({ urlPattern: '' });
1089
+ syncUrlPatternResetVisibility();
1090
+ });
1091
+ elementSearchInput.addEventListener('input', () => {
1092
+ updateElementSearch(elementSearchInput.value);
1093
+ });
1094
+
1095
+ // Two regions: a fixed head (viewport filter + stats/actions) and a
1096
+ // scrolling body (status, list header, list). Element search lives in the
1097
+ // sticky list header next to the selection and bulk-visibility controls,
1098
+ // so every list control sits in one row directly above the rows it
1099
+ // operates on. Keeping borders to a single head/body divider avoids the
1100
+ // dense stack of bordered bands that made the expanded panel feel
1101
+ // congested.
1102
+ const actions = h('div', { className: 'sdt-hm-actions' }, stats, showDeadToggle, overlayToggle);
1103
+ const head = h('div', { className: 'sdt-hm-head' },
1104
+ filterRow,
1105
+ actions,
1106
+ );
1107
+ // Datagrid-style list header: master checkbox + selection summary on the
1108
+ // left, bulk visibility actions on the right. Sticky inside the scrolling
1109
+ // body so the bulk actions stay reachable while the list scrolls.
1110
+ const listHeaderCheck = h('button', {
1111
+ className: 'sdt-hm-row-check',
1112
+ type: 'button',
1113
+ role: 'checkbox',
1114
+ 'aria-checked': 'false',
1115
+ 'aria-label': 'Select all elements',
1116
+ }) as HTMLButtonElement;
1117
+ const listHeaderSummary = h('span', { className: 'sdt-hm-list-header-summary' });
1118
+ const listShowButton = h('button', { className: 'sdt-hm-btn sdt-hm-btn-sm', type: 'button' }, 'Show all') as HTMLButtonElement;
1119
+ const listHideButton = h('button', { className: 'sdt-hm-btn sdt-hm-btn-sm', type: 'button' }, 'Hide all') as HTMLButtonElement;
1120
+ const listHeader = h('div', { className: 'sdt-hm-list-header' },
1121
+ listHeaderCheck,
1122
+ listHeaderSummary,
1123
+ elementSearchInput,
1124
+ listShowButton,
1125
+ listHideButton,
1126
+ );
1127
+ listHeaderCheck.addEventListener('click', () => {
1128
+ const allSelected = latestGroups.length > 0 && latestGroups.every((group) => selectedGroupSelectors.has(group.selector));
1129
+ if (allSelected) {
1130
+ clearSelection();
1131
+ return;
1132
+ }
1133
+ selectedGroupSelectors.clear();
1134
+ for (const group of latestGroups) selectedGroupSelectors.add(group.selector);
1135
+ selectionAnchorSelector = null;
1136
+ scheduleRender();
1137
+ });
1138
+ listShowButton.addEventListener('click', () => {
1139
+ for (const group of getBulkActionGroups()) mutedGroupSelectors.delete(group.selector);
1140
+ scheduleRender();
1141
+ });
1142
+ listHideButton.addEventListener('click', () => {
1143
+ for (const group of getBulkActionGroups()) mutedGroupSelectors.add(group.selector);
1144
+ scheduleRender();
1145
+ });
1146
+ const body = h('div', { className: 'sdt-hm-body' }, status, listHeader, list);
1147
+ const details = h('div', { className: 'sdt-hm-details' }, head, body);
1148
+
1149
+ function getGroups(): ClickmapClickGroup[] {
1150
+ const byKey = new Map<string, ClickmapClickGroup>();
1151
+ if (serverClickmap.path !== currentPath) {
1152
+ return [];
1153
+ }
1154
+
1155
+ const searchQuery = filters.elementSearch.trim().toLowerCase();
1156
+ const matchesSearch = (entry: ServerClickmapElement): boolean => {
1157
+ if (searchQuery === '') return true;
1158
+ const haystacks = [entry.elementsText, entry.tagName, entry.href ?? '', entry.elementsChain];
1159
+ return haystacks.some((value) => value.toLowerCase().includes(searchQuery));
1160
+ };
1161
+
1162
+ // Prefer the elements-chain inference path (PostHog-style).
1163
+ if (serverClickmap.elements.length > 0) {
1164
+ ensureDomIndex();
1165
+ for (const elementEntry of serverClickmap.elements) {
1166
+ if (!matchesSearch(elementEntry)) continue;
1167
+ const chain = parseElementsChain(elementEntry.elementsChain);
1168
+ let element = chain.length > 0 ? inferElementFromChain(chain) : null;
1169
+ if (element == null && elementEntry.href != null && elementEntry.href !== '' && elementEntry.tagName.toLowerCase() === 'a') {
1170
+ element = queryUniqueBySelector(`a[href="${cssEscapeAttrValue(elementEntry.href)}"]`);
1171
+ }
1172
+ if (element == null) continue;
1173
+ // Group by the trimmed click target, not the raw chain: span-leaf and
1174
+ // button-leaf rows for the same control land in one group.
1175
+ const { target, key } = resolveClickTarget(element);
1176
+ const existing = byKey.get(key);
1177
+ if (existing != null) {
1178
+ existing.count += elementEntry.clicks;
1179
+ existing.deadCount += elementEntry.deadClicks;
1180
+ continue;
1181
+ }
1182
+ byKey.set(key, {
1183
+ selector: key,
1184
+ label: getReadableElementLabel(target),
1185
+ count: elementEntry.clicks,
1186
+ deadCount: elementEntry.deadClicks,
1187
+ element: target,
1188
+ rect: target.getBoundingClientRect(),
1189
+ });
1190
+ }
1191
+ }
1192
+
1193
+ // Legacy selectors fallback (older backends or unresolved chains).
1194
+ if (byKey.size === 0) {
1195
+ for (const selectorClickmap of serverClickmap.selectors) {
1196
+ if (searchQuery !== '' && !selectorClickmap.selector.toLowerCase().includes(searchQuery)) continue;
1197
+ const element = getElementFromSelector(selectorClickmap.selector);
1198
+ if (element == null) continue;
1199
+ const { target, key } = resolveClickTarget(element);
1200
+ const existing = byKey.get(key);
1201
+ if (existing != null) {
1202
+ existing.count += selectorClickmap.clicks;
1203
+ continue;
1204
+ }
1205
+ byKey.set(key, {
1206
+ selector: key,
1207
+ label: getReadableElementLabel(target),
1208
+ count: selectorClickmap.clicks,
1209
+ // Legacy selector rows have no dead-click aggregation.
1210
+ deadCount: 0,
1211
+ element: target,
1212
+ rect: target.getBoundingClientRect(),
1213
+ });
1214
+ }
1215
+ }
1216
+
1217
+ let groups = Array.from(byKey.values());
1218
+ if (!filters.showDead) {
1219
+ // Dead clicks are hidden by default: an element whose clicks were all
1220
+ // dead has nothing left to display.
1221
+ groups = groups.filter((group) => getGroupDisplayCount(group) > 0);
1222
+ }
1223
+ return groups.sort((a, b) => getGroupDisplayCount(b) - getGroupDisplayCount(a) || stringCompare(a.selector, b.selector));
1224
+ }
1225
+
1226
+ // Displayed numbers follow the toggle: alive clicks only by default, full
1227
+ // totals (alive + dead) when dead clicks are shown. The clamp guards
1228
+ // against sampling-scaled dead counts rounding above the scaled total.
1229
+ function getGroupDisplayCount(group: ClickmapClickGroup): number {
1230
+ return filters.showDead ? group.count : Math.max(0, group.count - group.deadCount);
1231
+ }
1232
+
1233
+ function getDeadClickPercentage(group: ClickmapClickGroup): number {
1234
+ if (group.count <= 0) return 100;
1235
+ return Math.min(100, Math.round((group.deadCount / group.count) * 100));
1236
+ }
1237
+
1238
+ function scheduleRender() {
1239
+ cancelAnimationFrame(renderFrame);
1240
+ renderFrame = requestAnimationFrame(render);
1241
+ }
1242
+
1243
+ function clearClickmapOverlayElements() {
1244
+ groupOverlayElements.clear();
1245
+ overlayRoot.replaceChildren(overlayHighlight);
1246
+ overlayHighlight.classList.remove('sdt-hm-highlight-visible', 'sdt-hm-highlight-animating');
1247
+ highlightRenderedSelector = null;
1248
+ }
1249
+
1250
+ function clearClickmapListElements() {
1251
+ listRowElements.clear();
1252
+ list.replaceChildren();
1253
+ }
1254
+
1255
+ function getClickmapViewportSize(): { width: number, height: number } {
1256
+ const visualViewport = window.visualViewport;
1257
+ if (visualViewport != null) {
1258
+ return { width: visualViewport.width, height: visualViewport.height };
1259
+ }
1260
+ return { width: window.innerWidth, height: window.innerHeight };
1261
+ }
1262
+
1263
+ function shouldShowElements(): boolean {
1264
+ return overlayVisible;
1265
+ }
1266
+
1267
+ function toggleMutedGroup(selector: string) {
1268
+ if (mutedGroupSelectors.has(selector)) {
1269
+ mutedGroupSelectors.delete(selector);
1270
+ } else {
1271
+ mutedGroupSelectors.add(selector);
1272
+ }
1273
+ scheduleRender();
1274
+ }
1275
+
1276
+ function clearSelection() {
1277
+ if (selectedGroupSelectors.size === 0 && highlightedGroupSelector == null) return;
1278
+ selectedGroupSelectors.clear();
1279
+ selectionAnchorSelector = null;
1280
+ highlightedGroupSelector = null;
1281
+ scheduleRender();
1282
+ }
1283
+
1284
+ function toggleSelectedGroup(selector: string) {
1285
+ if (selectedGroupSelectors.has(selector)) {
1286
+ selectedGroupSelectors.delete(selector);
1287
+ if (highlightedGroupSelector === selector) highlightedGroupSelector = null;
1288
+ } else {
1289
+ selectedGroupSelectors.add(selector);
1290
+ highlightedGroupSelector = selector;
1291
+ }
1292
+ selectionAnchorSelector = selector;
1293
+ scheduleRender();
1294
+ }
1295
+
1296
+ // Datagrid click semantics on list rows: plain click selects just that row
1297
+ // (clicking the only selected row clears the selection again), ctrl/cmd
1298
+ // toggles membership, shift extends a contiguous range from the anchor in
1299
+ // list order. The most recently clicked row becomes the highlighted lead,
1300
+ // which drives the page's glide-highlight box.
1301
+ function selectGroupFromEvent(group: ClickmapClickGroup, event: { shiftKey: boolean, ctrlKey: boolean, metaKey: boolean }) {
1302
+ const toggle = event.ctrlKey || event.metaKey;
1303
+ if (event.shiftKey && selectionAnchorSelector != null) {
1304
+ const order = latestGroups.map((candidate) => candidate.selector);
1305
+ const anchorIndex = order.indexOf(selectionAnchorSelector);
1306
+ const targetIndex = order.indexOf(group.selector);
1307
+ if (anchorIndex !== -1 && targetIndex !== -1) {
1308
+ if (!toggle) selectedGroupSelectors.clear();
1309
+ const [start, end] = anchorIndex <= targetIndex ? [anchorIndex, targetIndex] : [targetIndex, anchorIndex];
1310
+ for (const selector of order.slice(start, end + 1)) {
1311
+ selectedGroupSelectors.add(selector);
1312
+ }
1313
+ highlightedGroupSelector = group.selector;
1314
+ scheduleRender();
1315
+ return;
1316
+ }
1317
+ }
1318
+ if (toggle) {
1319
+ toggleSelectedGroup(group.selector);
1320
+ return;
1321
+ }
1322
+ selectionAnchorSelector = group.selector;
1323
+ if (selectedGroupSelectors.size === 1 && selectedGroupSelectors.has(group.selector)) {
1324
+ selectedGroupSelectors.delete(group.selector);
1325
+ highlightedGroupSelector = null;
1326
+ } else {
1327
+ selectedGroupSelectors.clear();
1328
+ selectedGroupSelectors.add(group.selector);
1329
+ highlightedGroupSelector = group.selector;
1330
+ }
1331
+ scheduleRender();
1332
+ }
1333
+
1334
+ // Targets for the header's bulk show/hide: the selection when there is one,
1335
+ // otherwise every listed element.
1336
+ function getBulkActionGroups(): ClickmapClickGroup[] {
1337
+ if (selectedGroupSelectors.size > 0) {
1338
+ return latestGroups.filter((group) => selectedGroupSelectors.has(group.selector));
1339
+ }
1340
+ return latestGroups;
1341
+ }
1342
+
1343
+ function setHoveredGroup(selector: string | null) {
1344
+ if (hoveredGroupSelector === selector) return;
1345
+ hoveredGroupSelector = selector;
1346
+ scheduleRender();
1347
+ }
1348
+
1349
+ const checkIconSvg = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
1350
+ const dashIconSvg = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/></svg>';
1351
+
1352
+ function createListRowElement(selector: string): ClickmapListRowElement {
1353
+ const count = h('span', { className: 'sdt-hm-row-count' });
1354
+ const label = h('span', { className: 'sdt-hm-row-label' });
1355
+ const dead = h('span', { className: 'sdt-hm-row-dead' });
1356
+ const selectorText = h('span', { className: 'sdt-hm-row-selector' });
1357
+ const check = h('button', { className: 'sdt-hm-row-check', type: 'button', role: 'checkbox', 'aria-checked': 'false' }) as HTMLButtonElement;
1358
+ const eye = h('button', { className: 'sdt-hm-row-eye', type: 'button' }) as HTMLButtonElement;
1359
+ const row = h('div', {
1360
+ className: 'sdt-hm-row',
1361
+ role: 'button',
1362
+ tabindex: '0',
1363
+ },
1364
+ check,
1365
+ count,
1366
+ h('span', { className: 'sdt-hm-row-meta' },
1367
+ h('span', { className: 'sdt-hm-row-label-row' }, label, dead),
1368
+ selectorText,
1369
+ ),
1370
+ eye,
1371
+ );
1372
+ const rowElement: ClickmapListRowElement = { row, count, check, eye, label, dead, selector: selectorText, group: null, renderedEyeIcon: '', renderedCheckIcon: '' };
1373
+ check.addEventListener('click', (event) => {
1374
+ event.preventDefault();
1375
+ event.stopPropagation();
1376
+ if (rowElement.group == null) return;
1377
+ // The checkbox always toggles membership (no modifier needed), like a
1378
+ // datagrid checkbox column; shift still extends an additive range.
1379
+ if (event.shiftKey && selectionAnchorSelector != null) {
1380
+ selectGroupFromEvent(rowElement.group, { shiftKey: true, ctrlKey: true, metaKey: false });
1381
+ return;
1382
+ }
1383
+ toggleSelectedGroup(rowElement.group.selector);
1384
+ });
1385
+ eye.addEventListener('click', (event) => {
1386
+ event.preventDefault();
1387
+ event.stopPropagation();
1388
+ toggleMutedGroup(selector);
1389
+ });
1390
+ row.addEventListener('click', (event) => {
1391
+ if (rowElement.group == null) return;
1392
+ selectGroupFromEvent(rowElement.group, event);
1393
+ });
1394
+ row.addEventListener('keydown', (event) => {
1395
+ if (event.key !== 'Enter' && event.key !== ' ') return;
1396
+ event.preventDefault();
1397
+ if (rowElement.group == null) return;
1398
+ selectGroupFromEvent(rowElement.group, event);
1399
+ });
1400
+ return rowElement;
1401
+ }
1402
+
1403
+ function updateListRowElement(rowElement: ClickmapListRowElement, group: ClickmapClickGroup) {
1404
+ const muted = mutedGroupSelectors.has(group.selector);
1405
+ const highlighted = highlightedGroupSelector === group.selector;
1406
+ const selected = selectedGroupSelectors.has(group.selector);
1407
+ rowElement.group = group;
1408
+ rowElement.row.classList.toggle('sdt-hm-row-muted', muted);
1409
+ rowElement.row.classList.toggle('sdt-hm-row-highlighted', highlighted);
1410
+ rowElement.row.classList.toggle('sdt-hm-row-selected', selected);
1411
+ rowElement.check.setAttribute('aria-checked', String(selected));
1412
+ rowElement.check.setAttribute('aria-label', selected ? `Deselect ${group.label}` : `Select ${group.label}`);
1413
+ // Same dead-spot hazard as syncExpandIcon: only rewrite the SVG when the
1414
+ // selected state actually flips.
1415
+ const checkIcon = selected ? checkIconSvg : '';
1416
+ if (rowElement.renderedCheckIcon !== checkIcon) {
1417
+ rowElement.renderedCheckIcon = checkIcon;
1418
+ setHtml(rowElement.check, checkIcon);
1419
+ }
1420
+ rowElement.count.textContent = formatClickmapCount(getGroupDisplayCount(group));
1421
+ rowElement.eye.setAttribute('aria-pressed', String(muted));
1422
+ rowElement.eye.setAttribute('aria-label', muted ? `Unmute ${group.label}` : `Mute ${group.label}`);
1423
+ rowElement.eye.title = muted ? 'Unmute element' : 'Mute element';
1424
+ // Same dead-spot hazard as syncExpandIcon: only rewrite the SVG when the
1425
+ // muted state actually flips, or clicks land on a detached icon.
1426
+ const eyeIcon = muted ? eyeOffIconSvg : eyeIconSvg;
1427
+ if (rowElement.renderedEyeIcon !== eyeIcon) {
1428
+ rowElement.renderedEyeIcon = eyeIcon;
1429
+ setHtml(rowElement.eye, eyeIcon);
1430
+ }
1431
+ rowElement.label.textContent = group.label;
1432
+ if (filters.showDead && group.deadCount > 0) {
1433
+ const deadPct = getDeadClickPercentage(group);
1434
+ rowElement.dead.textContent = `${deadPct}% dead`;
1435
+ rowElement.dead.title = `${formatClickmapCount(group.deadCount)} of ${formatClickmapCount(group.count)} clicks had no visible effect`;
1436
+ rowElement.dead.classList.add('sdt-hm-row-dead-visible');
1437
+ } else {
1438
+ rowElement.dead.textContent = '';
1439
+ rowElement.dead.title = '';
1440
+ rowElement.dead.classList.remove('sdt-hm-row-dead-visible');
1441
+ }
1442
+ rowElement.selector.textContent = group.selector;
1443
+ }
1444
+
1445
+ function renderOverlay(groups: ClickmapClickGroup[]) {
1446
+ const nextMode = shouldShowElements() ? 'elements' : 'hidden';
1447
+ if (overlayMode !== nextMode) {
1448
+ overlayMode = nextMode;
1449
+ clearClickmapOverlayElements();
1450
+ }
1451
+ if (!shouldShowElements()) {
1452
+ return;
1453
+ }
1454
+
1455
+ const visibleGroupKeys = new Set<string>();
1456
+ const maxCount = Math.max(1, ...groups.map(getGroupDisplayCount));
1457
+ for (const group of groups) {
1458
+ if (group.rect == null || group.rect.width <= 0 || group.rect.height <= 0) {
1459
+ continue;
1460
+ }
1461
+ visibleGroupKeys.add(group.selector);
1462
+ const displayCount = getGroupDisplayCount(group);
1463
+ const hue = getClickmapHue(displayCount, maxCount);
1464
+ const muted = mutedGroupSelectors.has(group.selector);
1465
+ // Every selected row reads as highlighted on the page, so a multi-row
1466
+ // selection lights up all of its outlines at once; the glide box still
1467
+ // follows only the lead (highlightedGroupSelector).
1468
+ const highlighted = highlightedGroupSelector === group.selector || selectedGroupSelectors.has(group.selector);
1469
+ let overlayElement = groupOverlayElements.get(group.selector);
1470
+ if (overlayElement == null) {
1471
+ const marker = h('button', { className: 'sdt-hm-marker', type: 'button', tabindex: '-1' });
1472
+ marker.addEventListener('click', (event) => {
1473
+ event.preventDefault();
1474
+ event.stopPropagation();
1475
+ toggleMutedGroup(group.selector);
1476
+ });
1477
+ marker.addEventListener('pointerenter', () => setHoveredGroup(group.selector));
1478
+ marker.addEventListener('pointerleave', () => {
1479
+ if (hoveredGroupSelector === group.selector) setHoveredGroup(null);
1480
+ });
1481
+ overlayElement = {
1482
+ marker,
1483
+ outline: h('div', { className: 'sdt-hm-outline' }),
1484
+ };
1485
+ groupOverlayElements.set(group.selector, overlayElement);
1486
+ overlayRoot.append(overlayElement.outline, overlayElement.marker);
1487
+ }
1488
+ const { marker, outline } = overlayElement;
1489
+ const deadSuffix = filters.showDead && group.deadCount > 0 && group.count > 0
1490
+ ? ` (${getDeadClickPercentage(group)}% dead)`
1491
+ : '';
1492
+ marker.title = muted ? `Unmute ${group.selector}` : `Mute ${displayCount} clicks${deadSuffix} on ${group.selector}`;
1493
+ marker.setAttribute('aria-label', marker.title);
1494
+ marker.style.left = `${Math.round(group.rect.left + group.rect.width / 2)}px`;
1495
+ marker.style.top = `${Math.round(group.rect.top + group.rect.height / 2)}px`;
1496
+ marker.style.background = `hsla(${hue}, 96%, 58%, 0.94)`;
1497
+ marker.style.boxShadow = `0 0 0 1px hsla(${hue}, 96%, 22%, 0.35), 0 8px 24px hsla(${hue}, 96%, 45%, 0.32)`;
1498
+ marker.textContent = formatClickmapCount(displayCount);
1499
+ marker.classList.toggle('sdt-hm-marker-muted', muted);
1500
+ marker.classList.toggle('sdt-hm-marker-highlighted', highlighted);
1501
+
1502
+ outline.style.left = `${group.rect.left}px`;
1503
+ outline.style.top = `${group.rect.top}px`;
1504
+ outline.style.width = `${group.rect.width}px`;
1505
+ outline.style.height = `${group.rect.height}px`;
1506
+ outline.style.borderColor = `hsla(${hue}, 96%, 58%, 0.5)`;
1507
+ // Hover fills the box with a faint wash of its own border color; the
1508
+ // empty string falls back to the stylesheet's neutral background.
1509
+ outline.style.background = hoveredGroupSelector === group.selector ? `hsla(${hue}, 96%, 58%, 0.16)` : '';
1510
+ outline.classList.toggle('sdt-hm-outline-muted', muted);
1511
+ outline.classList.toggle('sdt-hm-outline-highlighted', highlighted);
1512
+ }
1513
+ for (const [key, overlayElement] of groupOverlayElements) {
1514
+ if (!visibleGroupKeys.has(key)) {
1515
+ overlayElement.marker.remove();
1516
+ overlayElement.outline.remove();
1517
+ groupOverlayElements.delete(key);
1518
+ }
1519
+ }
1520
+ renderHighlightBox(groups);
1521
+ }
1522
+
1523
+ function renderHighlightBox(groups: ClickmapClickGroup[]) {
1524
+ const group = highlightedGroupSelector == null
1525
+ ? null
1526
+ : groups.find((candidate) => candidate.selector === highlightedGroupSelector) ?? null;
1527
+ const rect = group?.rect ?? null;
1528
+ if (group == null || rect == null || rect.width <= 0 || rect.height <= 0) {
1529
+ if (highlightSettleTimer != null) {
1530
+ window.clearTimeout(highlightSettleTimer);
1531
+ highlightSettleTimer = null;
1532
+ }
1533
+ overlayHighlight.classList.remove('sdt-hm-highlight-visible', 'sdt-hm-highlight-animating');
1534
+ highlightRenderedSelector = null;
1535
+ return;
1536
+ }
1537
+ const wasVisible = overlayHighlight.classList.contains('sdt-hm-highlight-visible');
1538
+ if (wasVisible && highlightRenderedSelector !== group.selector) {
1539
+ // Geometry transitions stay on briefly after retargeting so the box can
1540
+ // glide between visible elements, then come off so manual scrolling
1541
+ // tracks the element exactly instead of lagging behind.
1542
+ overlayHighlight.classList.add('sdt-hm-highlight-animating');
1543
+ if (highlightSettleTimer != null) window.clearTimeout(highlightSettleTimer);
1544
+ highlightSettleTimer = window.setTimeout(() => {
1545
+ overlayHighlight.classList.remove('sdt-hm-highlight-animating');
1546
+ highlightSettleTimer = null;
1547
+ }, 700);
1548
+ }
1549
+ highlightRenderedSelector = group.selector;
1550
+ overlayHighlight.style.left = `${rect.left}px`;
1551
+ overlayHighlight.style.top = `${rect.top}px`;
1552
+ overlayHighlight.style.width = `${rect.width}px`;
1553
+ overlayHighlight.style.height = `${rect.height}px`;
1554
+ overlayHighlight.classList.add('sdt-hm-highlight-visible');
1555
+ }
1556
+
1557
+ function renderList(groups: ClickmapClickGroup[]) {
1558
+ if (groups.length === 0) {
1559
+ clearClickmapListElements();
1560
+ list.appendChild(empty);
1561
+ return;
1562
+ }
1563
+ const previousScrollTop = body.scrollTop;
1564
+ empty.remove();
1565
+ const renderedKeys = new Set<string>();
1566
+ let nextRowNode: ChildNode | null = list.firstChild;
1567
+ for (const group of groups.slice(0, 30)) {
1568
+ renderedKeys.add(group.selector);
1569
+ let rowElement = listRowElements.get(group.selector);
1570
+ if (rowElement == null) {
1571
+ rowElement = createListRowElement(group.selector);
1572
+ listRowElements.set(group.selector, rowElement);
1573
+ }
1574
+ updateListRowElement(rowElement, group);
1575
+ if (rowElement.row !== nextRowNode) {
1576
+ list.insertBefore(rowElement.row, nextRowNode);
1577
+ }
1578
+ nextRowNode = rowElement.row.nextSibling;
1579
+ }
1580
+ for (const [selector, rowElement] of listRowElements) {
1581
+ if (renderedKeys.has(selector)) continue;
1582
+ rowElement.row.remove();
1583
+ listRowElements.delete(selector);
1584
+ }
1585
+ body.scrollTop = previousScrollTop;
1586
+ }
1587
+
1588
+ // Same dead-spot hazard as syncExpandIcon: only rewrite the master
1589
+ // checkbox's SVG when its tri-state actually changes.
1590
+ let renderedHeaderCheckIcon = '';
1591
+ function syncListHeader(groups: ClickmapClickGroup[]) {
1592
+ // The header stays visible while a search is filtering everything out —
1593
+ // the search box lives here, so hiding it would leave no way to clear
1594
+ // the query. It only disappears in the true empty state (no data).
1595
+ const visible = groups.length > 0 || filters.elementSearch.trim() !== '';
1596
+ listHeader.classList.toggle('sdt-hm-list-header-visible', visible);
1597
+ if (!visible) return;
1598
+ const selectedCount = selectedGroupSelectors.size;
1599
+ const allSelected = selectedCount > 0 && groups.every((group) => selectedGroupSelectors.has(group.selector));
1600
+ listHeaderCheck.setAttribute('aria-checked', allSelected ? 'true' : selectedCount > 0 ? 'mixed' : 'false');
1601
+ listHeaderCheck.setAttribute('aria-label', allSelected ? 'Clear selection' : 'Select all elements');
1602
+ const headerCheckIcon = allSelected ? checkIconSvg : selectedCount > 0 ? dashIconSvg : '';
1603
+ if (renderedHeaderCheckIcon !== headerCheckIcon) {
1604
+ renderedHeaderCheckIcon = headerCheckIcon;
1605
+ setHtml(listHeaderCheck, headerCheckIcon);
1606
+ }
1607
+ listHeaderSummary.textContent = selectedCount > 0
1608
+ ? `${formatClickmapCount(selectedCount)} of ${formatClickmapCount(groups.length)} selected`
1609
+ : `${formatClickmapCount(groups.length)} element${groups.length === 1 ? '' : 's'}`;
1610
+ const bulkTargets = getBulkActionGroups();
1611
+ const bulkScope = selectedCount > 0 ? 'selected' : 'all';
1612
+ listShowButton.textContent = `Show ${bulkScope}`;
1613
+ listHideButton.textContent = `Hide ${bulkScope}`;
1614
+ // Disabled whenever the action would be a no-op on its targets.
1615
+ listShowButton.disabled = bulkTargets.every((group) => !mutedGroupSelectors.has(group.selector));
1616
+ listHideButton.disabled = bulkTargets.every((group) => mutedGroupSelectors.has(group.selector));
1617
+ }
1618
+
1619
+ function render() {
1620
+ if (currentPath !== window.location.pathname) {
1621
+ currentPath = window.location.pathname;
1622
+ serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
1623
+ serverClickmapError = null;
1624
+ clearClickmapListElements();
1625
+ syncAutoUrlPattern();
1626
+ runAsynchronously(loadServerClickmap());
1627
+ }
1628
+ const groups = getGroups();
1629
+ latestGroups = groups;
1630
+ const groupKeys = new Set(groups.map((group) => group.selector));
1631
+ for (const mutedGroupSelector of mutedGroupSelectors) {
1632
+ if (!groupKeys.has(mutedGroupSelector)) mutedGroupSelectors.delete(mutedGroupSelector);
1633
+ }
1634
+ for (const selectedGroupSelector of selectedGroupSelectors) {
1635
+ if (!groupKeys.has(selectedGroupSelector)) selectedGroupSelectors.delete(selectedGroupSelector);
1636
+ }
1637
+ if (selectionAnchorSelector != null && !groupKeys.has(selectionAnchorSelector)) {
1638
+ selectionAnchorSelector = null;
1639
+ }
1640
+ if (highlightedGroupSelector != null && !groupKeys.has(highlightedGroupSelector)) {
1641
+ highlightedGroupSelector = null;
1642
+ }
1643
+ if (hoveredGroupSelector != null && !groupKeys.has(hoveredGroupSelector)) {
1644
+ hoveredGroupSelector = null;
1645
+ }
1646
+ // Clicks mapped to an element that actually exists in the current DOM (what
1647
+ // the overlay can draw) vs. the true aggregate the filter matched server-side.
1648
+ // Follows the dead-clicks toggle so the message matches the drawn numbers.
1649
+ const mappedClicks = groups.reduce((sum, group) => sum + getGroupDisplayCount(group), 0);
1650
+ const aggregateClicks = serverClickmap.path === currentPath ? serverClickmap.totalClicks : 0;
1651
+ const viewport = getClickmapViewportSize();
1652
+ const roundedViewportWidth = Math.round(viewport.width);
1653
+ const roundedViewportHeight = Math.round(viewport.height);
1654
+ const selectedViewportBucket = getClickmapViewportBucket(filters.device);
1655
+ const viewportFilterMatches = selectedViewportBucket == null || isClickmapViewportWidthInBucket(roundedViewportWidth, selectedViewportBucket);
1656
+ statsCount.textContent = formatClickmapCount(aggregateClicks);
1657
+ selectorCount.textContent = formatClickmapCount(groups.length);
1658
+ viewportValue.textContent = `${roundedViewportWidth}x${roundedViewportHeight}`;
1659
+ overlayToggle.textContent = overlayVisible ? 'Hide overlay' : 'Show overlay';
1660
+ syncOverlayMiniToggle();
1661
+ viewportWarning.classList.toggle('sdt-hm-viewport-warning-visible', !viewportFilterMatches);
1662
+ if (selectedViewportBucket != null && !viewportFilterMatches) {
1663
+ const recommendedWidth = getClickmapRecommendedViewportWidth(selectedViewportBucket);
1664
+ const recommendedHeight = Math.max(1, roundedViewportHeight);
1665
+ viewportWarningTitle.textContent = 'Viewport filter mismatch';
1666
+ viewportWarningBody.textContent = `This page is ${roundedViewportWidth}px wide, but ${filters.device} is ${formatClickmapViewportBucket(selectedViewportBucket)}. Resize the window or use the DevTools device toolbar before comparing this clickmap.`;
1667
+ viewportWarningWidthValue.textContent = String(recommendedWidth);
1668
+ viewportWarningHeightValue.textContent = String(recommendedHeight);
1669
+ }
1670
+ const effectiveUrlPattern = getEffectiveUrlPattern();
1671
+ const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath);
1672
+ // Re-evaluated here too so route changes (which move the auto pattern under
1673
+ // a custom one) keep the revert affordance in sync.
1674
+ syncUrlPatternResetVisibility();
1675
+ const token = getClickmapTokenFromStorage();
1676
+ const tokenOrigin = getClickmapOriginFromStorage();
1677
+ if (token == null) {
1678
+ status.textContent = serverClickmapError ?? 'No clickmap token in sessionStorage. Paste one from the dashboard to load this page.';
1679
+ } else if (tokenOrigin != null && tokenOrigin !== window.location.origin) {
1680
+ status.textContent = `Token was minted for ${tokenOrigin}, but this page is ${window.location.origin}. Generate a token for this exact origin.`;
1681
+ } else if (loadingServerClickmap) {
1682
+ status.textContent = 'Loading aggregate clickmap...';
1683
+ } else if (serverClickmapError != null) {
1684
+ status.textContent = serverClickmapError;
1685
+ } else {
1686
+ const scope = effectiveUrlPattern !== '' && effectiveUrlPattern !== currentPath ? effectiveUrlPattern : currentPath;
1687
+ let message = `Loaded ${formatClickmapCount(aggregateClicks)} aggregate clicks for ${scope}.`;
1688
+ if (aggregateClicks === 0) {
1689
+ message = `No clicks recorded for ${scope} in this range.`;
1690
+ } else if (!urlPatternMatchesPath) {
1691
+ // The overlay is bound to the page you're viewing; off-pattern pages
1692
+ // can't render it. This is the "* / shows 0 dots" case made explicit.
1693
+ message += ' This page isn’t covered by the pattern — reset it or open a matching page to see the overlay.';
1694
+ } else if (groups.length === 0) {
1695
+ message += ' No matching elements found on this page yet.';
1696
+ } else if (mappedClicks < aggregateClicks) {
1697
+ message += ` ${formatClickmapCount(mappedClicks)} mapped to elements on this page.`;
1698
+ }
1699
+ status.textContent = message;
1700
+ }
1701
+ status.classList.toggle('sdt-hm-token-status-error', serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin));
1702
+ miniClicks.textContent = formatClickmapCount(aggregateClicks);
1703
+ miniElements.textContent = formatClickmapCount(groups.length);
1704
+ container.classList.toggle('sdt-hm-expanded', expanded);
1705
+ expandButton.setAttribute('aria-expanded', String(expanded));
1706
+ expandButton.setAttribute('aria-label', expanded ? 'Collapse clickmap options' : 'Expand clickmap options');
1707
+ expandButton.setAttribute('data-sdt-tip', expanded ? 'Collapse clickmap options' : 'Expand clickmap options');
1708
+ syncExpandIcon();
1709
+ // Keep the viewport switcher's sliding thumb aligned once the panel is laid
1710
+ // out (expand, resize-driven re-render); it no-ops while collapsed.
1711
+ positionDeviceThumb();
1712
+ renderOverlay(groups);
1713
+ syncListHeader(groups);
1714
+ renderList(groups);
1715
+ }
1716
+
1717
+ async function loadServerClickmap() {
1718
+ const requestId = serverClickmapRequestId + 1;
1719
+ serverClickmapRequestId = requestId;
1720
+ const isLatestRequest = () => requestId === serverClickmapRequestId;
1721
+ const token = getClickmapTokenFromStorage();
1722
+ if (token == null) {
1723
+ serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
1724
+ serverClickmapError = null;
1725
+ loadingServerClickmap = false;
1726
+ render();
1727
+ return;
1728
+ }
1729
+ const tokenOrigin = getClickmapOriginFromStorage();
1730
+ if (tokenOrigin != null && tokenOrigin !== window.location.origin) {
1731
+ serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
1732
+ serverClickmapError = null;
1733
+ loadingServerClickmap = false;
1734
+ render();
1735
+ return;
1736
+ }
1737
+
1738
+ loadingServerClickmap = true;
1739
+ serverClickmapError = null;
1740
+ render();
1741
+ try {
1742
+ const until = new Date();
1743
+ const since = new Date(until.getTime() - CLICKMAP_RANGE_MS[filters.range]);
1744
+ const requestedPath = window.location.pathname;
1745
+ const effectiveUrlPattern = getEffectiveUrlPattern();
1746
+ const body: Record<string, unknown> = {
1747
+ clickmap_token: token,
1748
+ origin: window.location.origin,
1749
+ since: since.toISOString(),
1750
+ until: until.toISOString(),
1751
+ };
1752
+ if (effectiveUrlPattern !== '') {
1753
+ body.url_pattern = effectiveUrlPattern;
1754
+ } else {
1755
+ body.route_path = requestedPath;
1756
+ }
1757
+ if (filters.device !== 'all') {
1758
+ body.device = filters.device;
1759
+ }
1760
+ const response = await app[hexclaveAppInternalsSymbol].sendRequest("/analytics/clickmap", {
1761
+ method: "POST",
1762
+ headers: { "content-type": "application/json" },
1763
+ body: JSON.stringify(body),
1764
+ }, "client");
1765
+ if (!response.ok) {
1766
+ throw new Error(`Clickmap request failed with HTTP ${response.status}`);
1767
+ }
1768
+ const responseBody: unknown = await response.json();
1769
+ if (!isLatestRequest()) {
1770
+ return;
1771
+ }
1772
+ serverClickmap = parseServerClickmapResponse(responseBody, requestedPath);
1773
+ } catch (error) {
1774
+ if (!isLatestRequest()) {
1775
+ return;
1776
+ }
1777
+ serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] };
1778
+ if (error instanceof Error && error.message.includes('Clickmap token does not belong to this project')) {
1779
+ clearClickmapTokenStorage();
1780
+ serverClickmapError = 'The stored clickmap token belongs to another project. Generate a fresh token for this project.';
1781
+ } else {
1782
+ serverClickmapError = error instanceof Error ? error.message : 'Failed to load clickmap data';
1783
+ }
1784
+ } finally {
1785
+ if (isLatestRequest()) {
1786
+ loadingServerClickmap = false;
1787
+ render();
1788
+ }
1789
+ }
1790
+ }
1791
+
1792
+ // The clickmap overlay leaves the page fully interactive. When the user
1793
+ // navigates away with a token loaded, drop a sentinel so the dev tool on the
1794
+ // next page can auto-reopen straight back into the clickmap tab.
1795
+ const onBeforeUnloadResume = () => {
1796
+ const token = getClickmapTokenFromStorage();
1797
+ const tokenOrigin = getClickmapOriginFromStorage();
1798
+ if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin)) {
1799
+ return;
1800
+ }
1801
+ try {
1802
+ sessionStorage.setItem(CLICKMAP_OVERLAY_RESUME_STORAGE_KEY, '1');
1803
+ } catch {
1804
+ // ignore (private mode, etc.)
1805
+ }
1806
+ };
1807
+
1808
+ // render() fires constantly while the overlay is open (route poll, scroll,
1809
+ // host-page mutations), so the toolbar can be rewritten or reflowed between
1810
+ // pointerdown and pointerup. If that churn replaces the node the press
1811
+ // started on, or shifts the button out from under the cursor, the browser
1812
+ // never synthesizes the click and the press is silently dropped. Capturing
1813
+ // the pointer pins the rest of the press — pointerup and the resulting
1814
+ // click included — to the button itself, so mid-press churn can't eat it.
1815
+ const pinPressToButton = (button: HTMLButtonElement) => {
1816
+ button.addEventListener('pointerdown', (event) => {
1817
+ try {
1818
+ button.setPointerCapture(event.pointerId);
1819
+ } catch {
1820
+ // The pointer may already be gone (e.g. pen lifted); a plain click
1821
+ // still works in that case.
1822
+ }
1823
+ });
1824
+ };
1825
+ pinPressToButton(overlayToggle);
1826
+ pinPressToButton(closeButton);
1827
+ pinPressToButton(expandButton);
1828
+ pinPressToButton(showDeadMiniToggle);
1829
+ pinPressToButton(showDeadToggle);
1830
+ pinPressToButton(overlayMiniToggle);
1831
+ pinPressToButton(listHeaderCheck);
1832
+ pinPressToButton(listShowButton);
1833
+ pinPressToButton(listHideButton);
1834
+ overlayToggle.addEventListener('click', () => {
1835
+ overlayVisible = !overlayVisible;
1836
+ render();
1837
+ });
1838
+ closeButton.addEventListener('click', onClose);
1839
+ expandButton.addEventListener('click', () => {
1840
+ expanded = !expanded;
1841
+ render();
1842
+ });
1843
+ const onTokenUpdated = () => {
1844
+ runAsynchronously(loadServerClickmap());
1845
+ };
1846
+ const routePollInterval = window.setInterval(scheduleRender, 500);
1847
+ // Mutations the overlay/dev-tool cause themselves must not drive a re-render:
1848
+ // `renderOverlay` rewrites marker/outline inline styles into `overlayRoot` on
1849
+ // every paint, so observing them would re-arm scheduleRender → paint → mutate
1850
+ // → … a permanent render loop while the tab is open. Ignore records whose
1851
+ // targets all sit inside our own overlay/panel roots or the dev tool's root.
1852
+ const isSelfMutationTarget = (target: Node | null): boolean => {
1853
+ const element = target instanceof Element ? target : target?.parentElement ?? null;
1854
+ if (element == null) return false;
1855
+ return overlayRoot.contains(element) || element.closest(`#${cssEscapeIdent(CLICKMAP_ROOT_ID)}, #${cssEscapeIdent(DEV_TOOL_ROOT_ID)}`) != null;
1856
+ };
1857
+ const mutationObserver = new MutationObserver((mutations) => {
1858
+ if (mutations.every((mutation) => isSelfMutationTarget(mutation.target))) {
1859
+ return;
1860
+ }
1861
+ scheduleDomIndexInvalidation();
1862
+ scheduleRender();
1863
+ });
1864
+ const visualViewport = window.visualViewport;
1865
+ mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true });
1866
+
1867
+ // Mounted inside the clickmap root (not document.body) so everything that
1868
+ // ignores Hexclave UI by root id — most importantly the SDK's dead-click
1869
+ // MutationObserver — also ignores the overlay's constant marker/outline
1870
+ // rewrites instead of reading them as the page reacting to a click. The
1871
+ // overlay root is position:fixed, so the unstyled parent changes nothing
1872
+ // visually.
1873
+ (document.getElementById(CLICKMAP_ROOT_ID) ?? document.body).appendChild(overlayRoot);
1874
+ rebuildDomIndex();
1875
+ scheduleRender();
1876
+ window.addEventListener('beforeunload', onBeforeUnloadResume);
1877
+ const onWindowResize = () => {
1878
+ scheduleRender();
1879
+ };
1880
+ document.addEventListener('scroll', scheduleRender, true);
1881
+ window.addEventListener('resize', onWindowResize);
1882
+ visualViewport?.addEventListener('resize', scheduleRender);
1883
+ visualViewport?.addEventListener('scroll', scheduleRender);
1884
+ window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, onTokenUpdated);
1885
+ // Dismiss the URL-pattern help popover on an outside click or Escape.
1886
+ const onDocumentPointerDown = (event: MouseEvent) => {
1887
+ if (!urlHelpOpen) return;
1888
+ if (event.target instanceof Node && urlPatternHelp.contains(event.target)) return;
1889
+ if (event.target instanceof Node && urlPatternInfo.contains(event.target)) return;
1890
+ setUrlHelpOpen(false);
1891
+ };
1892
+ const onDocumentKeyDown = (event: KeyboardEvent) => {
1893
+ if (event.key !== 'Escape') return;
1894
+ if (urlHelpOpen) {
1895
+ setUrlHelpOpen(false);
1896
+ return;
1897
+ }
1898
+ clearSelection();
1899
+ };
1900
+ document.addEventListener('mousedown', onDocumentPointerDown, true);
1901
+ document.addEventListener('keydown', onDocumentKeyDown, true);
1902
+ render();
1903
+ runAsynchronously(loadServerClickmap());
1904
+
1905
+ container.append(details, toolbar);
1906
+ return {
1907
+ element: container,
1908
+ cleanup: () => {
1909
+ cancelAnimationFrame(renderFrame);
1910
+ if (domIndexDebounce !== 0) window.clearTimeout(domIndexDebounce);
1911
+ if (filterReloadDebounce !== 0) window.clearTimeout(filterReloadDebounce);
1912
+ if (elementSearchDebounce !== 0) window.clearTimeout(elementSearchDebounce);
1913
+ window.clearInterval(routePollInterval);
1914
+ mutationObserver.disconnect();
1915
+ clearClickmapOverlayElements();
1916
+ domIndex.clear();
1917
+ window.removeEventListener('beforeunload', onBeforeUnloadResume);
1918
+ document.removeEventListener('scroll', scheduleRender, true);
1919
+ window.removeEventListener('resize', onWindowResize);
1920
+ visualViewport?.removeEventListener('resize', scheduleRender);
1921
+ visualViewport?.removeEventListener('scroll', scheduleRender);
1922
+ window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, onTokenUpdated);
1923
+ document.removeEventListener('mousedown', onDocumentPointerDown, true);
1924
+ document.removeEventListener('keydown', onDocumentKeyDown, true);
1925
+ overlayRoot.remove();
1926
+ },
1927
+ };
1928
+ }
1929
+
1930
+ // ===========================================================================================
1931
+ // Mount
1932
+ // ===========================================================================================
1933
+
1934
+ const GLOBAL_INSTANCE_KEY = '__hexclave-clickmap-instance';
1935
+
1936
+ /**
1937
+ * Opens the clickmap overlay: mounts its own root element, injects its own
1938
+ * styles, and shows the bottom-centered panel.
1939
+ *
1940
+ * Returns a cleanup that tears everything down. `onClosed` fires exactly once,
1941
+ * when the overlay is closed (by the user or via the returned cleanup), so the
1942
+ * caller can let a later token event reopen it.
1943
+ */
1944
+ export function openClickmapOverlay(app: StackClientApp<true>, onClosed: () => void): () => void {
1945
+ if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
1946
+ return () => {};
1947
+ }
1948
+ const body = Reflect.get(document, 'body');
1949
+ if (!hasAppendChild(body)) return () => {};
1950
+
1951
+ getGlobalUiInstance(GLOBAL_INSTANCE_KEY)?.cleanup();
1952
+ let existingRoot = document.getElementById(CLICKMAP_ROOT_ID);
1953
+ while (existingRoot !== null) {
1954
+ existingRoot.remove();
1955
+ existingRoot = document.getElementById(CLICKMAP_ROOT_ID);
1956
+ }
1957
+
1958
+ const root = document.createElement('div');
1959
+ root.id = CLICKMAP_ROOT_ID;
1960
+ body.appendChild(root);
1961
+
1962
+ const wrapper = h('div', { className: 'hexclave-clickmap' });
1963
+ root.appendChild(wrapper);
1964
+
1965
+ const style = document.createElement('style');
1966
+ style.textContent = clickmapCSS;
1967
+ wrapper.appendChild(style);
1968
+
1969
+ const panel = createClickmapPanel(app, () => instance.cleanup());
1970
+ wrapper.appendChild(
1971
+ h('div', { className: 'sdt-hm-panel' },
1972
+ h('div', { className: 'sdt-hm-panel-inner' }, panel.element),
1973
+ ),
1974
+ );
1975
+
1976
+ let didCleanup = false;
1977
+ const instance: UiGlobalInstance = {
1978
+ cleanup: () => {
1979
+ if (didCleanup) return;
1980
+ didCleanup = true;
1981
+ if (getGlobalUiInstance(GLOBAL_INSTANCE_KEY) === instance) {
1982
+ setGlobalUiInstance(GLOBAL_INSTANCE_KEY, null);
1983
+ }
1984
+ panel.cleanup?.();
1985
+ if (root.parentNode) {
1986
+ root.parentNode.removeChild(root);
1987
+ }
1988
+ onClosed();
1989
+ },
1990
+ };
1991
+ setGlobalUiInstance(GLOBAL_INSTANCE_KEY, instance);
1992
+
1993
+ return () => {
1994
+ instance.cleanup();
1995
+ };
1996
+ }
1997
+