@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,183 @@
1
+ // `ui.inspect` — the dev MCP server's third eye, and the one that reads instead of looking. A
2
+ // browser is the cost of every `ui.*` call, so this one reads MANY facts per navigation: for each
3
+ // selector the tag, text, box, visibility, attributes, named computed styles and (on request) the
4
+ // browser-computed role and name; for the document its title, `data-theme`, the focused element,
5
+ // the island count and both error streams. It is `runShot` with an `act` hook — the same boot,
6
+ // the same driver, the same verdict, the same PNG under an `inspect/` subdirectory — so `ok` here
7
+ // means exactly what `ui.shot`'s `ok` means, and a page that threw while an agent was reading
8
+ // its styles is a finding beside the styles.
9
+
10
+ // why: Bun exposes no path-join primitive, and the directory is a path an agent opens.
11
+ import { join } from 'node:path';
12
+ import type { UiInspectInput, UiInspectResult, UiInspectSelector } from '@ultimat3/mcp';
13
+ import { UI_INSPECT_LIMITS } from '@ultimat3/mcp';
14
+ import type { AxNode, ScrapeDriver, ScrapePage } from '@ultimat3/scraping';
15
+ import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
16
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
+ import type { ShotServer } from './shot-server';
18
+ import type { ShotVerdict } from './shot-verdict';
19
+ import type { InspectProbe } from './ui-inspect-probe';
20
+ import { inspectExpression, parseInspectProbe } from './ui-inspect-probe';
21
+
22
+ export interface InspectDeps {
23
+ readonly root: string;
24
+ /** The memoised scratch server (or the running `x dev`), as `uiCapabilities` hands it out. */
25
+ readonly boot: () => Promise<ShotServer>;
26
+ readonly driver: (viewport: UiInspectInput['viewport']) => Promise<ScrapeDriver>;
27
+ }
28
+
29
+ /** What the page answered before the parser had a say — `null` selectors when it answered nothing. */
30
+ export interface Seen {
31
+ probe: InspectProbe | null;
32
+ /** Per selector, in the input's order: the a11y nodes, or `null` when not asked. */
33
+ a11y: readonly (readonly AxNode[] | null)[];
34
+ }
35
+
36
+ /** The four fields the read is built from — `ui.inspect`'s input, or `ui.interact`'s block. */
37
+ export type InspectSpecInput = Pick<
38
+ UiInspectInput,
39
+ 'selectors' | 'styles' | 'a11y' | 'activeElement'
40
+ >;
41
+
42
+ /**
43
+ * The read itself, on a page somebody else navigated: the one probe expression, then (on request)
44
+ * one accessibility round trip per selector. `.catch(() => null)` for the island probe's reason:
45
+ * a page that refuses evaluation is a page with no facts, and the verdict — not a throw here — is
46
+ * what says why. Shared by `ui.inspect` and `ui.interact`, so both read the same facts.
47
+ */
48
+ export async function readInspect(page: ScrapePage, spec: InspectSpecInput): Promise<Seen> {
49
+ const expression = inspectExpression({
50
+ selectors: spec.selectors,
51
+ styles: spec.styles,
52
+ activeElement: spec.activeElement,
53
+ });
54
+ const probe = await page
55
+ .evaluate(expression)
56
+ .then(parseInspectProbe)
57
+ .catch(() => null);
58
+ if (!spec.a11y) return { probe, a11y: [] };
59
+ const nodes: (readonly AxNode[] | null)[] = [];
60
+ for (const selector of spec.selectors) {
61
+ nodes.push(await page.accessibility(selector, { max: UI_INSPECT_LIMITS.matches }));
62
+ }
63
+ return { probe, a11y: nodes };
64
+ }
65
+
66
+ /**
67
+ * The empty answer for a selector the page never described: the probe refused to run, or answered
68
+ * a shape the parser did not accept. `valid: false` is the honest reading — nothing about this
69
+ * selector was established — and the verdict beside it says whether the page was broken.
70
+ */
71
+ const unanswered = (selector: string): UiInspectSelector => ({
72
+ selector,
73
+ valid: false,
74
+ count: 0,
75
+ truncated: false,
76
+ matches: [],
77
+ });
78
+
79
+ export function selectorsOf(input: InspectSpecInput, seen: Seen): readonly UiInspectSelector[] {
80
+ return input.selectors.map((selector, index): UiInspectSelector => {
81
+ const found = seen.probe?.selectors[index];
82
+ if (found === undefined) return unanswered(selector);
83
+ const nodes = seen.a11y[index] ?? null;
84
+ return {
85
+ selector,
86
+ valid: found.valid,
87
+ count: found.count,
88
+ truncated: found.truncated,
89
+ // Merged BY INDEX: both reads walk `querySelectorAll` in document order over the same DOM,
90
+ // one navigation apart from nothing. A node the a11y read did not cover keeps no `a11y` key.
91
+ matches: found.matches.map((match, at) => {
92
+ const node = nodes?.[at];
93
+ return node === undefined
94
+ ? match
95
+ : { ...match, a11y: { role: node.role, name: node.name } };
96
+ }),
97
+ };
98
+ });
99
+ }
100
+
101
+ const encoded = (value: unknown): number => new TextEncoder().encode(JSON.stringify(value)).length;
102
+
103
+ /**
104
+ * The wire cap. Matches are dropped from the LAST selectors first — an agent lists what it cares
105
+ * about most first, and a cap that emptied the first selector would take the fact the call was
106
+ * for. The selector keeps its `count` and gains `truncated: true`, so the drop is visible.
107
+ */
108
+ export function capInspect(result: UiInspectResult): UiInspectResult {
109
+ if (encoded(result) <= UI_INSPECT_LIMITS.bytes) return result;
110
+ const selectors = [...result.selectors];
111
+ let truncated = result.truncated;
112
+ for (let index = selectors.length - 1; index >= 0; index -= 1) {
113
+ const entry = selectors[index];
114
+ if (entry === undefined || entry.matches.length === 0) continue;
115
+ selectors[index] = { ...entry, truncated: true, matches: [] };
116
+ truncated = true;
117
+ if (encoded({ ...result, selectors, truncated }) <= UI_INSPECT_LIMITS.bytes) break;
118
+ }
119
+ return { ...result, selectors, truncated };
120
+ }
121
+
122
+ const errorsOf = (verdict: ShotVerdict): readonly string[] =>
123
+ verdict.console.filter((line) => line.level === 'error').map((line) => line.text);
124
+
125
+ export async function inspectRoute(
126
+ deps: InspectDeps,
127
+ input: UiInspectInput,
128
+ ): Promise<UiInspectResult> {
129
+ // A holder rather than a `let`: an assignment inside the `act` closure does not reach the
130
+ // narrowing below, and a `let` typed `null` after the await is what the compiler would see.
131
+ const seen: Seen = { probe: null, a11y: [] };
132
+ const driver = await deps.driver(input.viewport);
133
+ // Its own subdirectory under the (route, viewport, scheme) one, so an inspect and a shot of the
134
+ // same route at the same size never overwrite each other's verdict.
135
+ const outDir = join(
136
+ deps.root,
137
+ SHOT_DIR,
138
+ shotSlug(input.route),
139
+ `${input.viewport.width}x${input.viewport.height}-${input.colorScheme}`,
140
+ 'inspect',
141
+ );
142
+ const artifacts = await runShot({
143
+ route: input.route,
144
+ outDir,
145
+ driver,
146
+ boot: deps.boot,
147
+ settleMs: DEFAULT_SETTLE_MS,
148
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
149
+ fullPage: true,
150
+ colorScheme: input.colorScheme,
151
+ act: async (page) => {
152
+ const read = await readInspect(page, input);
153
+ seen.probe = read.probe;
154
+ seen.a11y = read.a11y;
155
+ },
156
+ });
157
+ const verdict = artifacts.verdict;
158
+ return capInspect({
159
+ ok: verdict.ok,
160
+ route: input.route,
161
+ finalUrl: verdict.finalUrl,
162
+ title: seen.probe?.title ?? '',
163
+ theme: seen.probe?.theme ?? null,
164
+ activeElement: seen.probe?.activeElement ?? null,
165
+ islands:
166
+ verdict.islands === null
167
+ ? null
168
+ : {
169
+ declared: verdict.islands.declared,
170
+ booted: verdict.islands.booted,
171
+ mounted: verdict.islands.mounted,
172
+ failed: verdict.islands.failed,
173
+ },
174
+ consoleErrors: errorsOf(verdict),
175
+ pageErrors: verdict.pageErrors.map((error) => error.message),
176
+ refused: verdict.refused,
177
+ selectors: selectorsOf(input, seen),
178
+ image: artifacts.image,
179
+ verdictFile: artifacts.verdictFile,
180
+ truncated: false,
181
+ droppedStyles: [],
182
+ });
183
+ }
@@ -0,0 +1,304 @@
1
+ // `ui.interact` — the dev MCP server's hand. The three `ui.*` eyes photograph a route as it loads;
2
+ // this one drives it first — opens the ⌘K palette, raises a dialog, types into a field — and
3
+ // then takes the same picture, the same verdict and (on request) `ui.inspect`'s facts, in ONE
4
+ // navigation. It is `runShot` with an `act` hook whose body is the step list: after EVERY step the
5
+ // island poll re-runs (a click that mounts something changes the count the verdict reports) and
6
+ // one poll interval passes for the CSS transition the click started.
7
+ //
8
+ // Four refusals, and every one refuses WHOLE rather than trims, skips or follows: a dropped step,
9
+ // a swallowed keystroke or a navigation quietly followed changes what the picture is of, and an
10
+ // agent judging that picture would judge the wrong thing.
11
+
12
+ // why: Bun exposes no path-join primitive, and the directory is a path an agent opens.
13
+ import { join } from 'node:path';
14
+ import { isUltimateError, toUltimateError, UltimateError } from '@ultimat3/core';
15
+ import type { UiInteractInput, UiInteractResult, UiInteractStepResult } from '@ultimat3/mcp';
16
+ import { UI_INTERACT_LIMITS } from '@ultimat3/mcp';
17
+ import type { ScrapePage } from '@ultimat3/scraping';
18
+ import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
19
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
20
+ import type { InspectDeps, Seen } from './mcp-ui-inspect';
21
+ import { readInspect, selectorsOf } from './mcp-ui-inspect';
22
+ import { SETTLE_POLL_MS } from './shot-settle';
23
+ import type { IslandCount } from './shot-verdict';
24
+ import { verdictJson } from './shot-verdict';
25
+
26
+ export interface InteractDeps extends InspectDeps {
27
+ /** Injected by the test, so the per-step transition frame is proved without spending it. */
28
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
29
+ }
30
+
31
+ /** A step after parsing: one verb, its argument, and the bounds already held. */
32
+ export type InteractStep =
33
+ | { readonly kind: 'click'; readonly selector: string }
34
+ | { readonly kind: 'type'; readonly selector: string; readonly text: string }
35
+ | { readonly kind: 'press'; readonly chord: string }
36
+ | { readonly kind: 'focus'; readonly selector: string }
37
+ | { readonly kind: 'wait'; readonly ms: number }
38
+ | { readonly kind: 'wait'; readonly selector: string };
39
+
40
+ /**
41
+ * The four fix lines, verbatim from `mcp-errors.ts`'s `CLI_FIXES` — an error's fix travels IN the
42
+ * error, and `errors.explain` answers the same text.
43
+ */
44
+ const FIX = {
45
+ invalid:
46
+ 'x help shot --json # then resend ui.interact with at most 12 one-key steps, type.text under 500 chars and wait under 5000 ms',
47
+ secret:
48
+ 'x shot --all-islands --json # or --island <name>: a declared state renders the filled form without the secret ever being typed',
49
+ left: 'x routes --json # then resend ui.interact with steps that stay on one of its paths',
50
+ failed:
51
+ 'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
52
+ } as const;
53
+
54
+ const invalid = (cause: string): UltimateError =>
55
+ new UltimateError({ code: 'X_UI_INTERACT_STEPS_INVALID', cause, fix: FIX.invalid });
56
+
57
+ const nonEmpty = (value: unknown, at: string): string => {
58
+ if (typeof value !== 'string' || value === '') throw invalid(`${at} must be a non-empty string`);
59
+ return value;
60
+ };
61
+
62
+ function parseStep(raw: Readonly<Record<string, unknown>>, index: number): InteractStep {
63
+ const keys = Object.keys(raw);
64
+ const key = keys[0];
65
+ if (keys.length !== 1 || key === undefined) {
66
+ throw invalid(
67
+ `step ${index} names ${keys.length} verbs (${keys.join(', ')}); a step is ONE of click, type, press, focus, wait`,
68
+ );
69
+ }
70
+ const at = `step ${index} (${key})`;
71
+ // `Object.hasOwn` narrowed the key set above; the read below is on a literal the parser owns.
72
+ const value: unknown = Object.hasOwn(raw, key) ? raw[key] : undefined;
73
+ switch (key) {
74
+ case 'click':
75
+ return { kind: 'click', selector: nonEmpty(value, at) };
76
+ case 'focus':
77
+ return { kind: 'focus', selector: nonEmpty(value, at) };
78
+ case 'press':
79
+ return { kind: 'press', chord: nonEmpty(value, at) };
80
+ case 'type': {
81
+ if (typeof value !== 'object' || value === null)
82
+ throw invalid(`${at} needs { selector, text }`);
83
+ const field = value as { readonly selector?: unknown; readonly text?: unknown };
84
+ const text = nonEmpty(field.text, `${at}.text`);
85
+ if (text.length > UI_INTERACT_LIMITS.textChars) {
86
+ throw invalid(
87
+ `${at}.text is ${text.length} chars; the cap is ${UI_INTERACT_LIMITS.textChars}`,
88
+ );
89
+ }
90
+ return { kind: 'type', selector: nonEmpty(field.selector, `${at}.selector`), text };
91
+ }
92
+ case 'wait': {
93
+ if (typeof value === 'number') {
94
+ if (!Number.isFinite(value) || value < 0 || value > UI_INTERACT_LIMITS.waitMs) {
95
+ throw invalid(`${at} is ${value} ms; the cap is ${UI_INTERACT_LIMITS.waitMs}`);
96
+ }
97
+ return { kind: 'wait', ms: value };
98
+ }
99
+ return { kind: 'wait', selector: nonEmpty(value, at) };
100
+ }
101
+ default:
102
+ throw invalid(`${at} is not a verb; a step is ONE of click, type, press, focus, wait`);
103
+ }
104
+ }
105
+
106
+ /** The whole list or nothing: a trimmed list photographs a different scene than the one asked for. */
107
+ export function parseSteps(raw: UiInteractInput['steps']): readonly InteractStep[] {
108
+ if (raw.length > UI_INTERACT_LIMITS.steps) {
109
+ throw invalid(`${raw.length} steps; the cap is ${UI_INTERACT_LIMITS.steps}`);
110
+ }
111
+ return raw.map(parseStep);
112
+ }
113
+
114
+ /** Eight hex chars of `Bun.hash` over the raw list — deterministic per step list, never per run. */
115
+ export const stepsHash = (raw: UiInteractInput['steps']): string =>
116
+ Bun.hash(JSON.stringify(raw)).toString(16).padStart(16, '0').slice(0, 8);
117
+
118
+ const describe = (step: InteractStep): string => {
119
+ switch (step.kind) {
120
+ case 'click':
121
+ case 'focus':
122
+ return `${step.kind} ${JSON.stringify(step.selector)}`;
123
+ case 'press':
124
+ return `press ${JSON.stringify(step.chord)}`;
125
+ case 'type':
126
+ return `type into ${JSON.stringify(step.selector)}`;
127
+ case 'wait':
128
+ return 'ms' in step ? `wait ${step.ms}ms` : `wait for ${JSON.stringify(step.selector)}`;
129
+ }
130
+ };
131
+
132
+ async function perform(
133
+ page: ScrapePage,
134
+ step: InteractStep,
135
+ sleep: (ms: number) => Promise<void>,
136
+ ): Promise<void> {
137
+ switch (step.kind) {
138
+ case 'click':
139
+ return page.click(step.selector);
140
+ case 'focus':
141
+ return page.focus(step.selector);
142
+ case 'press':
143
+ return page.press(step.chord);
144
+ case 'type':
145
+ return page.type(step.selector, step.text);
146
+ case 'wait':
147
+ if ('ms' in step) return sleep(step.ms);
148
+ await page.waitFor(step.selector, { state: 'visible', timeout: DEFAULT_PAGE_TIMEOUT_MS });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Before any keystroke: a password field's value would land in the PNG, the verdict's console and
154
+ * an agent's transcript. The read is the driver's own `query`, so the fake answers it offline.
155
+ */
156
+ async function refuseSecretField(
157
+ page: ScrapePage,
158
+ step: InteractStep,
159
+ index: number,
160
+ ): Promise<void> {
161
+ if (step.kind !== 'type') return;
162
+ const [first] = await page.query(step.selector);
163
+ if (first?.attrs['type'] !== 'password') return;
164
+ throw new UltimateError({
165
+ code: 'X_UI_INTERACT_SECRET_FIELD',
166
+ cause: `step ${index} would type into ${JSON.stringify(step.selector)}, an <input type="password">`,
167
+ fix: FIX.secret,
168
+ meta: { step: index, selector: step.selector },
169
+ });
170
+ }
171
+
172
+ export interface StepsRun {
173
+ readonly steps: readonly UiInteractStepResult[];
174
+ /** Some step changed the page's URL — the navigation `ok` must not read as a redirect. */
175
+ readonly navigated: boolean;
176
+ }
177
+
178
+ /**
179
+ * The steps in order, each followed by a settle and one poll interval, each checked against the
180
+ * origin. A scraping error is wrapped, never passed through: the agent needs to know WHICH step,
181
+ * and the fix is a selector `ui.inspect` reports rather than whatever the driver's fix names.
182
+ */
183
+ export async function runSteps(
184
+ page: ScrapePage,
185
+ steps: readonly InteractStep[],
186
+ settle: () => Promise<IslandCount | null>,
187
+ origin: string,
188
+ sleep: (ms: number) => Promise<void>,
189
+ ): Promise<StepsRun> {
190
+ const results: UiInteractStepResult[] = [];
191
+ let navigated = false;
192
+ for (const [index, step] of steps.entries()) {
193
+ const before = page.url();
194
+ const started = performance.now();
195
+ await refuseSecretField(page, step, index);
196
+ try {
197
+ await perform(page, step, sleep);
198
+ } catch (error) {
199
+ const inner = isUltimateError(error) ? error : toUltimateError(error);
200
+ throw new UltimateError({
201
+ code: 'X_UI_INTERACT_STEP_FAILED',
202
+ cause: `step ${index} (${describe(step)}): ${inner.code} — ${inner.cause}`,
203
+ fix: FIX.failed,
204
+ meta: { step: index, code: inner.code },
205
+ sourceError: error,
206
+ });
207
+ }
208
+ const url = page.url();
209
+ if (new URL(url).origin !== origin) {
210
+ throw new UltimateError({
211
+ code: 'X_UI_INTERACT_LEFT_APP',
212
+ cause: `step ${index} (${describe(step)}) navigated to ${url}, off ${origin}`,
213
+ fix: FIX.left,
214
+ meta: { step: index, url },
215
+ });
216
+ }
217
+ await settle();
218
+ await sleep(SETTLE_POLL_MS);
219
+ const moved = url !== before;
220
+ navigated = navigated || moved;
221
+ results.push({
222
+ index,
223
+ kind: step.kind,
224
+ ms: Math.round(performance.now() - started),
225
+ navigated: moved,
226
+ url,
227
+ });
228
+ }
229
+ return { steps: results, navigated };
230
+ }
231
+
232
+ export async function interactRoute(
233
+ deps: InteractDeps,
234
+ input: UiInteractInput,
235
+ ): Promise<UiInteractResult> {
236
+ // Parsed BEFORE a browser exists: a refused list costs no navigation.
237
+ const steps = parseSteps(input.steps);
238
+ const sleep = deps.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
239
+ const server = await deps.boot();
240
+ const origin = new URL(server.url).origin;
241
+ const requestedUrl = new URL(input.route, server.url).toString();
242
+ const driver = await deps.driver(input.viewport);
243
+ const outDir = join(
244
+ deps.root,
245
+ SHOT_DIR,
246
+ shotSlug(input.route),
247
+ `${input.viewport.width}x${input.viewport.height}-${input.colorScheme}`,
248
+ `interact-${stepsHash(input.steps)}`,
249
+ );
250
+ // Holders rather than `let`s, for `ui.inspect`'s reason: an assignment inside `act` does not
251
+ // reach the narrowing below.
252
+ const ran: { run: StepsRun | null; seen: Seen | null; landedOn: string } = {
253
+ run: null,
254
+ seen: null,
255
+ landedOn: '',
256
+ };
257
+ const artifacts = await runShot({
258
+ route: input.route,
259
+ outDir,
260
+ driver,
261
+ boot: deps.boot,
262
+ settleMs: DEFAULT_SETTLE_MS,
263
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
264
+ fullPage: input.fullPage,
265
+ colorScheme: input.colorScheme,
266
+ act: async (page, settle) => {
267
+ ran.landedOn = page.url();
268
+ ran.run = await runSteps(page, steps, settle, origin, sleep);
269
+ if (input.inspect !== undefined) ran.seen = await readInspect(page, input.inspect);
270
+ },
271
+ });
272
+ const verdict = artifacts.verdict;
273
+ const run = ran.run ?? { steps: [], navigated: false };
274
+ // The verdict fails a capture whose final URL is not the one requested — the sign-in redirect
275
+ // rule. A step that navigated is the agent's doing, not the server's, so it is excused ONLY
276
+ // when the page landed where it was asked and the verdict's other three rules hold.
277
+ const excused =
278
+ run.navigated &&
279
+ ran.landedOn === requestedUrl &&
280
+ verdict.errors === 0 &&
281
+ verdict.pageErrors.length === 0 &&
282
+ (verdict.islands?.failed ?? 0) === 0;
283
+ const inspect =
284
+ input.inspect === undefined || ran.seen === null
285
+ ? undefined
286
+ : {
287
+ title: ran.seen.probe?.title ?? '',
288
+ theme: ran.seen.probe?.theme ?? null,
289
+ activeElement: ran.seen.probe?.activeElement ?? null,
290
+ selectors: selectorsOf(input.inspect, ran.seen),
291
+ truncated: false,
292
+ droppedStyles: [],
293
+ };
294
+ return {
295
+ ok: verdict.ok || excused,
296
+ route: input.route,
297
+ finalUrl: verdict.finalUrl,
298
+ image: artifacts.image,
299
+ verdictFile: artifacts.verdictFile,
300
+ verdict: verdictJson(verdict),
301
+ steps: run.steps,
302
+ ...(inspect === undefined ? {} : { inspect }),
303
+ };
304
+ }
package/src/mcp-ui.ts CHANGED
@@ -1,6 +1,9 @@
1
- // The dev MCP server's two eyes: `ui.shot` (a route) and `ui.island` (a component's states), as
2
- // the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a browser is the CLI's
3
- // to launch. Both are `x shot` under another name: the same server lookup (a running `x dev` is
1
+ // The dev MCP server's eyes and hand: `ui.shot` (a route), `ui.island` (a component's states),
2
+ // `ui.inspect` (DOM facts for a set of selectors, in `mcp-ui-inspect.ts`), `ui.interact` (steps
3
+ // first, then the picture, in `mcp-ui-interact.ts`) and `ui.diff` (two captures compared, in
4
+ // `mcp-ui-diff.ts`), as the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a
5
+ // browser is the CLI's to launch, and the files are the CLI's to read. The four that look
6
+ // are `x shot` under another name: the same server lookup (a running `x dev` is
4
7
  // reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
5
8
  // Nothing here is a new capability; it is the existing one made reachable from inside the loop
6
9
  // an agent already works in, so "does it look right" stops needing a hand-written script.
@@ -8,7 +11,18 @@
8
11
  // why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
9
12
  import { join } from 'node:path';
10
13
  import { UltimateError } from '@ultimat3/core';
11
- import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
14
+ import type {
15
+ UiDiffInput,
16
+ UiDiffResult,
17
+ UiInspectInput,
18
+ UiInspectResult,
19
+ UiInteractInput,
20
+ UiInteractResult,
21
+ UiIslandInput,
22
+ UiIslandResult,
23
+ UiShotInput,
24
+ UiShotResult,
25
+ } from '@ultimat3/mcp';
12
26
  import { describeRoutes } from '@ultimat3/render';
13
27
  import type { ScrapeDriver } from '@ultimat3/scraping';
14
28
  import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
@@ -17,6 +31,9 @@ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
17
31
  import { islandShot } from './cmd-shot-island';
18
32
  import type { Env } from './dev-services';
19
33
  import { islandVerdictJson } from './island-verdict';
34
+ import { diffShots } from './mcp-ui-diff';
35
+ import { inspectRoute } from './mcp-ui-inspect';
36
+ import { interactRoute } from './mcp-ui-interact';
20
37
  import { retryMemo } from './retry-memo';
21
38
  import { shotBrowserChoice } from './shot-browser';
22
39
  import { devServerFor, type ShotServer } from './shot-server';
@@ -44,7 +61,7 @@ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRo
44
61
  throw new UltimateError({
45
62
  code: 'X_UI_SHOT_ROUTE_UNKNOWN',
46
63
  cause: `no route in this app answers ${route}`,
47
- fix: 'x routes --json # then ui.shot with one of its `path` values',
64
+ fix: 'x routes --json # then call the ui.* tool with one of its `path` values',
48
65
  });
49
66
  }
50
67
  if (hit.budgetJs === null) {
@@ -78,6 +95,10 @@ export interface UiHostInput {
78
95
  export interface UiCapabilities {
79
96
  shotRoute(shot: UiShotInput): Promise<UiShotResult>;
80
97
  shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
98
+ inspectRoute(inspect: UiInspectInput): Promise<UiInspectResult>;
99
+ interactRoute(interact: UiInteractInput): Promise<UiInteractResult>;
100
+ /** Two captures under `.x/shot/` compared without a browser; never boots the scratch server. */
101
+ diffShots(diff: UiDiffInput): Promise<UiDiffResult>;
81
102
  /** Stops the scratch server, if one was booted. Never boots one in order to stop it. */
82
103
  close(): Promise<void>;
83
104
  }
@@ -100,6 +121,17 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
100
121
  // provider's CDP URL, or the launcher's own discovery.
101
122
  const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
102
123
  const routes = input.routes ?? describeRoutes;
124
+ // One browser per call, sized to the call: the injected driver in a test, `appBrowser` otherwise.
125
+ const driverFor = async (viewport: UiShotInput['viewport']): Promise<ScrapeDriver> => {
126
+ if (input.driver !== undefined) return input.driver(viewport);
127
+ const { cdpUrl, executablePath } = browser();
128
+ return appBrowser({
129
+ root,
130
+ viewport,
131
+ ...(executablePath === undefined ? {} : { executablePath }),
132
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
133
+ });
134
+ };
103
135
  let closed = false;
104
136
 
105
137
  return {
@@ -113,16 +145,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
113
145
 
114
146
  async shotRoute(shot) {
115
147
  assertBudgetedRoute(shot.route, routes());
116
- const { cdpUrl, executablePath } = browser();
117
- const driver =
118
- input.driver === undefined
119
- ? await appBrowser({
120
- root,
121
- viewport: shot.viewport,
122
- ...(executablePath === undefined ? {} : { executablePath }),
123
- ...(cdpUrl === undefined ? {} : { cdpUrl }),
124
- })
125
- : await input.driver(shot.viewport);
148
+ const driver = await driverFor(shot.viewport);
126
149
  // One directory per (route, viewport, scheme), so two pictures of one route at two widths
127
150
  // never overwrite each other and an agent can hold both.
128
151
  const outDir = join(
@@ -149,6 +172,21 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
149
172
  };
150
173
  },
151
174
 
175
+ async inspectRoute(inspect) {
176
+ // The same gate as `ui.shot`, for the same reason: facts about a draft are facts about the
177
+ // wrong thing.
178
+ assertBudgetedRoute(inspect.route, routes());
179
+ return inspectRoute({ root, boot, driver: driverFor }, inspect);
180
+ },
181
+
182
+ async interactRoute(interact) {
183
+ assertBudgetedRoute(interact.route, routes());
184
+ return interactRoute({ root, boot, driver: driverFor }, interact);
185
+ },
186
+ // No route gate and no boot: the captures were gated when they were taken, and a diff of two
187
+ // files needs neither a server nor a browser.
188
+ diffShots: (diff) => diffShots({ root }, diff),
189
+
152
190
  async shotIsland(island) {
153
191
  const { cdpUrl, executablePath } = browser();
154
192
  const artifacts = await islandShot({
package/src/output.ts CHANGED
@@ -64,7 +64,7 @@ export interface CommandResult {
64
64
  * Which fd this result is written to. `stdout` for every command, absent included — and
65
65
  * `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
66
66
  * --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
67
- * 15 tools` line printed after the loop exits is a malformed frame to whatever is reading.
67
+ * 18 tools` line printed after the loop exits is a malformed frame to whatever is reading.
68
68
  *
69
69
  * Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
70
70
  * where a rendered line goes, and a payload that also claimed it would be a second answer to a
package/src/prerender.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  serviceWorkerHead,
31
31
  serviceWorkerRegistration,
32
32
  } from './sw-artifacts';
33
+ import { loadThemeMode, themeBoot } from './theme-boot';
33
34
 
34
35
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
35
36
  // carries it, and this file already imports that module.
@@ -186,6 +187,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
186
187
  // wiring exists to close. `undefined` when the app is not installable, and then no document
187
188
  // names it either.
188
189
  const pwa = await loadPwaArtifacts(options.root);
190
+ const theme = themeBoot(await loadThemeMode(options.root));
189
191
  // The registration TAG now, the worker itself after the render loop — the two halves are wanted
190
192
  // at different moments and used to be taken at the same one. Every document below has to name
191
193
  // `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
@@ -238,6 +240,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
238
240
  runWithContext(as, () =>
239
241
  routeDocument(entry, data, {
240
242
  resolveIsland: (file: string) => islands.resolverFor(file),
243
+ themeHead: theme.head,
241
244
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
242
245
  }),
243
246
  );
@@ -146,6 +146,11 @@ const overlay = (root: string, app: string): string =>
146
146
  compilerOptions: {
147
147
  noEmit: true,
148
148
  paths: {
149
+ // The two subpath exports a generated app reaches for, spelled the way the packages'
150
+ // own `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a
151
+ // package name. Longest prefix wins, so these are consulted first.
152
+ '@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
153
+ '@ultimat3/render/server': [`${root}/packages/render/src/server`],
149
154
  '@ultimat3/*': [`${root}/packages/*/src`],
150
155
  [`@${app}/web/*`]: ['./apps/web/*'],
151
156
  [`@${app}/admin/*`]: ['./apps/admin/*'],
package/src/script-csp.ts CHANGED
@@ -11,7 +11,10 @@ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
11
11
  * Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
12
12
  * reach it. Read from `@ultimat3/render`'s own enumeration rather than restated here — the body
13
13
  * the document carries and the body the policy hashes have to be one string.
14
+ *
15
+ * `extra` is for sources the caller already hashed from a body it emits — the theme boot's,
16
+ * which `theme-boot.ts` derives from the same string it inlines.
14
17
  */
15
- export function inlineScriptSources(): readonly string[] {
16
- return [...new Set(HYDRATE_RUNTIME_BODIES.map(cspHashSource))].sort();
18
+ export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
19
+ return [...new Set([...HYDRATE_RUNTIME_BODIES.map(cspHashSource), ...extra])].sort();
17
20
  }