@polishd/next 0.2.3-dev.4 → 0.2.3-dev.6

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +12 -5
  3. package/bin/init.mjs +4 -1
  4. package/dist/ai/actions.d.ts +7 -1
  5. package/dist/ai/actions.d.ts.map +1 -1
  6. package/dist/ai/actions.js +10 -1
  7. package/dist/ai/actions.js.map +1 -1
  8. package/dist/ai/design.d.ts +5 -1
  9. package/dist/ai/design.d.ts.map +1 -1
  10. package/dist/ai/design.js +21 -4
  11. package/dist/ai/design.js.map +1 -1
  12. package/dist/ai/issues.d.ts +22 -11
  13. package/dist/ai/issues.d.ts.map +1 -1
  14. package/dist/ai/issues.js +102 -43
  15. package/dist/ai/issues.js.map +1 -1
  16. package/dist/ai/types.d.ts +3 -0
  17. package/dist/ai/types.d.ts.map +1 -1
  18. package/dist/dashboard/design-review-card.d.ts +3 -1
  19. package/dist/dashboard/design-review-card.d.ts.map +1 -1
  20. package/dist/dashboard/design-review-card.js +14 -6
  21. package/dist/dashboard/design-review-card.js.map +1 -1
  22. package/dist/dashboard/design.d.ts.map +1 -1
  23. package/dist/dashboard/design.js +75 -17
  24. package/dist/dashboard/design.js.map +1 -1
  25. package/dist/dashboard/file-bug-button.d.ts +14 -0
  26. package/dist/dashboard/file-bug-button.d.ts.map +1 -0
  27. package/dist/dashboard/file-bug-button.js +37 -0
  28. package/dist/dashboard/file-bug-button.js.map +1 -0
  29. package/dist/dashboard/snapshot-actions.d.ts +15 -5
  30. package/dist/dashboard/snapshot-actions.d.ts.map +1 -1
  31. package/dist/dashboard/snapshot-actions.js +69 -24
  32. package/dist/dashboard/snapshot-actions.js.map +1 -1
  33. package/dist/dashboard/snapshots.d.ts.map +1 -1
  34. package/dist/dashboard/snapshots.js +58 -8
  35. package/dist/dashboard/snapshots.js.map +1 -1
  36. package/dist/dashboard/summary.d.ts.map +1 -1
  37. package/dist/dashboard/summary.js +8 -36
  38. package/dist/dashboard/summary.js.map +1 -1
  39. package/dist/dashboard/ui.d.ts +1 -0
  40. package/dist/dashboard/ui.d.ts.map +1 -1
  41. package/dist/dashboard/ui.js +1 -0
  42. package/dist/dashboard/ui.js.map +1 -1
  43. package/dist/dashboard/value-preview.d.ts +47 -0
  44. package/dist/dashboard/value-preview.d.ts.map +1 -0
  45. package/dist/dashboard/value-preview.js +138 -0
  46. package/dist/dashboard/value-preview.js.map +1 -0
  47. package/dist/dashboard.css +170 -50
  48. package/dist/server/snapshots.d.ts +69 -10
  49. package/dist/server/snapshots.d.ts.map +1 -1
  50. package/dist/server/snapshots.js +407 -158
  51. package/dist/server/snapshots.js.map +1 -1
  52. package/dist/server/value-previews.d.ts +47 -0
  53. package/dist/server/value-previews.d.ts.map +1 -0
  54. package/dist/server/value-previews.js +313 -0
  55. package/dist/server/value-previews.js.map +1 -0
  56. package/dist/shared/design-values.d.ts +71 -0
  57. package/dist/shared/design-values.d.ts.map +1 -0
  58. package/dist/shared/design-values.js +98 -0
  59. package/dist/shared/design-values.js.map +1 -0
  60. package/docs/SETUP.md +10 -0
  61. package/package.json +1 -1
@@ -19,11 +19,27 @@
19
19
  * optional dependencies, so a standard install needs no extra wiring. Every
20
20
  * failure degrades to a message, never a crash — the same contract as the
21
21
  * rest of the server layer.
22
+ *
23
+ * Alongside the full shots, each route call also pictures the Design tab's
24
+ * flagged values in place — a ringed crop of the element carrying a one-off
25
+ * type style, color, radius or off-grid padding (server/value-previews.ts) —
26
+ * stored beside the shots under the same disk-or-Blob rule, and listed in the
27
+ * snapshot as `previews` so hovering the value on the dashboard shows where
28
+ * it renders.
29
+ *
30
+ * Capture is chunked into one server call per route: begin → one
31
+ * `captureSnapshotRoute` per route → finish, with the in-flight state parked
32
+ * in the store between calls. A whole-site capture in a single request would
33
+ * have to outlive a serverless host's function time limit — which the package
34
+ * cannot raise on the host's behalf (`maxDuration` must be declared inline in
35
+ * the host's own page module), and which kills the function mid-shoot with no
36
+ * result at all. One route per request keeps every call short enough to fit.
22
37
  */
23
38
  import { isAbsolute, join } from "node:path";
24
- import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
39
+ import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
25
40
  import { tmpdir } from "node:os";
26
41
  import { getMeta, setMeta, storeReady } from "./store";
42
+ import { captureValuePreviews } from "./value-previews";
27
43
  const INDEX_KEY = "design_snapshots";
28
44
  /** Snapshots kept before the oldest is pruned (files and index entry both). */
29
45
  const MAX_SNAPSHOTS = 6;
@@ -32,6 +48,19 @@ export const MAX_SNAPSHOT_ROUTES = 12;
32
48
  /** The device/theme matrix every snapshot shoots. */
33
49
  const SNAPSHOT_DEVICES = ["phone", "desktop"];
34
50
  const SNAPSHOT_THEMES = ["light", "dark"];
51
+ /**
52
+ * Where a flagged value is looked for, in order: the gallery's default view
53
+ * first, so most pictures match the shot the user is already looking at.
54
+ */
55
+ const PREVIEW_RENDERINGS = [
56
+ { device: "desktop", theme: "light" },
57
+ { device: "desktop", theme: "dark" },
58
+ { device: "phone", theme: "light" },
59
+ { device: "phone", theme: "dark" },
60
+ ];
61
+ /** Values pictured per route, and the time the pass may add to its call. */
62
+ const MAX_PREVIEWS_PER_ROUTE = 16;
63
+ const PREVIEW_BUDGET_MS = 15_000;
35
64
  /**
36
65
  * True when shots should upload to Vercel Blob rather than land on disk.
37
66
  * `BLOB_READ_WRITE_TOKEN` is injected automatically once a Blob store is
@@ -68,26 +97,32 @@ export async function listSnapshots() {
68
97
  return (await loadIndex()).snapshots;
69
98
  }
70
99
  /**
71
- * Read one shot's image as a data URL, or null when it doesn't exist. The
72
- * filename is only accepted when the index lists it for that snapshot, so a
73
- * caller can never read outside a snapshot directory — and Blob-stored shots
74
- * are only ever fetched from the URL the index recorded at capture time.
100
+ * Read one shot's — or one value preview's — image as a data URL, or null
101
+ * when it doesn't exist. The filename is only accepted when the index lists it
102
+ * for that snapshot, so a caller can never read outside a snapshot directory —
103
+ * and Blob-stored images are only ever fetched from the URL the index recorded
104
+ * at capture time.
75
105
  */
76
106
  export async function readSnapshotImage(snapshotId, file) {
77
107
  const index = await loadIndex();
78
108
  const snapshot = index.snapshots.find((s) => s.id === snapshotId);
79
- const shot = snapshot?.shots.find((s) => s.file === file && s.file.length > 0);
80
- if (!snapshot || !shot)
109
+ if (!snapshot || file.length === 0)
110
+ return null;
111
+ const shot = snapshot.shots.find((s) => s.file === file);
112
+ const preview = shot ? undefined : snapshot.previews?.find((p) => p.file === file);
113
+ const image = shot ?? preview;
114
+ if (!image)
81
115
  return null;
82
116
  try {
83
- if (shot.url) {
84
- const res = await fetch(shot.url);
117
+ if (image.url) {
118
+ const res = await fetch(image.url);
85
119
  if (!res.ok)
86
120
  return null;
87
121
  const bytes = Buffer.from(await res.arrayBuffer());
88
122
  return `data:image/png;base64,${bytes.toString("base64")}`;
89
123
  }
90
- const bytes = await readFile(join(snapshotsRoot(), snapshot.id, "shots", shot.file));
124
+ const dir = shot ? "shots" : "previews";
125
+ const bytes = await readFile(join(snapshotsRoot(), snapshot.id, dir, image.file));
91
126
  return `data:image/png;base64,${bytes.toString("base64")}`;
92
127
  }
93
128
  catch {
@@ -96,43 +131,129 @@ export async function readSnapshotImage(snapshotId, file) {
96
131
  }
97
132
  // ── Storage ──────────────────────────────────────────────────────────────────
98
133
  /**
99
- * Upload every captured PNG to Vercel Blob and annotate each shot with its
100
- * URL. The pathnames get a random suffix: public-but-unguessable is the only
101
- * access model Blob offers, and the dashboard still serves images through the
102
- * auth-gated action rather than handing these URLs to the browser. Returns an
103
- * error message, or null when every image landed; on failure the ones already
104
- * uploaded are deleted, so a half-stored snapshot never survives.
134
+ * Move one route's staged PNGs to their real home — Vercel Blob when a token
135
+ * is present, the snapshots directory otherwise — under a per-route filename
136
+ * prefix: allpages slugs are only unique within a single run, and each route
137
+ * is its own run now. Blob pathnames get a random suffix on top —
138
+ * public-but-unguessable is the only access model Blob offers, and the
139
+ * dashboard still serves images through the auth-gated action rather than
140
+ * handing these URLs to the browser. On a failed upload the pieces already
141
+ * uploaded are deleted, so a half-stored route never survives.
105
142
  */
106
- async function uploadShots(id, outDir, shots) {
107
- let blob;
143
+ async function storeRouteShots(id, routeIndex, stageDir, captured) {
144
+ const prefix = `r${routeIndex}-`;
145
+ const shots = [];
146
+ const toShot = (s, file, url) => ({
147
+ route: s.route.path,
148
+ device: s.device.name,
149
+ theme: s.theme,
150
+ file,
151
+ ...(url ? { url } : {}),
152
+ ...(s.skipped ? { skipped: s.skipped } : {}),
153
+ });
154
+ if (!blobConfigured()) {
155
+ const shotsDir = join(snapshotsRoot(), id, "shots");
156
+ await mkdir(shotsDir, { recursive: true });
157
+ for (const s of captured) {
158
+ if (!s.file) {
159
+ shots.push(toShot(s, ""));
160
+ continue;
161
+ }
162
+ await copyFile(join(stageDir, "shots", s.file), join(shotsDir, prefix + s.file));
163
+ shots.push(toShot(s, prefix + s.file));
164
+ }
165
+ return { shots };
166
+ }
167
+ const blob = await loadBlobSdk();
168
+ if ("message" in blob)
169
+ return blob;
170
+ const uploaded = [];
171
+ try {
172
+ for (const s of captured) {
173
+ if (!s.file) {
174
+ shots.push(toShot(s, ""));
175
+ continue;
176
+ }
177
+ const bytes = await readFile(join(stageDir, "shots", s.file));
178
+ const { url } = await blob.put(`polishd/snapshots/${id}/${prefix}${s.file}`, bytes, {
179
+ access: "public",
180
+ contentType: "image/png",
181
+ addRandomSuffix: true,
182
+ });
183
+ uploaded.push(url);
184
+ shots.push(toShot(s, prefix + s.file, url));
185
+ }
186
+ return { shots };
187
+ }
188
+ catch (err) {
189
+ if (uploaded.length > 0)
190
+ await blob.del(uploaded).catch(() => { });
191
+ return {
192
+ message: `Couldn't upload screenshots to Vercel Blob: ${err instanceof Error ? err.message : "unknown error"}`,
193
+ };
194
+ }
195
+ }
196
+ /** The Blob SDK, or why it couldn't load. */
197
+ async function loadBlobSdk() {
108
198
  try {
109
- blob = await import("@vercel/blob");
199
+ return await import("@vercel/blob");
110
200
  }
111
201
  catch {
112
- return ("Blob storage is configured (BLOB_READ_WRITE_TOKEN is set) but @vercel/blob " +
113
- "isn't installed. It ships as an optional dependency of @polishd/next — " +
114
- "reinstall without `--omit=optional`.");
202
+ return {
203
+ message: "Blob storage is configured (BLOB_READ_WRITE_TOKEN is set) but @vercel/blob " +
204
+ "isn't installed. It ships as an optional dependency of @polishd/next — " +
205
+ "reinstall without `--omit=optional`.",
206
+ };
115
207
  }
208
+ }
209
+ /**
210
+ * Store one route's value previews next to its shots — the snapshot's
211
+ * `previews/` directory, or Blob under the same rules as the shots. Names are
212
+ * `v-r<route>-<n>-<device>-<theme>.png`, unique within the snapshot and never
213
+ * mistakable for a shot's. All or nothing, like the shots: a failed upload
214
+ * deletes the pieces already sent.
215
+ */
216
+ async function storeRoutePreviews(id, routeIndex, route, crops) {
217
+ const named = crops.map((c, n) => ({
218
+ ...c,
219
+ file: `v-r${routeIndex}-${n}-${c.device}-${c.theme}.png`,
220
+ }));
221
+ const toPreview = (c, url) => ({
222
+ key: c.key,
223
+ route,
224
+ device: c.device,
225
+ theme: c.theme,
226
+ file: c.file,
227
+ ...(url ? { url } : {}),
228
+ });
229
+ if (!blobConfigured()) {
230
+ const dir = join(snapshotsRoot(), id, "previews");
231
+ await mkdir(dir, { recursive: true });
232
+ for (const c of named)
233
+ await writeFile(join(dir, c.file), c.bytes);
234
+ return named.map((c) => toPreview(c));
235
+ }
236
+ const blob = await loadBlobSdk();
237
+ if ("message" in blob)
238
+ return [];
116
239
  const uploaded = [];
117
240
  try {
118
- for (const shot of shots) {
119
- if (!shot.file)
120
- continue;
121
- const bytes = await readFile(join(outDir, "shots", shot.file));
122
- const { url } = await blob.put(`polishd/snapshots/${id}/${shot.file}`, bytes, {
241
+ const previews = [];
242
+ for (const c of named) {
243
+ const { url } = await blob.put(`polishd/snapshots/${id}/previews/${c.file}`, c.bytes, {
123
244
  access: "public",
124
245
  contentType: "image/png",
125
246
  addRandomSuffix: true,
126
247
  });
127
- shot.url = url;
128
248
  uploaded.push(url);
249
+ previews.push(toPreview(c, url));
129
250
  }
130
- return null;
251
+ return previews;
131
252
  }
132
- catch (err) {
253
+ catch {
133
254
  if (uploaded.length > 0)
134
255
  await blob.del(uploaded).catch(() => { });
135
- return `Couldn't upload screenshots to Vercel Blob: ${err instanceof Error ? err.message : "unknown error"}`;
256
+ return [];
136
257
  }
137
258
  }
138
259
  /**
@@ -142,7 +263,7 @@ async function uploadShots(id, outDir, shots) {
142
263
  * blob costs cents, a prune that crashed the capture would cost the feature.
143
264
  */
144
265
  async function deleteSnapshotImages(snapshot) {
145
- const urls = snapshot.shots.flatMap((s) => (s.url ? [s.url] : []));
266
+ const urls = [...snapshot.shots, ...(snapshot.previews ?? [])].flatMap((s) => s.url ? [s.url] : []);
146
267
  if (urls.length > 0) {
147
268
  try {
148
269
  const { del } = await import("@vercel/blob");
@@ -154,6 +275,251 @@ async function deleteSnapshotImages(snapshot) {
154
275
  }
155
276
  await rm(join(snapshotsRoot(), snapshot.id), { recursive: true, force: true }).catch(() => { });
156
277
  }
278
+ const PENDING_KEY = "design_snapshot_pending";
279
+ async function loadPending() {
280
+ const raw = await getMeta(PENDING_KEY);
281
+ if (!raw)
282
+ return null;
283
+ try {
284
+ const parsed = JSON.parse(raw);
285
+ if (parsed?.v === 1 && typeof parsed.id === "string") {
286
+ // Records parked by a version without value previews.
287
+ parsed.targets ??= [];
288
+ parsed.previews ??= [];
289
+ parsed.previewMisses ??= [];
290
+ return parsed;
291
+ }
292
+ }
293
+ catch {
294
+ // A corrupt pending record is simply abandoned.
295
+ }
296
+ return null;
297
+ }
298
+ /** Persist the in-flight capture; null clears it. */
299
+ async function savePending(pending) {
300
+ return setMeta(PENDING_KEY, pending ? JSON.stringify(pending) : "");
301
+ }
302
+ /**
303
+ * Start a capture: run the environment preflight, sweep any unfinished
304
+ * predecessor's images, and park the plan in the store. The returned routes
305
+ * are what the caller should then feed to `captureSnapshotRoute`, one call
306
+ * each, before `finishSnapshot` records the run.
307
+ */
308
+ export async function beginSnapshot(input) {
309
+ if (!(await storeReady())) {
310
+ return {
311
+ ok: false,
312
+ message: "The analytics store isn't writable here, so a snapshot couldn't be recorded. " +
313
+ "Run locally or configure a database — see DATABASE.md.",
314
+ };
315
+ }
316
+ // The same preflight the dashboard runs before enabling the capture button.
317
+ // Actions are reachable without the dashboard ever rendering, so a doomed
318
+ // environment must fail here too — with the diagnosis, not a minute of
319
+ // capture ending in an EROFS.
320
+ const issue = await snapshotCaptureIssue();
321
+ if (issue)
322
+ return { ok: false, message: issue };
323
+ const routes = input.routes.slice(0, MAX_SNAPSHOT_ROUTES);
324
+ if (routes.length === 0)
325
+ return { ok: false, message: "No routes to capture." };
326
+ // A predecessor that never finished (tab closed mid-run) left images
327
+ // behind; sweep them before its record is overwritten.
328
+ const stale = await loadPending();
329
+ if (stale)
330
+ await deleteSnapshotImages(stale);
331
+ const pending = {
332
+ v: 1,
333
+ id: Date.now().toString(36),
334
+ origin: input.origin,
335
+ fingerprint: input.fingerprint,
336
+ startedAt: Date.now(),
337
+ routes,
338
+ done: [],
339
+ shots: [],
340
+ authWalled: [],
341
+ noDarkMode: true,
342
+ targets: input.targets ?? [],
343
+ previews: [],
344
+ previewMisses: [],
345
+ };
346
+ if (!(await savePending(pending))) {
347
+ return { ok: false, message: "The store refused to record the capture in progress." };
348
+ }
349
+ return { ok: true, id: pending.id, routes };
350
+ }
351
+ /**
352
+ * Shoot one route of the capture `beginSnapshot` started — both devices, both
353
+ * themes — store its images, and append the results to the pending record.
354
+ * Bounded work per call by design: this is the request that has to fit inside
355
+ * a serverless host's function time limit.
356
+ */
357
+ export async function captureSnapshotRoute(id, route) {
358
+ const pending = await loadPending();
359
+ if (!pending || pending.id !== id) {
360
+ return {
361
+ ok: false,
362
+ message: "This capture is no longer in progress — start a new one.",
363
+ };
364
+ }
365
+ if (!pending.routes.includes(route)) {
366
+ return { ok: false, message: `${route} isn't part of this capture.` };
367
+ }
368
+ if (pending.done.includes(route)) {
369
+ // A retry after a lost response — the shots are already recorded.
370
+ return { ok: true, shots: pending.shots.filter((s) => s.route === route) };
371
+ }
372
+ let run;
373
+ try {
374
+ ({ allpages: run } = await import("allpages"));
375
+ }
376
+ catch (err) {
377
+ return {
378
+ ok: false,
379
+ message: `The allpages screenshot engine failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
380
+ };
381
+ }
382
+ const launched = await launchBrowser();
383
+ if ("message" in launched)
384
+ return { ok: false, message: launched.message };
385
+ // Every route stages into its own temp directory — the one location
386
+ // serverless hosts keep writable — and the files only exist for the moments
387
+ // between capture and `storeRouteShots` moving them home.
388
+ let stageDir;
389
+ try {
390
+ stageDir = await mkdtemp(join(tmpdir(), "polishd-snapshot-"));
391
+ }
392
+ catch (err) {
393
+ await launched.browser.close().catch(() => { });
394
+ return {
395
+ ok: false,
396
+ message: `Couldn't create the snapshot staging directory (${err instanceof Error ? err.message : "unknown error"}) — is the filesystem writable?`,
397
+ };
398
+ }
399
+ try {
400
+ const result = await run({
401
+ url: pending.origin,
402
+ routes: [route],
403
+ outDir: stageDir,
404
+ devices: [...SNAPSHOT_DEVICES],
405
+ themes: [...SNAPSHOT_THEMES],
406
+ // The dashboard composes its own grid; the stitched contact sheet would
407
+ // only duplicate every image on disk.
408
+ skipSheet: true,
409
+ browser: launched.browser,
410
+ });
411
+ const stored = await storeRouteShots(id, pending.routes.indexOf(route), stageDir, result.shots);
412
+ if ("message" in stored)
413
+ return { ok: false, message: stored.message };
414
+ const pictured = await pictureRouteValues(launched.browser, pending, route, stored.shots);
415
+ pending.done.push(route);
416
+ pending.shots.push(...stored.shots);
417
+ pending.previews.push(...pictured.previews);
418
+ pending.previewMisses.push(...pictured.missed);
419
+ for (const walled of result.authWalled) {
420
+ if (!pending.authWalled.includes(walled))
421
+ pending.authWalled.push(walled);
422
+ }
423
+ // A route that produced no images says nothing about dark mode.
424
+ if (stored.shots.some((s) => s.file)) {
425
+ pending.noDarkMode = pending.noDarkMode && result.noDarkMode;
426
+ }
427
+ if (!(await savePending(pending))) {
428
+ await deleteSnapshotImages({ id, shots: stored.shots, previews: pictured.previews });
429
+ return { ok: false, message: "The route was shot but the store refused to record it." };
430
+ }
431
+ return { ok: true, shots: stored.shots };
432
+ }
433
+ catch (err) {
434
+ return {
435
+ ok: false,
436
+ message: `Screenshot capture failed: ${err instanceof Error ? err.message : "unknown error"}`,
437
+ };
438
+ }
439
+ finally {
440
+ await launched.browser.close().catch(() => { });
441
+ await rm(stageDir, { recursive: true, force: true }).catch(() => { });
442
+ }
443
+ }
444
+ /**
445
+ * Picture the capture's flagged values that were measured on `route` and
446
+ * aren't pictured yet, in the renderings this route actually produced shots
447
+ * for (an auth-walled or failed route has none, so it's skipped). Never fails
448
+ * the route: any error leaves the route with its full shots and no previews.
449
+ */
450
+ async function pictureRouteValues(browser, pending, route, shots) {
451
+ const none = { previews: [], missed: [] };
452
+ const pictured = new Set(pending.previews.map((p) => p.key));
453
+ const targets = pending.targets
454
+ .filter((t) => t.pages.includes(route) && !pictured.has(t.key))
455
+ .slice(0, MAX_PREVIEWS_PER_ROUTE);
456
+ const renderings = PREVIEW_RENDERINGS.filter((r) => shots.some((s) => s.file && s.device === r.device && s.theme === r.theme));
457
+ if (targets.length === 0 || renderings.length === 0)
458
+ return none;
459
+ try {
460
+ const result = await captureValuePreviews(browser, pending.origin, route, targets, renderings, PREVIEW_BUDGET_MS);
461
+ const previews = result.crops.length > 0
462
+ ? await storeRoutePreviews(pending.id, pending.routes.indexOf(route), route, result.crops)
463
+ : [];
464
+ return { previews, missed: result.missed };
465
+ }
466
+ catch {
467
+ return none;
468
+ }
469
+ }
470
+ /**
471
+ * Close out the capture: record the accumulated shots in the snapshot index
472
+ * and prune old snapshots past `MAX_SNAPSHOTS`, images included. Called once
473
+ * every route has been attempted — routes whose calls failed outright are
474
+ * simply absent from the snapshot.
475
+ */
476
+ export async function finishSnapshot(id) {
477
+ const pending = await loadPending();
478
+ if (!pending || pending.id !== id) {
479
+ return {
480
+ ok: false,
481
+ message: "This capture is no longer in progress — start a new one.",
482
+ };
483
+ }
484
+ const snapshot = {
485
+ id: pending.id,
486
+ capturedAt: Date.now(),
487
+ origin: pending.origin,
488
+ fingerprint: pending.fingerprint,
489
+ shots: pending.shots,
490
+ authWalled: pending.authWalled,
491
+ noDarkMode: pending.noDarkMode,
492
+ previews: pending.previews,
493
+ // A value missed on one of its pages may have been pictured on another.
494
+ previewMisses: [...new Set(pending.previewMisses)].filter((key) => !pending.previews.some((p) => p.key === key)),
495
+ };
496
+ if (snapshot.shots.every((s) => !s.file)) {
497
+ await deleteSnapshotImages(snapshot);
498
+ await savePending(null);
499
+ return {
500
+ ok: false,
501
+ message: "Every page failed to capture — is the site reachable at " +
502
+ `${pending.origin} from its own server?`,
503
+ };
504
+ }
505
+ const index = await loadIndex();
506
+ index.snapshots.unshift(snapshot);
507
+ const pruned = index.snapshots.slice(MAX_SNAPSHOTS);
508
+ index.snapshots = index.snapshots.slice(0, MAX_SNAPSHOTS);
509
+ if (!(await setMeta(INDEX_KEY, JSON.stringify(index)))) {
510
+ await deleteSnapshotImages(snapshot);
511
+ await savePending(null);
512
+ return {
513
+ ok: false,
514
+ message: "The snapshot was shot but the store refused to record it.",
515
+ };
516
+ }
517
+ await savePending(null);
518
+ for (const old of pruned) {
519
+ await deleteSnapshotImages(old);
520
+ }
521
+ return { ok: true, snapshot };
522
+ }
157
523
  /**
158
524
  * Launch a Chromium the way the allpages CLI does — the user's own Chrome or
159
525
  * Edge first, Playwright's downloaded Chromium next — but never auto-download
@@ -164,7 +530,15 @@ async function deleteSnapshotImages(snapshot) {
164
530
  * dependency so production needs no setup step.
165
531
  */
166
532
  async function launchBrowser() {
167
- const { chromium } = await import("playwright-core");
533
+ let chromium;
534
+ try {
535
+ ({ chromium } = await import("playwright-core"));
536
+ }
537
+ catch (err) {
538
+ return {
539
+ message: `The browser driver (playwright-core) failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
540
+ };
541
+ }
168
542
  const args = ["--force-color-profile=srgb", "--hide-scrollbars"];
169
543
  for (const channel of ["chrome", "msedge", undefined]) {
170
544
  try {
@@ -262,129 +636,4 @@ export async function snapshotCaptureIssue() {
262
636
  }
263
637
  return null;
264
638
  }
265
- /**
266
- * Shoot the given routes and record the run in the snapshot index. Old
267
- * snapshots past `MAX_SNAPSHOTS` are pruned, images included.
268
- */
269
- export async function captureSnapshot(input) {
270
- if (!(await storeReady())) {
271
- return {
272
- ok: false,
273
- message: "The analytics store isn't writable here, so a snapshot couldn't be recorded. " +
274
- "Run locally or configure a database — see DATABASE.md.",
275
- };
276
- }
277
- // The same preflight the dashboard runs before enabling the capture button.
278
- // Actions are reachable without the dashboard ever rendering, so a doomed
279
- // environment must fail here too — with the diagnosis, not a minute of
280
- // capture ending in an EROFS.
281
- const issue = await snapshotCaptureIssue();
282
- if (issue)
283
- return { ok: false, message: issue };
284
- let run;
285
- try {
286
- ({ allpages: run } = await import("allpages"));
287
- }
288
- catch (err) {
289
- return {
290
- ok: false,
291
- message: `The allpages screenshot engine failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
292
- };
293
- }
294
- const launched = await launchBrowser();
295
- if ("message" in launched)
296
- return { ok: false, message: launched.message };
297
- const id = `${Date.now().toString(36)}`;
298
- const toBlob = blobConfigured();
299
- // In Blob mode allpages still writes to disk first, but into the system
300
- // temp directory — the one location serverless hosts keep writable — and
301
- // the files only exist for the moments between capture and upload.
302
- let outDir;
303
- try {
304
- if (toBlob) {
305
- outDir = await mkdtemp(join(tmpdir(), "polishd-snapshot-"));
306
- }
307
- else {
308
- outDir = join(snapshotsRoot(), id);
309
- await mkdir(outDir, { recursive: true });
310
- }
311
- }
312
- catch (err) {
313
- await launched.browser.close().catch(() => { });
314
- return {
315
- ok: false,
316
- message: `Couldn't create the snapshot directory (${err instanceof Error ? err.message : "unknown error"}) — is the filesystem writable?`,
317
- };
318
- }
319
- try {
320
- const result = await run({
321
- url: input.origin,
322
- routes: input.routes.slice(0, MAX_SNAPSHOT_ROUTES),
323
- outDir,
324
- devices: [...SNAPSHOT_DEVICES],
325
- themes: [...SNAPSHOT_THEMES],
326
- // The dashboard composes its own grid; the stitched contact sheet would
327
- // only duplicate every image on disk.
328
- skipSheet: true,
329
- browser: launched.browser,
330
- });
331
- const snapshot = {
332
- id,
333
- capturedAt: Date.now(),
334
- origin: input.origin,
335
- fingerprint: input.fingerprint,
336
- shots: result.shots.map((s) => ({
337
- route: s.route.path,
338
- device: s.device.name,
339
- theme: s.theme,
340
- file: s.file ?? "",
341
- ...(s.skipped ? { skipped: s.skipped } : {}),
342
- })),
343
- authWalled: result.authWalled,
344
- noDarkMode: result.noDarkMode,
345
- };
346
- if (snapshot.shots.every((s) => !s.file)) {
347
- await rm(outDir, { recursive: true, force: true }).catch(() => { });
348
- return {
349
- ok: false,
350
- message: "Every page failed to capture — is the site reachable at " +
351
- `${input.origin} from this process?`,
352
- };
353
- }
354
- if (toBlob) {
355
- const failure = await uploadShots(id, outDir, snapshot.shots);
356
- if (failure)
357
- return { ok: false, message: failure };
358
- }
359
- const index = await loadIndex();
360
- index.snapshots.unshift(snapshot);
361
- const pruned = index.snapshots.slice(MAX_SNAPSHOTS);
362
- index.snapshots = index.snapshots.slice(0, MAX_SNAPSHOTS);
363
- if (!(await setMeta(INDEX_KEY, JSON.stringify(index)))) {
364
- await deleteSnapshotImages(snapshot);
365
- return {
366
- ok: false,
367
- message: "The snapshot was shot but the store refused to record it.",
368
- };
369
- }
370
- for (const old of pruned) {
371
- await deleteSnapshotImages(old);
372
- }
373
- return { ok: true, snapshot };
374
- }
375
- catch (err) {
376
- await rm(outDir, { recursive: true, force: true }).catch(() => { });
377
- return {
378
- ok: false,
379
- message: `Screenshot capture failed: ${err instanceof Error ? err.message : "unknown error"}`,
380
- };
381
- }
382
- finally {
383
- await launched.browser.close().catch(() => { });
384
- // The temp staging directory never outlives the capture; the disk-mode
385
- // directory is the storage itself and stays.
386
- if (toBlob)
387
- await rm(outDir, { recursive: true, force: true }).catch(() => { });
388
- }
389
- }
390
639
  //# sourceMappingURL=snapshots.js.map