@reticlehq/browser 2.11.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
  }
@@ -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. */
@@ -91,7 +91,6 @@ export const SETTINGS_PLACEMENT_ATTR = 'data-reticle-settings-placement';
91
91
  /** Horizontal alignment of dock-anchored panels (start = left, end = right). */
92
92
  export const DOCK_ALIGN_ATTR = 'data-reticle-dock-align';
93
93
  export const Placement = { ABOVE: 'above', BELOW: 'below' };
94
- export const DockAlign = { START: 'start', END: 'end' };
95
94
  /** Show/hide annotation markers. */
96
95
  export const MARKERS_BTN_ATTR = 'data-reticle-markers-btn';
97
96
  /** Clear every annotation on the page. */
@@ -139,8 +138,6 @@ export const LOG_TIMESTAMPS_ATTR = 'data-reticle-log-ts';
139
138
  /** When 1, the chat panel uses a narrower width. */
140
139
  /** When 1, HUD motion is reduced for accessibility. */
141
140
  export const REDUCE_MOTION_ATTR = 'data-reticle-reduce-motion';
142
- /** Presenter settings card version label (browser package). */
143
- export const PRESENTER_UI_VERSION = '2.7.0';
144
141
  /** Docs opened from the settings footer. */
145
142
  export const MCP_DOCS_URL = 'https://github.com/reticlehq/reticle/blob/main/docs/getting-started.md';
146
143
  /** HUD has been dragged off the default dock - positioned with explicit left/top. */
@@ -1,5 +1,4 @@
1
1
  import { HumanControlKind, PresenterTone, SessionState } from '@reticlehq/core';
2
- export declare const PAUSED_BADGE_LABEL = "PAUSED";
3
2
  /** Border fade-out delay after a session ends (native timer; presenter-only tunable). */
4
3
  export declare const ENDED_FADE_MS = 4000;
5
4
  /** Payload the panel hands to its host when the human drives a control. */
@@ -12,8 +11,6 @@ export type ControlHandler = (intent: ControlIntent) => void;
12
11
  export declare const CONTROLS_CSS: string;
13
12
  /** Icon toolbar: pause, chat, markers, end, clear - copy/export appear after a run ends. */
14
13
  export declare const CONTROLS_TOOLBAR_HTML: string;
15
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
16
- export declare const CONTROLS_HEAD_HTML: string;
17
14
  /** Banner markup (between head and log, hidden unless ended). */
18
15
  export declare const CONTROLS_BANNER_HTML = "<div data-reticle-banner class=\"reticle-banner\">Session ended</div>";
19
16
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -26,7 +26,6 @@ const INPUT_PLACEHOLDER = 'Tell the agent something…';
26
26
  /** Accessible name for the composer (a placeholder is not an accessible name). */
27
27
  const INPUT_ARIA_LABEL = 'Message to the agent';
28
28
  const PAUSED_BADGE_TEXT = 'PAUSED';
29
- export const PAUSED_BADGE_LABEL = PAUSED_BADGE_TEXT;
30
29
  const ENDED_BANNER_TEXT = 'Session ended';
31
30
  const COPY_LABEL = 'Copy run';
32
31
  const EXPORT_LABEL = 'Export';
@@ -177,8 +176,6 @@ export const CONTROLS_TOOLBAR_HTML = [
177
176
  `<button type="button" data-reticle-export class="reticle-tb-btn reticle-tb-btn--export" title="${EXPORT_LABEL}" aria-label="${EXPORT_LABEL}" hidden>${hiIconHtml(PresenterIcon.DOWNLOAD, TB)}</button>`,
178
177
  '<span data-reticle-export-msg class="reticle-export-msg" aria-live="polite"></span>',
179
178
  ].join('');
180
- /** @deprecated Legacy head markup - replaced by CONTROLS_TOOLBAR_HTML in the morphing shell. */
181
- export const CONTROLS_HEAD_HTML = CONTROLS_TOOLBAR_HTML;
182
179
  /** Banner markup (between head and log, hidden unless ended). */
183
180
  export const CONTROLS_BANNER_HTML = `<div data-reticle-banner class="reticle-banner">${ENDED_BANNER_TEXT}</div>`;
184
181
  /** Replay-a-flow row (between log and footer); buttons are filled in by setFlows once flows arrive. */
@@ -70,7 +70,15 @@ const DEFAULT_SETTINGS = {
70
70
  blockPageInteractions: true,
71
71
  showTally: false,
72
72
  showTimestamps: true,
73
- autoOpenChat: false,
73
+ // ON by default: the chat IS the HUD's content, and shipping it off meant the default experience
74
+ // was a bare toolbar until somebody found the toggle. `expand()` has always opened the chat for
75
+ // the same reason; this is the other half — session start, with no click at all.
76
+ //
77
+ // Safe to default because `openChat()` expands a collapsed HUD, and session start is the one
78
+ // moment where that is what you want. A user who prefers the bare toolbar turns this off and it
79
+ // stays off: a stored `false` is honoured over the default, which is pinned in
80
+ // presenter-chat-default.test.ts so the product cannot change somebody's answer behind their back.
81
+ autoOpenChat: true,
74
82
  reduceMotion: false,
75
83
  };
76
84
  let activeSettings = loadPresenterSettings();
@@ -169,7 +177,7 @@ export function settingsPanelHtml() {
169
177
  const hideHelp = 'Hide the Reticle HUD until you reload the page';
170
178
  const tallyHelp = 'Show the pass/fail score pill in the toolbar';
171
179
  const timestampsHelp = 'Show relative timestamps on each activity-log row';
172
- const autoChatHelp = 'Open the agent chat by itself when a session starts and when you expand the HUD. Off leaves the toolbar bare until you ask for the chat.';
180
+ const autoChatHelp = 'On by default. Opens the agent chat by itself when a session starts and when you expand the HUD. Off leaves the toolbar bare until you ask for the chat.';
173
181
  const motionHelp = 'Reduce HUD animations for accessibility';
174
182
  const glowHelp = 'Glow the page edges in the status colour while a session is live. Off keeps the HUD signals and leaves your app alone.';
175
183
  return `<div ${SETTINGS_PANEL_ATTR} class="reticle-settings ${HUD_SURFACE_CLASS}" role="dialog" aria-label="Reticle settings" aria-hidden="true">
@@ -44,5 +44,3 @@ export declare class HudShell {
44
44
  /** Pulse the FAB when new activity arrives while collapsed. */
45
45
  pulseFab(active: boolean): void;
46
46
  }
47
- /** Whether the dock has been dragged off the default bottom-right position. */
48
- export declare function isDockDragged(dock: HTMLElement): boolean;