@enricai/barnacle 1.10.1 → 1.12.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.
@@ -23,6 +23,7 @@ exports.findRecentBackendError = findRecentBackendError;
23
23
  exports.findRecentPageTransition = findRecentPageTransition;
24
24
  exports.isWizardExitAction = isWizardExitAction;
25
25
  exports.isAdvanceStep = isAdvanceStep;
26
+ exports.shouldCaptureSelectionState = shouldCaptureSelectionState;
26
27
  exports.shouldWarnMissingAdvancePattern = shouldWarnMissingAdvancePattern;
27
28
  exports.parseCaptureIndex = parseCaptureIndex;
28
29
  exports.latestCaptureIndex = latestCaptureIndex;
@@ -32,9 +33,7 @@ exports.windowHasAdvanceTransition = windowHasAdvanceTransition;
32
33
  exports.shouldVetoFallbackAdvance = shouldVetoFallbackAdvance;
33
34
  exports.isDomOnlyAdvanceVerified = isDomOnlyAdvanceVerified;
34
35
  exports.isClickViewSwapVerified = isClickViewSwapVerified;
35
- exports.isClickStateToggleVerified = isClickStateToggleVerified;
36
36
  exports.selectionCountFromSignature = selectionCountFromSignature;
37
- exports.isSelectionCounterStalled = isSelectionCounterStalled;
38
37
  exports.shouldReadbackFillOnActSuccess = shouldReadbackFillOnActSuccess;
39
38
  exports.countNgInvalidContainers = countNgInvalidContainers;
40
39
  exports.describeAttemptEffectSignals = describeAttemptEffectSignals;
@@ -526,8 +525,15 @@ const DOM_SNAPSHOT_EXPR = `(() => { const b = document.body; if (!b) return { ht
526
525
  * `url()` rejects (OOPIF detached by the submit it just fired) can still
527
526
  * report the main frame's post-navigation URL instead of throwing the
528
527
  * whole attempt out of `executeStepWithHealing`.
528
+ *
529
+ * `captureSelectionState` (default false) additionally builds the per-element
530
+ * `selectionStateByXpath` baseline for the element-scoped click verifier. The
531
+ * caller sets it only for the pre/post pair of a selection/field-answer click
532
+ * step — never for submit/advance steps (so a self-toggling submit/Next button
533
+ * can't false-credit) nor for the mid-probe/retry snapshots — which also keeps
534
+ * this second full-DOM evaluate off the common (non-selection) path.
529
535
  */
530
- async function snapshotPage(target, signalCounter, page) {
536
+ async function snapshotPage(target, signalCounter, page, captureSelectionState = false) {
531
537
  let bodyHtmlLength = 0;
532
538
  let visibleTextSignature = "";
533
539
  let formValueSignature = "";
@@ -552,6 +558,25 @@ async function snapshotPage(target, signalCounter, page) {
552
558
  // Snapshot is observational; on failure, defaults to 0/"" so the verifier
553
559
  // sees no delta. Real state-class checks already cover the verified path.
554
560
  }
561
+ // Per-element selection baseline for the element-scoped click verifier
562
+ // (see StepSnapshot.selectionStateByXpath). Captured ONLY when the caller
563
+ // asks (`captureSelectionState`) — the pre/post pair of a selection/field-
564
+ // answer click step. Skipping it for submit/advance steps is both the perf
565
+ // guard (this is a second full-DOM evaluate on a hot function) AND the
566
+ // correctness gate: with no baseline, verifyDomEffect's element read-back
567
+ // finds nothing to diff and defers to network/URL, so a submit/advance
568
+ // button's own self-toggle can never false-credit the step. Its own
569
+ // try/catch so a failure here can't lose the coarse signals above; an empty
570
+ // map leaves the verifier deferring to network/URL, never a false credit.
571
+ let selectionStateByXpath = {};
572
+ if (captureSelectionState) {
573
+ try {
574
+ selectionStateByXpath = asSelectionStateMap(await target.evaluate(SELECTION_STATE_MAP_EXPR));
575
+ }
576
+ catch {
577
+ // observational — empty baseline on failure
578
+ }
579
+ }
555
580
  // A resolved child FrameTarget's url() reads location.href off the CDP
556
581
  // frame session (frame-target.ts's childFrameTarget), which rejects (or
557
582
  // trips its watchdog) once the OOPIF detaches — most commonly right after
@@ -566,6 +591,7 @@ async function snapshotPage(target, signalCounter, page) {
566
591
  visibleTextSignature,
567
592
  formValueSignature,
568
593
  selectionStateSignature,
594
+ selectionStateByXpath,
569
595
  };
570
596
  }
571
597
  /**
@@ -737,6 +763,22 @@ function isAdvanceStep(instruction) {
737
763
  const haystack = instruction.toLowerCase();
738
764
  return ADVANCE_STEP_PHRASES.some((p) => haystack.includes(p));
739
765
  }
766
+ /**
767
+ * Whether `snapshotPage` should build the per-element selection baseline
768
+ * (`StepSnapshot.selectionStateByXpath`) for this step — i.e. whether
769
+ * `verifyDomEffect`'s element-scoped click read-back is allowed to credit it.
770
+ * True ONLY for a field-answer/selection step: a submit, a final, or an advance
771
+ * step must be verified by a real network/URL transition, so its own
772
+ * self-toggling button (a submit flipping to a loading/pressed class, a "Next"
773
+ * flipping `aria-pressed`) must never earn an element-scoped credit — matching
774
+ * the `!submit`/`!final`/`!advance` exclusions the former
775
+ * `isClickStateToggleVerified` gate enforced. Pure + exported so the gate the
776
+ * cascade depends on is unit-testable, not buried in `executeStepWithHealing`.
777
+ */
778
+ function shouldCaptureSelectionState(params) {
779
+ const { step, isFinalStep, submitStep } = params;
780
+ return !(isFinalStep || submitStep || isAdvanceStep(step));
781
+ }
740
782
  /**
741
783
  * Whether a flow should be WARNed that its DOM-only advance guard is disarmed.
742
784
  * `isDomOnlyAdvanceVerified` only vetoes a DOM-only "advance" when
@@ -1009,38 +1051,6 @@ function isClickViewSwapVerified(params) {
1009
1051
  return true;
1010
1052
  return textChanged && bytesDelta >= VIEW_SWAP_REVEAL_MIN_BYTES;
1011
1053
  }
1012
- /**
1013
- * Credits a network-free click when it flipped a client-side SELECTION state —
1014
- * an `aria-pressed`/`aria-checked`/`aria-selected` attribute, a `data-state`,
1015
- * or a `selected`/`active`/`checked` class on the toggled control (captured by
1016
- * `StepSnapshot.selectionStateSignature`). React/SPA multi-select wizards toggle
1017
- * options this way with a trivial or NEGATIVE byte delta and no
1018
- * network/URL/innerText/input-value change, so `isClickViewSwapVerified` and
1019
- * the byte-floor phantom check both miss them and score the real selection a
1020
- * phantom no-op. This is the size-independent counterpart to that gate. Scope
1021
- * guards: only a plain `click`, never a final/submit step (those keep their
1022
- * stronger submit-judge gate), and only when zero network fired (a network POST
1023
- * is authoritative). **Stricter than `isClickViewSwapVerified` on advance
1024
- * steps:** it excludes ANY advance/"Next" step (`isAdvance`), not just an
1025
- * advance-WITH-pattern step. The selection signal fires on a validation-render
1026
- * that toggles a control's own state, and an advance without a configured
1027
- * `advanceTransitionBodyPattern` has no real-transition veto
1028
- * (see `shouldWarnMissingAdvancePattern`) — so crediting a bare selection change
1029
- * would desync the wizard step pointer. A real advance still verifies via
1030
- * network/URL; a field-toggle answer step (not an advance) keeps this credit.
1031
- */
1032
- function isClickStateToggleVerified(params) {
1033
- const { resolvedAction, isFinalStep, submitStep, isAdvance, networkDelta, selectionStateChanged, } = params;
1034
- if (resolvedAction?.method !== "click")
1035
- return false;
1036
- if (isFinalStep || submitStep)
1037
- return false;
1038
- if (isAdvance)
1039
- return false;
1040
- if (networkDelta !== 0)
1041
- return false;
1042
- return selectionStateChanged;
1043
- }
1044
1054
  /**
1045
1055
  * Reads the "N settings/items selected" running count that multi-select wizard
1046
1056
  * steps render as a live heading above the option grid. Returns the integer, or
@@ -1049,11 +1059,12 @@ function isClickStateToggleVerified(params) {
1049
1059
  * `"<len>:<first-200-chars-of-innerText>"` — the counter idiom sits at the top
1050
1060
  * of these steps, so it is always inside the 200-char window.
1051
1061
  *
1052
- * Exists because some SPA multi-selects (obfuscated hashed classes, no
1053
- * `aria-pressed`/`data-state`) expose no selection-state attribute at all, so
1054
- * {@link isClickStateToggleVerified}'s fingerprint is structurally blind and a
1055
- * phantom option click rides the generic DOM-delta gate to a FALSE credit — the
1056
- * running counter is the only trustworthy "did the selection register" signal.
1062
+ * Used by the deep-locator candidate walk to disambiguate WHICH of several
1063
+ * option candidates registered a selection: after clicking a candidate, the
1064
+ * running counter rising confirms that candidate was the real option (a
1065
+ * candidate-selection signal, distinct from the click verifier which now
1066
+ * reads the resolved element's own committed state directly via
1067
+ * {@link verifyDomEffect}).
1057
1068
  */
1058
1069
  function selectionCountFromSignature(visibleTextSignature) {
1059
1070
  const colon = visibleTextSignature.indexOf(":");
@@ -1062,27 +1073,6 @@ function selectionCountFromSignature(visibleTextSignature) {
1062
1073
  const digits = match?.[1];
1063
1074
  return digits === undefined ? null : Number.parseInt(digits, 10);
1064
1075
  }
1065
- /**
1066
- * Veto for a SELECTION-intent step whose weak DOM signals (view-swap byte delta,
1067
- * a non-advance `domVerified` reflow) would otherwise credit a click that did
1068
- * NOT register the selection. When the step's snapshot exposes a running
1069
- * "N selected" counter (see {@link selectionCountFromSignature}) and that count
1070
- * did not increase across the click, the option was not actually selected — a
1071
- * phantom the fingerprint can't catch — so the caller must NOT treat the weak
1072
- * signals as verification and should keep healing. Returns `true` only when a
1073
- * counter is present on BOTH snapshots and did not rise; absent a counter it
1074
- * returns `false` (no veto), so counter-less selection widgets are untouched.
1075
- */
1076
- function isSelectionCounterStalled(params) {
1077
- const { isSelectionStep, preVisibleTextSignature, postVisibleTextSignature } = params;
1078
- if (!isSelectionStep)
1079
- return false;
1080
- const pre = selectionCountFromSignature(preVisibleTextSignature);
1081
- const post = selectionCountFromSignature(postVisibleTextSignature);
1082
- if (pre === null || post === null)
1083
- return false;
1084
- return post <= pre;
1085
- }
1086
1076
  /**
1087
1077
  * Whether an act-success step warrants a committed-value read-back before the
1088
1078
  * weak view-swap/form-value signals are allowed to accept it. A controlled
@@ -2454,13 +2444,12 @@ async function fillTextDatepickerInput(target, selector, value) {
2454
2444
  // Month/year-picker variant: click the target month cell (0-based month
2455
2445
  // index maps to the __month-N class) — THIS is the commit. The year/month
2456
2446
  // dropdowns are set first only so the calendar shows the right year's month
2457
- // page. NOTE: "range-select" is a SITE-SPECIFIC class (the ATS site's own
2458
- // CSS), not a react-datepicker class; the bare "select" fallback covers
2459
- // other wrappers.
2447
+ // page. The bare "select" query covers every wrapper generically (site CSS
2448
+ // classes are never named here).
2460
2449
  // Stock react-datepicker renders its month/year dropdowns as clickable divs,
2461
2450
  // so on those the select loop is inert and the month-cell click carries it.
2462
2451
  if (monthCells.length > 0) {
2463
- const selects = cal.querySelectorAll("select.range-select, select");
2452
+ const selects = cal.querySelectorAll("select");
2464
2453
  for (const sel of selects) {
2465
2454
  const opts = Array.from(sel.options).map((o) => (o.value || "").trim());
2466
2455
  const yearOpt = opts.indexOf(String(year));
@@ -2660,6 +2649,239 @@ function xpathBodyForEvaluate(selector) {
2660
2649
  const stripped = selector.startsWith("xpath=") ? selector.slice("xpath=".length) : selector;
2661
2650
  return stripped.startsWith("/") || stripped.startsWith("(") ? stripped : null;
2662
2651
  }
2652
+ /**
2653
+ * In-page source for the absolute positional xpath walk, shared by every
2654
+ * expression that must produce keys byte-identical to Stagehand's resolved
2655
+ * `xpath=/html[1]/…` selectors AND to each other: the baseline map
2656
+ * ({@link SELECTION_STATE_MAP_EXPR}) and the ancestor read-back
2657
+ * ({@link selectionAncestorChanged}). Kept as one constant so a future edit
2658
+ * can't drift one copy — a divergent walk would silently produce keys that miss
2659
+ * the baseline, making the ancestor read-back a no-op. Hardcoded
2660
+ * `/html[1]/body[1]/` prefix, `previousElementSibling` index,
2661
+ * `nodeName.toLowerCase()`, stop at `document.body`.
2662
+ */
2663
+ const XPATH_OF_FN_SRC = `(node) => {
2664
+ const parts = [];
2665
+ while (node && node.nodeType === 1 && node !== document.body) {
2666
+ const tag = node.nodeName.toLowerCase();
2667
+ let idx = 1;
2668
+ let sib = node.previousElementSibling;
2669
+ while (sib) {
2670
+ if (sib.nodeName.toLowerCase() === tag) idx++;
2671
+ sib = sib.previousElementSibling;
2672
+ }
2673
+ parts.unshift(tag + "[" + idx + "]");
2674
+ node = node.parentElement;
2675
+ }
2676
+ return "/html[1]/body[1]/" + parts.join("/");
2677
+ }`;
2678
+ /**
2679
+ * In-page source for one element's {@link ElementSelectionFingerprint} object
2680
+ * literal, given the name of the element variable (`elVar`) and a variable
2681
+ * (`dsVar`) already bound to `elVar.getAttribute("data-state") || ""`. Shared by
2682
+ * {@link elementSelectionFingerprintExpr}, {@link SELECTION_STATE_MAP_EXPR}, and
2683
+ * {@link selectionAncestorChanged} so all three compute the SAME fingerprint —
2684
+ * a field drifting in one copy would make the pre/post diff compare mismatched
2685
+ * shapes. The `data-state` disclosure values `open`/`closed` (the
2686
+ * `aria-expanded` equivalent) are blanked by the caller's `dsVar` so opening a
2687
+ * popover isn't mistaken for a selection.
2688
+ */
2689
+ function selectionFingerprintObjSrc(elVar, dsVar) {
2690
+ return `{ kind: ${elVar}.getAttribute("kind") || "", cls: ${elVar}.getAttribute("class") || "", ariaPressed: ${elVar}.getAttribute("aria-pressed") || "", ariaChecked: ${elVar}.getAttribute("aria-checked") || "", ariaSelected: ${elVar}.getAttribute("aria-selected") || "", dataState: (${dsVar} === "open" || ${dsVar} === "closed") ? "" : ${dsVar}, dataSelected: ${elVar}.hasAttribute("data-selected") ? "1" : "", dataChecked: ${elVar}.hasAttribute("data-checked") ? "1" : "", checked: (${elVar}.type === "checkbox" || ${elVar}.type === "radio") ? (${elVar}.checked ? "1" : "0") : "", value: typeof ${elVar}.value === "string" ? ${elVar}.value.slice(0, 200) : "" }`;
2691
+ }
2692
+ /**
2693
+ * Browser-side expression body that resolves `xpath` and returns that one
2694
+ * element's {@link ElementSelectionFingerprint} (or `null` when the node is
2695
+ * absent). Trust boundary: `xpath` is a Stagehand-resolved selector, and
2696
+ * `JSON.stringify` produces a safe JS string literal, so composing it cannot
2697
+ * inject behavior. The `data-state` disclosure values `open`/`closed` (the
2698
+ * `aria-expanded` equivalent) are blanked so opening a popover isn't mistaken
2699
+ * for a selection.
2700
+ */
2701
+ function elementSelectionFingerprintExpr(xpath) {
2702
+ return `(() => { const r = document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); const el = r.singleNodeValue; if (!el) return null; const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; })()`;
2703
+ }
2704
+ /**
2705
+ * Browser-side expression: build `{ absolutePositionalXpath →
2706
+ * ElementSelectionFingerprint }` for every VISIBLE interactive element, the
2707
+ * pre-action baseline {@link verifyDomEffect} diffs the resolved element
2708
+ * against. Trust boundary: composed only from the {@link XPATH_OF_FN_SRC} and
2709
+ * {@link selectionFingerprintObjSrc} constants (no external interpolation). The
2710
+ * xpath is generated by the shared {@link XPATH_OF_FN_SRC} walk — the SAME
2711
+ * source {@link selectionAncestorChanged} recomputes ancestor keys with, so the
2712
+ * two byte-match each other and Stagehand's resolved `xpath=/html[1]/…`
2713
+ * selectors. `[role=dialog],[role=tooltip],[aria-live]` subtrees are
2714
+ * skipped — they churn without a selection change. Visibility uses the same
2715
+ * rect + `getComputedStyle` idiom as `deep-locator-scan.ts`'s `IS_VISIBLE_EXPR`
2716
+ * (rather than `offsetParent`), so on-screen `position:fixed` controls — a
2717
+ * sticky Next/Submit bar, whose `offsetParent` is `null` — still enter the map.
2718
+ */
2719
+ const SELECTION_STATE_MAP_EXPR = `(() => {
2720
+ const b = document.body;
2721
+ if (!b) return {};
2722
+ const xpathOf = ${XPATH_OF_FN_SRC};
2723
+ const visible = (el) => {
2724
+ const rect = el.getBoundingClientRect();
2725
+ if (rect.width === 0 && rect.height === 0) return false;
2726
+ const style = getComputedStyle(el);
2727
+ return style.display !== "none" && style.visibility !== "hidden";
2728
+ };
2729
+ const sel = "button,[role=button],a,[tabindex],input,select,textarea,[role=option],[role=tab],[role=switch],[role=checkbox],[role=menuitemcheckbox]";
2730
+ const skip = (el) => el.closest("[role=dialog],[role=tooltip],[aria-live]") !== null;
2731
+ const out = {};
2732
+ for (const el of b.querySelectorAll(sel)) {
2733
+ if (!visible(el) || skip(el)) continue;
2734
+ const ds = el.getAttribute("data-state") || "";
2735
+ out[xpathOf(el)] = ${selectionFingerprintObjSrc("el", "ds")};
2736
+ }
2737
+ return out;
2738
+ })()`;
2739
+ /**
2740
+ * Narrows an unknown `evaluate` result into the
2741
+ * {@link StepSnapshot.selectionStateByXpath} map, discarding any malformed
2742
+ * entry. Defaults to `{}` so a snapshot failure leaves the verifier with an
2743
+ * empty baseline (defer-to-network), never a throw.
2744
+ */
2745
+ function asSelectionStateMap(raw) {
2746
+ if (raw === null || typeof raw !== "object")
2747
+ return {};
2748
+ const out = {};
2749
+ for (const [xpath, value] of Object.entries(raw)) {
2750
+ const fp = asSelectionFingerprint(value);
2751
+ if (fp)
2752
+ out[xpath] = fp;
2753
+ }
2754
+ return out;
2755
+ }
2756
+ /** True when two {@link ElementSelectionFingerprint}s differ in any tracked field. */
2757
+ function selectionFingerprintChanged(pre, post) {
2758
+ return (pre.kind !== post.kind ||
2759
+ pre.cls !== post.cls ||
2760
+ pre.ariaPressed !== post.ariaPressed ||
2761
+ pre.ariaChecked !== post.ariaChecked ||
2762
+ pre.ariaSelected !== post.ariaSelected ||
2763
+ pre.dataState !== post.dataState ||
2764
+ pre.dataSelected !== post.dataSelected ||
2765
+ pre.dataChecked !== post.dataChecked ||
2766
+ pre.checked !== post.checked ||
2767
+ pre.value !== post.value);
2768
+ }
2769
+ /**
2770
+ * Narrows an unknown `evaluate` result to an {@link ElementSelectionFingerprint}.
2771
+ * Every field is emitted as a string by {@link elementSelectionFingerprintExpr},
2772
+ * so a present object with a string `kind` is a sufficient shape check.
2773
+ */
2774
+ function asSelectionFingerprint(raw) {
2775
+ if (raw === null || typeof raw !== "object")
2776
+ return null;
2777
+ const r = raw;
2778
+ if (typeof r.kind !== "string")
2779
+ return null;
2780
+ return {
2781
+ kind: r.kind,
2782
+ cls: typeof r.cls === "string" ? r.cls : "",
2783
+ ariaPressed: typeof r.ariaPressed === "string" ? r.ariaPressed : "",
2784
+ ariaChecked: typeof r.ariaChecked === "string" ? r.ariaChecked : "",
2785
+ ariaSelected: typeof r.ariaSelected === "string" ? r.ariaSelected : "",
2786
+ dataState: typeof r.dataState === "string" ? r.dataState : "",
2787
+ dataSelected: typeof r.dataSelected === "string" ? r.dataSelected : "",
2788
+ dataChecked: typeof r.dataChecked === "string" ? r.dataChecked : "",
2789
+ checked: typeof r.checked === "string" ? r.checked : "",
2790
+ value: typeof r.value === "string" ? r.value : "",
2791
+ };
2792
+ }
2793
+ /**
2794
+ * Reads one element's {@link ElementSelectionFingerprint} off `target` by
2795
+ * xpath, or `null` when the selector isn't an xpath / the node is absent / the
2796
+ * evaluate throws. Used by the click branch of {@link verifyDomEffect} to
2797
+ * compare the resolved element's committed selection state against the
2798
+ * pre-action baseline captured in {@link StepSnapshot.selectionStateByXpath}.
2799
+ */
2800
+ async function readElementSelectionFingerprint(target, selector) {
2801
+ const xpath = xpathBodyForEvaluate(selector);
2802
+ if (!xpath)
2803
+ return null;
2804
+ try {
2805
+ return asSelectionFingerprint(await target.evaluate(elementSelectionFingerprintExpr(xpath)));
2806
+ }
2807
+ catch {
2808
+ return null;
2809
+ }
2810
+ }
2811
+ /**
2812
+ * How far up from the clicked leaf {@link selectionAncestorChanged} walks
2813
+ * looking for the option/toggle that carries the selection. Design-system
2814
+ * options nest their label 1-2 levels deep (a `<span title>` inside a
2815
+ * `role="option"`, plus the odd icon/wrapper); 6 matches the vacuous-click
2816
+ * ancestor guard in {@link verifyDomEffect}'s click branch and covers that
2817
+ * nesting without over-reaching into an outer listbox/group.
2818
+ */
2819
+ const MAX_SELECTION_ANCESTOR_DEPTH = 6;
2820
+ /**
2821
+ * Element-scoped selection read-back for the case the clicked node's OWN
2822
+ * fingerprint can't credit: a design-system option that wraps its label in a
2823
+ * child element (Base Web `tag`, and the standard listbox/combobox idiom where
2824
+ * an option's accessible name comes from its descendant content). Stagehand
2825
+ * resolves the click to the label leaf, but `aria-selected` / the hashed class
2826
+ * flips on the ancestor `role="option"` — so the leaf has no baseline entry and
2827
+ * never changes state, and the leaf-only read-back mis-scores a genuine
2828
+ * selection as a phantom click.
2829
+ *
2830
+ * Walks from the leaf up to {@link MAX_SELECTION_ANCESTOR_DEPTH}, and on the
2831
+ * NEAREST ancestor that (a) carries a selection marker (a non-empty fingerprint
2832
+ * field, a selection `role`, or a `data-baseweb` attribute — `aria-expanded` is
2833
+ * deliberately NOT a marker so a bare disclosure/expander is skipped) AND (b) is
2834
+ * present in the pre-baseline map, diffs that ancestor's current fingerprint
2835
+ * against its baseline. Nearest-wins and returns even when unchanged, so a
2836
+ * re-click of an already-selected option (or a decoy marker on an outer group)
2837
+ * can never be laundered into a credit. Authoritative and element-scoped: only
2838
+ * baseline-keyed ancestors are consulted, so an unrelated element's change can
2839
+ * never credit the step. Recomputes ancestor xpaths with the SAME
2840
+ * {@link XPATH_OF_FN_SRC} walk that built the baseline so the keys byte-match.
2841
+ * Returns `false` on any miss / malformed result / evaluate throw (defer to the
2842
+ * network/URL signal).
2843
+ */
2844
+ async function selectionAncestorChanged(target, leafXpath, preSelectionState) {
2845
+ const expr = `(() => {
2846
+ const LEAF = ${JSON.stringify(leafXpath)};
2847
+ const BASE = ${JSON.stringify(preSelectionState)};
2848
+ const xpathOf = ${XPATH_OF_FN_SRC};
2849
+ const fp = (el) => { const ds = el.getAttribute("data-state") || ""; return ${selectionFingerprintObjSrc("el", "ds")}; };
2850
+ const SELECTION_ROLES = new Set(["option", "tab", "switch", "radio", "checkbox", "menuitemcheckbox"]);
2851
+ const hasMarker = (el, f) => {
2852
+ if (f.kind || f.ariaPressed || f.ariaChecked || f.ariaSelected || f.dataState || f.dataSelected || f.dataChecked || f.checked) return true;
2853
+ if (SELECTION_ROLES.has((el.getAttribute("role") || "").toLowerCase())) return true;
2854
+ if (el.hasAttribute("data-baseweb")) return true;
2855
+ return false;
2856
+ };
2857
+ const changed = (a, b) =>
2858
+ a.kind !== b.kind || a.cls !== b.cls || a.ariaPressed !== b.ariaPressed ||
2859
+ a.ariaChecked !== b.ariaChecked || a.ariaSelected !== b.ariaSelected ||
2860
+ a.dataState !== b.dataState || a.dataSelected !== b.dataSelected ||
2861
+ a.dataChecked !== b.dataChecked || a.checked !== b.checked || a.value !== b.value;
2862
+ const r = document.evaluate(LEAF, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
2863
+ let node = r.singleNodeValue;
2864
+ if (!node) return false;
2865
+ for (let depth = 0; depth < ${MAX_SELECTION_ANCESTOR_DEPTH} && node; depth++) {
2866
+ if (node.getAttribute) {
2867
+ const now = fp(node);
2868
+ if (hasMarker(node, now)) {
2869
+ const pre = BASE[xpathOf(node)];
2870
+ if (!pre) return false;
2871
+ return changed(pre, now);
2872
+ }
2873
+ }
2874
+ node = node.parentElement;
2875
+ }
2876
+ return false;
2877
+ })()`;
2878
+ try {
2879
+ return (await target.evaluate(expr)) === true;
2880
+ }
2881
+ catch {
2882
+ return false;
2883
+ }
2884
+ }
2663
2885
  /** How long the upload primitive waits for a post-setInputFiles network POST. */
2664
2886
  const UPLOAD_NETWORK_TIMEOUT_MS = 5_000;
2665
2887
  /** Polling interval while waiting for the upload's network signal. */
@@ -4572,8 +4794,16 @@ async function dispatchJqueryChangeEvent(target, selector) {
4572
4794
  *
4573
4795
  * `target` scopes both the locator/evaluate reads and the jQuery-change
4574
4796
  * dispatch to the resolved frame (main or a cross-origin child).
4797
+ *
4798
+ * `preSelectionState` is the pre-action per-element baseline
4799
+ * ({@link StepSnapshot.selectionStateByXpath}) the click branch diffs the
4800
+ * resolved element against — the element-scoped authoritative "did this
4801
+ * selection register" signal for design-system option/toggle buttons that
4802
+ * expose no native `checked` (Base Web `kind`, hashed styletron class, ARIA).
4803
+ * Defaults to `{}` so callers/tests that don't supply it keep the prior
4804
+ * radio/checkbox-and-network behavior.
4575
4805
  */
4576
- async function verifyDomEffect(target, action) {
4806
+ async function verifyDomEffect(target, action, preSelectionState = {}) {
4577
4807
  const selector = action.selector;
4578
4808
  const method = action.method;
4579
4809
  if (!selector || !method)
@@ -4717,9 +4947,15 @@ async function verifyDomEffect(target, action) {
4717
4947
  return true;
4718
4948
  case "click": {
4719
4949
  // Clicks on radios and checkboxes toggle `:checked` without firing a
4720
- // network request — same false-fail class as fill. For every other
4721
- // click (buttons, links, custom toggles) return false so the verifier
4722
- // falls back to the network/URL signal, which is the right signal there.
4950
+ // network request — same false-fail class as fill. Every OTHER click
4951
+ // (design-system option/toggle buttons, links, custom controls) is
4952
+ // verified element-scoped: compare the RESOLVED element's own committed
4953
+ // selection state before vs. after the click. A Base Web option flips
4954
+ // its `kind` (`tertiary`→`primary`) + a hashed styletron class with no
4955
+ // network, no URL change, and a trivial/negative byte delta — the
4956
+ // authoritative signal is that the clicked element's own fingerprint
4957
+ // moved, which this reads directly instead of guessing from page-wide
4958
+ // DOM deltas.
4723
4959
  const xpath = xpathBody(selector);
4724
4960
  if (!xpath)
4725
4961
  return false;
@@ -4740,8 +4976,26 @@ async function verifyDomEffect(target, action) {
4740
4976
  return false;
4741
4977
  }
4742
4978
  if (inputType !== "radio" && inputType !== "checkbox") {
4743
- // Real button/link click let network/URL signal decide.
4744
- return false;
4979
+ // Element-scoped selection read-back. The pre-map is keyed by the
4980
+ // same absolute positional xpath Stagehand resolves to, so a lookup
4981
+ // by the resolved element's xpath body yields THAT element's baseline.
4982
+ // Credit the click iff the element's own committed state moved across
4983
+ // it. A selection change on any OTHER element can never credit this
4984
+ // step.
4985
+ const preFingerprint = preSelectionState[xpath];
4986
+ if (preFingerprint) {
4987
+ const postFingerprint = await readElementSelectionFingerprint(target, selector);
4988
+ if (postFingerprint && selectionFingerprintChanged(preFingerprint, postFingerprint)) {
4989
+ return true;
4990
+ }
4991
+ }
4992
+ // The clicked node's own state didn't credit it (no baseline for the
4993
+ // leaf, or the leaf carries no selection state). A design-system
4994
+ // option that wraps its label in a child element commits its
4995
+ // selection on the ancestor `role="option"`, not the clicked leaf —
4996
+ // so walk up to the nearest baseline-present selection ancestor and
4997
+ // diff THAT. No eligible ancestor → false (defer to network/URL).
4998
+ return await selectionAncestorChanged(target, xpath, preSelectionState);
4745
4999
  }
4746
5000
  const isCheckedNow = await locator.isChecked();
4747
5001
  if (!isCheckedNow)
@@ -5281,6 +5535,13 @@ async function executeStepWithHealing(params) {
5281
5535
  // (verified 2026-06-15 on one measured tenant's telemetry). Site-agnostic: any flow
5282
5536
  // whose submit is mid-list can mark its submit step explicitly.
5283
5537
  const requireSubmitEndpoint = (isFinalStep || submitStep) && submitEndpointPattern !== null;
5538
+ // Capture the per-element selection baseline (for verifyDomEffect's element-
5539
+ // scoped click read-back) ONLY for selection/field-answer click steps — never
5540
+ // for submit/advance steps, whose own self-toggling buttons must not
5541
+ // false-credit the step, and whose advance/submit verdicts require a real
5542
+ // network/URL transition. Also keeps the extra full-DOM evaluate off the
5543
+ // submit/advance path. Step-level intent (available before the attempt loop).
5544
+ const captureSelectionState = shouldCaptureSelectionState({ step, isFinalStep, submitStep });
5284
5545
  const attempts = [];
5285
5546
  const triedSelectors = [];
5286
5547
  const failureReasons = [];
@@ -5538,6 +5799,7 @@ async function executeStepWithHealing(params) {
5538
5799
  visibleTextSignature: "",
5539
5800
  selectionStateSignature: "",
5540
5801
  formValueSignature: "",
5802
+ selectionStateByXpath: {},
5541
5803
  };
5542
5804
  attempts.push({
5543
5805
  attempt: 0,
@@ -5719,7 +5981,7 @@ async function executeStepWithHealing(params) {
5719
5981
  if (attempt > 1) {
5720
5982
  await page.waitForTimeout(attempt * ATTEMPT_BACKOFF_MS);
5721
5983
  }
5722
- const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page);
5984
+ const pre = await snapshotPage(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), signalCounter, page, captureSelectionState);
5723
5985
  // Snapshot the meta-tail length so the final-step pattern gate can scope
5724
5986
  // its URL scan to captures added DURING this attempt (not historical
5725
5987
  // tail from earlier steps).
@@ -6576,11 +6838,14 @@ async function executeStepWithHealing(params) {
6576
6838
  // State-class actions (fill/check/etc.) never move the network counter or URL,
6577
6839
  // so the legacy heuristic false-negatived every form fill. Re-read DOM state
6578
6840
  // for those; keep the navigation signal authoritative for clicks/links —
6579
- // but ALSO route clicks through verifyDomEffect, which internally returns
6580
- // false for non-radio/non-checkbox clicks so the network/URL signal still
6581
- // decides those. Radios/checkboxes are click-but-no-network just like fills.
6841
+ // but ALSO route clicks through verifyDomEffect, which authoritatively
6842
+ // credits a radio/checkbox toggle OR (via the pre/post element fingerprint
6843
+ // baseline `pre.selectionStateByXpath`) a design-system option/toggle whose
6844
+ // own committed state changed; otherwise it returns false and the
6845
+ // network/URL signal decides. Radios/checkboxes are click-but-no-network
6846
+ // just like fills.
6582
6847
  const domVerified = resolvedAction !== null && (isStateClass || isClick)
6583
- ? await verifyDomEffect(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction)
6848
+ ? await verifyDomEffect(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction, pre.selectionStateByXpath)
6584
6849
  : false;
6585
6850
  // Interior-advance transition gate (opt-in). On SPAs where a page advance
6586
6851
  // and a mere field-edit share one endpoint URL (the wizard ATS's `/gq`:
@@ -6654,20 +6919,15 @@ async function executeStepWithHealing(params) {
6654
6919
  bytesDelta: post.bodyHtmlLength - pre.bodyHtmlLength,
6655
6920
  textChanged: post.visibleTextSignature !== pre.visibleTextSignature,
6656
6921
  });
6657
- // Client-side state-toggle gate: credit a network-free click that flipped a
6658
- // selection-state signature (aria-pressed/checked/selected, data-state, or a
6659
- // selected/active/checked class) regardless of byte size. Fixes React/SPA
6660
- // multi-select wizards whose option buttons toggle client state with a
6661
- // trivial or NEGATIVE byte delta which clickViewSwapVerified's byte floor
6662
- // and the phantom byte-check both miss.
6663
- const clickStateToggleVerified = isClickStateToggleVerified({
6664
- resolvedAction,
6665
- isFinalStep,
6666
- submitStep,
6667
- isAdvance: isAdvanceStep(step),
6668
- networkDelta: post.networkCount - pre.networkCount,
6669
- selectionStateChanged: post.selectionStateSignature !== pre.selectionStateSignature,
6670
- });
6922
+ // A network-free selection/option toggle including a Base Web `kind`
6923
+ // flip or hashed-class swap that exposes no aria/data-state marker and
6924
+ // moves a trivial or NEGATIVE byte delta is now credited authoritatively
6925
+ // and element-scoped by `verifyDomEffect` (via the pre/post per-element
6926
+ // fingerprint baseline), flowing through `domVerified`. The former
6927
+ // page-wide `clickStateToggleVerified` guess and its `selectionCounterStalled`
6928
+ // veto (which only existed to suppress that guess's false positives) are
6929
+ // removed: there is nothing to guess or veto when the resolved element's own
6930
+ // committed state is read directly.
6671
6931
  // Form-value-diff signal: `visibleTextSignature` never reflects a plain
6672
6932
  // <input>/<textarea>/<select>'s `value` property, so a fill with no
6673
6933
  // secondary UI side effect (no toggle, no formatted display) had ZERO
@@ -6731,33 +6991,11 @@ async function executeStepWithHealing(params) {
6731
6991
  }
6732
6992
  }
6733
6993
  }
6734
- // Selection-counter veto. A multi-select option click whose widget exposes
6735
- // no aria/data-state marker (obfuscated hashed classes) leaves
6736
- // `clickStateToggleVerified` blind, so a phantom that merely reflowed the
6737
- // DOM would ride the weak `domVerifiedForStep`/`clickViewSwapVerified`
6738
- // signals to a FALSE credit — the flow then advances to a "Next" that
6739
- // no-ops because nothing was actually selected. When the step's running
6740
- // "N selected" counter did not rise, suppress those weak DOM-delta signals
6741
- // so the cascade keeps trying a real selection. Strong signals (real
6742
- // network/URL transition) and the counter-independent state-toggle /
6743
- // form-value signals — each of which IS the selection registering — are
6744
- // never vetoed; counter-less widgets are untouched (the helper no-ops).
6745
- const selectionCounterStalled = isSelectionCounterStalled({
6746
- isSelectionStep: parseSelectStep(step) !== null,
6747
- preVisibleTextSignature: pre.visibleTextSignature,
6748
- postVisibleTextSignature: post.visibleTextSignature,
6749
- });
6750
- if (selectionCounterStalled && (domVerifiedForStep || clickViewSwapVerified)) {
6751
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} selection step credited only via DOM reflow but the "N selected" counter did not rise — the option did not register; not treating the weak DOM signal as verified`);
6752
- }
6753
- const domVerifiedAfterCounter = selectionCounterStalled ? false : domVerifiedForStep;
6754
- const clickViewSwapAfterCounter = selectionCounterStalled ? false : clickViewSwapVerified;
6755
6994
  let verified = networkIsRealAdvance ||
6756
6995
  urlChanged ||
6757
- domVerifiedAfterCounter ||
6996
+ domVerifiedForStep ||
6758
6997
  datepickerCommitted ||
6759
- (!datepickerRejected &&
6760
- (clickViewSwapAfterCounter || formValueVerified || clickStateToggleVerified));
6998
+ (!datepickerRejected && (clickViewSwapVerified || formValueVerified));
6761
6999
  // Final-step submit-verification gate. Replaces the deterministic
6762
7000
  // submitEndpointPattern regex with a Haiku 4.5 LLM judgment over
6763
7001
  // multi-signal evidence (network captures, page URL/title, DOM
@@ -6884,9 +7122,7 @@ async function executeStepWithHealing(params) {
6884
7122
  ? "dom"
6885
7123
  : formValueVerified
6886
7124
  ? "form-value"
6887
- : clickStateToggleVerified
6888
- ? "state-toggle"
6889
- : "dom";
7125
+ : "dom";
6890
7126
  }
6891
7127
  // N+16 probe: Stagehand's CDP click sometimes lands on the button without
6892
7128
  // triggering React's SyntheticEvent layer (or jQuery delegated handlers).
@@ -6974,13 +7210,36 @@ async function executeStepWithHealing(params) {
6974
7210
  const retryHtmlDelta = retryPost.bodyHtmlLength - pre.bodyHtmlLength;
6975
7211
  const retryTextChanged = retryPost.visibleTextSignature !== pre.visibleTextSignature;
6976
7212
  const retryFormValueChanged = retryPost.formValueSignature !== pre.formValueSignature;
6977
- // Excludes advance/"Next" steps for the same reason the primary
6978
- // isClickStateToggleVerified does: a validation re-render can flip a
6979
- // control's selection state without moving the wizard, and an advance
6980
- // without a configured transition pattern has no real-transition veto,
6981
- // so crediting a bare selection change here would desync the step pointer.
7213
+ // Element-scoped selection read-back for the n+16 fallback the same
7214
+ // authoritative signal the primary verifier uses via `verifyDomEffect`,
7215
+ // applied to the element this fallback just re-clicked. Credits only
7216
+ // when the RESOLVED element's own committed state moved across the
7217
+ // fallback click (Base Web `kind`/class, ARIA, native checked), read
7218
+ // against the pre-action baseline. Excludes advance/"Next" steps: an
7219
+ // advance without a configured transition pattern has no real-transition
7220
+ // veto, so crediting a bare selection change would desync the step
7221
+ // pointer. No page-wide signature, no counter veto — nothing to guess
7222
+ // or suppress when the element's own state is read directly.
6982
7223
  const retrySelectionStateChanged = !isAdvanceStep(step) &&
6983
- retryPost.selectionStateSignature !== pre.selectionStateSignature;
7224
+ (await (async () => {
7225
+ if (!xpath || !resolvedAction?.selector)
7226
+ return false;
7227
+ const preFingerprint = pre.selectionStateByXpath[xpath];
7228
+ if (preFingerprint) {
7229
+ const postFingerprint = await readElementSelectionFingerprint(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), resolvedAction.selector);
7230
+ if (postFingerprint !== null &&
7231
+ selectionFingerprintChanged(preFingerprint, postFingerprint)) {
7232
+ return true;
7233
+ }
7234
+ }
7235
+ // Leaf had no baseline entry (or carried no selection state): a
7236
+ // design-system option that wraps its label commits its selection
7237
+ // on the ancestor `role="option"`, not the clicked leaf — walk up
7238
+ // to the nearest baseline-present selection ancestor and diff THAT,
7239
+ // the same fallback `verifyDomEffect`'s primary read-back uses. No
7240
+ // eligible ancestor → false (defer to the other retry signals).
7241
+ return await selectionAncestorChanged(frameTarget ?? (0, frame_target_1.mainFrameTarget)(page), xpath, pre.selectionStateByXpath);
7242
+ })());
6984
7243
  // RC2: an advance/`kind=click` "Next" that only grew the DOM
6985
7244
  // (validation errors rendered) with NO network/URL change is a
6986
7245
  // validation-blocked no-op, not a real transition — but the
@@ -7031,41 +7290,25 @@ async function executeStepWithHealing(params) {
7031
7290
  logger.info(`${formatStepPrefix(stepIndex, totalSteps)} n+16 fallback advanced but no real transition (non-advancing POST / field toggle); not treating as verified`);
7032
7291
  }
7033
7292
  // Weak DOM-only signals (an html-byte delta, a visible-text change, a
7034
- // form-value change, a selection-state flip) are NOT sufficient to
7293
+ // form-value change) are NOT sufficient to
7035
7294
  // verify a final/submit step on their own: a validation re-render
7036
7295
  // produces exactly these without the submit landing. The primary
7037
- // verifier is safe here because clickViewSwapVerified/formValueVerified/
7038
- // clickStateToggleVerified all self-exclude final/submit; the n+16
7296
+ // verifier is safe here because clickViewSwapVerified/formValueVerified
7297
+ // self-exclude final/submit; the n+16
7039
7298
  // fallback ORs the raw deltas, so gate them explicitly. On a
7040
7299
  // final/submit step they only count when the submit-endpoint judge will
7041
7300
  // actually run below (requireSubmitEndpoint) to corroborate — otherwise
7042
7301
  // only a strong signal (network/url) or a verified checkbox state may
7043
7302
  // pass. Non-final/submit steps are unaffected.
7044
7303
  const weakDomSignalsAllowed = (!isFinalStep && !submitStep) || requireSubmitEndpoint;
7045
- // Selection-counter veto for the n+16 fallback — the same gate the
7046
- // primary verifier applies, recomputed against `retryPost`. Without
7047
- // it this path would re-credit a stalled selection the primary veto
7048
- // already suppressed: the fallback `el.click()` can reflow the DOM
7049
- // (html/text/selection-state delta) without registering the option,
7050
- // and those weak deltas are exactly what the OR below admits for a
7051
- // non-final selection step. Only the weak-delta disjunct is vetoed;
7052
- // strong signals (network/url) and a verified checkbox state pass.
7053
- const retrySelectionCounterStalled = isSelectionCounterStalled({
7054
- isSelectionStep: parseSelectStep(step) !== null,
7055
- preVisibleTextSignature: pre.visibleTextSignature,
7056
- postVisibleTextSignature: retryPost.visibleTextSignature,
7057
- });
7058
7304
  let retryVerified = !clickBlockedByInvalid &&
7059
7305
  !fallbackDomOnlyAdvance &&
7060
7306
  (retryNetworkFired ||
7061
7307
  retryUrlChanged ||
7062
7308
  checkboxStateVerified ||
7309
+ retrySelectionStateChanged ||
7063
7310
  (weakDomSignalsAllowed &&
7064
- !retrySelectionCounterStalled &&
7065
- (retryHtmlDelta !== 0 ||
7066
- retryTextChanged ||
7067
- retryFormValueChanged ||
7068
- retrySelectionStateChanged)));
7311
+ (retryHtmlDelta !== 0 || retryTextChanged || retryFormValueChanged)));
7069
7312
  // Apply the same submit-endpoint gate the primary verifier uses.
7070
7313
  // Without this, the n+16 fallback would still ride past a
7071
7314
  // tracking-pixel-only click on the final step. Same Haiku LLM
@@ -7141,17 +7384,7 @@ async function executeStepWithHealing(params) {
7141
7384
  logger.info(`n+16 probe: step=${stepIndex + 1}/${totalSteps?.() ?? "?"} attempt=${attempt} el.click() fallback fired=${fired === true} kind=${probeResult.kind ?? "none"} checkboxStateVerified=${checkboxStateVerified} ancestorStillInvalid=${ancestorStillInvalid}; network=${retryNetworkFired} url=${retryUrlChanged} htmlDelta=${retryHtmlDelta} textChanged=${retryTextChanged} formValueChanged=${retryFormValueChanged} selectionStateChanged=${retrySelectionStateChanged} verified=${retryVerified}`);
7142
7385
  if (retryVerified) {
7143
7386
  if (record.verifiedBy === null) {
7144
- record.verifiedBy = retryUrlChanged
7145
- ? "url"
7146
- : retryNetworkFired
7147
- ? "network"
7148
- : retryHtmlDelta === 0 &&
7149
- !retryTextChanged &&
7150
- !retryFormValueChanged &&
7151
- !checkboxStateVerified &&
7152
- retrySelectionStateChanged
7153
- ? "state-toggle"
7154
- : "dom";
7387
+ record.verifiedBy = retryUrlChanged ? "url" : retryNetworkFired ? "network" : "dom";
7155
7388
  }
7156
7389
  record.post = retryPost;
7157
7390
  attempts.push(record);
@@ -7173,7 +7406,7 @@ async function executeStepWithHealing(params) {
7173
7406
  attempts.push(record);
7174
7407
  if (verified) {
7175
7408
  if (attempt > 1) {
7176
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} stateToggle=${clickStateToggleVerified} verifiedBy=${record.verifiedBy})`);
7409
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} healed on attempt ${attempt} via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} verifiedBy=${record.verifiedBy})`);
7177
7410
  }
7178
7411
  else {
7179
7412
  // Why log first-try wins explicitly: prior to this change, attempt-1
@@ -7183,7 +7416,7 @@ async function executeStepWithHealing(params) {
7183
7416
  // collapse" (log showed 2 heals) but telemetry calls.ndjson showed
7184
7417
  // 32 successful Stagehand acts. Surfacing attempt-1 wins lets the
7185
7418
  // log match telemetry and prevents the same false alarm.
7186
- logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} stateToggle=${clickStateToggleVerified} verifiedBy=${record.verifiedBy})`);
7419
+ logger.info(`${formatStepPrefix(stepIndex, totalSteps)} succeeded on attempt 1 via ${record.technique} (network=${networkFired} url=${urlChanged} dom=${domVerified} verifiedBy=${record.verifiedBy})`);
7187
7420
  }
7188
7421
  trajectory?.push({ stepIndex, verifiedBy: record.verifiedBy });
7189
7422
  return "completed";
@@ -7198,6 +7431,11 @@ async function executeStepWithHealing(params) {
7198
7431
  actResultSuccess: record.actResultSuccess,
7199
7432
  pre,
7200
7433
  post,
7434
+ // Authoritative element-scoped signal: `verifyDomEffect` read the resolved
7435
+ // element's own committed-state delta (Base Web `kind`/class, ARIA, native
7436
+ // checked) into `domVerified`. A registered selection toggle no longer
7437
+ // reads as a phantom just because it moved no network/URL/bytes.
7438
+ elementStateChanged: domVerified,
7201
7439
  isSubmitShapedStep: isFinalStep || submitStep,
7202
7440
  });
7203
7441
  const reason = record.errorMessage