@reticlehq/browser 2.10.0 → 2.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.
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,42 @@ 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
+ * Refs of the subtree roots the walk never entered, present ONLY when `truncated`. This is the
26
+ * cut's own frontier: re-snapshot each with `{ scope: ref, includeRoot: true }` and the union is
27
+ * the whole tree. Without it `truncated` says only THAT the read stopped, never WHERE, so nobody
28
+ * could finish it — and a reader who does not finish it must not conclude anything is ABSENT.
29
+ */
30
+ unread?: string[];
31
+ /**
32
+ * True when the frontier itself did not fit in `MAX_UNREAD_BRANCHES`, so `unread` is a prefix of
33
+ * the branches that were cut. Completion is then impossible from this read, and the difference
34
+ * between "impossible" and "not attempted" is the whole point of saying so.
35
+ */
36
+ unreadOverflow?: true;
24
37
  }
25
38
  interface SnapshotOptions {
26
39
  scope?: string | undefined;
27
40
  mode?: SnapshotMode | undefined;
28
41
  maxNodes?: number | undefined;
29
42
  maxDepth?: number | undefined;
43
+ /**
44
+ * Emit the scope element's OWN line before its descendants. Off by default (a scoped snapshot has
45
+ * always been the subtree UNDER the scope); the completion re-read needs it, because the branch it
46
+ * is re-reading is exactly the node the truncated walk stopped before emitting.
47
+ */
48
+ includeRoot?: boolean | undefined;
30
49
  }
50
+ /**
51
+ * Cap on how many cut branches one snapshot names.
52
+ *
53
+ * A hostile or merely huge DOM can cut thousands of siblings at once (a non-virtualized grid does it
54
+ * routinely), and a frontier that large is neither affordable to send nor useful to re-read one at a
55
+ * time. Fifty is well above what a real page's cut produces — the frontier is the branches at the
56
+ * node cap, not the nodes themselves — and small enough that the list stays a hint rather than a
57
+ * second snapshot. Past it the read declares `unreadOverflow` instead of quietly sending fewer.
58
+ */
59
+ export declare const MAX_UNREAD_BRANCHES = 50;
31
60
  /** Build the semantic accessibility snapshot of the page or a subtree. */
32
61
  export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
33
62
  export {};
@@ -55,6 +55,16 @@ function directText(el) {
55
55
  const collapsed = out.replace(/\s+/g, ' ').trim();
56
56
  return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
57
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 const MAX_UNREAD_BRANCHES = 50;
58
68
  /** Cheap skips that need no computed style (tags, overlays, aria-hidden, [hidden]). */
59
69
  function skipEarly(el) {
60
70
  if (SKIP_TAGS.has(el.tagName.toLowerCase()))
@@ -105,6 +115,16 @@ function layoutSignature(style) {
105
115
  }
106
116
  return '';
107
117
  }
118
+ /** Record a cut branch on the frontier, declaring an overflow rather than shortening it silently. */
119
+ function recordUnread(branches, ctx) {
120
+ for (const branch of branches) {
121
+ if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
122
+ ctx.unreadOverflow = true;
123
+ return;
124
+ }
125
+ ctx.unread.push(refs.refFor(branch));
126
+ }
127
+ }
108
128
  /**
109
129
  * The children to walk under `parent`, piercing boundaries a plain `.children` misses: an OPEN shadow
110
130
  * root renders as part of the element (web components / design systems), and a SAME-ORIGIN iframe's
@@ -134,46 +154,57 @@ function pierceChildren(parent) {
134
154
  }
135
155
  return out;
136
156
  }
157
+ /** Emit one element (if it earns a line) and descend into it. Split out of `walk` so the completion
158
+ * re-read can start AT a branch root — the node the truncated walk stopped just before emitting. */
159
+ function visit(child, depth, ctx, inLive) {
160
+ if (skipEarly(child))
161
+ return;
162
+ // Resolve computed style ONCE per node (the dominant snapshot cost) and thread it into both the
163
+ // display-none skip and the layout signature — was two forced style resolutions per node.
164
+ const view = child.ownerDocument.defaultView;
165
+ const style = view !== null ? view.getComputedStyle(child) : null;
166
+ if (style !== null && 'none' === style.display)
167
+ return;
168
+ const role = getRole(child);
169
+ const name = getAccessibleName(child);
170
+ const interactive = INTERACTIVE.has(role);
171
+ // Announcements are exempt from leanness, and so is everything inside one: a live region whose
172
+ // message sits in a child element would otherwise be included as a contentless `- generic`.
173
+ const announce = inLive || announces(child, role);
174
+ const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
175
+ // A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
176
+ // so the actionable-only view stays lean while FULL/meaningful views see content regressions.
177
+ const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
178
+ // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
179
+ const layout = lean ? '' : layoutSignature(style);
180
+ const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
181
+ const include = lean ? interactive : meaningful;
182
+ if (include) {
183
+ ctx.nodes += 1;
184
+ ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
185
+ ? formatTextLine(depth, text)
186
+ : formatLine(child, depth, role, name, layout));
187
+ walk(child, depth + 1, ctx, announce);
188
+ }
189
+ else {
190
+ walk(child, depth, ctx, announce);
191
+ }
192
+ }
137
193
  function walk(parent, depth, ctx, inLive = false) {
138
194
  if (depth > ctx.maxDepth)
139
195
  return;
140
- for (const child of pierceChildren(parent)) {
196
+ const children = pierceChildren(parent);
197
+ for (let index = 0; index < children.length; index += 1) {
141
198
  if (ctx.nodes >= ctx.maxNodes) {
142
199
  ctx.truncated = true;
200
+ // Everything from here on is unread, not absent. Naming the frontier is what makes the cut
201
+ // recoverable — see `unread` on SnapshotResult.
202
+ recordUnread(children.slice(index), ctx);
143
203
  return;
144
204
  }
145
- if (skipEarly(child))
146
- continue;
147
- // Resolve computed style ONCE per node (the dominant snapshot cost) and thread it into both the
148
- // display-none skip and the layout signature — was two forced style resolutions per node.
149
- const view = child.ownerDocument.defaultView;
150
- const style = view !== null ? view.getComputedStyle(child) : null;
151
- if (style !== null && 'none' === style.display)
152
- continue;
153
- const role = getRole(child);
154
- const name = getAccessibleName(child);
155
- const interactive = INTERACTIVE.has(role);
156
- // Announcements are exempt from leanness, and so is everything inside one: a live region whose
157
- // message sits in a child element would otherwise be included as a contentless `- generic`.
158
- const announce = inLive || announces(child, role);
159
- const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
160
- // A generic, unnamed container's own text content — only consulted outside INTERACTIVE mode,
161
- // so the actionable-only view stays lean while FULL/meaningful views see content regressions.
162
- const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
163
- // Layout signature for grid/flex containers — makes CLS/layout regressions visible.
164
- const layout = lean ? '' : layoutSignature(style);
165
- const meaningful = interactive || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
166
- const include = lean ? interactive : meaningful;
167
- if (include) {
168
- ctx.nodes += 1;
169
- ctx.lines.push(text.length > 0 && 0 === name.length && 0 === layout.length
170
- ? formatTextLine(depth, text)
171
- : formatLine(child, depth, role, name, layout));
172
- walk(child, depth + 1, ctx, announce);
173
- }
174
- else {
175
- walk(child, depth, ctx, announce);
176
- }
205
+ const child = children[index];
206
+ if (child !== undefined)
207
+ visit(child, depth, ctx, inLive);
177
208
  }
178
209
  }
179
210
  function collectDialogs(root) {
@@ -259,12 +290,19 @@ export function buildSnapshot(options = {}) {
259
290
  mode,
260
291
  maxNodes: options.maxNodes ?? 400,
261
292
  maxDepth: options.maxDepth ?? 20,
293
+ unread: [],
294
+ unreadOverflow: false,
262
295
  };
263
- walk(root, 0, ctx);
296
+ if (true === options.includeRoot)
297
+ visit(root, 0, ctx, false);
298
+ else
299
+ walk(root, 0, ctx);
264
300
  return {
265
301
  tree: ctx.lines.join('\n'),
266
302
  status,
267
303
  nodes: ctx.nodes,
268
304
  truncated: ctx.truncated,
305
+ ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
306
+ ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
269
307
  };
270
308
  }
@@ -0,0 +1,32 @@
1
+ import { type RefRegistry } from './dom/refs.js';
2
+ export declare class EditEpoch {
3
+ #private;
4
+ /** The registry is injected for the same reason the clock is: the interesting property is the
5
+ * RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
6
+ constructor(registry: RefRegistry);
7
+ /**
8
+ * How many hot updates this document has applied.
9
+ *
10
+ * `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
11
+ * have no channel that could report one, so absence here is UNKNOWN.
12
+ */
13
+ get current(): number;
14
+ /** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
15
+ * no channel is the normal case, not an error. */
16
+ observe(hot: unknown): void;
17
+ /** Record that an update was applied, naming the files it changed (which may be none). */
18
+ applied(files: readonly string[]): void;
19
+ /**
20
+ * The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
21
+ *
22
+ * The generic wording is untouched and stays at the front: it is already tuned, and the server's
23
+ * recovery table matches on it. When the ref predates the last hot update the diagnosis is
24
+ * appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
25
+ * stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
26
+ * blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
27
+ * worse than the vague right one.
28
+ */
29
+ staleRefMessage(ref: string): string;
30
+ }
31
+ /** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
32
+ export declare const editEpoch: EditEpoch;
@@ -0,0 +1,108 @@
1
+ import { NO_EDITS_OBSERVED } from '@reticlehq/core';
2
+ import { echoRef, refs } from './dom/refs.js';
3
+ /**
4
+ * What the page can tell us about its own source being edited underneath it.
5
+ *
6
+ * The agent driving Reticle edits source and re-verifies in a loop. When an edit lands, the dev
7
+ * server hot-updates the module and the framework re-renders: DOM nodes are REPLACED, every ref the
8
+ * agent holds points at a detached node — and there was no navigation, so the document is the same
9
+ * one and `documentId` never moves. The agent got a generic "that ref is stale" and could not tell
10
+ * its own edit landing from a bug in the app it was verifying.
11
+ *
12
+ * DELIBERATELY NOT A BUILD INTEGRATION. Nothing here watches files, reads the user's repo or knows
13
+ * what a module graph is. The page is told by its own dev server that it hot-updated; this records
14
+ * that it happened and what it named, and that is the whole feature.
15
+ */
16
+ /**
17
+ * Vite's "the update has been applied" event, on a hot context.
18
+ *
19
+ * `vite:afterUpdate` rather than `vite:beforeUpdate`: the point of the epoch is that the DOM has
20
+ * been replaced, and before the update it has not been yet.
21
+ *
22
+ * The SDK does NOT depend on Vite — it ships to Next, Electron, Tauri and plain pages, and it cannot
23
+ * read `import.meta.hot` of its own module in any of them (a dependency is pre-bundled by Vite's
24
+ * optimizer and gets no hot context at all). The channel is handed IN by whoever has one, and this
25
+ * name is the only Vite-shaped thing in the SDK.
26
+ */
27
+ const HOT_UPDATE_APPLIED = 'vite:afterUpdate';
28
+ /** How many changed files a refusal names before it stops being a diagnosis and starts being a list. */
29
+ const MAX_NAMED_FILES = 3;
30
+ /** The exact substring the server's recovery table keys off. Changing it silently unclassifies. */
31
+ const STALE_REF_REFUSAL = 'no longer resolves to an element';
32
+ /** The clause that turns a dead end into a diagnosis, and the one the server's table matches on. */
33
+ const CODE_CHANGED = 'the code changed underneath it';
34
+ /** Anything object-shaped, as a bag of unknowns. The payload comes from the page, so nothing about
35
+ * its shape is guaranteed and every field has to be checked before it is read. */
36
+ function fields(value) {
37
+ return null !== value && 'object' === typeof value
38
+ ? value
39
+ : undefined;
40
+ }
41
+ function isHotLike(value) {
42
+ return 'function' === typeof fields(value)?.['on'];
43
+ }
44
+ /** The file paths a hot-update payload named, narrowed from `unknown`. */
45
+ function updatedPaths(payload) {
46
+ const updates = fields(payload)?.['updates'];
47
+ if (!Array.isArray(updates))
48
+ return [];
49
+ const paths = [];
50
+ for (const update of updates) {
51
+ const path = fields(update)?.['path'];
52
+ if ('string' === typeof path && path.length > 0 && !paths.includes(path))
53
+ paths.push(path);
54
+ }
55
+ return paths;
56
+ }
57
+ export class EditEpoch {
58
+ #epoch = NO_EDITS_OBSERVED;
59
+ #files = [];
60
+ #refs;
61
+ /** The registry is injected for the same reason the clock is: the interesting property is the
62
+ * RELATION between when a ref was minted and when an edit landed, and a global has no seam. */
63
+ constructor(registry) {
64
+ this.#refs = registry;
65
+ }
66
+ /**
67
+ * How many hot updates this document has applied.
68
+ *
69
+ * `NO_EDITS_OBSERVED` means exactly that and never "none happened": most pages Reticle instruments
70
+ * have no channel that could report one, so absence here is UNKNOWN.
71
+ */
72
+ get current() {
73
+ return this.#epoch;
74
+ }
75
+ /** Subscribe to a hot-update channel if one was handed in. Anything else is silently ignored —
76
+ * no channel is the normal case, not an error. */
77
+ observe(hot) {
78
+ if (!isHotLike(hot))
79
+ return;
80
+ hot.on(HOT_UPDATE_APPLIED, (payload) => this.applied(updatedPaths(payload)));
81
+ }
82
+ /** Record that an update was applied, naming the files it changed (which may be none). */
83
+ applied(files) {
84
+ this.#epoch += 1;
85
+ this.#files = files.slice(0, MAX_NAMED_FILES);
86
+ this.#refs.markEdited();
87
+ }
88
+ /**
89
+ * The refusal for a ref that no longer resolves — saying WHY when the page can tell us.
90
+ *
91
+ * The generic wording is untouched and stays at the front: it is already tuned, and the server's
92
+ * recovery table matches on it. When the ref predates the last hot update the diagnosis is
93
+ * appended, because "you edited TripCard.tsx and it re-rendered" is a next step and "that ref is
94
+ * stale" is a dead end. A ref minted AFTER the last update gets the generic message unchanged —
95
+ * blaming an edit for an ordinary post-click stale ref would be a confident wrong answer, which is
96
+ * worse than the vague right one.
97
+ */
98
+ staleRefMessage(ref) {
99
+ const generic = `ref '${echoRef(ref)}' ${STALE_REF_REFUSAL}`;
100
+ if (!this.#refs.mintedBeforeLastEdit(ref))
101
+ return generic;
102
+ const named = 0 === this.#files.length ? 'the page hot-updated' : `${this.#files.join(', ')} hot-updated`;
103
+ return (`${generic} — ${named} after this ref was taken, so ${CODE_CHANGED} and the framework ` +
104
+ 'replaced the node. Query again for a fresh ref: this is your own edit landing, not the app failing.');
105
+ }
106
+ }
107
+ /** The epoch of the page, paired with the process-wide ref registry it has to reason against. */
108
+ export const editEpoch = new EditEpoch(refs);
@@ -0,0 +1,12 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ /**
3
+ * The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
4
+ * just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
5
+ * the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
6
+ * changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
7
+ * accusing the client of ignoring a response it handed to a window nobody can see.
8
+ *
9
+ * The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
10
+ * first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
11
+ */
12
+ export declare function installContextOpen(emit: Emit): Teardown;
@@ -0,0 +1,25 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { captureMethod } from '../patching/capture-method.js';
3
+ /**
4
+ * The page asked for ANOTHER browsing context — `window.open` — so the consequence of whatever was
5
+ * just clicked may live where this document's SDK cannot follow. An OAuth sign-in is the archetype:
6
+ * the POST succeeds, the popup carries the whole flow, and the original tab legitimately never
7
+ * changes (#508). Without this event the contradiction layer reads that shape as response-ignored,
8
+ * accusing the client of ignoring a response it handed to a window nobody can see.
9
+ *
10
+ * The wrap preserves the call exactly: same arguments, same return value, and a null/undefined
11
+ * first argument (the "open a blank tab" form) still emits, since a blank tab can navigate anywhere.
12
+ */
13
+ export function installContextOpen(emit) {
14
+ const originalOpen = captureMethod(window, 'open');
15
+ if (originalOpen === undefined)
16
+ return () => { };
17
+ const openPatch = function (href, target, features) {
18
+ emit(EventType.CONTEXT_OPENED, { ...(href === undefined ? {} : { href }) });
19
+ return originalOpen.call(this, href, target, features);
20
+ };
21
+ window.open = openPatch;
22
+ return () => {
23
+ window.open = originalOpen;
24
+ };
25
+ }
@@ -17,6 +17,7 @@ import { installStoreState } from './state.js';
17
17
  import { installFocus } from './focus.js';
18
18
  import { installBlindSpots } from './blind-spots.js';
19
19
  import { installDownload } from './download.js';
20
+ import { installContextOpen } from './context-open.js';
20
21
  import { installNetwork } from './network.js';
21
22
  import { installIpc, ipcNetOverrides, isReticleOwnIpc } from './ipc.js';
22
23
  import { installPerf } from './perf.js';
@@ -62,5 +63,6 @@ export function installAllObservers(emit, options) {
62
63
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
63
64
  guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)), // cross-origin iframes the SDK can't see → BLIND_SPOT (coverage: partial)
64
65
  guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)), // page visibility/focus health + heartbeat
66
+ guard(emit, SdkSite.DOM_OBSERVER, () => installContextOpen(emit)), // window.open → CONTEXT_OPENED (the consequence may live in another context)
65
67
  ];
66
68
  }
@@ -256,6 +256,7 @@ export function installNetwork(emit, opts = {}) {
256
256
  const contentType = res.headers.get('content-type');
257
257
  // The request is done; the BODY may not be. Watch it so settle cannot pass mid-stream.
258
258
  watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
259
+ reportedNetUrls.add(rawUrl);
259
260
  const emitRequest = (responseBodyFields) => {
260
261
  emit(EventType.NET_REQUEST, {
261
262
  id,
@@ -385,6 +386,7 @@ export function installNetwork(emit, opts = {}) {
385
386
  const cur = meta.get(this);
386
387
  if (cur === undefined)
387
388
  return;
389
+ reportedNetUrls.add(cur.rawUrl);
388
390
  const xhrContentType = this.getResponseHeader('content-type');
389
391
  let responseBodyFields = {};
390
392
  // responseText throws unless responseType is '' or 'text' — guard before reading.
@@ -523,7 +525,64 @@ export function installNetwork(emit, opts = {}) {
523
525
  };
524
526
  navProto.sendBeacon = patchedBeacon;
525
527
  }
528
+ // Document-initiated subresources (link/css/img/script/manifest): fetch and XHR patches never see
529
+ // these, so a `{net}` predicate over a favicon or manifest used to read `assertion_failed` — "your
530
+ // change is broken" — when the truthful answer is "not observable". A PerformanceObserver over
531
+ // `resource` entries reports them with no CDP. Status is the known gap: entries carry one only on
532
+ // newer Chromium, so the field is emitted ONLY when readable and the evaluation seam downgrades
533
+ // status assertions it cannot verify instead of guessing.
534
+ //
535
+ // Dedup: any URL the patched transports already reported (or later report) is skipped — resource
536
+ // timing also records fetch/XHR loads. The seen-set is filled eagerly here AND at every
537
+ // NET_REQUEST emit below, so both orders of arrival stay single-reported.
538
+ const reportedNetUrls = new Set();
539
+ let subresourceEvents = 0;
540
+ const SUBRESOURCE_CAP = 200;
541
+ let subresourceObserver;
542
+ if (typeof PerformanceObserver !== 'undefined') {
543
+ try {
544
+ const observer = new PerformanceObserver((list) => {
545
+ for (const raw of list.getEntries()) {
546
+ const entry = raw;
547
+ if (subresourceEvents >= SUBRESOURCE_CAP)
548
+ return;
549
+ const type = entry.initiatorType || 'other';
550
+ // Patched transports own their records; only document-initiated types are new here.
551
+ if ('fetch' === type || 'xmlhttprequest' === type)
552
+ continue;
553
+ const rawUrl = entry.name;
554
+ if (reportedNetUrls.has(rawUrl))
555
+ continue;
556
+ reportedNetUrls.add(rawUrl);
557
+ subresourceEvents += 1;
558
+ emit(EventType.NET_REQUEST, {
559
+ id: nextId(),
560
+ method: 'GET',
561
+ url: redactUrl(rawUrl),
562
+ durationMs: Math.round(entry.duration),
563
+ initiator: type,
564
+ ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
565
+ // responseStatus is Chromium-only; omitted entirely when unreadable so the wire never
566
+ // carries a guessed status. Its absence is what the server-side seam reads as unknown.
567
+ ...((entry.responseStatus ?? 0) > 0
568
+ ? {
569
+ status: entry.responseStatus,
570
+ ok: (entry.responseStatus ?? 0) >= 200 && (entry.responseStatus ?? 0) < 400,
571
+ }
572
+ : {}),
573
+ });
574
+ }
575
+ });
576
+ subresourceObserver = observer;
577
+ observer.observe({ type: 'resource', buffered: true });
578
+ // Disconnected in the disposer below.
579
+ }
580
+ catch {
581
+ // No observer support: document-initiated loads stay unobserved, stated rather than guessed.
582
+ }
583
+ }
526
584
  return () => {
585
+ subresourceObserver?.disconnect();
527
586
  // Restore each slot ONLY if it still holds OUR wrapper. Between connect() and disconnect() the app
528
587
  // (or Sentry/analytics/a router) may have wrapped fetch/XHR/EventSource/WebSocket/sendBeacon ON TOP
529
588
  // of ours; blindly writing the original back would silently uninstall their instrumentation — the
@@ -65,7 +65,7 @@ export function readStorage(area) {
65
65
  return readArea(safeArea(() => window.localStorage));
66
66
  if (area === StorageArea.SESSION)
67
67
  return readArea(safeArea(() => window.sessionStorage));
68
- if ('cookies' === area)
68
+ if (StorageArea.COOKIE === area)
69
69
  return readCookies();
70
70
  return {
71
71
  local: readArea(safeArea(() => window.localStorage)),
@@ -9,28 +9,14 @@
9
9
  */
10
10
  /**
11
11
  * Brand marks, inlined. The SDK is bundled into someone else's page: it cannot read
12
- * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the two
13
- * artwork files live here as strings (same precedent as SEND_ICON in presenter-controls.ts).
14
- * Both are minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and paint
12
+ * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the mark
13
+ * lives here as a string (same precedent as SEND_ICON in presenter-controls.ts).
14
+ * Minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and painted
15
15
  * with `currentColor` so the mark tracks --reticle-fg instead of hardcoding the export's #FAFAFA.
16
16
  */
17
- /** Accessible name of the HUD - the wordmark is artwork, this is what a screen reader reads. */
17
+ /** Accessible name of the HUD. */
18
18
  export declare const BRAND_NAME = "Reticle";
19
19
  /** Mark alone (shown in the FAB and compact toolbar). */
20
20
  export declare const MARK_SVG: string;
21
- /** FAB-sized mark for the collapsed circular control. */
22
- export declare const FAB_MARK_SVG: string;
23
- /** Mark + "reticle" lettering (legacy wordmark artwork). */
24
- export declare const WORDMARK_SVG: string;
25
- /** Compact text label beside the mark in the expanded toolbar. */
26
- export declare const BRAND_MINI_CLASS = "reticle-brand-mini";
27
- /** Toolbar brand: mark + Reticle word (always shown together when expanded). */
28
- export declare const TOOLBAR_BRAND_HTML: string;
29
- /**
30
- * Header brand block. Both marks are always in the DOM and CSS picks one off the collapsed
31
- * attribute, so there is no JS state to keep in sync. The artwork is decorative (aria-hidden);
32
- * the visually-hidden name is what keeps the HUD identifiable to a screen reader in BOTH states.
33
- */
34
- export declare const BRAND_HTML: string;
35
21
  /** Collapsed FAB control - Reticle mark only. */
36
22
  export declare const FAB_TOGGLE_HTML: string;
@@ -9,30 +9,18 @@
9
9
  */
10
10
  /**
11
11
  * Brand marks, inlined. The SDK is bundled into someone else's page: it cannot read
12
- * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the two
13
- * artwork files live here as strings (same precedent as SEND_ICON in presenter-controls.ts).
14
- * Both are minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and paint
12
+ * `assets/logo/*.svg` from disk at runtime and must never fetch from our own domain, so the mark
13
+ * lives here as a string (same precedent as SEND_ICON in presenter-controls.ts).
14
+ * Minified - editor metadata, clip paths, masks and sub-0.01 precision stripped - and painted
15
15
  * with `currentColor` so the mark tracks --reticle-fg instead of hardcoding the export's #FAFAFA.
16
16
  */
17
- /** Accessible name of the HUD - the wordmark is artwork, this is what a screen reader reads. */
17
+ /** Accessible name of the HUD. */
18
18
  export const BRAND_NAME = 'Reticle';
19
- /** The two glyph paths of the mark, shared by both marks - the wordmark just offsets them by (1,1). */
19
+ /** The two glyph paths of the mark. */
20
20
  const MARK_PATHS = `<path d="M92.82 92.16C93.07 93.4 91.77 94.4 90.63 93.83C89.62 93.33 88.62 92.82 87.63 92.3C71.14 83.78 54.79 72.27 39.61 57.96C39.09 57.49 38.57 56.98 38.06 56.49C22.45 41.52 9.78 25.14 .33 8.52C0.29 8.45 .25 8.38 .2 8.31C-0.44 7.2 .51 5.84 1.79 6.05L24.07 9.71C43.06 12.83 60.63 11.75 75.55 6.53L93.95 .09C95.15-0.33 96.31 .78 95.93 1.98L90.1 20.49C85.37 35.48 84.9 52.97 88.69 71.73L92.83 92.16H92.82Z"/><path d="M58.72 89.23L6.15 92.8C5.24 92.86 4.48 92.13 4.51 91.22L6.56 30.39C6.61 28.91 8.51 28.36 9.35 29.57C16.23 39.43 24.98 52.35 33.01 61.26C41.05 70.17 50.6 78.72 59.62 86.56C60.65 87.45 60.08 89.13 58.72 89.23Z"/>`;
21
21
  /** Mark alone (shown in the FAB and compact toolbar). */
22
22
  export const MARK_SVG = `<svg class="reticle-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
23
23
  /** FAB-sized mark for the collapsed circular control. */
24
- export const FAB_MARK_SVG = `<svg class="reticle-fab-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
25
- /** Mark + "reticle" lettering (legacy wordmark artwork). */
26
- export const WORDMARK_SVG = `<svg class="reticle-wordmark" viewBox="0 0 347 96" fill="currentColor" aria-hidden="true"><g transform="translate(1 1)">${MARK_PATHS}</g><path d="M116.43 78V27.09H136.51C140.35 27.09 143.64 27.78 146.35 29.15C149.09 30.51 151.17 32.44 152.59 34.95C154.04 37.43 154.76 40.36 154.76 43.72C154.76 47.1 154.03 50.01 152.57 52.45C151.11 54.87 149 56.72 146.23 58.01C143.48 59.31 140.15 59.95 136.24 59.95H122.79V51.3H134.5C136.55 51.3 138.26 51.02 139.62 50.46C140.98 49.89 141.99 49.05 142.65 47.92C143.33 46.8 143.67 45.39 143.67 43.72C143.67 42.03 143.33 40.61 142.65 39.45C141.99 38.29 140.97 37.41 139.59 36.81C138.23 36.2 136.52 35.89 134.45 35.89H127.19V78H116.43ZM143.92 54.83L156.57 78H144.69L132.31 54.83H143.92ZM178.95 78.75C175.02 78.75 171.64 77.95 168.81 76.36C165.99 74.75 163.82 72.48 162.29 69.55C160.77 66.6 160.01 63.11 160.01 59.08C160.01 55.16 160.77 51.71 162.29 48.74C163.82 45.78 165.97 43.46 168.73 41.81C171.52 40.15 174.78 39.32 178.53 39.32C181.05 39.32 183.39 39.73 185.56 40.54C187.75 41.33 189.65 42.54 191.28 44.14C192.92 45.75 194.2 47.77 195.11 50.21C196.02 52.63 196.47 55.46 196.47 58.71V61.62H164.23V55.06H186.51C186.51 53.53 186.17 52.18 185.51 51C184.85 49.83 183.93 48.91 182.75 48.25C181.59 47.57 180.24 47.23 178.7 47.23C177.09 47.23 175.67 47.6 174.43 48.34C173.2 49.07 172.24 50.06 171.54 51.3C170.85 52.53 170.49 53.9 170.47 55.4V61.64C170.47 63.53 170.82 65.17 171.52 66.54C172.23 67.92 173.23 68.98 174.52 69.72C175.82 70.47 177.35 70.84 179.12 70.84C180.3 70.84 181.38 70.68 182.35 70.34C183.33 70.01 184.17 69.52 184.87 68.85C185.56 68.19 186.09 67.38 186.46 66.42L196.25 67.06C195.75 69.42 194.73 71.47 193.19 73.23C191.67 74.97 189.7 76.33 187.28 77.3C184.87 78.27 182.1 78.75 178.95 78.75ZM223.69 39.82V47.77H200.7V39.82H223.69ZM205.92 30.67H216.51V66.27C216.51 67.24 216.66 68.01 216.96 68.55C217.26 69.08 217.67 69.46 218.2 69.67C218.75 69.89 219.38 70 220.09 70C220.59 70 221.08 69.95 221.58 69.87C222.08 69.77 222.46 69.7 222.72 69.65L224.39 77.53C223.86 77.69 223.11 77.88 222.15 78.1C221.19 78.33 220.02 78.47 218.65 78.52C216.1 78.62 213.86 78.28 211.94 77.5C210.03 76.72 208.55 75.51 207.49 73.87C206.43 72.23 205.9 70.16 205.92 67.66V30.67ZM230.59 78V39.82H241.18V78H230.59ZM235.91 34.9C234.34 34.9 232.99 34.37 231.86 33.33C230.75 32.27 230.19 31 230.19 29.53C230.19 28.07 230.75 26.82 231.86 25.77C232.99 24.71 234.34 24.18 235.91 24.18C237.49 24.18 238.83 24.71 239.94 25.77C241.07 26.82 241.63 28.07 241.63 29.53C241.63 31 241.07 32.27 239.94 33.33C238.83 34.37 237.49 34.9 235.91 34.9ZM266.92 78.75C263 78.75 259.64 77.92 256.82 76.26C254.02 74.59 251.87 72.27 250.36 69.3C248.87 66.33 248.12 62.92 248.12 59.06C248.12 55.15 248.88 51.72 250.38 48.77C251.91 45.8 254.07 43.49 256.87 41.83C259.67 40.16 263 39.32 266.87 39.32C270.2 39.32 273.11 39.93 275.62 41.14C278.12 42.35 280.1 44.04 281.56 46.23C283.01 48.42 283.82 50.99 283.97 53.94H273.97C273.69 52.03 272.95 50.5 271.74 49.34C270.54 48.16 268.98 47.57 267.04 47.57C265.4 47.57 263.96 48.02 262.74 48.92C261.53 49.79 260.58 51.08 259.9 52.77C259.23 54.46 258.89 56.51 258.89 58.91C258.89 61.35 259.22 63.42 259.88 65.12C260.56 66.83 261.51 68.13 262.74 69.03C263.96 69.92 265.4 70.37 267.04 70.37C268.25 70.37 269.33 70.12 270.3 69.62C271.27 69.13 272.08 68.4 272.71 67.46C273.35 66.5 273.78 65.35 273.97 64.01H283.97C283.8 66.92 283.01 69.49 281.58 71.71C280.17 73.92 278.23 75.64 275.74 76.88C273.25 78.12 270.31 78.75 266.92 78.75ZM301.34 27.09V78H290.75V27.09H301.34ZM327.22 78.75C323.29 78.75 319.91 77.95 317.08 76.36C314.26 74.75 312.09 72.48 310.57 69.55C309.04 66.6 308.28 63.11 308.28 59.08C308.28 55.16 309.04 51.71 310.57 48.74C312.09 45.78 314.24 43.46 317 41.81C319.79 40.15 323.05 39.32 326.8 39.32C329.32 39.32 331.66 39.73 333.83 40.54C336.02 41.33 337.93 42.54 339.55 44.14C341.19 45.75 342.47 47.77 343.38 50.21C344.29 52.63 344.75 55.46 344.75 58.71V61.62H312.5V55.06H334.78C334.78 53.53 334.45 52.18 333.78 51C333.12 49.83 332.2 48.91 331.02 48.25C329.86 47.57 328.51 47.23 326.97 47.23C325.36 47.23 323.94 47.6 322.7 48.34C321.47 49.07 320.51 50.06 319.81 51.3C319.12 52.53 318.76 53.9 318.74 55.4V61.64C318.74 63.53 319.09 65.17 319.79 66.54C320.5 67.92 321.5 68.98 322.8 69.72C324.09 70.47 325.62 70.84 327.39 70.84C328.57 70.84 329.65 70.68 330.63 70.34C331.6 70.01 332.44 69.52 333.14 68.85C333.83 68.19 334.36 67.38 334.73 66.42L344.52 67.06C344.02 69.42 343.01 71.47 341.46 73.23C339.94 74.97 337.97 76.33 335.55 77.3C333.15 78.27 330.37 78.75 327.22 78.75Z"/></svg>`;
27
- /** Compact text label beside the mark in the expanded toolbar. */
28
- export const BRAND_MINI_CLASS = 'reticle-brand-mini';
29
- /** Toolbar brand: mark + Reticle word (always shown together when expanded). */
30
- export const TOOLBAR_BRAND_HTML = `<span class="reticle-brand reticle-brand--toolbar">${MARK_SVG}<span class="${BRAND_MINI_CLASS}">${BRAND_NAME}</span><span class="reticle-brand-sr">${BRAND_NAME}</span></span>`;
31
- /**
32
- * Header brand block. Both marks are always in the DOM and CSS picks one off the collapsed
33
- * attribute, so there is no JS state to keep in sync. The artwork is decorative (aria-hidden);
34
- * the visually-hidden name is what keeps the HUD identifiable to a screen reader in BOTH states.
35
- */
36
- export const BRAND_HTML = `<span class="reticle-brand">${MARK_SVG}${WORDMARK_SVG}<span class="${BRAND_MINI_CLASS}">${BRAND_NAME}</span><span class="reticle-brand-sr">${BRAND_NAME}</span></span>`;
24
+ const FAB_MARK_SVG = `<svg class="reticle-fab-mark" viewBox="0 0 96 94" fill="currentColor" aria-hidden="true">${MARK_PATHS}</svg>`;
37
25
  /** Collapsed FAB control - Reticle mark only. */
38
26
  export const FAB_TOGGLE_HTML = `<button type="button" class="reticle-fab" data-reticle-fab aria-label="Start ${BRAND_NAME}" aria-expanded="false">${FAB_MARK_SVG}<span class="reticle-fab-pulse" aria-hidden="true"></span><span data-reticle-mark-count class="reticle-fab-badge" hidden>0</span></button>`;
@@ -126,10 +126,6 @@ export declare const Placement: {
126
126
  readonly ABOVE: "above";
127
127
  readonly BELOW: "below";
128
128
  };
129
- export declare const DockAlign: {
130
- readonly START: "start";
131
- readonly END: "end";
132
- };
133
129
  /** Show/hide annotation markers. */
134
130
  export declare const MARKERS_BTN_ATTR = "data-reticle-markers-btn";
135
131
  /** Clear every annotation on the page. */
@@ -177,8 +173,6 @@ export declare const LOG_TIMESTAMPS_ATTR = "data-reticle-log-ts";
177
173
  /** When 1, the chat panel uses a narrower width. */
178
174
  /** When 1, HUD motion is reduced for accessibility. */
179
175
  export declare const REDUCE_MOTION_ATTR = "data-reticle-reduce-motion";
180
- /** Presenter settings card version label (browser package). */
181
- export declare const PRESENTER_UI_VERSION = "2.7.0";
182
176
  /** Docs opened from the settings footer. */
183
177
  export declare const MCP_DOCS_URL = "https://github.com/reticlehq/reticle/blob/main/docs/getting-started.md";
184
178
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */