@broberg/lens-daemon 0.0.0-stage → 0.1.5

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,97 @@
1
+ // Vision-model validation — runs each candidate against TWO cases:
2
+ // 1. spatial-overlap — the ✕ over the version badge (geometry's home turf;
3
+ // geometry nails it free — here we see if vision can even see it).
4
+ // 2. fuzzy-aesthetic — inverted hierarchy + low-contrast-on-gradient +
5
+ // clashing illegible button (NO overlap; geometry is blind here — THIS is
6
+ // vision's real job). A gradient bg means the geometry contrast check skips.
7
+ //
8
+ // Pick cheapest-that-DUER for vision's actual domain. Add Pixtral to CANDIDATES
9
+ // when it lands in the SDK. EU/Mistral only — no paid-Anthropic route (house rule).
10
+ //
11
+ // MISTRAL_API_KEY auto-loaded from .env:
12
+ // bun run apps/agent/src/lens/validate-vision.ts
13
+
14
+ import { withPage } from './browser.ts';
15
+
16
+ const FIXTURES = [
17
+ {
18
+ name: 'spatial-overlap',
19
+ html: `<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;margin:0;background:#fff">
20
+ <div style="position:relative;width:420px;height:160px;margin:48px;background:#f6f7f9;border:1px solid #ccc;border-radius:12px;padding:20px">
21
+ <h2 style="margin:0 0 8px">Release notes</h2>
22
+ <span style="position:absolute;left:24px;top:74px;width:160px;height:30px;line-height:30px;background:#e7ecff;border-radius:8px;padding:0 10px;color:#1a2b4a">shipped v0.2.0</span>
23
+ <button style="position:absolute;left:78px;top:68px;width:120px;height:42px;background:#fff;border:1px solid #ddd;font-size:18px">✕ close</button>
24
+ </div></body></html>`,
25
+ looksFor: /overlap|on top|cover|hidden|behind|close button|truncat|cut off|clip|awkward/i,
26
+ },
27
+ {
28
+ name: 'fuzzy-aesthetic',
29
+ html: `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;background:#fff">
30
+ <div style="width:460px;margin:40px;background:linear-gradient(135deg,#7b2ff7,#f107a3);padding:28px;border-radius:14px;font-family:system-ui">
31
+ <p style="font-size:11px;color:#d8b8ff;margin:0 0 4px">UPGRADE</p>
32
+ <h1 style="font-size:13px;color:#e3c0ff;margin:0 0 18px;font-weight:400">Pro plan</h1>
33
+ <p style="font-size:23px;color:#c79fe6;margin:0 0 6px;line-height:1.15">Everything in Free, plus unlimited projects, priority support, and advanced analytics for your whole team.</p>
34
+ <button style="background:#e8ff00;color:#fbff66;border:none;padding:12px 20px;border-radius:8px;font-size:16px;margin-top:14px">Get Pro</button>
35
+ </div></body></html>`,
36
+ looksFor: /hierarch|contrast|legib|readab|illegible|cramp|spacing|clash|color|heading|small|tiny|button/i,
37
+ },
38
+ ];
39
+
40
+ const SYSTEM =
41
+ 'You are a UI composition critic. You are shown ONE screenshot of a single app screen. ' +
42
+ 'Report ONLY genuine VISUAL composition problems a careful designer would flag: overlapping ' +
43
+ 'or colliding elements, a control sitting on top of text, text that is clipped/truncated/' +
44
+ 'unreadable, content cut off or off-screen, low contrast, broken hierarchy, clashing/illegible ' +
45
+ 'colours, anything that plainly looks broken. If the screen looks coherent, return an empty list. Be terse.';
46
+ const PROMPT =
47
+ 'Return ONLY a JSON object: {"findings":[{"severity":"high|medium|low","region":"<where>","issue":"<what>"}]}. ' +
48
+ 'Empty findings array if the composition holds together.';
49
+
50
+ const CANDIDATES = [
51
+ // ai-sdk: Pixtral is retired; magistral-medium (reasoning + vision) is the
52
+ // strongest GDPR-safe bet for a critic that must judge aesthetics — try first.
53
+ { key: 'magistral-medium ($2/$5, EU, reasoning)', provider: 'mistral', model: 'magistral-medium-latest', envKey: 'MISTRAL_API_KEY' },
54
+ { key: 'mistral-large ($0.5/$1.5, EU)', provider: 'mistral', model: 'mistral-large-2512', envKey: 'MISTRAL_API_KEY' },
55
+ ];
56
+
57
+ const sdk = await import('@broberg/ai-sdk');
58
+ const providers: Record<string, unknown> = {};
59
+ if (process.env.MISTRAL_API_KEY) providers.mistral = sdk.mistralAdapter({ apiKey: process.env.MISTRAL_API_KEY });
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ const ai = sdk.createAI({ providers: providers as any }) as any;
62
+
63
+ for (const fx of FIXTURES) {
64
+ const url = 'data:text/html;charset=utf-8,' + encodeURIComponent(fx.html);
65
+ const shot = await withPage({ viewport: { width: 560, height: 320 } }, async (page) => {
66
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
67
+ await page.waitForTimeout(200);
68
+ return Buffer.from(await page.screenshot());
69
+ });
70
+ console.log(`\n\n######## CASE: ${fx.name} ########`);
71
+ for (const c of CANDIDATES) {
72
+ if (!process.env[c.envKey]) {
73
+ console.log(`\n=== ${c.key} — SKIPPED (no ${c.envKey}) ===`);
74
+ continue;
75
+ }
76
+ console.log(`\n=== ${c.key} ===`);
77
+ try {
78
+ const res = await ai.vision({
79
+ image: shot,
80
+ mimeType: 'image/png',
81
+ prompt: SYSTEM + '\n\n' + PROMPT,
82
+ override: { provider: c.provider, model: c.model, transport: 'http' },
83
+ purpose: 'vision-validation',
84
+ });
85
+ const text = typeof res.text === 'string' ? res.text : JSON.stringify(res.text ?? '');
86
+ const parsed = sdk.parseJsonLoose(text) as { findings?: unknown };
87
+ const findings = Array.isArray(parsed?.findings) ? parsed.findings : [];
88
+ const onTarget = fx.looksFor.test(JSON.stringify(findings));
89
+ console.log('cost :', res.usage?.costUsd != null ? `$${res.usage.costUsd.toFixed(5)}` : '—');
90
+ console.log('on-target finding?', onTarget ? 'YES ✓' : 'NO ✗', `· ${findings.length} finding(s)`);
91
+ console.log(JSON.stringify(findings, null, 2));
92
+ } catch (err) {
93
+ console.log('ERROR:', err instanceof Error ? err.message : String(err));
94
+ }
95
+ }
96
+ }
97
+ process.exit(0);
package/src/verify.ts ADDED
@@ -0,0 +1,411 @@
1
+ // F074.2 — Lens verify primitives.
2
+ //
3
+ // Verification = diff AND assertion, not just a raw screenshot. One page
4
+ // visit yields: a screenshot, an optional pixel-diff against a baseline, an
5
+ // optional DOM measurement (assert.js via page.evaluate), a structural
6
+ // domHash, and console/network capture (reported, never auto-fatal).
7
+ //
8
+ // Status logic (prioritised — see F074 plan):
9
+ // 1. assert set and assertResult.pass === false → fail
10
+ // 2. else baselineKey set + baseline exists → pixelmatch:
11
+ // diffRatio < threshold → pass, else → fail
12
+ // 3. else baselineKey set, no baseline → no-baseline
13
+ // 4. else (plain capture) page loaded w/o uncaught error → pass
14
+ //
15
+ // Console/network errors are reported but never downgrade status on their
16
+ // own — "assertion failed" is separated from "page logged a warning". The
17
+ // caller (cc) decides what to do with the console/network signal.
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { PNG } from 'pngjs';
21
+ import pixelmatch from 'pixelmatch';
22
+ import type { Page } from 'playwright';
23
+ import { withPage } from './browser.ts';
24
+ import { navigateAndAct, resolveExtensionUrl, resolvePrepareContext, takeShot, type CaptureMode, type CaptureOptions } from './capture.ts';
25
+ import { storagePayload } from './seed-storage.ts';
26
+ import { preflight, type PreflightResult } from './preflight.ts';
27
+ import { runCritic, topFindingMessage, type CriticReport } from './critic.ts';
28
+ import { checkVideo, type VideoCheckOptions, type VideoResult } from './video.ts';
29
+ // F074.39 — ONE assert evaluator, shared with the flow surface (see runAssert).
30
+ import { noVerdictMessage } from '@broberg/lens-engine';
31
+ import { evalAssertBody } from './flow.ts';
32
+ import { observeBuild } from './build-marker.ts';
33
+
34
+ export type LensStatus = 'pass' | 'fail' | 'no-baseline' | 'error';
35
+
36
+ export interface VerifyOptions extends CaptureOptions {
37
+ /** A JS statement-body evaluated in the page. Should `return` either a
38
+ * boolean or `{ pass: boolean, detail?: any }`. Runs in the browser
39
+ * context (sandboxed to the page), e.g.:
40
+ * "const el = document.querySelector('[data-testid=cta]');
41
+ * return { pass: el.getBoundingClientRect().width > 100, detail: el.offsetWidth }" */
42
+ assert?: string;
43
+ /** PNG bytes of the approved baseline for this surface, if one exists. */
44
+ baseline?: Buffer | null;
45
+ /** A key naming the baseline slot. When set without `baseline`, status is
46
+ * `no-baseline` (the shot becomes promotable). */
47
+ baselineKey?: string;
48
+ /** Pixel-diff pass threshold as a fraction of total pixels. Default 0.01. */
49
+ diffThreshold?: number;
50
+ /** F074.57 — BCP-47 locale + IANA timezone for the browser context. Unset
51
+ * passes nothing to Playwright (existing baselines must not move). */
52
+ locale?: string;
53
+ timezone?: string;
54
+ /** F074.59 — prefers-reduced-motion for this capture. Default 'reduce', which is
55
+ * what every capture did before this option existed, so no baseline can move. */
56
+ reducedMotion?: 'reduce' | 'no-preference';
57
+ /** Collect console messages (default true). */
58
+ collectConsole?: boolean;
59
+ /** Collect failed/non-2xx network responses (default true). */
60
+ collectNetwork?: boolean;
61
+ /** Skip the pre-flight stage (used when the caller already proved reachability). */
62
+ skipPreflight?: boolean;
63
+ /** Video-liveness: drive a <video> and assert it actually plays (currentTime
64
+ * advances + frames move + no MediaError). A not-playing verdict fails the
65
+ * verify. Presence of this object turns the check on. */
66
+ video?: VideoCheckOptions;
67
+ }
68
+
69
+ export interface DiffResult {
70
+ diffRatio: number;
71
+ diffPixels: number;
72
+ totalPixels: number;
73
+ /** PNG bytes highlighting changed pixels, or null on a dimension mismatch. */
74
+ diffPng: Buffer | null;
75
+ /** True when baseline + current differ in size (an automatic fail). */
76
+ dimensionMismatch: boolean;
77
+ }
78
+
79
+ export interface ConsoleEntry {
80
+ type: string;
81
+ text: string;
82
+ }
83
+ export interface NetworkEntry {
84
+ url: string;
85
+ status: number;
86
+ ok: boolean;
87
+ failure?: string;
88
+ }
89
+
90
+ export interface VerifyResult {
91
+ status: LensStatus;
92
+ reason: string;
93
+ mode: CaptureMode;
94
+ selector: string | null;
95
+ url: string;
96
+ finalUrl: string | null;
97
+ title: string | null;
98
+ screenshot: Buffer | null;
99
+ width: number;
100
+ height: number;
101
+ elementBox: { x: number; y: number; width: number; height: number } | null;
102
+ diff: DiffResult | null;
103
+ assertResult: { pass: boolean; detail: unknown } | null;
104
+ domHash: string | null;
105
+ console: ConsoleEntry[];
106
+ network: NetworkEntry[];
107
+ pageErrors: string[];
108
+ preflight: PreflightResult | null;
109
+ /** F074.69 — the extension this run loaded and the id Chromium generated for
110
+ * it, so a caller can cite the exact surface photographed. Null otherwise. */
111
+ extension?: { id: string; path: string } | null;
112
+ durationMs: number;
113
+ /** F126.4 — composition-critic findings, when a critic was requested. A
114
+ * high-severity finding folds the status to `fail` (it blocks Review→Done). */
115
+ critic: CriticReport | null;
116
+ /** Video-liveness result, when a `video` check was requested. A not-playing
117
+ * verdict folds the status to `fail`. */
118
+ video: VideoResult | null;
119
+ /** F074.30 — the build sha this capture was looking at, read at capture time.
120
+ * NULL when the target exposes no marker: "not measured", never "same build
121
+ * as the other run". */
122
+ build: string | null;
123
+ }
124
+
125
+ /** sha256 over the target's normalized outerHTML — a cheap structural
126
+ * fingerprint that changes when the DOM changes but is stable across
127
+ * re-renders of identical markup. */
128
+ async function computeDomHash(page: Page, selector: string | null): Promise<string> {
129
+ const html = await page.evaluate((sel) => {
130
+ const node = sel ? document.querySelector(sel) : document.documentElement;
131
+ return node ? (node as HTMLElement).outerHTML : '';
132
+ }, selector);
133
+ const normalized = html.replace(/\s+/g, ' ').trim();
134
+ return createHash('sha256').update(normalized, 'utf-8').digest('hex');
135
+ }
136
+
137
+ /**
138
+ * Evaluate the assert body in the page; normalize boolean → {pass, detail}.
139
+ *
140
+ * F074.39 — this runs the SAME `evalAssertBody` the flow assert runs, because
141
+ * an assert contract that differs per surface is one nobody can hold in their
142
+ * head. It had diverged in a way that produced a false RED: verify used only
143
+ * the block wrapper, so a bare-expression assert (`document.title.length > 0` —
144
+ * exactly what the flow surface accepts, and what our own docs show) returned
145
+ * `undefined` here and failed a page that was fine. One evaluator, one contract:
146
+ * expression-or-block, `await` allowed, `{ pass, detail }` honoured.
147
+ *
148
+ * A broken PROBE stays distinguishable from a failing app: syntax + throw are
149
+ * re-thrown, so the run reports `error` with the reason rather than `fail` —
150
+ * "your predicate is false" and "your predicate never ran" are different
151
+ * sentences, and this surface has always been honest about that.
152
+ */
153
+ export async function runAssert(page: Page, assertBody: string): Promise<{ pass: boolean; detail: unknown }> {
154
+ const out = await page.evaluate(evalAssertBody, assertBody);
155
+ if (out.kind === 'syntax') {
156
+ throw new Error(`assert body is not valid JavaScript (${out.message})`);
157
+ }
158
+ if (out.kind === 'threw') {
159
+ // No polling on this surface (verify is a single measurement), so a throw
160
+ // is terminal — and it is the INSTRUMENT that broke, never a verdict.
161
+ throw new Error(`assert body threw: ${out.message}`);
162
+ }
163
+ if (out.kind === 'no-verdict') {
164
+ // Same author error as on the flow path, and it throws here for the same
165
+ // reason a syntax error does: the instrument was handed something that
166
+ // cannot express a verdict, so reporting pass OR fail would be inventing
167
+ // one. Wording from the engine (0.6.1) so the two surfaces cannot drift.
168
+ throw new Error(noVerdictMessage(out.keys));
169
+ }
170
+ return { pass: out.value, detail: out.detail ?? null };
171
+ }
172
+
173
+ /** pixelmatch the current shot against the baseline. Dimension mismatch is an
174
+ * automatic fail (the surface changed size). */
175
+ function diffAgainstBaseline(current: Buffer, baseline: Buffer): DiffResult {
176
+ const a = PNG.sync.read(baseline);
177
+ const b = PNG.sync.read(current);
178
+ if (a.width !== b.width || a.height !== b.height) {
179
+ return {
180
+ diffRatio: 1,
181
+ diffPixels: a.width * a.height,
182
+ totalPixels: a.width * a.height,
183
+ diffPng: null,
184
+ dimensionMismatch: true,
185
+ };
186
+ }
187
+ const { width, height } = a;
188
+ const diff = new PNG({ width, height });
189
+ const diffPixels = pixelmatch(a.data, b.data, diff.data, width, height, {
190
+ threshold: 0.1, // per-pixel colour sensitivity (not the pass/fail ratio)
191
+ // F094.7 — detect + ignore anti-aliased pixels (the default, made explicit):
192
+ // text edges differ sub-pixel between captures and must not count as a diff.
193
+ includeAA: false,
194
+ });
195
+ const totalPixels = width * height;
196
+ return {
197
+ diffRatio: totalPixels === 0 ? 0 : diffPixels / totalPixels,
198
+ diffPixels,
199
+ totalPixels,
200
+ diffPng: PNG.sync.write(diff),
201
+ dimensionMismatch: false,
202
+ };
203
+ }
204
+
205
+ /** F074.92 — A SELECTOR WITHOUT A MODE USED TO PHOTOGRAPH THE WHOLE PAGE.
206
+ *
207
+ * `mode` defaulted to 'viewport' unconditionally, and takeShot() reads the
208
+ * selector ONLY in 'element' mode — so a caller who passed `selector` and no
209
+ * `mode` got a full-page shot. The response gave no sign of it: the selector
210
+ * came back echoed, `surface_key` was set to it, and `dom_hash` was computed
211
+ * from that very element. Four fields agreed it was an element shot; only the
212
+ * picture disagreed. Reported by broberg-id, who measured two shots of the same
213
+ * size and concluded, correctly, that the element had been ignored.
214
+ *
215
+ * Nobody passes a selector meaning "and ignore it", so the mode is inferred.
216
+ * That is also what our OWN manifest meant: its `landing-hero` surface carries
217
+ * a selector and no mode, and has been storing a full-page baseline under an
218
+ * element's name ever since it was written. Expect that one baseline to diff
219
+ * on its next run — the new shot is right and the stored one never was.
220
+ *
221
+ * An explicit mode ALWAYS wins, so a surface that deliberately pairs a selector
222
+ * with viewport (to key a baseline by name) is untouched. And the inference is
223
+ * visible rather than magic: `mode` is echoed in the result. */
224
+ export function resolveCaptureMode(opts: { mode?: CaptureMode; selector?: string | null }): CaptureMode {
225
+ return opts.mode ?? (opts.selector ? 'element' : 'viewport');
226
+ }
227
+
228
+ export async function verify(opts: VerifyOptions): Promise<VerifyResult> {
229
+ const startedAt = Date.now();
230
+ const mode: CaptureMode = resolveCaptureMode(opts);
231
+ const timeoutMs = opts.timeoutMs ?? 30_000;
232
+ const threshold = opts.diffThreshold ?? 0.01;
233
+ const collectConsole = opts.collectConsole ?? true;
234
+ const collectNetwork = opts.collectNetwork ?? true;
235
+
236
+ // F074.30 — stamped HERE, at capture, not read back later: asking after the
237
+ // fact returns today's build for an OLD recording. Best-effort: null when the
238
+ // target has no marker, and never able to fail a capture that would pass.
239
+ // F074.69 — an extension:// url is not reachable over the network and has no
240
+ // build marker. Probing it would fail for a reason that has nothing to do with
241
+ // the surface, and the failure would read as "the extension is broken".
242
+ const isExtension = !!opts.extension || opts.url.startsWith('extension://');
243
+ const build = isExtension ? null : await observeBuild(opts.url);
244
+
245
+ const empty = {
246
+ mode,
247
+ selector: opts.selector ?? null,
248
+ url: opts.url,
249
+ build,
250
+ finalUrl: null,
251
+ title: null,
252
+ screenshot: null,
253
+ width: 0,
254
+ height: 0,
255
+ elementBox: null,
256
+ diff: null,
257
+ assertResult: null,
258
+ domHash: null,
259
+ console: [] as ConsoleEntry[],
260
+ network: [] as NetworkEntry[],
261
+ pageErrors: [] as string[],
262
+ critic: null as CriticReport | null,
263
+ video: null as VideoResult | null,
264
+ };
265
+
266
+ // 1. Pre-flight (unless the caller already proved reachability).
267
+ let pre: PreflightResult | null = null;
268
+ if (!opts.skipPreflight && !isExtension) {
269
+ pre = await preflight(opts.url, Math.min(timeoutMs, 5_000));
270
+ if (!pre.ok) {
271
+ return { ...empty, status: 'error', reason: pre.reason ?? 'pre-flight failed', preflight: pre, durationMs: Date.now() - startedAt };
272
+ }
273
+ }
274
+
275
+ try {
276
+ const result = await withPage(
277
+ {
278
+ engine: opts.engine,
279
+ locale: opts.locale,
280
+ timezone: opts.timezone,
281
+ reducedMotion: opts.reducedMotion,
282
+ viewport: opts.viewport,
283
+ deviceScaleFactor: opts.deviceScaleFactor,
284
+ prepareContext: resolvePrepareContext(opts),
285
+ // F074.97 — seeded state reaches lens_verify too: the tool that GATES a
286
+ // card is the one that most needs to photograph a saved-state bug.
287
+ seedStorage: storagePayload(opts.storage, opts.url),
288
+ extension: opts.extension,
289
+ },
290
+ async (page, _context, extension) => {
291
+ const pageErrors: string[] = [];
292
+ const consoleEntries: ConsoleEntry[] = [];
293
+ const network: NetworkEntry[] = [];
294
+ page.on('pageerror', (err) => pageErrors.push(err.message));
295
+ if (collectConsole) {
296
+ page.on('console', (msg) => consoleEntries.push({ type: msg.type(), text: msg.text() }));
297
+ }
298
+ if (collectNetwork) {
299
+ page.on('response', (res) => {
300
+ if (!res.ok()) network.push({ url: res.url(), status: res.status(), ok: false });
301
+ });
302
+ page.on('requestfailed', (req) => {
303
+ network.push({ url: req.url(), status: 0, ok: false, failure: req.failure()?.errorText ?? 'failed' });
304
+ });
305
+ }
306
+
307
+ // F074.69 — substitute the generated extension id only now that the
308
+ // browser has reported it. A caller cannot know it in advance, so
309
+ // `extension://popup.html` is the shorthand they write instead.
310
+ await navigateAndAct(page, { ...opts, url: resolveExtensionUrl(opts.url, extension?.id) });
311
+ const title = await page.title();
312
+ const finalUrl = page.url();
313
+ const shot = await takeShot(page, mode, opts.selector ?? null, timeoutMs);
314
+ const domHash = await computeDomHash(page, opts.selector ?? null);
315
+ const assertResult = opts.assert ? await runAssert(page, opts.assert) : null;
316
+ const critic =
317
+ opts.critic && opts.critic !== 'off'
318
+ ? await runCritic(page, shot.screenshot, opts.critic, opts.criticVisionModel)
319
+ : null;
320
+ const video = opts.video ? await checkVideo(page, opts.video) : null;
321
+
322
+ return { title, finalUrl, shot, domHash, assertResult, pageErrors, console: consoleEntries, network, critic, video, extension: extension ?? null };
323
+ },
324
+ );
325
+
326
+ // 3. Diff against baseline (pure, post-session).
327
+ let diff: DiffResult | null = null;
328
+ if (opts.baselineKey && opts.baseline) {
329
+ diff = diffAgainstBaseline(result.shot.screenshot, opts.baseline);
330
+ }
331
+
332
+ // 4. Prioritised status logic.
333
+ let status: LensStatus;
334
+ let reason: string;
335
+ if (opts.assert && result.assertResult && !result.assertResult.pass) {
336
+ status = 'fail';
337
+ reason = `assertion failed: ${JSON.stringify(result.assertResult.detail)}`;
338
+ } else if (opts.baselineKey && opts.baseline && diff) {
339
+ if (diff.dimensionMismatch) {
340
+ status = 'fail';
341
+ reason = 'baseline dimension mismatch — surface changed size';
342
+ } else if (diff.diffRatio < threshold) {
343
+ status = 'pass';
344
+ reason = `diff ${(diff.diffRatio * 100).toFixed(3)}% < ${(threshold * 100).toFixed(2)}% threshold`;
345
+ } else {
346
+ status = 'fail';
347
+ reason = `diff ${(diff.diffRatio * 100).toFixed(3)}% ≥ ${(threshold * 100).toFixed(2)}% threshold`;
348
+ }
349
+ } else if (opts.baselineKey && !opts.baseline) {
350
+ status = 'no-baseline';
351
+ reason = `no baseline for "${opts.baselineKey}" — shot is promotable`;
352
+ } else {
353
+ const ok = result.pageErrors.length === 0;
354
+ status = ok ? 'pass' : 'fail';
355
+ reason = ok ? 'page loaded with no uncaught error' : `uncaught page error(s): ${result.pageErrors.join('; ')}`;
356
+ }
357
+
358
+ // F126.4 — a high-severity composition finding folds an otherwise-passing
359
+ // (or promotable) surface to `fail`: the screen is visibly broken
360
+ // (something overlapping / covered / clipped / off-screen) even though its
361
+ // pixels/assert passed. This is what makes the critic BLOCK Review→Done. An
362
+ // existing assert/baseline/error fail keeps its (more specific) reason.
363
+ if (result.critic && result.critic.high > 0 && status !== 'fail') {
364
+ status = 'fail';
365
+ const top = topFindingMessage(result.critic);
366
+ reason = `composition: ${result.critic.high} high-severity issue(s)${top ? ` — ${top}` : ''}`;
367
+ }
368
+
369
+ // Video-liveness: if a video check was requested and the video isn't
370
+ // playing, that's a definitive fail (it's the point of the check). Keep an
371
+ // earlier assert/baseline fail's more-specific reason.
372
+ if (result.video && !result.video.playing && status !== 'fail') {
373
+ status = 'fail';
374
+ reason = `video: ${result.video.reason}`;
375
+ }
376
+
377
+ return {
378
+ status,
379
+ reason,
380
+ mode,
381
+ selector: opts.selector ?? null,
382
+ url: opts.url,
383
+ build,
384
+ finalUrl: result.finalUrl,
385
+ title: result.title,
386
+ screenshot: result.shot.screenshot,
387
+ width: result.shot.width,
388
+ height: result.shot.height,
389
+ elementBox: result.shot.elementBox,
390
+ diff,
391
+ assertResult: result.assertResult,
392
+ domHash: result.domHash,
393
+ console: result.console,
394
+ network: result.network,
395
+ pageErrors: result.pageErrors,
396
+ preflight: pre,
397
+ durationMs: Date.now() - startedAt,
398
+ critic: result.critic,
399
+ video: result.video,
400
+ extension: result.extension,
401
+ };
402
+ } catch (err) {
403
+ return {
404
+ ...empty,
405
+ status: 'error',
406
+ reason: `capture/verify threw: ${err instanceof Error ? err.message : String(err)}`,
407
+ preflight: pre,
408
+ durationMs: Date.now() - startedAt,
409
+ };
410
+ }
411
+ }