claude4arc 0.5.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.
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/bin/claude4arc.js +405 -0
- package/extension/background.js +153 -0
- package/extension/guard.js +66 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +41 -0
- package/host/host.js +368 -0
- package/lib/blocklist.js +52 -0
- package/lib/browsers.js +56 -0
- package/lib/client.js +89 -0
- package/lib/commands.js +353 -0
- package/lib/config.js +22 -0
- package/lib/dnd.js +60 -0
- package/lib/editors.js +373 -0
- package/lib/frames.js +39 -0
- package/lib/housekeeping.js +46 -0
- package/lib/inpage.js +1381 -0
- package/lib/input.js +242 -0
- package/lib/keys.js +109 -0
- package/lib/page.js +1530 -0
- package/lib/paths.js +10 -0
- package/lib/shim.js +186 -0
- package/lib/task.js +350 -0
- package/lib/util.js +64 -0
- package/package.json +43 -0
- package/skill/SKILL.md +138 -0
package/lib/inpage.js
ADDED
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function inPageLibrary() {
|
|
4
|
+
const refs = new Map();
|
|
5
|
+
const elementRefs = new WeakMap();
|
|
6
|
+
let nextRef = 1;
|
|
7
|
+
|
|
8
|
+
const SKIP_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "META", "LINK", "HEAD", "TITLE", "BASE"]);
|
|
9
|
+
const LEAF_TAGS = new Set(["SVG", "svg", "CANVAS", "VIDEO", "AUDIO", "IMG", "INPUT", "SELECT", "TEXTAREA", "IFRAME", "FRAME"]);
|
|
10
|
+
const INTERACTIVE_ROLES = new Set([
|
|
11
|
+
"button", "link", "textbox", "searchbox", "checkbox", "radio", "switch", "combobox", "listbox",
|
|
12
|
+
"option", "slider", "spinbutton", "tab", "menuitem", "menuitemcheckbox", "menuitemradio",
|
|
13
|
+
"treeitem", "gridcell", "columnheader", "rowheader",
|
|
14
|
+
]);
|
|
15
|
+
const STRUCTURE_ROLES = new Set([
|
|
16
|
+
"heading", "img", "navigation", "main", "banner", "contentinfo", "complementary", "form",
|
|
17
|
+
"dialog", "alertdialog", "alert", "list", "listitem", "table", "row", "cell", "region",
|
|
18
|
+
"tablist", "tabpanel", "menu", "menubar", "tree", "grid", "search", "iframe", "figure", "status",
|
|
19
|
+
]);
|
|
20
|
+
const NAME_FROM_CONTENT = new Set([
|
|
21
|
+
"button", "link", "heading", "tab", "option", "menuitem", "menuitemcheckbox", "menuitemradio",
|
|
22
|
+
"treeitem", "cell", "gridcell", "columnheader", "rowheader", "checkbox", "radio", "switch",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const clean = (text, max = 100) => {
|
|
26
|
+
const value = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
27
|
+
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const refFor = (element) => {
|
|
31
|
+
let ref = elementRefs.get(element);
|
|
32
|
+
if (!ref) {
|
|
33
|
+
ref = nextRef++;
|
|
34
|
+
elementRefs.set(element, ref);
|
|
35
|
+
refs.set(ref, new WeakRef(element));
|
|
36
|
+
}
|
|
37
|
+
return ref;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const elementForRef = (ref) => {
|
|
41
|
+
const element = refs.get(Number(ref))?.deref();
|
|
42
|
+
if (!element || !element.isConnected) {
|
|
43
|
+
throw new Error(`Ref @${ref} is stale or unknown. Take a new snapshot.`);
|
|
44
|
+
}
|
|
45
|
+
return element;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const inputRole = (element) => {
|
|
49
|
+
const type = (element.getAttribute("type") || "text").toLowerCase();
|
|
50
|
+
if (["button", "submit", "reset", "image"].includes(type)) return "button";
|
|
51
|
+
if (type === "checkbox") return element.getAttribute("role") === "switch" ? "switch" : "checkbox";
|
|
52
|
+
if (type === "radio") return "radio";
|
|
53
|
+
if (type === "range") return "slider";
|
|
54
|
+
if (type === "number") return "spinbutton";
|
|
55
|
+
if (type === "search") return element.hasAttribute("list") ? "combobox" : "searchbox";
|
|
56
|
+
if (type === "hidden") return null;
|
|
57
|
+
if (type === "file") return "button";
|
|
58
|
+
if (["color", "date", "datetime-local", "month", "time", "week"].includes(type)) return "textbox";
|
|
59
|
+
return element.hasAttribute("list") ? "combobox" : "textbox";
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const roleOf = (element) => {
|
|
63
|
+
const explicit = element.getAttribute("role")?.trim().split(/\s+/)[0];
|
|
64
|
+
if (explicit && explicit !== "none" && explicit !== "presentation" && explicit !== "generic") return explicit;
|
|
65
|
+
if (explicit === "none" || explicit === "presentation") return null;
|
|
66
|
+
const tag = element.tagName.toUpperCase();
|
|
67
|
+
switch (tag) {
|
|
68
|
+
case "A":
|
|
69
|
+
case "AREA":
|
|
70
|
+
return element.hasAttribute("href") ? "link" : null;
|
|
71
|
+
case "BUTTON":
|
|
72
|
+
case "SUMMARY":
|
|
73
|
+
return "button";
|
|
74
|
+
case "INPUT":
|
|
75
|
+
return inputRole(element);
|
|
76
|
+
case "SELECT":
|
|
77
|
+
return element.multiple || element.size > 1 ? "listbox" : "combobox";
|
|
78
|
+
case "TEXTAREA":
|
|
79
|
+
return "textbox";
|
|
80
|
+
case "OPTION":
|
|
81
|
+
return "option";
|
|
82
|
+
case "H1": case "H2": case "H3": case "H4": case "H5": case "H6":
|
|
83
|
+
return "heading";
|
|
84
|
+
case "IMG":
|
|
85
|
+
return element.getAttribute("alt") === "" ? null : "img";
|
|
86
|
+
case "NAV":
|
|
87
|
+
return "navigation";
|
|
88
|
+
case "MAIN":
|
|
89
|
+
return "main";
|
|
90
|
+
case "HEADER":
|
|
91
|
+
return element.closest("article, aside, main, nav, section") ? null : "banner";
|
|
92
|
+
case "FOOTER":
|
|
93
|
+
return element.closest("article, aside, main, nav, section") ? null : "contentinfo";
|
|
94
|
+
case "ASIDE":
|
|
95
|
+
return "complementary";
|
|
96
|
+
case "FORM":
|
|
97
|
+
return element.hasAttribute("name") || element.hasAttribute("aria-label") ? "form" : null;
|
|
98
|
+
case "DIALOG":
|
|
99
|
+
return "dialog";
|
|
100
|
+
case "UL": case "OL": case "MENU":
|
|
101
|
+
return "list";
|
|
102
|
+
case "LI":
|
|
103
|
+
return "listitem";
|
|
104
|
+
case "TABLE":
|
|
105
|
+
return "table";
|
|
106
|
+
case "TR":
|
|
107
|
+
return "row";
|
|
108
|
+
case "TD":
|
|
109
|
+
return "cell";
|
|
110
|
+
case "TH":
|
|
111
|
+
return "columnheader";
|
|
112
|
+
case "IFRAME": case "FRAME":
|
|
113
|
+
return "iframe";
|
|
114
|
+
case "FIGURE":
|
|
115
|
+
return "figure";
|
|
116
|
+
case "SECTION":
|
|
117
|
+
return element.hasAttribute("aria-label") || element.hasAttribute("aria-labelledby") ? "region" : null;
|
|
118
|
+
case "SVG":
|
|
119
|
+
return element.getAttribute("aria-label") || element.querySelector(":scope > title") ? "img" : null;
|
|
120
|
+
}
|
|
121
|
+
if (element.isContentEditable && !element.parentElement?.isContentEditable) return "textbox";
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const nameOf = (element, role) => {
|
|
126
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
127
|
+
if (labelledBy) {
|
|
128
|
+
const text = labelledBy
|
|
129
|
+
.split(/\s+/)
|
|
130
|
+
.map((id) => element.ownerDocument.getElementById(id)?.innerText ?? "")
|
|
131
|
+
.join(" ");
|
|
132
|
+
if (clean(text)) return clean(text);
|
|
133
|
+
}
|
|
134
|
+
const ariaLabel = element.getAttribute("aria-label");
|
|
135
|
+
if (ariaLabel && clean(ariaLabel)) return clean(ariaLabel);
|
|
136
|
+
const tag = element.tagName.toUpperCase();
|
|
137
|
+
if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") {
|
|
138
|
+
const type = (element.getAttribute("type") || "").toLowerCase();
|
|
139
|
+
if (["button", "submit", "reset"].includes(type)) return clean(element.value || type);
|
|
140
|
+
if (type === "image") return clean(element.alt || element.value || "image");
|
|
141
|
+
const label = [...(element.labels ?? [])].map((node) => node.innerText).join(" ");
|
|
142
|
+
if (clean(label)) return clean(label);
|
|
143
|
+
if (element.placeholder) return clean(element.placeholder);
|
|
144
|
+
if (element.title) return clean(element.title);
|
|
145
|
+
if (type === "file") return "Choose file";
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
if (tag === "IMG") return clean(element.alt || element.title);
|
|
149
|
+
if (tag === "SVG") return clean(element.querySelector(":scope > title")?.textContent);
|
|
150
|
+
if (tag === "IFRAME" || tag === "FRAME") return clean(element.title || element.name || element.src, 80);
|
|
151
|
+
if (NAME_FROM_CONTENT.has(role)) {
|
|
152
|
+
const text = clean(element.innerText || element.textContent);
|
|
153
|
+
if (text) return text;
|
|
154
|
+
const image = element.querySelector("img[alt], svg[aria-label], [aria-label]");
|
|
155
|
+
if (image) return clean(image.getAttribute("alt") || image.getAttribute("aria-label"));
|
|
156
|
+
}
|
|
157
|
+
return clean(element.title);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const frameOffset = (element) => {
|
|
161
|
+
let x = 0;
|
|
162
|
+
let y = 0;
|
|
163
|
+
let view = element.ownerDocument.defaultView;
|
|
164
|
+
while (view && view !== window) {
|
|
165
|
+
const frame = view.frameElement;
|
|
166
|
+
if (!frame) break;
|
|
167
|
+
const rect = frame.getBoundingClientRect();
|
|
168
|
+
const style = view.parent.getComputedStyle(frame);
|
|
169
|
+
x += rect.left + frame.clientLeft + parseFloat(style.paddingLeft);
|
|
170
|
+
y += rect.top + frame.clientTop + parseFloat(style.paddingTop);
|
|
171
|
+
view = view.parent;
|
|
172
|
+
}
|
|
173
|
+
return { x, y };
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const viewportRect = (element) => {
|
|
177
|
+
const rect = element.getBoundingClientRect();
|
|
178
|
+
const offset = frameOffset(element);
|
|
179
|
+
return { x: rect.left + offset.x, y: rect.top + offset.y, width: rect.width, height: rect.height };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const inViewport = (rect) =>
|
|
183
|
+
rect.width > 0 &&
|
|
184
|
+
rect.height > 0 &&
|
|
185
|
+
rect.x < window.innerWidth &&
|
|
186
|
+
rect.y < window.innerHeight &&
|
|
187
|
+
rect.x + rect.width > 0 &&
|
|
188
|
+
rect.y + rect.height > 0;
|
|
189
|
+
|
|
190
|
+
const isVisible = (element) => {
|
|
191
|
+
if (typeof element.checkVisibility === "function") {
|
|
192
|
+
return element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true });
|
|
193
|
+
}
|
|
194
|
+
const rect = element.getBoundingClientRect();
|
|
195
|
+
return rect.width > 0 || rect.height > 0;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const closedRoots = new WeakMap();
|
|
199
|
+
const probedHosts = new WeakSet();
|
|
200
|
+
|
|
201
|
+
const shadowOf = (element) => element.shadowRoot ?? closedRoots.get(element) ?? null;
|
|
202
|
+
|
|
203
|
+
const childNodesOf = (node) => {
|
|
204
|
+
if (node.tagName === "SLOT") {
|
|
205
|
+
const assigned = node.assignedNodes({ flatten: true });
|
|
206
|
+
return assigned.length ? assigned : [...node.childNodes];
|
|
207
|
+
}
|
|
208
|
+
const shadow = node.nodeType === Node.ELEMENT_NODE ? shadowOf(node) : null;
|
|
209
|
+
if (shadow) return [...shadow.childNodes];
|
|
210
|
+
return [...node.childNodes];
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const compactHref = (element) => {
|
|
214
|
+
const raw = element.getAttribute("href");
|
|
215
|
+
if (!raw || raw.startsWith("javascript:") || raw === "#") return null;
|
|
216
|
+
let url;
|
|
217
|
+
try {
|
|
218
|
+
url = new URL(element.href);
|
|
219
|
+
} catch {
|
|
220
|
+
return clean(raw, 50);
|
|
221
|
+
}
|
|
222
|
+
const text = url.origin === location.origin ? url.pathname + url.search + url.hash : url.host + (url.pathname === "/" ? "" : url.pathname);
|
|
223
|
+
return clean(text, 50);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const placeholderOf = (element, name) => {
|
|
227
|
+
const placeholder = element.getAttribute?.("placeholder");
|
|
228
|
+
if (!placeholder || element.value || clean(placeholder) === name) return "";
|
|
229
|
+
return ` placeholder="${clean(placeholder, 60).replace(/"/g, "'")}"`;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const multiline = (text, max = 80) => {
|
|
233
|
+
const value = String(text ?? "").replace(/\u00a0/g, " ").replace(/\n+$/, "").replace(/\n/g, "\\n");
|
|
234
|
+
return value.length > max ? value.slice(0, max - 1) + "…" : value;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const stateOf = (element, role) => {
|
|
238
|
+
const flags = [];
|
|
239
|
+
let value = null;
|
|
240
|
+
const tag = element.tagName.toUpperCase();
|
|
241
|
+
if (tag === "INPUT" || tag === "TEXTAREA") {
|
|
242
|
+
const type = (element.getAttribute("type") || "").toLowerCase();
|
|
243
|
+
if (type === "checkbox" || type === "radio") {
|
|
244
|
+
if (element.checked) flags.push("checked");
|
|
245
|
+
} else if (type === "file") {
|
|
246
|
+
if (element.files?.length) flags.push(`${element.files.length} file${element.files.length > 1 ? "s" : ""}`);
|
|
247
|
+
} else if (!["button", "submit", "reset", "image"].includes(type) && element.value) {
|
|
248
|
+
value = type === "password" ? "••••" : tag === "TEXTAREA" ? multiline(element.value) : clean(element.value, 60);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (tag === "SELECT") value = [...element.selectedOptions].map((option) => clean(option.label, 30)).join(", ");
|
|
252
|
+
if (role === "textbox" && element.isContentEditable && element.innerText.trim()) value = multiline(element.innerText);
|
|
253
|
+
const checked = element.getAttribute("aria-checked");
|
|
254
|
+
if (checked === "true" && tag !== "INPUT") flags.push("checked");
|
|
255
|
+
if (checked === "mixed") flags.push("mixed");
|
|
256
|
+
const expanded = element.getAttribute("aria-expanded");
|
|
257
|
+
if (expanded) flags.push(expanded === "true" ? "expanded" : "collapsed");
|
|
258
|
+
if (element.getAttribute("aria-selected") === "true") flags.push("selected");
|
|
259
|
+
if (element.getAttribute("aria-pressed") === "true") flags.push("pressed");
|
|
260
|
+
if (element.disabled || element.getAttribute("aria-disabled") === "true") flags.push("disabled");
|
|
261
|
+
if (element === element.ownerDocument.activeElement && element !== element.ownerDocument.body) flags.push("focused");
|
|
262
|
+
let text = flags.length ? ` [${flags.join(",")}]` : "";
|
|
263
|
+
if (value !== null) text += ` ="${value.replace(/"/g, "'")}"`;
|
|
264
|
+
if (role === "link") {
|
|
265
|
+
const href = compactHref(element);
|
|
266
|
+
const name = nameOf(element, role).toLowerCase();
|
|
267
|
+
const rawTail = (href ?? "").split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? "";
|
|
268
|
+
let tail = rawTail;
|
|
269
|
+
try {
|
|
270
|
+
tail = decodeURIComponent(rawTail);
|
|
271
|
+
} catch {}
|
|
272
|
+
tail = tail.toLowerCase().replace(/[-_]+/g, " ");
|
|
273
|
+
const redundant = href && tail && name && (name === tail || (tail.length > 3 && name.includes(tail)));
|
|
274
|
+
if (href && !redundant) text += ` →${href}`;
|
|
275
|
+
}
|
|
276
|
+
return text;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const isInteractive = (element, role, style) => {
|
|
280
|
+
if (role && INTERACTIVE_ROLES.has(role)) return true;
|
|
281
|
+
if (element.hasAttribute("onclick")) return true;
|
|
282
|
+
const tabIndex = element.getAttribute("tabindex");
|
|
283
|
+
if (tabIndex !== null && Number(tabIndex) >= 0) return true;
|
|
284
|
+
if (style && style.cursor === "pointer" && !role) {
|
|
285
|
+
const parent = element.parentElement;
|
|
286
|
+
const parentCursor = parent ? parent.ownerDocument.defaultView.getComputedStyle(parent).cursor : "auto";
|
|
287
|
+
if (parentCursor !== "pointer") return true;
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const QUIET_ROLES = new Set(["list", "listitem", "row", "cell", "figure", "region", "table", "status", "menu", "tree", "grid", "tabpanel"]);
|
|
293
|
+
|
|
294
|
+
const collect = ({ scope = "viewport", root } = {}) => {
|
|
295
|
+
const entries = [];
|
|
296
|
+
const displays = new Map();
|
|
297
|
+
const displayOf = (element) => {
|
|
298
|
+
let display = displays.get(element);
|
|
299
|
+
if (display === undefined) {
|
|
300
|
+
display = element.ownerDocument.defaultView.getComputedStyle(element).display;
|
|
301
|
+
displays.set(element, display);
|
|
302
|
+
}
|
|
303
|
+
return display;
|
|
304
|
+
};
|
|
305
|
+
const blockOf = (element) => {
|
|
306
|
+
let node = element;
|
|
307
|
+
while (node && node.nodeType === Node.ELEMENT_NODE) {
|
|
308
|
+
const display = displayOf(node);
|
|
309
|
+
if (!display.startsWith("inline") && display !== "contents") return node;
|
|
310
|
+
node = node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
311
|
+
}
|
|
312
|
+
return node;
|
|
313
|
+
};
|
|
314
|
+
const viewportOnly = scope === "viewport";
|
|
315
|
+
const start = root ? elementForRef(String(root).replace(/^@|^ref=/, "")) : document.body ?? document.documentElement;
|
|
316
|
+
|
|
317
|
+
const walk = (node, depth, insideNamed) => {
|
|
318
|
+
if (entries.length > 20_000) return;
|
|
319
|
+
if (!node) return;
|
|
320
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
321
|
+
if (insideNamed) return;
|
|
322
|
+
const text = clean(node.textContent, 160);
|
|
323
|
+
if (!text) return;
|
|
324
|
+
const parent = node.parentElement;
|
|
325
|
+
if (viewportOnly && parent && !inViewport(viewportRect(parent))) return;
|
|
326
|
+
const previous = entries.at(-1);
|
|
327
|
+
if (previous && !previous.element && previous.text === text) return;
|
|
328
|
+
entries.push({ depth, text, element: null, parent, inline: true, block: parent ? blockOf(parent) : null });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
332
|
+
for (const child of node.childNodes) walk(child, depth, insideNamed);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
|
336
|
+
const element = node;
|
|
337
|
+
const tag = element.tagName.toUpperCase();
|
|
338
|
+
if (SKIP_TAGS.has(tag)) return;
|
|
339
|
+
if (element.getAttribute("aria-hidden") === "true") return;
|
|
340
|
+
if (element.hasAttribute("data-arc-for-claude")) return;
|
|
341
|
+
const style = element.ownerDocument.defaultView.getComputedStyle(element);
|
|
342
|
+
const isContents = style.display === "contents";
|
|
343
|
+
if (!isContents && !isVisible(element)) return;
|
|
344
|
+
|
|
345
|
+
const role = roleOf(element);
|
|
346
|
+
const interactive = isInteractive(element, role, style);
|
|
347
|
+
if (!interactive && !isContents) {
|
|
348
|
+
const box = element.getBoundingClientRect();
|
|
349
|
+
const clipped = style.overflow === "hidden" || style.clip !== "auto" || style.clipPath !== "none";
|
|
350
|
+
if (box.width <= 1 && box.height <= 1 && clipped && !element.querySelector("a[href], button, input, select, textarea, [tabindex]")) return;
|
|
351
|
+
}
|
|
352
|
+
const rect = isContents ? null : viewportRect(element);
|
|
353
|
+
const visibleHere = !viewportOnly || !rect || inViewport(rect);
|
|
354
|
+
|
|
355
|
+
if (tag === "IFRAME" || tag === "FRAME") {
|
|
356
|
+
if (!visibleHere) return;
|
|
357
|
+
let inner = null;
|
|
358
|
+
try {
|
|
359
|
+
inner = element.contentDocument?.body ?? null;
|
|
360
|
+
} catch {}
|
|
361
|
+
const name = nameOf(element, "iframe");
|
|
362
|
+
entries.push({ depth, element, text: `iframe "${name}"${inner ? "" : " (cross-origin)"}` });
|
|
363
|
+
if (inner) walk(inner, depth + 1, false);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
let childDepth = depth;
|
|
368
|
+
let named = insideNamed || (tag === "LABEL" && Boolean(element.control) && isVisible(element.control));
|
|
369
|
+
const structural = role && STRUCTURE_ROLES.has(role);
|
|
370
|
+
if ((interactive || structural) && visibleHere) {
|
|
371
|
+
const name = nameOf(element, role || "generic");
|
|
372
|
+
const quiet = !interactive && !name && QUIET_ROLES.has(role);
|
|
373
|
+
const bareImage = role === "img" && !interactive && (!name || insideNamed);
|
|
374
|
+
if (!quiet && !bareImage) {
|
|
375
|
+
let label = role || "clickable";
|
|
376
|
+
if (role === "heading") label = `h${element.getAttribute("aria-level") || tag.match(/^H(\d)$/)?.[1] || ""}`;
|
|
377
|
+
entries.push({
|
|
378
|
+
depth,
|
|
379
|
+
element,
|
|
380
|
+
role,
|
|
381
|
+
name,
|
|
382
|
+
text: `${label}${name ? ` "${name.replace(/"/g, "'")}"` : ""}${stateOf(element, role)}${placeholderOf(element, name)}`,
|
|
383
|
+
inline: style.display.startsWith("inline"),
|
|
384
|
+
block: element.parentElement ? blockOf(element.parentElement) : null,
|
|
385
|
+
});
|
|
386
|
+
childDepth = depth + 1;
|
|
387
|
+
if (name && (NAME_FROM_CONTENT.has(role) || interactive)) named = true;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (LEAF_TAGS.has(element.tagName)) return;
|
|
391
|
+
if (role === "textbox" && element.isContentEditable && element.innerText.trim().length <= 80) return;
|
|
392
|
+
for (const child of childNodesOf(element)) walk(child, childDepth, named);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
walk(start, 0, false);
|
|
396
|
+
return entries;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const formatEntry = (entry) => " ".repeat(entry.depth) + (entry.element ? `@${refFor(entry.element)} ${entry.text}` : entry.text);
|
|
400
|
+
|
|
401
|
+
const isInlineLeaf = (entries, index) => {
|
|
402
|
+
const entry = entries[index];
|
|
403
|
+
if (!entry.inline) return false;
|
|
404
|
+
if (!entry.element) return true;
|
|
405
|
+
if (entry.role !== "link" && entry.role !== "button") return false;
|
|
406
|
+
const next = entries[index + 1];
|
|
407
|
+
return !next || next.depth <= entry.depth;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const formatEntries = (entries) => {
|
|
411
|
+
const lines = [];
|
|
412
|
+
let index = 0;
|
|
413
|
+
while (index < entries.length) {
|
|
414
|
+
const first = entries[index];
|
|
415
|
+
let end = index;
|
|
416
|
+
while (
|
|
417
|
+
end < entries.length &&
|
|
418
|
+
entries[end].depth === first.depth &&
|
|
419
|
+
entries[end].block === first.block &&
|
|
420
|
+
isInlineLeaf(entries, end)
|
|
421
|
+
) {
|
|
422
|
+
end++;
|
|
423
|
+
}
|
|
424
|
+
const run = entries.slice(index, end);
|
|
425
|
+
if (run.length >= 2 && run.some((entry) => !entry.element)) {
|
|
426
|
+
const text = run
|
|
427
|
+
.map((entry) => (entry.element ? `[${entry.name || entry.role}](@${refFor(entry.element)})` : entry.text))
|
|
428
|
+
.join(" ")
|
|
429
|
+
.replace(/ ([,.;:!?)\]])/g, "$1")
|
|
430
|
+
.replace(/([(\[]) /g, "$1");
|
|
431
|
+
lines.push(" ".repeat(first.depth) + (text.length > 300 ? text.slice(0, 299) + "…" : text));
|
|
432
|
+
index = end;
|
|
433
|
+
} else {
|
|
434
|
+
lines.push(formatEntry(first));
|
|
435
|
+
index++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return lines;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
const header = () =>
|
|
442
|
+
`${clean(document.title, 100) || "(untitled)"} | ${clean(location.href, 120)} | ${window.innerWidth}x${window.innerHeight} y=${Math.round(window.scrollY)}/${document.documentElement.scrollHeight}`;
|
|
443
|
+
|
|
444
|
+
const previousSnapshots = new Map();
|
|
445
|
+
|
|
446
|
+
const snapshot = ({ scope = "viewport", root, maxLines = 300, diff = false } = {}) => {
|
|
447
|
+
const lines = formatEntries(collect({ scope, root }));
|
|
448
|
+
const key = `${scope}:${root ?? ""}`;
|
|
449
|
+
const previous = previousSnapshots.get(key);
|
|
450
|
+
previousSnapshots.set(key, lines);
|
|
451
|
+
const head = header() + (root ? ` | subtree ${root}` : scope !== "viewport" ? ` | ${scope}` : "");
|
|
452
|
+
if (diff && previous) {
|
|
453
|
+
const counts = new Map();
|
|
454
|
+
for (const line of previous) counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
455
|
+
const added = [];
|
|
456
|
+
for (const line of lines) {
|
|
457
|
+
const count = counts.get(line) ?? 0;
|
|
458
|
+
if (count > 0) counts.set(line, count - 1);
|
|
459
|
+
else added.push(line);
|
|
460
|
+
}
|
|
461
|
+
let removed = [...counts.entries()].flatMap(([line, count]) => Array(count).fill(line));
|
|
462
|
+
if (!added.length && !removed.length) return `${head}\n(no change since last snapshot)`;
|
|
463
|
+
if (added.length < lines.length * 0.6) {
|
|
464
|
+
const refOf = (line) => line.trimStart().match(/^@(\d+) /)?.[1];
|
|
465
|
+
const removedRefs = new Set(removed.map(refOf).filter(Boolean));
|
|
466
|
+
const changedRefs = new Set(added.map(refOf).filter((ref) => ref && removedRefs.has(ref)));
|
|
467
|
+
removed = removed.filter((line) => !changedRefs.has(refOf(line)));
|
|
468
|
+
const body = [
|
|
469
|
+
...added.slice(0, maxLines).map((line) => (changedRefs.has(refOf(line)) ? "~ " : "+ ") + line.trimStart()),
|
|
470
|
+
...removed.slice(0, 20).map((line) => "- " + line.trimStart()),
|
|
471
|
+
];
|
|
472
|
+
if (removed.length > 20) body.push(`- … ${removed.length - 20} more removed`);
|
|
473
|
+
return `${head}\n(diff: ${changedRefs.size} changed, ${added.length - changedRefs.size} new, ${removed.length} gone)\n${body.join("\n")}`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const shown = lines.slice(0, maxLines);
|
|
477
|
+
if (lines.length > maxLines) shown.push(`… ${lines.length - maxLines} more lines. Use find(), scroll, or { root: "@N" }.`);
|
|
478
|
+
if (!shown.length) shown.push("(nothing visible)");
|
|
479
|
+
return `${head}\n${shown.join("\n")}`;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const find = (query, { limit = 15 } = {}) => {
|
|
483
|
+
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
484
|
+
const matches = [];
|
|
485
|
+
const seen = new Set();
|
|
486
|
+
for (const entry of collect({ scope: "full_page" })) {
|
|
487
|
+
const haystack = entry.text.toLowerCase();
|
|
488
|
+
if (!words.every((word) => haystack.includes(word))) continue;
|
|
489
|
+
const element = entry.element ?? entry.parent;
|
|
490
|
+
if (!element || seen.has(element)) continue;
|
|
491
|
+
seen.add(element);
|
|
492
|
+
const off = inViewport(viewportRect(element)) ? "" : " (offscreen)";
|
|
493
|
+
if (entry.element) {
|
|
494
|
+
matches.push(`@${refFor(element)} ${entry.text}${off}`);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const at = haystack.indexOf(words[0]);
|
|
498
|
+
const from = Math.max(0, at - 30);
|
|
499
|
+
const excerpt = (from > 0 ? "…" : "") + entry.text.slice(from, from + 90) + (from + 90 < entry.text.length ? "…" : "");
|
|
500
|
+
matches.push(`@${refFor(element)} ${element.tagName.toLowerCase()}: ${excerpt}${off}`);
|
|
501
|
+
}
|
|
502
|
+
if (!matches.length) {
|
|
503
|
+
const modal = deepQuery("dialog[open], [role=dialog], [role=alertdialog]").find(isVisible);
|
|
504
|
+
return modal ? `no match for "${query}" — a modal is open: ${describe(modal)}. Close it first.` : `no match for "${query}"`;
|
|
505
|
+
}
|
|
506
|
+
const shown = matches.slice(0, limit);
|
|
507
|
+
if (matches.length > limit) shown.push(`… ${matches.length - limit} more`);
|
|
508
|
+
return shown.join("\n");
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const allRoots = () => {
|
|
512
|
+
const roots = [];
|
|
513
|
+
const visit = (root) => {
|
|
514
|
+
roots.push(root);
|
|
515
|
+
const walker = (root.ownerDocument ?? root).createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
|
|
516
|
+
let node = walker.currentNode;
|
|
517
|
+
while (node) {
|
|
518
|
+
const shadow = shadowOf(node);
|
|
519
|
+
if (shadow) visit(shadow);
|
|
520
|
+
if (node.tagName === "IFRAME" || node.tagName === "FRAME") {
|
|
521
|
+
try {
|
|
522
|
+
if (node.contentDocument) visit(node.contentDocument);
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
node = walker.nextNode();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
visit(document);
|
|
529
|
+
return roots;
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const deepQuery = (css) => allRoots().flatMap((root) => [...root.querySelectorAll(css)]);
|
|
533
|
+
|
|
534
|
+
const composedParent = (node) => node.parentElement ?? node.parentNode?.host ?? null;
|
|
535
|
+
|
|
536
|
+
const composedContains = (ancestor, node) => {
|
|
537
|
+
for (let current = node; current; current = composedParent(current)) {
|
|
538
|
+
if (current === ancestor) return true;
|
|
539
|
+
}
|
|
540
|
+
return false;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const closedHostCandidates = (limit) => {
|
|
544
|
+
const found = [];
|
|
545
|
+
for (const root of allRoots()) {
|
|
546
|
+
for (const element of root.querySelectorAll("*")) {
|
|
547
|
+
if (!element.localName.includes("-") || element.shadowRoot || closedRoots.has(element) || probedHosts.has(element)) continue;
|
|
548
|
+
const box = element.getBoundingClientRect();
|
|
549
|
+
if (box.width < 8 || box.height < 8) continue;
|
|
550
|
+
if ([...element.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim())) continue;
|
|
551
|
+
if ([...element.children].some((child) => child.getClientRects().length)) continue;
|
|
552
|
+
found.push(element);
|
|
553
|
+
if (found.length >= limit) return found;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return found;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const takeClosedHostCandidates = () => {
|
|
560
|
+
const hosts = closedHostCandidates(50);
|
|
561
|
+
for (const host of hosts) probedHosts.add(host);
|
|
562
|
+
return hosts.length ? hosts : null;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const registerClosedRoot = (root) => {
|
|
566
|
+
if (!root?.host) return false;
|
|
567
|
+
closedRoots.set(root.host, root);
|
|
568
|
+
return true;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const textOf = (element) => {
|
|
572
|
+
const tag = element.tagName.toUpperCase();
|
|
573
|
+
if (tag === "INPUT" && ["button", "submit", "reset"].includes(element.type)) return element.value;
|
|
574
|
+
return element.innerText ?? element.textContent ?? "";
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const parseQuoted = (raw) => {
|
|
578
|
+
const value = raw.trim();
|
|
579
|
+
const match = value.match(/^(["'])([\s\S]*)\1$/);
|
|
580
|
+
return match ? { text: match[2], exact: true } : { text: value, exact: false };
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const textMatcher = (raw) => {
|
|
584
|
+
const { text, exact } = parseQuoted(raw);
|
|
585
|
+
const needle = exact ? text : clean(text, 10_000).toLowerCase();
|
|
586
|
+
return (value) => {
|
|
587
|
+
const hay = exact ? clean(value, 10_000) : clean(value, 10_000).toLowerCase();
|
|
588
|
+
return exact ? hay === clean(needle, 10_000) : hay.includes(needle);
|
|
589
|
+
};
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const deepest = (elements) => {
|
|
593
|
+
const hasMatchingDescendant = new Set();
|
|
594
|
+
for (const element of elements) {
|
|
595
|
+
let node = element.parentNode;
|
|
596
|
+
while (node) {
|
|
597
|
+
if (hasMatchingDescendant.has(node)) break;
|
|
598
|
+
hasMatchingDescendant.add(node);
|
|
599
|
+
node = node.parentNode ?? node.host ?? null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return elements.filter((element) => !hasMatchingDescendant.has(element));
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const byText = (raw) => {
|
|
606
|
+
const matches = textMatcher(raw);
|
|
607
|
+
const candidates = deepQuery("*").filter((element) => {
|
|
608
|
+
const tag = element.tagName.toUpperCase();
|
|
609
|
+
if (SKIP_TAGS.has(tag) || tag === "HTML") return false;
|
|
610
|
+
return matches(textOf(element)) || matches(element.getAttribute("aria-label") ?? "");
|
|
611
|
+
});
|
|
612
|
+
return deepest(candidates);
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const ROLE_FAMILIES = {
|
|
616
|
+
textbox: ["textbox", "searchbox", "combobox"],
|
|
617
|
+
searchbox: ["textbox", "searchbox", "combobox"],
|
|
618
|
+
combobox: ["textbox", "searchbox", "combobox"],
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
const byRole = (raw) => {
|
|
622
|
+
const match = raw.match(/^([a-z]+)\s*(?:\[\s*name\s*([*^$]?=)\s*(["']?)([\s\S]*?)\3\s*\])?\s*$/i);
|
|
623
|
+
if (!match) throw new Error(`Cannot parse role selector: ${raw}`);
|
|
624
|
+
const [, role, operator, , name] = match;
|
|
625
|
+
const family = ROLE_FAMILIES[role.toLowerCase()] ?? [role.toLowerCase()];
|
|
626
|
+
return deepQuery("*").filter((element) => {
|
|
627
|
+
if (!family.includes(roleOf(element))) return false;
|
|
628
|
+
if (name === undefined) return true;
|
|
629
|
+
const actual = nameOf(element, role.toLowerCase()) || clean(textOf(element));
|
|
630
|
+
if (operator === "*=") return actual.toLowerCase().includes(name.toLowerCase());
|
|
631
|
+
if (operator === "^=") return actual.toLowerCase().startsWith(name.toLowerCase());
|
|
632
|
+
if (operator === "$=") return actual.toLowerCase().endsWith(name.toLowerCase());
|
|
633
|
+
return actual === name;
|
|
634
|
+
});
|
|
635
|
+
};
|
|
636
|
+
|
|
637
|
+
const byXPath = (expression) => {
|
|
638
|
+
const result = document.evaluate(expression, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
639
|
+
const nodes = [];
|
|
640
|
+
for (let index = 0; index < result.snapshotLength; index++) {
|
|
641
|
+
const node = result.snapshotItem(index);
|
|
642
|
+
if (node.nodeType === Node.ELEMENT_NODE) nodes.push(node);
|
|
643
|
+
}
|
|
644
|
+
return nodes;
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const queryAll = (selector) => {
|
|
648
|
+
let source = String(selector).trim();
|
|
649
|
+
let nth = null;
|
|
650
|
+
const nthMatch = source.match(/\s*>>\s*nth=(-?\d+)\s*$/);
|
|
651
|
+
if (nthMatch) {
|
|
652
|
+
nth = Number(nthMatch[1]);
|
|
653
|
+
source = source.slice(0, nthMatch.index);
|
|
654
|
+
}
|
|
655
|
+
let elements;
|
|
656
|
+
const refMatch = source.match(/^(?:@|ref=)(\d+)$/);
|
|
657
|
+
if (refMatch) elements = [elementForRef(refMatch[1])];
|
|
658
|
+
else if (source.startsWith("text=")) elements = byText(source.slice(5));
|
|
659
|
+
else if (source.startsWith("loc=role:")) elements = byRole(source.slice(9));
|
|
660
|
+
else if (source.startsWith("role=")) elements = byRole(source.slice(5));
|
|
661
|
+
else if (source.startsWith("loc=href:")) {
|
|
662
|
+
const needle = parseQuoted(source.slice(9)).text;
|
|
663
|
+
elements = deepQuery("a[href], area[href]").filter((element) => element.getAttribute("href").includes(needle) || element.href.includes(needle));
|
|
664
|
+
} else if (source.startsWith("xpath=")) elements = byXPath(source.slice(6));
|
|
665
|
+
else elements = cssQuery(source.replace(/^loc=css:|^css=/, "").trim());
|
|
666
|
+
if (nth !== null) {
|
|
667
|
+
const picked = nth < 0 ? elements.at(nth) : elements[nth];
|
|
668
|
+
elements = picked ? [picked] : [];
|
|
669
|
+
}
|
|
670
|
+
return elements;
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const TEXT_PSEUDO = /:(has-text|text-is)\((["'])([\s\S]*?)\2\)/;
|
|
674
|
+
|
|
675
|
+
const textTest = (kind, value) => {
|
|
676
|
+
if (kind === "text-is") return (element) => clean(textOf(element), 10_000) === clean(value, 10_000);
|
|
677
|
+
const matches = textMatcher(value);
|
|
678
|
+
return (element) => matches(textOf(element));
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
const cssQuery = (css, roots = null) => {
|
|
682
|
+
const query = (selector) =>
|
|
683
|
+
roots ? [...new Set(roots.flatMap((root) => [...root.querySelectorAll(`:scope${selector}`)]))] : deepQuery(selector || "*");
|
|
684
|
+
const match = css.match(TEXT_PSEUDO);
|
|
685
|
+
if (!match) return query(css);
|
|
686
|
+
let found = query(css.slice(0, match.index)).filter(textTest(match[1], match[3]));
|
|
687
|
+
let rest = css.slice(match.index + match[0].length);
|
|
688
|
+
const sameElement = rest.match(/^[^\s>+~]+/)?.[0];
|
|
689
|
+
if (sameElement) {
|
|
690
|
+
found = found.filter((element) => element.matches(`*${sameElement}`));
|
|
691
|
+
rest = rest.slice(sameElement.length);
|
|
692
|
+
}
|
|
693
|
+
return rest.trim() ? cssQuery(rest, found) : found;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const hiddenHint = (element) => {
|
|
697
|
+
for (let node = element; node && node !== document.body; node = node.parentElement) {
|
|
698
|
+
if (node.getAttribute?.("role") === "tabpanel" && !isVisible(node)) {
|
|
699
|
+
const labelled = node.getAttribute("aria-labelledby");
|
|
700
|
+
const tab =
|
|
701
|
+
(node.id && document.querySelector(`[role=tab][aria-controls="${CSS.escape(node.id)}"]`)) ||
|
|
702
|
+
(labelled && document.getElementById(labelled.split(/\s+/)[0]));
|
|
703
|
+
if (tab) return ` It is in a hidden tab panel: open the tab first (${describe(tab)}).`;
|
|
704
|
+
}
|
|
705
|
+
if (node !== element && (node.hidden || node.getAttribute?.("aria-hidden") === "true" || getComputedStyle(node).display === "none")) {
|
|
706
|
+
const heading = node.querySelector?.("h1, h2, h3, h4, legend");
|
|
707
|
+
return ` It is inside a hidden section${heading ? ` ("${clean(heading.innerText || heading.textContent, 60)}")` : ""}: reveal it first, for example with the step or tab that leads there.`;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return "";
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const describe = (element) => {
|
|
714
|
+
const role = roleOf(element);
|
|
715
|
+
const name = nameOf(element, role || "generic");
|
|
716
|
+
const tag = element.tagName.toLowerCase();
|
|
717
|
+
return `@${refFor(element)} ${role || tag}${name ? ` "${name}"` : ""}`;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const preferredTextMatch = (selector, pool) => {
|
|
721
|
+
const match = String(selector).trim().match(/^text=(["']?)([\s\S]+)\1$/);
|
|
722
|
+
if (!match) return null;
|
|
723
|
+
const wanted = clean(match[2]).toLowerCase();
|
|
724
|
+
const exact = pool.filter((element) => clean(textOf(element)).toLowerCase() === wanted || clean(element.getAttribute("aria-label") ?? "").toLowerCase() === wanted);
|
|
725
|
+
if (exact.length === 1) return exact[0];
|
|
726
|
+
const interactive = (exact.length ? exact : pool).filter((element) => INTERACTIVE_ROLES.has(roleOf(element) ?? ""));
|
|
727
|
+
return interactive.length === 1 ? interactive[0] : null;
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const resolve = (selector, { visibleOnly = true } = {}) => {
|
|
731
|
+
const all = queryAll(selector);
|
|
732
|
+
const refDirect = /^(?:@|ref=)\d+$/.test(String(selector).trim());
|
|
733
|
+
const visible = refDirect ? all : all.filter(isVisible);
|
|
734
|
+
const pool = visibleOnly ? visible : all;
|
|
735
|
+
if (pool.length === 1) return { ref: refFor(pool[0]), description: describe(pool[0]) };
|
|
736
|
+
const preferred = pool.length > 1 ? preferredTextMatch(selector, pool) : null;
|
|
737
|
+
if (preferred) return { ref: refFor(preferred), description: describe(preferred) };
|
|
738
|
+
if (pool.length === 0) {
|
|
739
|
+
const hidden = all.filter((element) => !visible.includes(element));
|
|
740
|
+
throw new Error(
|
|
741
|
+
hidden.length > 0 && visibleOnly
|
|
742
|
+
? `Selector ${selector} matches ${hidden.length} hidden element(s) and no visible one.${hiddenHint(hidden[0])}`
|
|
743
|
+
: `Selector ${selector} matches no element.`,
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
const list = pool.slice(0, 8).map((element) => " " + describe(element)).join("\n");
|
|
747
|
+
throw new Error(`Selector ${selector} matches ${pool.length} elements. Use a ref or a narrower selector:\n${list}`);
|
|
748
|
+
};
|
|
749
|
+
|
|
750
|
+
const count = (selector, { visibleOnly = true } = {}) => {
|
|
751
|
+
const all = queryAll(selector);
|
|
752
|
+
return visibleOnly ? all.filter(isVisible).length : all.length;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const coverageAt = (element) => {
|
|
756
|
+
const rect = viewportRect(element);
|
|
757
|
+
if (rect.width === 0 || rect.height === 0) throw new Error(`${describe(element)} has no size and cannot receive pointer input.`);
|
|
758
|
+
const x = Math.round(rect.x + rect.width / 2);
|
|
759
|
+
const y = Math.round(rect.y + rect.height / 2);
|
|
760
|
+
const offset = frameOffset(element);
|
|
761
|
+
const root = element.getRootNode();
|
|
762
|
+
const hit = (typeof root.elementFromPoint === "function" ? root : element.ownerDocument).elementFromPoint(x - offset.x, y - offset.y);
|
|
763
|
+
const reachable =
|
|
764
|
+
hit && (hit === element || element.contains(hit) || hit.contains(element) || (hit.shadowRoot && hit.contains(element)) || composedContains(hit, element));
|
|
765
|
+
const sameWidget = (() => {
|
|
766
|
+
if (!hit) return false;
|
|
767
|
+
let ancestor = element.parentElement;
|
|
768
|
+
for (let level = 0; ancestor && level < 3; level++, ancestor = ancestor.parentElement) {
|
|
769
|
+
if (ancestor === document.body || ancestor === document.documentElement) return false;
|
|
770
|
+
if (ancestor.contains(hit)) {
|
|
771
|
+
const style = hit.ownerDocument.defaultView.getComputedStyle(hit);
|
|
772
|
+
return style.position !== "fixed";
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return false;
|
|
776
|
+
})();
|
|
777
|
+
let covered = null;
|
|
778
|
+
if (!reachable && !sameWidget) {
|
|
779
|
+
const labelFor = hit?.closest?.("label");
|
|
780
|
+
if (!(labelFor && labelFor.control === element)) covered = hit ? describe(hit) : "nothing (outside viewport)";
|
|
781
|
+
}
|
|
782
|
+
return { x, y, covered };
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
const clippedByAncestor = (element) => {
|
|
786
|
+
const rect = element.getBoundingClientRect();
|
|
787
|
+
const x = rect.left + rect.width / 2;
|
|
788
|
+
const y = rect.top + rect.height / 2;
|
|
789
|
+
const view = element.ownerDocument.defaultView;
|
|
790
|
+
for (let node = element.parentElement; node && node !== element.ownerDocument.documentElement; node = node.parentElement) {
|
|
791
|
+
const style = view.getComputedStyle(node);
|
|
792
|
+
if (!/auto|scroll|hidden|clip/.test(`${style.overflowX} ${style.overflowY}`)) continue;
|
|
793
|
+
const box = node.getBoundingClientRect();
|
|
794
|
+
if (x < box.left || x > box.right || y < box.top || y > box.bottom) return true;
|
|
795
|
+
}
|
|
796
|
+
return false;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const actionPoint = (ref) => {
|
|
800
|
+
const element = elementForRef(ref);
|
|
801
|
+
const rect = viewportRect(element);
|
|
802
|
+
let scrolled = false;
|
|
803
|
+
if (!inViewport(rect) || rect.y < 0 || rect.y + rect.height > window.innerHeight || clippedByAncestor(element)) {
|
|
804
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
805
|
+
scrolled = true;
|
|
806
|
+
}
|
|
807
|
+
let point = coverageAt(element);
|
|
808
|
+
if (point.covered && !scrolled) {
|
|
809
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
810
|
+
point = coverageAt(element);
|
|
811
|
+
}
|
|
812
|
+
const disabled = Boolean(element.disabled || element.closest("[aria-disabled=true], fieldset:disabled"));
|
|
813
|
+
return { ...point, disabled, description: describe(element) };
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const VALUE_TYPES = {
|
|
817
|
+
range: "a number",
|
|
818
|
+
color: "a hex color like #ff8800",
|
|
819
|
+
date: "YYYY-MM-DD",
|
|
820
|
+
time: "HH:MM or HH:MM:SS",
|
|
821
|
+
month: "YYYY-MM",
|
|
822
|
+
week: "YYYY-Www",
|
|
823
|
+
"datetime-local": "YYYY-MM-DDTHH:MM",
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const fillValue = (ref, value) => {
|
|
827
|
+
const element = elementForRef(ref);
|
|
828
|
+
const type = element.type;
|
|
829
|
+
const wanted = type === "color" ? value.toLowerCase() : value;
|
|
830
|
+
const invalid = (reason) => new Error(`Cannot fill ${describe(element)} (type ${type}) with "${value}": ${reason}`);
|
|
831
|
+
if (type === "range") {
|
|
832
|
+
const number = Number(value);
|
|
833
|
+
const min = element.min === "" ? 0 : Number(element.min);
|
|
834
|
+
const max = element.max === "" ? 100 : Number(element.max);
|
|
835
|
+
if (value.trim() === "" || !Number.isFinite(number)) throw invalid(`expected ${VALUE_TYPES.range}.`);
|
|
836
|
+
if (number < min || number > max) throw invalid(`expected a number from ${min} to ${max}.`);
|
|
837
|
+
}
|
|
838
|
+
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set;
|
|
839
|
+
const previous = element.value;
|
|
840
|
+
setValue.call(element, wanted);
|
|
841
|
+
const actual = element.value;
|
|
842
|
+
const accepted = type === "range" ? Number(actual) === Number(value) : actual === wanted;
|
|
843
|
+
if (!accepted && wanted !== "") {
|
|
844
|
+
setValue.call(element, previous);
|
|
845
|
+
if (type === "range") throw invalid(`the input rounds it to ${actual} (step ${element.step || 1}).`);
|
|
846
|
+
throw invalid(`expected ${VALUE_TYPES[type]}.`);
|
|
847
|
+
}
|
|
848
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
849
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
850
|
+
return actual;
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const labelled = (element) => (element.tagName === "LABEL" && element.control ? element.control : element);
|
|
854
|
+
|
|
855
|
+
const prepareFill = (ref) => {
|
|
856
|
+
const element = labelled(elementForRef(ref));
|
|
857
|
+
const tag = element.tagName.toUpperCase();
|
|
858
|
+
if (tag === "SELECT") throw new Error(`${describe(element)} is a select. Use selectOption().`);
|
|
859
|
+
if (tag === "INPUT" && ["checkbox", "radio", "file", "button", "submit", "reset", "image"].includes(element.type)) {
|
|
860
|
+
throw new Error(`${describe(element)} is an input of type ${element.type} and cannot be filled with text.`);
|
|
861
|
+
}
|
|
862
|
+
const rect = viewportRect(element);
|
|
863
|
+
if (!inViewport(rect) || rect.y < 0 || rect.y + rect.height > window.innerHeight) element.scrollIntoView({ block: "center", behavior: "instant" });
|
|
864
|
+
element.focus();
|
|
865
|
+
const box = viewportRect(element);
|
|
866
|
+
const point = { x: Math.round(box.x + Math.min(box.width / 2, 40)), y: Math.round(box.y + box.height / 2) };
|
|
867
|
+
if (tag === "INPUT" && VALUE_TYPES[element.type]) return { kind: "value", ref: refFor(element), ...point };
|
|
868
|
+
if (tag === "INPUT" || tag === "TEXTAREA") {
|
|
869
|
+
element.select();
|
|
870
|
+
return { kind: "input", ref: refFor(element), ...point };
|
|
871
|
+
}
|
|
872
|
+
if (element.isContentEditable) {
|
|
873
|
+
const selection = element.ownerDocument.getSelection();
|
|
874
|
+
const range = element.ownerDocument.createRange();
|
|
875
|
+
range.selectNodeContents(element);
|
|
876
|
+
selection.removeAllRanges();
|
|
877
|
+
selection.addRange(range);
|
|
878
|
+
return { kind: "contenteditable", ref: refFor(element), ...point };
|
|
879
|
+
}
|
|
880
|
+
const editable = element.querySelectorAll("input:not([type=hidden]), textarea, [contenteditable=''], [contenteditable=true]");
|
|
881
|
+
if (editable.length === 1) return prepareFill(refFor(editable[0]));
|
|
882
|
+
throw new Error(`${describe(element)} is not editable.`);
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const fileInput = (ref) => {
|
|
886
|
+
const element = elementForRef(ref);
|
|
887
|
+
if (element.tagName === "INPUT" && element.type === "file") return ref;
|
|
888
|
+
if (element.tagName === "LABEL" && element.control?.type === "file") return refFor(element.control);
|
|
889
|
+
const inputs = element.querySelectorAll("input[type=file]");
|
|
890
|
+
if (inputs.length !== 1) throw new Error(`${describe(element)} is not a file input and contains ${inputs.length} file inputs.`);
|
|
891
|
+
return refFor(inputs[0]);
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
const CHECKABLE_ROLES = new Set(["checkbox", "radio", "switch", "menuitemcheckbox", "menuitemradio"]);
|
|
895
|
+
|
|
896
|
+
const checkedState = (ref) => {
|
|
897
|
+
const element = elementForRef(ref);
|
|
898
|
+
const isCheckable = (node) => CHECKABLE_ROLES.has(roleOf(node) ?? "");
|
|
899
|
+
let control = isCheckable(element) ? element : null;
|
|
900
|
+
if (!control && element.tagName === "LABEL" && element.control && isCheckable(element.control)) control = element.control;
|
|
901
|
+
if (!control) {
|
|
902
|
+
const inner = [...element.querySelectorAll("input[type=checkbox], input[type=radio], [role=checkbox], [role=radio], [role=switch]")];
|
|
903
|
+
if (inner.length === 1) control = inner[0];
|
|
904
|
+
}
|
|
905
|
+
if (!control) {
|
|
906
|
+
const label = element.closest("label");
|
|
907
|
+
if (label?.control && isCheckable(label.control)) control = label.control;
|
|
908
|
+
}
|
|
909
|
+
if (!control) throw new Error(`${describe(element)} is not a checkbox, radio, or switch.`);
|
|
910
|
+
const checked = control.tagName === "INPUT" ? control.checked : control.getAttribute("aria-checked") === "true";
|
|
911
|
+
return { checked, control: describe(control) };
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const valueOf = (ref) => {
|
|
915
|
+
const element = elementForRef(ref);
|
|
916
|
+
if (element.isContentEditable) return element.innerText;
|
|
917
|
+
return element.value ?? null;
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const forceValue = (ref, value) => {
|
|
921
|
+
const element = elementForRef(ref);
|
|
922
|
+
const prototype = element.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
|
|
923
|
+
Object.getOwnPropertyDescriptor(prototype, "value").set.call(element, value);
|
|
924
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
925
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const selectOption = (ref, wanted) => {
|
|
929
|
+
const element = labelled(elementForRef(ref));
|
|
930
|
+
if (element.tagName !== "SELECT") throw new Error(`${describe(element)} is not a select element.`);
|
|
931
|
+
const list = wanted === null ? [] : Array.isArray(wanted) ? wanted : [wanted];
|
|
932
|
+
const options = [...element.options];
|
|
933
|
+
const picked = list.map((item) => {
|
|
934
|
+
const found = options.find((option, index) => {
|
|
935
|
+
if (typeof item === "number") return index === item;
|
|
936
|
+
if (typeof item === "string") return option.value === item || clean(option.label) === clean(item);
|
|
937
|
+
if (item.value !== undefined) return option.value === item.value;
|
|
938
|
+
if (item.label !== undefined) return clean(option.label) === clean(item.label);
|
|
939
|
+
if (item.index !== undefined) return index === item.index;
|
|
940
|
+
return false;
|
|
941
|
+
});
|
|
942
|
+
if (!found) throw new Error(`No option matches ${JSON.stringify(item)} in ${describe(element)}.`);
|
|
943
|
+
return found;
|
|
944
|
+
});
|
|
945
|
+
if (!element.multiple && picked.length > 1) throw new Error(`${describe(element)} allows one option only.`);
|
|
946
|
+
element.focus();
|
|
947
|
+
for (const option of options) option.selected = picked.includes(option);
|
|
948
|
+
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
949
|
+
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
950
|
+
return picked.map((option) => option.value);
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const focus = (ref) => {
|
|
954
|
+
const element = elementForRef(ref);
|
|
955
|
+
element.focus();
|
|
956
|
+
if (element.ownerDocument.activeElement !== element) {
|
|
957
|
+
const inner = element.querySelector("input, textarea, select, button, [contenteditable], [tabindex], a[href]");
|
|
958
|
+
inner?.focus();
|
|
959
|
+
}
|
|
960
|
+
return describe(element.ownerDocument.activeElement);
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const scrollerAt = (x, y, ref) => {
|
|
964
|
+
let node = ref !== null && ref !== undefined ? elementForRef(ref) : document.elementFromPoint(x, y);
|
|
965
|
+
while (node && node !== document.body && node !== document.documentElement) {
|
|
966
|
+
const style = getComputedStyle(node);
|
|
967
|
+
const scrollable = /(auto|scroll|overlay)/.test(style.overflowY + style.overflowX);
|
|
968
|
+
if (scrollable && (node.scrollHeight > node.clientHeight + 1 || node.scrollWidth > node.clientWidth + 1)) return node;
|
|
969
|
+
node = node.parentElement ?? node.getRootNode()?.host ?? null;
|
|
970
|
+
}
|
|
971
|
+
return document.scrollingElement ?? document.documentElement;
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const scrollState = (x, y, ref) => {
|
|
975
|
+
const scroller = scrollerAt(x, y, ref);
|
|
976
|
+
const page = scroller === document.scrollingElement || scroller === document.documentElement;
|
|
977
|
+
return {
|
|
978
|
+
ref: page ? null : refFor(scroller),
|
|
979
|
+
name: page ? "page" : describe(scroller),
|
|
980
|
+
top: Math.round(scroller.scrollTop),
|
|
981
|
+
left: Math.round(scroller.scrollLeft),
|
|
982
|
+
height: scroller.scrollHeight,
|
|
983
|
+
view: scroller.clientHeight,
|
|
984
|
+
};
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
const scrollByProgram = (x, y, ref, deltaX, deltaY) => {
|
|
988
|
+
const scroller = scrollerAt(x, y, ref);
|
|
989
|
+
scroller.scrollBy({ left: deltaX, top: deltaY, behavior: "instant" });
|
|
990
|
+
return scrollState(x, y, ref);
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
const tableText = ({ ref = null, maxRows = 200 } = {}) => {
|
|
994
|
+
let table = ref !== null ? elementForRef(ref) : null;
|
|
995
|
+
if (table && !table.matches("table, [role=table], [role=grid], [role=treegrid]")) {
|
|
996
|
+
table = table.closest("table, [role=table], [role=grid], [role=treegrid]") ?? table.querySelector("table, [role=table], [role=grid], [role=treegrid]");
|
|
997
|
+
}
|
|
998
|
+
if (!table) {
|
|
999
|
+
const tables = deepQuery("table, [role=table], [role=grid], [role=treegrid]").filter(isVisible);
|
|
1000
|
+
table = tables.sort((a, b) => b.querySelectorAll("tr, [role=row]").length - a.querySelectorAll("tr, [role=row]").length)[0];
|
|
1001
|
+
}
|
|
1002
|
+
if (!table) return "no table found";
|
|
1003
|
+
const rows = [...table.querySelectorAll("tr, [role=row]")].filter((row) => row.closest("table, [role=table], [role=grid], [role=treegrid]") === table);
|
|
1004
|
+
const lines = rows.slice(0, maxRows).map((row) =>
|
|
1005
|
+
[...row.querySelectorAll("th, td, [role=cell], [role=gridcell], [role=columnheader], [role=rowheader]")]
|
|
1006
|
+
.filter((cell) => cell.closest("tr, [role=row]") === row)
|
|
1007
|
+
.map((cell) => clean(cell.innerText, 200).replace(/\t/g, " "))
|
|
1008
|
+
.join("\t"),
|
|
1009
|
+
);
|
|
1010
|
+
if (rows.length > maxRows) lines.push(`… ${rows.length - maxRows} more rows`);
|
|
1011
|
+
return `${describe(table)} (${rows.length} rows)\n${lines.join("\n")}`;
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
const linkList = ({ query = "", limit = 60 } = {}) => {
|
|
1015
|
+
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
1016
|
+
const seen = new Set();
|
|
1017
|
+
const lines = [];
|
|
1018
|
+
let total = 0;
|
|
1019
|
+
for (const anchor of deepQuery("a[href]")) {
|
|
1020
|
+
if (!isVisible(anchor) || anchor.href.startsWith("javascript:")) continue;
|
|
1021
|
+
const name = nameOf(anchor, "link") || clean(anchor.href, 60);
|
|
1022
|
+
const key = `${name}|${anchor.href}`;
|
|
1023
|
+
if (seen.has(key)) continue;
|
|
1024
|
+
const haystack = `${name} ${anchor.href}`.toLowerCase();
|
|
1025
|
+
if (!words.every((word) => haystack.includes(word))) continue;
|
|
1026
|
+
seen.add(key);
|
|
1027
|
+
total++;
|
|
1028
|
+
if (lines.length < limit) lines.push(`@${refFor(anchor)} ${name} → ${anchor.href}`);
|
|
1029
|
+
}
|
|
1030
|
+
if (total > limit) lines.push(`… ${total - limit} more (narrow with words)`);
|
|
1031
|
+
return lines.length ? lines.join("\n") : "no links";
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
const readable = (root) => {
|
|
1035
|
+
const style = document.createElement("style");
|
|
1036
|
+
style.setAttribute("data-arc-for-claude", "");
|
|
1037
|
+
style.textContent = "select, [aria-hidden=true] { display: none !important; }";
|
|
1038
|
+
(document.head ?? document.documentElement).appendChild(style);
|
|
1039
|
+
let value;
|
|
1040
|
+
try {
|
|
1041
|
+
value = root?.innerText ?? root?.textContent ?? "";
|
|
1042
|
+
} finally {
|
|
1043
|
+
style.remove();
|
|
1044
|
+
}
|
|
1045
|
+
const lines = [];
|
|
1046
|
+
for (const raw of value.split("\n")) {
|
|
1047
|
+
const line = raw.replace(/\s+$/, "");
|
|
1048
|
+
if (!line.trim() && (!lines.length || !lines.at(-1).trim())) continue;
|
|
1049
|
+
lines.push(line);
|
|
1050
|
+
}
|
|
1051
|
+
return lines.join("\n").trim();
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
const text = ({ maxChars = 8_000, all = false, ref = null } = {}) => {
|
|
1055
|
+
const root = ref !== null
|
|
1056
|
+
? elementForRef(ref)
|
|
1057
|
+
: all
|
|
1058
|
+
? document.body
|
|
1059
|
+
: document.querySelector("main, [role=main]") ?? document.querySelector("article") ?? document.body;
|
|
1060
|
+
const value = readable(root);
|
|
1061
|
+
return value.length > maxChars ? value.slice(0, maxChars) + `\n… truncated (${value.length} chars total)` : value;
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
let cursor = null;
|
|
1065
|
+
const showCursor = (x, y, label) => {
|
|
1066
|
+
if (!document.body) return;
|
|
1067
|
+
if (!cursor || !cursor.isConnected) {
|
|
1068
|
+
cursor = document.createElement("div");
|
|
1069
|
+
cursor.setAttribute("data-arc-for-claude", "");
|
|
1070
|
+
const shadow = cursor.attachShadow({ mode: "closed" });
|
|
1071
|
+
shadow.innerHTML = `<style>
|
|
1072
|
+
:host{all:initial;position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;transition:transform .18s ease,opacity .4s ease;opacity:0}
|
|
1073
|
+
.dot{width:14px;height:14px;margin:-7px 0 0 -7px;border-radius:50%;background:#FF5C35;box-shadow:0 0 0 3px rgba(255,92,53,.25)}
|
|
1074
|
+
.tag{position:absolute;left:12px;top:6px;white-space:nowrap;font:500 11px/1.4 -apple-system,system-ui,sans-serif;color:#fff;background:#1F1F1F;padding:3px 7px;border-radius:5px}
|
|
1075
|
+
</style><div class="dot"></div><div class="tag"></div>`;
|
|
1076
|
+
cursor._tag = shadow.querySelector(".tag");
|
|
1077
|
+
document.documentElement.appendChild(cursor);
|
|
1078
|
+
}
|
|
1079
|
+
cursor.style.transition = "";
|
|
1080
|
+
cursor.style.transform = `translate(${x}px, ${y}px)`;
|
|
1081
|
+
cursor.style.opacity = "1";
|
|
1082
|
+
cursor._tag.textContent = label || "Claude";
|
|
1083
|
+
clearTimeout(cursor._timer);
|
|
1084
|
+
cursor._timer = setTimeout(() => {
|
|
1085
|
+
if (cursor) cursor.style.opacity = "0";
|
|
1086
|
+
}, 2500);
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
const hideCursor = () => {
|
|
1090
|
+
if (!cursor) return;
|
|
1091
|
+
clearTimeout(cursor._timer);
|
|
1092
|
+
cursor.style.transition = "none";
|
|
1093
|
+
cursor.style.opacity = "0";
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
const isCrossFrame = (element) => {
|
|
1097
|
+
if (element.tagName !== "IFRAME" && element.tagName !== "FRAME") return false;
|
|
1098
|
+
try {
|
|
1099
|
+
return !element.contentDocument;
|
|
1100
|
+
} catch {
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
const frameBox = (ref) => {
|
|
1106
|
+
const element = elementForRef(ref);
|
|
1107
|
+
if (element.tagName !== "IFRAME" && element.tagName !== "FRAME") throw new Error(`${describe(element)} is not a frame.`);
|
|
1108
|
+
let rect = viewportRect(element);
|
|
1109
|
+
if (!inViewport(rect)) {
|
|
1110
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1111
|
+
rect = viewportRect(element);
|
|
1112
|
+
}
|
|
1113
|
+
const style = element.ownerDocument.defaultView.getComputedStyle(element);
|
|
1114
|
+
return {
|
|
1115
|
+
x: rect.x + element.clientLeft + parseFloat(style.paddingLeft),
|
|
1116
|
+
y: rect.y + element.clientTop + parseFloat(style.paddingTop),
|
|
1117
|
+
cross: isCrossFrame(element),
|
|
1118
|
+
};
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
let mutations = 0;
|
|
1122
|
+
let observer = null;
|
|
1123
|
+
const mutationCount = () => {
|
|
1124
|
+
if (!observer) {
|
|
1125
|
+
observer = new MutationObserver((records) => {
|
|
1126
|
+
mutations += records.length;
|
|
1127
|
+
});
|
|
1128
|
+
observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
1129
|
+
}
|
|
1130
|
+
return mutations;
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1133
|
+
const rectOf = (ref) => {
|
|
1134
|
+
const element = elementForRef(ref);
|
|
1135
|
+
let rect = viewportRect(element);
|
|
1136
|
+
if (!inViewport(rect) || rect.y < 0 || rect.y + rect.height > window.innerHeight) {
|
|
1137
|
+
element.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
|
|
1138
|
+
rect = viewportRect(element);
|
|
1139
|
+
}
|
|
1140
|
+
return rect;
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
const crossFrames = ({ visibleOnly = false } = {}) =>
|
|
1144
|
+
deepQuery("iframe, frame")
|
|
1145
|
+
.filter((element) => isCrossFrame(element) && isVisible(element) && (!visibleOnly || inViewport(viewportRect(element))))
|
|
1146
|
+
.map((element) => ({ ref: refFor(element), name: nameOf(element, "iframe") }));
|
|
1147
|
+
|
|
1148
|
+
const refIsCrossFrame = (ref) => isCrossFrame(elementForRef(ref));
|
|
1149
|
+
|
|
1150
|
+
const SELECTOR_PREFIX = /^(?:@|ref=|text=|role=|css=|xpath=|loc=)/;
|
|
1151
|
+
|
|
1152
|
+
const seekSelector = (raw) => {
|
|
1153
|
+
const value = String(raw).trim();
|
|
1154
|
+
if (SELECTOR_PREFIX.test(value)) return value;
|
|
1155
|
+
if (/[#.[\]:>*=]/.test(value)) {
|
|
1156
|
+
try {
|
|
1157
|
+
document.querySelector(value.replace(/:(has-text|text-is)\((["'])[\s\S]*?\2\)/g, "") || "*");
|
|
1158
|
+
return value;
|
|
1159
|
+
} catch {}
|
|
1160
|
+
}
|
|
1161
|
+
return `text=${value}`;
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
const blocksScroll = (style) => style.overflowY === "hidden" || style.overflowY === "clip";
|
|
1165
|
+
|
|
1166
|
+
const scrollsY = (element) => {
|
|
1167
|
+
if (element.scrollHeight <= element.clientHeight + 4) return false;
|
|
1168
|
+
const overflow = element.ownerDocument.defaultView.getComputedStyle(element).overflowY;
|
|
1169
|
+
return overflow === "auto" || overflow === "scroll" || overflow === "overlay";
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
const pageScroller = () => document.scrollingElement ?? document.documentElement;
|
|
1173
|
+
|
|
1174
|
+
const pageScrolls = () => {
|
|
1175
|
+
if (pageScroller().scrollHeight <= window.innerHeight + 4) return false;
|
|
1176
|
+
const html = getComputedStyle(document.documentElement);
|
|
1177
|
+
if (blocksScroll(html)) return false;
|
|
1178
|
+
return !(document.body && html.overflowY === "visible" && blocksScroll(getComputedStyle(document.body)));
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const isPageElement = (element) => element === document.documentElement || element === document.body || element === pageScroller();
|
|
1182
|
+
|
|
1183
|
+
const scrollParentOf = (element) => {
|
|
1184
|
+
for (let node = composedParent(element); node && !isPageElement(node); node = composedParent(node)) {
|
|
1185
|
+
if (scrollsY(node)) return node;
|
|
1186
|
+
}
|
|
1187
|
+
return null;
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const scrollOwnerOf = (element) => (isPageElement(element) ? null : scrollsY(element) ? element : scrollParentOf(element));
|
|
1191
|
+
|
|
1192
|
+
const ITEM_SELECTOR =
|
|
1193
|
+
"li, tr, article, [role=listitem], [role=row], [role=option], [role=article], [role=treeitem], [role=gridcell], [role=feed] > *";
|
|
1194
|
+
|
|
1195
|
+
const visibleArea = (rect) =>
|
|
1196
|
+
Math.max(0, Math.min(rect.x + rect.width, window.innerWidth) - Math.max(rect.x, 0)) *
|
|
1197
|
+
Math.max(0, Math.min(rect.y + rect.height, window.innerHeight) - Math.max(rect.y, 0));
|
|
1198
|
+
|
|
1199
|
+
const pickScroller = () => {
|
|
1200
|
+
const scores = new Map();
|
|
1201
|
+
const pageOk = pageScrolls();
|
|
1202
|
+
let counted = 0;
|
|
1203
|
+
for (const item of deepQuery(ITEM_SELECTOR)) {
|
|
1204
|
+
if (item.ownerDocument !== document) continue;
|
|
1205
|
+
const area = visibleArea(item.getBoundingClientRect());
|
|
1206
|
+
if (!area) continue;
|
|
1207
|
+
const owner = scrollParentOf(item) ?? (pageOk ? "page" : null);
|
|
1208
|
+
if (owner) scores.set(owner, (scores.get(owner) ?? 0) + area);
|
|
1209
|
+
if (++counted >= 400) break;
|
|
1210
|
+
}
|
|
1211
|
+
if (scores.size) return [...scores.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
1212
|
+
if (pageOk) return "page";
|
|
1213
|
+
let best = null;
|
|
1214
|
+
let bestArea = 0;
|
|
1215
|
+
for (const element of deepQuery("*")) {
|
|
1216
|
+
if (element.ownerDocument !== document || element.scrollHeight <= element.clientHeight + 4 || !scrollsY(element)) continue;
|
|
1217
|
+
const area = visibleArea(element.getBoundingClientRect());
|
|
1218
|
+
if (area > bestArea) {
|
|
1219
|
+
best = element;
|
|
1220
|
+
bestArea = area;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
if (!best) throw new Error("Found no scrollable list or page to seek in. Pass a container selector.");
|
|
1224
|
+
return best;
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
const scrollerFor = (container) => {
|
|
1228
|
+
if (!container) return pickScroller();
|
|
1229
|
+
const { ref } = resolve(container);
|
|
1230
|
+
const element = elementForRef(ref);
|
|
1231
|
+
if (isPageElement(element)) return "page";
|
|
1232
|
+
return scrollOwnerOf(element) ?? "page";
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
const deepHit = (x, y) => {
|
|
1236
|
+
let hit = document.elementFromPoint(x, y);
|
|
1237
|
+
while (hit) {
|
|
1238
|
+
const shadow = shadowOf(hit);
|
|
1239
|
+
const inner = shadow?.elementFromPoint(x, y);
|
|
1240
|
+
if (!inner || inner === hit) break;
|
|
1241
|
+
hit = inner;
|
|
1242
|
+
}
|
|
1243
|
+
return hit;
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
const wheelPoint = (scroller) => {
|
|
1247
|
+
const bounds = scroller === "page" ? { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight } : scroller.getBoundingClientRect();
|
|
1248
|
+
const left = Math.max(bounds.x, 0);
|
|
1249
|
+
const top = Math.max(bounds.y, 0);
|
|
1250
|
+
const width = Math.min(bounds.x + bounds.width, window.innerWidth) - left;
|
|
1251
|
+
const height = Math.min(bounds.y + bounds.height, window.innerHeight) - top;
|
|
1252
|
+
const fractions = [[0.5, 0.5], [0.5, 0.25], [0.5, 0.75], [0.25, 0.5], [0.75, 0.5], [0.25, 0.25], [0.75, 0.75]];
|
|
1253
|
+
const points = fractions.map(([fx, fy]) => ({ x: Math.round(left + width * fx), y: Math.round(top + height * fy) }));
|
|
1254
|
+
const wanted = scroller === "page" ? null : scroller;
|
|
1255
|
+
return points.find((point) => {
|
|
1256
|
+
const hit = deepHit(point.x, point.y);
|
|
1257
|
+
return hit && scrollOwnerOf(hit) === wanted;
|
|
1258
|
+
}) ?? points[0];
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
const seekLine = (element) => {
|
|
1262
|
+
const role = roleOf(element);
|
|
1263
|
+
const ref = refFor(element);
|
|
1264
|
+
const off = inViewport(viewportRect(element)) ? "" : " (offscreen)";
|
|
1265
|
+
if (!role) return `@${ref} ${element.tagName.toLowerCase()}: ${clean(textOf(element), 90)}${off}`;
|
|
1266
|
+
const name = nameOf(element, role);
|
|
1267
|
+
return `@${ref} ${role}${name ? ` "${name.replace(/"/g, "'")}"` : ""}${stateOf(element, role)}${off}`;
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const seekFind = (raw) => {
|
|
1271
|
+
const matches = queryAll(seekSelector(raw)).filter(isVisible);
|
|
1272
|
+
return matches.length ? seekLine(matches[0]) + (matches.length > 1 ? ` (+${matches.length - 1} more)` : "") : null;
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const NEXT_NAME = /^(next|next page|older|more results|›|»|>|→)$/i;
|
|
1276
|
+
|
|
1277
|
+
const nextPageControl = () => {
|
|
1278
|
+
const pager = (element) => element.closest("nav, [role=navigation], [aria-label*=pag i], [class*=pag i], [id*=pag i]");
|
|
1279
|
+
const enabled = (element) => !element.disabled && element.getAttribute("aria-disabled") !== "true";
|
|
1280
|
+
const candidates = deepQuery("a[rel~=next], a[href], button, [role=button], [role=link]").filter(
|
|
1281
|
+
(element) =>
|
|
1282
|
+
isVisible(element) &&
|
|
1283
|
+
enabled(element) &&
|
|
1284
|
+
(element.matches("a[rel~=next]") || (NEXT_NAME.test(clean(nameOf(element, roleOf(element) || "button") || textOf(element))) && pager(element))),
|
|
1285
|
+
);
|
|
1286
|
+
return candidates.length ? refFor(candidates[0]) : null;
|
|
1287
|
+
};
|
|
1288
|
+
|
|
1289
|
+
const seekProbe = (raw, { container = null, scroller: scrollerRef = null, advance = 0 } = {}) => {
|
|
1290
|
+
const scroller = scrollerRef === null ? scrollerFor(container) : scrollerRef === 0 ? "page" : elementForRef(scrollerRef);
|
|
1291
|
+
const within = container && scroller !== "page" ? scroller : null;
|
|
1292
|
+
const matches = queryAll(seekSelector(raw)).filter((element) => isVisible(element) && (!within || composedContains(within, element)));
|
|
1293
|
+
const box = scroller === "page" ? pageScroller() : scroller;
|
|
1294
|
+
const client = scroller === "page" ? window.innerHeight : box.clientHeight;
|
|
1295
|
+
const top = Math.round(box.scrollTop);
|
|
1296
|
+
const max = Math.max(0, Math.round(box.scrollHeight - client));
|
|
1297
|
+
const step = Math.max(40, Math.round(client * 0.9));
|
|
1298
|
+
const found = matches.length ? seekLine(matches[0]) + (matches.length > 1 ? ` (+${matches.length - 1} more)` : "") : null;
|
|
1299
|
+
let after = top;
|
|
1300
|
+
if (advance && !found) {
|
|
1301
|
+
box.scrollTo({ top: Math.min(max, Math.max(0, top + advance * step)), behavior: "instant" });
|
|
1302
|
+
after = Math.round(box.scrollTop);
|
|
1303
|
+
if (after !== top) (scroller === "page" ? window : box).dispatchEvent(new Event("scroll"));
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
found,
|
|
1307
|
+
scroller: scroller === "page" ? 0 : refFor(scroller),
|
|
1308
|
+
name: scroller === "page" ? "the page" : describe(scroller),
|
|
1309
|
+
top,
|
|
1310
|
+
after,
|
|
1311
|
+
max,
|
|
1312
|
+
height: Math.round(box.scrollHeight),
|
|
1313
|
+
step,
|
|
1314
|
+
...wheelPoint(scroller),
|
|
1315
|
+
};
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
const headingLevel = (element) => Number(element.getAttribute("aria-level") || element.tagName.match(/^H(\d)$/i)?.[1] || 2);
|
|
1319
|
+
|
|
1320
|
+
const labelsFor = (wanted) =>
|
|
1321
|
+
deepest(deepQuery("body *").filter((element) => !SKIP_TAGS.has(element.tagName) && clean(element.innerText).toLowerCase() === wanted && isVisible(element)));
|
|
1322
|
+
|
|
1323
|
+
const sectionAfter = (heading, level, headings, maxChars) => {
|
|
1324
|
+
const boundary =
|
|
1325
|
+
headings.find(
|
|
1326
|
+
(element) => element !== heading && heading.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING && !heading.contains(element) && headingLevel(element) <= level,
|
|
1327
|
+
) ?? null;
|
|
1328
|
+
const parts = [];
|
|
1329
|
+
let length = 0;
|
|
1330
|
+
let node = heading;
|
|
1331
|
+
walk: while (node && node !== document.body && length < maxChars) {
|
|
1332
|
+
for (let sibling = node.nextElementSibling; sibling; sibling = sibling.nextElementSibling) {
|
|
1333
|
+
if (boundary && (sibling === boundary || sibling.contains(boundary))) break walk;
|
|
1334
|
+
if (!isVisible(sibling) || SKIP_TAGS.has(sibling.tagName)) continue;
|
|
1335
|
+
const value = sibling.innerText?.trim();
|
|
1336
|
+
if (value) {
|
|
1337
|
+
parts.push(value);
|
|
1338
|
+
length += value.length;
|
|
1339
|
+
if (length >= maxChars) break walk;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
node = node.parentElement;
|
|
1343
|
+
}
|
|
1344
|
+
const body = parts.join("\n\n").replace(/\n{3,}/g, "\n\n");
|
|
1345
|
+
const shown = body.length > maxChars ? `${body.slice(0, maxChars)}\n… truncated` : body;
|
|
1346
|
+
return `${clean(heading.innerText)}\n${shown || "(empty section)"}`;
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
const sectionText = (query, { maxChars = 4000 } = {}) => {
|
|
1350
|
+
const wanted = clean(query).toLowerCase();
|
|
1351
|
+
const headings = deepQuery("h1, h2, h3, h4, h5, h6, [role=heading]").filter(isVisible);
|
|
1352
|
+
const named = headings.map((element) => ({ element, name: clean(element.innerText).toLowerCase() }));
|
|
1353
|
+
const pick =
|
|
1354
|
+
named.find((item) => item.name === wanted) ?? named.find((item) => item.name.startsWith(wanted)) ?? named.find((item) => item.name.includes(wanted));
|
|
1355
|
+
if (pick) return sectionAfter(pick.element, headingLevel(pick.element), headings, maxChars);
|
|
1356
|
+
const labels = labelsFor(wanted).slice(0, 3);
|
|
1357
|
+
if (!labels.length) {
|
|
1358
|
+
const list = headings.slice(0, 30).map((element) => clean(element.innerText, 60)).join(" | ");
|
|
1359
|
+
throw new Error(`No heading matches "${query}". Headings: ${list}`);
|
|
1360
|
+
}
|
|
1361
|
+
if (labels.length === 1) return sectionAfter(labels[0], 6, headings, maxChars);
|
|
1362
|
+
return labels.map((label, index) => `--- match ${index + 1}/${labels.length} ---\n${sectionAfter(label, 6, headings, maxChars)}`).join("\n");
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const api = {
|
|
1366
|
+
sectionText,
|
|
1367
|
+
snapshot, find, resolve, count, actionPoint, prepareFill, fillValue, fileInput, checkedState, valueOf, forceValue, selectOption, focus,
|
|
1368
|
+
text, tableText, linkList, scrollState, scrollByProgram, element: elementForRef, describe, showCursor, hideCursor, frameBox, rectOf, crossFrames, refIsCrossFrame, mutationCount,
|
|
1369
|
+
seekProbe, seekFind, nextPageControl, takeClosedHostCandidates, registerClosedRoot,
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
api.guarded = (method, args, check) => (check && closedHostCandidates(1).length ? { __arcClosedHosts: true } : api[method](...args));
|
|
1373
|
+
|
|
1374
|
+
return api;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const SOURCE = inPageLibrary.toString();
|
|
1378
|
+
|
|
1379
|
+
export const LIBRARY_KEY = `__arcForClaude_${createHash("sha1").update(SOURCE).digest("hex").slice(0, 10)}`;
|
|
1380
|
+
|
|
1381
|
+
export const LIBRARY_PRELUDE = `(globalThis[${JSON.stringify(LIBRARY_KEY)}] ||= (${SOURCE})())`;
|