@reticlehq/browser 2.13.0 → 2.14.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/actions/actions-dom.d.ts +0 -20
- package/dist/actions/actions-dom.js +0 -66
- package/dist/actions/actions-press.d.ts +10 -0
- package/dist/actions/actions-press.js +77 -0
- package/dist/actions/actions.d.ts +0 -89
- package/dist/actions/actions.js +24 -358
- package/dist/actions/appeared-text.d.ts +0 -20
- package/dist/actions/appeared-text.js +0 -36
- package/dist/actions/danger-context.d.ts +3 -0
- package/dist/actions/danger-context.js +39 -0
- package/dist/actions/scroll.d.ts +0 -9
- package/dist/actions/scroll.js +0 -7
- package/dist/actions/synthetic-input.d.ts +0 -7
- package/dist/actions/synthetic-input.js +0 -27
- package/dist/actions/value-input.d.ts +0 -30
- package/dist/actions/value-input.js +0 -33
- package/dist/commands/commands.d.ts +0 -2
- package/dist/commands/commands.js +0 -67
- package/dist/connect-options.d.ts +2 -96
- package/dist/dom/a11y.d.ts +0 -29
- package/dist/dom/a11y.js +1 -88
- package/dist/dom/auto-anchor.d.ts +0 -40
- package/dist/dom/auto-anchor.js +0 -33
- package/dist/dom/chart.d.ts +0 -61
- package/dist/dom/chart.js +0 -60
- package/dist/dom/desktop-capture.d.ts +0 -30
- package/dist/dom/desktop-capture.js +0 -72
- package/dist/dom/dom-ignore.d.ts +0 -30
- package/dist/dom/dom-ignore.js +0 -47
- package/dist/dom/occlusion.d.ts +0 -7
- package/dist/dom/occlusion.js +0 -7
- package/dist/dom/query.d.ts +0 -14
- package/dist/dom/query.js +48 -214
- package/dist/dom/realm.d.ts +0 -20
- package/dist/dom/realm.js +0 -28
- package/dist/dom/refs.d.ts +0 -76
- package/dist/dom/refs.js +2 -133
- package/dist/dom/shadow-registry.d.ts +0 -33
- package/dist/dom/shadow-registry.js +2 -72
- package/dist/dom/snapshot.d.ts +2 -43
- package/dist/dom/snapshot.js +26 -151
- package/dist/dom/source.d.ts +0 -44
- package/dist/dom/source.js +0 -45
- package/dist/dom/theme.d.ts +0 -37
- package/dist/dom/theme.js +2 -52
- package/dist/dom/virtualized.d.ts +0 -17
- package/dist/dom/virtualized.js +0 -27
- package/dist/edit-epoch.d.ts +0 -22
- package/dist/edit-epoch.js +0 -52
- package/dist/index.js +1 -23
- package/dist/observers/animation.d.ts +0 -1
- package/dist/observers/animation.js +0 -3
- package/dist/observers/blind-spots.d.ts +0 -26
- package/dist/observers/blind-spots.js +2 -61
- package/dist/observers/console.d.ts +1 -2
- package/dist/observers/console.js +41 -30
- package/dist/observers/context-open.d.ts +1 -11
- package/dist/observers/context-open.js +10 -14
- package/dist/observers/departure.d.ts +3 -0
- package/dist/observers/departure.js +52 -0
- package/dist/observers/dialogs.d.ts +2 -0
- package/dist/observers/dialogs.js +39 -0
- package/dist/observers/dom.js +0 -44
- package/dist/observers/download.d.ts +1 -2
- package/dist/observers/download.js +38 -55
- package/dist/observers/focus.d.ts +0 -6
- package/dist/observers/focus.js +0 -7
- package/dist/observers/frames.d.ts +0 -9
- package/dist/observers/frames.js +1 -28
- package/dist/observers/health.d.ts +0 -5
- package/dist/observers/health.js +1 -33
- package/dist/observers/install-all.d.ts +2 -0
- package/dist/observers/install-all.js +26 -27
- package/dist/observers/ipc.d.ts +0 -25
- package/dist/observers/ipc.js +0 -65
- package/dist/observers/navigation.d.ts +2 -0
- package/dist/observers/navigation.js +33 -0
- package/dist/observers/network-body.d.ts +5 -28
- package/dist/observers/network-body.js +11 -108
- package/dist/observers/network-redact.d.ts +1 -20
- package/dist/observers/network-redact.js +1 -80
- package/dist/observers/network-stream.d.ts +0 -21
- package/dist/observers/network-stream.js +1 -44
- package/dist/observers/network.d.ts +2 -26
- package/dist/observers/network.js +177 -294
- package/dist/observers/perf.d.ts +0 -10
- package/dist/observers/perf.js +0 -16
- package/dist/observers/route.d.ts +1 -2
- package/dist/observers/route.js +13 -19
- package/dist/observers/scroll.d.ts +0 -1
- package/dist/observers/scroll.js +0 -9
- package/dist/observers/sdk-failure.d.ts +1 -9
- package/dist/observers/sdk-failure.js +1 -30
- package/dist/observers/state.d.ts +1 -11
- package/dist/observers/state.js +13 -41
- package/dist/observers/storage.d.ts +0 -14
- package/dist/observers/storage.js +10 -34
- package/dist/observers/types.d.ts +0 -15
- package/dist/observers/types.js +0 -14
- package/dist/patching/capture-method.d.ts +0 -24
- package/dist/patching/capture-method.js +0 -26
- package/dist/presenter/overlay.d.ts +0 -1
- package/dist/presenter/overlay.js +0 -1
- package/dist/presenter/presenter-brand.d.ts +0 -19
- package/dist/presenter/presenter-brand.js +0 -21
- package/dist/presenter/presenter-config.d.ts +1 -105
- package/dist/presenter/presenter-config.js +2 -91
- package/dist/presenter/presenter-controls.d.ts +0 -29
- package/dist/presenter/presenter-controls.js +3 -131
- package/dist/presenter/presenter-dock-layout.d.ts +0 -3
- package/dist/presenter/presenter-dock-layout.js +0 -4
- package/dist/presenter/presenter-drag.d.ts +2 -24
- package/dist/presenter/presenter-drag.js +0 -26
- package/dist/presenter/presenter-effects.d.ts +0 -9
- package/dist/presenter/presenter-effects.js +0 -9
- package/dist/presenter/presenter-glow.d.ts +0 -21
- package/dist/presenter/presenter-glow.js +1 -27
- package/dist/presenter/presenter-heroicons-data.d.ts +0 -8
- package/dist/presenter/presenter-heroicons-data.js +0 -8
- package/dist/presenter/presenter-hud-chrome.d.ts +0 -10
- package/dist/presenter/presenter-hud-chrome.js +0 -10
- package/dist/presenter/presenter-icons.d.ts +0 -6
- package/dist/presenter/presenter-icons.js +0 -6
- package/dist/presenter/presenter-log.d.ts +0 -38
- package/dist/presenter/presenter-log.js +1 -45
- package/dist/presenter/presenter-report-copy.d.ts +5 -66
- package/dist/presenter/presenter-report-copy.js +5 -66
- package/dist/presenter/presenter-report-styles.d.ts +0 -6
- package/dist/presenter/presenter-report-styles.js +6 -6
- package/dist/presenter/presenter-report.d.ts +4 -7
- package/dist/presenter/presenter-report.js +18 -76
- package/dist/presenter/presenter-run-state.d.ts +0 -7
- package/dist/presenter/presenter-run-state.js +0 -5
- package/dist/presenter/presenter-settings-styles.d.ts +0 -1
- package/dist/presenter/presenter-settings-styles.js +0 -1
- package/dist/presenter/presenter-settings.d.ts +1 -23
- package/dist/presenter/presenter-settings.js +1 -31
- package/dist/presenter/presenter-shell-styles.js +2 -5
- package/dist/presenter/presenter-shell.d.ts +2 -18
- package/dist/presenter/presenter-shell.js +1 -73
- package/dist/presenter/presenter-styles.d.ts +0 -9
- package/dist/presenter/presenter-styles.js +0 -9
- package/dist/presenter/presenter-tally.d.ts +0 -5
- package/dist/presenter/presenter-tally.js +0 -4
- package/dist/presenter/presenter-test-helpers.d.ts +0 -2
- package/dist/presenter/presenter-test-helpers.js +0 -2
- package/dist/presenter/presenter-verbs.js +0 -7
- package/dist/presenter/presenter-workspace.d.ts +0 -4
- package/dist/presenter/presenter-workspace.js +1 -8
- package/dist/presenter/presenter.d.ts +0 -60
- package/dist/presenter/presenter.js +4 -118
- package/dist/recorder/recorder-styles.d.ts +0 -6
- package/dist/recorder/recorder-styles.js +0 -6
- package/dist/recorder/recorder.d.ts +0 -34
- package/dist/recorder/recorder.js +0 -49
- package/dist/registry/adapters.d.ts +0 -10
- package/dist/registry/adapters.js +0 -10
- package/dist/registry/auto-testids.d.ts +0 -19
- package/dist/registry/auto-testids.js +1 -21
- package/dist/registry/capabilities.d.ts +0 -31
- package/dist/registry/capabilities.js +1 -46
- package/dist/registry/commit-and-signal.d.ts +0 -6
- package/dist/registry/commit-and-signal.js +1 -7
- package/dist/registry/domains.d.ts +0 -8
- package/dist/registry/domains.js +0 -7
- package/dist/registry/emitter.d.ts +0 -14
- package/dist/registry/emitter.js +0 -10
- package/dist/registry/store-adapters.d.ts +0 -186
- package/dist/registry/store-adapters.js +0 -163
- package/dist/registry/stores.d.ts +0 -33
- package/dist/registry/stores.js +0 -59
- package/dist/reticle-presenter-helpers.d.ts +0 -13
- package/dist/reticle-presenter-helpers.js +0 -14
- package/dist/reticle.d.ts +1 -52
- package/dist/reticle.js +20 -145
- package/dist/review/annotator-styles.js +0 -7
- package/dist/review/annotator.d.ts +2 -13
- package/dist/review/annotator.js +0 -40
- package/dist/review/mark-anchor.d.ts +0 -11
- package/dist/review/mark-anchor.js +0 -8
- package/dist/security/serialization.d.ts +0 -20
- package/dist/security/serialization.js +1 -66
- package/dist/session-continuity.d.ts +0 -16
- package/dist/session-continuity.js +1 -20
- package/dist/timers/clock.d.ts +1 -14
- package/dist/timers/clock.js +59 -60
- package/dist/timers/native-console.js +0 -4
- package/dist/timers/native-timers.d.ts +0 -25
- package/dist/timers/native-timers.js +3 -33
- package/dist/transport/transport.d.ts +0 -51
- package/dist/transport/transport.js +3 -129
- package/dist/transport/unreachable-message.d.ts +0 -17
- package/dist/transport/unreachable-message.js +0 -17
- package/dist/util/captured-method.d.ts +0 -35
- package/dist/util/captured-method.js +0 -35
- package/package.json +2 -2
package/dist/dom/snapshot.js
CHANGED
|
@@ -20,62 +20,35 @@ const INTERACTIVE = new Set([
|
|
|
20
20
|
'option',
|
|
21
21
|
]);
|
|
22
22
|
const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head', 'meta', 'link']);
|
|
23
|
-
/** Roles whose whole purpose is announcing a change; both imply an implicit aria-live. */
|
|
24
23
|
const ANNOUNCE_ROLES = new Set(['alert', 'status']);
|
|
25
|
-
/**
|
|
26
|
-
* Is this element the app SAYING something changed?
|
|
27
|
-
*
|
|
28
|
-
* INTERACTIVE mode keeps only actionable elements, which silently drops the one node that explains
|
|
29
|
-
* why an action did nothing — the error the app just rendered. An agent told to prefer the cheaper
|
|
30
|
-
* mode was structurally blind to the failure it had caused: the click reports settled with a DOM
|
|
31
|
-
* mutation, and the lean snapshot shows the same controls as before. Live regions exist precisely
|
|
32
|
-
* to announce state changes to a consumer that cannot see the screen, which is exactly the agent,
|
|
33
|
-
* so they are the principled exception — and a bounded one, since a page has very few.
|
|
34
|
-
*/
|
|
35
24
|
function announces(el, role) {
|
|
36
25
|
if (ANNOUNCE_ROLES.has(role))
|
|
37
26
|
return true;
|
|
38
27
|
const live = el.getAttribute('aria-live');
|
|
39
28
|
return live !== null && 'off' !== live;
|
|
40
29
|
}
|
|
41
|
-
/** Cap on inlined text content so a verbose node can't blow up the snapshot. */
|
|
42
30
|
const TEXT_MAX = 80;
|
|
43
|
-
/**
|
|
44
|
-
* Concatenated DIRECT text of an element (its own text nodes, not descendants' — those are
|
|
45
|
-
* captured when their own element is walked, so no duplication). Collapsed + truncated.
|
|
46
|
-
* This is what makes a silent removal of non-interactive content (e.g. a KPI card) visible:
|
|
47
|
-
* the accessibility role tree alone omits generic containers' text.
|
|
48
|
-
*/
|
|
49
31
|
function directText(el) {
|
|
50
32
|
let out = '';
|
|
51
33
|
for (const node of el.childNodes) {
|
|
52
|
-
if (3 === node.nodeType
|
|
34
|
+
if (3 === node.nodeType)
|
|
53
35
|
out += node.textContent ?? '';
|
|
54
36
|
}
|
|
55
37
|
const collapsed = out.replace(/\s+/g, ' ').trim();
|
|
56
38
|
return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
|
|
57
39
|
}
|
|
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
40
|
export const MAX_UNREAD_BRANCHES = 50;
|
|
68
|
-
|
|
41
|
+
function hiddenByMarkup(el) {
|
|
42
|
+
if ('true' === el.getAttribute('aria-hidden'))
|
|
43
|
+
return true;
|
|
44
|
+
return el instanceof HTMLElement && el.hidden;
|
|
45
|
+
}
|
|
69
46
|
function skipEarly(el) {
|
|
70
47
|
if (SKIP_TAGS.has(el.tagName.toLowerCase()))
|
|
71
48
|
return true;
|
|
72
49
|
if (isIgnored(el))
|
|
73
|
-
return true; // Reticle overlay + known dev overlays
|
|
74
|
-
if ('true' === el.getAttribute('aria-hidden'))
|
|
75
50
|
return true;
|
|
76
|
-
|
|
77
|
-
return true;
|
|
78
|
-
return false;
|
|
51
|
+
return hiddenByMarkup(el);
|
|
79
52
|
}
|
|
80
53
|
function stateSuffix(el) {
|
|
81
54
|
const states = getStates(el).filter((s) => s === ElementState.DISABLED ||
|
|
@@ -84,42 +57,30 @@ function stateSuffix(el) {
|
|
|
84
57
|
s === ElementState.FOCUSED);
|
|
85
58
|
return states.length > 0 ? ` [${states.join(',')}]` : '';
|
|
86
59
|
}
|
|
87
|
-
function formatLine(el, depth, role, name, layout) {
|
|
60
|
+
function formatLine(el, depth, role, name, layout, ownText = '') {
|
|
88
61
|
const indent = ' '.repeat(depth);
|
|
89
62
|
const value = getValue(el);
|
|
90
|
-
// A testid stands in for the accessible name when there is none. Including a role-less control and
|
|
91
|
-
// then printing it as a bare `- generic` is worse than leaving it out: it is visible and it cannot
|
|
92
|
-
// be addressed. This is the name its author gave it.
|
|
93
63
|
const label = name;
|
|
94
64
|
const namePart = label.length > 0 ? ` "${label}"` : '';
|
|
95
65
|
const refPart = INTERACTIVE.has(role) || label.length > 0 ? ` (ref=${refs.refFor(el)})` : '';
|
|
96
66
|
const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
|
|
97
67
|
const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
|
|
98
|
-
|
|
68
|
+
const textPart = ownText.length > 0 ? ` [text="${ownText}"]` : '';
|
|
69
|
+
return `${indent}- ${role}${namePart}${refPart}${valuePart}${textPart}${layoutPart}${stateSuffix(el)}`;
|
|
99
70
|
}
|
|
100
|
-
/** A generic container's own text content, with no ref (kept lean — text isn't actionable). */
|
|
101
71
|
function formatTextLine(depth, text) {
|
|
102
72
|
return `${' '.repeat(depth)}- text "${text}"`;
|
|
103
73
|
}
|
|
104
|
-
/**
|
|
105
|
-
* Compact signature of an element's own layout when it is a grid/flex container. A layout
|
|
106
|
-
* regression (e.g. grid columns 2 -> 3) leaves the role+text tree identical, so a role-only
|
|
107
|
-
* snapshot is blind to it; this line makes it visible. Empty for non-container elements.
|
|
108
|
-
*/
|
|
109
74
|
function layoutSignature(style) {
|
|
110
75
|
if (null === style)
|
|
111
76
|
return '';
|
|
112
77
|
const display = style.display;
|
|
113
|
-
// Grid track templates are the high-signal CLS case (column/row count + sizing) and there are
|
|
114
|
-
// few grid containers per page. Flex is intentionally excluded: nearly every row is a flex
|
|
115
|
-
// box, so signing them all floods the snapshot for little regression value.
|
|
116
78
|
if ('grid' === display || 'inline-grid' === display) {
|
|
117
79
|
const cols = style.gridTemplateColumns;
|
|
118
80
|
return cols !== '' && cols !== 'none' ? `grid-cols:${cols}` : 'grid';
|
|
119
81
|
}
|
|
120
82
|
return '';
|
|
121
83
|
}
|
|
122
|
-
/** Record a cut branch on the frontier, declaring an overflow rather than shortening it silently. */
|
|
123
84
|
function recordUnread(branches, ctx) {
|
|
124
85
|
for (const branch of branches) {
|
|
125
86
|
if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
|
|
@@ -129,23 +90,11 @@ function recordUnread(branches, ctx) {
|
|
|
129
90
|
ctx.unread.push(refs.refFor(branch));
|
|
130
91
|
}
|
|
131
92
|
}
|
|
132
|
-
/**
|
|
133
|
-
* The children to walk under `parent`, piercing boundaries a plain `.children` misses: an OPEN shadow
|
|
134
|
-
* root renders as part of the element (web components / design systems), and a SAME-ORIGIN iframe's
|
|
135
|
-
* body is real content. Cross-origin frames throw on access and are skipped by design. Without this,
|
|
136
|
-
* an entire category of modern apps is invisible to reticle_snapshot (systematic false negative).
|
|
137
|
-
*/
|
|
138
93
|
function pierceChildren(parent) {
|
|
139
94
|
const out = [...parent.children];
|
|
140
|
-
// A CLOSED root reports `shadowRoot === null` forever, but the registry captured it at the moment
|
|
141
|
-
// `attachShadow` returned it. `query` has been able to reach that content since; the snapshot could
|
|
142
|
-
// not, so the two tools disagreed about what is on the page — and the snapshot is the one an agent
|
|
143
|
-
// reads to decide what to query for, which makes its omission the one that actually costs.
|
|
144
95
|
const shadow = parent.shadowRoot ?? capturedRootOf(parent);
|
|
145
96
|
if (shadow !== null)
|
|
146
97
|
out.push(...shadow.children);
|
|
147
|
-
// Realm-aware: an iframe nested INSIDE another frame's body belongs to that frame's realm, so the
|
|
148
|
-
// top window's constructor never matches it and nested embeds were silently never pierced.
|
|
149
98
|
if (isFrame(parent)) {
|
|
150
99
|
try {
|
|
151
100
|
const body = parent.contentDocument?.body;
|
|
@@ -153,84 +102,39 @@ function pierceChildren(parent) {
|
|
|
153
102
|
out.push(...body.children);
|
|
154
103
|
}
|
|
155
104
|
catch {
|
|
156
|
-
/* cross-origin frame — inaccessible by design */
|
|
157
105
|
}
|
|
158
106
|
}
|
|
159
107
|
return out;
|
|
160
108
|
}
|
|
161
|
-
/** Emit one element (if it earns a line) and descend into it. Split out of `walk` so the completion
|
|
162
|
-
* re-read can start AT a branch root — the node the truncated walk stopped just before emitting. */
|
|
163
109
|
function visit(child, depth, ctx, inLive) {
|
|
164
|
-
if (skipEarly(child))
|
|
110
|
+
if (skipEarly(child)) {
|
|
111
|
+
if (hiddenByMarkup(child))
|
|
112
|
+
ctx.hiddenSkipped += 1;
|
|
165
113
|
return;
|
|
166
|
-
|
|
167
|
-
// display-none skip and the layout signature — was two forced style resolutions per node.
|
|
114
|
+
}
|
|
168
115
|
const view = child.ownerDocument.defaultView;
|
|
169
116
|
const style = view !== null ? view.getComputedStyle(child) : null;
|
|
170
|
-
if (style !== null && 'none' === style.display)
|
|
117
|
+
if (style !== null && 'none' === style.display) {
|
|
118
|
+
ctx.hiddenSkipped += 1;
|
|
171
119
|
return;
|
|
120
|
+
}
|
|
172
121
|
const role = getRole(child);
|
|
173
122
|
const name = getAccessibleName(child);
|
|
174
123
|
const interactive = INTERACTIVE.has(role);
|
|
175
|
-
// Announcements are exempt from leanness, and so is everything inside one: a live region whose
|
|
176
|
-
// message sits in a child element would otherwise be included as a contentless `- generic`.
|
|
177
124
|
const announce = inLive || announces(child, role);
|
|
178
125
|
const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
|
|
179
|
-
|
|
180
|
-
// so the actionable-only view stays lean while FULL/meaningful views see content regressions.
|
|
181
|
-
const text = !lean && 'generic' === role && 0 === name.length ? directText(child) : '';
|
|
182
|
-
// Layout signature for grid/flex containers — makes CLS/layout regressions visible.
|
|
126
|
+
const text = !lean && 0 === name.length ? directText(child) : '';
|
|
183
127
|
const layout = lean ? '' : layoutSignature(style);
|
|
184
|
-
// Actionable WITHOUT an interactive role, which is most of the real web.
|
|
185
|
-
//
|
|
186
|
-
// `interactive` is a role test, and a large share of production UI carries no roles at all.
|
|
187
|
-
// Measured on MarkText, a production Electron editor: `mode:"interactive"` returned an EMPTY tree
|
|
188
|
-
// where `mode:"full"` found 47 nodes, because its whole block picker is `<div>`s. An empty tree is
|
|
189
|
-
// indistinguishable from an empty page, and the tool description recommends this mode as the
|
|
190
|
-
// default — so the cheaper view told an agent there was nothing to drive.
|
|
191
|
-
//
|
|
192
|
-
// A `data-testid` ONLY. It is a handle its author put there to be driven, and it marks one element
|
|
193
|
-
// rather than a subtree.
|
|
194
|
-
//
|
|
195
|
-
// `cursor: pointer` was tried here as a second signal — it is how an app tells a HUMAN that a
|
|
196
|
-
// thing is clickable — and measured on MarkText it was a disaster: interactive went from 0 nodes
|
|
197
|
-
// and 72 tokens to 180 nodes and 870, against a FULL snapshot of 47 nodes and 424. The pointer
|
|
198
|
-
// cursor is inherited, so every wrapper div inside a clickable row matched, and the lean mode
|
|
199
|
-
// became twice the size of the complete one while adding nothing but nameless `- generic` lines.
|
|
200
|
-
// The mode's whole claim is that it is smaller; a signal that cannot tell a control from its
|
|
201
|
-
// ancestors cannot be used to decide what a control is.
|
|
202
|
-
// `data-testid` was tried here as a second signal, on the premise that it is a handle its author
|
|
203
|
-
// put there to be driven. Measured against our own instrumented bench app, the premise is false:
|
|
204
|
-
// the lean tree on its dashboard came to 16 nodes and 175 tokens, and EIGHT were display elements
|
|
205
|
-
// — kpi-deploys, kpi-success, kpi-p95, kpi-services, area-chart, activity-feed, brand. Half the
|
|
206
|
-
// "actionable" view was things nothing can act on, and the mode roughly doubled to carry them; the
|
|
207
|
-
// benchmark read it as a 4% verification-efficiency regression. A testid marks what a TEST cares
|
|
208
|
-
// about, which is a superset of what a driver can use.
|
|
209
|
-
//
|
|
210
|
-
// It did not even help the case it shipped for: MarkText's controls carry no testid. `leanSkipped`
|
|
211
|
-
// is what fixed that — an empty tree that says how many it passed over is no longer an empty page.
|
|
212
|
-
//
|
|
213
|
-
// `cursor: pointer` was tried too and was worse: interactive went from 0 nodes and 72 tokens to
|
|
214
|
-
// 180 and 870 against a FULL snapshot of 47/424, because the pointer cursor is inherited and every
|
|
215
|
-
// wrapper div inside a clickable row matched. A signal that cannot tell a control from its
|
|
216
|
-
// ancestors cannot decide what a control is.
|
|
217
128
|
const actionable = interactive;
|
|
218
|
-
// A testid still makes an element MEANINGFUL, so `full` keeps it and can name and address it. It
|
|
219
|
-
// just is not evidence that the element is ACTIONABLE, which is the only question lean mode asks.
|
|
220
129
|
const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
|
|
221
130
|
const include = lean ? actionable : meaningful;
|
|
222
|
-
// Counted, not just skipped. An empty INTERACTIVE tree is indistinguishable from an empty page,
|
|
223
|
-
// and on a real app the difference is everything: MarkText's block picker is 20+ live controls
|
|
224
|
-
// that carry no role, no testid and no pointer cursor, so the lean view is empty while the app is
|
|
225
|
-
// full of things to drive. Knowing how many were passed over is what turns "" into "look again in
|
|
226
|
-
// full mode" — see the note the snapshot tool builds from this.
|
|
227
131
|
if (lean && !include && meaningful)
|
|
228
132
|
ctx.leanSkipped += 1;
|
|
229
133
|
if (include) {
|
|
230
134
|
ctx.nodes += 1;
|
|
231
|
-
ctx.lines.push(text.length > 0 && 0 ===
|
|
135
|
+
ctx.lines.push('generic' === role && text.length > 0 && 0 === layout.length
|
|
232
136
|
? formatTextLine(depth, text)
|
|
233
|
-
: formatLine(child, depth, role, name, layout));
|
|
137
|
+
: formatLine(child, depth, role, name, layout, text));
|
|
234
138
|
walk(child, depth + 1, ctx, announce);
|
|
235
139
|
}
|
|
236
140
|
else {
|
|
@@ -244,8 +148,6 @@ function walk(parent, depth, ctx, inLive = false) {
|
|
|
244
148
|
for (let index = 0; index < children.length; index += 1) {
|
|
245
149
|
if (ctx.nodes >= ctx.maxNodes) {
|
|
246
150
|
ctx.truncated = true;
|
|
247
|
-
// Everything from here on is unread, not absent. Naming the frontier is what makes the cut
|
|
248
|
-
// recoverable — see `unread` on SnapshotResult.
|
|
249
151
|
recordUnread(children.slice(index), ctx);
|
|
250
152
|
return;
|
|
251
153
|
}
|
|
@@ -254,16 +156,6 @@ function walk(parent, depth, ctx, inLive = false) {
|
|
|
254
156
|
visit(child, depth, ctx, inLive);
|
|
255
157
|
}
|
|
256
158
|
}
|
|
257
|
-
/**
|
|
258
|
-
* The APP's open dialogs. Reticle's own panels are excluded, always.
|
|
259
|
-
*
|
|
260
|
-
* The HUD's chat and report panels carry `role="dialog"` because that is the correct role for what
|
|
261
|
-
* they are — but they are OUR surface, not the application's. Once the presenter became visible to
|
|
262
|
-
* the tool surface (so that Reticle can be used to check its own HUD), every snapshot of every page
|
|
263
|
-
* started reporting `visibleDialogs: ["Reticle agent chat"]`, telling the agent a modal was up when
|
|
264
|
-
* the app had none. An agent that believes a dialog is open dismisses it before doing anything else,
|
|
265
|
-
* which is a wasted action at best and a dismissed REAL dialog at worst.
|
|
266
|
-
*/
|
|
267
159
|
function collectDialogs(root) {
|
|
268
160
|
const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
|
|
269
161
|
const names = [];
|
|
@@ -275,40 +167,22 @@ function collectDialogs(root) {
|
|
|
275
167
|
}
|
|
276
168
|
return names;
|
|
277
169
|
}
|
|
278
|
-
/**
|
|
279
|
-
* Status for a snapshot, with uninformative defaults OMITTED.
|
|
280
|
-
*
|
|
281
|
-
* `reticle_act` already follows this rule — "fields at their uninformative default are omitted so a
|
|
282
|
-
* clean action collapses to its consequence" — and snapshot did not, so every response carried
|
|
283
|
-
* `visibleDialogs: []` whether or not a dialog existed. An empty array is the overwhelmingly common
|
|
284
|
-
* case, so the field was almost pure repetition; a reader learns nothing from its presence and
|
|
285
|
-
* everything from it.
|
|
286
|
-
*/
|
|
287
|
-
/**
|
|
288
|
-
* A focus-trap modal (Radix and friends) marks every sibling of the portal root aria-hidden. The
|
|
289
|
-
* snapshot walk correctly skips aria-hidden subtrees, so a whole-page snapshot can return only the
|
|
290
|
-
* overlay's own nodes and nothing else -- indistinguishable from "this page is empty". If the
|
|
291
|
-
* overlay's cleanup is stuck (common on a throttled tab), every later action then dispatches with no
|
|
292
|
-
* effect because the overlay genuinely captures pointer events. Say so instead of returning a
|
|
293
|
-
* near-empty tree with no explanation. (#397)
|
|
294
|
-
*/
|
|
295
170
|
function overlayHidingPage(root) {
|
|
296
|
-
// Only meaningful for a whole-page snapshot; a scoped snapshot is the subtree the caller chose.
|
|
297
171
|
if (root !== document.body)
|
|
298
172
|
return undefined;
|
|
299
173
|
const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
|
|
300
174
|
let modal;
|
|
175
|
+
let hiddenCandidate;
|
|
301
176
|
for (const d of dialogs) {
|
|
302
|
-
// Never OUR overlay. Reticle's HUD must not be able to explain the app's absence with itself:
|
|
303
|
-
// that turns "the page did not render" into "an overlay is covering it", which sends the reader
|
|
304
|
-
// to dismiss a panel that was never the problem.
|
|
305
177
|
if (isReticleOverlay(d))
|
|
306
178
|
continue;
|
|
307
179
|
if (isVisible(d)) {
|
|
308
180
|
modal = d;
|
|
309
181
|
break;
|
|
310
182
|
}
|
|
183
|
+
hiddenCandidate ??= d;
|
|
311
184
|
}
|
|
185
|
+
modal ??= hiddenCandidate;
|
|
312
186
|
if (modal === undefined)
|
|
313
187
|
return undefined;
|
|
314
188
|
const modalEl = modal;
|
|
@@ -331,13 +205,11 @@ function buildStatus(root) {
|
|
|
331
205
|
...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
|
|
332
206
|
};
|
|
333
207
|
}
|
|
334
|
-
/** Build the semantic accessibility snapshot of the page or a subtree. */
|
|
335
208
|
export function buildSnapshot(options = {}) {
|
|
336
209
|
const mode = options.mode ?? SnapshotMode.FULL;
|
|
337
210
|
const scopeEl = options.scope !== undefined
|
|
338
211
|
? (refs.resolve(options.scope) ?? document.querySelector(options.scope))
|
|
339
212
|
: document.body;
|
|
340
|
-
// A given-but-missing scope snapshots NOTHING and says so — never a silent whole-page fallback.
|
|
341
213
|
const scopeMissing = options.scope !== undefined && !(scopeEl instanceof Element);
|
|
342
214
|
const root = scopeEl instanceof Element ? scopeEl : document.body;
|
|
343
215
|
const status = buildStatus(root);
|
|
@@ -351,6 +223,7 @@ export function buildSnapshot(options = {}) {
|
|
|
351
223
|
lines: [],
|
|
352
224
|
nodes: 0,
|
|
353
225
|
leanSkipped: 0,
|
|
226
|
+
hiddenSkipped: 0,
|
|
354
227
|
truncated: false,
|
|
355
228
|
mode,
|
|
356
229
|
maxNodes: options.maxNodes ?? 400,
|
|
@@ -367,7 +240,9 @@ export function buildSnapshot(options = {}) {
|
|
|
367
240
|
status,
|
|
368
241
|
nodes: ctx.nodes,
|
|
369
242
|
truncated: ctx.truncated,
|
|
243
|
+
...(0 === ctx.nodes ? { domElements: root.querySelectorAll('*').length } : {}),
|
|
370
244
|
...(ctx.leanSkipped > 0 ? { leanSkipped: ctx.leanSkipped } : {}),
|
|
245
|
+
...(ctx.hiddenSkipped > 0 ? { hiddenSkipped: ctx.hiddenSkipped } : {}),
|
|
371
246
|
...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
|
|
372
247
|
...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
|
|
373
248
|
};
|
package/dist/dom/source.d.ts
CHANGED
|
@@ -1,53 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where an element is defined in the codebase.
|
|
3
|
-
*
|
|
4
|
-
* This is the single most valuable thing Reticle can hand an agent that has to fix what Reticle just
|
|
5
|
-
* found. Locating the right FILE is what turns a report into an edit; without it the agent pays for a
|
|
6
|
-
* search loop that the instrumentation already knows the answer to.
|
|
7
|
-
*
|
|
8
|
-
* Two lookups, because the callers have very different cost budgets:
|
|
9
|
-
*
|
|
10
|
-
* sourceFromDom — reads the babel-stamped `data-reticle-source` off the nearest ancestor. One
|
|
11
|
-
* native upward walk, bounded by tree depth. Safe to call per element on paths
|
|
12
|
-
* that describe many elements at once (query results, snapshots).
|
|
13
|
-
* sourceFor — prefers a framework adapter's answer (which knows the component that RENDERED
|
|
14
|
-
* the element, not just the nearest stamped host) and falls back to the DOM. The
|
|
15
|
-
* adapter path walks the fiber tree, so this belongs on single-element paths
|
|
16
|
-
* (inspect, act, review) and not in a loop.
|
|
17
|
-
*
|
|
18
|
-
* Both report source independently of how the element is ANCHORED. Anchor answers "how do I find this
|
|
19
|
-
* again", source answers "where is it written" — collapsing the two once meant that adding a
|
|
20
|
-
* data-testid, which Reticle's own docs recommend, silently removed the source pointer.
|
|
21
|
-
*/
|
|
22
1
|
export interface SourceLocation {
|
|
23
2
|
file: string;
|
|
24
3
|
line: number;
|
|
25
4
|
}
|
|
26
|
-
/** Parse a `data-reticle-source="file:line:column"` value into `{ file, line }` (column dropped). */
|
|
27
5
|
export declare function parseSourceAttr(value: string | null): SourceLocation | undefined;
|
|
28
|
-
/** The nearest babel-stamped source at or above this element. Cheap enough to call in bulk. */
|
|
29
6
|
export declare function sourceFromDom(el: Element): SourceLocation | undefined;
|
|
30
|
-
/** Best source for the element: the framework adapter's, else the nearest babel-stamped attribute. */
|
|
31
7
|
export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
|
|
32
|
-
/**
|
|
33
|
-
* Does ANY element in this document carry a source stamp?
|
|
34
|
-
*
|
|
35
|
-
* The difference between "this element has no stamp" and "the stamping loader is not running at
|
|
36
|
-
* all" — two answers that look identical from a single missing `source` field, and that call for
|
|
37
|
-
* completely different next actions. The first is ordinary: not every element sits under a stamped
|
|
38
|
-
* host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
|
|
39
|
-
* a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
|
|
40
|
-
* reading the adapter's source rather than from anything Reticle said.
|
|
41
|
-
*
|
|
42
|
-
* One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
|
|
43
|
-
* single-element path like inspect. It is only consulted when a source is already missing.
|
|
44
|
-
*/
|
|
45
8
|
export declare function documentHasSourceStamps(doc: Document): boolean;
|
|
46
|
-
/**
|
|
47
|
-
* `file:line` — the form an agent can act on directly.
|
|
48
|
-
*
|
|
49
|
-
* Deliberately a single compact string rather than an object: it costs a couple of dozen bytes on a
|
|
50
|
-
* descriptor that may repeat hundreds of times in one response, and every consumer of it is either
|
|
51
|
-
* opening an editor or printing it.
|
|
52
|
-
*/
|
|
53
9
|
export declare function formatSource(source: SourceLocation | undefined): string | undefined;
|
package/dist/dom/source.js
CHANGED
|
@@ -1,28 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where an element is defined in the codebase.
|
|
3
|
-
*
|
|
4
|
-
* This is the single most valuable thing Reticle can hand an agent that has to fix what Reticle just
|
|
5
|
-
* found. Locating the right FILE is what turns a report into an edit; without it the agent pays for a
|
|
6
|
-
* search loop that the instrumentation already knows the answer to.
|
|
7
|
-
*
|
|
8
|
-
* Two lookups, because the callers have very different cost budgets:
|
|
9
|
-
*
|
|
10
|
-
* sourceFromDom — reads the babel-stamped `data-reticle-source` off the nearest ancestor. One
|
|
11
|
-
* native upward walk, bounded by tree depth. Safe to call per element on paths
|
|
12
|
-
* that describe many elements at once (query results, snapshots).
|
|
13
|
-
* sourceFor — prefers a framework adapter's answer (which knows the component that RENDERED
|
|
14
|
-
* the element, not just the nearest stamped host) and falls back to the DOM. The
|
|
15
|
-
* adapter path walks the fiber tree, so this belongs on single-element paths
|
|
16
|
-
* (inspect, act, review) and not in a loop.
|
|
17
|
-
*
|
|
18
|
-
* Both report source independently of how the element is ANCHORED. Anchor answers "how do I find this
|
|
19
|
-
* again", source answers "where is it written" — collapsing the two once meant that adding a
|
|
20
|
-
* data-testid, which Reticle's own docs recommend, silently removed the source pointer.
|
|
21
|
-
*/
|
|
22
1
|
import { DATA_RETICLE_SOURCE_ATTR } from '@reticlehq/core';
|
|
23
|
-
/** Stamped by @reticlehq/babel-plugin / @reticlehq/vite-plugin in dev as `file:line:column`. */
|
|
24
2
|
const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
|
|
25
|
-
/** Parse a `data-reticle-source="file:line:column"` value into `{ file, line }` (column dropped). */
|
|
26
3
|
export function parseSourceAttr(value) {
|
|
27
4
|
if (null === value)
|
|
28
5
|
return undefined;
|
|
@@ -35,40 +12,18 @@ export function parseSourceAttr(value) {
|
|
|
35
12
|
return undefined;
|
|
36
13
|
return { file, line };
|
|
37
14
|
}
|
|
38
|
-
/** The nearest babel-stamped source at or above this element. Cheap enough to call in bulk. */
|
|
39
15
|
export function sourceFromDom(el) {
|
|
40
16
|
const host = el.closest(`[${SOURCE_ATTR}]`);
|
|
41
17
|
return host !== null ? parseSourceAttr(host.getAttribute(SOURCE_ATTR)) : undefined;
|
|
42
18
|
}
|
|
43
|
-
/** Best source for the element: the framework adapter's, else the nearest babel-stamped attribute. */
|
|
44
19
|
export function sourceFor(el, adapterSource) {
|
|
45
20
|
if (adapterSource !== undefined)
|
|
46
21
|
return { file: adapterSource.file, line: adapterSource.line };
|
|
47
22
|
return sourceFromDom(el);
|
|
48
23
|
}
|
|
49
|
-
/**
|
|
50
|
-
* Does ANY element in this document carry a source stamp?
|
|
51
|
-
*
|
|
52
|
-
* The difference between "this element has no stamp" and "the stamping loader is not running at
|
|
53
|
-
* all" — two answers that look identical from a single missing `source` field, and that call for
|
|
54
|
-
* completely different next actions. The first is ordinary: not every element sits under a stamped
|
|
55
|
-
* host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
|
|
56
|
-
* a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
|
|
57
|
-
* reading the adapter's source rather than from anything Reticle said.
|
|
58
|
-
*
|
|
59
|
-
* One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
|
|
60
|
-
* single-element path like inspect. It is only consulted when a source is already missing.
|
|
61
|
-
*/
|
|
62
24
|
export function documentHasSourceStamps(doc) {
|
|
63
25
|
return doc.querySelector(`[${SOURCE_ATTR}]`) !== null;
|
|
64
26
|
}
|
|
65
|
-
/**
|
|
66
|
-
* `file:line` — the form an agent can act on directly.
|
|
67
|
-
*
|
|
68
|
-
* Deliberately a single compact string rather than an object: it costs a couple of dozen bytes on a
|
|
69
|
-
* descriptor that may repeat hundreds of times in one response, and every consumer of it is either
|
|
70
|
-
* opening an editor or printing it.
|
|
71
|
-
*/
|
|
72
27
|
export function formatSource(source) {
|
|
73
28
|
return source === undefined ? undefined : `${source.file}:${String(source.line)}`;
|
|
74
29
|
}
|
package/dist/dom/theme.d.ts
CHANGED
|
@@ -1,48 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
|
|
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 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.
|
|
8
|
-
*
|
|
9
|
-
* Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
|
|
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.
|
|
24
|
-
*/
|
|
25
1
|
interface ThemeReport {
|
|
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
|
-
*/
|
|
32
2
|
colorToken: string | null;
|
|
33
|
-
/** Every design token whose resolved value is the text color. Empty when the color is off-palette. */
|
|
34
3
|
colorTokens: string[];
|
|
35
|
-
/** The one design token matching the background color, or null (same abstain-on-tie rule). */
|
|
36
4
|
backgroundToken: string | null;
|
|
37
|
-
/** Every design token whose resolved value is the background color. */
|
|
38
5
|
backgroundTokens: string[];
|
|
39
|
-
/** True when a set, opaque color matches NO design token — a theme violation worth surfacing. */
|
|
40
6
|
offTheme: boolean;
|
|
41
|
-
/** How many color tokens resolved under the active theme (0 ⇒ offTheme is never asserted). */
|
|
42
7
|
tokenCount: number;
|
|
43
|
-
/** Theme scope active at capture time, e.g. `.dark`, so two results taken apart are comparable. */
|
|
44
8
|
themeScope: string | null;
|
|
45
9
|
}
|
|
46
|
-
/** Report an element's theme compliance from its computed style. */
|
|
47
10
|
export declare function themeReport(cs: CSSStyleDeclaration): ThemeReport;
|
|
48
11
|
export {};
|
package/dist/dom/theme.js
CHANGED
|
@@ -1,36 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
|
|
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 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.
|
|
8
|
-
*
|
|
9
|
-
* Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
|
|
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.
|
|
24
|
-
*/
|
|
25
|
-
/** Attribute an app flips to switch themes, reported as part of the scope so results are comparable. */
|
|
26
1
|
const THEME_ATTRIBUTE = 'data-theme';
|
|
27
|
-
/** Collect every custom-property NAME the app declares, in any rule. */
|
|
28
2
|
function tokenNames() {
|
|
29
3
|
const names = new Set();
|
|
30
4
|
for (const sheet of Array.from(document.styleSheets)) {
|
|
31
5
|
let rules = null;
|
|
32
6
|
try {
|
|
33
|
-
rules = sheet.cssRules;
|
|
7
|
+
rules = sheet.cssRules;
|
|
34
8
|
}
|
|
35
9
|
catch {
|
|
36
10
|
continue;
|
|
@@ -40,10 +14,6 @@ function tokenNames() {
|
|
|
40
14
|
for (const rule of Array.from(rules)) {
|
|
41
15
|
if (!(rule instanceof CSSStyleRule))
|
|
42
16
|
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.
|
|
47
17
|
for (const prop of Array.from(rule.style)) {
|
|
48
18
|
if (prop.startsWith('--'))
|
|
49
19
|
names.add(prop);
|
|
@@ -52,21 +22,12 @@ function tokenNames() {
|
|
|
52
22
|
}
|
|
53
23
|
return names;
|
|
54
24
|
}
|
|
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
|
-
*/
|
|
62
25
|
function palette() {
|
|
63
26
|
const byColor = new Map();
|
|
64
27
|
const root = document.body ?? document.documentElement;
|
|
65
28
|
if (null === root)
|
|
66
29
|
return byColor;
|
|
67
30
|
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
31
|
const probe = document.createElement('span');
|
|
71
32
|
probe.style.position = 'absolute';
|
|
72
33
|
probe.style.pointerEvents = 'none';
|
|
@@ -86,30 +47,22 @@ function palette() {
|
|
|
86
47
|
finally {
|
|
87
48
|
probe.remove();
|
|
88
49
|
}
|
|
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
50
|
for (const named of byColor.values())
|
|
92
51
|
named.sort();
|
|
93
52
|
return byColor;
|
|
94
53
|
}
|
|
95
|
-
/** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
|
|
96
54
|
function toRgb(probe, value) {
|
|
97
55
|
if (0 === value.length)
|
|
98
56
|
return null;
|
|
99
57
|
probe.style.color = '';
|
|
100
|
-
probe.style.color = value;
|
|
58
|
+
probe.style.color = value;
|
|
101
59
|
if ('' === probe.style.color)
|
|
102
60
|
return null;
|
|
103
61
|
return getComputedStyle(probe).color;
|
|
104
62
|
}
|
|
105
|
-
/** True for a color that carries no visual weight (fully transparent) — never flagged off-theme. */
|
|
106
63
|
function isTransparent(rgb) {
|
|
107
64
|
return 'rgba(0, 0, 0, 0)' === rgb || 'transparent' === rgb;
|
|
108
65
|
}
|
|
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
66
|
function themeScope() {
|
|
114
67
|
const el = document.documentElement;
|
|
115
68
|
if (null === el)
|
|
@@ -121,11 +74,9 @@ function themeScope() {
|
|
|
121
74
|
const scope = `${classes}${null === attr ? '' : `[${THEME_ATTRIBUTE}="${attr}"]`}`;
|
|
122
75
|
return 0 === scope.length ? null : scope;
|
|
123
76
|
}
|
|
124
|
-
/** The unambiguous match, or null when the palette cannot tell the candidates apart. */
|
|
125
77
|
function sole(names) {
|
|
126
78
|
return 1 === names.length ? (names[0] ?? null) : null;
|
|
127
79
|
}
|
|
128
|
-
/** Report an element's theme compliance from its computed style. */
|
|
129
80
|
export function themeReport(cs) {
|
|
130
81
|
const byColor = palette();
|
|
131
82
|
const colorTokens = byColor.get(cs.color) ?? [];
|
|
@@ -140,7 +91,6 @@ export function themeReport(cs) {
|
|
|
140
91
|
colorTokens,
|
|
141
92
|
backgroundToken: sole(backgroundTokens),
|
|
142
93
|
backgroundTokens,
|
|
143
|
-
// Only meaningful when a palette exists; an app with no tokens can't violate one.
|
|
144
94
|
offTheme: tokenCount > 0 && (colorOff || bgOff),
|
|
145
95
|
tokenCount,
|
|
146
96
|
themeScope: themeScope(),
|
|
@@ -1,19 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Rows a scroll container has reserved space for but has NOT rendered.
|
|
3
|
-
*
|
|
4
|
-
* `BlindSpotKind.VIRTUALIZED_UNMOUNTED` has had a label on the server since it was defined and
|
|
5
|
-
* nothing ever emitted it — a declared blind spot that could not fire, shaped exactly like the most
|
|
6
|
-
* common data-heavy UI there is. Measured on a 10,000-row console: 29 rows in the DOM, and a verdict
|
|
7
|
-
* that reported full coverage. An assertion like "no shipment is held" was a claim about 29 rows,
|
|
8
|
-
* delivered as a claim about all of them.
|
|
9
|
-
*
|
|
10
|
-
* The detector measures EMPTY RESERVED SPACE rather than guessing from child counts. A virtualizer
|
|
11
|
-
* sizes its scroll area for the whole list and renders only a window, so the area above the first
|
|
12
|
-
* child and below the last is space the container has promised and not filled. An ordinary list —
|
|
13
|
-
* even one with large gaps or margins between rows — spans its own scroll height, so it measures
|
|
14
|
-
* zero and never reports.
|
|
15
|
-
*/
|
|
16
|
-
/** How many rows a container is holding space for but has not rendered. 0 when it is not virtualized. */
|
|
17
1
|
export declare function unmountedRowsIn(container: HTMLElement): number;
|
|
18
|
-
/** Total unmounted rows across every scroll container on the page. */
|
|
19
2
|
export declare function countUnmountedRows(): number;
|