@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 +2 -2
- package/dist/commands/commands.js +7 -0
- package/dist/dom/desktop-capture.js +20 -3
- package/dist/dom/refs.d.ts +28 -0
- package/dist/dom/refs.js +87 -0
- package/dist/dom/theme.d.ts +33 -10
- package/dist/dom/theme.js +109 -50
- package/dist/observers/console.js +5 -2
- package/dist/observers/network.js +5 -3
- package/dist/registry/emitter.d.ts +2 -2
- package/dist/reticle.js +4 -6
- package/dist/timers/clock.js +8 -5
- package/dist/transport/unreachable-message.d.ts +12 -0
- package/dist/transport/unreachable-message.js +20 -0
- package/dist/util/captured-method.d.ts +37 -0
- package/dist/util/captured-method.js +45 -0
- package/package.json +4 -4
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(
|
|
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.
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
}
|
package/dist/dom/refs.d.ts
CHANGED
|
@@ -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);
|
package/dist/dom/theme.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
* color/background
|
|
6
|
-
* signal no outside-the-page tool has
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
5
|
-
* color/background
|
|
6
|
-
* signal no outside-the-page tool has
|
|
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
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
+
names.add(prop);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
return
|
|
53
|
+
return names;
|
|
53
54
|
}
|
|
54
|
-
/**
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const colorOff = !isTransparent(cs.color) &&
|
|
77
|
-
const bgOff = !isTransparent(cs.backgroundColor) &&
|
|
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
|
-
|
|
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:
|
|
83
|
-
tokenCount
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
const patchedFetch = window
|
|
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>)
|
|
10
|
-
state(name: string, value: unknown)
|
|
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 ⇒
|
|
232
|
-
//
|
|
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(
|
|
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
|
package/dist/timers/clock.js
CHANGED
|
@@ -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
|
|
22
|
-
clearTimeout: window
|
|
23
|
-
setInterval: window
|
|
24
|
-
clearInterval: window
|
|
25
|
-
dateNow: Date
|
|
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.
|
|
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.
|
|
17
|
+
"@reticlehq/core": "2.8.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@tanstack/query-core": "^5.101.4",
|
|
21
|
-
"@types/jsdom": "^
|
|
21
|
+
"@types/jsdom": "^30.0.0",
|
|
22
22
|
"jotai": "^2.20.2",
|
|
23
|
-
"jsdom": "^
|
|
23
|
+
"jsdom": "^30.0.1",
|
|
24
24
|
"mobx": "^7.0.0",
|
|
25
25
|
"pinia": "^4.0.2",
|
|
26
26
|
"react": "^19.2.8",
|