@broberg/lens-daemon 0.0.0-stage → 0.1.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/src/service.ts ADDED
@@ -0,0 +1,1483 @@
1
+ // F074.5 — Lens service layer.
2
+ //
3
+ // The browser-owning operations, as pure functions shared by BOTH the HTTP
4
+ // router (http.ts) and the MCP server (mcp.ts). Each is hard-scoped to a
5
+ // project_slug — a project only ever touches its own runs/baselines.
6
+ //
7
+ // Artifact bytes are served back by the daemon itself (artifactUrl); the SPA
8
+ // dashboard + cc point <img>/links at the daemon, the same way the rest of
9
+ // the SPA already talks to 127.0.0.1:7475.
10
+
11
+ import { randomUUID } from 'node:crypto';
12
+ import sharp from 'sharp';
13
+ import { durabilityOf } from './durability.ts';
14
+ import type { Database } from 'bun:sqlite';
15
+ import type { CaptureAction, CaptureMode } from './capture.ts';
16
+ import { coerceStorageSeed } from './seed-storage.ts';
17
+ import type { LensEngine } from './browser.ts';
18
+ import { verify as runVerify, type LensStatus } from './verify.ts';
19
+ import type { CriticMode } from './critic.ts';
20
+ import type { VisionModelKey } from './critic-vision.ts';
21
+ import type { VideoCheckOptions } from './video.ts';
22
+ import type { AuthConfig, AuthEnv } from './auth/index.ts';
23
+ import {
24
+ recordRun,
25
+ listRuns,
26
+ getRun,
27
+ listBaselines,
28
+ getBaselineBytes,
29
+ promoteBaseline,
30
+ deleteBaseline,
31
+ listBaselineVersions,
32
+ revertBaseline,
33
+ } from './store.ts';
34
+ import { runFlow, parseFlow, compileManuscript, type FlowResult, type FlowStepResult } from './flow.ts';
35
+ // F074.24 — the daemon HOSTS the engine's own runFlow for flat cloud-shape
36
+ // FlowRequests (one wire-shape, driven via @broberg/lens-client). The engine
37
+ // natively speaks the cloud grammar (all 12 action-verbs), self-heals via the
38
+ // same resolveTarget the daemon uses (F074.23), and does the F049 base_url nav —
39
+ // so there is ZERO grammar translation. It owns its own Chromium + takes a
40
+ // storageState directly, so nothing crosses the playwright version boundary.
41
+ import { runFlow as engineRunFlow, flowBodySchema, type FlowResult as EngineFlowResult } from '@broberg/lens-engine';
42
+ import { authProvenance, resolveAuthPaths, resolveLocale, manifestLocale } from './manifest.ts';
43
+ import { recordFlowRun, listFlowRuns, type StoredFlowStep } from './flow-store.ts';
44
+ import { artifactKeys, type LensStorage } from './storage.ts';
45
+ import { exportPage, looksLikePdf, readMediaBox, ptToMm, type ExportMedia } from './export.ts';
46
+ import { pdfContentStats, isVectorText, matchPhrase, outlineFontNames } from './pdf-text.ts';
47
+ import type { LensRunRow, LensBaselineRow, LensFlowRunRow } from './schema.ts';
48
+ import { runRecord } from './record-run.ts';
49
+
50
+ export interface LensDeps {
51
+ db: Database;
52
+ storage: LensStorage;
53
+ port: number;
54
+ /** Sandbox roots — used to resolve a flow/manuscript's relative auth statePath
55
+ * when local_path is passed. NOTE: inheriting a project's manifest auth is
56
+ * the FLOW/MANUSCRIPT path only (effectiveFlowAuth in manifest.ts); this
57
+ * comment used to claim it happened here too, which measurably it does not
58
+ * — see resolveVerifyAuth below, which returns early when no auth is given. */
59
+ sandboxRoots: string[];
60
+ /** F272.2 — a getter, not the client itself: the daemon's cloud client is
61
+ * re-made on login and nulled on logout, so capturing the value at wiring time
62
+ * would hand this a client that is permanently stale (or permanently null on a
63
+ * daemon that started signed-out and was signed in afterwards). */
64
+ cloud?: () => CloudClientLike | null;
65
+ /** lens F001.5 — the daemon's own sandbox check, injected (see sandbox.ts). The
66
+ * package holds no copy of this security boundary; createLensRouter installs it. */
67
+ resolveSandboxedPath?: (path: string, sandboxRoots: string[]) => string;
68
+ }
69
+
70
+ /** The one method promoteFlowEvidence needs. Structural on purpose so a test can
71
+ * pass a stub without dragging the daemon's real client (and its fetch) in. */
72
+ export interface CloudClientLike {
73
+ callTool<T = unknown>(name: string, args?: Record<string, unknown>): Promise<T>;
74
+ }
75
+
76
+ /** F312.25 — `lens_record` through the MCP door.
77
+ *
78
+ * Christian: «byg lens_record MCP-verbet». Before this, recording existed ONLY
79
+ * as POST /lens/record on the daemon, so no other repo could use it — and F112
80
+ * forbids them hand-rolling a call to :7475. Two repos hit that wall within one
81
+ * hour on 6 September (this one, and fd-sundhed's F006.385).
82
+ *
83
+ * IT DELEGATES. runRecord() is the same function the HTTP route calls; there is
84
+ * no second handler here that "does the same thing". F095.15 is the precedent —
85
+ * a rule present through one door and absent through the other looked correct
86
+ * from either side, and a peer repo found it.
87
+ */
88
+ export async function lensRecord(deps: LensDeps, args: Record<string, unknown>) {
89
+ // NAMED before a browser is launched. An empty `seconds` that reaches the
90
+ // recorder becomes a 0-second run that reports zero findings — indistinguishable
91
+ // from a clean result, which is the confusion this whole feature removes.
92
+ const missing = (['url', 'seconds'] as const).filter((k) => args[k] == null);
93
+ if (missing.length)
94
+ return {
95
+ ok: false as const,
96
+ error: `lens_record needs ${missing.join(' and ')}. \`url\` is the surface to watch and \`seconds\` is how long — a recording with no duration cannot say whether nothing happened or nothing was watched.`,
97
+ };
98
+
99
+ const { status: _status, ...rest } = await runRecord(args, {
100
+ config: { port: deps.port, sandbox_roots: deps.sandboxRoots },
101
+ storage: deps.storage,
102
+ });
103
+ return rest;
104
+ }
105
+
106
+ export function artifactUrl(port: number, key: string | null): string | null {
107
+ if (!key) return null;
108
+ return `http://127.0.0.1:${port}/lens/artifact?key=${encodeURIComponent(key)}`;
109
+ }
110
+
111
+ interface SurfaceInput {
112
+ url?: string;
113
+ mode?: CaptureMode;
114
+ selector?: string;
115
+ selector_index?: number;
116
+ /** {width,height} OR a device preset (`device`). A string is tolerated (some
117
+ * MCP harnesses stringify object params). See coerceViewport. */
118
+ viewport?: { width: number; height: number } | string;
119
+ device?: string;
120
+ /** F252.1 — deviceScaleFactor for retina / 2×–3× captures. Default 1. */
121
+ scale?: number;
122
+ /** F098.9 — engine to drive (chromium default; webkit/firefox for cross-engine bugs). */
123
+ engine?: LensEngine;
124
+ /** F074.57 — BCP-47 locale + IANA timezone. Wins over the project's
125
+ * lens.manifest.json; unset falls back to the manifest, then to nothing. */
126
+ locale?: string;
127
+ timezone?: string;
128
+ /** F074.59 — what this run reports for `prefers-reduced-motion`. Default
129
+ * 'reduce' (unchanged from every run before the option existed); pass
130
+ * 'no-preference' to let the run SEE motion. Never inherited from the
131
+ * manifest: whether a run may animate is a property of the QUESTION being
132
+ * asked, not of the project, and inheriting it would silently move baselines
133
+ * for every capture in a repo that set it once. */
134
+ reduced_motion?: 'reduce' | 'no-preference';
135
+ /** F098.6 — navigation settle strategy (default 'domcontentloaded'). */
136
+ waitUntil?: 'load' | 'domcontentloaded' | 'commit';
137
+ waitFor?: string | number;
138
+ actions?: CaptureAction[];
139
+ auth?: AuthConfig;
140
+ /** F074.97 — {local, session} written BEFORE the first navigation, scoped to
141
+ * this url's origin. Lets a saved-client-state bug be PHOTOGRAPHED instead of
142
+ * only unit-fixtured. Omitted registers no init script, so no baseline moves. */
143
+ storage?: unknown;
144
+ surface_key?: string | null;
145
+ timeout_ms?: number;
146
+ /** F126.4 — composition critic: 'off' (default) | 'dom' | 'vision' | 'both'. */
147
+ critic?: CriticMode;
148
+ /** F126.4 — vision model for the critic's vision pass ('haiku' | 'sonnet'). */
149
+ critic_vision_model?: VisionModelKey;
150
+ /** F074.69 — load an UNPACKED extension build directory and capture its own
151
+ * pages. Write the url as `extension://popup.html`; Lens substitutes the id
152
+ * Chromium generates, because the caller cannot know it in advance. */
153
+ extension?: { path: string };
154
+ }
155
+
156
+ export interface CaptureInput extends SurfaceInput {
157
+ project: string;
158
+ url: string;
159
+ }
160
+
161
+ export interface VerifyInput extends CaptureInput {
162
+ assert?: string;
163
+ baseline_key?: string;
164
+ diff_threshold?: number;
165
+ /** F089.2 — smoke-only: skip the baseline diff entirely (render + assert). */
166
+ no_diff?: boolean;
167
+ /** Video-liveness: drive a <video> + assert it plays. Not-playing → fail. */
168
+ video?: VideoCheckOptions;
169
+ }
170
+
171
+ function requireProject(project: unknown): string {
172
+ if (typeof project !== 'string' || !project.trim()) {
173
+ throw new Error('project (slug) is required and scopes all runs/baselines');
174
+ }
175
+ return project.trim();
176
+ }
177
+
178
+ function runView(run: LensRunRow, port: number) {
179
+ return {
180
+ run_id: run.id,
181
+ project: run.project_slug,
182
+ surface_key: run.surface_key,
183
+ url: run.url,
184
+ mode: run.mode,
185
+ selector: run.selector,
186
+ status: run.status,
187
+ reason: run.reason,
188
+ diff_ratio: run.diff_ratio,
189
+ dom_hash: run.dom_hash,
190
+ assert_pass: run.assert_pass === null ? null : run.assert_pass === 1,
191
+ width: run.width,
192
+ height: run.height,
193
+ duration_ms: run.duration_ms,
194
+ created_at: run.created_at,
195
+ // F074.30 — which build this capture measured; null = not measured.
196
+ build: run.build ?? null,
197
+ screenshot_url: artifactUrl(port, run.artifact_key),
198
+ diff_url: artifactUrl(port, run.diff_artifact_key),
199
+ // F126.4 — composition-critic findings persisted with the run (history).
200
+ critic: run.critic_json ? safeParse(run.critic_json) : null,
201
+ };
202
+ }
203
+
204
+ function safeParse(json: string): unknown {
205
+ try {
206
+ return JSON.parse(json);
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ // Lens-mobile — resolve a viewport from a {width,height} object, a device
213
+ // preset, or a STRINGIFIED object. The MCP harness stringifies nested object
214
+ // params that aren't declared in the tool schema → the daemon used to forward
215
+ // that string to Playwright newContext ("expected object, got string" → hang).
216
+ // Declaring `viewport` in the schema (mcp.ts) fixes the root; this coerce is the
217
+ // belt-and-suspenders + the `device` preset convenience.
218
+ const DEVICE_PRESETS: Record<string, { width: number; height: number }> = {
219
+ 'iphone-15': { width: 393, height: 852 },
220
+ 'iphone-13': { width: 390, height: 664 }, // F098.9 — sa's F024 webkit repro device
221
+ 'iphone-15-pro-max': { width: 430, height: 932 },
222
+ 'iphone-se': { width: 375, height: 667 },
223
+ 'pixel-8': { width: 412, height: 915 },
224
+ ipad: { width: 820, height: 1180 },
225
+ mobile: { width: 390, height: 844 },
226
+ tablet: { width: 820, height: 1180 },
227
+ desktop: { width: 1280, height: 800 },
228
+ };
229
+ export function coerceViewport(
230
+ viewport?: { width: number; height: number } | string,
231
+ device?: string,
232
+ ): { width: number; height: number } | undefined {
233
+ // F098.4 — AN UNRESOLVABLE VIEWPORT IS AN ERROR, NOT A DESKTOP CAPTURE.
234
+ //
235
+ // Every branch below used to `return undefined` on bad input, and undefined
236
+ // means "use Playwright's default" — 1280×800. So asking for mobile with a
237
+ // typo'd device name or a viewport string that isn't JSON produced a DESKTOP
238
+ // screenshot with status "pass". Measured 20 Sep 2026: viewport:"ikke-json"
239
+ // → width 1280, height 800, pass. You get an answer about the wrong screen
240
+ // and nothing in it says so — the exact shape this card's original bug had,
241
+ // one branch further along.
242
+ //
243
+ // Asking for nothing is still fine: no viewport and no device → undefined →
244
+ // the default, deliberately. Only a viewport that was REQUESTED and could not
245
+ // be honoured throws.
246
+ if (device) {
247
+ const preset = DEVICE_PRESETS[device.toLowerCase()];
248
+ if (preset) return preset;
249
+ throw new Error(
250
+ `unknown device preset "${device}" — known: ${Object.keys(DEVICE_PRESETS).join(', ')}`,
251
+ );
252
+ }
253
+ if (viewport === undefined || viewport === null) return undefined;
254
+ let v: unknown = viewport;
255
+ if (typeof v === 'string') {
256
+ try {
257
+ v = JSON.parse(v);
258
+ } catch {
259
+ throw new Error(`viewport is not valid JSON: ${JSON.stringify(viewport)}`);
260
+ }
261
+ }
262
+ if (v && typeof v === 'object') {
263
+ const w = (v as { width?: unknown }).width;
264
+ const h = (v as { height?: unknown }).height;
265
+ if (typeof w === 'number' && typeof h === 'number') return { width: w, height: h };
266
+ }
267
+ throw new Error(`viewport must be {width:number,height:number} — got ${JSON.stringify(viewport)}`);
268
+ }
269
+
270
+ /** F074.22 — resolve a bare capture/verify's relative auth secretPath/statePath
271
+ * against the CALLING PROJECT's registered local_path (the flow/manuscript path
272
+ * already does this via effectiveFlowAuth). Without it a relative path is read
273
+ * against the daemon's cwd — i.e. cardmem's own repo — so a consumer's relative
274
+ * `.lens-mint-secret` silently reads cardmem's credential and 401s. Unregistered
275
+ * project or an unresolvable path → left as-is (an absolute path still works).
276
+ *
277
+ * IT DOES NOT INHERIT. `if (!auth) return auth` is deliberate: a capture with no
278
+ * auth stays unauthenticated. Reported by xrt81 (2026-08-12) after a capture
279
+ * passed only local_path, landed on the sign-in wall and timed out after 30s
280
+ * while the same call as a manuscript was green — the tool description implied
281
+ * inheritance applied to both. The description now says which is which.
282
+ *
283
+ * Whether capture SHOULD inherit is a real question and deliberately not
284
+ * answered here: auto-inheriting would change what every existing capture
285
+ * photographs, and a manifest can carry `auth: null` per surface precisely
286
+ * because an authenticated visitor gets redirected away from a public landing
287
+ * page. Opt-in, if ever. */
288
+ /** F074.57 — the project's registered repo root, so a capture inherits its
289
+ * project's locale WITHOUT the caller passing local_path. Deliberately unlike
290
+ * auth, and the asymmetry is the point: inheriting a credential changes WHO the
291
+ * browser is and can silently photograph a login wall, so it stayed opt-in.
292
+ * Inheriting a locale changes only how the SAME page is formatted, which is
293
+ * exactly what "per project" has to mean for Christian's case — otherwise every
294
+ * call site has to remember, and a rule you must remember is not a rule. */
295
+ function projectLocale(deps: LensDeps, project: string): { locale?: string; timezone?: string } | null {
296
+ const row = deps.db
297
+ .query('SELECT local_path FROM registered_projects WHERE slug = ?')
298
+ .get(project) as { local_path: string } | undefined;
299
+ return row?.local_path ? manifestLocale(row.local_path, deps.sandboxRoots) : null;
300
+ }
301
+
302
+ export function resolveVerifyAuth(
303
+ deps: LensDeps,
304
+ project: string,
305
+ auth: AuthConfig | undefined,
306
+ ): AuthConfig | undefined {
307
+ if (!auth) return auth;
308
+ const row = deps.db
309
+ .query('SELECT local_path FROM registered_projects WHERE slug = ?')
310
+ .get(project) as { local_path: string } | undefined;
311
+ if (!row?.local_path) return auth;
312
+ return (resolveAuthPaths(auth, row.local_path) as AuthConfig | undefined) ?? auth;
313
+ }
314
+
315
+ /** F074.39 — lens_capture takes no `assert` and never ran one, but accepted the
316
+ * parameter and answered `assert_pass: null` on every call, including a body
317
+ * that could not possibly pass (beacon, idea `019fdcae`). A caller reading
318
+ * "status: pass" had no way to know their assertion had simply been dropped.
319
+ *
320
+ * So it REFUSES rather than ignores, and the message names the tool that does
321
+ * run asserts. Same family as the `project_id` find of 2026-08-07: a parameter
322
+ * the tool does not implement is silently accepted and the call succeeds. */
323
+ export function rejectUnsupportedAssert(input: unknown, tool: string): void {
324
+ if (input && typeof input === 'object' && (input as { assert?: unknown }).assert !== undefined) {
325
+ throw new Error(
326
+ `${tool} does not run asserts — it only screenshots, and silently ignoring one would report a pass for an assertion that never ran. Use lens_verify({ assert }) instead.`,
327
+ );
328
+ }
329
+ }
330
+
331
+ /** lens_capture — screenshot a surface, record the run, return the shot URL. */
332
+ export async function lensCapture(deps: LensDeps, input: CaptureInput) {
333
+ const project = requireProject(input.project);
334
+ rejectUnsupportedAssert(input, 'lens_capture');
335
+ const result = await runVerify({
336
+ url: input.url,
337
+ mode: input.mode,
338
+ selector: input.selector,
339
+ engine: input.engine,
340
+ ...resolveLocale(input, projectLocale(deps, project)),
341
+ reducedMotion: input.reduced_motion,
342
+ viewport: coerceViewport(input.viewport, input.device),
343
+ // F252.1 — some MCP harnesses stringify number params ("2"), and Playwright's
344
+ // newContext rejects a string deviceScaleFactor. Coerce; garbage → default 1.
345
+ deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
346
+ waitUntil: input.waitUntil,
347
+ waitFor: input.waitFor,
348
+ actions: input.actions,
349
+ auth: resolveVerifyAuth(deps, project, input.auth),
350
+ timeoutMs: input.timeout_ms,
351
+ critic: input.critic,
352
+ criticVisionModel: input.critic_vision_model,
353
+ storage: coerceStorageSeed(input.storage),
354
+ extension: input.extension,
355
+ // capture-only: no baseline, no assert → status is page-loaded-ok
356
+ });
357
+ const run = await recordRun(deps.db, deps.storage, result, {
358
+ project_slug: project,
359
+ surface_key: input.surface_key ?? null,
360
+ });
361
+ // F074.69 — the extension id is returned so the caller can cite WHICH surface
362
+ // was photographed. It is generated at load time, so without this the run says
363
+ // only "an extension" and a reader cannot tell one build from another.
364
+ return { ...runView(run, deps.port), critic: result.critic, extension: result.extension ?? null };
365
+ }
366
+
367
+ /** lens_verify — capture + (optional) baseline diff + (optional) assert, with
368
+ * the prioritised status logic. Baseline is auto-loaded from the store by
369
+ * baseline_key when present. */
370
+ export async function lensVerify(deps: LensDeps, input: VerifyInput) {
371
+ const project = requireProject(input.project);
372
+ // upmetrics #3 — auto-diff when a baseline EXISTS for the surface, even if the
373
+ // manifest surface didn't repeat baseline_key. lens_approve_baseline stores
374
+ // under the run's surface_key, so fall back to surface_key: approving a shot
375
+ // now activates the diff on the next gate with no manifest edit.
376
+ // F089.2 — a smoke surface NEVER diffs, even if a baseline exists (live-data
377
+ // dashboards drift to a false red otherwise). Render + anchor + assert only.
378
+ const effectiveBaselineKey = input.no_diff ? null : (input.baseline_key ?? input.surface_key ?? null);
379
+ let baseline: Buffer | null = null;
380
+ if (effectiveBaselineKey) {
381
+ baseline = await getBaselineBytes(deps.db, deps.storage, project, effectiveBaselineKey);
382
+ }
383
+ const result = await runVerify({
384
+ url: input.url,
385
+ mode: input.mode,
386
+ selector: input.selector,
387
+ engine: input.engine,
388
+ ...resolveLocale(input, projectLocale(deps, project)),
389
+ reducedMotion: input.reduced_motion,
390
+ viewport: coerceViewport(input.viewport, input.device),
391
+ // F255.10 — verify inherits `scale` from the capture schema; it was accepted and
392
+ // dropped here, so a scale:4 verify rendered 1×. Same coercion as lensCapture.
393
+ deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
394
+ waitUntil: input.waitUntil,
395
+ waitFor: input.waitFor,
396
+ actions: input.actions,
397
+ auth: resolveVerifyAuth(deps, project, input.auth),
398
+ timeoutMs: input.timeout_ms,
399
+ assert: input.assert,
400
+ // Only declare a baselineKey to the status logic when a baseline actually
401
+ // exists — else a surface with a key but no approved shot would read as a
402
+ // hard "no-baseline" fail instead of a smoke pass.
403
+ // no_diff → no baselineKey at all (pure smoke). Else only declare one when a
404
+ // baseline actually exists, so a key with no approved shot reads as a smoke
405
+ // pass, not a hard "no-baseline" fail.
406
+ baselineKey: input.no_diff ? undefined : baseline ? effectiveBaselineKey ?? undefined : input.baseline_key,
407
+ baseline,
408
+ diffThreshold: input.diff_threshold,
409
+ critic: input.critic,
410
+ criticVisionModel: input.critic_vision_model,
411
+ storage: coerceStorageSeed(input.storage),
412
+ video: input.video,
413
+ });
414
+ const run = await recordRun(deps.db, deps.storage, result, {
415
+ project_slug: project,
416
+ surface_key: input.surface_key ?? input.baseline_key ?? null,
417
+ });
418
+ return {
419
+ ...runView(run, deps.port),
420
+ assert_detail: result.assertResult?.detail ?? null,
421
+ console: result.console,
422
+ network: result.network,
423
+ page_errors: result.pageErrors,
424
+ preflight_ok: result.preflight?.ok ?? true,
425
+ critic: result.critic,
426
+ video: result.video,
427
+ };
428
+ }
429
+
430
+ export interface CatalogueSurface {
431
+ name: string;
432
+ path: string;
433
+ mode?: CaptureMode;
434
+ selector?: string;
435
+ /** F098.6 — navigation settle strategy (default 'domcontentloaded'). */
436
+ waitUntil?: 'load' | 'domcontentloaded' | 'commit';
437
+ waitFor?: string | number;
438
+ assert?: string;
439
+ baseline_key?: string;
440
+ /** F089.2 — smoke-only: never pixel-diff this surface (live-data dashboard). */
441
+ no_diff?: boolean;
442
+ /** F092.9 — per-surface viewport (e.g. 390x844 for a mobile baseline). Object,
443
+ * device preset, or stringified object (coerceViewport). */
444
+ viewport?: { width: number; height: number } | string;
445
+ device?: string;
446
+ /** F098.9 — per-surface engine override (chromium default; webkit/firefox). */
447
+ engine?: LensEngine;
448
+ /** Per-surface auth override. `undefined` = inherit the catalogue-level auth;
449
+ * `null` = explicitly NO auth (e.g. a public landing page that would redirect
450
+ * an authenticated visitor away). Lets one manifest mix public + authed. */
451
+ auth?: AuthConfig | null;
452
+ }
453
+
454
+ export interface CatalogueInput {
455
+ project: string;
456
+ base_url: string;
457
+ surfaces: CatalogueSurface[];
458
+ auth?: AuthConfig;
459
+ diff_threshold?: number;
460
+ }
461
+
462
+ /** lens_capture_catalogue — verify a batch of surfaces against one base URL.
463
+ * F074.6 adds a lens.manifest.json loader that feeds this; here surfaces are
464
+ * passed inline. Returns a per-surface verdict + an aggregate pass/fail. */
465
+ export async function lensCaptureCatalogue(deps: LensDeps, input: CatalogueInput) {
466
+ const project = requireProject(input.project);
467
+ if (!Array.isArray(input.surfaces) || input.surfaces.length === 0) {
468
+ throw new Error('surfaces[] is required');
469
+ }
470
+ const base = input.base_url.replace(/\/$/, '');
471
+ const results = [];
472
+ for (const s of input.surfaces) {
473
+ const url = `${base}${s.path.startsWith('/') ? s.path : `/${s.path}`}`;
474
+ const view = await lensVerify(deps, {
475
+ project,
476
+ url,
477
+ mode: s.mode,
478
+ selector: s.selector,
479
+ waitUntil: s.waitUntil,
480
+ waitFor: s.waitFor,
481
+ assert: s.assert,
482
+ baseline_key: s.baseline_key,
483
+ no_diff: s.no_diff,
484
+ engine: s.engine,
485
+ viewport: coerceViewport(s.viewport, s.device),
486
+ surface_key: s.baseline_key ?? s.name,
487
+ // Per-surface auth wins; `null` = explicit no-auth; undefined inherits.
488
+ auth: (s.auth === null ? undefined : s.auth ?? input.auth),
489
+ diff_threshold: input.diff_threshold,
490
+ });
491
+ results.push({ name: s.name, ...view });
492
+ }
493
+ const failed = results.filter((r) => r.status === 'fail' || r.status === 'error');
494
+ // upmetrics #5 — a "pass" is ambiguous: a surface diffed against a baseline
495
+ // (diff_ratio !== null) is genuinely VISUAL-verified; a surface with no
496
+ // baseline that merely loaded without errors is a SMOKE pass. Split them so a
497
+ // green "7/7" can't masquerade as visual coverage it doesn't have.
498
+ const passes = results.filter((r) => r.status === 'pass');
499
+ const diffVerified = passes.filter((r) => r.diff_ratio !== null).length;
500
+ const smoke = passes.length - diffVerified;
501
+ return {
502
+ project,
503
+ total: results.length,
504
+ passed: passes.length,
505
+ diff_verified: diffVerified,
506
+ smoke,
507
+ no_baseline: results.filter((r) => r.status === 'no-baseline').length,
508
+ failed: failed.length,
509
+ aggregate: (failed.length === 0 ? 'pass' : 'fail') as LensStatus | 'pass' | 'fail',
510
+ surfaces: results,
511
+ };
512
+ }
513
+
514
+ export interface ApproveInput {
515
+ project: string;
516
+ run_id: string;
517
+ baseline_key?: string;
518
+ }
519
+
520
+ export async function lensApproveBaseline(deps: LensDeps, input: ApproveInput): Promise<LensBaselineRow & { artifact_url: string | null }> {
521
+ const project = requireProject(input.project);
522
+ const run = getRun(deps.db, input.run_id);
523
+ if (!run) throw new Error(`run not found: ${input.run_id}`);
524
+ if (run.project_slug !== project) {
525
+ throw new Error(`run ${input.run_id} belongs to "${run.project_slug}", not "${project}"`);
526
+ }
527
+ const baseline = await promoteBaseline(deps.db, deps.storage, input.run_id, input.baseline_key);
528
+ return { ...baseline, artifact_url: artifactUrl(deps.port, baseline.artifact_key) };
529
+ }
530
+
531
+ // F089 — clear (unapprove) a baseline so its surface returns to no-baseline
532
+ // (smoke). A live-data surface wrongly approved diffs forever otherwise; approve
533
+ // was one-way. upmetrics 2026-06-02.
534
+ export async function lensClearBaseline(deps: LensDeps, input: { project: string; baseline_key: string }) {
535
+ const project = requireProject(input.project);
536
+ if (!input.baseline_key) throw new Error('baseline_key is required');
537
+ const cleared = await deleteBaseline(deps.db, deps.storage, project, input.baseline_key);
538
+ return { project, baseline_key: input.baseline_key, cleared };
539
+ }
540
+
541
+ export interface ListRunsInput {
542
+ project: string;
543
+ status?: string;
544
+ surface_key?: string;
545
+ limit?: number;
546
+ }
547
+
548
+ export function lensListRuns(deps: LensDeps, input: ListRunsInput) {
549
+ const project = requireProject(input.project);
550
+ const runs = listRuns(deps.db, project, {
551
+ status: input.status,
552
+ surface_key: input.surface_key,
553
+ limit: input.limit,
554
+ });
555
+ return { project, count: runs.length, runs: runs.map((r) => runView(r, deps.port)) };
556
+ }
557
+
558
+ /** lens_billboard — a Sharp contact-sheet grid of recent run screenshots.
559
+ * Returns PNG bytes (a glanceable "wall" of the project's surfaces). */
560
+ export async function lensBillboard(deps: LensDeps, project: string, limit = 24): Promise<Buffer> {
561
+ const p = requireProject(project);
562
+ const runs = listRuns(deps.db, p, { limit });
563
+ const cw = 220;
564
+ const ch = 140;
565
+ const gap = 8;
566
+ const cols = 4;
567
+ const bg = { r: 244, g: 241, b: 234 }; // --color-ivory-ish
568
+
569
+ const thumbs: Buffer[] = [];
570
+ for (const r of runs) {
571
+ if (!r.artifact_key) continue;
572
+ const bytes = await deps.storage.get(r.artifact_key);
573
+ if (!bytes) continue;
574
+ thumbs.push(await sharp(bytes).resize(cw, ch, { fit: 'cover', position: 'top' }).png().toBuffer());
575
+ }
576
+ if (thumbs.length === 0) {
577
+ return sharp({ create: { width: cw, height: ch, channels: 3, background: bg } }).png().toBuffer();
578
+ }
579
+ const rows = Math.ceil(thumbs.length / cols);
580
+ const W = cols * cw + (cols + 1) * gap;
581
+ const H = rows * ch + (rows + 1) * gap;
582
+ const composite = thumbs.map((input, i) => ({
583
+ input,
584
+ left: gap + (i % cols) * (cw + gap),
585
+ top: gap + Math.floor(i / cols) * (ch + gap),
586
+ }));
587
+ return sharp({ create: { width: W, height: H, channels: 3, background: bg } })
588
+ .composite(composite)
589
+ .png()
590
+ .toBuffer();
591
+ }
592
+
593
+ // ── F082 — E2E flow runs ────────────────────────────────────────────────────
594
+
595
+ /**
596
+ * F074.36 — accept an auth block that arrived as a JSON STRING.
597
+ *
598
+ * `parseFlow` already tolerates this for `flow`, because MCP clients routinely
599
+ * serialize a nested object argument. `auth` did not, and the result was a
600
+ * truthy non-object that reached the adapter switch as `unknown auth adapter:
601
+ * undefined` — so the manuscript capability was broken for exactly the callers
602
+ * it was built for (MCP), while working over HTTP. Verified both ways before
603
+ * adding this: the HTTP call with a real object passed, the MCP call with the
604
+ * same content did not.
605
+ *
606
+ * Deliberately NOT silent: unparseable input throws rather than degrading to "no
607
+ * auth", which would drop the run back into the exact silence this card removes.
608
+ */
609
+ export function coerceAuth(raw: unknown): AuthConfig | undefined {
610
+ if (raw == null) return undefined;
611
+ if (typeof raw === 'string') {
612
+ const s = raw.trim();
613
+ if (!s) return undefined;
614
+ let parsed: unknown;
615
+ try {
616
+ parsed = JSON.parse(s);
617
+ } catch (err) {
618
+ throw new Error(`auth was a string but not valid JSON: ${err instanceof Error ? err.message : err}`);
619
+ }
620
+ return coerceAuth(parsed);
621
+ }
622
+ if (typeof raw !== 'object') throw new Error(`auth must be an object, got ${typeof raw}`);
623
+ const a = raw as { adapter?: unknown };
624
+ if (typeof a.adapter !== 'string' || !a.adapter) {
625
+ throw new Error('auth.adapter is required (storageState | cookieJwt | mintEndpoint)');
626
+ }
627
+ return raw as AuthConfig;
628
+ }
629
+
630
+ /** F074.36 — the run's auth provenance for the report. A run recorded before
631
+ * this shipped has no column value; it says so rather than implying it ran
632
+ * authenticated. */
633
+ function parseAuthJson(raw: string | null | undefined): { source: string; adapter: string | null; note?: string } {
634
+ if (!raw) return { source: 'unknown', adapter: null, note: 'this run predates auth reporting' };
635
+ try {
636
+ return JSON.parse(raw) as { source: string; adapter: string | null; note?: string };
637
+ } catch {
638
+ return { source: 'unknown', adapter: null, note: 'auth provenance could not be read back' };
639
+ }
640
+ }
641
+
642
+ /** NULL = the row predates the column, so we have no answer; `{}` = the run
643
+ * looked and found nothing. Malformed JSON is reported as unknown rather than
644
+ * as "clean": a parse failure must never read as an all-clear. */
645
+ function parseFindingsJson(raw: string | null | undefined): Record<string, unknown> | null {
646
+ if (raw == null) return null;
647
+ try {
648
+ const v = JSON.parse(raw);
649
+ return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
650
+ } catch {
651
+ return null;
652
+ }
653
+ }
654
+
655
+ export function flowRunView(row: LensFlowRunRow, port: number) {
656
+ const steps = (JSON.parse(row.steps_json) as StoredFlowStep[]).map((s) => ({
657
+ index: s.index,
658
+ type: s.type,
659
+ label: s.label,
660
+ status: s.status,
661
+ testid: s.testid,
662
+ detail: s.detail,
663
+ ...(s.fault ? { fault: s.fault } : {}),
664
+ // F074.49 — the app's own failed responses. Present only on steps that have
665
+ // them, so a clean run reads exactly as before.
666
+ ...(s.network?.length ? { network: s.network } : {}),
667
+ // F126.7 — the composition findings, all of them. Named here as well as in
668
+ // flow.ts and flow-store.ts: the three-place rule F074.49 exists to enforce.
669
+ ...(s.critic ? { critic: s.critic } : {}),
670
+ // F213.12 — the third layer of the three-place rule, for F213.11's timing.
671
+ // Absent means the step never ran; 0 means it ran immediately, so the test
672
+ // is on the TYPE. A skipped step must stay silent rather than claim 0.
673
+ ...(typeof s.ms_after_navigation === 'number' ? { ms_after_navigation: s.ms_after_navigation } : {}),
674
+ // F213.13 — the step's own duration. start + duration is what makes a step
675
+ // with internal waiting (a click retrying actionability) answerable at all.
676
+ ...(typeof s.ms_step_duration === 'number' ? { ms_step_duration: s.ms_step_duration } : {}),
677
+ screenshot_url: artifactUrl(port, s.artifact_key),
678
+ }));
679
+ return {
680
+ flow_run_id: row.id,
681
+ project: row.project_slug,
682
+ flow_name: row.flow_name,
683
+ status: row.status,
684
+ failed_at: row.failed_at,
685
+ reason: row.reason,
686
+ step_count: row.step_count,
687
+ duration_ms: row.duration_ms,
688
+ created_at: row.created_at,
689
+ // F074.30 — WHICH build this run measured. null = not measured; a caller
690
+ // comparing two runs must refuse on a null rather than assume "same build".
691
+ build: row.build ?? null,
692
+ // F074.36 — the two facts that decide whether a verdict is worth anything:
693
+ // which page it judged, and whether it was logged in. `auth` is never
694
+ // omitted — a run with no credential says so, with the reason.
695
+ final_url: row.final_url ?? null,
696
+ auth: parseAuthJson(row.auth_json),
697
+ // F074.59 — which browser this verdict came from. 'reduce' | 'no-preference' |
698
+ // null = NOT RECORDED (a run predating the field). Always present, never
699
+ // defaulted: a green run that cannot say whether motion was on is
700
+ // uninterpretable, and guessing on its behalf is how that ambiguity survives.
701
+ reduced_motion: (row.reduced_motion as 'reduce' | 'no-preference' | null) ?? null,
702
+ // F272.2 — WHERE this run's screenshots live, so a session citing the run as
703
+ // evidence can see the answer AT CITATION TIME instead of discovering it after
704
+ // the machine is gone. 'local' = one Mac's disk behind 127.0.0.1, reachable by
705
+ // nobody else and by no one once the daemon stops. 'tigris' = durable. null =
706
+ // NOT RECORDED (a run predating the column) — a third fact, and `unknown` is
707
+ // never rounded up to durable. Always present, never defaulted, for the same
708
+ // reason reduced_motion above is.
709
+ artifact_backend: (row.artifact_backend as 'local' | 'tigris' | null) ?? null,
710
+ // F272.2 — the derived one-word answer to the only question a citation asks:
711
+ // can the owner still open this after this machine is gone?
712
+ evidence_durability: durabilityOf(row.artifact_backend),
713
+ // F074.99 — the run-level findings a reader can ACT on: a password that came
714
+ // back in the URL, a page that was never listening while we wrote to it.
715
+ // `{}` means the run looked and found nothing; NULL means the row predates
716
+ // the field and carries no answer either way — the same three-value posture
717
+ // as reduced_motion and artifact_backend above, and for the same reason.
718
+ //
719
+ // WHY THIS LINE EXISTS: both findings were computed correctly in flow.ts and
720
+ // surfaced NOWHERE. fd-sundhed searched the whole body of lens_run_manuscript
721
+ // and lens_list_flow_runs for the string and found neither — "the instrument
722
+ // measures right and nobody is told", which is the same shape as the probe
723
+ // that sat unwired to `fill` for a month.
724
+ findings: parseFindingsJson(row.findings_json),
725
+ steps,
726
+ // F255.5 — present only when the run was recorded. Same artifact mechanism as
727
+ // the step screenshots, so a consumer fetches it exactly the same way.
728
+ ...(row.video_key ? { video_url: artifactUrl(port, row.video_key) } : {}),
729
+ };
730
+ }
731
+
732
+ export interface RunFlowInput {
733
+ project: string;
734
+ /** Untrusted flow object — validated by parseFlow at the boundary. */
735
+ flow: unknown;
736
+ /** F082.4 — resume: skip flattened steps before this index. */
737
+ from?: number;
738
+ /** F082.6 — repo root. Resolves a flow's relative auth statePath AND lets the
739
+ * flow inherit the project's lens.manifest.json auth when it has none. */
740
+ local_path?: string;
741
+ /** F092.9 — run the flow at a non-default viewport (e.g. 390x844 for mobile
742
+ * E2E + baselines). Defaults to the 1280x800 desktop context. Object, device
743
+ * preset, or stringified object (coerceViewport). */
744
+ viewport?: { width: number; height: number } | string;
745
+ device?: string;
746
+ /** F098.9 — engine to drive the flow (chromium default; webkit/firefox). */
747
+ engine?: LensEngine;
748
+ /** F074.57 — BCP-47 locale + IANA timezone. Wins over lens.manifest.json. A
749
+ * bilingual site is captured once per language via this override. */
750
+ locale?: string;
751
+ timezone?: string;
752
+ /** F074.59 — what this run reports for `prefers-reduced-motion`. Default
753
+ * 'reduce' (unchanged from every run before the option existed); pass
754
+ * 'no-preference' to let the run SEE motion. Never inherited from the
755
+ * manifest: whether a run may animate is a property of the QUESTION being
756
+ * asked, not of the project, and inheriting it would silently move baselines
757
+ * for every capture in a repo that set it once. */
758
+ reduced_motion?: 'reduce' | 'no-preference';
759
+ /** F126.4 — composition critic on each screenshot step ('off'|'dom'|'vision'|'both'). */
760
+ critic?: CriticMode;
761
+ /** F255.5 — record the whole run to a .webm and return it as video_url. */
762
+ record?: boolean;
763
+ /** F074.97 — {local, session} seeded before the flow's first navigation,
764
+ * scoped to the flow's own base_url origin. */
765
+ storage?: unknown;
766
+ }
767
+
768
+ // F074.24 — a flat cloud FlowRequest ({name, base_url, steps:[{action,...}]}, no
769
+ // {project,flow} wrapper) is recognised by a top-level `steps`/`base_url` and the
770
+ // absence of `flow`. It is handed to the engine's runFlow; the native
771
+ // {project, flow} body keeps the daemon's own manuscript executor.
772
+ export interface FlatFlowInput {
773
+ /** Optional project scope (recommendation A: optional daemon-side). Runs land
774
+ * under FLAT_FLOW_DEFAULT_PROJECT when absent. */
775
+ project?: string;
776
+ base_url: string;
777
+ steps: unknown[];
778
+ [k: string]: unknown;
779
+ }
780
+
781
+ export function isFlatFlowBody(input: unknown): input is FlatFlowInput {
782
+ if (!input || typeof input !== 'object') return false;
783
+ const o = input as Record<string, unknown>;
784
+ return o.flow === undefined && (Array.isArray(o.steps) || typeof o.base_url === 'string');
785
+ }
786
+
787
+ /** Runs from a flat cloud FlowRequest with no `project` land under this bucket. */
788
+ export const FLAT_FLOW_DEFAULT_PROJECT = '_external';
789
+
790
+ /** Map the engine's FlowResult (cloud shape) onto the daemon's FlowResult so it
791
+ * persists as an ordinary daemon flow-run (recordFlowRun). resolved_via becomes
792
+ * the step detail — the self-heal audit trail for a testid-less field. */
793
+ export function mapEngineFlowResult(r: EngineFlowResult): FlowResult {
794
+ let failed_at: number | null = null;
795
+ let reason: string | null = null;
796
+ let duration_ms = 0;
797
+ const steps: FlowStepResult[] = r.steps.map((s) => {
798
+ duration_ms += s.ms ?? 0;
799
+ const status: FlowStepResult['status'] = s.status === 'ok' ? 'pass' : 'fail';
800
+ if (status === 'fail' && failed_at === null) {
801
+ failed_at = s.index;
802
+ reason = s.error ?? 'step failed';
803
+ }
804
+ return {
805
+ index: s.index,
806
+ type: s.action,
807
+ label: s.detail ?? s.action,
808
+ status,
809
+ testid: null,
810
+ detail: s.error ?? (s.resolved_via ? `resolved via ${s.resolved_via}` : (s.detail ?? null)),
811
+ screenshot: s.png ?? null,
812
+ };
813
+ });
814
+ return {
815
+ name: r.name ?? 'flow',
816
+ status: r.status === 'passed' ? 'pass' : 'fail',
817
+ steps,
818
+ failed_at,
819
+ reason,
820
+ duration_ms,
821
+ };
822
+ }
823
+
824
+ /** Build the engine's NATIVE cloud FlowResult for a flat-body response (F074.24
825
+ * response convergence — storeform #16012). A flat (cloud-shape) caller must get
826
+ * the same shape BOTH Lens surfaces share: `action` verbs, status `passed`/`ok`,
827
+ * and a STRUCTURED `resolved_via` (not free-text detail) — so a consumer's
828
+ * pass/fail check + self-heal drift telemetry read ONE contract. Re-mapping to
829
+ * the daemon's legacy step-shape (as flowRunView does for native callers) is the
830
+ * bug this fixes. png bytes are stripped; screenshot_url reuses the artifacts
831
+ * recordFlowRun already persisted. */
832
+ export function toCloudFlowResult(result: EngineFlowResult, row: LensFlowRunRow, port: number) {
833
+ const keyByIndex = new Map(
834
+ (JSON.parse(row.steps_json) as StoredFlowStep[]).map((s) => [s.index, s.artifact_key] as const),
835
+ );
836
+ return {
837
+ run_id: row.id,
838
+ name: result.name,
839
+ status: result.status,
840
+ final_url: result.final_url,
841
+ // F074.99 — the same findings the native surface carries. A flat/cloud caller
842
+ // (a CI runner with no daemon) is the consumer LEAST able to go and look
843
+ // somewhere else, so leaving them off this shape would be the gap at its
844
+ // worst.
845
+ findings: parseFindingsJson(row.findings_json),
846
+ steps: result.steps.map((s) => ({
847
+ index: s.index,
848
+ action: s.action,
849
+ status: s.status,
850
+ ms: s.ms,
851
+ detail: s.detail ?? null,
852
+ resolved_via: s.resolved_via ?? null,
853
+ error: s.error ?? null,
854
+ screenshot_url: artifactUrl(port, keyByIndex.get(s.index) ?? null),
855
+ })),
856
+ };
857
+ }
858
+
859
+ /** Host the engine's runFlow for a flat cloud FlowRequest (F074.24). Validates
860
+ * with the engine's OWN flowBodySchema (single-source contract), runs it (the
861
+ * engine owns its Chromium + applies any supplied storageState), persists the
862
+ * result as a daemon flow-run, and returns the engine's NATIVE cloud FlowResult
863
+ * so the response shape converges with the request (storeform #16012). Dispatched
864
+ * from the HTTP /lens/flow handler on shape (isFlatFlowBody); the MCP tool +
865
+ * native {project, flow} callers stay on lensRunFlow (unchanged daemon shape). */
866
+ export async function runFlatFlow(deps: LensDeps, input: FlatFlowInput) {
867
+ const project = typeof input.project === 'string' && input.project.trim() ? input.project.trim() : FLAT_FLOW_DEFAULT_PROJECT;
868
+ // `project` is OUR routing field, not part of the engine body — strip it before
869
+ // validating instead of relying on the schema to ignore it.
870
+ //
871
+ // @broberg/lens-engine 0.7.0 made flowBodySchema STRICT: it now rejects any
872
+ // unrecognized key, where 0.6.x silently stripped them. So this line was the
873
+ // difference between "cloud flows run" and "every cloud flow throws
874
+ // Unrecognized key(s) in object: 'project'". Caught by the suite on the bump,
875
+ // which is the whole reason the bump goes through a gate.
876
+ //
877
+ // Strict is the BETTER contract (a typo'd `base_ur1` is now an error instead of
878
+ // a silently ignored key), so we adapt rather than ask for a revert.
879
+ //
880
+ // AND IT WAS DOCUMENTED. My first version of this comment said the change was
881
+ // unflagged. It was not: the 0.7.0 README leads with "Read this before
882
+ // upgrading: a flow that parses today can stop parsing", names `project`
883
+ // explicitly, and gives `.extend()` as the supported path. I concluded
884
+ // "not flagged" from "I did not see a flag", without looking where flags live —
885
+ // the same shape as everything else this week, aimed at a peer this time.
886
+ // Verified against the published tarball after they corrected me (#21371).
887
+ const { project: _routing, ...engineBody } = input;
888
+ const parsed = flowBodySchema.safeParse(engineBody);
889
+ if (!parsed.success) {
890
+ throw new Error(`flat flow: ${parsed.error.issues[0]?.message ?? 'invalid flow body'}`);
891
+ }
892
+ const result = await engineRunFlow(parsed.data);
893
+ // Persist as a daemon flow-run (own /flow-runs listing + artifact storage)…
894
+ const row = await recordFlowRun(deps.db, deps.storage, project, mapEngineFlowResult(result));
895
+ // …but RETURN the engine's native cloud FlowResult (not the daemon step-shape).
896
+ return toCloudFlowResult(result, row, deps.port);
897
+ }
898
+
899
+ /** lens_run_flow — execute a flow end-to-end in one authed page, persist the
900
+ * per-step report + screenshots, return the E2E result. F074.24: a flat cloud
901
+ * FlowRequest is delegated to the engine's runFlow; the native {project, flow}
902
+ * body keeps the daemon's own manuscript executor. */
903
+ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
904
+ const project = requireProject(input.project);
905
+ const flow = parseFlow(input.flow);
906
+ // F082.6 — auth reaches flows: own auth (relative paths resolved) or inherit
907
+ // the manifest's. Fixes the blocker where authed routes redirected to login.
908
+ // F074.36 — and the run now REPORTS which of those two it used, or why neither.
909
+ const provenance = authProvenance(flow.auth, input.local_path, deps.sandboxRoots);
910
+ flow.auth = provenance.auth;
911
+ const result = await runFlow(flow, process.env as AuthEnv, {
912
+ from: input.from,
913
+ engine: input.engine,
914
+ ...resolveLocale(input, manifestLocale(input.local_path, deps.sandboxRoots)),
915
+ reducedMotion: input.reduced_motion,
916
+ viewport: coerceViewport(input.viewport, input.device),
917
+ critic: input.critic,
918
+ localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
919
+ record: input.record === true, // F255.5 — opt-in .webm of the whole run
920
+ storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
921
+ });
922
+ const row = await recordFlowRun(deps.db, deps.storage, project, result, provenance);
923
+ return flowRunView(row, deps.port);
924
+ }
925
+
926
+ export interface RunManuscriptInput {
927
+ project: string;
928
+ /** Markdown manuscript — compiled to a flow then executed. */
929
+ manuscript: string;
930
+ /** F074.36 — an explicit auth block, same shape lens_run_flow takes. The prose
931
+ * manuscript is the surface we tell people to reach for first, and until now
932
+ * it was the only one that could not authenticate — so the easiest tool was
933
+ * the one that silently tested the login screen. Wins over the manifest. */
934
+ auth?: AuthConfig;
935
+ /** F082.6 — repo root for relative auth + manifest-auth inheritance. */
936
+ local_path?: string;
937
+ /** F252.2 — run the manuscript at a non-default viewport/device so mobile-only
938
+ * UI (behind lg:hidden etc.) can be click-driven, not just DOM-asserted. */
939
+ viewport?: { width: number; height: number } | string;
940
+ /** F074.57 — locale/timezone for the manuscript run; wins over the manifest. */
941
+ locale?: string;
942
+ timezone?: string;
943
+ /** F074.59 — what this run reports for `prefers-reduced-motion`. Default
944
+ * 'reduce' (unchanged from every run before the option existed); pass
945
+ * 'no-preference' to let the run SEE motion. Never inherited from the
946
+ * manifest: whether a run may animate is a property of the QUESTION being
947
+ * asked, not of the project, and inheriting it would silently move baselines
948
+ * for every capture in a repo that set it once. */
949
+ reduced_motion?: 'reduce' | 'no-preference';
950
+ device?: string;
951
+ engine?: LensEngine;
952
+ /** F255.5 — record the whole run to a .webm and return it as video_url. */
953
+ record?: boolean;
954
+ /** F074.97 — {local, session} seeded before the manuscript's first navigation.
955
+ * The prose surface is the one we tell people to reach for first, so a
956
+ * capability it lacks is a capability most callers do not have. */
957
+ storage?: unknown;
958
+ }
959
+
960
+ /** lens_run_manuscript — compile a markdown manuscript to a flow and run it E2E.
961
+ * The authoring shortcut: a repo writes prose, not JSON. */
962
+ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInput) {
963
+ const project = requireProject(input.project);
964
+ if (typeof input.manuscript !== 'string' || !input.manuscript.trim()) {
965
+ throw new Error('manuscript (markdown string) is required');
966
+ }
967
+ const flow = compileManuscript(input.manuscript);
968
+ // F082.6 — a manuscript inherits the project's manifest auth (DRY: the
969
+ // credential lives in one place) unless it declared its own.
970
+ // F074.36 — an explicit `auth` argument outranks both, and the provenance is
971
+ // REPORTED: "we ran without a credential" must never be something the caller
972
+ // has to infer from a confusing failure.
973
+ const provenance = authProvenance(coerceAuth(input.auth) ?? flow.auth, input.local_path, deps.sandboxRoots);
974
+ flow.auth = provenance.auth;
975
+ const result = await runFlow(flow, process.env as AuthEnv, {
976
+ engine: input.engine,
977
+ ...resolveLocale(input, manifestLocale(input.local_path, deps.sandboxRoots)),
978
+ reducedMotion: input.reduced_motion,
979
+ viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
980
+ localPath: input.local_path, // F213.6 — uploadFile fixture root
981
+ record: input.record === true, // F255.5 — opt-in .webm of the whole run
982
+ storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
983
+ });
984
+ const row = await recordFlowRun(deps.db, deps.storage, project, result, provenance);
985
+ return flowRunView(row, deps.port);
986
+ }
987
+
988
+ export interface ListFlowRunsInput {
989
+ project: string;
990
+ flow_name?: string;
991
+ status?: string;
992
+ limit?: number;
993
+ }
994
+
995
+ export function lensListFlowRuns(deps: LensDeps, input: ListFlowRunsInput) {
996
+ const project = requireProject(input.project);
997
+ const runs = listFlowRuns(deps.db, project, {
998
+ flow_name: input.flow_name,
999
+ status: input.status,
1000
+ limit: input.limit,
1001
+ });
1002
+ return { project, count: runs.length, flow_runs: runs.map((r) => flowRunView(r, deps.port)) };
1003
+ }
1004
+
1005
+ export function lensListBaselines(deps: LensDeps, project: string) {
1006
+ const p = requireProject(project);
1007
+ const baselines = listBaselines(deps.db, p);
1008
+ return {
1009
+ project: p,
1010
+ count: baselines.length,
1011
+ baselines: baselines.map((b) => ({
1012
+ baseline_key: b.baseline_key,
1013
+ width: b.width,
1014
+ height: b.height,
1015
+ approved_from_run_id: b.approved_from_run_id,
1016
+ updated_at: b.updated_at,
1017
+ current_version_id: b.current_version_id,
1018
+ artifact_url: artifactUrl(deps.port, b.artifact_key),
1019
+ })),
1020
+ };
1021
+ }
1022
+
1023
+ // F094.3 — the approved-version history for one surface (newest first), each with
1024
+ // a thumbnail URL, so the dashboard can render a visual timeline (F094.4).
1025
+ export function lensListBaselineVersions(
1026
+ deps: LensDeps,
1027
+ input: { project: string; baseline_key: string },
1028
+ ) {
1029
+ const p = requireProject(input.project);
1030
+ if (!input.baseline_key) throw new Error('baseline_key is required');
1031
+ const versions = listBaselineVersions(deps.db, p, input.baseline_key);
1032
+ const current = listBaselines(deps.db, p).find((b) => b.baseline_key === input.baseline_key);
1033
+ return {
1034
+ project: p,
1035
+ baseline_key: input.baseline_key,
1036
+ current_version_id: current?.current_version_id ?? null,
1037
+ count: versions.length,
1038
+ versions: versions.map((v) => ({
1039
+ version_no: v.version_no,
1040
+ version_id: v.id,
1041
+ width: v.width,
1042
+ height: v.height,
1043
+ approved_from_run_id: v.approved_from_run_id,
1044
+ commit_sha: v.commit_sha,
1045
+ note: v.note,
1046
+ approved_at: v.approved_at,
1047
+ is_current: v.id === current?.current_version_id,
1048
+ artifact_url: artifactUrl(deps.port, v.artifact_key),
1049
+ })),
1050
+ };
1051
+ }
1052
+
1053
+ // F094.3 — revert a surface's active baseline to a prior version (re-point, no
1054
+ // history loss). The next verify diffs against the reverted image.
1055
+ export function lensRevertBaseline(
1056
+ deps: LensDeps,
1057
+ input: { project: string; baseline_key: string; version_no: number },
1058
+ ): LensBaselineRow & { artifact_url: string | null } {
1059
+ const project = requireProject(input.project);
1060
+ if (!input.baseline_key) throw new Error('baseline_key is required');
1061
+ if (typeof input.version_no !== 'number') throw new Error('version_no (number) is required');
1062
+ const baseline = revertBaseline(deps.db, project, input.baseline_key, input.version_no);
1063
+ return { ...baseline, artifact_url: artifactUrl(deps.port, baseline.artifact_key) };
1064
+ }
1065
+
1066
+
1067
+ // ── F272.2 — promote a flow run's evidence into durable storage ──────────
1068
+ //
1069
+ // THE CLAIM THIS EXISTS TO MAKE TRUE: "the owner can open this with the daemon
1070
+ // off." A 127.0.0.1 artifact URL can never satisfy it, no matter how correct the
1071
+ // bytes are — it is reachable from exactly one machine, by exactly one person,
1072
+ // and only while a process happens to be up.
1073
+ //
1074
+ // The daemon does the copying itself, deliberately. It is the only process that
1075
+ // can read the PNGs, and having it POST them straight to the cloud means the
1076
+ // bytes never pass through an agent's context — which is both the token-frugal
1077
+ // choice and the one that keeps a screenshot of a customer's screen out of a
1078
+ // transcript.
1079
+
1080
+ export interface PromoteEvidenceInput {
1081
+ project: string;
1082
+ /** A flow run (lens_run_flow). Exactly one of flow_run_id / run_id. */
1083
+ flow_run_id?: string;
1084
+ /** F074.68 — a plain CAPTURE run (lens_capture / lens_verify). Until this
1085
+ * existed, the durable-evidence path we RECOMMEND could not be taken for a
1086
+ * screenshot: the warning told people to promote, and promote refused the id
1087
+ * they had. sanne cited six captures as proof for acceptance criteria and none
1088
+ * was promotable, so a correct action still read as `local_only` — which is
1089
+ * how a warning teaches people to ignore it. */
1090
+ run_id?: string;
1091
+ /** Only steps whose screenshot exists are promoted; pass a subset to promote
1092
+ * fewer. Omitted = every step that has one. Flow runs only. */
1093
+ step_indexes?: number[];
1094
+ }
1095
+
1096
+ export interface PromotedStep {
1097
+ index: number;
1098
+ label: string;
1099
+ /** F074.68 — WHAT this artifact is. A capture run can carry two pictures, and
1100
+ * "2 promoted" does not say whether the second is the diff or a second screen.
1101
+ * Named rather than counted, so a caller never has to infer it. */
1102
+ kind: 'step' | 'screenshot' | 'diff';
1103
+ status: string;
1104
+ artifact_id: string;
1105
+ url: string;
1106
+ }
1107
+
1108
+ export interface PromoteEvidenceResult {
1109
+ /** Whichever kind of run was promoted; the other is null. */
1110
+ flow_run_id: string | null;
1111
+ run_id: string | null;
1112
+ project: string;
1113
+ /** What the run's backend was BEFORE this call — the reason it needed promoting. */
1114
+ was: 'local' | 'tigris' | null;
1115
+ promoted: PromotedStep[];
1116
+ /** Steps that had no screenshot at all. Reported, never silently dropped: with
1117
+ * today's 12% capture rate (F272.3) this is the majority, and a caller that is
1118
+ * not told will read "3 promoted" as "the whole run is keepable". */
1119
+ steps_without_screenshot: number;
1120
+ /** The line to paste as AC evidence. Server-verifiable — cardmem_record_review
1121
+ * looks these ids up rather than believing a claim about them. */
1122
+ evidence: string;
1123
+ }
1124
+
1125
+ export async function promoteEvidence(
1126
+ deps: LensDeps,
1127
+ input: PromoteEvidenceInput,
1128
+ ): Promise<PromoteEvidenceResult> {
1129
+ // Exactly one id. Accepting both and silently preferring one is how a caller
1130
+ // ends up promoting a run they did not mean; refusing says which they gave.
1131
+ if (!!input.flow_run_id === !!input.run_id) {
1132
+ throw new Error(
1133
+ 'pass exactly ONE of flow_run_id (a lens_run_flow run) or run_id (a lens_capture / lens_verify run)' +
1134
+ (input.flow_run_id ? ' — both were given' : ' — neither was given'),
1135
+ );
1136
+ }
1137
+
1138
+ const cloud = deps.cloud?.();
1139
+ if (!cloud) {
1140
+ throw new Error(
1141
+ 'not signed in to cardmem — promoting evidence needs the daemon\'s cloud key (run `cardmem login`). Nothing was copied.',
1142
+ );
1143
+ }
1144
+
1145
+ // Resolve the cloud project id LAZILY. The daemon knows slugs; place_artifact
1146
+ // needs the uuid, and guessing one would write into whatever project happened to
1147
+ // match. Deferred until there is actually something to place, so the LOCAL "that
1148
+ // run is not in this project" answer still comes first — resolving eagerly made
1149
+ // an unknown project report "not known to cardmem" instead, which is true but is
1150
+ // not the reason the caller's request failed. It also spares a network call for
1151
+ // a run that turns out to have no screenshot at all.
1152
+ let projectIdCache: string | null = null;
1153
+ const projectId = async () => {
1154
+ if (projectIdCache) return projectIdCache;
1155
+ const projects = await cloud.callTool<{ projects?: Array<{ id: string; slug: string }> }>(
1156
+ 'cardmem_list_projects',
1157
+ {},
1158
+ );
1159
+ const id = projects?.projects?.find((p) => p.slug === input.project)?.id;
1160
+ if (!id) throw new Error(`project ${input.project} is not known to cardmem — cannot place artifacts`);
1161
+ projectIdCache = id;
1162
+ return id;
1163
+ };
1164
+
1165
+ /** Read the bytes and hand them to the cloud. The daemon copies them itself, so a
1166
+ * screenshot of a customer's screen never passes through an agent's context. */
1167
+ const place = async (key: string, name: string, whose: string) => {
1168
+ // Project first, bytes second — the order the tests pin, and the right one: an
1169
+ // unknown project is a mistake in the REQUEST, while missing bytes are a fact
1170
+ // about the world. Report the caller's error before the world's.
1171
+ const target = await projectId();
1172
+ const bytes = await deps.storage.get(key);
1173
+ if (!bytes) {
1174
+ // The row says there is a picture and the storage does not have it. That is
1175
+ // the exact silent loss this mechanism exists to prevent, so it is a real
1176
+ // failure rather than a quiet omission.
1177
+ throw new Error(
1178
+ `${whose} claims a screenshot (${key}) but the bytes are GONE from ${deps.storage.backend} storage — the evidence this run cites no longer exists.`,
1179
+ );
1180
+ }
1181
+ return cloud.callTool<{ artifact_id: string; url: string }>('cardmem_place_artifact', {
1182
+ project_id: target,
1183
+ name,
1184
+ mime: 'image/png',
1185
+ base64: bytes.toString('base64'),
1186
+ source: 'lens-evidence',
1187
+ });
1188
+ };
1189
+
1190
+ const promoted: PromotedStep[] = [];
1191
+ let withoutShot = 0;
1192
+
1193
+ // ── a plain CAPTURE run (F074.68) ────────────────────────────────────────
1194
+ if (input.run_id) {
1195
+ const run = deps.db
1196
+ .query('SELECT * FROM lens_runs WHERE id = ? AND project_slug = ?')
1197
+ .get(input.run_id, input.project) as LensRunRow | undefined;
1198
+ // Scoped by project on purpose: a run id under a DIFFERENT project is not this
1199
+ // project's evidence, and "not found" is the honest answer.
1200
+ if (!run) throw new Error(`capture run ${input.run_id} not found in project ${input.project}`);
1201
+
1202
+ const stem = `lens-${run.surface_key || run.mode}-${input.run_id.slice(0, 8)}`;
1203
+ // BOTH pictures when both exist, each NAMED. A diff is evidence of a different
1204
+ // claim than the screenshot is, and promoting one silently while counting two
1205
+ // is the ambiguity this card was told to decide rather than leave open.
1206
+ if (run.artifact_key) {
1207
+ const placed = await place(run.artifact_key, `${stem}.png`, `run ${input.run_id}`);
1208
+ // Named fields, NOT `...placed`. The cloud's place_artifact response carries its
1209
+ // own `kind` ("image"), and spreading it last silently overwrote ours — the
1210
+ // stub in the test returns only {artifact_id,url}, so it could not see that.
1211
+ // Measured live: the very first real promote came back kind:"image" while every
1212
+ // test said "screenshot".
1213
+ promoted.push({
1214
+ index: 0, label: 'screenshot', kind: 'screenshot', status: run.status,
1215
+ artifact_id: placed.artifact_id, url: placed.url,
1216
+ });
1217
+ } else {
1218
+ withoutShot++;
1219
+ }
1220
+ if (run.diff_artifact_key) {
1221
+ const placed = await place(run.diff_artifact_key, `${stem}-diff.png`, `run ${input.run_id} (diff)`);
1222
+ promoted.push({
1223
+ index: 1, label: 'diff', kind: 'diff', status: run.status,
1224
+ artifact_id: placed.artifact_id, url: placed.url,
1225
+ });
1226
+ }
1227
+
1228
+ const evidence =
1229
+ promoted.length === 0
1230
+ ? `Lens capture run ${input.run_id} (${run.surface_key || run.mode}): NOTHING PROMOTED — this run stored no screenshot, so there is no pictorial evidence for it.`
1231
+ : `Lens capture run ${input.run_id} (${run.surface_key || run.mode}, ${run.status}) — ${promoted
1232
+ .map((p) => p.label)
1233
+ .join(' + ')} promoted to durable storage: ${promoted.map((p) => p.url).join(' ')}`;
1234
+
1235
+ return {
1236
+ flow_run_id: null,
1237
+ run_id: input.run_id,
1238
+ project: input.project,
1239
+ // lens_runs carries no artifact_backend column, so this is genuinely unknown
1240
+ // rather than 'local' — saying 'local' would be a guess dressed as a reading.
1241
+ was: null,
1242
+ promoted,
1243
+ steps_without_screenshot: withoutShot,
1244
+ evidence,
1245
+ };
1246
+ }
1247
+
1248
+ // ── a FLOW run ───────────────────────────────────────────────────────────
1249
+ const row = deps.db
1250
+ .query('SELECT * FROM lens_flow_runs WHERE id = ? AND project_slug = ?')
1251
+ .get(input.flow_run_id!, input.project) as LensFlowRunRow | undefined;
1252
+ if (!row) {
1253
+ throw new Error(`flow run ${input.flow_run_id} not found in project ${input.project}`);
1254
+ }
1255
+
1256
+ const steps = JSON.parse(row.steps_json) as StoredFlowStep[];
1257
+ const wanted = input.step_indexes ? new Set(input.step_indexes) : null;
1258
+
1259
+ for (const step of steps) {
1260
+ if (wanted && !wanted.has(step.index)) continue;
1261
+ if (!step.artifact_key) {
1262
+ withoutShot++;
1263
+ continue;
1264
+ }
1265
+ const name = `lens-${row.flow_name}-${input.flow_run_id!.slice(0, 8)}-step-${String(step.index).padStart(2, '0')}.png`;
1266
+ const placed = await place(step.artifact_key, name, `step ${step.index} of ${input.flow_run_id}`);
1267
+ promoted.push({
1268
+ index: step.index,
1269
+ label: step.label,
1270
+ kind: 'step',
1271
+ status: step.status,
1272
+ artifact_id: placed.artifact_id,
1273
+ url: placed.url,
1274
+ });
1275
+ }
1276
+
1277
+ const evidence =
1278
+ promoted.length === 0
1279
+ ? `Lens flow run ${input.flow_run_id} (${row.flow_name}): NOTHING PROMOTED — none of the ${steps.length} steps stored a screenshot. There is no pictorial evidence for this run.`
1280
+ : `Lens flow run ${input.flow_run_id} (${row.flow_name}, ${row.status}) — ${promoted.length} step still(s) promoted to durable storage: ${promoted
1281
+ .map((p) => p.url)
1282
+ .join(' ')}`;
1283
+
1284
+ return {
1285
+ flow_run_id: input.flow_run_id!,
1286
+ run_id: null,
1287
+ project: input.project,
1288
+ was: (row.artifact_backend as 'local' | 'tigris' | null) ?? null,
1289
+ promoted,
1290
+ steps_without_screenshot: withoutShot,
1291
+ evidence,
1292
+ };
1293
+ }
1294
+
1295
+ /** @deprecated F074.68 — kept so nothing breaks mid-rename; promoteEvidence now
1296
+ * takes a capture run too, and the old name says it does not. */
1297
+ export const promoteFlowEvidence = promoteEvidence;
1298
+
1299
+ // ─── F074.63 — lens_export ────────────────────────────────────────────────────
1300
+
1301
+ export interface ExportInput {
1302
+ project: string;
1303
+ url: string;
1304
+ format?: 'pdf' | 'svg';
1305
+ page_size?: string;
1306
+ landscape?: boolean;
1307
+ print_background?: boolean;
1308
+ margin?: { top?: string; right?: string; bottom?: string; left?: string };
1309
+ media?: ExportMedia;
1310
+ /** F074.64 — which surface to export; a print job is one sheet. */
1311
+ /** F074.64 — which surface to export; a print job is ONE sheet and a mockup
1312
+ * document usually holds several. */
1313
+ selector?: string;
1314
+ /** Which match when the selector hits several — a bifold folder has two sheets
1315
+ * under one class. Default 0. */
1316
+ selector_index?: number;
1317
+ engine?: LensEngine;
1318
+ viewport?: { width: number; height: number } | string;
1319
+ device?: string;
1320
+ scale?: number;
1321
+ waitUntil?: 'load' | 'domcontentloaded' | 'commit';
1322
+ waitFor?: string | number;
1323
+ actions?: CaptureAction[];
1324
+ auth?: AuthConfig;
1325
+ timeout_ms?: number;
1326
+ /** Strings the caller expects to find AS TEXT in the produced PDF. Checked
1327
+ * server-side and answered as booleans — the extracted text itself is never
1328
+ * returned, because a poster's worth of it is thousands of tokens the caller
1329
+ * did not ask for and cannot use. */
1330
+ expect_text?: string[];
1331
+ }
1332
+
1333
+ /** lens_export — render a URL to a VECTOR document, where text stays text.
1334
+ *
1335
+ * The verdict block is the point of the whole tool, so it is computed from the
1336
+ * produced bytes and returned every time rather than left to the caller to
1337
+ * check. fd-sundhed's existing broken artifact is a valid PDF with a valid MIME
1338
+ * type; the only thing that separates it from a usable one is whether a
1339
+ * designer can select the text. So that is what the response reports. */
1340
+ export async function lensExport(deps: LensDeps, input: ExportInput) {
1341
+ const project = requireProject(input.project);
1342
+ const format = input.format ?? 'pdf';
1343
+ if (format !== 'pdf' && format !== 'svg') {
1344
+ throw new Error(`lens_export supports format:'pdf' or 'svg'. Got '${format}'.`);
1345
+ }
1346
+
1347
+ const result = await exportPage({
1348
+ url: input.url,
1349
+ engine: input.engine,
1350
+ viewport: coerceViewport(input.viewport, input.device),
1351
+ deviceScaleFactor: input.scale == null ? undefined : Number(input.scale) || undefined,
1352
+ waitUntil: input.waitUntil,
1353
+ waitFor: input.waitFor,
1354
+ actions: input.actions,
1355
+ auth: resolveVerifyAuth(deps, project, input.auth),
1356
+ timeoutMs: input.timeout_ms,
1357
+ format,
1358
+ pageSize: input.page_size,
1359
+ landscape: input.landscape,
1360
+ printBackground: input.print_background,
1361
+ margin: input.margin,
1362
+ media: input.media,
1363
+ selector: input.selector,
1364
+ selectorIndex: input.selector_index,
1365
+ });
1366
+
1367
+ // "We wrote bytes" and "we wrote the format we promised" are different claims;
1368
+ // only one of them is worth reporting to someone with a print deadline.
1369
+ if (format === 'pdf' && !looksLikePdf(result.bytes)) {
1370
+ throw new Error('lens_export produced bytes that are not a PDF (missing %PDF- header)');
1371
+ }
1372
+ if (format === 'svg' && !result.bytes.subarray(0, 200).toString('utf8').includes('<svg')) {
1373
+ throw new Error('lens_export produced bytes that are not an SVG (no <svg root)');
1374
+ }
1375
+
1376
+ const exportId = randomUUID();
1377
+ const key = artifactKeys.exportDoc(project, exportId, format);
1378
+ await deps.storage.put(key, result.bytes, format === 'svg' ? 'image/svg+xml' : 'application/pdf');
1379
+
1380
+ const stats = format === 'pdf' ? pdfContentStats(result.bytes) : null;
1381
+ const box = format === 'pdf' ? readMediaBox(result.bytes) : null;
1382
+ // Answered here, on the bytes, rather than by handing back the whole text:
1383
+ // the caller wants to know whether their headline survived, not to read the
1384
+ // document back through their own context.
1385
+ const expected = Array.isArray(input.expect_text) ? input.expect_text.filter((t) => typeof t === 'string') : [];
1386
+ const svgText = format === 'svg' ? result.bytes.toString('utf8').replace(/<[^>]+>/g, ' ') : '';
1387
+ // F074.67 — HOW each phrase matched, not just whether. A phrase the page drew
1388
+ // with different capitalisation than the caller wrote is FOUND (that is what
1389
+ // was asked) but the difference is reported, because it is almost always
1390
+ // `text-transform` in the stylesheet and the caller should see that the page
1391
+ // says something other than their source does.
1392
+ const matches = expected.map((t) => [t, matchPhrase(stats ?? svgText, t)] as const);
1393
+ const textFound = Object.fromEntries(matches.map(([t, m]) => [t, m !== 'absent']));
1394
+ const casingDiffers = matches.filter(([, m]) => m === 'case-insensitive').map(([t]) => t);
1395
+ // F074.65 — a `false` here had TWO meanings and no way to tell them apart.
1396
+ //
1397
+ // Measured on the committed fixture: `expect_text` answered false for a phrase
1398
+ // that IS on the page (drawn in a Type 3 font we could not decode) and false
1399
+ // for a phrase in no document at all. Identical answers, opposite fixes — one
1400
+ // says "your headline is missing", the other says "your headline became
1401
+ // outlines". A caller acting on the first would go looking for a layout bug
1402
+ // that does not exist.
1403
+ //
1404
+ // So when this document dropped text, the misses are named as UNVERIFIABLE
1405
+ // rather than reported as absences. THE HONEST LIMIT, stated rather than
1406
+ // papered over: within one document that dropped runs we cannot separate the
1407
+ // two, so every miss is listed. Across documents we can — a file with nothing
1408
+ // dropped lists nothing, which is what keeps this from degrading into "every
1409
+ // miss is probably outlines".
1410
+ const unreadable = stats && stats.undecodedRuns > 0
1411
+ ? expected.filter((t) => !textFound[t])
1412
+ : [];
1413
+
1414
+ return {
1415
+ export_id: exportId,
1416
+ project,
1417
+ url: input.url,
1418
+ final_url: result.finalUrl,
1419
+ title: result.title,
1420
+ format,
1421
+ export_url: artifactUrl(deps.port, key),
1422
+ artifact_key: key,
1423
+ bytes: result.bytes.length,
1424
+ page_size: result.pageSize,
1425
+ landscape: result.landscape,
1426
+ media: result.media,
1427
+ print_background: result.printBackground,
1428
+ // null = NOT MEASURED, never a plausible default: an assertion on page
1429
+ // geometry must not pass on a PDF whose geometry was never read.
1430
+ page_mm: box
1431
+ ? { width: Number(ptToMm(box.widthPt).toFixed(1)), height: Number(ptToMm(box.heightPt).toFixed(1)) }
1432
+ : null,
1433
+ // The verdict a graphic designer actually cares about.
1434
+ editable_text: stats ? isVectorText(stats) : (result.svg?.counts.text ?? 0) > 0,
1435
+ // Counts only. Extracted text is deliberately never returned — see
1436
+ // expect_text above.
1437
+ content: stats
1438
+ ? {
1439
+ textRuns: stats.textRuns,
1440
+ fonts: stats.fonts,
1441
+ images: stats.images,
1442
+ undecodableStreams: stats.undecodableStreams,
1443
+ // F074.65 — runs whose characters we dropped. Separate from
1444
+ // undecodableStreams (inflate failures) on purpose: merging them is how
1445
+ // a lost line was reported as 0 problems.
1446
+ undecodedRuns: stats.undecodedRuns,
1447
+ textChars: stats.text.length,
1448
+ }
1449
+ : result.svg?.counts ?? null,
1450
+ // F074.65 — the verdict a designer can ACT on. Present only when the page is
1451
+ // drawn with outline fonts, so its absence is the good case rather than a
1452
+ // field nobody reads.
1453
+ ...(stats && outlineFontNames(stats).length > 0
1454
+ ? {
1455
+ outline_fonts: {
1456
+ families: outlineFontNames(stats),
1457
+ cause:
1458
+ 'Chromium cannot subset a VARIABLE font into a PDF font program, so it draws every ' +
1459
+ 'glyph as a Type 3 procedure. The document prints correctly and opens in Illustrator ' +
1460
+ 'as paths — the text can be moved but never re-typed.',
1461
+ remedy:
1462
+ 'Serve STATIC instances of these families instead of the variable font files ' +
1463
+ '(fontTools\' instancer produces them). Note that macOS system-ui / -apple-system is ' +
1464
+ 'a variable font, so Tailwind\'s default --font-sans stack lands here by default. ' +
1465
+ 'Or export format:\'svg\', which writes the font family by NAME and stays editable.',
1466
+ },
1467
+ }
1468
+ : {}),
1469
+ // F074.64 — what the SVG could NOT represent, named rather than approximated.
1470
+ // An export that silently omits an element looks complete and is not.
1471
+ ...(result.svg ? { unsupported: result.svg.unsupported } : {}),
1472
+ ...(expected.length > 0 ? { text_found: textFound } : {}),
1473
+ // A miss we cannot vouch for. Empty (and absent) whenever the document read
1474
+ // cleanly, so a plain `false` keeps meaning "not on the page".
1475
+ ...(unreadable.length > 0 ? { text_found_unverifiable: unreadable } : {}),
1476
+ // F074.67 — found, but the page painted it in different capitals than the
1477
+ // caller typed. Named rather than folded into a bare `true`: the difference
1478
+ // is real and is nearly always a `text-transform` rule.
1479
+ ...(casingDiffers.length > 0 ? { text_found_casing_differs: casingDiffers } : {}),
1480
+ page_errors: result.pageErrors,
1481
+ duration_ms: result.durationMs,
1482
+ };
1483
+ }