@ultimat3/cli 20.1.5 → 20.2.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.
@@ -0,0 +1,287 @@
1
+ // The generated landing page — `apps/web/site/page.tsx` and its stylesheet and test. Split out of
2
+ // `scaffold-app.ts` when it grew from an `<h1>` and a link into a hero: a dot-grid ground, an
3
+ // eyebrow, a balanced headline, a lede, two calls to action and three feature cards, every string
4
+ // a catalog key and every colour a token. Still 0kb: `render: 'static'`, `hydrate: 'never'`, no
5
+ // island, and it does not wear the app shell — see the page's own header comment.
6
+
7
+ import { sortedImports } from './imports';
8
+ import type { GeneratedFile, NameSet } from './naming';
9
+
10
+ // Plain strings for the framework lines, never template literals: the workspace-dependency scanner
11
+ // blanks a string's contents but not a nested template's, so a template here would bill the CLI
12
+ // for the imports of the app it writes.
13
+ const sitePage = (
14
+ app: NameSet,
15
+ ): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
16
+ //
17
+ // Strings come from \`useT()\` — this app's own catalog module — and never from
18
+ // \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
19
+ // this page's graph, so rendering a string is what registers the catalogs. A page that reached
20
+ // past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
21
+ //
22
+ // Not inside \`shared/shell.tsx\`: the shell is the signed-in product's chrome, and a landing page
23
+ // that wore it would ship a sidebar to visitors who cannot open anything in it.
24
+ ${sortedImports([
25
+ `import { useT } from '@${app.kebab}/i18n';`,
26
+ "import { defineRoute } from '@ultimat3/render';",
27
+ "import { Icon } from '@ultimat3/ui';",
28
+ "import { iconArrowRight } from '@ultimat3/ui/icons/arrow-right';",
29
+ "import { iconGauge } from '@ultimat3/ui/icons/gauge';",
30
+ "import { iconShieldCheck } from '@ultimat3/ui/icons/shield-check';",
31
+ "import { iconZap } from '@ultimat3/ui/icons/zap';",
32
+ ])}
33
+ import styles from './page.module.scss';
34
+
35
+ export const config = defineRoute({
36
+ render: 'static',
37
+ hydrate: 'never',
38
+ offline: 'precache',
39
+ budget: { js: '0kb' },
40
+ // \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
41
+ // request's locale before the head is built.
42
+ meta: ({ t }) => ({
43
+ title: t('site.home.title'),
44
+ description: t('site.home.description'),
45
+ }),
46
+ });
47
+
48
+ export function HomePage() {
49
+ const t = useT();
50
+
51
+ return (
52
+ <main class={styles.page}>
53
+ <header class={styles.hero}>
54
+ <p class={styles.eyebrow}>{t('site.home.eyebrow')}</p>
55
+ <h1 class={styles.title}>{t('site.home.headline')}</h1>
56
+ <p class={styles.lede}>{t('site.home.lede')}</p>
57
+ <div class={styles.actions}>
58
+ <a class={styles.cta} href="/dashboard">
59
+ {t('site.home.cta')}
60
+ <Icon glyph={iconArrowRight} size="sm" />
61
+ </a>
62
+ <a class={styles.ghost} href="/admin">
63
+ {t('site.home.secondary')}
64
+ </a>
65
+ </div>
66
+ </header>
67
+ {/* What this app already IS, in three facts — not three adjectives. Static markup: this
68
+ route ships no island and its JS budget is zero. */}
69
+ <ul class={styles.features}>
70
+ <li class={styles.feature}>
71
+ <span class={styles.featureIcon} aria-hidden="true">
72
+ <Icon glyph={iconZap} size="sm" />
73
+ </span>
74
+ <h2>{t('site.home.f1Title')}</h2>
75
+ <p>{t('site.home.f1Body')}</p>
76
+ </li>
77
+ <li class={styles.feature}>
78
+ <span class={styles.featureIcon} aria-hidden="true">
79
+ <Icon glyph={iconShieldCheck} size="sm" />
80
+ </span>
81
+ <h2>{t('site.home.f2Title')}</h2>
82
+ <p>{t('site.home.f2Body')}</p>
83
+ </li>
84
+ <li class={styles.feature}>
85
+ <span class={styles.featureIcon} aria-hidden="true">
86
+ <Icon glyph={iconGauge} size="sm" />
87
+ </span>
88
+ <h2>{t('site.home.f3Title')}</h2>
89
+ <p>{t('site.home.f3Body')}</p>
90
+ </li>
91
+ </ul>
92
+ </main>
93
+ );
94
+ }
95
+
96
+ export const appName = '${app.kebab}';
97
+ `;
98
+
99
+ const siteStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
100
+
101
+ .page {
102
+ display: flex;
103
+ flex-direction: column;
104
+ gap: tokens.space(12);
105
+ max-inline-size: 72rem;
106
+ min-block-size: 100dvh;
107
+ margin-inline: auto;
108
+ padding: tokens.space(8) tokens.space(4) tokens.space(16);
109
+ background: tokens.role('bg');
110
+ color: tokens.role('fg');
111
+
112
+ @include tokens.respond-to(md) {
113
+ padding: tokens.space(16) tokens.space(8);
114
+ }
115
+ }
116
+
117
+ .hero {
118
+ @include tokens.dot-grid;
119
+
120
+ position: relative;
121
+ display: flex;
122
+ flex-direction: column;
123
+ align-items: flex-start;
124
+ gap: tokens.space(5);
125
+ padding: tokens.space(8) tokens.space(6);
126
+ overflow: hidden;
127
+ border: 1px solid tokens.role('line', 0.6);
128
+ border-radius: tokens.radius('xl');
129
+
130
+ @include tokens.respond-to(md) {
131
+ padding: tokens.space(16) tokens.space(12);
132
+ }
133
+ }
134
+
135
+ .eyebrow {
136
+ @include tokens.label-caps;
137
+
138
+ margin: 0;
139
+ padding: tokens.space(1) tokens.space(3);
140
+ border: 1px solid tokens.role('accent', 0.35);
141
+ border-radius: tokens.radius('pill');
142
+ background: tokens.role('accent', 0.1);
143
+ color: tokens.role('accent');
144
+ }
145
+
146
+ .title {
147
+ max-inline-size: 18ch;
148
+ margin: 0;
149
+ color: tokens.role('fg-strong');
150
+ font-size: tokens.text('3xl');
151
+ font-weight: tokens.weight('semibold');
152
+ letter-spacing: tokens.tracking('tight');
153
+ line-height: 1.05;
154
+ text-wrap: balance;
155
+ }
156
+
157
+ .lede {
158
+ max-inline-size: 36rem;
159
+ margin: 0;
160
+ color: tokens.role('fg-muted');
161
+ font-size: tokens.text('lg');
162
+ line-height: tokens.leading('normal');
163
+ text-wrap: pretty;
164
+ }
165
+
166
+ .actions {
167
+ display: flex;
168
+ flex-wrap: wrap;
169
+ gap: tokens.space(3);
170
+ margin-block-start: tokens.space(2);
171
+ }
172
+
173
+ // Both calls to action share one shape; only the paint differs.
174
+ @mixin action {
175
+ @include tokens.focus-ring;
176
+
177
+ display: inline-flex;
178
+ align-items: center;
179
+ gap: tokens.space(2);
180
+ block-size: 2.75rem;
181
+ padding-inline: tokens.space(5);
182
+ border-radius: tokens.radius('md');
183
+ font-weight: tokens.weight('medium');
184
+ text-decoration: none;
185
+ transition:
186
+ background-color tokens.duration('fast') tokens.easing('out'),
187
+ border-color tokens.duration('fast') tokens.easing('out'),
188
+ color tokens.duration('fast') tokens.easing('out');
189
+ }
190
+
191
+ .cta {
192
+ @include action;
193
+
194
+ background: tokens.role('accent');
195
+ color: tokens.role('accent-fg');
196
+
197
+ &:hover {
198
+ background: tokens.role('accent-strong');
199
+ }
200
+ }
201
+
202
+ .ghost {
203
+ @include action;
204
+
205
+ border: 1px solid tokens.role('line');
206
+ color: tokens.role('fg');
207
+
208
+ &:hover {
209
+ border-color: tokens.role('fg-muted');
210
+ color: tokens.role('fg-strong');
211
+ }
212
+ }
213
+
214
+ .features {
215
+ display: grid;
216
+ gap: tokens.space(4);
217
+ margin: 0;
218
+ padding: 0;
219
+ list-style: none;
220
+
221
+ @include tokens.respond-to(md) {
222
+ grid-template-columns: repeat(3, minmax(0, 1fr));
223
+ }
224
+ }
225
+
226
+ .feature {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: tokens.space(3);
230
+ padding: tokens.space(6);
231
+ border: 1px solid tokens.role('line', 0.6);
232
+ border-radius: tokens.radius('lg');
233
+ background: tokens.role('surface');
234
+
235
+ h2 {
236
+ margin: 0;
237
+ color: tokens.role('fg-strong');
238
+ font-size: tokens.text('md');
239
+ font-weight: tokens.weight('medium');
240
+ }
241
+
242
+ p {
243
+ margin: 0;
244
+ color: tokens.role('fg-muted');
245
+ font-size: tokens.text('sm');
246
+ line-height: tokens.leading('normal');
247
+ }
248
+ }
249
+
250
+ .featureIcon {
251
+ display: inline-grid;
252
+ place-items: center;
253
+ inline-size: tokens.space(8);
254
+ block-size: tokens.space(8);
255
+ border: 1px solid tokens.role('line');
256
+ border-radius: tokens.radius('md');
257
+ background: tokens.role('surface-raised');
258
+ color: tokens.role('accent');
259
+ }
260
+ `;
261
+
262
+ const sitePageTest =
263
+ (): string => `// The landing page ships zero JS and declares its metadata. Both are promises the file makes in
264
+ // its config, and both are the kind that rot silently when someone adds one import.
265
+ import { metaContextFor, routeDataFor } from '@ultimat3/render';
266
+ import { expect, unitTest } from '@ultimat3/testing';
267
+ import { config } from './page';
268
+
269
+ // The same two objects a render builds: \`routeDataFor\` resolves the route's data once, and
270
+ // \`metaContextFor\` wraps it the way every render mode wraps it before calling \`meta\`.
271
+ const ctx = { params: {}, url: 'https://example.test/' };
272
+
273
+ unitTest('the landing page ships zero JS and declares metadata', async () => {
274
+ expect(config.render).toBe('static');
275
+ expect(config.hydrate).toBe('never');
276
+ expect(config.budget.js).toBe('0kb');
277
+ const meta = await config.meta(metaContextFor(ctx, await routeDataFor(config, ctx)));
278
+ expect(meta.title ?? '').not.toBe('');
279
+ });
280
+ `;
281
+
282
+ /** The landing page, its stylesheet and its test. */
283
+ export const siteFiles = (app: NameSet): readonly GeneratedFile[] => [
284
+ { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
285
+ { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
286
+ { path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
287
+ ];
@@ -0,0 +1,59 @@
1
+ // The no-flash theme script, inlined into every document a served or exported process writes,
2
+ // with `theme.defaultMode` from `app.config.ts` as its fallback. Before this the key had no reader
3
+ // at all (pinned as a dead key in `scripts/lib/config-reader-pins.ts`) and neither of the two
4
+ // scripts the framework exported was wired into a document: an app that wanted to open dark had to
5
+ // write its own boot script AND admit it to the CSP by hand — which every app forgot in one of the
6
+ // two places. Sibling of `app-auth.ts`'s `loadSignInPath` and imports the config for the same
7
+ // reason: a regex over the app's source is the pattern `app-load.ts` refuses.
8
+
9
+ // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
10
+ // necessity `app-auth.ts` records.
11
+ import { join } from 'node:path';
12
+ import type { ThemeMode } from '@ultimat3/core';
13
+ import { cspHashSource } from '@ultimat3/http';
14
+ import { renderHead, themeScript, themeScriptBody } from '@ultimat3/render';
15
+ import { APP_CONFIG_EXPORT } from './app-auth';
16
+ import { APP_CONFIG_FILE } from './app-root';
17
+
18
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
19
+ typeof value === 'object' && value !== null;
20
+
21
+ const isThemeMode = (value: unknown): value is ThemeMode =>
22
+ value === 'light' || value === 'dark' || value === 'system';
23
+
24
+ /**
25
+ * `theme.defaultMode`, or `'system'` — the value `defineConfig` fills in — when the app declares
26
+ * none or the file is absent. Structural, never `instanceof`, for `loadSignInPath`'s reason.
27
+ */
28
+ export async function loadThemeMode(root: string): Promise<ThemeMode> {
29
+ const configPath = join(root, APP_CONFIG_FILE);
30
+ if (!(await Bun.file(configPath).exists())) return 'system';
31
+ const module = (await import(configPath)) as Record<string, unknown>;
32
+ const config = module[APP_CONFIG_EXPORT];
33
+ if (!isRecord(config)) return 'system';
34
+ const theme = config['theme'];
35
+ if (!isRecord(theme)) return 'system';
36
+ const mode = theme['defaultMode'];
37
+ return isThemeMode(mode) ? mode : 'system';
38
+ }
39
+
40
+ export interface ThemeBoot {
41
+ /** The rendered `<script>` tag, for `DocumentOptions.themeHead`. */
42
+ readonly head: string;
43
+ /** The `script-src` source that admits exactly that tag's body. */
44
+ readonly cspSource: string;
45
+ }
46
+
47
+ /**
48
+ * Tag and hash from ONE body: `themeScriptBody` is what `themeScript` inlines, so a policy hashed
49
+ * here admits the script the document carries and not a restatement of it. The storage key is
50
+ * render's `THEME_STORAGE_KEY`, which `theme-boot.test.ts` pins equal to `@ultimat3/ui`'s — the
51
+ * toggle writes the key the boot reads.
52
+ */
53
+ export function themeBoot(mode: ThemeMode): ThemeBoot {
54
+ const options = { fallback: mode };
55
+ return {
56
+ head: renderHead([themeScript(options)]),
57
+ cspSource: cspHashSource(themeScriptBody(options)),
58
+ };
59
+ }
package/src/ui-diff.ts ADDED
@@ -0,0 +1,90 @@
1
+ // The pixel half of `ui.diff`: two RGBA buffers of one size in, a count, a bounding box and a
2
+ // diff picture out. Pure — no file, no PNG container, no browser — so the rule a picture is judged
3
+ // by can be read in one screen and tested with four-byte images.
4
+ //
5
+ // The rule is per channel: a pixel is CHANGED when any of its four channels moved by more than
6
+ // `threshold * 255` between the two captures. There is deliberately NO anti-alias detection: a
7
+ // pixelmatch-style "this differs but a neighbour matches, so it is a font hinting artefact" pass
8
+ // is a heuristic a model then has to reason about, and the threshold already absorbs a one-level
9
+ // wobble. A capture that rendered a glyph one subpixel over is a change — an agent that judges it
10
+ // noise raises the threshold, which is a number in the call rather than a rule in this file.
11
+ //
12
+ // The diff picture is the `after` capture faded to a quarter over a light grey, with every changed
13
+ // pixel solid red. Faded rather than removed, so a red pixel is read against what it sits on.
14
+
15
+ import type { UiInspectBox } from '@ultimat3/mcp';
16
+
17
+ export interface PixelDiff {
18
+ readonly changedPixels: number;
19
+ /** The tightest box around every changed pixel, or `null` when nothing changed. */
20
+ readonly changedBox: UiInspectBox | null;
21
+ /** RGBA, the same size as the inputs: the faded `after` with changed pixels in `DIFF_RED`. */
22
+ readonly diffRgba: Uint8ClampedArray;
23
+ }
24
+
25
+ const CHANNELS = 4;
26
+ /** Where an unchanged pixel lands: a quarter of its own colour over `FADE_GROUND`. */
27
+ export const FADE_ALPHA = 0.25;
28
+ export const FADE_GROUND = 230;
29
+ export const DIFF_RED = [255, 0, 0, 255] as const;
30
+
31
+ /** `threshold` in `[0, 1]` as a fraction of a channel's range; a delta strictly above it counts. */
32
+ export function channelLimit(threshold: number): number {
33
+ return Math.min(1, Math.max(0, threshold)) * 255;
34
+ }
35
+
36
+ /** The faded value of one channel: `FADE_ALPHA` of it over the ground, rounded like a compositor. */
37
+ export const faded = (channel: number): number =>
38
+ Math.round(channel * FADE_ALPHA + FADE_GROUND * (1 - FADE_ALPHA));
39
+
40
+ export function diffPixels(
41
+ before: Uint8ClampedArray,
42
+ after: Uint8ClampedArray,
43
+ width: number,
44
+ height: number,
45
+ threshold: number,
46
+ ): PixelDiff {
47
+ const limit = channelLimit(threshold);
48
+ const diffRgba = new Uint8ClampedArray(width * height * CHANNELS);
49
+ let changedPixels = 0;
50
+ let minX = width;
51
+ let minY = height;
52
+ let maxX = -1;
53
+ let maxY = -1;
54
+ for (let y = 0; y < height; y += 1) {
55
+ for (let x = 0; x < width; x += 1) {
56
+ const at = (y * width + x) * CHANNELS;
57
+ const changed =
58
+ Math.abs((before[at] ?? 0) - (after[at] ?? 0)) > limit ||
59
+ Math.abs((before[at + 1] ?? 0) - (after[at + 1] ?? 0)) > limit ||
60
+ Math.abs((before[at + 2] ?? 0) - (after[at + 2] ?? 0)) > limit ||
61
+ Math.abs((before[at + 3] ?? 0) - (after[at + 3] ?? 0)) > limit;
62
+ if (changed) {
63
+ changedPixels += 1;
64
+ if (x < minX) minX = x;
65
+ if (x > maxX) maxX = x;
66
+ if (y < minY) minY = y;
67
+ if (y > maxY) maxY = y;
68
+ diffRgba.set(DIFF_RED, at);
69
+ } else {
70
+ diffRgba[at] = faded(after[at] ?? 0);
71
+ diffRgba[at + 1] = faded(after[at + 1] ?? 0);
72
+ diffRgba[at + 2] = faded(after[at + 2] ?? 0);
73
+ // Opaque: the fade is baked into the colour, so the picture reads the same on any viewer.
74
+ diffRgba[at + 3] = 255;
75
+ }
76
+ }
77
+ }
78
+ const changedBox =
79
+ changedPixels === 0
80
+ ? null
81
+ : { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
82
+ return { changedPixels, changedBox, diffRgba };
83
+ }
84
+
85
+ /** `changedPixels / (width * height)` as a percentage with two decimals; `0` for an empty image. */
86
+ export function changedPercent(changedPixels: number, width: number, height: number): number {
87
+ const total = width * height;
88
+ if (total === 0) return 0;
89
+ return Math.round((changedPixels / total) * 10_000) / 100;
90
+ }
@@ -0,0 +1,131 @@
1
+ // The one expression `ui.inspect` runs in the page, and the parser that refuses to trust what
2
+ // came back. Built from plain values and DETERMINISTIC for one spec: the offline drivers answer
3
+ // `evaluate` from a recording keyed by the exact expression string, so `mcp-ui-inspect.test.ts`
4
+ // records against `inspectExpression(spec)` and proves the whole tool on a box with no Chrome.
5
+ //
6
+ // Every cap is applied IN the page — matches, text length, attribute count — so the wire payload
7
+ // is bounded before the browser serialises it. A `*` selector on a long page is thousands of
8
+ // nodes, and `textContent` of `<body>` is the whole document; a probe that fetched all of it and
9
+ // trimmed afterwards would still have paid for all of it.
10
+
11
+ import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
12
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
13
+ import { t, validate } from '@ultimat3/schema';
14
+
15
+ export interface InspectSpec {
16
+ readonly selectors: readonly string[];
17
+ /** Computed property names, kebab-case, already filtered by the tool handler. */
18
+ readonly styles: readonly string[];
19
+ readonly activeElement: boolean;
20
+ }
21
+
22
+ export interface InspectProbeMatch {
23
+ readonly tag: string;
24
+ readonly text: string;
25
+ readonly box: {
26
+ readonly x: number;
27
+ readonly y: number;
28
+ readonly width: number;
29
+ readonly height: number;
30
+ };
31
+ readonly visible: boolean;
32
+ readonly attrs: Readonly<Record<string, string>>;
33
+ readonly styles: Readonly<Record<string, string>>;
34
+ }
35
+
36
+ export interface InspectProbeSelector {
37
+ readonly selector: string;
38
+ readonly valid: boolean;
39
+ readonly count: number;
40
+ readonly truncated: boolean;
41
+ readonly matches: readonly InspectProbeMatch[];
42
+ }
43
+
44
+ export interface InspectProbe {
45
+ readonly title: string;
46
+ readonly theme: string | null;
47
+ readonly activeElement: {
48
+ readonly tag: string;
49
+ readonly id: string;
50
+ readonly role: string;
51
+ readonly name: string;
52
+ } | null;
53
+ readonly selectors: readonly InspectProbeSelector[];
54
+ }
55
+
56
+ /**
57
+ * ES5 and an expression, never a closure: `CdpPageLike.evaluate` takes the string form only, and
58
+ * the page may be older than the CLI. `querySelectorAll` runs inside a `try` so a selector the
59
+ * engine cannot parse is reported as `valid: false` beside the ones it could — one bad selector
60
+ * must not cost the whole navigation. Rounded boxes: a sub-pixel `x` is noise an agent diffs on.
61
+ */
62
+ export function inspectExpression(spec: InspectSpec): string {
63
+ const selectors = JSON.stringify([...spec.selectors]);
64
+ const styles = JSON.stringify([...spec.styles]);
65
+ const active = spec.activeElement ? 'true' : 'false';
66
+ const { matches, textChars, attrs } = UI_INSPECT_LIMITS;
67
+ return (
68
+ `(function(){var S=${selectors};var P=${styles};var A=${active};` +
69
+ 'function nm(el){return el.getAttribute("aria-label")||el.getAttribute("name")||"";}' +
70
+ `function at(el){var o={};var a=el.attributes;for(var i=0;i<a.length&&i<${attrs};i+=1){` +
71
+ `o[a[i].name]=String(a[i].value).slice(0,${textChars});}return o;}` +
72
+ 'function st(el){var o={};if(P.length===0)return o;var cs=getComputedStyle(el);' +
73
+ 'for(var i=0;i<P.length;i+=1){o[P[i]]=cs.getPropertyValue(P[i]);}return o;}' +
74
+ 'function m(el){var r=el.getBoundingClientRect();var cs=getComputedStyle(el);' +
75
+ 'return{tag:el.tagName.toLowerCase(),' +
76
+ `text:(el.textContent||"").replace(/\\s+/g," ").trim().slice(0,${textChars}),` +
77
+ 'box:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},' +
78
+ 'visible:r.width>0&&r.height>0&&cs.visibility!=="hidden"&&cs.display!=="none",' +
79
+ 'attrs:at(el),styles:st(el)};}' +
80
+ 'var out=[];for(var i=0;i<S.length;i+=1){var sel=S[i];var els;' +
81
+ 'try{els=document.querySelectorAll(sel);}catch(e){' +
82
+ 'out.push({selector:sel,valid:false,count:0,truncated:false,matches:[]});continue;}' +
83
+ `var ms=[];for(var j=0;j<els.length&&j<${matches};j+=1)ms.push(m(els[j]));` +
84
+ `out.push({selector:sel,valid:true,count:els.length,truncated:els.length>${matches},matches:ms});}` +
85
+ 'var ae=document.activeElement;var active=A&&ae?{tag:ae.tagName.toLowerCase(),id:ae.id||"",' +
86
+ 'role:ae.getAttribute("role")||"",name:nm(ae)}:null;' +
87
+ 'return{title:document.title||"",theme:document.documentElement.getAttribute("data-theme"),' +
88
+ 'activeElement:active,selectors:out};})()'
89
+ );
90
+ }
91
+
92
+ const matchSchema = t.object({
93
+ tag: t.string,
94
+ text: t.string.min(0),
95
+ box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
96
+ visible: t.boolean,
97
+ attrs: t.record(t.string.min(0)),
98
+ styles: t.record(t.string.min(0)),
99
+ });
100
+
101
+ const inspectProbeSchema: StandardSchemaV1<unknown, InspectProbe> = t.object({
102
+ title: t.string.min(0),
103
+ theme: t.nullable(t.string.min(0)),
104
+ activeElement: t.nullable(
105
+ t.object({
106
+ tag: t.string,
107
+ id: t.string.min(0),
108
+ role: t.string.min(0),
109
+ name: t.string.min(0),
110
+ }),
111
+ ),
112
+ selectors: t.array(
113
+ t.object({
114
+ selector: t.string.min(0),
115
+ valid: t.boolean,
116
+ count: t.number,
117
+ truncated: t.boolean,
118
+ matches: t.array(matchSchema),
119
+ }),
120
+ ),
121
+ }) as unknown as StandardSchemaV1<unknown, InspectProbe>;
122
+
123
+ /**
124
+ * `evaluate()` answers `unknown` on every driver, so the probe's result is PARSED and never cast —
125
+ * the rule `parseIslandProbe` follows. `null` for anything that does not fit: the picture and the
126
+ * verdict were already taken, and a malformed probe must not take them down with it.
127
+ */
128
+ export function parseInspectProbe(value: unknown): InspectProbe | null {
129
+ const result = validate(inspectProbeSchema, value);
130
+ return result.issues === undefined ? result.value : null;
131
+ }