@reticlehq/browser 2.7.0 → 2.8.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/README.md CHANGED
@@ -8,10 +8,10 @@ npm i -D @reticlehq/browser
8
8
 
9
9
  ```ts
10
10
  import { reticle } from '@reticlehq/browser';
11
- if (import.meta.env.DEV) reticle.connect({ session: 'my-app' });
11
+ if (import.meta.env.DEV) reticle.connect(); // session defaults to a fresh per-tab id
12
12
 
13
13
  // surface non-DOM events too:
14
14
  reticle.signal('webhook:received', { provider: 'stripe' });
15
15
  ```
16
16
 
17
- Pair with [`@reticlehq/server`](https://www.npmjs.com/package/@reticlehq/server) (the bridge + MCP server) and optionally [`@reticlehq/react`](https://www.npmjs.com/package/@reticlehq/react) for component/source mapping. See the [main README](https://github.com/reticlehq/reticle) for the full picture. Dev-only, localhost-only by default. MIT.
17
+ Pair with [`@reticlehq/server`](https://www.npmjs.com/package/@reticlehq/server) (the bridge + MCP server) and optionally [`@reticlehq/react`](https://www.npmjs.com/package/@reticlehq/react) for component/source mapping. See the [main README](https://github.com/reticlehq/reticle) for the full picture. Dev-only, localhost-only by default. Apache-2.0.
@@ -91,6 +91,12 @@ function inspect(ref) {
91
91
  // stamped host. `act` already did this; inspect did not, so the two tools disagreed about the same
92
92
  // ref and inspect — the tool you reach for to ask where something lives — was the one saying null.
93
93
  const source = formatSource(sourceFor(el, component?.source));
94
+ const scroll = {
95
+ scrollTop: el.scrollTop,
96
+ scrollHeight: el.scrollHeight,
97
+ clientHeight: el.clientHeight,
98
+ overflowY: cs?.overflowY ?? 'visible',
99
+ };
94
100
  return {
95
101
  ...describe(el),
96
102
  ...(source !== undefined ? { source } : {}),
@@ -103,6 +109,7 @@ function inspect(ref) {
103
109
  // not this control (a z-index/overlay bug the DOM tree cannot show).
104
110
  occluded: isOccluded(el, rect),
105
111
  styles,
112
+ scroll,
106
113
  // Theme compliance vs the app's design tokens (off-theme colors a DOM tool can't judge).
107
114
  theme: cs !== null ? themeReport(cs) : null,
108
115
  component,
@@ -32,6 +32,18 @@ import { RETICLE_OVERLAY } from './dom-ignore.js';
32
32
  * later diff reports as matching. So the shells say so, and the tool reports the reason.
33
33
  */
34
34
  const FULL_PAGE_UNSUPPORTED_MARKER = VisualReason.FULL_PAGE_UNSUPPORTED;
35
+ /**
36
+ * Reasons a shell can name, in the order they are matched.
37
+ *
38
+ * The shell throws one of these rather than answering a bare null, because "nothing came back" was
39
+ * covering three different problems at once: a window with no composited frame yet, a webContents
40
+ * that had gone away, and any error at all. They need different responses from the reader, and the
41
+ * tool reported them identically.
42
+ */
43
+ const RECOGNISED_MARKERS = [
44
+ FULL_PAGE_UNSUPPORTED_MARKER,
45
+ VisualReason.NOT_COMPOSITED,
46
+ ];
35
47
  const TAURI_INTERNALS_GLOBAL = '__TAURI_INTERNALS__';
36
48
  /**
37
49
  * Tauri's capture path, used when no preload-installed channel exists.
@@ -106,8 +118,13 @@ export async function captureDesktopWindow(fullPage = false) {
106
118
  }
107
119
  catch (error) {
108
120
  const reason = error instanceof Error ? error.message : String(error);
109
- return reason.includes(FULL_PAGE_UNSUPPORTED_MARKER)
110
- ? { ok: false, reason: FULL_PAGE_UNSUPPORTED_MARKER }
111
- : { ok: false, reason };
121
+ // Recognised markers are reported as the reason ITSELF, so the tool can branch on a closed
122
+ // vocabulary rather than on prose. Everything else keeps its own message: dressing an unknown
123
+ // failure up as a known one would send the reader to the wrong place entirely.
124
+ for (const marker of RECOGNISED_MARKERS) {
125
+ if (reason.includes(marker))
126
+ return { ok: false, reason: marker };
127
+ }
128
+ return { ok: false, reason };
112
129
  }
113
130
  }
@@ -31,8 +31,36 @@ export declare const MAX_TRACKED_REFS = 10000;
31
31
  * the message.
32
32
  */
33
33
  export declare function echoRef(ref: string): string;
34
+ /**
35
+ * How many numbers a document claims at once.
36
+ *
37
+ * The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
38
+ * (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
39
+ * mints is free.
40
+ *
41
+ * Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
42
+ * how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
43
+ * paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
44
+ * message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
45
+ *
46
+ * Measured on one unchanged three-element page across a session: 61 tokens on the first document,
47
+ * 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
48
+ * same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
49
+ *
50
+ * So the block is small. A document claims another one when it outmints this, which costs one more
51
+ * storage write and is exactly proportional: a page with thousands of elements has a long snapshot
52
+ * anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
53
+ * three-digit refs across dozens of navigations.
54
+ */
55
+ export declare const REF_BLOCK = 100;
34
56
  export declare class RefRegistry {
35
57
  #private;
58
+ /**
59
+ * Storage is injected rather than read from the global for the same reason the clock is: it is an
60
+ * ambient dependency that decides behaviour, and the cross-document property is untestable if the
61
+ * only way to get a second document is to actually navigate.
62
+ */
63
+ constructor(storage?: () => Storage | undefined);
36
64
  /** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
37
65
  get size(): number;
38
66
  /** Get the existing ref for an element, or mint a new one. */
package/dist/dom/refs.js CHANGED
@@ -44,11 +44,96 @@ export function echoRef(ref) {
44
44
  * over-cap mint, and the expensive dead-entry sweep runs once per this many mints (amortized O(1)).
45
45
  */
46
46
  const SWEEP_EVERY_MINTS = 1000;
47
+ /**
48
+ * Where a document records the ref numbers it has claimed, so the next one starts past them.
49
+ *
50
+ * Refs are per-DOCUMENT: a reload or a cross-page link tears this module down and the next document
51
+ * used to start minting from `e1` again. So `e7` from page A was a valid, resolvable, DIFFERENT
52
+ * element on page B — nothing refused, the wrong element was acted on, and the result came back `ok`.
53
+ * The SPA case was always safe (WeakRef liveness + isConnected); this was the other half, and the
54
+ * more dangerous one, because it produced a false green instead of an error.
55
+ *
56
+ * Deliberately NOT a change to the ref grammar. Stamping refs `e7@3` would have worked too, but every
57
+ * agent passes refs back verbatim and every saved flow on disk already holds bare ones, so it buys a
58
+ * wire-format migration for a property the sequence itself can carry. A number that never restarts
59
+ * means a stale ref simply misses the map, and the refusal that already exists — already worded for
60
+ * exactly this case — fires unchanged.
61
+ *
62
+ * Namespaced like the session key it sits beside; `sessionStorage` is the right scope for the same
63
+ * reason session continuity uses it (survives reloads and same-tab navigations, not shared with
64
+ * another tab, which is a different session with its own registry anyway).
65
+ */
66
+ const REF_BASE_KEY = '__reticle_ref_base';
67
+ /**
68
+ * How many numbers a document claims at once.
69
+ *
70
+ * The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
71
+ * (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
72
+ * mints is free.
73
+ *
74
+ * Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
75
+ * how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
76
+ * paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
77
+ * message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
78
+ *
79
+ * Measured on one unchanged three-element page across a session: 61 tokens on the first document,
80
+ * 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
81
+ * same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
82
+ *
83
+ * So the block is small. A document claims another one when it outmints this, which costs one more
84
+ * storage write and is exactly proportional: a page with thousands of elements has a long snapshot
85
+ * anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
86
+ * three-digit refs across dozens of navigations.
87
+ */
88
+ export const REF_BLOCK = 100;
89
+ /** The reserved-block high-water mark, or 0 when storage is unavailable or its value is junk. */
90
+ function readBase(store) {
91
+ try {
92
+ const raw = store?.getItem(REF_BASE_KEY);
93
+ const value = null === raw || undefined === raw ? Number.NaN : Number(raw);
94
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
95
+ }
96
+ catch {
97
+ return 0; // a sandboxed iframe throws on access; a session must still start
98
+ }
99
+ }
47
100
  export class RefRegistry {
48
101
  #toRef = new WeakMap();
49
102
  #fromRef = new Map();
103
+ #storage;
50
104
  #seq = 0;
105
+ /** The end of the block currently reserved. Minting past it claims the next one. */
106
+ #reserved = 0;
51
107
  #mintsSinceSweep = 0;
108
+ /**
109
+ * Storage is injected rather than read from the global for the same reason the clock is: it is an
110
+ * ambient dependency that decides behaviour, and the cross-document property is untestable if the
111
+ * only way to get a second document is to actually navigate.
112
+ */
113
+ constructor(storage = () => globalThis.sessionStorage) {
114
+ this.#storage = storage;
115
+ }
116
+ /** Read the tab's high-water mark and claim the next block, BEFORE any of it is handed out. */
117
+ #reserve() {
118
+ let store;
119
+ try {
120
+ store = this.#storage();
121
+ }
122
+ catch {
123
+ store = undefined; // hostile storage: no cross-document guarantee, but the session still starts
124
+ }
125
+ const base = Math.max(readBase(store), this.#seq);
126
+ this.#reserved = base + REF_BLOCK;
127
+ try {
128
+ store?.setItem(REF_BASE_KEY, String(this.#reserved));
129
+ }
130
+ catch {
131
+ /* same: never block a mint on storage */
132
+ }
133
+ // Claimed on the FIRST mint, not at unload — a page that crashes, is killed, or navigates away
134
+ // without firing unload has still permanently spent its numbers.
135
+ this.#seq = base;
136
+ }
52
137
  /** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
53
138
  get size() {
54
139
  return this.#fromRef.size;
@@ -85,6 +170,8 @@ export class RefRegistry {
85
170
  this.#fromRef.set(existing, weak ?? new WeakRef(el));
86
171
  return existing;
87
172
  }
173
+ if (this.#seq >= this.#reserved)
174
+ this.#reserve();
88
175
  this.#seq += 1;
89
176
  const ref = asRef(`e${String(this.#seq)}`);
90
177
  this.#toRef.set(el, ref);
@@ -1,25 +1,48 @@
1
1
  /**
2
2
  * Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
3
3
  * tool sees a color rendered; it does not know the app's INTENDED palette. Reticle reads the design
4
- * tokens the app declares as `:root` CSS custom properties and reports, per element, whether its
5
- * color/background maps to a token or is an off-palette hardcoded value. That is a theme-compliance
6
- * signal no outside-the-page tool has without re-deriving the palette itself.
4
+ * tokens the app declares as CSS custom properties, resolves them AS THE PAGE CURRENTLY RENDERS
5
+ * THEM, and reports, per element, which tokens its color/background map to or whether the value is
6
+ * an off-palette hardcoded one. That is a theme-compliance signal no outside-the-page tool has
7
+ * without re-deriving the palette itself.
7
8
  *
8
9
  * Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
9
- * the matched token name is returned when there is one — so the agent can judge, not just trust a flag.
10
+ * every matching token name is returned — so the agent can judge, not just trust a flag.
11
+ *
12
+ * Two defects made this field untrustworthy in BOTH directions before it was rewritten:
13
+ * - the reverse map was color → ONE name, first writer wins, so a button styled with a brand token
14
+ * was told its background was a focus-border token because the two resolve to the same hex. The
15
+ * name is the part an agent reasons about, and it was arbitrary among the candidates.
16
+ * - the palette was built from the RAW `:root` declaration text and cached for the page lifetime,
17
+ * on the reasoning that a theme rarely changes at runtime. A human toggling the theme between
18
+ * two inspect calls left every lookup keyed on the previous theme's colors, so a `<summary>` that
19
+ * had just been changed to a text token reported a surface token. The reporter read that as their
20
+ * edit not applying and went hunting for a theme-scope bug that did not exist.
21
+ * Hence: no cache, values resolved through `getComputedStyle` so the active theme wins, and the
22
+ * scope that was active at capture time reported alongside, so two results minutes apart are
23
+ * comparable at all.
10
24
  */
11
25
  interface ThemeReport {
12
- /** Matched design-token name for the text color, or null when the color is off-palette. */
26
+ /**
27
+ * The ONE design token matching the text color, or null. Null both when nothing matches and when
28
+ * several tokens share the color: the previous behaviour picked an arbitrary winner and the caller
29
+ * could not tell, which is the whole defect. Read `colorTokens` for the full answer; this field
30
+ * stays for callers that only ever wanted the unambiguous case.
31
+ */
13
32
  colorToken: string | null;
14
- /** Matched design-token name for the background color, or null. */
33
+ /** Every design token whose resolved value is the text color. Empty when the color is off-palette. */
34
+ colorTokens: string[];
35
+ /** The one design token matching the background color, or null (same abstain-on-tie rule). */
15
36
  backgroundToken: string | null;
16
- /** True when a set, opaque color uses no design token — a theme violation worth surfacing. */
37
+ /** Every design token whose resolved value is the background color. */
38
+ backgroundTokens: string[];
39
+ /** True when a set, opaque color matches NO design token — a theme violation worth surfacing. */
17
40
  offTheme: boolean;
18
- /** How many color tokens the app declared (0 ⇒ no palette found, so offTheme is never asserted). */
41
+ /** How many color tokens resolved under the active theme (0 ⇒ offTheme is never asserted). */
19
42
  tokenCount: number;
43
+ /** Theme scope active at capture time, e.g. `.dark`, so two results taken apart are comparable. */
44
+ themeScope: string | null;
20
45
  }
21
46
  /** Report an element's theme compliance from its computed style. */
22
47
  export declare function themeReport(cs: CSSStyleDeclaration): ThemeReport;
23
- /** Drop the cached palette (call if the app swaps themes at runtime). */
24
- export declare function resetThemeCache(): void;
25
48
  export {};
package/dist/dom/theme.js CHANGED
@@ -1,33 +1,32 @@
1
1
  /**
2
2
  * Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
3
3
  * tool sees a color rendered; it does not know the app's INTENDED palette. Reticle reads the design
4
- * tokens the app declares as `:root` CSS custom properties and reports, per element, whether its
5
- * color/background maps to a token or is an off-palette hardcoded value. That is a theme-compliance
6
- * signal no outside-the-page tool has without re-deriving the palette itself.
4
+ * tokens the app declares as CSS custom properties, resolves them AS THE PAGE CURRENTLY RENDERS
5
+ * THEM, and reports, per element, which tokens its color/background map to or whether the value is
6
+ * an off-palette hardcoded one. That is a theme-compliance signal no outside-the-page tool has
7
+ * without re-deriving the palette itself.
7
8
  *
8
9
  * Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
9
- * the matched token name is returned when there is one — so the agent can judge, not just trust a flag.
10
+ * every matching token name is returned — so the agent can judge, not just trust a flag.
11
+ *
12
+ * Two defects made this field untrustworthy in BOTH directions before it was rewritten:
13
+ * - the reverse map was color → ONE name, first writer wins, so a button styled with a brand token
14
+ * was told its background was a focus-border token because the two resolve to the same hex. The
15
+ * name is the part an agent reasons about, and it was arbitrary among the candidates.
16
+ * - the palette was built from the RAW `:root` declaration text and cached for the page lifetime,
17
+ * on the reasoning that a theme rarely changes at runtime. A human toggling the theme between
18
+ * two inspect calls left every lookup keyed on the previous theme's colors, so a `<summary>` that
19
+ * had just been changed to a text token reported a surface token. The reporter read that as their
20
+ * edit not applying and went hunting for a theme-scope bug that did not exist.
21
+ * Hence: no cache, values resolved through `getComputedStyle` so the active theme wins, and the
22
+ * scope that was active at capture time reported alongside, so two results minutes apart are
23
+ * comparable at all.
10
24
  */
11
- let cached = null;
12
- /** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
13
- function toRgb(value) {
14
- if (0 === value.length)
15
- return null;
16
- const probe = document.createElement('span');
17
- probe.style.color = '';
18
- probe.style.color = value; // invalid colors leave it empty (the setter rejects them)
19
- if ('' === probe.style.color)
20
- return null;
21
- probe.style.position = 'absolute';
22
- probe.style.pointerEvents = 'none';
23
- document.body.appendChild(probe);
24
- const rgb = getComputedStyle(probe).color;
25
- probe.remove();
26
- return rgb;
27
- }
28
- /** Collect `--token: value` declarations from every:root/html rule across the app's stylesheets. */
29
- function collectTokens() {
30
- const out = {};
25
+ /** Attribute an app flips to switch themes, reported as part of the scope so results are comparable. */
26
+ const THEME_ATTRIBUTE = 'data-theme';
27
+ /** Collect every custom-property NAME the app declares, in any rule. */
28
+ function tokenNames() {
29
+ const names = new Set();
31
30
  for (const sheet of Array.from(document.styleSheets)) {
32
31
  let rules = null;
33
32
  try {
@@ -41,49 +40,109 @@ function collectTokens() {
41
40
  for (const rule of Array.from(rules)) {
42
41
  if (!(rule instanceof CSSStyleRule))
43
42
  continue;
44
- if (!/(^|,)\s*(:root|html)\b/.test(rule.selectorText))
45
- continue;
43
+ // Deliberately NOT filtered to `:root`/`html`: a theme block is usually `.dark` or
44
+ // `[data-theme="dark"]`, and an app that declares its palette only there had no palette at all
45
+ // under the old filter. Names are cheap; a name that is not in scope resolves to nothing below
46
+ // and is dropped, so widening collection cannot invent a token.
46
47
  for (const prop of Array.from(rule.style)) {
47
48
  if (prop.startsWith('--'))
48
- out[prop] = rule.style.getPropertyValue(prop).trim();
49
+ names.add(prop);
49
50
  }
50
51
  }
51
52
  }
52
- return out;
53
+ return names;
53
54
  }
54
- /** The app's palette (color tokens → rgb), built once and cached (theme rarely changes at runtime). */
55
+ /**
56
+ * The palette as the page renders it right now: canonical `rgb(...)` → every token with that color.
57
+ *
58
+ * Resolved against `document.body` rather than the raw declaration text, because custom properties
59
+ * inherit: this picks up a theme scoped to `<html>` or to `<body>`, and picks up the value the
60
+ * ACTIVE theme block won with rather than whichever block the stylesheet happened to list first.
61
+ */
55
62
  function palette() {
56
- if (cached !== null)
57
- return cached;
58
63
  const byColor = new Map();
59
- for (const [name, value] of Object.entries(collectTokens())) {
60
- const rgb = toRgb(value);
61
- if (rgb !== null && !byColor.has(rgb))
62
- byColor.set(rgb, name);
64
+ const root = document.body ?? document.documentElement;
65
+ if (null === root)
66
+ return byColor;
67
+ const rootStyle = getComputedStyle(root);
68
+ // One shared probe for the whole build. There is no cache any more, so this runs per inspect, and
69
+ // a span per token would be a few hundred appends on a real design system.
70
+ const probe = document.createElement('span');
71
+ probe.style.position = 'absolute';
72
+ probe.style.pointerEvents = 'none';
73
+ document.body.appendChild(probe);
74
+ try {
75
+ for (const name of tokenNames()) {
76
+ const rgb = toRgb(probe, rootStyle.getPropertyValue(name).trim());
77
+ if (null === rgb)
78
+ continue;
79
+ const named = byColor.get(rgb);
80
+ if (named === undefined)
81
+ byColor.set(rgb, [name]);
82
+ else
83
+ named.push(name);
84
+ }
85
+ }
86
+ finally {
87
+ probe.remove();
63
88
  }
64
- cached = { byColor };
65
- return cached;
89
+ // Sorted so the reported order is a property of the app's tokens, not of stylesheet iteration
90
+ // order — the same collision must read the same way on two consecutive inspects.
91
+ for (const named of byColor.values())
92
+ named.sort();
93
+ return byColor;
94
+ }
95
+ /** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
96
+ function toRgb(probe, value) {
97
+ if (0 === value.length)
98
+ return null;
99
+ probe.style.color = '';
100
+ probe.style.color = value; // invalid colors leave it empty (the setter rejects them)
101
+ if ('' === probe.style.color)
102
+ return null;
103
+ return getComputedStyle(probe).color;
66
104
  }
67
105
  /** True for a color that carries no visual weight (fully transparent) — never flagged off-theme. */
68
106
  function isTransparent(rgb) {
69
107
  return 'rgba(0, 0, 0, 0)' === rgb || 'transparent' === rgb;
70
108
  }
109
+ /**
110
+ * The theme scope in force at capture time, as a selector-shaped string (`.dark[data-theme="dark"]`),
111
+ * or null when the root carries no theme marker at all.
112
+ */
113
+ function themeScope() {
114
+ const el = document.documentElement;
115
+ if (null === el)
116
+ return null;
117
+ const classes = Array.from(el.classList)
118
+ .map((c) => `.${c}`)
119
+ .join('');
120
+ const attr = el.getAttribute(THEME_ATTRIBUTE);
121
+ const scope = `${classes}${null === attr ? '' : `[${THEME_ATTRIBUTE}="${attr}"]`}`;
122
+ return 0 === scope.length ? null : scope;
123
+ }
124
+ /** The unambiguous match, or null when the palette cannot tell the candidates apart. */
125
+ function sole(names) {
126
+ return 1 === names.length ? (names[0] ?? null) : null;
127
+ }
71
128
  /** Report an element's theme compliance from its computed style. */
72
129
  export function themeReport(cs) {
73
- const p = palette();
74
- const colorToken = p.byColor.get(cs.color) ?? null;
75
- const backgroundToken = p.byColor.get(cs.backgroundColor) ?? null;
76
- const colorOff = !isTransparent(cs.color) && null === colorToken;
77
- const bgOff = !isTransparent(cs.backgroundColor) && null === backgroundToken;
130
+ const byColor = palette();
131
+ const colorTokens = byColor.get(cs.color) ?? [];
132
+ const backgroundTokens = byColor.get(cs.backgroundColor) ?? [];
133
+ const colorOff = !isTransparent(cs.color) && 0 === colorTokens.length;
134
+ const bgOff = !isTransparent(cs.backgroundColor) && 0 === backgroundTokens.length;
135
+ let tokenCount = 0;
136
+ for (const named of byColor.values())
137
+ tokenCount += named.length;
78
138
  return {
79
- colorToken,
80
- backgroundToken,
139
+ colorToken: sole(colorTokens),
140
+ colorTokens,
141
+ backgroundToken: sole(backgroundTokens),
142
+ backgroundTokens,
81
143
  // Only meaningful when a palette exists; an app with no tokens can't violate one.
82
- offTheme: p.byColor.size > 0 && (colorOff || bgOff),
83
- tokenCount: p.byColor.size,
144
+ offTheme: tokenCount > 0 && (colorOff || bgOff),
145
+ tokenCount,
146
+ themeScope: themeScope(),
84
147
  };
85
148
  }
86
- /** Drop the cached palette (call if the app swaps themes at runtime). */
87
- export function resetThemeCache() {
88
- cached = null;
89
- }
@@ -1,5 +1,6 @@
1
1
  import { EventType, TRANSPORT_LIMITS } from '@reticlehq/core';
2
2
  import { safeStringify } from '../security/serialization.js';
3
+ import { requireCapturedMethod } from '../util/captured-method.js';
3
4
  const METHOD_EVENT = {
4
5
  log: EventType.CONSOLE_LOG,
5
6
  warn: EventType.CONSOLE_WARN,
@@ -41,8 +42,10 @@ export function installConsole(emit) {
41
42
  const originals = new Map();
42
43
  const patched = new Map();
43
44
  for (const method of methods) {
44
- // Store the true original for teardown identity; call through a bound copy.
45
- const original = console[method];
45
+ // Store the true original for teardown identity; call through a bound copy. Read as a stored
46
+ // VALUE rather than a method reference — see capturedMethod for why that distinction is the
47
+ // honest way to say "I am detaching this deliberately and putting it back".
48
+ const original = requireCapturedMethod(console, method);
46
49
  originals.set(method, original);
47
50
  const callOriginal = original.bind(console);
48
51
  const wrapper = (...args) => {
@@ -3,6 +3,7 @@ import { captureMethod } from '../patching/capture-method.js';
3
3
  import { isCapturableType, projectBody, withBodyDeadline } from './network-body.js';
4
4
  import { redactUrl } from './network-redact.js';
5
5
  import { watchStreamedBody } from './network-stream.js';
6
+ import { requireCapturedMethod } from '../util/captured-method.js';
6
7
  // Redaction moved to its own cohesive module (network.ts is at its line cap); re-exported so callers
7
8
  // and the existing test suite keep importing it from here.
8
9
  export { redactUrl };
@@ -195,7 +196,7 @@ export function installNetwork(emit, opts = {}) {
195
196
  const reinterpret = opts.reinterpret;
196
197
  // Keep the true original for teardown identity, plus a window-bound copy to invoke
197
198
  // (fetch throws "Illegal invocation" if called with the wrong `this`).
198
- const origFetch = window.fetch;
199
+ const origFetch = requireCapturedMethod(window, 'fetch');
199
200
  const callFetch = origFetch.bind(window);
200
201
  // Declare it if we are not the first to wrap fetch.
201
202
  //
@@ -320,8 +321,9 @@ export function installNetwork(emit, opts = {}) {
320
321
  }
321
322
  };
322
323
  // Remember it, so a later install recognises our own wrapper instead of reporting the app.
323
- OURS.add(window.fetch);
324
- const patchedFetch = window.fetch;
324
+ // Our own wrapper, read the same way — it is a value we stored a line ago, not a method call.
325
+ const patchedFetch = requireCapturedMethod(window, 'fetch');
326
+ OURS.add(patchedFetch);
325
327
  const meta = new WeakMap();
326
328
  const proto = XMLHttpRequest.prototype;
327
329
  const origOpen = captureMethod(proto, 'open');
@@ -6,8 +6,8 @@
6
6
  */
7
7
  /** Structural emitter the host app's components depend on. */
8
8
  export interface ReticleEmitter {
9
- signal(name: string, data?: Record<string, unknown>): void;
10
- state(name: string, value: unknown): void;
9
+ signal: (name: string, data?: Record<string, unknown>) => void;
10
+ state: (name: string, value: unknown) => void;
11
11
  }
12
12
  /** The subset of the reticle singleton an emitter proxies to (lets tests inject a fake). */
13
13
  export interface EmitterTarget {
package/dist/reticle.js CHANGED
@@ -2,6 +2,7 @@ import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_
2
2
  import { rememberSessionLabel } from './session-continuity.js';
3
3
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
4
4
  import { Transport } from './transport/transport.js';
5
+ import { unreachableMessage } from './transport/unreachable-message.js';
5
6
  import { adapterNames } from './registry/adapters.js';
6
7
  import { registerCapabilities, setCapabilitiesListener, hasCapabilities, } from './registry/capabilities.js';
7
8
  import { installAllObservers } from './observers/install-all.js';
@@ -228,13 +229,10 @@ export class Reticle {
228
229
  this.#presenter.setState(SessionState.ENDED, BRIDGE_LOST_SUMMARY);
229
230
  }
230
231
  },
231
- // First-connect never succeeded ⇒ the bridge is unreachable at this URL. Tell the developer
232
- // exactly what went wrong and how to fix it, instead of retrying silently forever.
232
+ // First-connect never succeeded ⇒ this page's socket is not opening at this URL. Say what the
233
+ // page observed rather than guessing at the daemon, which it cannot see from in here.
233
234
  onUnreachable: ({ url: tried, attempts }) => {
234
- nativeWarn(`[Reticle] could not reach the bridge at ${tried} after ${String(attempts)} attempts. ` +
235
- `Is the Reticle daemon running on that port? If your app runs in a container/devcontainer/WSL, ` +
236
- `the daemon is on a different host — set the WS URL explicitly (Vite: VITE_RETICLE_WS_URL, ` +
237
- `or reticle.connect({ url })). Still retrying…`);
235
+ nativeWarn(unreachableMessage(tried, attempts));
238
236
  },
239
237
  });
240
238
  // Capabilities registered AFTER connect (which is when they are registered, by design) must
@@ -2,6 +2,7 @@
2
2
  // can deterministically advance time (toasts, debounces, auto-dismiss, commit-on-blur).
3
3
  // We do NOT patch requestAnimationFrame/microtasks/MessageChannel — React's scheduler relies
4
4
  // on those, and freezing them would stall the page. Opt-in + reversible.
5
+ import { requireCapturedMethod } from '../util/captured-method.js';
5
6
  let installed = false;
6
7
  let virtualNow = 0;
7
8
  let realBase = 0;
@@ -17,12 +18,14 @@ export function freezeClock() {
17
18
  installed = true;
18
19
  virtualNow = 0;
19
20
  realBase = Date.now();
21
+ // Captured as stored VALUES, so teardown restores the same function objects the page had. See
22
+ // capturedMethod: these are deliberate unbound captures and the descriptor read says so.
20
23
  originals = {
21
- setTimeout: window.setTimeout,
22
- clearTimeout: window.clearTimeout,
23
- setInterval: window.setInterval,
24
- clearInterval: window.clearInterval,
25
- dateNow: Date.now,
24
+ setTimeout: requireCapturedMethod(window, 'setTimeout'),
25
+ clearTimeout: requireCapturedMethod(window, 'clearTimeout'),
26
+ setInterval: requireCapturedMethod(window, 'setInterval'),
27
+ clearInterval: requireCapturedMethod(window, 'clearInterval'),
28
+ dateNow: requireCapturedMethod(Date, 'now'),
26
29
  };
27
30
  const schedule = (cb, delay, interval) => {
28
31
  const id = seq;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What the page says when its websocket never opened.
3
+ *
4
+ * Pulled out of the connect path so it is a pure string function with a test on its CLAIMS rather
5
+ * than a template inlined in a warn call. The rule it exists to hold: report what this page observed
6
+ * and what it could not determine, never a cause. See unreachable-message.test.ts.
7
+ */
8
+ /**
9
+ * The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
10
+ * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
+ */
12
+ export declare function unreachableMessage(url: string, attempts: number): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What the page says when its websocket never opened.
3
+ *
4
+ * Pulled out of the connect path so it is a pure string function with a test on its CLAIMS rather
5
+ * than a template inlined in a warn call. The rule it exists to hold: report what this page observed
6
+ * and what it could not determine, never a cause. See unreachable-message.test.ts.
7
+ */
8
+ /**
9
+ * The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
10
+ * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
+ */
12
+ export function unreachableMessage(url, attempts) {
13
+ return (`[Reticle] this page could not open a websocket to ${url}. ${String(attempts)} attempts, all ` +
14
+ `failed. That is everything the page can see: from inside the browser it cannot tell a daemon ` +
15
+ `that is not there from one it is not allowed to reach, so this is not evidence about the ` +
16
+ `daemon. What answers it: run \`npx @reticlehq/server status\` to see whether one is listening ` +
17
+ `and on which port. If your app runs in a container, devcontainer or WSL, the daemon is on a ` +
18
+ `different host, so set the URL explicitly (Vite: VITE_RETICLE_WS_URL, or ` +
19
+ `reticle.connect({ url })). An https page cannot open a ws:// socket at all. Still retrying…`);
20
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read a host object's method as a STORED VALUE, for code that has to put it back.
3
+ *
4
+ * Every observer here patches a global and must be able to undo it: `console.error`, `window.fetch`,
5
+ * `window.setTimeout`, `Date.now`. Undoing means holding the true original — the same function
6
+ * object, so teardown restores exactly what was there and a later install can recognise it by
7
+ * identity — and calling through a separately bound copy, because a host method invoked with the
8
+ * wrong receiver throws (`fetch` gives "Illegal invocation").
9
+ *
10
+ * Written as `const original = window.fetch`, that is a bare unbound method reference, and
11
+ * `@typescript-eslint/unbound-method` is right to flag the SHAPE even though this use is deliberate:
12
+ * the rule cannot see that the next line binds it, nor that the raw value only ever goes back into
13
+ * the slot it came from.
14
+ *
15
+ * The two obvious ways out are both wrong here. An `eslint-disable` is against this repo's standing
16
+ * rule and hides the shape from every future reader. Annotating `this: void` asserts the function
17
+ * does not use its receiver — which for a console method or `fetch` is simply false, and is the very
18
+ * reason the binding exists.
19
+ *
20
+ * So the intent is expressed instead of suppressed. A property descriptor's `value` is a stored
21
+ * value, not a method being detached from its object, which is exactly the distinction the rule is
22
+ * drawing and exactly what this code wants. It is also strictly more accurate for the restore case:
23
+ * it reads what the slot HOLDS rather than what calling the property would do, so an accessor
24
+ * defined on the object cannot be silently flattened into its result.
25
+ *
26
+ * Returns undefined when the property is absent or inherited rather than own — a caller that needs a
27
+ * fallback to the prototype chain should say so at its own call site rather than have it hidden here.
28
+ */
29
+ export declare function capturedMethod<T>(target: object, key: PropertyKey): T | undefined;
30
+ /**
31
+ * The same, for a property this code knows is present — the globals every observer patches.
32
+ *
33
+ * Throws rather than returning a broken teardown. A missing `window.fetch` is not a condition any
34
+ * caller here can recover from, and silently installing a patch that cannot be undone is worse than
35
+ * refusing: the page keeps the wrapper for the rest of its life with nothing able to remove it.
36
+ */
37
+ export declare function requireCapturedMethod<T>(target: object, key: PropertyKey): T;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read a host object's method as a STORED VALUE, for code that has to put it back.
3
+ *
4
+ * Every observer here patches a global and must be able to undo it: `console.error`, `window.fetch`,
5
+ * `window.setTimeout`, `Date.now`. Undoing means holding the true original — the same function
6
+ * object, so teardown restores exactly what was there and a later install can recognise it by
7
+ * identity — and calling through a separately bound copy, because a host method invoked with the
8
+ * wrong receiver throws (`fetch` gives "Illegal invocation").
9
+ *
10
+ * Written as `const original = window.fetch`, that is a bare unbound method reference, and
11
+ * `@typescript-eslint/unbound-method` is right to flag the SHAPE even though this use is deliberate:
12
+ * the rule cannot see that the next line binds it, nor that the raw value only ever goes back into
13
+ * the slot it came from.
14
+ *
15
+ * The two obvious ways out are both wrong here. An `eslint-disable` is against this repo's standing
16
+ * rule and hides the shape from every future reader. Annotating `this: void` asserts the function
17
+ * does not use its receiver — which for a console method or `fetch` is simply false, and is the very
18
+ * reason the binding exists.
19
+ *
20
+ * So the intent is expressed instead of suppressed. A property descriptor's `value` is a stored
21
+ * value, not a method being detached from its object, which is exactly the distinction the rule is
22
+ * drawing and exactly what this code wants. It is also strictly more accurate for the restore case:
23
+ * it reads what the slot HOLDS rather than what calling the property would do, so an accessor
24
+ * defined on the object cannot be silently flattened into its result.
25
+ *
26
+ * Returns undefined when the property is absent or inherited rather than own — a caller that needs a
27
+ * fallback to the prototype chain should say so at its own call site rather than have it hidden here.
28
+ */
29
+ export function capturedMethod(target, key) {
30
+ return Object.getOwnPropertyDescriptor(target, key)?.value;
31
+ }
32
+ /**
33
+ * The same, for a property this code knows is present — the globals every observer patches.
34
+ *
35
+ * Throws rather than returning a broken teardown. A missing `window.fetch` is not a condition any
36
+ * caller here can recover from, and silently installing a patch that cannot be undone is worse than
37
+ * refusing: the page keeps the wrapper for the rest of its life with nothing able to remove it.
38
+ */
39
+ export function requireCapturedMethod(target, key) {
40
+ const value = capturedMethod(target, key);
41
+ if (value === undefined) {
42
+ throw new Error(`[Reticle] cannot patch ${String(key)}: it is not an own property of the target`);
43
+ }
44
+ return value;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,13 +14,13 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.7.0"
17
+ "@reticlehq/core": "2.8.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@tanstack/query-core": "^5.101.4",
21
- "@types/jsdom": "^28.0.3",
21
+ "@types/jsdom": "^30.0.0",
22
22
  "jotai": "^2.20.2",
23
- "jsdom": "^29.1.1",
23
+ "jsdom": "^30.0.1",
24
24
  "mobx": "^7.0.0",
25
25
  "pinia": "^4.0.2",
26
26
  "react": "^19.2.8",