cbrowser 19.1.9 → 19.2.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.
@@ -3,7 +3,6 @@
3
3
  * Copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com
4
4
  * Learn more at https://cbrowser.ai - MIT License
5
5
  */
6
- import { idSelector, attrSelector, escapeCssIdent } from "../selector-escape.js";
7
6
  /**
8
7
  * Natural language command patterns.
9
8
  */
@@ -108,539 +107,2431 @@ export async function executeNaturalLanguageScript(browser, commands) {
108
107
  }
109
108
  return results;
110
109
  }
111
- /** ARIA-first selector priority strategies (highest to lowest) */
112
- const SELECTOR_PRIORITY = [
113
- {
114
- name: "aria-label",
115
- extract: (el) => el.ariaLabel || null,
116
- toSelector: (v) => attrSelector("aria-label", v),
117
- confidence: 0.95,
118
- },
119
- {
120
- name: "aria-labelledby",
121
- extract: (el) => el.ariaLabelledby || null,
122
- toSelector: (v) => attrSelector("aria-labelledby", v),
123
- confidence: 0.93,
124
- },
125
- {
126
- name: "role",
127
- extract: (el) => el.role || null,
128
- toSelector: (v, el) => el.text ? `${el.tag}${attrSelector("role", v)}` : attrSelector("role", v),
129
- confidence: 0.90,
130
- },
131
- {
132
- name: "semantic-element",
133
- extract: (el) => el.isSemanticElement ? el.tag : null,
134
- toSelector: (v) => v,
135
- confidence: 0.85,
136
- },
137
- {
138
- name: "input-type",
139
- extract: (el) => el.tag === "input" && el.type ? el.type : null,
140
- toSelector: (v) => `input${attrSelector("type", v)}`,
141
- confidence: 0.80,
142
- },
143
- {
144
- name: "id",
145
- extract: (el) => el.id || null,
146
- toSelector: (v) => idSelector(v),
147
- confidence: 0.85,
148
- },
149
- {
150
- name: "data-testid",
151
- extract: (el) => el.dataTestId || null,
152
- toSelector: (v) => attrSelector("data-testid", v),
153
- confidence: 0.82,
154
- },
155
- {
156
- name: "name",
157
- extract: (el) => el.name || null,
158
- toSelector: (v) => attrSelector("name", v),
159
- confidence: 0.80,
160
- },
161
- {
162
- name: "css-class",
163
- extract: (el) => {
164
- const cls = el.classes.split(" ").find((c) => c.length > 3 && !c.includes("_") && !/^[a-z]{1,2}\d/.test(c));
165
- return cls || null;
166
- },
167
- toSelector: (v) => `.${escapeCssIdent(v)}`,
168
- confidence: 0.60,
169
- },
170
- ];
171
- const _SEMANTIC_ELEMENTS = new Set(["button", "nav", "main", "header", "footer", "article", "aside", "section", "form", "dialog"]);
110
+ /** The MCP tool description, shared by both server registrations so they cannot drift. */
111
+ export const FIND_ELEMENT_BY_INTENT_DESCRIPTION = "Find ONE element from a natural-language intent by walking a cascade of accessible-name locators "
112
+ + "(exact name > a real button named X plus one word (\"Features tour\"; never when X is destructive, financial, a sign-out or "
113
+ + "a paging word) > exact link > badge-count name > synonym > contains > label/placeholder > all words > visible text > attributes > "
114
+ + "stems > sub-phrase), "
115
+ + "scoped to a landmark when the intent says so ('in the header', 'in the navigation', 'in the footer'). "
116
+ + "Returns a plain CSS selector that Playwright resolves to exactly that element (open shadow roots included) and that cbrowser's click "
117
+ + "resolver will not read as page text, plus confidence (2 decimals). Confidence is the rung, not a probability: "
118
+ + "0.95 = a unique match on the exact name, or a unique landmark ('header', 'footer navigation'); "
119
+ + "0.88-0.92 = a unique synonym, label/placeholder, typed field, badge-count name ('Cart (2)', never on a destructive name), a kind-only "
120
+ + "intent ('first link', 'button') with an ordinal or a single candidate, or a recognised logo/home/search/menu/theme/language control; "
121
+ + "0.7-0.85 = fuzzy (the name contains the phrase, or every word of it; a search-form submit; an exact button for a 'link' intent; "
122
+ + "a logo found page-wide, 0.8, or by its image, 0.75; a language named by its own name, 0.7); "
123
+ + "0.65 = a weak synonym (exit -> Close, remove -> Delete, learn more -> See more); 0.6 = a guess (attributes carry the words, a stem, "
124
+ + "a sub-phrase of the intent, the only X under 'in the navigation' sitting in a footer nav). An ordinal keeps its rung's band less a "
125
+ + "little (exact 0.92, contains 0.78). Several equally good matches lower each rung (exact 0.65, landmark 0.8, specials 0.7-0.75, "
126
+ + "fuzzy 0.5-0.55) and are listed in alternatives. matchedBy names the rung. "
127
+ + "Also accessibleName (the finder's own name computation, which can differ from Playwright's in edge cases), visible (laid out and not "
128
+ + "display:none, not clipped by an overflow hidden/clip ancestor - body and html clip only horizontally, a horizontal scroller never clips - "
129
+ + "not transparent unless a visible label or a styled parent box stands for it, not aria-hidden or inert, not covered by an opaque on-screen "
130
+ + "modal; scrolled-away content IS visible), zone, candidates (how many visible elements that rung matched), and alternatives (each with its own unique selector). "
131
+ + "zone is the click gate's verdict on the element itself (green/yellow/red; red is refused without force however it is addressed); "
132
+ + "a click on a container is ADDITIONALLY judged where the pointer lands, so a card or section can read yellow and still be refused "
133
+ + "when its centre is a red control, and a stale self-healing cache entry for the same selector string can still steer click() elsewhere. "
134
+ + "CHECK zone AND visible BEFORE clicking. "
135
+ + "It returns null / found:false when nothing qualifies; when the only exact match is hidden at this viewport, whatever its kind (two "
136
+ + "exceptions: a visible real button named X plus one word still answers, since that rung runs before the exact ones - a hidden 'Save' "
137
+ + "with a visible 'Save changes' gives 'Save changes' at 0.8; and an exact match display-hidden inside a folded navigation or the page "
138
+ + "header lets the search continue to the page's visible equivalent, capped at 0.7, and only to an exact-family match or a name that "
139
+ + "starts with the phrase); when an ordinal asks for "
140
+ + "more matches than the first rung that matched has ('second remove button' with one Remove); when 'X in the navigation' finds X only in "
141
+ + "a footer while the header nav is collapsed; when a fuzzy match adds an action the intent did not say - a destructive, financial or "
142
+ + "bulk verb (delete, remove, cancel, clear, reset, leave, end, wipe, archive, transfer, upgrade, place, approve, pay, buy, ...: 'account "
143
+ + "button' never reaches Delete account), a bulk quantifier (all, everything, selected, everyone, everywhere, 'of all', or a count ending "
144
+ + "the name), or any word in front of the phrase other than a known-benign one (view, send, report, confirm, edit, add, ...: 'funds button' "
145
+ + "never reaches Withdraw funds, 'spam button' does reach Report spam); and for row-scoped intents on table-layout pages with no "
146
+ + "landmarks ('upvote button for the first story' - say 'first upvote link' instead). "
147
+ + "Ordinals count person-visible elements in DOM order, not visual order ('third delete button'; 'second cheapest product' ranks by price). "
148
+ + "verbose=true on a miss lists the first visible links, buttons and fields with unique selectors so you can rephrase, and says when a "
149
+ + "locator evaluation failed during the search.";
172
150
  /**
173
- * Generate priority-ordered selectors for an element.
151
+ * Page-side picker. Runs inside locator.evaluateAll(), so it must be self-contained.
174
152
  */
175
- function generatePrioritySelectors(el) {
176
- const candidates = [];
177
- for (const strategy of SELECTOR_PRIORITY) {
178
- const value = strategy.extract(el);
179
- if (value) {
180
- candidates.push({
181
- selector: strategy.toSelector(value, el),
182
- type: strategy.name,
183
- confidence: strategy.confidence,
184
- });
153
+ const PICK = (input, arg) => {
154
+ const CONTAINERS = new Set(["HEADER", "NAV", "FOOTER", "MAIN", "ASIDE", "SECTION", "FORM", "ARTICLE", "DIV", "UL", "OL", "TR", "TABLE", "TBODY", "DIALOG", "LI"]);
155
+ // The verbs that make a name destructive or financial. A fuzzy rung may only land on a name carrying one of
156
+ // these when the intent itself says that verb: "account button" never reaches "Delete account".
157
+ // (host-side copy: DANGER_VERB_LIST; "send" and "confirm" are not destructive, financial or bulk and were dropped)
158
+ const DANGER_VERBS = ["delete", "remove", "cancel", "unsubscribe", "leave", "clear", "reset", "disconnect", "revoke", "erase",
159
+ "deactivate", "disable", "discard", "destroy", "terminate", "end", "empty", "wipe", "archive", "transfer", "upgrade", "place",
160
+ "approve", "pay", "buy", "purchase", "checkout"];
161
+ // A bulk quantifier in the name ("Delete all", "Remove selected", "Sign out everywhere", "Approve 12") needs the same in the intent.
162
+ const QUANTIFIERS = ["all", "everything", "every", "selected", "everyone", "everybody", "everywhere"];
163
+ const GENERATED_ID = /^(radix-|:r|«r|_R_|:R|ember\d|mui-|headlessui-|react-aria|__|rc-|ant-|chakra-|downshift-|react-select)|[:«»]|^[a-z]{1,2}\d+$|^[0-9a-f]{8,}$/i;
164
+ const STATE_CLASS = /^(active|open|opened|closed|hover|focus|focused|visible|hidden|show|shown|selected|current|expanded|collapsed|disabled|checked|is-|has-|js-)/;
165
+ const HASHED_CLASS = /^(css-|sc-|_|jsx-)|[_-][a-z0-9]{6,}$|\d{3,}/i;
166
+ const q = (v) => String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
167
+ const esc = (s) => (typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(s) : s.replace(/([^\w-])/g, "\\$1"));
168
+ // checkVisibility is missing from older engines; treat "unavailable" as "not hidden by it"
169
+ const cssVisible = (e) => typeof e.checkVisibility !== "function" || e.checkVisibility({ visibilityProperty: true });
170
+ let bodyClipsXCache;
171
+ const bodyClipsX = () => {
172
+ if (bodyClipsXCache === undefined) {
173
+ bodyClipsXCache = [document.body, document.documentElement].some(n => n && /^(hidden|clip)$/.test(getComputedStyle(n).overflowX));
174
+ }
175
+ return bodyClipsXCache;
176
+ };
177
+ // SVG elements carry an SVGAnimatedString, not a string
178
+ const classNameOf = (e) => {
179
+ const c = e.className;
180
+ return typeof c === "string" ? c : (c?.baseVal ?? "");
181
+ };
182
+ // ---- visibility: what a person can see at this width ----
183
+ // Modals that can block the page: opaque, accepting pointer events, and on screen. A closed off-canvas
184
+ // drawer (translateX(-100%)), an opacity-0 closed modal, and a cookie banner that sits at the bottom are
185
+ // either not blocking at all or block only what they cover, which the hit-test below decides.
186
+ let modalsCache;
187
+ const blockingModals = () => {
188
+ if (modalsCache)
189
+ return modalsCache;
190
+ modalsCache = [];
191
+ try {
192
+ const vw = window.innerWidth, vh = window.innerHeight;
193
+ for (const m of Array.from(document.querySelectorAll('[aria-modal="true"], dialog:modal'))) {
194
+ if (!cssVisible(m))
195
+ continue;
196
+ const cs = getComputedStyle(m);
197
+ if (cs.opacity === "0" || cs.pointerEvents === "none" || cs.visibility === "hidden")
198
+ continue;
199
+ const b = m.getBoundingClientRect();
200
+ if (b.width < 2 || b.height < 2 || b.right <= 0 || b.bottom <= 0 || b.left >= vw || b.top >= vh)
201
+ continue;
202
+ modalsCache.push(m);
203
+ }
204
+ }
205
+ catch { /* :modal unsupported */ }
206
+ return modalsCache;
207
+ };
208
+ function coveredByModal(e, r) {
209
+ const modals = blockingModals();
210
+ if (!modals.length || modals.some(m => m.contains(e)))
211
+ return false;
212
+ const vw = window.innerWidth, vh = window.innerHeight;
213
+ // a modal that covers the whole viewport blocks everything outside it, on screen or not
214
+ if (modals.some(m => { const b = m.getBoundingClientRect(); return b.left <= 0 && b.top <= 0 && b.right >= vw && b.bottom >= vh; }))
215
+ return true;
216
+ // otherwise only what the modal actually covers: hit-test the element's centre when it is on screen
217
+ if (r.right <= 0 || r.bottom <= 0 || r.left >= vw || r.top >= vh)
218
+ return false;
219
+ const cx = Math.min(Math.max(r.left + r.width / 2, 0), vw - 1), cy = Math.min(Math.max(r.top + r.height / 2, 0), vh - 1);
220
+ const hit = document.elementFromPoint(cx, cy);
221
+ if (!hit || hit === e || e.contains(hit))
222
+ return false;
223
+ return modals.some(m => m.contains(hit));
224
+ }
225
+ function leafVisible(e) {
226
+ if (!e || !e.isConnected)
227
+ return false;
228
+ if (!cssVisible(e))
229
+ return false;
230
+ const r = e.getBoundingClientRect();
231
+ if (r.width < 2 || r.height < 2)
232
+ return false;
233
+ const sx = window.scrollX, sy = window.scrollY;
234
+ if (r.right + sx <= 0 || r.bottom + sy <= 0)
235
+ return false;
236
+ // Past the document's right edge: off the page, unless the element sits in a horizontal scroller (a
237
+ // .table-responsive, a product rail), which a person can scroll. body/html clip only horizontally:
238
+ // overflow-x:hidden on body is the off-canvas idiom (a drawer parked at left:100% is not on the page);
239
+ // vertical overflow on body is scrolling, never clipping.
240
+ const docW = Math.max(document.documentElement.scrollWidth, document.documentElement.clientWidth);
241
+ if (r.left + sx >= docW || (r.left >= window.innerWidth && bodyClipsX())) {
242
+ let a = e.parentElement, scroller = false;
243
+ while (a && a !== document.body && a !== document.documentElement) {
244
+ if (/^(auto|scroll)$/.test(getComputedStyle(a).overflowX)) {
245
+ scroller = true;
246
+ break;
247
+ }
248
+ a = a.parentElement;
249
+ }
250
+ if (!scroller)
251
+ return false;
252
+ }
253
+ const cs = getComputedStyle(e);
254
+ if (cs.clip && cs.clip !== "auto" && /rect\((0|1)px?,?\s*(0|1)px?/.test(cs.clip))
255
+ return false;
256
+ if (cs.clipPath && /inset\(50%\)/.test(cs.clipPath))
257
+ return false;
258
+ // transparent, hidden from assistive tech, inert, or behind an open modal: not something a person can act on.
259
+ // A transparent control with a visible label is what the person sees (Wikipedia's checkbox-as-button inputs);
260
+ // a transparent button with nothing in its place is a ghost.
261
+ if (cs.opacity === "0") {
262
+ const labels = e.labels;
263
+ const labelled = !!labels && Array.from(labels).slice(0, 3).some(l => getComputedStyle(l).opacity !== "0" && cssVisible(l) && l.getBoundingClientRect().width >= 2);
264
+ // a transparent native select/input laid over a styled box is what the person sees (the box is its parent)
265
+ const p = e.parentElement;
266
+ const styledBox = /^(SELECT|INPUT|TEXTAREA)$/.test(e.tagName) && !!p && cssVisible(p) && getComputedStyle(p).opacity !== "0"
267
+ && p.getBoundingClientRect().width >= 2 && p.getBoundingClientRect().height >= 2;
268
+ if (!labelled && !styledBox)
269
+ return false;
270
+ }
271
+ if (e.closest('[aria-hidden="true"], [inert]'))
272
+ return false;
273
+ if (coveredByModal(e, r))
274
+ return false;
275
+ // Clipping ancestors: only overflow hidden/clip clips for good; auto/scroll can be scrolled to, so it is
276
+ // not clipping. Body and html are never clipping ancestors (html,body{height:100%} + overflow-x:hidden makes
277
+ // body's overflow-y compute to auto and would hide everything below the first screen).
278
+ let p = e.parentElement;
279
+ while (p && p !== document.body && p !== document.documentElement) {
280
+ const pcs = getComputedStyle(p);
281
+ if (pcs.opacity === "0")
282
+ return false;
283
+ if (pcs.position === "fixed")
284
+ break;
285
+ const clipX = /^(hidden|clip)$/.test(pcs.overflowX), clipY = /^(hidden|clip)$/.test(pcs.overflowY);
286
+ if (clipX || clipY) {
287
+ const pr = p.getBoundingClientRect();
288
+ if (pr.width < 1 || pr.height < 1)
289
+ return false;
290
+ if (clipX && (r.right <= pr.left || r.left >= pr.right))
291
+ return false;
292
+ if (clipY && (r.bottom <= pr.top || r.top >= pr.bottom))
293
+ return false;
294
+ }
295
+ p = p.parentElement;
296
+ }
297
+ return true;
298
+ }
299
+ function isVisible(e, container = arg.container) {
300
+ if (leafVisible(e))
301
+ return true;
302
+ if (!container)
303
+ return false;
304
+ const contents = e.isConnected && getComputedStyle(e).display === "contents";
305
+ if (e.isConnected && CONTAINERS.has(e.tagName) && (contents || cssVisible(e))) {
306
+ const kids = e.querySelectorAll("a, button, input, select, textarea, img, svg, h1, h2, h3, h4, p, span, li, label");
307
+ for (let i = 0; i < kids.length && i < 300; i++)
308
+ if (leafVisible(kids[i]))
309
+ return true;
310
+ }
311
+ return false;
312
+ }
313
+ // ---- accessible name (approximation; used for descriptions, guards and zone) ----
314
+ const norm = (s) => String(s || "").replace(/\s+/g, " ").trim();
315
+ function ownText(e) {
316
+ return norm(e.innerText || e.textContent || "");
317
+ }
318
+ function accName(e) {
319
+ const rootNode = typeof e.getRootNode === "function" ? e.getRootNode() : document;
320
+ const byId = (id) => ("getElementById" in rootNode ? rootNode.getElementById(id) : document.getElementById(id));
321
+ const lb = e.getAttribute("aria-labelledby");
322
+ if (lb) {
323
+ const t = lb.split(/\s+/).map(id => byId(id)).filter((n) => !!n).map(n => ownText(n)).join(" ").trim();
324
+ if (t)
325
+ return norm(t).slice(0, 200);
326
+ }
327
+ const al = e.getAttribute("aria-label");
328
+ if (al && al.trim())
329
+ return norm(al).slice(0, 200);
330
+ const tag = e.tagName;
331
+ if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") {
332
+ const ie = e;
333
+ if (tag === "INPUT" && /^(submit|button|reset|image)$/i.test(ie.type)) {
334
+ if (ie.value)
335
+ return norm(ie.value).slice(0, 200);
336
+ const alt = e.getAttribute("alt");
337
+ if (alt)
338
+ return norm(alt);
339
+ }
340
+ const labels = ie.labels;
341
+ if (labels && labels.length) {
342
+ const t = Array.from(labels).map(l => ownText(l)).join(" ").trim();
343
+ if (t)
344
+ return norm(t).slice(0, 200);
345
+ }
346
+ const ph = e.getAttribute("placeholder");
347
+ if (ph)
348
+ return norm(ph).slice(0, 200);
349
+ const ti = e.getAttribute("title");
350
+ if (ti)
351
+ return norm(ti).slice(0, 200);
352
+ if (tag === "INPUT" && /^(checkbox|radio)$/i.test(ie.type)) {
353
+ // unassociated label text right after the control: "<input type=checkbox> I agree to the terms"
354
+ let t = "", n = e.nextSibling;
355
+ while (n && t.length < 120) {
356
+ if (n.nodeType === 3)
357
+ t += n.textContent || "";
358
+ else if (n.nodeType === 1 && /^(SPAN|LABEL|B|I|EM|STRONG|A)$/.test(n.tagName))
359
+ t += " " + (n.innerText || "");
360
+ else
361
+ break;
362
+ n = n.nextSibling;
363
+ }
364
+ if (norm(t))
365
+ return norm(t).slice(0, 200);
366
+ const lp = e.closest("label");
367
+ if (lp)
368
+ return ownText(lp).slice(0, 200);
369
+ }
370
+ return "";
371
+ }
372
+ const t = ownText(e);
373
+ if (t)
374
+ return t.slice(0, 200);
375
+ const img = e.querySelector("img[alt], [role='img'][aria-label], svg[aria-label], svg > title");
376
+ if (img) {
377
+ const a = img.getAttribute("alt") || img.getAttribute("aria-label") || (img.tagName.toLowerCase() === "title" ? img.textContent : "");
378
+ if (a && a.trim())
379
+ return norm(a).slice(0, 200);
380
+ }
381
+ const inner = e.querySelector("[aria-label]");
382
+ if (inner && inner.getAttribute("aria-label"))
383
+ return norm(inner.getAttribute("aria-label") || "").slice(0, 200);
384
+ return norm(e.getAttribute("title") || e.getAttribute("alt") || "").slice(0, 200);
385
+ }
386
+ function roleOf(e) {
387
+ const r = e.getAttribute("role");
388
+ if (r)
389
+ return r.split(/\s+/)[0];
390
+ const tag = e.tagName.toLowerCase();
391
+ if (tag === "a")
392
+ return e.hasAttribute("href") ? "link" : "";
393
+ if (tag === "button")
394
+ return "button";
395
+ if (tag === "select")
396
+ return "combobox";
397
+ if (tag === "textarea")
398
+ return "textbox";
399
+ if (tag === "input") {
400
+ const t = (e.type || "text").toLowerCase();
401
+ if (t === "submit" || t === "button" || t === "reset" || t === "image")
402
+ return "button";
403
+ if (t === "checkbox" || t === "radio")
404
+ return t;
405
+ if (t === "search")
406
+ return "searchbox";
407
+ if (t === "number")
408
+ return "spinbutton";
409
+ if (t === "range")
410
+ return "slider";
411
+ return "textbox";
412
+ }
413
+ if (tag === "nav")
414
+ return "navigation";
415
+ if (tag === "main")
416
+ return "main";
417
+ if (tag === "header")
418
+ return "banner";
419
+ if (tag === "footer")
420
+ return "contentinfo";
421
+ if (tag === "aside")
422
+ return "complementary";
423
+ if (tag === "form")
424
+ return "form";
425
+ if (tag === "dialog")
426
+ return "dialog";
427
+ if (tag === "img")
428
+ return "img";
429
+ if (/^h[1-6]$/.test(tag))
430
+ return "heading";
431
+ return "";
432
+ }
433
+ // Every name the element carries, for the zone: a testid selector says nothing, but "Pay with card" in
434
+ // the value, the title or the visible text does, and the most severe of them is the one that counts.
435
+ function allNames(e) {
436
+ const out = new Set();
437
+ const add = (v) => { const t = norm(v || "").slice(0, 200); if (t)
438
+ out.add(t); };
439
+ add(accName(e));
440
+ add(e.getAttribute("aria-label"));
441
+ add(e.getAttribute("title"));
442
+ add(e.getAttribute("alt"));
443
+ add(e.getAttribute("placeholder"));
444
+ if (e.tagName === "INPUT" || e.tagName === "SELECT" || e.tagName === "TEXTAREA") {
445
+ const ie = e;
446
+ if (e.tagName === "INPUT" && /^(submit|button|reset|image)$/i.test(ie.type))
447
+ add(ie.value);
448
+ if (ie.labels)
449
+ for (const l of Array.from(ie.labels).slice(0, 3))
450
+ add(ownText(l));
451
+ }
452
+ add(ownText(e));
453
+ const img = e.querySelector("img[alt], [aria-label], svg > title");
454
+ if (img)
455
+ add(img.getAttribute("alt") || img.getAttribute("aria-label") || (img.tagName.toLowerCase() === "title" ? img.textContent : ""));
456
+ return [...out];
457
+ }
458
+ // ---- stable, verified-unique selector ----
459
+ // Playwright's CSS pierces open shadow roots and cbrowser's click resolver uses Playwright, so uniqueness is
460
+ // counted across every open shadow root too, and a candidate inside one gets "<host path> <inner path>".
461
+ let rootsCache = null;
462
+ const shadowRoots = () => {
463
+ if (rootsCache)
464
+ return rootsCache;
465
+ const out = [];
466
+ const walk = (root) => {
467
+ const all = root.querySelectorAll("*");
468
+ for (let i = 0; i < all.length && out.length < 200; i++) {
469
+ const sr = all[i].shadowRoot;
470
+ if (sr) {
471
+ out.push(sr);
472
+ walk(sr);
473
+ }
474
+ }
475
+ };
476
+ try {
477
+ walk(document);
478
+ }
479
+ catch { /* ignore */ }
480
+ rootsCache = out;
481
+ return out;
482
+ };
483
+ function uniq(sel, el) {
484
+ try {
485
+ const m = document.querySelectorAll(sel);
486
+ if (m.length > 1 || (m.length === 1 && m[0] !== el))
487
+ return false;
488
+ let n = m.length;
489
+ for (const sr of shadowRoots()) {
490
+ const k = sr.querySelectorAll(sel);
491
+ n += k.length;
492
+ if (n > 1 || (k.length === 1 && k[0] !== el))
493
+ return false;
494
+ }
495
+ return n === 1;
496
+ }
497
+ catch {
498
+ return false;
499
+ }
500
+ }
501
+ function uniqWithin(sel, el, scope) {
502
+ try {
503
+ const m = scope.querySelectorAll(sel);
504
+ return m.length === 1 && m[0] === el;
505
+ }
506
+ catch {
507
+ return false;
508
+ }
509
+ }
510
+ function stableId(e) {
511
+ const id = e.id;
512
+ if (!id || GENERATED_ID.test(id))
513
+ return null;
514
+ return id;
515
+ }
516
+ function stableClasses(e) {
517
+ return classNameOf(e).split(/\s+/).filter(c => c && /^[a-zA-Z][\w-]*$/.test(c) && !STATE_CLASS.test(c) && !HASHED_CLASS.test(c)).slice(0, 3);
518
+ }
519
+ function part(e) {
520
+ const tag = e.tagName.toLowerCase();
521
+ const parent = e.parentElement;
522
+ let p = tag;
523
+ if (parent) {
524
+ const same = Array.from(parent.children).filter(c => c.tagName === e.tagName);
525
+ if (same.length > 1)
526
+ p += `:nth-of-type(${same.indexOf(e) + 1})`;
185
527
  }
528
+ return p;
186
529
  }
187
- return candidates;
530
+ function anchorFor(e) {
531
+ const id = stableId(e);
532
+ if (id)
533
+ return "#" + esc(id);
534
+ for (const a of ["data-testid", "data-test-id", "data-test", "data-cy", "data-qa"]) {
535
+ const v = e.getAttribute(a);
536
+ if (v)
537
+ return `[${a}="${q(v)}"]`;
538
+ }
539
+ return null;
540
+ }
541
+ // cbrowser's click resolver reads a selector string as page TEXT first (exact, then a case-insensitive
542
+ // substring, then placeholder / label / button-name), and only then as CSS. So a form is usable only when
543
+ // no text or name on the page contains it: "#home" with a hashtag "#home" in a post, or a lone "button"
544
+ // beside "Press the button below", would click the wrong element. One haystack per pick, built lazily.
545
+ let hayCache = null;
546
+ const haystack = () => {
547
+ if (hayCache !== null)
548
+ return hayCache;
549
+ const parts = [norm(document.body ? document.body.textContent || "" : "")];
550
+ const addNamed = (root) => {
551
+ const named = root.querySelectorAll("[aria-label], [placeholder], [title], [alt], input[value]");
552
+ for (let i = 0; i < named.length && i < 3000; i++) {
553
+ for (const a of ["aria-label", "placeholder", "title", "alt", "value"]) {
554
+ const v = named[i].getAttribute(a);
555
+ if (v)
556
+ parts.push(norm(v));
557
+ }
558
+ }
559
+ };
560
+ addNamed(document);
561
+ // getByText sees into open shadow roots too
562
+ for (const sr of shadowRoots()) {
563
+ parts.push(norm(sr.textContent || ""));
564
+ addNamed(sr);
565
+ }
566
+ hayCache = parts.join(" \n ").toLowerCase();
567
+ return hayCache;
568
+ };
569
+ const collides = (sel) => haystack().includes(norm(sel).toLowerCase());
570
+ function uniqueSelector(e) {
571
+ const root = e.getRootNode();
572
+ if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
573
+ // "<host path> <inner path>": Playwright's CSS descends into the open shadow root of the host
574
+ const host = uniqueSelector(root.host);
575
+ const inner = uniqueSelectorWith(e, (sel, el) => uniqWithin(sel, el, root) && !collides(sel), root);
576
+ return host.selector && inner.selector ? { selector: `${host.selector} ${inner.selector}`, type: inner.type } : { selector: "", type: "nth-of-type" };
577
+ }
578
+ return uniqueSelectorWith(e, (sel, el) => uniq(sel, el) && !collides(sel), null);
579
+ }
580
+ function uniqueSelectorWith(e, usable, scope) {
581
+ const tag = e.tagName.toLowerCase();
582
+ const tries = [];
583
+ for (const a of ["data-testid", "data-test-id", "data-test", "data-cy", "data-qa"]) {
584
+ const v = e.getAttribute(a);
585
+ if (v)
586
+ tries.push([`[${a}="${q(v)}"]`, "data-testid"]);
587
+ }
588
+ const al = e.getAttribute("aria-label");
589
+ if (al)
590
+ tries.push([`${tag}[aria-label="${q(al)}"]`, "aria-label"]);
591
+ if (tag === "input") {
592
+ const ie = e;
593
+ const v = e.getAttribute("value");
594
+ if (v && /^(submit|button|reset)$/i.test(ie.type))
595
+ tries.push([`input[value="${q(v)}"]`, "input-type"]);
596
+ }
597
+ const id = stableId(e);
598
+ if (id)
599
+ tries.push(["#" + esc(id), "id"]);
600
+ const nm = e.getAttribute("name");
601
+ if (nm)
602
+ tries.push([`${tag}[name="${q(nm)}"]`, "name"]);
603
+ const ti = e.getAttribute("title");
604
+ if (ti)
605
+ tries.push([`${tag}[title="${q(ti)}"]`, "aria-label"]);
606
+ const ph = e.getAttribute("placeholder");
607
+ if (ph)
608
+ tries.push([`${tag}[placeholder="${q(ph)}"]`, "name"]);
609
+ if (tag === "a") {
610
+ const href = e.getAttribute("href");
611
+ if (href && href !== "#" && !/^javascript:/i.test(href))
612
+ tries.push([`a[href="${q(href)}"]`, "css-class"]);
613
+ }
614
+ if (tag === "input") {
615
+ const t = e.getAttribute("type");
616
+ if (t)
617
+ tries.push([`input[type="${q(t)}"]`, "input-type"]);
618
+ }
619
+ const role = e.getAttribute("role");
620
+ if (role)
621
+ tries.push([`${tag}[role="${q(role)}"]`, "role"]);
622
+ // "body > footer" rather than bare "footer": cbrowser's click resolver reads a bare word as page text first
623
+ if (["header", "footer", "main", "nav", "aside", "form", "dialog", "section", "article"].includes(tag)) {
624
+ const parent = e.parentElement;
625
+ if (parent && parent.tagName === "BODY")
626
+ tries.push(["body > " + tag, "semantic-element"]);
627
+ tries.push(["body " + tag, "semantic-element"]);
628
+ }
629
+ const lb = e.getAttribute("aria-labelledby");
630
+ if (lb)
631
+ tries.push([`${tag}[aria-labelledby="${q(lb)}"]`, "aria-labelledby"]);
632
+ const cls = stableClasses(e);
633
+ if (cls.length)
634
+ tries.push([tag + cls.map(c => "." + esc(c)).join(""), "css-class"]);
635
+ for (const [s, t] of tries)
636
+ if (usable(s, e))
637
+ return { selector: s, type: t };
638
+ // Structural path: walk up until unique, anchoring at the nearest stable id/testid. Never a lone part:
639
+ // a bare tag ("button") is page text to the click resolver, and a lone positional part is unique only by accident.
640
+ const parts = [part(e)];
641
+ let cur = e.parentElement;
642
+ let guardN = 0;
643
+ while (cur && cur !== document.documentElement && guardN++ < 40) {
644
+ const sel = parts.join(" > ");
645
+ if (parts.length > 1 && usable(sel, e))
646
+ return { selector: sel, type: "nth-of-type" };
647
+ const anchor = anchorFor(cur);
648
+ if (anchor) {
649
+ const s2 = anchor + " > " + sel;
650
+ if (usable(s2, e))
651
+ return { selector: s2, type: "nth-of-type" };
652
+ }
653
+ if (cur.tagName === "BODY") {
654
+ parts.unshift("body");
655
+ break;
656
+ }
657
+ parts.unshift(part(cur));
658
+ cur = cur.parentElement;
659
+ }
660
+ const full = parts.join(" > ");
661
+ if (scope ? uniqWithin(full, e, scope) : uniq(full, e))
662
+ return { selector: full, type: "nth-of-type" };
663
+ return { selector: "", type: "nth-of-type" };
664
+ }
665
+ // ---- filters ----
666
+ const re = (s) => (s ? new RegExp(s, "i") : null);
667
+ function attrBlob(e) {
668
+ return [
669
+ e.id, classNameOf(e), e.getAttribute("aria-label"), e.getAttribute("name"), e.getAttribute("data-testid"),
670
+ e.getAttribute("title"), e.getAttribute("role"), e.getAttribute("action"), e.getAttribute("type"),
671
+ ].filter(Boolean).join(" ");
672
+ }
673
+ function passes(e, f, name) {
674
+ if (f.anyOf)
675
+ return f.anyOf.some(sub => passes(e, sub, name));
676
+ if (f.matchCss) {
677
+ try {
678
+ if (!e.matches(f.matchCss))
679
+ return false;
680
+ }
681
+ catch {
682
+ return false;
683
+ }
684
+ }
685
+ if (f.hasVisibleCss) {
686
+ try {
687
+ if (Array.from(e.querySelectorAll(f.hasVisibleCss)).slice(0, 300).filter(k => leafVisible(k)).length < (f.minVisible ?? 1))
688
+ return false;
689
+ }
690
+ catch {
691
+ return false;
692
+ }
693
+ }
694
+ if (f.notHasVisibleCss) {
695
+ try {
696
+ if (Array.from(e.querySelectorAll(f.notHasVisibleCss)).slice(0, 300).some(k => leafVisible(k)))
697
+ return false;
698
+ }
699
+ catch {
700
+ return false;
701
+ }
702
+ }
703
+ const nr = re(f.nameRe);
704
+ if (nr && !nr.test(name))
705
+ return false;
706
+ const ar = re(f.attrRe);
707
+ if (ar && !ar.test(attrBlob(e)))
708
+ return false;
709
+ const tr = re(f.textRe);
710
+ if (tr && !tr.test(ownText(e)))
711
+ return false;
712
+ const gr = re(f.tagRe);
713
+ if (gr && !gr.test(e.tagName.toLowerCase()))
714
+ return false;
715
+ if (f.hasCss) {
716
+ try {
717
+ if (!e.querySelector(f.hasCss))
718
+ return false;
719
+ }
720
+ catch {
721
+ return false;
722
+ }
723
+ }
724
+ if (f.withinCss) {
725
+ try {
726
+ if (!e.closest(f.withinCss))
727
+ return false;
728
+ }
729
+ catch {
730
+ return false;
731
+ }
732
+ }
733
+ if (f.notWithinCss) {
734
+ try {
735
+ if (e.closest(f.notWithinCss))
736
+ return false;
737
+ }
738
+ catch {
739
+ return false;
740
+ }
741
+ }
742
+ const nn = re(f.notNameRe);
743
+ if (nn && nn.test(name))
744
+ return false;
745
+ if (f.iconLink && !(e.querySelector("img, svg, picture") && !norm(ownText(e))))
746
+ return false;
747
+ if (f.rootLink) {
748
+ if (e.tagName !== "A")
749
+ return false;
750
+ const href = (e.getAttribute("href") || "").trim();
751
+ // literal roots first (location can be about:blank, where relative URLs do not parse), then an absolute wordmark href
752
+ let isRoot = /^(\/|\.\/|index\.html|\/index\.html)$/.test(href);
753
+ if (!isRoot) {
754
+ try {
755
+ const u = new URL(href);
756
+ isRoot = u.pathname === "/" && !u.search && !u.hash;
757
+ }
758
+ catch { /* relative, not a root */ }
759
+ }
760
+ if (!isRoot || e.closest("nav, [role='navigation'], ul, ol, [role='menu']") || /^\s*(home|homepage|home page)\s*$/i.test(name))
761
+ return false;
762
+ }
763
+ return true;
764
+ }
765
+ const words = (s) => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean);
766
+ function guardOk(name) {
767
+ const g = arg.guard;
768
+ if (!g)
769
+ return true;
770
+ const nw = words(name);
771
+ const iw = g.words;
772
+ if (g.danger) {
773
+ for (const v of DANGER_VERBS)
774
+ if (nw.includes(v) && !iw.includes(v))
775
+ return false;
776
+ // "Close account" is the one destructive name without a listed verb
777
+ if (/\bclose\s+(?:my\s+|your\s+|the\s+)?account\b/.test(name.toLowerCase()) && !(iw.includes("close") && iw.includes("account")))
778
+ return false;
779
+ }
780
+ // "all" / "everything" anywhere, or a count (1-3 digits, or a year) right after the phrase ("Delete 2",
781
+ // "Pricing 2025", "Call 988"); a longer number is an identifier ("Approve order 10002"). A container's text
782
+ // is not a control's name, so container kinds skip the number rule (a card's price follows its title).
783
+ if (g.quant !== false) {
784
+ // The quantifier is the phrase's object: "Remove all", "Delete everything", "Approve 12", "Delete 2", "Pricing 2025",
785
+ // "Call 988". Not "Unsubscribe from all emails" (all modifies emails), not "Try Free - 5 Tests" (the count is
786
+ // mid-name), not "Pay $12.00" (a price). A count is 1-3 digits or a year, and must end the name.
787
+ const lastIntent = iw[iw.length - 1];
788
+ const after = (i) => i > 0 && nw[i - 1] === lastIntent;
789
+ const priceLike = /[$£€¥]\s?\d|\b\d{1,3}[.,]\d{2}\b/.test(name);
790
+ const isCount = (w) => /^\d{1,3}$/.test(w) || /^(19|20)\d{2}$/.test(w);
791
+ // "of all" anywhere is bulk too ("Sign out of all devices"); "from all" is not ("Unsubscribe from all emails")
792
+ const quantAfterPhrase = nw.some((w, i) => QUANTIFIERS.includes(w) && (after(i) || (w === "all" && i > 0 && nw[i - 1] === "of")));
793
+ const countEndsName = !priceLike && nw.length > 0 && isCount(nw[nw.length - 1]) && after(nw.length - 1);
794
+ if ((quantAfterPhrase && !iw.some(w => QUANTIFIERS.includes(w))) || (countEndsName && !iw.some(w => /^\d+$/.test(w))))
795
+ return false;
796
+ }
797
+ // An object-only intent ("funds button", "pull request button") never takes a name that puts words in front of the
798
+ // phrase: the word in front is the action ("Withdraw funds", "Merge pull request", "Cookie settings"), and the intent
799
+ // did not say it. Field labels are sentences, so field kinds are exempt.
800
+ // A known-benign lead is allowed ("Report spam", "Send invite", "Confirm email", "View orders"); anything else in front
801
+ // of the phrase is an action the intent did not name (Withdraw, Merge, Refund, Suspend, Ban, Stop, Publish, Deploy, Cookie).
802
+ if (g.noWordsBefore && iw.length) {
803
+ const BENIGN_LEAD = new Set(["report", "view", "show", "open", "see", "read", "add", "new", "create", "edit", "learn", "get", "start", "go",
804
+ "browse", "search", "find", "filter", "sort", "export", "download", "print", "share", "copy", "select", "choose", "toggle", "change",
805
+ "update", "manage", "contact", "send", "confirm", "write", "compose", "reply", "mark", "save", "continue", "more", "my", "your", "our",
806
+ "the", "a", "an", "to", "hide", "expand", "collapse", "back", "forward", "previous", "next", "main", "skip", "visit", "explore", "watch",
807
+ "play", "listen", "subscribe", "follow", "like", "upload", "attach", "insert", "request", "invite", "join", "accept", "sign", "log"]);
808
+ let start = -1;
809
+ for (let i = 0; i + iw.length <= nw.length && start < 0; i++)
810
+ if (iw.every((w, j) => nw[i + j] === w))
811
+ start = i;
812
+ if (start < 0)
813
+ start = nw.indexOf(iw[0]);
814
+ if (start > 0 && nw.slice(0, start).some(w => !iw.includes(w) && !BENIGN_LEAD.has(w)))
815
+ return false;
816
+ }
817
+ // "X plus one word": the extra must be a word ("Features tour"), not a number or a price ("Save $5", "Delete 2")
818
+ if (g.lettersOnlyExtra && nw.some(w => !iw.includes(w) && !/^\p{L}+$/u.test(w)))
819
+ return false;
820
+ const extra = nw.length - iw.length;
821
+ if (extra <= g.maxExtra)
822
+ return true;
823
+ // prefix match: the name starts with the intent words
824
+ let prefix = iw.length > 0;
825
+ for (let i = 0; i < iw.length; i++)
826
+ if (nw[i] !== iw[i]) {
827
+ prefix = false;
828
+ break;
829
+ }
830
+ return prefix && extra <= g.prefixMaxExtra;
831
+ }
832
+ function isPrefix(name) {
833
+ const g = arg.guard;
834
+ if (!g || !g.words.length)
835
+ return false;
836
+ const nw = words(name);
837
+ for (let i = 0; i < g.words.length; i++)
838
+ if (nw[i] !== g.words[i])
839
+ return false;
840
+ return true;
841
+ }
842
+ // ---- pipeline ----
843
+ let els = [...new Set(input.filter(e => e && e.nodeType === 1 && !/^(HTML|BODY|HEAD)$/.test(e.tagName)))];
844
+ if (arg.liftToControl) {
845
+ const lifted = els.map(e => e.closest("a[href], button, [role='button'], [role='link'], [role='tab'], [role='menuitem'], summary, label, select, input") || e);
846
+ els = [...new Set(lifted)];
847
+ }
848
+ const total = els.length;
849
+ const named = els.map(e => ({ e, name: accName(e) }));
850
+ let cands = named.filter(x => arg.require.every(f => passes(x.e, f, x.name)));
851
+ cands = cands.filter(x => guardOk(x.name));
852
+ if (arg.minNameWords)
853
+ cands = cands.filter(x => words(x.name).length >= arg.minNameWords);
854
+ if (arg.badge) {
855
+ // "Cart (2)" and "Star <span class=Counter>1.2k</span>" are the exact name with a badge; "Delete 2", "Call 988"
856
+ // and "Pricing 2025" are different names that happen to end in a number
857
+ cands = cands.filter(x => {
858
+ const m = x.name.match(/(\(\s*[\d.,]+k?\+?\s*\)|[\d.,]+k?\+?)[\s\W_]*$/i);
859
+ if (!m)
860
+ return true;
861
+ // a count on a destructive or financial name is a bulk action ("Delete (2)", "Archive <span>3</span>"), never the exact name
862
+ if (words(x.name).some(w => DANGER_VERBS.includes(w)))
863
+ return false;
864
+ if (m[1].startsWith("("))
865
+ return true;
866
+ const count = norm(m[1]);
867
+ return Array.from(x.e.querySelectorAll("span, b, i, em, strong, sup, small, div")).slice(0, 20).some(c => norm(c.textContent || "") === count);
868
+ });
869
+ }
870
+ // nesting is resolved among the candidates that survived the filters (a product card contains its image)
871
+ if (arg.dedupeNested) {
872
+ const set = new Set(cands.map(x => x.e));
873
+ cands = cands.filter(x => ![...set].some(o => o !== x.e && x.e.contains(o)));
874
+ }
875
+ if (arg.dedupeOuter) {
876
+ const set = new Set(cands.map(x => x.e));
877
+ cands = cands.filter(x => ![...set].some(o => o !== x.e && o.contains(x.e)));
878
+ }
879
+ const visibleAll = cands.filter(x => isVisible(x.e));
880
+ const hiddenOnly = cands.length > 0 && visibleAll.length === 0;
881
+ // Hidden how? A match display-hidden inside a navigation, header, menu or dropdown is a collapsed copy of a
882
+ // control the page shows elsewhere (the footer Documentation link when the header nav is folded at 393); a
883
+ // control hidden on its own (a display:none Save, an opacity-0 row action) is simply not there.
884
+ // Real navigation only: a nav landmark or the page header (whose links fold at narrow widths). A row's action
885
+ // dropdown or a <details> menu in the main content is not a folded copy of the page's navigation.
886
+ const COLLAPSIBLE = "nav, [role='navigation'], [role='menubar'], header, [role='banner']";
887
+ const hiddenInCollapsedNav = hiddenOnly && cands.every(x => !cssVisible(x.e) && !!x.e.closest(COLLAPSIBLE));
888
+ let pool = visibleAll;
889
+ if (arg.ordinal === null) {
890
+ for (const f of arg.prefer) {
891
+ const kept = pool.filter(x => passes(x.e, f, x.name));
892
+ if (kept.length)
893
+ pool = kept;
894
+ }
895
+ if (arg.guard && arg.guard.words.length) {
896
+ // a name that starts with the phrase ("Cart (2)") beats one that merely contains it ("Add to cart")
897
+ const pre = pool.filter(x => isPrefix(x.name)), rest = pool.filter(x => !isPrefix(x.name));
898
+ if (pre.length && rest.length)
899
+ pool = [...pre, ...rest];
900
+ }
901
+ if (arg.preferLonger && pool.length > 1) {
902
+ // "Hacker News" over "News": the name that covers the most intent words
903
+ const wc = (x) => words(x.name).length;
904
+ const most = Math.max(...pool.map(wc));
905
+ pool = pool.filter(x => wc(x) === most);
906
+ }
907
+ }
908
+ const info = (x) => {
909
+ const u = uniqueSelector(x.e);
910
+ const c = {
911
+ selector: u.selector, selectorType: u.type, name: x.name, names: allNames(x.e), text: ownText(x.e).slice(0, 100),
912
+ tag: x.e.tagName.toLowerCase(), role: roleOf(x.e), visible: isVisible(x.e),
913
+ };
914
+ if (arg.markCss) {
915
+ try {
916
+ c.marked = !!x.e.closest(arg.markCss);
917
+ }
918
+ catch {
919
+ c.marked = false;
920
+ }
921
+ }
922
+ return c;
923
+ };
924
+ const out = { total, visibleCount: visibleAll.length, hiddenOnly, hiddenInCollapsedNav, ambiguous: false, chosen: null, others: [] };
925
+ if (arg.countCss) {
926
+ try {
927
+ out.countVisible = Array.from(document.querySelectorAll(arg.countCss)).slice(0, 200).filter(e => isVisible(e, true)).length;
928
+ }
929
+ catch { /* unsupported selector: leave undefined */ }
930
+ }
931
+ if (!pool.length)
932
+ return out;
933
+ let idx = 0;
934
+ if (arg.ordinal !== null) {
935
+ idx = arg.ordinal === -1 ? pool.length - 1 : arg.ordinal;
936
+ if (idx < 0 || idx >= pool.length) {
937
+ out.ordinalOutOfRange = true;
938
+ return out;
939
+ }
940
+ }
941
+ else if (pool.length > 1) {
942
+ out.ambiguous = true;
943
+ if (arg.mode === "unique") {
944
+ out.others = pool.slice(0, arg.maxOthers ?? 5).map(info).filter(c => c.selector);
945
+ return out;
946
+ }
947
+ }
948
+ const chosen = info(pool[idx]);
949
+ if (!chosen.selector)
950
+ return out;
951
+ out.chosen = chosen;
952
+ out.others = pool.filter((_, i) => i !== idx).slice(0, arg.maxOthers ?? 5).map(info).filter(c => c.selector);
953
+ return out;
954
+ };
955
+ const ORDINALS = {
956
+ first: 0, second: 1, third: 2, fourth: 3, fifth: 4, sixth: 5, seventh: 6, eighth: 7, ninth: 8, tenth: 9,
957
+ "1st": 0, "2nd": 1, "3rd": 2, "4th": 3, "5th": 4, "6th": 5, "7th": 6, "8th": 7, "9th": 8, "10th": 9,
958
+ last: -1, final: -1,
959
+ };
960
+ const SCOPE_WORDS = {
961
+ header: "header", banner: "header", "top bar": "header", topbar: "header", masthead: "header",
962
+ nav: "nav", navbar: "nav", navigation: "nav", menu: "nav", "navigation bar": "nav", "nav bar": "nav", "menu bar": "nav", menubar: "nav",
963
+ footer: "footer", "page footer": "footer", "site footer": "footer",
964
+ sidebar: "aside", aside: "aside", "side bar": "aside",
965
+ main: "main", content: "main", "main content": "main", body: "main",
966
+ form: "form", dialog: "dialog", modal: "dialog", popup: "dialog",
967
+ "table of contents": "toc", toc: "toc", pagination: "pagination",
968
+ };
969
+ const LANDMARK_LAST = {
970
+ header: "header", banner: "header", masthead: "header", topbar: "header",
971
+ footer: "footer",
972
+ main: "main", content: "main", area: "main",
973
+ nav: "nav", navbar: "nav", navigation: "nav", menu: "nav", bar: "nav", menubar: "nav",
974
+ sidebar: "aside", aside: "aside",
975
+ form: "form",
976
+ section: "section", article: "article",
977
+ dialog: "dialog", modal: "dialog", popup: "dialog",
978
+ contents: "toc", toc: "toc", pagination: "pagination",
979
+ };
980
+ // Words that are only landmark qualifiers (never a control name on their own).
981
+ const LANDMARK_QUALIFIERS = new Set([
982
+ "the", "main", "primary", "secondary", "global", "site", "page", "top", "bottom", "fixed", "fixed-position", "sticky",
983
+ "header", "footer", "article", "post", "docs", "nav", "navigation", "menu", "login", "signin", "sign-in", "signup",
984
+ "register", "registration", "newsletter", "contact", "search", "pricing", "left", "right", "of", "table", "contents",
985
+ "content", "area", "bar", "hero", "features", "sidebar", "mobile", "desktop", "app", "shell", "web",
986
+ ]);
987
+ const KIND_WORDS = [
988
+ [/\b(?:button|btn|cta)$/, "button"],
989
+ [/\b(?:link|anchor|hyperlink)$/, "link"],
990
+ [/\btab$/, "tab"],
991
+ [/\b(?:checkbox|check box|tick box)$/, "checkbox"],
992
+ [/\b(?:radio|radio button|radio option)$/, "radio"],
993
+ [/\b(?:dropdown|drop-down|drop down|select|selector|combobox|combo box|picker)$/, "dropdown"],
994
+ [/\b(?:textarea|text area|comment box|message box)$/, "textarea"],
995
+ [/\b(?:field|input|input field|text input|text field|text box|textbox|box|entry)$/, "field"],
996
+ [/\b(?:menu item|menuitem|option)$/, "menuitem"],
997
+ [/\b(?:image|img|picture|icon)$/, "image"],
998
+ [/\b(?:card|tile|panel)$/, "card"],
999
+ [/\b(?:product|item|listing)$/, "product"],
1000
+ [/\b(?:heading|headline|title)$/, "heading"],
1001
+ [/\b(?:toggle|switch)$/, "toggle"],
1002
+ [/\bsection$/, "section"],
1003
+ [/\barticle$/, "article"],
1004
+ [/\bform$/, "form"],
1005
+ ];
1006
+ const STOP = new Set(["the", "a", "an", "to", "for", "of", "on", "at", "is", "that", "this", "it", "my", "me", "please", "some", "any"]);
1007
+ function parseIntent(raw) {
1008
+ let s = String(raw || "").toLowerCase().replace(/["'`“”‘’]/g, "").replace(/\s+/g, " ").trim();
1009
+ const out = {
1010
+ raw, phrase: "", phraseFull: "", clickVerb: false, words: [], ordinal: null, scope: null,
1011
+ kind: "any", kindExplicit: false, kindWord: "", landmark: null, special: null,
1012
+ };
1013
+ // leading verbs
1014
+ const vm = s.match(/^(?:please\s+)?(click|clicking|press|tap|hit|go to|navigate to|take me to|jump to|focus|focus on|locate|where is|show me)\s+(?:on\s+)?(?:the\s+)?/);
1015
+ if (vm) {
1016
+ if (/^(click|clicking|press|tap|hit)$/.test(vm[1]))
1017
+ out.clickVerb = true;
1018
+ s = s.slice(vm[0].length);
1019
+ }
1020
+ s = s.replace(/^(?:the|a|an)\s+/, "");
1021
+ // scope: "... in the header" / "... inside the navigation" / "... in nav"
1022
+ const scopeKeys = Object.keys(SCOPE_WORDS).sort((a, b) => b.length - a.length);
1023
+ const scopeAlts = scopeKeys.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
1024
+ const scopeRe = new RegExp(`\\s*\\b(?:in|inside|within|on|at|from|of|under)\\s+(?:the\\s+|this\\s+)?(?:(?:main|primary|top|site|page|global)\\s+)?` +
1025
+ `(${scopeAlts})\\b\\s*(?:area|bar|region|section)?\\s*$`);
1026
+ const sm = s.match(scopeRe);
1027
+ if (sm && sm.index > 0) {
1028
+ out.scope = SCOPE_WORDS[sm[1]];
1029
+ s = s.slice(0, sm.index).trim();
1030
+ }
1031
+ // ordinal
1032
+ const toks = s.split(" ");
1033
+ for (let i = 0; i < toks.length; i++) {
1034
+ if (ORDINALS[toks[i]] !== undefined && !(toks[i] === "last" && i === toks.length - 1 && toks.length > 1 && false)) {
1035
+ out.ordinal = ORDINALS[toks[i]];
1036
+ toks.splice(i, 1);
1037
+ break;
1038
+ }
1039
+ }
1040
+ s = toks.join(" ").replace(/^(?:the|a|an)\s+/, "").trim();
1041
+ // "link to X" / "button to X" / "button for X"
1042
+ const lt = s.match(/^(button|link|tab|field|input|checkbox)\s+(?:to|for|that says|labelled|labeled|named|called|with text|with label)\s+(.+)$/);
1043
+ if (lt) {
1044
+ s = lt[2].replace(/^(?:the|a|an)\s+/, "").trim();
1045
+ out.kind = lt[1] === "input" ? "field" : lt[1];
1046
+ out.kindExplicit = true;
1047
+ }
1048
+ // "<container> <kind>" with nothing else: "nav button", "header link", "footer button", "sidebar button", "form button"
1049
+ const ck = s.match(/^(header|nav|navbar|navigation|footer|sidebar|aside|form|main|dialog|modal)\s+(button|btn|link|input|field|checkbox|dropdown|select)$/);
1050
+ if (ck && !out.scope) {
1051
+ out.scope = SCOPE_WORDS[ck[1]] ?? (ck[1] === "modal" ? "dialog" : "nav");
1052
+ s = ck[2];
1053
+ }
1054
+ // price specials
1055
+ if (/\b(cheapest|lowest[- ]price[d]?|least expensive)\b/.test(s))
1056
+ out.special = "cheapest";
1057
+ else if (/\b(most expensive|highest[- ]price[d]?|priciest|dearest)\b/.test(s))
1058
+ out.special = "expensive";
1059
+ // landmark intents (whole phrase)
1060
+ const lmWords = s.split(" ").filter(Boolean);
1061
+ const lastWord = lmWords[lmWords.length - 1] || "";
1062
+ const lmKind = LANDMARK_LAST[lastWord];
1063
+ const onlyQualifiers = lmWords.slice(0, -1).every(w => LANDMARK_QUALIFIERS.has(w) || LANDMARK_LAST[w] !== undefined);
1064
+ const isTocPhrase = /^(?:the\s+)?table of contents$/.test(s);
1065
+ const isBareMenuControl = /^(?:open|close|toggle|hamburger|user|account|profile|product|products|more|options|kebab|overflow|dropdown|context)\s+menu$/.test(s);
1066
+ if (!out.special && lmKind && onlyQualifiers && !out.kindExplicit && !isBareMenuControl && (lmWords.length <= 4 || isTocPhrase)) {
1067
+ const quals = isTocPhrase ? [] : lmWords.slice(0, -1).filter(w => w !== "the" && w !== "of");
1068
+ // "nav menu" / "navigation menu" / "navigation bar" / "menu bar": the extra landmark word is not a qualifier
1069
+ out.landmark = { kind: isTocPhrase ? "toc" : lmKind, qualifiers: quals.filter(w => !(["nav", "navigation", "menu", "bar", "navbar"].includes(w) && lmKind === "nav")) };
1070
+ if (lmKind === "nav" && lastWord === "bar")
1071
+ out.landmark.kind = "nav";
1072
+ out.phrase = s;
1073
+ out.phraseFull = s;
1074
+ out.words = lmWords;
1075
+ return out;
1076
+ }
1077
+ // kind (trailing word)
1078
+ if (!out.kindExplicit) {
1079
+ for (const [rx, k] of KIND_WORDS) {
1080
+ const m = s.match(rx);
1081
+ if (m && m.index !== undefined) {
1082
+ // keep single-word intents like "button", "link", "input" as kind-only
1083
+ out.kind = k;
1084
+ out.kindExplicit = true;
1085
+ out.kindWord = m[0].trim();
1086
+ s = s.slice(0, m.index).trim().replace(/\s+(?:the|a|an)$/, "").trim();
1087
+ break;
1088
+ }
1089
+ }
1090
+ }
1091
+ // "product card" / "item tile": a card of product kind, not a card named "product"
1092
+ if ((out.kind === "card" || out.kind === "product") && /^(?:product|item|listing)s?$/.test(s))
1093
+ s = "";
1094
+ // "x button" -> close ; "close the dialog" -> close
1095
+ if (out.kind === "button" && /^(x|×|✕|✖|cross)$/.test(s)) {
1096
+ out.special = "close";
1097
+ s = "close";
1098
+ }
1099
+ if (/^(close|dismiss|exit)\s+(?:the\s+|this\s+)?(dialog|modal|popup|pop-up|window|overlay|banner|notification|toast|alert|panel|drawer|sheet|box)$/.test(s))
1100
+ s = s.split(" ")[0];
1101
+ out.phrase = s;
1102
+ out.phraseFull = vm ? String(raw || "").toLowerCase().replace(/["'`“”‘’]/g, "").replace(/\s+/g, " ").trim().replace(/^(?:please\s+)?/, "").replace(scopeRe, "").trim().slice(0, 200) : s;
1103
+ out.words = s.split(/[^\p{L}\p{N}]+/u).filter(w => w && !STOP.has(w));
1104
+ // a click verb on a bare phrase means a clickable: "click search" is the search button, "click docs" the link
1105
+ if (out.clickVerb && out.kind === "any") {
1106
+ out.kind = "button";
1107
+ }
1108
+ const ph = s.replace(/^(?:the|a|an)\s+/, "");
1109
+ if (!out.special) {
1110
+ if (/^(?:submit|submission)$/.test(ph) && (out.kind === "button" || out.kind === "any"))
1111
+ out.special = "submit";
1112
+ else if (/^(?:site |store |company |brand |page )?(?:logo|brand|logotype|wordmark|brand logo|logo image|logo link)$/.test(ph) && out.kind !== "field")
1113
+ out.special = "logo";
1114
+ else if (/^\S+(?:\s+\S+)?\s+logo$/.test(ph) && out.kind !== "field") {
1115
+ // "GOV.UK logo", "Acme Corp logo": the logo special, with the brand words to look for in the link's name
1116
+ out.special = "logo";
1117
+ out.brand = ph.replace(/\s+logo$/, "").split(/[^\p{L}\p{N}]+/u).filter(w => w && !STOP.has(w));
1118
+ }
1119
+ else if (/^(?:home|homepage|home page)$/.test(ph) && out.kind !== "field" && out.kind !== "button")
1120
+ out.special = "home";
1121
+ else if (/^(?:search|site search|search bar|search box|search input|search field)$/.test(ph) && (out.kind === "any" || out.kind === "field") && !out.clickVerb)
1122
+ out.special = "search";
1123
+ else if (/^(?:menu|hamburger|hamburger menu|burger|burger menu|mobile menu|navigation toggle|menu toggle|nav toggle)$/.test(ph)
1124
+ && (out.kind === "button" || out.kind === "any" || out.kind === "toggle" || out.kind === "image"))
1125
+ out.special = "menu";
1126
+ else if (/^(?:language|languages|language selector|language switcher|locale|change language|select language|language picker)$/.test(ph))
1127
+ out.special = "language";
1128
+ else if (/^(?:theme|dark mode|light mode|dark theme|color scheme|colour scheme|theme toggle|dark mode toggle|appearance)$/.test(ph))
1129
+ out.special = "theme";
1130
+ else if (/^(?:close|dismiss|x|×|✕)$/.test(ph) && (out.kind === "button" || out.kind === "any" || out.kind === "image"))
1131
+ out.special = "close";
1132
+ }
1133
+ return out;
1134
+ }
1135
+ // ============================================================================
1136
+ // Name regexes and synonyms
1137
+ // ============================================================================
1138
+ const COMPOUNDS = { login: "log in", logout: "log out", signin: "sign in", signout: "sign out", signup: "sign up", checkout: "check out", dropdown: "drop down" };
1139
+ const SYNONYM_GROUPS = [
1140
+ ["log in", "login", "sign in", "signin", "log on", "logon"],
1141
+ ["log out", "logout", "sign out", "signout"],
1142
+ // Synonyms are the same action under another name. Pairs that cross actions were split out after a
1143
+ // cross-vendor audit: "join" (a meeting), "start free trial", "exit", "apply", "go", "forward", "unsubscribe".
1144
+ ["sign up", "signup", "register", "create account", "create an account", "create your account", "create free account",
1145
+ "create a free account", "create one", "create one free", "create one now", "join now"],
1146
+ ["search", "find", "look up", "lookup", "look for"],
1147
+ ["close", "dismiss", "x", "×", "✕", "✖", "close dialog", "close modal", "close window"],
1148
+ ["submit", "send"],
1149
+ ["menu", "hamburger", "hamburger menu", "open menu", "toggle menu", "toggle navigation", "toggle navigation menu",
1150
+ "navigation menu", "main menu", "open main menu", "open navigation", "show menu", "nav menu", "burger"],
1151
+ ["home", "homepage", "home page", "start page"],
1152
+ ["docs", "documentation"],
1153
+ ["cart", "basket", "my cart", "shopping cart", "my basket", "shopping bag", "bag"],
1154
+ ["theme", "dark mode", "light mode", "toggle theme", "toggle dark mode", "switch theme", "dark theme", "appearance", "color scheme"],
1155
+ ["language", "languages", "change language", "select language", "switch language", "locale"],
1156
+ ["pricing", "plans", "prices", "plans and pricing", "pricing plans"],
1157
+ ["buy", "buy now", "purchase", "purchase now"],
1158
+ ["pay", "pay now", "make payment"],
1159
+ ["checkout", "check out", "proceed to checkout", "go to checkout"],
1160
+ // deleting, closing and deactivating an account are three different actions (two of them reversible)
1161
+ ["delete account", "delete my account", "remove account"],
1162
+ ["close account", "close my account"],
1163
+ ["deactivate account", "deactivate my account"],
1164
+ ["cancel subscription", "end subscription"],
1165
+ ["next", "next page"],
1166
+ ["previous", "prev", "previous page"],
1167
+ ["settings", "preferences"],
1168
+ ["profile", "my profile", "account", "my account"],
1169
+ ["help", "support", "help center", "help centre"],
1170
+ ["contact", "contact us", "get in touch"],
1171
+ ["about", "about us"],
1172
+ ["try free", "try for free", "free trial", "start free", "try it free"],
1173
+ ["github", "view on github", "source code"],
1174
+ ["add to cart", "add to basket", "add to bag"],
1175
+ ["forgot password", "forgot your password", "lost password", "lost your password", "reset password", "forgotten password"],
1176
+ ["continue", "next step", "proceed"],
1177
+ ["save", "save changes"],
1178
+ ["cancel", "nevermind", "never mind"],
1179
+ ["edit", "modify", "change"],
1180
+ ["open", "view", "show"],
1181
+ ["learn more", "read more", "more info", "find out more"],
1182
+ ["download", "downloads"],
1183
+ ["skip to content", "skip to main content", "skip navigation", "skip to main"],
1184
+ ["back to site", "go to site", "back to home", "return to site", "back to homepage", "back to website", "go to website"],
1185
+ ["remove card", "remove payment method", "delete card", "remove credit card", "delete payment method", "remove payment card"],
1186
+ ["privacy", "privacy policy"],
1187
+ ["terms", "terms of service", "terms and conditions", "terms of use"],
1188
+ ];
1189
+ // Near-synonyms that usually, not always, mean the same control: matched exactly, scored as a guess (0.65).
1190
+ const WEAK_SYNONYM_GROUPS = [
1191
+ ["exit", "close", "dismiss"],
1192
+ ["remove", "delete"],
1193
+ ["learn more", "see more"],
1194
+ ];
1195
+ // The destructive, financial and bulk verbs, host-side copy of the list inside PICK (keep both in step).
1196
+ const DANGER_VERB_LIST = ["delete", "remove", "cancel", "unsubscribe", "leave", "clear", "reset", "disconnect", "revoke", "erase",
1197
+ "deactivate", "disable", "discard", "destroy", "terminate", "end", "empty", "wipe", "archive", "transfer", "upgrade", "place",
1198
+ "approve", "pay", "buy", "purchase", "checkout"];
1199
+ function weakSynonymsOf(phrase) {
1200
+ const p = phrase.toLowerCase().trim();
1201
+ const out = new Set();
1202
+ for (const g of WEAK_SYNONYM_GROUPS)
1203
+ if (g.includes(p))
1204
+ for (const s of g)
1205
+ if (s !== p)
1206
+ out.add(s);
1207
+ return [...out];
1208
+ }
1209
+ // Groups where fuzzy (substring) matching over synonyms is too loose to trust.
1210
+ const NO_FUZZY_SYNONYMS = new Set([
1211
+ "remove", "delete", "edit", "open", "cancel", "close", "learn more", "continue", "save", "profile", "settings",
1212
+ "help", "contact", "about", "search", "submit", "account", "options", "view", "show", "change", "modify",
1213
+ ]);
1214
+ const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1215
+ /** "sign up" -> "sign[\s\-_]*up" (so it matches "Sign up", "Signup", "Sign-up"). Compound words are split first. */
1216
+ function phraseRe(phrase) {
1217
+ const ws = phrase.toLowerCase().split(/[^\p{L}\p{N}']+/u).filter(Boolean).map(w => COMPOUNDS[w] ?? w).join(" ").split(" ").filter(Boolean);
1218
+ if (!ws.length)
1219
+ return "";
1220
+ return ws.map(escRe).join("[\\s\\-_]*(?:(?:the|a|an|my|your|to|of|for|our)[\\s\\-_]+)?");
1221
+ }
1222
+ function synonymsOf(phrase) {
1223
+ const p = phrase.toLowerCase().trim();
1224
+ const canon = p.split(/\s+/).map(w => COMPOUNDS[w] ?? w).join(" ");
1225
+ const out = new Set();
1226
+ for (const g of SYNONYM_GROUPS) {
1227
+ if (g.includes(p) || g.includes(canon))
1228
+ for (const s of g)
1229
+ if (s !== p && s !== canon)
1230
+ out.add(s);
1231
+ }
1232
+ return [...out];
1233
+ }
1234
+ const EDGE = "[\\s\\W_]*";
1235
+ function exactRe(alts) {
1236
+ const parts = alts.map(phraseRe).filter(Boolean);
1237
+ if (!parts.length)
1238
+ return null;
1239
+ return new RegExp(`^${EDGE}(?:${parts.join("|")})${EDGE}$`, "i");
188
1240
  }
189
1241
  /**
190
- * Calculate accessibility score (0-1) for an element.
1242
+ * The exact name followed by a badge count: "Cart (2)", "Inbox (12)", or "Star 1.2k" when the count sits in its own
1243
+ * element (the picker checks that with `badge`). "Save $5", "Save 20%", "Delete 2" and "Call 988" are not this.
191
1244
  */
192
- function calculateAccessibilityScore(el) {
193
- let score = 0;
194
- if (el.ariaLabel)
195
- score += 0.3;
196
- if (el.role)
197
- score += 0.2;
198
- if (el.isSemanticElement)
199
- score += 0.2;
200
- // Label association check (for inputs)
201
- if (el.tag === "input" || el.tag === "textarea" || el.tag === "select") {
202
- if (el.ariaLabel || el.ariaLabelledby || el.placeholder)
203
- score += 0.2;
204
- }
205
- else {
206
- // Non-input elements get points for having text content
207
- if (el.text.length > 0)
208
- score += 0.2;
209
- }
210
- if (el.text.length > 0 || el.ariaLabel)
211
- score += 0.1;
212
- return Math.min(1, Math.round(score * 100) / 100);
1245
+ function exactBadgeRe(alts) {
1246
+ const parts = alts.map(phraseRe).filter(Boolean);
1247
+ if (!parts.length)
1248
+ return null;
1249
+ return new RegExp(`^${EDGE}(?:${parts.join("|")})(?:\\s*\\(\\s*[\\d.,]+k?\\+?\\s*\\)|\\s+[\\d.,]+k?\\+?)\\s*$`, "i");
1250
+ }
1251
+ function containsRe(alts) {
1252
+ const parts = alts.map(phraseRe).filter(Boolean);
1253
+ if (!parts.length)
1254
+ return null;
1255
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}])(?:${parts.join("|")})(?=$|[^\\p{L}\\p{N}])`, "iu");
1256
+ }
1257
+ function allWordsRe(words) {
1258
+ const ws = words.filter(w => w.length > 1 || /\d/.test(w));
1259
+ if (ws.length < 2)
1260
+ return null;
1261
+ return new RegExp(ws.map(w => `(?=.*(?:^|[^\\p{L}\\p{N}])${escRe(w)}(?=$|[^\\p{L}\\p{N}]))`).join("") + ".*", "iu");
1262
+ }
1263
+ function stemWordsRe(words) {
1264
+ if (!words.length || !words.some(w => w.length >= 3))
1265
+ return null;
1266
+ const stem = (w) => w.replace(/(ing|ed|es|s|er|ers|ion|ions)$/, "");
1267
+ return new RegExp(words.map(w => w.length >= 3
1268
+ ? `(?=.*(?:^|[^\\p{L}\\p{N}])${escRe(stem(w).length >= 3 ? stem(w) : w)}\\p{L}*)`
1269
+ : `(?=.*(?:^|[^\\p{L}\\p{N}])${escRe(w)}(?=$|[^\\p{L}\\p{N}]))`).join("") + ".*", "iu");
213
1270
  }
214
1271
  /**
215
- * Build result for a matched element with priority selectors.
1272
+ * Inflections only, for links and buttons: "category" matches "Categories", "story" matches "Stories",
1273
+ * but "stor" never reaches "Store". A stem shorter than 4 letters is used verbatim ("news" stays "news").
216
1274
  */
217
- function buildElementResult(el, description, baseConfidence) {
218
- const candidates = generatePrioritySelectors(el);
219
- const best = candidates[0] || { selector: el.tag, type: "nth-of-type", confidence: 0.3 };
220
- // v14.2.4: Clamp confidence to [0, 1] range to avoid invalid values
221
- const rawConfidence = Math.max(best.confidence, baseConfidence);
222
- // Rounded to 2 decimals (2026-10-07): float sums surfaced as 0.9999999999999999
223
- // and 0.7749999999999999 in tool output. Display only - ranking is unaffected.
224
- const clampedConfidence = Math.round(Math.max(0, Math.min(1, rawConfidence)) * 100) / 100;
225
- return {
226
- selector: best.selector,
227
- confidence: clampedConfidence,
228
- description,
229
- selectorType: best.type,
230
- accessibilityScore: calculateAccessibilityScore(el),
231
- alternatives: candidates.slice(1).map(c => ({
232
- selector: c.selector,
233
- text: el.text.slice(0, 60),
234
- tag: el.tag,
235
- type: c.type,
236
- confidence: Math.max(0, Math.min(1, c.confidence)), // v14.2.4: Clamp alternatives too
237
- })),
1275
+ function inflections(w) {
1276
+ const e = escRe;
1277
+ // the word itself, plus the base it was inflected from (settings -> setting, stories -> story, removed -> remove)
1278
+ const bases = new Set([w]);
1279
+ if (/ies$/.test(w) && w.length >= 5)
1280
+ bases.add(w.slice(0, -3) + "y");
1281
+ else if (/(ings|ers)$/.test(w) && w.length >= 6)
1282
+ bases.add(w.slice(0, -1));
1283
+ else if (/ing$/.test(w) && w.length >= 6) {
1284
+ bases.add(w.slice(0, -3));
1285
+ bases.add(w.slice(0, -3) + "e");
1286
+ }
1287
+ else if (/(ed|es)$/.test(w) && w.length >= 5) {
1288
+ bases.add(w.slice(0, -2));
1289
+ bases.add(w.slice(0, -1));
1290
+ }
1291
+ else if (/[^s]s$/.test(w) && w.length >= 5)
1292
+ bases.add(w.slice(0, -1));
1293
+ const alts = new Set();
1294
+ for (const b of bases) {
1295
+ if (b.length < 4) {
1296
+ if (b === w)
1297
+ alts.add(e(w));
1298
+ continue;
1299
+ } // "news" stays "news", never "new"
1300
+ if (b.endsWith("y"))
1301
+ alts.add(`${e(b.slice(0, -1))}(?:y|ies)`);
1302
+ else if (b.endsWith("e")) {
1303
+ alts.add(`${e(b)}(?:s|d|r|rs)?`);
1304
+ alts.add(`${e(b.slice(0, -1))}(?:ing|er|ers|ion|ions)`);
1305
+ }
1306
+ else
1307
+ alts.add(`${e(b)}(?:s|es|ed|ing|er|ers|ion|ions)?`);
1308
+ }
1309
+ return `(?:${[...alts].join("|")})`;
1310
+ }
1311
+ function inflectRe(words) {
1312
+ const ws = words.filter(w => w.length > 1 || /\d/.test(w));
1313
+ if (!ws.length || !ws.some(w => w.length >= 4))
1314
+ return null;
1315
+ return new RegExp(ws.map(w => `(?=.*(?:^|[^\\p{L}\\p{N}])${w.length >= 4 ? inflections(w) : escRe(w)}(?=$|[^\\p{L}\\p{N}]))`).join("") + ".*", "iu");
1316
+ }
1317
+ /** The whole accessible name is made of intent words: "purchase credits" reaches a control named "Purchase". */
1318
+ function supersetRe(words) {
1319
+ const ws = words.filter(w => w.length > 1 || /\d/.test(w));
1320
+ if (ws.length < 2)
1321
+ return null;
1322
+ return new RegExp(`^${EDGE}(?:(?:${ws.map(escRe).join("|")})[\\s\\-_]*)+${EDGE}$`, "iu");
1323
+ }
1324
+ // ============================================================================
1325
+ // Scopes and pools (CSS)
1326
+ // ============================================================================
1327
+ const LANDMARK_CSS = {
1328
+ header: { primary: "header, [role='banner']", fallback: "#header, .header, .site-header, .page-header, .masthead, .topbar, .top-bar, #masthead, #top" },
1329
+ footer: { primary: "footer, [role='contentinfo']", fallback: "#footer, .footer, .site-footer, .page-footer, #colophon" },
1330
+ main: { primary: "main, [role='main']", fallback: "#main, #content, .main, .content, #main-content, .main-content, #primary" },
1331
+ nav: {
1332
+ primary: "nav, [role='navigation'], [role='menubar']",
1333
+ fallback: ".navbar, .nav, #nav, .navigation, #navigation, .menu, #menu, .mobile-menu, .main-menu, .site-nav, .primary-menu, [role='menu']",
1334
+ },
1335
+ aside: { primary: "aside, [role='complementary'], nav[aria-label*='sidebar' i]", fallback: ".sidebar, #sidebar, .side-bar, #secondary, [class*='sidebar' i]" },
1336
+ form: { primary: "form, [role='form'], [role='search']", fallback: "" },
1337
+ section: { primary: "section", fallback: "" },
1338
+ article: { primary: "article", fallback: ".post, .article" },
1339
+ dialog: { primary: "dialog, [role='dialog'], [role='alertdialog']", fallback: ".modal, .dialog, .popup" },
1340
+ toc: {
1341
+ primary: "nav[aria-label*='contents' i], [role='navigation'][aria-label*='contents' i], .toc, #toc, .table-of-contents, #table-of-contents, "
1342
+ + "[class*='tableOfContents' i], nav[id*='toc' i], div[id*='toc' i], aside[id*='toc' i], nav[class*='toc' i], [role='navigation'][class*='toc' i]",
1343
+ fallback: "",
1344
+ },
1345
+ pagination: { primary: "nav[aria-label*='pagination' i], nav[aria-label*='pages' i], .pagination, .pager, [role='navigation'].pagination", fallback: "" },
1346
+ };
1347
+ const scopeCss = (k) => [LANDMARK_CSS[k].primary, LANDMARK_CSS[k].fallback].filter(Boolean).join(", ");
1348
+ const BUTTON_CSS = "button, input[type='submit'], input[type='button'], input[type='reset'], input[type='image'], [role='button'], summary";
1349
+ const LINK_BUTTON_CSS = "a[href][role='button'], a[href][data-slot='button'], a[href].btn, a[href].button, a[href][class*='btn'], "
1350
+ + "a[href][class*='button'], a[href][class*='cta' i]";
1351
+ const LINK_CSS = "a[href], [role='link']";
1352
+ // an <input> that takes typed or chosen input (not a button, not a check, not a file/range/color widget)
1353
+ const INPUT_FIELD_CSS = "input:not([type='hidden']):not([type='submit']):not([type='button']):not([type='reset']):not([type='image'])"
1354
+ + ":not([type='checkbox']):not([type='radio']):not([type='file']):not([type='range']):not([type='color'])";
1355
+ const FIELD_CSS = `${INPUT_FIELD_CSS}, textarea, select, [role='textbox'], [role='searchbox'], [role='combobox'], [contenteditable='true'], [contenteditable='']`;
1356
+ const SUBMIT_CSS = "form button:not([type='button']):not([type='reset']), form input[type='submit'], form input[type='image'], "
1357
+ + "button[type='submit'], input[type='submit'], form [role='button']:not(a)";
1358
+ const KIND_POOL = {
1359
+ button: `${BUTTON_CSS}, ${LINK_BUTTON_CSS}`,
1360
+ link: LINK_CSS,
1361
+ tab: "[role='tab']",
1362
+ checkbox: "input[type='checkbox'], [role='checkbox'], [role='switch']",
1363
+ radio: "input[type='radio'], [role='radio']",
1364
+ dropdown: "select, [role='combobox'], [role='listbox'], [aria-haspopup='listbox'], [aria-haspopup='menu'], .dropdown, [class*='dropdown' i]",
1365
+ field: FIELD_CSS,
1366
+ textarea: "textarea, [role='textbox'][aria-multiline='true'], [contenteditable='true']",
1367
+ menuitem: "[role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'], option",
1368
+ image: "img, [role='img'], svg, picture",
1369
+ card: ".card, [class*='card' i], article, [role='article'], li.item, a[href]:has(h2, h3, h4)",
1370
+ product: ".product, [class*='product' i]:not(img):not(span):not(p):not(h1):not(h2):not(h3), [data-product], [data-price], [id^='product'], li.item",
1371
+ heading: "h1, h2, h3, h4, h5, h6, [role='heading']",
1372
+ toggle: "[role='switch'], input[type='checkbox'], button[aria-pressed], button[aria-checked], button[class*='toggle' i], button[aria-label*='toggle' i], button",
1373
+ section: "section",
1374
+ article: "article",
1375
+ form: "form, [role='form'], [role='search']",
1376
+ any: `${BUTTON_CSS}, ${LINK_CSS}, ${FIELD_CSS}, input[type='checkbox'], input[type='radio'], [role='tab'], [role='menuitem'], `
1377
+ + "[role='checkbox'], [role='radio'], [role='switch'], [role='option'], summary",
1378
+ };
1379
+ const KIND_ROLES = {
1380
+ button: ["button"],
1381
+ link: ["link"],
1382
+ tab: ["tab"],
1383
+ checkbox: ["checkbox", "switch"],
1384
+ radio: ["radio"],
1385
+ dropdown: ["combobox", "listbox", "button"],
1386
+ field: ["textbox", "searchbox", "combobox", "spinbutton"],
1387
+ textarea: ["textbox"],
1388
+ menuitem: ["menuitem", "menuitemcheckbox", "menuitemradio", "option"],
1389
+ image: ["img"],
1390
+ card: [],
1391
+ product: [],
1392
+ heading: ["heading"],
1393
+ toggle: ["switch", "checkbox", "button"],
1394
+ section: ["region"],
1395
+ article: ["article"],
1396
+ form: ["form", "search"],
1397
+ any: ["link", "button", "tab", "menuitem", "checkbox", "radio", "switch", "combobox", "textbox", "searchbox", "option"],
1398
+ };
1399
+ const FOOTERISH_CSS = "footer, [role='contentinfo'], #footer, .footer, .site-footer";
1400
+ // A navigation that is the page's own: in the header, or labelled/classed as the main one.
1401
+ const PRIMARY_NAV_CSS = "header nav, [role='banner'] nav, header [role='navigation'], #header nav, .header nav, .site-header nav, .masthead nav, "
1402
+ + "nav.navbar, .navbar, nav[aria-label*='main' i], nav[aria-label*='primary' i], nav[aria-label*='global' i], nav[aria-label*='site' i], "
1403
+ + "[role='navigation'][aria-label*='main' i], [role='navigation'][aria-label*='primary' i], nav[class*='main' i], nav[class*='primary' i], "
1404
+ + "nav[id*='main' i], nav[id*='primary' i], .main-menu, .primary-menu, .site-nav, .main-nav, #main-nav";
1405
+ // ============================================================================
1406
+ // Prefilter: the elements a kind's rungs can ever return, as one CSS pool
1407
+ // ============================================================================
1408
+ //
1409
+ // Wider than KIND_POOL where the role unions reach further (a "button" intent also tries plain links,
1410
+ // a "dropdown" tries every button). Checked in one round trip for a count and for any intent word.
1411
+ // What text-exact lifts a text match to (the same list the page-side picker uses): any non-container kind can land here.
1412
+ const LIFT_CSS = "a[href], button, [role='button'], [role='link'], [role='tab'], [role='menuitem'], summary, label, select, input";
1413
+ const PREFILTER_POOL = {
1414
+ button: `${KIND_POOL.button}, ${LINK_CSS}, ${LIFT_CSS}`,
1415
+ link: `${LINK_CSS}, ${LIFT_CSS}`,
1416
+ tab: `${KIND_POOL.tab}, ${LIFT_CSS}`,
1417
+ checkbox: `${KIND_POOL.checkbox}, ${LIFT_CSS}`,
1418
+ radio: `${KIND_POOL.radio}, ${LIFT_CSS}`,
1419
+ dropdown: `${KIND_POOL.dropdown}, ${BUTTON_CSS}, [role='menu'], ${LIFT_CSS}`,
1420
+ field: `${FIELD_CSS}, [role='spinbutton'], [role='listbox'], ${LIFT_CSS}`,
1421
+ textarea: `${FIELD_CSS}, [role='spinbutton'], [role='listbox'], ${LIFT_CSS}`,
1422
+ menuitem: `${KIND_POOL.menuitem}, ${LIFT_CSS}`,
1423
+ image: `${KIND_POOL.image}, [alt], ${LIFT_CSS}`,
1424
+ card: KIND_POOL.card,
1425
+ product: KIND_POOL.product,
1426
+ heading: `${KIND_POOL.heading}, ${LIFT_CSS}`,
1427
+ toggle: `${KIND_POOL.toggle}, ${BUTTON_CSS}, [role='checkbox'], ${LIFT_CSS}`,
1428
+ section: KIND_POOL.section,
1429
+ article: KIND_POOL.article,
1430
+ form: KIND_POOL.form,
1431
+ any: `${KIND_POOL.any}, ${LIFT_CSS}`,
1432
+ };
1433
+ // An intent that names one of these is asking for a structure, which no control named with its other words is.
1434
+ const STRUCTURE_WORDS = new Set(["table", "list", "grid", "chart", "map", "video", "gallery", "carousel", "sidebar", "section", "form", "dialog",
1435
+ "modal", "menu", "panel", "banner", "footer", "header", "nav", "navigation", "page", "sheet", "calendar", "editor", "player", "widget"]);
1436
+ /** Page-side: how many pool elements there are, and which token sets some element carries anywhere a name can come from. */
1437
+ const PREFILTER = (els, need) => {
1438
+ const out = { count: els.length, literal: !need.literal.length, syn: !need.syn.length, any: !need.any.length, cover: 0 };
1439
+ if (out.literal && out.syn && out.any)
1440
+ return { ...out, cover: need.cover.length };
1441
+ const ATTRS = ["id", "class", "name", "aria-label", "title", "alt", "placeholder", "value", "data-testid", "href", "type"];
1442
+ const cap = need.container ? 20000 : 3000;
1443
+ for (const e of els) {
1444
+ let blob = (e.textContent || "").slice(0, cap);
1445
+ for (const a of ATTRS) {
1446
+ const v = e.getAttribute(a);
1447
+ if (v)
1448
+ blob += " " + v;
1449
+ }
1450
+ const lb = e.getAttribute("aria-labelledby");
1451
+ if (lb)
1452
+ for (const id of lb.split(/\s+/)) {
1453
+ const n = document.getElementById(id);
1454
+ if (n)
1455
+ blob += " " + (n.textContent || "");
1456
+ }
1457
+ const labels = e.labels;
1458
+ if (labels)
1459
+ for (const l of Array.from(labels))
1460
+ blob += " " + (l.textContent || "");
1461
+ // an unassociated label is the text next to the box
1462
+ if (e.tagName === "INPUT" && /^(checkbox|radio)$/i.test(e.type) && e.parentElement)
1463
+ blob += " " + (e.parentElement.textContent || "").slice(0, 300);
1464
+ const inner = e.querySelectorAll("[alt], [aria-label], [title]");
1465
+ for (let i = 0; i < inner.length && i < 5; i++) {
1466
+ blob += " " + (inner[i].getAttribute("alt") || "") + " " + (inner[i].getAttribute("aria-label") || "") + " " + (inner[i].getAttribute("title") || "");
1467
+ }
1468
+ blob = blob.toLowerCase();
1469
+ const has = (t) => blob.includes(t);
1470
+ if (!out.any && need.any.some(has))
1471
+ out.any = true;
1472
+ if (!out.literal && need.literal.some(alt => alt.every(has)))
1473
+ out.literal = true;
1474
+ if (!out.syn && need.syn.some(alt => alt.every(has)))
1475
+ out.syn = true;
1476
+ const c = need.cover.filter(has).length;
1477
+ if (c > out.cover)
1478
+ out.cover = c;
1479
+ if (out.literal && out.syn && out.any && out.cover >= need.cover.length)
1480
+ break;
1481
+ }
1482
+ return out;
1483
+ };
1484
+ /** The substrings each rung family needs to see: intent and synonym words, compound-split, stemmed loosely. */
1485
+ function prefilterNeed(q, syns, container) {
1486
+ const stem = (w) => w.replace(/(ies|ing|ed|es|s|er|ers|ion|ions|y)$/, "");
1487
+ const tok = (s) => {
1488
+ const out = new Set();
1489
+ for (const w0 of s.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w && !STOP.has(w))) {
1490
+ for (const w of (COMPOUNDS[w0] ? COMPOUNDS[w0].split(" ") : [w0])) {
1491
+ const st = stem(w);
1492
+ const t = st.length >= 3 ? st : w;
1493
+ if (t.length > 1 || /\d/.test(t))
1494
+ out.add(t);
1495
+ }
1496
+ }
1497
+ return [...out];
238
1498
  };
1499
+ const literal = [...new Set([q.phraseFull, q.phrase])].filter(Boolean).map(tok).filter(a => a.length);
1500
+ const syn = syns.map(tok).filter(a => a.length);
1501
+ const cover = tok(q.words.join(" "));
1502
+ const any = [...new Set([...literal.flat(), ...syn.flat()])];
1503
+ return { literal, syn, cover, any, container };
1504
+ }
1505
+ function unionRole(root, roles, name) {
1506
+ let loc = null;
1507
+ for (const r of roles) {
1508
+ const l = root.getByRole(r, name ? { name, includeHidden: true } : { includeHidden: true });
1509
+ loc = loc ? loc.or(l) : l;
1510
+ }
1511
+ return loc;
1512
+ }
1513
+ const red2 = (n) => Math.round(Math.max(0, Math.min(1, n)) * 100) / 100;
1514
+ /** The most severe zone over every name the element carries (an aria-label can be benign while the value says "Pay"). */
1515
+ const ZONE_SEVERITY = { green: 0, yellow: 1, red: 2, black: 3 };
1516
+ function dangerZone(names, classify) {
1517
+ if (!classify)
1518
+ return undefined;
1519
+ let worst;
1520
+ for (const n of names) {
1521
+ let z;
1522
+ try {
1523
+ z = classify(n);
1524
+ }
1525
+ catch {
1526
+ continue;
1527
+ }
1528
+ if (worst === undefined || (ZONE_SEVERITY[z] ?? 0) > (ZONE_SEVERITY[worst] ?? 0))
1529
+ worst = z;
1530
+ }
1531
+ return worst;
239
1532
  }
240
1533
  /**
241
1534
  * AI-powered semantic element finding with accessibility-first selectors.
242
- * Selector priority: ARIA > semantic HTML > ID > name > class.
243
- * Returns selectorType, accessibilityScore, and alternatives.
1535
+ * Playwright locator cascade over accessible names; selectors are verified unique.
244
1536
  *
245
- * Examples: "the cheapest product", "login form", "main navigation"
1537
+ * Examples: "the cheapest product", "login form", "main navigation", "sign up button in the header"
246
1538
  */
247
1539
  export async function findElementByIntent(browser, intent, options = {}) {
248
- const page = await browser.getPage();
249
- // Extract enriched page structure with ARIA attributes and container context
250
- // v11.2.0: Added container tracking for contextual matching ("in the navigation")
251
- const pageData = await page.evaluate(() => {
252
- const semanticSet = new Set(["BUTTON", "NAV", "MAIN", "HEADER", "FOOTER", "ARTICLE", "ASIDE", "SECTION", "FORM", "DIALOG"]);
253
- const containerTags = new Set(["NAV", "HEADER", "FOOTER", "MAIN", "ASIDE", "SECTION", "FORM"]);
254
- const elements = [];
255
- // Find the nearest semantic container for an element
256
- const getContainer = (el) => {
257
- let parent = el.parentElement;
258
- while (parent) {
259
- if (containerTags.has(parent.tagName)) {
260
- return parent.tagName.toLowerCase();
261
- }
262
- const role = parent.getAttribute("role");
263
- if (role === "navigation")
264
- return "nav";
265
- if (role === "banner")
266
- return "header";
267
- if (role === "contentinfo")
268
- return "footer";
269
- if (role === "main")
270
- return "main";
271
- if (role === "complementary")
272
- return "aside";
273
- parent = parent.parentElement;
1540
+ const res = await findElementByIntentCore(browser, intent, options);
1541
+ // The reported zone is the click gate's own verdict on the element when a real browser is behind the
1542
+ // call; the name-based zone computed during the search is the fallback (e.g. the corpus scorer).
1543
+ if (res && res.selector && res.confidence > 0) {
1544
+ const gate = browser.elementZone;
1545
+ if (typeof gate === "function") {
1546
+ try {
1547
+ const z = await gate.call(browser, res.selector);
1548
+ if (z)
1549
+ res.zone = z.zone;
274
1550
  }
275
- return "";
1551
+ catch { /* keep the name-based zone */ }
1552
+ }
1553
+ }
1554
+ return res;
1555
+ }
1556
+ async function findElementByIntentCore(browser, intent, options = {}) {
1557
+ const page = await browser.getPage();
1558
+ const parsed = parseIntent(intent);
1559
+ // Element-level danger: classifyAction applied to the accessible name of what was found.
1560
+ // It is private on CBrowser, so it is read structurally off the prototype.
1561
+ let classify = null;
1562
+ try {
1563
+ const mod = await import("../browser.js");
1564
+ const proto = mod.CBrowser?.prototype;
1565
+ const fn = proto?.classifyAction;
1566
+ // Black-zone words describe an instruction, not a page control (same rule as the click gate).
1567
+ if (typeof fn === "function")
1568
+ classify = (s) => fn.call({}, "click", s, { ignoreBlack: true });
1569
+ }
1570
+ catch { /* browser module unavailable (tests); zone stays undefined */ }
1571
+ const root = parsed.scope ? page.locator(scopeCss(parsed.scope)) : page;
1572
+ const basePick = { ordinal: parsed.ordinal, mode: "first", container: false, require: [], prefer: [], maxOthers: 5 };
1573
+ // A rung that throws (a navigation mid-search, a closed page, an unsupported selector) is skipped, and the
1574
+ // verbose miss says so, so "no match" is not mistaken for "nothing happened".
1575
+ const rungErrors = [];
1576
+ const run = async (label, locator, pick) => {
1577
+ try {
1578
+ return await locator.evaluateAll(PICK, { ...basePick, ...pick });
1579
+ }
1580
+ catch (e) {
1581
+ if (rungErrors.length < 5)
1582
+ rungErrors.push(`${label}: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}`);
1583
+ return null;
1584
+ }
1585
+ };
1586
+ const verboseMiss = (p, i) => verboseMissWith(p, i, rungErrors);
1587
+ const accessibilityScore = (c) => {
1588
+ let s = 0;
1589
+ if (c.role)
1590
+ s += 0.3;
1591
+ if (c.name)
1592
+ s += 0.3;
1593
+ if (["button", "a", "input", "select", "textarea", "nav", "main", "header", "footer", "form", "aside", "dialog"].includes(c.tag))
1594
+ s += 0.2;
1595
+ if (c.selectorType === "data-testid" || c.selectorType === "aria-label" || c.selectorType === "id")
1596
+ s += 0.2;
1597
+ return Math.min(1, Math.round(s * 100) / 100);
1598
+ };
1599
+ const build = (r, label, conf, descriptionPrefix) => {
1600
+ const c = r.chosen;
1601
+ const zone = dangerZone(c.names.length ? c.names : [c.name || c.text], classify);
1602
+ return {
1603
+ selector: c.selector,
1604
+ confidence: red2(conf),
1605
+ description: `${descriptionPrefix}: ${c.name || c.text || c.tag}`.slice(0, 120),
1606
+ selectorType: c.selectorType,
1607
+ accessibilityScore: accessibilityScore(c),
1608
+ alternatives: r.others.map(o => ({ selector: o.selector, text: (o.name || o.text).slice(0, 60), tag: o.tag, type: o.selectorType, confidence: red2(conf * 0.6) })),
1609
+ accessibleName: c.name,
1610
+ visible: c.visible,
1611
+ zone,
1612
+ matchedBy: label,
1613
+ candidates: r.visibleCount,
276
1614
  };
277
- const interactives = document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='link'], [role='tab'], [role='menuitem'], [onclick], nav, main, header, footer, article, aside, section, form, .btn, .card, .product, [data-price], .price");
278
- interactives.forEach((el) => {
279
- const htmlEl = el;
280
- const inputEl = el;
281
- const text = htmlEl.innerText?.trim().slice(0, 100) || "";
282
- const priceMatch = text.match(/\$[\d,.]+|\d+\.\d{2}/);
283
- elements.push({
284
- tag: el.tagName.toLowerCase(),
285
- text,
286
- classes: String(el.className?.baseVal ?? el.className ?? "").slice(0, 100),
287
- id: el.id || "",
288
- role: el.getAttribute("role") || "",
289
- type: inputEl.type || "",
290
- ariaLabel: el.getAttribute("aria-label") || "",
291
- ariaLabelledby: el.getAttribute("aria-labelledby") || "",
292
- name: el.getAttribute("name") || "",
293
- title: el.getAttribute("title") || "",
294
- dataTestId: el.getAttribute("data-testid") || "",
295
- placeholder: inputEl.placeholder || "",
296
- price: priceMatch ? priceMatch[0] : undefined,
297
- isSemanticElement: semanticSet.has(el.tagName),
298
- container: getContainer(el),
299
- });
300
- });
301
- return elements;
302
- });
303
- let intentLower = intent.toLowerCase();
304
- // =========================================================================
305
- // Word-level fuzzy matching utilities (v10.10.0)
306
- // v14.2.4: Added synonym normalization for common variations
307
- // =========================================================================
308
- // Synonym mappings for common variations
309
- const synonymMap = {
310
- // Login variations
311
- "sign in": "login", "signin": "login", "log in": "login", "log-in": "login",
312
- // Signup variations
313
- "sign up": "signup", "sign-up": "signup", "register": "signup", "create account": "signup",
314
- // Submit variations
315
- "send": "submit", "confirm": "submit", "go": "submit",
316
- // Search variations
317
- "find": "search", "look for": "search", "look up": "search",
318
- // Close variations
319
- "dismiss": "close", "cancel": "close", "exit": "close",
320
- // Navigation variations
321
- "nav": "navigation", "navbar": "navigation", "menu bar": "navigation",
322
1615
  };
323
- // Apply synonym normalization
324
- for (const [synonym, normalized] of Object.entries(synonymMap)) {
325
- if (intentLower.includes(synonym)) {
326
- intentLower = intentLower.replace(synonym, normalized);
327
- }
328
- }
329
- // Tokenize intent into meaningful words (remove stop words)
330
- const stopWords = new Set(["the", "a", "an", "to", "for", "of", "in", "on", "at", "is", "are", "that", "this", "it"]);
331
- const intentWords = intentLower
332
- .replace(/[^\w\s]/g, " ")
333
- .split(/\s+/)
334
- .filter(w => w.length > 1 && !stopWords.has(w));
335
- // Extract ordinal position if present ("first", "second", "last", etc.)
336
- // v11.2.0: Added "last" ordinal support (-1 means last element)
337
- const ordinalMap = {
338
- first: 0, second: 1, third: 2, fourth: 3, fifth: 4,
339
- sixth: 5, seventh: 6, eighth: 7, ninth: 8, tenth: 9,
340
- "1st": 0, "2nd": 1, "3rd": 2, "4th": 3, "5th": 4,
341
- "6th": 5, "7th": 6, "8th": 7, "9th": 8, "10th": 9,
342
- last: -1, final: -1,
1616
+ const take = async (label, locator, pick, conf, desc) => {
1617
+ const r = await run(label, locator, pick);
1618
+ if (!r || !r.chosen)
1619
+ return r ? { r, res: null } : null;
1620
+ const conf1 = parsed.ordinal !== null ? conf.ordinal : (r.ambiguous ? conf.ambiguous : conf.unique);
1621
+ return { r, res: build(r, label, conf1, desc) };
343
1622
  };
344
- let targetIndex = null;
345
- for (const word of intentWords) {
346
- if (ordinalMap[word] !== undefined) {
347
- targetIndex = ordinalMap[word];
348
- break;
1623
+ // --------------------------------------------------------------------------
1624
+ // 1. Price intents
1625
+ // --------------------------------------------------------------------------
1626
+ if (parsed.special === "cheapest" || parsed.special === "expensive") {
1627
+ const wantMin = parsed.special === "cheapest";
1628
+ const PROD_CSS = ".product, .card, [data-price], [data-product], article, li, a[href], tr, [class*='product' i], [class*='item' i], [class*='card' i]";
1629
+ const pool = root.locator(`${PROD_CSS}, .price, [class*='price' i]`);
1630
+ const r = await run("price", pool, {
1631
+ ordinal: null, container: true, mode: "first",
1632
+ require: [{ textRe: "(?:[$£€¥]\\s?\\d[\\d,]*(?:\\.\\d{2})?|\\d[\\d,]*\\.\\d{2}\\s?(?:usd|eur|gbp)?|\\d[\\d,]*\\s?[$£€¥])" }],
1633
+ prefer: [],
1634
+ });
1635
+ // The pool contains nested containers; choose by price in a second pass over the visible ones.
1636
+ if (r && r.visibleCount > 0) {
1637
+ // "buy credits button for the cheapest pack": the words that name a control inside the chosen container
1638
+ const PRICE_NOISE = ["cheapest", "expensive", "most", "lowest", "highest", "least", "price", "priced", "product", "pack", "plan",
1639
+ "item", "button", "link", "one", "card", "tier", "option"];
1640
+ const ctlWords = parsed.words.filter(w => !PRICE_NOISE.includes(w));
1641
+ const ctlReSrc = ctlWords.length ? (containsRe([ctlWords.join(" ")])?.source ?? null) : null;
1642
+ const chosen = await pool.evaluateAll((els, want) => {
1643
+ const priceOf = (t) => {
1644
+ const m = t.match(/[$£€¥]\s?(\d[\d,]*(?:\.\d{1,2})?)|(\d[\d,]*\.\d{2})/);
1645
+ return m ? parseFloat((m[1] || m[2]).replace(/,/g, "")) : NaN;
1646
+ };
1647
+ // the price a person would pay: text that is not struck through (<s>, <del>, line-through) comes first
1648
+ const struck = (n, top) => {
1649
+ let p = n.parentElement;
1650
+ while (p && p !== top.parentElement) {
1651
+ if (/^(S|DEL|STRIKE)$/.test(p.tagName) || getComputedStyle(p).textDecorationLine.includes("line-through"))
1652
+ return true;
1653
+ p = p.parentElement;
1654
+ }
1655
+ return false;
1656
+ };
1657
+ const currentText = (el) => {
1658
+ const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
1659
+ let t = "", n, i = 0;
1660
+ while ((n = w.nextNode()) && i++ < 2000)
1661
+ if (!struck(n, el))
1662
+ t += (n.textContent || "") + " ";
1663
+ return t;
1664
+ };
1665
+ const priceOfEl = (el) => {
1666
+ const v = priceOf(currentText(el));
1667
+ return isNaN(v) ? priceOf(el.innerText || "") : v;
1668
+ };
1669
+ const vis = (e) => {
1670
+ if (typeof e.checkVisibility === "function" && !e.checkVisibility({ visibilityProperty: true }))
1671
+ return false;
1672
+ const b = e.getBoundingClientRect();
1673
+ return b.width >= 2 && b.height >= 2;
1674
+ };
1675
+ // product = smallest container that holds exactly one price text node family
1676
+ const priceEls = els.filter(e => vis(e) && e.matches(".price, [class*='price' i]") && !isNaN(priceOfEl(e)));
1677
+ const products = [];
1678
+ const PROD = want.prod;
1679
+ if (priceEls.length) {
1680
+ for (const p of priceEls) {
1681
+ const c = p.closest(PROD);
1682
+ if (c && !products.includes(c))
1683
+ products.push(c);
1684
+ }
1685
+ }
1686
+ else {
1687
+ for (const e of els)
1688
+ if (vis(e) && e.matches(PROD) && !isNaN(priceOfEl(e)))
1689
+ products.push(e);
1690
+ // keep innermost
1691
+ const set = new Set(products);
1692
+ for (const e of [...set])
1693
+ if ([...set].some(o => o !== e && e.contains(o)))
1694
+ set.delete(e);
1695
+ products.length = 0;
1696
+ products.push(...set);
1697
+ }
1698
+ if (want.ctlRe) {
1699
+ const cr = new RegExp(want.ctlRe, "i");
1700
+ const ctlName = (b) => (b.innerText || b.getAttribute("aria-label") || b.value || "").trim();
1701
+ const withCtl = products.filter(p => Array.from(p.querySelectorAll("button, a[href], input[type='submit']")).some(b => cr.test(ctlName(b))));
1702
+ products.length = 0;
1703
+ products.push(...withCtl);
1704
+ }
1705
+ if (!products.length)
1706
+ return -1;
1707
+ // rank by price; "second cheapest" is the ordinal into that ranking, out of range is null
1708
+ const ranked = products.map(p => ({ p, v: priceOfEl(p) })).filter(x => !isNaN(x.v)).sort((a, b) => want.min ? a.v - b.v : b.v - a.v);
1709
+ if (!ranked.length)
1710
+ return -1;
1711
+ const k = want.ordinal === null ? 0 : (want.ordinal === -1 ? ranked.length - 1 : want.ordinal);
1712
+ if (k < 0 || k >= ranked.length)
1713
+ return -1;
1714
+ // return the element index in els so the caller can address it
1715
+ return els.indexOf(ranked[k].p);
1716
+ }, { min: wantMin, container: true, ctlRe: ctlReSrc, prod: PROD_CSS, ordinal: parsed.ordinal }).catch(() => -1);
1717
+ if (chosen >= 0) {
1718
+ const single = pool.nth(chosen);
1719
+ if (ctlWords.length) {
1720
+ const ctlPhrase = ctlWords.join(" ");
1721
+ const cre = exactRe([ctlPhrase]) || containsRe([ctlPhrase]);
1722
+ const ctl = single.getByRole("button", { name: cre, includeHidden: true }).or(single.getByRole("link", { name: cre, includeHidden: true }));
1723
+ const rc = await run("price-control", ctl, { ordinal: null, container: false, mode: "first", require: [], prefer: [] });
1724
+ if (rc && rc.chosen)
1725
+ return build(rc, "price-control", 0.85, wantMin ? "Cheapest" : "Most expensive");
1726
+ const cre2 = containsRe([ctlPhrase]);
1727
+ const ctl2 = single.getByRole("button", { name: cre2, includeHidden: true }).or(single.getByRole("link", { name: cre2, includeHidden: true }));
1728
+ const rc2 = await run("price-control", ctl2, { ordinal: null, container: false, mode: "first", require: [], prefer: [] });
1729
+ if (rc2 && rc2.chosen)
1730
+ return build(rc2, "price-control", 0.8, wantMin ? "Cheapest" : "Most expensive");
1731
+ return options.verbose ? await verboseMiss(page, intent) : null;
1732
+ }
1733
+ const rr = await run("price", single, { ordinal: null, container: true, mode: "first", require: [], prefer: [] });
1734
+ if (rr && rr.chosen)
1735
+ return build(rr, "price", 0.85, wantMin ? "Cheapest" : "Most expensive");
1736
+ }
349
1737
  }
1738
+ return options.verbose ? await verboseMiss(page, intent) : null;
350
1739
  }
351
- // v11.2.0: Extract container context from intent ("in the navigation", "in the header")
352
- const containerContextMap = {
353
- navigation: "nav", nav: "nav", navbar: "nav", menu: "nav",
354
- header: "header", banner: "header", top: "header",
355
- footer: "footer", bottom: "footer",
356
- sidebar: "aside", aside: "aside",
357
- main: "main", content: "main",
358
- form: "form",
359
- };
360
- let requiredContainer = null;
361
- const inMatch = intentLower.match(/\b(?:in|inside|within)\s+(?:the\s+)?(\w+)/);
362
- if (inMatch) {
363
- const containerWord = inMatch[1];
364
- if (containerContextMap[containerWord]) {
365
- requiredContainer = containerContextMap[containerWord];
366
- }
367
- }
368
- // Calculate word overlap score between intent and element text
369
- // v11.2.0: Enhanced with container context awareness
370
- // v11.3.0: Added semantic role matching for spatial/structural intents
371
- const calculateWordScore = (el) => {
372
- // If we require a specific container and element is not in it, heavily penalize
373
- if (requiredContainer && el.container !== requiredContainer) {
374
- return 0.05; // Very low score but not zero (for debugging)
375
- }
376
- const elementText = `${el.text} ${el.ariaLabel} ${el.title} ${el.placeholder} ${el.name}`.toLowerCase();
377
- const elementWords = elementText.split(/\s+/).filter(w => w.length > 1);
378
- let matchCount = 0;
379
- let totalWeight = 0;
380
- // v11.3.0: Semantic role synonyms - map intent words to element structural properties
381
- // This enables "main navigation menu" to match <nav aria-label="Main"> with high confidence
382
- const semanticRoleMatches = {
383
- navigation: (e) => e.tag === "nav" || e.role === "navigation" || e.ariaLabel.toLowerCase().includes("nav"),
384
- nav: (e) => e.tag === "nav" || e.role === "navigation",
385
- menu: (e) => e.role === "menu" || e.role === "menubar" || e.role === "navigation" || e.tag === "nav",
386
- header: (e) => e.tag === "header" || e.role === "banner" || e.container === "header",
387
- footer: (e) => e.tag === "footer" || e.role === "contentinfo" || e.container === "footer",
388
- sidebar: (e) => e.tag === "aside" || e.role === "complementary",
389
- main: (e) => e.tag === "main" || e.role === "main" || e.ariaLabel.toLowerCase().includes("main") || e.container === "main",
390
- form: (e) => e.tag === "form" || e.role === "form",
391
- search: (e) => e.role === "search" || e.type === "search" || e.ariaLabel.toLowerCase().includes("search"),
392
- banner: (e) => e.role === "banner" || e.tag === "header",
1740
+ // --------------------------------------------------------------------------
1741
+ // 2. Landmark intents
1742
+ // --------------------------------------------------------------------------
1743
+ if (parsed.landmark) {
1744
+ const { kind, qualifiers } = parsed.landmark;
1745
+ const css = LANDMARK_CSS[kind];
1746
+ const quals = new Set(qualifiers);
1747
+ const inside = (k) => scopeCss(k);
1748
+ const req = [];
1749
+ const pref = [];
1750
+ const mode = "first";
1751
+ const confUnique = 0.95;
1752
+ let confAmb = 0.55;
1753
+ let fallbackToControl = false;
1754
+ const FOOTERISH = FOOTERISH_CSS;
1755
+ const SIDEISH = "aside, [role='complementary']";
1756
+ // container qualifiers: "footer navigation", "article header", "post footer", "header navigation", "sidebar navigation"
1757
+ if (quals.has("footer"))
1758
+ req.push({ withinCss: inside("footer") });
1759
+ if (quals.has("header") || quals.has("top"))
1760
+ req.push({ withinCss: kind === "header" ? "body" : inside("header") });
1761
+ if (quals.has("article") || quals.has("post"))
1762
+ req.push({ withinCss: inside("article") });
1763
+ if (quals.has("sidebar"))
1764
+ req.push({ withinCss: inside("aside") });
1765
+ if (kind === "nav") {
1766
+ // A navigation is something with visible links in it.
1767
+ if (quals.has("secondary")) {
1768
+ req.push({ hasVisibleCss: "a[href], [role='link'], [role='menuitem']" });
1769
+ pref.push({ attrRe: "\\b(secondary|sub|utility|aux)\\b" });
1770
+ pref.push({ tagRe: "^nav$" });
1771
+ confAmb = 0.5;
1772
+ }
1773
+ else {
1774
+ const LINKS = "a[href], [role='link'], [role='menuitem']";
1775
+ const TOGGLE = "button[aria-expanded], button[aria-controls], button[aria-label*='menu' i], button[aria-label*='navigation' i], "
1776
+ + "[class*='hamburger' i], [class*='burger' i], button[class*='toggle' i]";
1777
+ // a navigation has visible links; one link next to a visible menu toggle is a collapsed navigation, not a navigation
1778
+ if (quals.has("footer") || quals.has("sidebar") || quals.has("header") || quals.has("top"))
1779
+ req.push({ hasVisibleCss: LINKS });
1780
+ else
1781
+ req.push({ anyOf: [{ hasVisibleCss: LINKS, minVisible: 2 }, { hasVisibleCss: LINKS, notHasVisibleCss: TOGGLE }] });
1782
+ if (!quals.has("footer") && !quals.has("sidebar"))
1783
+ req.push({ notWithinCss: FOOTERISH });
1784
+ if (!quals.has("sidebar") && !quals.has("secondary"))
1785
+ pref.push({ notWithinCss: SIDEISH });
1786
+ if (quals.has("main") || quals.has("primary") || quals.has("global") || quals.has("site") || quals.has("page"))
1787
+ pref.push({ attrRe: "\\b(main|primary|global|site|page)\\b" });
1788
+ pref.push({ withinCss: inside("header") });
1789
+ pref.push({ tagRe: "^nav$" });
1790
+ confAmb = 0.7;
1791
+ if (!qualifiers.length || quals.has("main") || quals.has("primary"))
1792
+ fallbackToControl = true;
1793
+ }
1794
+ }
1795
+ else if (kind === "header") {
1796
+ if (!(quals.has("article") || quals.has("post")))
1797
+ pref.push({ notWithinCss: "article, section, aside, [role='article']" });
1798
+ pref.push({ notWithinCss: "main, [role='main']" });
1799
+ confAmb = 0.8;
1800
+ }
1801
+ else if (kind === "footer") {
1802
+ if (!(quals.has("article") || quals.has("post")))
1803
+ pref.push({ notWithinCss: "article, section, aside, [role='article']" });
1804
+ pref.push({ notWithinCss: "main, [role='main']" });
1805
+ confAmb = 0.8;
1806
+ }
1807
+ else if (kind === "form") {
1808
+ if (quals.has("login") || quals.has("signin") || quals.has("sign-in")) {
1809
+ req.push({ anyOf: [{ hasCss: "input[type='password'], input[autocomplete='current-password']" }, { attrRe: "log-?in|sign-?in|session|auth" }, { textRe: "\\b(log|sign)[ -]?in\\b" }] });
1810
+ pref.push({ hasCss: "input[type='password']" });
1811
+ }
1812
+ else if (quals.has("signup") || quals.has("register") || quals.has("registration")) {
1813
+ req.push({ anyOf: [{ attrRe: "sign-?up|register|create" }, { textRe: "\\b(sign[ -]?up|register|create account)\\b" }] });
1814
+ pref.push({ hasCss: "input[type='password']" });
1815
+ }
1816
+ else if (quals.has("newsletter")) {
1817
+ req.push({ anyOf: [{ attrRe: "newsletter|subscribe|\\bnl\\b" }, { textRe: "newsletter|subscribe" }] });
1818
+ }
1819
+ else if (quals.has("contact")) {
1820
+ req.push({ anyOf: [{ attrRe: "contact|message|enquir|inquir" }, { textRe: "contact|message" }, { hasCss: "textarea" }] });
1821
+ }
1822
+ else if (quals.has("search")) {
1823
+ req.push({ anyOf: [{ attrRe: "search" }, { hasCss: "input[type='search'], [role='searchbox'], input[name='q'], input[name='s']" }] });
1824
+ }
1825
+ else if (qualifiers.length) {
1826
+ req.push({ anyOf: [{ attrRe: qualifiers.join("|") }, { textRe: qualifiers.join("|") }] });
1827
+ }
1828
+ else {
1829
+ pref.push({ hasCss: "input[type='password']" });
1830
+ pref.push({ withinCss: inside("main") });
1831
+ confAmb = 0.55;
1832
+ }
1833
+ }
1834
+ else if (kind === "section" || kind === "article") {
1835
+ if (qualifiers.length)
1836
+ req.push({ attrRe: qualifiers.map(escRe).join("|") });
1837
+ confAmb = 0.55;
1838
+ }
1839
+ else if (kind === "main") {
1840
+ pref.push({ notWithinCss: "article, aside" });
1841
+ confAmb = 0.8;
1842
+ }
1843
+ else if (kind === "aside") {
1844
+ if (qualifiers.length) {
1845
+ req.push({ attrRe: qualifiers.map(escRe).join("|") });
1846
+ pref.push({ tagRe: "^nav$" });
1847
+ }
1848
+ pref.push({ tagRe: "^(aside|nav)$" });
1849
+ confAmb = 0.7;
1850
+ }
1851
+ else if (kind === "dialog") {
1852
+ confAmb = 0.7;
1853
+ }
1854
+ const tryPool = async (selector, label, extra = {}) => {
1855
+ const loc = page.locator(selector);
1856
+ const r = await run(label, loc, { container: true, mode, require: req, prefer: pref, dedupeNested: false, ...extra });
1857
+ if (r && r.chosen) {
1858
+ const conf = parsed.ordinal !== null ? 0.92 : (r.ambiguous ? confAmb : confUnique);
1859
+ return build(r, label, conf, "Landmark");
1860
+ }
1861
+ return r;
393
1862
  };
394
- // Count semantic matches and add them as weighted matches
395
- let semanticMatches = 0;
396
- for (const intentWord of intentWords) {
397
- const checker = semanticRoleMatches[intentWord];
398
- if (checker && checker(el)) {
399
- semanticMatches += 1;
400
- totalWeight += 1;
401
- matchCount += 1; // Full match for semantic role
402
- }
403
- }
404
- for (const intentWord of intentWords) {
405
- // Skip stop words, ordinals
406
- if (stopWords.has(intentWord) || ordinalMap[intentWord] !== undefined)
407
- continue;
408
- // Skip words already handled by semantic matching
409
- if (semanticRoleMatches[intentWord])
410
- continue;
411
- totalWeight += 1;
412
- // Exact match
413
- if (elementWords.some(ew => ew === intentWord)) {
414
- matchCount += 1;
415
- }
416
- // Partial match (word contains intent word or vice versa)
417
- else if (elementWords.some(ew => ew.includes(intentWord) || intentWord.includes(ew))) {
418
- matchCount += 0.7;
419
- }
420
- // Check if element text contains the word directly
421
- else if (elementText.includes(intentWord)) {
422
- matchCount += 0.5;
423
- }
424
- }
425
- // Boost for tag/role matches
426
- if (intentWords.includes("link") && el.tag === "a")
427
- matchCount += 0.5;
428
- if (intentWords.includes("button") && (el.tag === "button" || el.role === "button"))
429
- matchCount += 0.5;
430
- if (intentWords.includes("input") && ["input", "textarea", "select"].includes(el.tag))
431
- matchCount += 0.5;
432
- if (intentWords.includes("headline") && /^h[1-6]$/.test(el.tag))
433
- matchCount += 0.5;
434
- if (intentWords.includes("story") && el.tag === "a" && el.text.length > 10)
435
- matchCount += 0.3;
436
- // v11.8.0: Boost inputs when intent implies typing/entering (issue #87)
437
- const typingKeywords = ["type", "enter", "fill", "input", "field", "textbox", "text"];
438
- const impliesTyping = typingKeywords.some(kw => intentWords.includes(kw));
439
- if (impliesTyping && ["input", "textarea"].includes(el.tag)) {
440
- matchCount += 0.6; // Strong boost for form inputs when typing is implied
441
- }
442
- // Also boost if intent has "where" + action verb (e.g., "where I type my username")
443
- if (intentLower.includes("where") && (intentLower.includes("type") || intentLower.includes("enter") || intentLower.includes("put"))) {
444
- if (["input", "textarea"].includes(el.tag)) {
445
- matchCount += 0.7;
446
- }
447
- }
448
- // v11.2.0: Boost elements that ARE in the requested container
449
- if (requiredContainer && el.container === requiredContainer) {
450
- matchCount += 0.3;
451
- }
452
- // v11.3.0: Bonus for multiple semantic matches (e.g., "main navigation menu" matching nav with aria-label="Main")
453
- if (semanticMatches >= 2) {
454
- matchCount += 0.2 * (semanticMatches - 1);
455
- }
456
- return totalWeight > 0 ? matchCount / totalWeight : 0;
457
- };
458
- // Price-based intents
459
- if (intentLower.includes("cheapest") || intentLower.includes("lowest price")) {
460
- const withPrices = pageData.filter(el => el.price);
461
- if (withPrices.length > 0) {
462
- const sorted = withPrices.sort((a, b) => parseFloat(a.price.replace(/[$,]/g, "")) - parseFloat(b.price.replace(/[$,]/g, "")));
463
- return buildElementResult(sorted[0], `Cheapest item: ${sorted[0].text.slice(0, 50)} (${sorted[0].price})`, 0.8);
464
- }
465
- }
466
- if (intentLower.includes("most expensive") || intentLower.includes("highest price")) {
467
- const withPrices = pageData.filter(el => el.price);
468
- if (withPrices.length > 0) {
469
- const sorted = withPrices.sort((a, b) => parseFloat(b.price.replace(/[$,]/g, "")) - parseFloat(a.price.replace(/[$,]/g, "")));
470
- return buildElementResult(sorted[0], `Most expensive: ${sorted[0].text.slice(0, 50)} (${sorted[0].price})`, 0.8);
471
- }
472
- }
473
- // =========================================================================
474
- // v11.11.0: Semantic role heuristics for button/submit intents (stress test fix)
475
- // "submit button", "login button", "click the button" → prioritize <button>, input[type=submit]
476
- // =========================================================================
477
- const buttonKeywords = ["button", "submit", "btn", "click"];
478
- const impliesButton = buttonKeywords.some(kw => intentWords.includes(kw));
479
- if (impliesButton) {
480
- // Get all button-like elements
481
- const buttonElements = pageData.filter(el => el.tag === "button" ||
482
- el.type === "submit" ||
483
- el.role === "button" ||
484
- (el.tag === "input" && el.type === "submit") ||
485
- (el.tag === "a" && el.classes.includes("btn")));
486
- if (buttonElements.length > 0) {
487
- // Extract action keyword from intent (login, submit, search, etc.)
488
- const actionKeywords = intentWords.filter(w => !buttonKeywords.includes(w) && !stopWords.has(w) && ordinalMap[w] === undefined);
489
- // Score buttons by how well they match the action
490
- const scoredButtons = buttonElements.map(el => {
491
- const elText = `${el.text} ${el.ariaLabel} ${el.title} ${el.name}`.toLowerCase();
492
- let score = 0.5; // Base score for being a button
493
- for (const action of actionKeywords) {
494
- if (elText.includes(action))
495
- score += 0.4;
496
- else if (elText.split(/\s+/).some(w => w.includes(action) || action.includes(w)))
497
- score += 0.2;
1863
+ if (kind === "nav") {
1864
+ // One pool: real navs and div-based menus together, so a visible mobile menu beats a hidden <nav>.
1865
+ const poolCss = css.primary + ", " + css.fallback;
1866
+ if (quals.has("secondary")) {
1867
+ // "secondary": the nav labelled so; otherwise the second of exactly two navigations.
1868
+ const probe = await run("landmark:nav-count", page.locator(poolCss), {
1869
+ container: true, mode: "unique", require: req, prefer: [{ tagRe: "^nav$" }], dedupeNested: true,
1870
+ });
1871
+ const labelledSecondary = async () => {
1872
+ const labelled = await run("landmark:nav-secondary", page.locator(poolCss), {
1873
+ container: true, mode: "first", require: [...req, { attrRe: "\\b(secondary|sub|utility|aux)\\b" }], prefer: [],
1874
+ });
1875
+ return labelled && labelled.chosen ? build(labelled, "landmark:nav", 0.9, "Landmark") : null;
1876
+ };
1877
+ if (probe && probe.chosen) {
1878
+ const labelled = await labelledSecondary();
1879
+ if (labelled)
1880
+ return labelled;
498
1881
  }
499
- // Boost for submit type
500
- if (el.type === "submit")
501
- score += 0.1;
502
- return { el, score };
503
- }).sort((a, b) => b.score - a.score);
504
- // Return best match if score is reasonable
505
- if (scoredButtons[0].score >= 0.5) {
506
- const best = scoredButtons[0];
507
- return buildElementResult(best.el, `Button: ${best.el.text.slice(0, 30) || best.el.ariaLabel || "submit"}`, best.score);
508
- }
509
- }
510
- }
511
- // Form-based intents (only if not already handled by button matcher above)
512
- if ((intentLower.includes("login") || intentLower.includes("sign in")) && !impliesButton) {
513
- const loginEl = pageData.find(el => el.text.toLowerCase().includes("login") ||
514
- el.text.toLowerCase().includes("sign in") ||
515
- el.ariaLabel.toLowerCase().includes("login") ||
516
- el.ariaLabel.toLowerCase().includes("sign in") ||
517
- el.classes.includes("login"));
518
- if (loginEl) {
519
- return buildElementResult(loginEl, "Login button/form", 0.9);
520
- }
521
- }
522
- if (intentLower.includes("search")) {
523
- // v11.6.0: Prioritize actual input elements over containers (forms, divs)
524
- // First try to find an actual input/textarea with search attributes
525
- const inputTags = new Set(["input", "textarea"]);
526
- const searchInput = pageData.find(el => inputTags.has(el.tag) && (el.type === "search" ||
527
- el.ariaLabel.toLowerCase().includes("search") ||
528
- el.placeholder.toLowerCase().includes("search") ||
529
- el.name.toLowerCase().includes("search") ||
530
- el.id.toLowerCase().includes("search")));
531
- if (searchInput) {
532
- return buildElementResult(searchInput, "Search input", 0.95);
533
- }
534
- // Fall back to any element with search in class/id (for custom search components)
535
- const searchEl = pageData.find(el => el.type === "search" ||
536
- el.ariaLabel.toLowerCase().includes("search") ||
537
- el.classes.includes("search") ||
538
- el.id.includes("search") ||
539
- el.placeholder.toLowerCase().includes("search"));
540
- if (searchEl) {
541
- return buildElementResult(searchEl, "Search element", 0.85);
542
- }
543
- }
544
- // Navigation intent
545
- // v11.3.0: Enhanced to prioritize qualifiers like "main" in aria-labels
546
- if (intentLower.includes("navigation") || intentLower.includes("nav") || intentLower.includes("menu")) {
547
- const navElements = pageData.filter(el => el.tag === "nav" || el.role === "navigation" || el.role === "menu" || el.role === "menubar" || el.ariaLabel.toLowerCase().includes("nav"));
548
- if (navElements.length > 0) {
549
- // Check for qualifiers like "main", "primary", "global", "site"
550
- const qualifiers = ["main", "primary", "global", "site", "top", "header"];
551
- const qualifierMatch = qualifiers.find(q => intentLower.includes(q));
552
- if (qualifierMatch) {
553
- // Prefer nav element with matching qualifier in aria-label
554
- const qualified = navElements.find(el => el.ariaLabel.toLowerCase().includes(qualifierMatch));
555
- if (qualified) {
556
- return buildElementResult(qualified, `Navigation (${qualifierMatch})`, 0.9);
1882
+ else if (probe && probe.ambiguous && probe.others.length === 2) {
1883
+ const labelled = await labelledSecondary();
1884
+ if (labelled)
1885
+ return labelled;
1886
+ const second = await run("landmark:nav-second", page.locator(poolCss), {
1887
+ container: true, mode: "first", ordinal: 1, require: req, prefer: [], dedupeNested: true,
1888
+ });
1889
+ if (second && second.chosen)
1890
+ return build(second, "landmark:nav-second", 0.6, "Landmark");
557
1891
  }
1892
+ else if (probe && probe.ambiguous) {
1893
+ const labelled = await labelledSecondary();
1894
+ if (labelled)
1895
+ return labelled;
1896
+ }
1897
+ return options.verbose ? await verboseMiss(page, intent) : null;
1898
+ }
1899
+ const rn = await tryPool(poolCss, `landmark:${kind}`, { dedupeNested: false });
1900
+ if (rn && "selector" in rn)
1901
+ return rn;
1902
+ if (!fallbackToControl)
1903
+ return options.verbose ? await verboseMiss(page, intent) : null;
1904
+ // A navigation whose links are collapsed behind a toggle is still the navigation landmark: take a nav that is
1905
+ // showing (its toggle or heading is), holds links in the DOM, and is not in the footer. The toggle is a control, not a landmark.
1906
+ // ("menu" alone still means the control that opens it, which the corpus and common usage agree on.)
1907
+ if (parsed.words[parsed.words.length - 1] !== "menu") {
1908
+ const reqCollapsed = [...req.filter(f => !f.anyOf && !f.hasVisibleCss), { hasCss: "a[href], [role='link'], [role='menuitem']" }];
1909
+ const rc = await run("landmark:nav-collapsed", page.locator(poolCss), { container: true, mode, require: reqCollapsed, prefer: pref, dedupeNested: true });
1910
+ if (rc && rc.chosen)
1911
+ return build(rc, "landmark:nav-collapsed", parsed.ordinal !== null ? 0.85 : (rc.ambiguous ? 0.6 : 0.85), "Landmark");
558
1912
  }
559
- // Fall back to first nav element
560
- return buildElementResult(navElements[0], "Navigation", 0.85);
561
- }
562
- }
563
- // ARIA-label matching (new: search by aria-label content)
564
- const ariaMatch = pageData.find(el => el.ariaLabel.toLowerCase().includes(intentLower));
565
- if (ariaMatch) {
566
- return buildElementResult(ariaMatch, `ARIA match: ${ariaMatch.ariaLabel.slice(0, 50)}`, 0.85);
567
- }
568
- // =========================================================================
569
- // Word-level fuzzy matching (v10.10.0)
570
- // =========================================================================
571
- // Score all elements and find best matches
572
- const scoredElements = pageData
573
- .map(el => ({ el, score: calculateWordScore(el) }))
574
- .filter(({ score }) => score > 0.3) // Minimum threshold
575
- .sort((a, b) => b.score - a.score);
576
- // Handle ordinal positions ("first headline link", "second story", "last link")
577
- // v11.2.0: Added "last" support (-1 means last element)
578
- if (targetIndex !== null && scoredElements.length > 0) {
579
- let actualIndex;
580
- let ordinalLabel;
581
- if (targetIndex === -1) {
582
- // "last" - get the final matching element
583
- actualIndex = scoredElements.length - 1;
584
- ordinalLabel = "last";
585
- }
586
- else if (targetIndex < scoredElements.length) {
587
- actualIndex = targetIndex;
588
- ordinalLabel = `#${targetIndex + 1}`;
589
1913
  }
590
1914
  else {
591
- // Requested index out of bounds, fall through to regular matching
592
- actualIndex = -1;
593
- ordinalLabel = "";
594
- }
595
- if (actualIndex >= 0) {
596
- const match = scoredElements[actualIndex];
597
- const confidence = Math.min(0.95, 0.6 + match.score * 0.35);
598
- return buildElementResult(match.el, `Ordinal match ${ordinalLabel}: ${match.el.text.slice(0, 50)}`, confidence);
599
- }
600
- }
601
- // Return best match if score is high enough
602
- if (scoredElements.length > 0 && scoredElements[0].score > 0.4) {
603
- const best = scoredElements[0];
604
- const confidence = Math.min(0.95, 0.5 + best.score * 0.45);
605
- return buildElementResult(best.el, `Word match (${Math.round(best.score * 100)}%): ${best.el.text.slice(0, 50)}`, confidence);
606
- }
607
- // Legacy text-based matching as fallback
608
- const textMatch = pageData.find(el => el.text.toLowerCase().includes(intentLower) ||
609
- el.classes.toLowerCase().includes(intentLower));
610
- if (textMatch) {
611
- return buildElementResult(textMatch, `Text match: ${textMatch.text.slice(0, 50)}`, 0.6);
612
- }
613
- // No match found — return verbose data if requested
614
- if (options.verbose) {
615
- const alternatives = pageData
616
- .filter(el => el.text.length > 0 || el.ariaLabel.length > 0)
617
- .slice(0, 10)
618
- .map(el => {
619
- const intentWords = intentLower.split(/\s+/);
620
- const searchText = `${el.text} ${el.ariaLabel}`.toLowerCase();
621
- const matchingWords = intentWords.filter(w => w.length > 2 && searchText.includes(w));
622
- const confidence = intentWords.length > 0 ? matchingWords.length / intentWords.length * 0.6 : 0;
623
- const selectors = generatePrioritySelectors(el);
624
- const best = selectors[0];
625
- return {
626
- selector: best?.selector || el.tag,
627
- text: (el.ariaLabel || el.text).slice(0, 60),
628
- tag: el.tag,
629
- type: (best?.type || "nth-of-type"),
630
- confidence: Math.round(confidence * 100) / 100,
631
- };
632
- })
633
- .sort((a, b) => b.confidence - a.confidence);
634
- const list = alternatives.slice(0, 8).map(a => ` • ${a.tag}: "${a.text}" (${a.type}) → ${a.selector}`).join("\n");
635
- const aiSuggestion = `No element matching "${intent}" found.\n\nAvailable interactive elements:\n${list}\n\nTry using the exact text, aria-label, or a more specific description.`;
636
- return {
637
- selector: "",
638
- confidence: 0,
639
- description: "No match found",
640
- alternatives,
641
- aiSuggestion,
1915
+ const r1 = await tryPool(css.primary, `landmark:${kind}`);
1916
+ if (r1 && "selector" in r1)
1917
+ return r1;
1918
+ if (css.fallback && !(r1 && r1.hiddenOnly)) {
1919
+ const r2 = await tryPool(css.fallback, `landmark-fallback:${kind}`);
1920
+ if (r2 && "selector" in r2)
1921
+ return { ...r2, confidence: red2(Math.min(r2.confidence, 0.85)) };
1922
+ }
1923
+ if (kind === "section" && qualifiers.length) {
1924
+ // a section whose heading mentions the qualifier
1925
+ const loc = page.locator("section, [role='region'], div[id], div[class]").filter({ has: page.locator("h1, h2, h3", { hasText: new RegExp(qualifiers.map(escRe).join("|"), "i") }) });
1926
+ const r = await run("landmark:section-heading", loc, { container: true, mode: "first", require: [], prefer: [], dedupeNested: true });
1927
+ if (r && r.chosen)
1928
+ return build(r, "landmark:section-heading", 0.75, "Section");
1929
+ }
1930
+ return options.verbose ? await verboseMiss(page, intent) : null;
1931
+ }
1932
+ // "menu" / "navigation" with no visible nav: the control that opens it
1933
+ const menuRe = exactRe(["menu", ...synonymsOf("menu")]);
1934
+ const ctl = unionRole(root, ["button", "link"], menuRe);
1935
+ const r = await take("menu-control", ctl, { container: false, mode: "first", require: [], prefer: [{ tagRe: "^button$" }] }, { unique: 0.8, ordinal: 0.8, ambiguous: 0.5 }, "Menu control");
1936
+ if (r && r.res)
1937
+ return r.res;
1938
+ const ctl2 = unionRole(root, ["button"], containsRe(["menu", "navigation"]));
1939
+ const r2 = await take("menu-control-fuzzy", ctl2, { container: false, mode: "first", require: [], prefer: [], guard: { words: ["menu"], maxExtra: 3, prefixMaxExtra: 3, danger: true } }, { unique: 0.7, ordinal: 0.7, ambiguous: 0.45 }, "Menu control");
1940
+ if (r2 && r2.res)
1941
+ return r2.res;
1942
+ return options.verbose ? await verboseMiss(page, intent) : null;
1943
+ }
1944
+ // --------------------------------------------------------------------------
1945
+ // 3. Specials
1946
+ // --------------------------------------------------------------------------
1947
+ const kind = parsed.kind;
1948
+ const phrase = parsed.phrase.trim();
1949
+ const nameWords = parsed.words;
1950
+ const loose = ["field", "textarea", "checkbox", "radio", "dropdown", "card", "product"].includes(parsed.kind);
1951
+ // form labels are sentences, so the loose kinds tolerate longer names
1952
+ const guard = (maxExtra, prefixMaxExtra) => ({
1953
+ words: nameWords.map(w => COMPOUNDS[w] ? COMPOUNDS[w].split(" ") : [w]).flat(),
1954
+ maxExtra: loose ? maxExtra + 6 : maxExtra,
1955
+ prefixMaxExtra: loose ? prefixMaxExtra + 8 : prefixMaxExtra,
1956
+ danger: true,
1957
+ });
1958
+ if (parsed.special === "logo") {
1959
+ // A logo/brand/home link in the header; else the first header link; else a site-root link; else a logo image lifted to its link.
1960
+ // "GOV.UK logo": the link must also carry the brand words somewhere a name comes from (aria-label, alt, title, text, id/class).
1961
+ const brandRe = parsed.brand?.length ? parsed.brand.map(w => `(?=.*(?:^|[^\\p{L}\\p{N}])${escRe(w)})`).join("") + ".*" : null;
1962
+ const brandReq = brandRe ? [{ anyOf: [{ nameRe: brandRe }, { attrRe: brandRe }] }] : [];
1963
+ // Real logo evidence: an icon-only link (an image/svg and no text of its own), logo/brand/wordmark in its
1964
+ // id/class/aria-label, or, with a brand word, the brand in its name. "<svg/> Account" is a control with an
1965
+ // icon, not a logo; a "Sign in" link beside an unlinked logo image has none of these.
1966
+ // Attribute evidence (class/id/aria-label with logo/brand/wordmark) counts only outside a nav or list: "Brands"
1967
+ // (class nav-brands) and aria-label "Shop by brand" are nav items, and an icon-only or site-root link always
1968
+ // outranks attribute evidence ("Brand guidelines" beside the real logo).
1969
+ const attrEvidence = { attrRe: "logo|brand|wordmark", notWithinCss: "nav, [role='navigation'], ul, ol, [role='menu']" };
1970
+ const evidence = { anyOf: [{ iconLink: true }, attrEvidence, ...(brandRe ? [{ nameRe: brandRe }] : [])] };
1971
+ // inside the header, a text link to the site root ("Acme", an absolute wordmark href) is the brand link too,
1972
+ // unless it sits in a nav or list or is named Home - that is the home link
1973
+ const headerEvidence = { anyOf: [...evidence.anyOf, { rootLink: true }] };
1974
+ const hdr = page.locator(scopeCss("header"));
1975
+ const r = await run("logo", hdr.locator("a[href]"), {
1976
+ container: false, mode: "first", require: [headerEvidence, ...brandReq],
1977
+ prefer: [{ anyOf: [{ iconLink: true }, { rootLink: true }] }, { attrRe: "logo|brand|wordmark" }, { hasCss: "img, svg" }, { rootLink: true }],
1978
+ });
1979
+ if (r && r.chosen)
1980
+ return build(r, "logo", r.ambiguous ? 0.75 : 0.9, "Logo");
1981
+ if (brandRe) {
1982
+ // the header's link to the site root, named by the brand
1983
+ const r1 = await run("logo-brand", hdr.locator("a[href]"), { container: false, mode: "first", require: [{ rootLink: true }, ...brandReq], prefer: [{ hasCss: "img, svg, picture" }] });
1984
+ if (r1 && r1.chosen)
1985
+ return build(r1, "logo-brand", r1.ambiguous ? 0.7 : 0.85, "Logo");
1986
+ }
1987
+ const ROOT_LINKS = "a[href='/'], a[href='./'], a[href='index.html'], a[href='/index.html'], a[aria-label*='home' i], a[class*='wordmark' i], "
1988
+ + "a[aria-label*='logo' i], a[class*='logo' i], a[id*='logo' i], a[class*='brand' i], [class*='logo' i] a[href]";
1989
+ const r2 = await run("logo-root", page.locator(ROOT_LINKS), {
1990
+ container: false, mode: "first", require: [evidence, ...brandReq], prefer: [{ attrRe: "logo|brand" }, { hasCss: "img, svg, picture" }],
1991
+ });
1992
+ if (r2 && r2.chosen)
1993
+ return build(r2, "logo-root", 0.8, "Logo");
1994
+ // a logo image counts only when it lifts to a link or button; an unlinked logo is not something to click
1995
+ const r3 = await run("logo-img", page.locator("img[alt*='logo' i], img[class*='logo' i], [class*='logo' i] img, svg[aria-label*='logo' i]"), { container: false, mode: "first", require: [{ tagRe: "^(a|button)$" }, ...brandReq], prefer: [], liftToControl: true });
1996
+ if (r3 && r3.chosen)
1997
+ return build(r3, "logo-img", 0.75, "Logo");
1998
+ return options.verbose ? await verboseMiss(page, intent) : null;
1999
+ }
2000
+ if (parsed.special === "home") {
2001
+ const r = await take("home-link", unionRole(root, ["link"], exactRe(["home", ...synonymsOf("home")])), { container: false, mode: "first", require: [], prefer: [{ withinCss: scopeCss("nav") }] }, { unique: 0.92, ordinal: 0.9, ambiguous: 0.6 }, "Home link");
2002
+ if (r && r.res)
2003
+ return r.res;
2004
+ const r2 = await run("home-root", root.locator("a[href='/'], a[href='./'], a[href='index.html'], a[href='/index.html'], a[aria-label*='home' i]"), { container: false, mode: "first", require: [], prefer: [{ withinCss: scopeCss("header") }, { attrRe: "logo|brand|home" }] });
2005
+ if (r2 && r2.chosen)
2006
+ return build(r2, "home-root", 0.8, "Home link");
2007
+ return options.verbose ? await verboseMiss(page, intent) : null;
2008
+ }
2009
+ if (parsed.special === "search" && kind !== "button") {
2010
+ const NOT_BTN = ":not([type='submit']):not([type='button']):not([type='image']):not([type='hidden']):not([type='checkbox']):not([type='radio'])";
2011
+ const inputs = root.locator(`input[type='search'], [role='searchbox'], input[name='q']${NOT_BTN}, input[name='s']${NOT_BTN}, input[name*='search' i]${NOT_BTN}, `
2012
+ + `input[id*='search' i]${NOT_BTN}, input[placeholder*='search' i]${NOT_BTN}, input[aria-label*='search' i]${NOT_BTN}, `
2013
+ + `[role='search'] input${NOT_BTN}, form[action*='search' i] input${NOT_BTN}, input[class*='search' i]${NOT_BTN}, [role='search'] [contenteditable='true']`);
2014
+ const r = await run("search-input", inputs, {
2015
+ container: false, mode: "first", require: [],
2016
+ prefer: [{ matchCss: "input[type='search'], [role='searchbox']" }, { attrRe: "search" }, { tagRe: "^input$" }],
2017
+ });
2018
+ if (r && r.chosen)
2019
+ return build(r, "search-input", r.ambiguous ? 0.7 : 0.92, "Search input");
2020
+ const trig = unionRole(root, ["button", "link"], containsRe(["search"]));
2021
+ const r2 = await take("search-trigger", trig, { container: false, mode: "first", require: [], prefer: [], guard: guard(3, 3) }, { unique: 0.8, ordinal: 0.8, ambiguous: 0.5 }, "Search control");
2022
+ if (r2 && r2.res)
2023
+ return r2.res;
2024
+ return options.verbose ? await verboseMiss(page, intent) : null;
2025
+ }
2026
+ if (parsed.special === "submit") {
2027
+ const r = await run("submit", root.locator(SUBMIT_CSS), {
2028
+ container: false, mode: "first", require: [],
2029
+ prefer: [{ withinCss: "form:has(input[type='password'])" }, { withinCss: "main, [role='main']" }], dedupeNested: true,
2030
+ });
2031
+ if (r && r.chosen)
2032
+ return build(r, "submit", parsed.ordinal !== null ? 0.9 : (r.ambiguous ? 0.6 : 0.9), "Submit control");
2033
+ return options.verbose ? await verboseMiss(page, intent) : null;
2034
+ }
2035
+ if (parsed.special === "language") {
2036
+ // A language control is NAMED as one ("Change language", "Language", "Locale", "Select your language"); "Golang" and
2037
+ // "Language Arts" are not. A link or button named with a language itself ("English", "Deutsch") is the selector on
2038
+ // many sites, scored below 0.9.
2039
+ const LANG_NAME = new RegExp("^\\W*(?:(?:change|select|switch|choose|set)\\s+(?:your\\s+|the\\s+|site\\s+|display\\s+)?)?"
2040
+ + "(?:language|languages|locale|site language|display language|lang)(?:\\s+(?:selector|switcher|menu|settings?|picker|options?))?\\W*$", "i");
2041
+ const LANG_WORD = new RegExp("^\\W*(?:english|español|espanol|deutsch|français|francais|italiano|português|portugues|nederlands|polski|svenska|norsk|dansk|suomi|"
2042
+ + "türkçe|turkce|čeština|magyar|română|ελληνικά|русский|українська|日本語|中文|简体中文|繁體中文|한국어|العربية|हिन्दी|bahasa indonesia|"
2043
+ + "tiếng việt|ไทย)\\W*$", "i");
2044
+ const byWord = root.getByRole("link", { name: LANG_WORD, includeHidden: true }).or(root.getByRole("button", { name: LANG_WORD, includeHidden: true }));
2045
+ const rw = await run("language-name", byWord, { container: false, mode: "first", require: [], prefer: [{ withinCss: "header, footer, nav, [role='navigation']" }], dedupeNested: true });
2046
+ const loc = root.getByRole("combobox", { name: LANG_NAME, includeHidden: true })
2047
+ .or(root.getByRole("button", { name: LANG_NAME, includeHidden: true }))
2048
+ .or(root.getByRole("link", { name: LANG_NAME, includeHidden: true }))
2049
+ .or(root.locator("select[name*='lang' i], select[id*='lang' i], select[class*='lang' i], [class*='language' i] select, [class*='lang-switch' i], [aria-label*='language' i]"));
2050
+ const r = await run("language", loc, { container: false, mode: "first", require: [], prefer: [{ tagRe: "^(select|button)$" }], dedupeNested: true });
2051
+ if (r && r.chosen)
2052
+ return build(r, "language", r.ambiguous ? 0.7 : 0.9, "Language control");
2053
+ if (rw && rw.chosen)
2054
+ return build(rw, "language-name", rw.ambiguous ? 0.5 : 0.7, "Language control");
2055
+ return options.verbose ? await verboseMiss(page, intent) : null;
2056
+ }
2057
+ if (parsed.special === "theme") {
2058
+ // A theme control is NAMED as one ("Toggle dark mode", "Dark mode", "Theme", "Switch to light theme", "Appearance");
2059
+ // "Dark roast" and "Add Dark Roast to cart" are not. Whole-name match at 0.9; a class/data-attribute fallback below it.
2060
+ // a theme PHRASE anywhere in the name ("Switch between dark and light mode (currently system mode)"), or a name
2061
+ // that is just Dark / Light; the bare word in a product name ("Dark roast") is not a phrase
2062
+ const THEME_NAME = new RegExp("(?:\\b(?:dark|light|night|day)\\s*(?:mode|theme)s?\\b|\\btheme\\b|\\bappearance\\b|\\bcolou?r\\s*scheme\\b"
2063
+ + "|^\\W*(?:(?:toggle|switch to|enable|use)\\s+)?(?:dark|light)\\W*$)", "i");
2064
+ const named = root.getByRole("button", { name: THEME_NAME, includeHidden: true })
2065
+ .or(root.getByRole("switch", { name: THEME_NAME, includeHidden: true }))
2066
+ .or(root.getByRole("checkbox", { name: THEME_NAME, includeHidden: true }))
2067
+ .or(root.getByRole("menuitem", { name: THEME_NAME, includeHidden: true }));
2068
+ const r = await run("theme", named, { container: false, mode: "first", require: [], prefer: [], dedupeNested: true });
2069
+ if (r && r.chosen)
2070
+ return build(r, "theme", r.ambiguous ? 0.7 : 0.9, "Theme control");
2071
+ const r2 = await run("theme-css", root.locator("button[class*='theme' i], button[id*='theme' i], [data-theme-toggle], button[aria-label*='theme' i], [role='switch'][aria-label*='theme' i]"), { container: false, mode: "first", require: [], prefer: [], dedupeNested: true });
2072
+ if (r2 && r2.chosen)
2073
+ return build(r2, "theme-css", r2.ambiguous ? 0.6 : 0.8, "Theme control");
2074
+ return options.verbose ? await verboseMiss(page, intent) : null;
2075
+ }
2076
+ if (parsed.special === "menu") {
2077
+ const menuExact = exactRe(["menu", ...synonymsOf("menu")]);
2078
+ const ctl = unionRole(root, ["button", "link"], menuExact);
2079
+ const r = await take("menu-control", ctl, { container: false, mode: "first", require: [], prefer: [{ tagRe: "^button$" }] }, { unique: 0.9, ordinal: 0.9, ambiguous: 0.6 }, "Menu control");
2080
+ if (r && r.res)
2081
+ return r.res;
2082
+ // An exactly-named "Menu" control that is hidden at this width means null, unless it is display-hidden inside
2083
+ // the navigation itself (a folded or no-JS copy such as GOV.UK's <a hidden>Menu</a> inside its <nav>): then the
2084
+ // visible toggle is still the answer, capped at 0.7.
2085
+ const hiddenExact = !!(r && r.r.hiddenOnly);
2086
+ if (hiddenExact && !r.r.hiddenInCollapsedNav)
2087
+ return options.verbose ? await verboseMiss(page, intent) : null;
2088
+ const capped = (res) => res && hiddenExact ? { ...res, confidence: red2(Math.min(res.confidence, 0.7)) } : res;
2089
+ // the navigation toggle over a search or account toggle when several buttons mention "menu"
2090
+ const ctl2 = unionRole(root, ["button"], containsRe(["menu", "navigation", "hamburger"]));
2091
+ const r2 = await take("menu-control-fuzzy", ctl2, { container: false, mode: "first", require: [], prefer: [{ notNameRe: "search|account|user|profile|language" }, { nameRe: "navigation|\\bnav\\b|hamburger|main|site|primary" }],
2092
+ guard: { words: ["menu"], maxExtra: 3, prefixMaxExtra: 3, danger: true } }, { unique: 0.8, ordinal: 0.8, ambiguous: 0.5 }, "Menu control");
2093
+ if (r2 && r2.res)
2094
+ return capped(r2.res);
2095
+ // the visible text says Menu even though the accessible name (an aria-label) says something else
2096
+ const rt = await run("menu-text", root.locator(BUTTON_CSS), {
2097
+ container: false, mode: "first", require: [{ textRe: "^\\s*(?:menu|hamburger|burger|navigation)\\s*$" }], prefer: [],
2098
+ });
2099
+ if (rt && rt.chosen)
2100
+ return capped(build(rt, "menu-text", rt.ambiguous ? 0.5 : 0.8, "Menu control"));
2101
+ const MENU_CSS = "button[class*='hamburger' i], button[class*='burger' i], button[class*='menu-toggle' i], button[class*='navbar-toggle' i], "
2102
+ + "button[class*='nav-toggle' i], .hamburger, [aria-controls*='menu' i][aria-expanded], [aria-controls*='nav' i][aria-expanded]";
2103
+ const r3 = await run("menu-css", root.locator(MENU_CSS), { container: false, mode: "first", require: [], prefer: [] });
2104
+ if (r3 && r3.chosen)
2105
+ return build(r3, "menu-css", 0.7, "Menu control");
2106
+ return options.verbose ? await verboseMiss(page, intent) : null;
2107
+ }
2108
+ // --------------------------------------------------------------------------
2109
+ // 4. Kind-only intents ("button", "first link", "the input", "second tab")
2110
+ // --------------------------------------------------------------------------
2111
+ if (!nameWords.length || (!phrase && kind !== "any")) {
2112
+ if (kind === "any")
2113
+ return options.verbose ? await verboseMiss(page, intent) : null;
2114
+ const isField = ["field", "textarea", "dropdown", "checkbox", "radio"].includes(kind);
2115
+ const prefer = [];
2116
+ // judgment: "button" means a real button; a link-button counts only when it is the only button-like control
2117
+ if (kind === "button")
2118
+ prefer.push({ tagRe: "^(button|input|summary)$" }, { tagRe: "^(button|input|summary|span|div)$" });
2119
+ if (kind === "field")
2120
+ prefer.push({ tagRe: "^(input|textarea)$" });
2121
+ const mode = isField || parsed.ordinal !== null ? "first" : "unique";
2122
+ const realOnly = kind === "button" && parsed.scope === "nav";
2123
+ let pool2 = realOnly ? root.locator(BUTTON_CSS) : root.locator(KIND_POOL[kind]);
2124
+ if (kind === "button" && !realOnly) {
2125
+ // "first button": real buttons; a link styled as a button counts only when no real button is visible
2126
+ const rb = await run("kind:button", root.locator(BUTTON_CSS), { container: false, mode, require: [], prefer: [] });
2127
+ if (rb && rb.chosen)
2128
+ return build(rb, "kind:button", parsed.ordinal !== null ? 0.92 : (rb.ambiguous ? 0.6 : 0.9), "Button");
2129
+ if (rb && rb.ambiguous)
2130
+ return options.verbose ? await verboseMiss(page, intent) : null;
2131
+ if (rb && rb.visibleCount > 0)
2132
+ return options.verbose ? await verboseMiss(page, intent) : null;
2133
+ pool2 = root.locator(LINK_BUTTON_CSS);
2134
+ }
2135
+ const PRICE_RE = "(?:[$£€¥]\\s?\\d[\\d,]*(?:\\.\\d{2})?|\\d[\\d,]*\\.\\d{2}\\s?(?:usd|eur|gbp)?|\\d[\\d,]*\\s?[$£€¥])";
2136
+ const isProd = kind === "card" || kind === "product";
2137
+ let r = isProd ? await run(`kind:${kind}`, pool2, { container: true, mode, require: [{ textRe: PRICE_RE }], prefer, dedupeNested: true }) : null;
2138
+ if (!r || !r.chosen) {
2139
+ r = await run(`kind:${kind}`, pool2, {
2140
+ container: kind === "section" || kind === "article" || isProd || kind === "form", mode, require: [], prefer, dedupeOuter: isProd,
2141
+ });
2142
+ }
2143
+ if (r && r.chosen)
2144
+ return build(r, `kind:${kind}`, parsed.ordinal !== null ? 0.92 : (r.ambiguous ? 0.6 : 0.9), kind[0].toUpperCase() + kind.slice(1));
2145
+ if (realOnly && parsed.ordinal === null && !(r && r.ambiguous)) {
2146
+ // "nav button" when the navigation is collapsed: the control that opens it
2147
+ const ctl = unionRole(page, ["button"], exactRe(["menu", ...synonymsOf("menu")]));
2148
+ const rm = await take("menu-control", ctl, { container: false, mode: "first", require: [], prefer: [] }, { unique: 0.8, ordinal: 0.8, ambiguous: 0.5 }, "Menu control");
2149
+ if (rm && rm.res)
2150
+ return rm.res;
2151
+ const ctl2 = unionRole(page, ["button"], containsRe(["menu", "navigation"]));
2152
+ const rm2 = await take("menu-control-fuzzy", ctl2, { container: false, mode: "first", require: [], prefer: [], guard: { words: ["menu"], maxExtra: 3, prefixMaxExtra: 3, danger: true } }, { unique: 0.7, ordinal: 0.7, ambiguous: 0.45 }, "Menu control");
2153
+ if (rm2 && rm2.res)
2154
+ return rm2.res;
2155
+ }
2156
+ return options.verbose ? await verboseMiss(page, intent) : null;
2157
+ }
2158
+ const navScoped = parsed.scope === "nav";
2159
+ // "X in the navigation" never means the footer's links; a footer intent says footer.
2160
+ const NAV_NOT_FOOTER_CSS = scopeCss("nav").split(", ").map(p => `${p}:not(${FOOTERISH_CSS.split(", ").map(f => `${f} *`).join(", ")})`).join(", ");
2161
+ const namedCascade = async (q) => {
2162
+ const kind = q.kind;
2163
+ const phrase = q.phrase;
2164
+ const nameWords = q.words;
2165
+ const loose = ["field", "textarea", "checkbox", "radio", "dropdown", "card", "product"].includes(kind);
2166
+ const guard = (maxExtra, prefixMaxExtra) => ({
2167
+ words: nameWords.map(w => COMPOUNDS[w] ? COMPOUNDS[w].split(" ") : [w]).flat(),
2168
+ maxExtra: loose ? maxExtra + 6 : maxExtra,
2169
+ prefixMaxExtra: loose ? prefixMaxExtra + 8 : prefixMaxExtra,
2170
+ danger: true,
2171
+ quant: !["card", "product", "section", "article", "form"].includes(kind),
2172
+ noWordsBefore: !loose && !["section", "article", "form"].includes(kind),
2173
+ });
2174
+ const literal = [...new Set([q.phraseFull, phrase].filter(Boolean))];
2175
+ const syns = synonymsOf(phrase);
2176
+ const rungs = [];
2177
+ // What pool a kind resolves to, as a union of Playwright role locators plus CSS where ARIA roles fall short.
2178
+ const roleUnion = (name, whichRoles) => unionRole(root, whichRoles, name);
2179
+ // For links, anchors with role=button are still links.
2180
+ const linkPool = (name) => {
2181
+ const l1 = roleUnion(name, ["link"]);
2182
+ const l2 = (roleUnion(name, ["button"])).and(root.locator("a[href]"));
2183
+ return l1.or(l2);
2184
+ };
2185
+ const buttonPool = (name) => {
2186
+ const b = roleUnion(name, ["button"]);
2187
+ const lb = roleUnion(name, ["link"]).and(root.locator(LINK_BUTTON_CSS));
2188
+ return b.or(lb);
2189
+ };
2190
+ const buttonOrLinkPool = (name) => roleUnion(name, ["button", "link"]);
2191
+ const fieldPool = (name) => {
2192
+ const r1 = roleUnion(name, ["textbox", "searchbox", "combobox", "spinbutton", "listbox"]);
2193
+ const lab = name ? root.getByLabel(name).or(root.getByPlaceholder(name)) : null;
2194
+ const base = root.locator(FIELD_CSS);
2195
+ let u = r1;
2196
+ if (lab)
2197
+ u = u.or(lab.and(base));
2198
+ return u;
2199
+ };
2200
+ // the role union, plus the labelled <input>/<select> of that css when a name is given
2201
+ const roleOrLabelled = (name, whichRoles, css) => roleUnion(name, whichRoles).or(name ? root.getByLabel(name).and(root.locator(css)) : root.locator(css));
2202
+ const poolFor = (name) => {
2203
+ switch (kind) {
2204
+ case "button": return buttonOrLinkPool(name);
2205
+ case "link": return linkPool(name);
2206
+ case "field":
2207
+ case "textarea": return fieldPool(name);
2208
+ case "dropdown": return roleOrLabelled(name, ["combobox", "listbox", "button", "menu"], "select");
2209
+ case "checkbox": return roleOrLabelled(name, ["checkbox", "switch"], "input[type='checkbox']");
2210
+ case "radio": return roleOrLabelled(name, ["radio"], "input[type='radio']");
2211
+ case "tab": return roleUnion(name, ["tab"]);
2212
+ case "menuitem": return roleUnion(name, ["menuitem", "menuitemcheckbox", "menuitemradio", "option"]);
2213
+ case "image": return name ? root.getByAltText(name).or(root.getByRole("img", { name, includeHidden: true })) : root.locator("img, [role='img']");
2214
+ case "heading": return roleUnion(name, ["heading"]);
2215
+ case "toggle": return roleUnion(name, ["switch", "checkbox", "button"]);
2216
+ case "card":
2217
+ case "product":
2218
+ case "section":
2219
+ case "article":
2220
+ case "form":
2221
+ return name ? root.locator(KIND_POOL[kind]).filter({ hasText: name }) : root.locator(KIND_POOL[kind]);
2222
+ default: return roleUnion(name, KIND_ROLES.any);
2223
+ }
642
2224
  };
2225
+ const isContainerKind = ["card", "product", "section", "article", "form"].includes(kind);
2226
+ const preferReal = kind === "button" ? [{ tagRe: "^(button|input|summary|span|div)$" }]
2227
+ : kind === "any" ? [{ tagRe: "^(a|button|input|select|textarea|summary)$" }] : [];
2228
+ // "close button" with a dialog open: the dialog's close, not a banner's or a web component's
2229
+ if (/^(close|dismiss|x|×|✕)$/.test(phrase))
2230
+ preferReal.unshift({ withinCss: "dialog, [role='dialog'], [role='alertdialog'], [aria-modal='true'], .modal" });
2231
+ const scopeRequire = navScoped && !q.footerOk ? [{ notWithinCss: FOOTERISH_CSS }] : [];
2232
+ const pickBase = {
2233
+ container: isContainerKind, mode: "first", require: scopeRequire, prefer: preferReal, dedupeNested: isContainerKind,
2234
+ ...(navScoped ? { markCss: PRIMARY_NAV_CSS, countCss: NAV_NOT_FOOTER_CSS } : {}),
2235
+ };
2236
+ const exactLit = exactRe(literal);
2237
+ const exactSyn = syns.length ? exactRe(syns) : null;
2238
+ const containsLit = containsRe(literal);
2239
+ const fuzzySyns = syns.filter(s => !NO_FUZZY_SYNONYMS.has(s) && s.length > 2);
2240
+ const containsSyn = fuzzySyns.length && !NO_FUZZY_SYNONYMS.has(phrase) ? containsRe(fuzzySyns) : null;
2241
+ const allWords = allWordsRe(nameWords);
2242
+ const stems = stemWordsRe(nameWords);
2243
+ const CONF_EXACT = { unique: 0.95, ordinal: 0.92, ambiguous: 0.65 };
2244
+ const CONF_STRONG = { unique: 0.9, ordinal: 0.88, ambiguous: 0.6 };
2245
+ const CONF_GOOD = { unique: 0.85, ordinal: 0.85, ambiguous: 0.6 };
2246
+ const CONF_CONTAINS = { unique: 0.8, ordinal: 0.78, ambiguous: 0.55 };
2247
+ if (kind === "link" && /^(citation|cite|reference|footnote|ref)s?$/.test(phrase)) {
2248
+ const CITE_CSS = "sup a[href], a[href^='#cite'], .reference a[href], a[href*='cite_note'], .citation a[href], a.citation, a[role='doc-noteref'], a[href^='#fn'], a[href^='#ref']";
2249
+ rungs.push({ label: "citation", locator: root.locator(CITE_CSS), conf: CONF_GOOD, pick: pickBase });
2250
+ }
2251
+ // "search button" with no control named Search: the submit of the search form
2252
+ const SEARCH_SUBMIT_CSS = "[role='search'] button, [role='search'] input[type='submit'], [role='search'] input[type='image'], "
2253
+ + "form:has(input[type='search']) button, form:has(input[type='search']) input[type='submit'], "
2254
+ + "form:has(input[name='q']) button, form:has(input[name='q']) input[type='submit'], "
2255
+ + "form:has(input[name='s']) button, form:has(input[name='s']) input[type='submit'], "
2256
+ + "form[action*='search' i] button, form[action*='search' i] input[type='submit'], "
2257
+ + "button[class*='search' i], button[id*='search' i], a[class*='search' i][href]";
2258
+ const searchSubmitRung = () => ({ label: "search-submit", locator: root.locator(SEARCH_SUBMIT_CSS), conf: CONF_GOOD,
2259
+ pick: { ...pickBase, prefer: [{ matchCss: "[type='submit'], button:not([type='button'])" }] } });
2260
+ const CONF_BADGE = { unique: 0.88, ordinal: 0.86, ambiguous: 0.6 };
2261
+ const badgeLit = exactBadgeRe(literal);
2262
+ if (kind === "button" && exactLit) {
2263
+ // A control named exactly X, button or link, beats everything fuzzy: "cart button" is the Cart link, not
2264
+ // Empty cart; "settings button" is the Settings link, not the Options kebab. The one rung above the exact link
2265
+ // is a real button that is X plus one word ("Features tour"), with the limits described below.
2266
+ rungs.push({ label: "exact", locator: buttonPool(exactLit), conf: CONF_EXACT, pick: pickBase });
2267
+ // A real button that is X followed by ONE word is X qualified ("Features tour", "Download now") and beats a
2268
+ // plain link named X; a verb-first name ("Edit profile", "Mute notifications") does not, and a paging word's
2269
+ // exact link ("Next", "Previous") is always the canonical one.
2270
+ // ... and never when X is itself destructive, financial or a sign-out: "delete button" is not Delete selected,
2271
+ // "cancel button" is not Cancel subscription, "clear button" is not Clear history.
2272
+ const paging = /^(next|previous|prev|back|forward|first|last)$/.test(phrase);
2273
+ const xDanger = nameWords.some(w => DANGER_VERB_LIST.includes(w)) || /^(sign ?out|log ?out)$/.test(phrase);
2274
+ if (containsLit && !paging && !xDanger) {
2275
+ rungs.push({ label: "contains", locator: roleUnion(containsLit, ["button"]), conf: CONF_CONTAINS,
2276
+ pick: { ...pickBase, guard: { ...guard(0, 1), lettersOnlyExtra: true } } });
2277
+ }
2278
+ rungs.push({ label: "exact-link", locator: linkPool(exactLit), conf: CONF_STRONG, pick: pickBase });
2279
+ if (badgeLit)
2280
+ rungs.push({ label: "exact-badge", locator: buttonOrLinkPool(badgeLit), conf: CONF_BADGE, pick: { ...pickBase, badge: true } });
2281
+ if (exactSyn)
2282
+ rungs.push({ label: "exact-synonym", locator: buttonPool(exactSyn), conf: CONF_STRONG, pick: pickBase });
2283
+ if (/^search$/.test(phrase))
2284
+ rungs.push(searchSubmitRung());
2285
+ if (containsLit)
2286
+ rungs.push({ label: "contains", locator: roleUnion(containsLit, ["button"]), conf: CONF_CONTAINS, pick: { ...pickBase, guard: guard(2, 4) } });
2287
+ if (exactSyn)
2288
+ rungs.push({ label: "exact-synonym-link", locator: linkPool(exactSyn), conf: CONF_GOOD, pick: pickBase });
2289
+ }
2290
+ else if (exactLit) {
2291
+ rungs.push({ label: "exact", locator: poolFor(exactLit), conf: CONF_EXACT, pick: pickBase });
2292
+ if (badgeLit && !isContainerKind)
2293
+ rungs.push({ label: "exact-badge", locator: poolFor(badgeLit), conf: CONF_BADGE, pick: { ...pickBase, badge: true } });
2294
+ // "cancel link" with an exact Cancel BUTTON and a "Cancel subscription" link: the exact button, not the fuzzy link
2295
+ if (kind === "link")
2296
+ rungs.push({ label: "exact-button", locator: roleUnion(exactLit, ["button"]), conf: CONF_GOOD, pick: pickBase });
2297
+ }
2298
+ if (exactSyn && kind !== "button")
2299
+ rungs.push({ label: "exact-synonym", locator: poolFor(exactSyn), conf: CONF_STRONG, pick: pickBase });
2300
+ if (kind === "button" && /^search$/.test(phrase) && !exactLit)
2301
+ rungs.push(searchSubmitRung());
2302
+ // Label / placeholder / title / alt for the exact phrase (fields and icon controls)
2303
+ const isFieldKind = ["field", "textarea", "dropdown", "checkbox", "radio"].includes(kind);
2304
+ if (exactLit && (isFieldKind || kind === "any")) {
2305
+ const lab = root.getByLabel(exactLit).or(root.getByPlaceholder(exactLit)).or(root.getByTitle(exactLit)).or(root.getByAltText(exactLit));
2306
+ rungs.push({ label: "label-exact", locator: kind === "any" ? lab : lab.and(root.locator(FIELD_CSS)), conf: CONF_STRONG, pick: { ...pickBase, liftToControl: true } });
2307
+ }
2308
+ // Typed fields: "email field", "password field", "search box", "url input"
2309
+ if (kind === "field" || kind === "textarea") {
2310
+ const typeMap = {
2311
+ email: "input[type='email'], input[name*='email' i], input[id*='email' i], input[autocomplete='email']",
2312
+ password: "input[type='password']",
2313
+ search: "input[type='search'], [role='searchbox'], input[name='q'], input[name*='search' i], input[placeholder*='search' i]",
2314
+ url: "input[type='url'], input[name*='url' i], input[id*='url' i], input[placeholder*='url' i]",
2315
+ website: "input[type='url'], input[name*='url' i], input[name*='website' i], input[id*='website' i]",
2316
+ phone: "input[type='tel'], input[name*='phone' i], input[autocomplete='tel']",
2317
+ tel: "input[type='tel']",
2318
+ telephone: "input[type='tel']",
2319
+ username: "input[name='username'], input[autocomplete='username'], input[id*='user' i], input[name*='user' i], input[name='login'], input[id='login']",
2320
+ user: "input[name='username'], input[autocomplete='username'], input[id*='user' i], input[name*='user' i]",
2321
+ name: "input[name='name'], input[id='name'], input[autocomplete='name'], input[name*='name' i]:not([name*='user' i])",
2322
+ date: "input[type='date']",
2323
+ number: "input[type='number']",
2324
+ message: "textarea",
2325
+ comment: "textarea, input[name*='comment' i]",
2326
+ query: "input[name='q'], input[name='query'], input[type='search']",
2327
+ };
2328
+ const key = nameWords.find(w => typeMap[w]);
2329
+ if (key) {
2330
+ // the entry's first selector is its strongest signal (input[type=email] over input[id*=email])
2331
+ rungs.push({ label: "field-type", locator: root.locator(typeMap[key]), conf: CONF_STRONG,
2332
+ pick: { ...pickBase, prefer: [{ withinCss: "form:has(input[type='password'])" }, { matchCss: typeMap[key].split(",")[0].trim() }] } });
2333
+ }
2334
+ }
2335
+ if (containsLit)
2336
+ rungs.push({ label: "contains", locator: poolFor(containsLit), conf: CONF_CONTAINS, pick: { ...pickBase, guard: guard(2, 4) } });
2337
+ if (containsLit && isFieldKind) {
2338
+ const lab = root.getByLabel(containsLit).or(root.getByPlaceholder(containsLit));
2339
+ rungs.push({ label: "label-contains", locator: lab.and(root.locator(FIELD_CSS)), conf: CONF_CONTAINS, pick: { ...pickBase, guard: guard(3, 5) } });
2340
+ }
2341
+ if (containsLit && isFieldKind && kind !== "textarea") {
2342
+ rungs.push({ label: "field-text", locator: root.locator(KIND_POOL[kind]), conf: { unique: 0.75, ordinal: 0.72, ambiguous: 0.5 },
2343
+ pick: { ...pickBase, require: [...scopeRequire, { nameRe: containsLit.source }], guard: guard(3, 5) } });
2344
+ }
2345
+ if (containsSyn)
2346
+ rungs.push({ label: "contains-synonym", locator: poolFor(containsSyn), conf: { unique: 0.75, ordinal: 0.72, ambiguous: 0.5 }, pick: { ...pickBase, guard: guard(2, 3) } });
2347
+ // OAuth providers: "google sign in button", "continue with github"
2348
+ const PROVIDERS = ["google", "github", "facebook", "apple", "microsoft", "twitter", "linkedin", "gitlab", "okta", "sso"];
2349
+ const AUTH_WORDS = ["sign", "signin", "login", "log", "continue", "with", "auth", "oauth", "connect", "register", "signup"];
2350
+ const provider = nameWords.find(w => PROVIDERS.includes(w));
2351
+ if (provider && nameWords.some(w => AUTH_WORDS.includes(w) || w === provider)) {
2352
+ rungs.push({ label: "provider", locator: buttonOrLinkPool(new RegExp(`(?:^|[^\\p{L}])${escRe(provider)}(?=$|[^\\p{L}])`, "iu")),
2353
+ conf: { unique: 0.85, ordinal: 0.8, ambiguous: 0.55 }, pick: { ...pickBase, prefer: [{ nameRe: "sign|log|continue|with|auth" }, ...preferReal] } });
2354
+ }
2355
+ if (allWords)
2356
+ rungs.push({ label: "all-words", locator: poolFor(allWords), conf: { unique: 0.7, ordinal: 0.68, ambiguous: 0.5 }, pick: { ...pickBase, guard: guard(2, 4) } });
2357
+ // "exit button" -> Close: usually the same control, so an exact match on a weak synonym is a labelled guess,
2358
+ // after anything that carries the phrase itself ("Exit fullscreen")
2359
+ const weakSyn = weakSynonymsOf(phrase);
2360
+ const exactWeak = weakSyn.length ? exactRe(weakSyn) : null;
2361
+ if (exactWeak)
2362
+ rungs.push({ label: "exact-synonym-weak", locator: poolFor(exactWeak), conf: { unique: 0.65, ordinal: 0.65, ambiguous: 0.5 }, pick: pickBase });
2363
+ // Text fallback: the phrase as visible text, lifted to the nearest control
2364
+ if (exactLit && !isContainerKind) {
2365
+ // a <label> is a control only for field intents: "message button" must not land on the Message label
2366
+ const isControl = { anyOf: [
2367
+ { tagRe: isFieldKind ? "^(a|button|input|select|textarea|summary|label)$" : "^(a|button|input|select|textarea|summary)$" },
2368
+ { matchCss: "[role='button'], [role='link'], [role='tab'], [role='menuitem']" },
2369
+ ] };
2370
+ rungs.push({ label: "text-exact", locator: root.getByText(exactLit), conf: { unique: 0.7, ordinal: 0.68, ambiguous: 0.5 },
2371
+ pick: { ...pickBase, liftToControl: true, require: [...scopeRequire, isControl] } });
2372
+ }
2373
+ // "<name> menu": the control that opens a menu, named without the word. Before the attribute fallback:
2374
+ // a trigger named "Account" with data-testid="account-menu" is the trigger, not an attribute coincidence.
2375
+ if ((kind === "any" || kind === "button") && /\s+menu$/.test(phrase) && !parsed.special) {
2376
+ const stripped = phrase.replace(/\s+menu$/, "");
2377
+ const re1 = exactRe([stripped]);
2378
+ if (re1) {
2379
+ rungs.push({ label: "menu-trigger", locator: buttonOrLinkPool(re1), conf: { unique: 0.85, ordinal: 0.8, ambiguous: 0.55 },
2380
+ pick: { ...pickBase, prefer: [{ matchCss: "[aria-haspopup], [aria-expanded], [aria-controls], [data-state]" }, ...preferReal] } });
2381
+ }
2382
+ }
2383
+ // Attribute fallback (ids / names / testids that carry the words): "wishlist button" -> button#wishlist
2384
+ const attrWords = nameWords.filter(w => !["button", "link", "icon"].includes(w));
2385
+ if (q.kindExplicit && attrWords.length && attrWords.every(w => w.length >= 4)) {
2386
+ let attrLoc = root.locator(kind === "any" ? KIND_POOL.any : KIND_POOL[kind]);
2387
+ for (const w of attrWords) {
2388
+ attrLoc = attrLoc.and(root.locator(`[id*="${w}" i], [name*="${w}" i], [data-testid*="${w}" i], [class*="${w}" i], [aria-label*="${w}" i], [title*="${w}" i], a[href*="${w}" i]`));
2389
+ }
2390
+ // no length guard (the words are in the attributes, not the name), but the danger and quantifier rules still apply
2391
+ rungs.push({ label: "attribute", locator: attrLoc, conf: { unique: 0.6, ordinal: 0.6, ambiguous: 0.4 }, pick: { ...pickBase, guard: guard(99, 99) } });
2392
+ }
2393
+ const CONF_STEM = { unique: 0.6, ordinal: 0.58, ambiguous: 0.4 };
2394
+ if (stems && (loose || kind === "tab"))
2395
+ rungs.push({ label: "stem", locator: poolFor(stems), conf: CONF_STEM, pick: { ...pickBase, guard: guard(3, 5) } });
2396
+ // Links and buttons take inflections only ("category link" -> "Categories"); the promiscuous phrases stay out.
2397
+ const inflect = (kind === "link" || kind === "button") && !NO_FUZZY_SYNONYMS.has(phrase) ? inflectRe(nameWords) : null;
2398
+ // (the contains guard, not the field one: "Direct link to System requirements" is not "system requirements link")
2399
+ if (inflect)
2400
+ rungs.push({ label: "stem", locator: poolFor(inflect), conf: CONF_STEM, pick: { ...pickBase, guard: guard(2, 4) } });
2401
+ // Last: the name is a sub-phrase of the intent ("purchase credits" -> "Purchase", "hacker news home link" -> "Hacker News").
2402
+ // The name must cover at least half of the intent's words, or "upvote button for the first story" becomes a guess at "upvote";
2403
+ // and an intent that names a structure ("pricing table") is not asking for a control named with the rest of its words.
2404
+ const superset = nameWords.some(w => STRUCTURE_WORDS.has(w)) ? null : supersetRe(nameWords);
2405
+ if (superset) {
2406
+ rungs.push({ label: "superset", locator: poolFor(superset), conf: { unique: 0.6, ordinal: 0.6, ambiguous: 0.4 },
2407
+ pick: { ...pickBase, guard: guard(2, 4), preferLonger: true, minNameWords: Math.ceil(nameWords.length / 2) } });
2408
+ }
2409
+ // One round trip before the rungs: an empty pool, or a pool none of whose elements carries the tokens a rung needs,
2410
+ // cannot match that rung. Rungs that match by CSS alone (typed fields, search submits, citations) always run.
2411
+ const pre = await root.locator(PREFILTER_POOL[kind]).evaluateAll(PREFILTER, prefilterNeed(q, syns, isContainerKind)).catch(() => null);
2412
+ if (pre && pre.count === 0)
2413
+ return null;
2414
+ const NEEDS_LITERAL = /^(exact|exact-link|contains|label-exact|label-contains|field-text|all-words|text-exact|attribute|stem)$/;
2415
+ const NEEDS_SYN = /^(exact-synonym|exact-synonym-link|contains-synonym)$/;
2416
+ const NEEDS_ANY = /^(provider|menu-trigger)$/;
2417
+ let sawCollapsedExact = false;
2418
+ for (const rung of rungs) {
2419
+ // the exact name exists only in a folded navigation: the visible equivalent elsewhere is a capped find,
2420
+ // but the weak rungs would be guesses
2421
+ if (sawCollapsedExact && /^(all-words|text-exact|attribute|stem|menu-trigger|superset)$/.test(rung.label))
2422
+ continue;
2423
+ if (pre) {
2424
+ if (NEEDS_LITERAL.test(rung.label) && !pre.literal)
2425
+ continue;
2426
+ if (NEEDS_SYN.test(rung.label) && !pre.syn)
2427
+ continue;
2428
+ if (NEEDS_ANY.test(rung.label) && !pre.any)
2429
+ continue;
2430
+ if (rung.label === "superset" && pre.cover < (rung.pick.minNameWords ?? 1))
2431
+ continue;
2432
+ }
2433
+ // after a collapsed exact, a contains hit must START with the phrase ("Billing help" for a folded Billing,
2434
+ // "Sign up for free" for a folded Sign up): "Cookie settings" is not the folded Settings link
2435
+ const pick = sawCollapsedExact && /^contains/.test(rung.label) && rung.pick.guard
2436
+ ? { ...rung.pick, guard: { ...rung.pick.guard, maxExtra: 0 } } : rung.pick;
2437
+ const r = await run(rung.label, rung.locator, pick);
2438
+ if (!r)
2439
+ continue;
2440
+ // "second remove button" with one visible Remove, "third approve button" with two "Approve request": the
2441
+ // ordinal is the answer's shape, and a looser rung ("Remove payment method", "Approve all requests") is not
2442
+ // the second or third of them. The first rung that had visible matches decides.
2443
+ if (r.ordinalOutOfRange)
2444
+ return null;
2445
+ if (r.chosen) {
2446
+ let conf = parsed.ordinal !== null ? rung.conf.ordinal : (r.ambiguous ? rung.conf.ambiguous : rung.conf.unique);
2447
+ if (sawCollapsedExact)
2448
+ conf = Math.min(conf, 0.7);
2449
+ if (q.cap !== undefined)
2450
+ conf = Math.min(conf, q.cap);
2451
+ // a fuzzy hit in a sidebar or breadcrumb nav, when the page has a primary one, is a guess
2452
+ if (navScoped && /^contains/.test(rung.label) && r.chosen.marked === false && (r.countVisible ?? 0) > 1)
2453
+ conf = Math.min(conf, 0.6);
2454
+ return build(r, rung.label, conf, rung.label === "exact" ? "Exact match" : `Match (${rung.label})`);
2455
+ }
2456
+ if (r.hiddenOnly && /^(exact|exact-synonym|exact-link|exact-synonym-link|label-exact)$/.test(rung.label)) {
2457
+ // hidden in a folded navigation: the page shows the same control elsewhere, keep looking (capped);
2458
+ // hidden on its own (display:none Save, opacity-0 row Remove or Remove link): it means null, a fuzzy visible
2459
+ // one is not it, whatever kind the hidden one was
2460
+ if (r.hiddenInCollapsedNav)
2461
+ sawCollapsedExact = true;
2462
+ else
2463
+ return null;
2464
+ }
2465
+ if (r.ambiguous && rung.pick.mode === "unique")
2466
+ break;
2467
+ }
2468
+ return null;
2469
+ };
2470
+ const q0 = { kind, phrase, phraseFull: parsed.phraseFull.trim(), words: nameWords, kindExplicit: parsed.kindExplicit };
2471
+ const first = await namedCascade(q0);
2472
+ if (first)
2473
+ return first;
2474
+ // "X in the navigation" with X only in a footer nav: a guess, allowed only while the page's own navigation is visible
2475
+ // (a header nav collapsed at this width means null, not the footer's copy).
2476
+ if (navScoped) {
2477
+ const primaryVisible = await page.locator(NAV_NOT_FOOTER_CSS).evaluateAll((els, primaryCss) => {
2478
+ const box = (x) => { const b = x.getBoundingClientRect(); return b.width >= 2 && b.height >= 2; };
2479
+ const vis = (x) => (typeof x.checkVisibility !== "function" || x.checkVisibility({ visibilityProperty: true })) && box(x);
2480
+ const navVisible = (e) => vis(e) && Array.from(e.querySelectorAll("a[href], [role='link'], [role='menuitem']")).slice(0, 50).some(vis);
2481
+ const primary = els.filter(e => { try {
2482
+ return e.matches(primaryCss);
2483
+ }
2484
+ catch {
2485
+ return false;
2486
+ } });
2487
+ // the page's own navigation is visible; a breadcrumb in main does not stand in for a collapsed header nav
2488
+ return primary.length ? primary.some(navVisible) : els.some(navVisible);
2489
+ }, PRIMARY_NAV_CSS).catch(() => false);
2490
+ if (primaryVisible) {
2491
+ const fb = await namedCascade({ ...q0, footerOk: true, cap: 0.6 });
2492
+ if (fb)
2493
+ return fb;
2494
+ }
643
2495
  }
644
- return null;
2496
+ // "pay with card": a trailing container word that named no container was part of the name all along
2497
+ if (/^(card|tile|panel|product|item|listing)$/.test(parsed.kindWord) && !parsed.special && nameWords.length) {
2498
+ const phrase2 = `${phrase} ${parsed.kindWord}`.trim();
2499
+ const full = parsed.phraseFull.trim();
2500
+ const again = await namedCascade({
2501
+ kind: "any", phrase: phrase2, phraseFull: full.endsWith(parsed.kindWord) ? full : `${full} ${parsed.kindWord}`,
2502
+ words: phrase2.split(/[^\p{L}\p{N}]+/u).filter(w => w && !STOP.has(w)), kindExplicit: false,
2503
+ });
2504
+ if (again)
2505
+ return again;
2506
+ }
2507
+ return options.verbose ? await verboseMiss(page, intent) : null;
2508
+ }
2509
+ /** The verbose "no match" shape: the first visible interactive elements, each with a unique selector. */
2510
+ async function verboseMissWith(page, intent, errors = []) {
2511
+ let alternatives = [];
2512
+ try {
2513
+ const arg = { ordinal: null, mode: "first", container: false, require: [], prefer: [], maxOthers: 12 };
2514
+ const r = await page.locator(KIND_POOL.any).evaluateAll(PICK, arg);
2515
+ const all = r.chosen ? [r.chosen, ...r.others] : r.others;
2516
+ const words = intent.toLowerCase().split(/\s+/).filter(w => w.length > 2);
2517
+ alternatives = all.filter(c => c.selector && (c.name || c.text)).map(c => {
2518
+ const blob = (c.name + " " + c.text).toLowerCase();
2519
+ const hits = words.filter(w => blob.includes(w)).length;
2520
+ return { selector: c.selector, text: (c.name || c.text).slice(0, 60), tag: c.tag, type: c.selectorType, confidence: Math.round((words.length ? hits / words.length * 0.6 : 0) * 100) / 100 };
2521
+ }).sort((a, b) => b.confidence - a.confidence).slice(0, 10);
2522
+ }
2523
+ catch { /* ignore */ }
2524
+ const list = alternatives.slice(0, 8).map(a => ` • ${a.tag}: "${a.text}" (${a.type}) → ${a.selector}`).join("\n");
2525
+ return {
2526
+ selector: "",
2527
+ confidence: 0,
2528
+ description: "No match found",
2529
+ alternatives,
2530
+ aiSuggestion: (errors.length
2531
+ ? `Note: ${errors.length} locator evaluation(s) failed during the search (${errors.join("; ")}); `
2532
+ + "the page may have navigated or closed, so this miss is not conclusive.\n\n"
2533
+ : "")
2534
+ + `No element matching "${intent}" found.\n\nAvailable interactive elements:\n${list}\n\nTry using the exact text, aria-label, or a more specific description.`,
2535
+ };
645
2536
  }
646
2537
  //# sourceMappingURL=natural-language.js.map