context_drop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +21 -0
  3. data/README.md +337 -0
  4. data/Rakefile +20 -0
  5. data/SPEC.md +532 -0
  6. data/app/assets/javascripts/context_drop/context_drop.js +3508 -0
  7. data/app/assets/stylesheets/context_drop/application.css +2 -0
  8. data/app/controllers/context_drop/application_controller.rb +114 -0
  9. data/app/controllers/context_drop/attachments_controller.rb +31 -0
  10. data/app/controllers/context_drop/configs_controller.rb +20 -0
  11. data/app/controllers/context_drop/dashboard/feedbacks_controller.rb +107 -0
  12. data/app/controllers/context_drop/feedbacks_controller.rb +208 -0
  13. data/app/controllers/context_drop/health_controller.rb +9 -0
  14. data/app/controllers/context_drop/mcp_controller.rb +67 -0
  15. data/app/helpers/context_drop/widget_helper.rb +62 -0
  16. data/app/javascript/context_drop/__tests__/widget.test.ts +319 -0
  17. data/app/javascript/context_drop/api-client.ts +87 -0
  18. data/app/javascript/context_drop/element-picker.ts +1004 -0
  19. data/app/javascript/context_drop/icons.ts +35 -0
  20. data/app/javascript/context_drop/index.ts +11 -0
  21. data/app/javascript/context_drop/sanitizer.ts +454 -0
  22. data/app/javascript/context_drop/screenshot.ts +214 -0
  23. data/app/javascript/context_drop/styles.ts +690 -0
  24. data/app/javascript/context_drop/types.ts +110 -0
  25. data/app/javascript/context_drop/widget.ts +1120 -0
  26. data/app/models/context_drop/application_record.rb +7 -0
  27. data/app/models/context_drop/attachment.rb +30 -0
  28. data/app/models/context_drop/element_reference.rb +47 -0
  29. data/app/models/context_drop/feedback.rb +94 -0
  30. data/app/services/context_drop/metadata_sanitizer.rb +215 -0
  31. data/app/services/context_drop/upload_validator.rb +217 -0
  32. data/app/views/context_drop/dashboard/feedbacks/index.html.erb +86 -0
  33. data/app/views/context_drop/dashboard/feedbacks/show.html.erb +121 -0
  34. data/app/views/layouts/context_drop/application.html.erb +215 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20261109000001_create_context_drop_feedbacks.rb +23 -0
  37. data/db/migrate/20261109000002_create_context_drop_element_references.rb +29 -0
  38. data/db/migrate/20261109000003_create_context_drop_attachments.rb +24 -0
  39. data/lib/context_drop/base64_util.rb +15 -0
  40. data/lib/context_drop/configuration.rb +155 -0
  41. data/lib/context_drop/engine.rb +32 -0
  42. data/lib/context_drop/mcp/dispatcher.rb +197 -0
  43. data/lib/context_drop/mcp/prompts/investigate_feedback.rb +83 -0
  44. data/lib/context_drop/mcp/server.rb +25 -0
  45. data/lib/context_drop/mcp/stdio_runner.rb +62 -0
  46. data/lib/context_drop/mcp/tools/get_feedback.rb +133 -0
  47. data/lib/context_drop/mcp/tools/get_feedback_attachment.rb +71 -0
  48. data/lib/context_drop/mcp/tools/list_feedbacks.rb +127 -0
  49. data/lib/context_drop/mcp/tools/update_feedback_status.rb +72 -0
  50. data/lib/context_drop/version.rb +5 -0
  51. data/lib/context_drop.rb +32 -0
  52. data/lib/generators/context_drop/install/install_generator.rb +52 -0
  53. data/lib/generators/context_drop/install/templates/initializer.rb.tt +55 -0
  54. data/lib/tasks/context_drop_tasks.rake +14 -0
  55. metadata +246 -0
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Authored inline SVG icon set for ContextDrop.
3
+ * All icons share a 16x16 or 20x20 viewBox, 1.75px stroke weight, and round caps/joins.
4
+ */
5
+
6
+ export const ICON_PIN = `<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.8 3.2l5 5-2.1 2.1-1.8-.6-3.2 3.2.6 2.5-1.6 1.6-3.1-3.1-3.1 3.1v-.1l3.1-3.1-3.1-3.1 1.6-1.6 2.5.6 3.2-3.2-.6-1.8 2.1-2.1z"/></svg>`;
7
+
8
+ export const ICON_PIN_DROP = `<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 2.5a4.5 4.5 0 0 1 4.5 4.5c0 3.1-4.5 7.5-4.5 7.5S5.5 10.1 5.5 7A4.5 4.5 0 0 1 10 2.5z"/><circle cx="10" cy="7" r="1.6"/><path d="M5 17.5h10"/></svg>`;
9
+
10
+ export const ICON_LAYERS = `<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="10 2.5 17.5 6.5 10 10.5 2.5 6.5 10 2.5"/><polyline points="2.5 10.2 10 14.2 17.5 10.2"/><polyline points="2.5 13.8 10 17.8 17.5 13.8"/></svg>`;
11
+
12
+ export const ICON_CHEVRON_UP = `<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3.5 10 8 5.5 12.5 10"/></svg>`;
13
+
14
+ export const ICON_CHEVRON_DOWN = `<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3.5 6 8 10.5 12.5 6"/></svg>`;
15
+
16
+ export const ICON_CHEVRON_RIGHT = `<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 3.5 10.5 8 6 12.5"/></svg>`;
17
+
18
+ export const ICON_CAMERA = `<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5h2.5l1.4-2h6.2l1.4 2H17a1.5 1.5 0 0 1 1.5 1.5v7.5A1.5 1.5 0 0 1 17 17H3a1.5 1.5 0 0 1-1.5-1.5V8A1.5 1.5 0 0 1 3 6.5z"/><circle cx="10" cy="11.5" r="2.75"/></svg>`;
19
+
20
+ export const ICON_IMAGE = `<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2"/><circle cx="7" cy="8" r="1.3"/><path d="M17.5 13.2l-3.8-3.7L6 16.5"/></svg>`;
21
+
22
+ export const ICON_SEND = `<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 16V4"/><polyline points="5 9 10 4 15 9"/></svg>`;
23
+
24
+ export const ICON_CLOSE = `<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>`;
25
+
26
+ export const ICON_MINIMIZE = `<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3.5" y1="8" x2="12.5" y2="8"/></svg>`;
27
+
28
+ export const ICON_EXPAND = `<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 3.5 12.5 3.5 12.5 10"/><line x1="12.5" y1="3.5" x2="3.5" y2="12.5"/></svg>`;
29
+
30
+ export const ICON_GRIP = `<svg viewBox="0 0 12 16" width="10" height="14" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="4" r="1.1"/><circle cx="8.5" cy="4" r="1.1"/><circle cx="3.5" cy="8" r="1.1"/><circle cx="8.5" cy="8" r="1.1"/><circle cx="3.5" cy="12" r="1.1"/><circle cx="8.5" cy="12" r="1.1"/></svg>`;
31
+
32
+ export const ICON_CHECK = `<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3.5 8.5 6.5 11.5 12.5 4.5"/></svg>`;
33
+
34
+ export const ICON_CHAT = `<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 4h12a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8l-4 3v-3H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>`;
35
+
@@ -0,0 +1,11 @@
1
+ import { registerContextDropWidget } from "./widget.ts";
2
+
3
+ export * from "./types.ts";
4
+ export * from "./sanitizer.ts";
5
+ export * from "./styles.ts";
6
+ export * from "./api-client.ts";
7
+ export * from "./screenshot.ts";
8
+ export * from "./element-picker.ts";
9
+ export * from "./widget.ts";
10
+
11
+ registerContextDropWidget();
@@ -0,0 +1,454 @@
1
+ import type {
2
+ BoundingRectSnapshot,
3
+ ElementCandidateInfo,
4
+ ElementCandidateRelation,
5
+ ElementReferenceSnapshot,
6
+ ViewportSnapshot
7
+ } from "./types.ts";
8
+
9
+ export const MANDATORY_EXCLUDED_SELECTORS: readonly string[] = [
10
+ "[data-feedback-ignore]",
11
+ "[data-feedback-sensitive]",
12
+ "input[type='password']",
13
+ "input[type='hidden']",
14
+ "[autocomplete^='cc-']",
15
+ "context-drop-widget",
16
+ "[data-context-drop-ui]"
17
+ ];
18
+
19
+ export const ALLOWED_HTML_ATTRIBUTES: readonly string[] = [
20
+ "id",
21
+ "class",
22
+ "role",
23
+ "name",
24
+ "type",
25
+ "href",
26
+ "title",
27
+ "placeholder",
28
+ "alt",
29
+ "data-testid",
30
+ "data-test",
31
+ "data-cy",
32
+ "data-qa",
33
+ "data-turbo-frame"
34
+ ];
35
+
36
+ const IGNORED_TEXT_TAGS = new Set([
37
+ "SCRIPT",
38
+ "STYLE",
39
+ "NOSCRIPT",
40
+ "TEMPLATE",
41
+ "SVG",
42
+ "PATH",
43
+ "INPUT",
44
+ "TEXTAREA",
45
+ "SELECT"
46
+ ]);
47
+
48
+ export function buildExcludedSelectorList(customSelectors: string[] = []): string[] {
49
+ const combined = new Set<string>(MANDATORY_EXCLUDED_SELECTORS);
50
+ for (const sel of customSelectors) {
51
+ if (typeof sel === "string" && sel.trim().length > 0) {
52
+ combined.add(sel.trim());
53
+ }
54
+ }
55
+ return Array.from(combined);
56
+ }
57
+
58
+ function elementMatchesAny(el: Element, selectors: string[]): boolean {
59
+ const tagName = (el.tagName || "").toLowerCase();
60
+ if (tagName === "context-drop-widget") return true;
61
+ if (el.hasAttribute("data-feedback-ignore") || el.hasAttribute("data-feedback-sensitive")) {
62
+ return true;
63
+ }
64
+ if (el.hasAttribute("data-context-drop-ui")) {
65
+ return true;
66
+ }
67
+ if (tagName === "input") {
68
+ const inputType = (el.getAttribute("type") || "").toLowerCase();
69
+ if (inputType === "password" || inputType === "hidden") return true;
70
+ }
71
+ const autocomplete = (el.getAttribute("autocomplete") || "").toLowerCase();
72
+ if (autocomplete.startsWith("cc-")) return true;
73
+
74
+ if (typeof el.matches === "function") {
75
+ for (const sel of selectors) {
76
+ try {
77
+ if (el.matches(sel)) return true;
78
+ } catch {
79
+ // Ignore invalid custom CSS selector syntax
80
+ }
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ export function getParentOrHostElement(node: Element | null): Element | null {
87
+ if (!node) return null;
88
+ if (node.parentElement) {
89
+ return node.parentElement;
90
+ }
91
+ const rootNode = typeof node.getRootNode === "function" ? node.getRootNode() : null;
92
+ if (rootNode && "host" in rootNode && (rootNode as ShadowRoot).host instanceof Element) {
93
+ return (rootNode as ShadowRoot).host;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export function isElementExcluded(node: Element | null, customSelectors: string[] = []): boolean {
99
+ if (!node) return true;
100
+ const selectors = buildExcludedSelectorList(customSelectors);
101
+
102
+ let current: Element | null = node;
103
+ while (current) {
104
+ if (elementMatchesAny(current, selectors)) {
105
+ return true;
106
+ }
107
+ current = getParentOrHostElement(current);
108
+ }
109
+ return false;
110
+ }
111
+
112
+ export function isSelectableElement(node: Element | null, customSelectors: string[] = []): boolean {
113
+ if (!node) return false;
114
+ const tag = (node.tagName || "").toUpperCase();
115
+ if (!tag || tag === "HTML" || tag === "BODY" || tag === "HEAD" || tag === "SCRIPT" || tag === "STYLE") {
116
+ return false;
117
+ }
118
+ if (typeof node.closest === "function") {
119
+ if (node.closest("context-drop-widget") || node.closest("[data-context-drop-ui]")) {
120
+ return false;
121
+ }
122
+ }
123
+ if (isElementExcluded(node, customSelectors)) {
124
+ return false;
125
+ }
126
+ const rect = typeof node.getBoundingClientRect === "function"
127
+ ? node.getBoundingClientRect()
128
+ : { width: 0, height: 0 };
129
+ if ((rect.width ?? 0) <= 0 || (rect.height ?? 0) <= 0) {
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ export function extractSanitizedAttributes(el: Element): Record<string, string> {
136
+ const snapshot: Record<string, string> = {};
137
+ const attrNames = typeof el.getAttributeNames === "function" ? el.getAttributeNames() : [];
138
+
139
+ for (const rawName of attrNames) {
140
+ const name = rawName.toLowerCase().trim();
141
+ if (!name || name === "value" || name === "defaultvalue" || name.startsWith("on")) {
142
+ continue;
143
+ }
144
+ if (name === "data-feedback-ignore" || name === "data-feedback-sensitive") {
145
+ continue;
146
+ }
147
+
148
+ const isAllowed =
149
+ ALLOWED_HTML_ATTRIBUTES.includes(name) ||
150
+ (name.startsWith("aria-") && name !== "aria-hidden");
151
+
152
+ if (!isAllowed) continue;
153
+
154
+ const rawVal = el.getAttribute(rawName);
155
+ if (rawVal === null) continue;
156
+
157
+ const trimmed = rawVal.trim();
158
+ if (name === "href" && /^(javascript|data):/i.test(trimmed)) {
159
+ continue;
160
+ }
161
+ snapshot[name] = trimmed.slice(0, 300);
162
+ }
163
+
164
+ return snapshot;
165
+ }
166
+
167
+ export function extractSanitizedText(el: Element, customSelectors: string[] = []): string | null {
168
+ const tagName = (el.tagName || "").toUpperCase();
169
+ if (IGNORED_TEXT_TAGS.has(tagName)) {
170
+ return null;
171
+ }
172
+
173
+ const selectors = buildExcludedSelectorList(customSelectors);
174
+ const parts: string[] = [];
175
+
176
+ function walk(node: Node): void {
177
+ if (node.nodeType === 3) {
178
+ // Node.TEXT_NODE === 3
179
+ const text = node.nodeValue || "";
180
+ if (text.trim().length > 0) {
181
+ parts.push(text.trim());
182
+ }
183
+ return;
184
+ }
185
+
186
+ if (node.nodeType === 1) {
187
+ // Node.ELEMENT_NODE === 1
188
+ const childEl = node as Element;
189
+ const childTag = (childEl.tagName || "").toUpperCase();
190
+ if (IGNORED_TEXT_TAGS.has(childTag)) return;
191
+ if (elementMatchesAny(childEl, selectors)) return;
192
+
193
+ const children = childEl.childNodes || [];
194
+ for (let i = 0; i < children.length; i += 1) {
195
+ walk(children[i]);
196
+ }
197
+ }
198
+ }
199
+
200
+ walk(el);
201
+
202
+ const joined = parts.join(" ").replace(/\s+/g, " ").trim();
203
+ if (joined.length === 0) return null;
204
+ return joined.slice(0, 500);
205
+ }
206
+
207
+ function escapeCssIdentifier(ident: string): string {
208
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
209
+ return CSS.escape(ident);
210
+ }
211
+ return ident.replace(/([^a-zA-Z0-9_-])/g, "\\$1");
212
+ }
213
+
214
+ export function computeSelector(el: Element): string {
215
+ const tag = (el.tagName || "div").toLowerCase();
216
+ const id = (el.getAttribute("id") || "").trim();
217
+ if (id) {
218
+ return `#${escapeCssIdentifier(id)}`;
219
+ }
220
+
221
+ const testId =
222
+ el.getAttribute("data-testid") ||
223
+ el.getAttribute("data-test") ||
224
+ el.getAttribute("data-cy") ||
225
+ el.getAttribute("data-qa");
226
+
227
+ if (testId && testId.trim()) {
228
+ const attrKey = el.hasAttribute("data-testid")
229
+ ? "data-testid"
230
+ : el.hasAttribute("data-test")
231
+ ? "data-test"
232
+ : el.hasAttribute("data-cy")
233
+ ? "data-cy"
234
+ : "data-qa";
235
+ return `${tag}[${attrKey}="${testId.trim().replace(/"/g, '\\"')}"]`;
236
+ }
237
+
238
+ const classAttr = (el.getAttribute("class") || "").trim();
239
+ if (classAttr) {
240
+ const classes = classAttr
241
+ .split(/\s+/)
242
+ .filter((c) => c.length > 0 && !c.startsWith("cd-"))
243
+ .slice(0, 3)
244
+ .map((c) => `.${escapeCssIdentifier(c)}`)
245
+ .join("");
246
+ if (classes) {
247
+ return `${tag}${classes}`;
248
+ }
249
+ }
250
+
251
+ return tag;
252
+ }
253
+
254
+ export function computeDomPath(el: Element): string {
255
+ const segments: string[] = [];
256
+ let current: Element | null = el;
257
+
258
+ while (current && segments.length < 15) {
259
+ const tag = (current.tagName || "").toLowerCase();
260
+ if (!tag) break;
261
+
262
+ let segment = tag;
263
+ const id = (current.getAttribute("id") || "").trim();
264
+ if (id) {
265
+ segment += `#${id}`;
266
+ } else {
267
+ const classAttr = (current.getAttribute("class") || "").trim();
268
+ if (classAttr) {
269
+ const firstClasses = classAttr
270
+ .split(/\s+/)
271
+ .filter(Boolean)
272
+ .slice(0, 2)
273
+ .join(".");
274
+ if (firstClasses) {
275
+ segment += `.${firstClasses}`;
276
+ }
277
+ }
278
+ }
279
+
280
+ segments.unshift(segment);
281
+ current = getParentOrHostElement(current);
282
+ }
283
+
284
+ return segments.join(" > ").slice(0, 1000);
285
+ }
286
+
287
+ export function computeBoundingRect(el: Element): BoundingRectSnapshot {
288
+ const rect = typeof el.getBoundingClientRect === "function"
289
+ ? el.getBoundingClientRect()
290
+ : { x: 0, y: 0, width: 0, height: 0 };
291
+
292
+ return {
293
+ x: Math.round((rect.x ?? (rect as DOMRect).left ?? 0) * 100) / 100,
294
+ y: Math.round((rect.y ?? (rect as DOMRect).top ?? 0) * 100) / 100,
295
+ width: Math.round((rect.width ?? 0) * 100) / 100,
296
+ height: Math.round((rect.height ?? 0) * 100) / 100
297
+ };
298
+ }
299
+
300
+ export function describeElementCandidate(
301
+ el: Element,
302
+ relation: ElementCandidateRelation,
303
+ depth: number,
304
+ customSelectors: string[] = []
305
+ ): ElementCandidateInfo {
306
+ const rawText = extractSanitizedText(el, customSelectors);
307
+ const textPreview = rawText
308
+ ? rawText.length > 64
309
+ ? `${rawText.slice(0, 61)}...`
310
+ : rawText
311
+ : null;
312
+
313
+ return {
314
+ element: el,
315
+ tagName: (el.tagName || "div").toLowerCase(),
316
+ selector: computeSelector(el),
317
+ domPath: computeDomPath(el),
318
+ textPreview,
319
+ rect: computeBoundingRect(el),
320
+ relation,
321
+ depth
322
+ };
323
+ }
324
+
325
+ export function resolveAncestorChain(
326
+ el: Element | null,
327
+ customSelectors: string[] = [],
328
+ maxDepth: number = 8
329
+ ): Element[] {
330
+ if (!el || !isSelectableElement(el, customSelectors)) {
331
+ return [];
332
+ }
333
+
334
+ const chain: Element[] = [];
335
+ let current: Element | null = el;
336
+
337
+ while (current && chain.length < maxDepth) {
338
+ if (isSelectableElement(current, customSelectors)) {
339
+ chain.unshift(current);
340
+ }
341
+ current = getParentOrHostElement(current);
342
+ }
343
+
344
+ return chain;
345
+ }
346
+
347
+ function isAncestorOf(possibleAncestor: Element, node: Element): boolean {
348
+ let current: Element | null = getParentOrHostElement(node);
349
+ while (current) {
350
+ if (current === possibleAncestor) return true;
351
+ current = getParentOrHostElement(current);
352
+ }
353
+ return false;
354
+ }
355
+
356
+ /**
357
+ * Builds an ordered list of candidate elements for a pointer location so users can
358
+ * disambiguate overlapping sibling layers, deeply nested children, and parent containers.
359
+ *
360
+ * Order:
361
+ * 1. Direct child elements (if the target is a container with immediate selectable children)
362
+ * 2. Primary hovered/clicked target ("target")
363
+ * 3. Overlapping non-ancestor elements at the same screen coordinate ("overlap")
364
+ * 4. DOM ancestors from immediate parent up to top-level layout section ("ancestor")
365
+ */
366
+ export function resolveCandidateStack(
367
+ primaryTarget: Element | null,
368
+ pointElements: Element[] = [],
369
+ customSelectors: string[] = [],
370
+ maxCandidates: number = 10
371
+ ): ElementCandidateInfo[] {
372
+ const seen = new Set<Element>();
373
+ const stack: ElementCandidateInfo[] = [];
374
+
375
+ const validPrimary =
376
+ primaryTarget && isSelectableElement(primaryTarget, customSelectors)
377
+ ? primaryTarget
378
+ : pointElements.find((candidate) => isSelectableElement(candidate, customSelectors)) || null;
379
+
380
+ if (!validPrimary) {
381
+ return [];
382
+ }
383
+
384
+ // 1. Primary target at index 0 so default hover/click is the exact element under pointer
385
+ seen.add(validPrimary);
386
+ stack.push(describeElementCandidate(validPrimary, "target", 0, customSelectors));
387
+
388
+ // 2. Overlapping non-ancestor elements from elementsFromPoint (e.g., elements behind an overlay or badge)
389
+ for (const ptEl of pointElements) {
390
+ if (stack.length >= maxCandidates) break;
391
+ if (seen.has(ptEl)) continue;
392
+ if (!isSelectableElement(ptEl, customSelectors)) continue;
393
+ if (!isAncestorOf(ptEl, validPrimary)) {
394
+ seen.add(ptEl);
395
+ stack.push(describeElementCandidate(ptEl, "overlap", stack.length, customSelectors));
396
+ }
397
+ }
398
+
399
+ // 3. Direct selectable children of the primary target (when a wrapper intercepts hover)
400
+ const childNodes = (validPrimary as unknown as { children?: ArrayLike<Element>; childNodes?: ArrayLike<Node> }).children ||
401
+ (validPrimary.childNodes || []);
402
+ for (let i = 0; i < childNodes.length; i += 1) {
403
+ if (stack.length >= maxCandidates) break;
404
+ const node = childNodes[i] as Element;
405
+ if (node && node.nodeType === 1 && !seen.has(node) && isSelectableElement(node, customSelectors)) {
406
+ seen.add(node);
407
+ stack.push(describeElementCandidate(node, "child", stack.length, customSelectors));
408
+ }
409
+ }
410
+
411
+ // 4. Ancestor chain walking up from validPrimary
412
+ let parent = getParentOrHostElement(validPrimary);
413
+ while (parent && stack.length < maxCandidates) {
414
+ if (!seen.has(parent) && isSelectableElement(parent, customSelectors)) {
415
+ seen.add(parent);
416
+ stack.push(describeElementCandidate(parent, "ancestor", stack.length, customSelectors));
417
+ }
418
+ parent = getParentOrHostElement(parent);
419
+ }
420
+
421
+ return stack;
422
+ }
423
+
424
+ export function captureElementSnapshot(
425
+ el: Element,
426
+ customSelectors: string[] = [],
427
+ clientRef?: string
428
+ ): ElementReferenceSnapshot | null {
429
+ if (isElementExcluded(el, customSelectors)) {
430
+ return null;
431
+ }
432
+
433
+ const boundingRect = computeBoundingRect(el);
434
+
435
+ const viewport: ViewportSnapshot = {
436
+ width: typeof window !== "undefined" ? window.innerWidth || 1024 : 1024,
437
+ height: typeof window !== "undefined" ? window.innerHeight || 768 : 768
438
+ };
439
+
440
+ const generatedRef = clientRef || `el_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
441
+
442
+ return {
443
+ client_ref: generatedRef,
444
+ tag_name: (el.tagName || "div").toLowerCase(),
445
+ selector: computeSelector(el),
446
+ dom_path: computeDomPath(el),
447
+ text_content: extractSanitizedText(el, customSelectors),
448
+ attributes_snapshot: extractSanitizedAttributes(el),
449
+ bounding_rect: boundingRect,
450
+ viewport: viewport,
451
+ metadata: {},
452
+ captured_at: new Date().toISOString()
453
+ };
454
+ }