@reticlehq/browser 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/actions/actions-dom.js +41 -3
  2. package/dist/actions/actions.js +43 -11
  3. package/dist/commands/commands.js +6 -2
  4. package/dist/connect-options.d.ts +18 -1
  5. package/dist/dom/a11y.js +92 -9
  6. package/dist/dom/dom-ignore.d.ts +9 -0
  7. package/dist/dom/dom-ignore.js +32 -3
  8. package/dist/dom/query.js +168 -97
  9. package/dist/dom/refs.d.ts +11 -0
  10. package/dist/dom/refs.js +30 -1
  11. package/dist/dom/snapshot.d.ts +37 -0
  12. package/dist/dom/snapshot.js +141 -37
  13. package/dist/edit-epoch.d.ts +32 -0
  14. package/dist/edit-epoch.js +108 -0
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.js +5 -1
  17. package/dist/observers/animation.js +6 -8
  18. package/dist/observers/context-open.d.ts +12 -0
  19. package/dist/observers/context-open.js +25 -0
  20. package/dist/observers/focus.js +5 -6
  21. package/dist/observers/health.js +6 -9
  22. package/dist/observers/install-all.js +2 -0
  23. package/dist/observers/network-redact.d.ts +13 -0
  24. package/dist/observers/network-redact.js +12 -1
  25. package/dist/observers/network.d.ts +2 -2
  26. package/dist/observers/network.js +24 -17
  27. package/dist/observers/scroll.js +4 -2
  28. package/dist/observers/storage.js +1 -1
  29. package/dist/presenter/presenter-brand.d.ts +4 -18
  30. package/dist/presenter/presenter-brand.js +6 -18
  31. package/dist/presenter/presenter-config.d.ts +11 -8
  32. package/dist/presenter/presenter-config.js +9 -3
  33. package/dist/presenter/presenter-controls.d.ts +0 -3
  34. package/dist/presenter/presenter-controls.js +0 -3
  35. package/dist/presenter/presenter-hud-chrome.d.ts +1 -1
  36. package/dist/presenter/presenter-report-copy.d.ts +30 -1
  37. package/dist/presenter/presenter-report-copy.js +31 -2
  38. package/dist/presenter/presenter-report-styles.js +24 -0
  39. package/dist/presenter/presenter-report.d.ts +2 -2
  40. package/dist/presenter/presenter-report.js +95 -3
  41. package/dist/presenter/presenter-settings.js +10 -2
  42. package/dist/presenter/presenter-shell-styles.js +20 -1
  43. package/dist/presenter/presenter-shell.d.ts +0 -2
  44. package/dist/presenter/presenter-shell.js +1 -6
  45. package/dist/presenter/presenter.d.ts +13 -0
  46. package/dist/presenter/presenter.js +37 -2
  47. package/dist/registry/auto-testids.d.ts +21 -0
  48. package/dist/registry/auto-testids.js +48 -0
  49. package/dist/registry/capabilities.d.ts +28 -1
  50. package/dist/registry/capabilities.js +59 -7
  51. package/dist/registry/store-adapters.d.ts +1 -1
  52. package/dist/registry/store-adapters.js +6 -1
  53. package/dist/registry/stores.d.ts +4 -0
  54. package/dist/registry/stores.js +35 -0
  55. package/dist/reticle.d.ts +12 -0
  56. package/dist/reticle.js +40 -1
  57. package/dist/transport/unreachable-message.d.ts +7 -0
  58. package/dist/transport/unreachable-message.js +11 -1
  59. package/package.json +4 -7
  60. package/dist/dom/blind-spots.d.ts +0 -13
  61. package/dist/dom/blind-spots.js +0 -29
  62. package/dist/observers/subresources.d.ts +0 -10
  63. package/dist/observers/subresources.js +0 -63
package/dist/dom/query.js CHANGED
@@ -1,11 +1,10 @@
1
- import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, getDefaultNormalizer, } from '@testing-library/dom';
2
1
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
- import { isFrame, isHtmlElement } from './realm.js';
2
+ import { isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
4
3
  import { capturedRootOf } from './shadow-registry.js';
5
4
  import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
6
5
  import { isIgnored } from './dom-ignore.js';
7
6
  import { isSensitiveKey } from '../security/serialization.js';
8
- import { getCapabilities } from '../registry/capabilities.js';
7
+ import { declaredTestids } from '../registry/capabilities.js';
9
8
  import { identifyComponent } from '../registry/adapters.js';
10
9
  import { refs } from './refs.js';
11
10
  const TESTID_ATTR = 'data-testid';
@@ -15,6 +14,72 @@ const MAX_PRESENT_TESTIDS = 12;
15
14
  const MAX_COMPONENT_CANDIDATES = 2000;
16
15
  /** Likely-actionable elements considered when resolving a component anchor without a source stamp. */
17
16
  const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
17
+ /**
18
+ * Every candidate a semantic locator may match: the container ITSELF first, then its descendants.
19
+ *
20
+ * The container's own text, role or name was matched by every engine this replaces - a scoped
21
+ * `{ text: "Saved" }` against `<div id="status">Saved</div>` found #status itself - so dropping
22
+ * the root here would turn exactly those scoped queries into silent zero-match answers: the worst
23
+ * failure mode, indistinguishable from the element being absent. `self: true` stays the way to
24
+ * reach an UNLABELLED root (it skips the predicate entirely); this keeps a root that DOES satisfy
25
+ * the predicate findable without a second spelling.
26
+ */
27
+ function elementsUnder(container) {
28
+ // Embedded roots include ShadowRoots, which are DocumentFragments: they have no attributes or
29
+ // tag to match on, so only a true Element root joins the candidates.
30
+ const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
31
+ return [...self, ...Array.from(container.querySelectorAll('*'))];
32
+ }
33
+ /**
34
+ * Match user-visible prose the way the public query API has always promised: trim and collapse
35
+ * whitespace, compare canonically equivalent Unicode in NFC, and keep fuzzy text queries
36
+ * case-insensitive. Attribute identifiers such as testid stay exact and do not pass through here.
37
+ */
38
+ function normaliseVisibleText(value) {
39
+ return value.replace(/\s+/g, ' ').trim().normalize('NFC');
40
+ }
41
+ function fuzzyVisibleText(actual, expected) {
42
+ return normaliseVisibleText(actual)
43
+ .toLowerCase()
44
+ .includes(normaliseVisibleText(expected).toLowerCase());
45
+ }
46
+ function exactVisibleText(actual, expected) {
47
+ return normaliseVisibleText(actual) === normaliseVisibleText(expected);
48
+ }
49
+ function directText(el) {
50
+ if (isInput(el)) {
51
+ const type = el.type.toLowerCase();
52
+ if ('submit' === type || 'button' === type || 'reset' === type)
53
+ return el.value;
54
+ }
55
+ const tag = el.tagName.toLowerCase();
56
+ if ('script' === tag || 'style' === tag)
57
+ return '';
58
+ return Array.from(el.childNodes)
59
+ .filter((node) => Node.TEXT_NODE === node.nodeType)
60
+ .map((node) => node.textContent ?? '')
61
+ .join('');
62
+ }
63
+ /**
64
+ * Elements whose accessible name comes from author-supplied naming rather than free subtree
65
+ * content - the set `by: label` addresses.
66
+ *
67
+ * Form controls plus anything explicitly named with aria-label/aria-labelledby covers the common
68
+ * case. `button`, `meter`, `output` and `progress` are included because their names arrive on
69
+ * attributes (`value`) or from their caption, which is precisely what a label query is looking
70
+ * for; leaving them out would silently drop buttons from label searches.
71
+ */
72
+ function semanticNameTarget(el) {
73
+ if (isInput(el) || isTextArea(el) || isSelect(el))
74
+ return true;
75
+ const tag = el.tagName.toLowerCase();
76
+ return ('button' === tag ||
77
+ 'meter' === tag ||
78
+ 'output' === tag ||
79
+ 'progress' === tag ||
80
+ el.hasAttribute('aria-label') ||
81
+ el.hasAttribute('aria-labelledby'));
82
+ }
18
83
  /**
19
84
  * Resolve a scope to its container. A container of `null` means a scope was GIVEN but resolved to
20
85
  * nothing (unmounted, or a selector that matches no element) - the caller must NOT fall back to the
@@ -84,70 +149,37 @@ function findByComponent(container, query) {
84
149
  return [];
85
150
  }
86
151
  /**
87
- * Role + name, matched the way Reticle REPORTS them - not only the way Testing Library computes them.
88
- *
89
- * Two implementations were disagreeing, on both axes, for the same element:
90
- *
91
- * <input type="search" placeholder="Search User">
92
- * Reticle getRole -> "textbox" TL/dom-accessibility-api -> "searchbox"
93
- * Reticle getAccessibleName -> "Search User" TL -> "" (no placeholder)
94
- *
95
- * `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
96
- * TL's. So Reticle printed `textbox "Search User"` and then could not find it by that exact
97
- * role and name - the same query call reporting an identity it cannot match.
152
+ * Role + name, matched with the same local accessibility engine used to describe results.
98
153
  *
99
- * It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
100
- * graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
101
- * first replay in a different session. The reporter diagnosed it as the replay resolver computing
102
- * names differently; it is narrower and worse, because no caller could ever have closed the round
103
- * trip.
104
- *
105
- * The fallback runs ONLY when the spec-accurate query finds nothing, so every name that resolves
106
- * today keeps resolving exactly as before and this can never take a match away. Fixed on the
107
- * matching side rather than by changing what is reported: dropping `placeholder` would leave an
108
- * input whose only label is its placeholder nameless in every snapshot, and renaming the role would
109
- * churn every stored anchor - both worse trades than a fallback that runs on the empty path.
154
+ * This intentionally makes Reticle's reported role and name the source of truth. If an element is
155
+ * described as `textbox "Search User"`, the resolver must accept that exact pair, not a different
156
+ * role or name from a second library.
110
157
  */
111
158
  function queryByRoleAndName(container, role, name) {
112
- if (name === undefined)
113
- return queryAllByRole(container, role, { hidden: true });
114
- const spec = queryAllByRole(container, role, { hidden: true, name });
115
- if (spec.length > 0)
116
- return spec;
117
- const wanted = name.trim();
118
- // Reticle's own role AND name: the pair actually printed to the caller.
119
- return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
159
+ return elementsUnder(container).filter((el) => getRole(el) === role && (name === undefined || exactVisibleText(getAccessibleName(el), name)));
120
160
  }
121
- /**
122
- * The same text, written two legal ways, did not match itself.
123
- *
124
- * Unicode lets `café` be one code point (`é`) or two (`e` + a combining acute). They render
125
- * identically, they are canonically equivalent, and JavaScript string comparison says they differ —
126
- * so a query typed one way against a DOM holding the other found nothing. That is a false RED: the
127
- * agent asserts text that is visibly on screen, is told it is absent, and reports a working app as
128
- * broken.
129
- *
130
- * It cannot happen in English, which is why it survived here. It is ordinary in French, Vietnamese
131
- * and Korean, and the decomposed form is what macOS filesystems, several IMEs and some databases
132
- * hand back.
133
- *
134
- * Normalising both sides can only ADD matches: normalisation is idempotent and
135
- * canonical-equivalence-preserving, so two strings equal before are equal after and nothing that
136
- * matched can stop matching. That property is why this is safe to apply to the matching core.
137
- *
138
- * Applied only to queries over USER-VISIBLE text. A testid is an attribute the developer typed on
139
- * both sides and a component name is an identifier; normalising those would change what an exact
140
- * match means, for no case anyone has.
141
- */
142
- const toNfc = (value) => value.normalize('NFC');
143
- /**
144
- * Testing Library's own normaliser — whitespace trimming and collapsing — with NFC on top.
145
- *
146
- * Composed rather than replaced: writing a normaliser from scratch here would silently drop their
147
- * whitespace handling, which every existing match depends on.
148
- */
149
- const TEXT_NORMALIZER = { normalizer: (text) => toNfc(getDefaultNormalizer()(text)) };
150
- /** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
161
+ function queryByText(container, value) {
162
+ return elementsUnder(container).filter((el) => fuzzyVisibleText(directText(el), value));
163
+ }
164
+ function queryByLabel(container, value) {
165
+ return elementsUnder(container).filter((el) => semanticNameTarget(el) && fuzzyVisibleText(getAccessibleName(el), value));
166
+ }
167
+ function queryByPlaceholder(container, value) {
168
+ return elementsUnder(container).filter((el) => {
169
+ const placeholder = el.getAttribute('placeholder');
170
+ return placeholder !== null && fuzzyVisibleText(placeholder, value);
171
+ });
172
+ }
173
+ function queryByTestId(container, value) {
174
+ return elementsUnder(container).filter((el) => el.getAttribute(TESTID_ATTR) === value);
175
+ }
176
+ function queryByAlt(container, value) {
177
+ return elementsUnder(container).filter((el) => {
178
+ const alt = el.getAttribute('alt');
179
+ return alt !== null && fuzzyVisibleText(alt, value);
180
+ });
181
+ }
182
+ /** Run the appropriate local query against ONE root, light DOM or a shadow root. */
151
183
  function findIn(container, query) {
152
184
  const by = query.by;
153
185
  const value = query.value;
@@ -157,18 +189,15 @@ function findIn(container, query) {
157
189
  case QueryBy.ROLE:
158
190
  return queryByRoleAndName(container, value, query.name);
159
191
  case QueryBy.TEXT:
160
- return queryAllByText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
192
+ return queryByText(container, value);
161
193
  case QueryBy.LABEL:
162
- return queryAllByLabelText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
194
+ return queryByLabel(container, value);
163
195
  case QueryBy.PLACEHOLDER:
164
- return queryAllByPlaceholderText(container, toNfc(value), {
165
- exact: false,
166
- ...TEXT_NORMALIZER,
167
- });
196
+ return queryByPlaceholder(container, value);
168
197
  case QueryBy.TESTID:
169
- return queryAllByTestId(container, value, { exact: true });
198
+ return queryByTestId(container, value);
170
199
  case QueryBy.ALT:
171
- return queryAllByAltText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
200
+ return queryByAlt(container, value);
172
201
  case QueryBy.COMPONENT:
173
202
  // value is the component name;.source (if present) still takes precedence inside.
174
203
  return findByComponent(container, { ...query, component: query.component ?? value });
@@ -193,20 +222,15 @@ function findIn(container, query) {
193
222
  return queryByRoleAndName(container, query.role, query.name);
194
223
  }
195
224
  if (query.text !== undefined)
196
- return queryAllByText(container, toNfc(query.text), { exact: false, ...TEXT_NORMALIZER });
197
- if (query.label !== undefined) {
198
- return queryAllByLabelText(container, toNfc(query.label), { exact: false, ...TEXT_NORMALIZER });
199
- }
200
- if (query.placeholder !== undefined) {
201
- return queryAllByPlaceholderText(container, toNfc(query.placeholder), {
202
- exact: false,
203
- ...TEXT_NORMALIZER,
204
- });
205
- }
225
+ return queryByText(container, query.text);
226
+ if (query.label !== undefined)
227
+ return queryByLabel(container, query.label);
228
+ if (query.placeholder !== undefined)
229
+ return queryByPlaceholder(container, query.placeholder);
206
230
  if (query.testid !== undefined)
207
- return queryAllByTestId(container, query.testid, { exact: true });
231
+ return queryByTestId(container, query.testid);
208
232
  if (query.alt !== undefined)
209
- return queryAllByAltText(container, toNfc(query.alt), { exact: false, ...TEXT_NORMALIZER });
233
+ return queryByAlt(container, query.alt);
210
234
  return [];
211
235
  }
212
236
  /**
@@ -247,8 +271,8 @@ function embeddedRootsUnder(root) {
247
271
  for (const el of node.querySelectorAll('*')) {
248
272
  const shadow = el.shadowRoot ?? capturedRootOf(el);
249
273
  if (shadow !== null) {
250
- // A ShadowRoot is a DocumentFragment; the Testing-Library queries accept any HTMLElement-like
251
- // container, and every call site only reads from it.
274
+ // A ShadowRoot is a DocumentFragment. The local query helpers only call querySelectorAll on
275
+ // the container, and every call site only reads from it.
252
276
  found.push(shadow);
253
277
  walk(shadow, depth); // nested web components
254
278
  continue;
@@ -284,9 +308,10 @@ function embeddedRootsUnder(root) {
284
308
  /**
285
309
  * Run the query against the light DOM AND every open shadow root beneath the scope.
286
310
  *
287
- * Testing Library only walks the light DOM, so a control inside a web component returned zero matches
288
- * on a completely healthy page - a miss indistinguishable from a genuinely absent element. The
289
- * snapshot has always pierced open roots; this makes `query` agree with it.
311
+ * A single querySelectorAll call only walks the root it starts from, so a control inside a web
312
+ * component returned zero matches on a completely healthy page - a miss indistinguishable from a
313
+ * genuinely absent element. The snapshot has always pierced open roots; this makes `query` agree
314
+ * with it.
290
315
  */
291
316
  function findCandidates(query) {
292
317
  const { container, scopeMissing } = resolveContainer(query.scope);
@@ -449,13 +474,7 @@ function buildPresentRegions(query) {
449
474
  'tablist',
450
475
  ];
451
476
  for (const role of CONTAINER_ROLES) {
452
- let containers;
453
- try {
454
- containers = queryAllByRole(container, role, { hidden: true });
455
- }
456
- catch {
457
- continue;
458
- }
477
+ const containers = queryByRoleAndName(container, role, undefined);
459
478
  for (const el of containers) {
460
479
  const name = el.getAttribute('aria-label') ??
461
480
  resolveLabelledBy(el) ?? // aria-labelledby is an element ID - resolve it to the referenced TEXT
@@ -485,6 +504,47 @@ function buildPresentRegions(query) {
485
504
  }
486
505
  return regions;
487
506
  }
507
+ /**
508
+ * The tightest single element whose SUBTREE text carries `wanted`, when no element's OWN text does.
509
+ *
510
+ * `by: text` matches an element's direct text nodes (`directText`), so a label rendered across
511
+ * several child nodes — `v-html`, an inline `<span>` per word, a highlighted substring — exists on
512
+ * the page and matches nothing. The two channels disagree by construction: `act_and_wait` reports
513
+ * `appeared` by concatenating an added subtree, so the string an agent is most likely to copy into
514
+ * its next predicate is exactly the shape this query cannot find.
515
+ *
516
+ * That failure is indistinguishable from the element being absent, which is the expensive half: the
517
+ * agent reads "no element matched", concludes the app did not render, and reports a bug against
518
+ * correct code.
519
+ *
520
+ * Returns the DEEPEST container rather than the first, because every ancestor up to `<body>` also
521
+ * contains the string and naming `<body>` is not a locator. The deepest one is the tightest scope
522
+ * that still holds the whole string, which is the one worth pasting back.
523
+ */
524
+ function splitTextOwner(container, wanted) {
525
+ let best;
526
+ let bestDepth = -1;
527
+ for (const el of elementsUnder(container)) {
528
+ if (isIgnored(el))
529
+ continue; // never point an agent at Reticle's own UI
530
+ if (!fuzzyVisibleText(el.textContent ?? '', wanted))
531
+ continue;
532
+ let depth = 0;
533
+ for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
534
+ depth++;
535
+ if (depth > bestDepth) {
536
+ best = el;
537
+ bestDepth = depth;
538
+ }
539
+ }
540
+ return best;
541
+ }
542
+ /** The text this query searched for, in either spelling, or undefined when it searched by something else. */
543
+ function wantedTextOf(query) {
544
+ if (query.text !== undefined)
545
+ return query.text;
546
+ return QueryBy.TEXT === query.by ? query.value : undefined;
547
+ }
488
548
  /** Diagnostic hint for a zero-match query: what testids ARE present in the searched scope. */
489
549
  function buildEmptyHint(query) {
490
550
  const container = resolveContainer(query.scope).container ?? document.body;
@@ -500,15 +560,26 @@ function buildEmptyHint(query) {
500
560
  break;
501
561
  }
502
562
  }
503
- const registered = getCapabilities().testids;
563
+ // DECLARED, not observed: see declaredTestids. Every present testid is also an observed one, so
564
+ // the merged list would make this flag true for any page that has a testid at all.
565
+ const registered = declaredTestids();
504
566
  const knownEmptyState = present.some((id) => registered.includes(id));
505
567
  const route = `${location.pathname}${location.search}`;
506
- return {
568
+ const hint = {
507
569
  route,
508
570
  presentTestids: present,
509
571
  presentRegions: buildPresentRegions(query),
510
572
  knownEmptyState,
511
573
  };
574
+ // Only for a text search, and only when the string IS here — otherwise this is an ordinary miss
575
+ // and the extra clause would lengthen every failure to say nothing.
576
+ const wanted = wantedTextOf(query);
577
+ if (wanted !== undefined) {
578
+ const owner = splitTextOwner(container, wanted);
579
+ if (owner !== undefined)
580
+ hint.splitText = describe(owner);
581
+ }
582
+ return hint;
512
583
  }
513
584
  /**
514
585
  * Resolve a query to descriptors for the `query` MCP tool.
@@ -63,6 +63,17 @@ export declare class RefRegistry {
63
63
  constructor(storage?: () => Storage | undefined);
64
64
  /** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
65
65
  get size(): number;
66
+ /**
67
+ * A hot update just replaced modules in this document: every ref handed out so far was minted
68
+ * against the DOM the previous version of that code produced.
69
+ *
70
+ * Only the boundary is recorded. Nothing is evicted — a hot update is not a navigation, and most
71
+ * of the page survives one, so refs minted before it very often still resolve. This exists so the
72
+ * ones that DON'T can say why.
73
+ */
74
+ markEdited(): void;
75
+ /** Was this ref handed out before the last hot update? False while no update has been observed. */
76
+ mintedBeforeLastEdit(ref: string): boolean;
66
77
  /** Get the existing ref for an element, or mint a new one. */
67
78
  refFor(el: Element): Ref;
68
79
  /**
package/dist/dom/refs.js CHANGED
@@ -19,6 +19,8 @@ import { TRANSPORT_LIMITS, asRef } from '@reticlehq/core';
19
19
  export const MAX_TRACKED_REFS = 10000;
20
20
  /** Marks a ref that was too long to echo in full. */
21
21
  const REF_ELISION = '…';
22
+ /** Every ref Reticle mints is this letter followed by its sequence number. */
23
+ const REF_PREFIX = 'e';
22
24
  /**
23
25
  * A ref, made safe to interpolate into an error message.
24
26
  *
@@ -105,6 +107,15 @@ export class RefRegistry {
105
107
  /** The end of the block currently reserved. Minting past it claims the next one. */
106
108
  #reserved = 0;
107
109
  #mintsSinceSweep = 0;
110
+ /**
111
+ * The highest number handed out before the last hot update was applied, or 0 while none has been.
112
+ *
113
+ * A watermark rather than a per-ref stamp: the sequence never restarts within a document, so one
114
+ * number separates "minted before the code changed" from "minted after it" for every ref at once.
115
+ * The alternative — an epoch on every map entry — is bookkeeping paid on every mint for an answer
116
+ * needed only on the rare refusal.
117
+ */
118
+ #editedAtSeq = 0;
108
119
  /**
109
120
  * Storage is injected rather than read from the global for the same reason the clock is: it is an
110
121
  * ambient dependency that decides behaviour, and the cross-document property is untestable if the
@@ -156,6 +167,24 @@ export class RefRegistry {
156
167
  while (this.#fromRef.size > MAX_TRACKED_REFS)
157
168
  this.#evictOldest();
158
169
  }
170
+ /**
171
+ * A hot update just replaced modules in this document: every ref handed out so far was minted
172
+ * against the DOM the previous version of that code produced.
173
+ *
174
+ * Only the boundary is recorded. Nothing is evicted — a hot update is not a navigation, and most
175
+ * of the page survives one, so refs minted before it very often still resolve. This exists so the
176
+ * ones that DON'T can say why.
177
+ */
178
+ markEdited() {
179
+ this.#editedAtSeq = this.#seq;
180
+ }
181
+ /** Was this ref handed out before the last hot update? False while no update has been observed. */
182
+ mintedBeforeLastEdit(ref) {
183
+ if (0 === this.#editedAtSeq || !ref.startsWith(REF_PREFIX))
184
+ return false;
185
+ const n = Number(ref.slice(REF_PREFIX.length));
186
+ return Number.isSafeInteger(n) && n > 0 && n <= this.#editedAtSeq;
187
+ }
159
188
  /** Get the existing ref for an element, or mint a new one. */
160
189
  refFor(el) {
161
190
  const existing = this.#toRef.get(el);
@@ -173,7 +202,7 @@ export class RefRegistry {
173
202
  if (this.#seq >= this.#reserved)
174
203
  this.#reserve();
175
204
  this.#seq += 1;
176
- const ref = asRef(`e${String(this.#seq)}`);
205
+ const ref = asRef(`${REF_PREFIX}${String(this.#seq)}`);
177
206
  this.#toRef.set(el, ref);
178
207
  this.#fromRef.set(ref, new WeakRef(el));
179
208
  // Keep bounded cheaply on every mint; run the full dead-entry sweep only periodically.
@@ -21,13 +21,50 @@ export interface SnapshotResult {
21
21
  * must not receive the entire page as if it were the modal.
22
22
  */
23
23
  scopeMissing?: boolean;
24
+ /**
25
+ * How many MEANINGFUL nodes the lean walk passed over, present only in a lean mode and only when
26
+ * non-zero. This is what stops an empty `interactive` tree reading as an empty page — the mode is
27
+ * a role filter, and a production app whose controls carry no roles yields nothing while being
28
+ * full of things to drive. It shipped untyped: the value was spread into the result and every
29
+ * consumer had to reach for it without a type, including the tool that builds the note from it.
30
+ */
31
+ leanSkipped?: number;
32
+ /**
33
+ * Refs of the subtree roots the walk never entered, present ONLY when `truncated`. This is the
34
+ * cut's own frontier: re-snapshot each with `{ scope: ref, includeRoot: true }` and the union is
35
+ * the whole tree. Without it `truncated` says only THAT the read stopped, never WHERE, so nobody
36
+ * could finish it — and a reader who does not finish it must not conclude anything is ABSENT.
37
+ */
38
+ unread?: string[];
39
+ /**
40
+ * True when the frontier itself did not fit in `MAX_UNREAD_BRANCHES`, so `unread` is a prefix of
41
+ * the branches that were cut. Completion is then impossible from this read, and the difference
42
+ * between "impossible" and "not attempted" is the whole point of saying so.
43
+ */
44
+ unreadOverflow?: true;
24
45
  }
25
46
  interface SnapshotOptions {
26
47
  scope?: string | undefined;
27
48
  mode?: SnapshotMode | undefined;
28
49
  maxNodes?: number | undefined;
29
50
  maxDepth?: number | undefined;
51
+ /**
52
+ * Emit the scope element's OWN line before its descendants. Off by default (a scoped snapshot has
53
+ * always been the subtree UNDER the scope); the completion re-read needs it, because the branch it
54
+ * is re-reading is exactly the node the truncated walk stopped before emitting.
55
+ */
56
+ includeRoot?: boolean | undefined;
30
57
  }
58
+ /**
59
+ * Cap on how many cut branches one snapshot names.
60
+ *
61
+ * A hostile or merely huge DOM can cut thousands of siblings at once (a non-virtualized grid does it
62
+ * routinely), and a frontier that large is neither affordable to send nor useful to re-read one at a
63
+ * time. Fifty is well above what a real page's cut produces — the frontier is the branches at the
64
+ * node cap, not the nodes themselves — and small enough that the list stays a hint rather than a
65
+ * second snapshot. Past it the read declares `unreadOverflow` instead of quietly sending fewer.
66
+ */
67
+ export declare const MAX_UNREAD_BRANCHES = 50;
31
68
  /** Build the semantic accessibility snapshot of the page or a subtree. */
32
69
  export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
33
70
  export {};