@piagent/platform 1.39.0 → 1.41.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/architecture/layers.json +10 -0
  5. package/docs/command-reference-vietnamese.md +2 -2
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/en/architecture.md +1 -0
  8. package/docs/languages.json +4 -4
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/architecture.md +1 -0
  14. package/docs-site/architecture.html +1 -1
  15. package/docs-site/benchmark.html +1 -1
  16. package/docs-site/commands.html +1 -1
  17. package/docs-site/content/en/index.html +4 -4
  18. package/docs-site/content/en/whats-new.html +12 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +12 -0
  21. package/docs-site/context-engine.html +1 -1
  22. package/docs-site/ecosystem.html +1 -1
  23. package/docs-site/en/architecture.html +1 -1
  24. package/docs-site/en/benchmark.html +1 -1
  25. package/docs-site/en/commands.html +1 -1
  26. package/docs-site/en/context-engine.html +1 -1
  27. package/docs-site/en/ecosystem.html +1 -1
  28. package/docs-site/en/index.html +5 -5
  29. package/docs-site/en/mcp-approval.html +1 -1
  30. package/docs-site/en/mcp-auth.html +1 -1
  31. package/docs-site/en/mcp-servers.html +1 -1
  32. package/docs-site/en/mcp.html +1 -1
  33. package/docs-site/en/permissions.html +1 -1
  34. package/docs-site/en/profiles.html +1 -1
  35. package/docs-site/en/quickstart.html +1 -1
  36. package/docs-site/en/runtime.html +1 -1
  37. package/docs-site/en/scope.html +1 -1
  38. package/docs-site/en/security.html +1 -1
  39. package/docs-site/en/team.html +1 -1
  40. package/docs-site/en/whats-new.html +13 -1
  41. package/docs-site/en/windows.html +1 -1
  42. package/docs-site/en/workflows.html +1 -1
  43. package/docs-site/index.html +5 -5
  44. package/docs-site/mcp-approval.html +1 -1
  45. package/docs-site/mcp-auth.html +1 -1
  46. package/docs-site/mcp-servers.html +1 -1
  47. package/docs-site/mcp.html +1 -1
  48. package/docs-site/permissions.html +1 -1
  49. package/docs-site/profiles.html +1 -1
  50. package/docs-site/quickstart.html +1 -1
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/whats-new.html +13 -1
  56. package/docs-site/windows.html +1 -1
  57. package/docs-site/workflows.html +1 -1
  58. package/evals/fixtures/piagent-webui/document-asset-v1.invalid.json +12 -0
  59. package/evals/fixtures/piagent-webui/document-asset-v1.valid.json +12 -0
  60. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  61. package/package.json +1 -1
  62. package/packages/piagent-core/managed/request-stream.mjs +2 -2
  63. package/packages/piagent-core/managed/workspace-projects.mjs +61 -0
  64. package/packages/piagent-core/managed/workspace-report.mjs +39 -18
  65. package/packages/piagent-core/package.json +1 -1
  66. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +78 -15
  67. package/packages/piagent-core/skills/fe-change-report/SKILL.md +175 -0
  68. package/packages/piagent-core/skills/fe-change-report/scripts/build-report.mjs +183 -0
  69. package/packages/piagent-core/skills/fe-change-report/scripts/capture.mjs +150 -0
  70. package/packages/piagent-core/skills/fe-change-report/scripts/diff-regions.mjs +110 -0
  71. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-CMZESBjK.js → MarkdownMessage-DeLo-xiz.js} +1 -1
  72. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-DlcfsRaR.js → TerminalPanel-BG-DP_qm.js} +2 -2
  73. package/packages/piagent-webui/dist/client/assets/{TerminalView-B83HGKgY.js → TerminalView-r9rG24IO.js} +1 -1
  74. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-Ctf0VMQh.js → WorkspaceCodeEditor-BnqaR3pK.js} +1 -1
  75. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-fO2uXHZg.js → WorkspaceExplorer-BIxwiGtl.js} +3 -3
  76. package/packages/piagent-webui/dist/client/assets/{WorkspaceQuickOpen-ClaXnKl1.js → WorkspaceQuickOpen-CK3dILhQ.js} +1 -1
  77. package/packages/piagent-webui/dist/client/assets/index-DBUTrEXi.css +1 -0
  78. package/packages/piagent-webui/dist/client/assets/index-rlyRccut.js +102 -0
  79. package/packages/piagent-webui/dist/client/assets/{tab-close-ggR0dHlZ.js → tab-close-BoJRyBO_.js} +1 -1
  80. package/packages/piagent-webui/dist/client/index.html +2 -2
  81. package/packages/piagent-webui/extension/piagent-webui.ts +1 -0
  82. package/packages/piagent-webui/gateway/managed-projection.ts +4 -3
  83. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -0
  84. package/packages/piagent-webui/server/ipc-read-model-client.ts +1 -0
  85. package/packages/piagent-webui/server/read-model-provider.ts +1 -0
  86. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  87. package/packages/piagent-webui/shared/project-warning-copy.ts +15 -10
  88. package/schemas/piagent-webui/catalog-v1.json +9 -0
  89. package/schemas/piagent-webui/document-asset-v1.schema.json +57 -0
  90. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  91. package/scripts/check-runtime-advisories.mjs +28 -3
  92. package/scripts/runtime-advisory-exceptions.json +26 -1
  93. package/packages/piagent-webui/dist/client/assets/index-BeBiJUDV.js +0 -102
  94. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +0 -1
@@ -1,36 +1,57 @@
1
1
  import { CLIENT_AGENT } from './client-agent.mjs';
2
2
  import { findRepositories } from './memory-repos.mjs';
3
+ import { findProjects, folderName } from './workspace-projects.mjs';
3
4
  import { hasCapability } from './studio-capabilities.mjs';
4
5
 
5
- // Tells Studio which repositories a company conversation works in, once per
6
- // conversation, with the run's token (POST /studio/v1/managed/workspace).
7
- // Studio warns its administrators when a repository does not belong to the
8
- // key's team (Logs → "Sai project"); the member sees one line in the
9
- // dashboard and in piagent studio (agent-watch-project). Repositories are
10
- // sent as memory already sends them (host/path of the remote, never a local
11
- // path), only to a Studio that serves it (managed.workspace.v1). Nothing
12
- // here can fail a turn.
6
+ // Tells Studio which projects a company conversation works in, once per run
7
+ // (a run is one turn), with the run's token (POST /studio/v1/managed/workspace).
8
+ // Studio warns its administrators when a project does not belong to the key's
9
+ // team (Logs → "Sai project"), and lists a key and computer whose runs never
10
+ // report; the member sees one line in the dashboard and in piagent studio
11
+ // (agent-watch-project). Repositories are sent as memory sends them
12
+ // (host/path of the remote). A Studio with managed.workspace.v2 also gets
13
+ // folders without a readable remote as local projects (workspace-projects.mjs)
14
+ // with their folder names; a path never leaves the computer. Nothing here can
15
+ // fail a turn.
13
16
  export const PROJECT_ENTRY = 'agent-watch-project';
14
- const SHOWN = new Set(['other_team', 'unlisted']), ATTEMPTS = 3;
17
+ const KINDS = new Set(['other_team', 'unlisted', 'unregistered', 'no_repository']), V1_SHOWN = new Set(['other_team', 'unlisted']), ATTEMPTS = 3;
15
18
 
16
- export function reportWorkspace(owner, token, { fetchImpl = fetch, find = findRepositories, now = Date.now } = {}) {
17
- const state = owner.workspaceReport ??= { done: false, sending: null, attempts: 0 };
18
- if (state.done || state.sending || !token || !owner.cwd || !owner.origin || !hasCapability(owner, 'managed.workspace.v1')) return null;
19
+ export function reportWorkspace(owner, token, { run = null, fetchImpl = fetch, find = findRepositories, projects = findProjects, now = Date.now } = {}) {
20
+ if (!token || !owner.cwd || !owner.origin || owner.workspaceUnsupported || !hasCapability(owner, 'managed.workspace.v1')) return null;
21
+ // A new run reports again; a run reports once.
22
+ if (owner.workspaceReport && owner.workspaceReport.run !== run && !owner.workspaceReport.sending) owner.workspaceReport = null;
23
+ const state = owner.workspaceReport ??= { run, done: false, sending: null, attempts: 0 };
24
+ if (state.done || state.sending) return null;
19
25
  const retry = () => { if ((state.attempts += 1) >= ATTEMPTS) state.done = true; };
26
+ const v2 = hasCapability(owner, 'managed.workspace.v2');
20
27
  state.sending = (async () => {
21
28
  try {
22
- const { repos, folder } = await find(owner.cwd);
23
- const body = repos.length ? { repositories: repos.map(repo => repo.key) } : { repositories: [], ...(folder ? { folder } : {}) };
29
+ let body, names = new Map();
30
+ if (v2) {
31
+ const found = await projects(owner.cwd);
32
+ for (const project of found) if (project.name) names.set(project.key, project.name);
33
+ body = found.length ? { repositories: found.map(project => project.key), ...(names.size ? { names: Object.fromEntries(names) } : {}) }
34
+ : { repositories: [], folder: 'not_a_repository' };
35
+ } else {
36
+ const { repos, folder } = await find(owner.cwd);
37
+ body = repos.length ? { repositories: repos.map(repo => repo.key) } : { repositories: [], ...(folder ? { folder } : {}) };
38
+ }
24
39
  const response = await fetchImpl(`${owner.origin}/studio/v1/managed/workspace`, { method: 'POST', signal: AbortSignal.timeout(10_000),
25
40
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT }, body: JSON.stringify(body) });
26
- // A Studio without the route, or one refusing the body, would answer the same again.
27
- if (response.status === 400 || response.status === 404 || response.status === 405) { state.done = true; return; }
41
+ // A Studio without the route, or one refusing the body, would answer the
42
+ // same again, on this run and the next ones.
43
+ if (response.status === 400 || response.status === 404 || response.status === 405) { state.done = true; owner.workspaceUnsupported = true; return; }
28
44
  if (!response.ok) { retry(); return; }
29
45
  state.done = true;
30
46
  const answer = await response.json().catch(() => null);
47
+ const shown = v2 ? KINDS : V1_SHOWN, here = folderName(owner.cwd) ?? owner.cwd;
31
48
  const warnings = (Array.isArray(answer?.warnings) ? answer.warnings : [])
32
- .filter(warning => SHOWN.has(warning?.kind) && typeof warning.repo_key === 'string' && warning.repo_key)
33
- .slice(0, 6).map(warning => ({ repo: warning.repo_key.slice(0, 200), kind: warning.kind }));
49
+ .filter(warning => shown.has(warning?.kind) && (warning.kind === 'no_repository' || (typeof warning.repo_key === 'string' && warning.repo_key)))
50
+ .slice(0, 6).map(warning => {
51
+ const repo = warning.kind === 'no_repository' ? here : warning.repo_key;
52
+ const name = typeof warning.project_name === 'string' && warning.project_name.trim() ? warning.project_name.trim() : names.get(repo);
53
+ return { repo: repo.slice(0, 200), kind: warning.kind, ...(name && name !== repo ? { name: name.slice(0, 80) } : {}) };
54
+ });
34
55
  const team = typeof answer?.team === 'string' ? answer.team.slice(0, 80) : null;
35
56
  recordProjectWarnings(owner.session?.sessionManager, warnings, team, now);
36
57
  if (warnings.length) owner.notify?.({ type: 'managed_project_warning', warnings, team });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.39.0",
3
+ "version": "1.41.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -142,6 +142,10 @@ function walkRoot(root: DocumentRoot, budget: { remaining: number }, isProtected
142
142
  // an operator keeps documents, and reading all of it is what makes a listing
143
143
  // take seconds.
144
144
  if (entries.length > DOCUMENT_WORKSPACE_LIMITS.entriesPerDirectory) continue;
145
+ // A change report's shots/ folder holds screenshots and the capture
146
+ // bookkeeping (meta.json, regions.json). The report opens them itself, so
147
+ // listing them would only bury report.json among its own working files.
148
+ const changeReport = entries.some((entry) => entry.isFile() && entry.name === "report.json");
145
149
  for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
146
150
  if (budget.remaining <= 0) break;
147
151
  // A Dirent already reports a link as a link, so the isFile and isDirectory
@@ -151,7 +155,7 @@ function walkRoot(root: DocumentRoot, budget: { remaining: number }, isProtected
151
155
  const absolute = path.join(current.directory, entry.name);
152
156
  const relative = path.relative(root.path, absolute).split(path.sep).join("/");
153
157
  if (entry.isDirectory()) {
154
- if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name)) continue;
158
+ if (entry.name.startsWith(".") || SKIPPED_DIRECTORIES.has(entry.name) || (changeReport && entry.name === "shots")) continue;
155
159
  if (current.depth + 1 > DOCUMENT_WORKSPACE_LIMITS.depth) continue;
156
160
  if (isProtected(relative, absolute)) continue;
157
161
  queue.push({ directory: absolute, depth: current.depth + 1 });
@@ -223,39 +227,50 @@ export function projectDocumentWorkspaceListing(input: DocumentWorkspaceInput):
223
227
  return collectDocuments(input).listing;
224
228
  }
225
229
 
226
- export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput & { documentRef: string }): DocumentWorkspaceDocument {
227
- const now = (input.now ?? (() => new Date()))();
228
- const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
229
- messageType: "document" as const, generatedAt: timestamp(now), documentRef: input.documentRef };
230
- const unavailable = (reasonCode: string): DocumentWorkspaceDocument => ({ ...base, state: "unavailable", name: null, relativePath: null,
231
- rootRef: null, format: null, text: null, sizeBytes: null, truncated: false, redacted: false, reasonCode });
232
-
230
+ // A read names a document only by the ref a listing handed out, so every read
231
+ // goes through this: re-list, match, resolve, and re-prove the ref.
232
+ function locateListedDocument(input: DocumentWorkspaceInput, documentRef: string): { reasonCode: string }
233
+ | { entry: DocumentWorkspaceEntry; resolved: Extract<ReturnType<typeof resolveDocumentPath>, { status: "ok" }>;
234
+ isProtected: (relative: string, absolute: string) => boolean } {
233
235
  // The ref is matched against a listing taken now, so opening a document proves
234
236
  // it is still listable rather than trusting a ref the browser has been holding.
235
237
  const { listing, roots, paths } = collectDocuments(input);
236
- if (listing.state !== "ready") return unavailable(listing.reasonCode ?? "document-roots-unavailable");
237
- const entry = listing.documents.find((candidate) => candidate.documentRef === input.documentRef);
238
- const absolute = paths.get(input.documentRef);
239
- if (!entry || !absolute) return unavailable("document-not-listed");
238
+ if (listing.state !== "ready") return { reasonCode: listing.reasonCode ?? "document-roots-unavailable" };
239
+ const entry = listing.documents.find((candidate) => candidate.documentRef === documentRef);
240
+ const absolute = paths.get(documentRef);
241
+ if (!entry || !absolute) return { reasonCode: "document-not-listed" };
240
242
 
241
243
  // Resolution runs against the same roots the walk used, so the extension gate,
242
244
  // the size cap, the symlink canonicalisation and the containment check are the
243
245
  // ones the document tool applies — including the identity that extractDocument
244
246
  // re-proves when it opens the file.
245
247
  const resolved = resolveDocumentPath(absolute, roots, { cwd: input.cwd, home: input.home });
246
- if (resolved.status === "error") return unavailable("document-unavailable");
248
+ if (resolved.status === "error") return { reasonCode: "document-unavailable" };
247
249
  // The walk above already dropped protected documents, so a protected ref fails
248
250
  // to match a listing entry before it reaches here. Both of the next two checks
249
251
  // are the invariant stated rather than assumed: they hold the guarantee in
250
252
  // place if the listing filter is ever loosened or the ref scheme changes.
251
253
  const isProtected = protectedPredicate(input);
252
254
  const relative = path.relative(resolved.root.path, resolved.absolutePath).split(path.sep).join("/");
253
- if (isProtected(relative, resolved.absolutePath)) return unavailable("document-protected");
255
+ if (isProtected(relative, resolved.absolutePath)) return { reasonCode: "document-protected" };
254
256
  // Roots are canonical and the walk never traverses a link, so the listed path
255
257
  // is already the canonical one and this equality holds by construction. It is
256
258
  // re-derived anyway: the cost is a hash, and what it protects is the rule that
257
259
  // the browser can only ever name a document the listing chose to expose.
258
- if (ref("document", resolved.absolutePath) !== input.documentRef) return unavailable("document-changed");
260
+ if (ref("document", resolved.absolutePath) !== documentRef) return { reasonCode: "document-changed" };
261
+ return { entry, resolved, isProtected };
262
+ }
263
+
264
+ export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput & { documentRef: string }): DocumentWorkspaceDocument {
265
+ const now = (input.now ?? (() => new Date()))();
266
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-workspace-v1" as const,
267
+ messageType: "document" as const, generatedAt: timestamp(now), documentRef: input.documentRef };
268
+ const unavailable = (reasonCode: string): DocumentWorkspaceDocument => ({ ...base, state: "unavailable", name: null, relativePath: null,
269
+ rootRef: null, format: null, text: null, sizeBytes: null, truncated: false, redacted: false, reasonCode });
270
+
271
+ const located = locateListedDocument(input, input.documentRef);
272
+ if ("reasonCode" in located) return unavailable(located.reasonCode);
273
+ const { entry, resolved } = located;
259
274
 
260
275
  const extracted = extractDocument(resolved);
261
276
  if (extracted.status === "error") {
@@ -274,3 +289,51 @@ export function projectDocumentWorkspaceDocument(input: DocumentWorkspaceInput &
274
289
  format: extracted.kind, text: capped.text, sizeBytes: entry.sizeBytes,
275
290
  truncated: extracted.truncated || capped.truncated, redacted: safe.redacted, reasonCode: null };
276
291
  }
292
+
293
+ // Screenshots of a front-end change report. The report itself is a listed
294
+ // `report.json`; its images are never listed and are named only relative to it,
295
+ // so the browser still cannot point at an arbitrary file. Images cross as base64
296
+ // inside JSON: the read model, the session IPC and the company relay already
297
+ // carry JSON, and a PNG drawn into an <img> runs nothing.
298
+ export const DOCUMENT_ASSET_NAME = /^(before|after|diff)-[a-z0-9][a-z0-9-]{0,100}\.png$/;
299
+ const MAX_DOCUMENT_ASSET_BYTES = 8 * 1024 * 1024;
300
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
301
+
302
+ export type DocumentWorkspaceAsset = {
303
+ schemaVersion: 1;
304
+ version: "piagent-webui-document-asset-v1";
305
+ generatedAt: string;
306
+ documentRef: string;
307
+ // Null when the requested name was refused: a refusal never echoes it back.
308
+ name: string | null;
309
+ state: "ready" | "unavailable";
310
+ mediaType: "image/png" | null;
311
+ sizeBytes: number | null;
312
+ base64: string | null;
313
+ reasonCode: string | null;
314
+ };
315
+
316
+ export function projectDocumentWorkspaceAsset(input: DocumentWorkspaceInput & { documentRef: string; name: string }): DocumentWorkspaceAsset {
317
+ const base = { schemaVersion: 1 as const, version: "piagent-webui-document-asset-v1" as const,
318
+ generatedAt: timestamp((input.now ?? (() => new Date()))()), documentRef: input.documentRef,
319
+ name: DOCUMENT_ASSET_NAME.test(input.name) ? input.name : null };
320
+ const unavailable = (reasonCode: string): DocumentWorkspaceAsset => ({ ...base, state: "unavailable", mediaType: null, sizeBytes: null, base64: null, reasonCode });
321
+ if (base.name === null) return unavailable("document-asset-name-invalid");
322
+ const located = locateListedDocument(input, input.documentRef);
323
+ if ("reasonCode" in located) return unavailable(located.reasonCode);
324
+ if (path.basename(located.resolved.absolutePath) !== "report.json") return unavailable("document-asset-not-a-report");
325
+ const directory = path.join(path.dirname(located.resolved.absolutePath), "shots"), absolute = path.join(directory, input.name);
326
+ const relative = path.relative(located.resolved.root.path, absolute).split(path.sep).join("/");
327
+ if (located.isProtected(relative, absolute)) return unavailable("document-protected");
328
+ let bytes: Buffer;
329
+ try {
330
+ // lstat on both levels: a linked shots folder or image could lead out of the root.
331
+ if (!fs.lstatSync(directory).isDirectory()) return unavailable("document-asset-unavailable");
332
+ const stat = fs.lstatSync(absolute);
333
+ if (!stat.isFile()) return unavailable("document-asset-unavailable");
334
+ if (stat.size > MAX_DOCUMENT_ASSET_BYTES) return unavailable("document-asset-too-large");
335
+ bytes = fs.readFileSync(absolute);
336
+ } catch { return unavailable("document-asset-unavailable"); }
337
+ if (bytes.length > MAX_DOCUMENT_ASSET_BYTES || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) return unavailable("document-asset-unreadable");
338
+ return { ...base, state: "ready", mediaType: "image/png", sizeBytes: bytes.length, base64: bytes.toString("base64"), reasonCode: null };
339
+ }
@@ -0,0 +1,175 @@
1
+ ---
2
+ name: fe-change-report
3
+ description: "Use when a front-end task asks for a change report: before/after screenshots with numbered red frames, headless E2E, and what changed in business rules, logic, and UI/UX, delivered as one HTML file the dashboard shows."
4
+ ---
5
+
6
+ # Front-end change report
7
+
8
+ The report answers four questions for the person who asked for the change:
9
+ what the business now does differently, what logic changed, what the screen
10
+ looked like before and after, and whether the checks passed. Everything in it
11
+ must come from something you ran or read in this task. Never describe a screen
12
+ you did not capture or a check you did not run.
13
+
14
+ The scripts are in `scripts/` next to this file; resolve that path against
15
+ this skill's directory. They use the Playwright installed in the project under
16
+ test (`--project <dir>` when you run them from elsewhere), run Chromium
17
+ headless, and add no dependency. If the project has Playwright but no browser,
18
+ `npx playwright install chromium` downloads one: ask the member first.
19
+
20
+ ## 1. Scope
21
+
22
+ - Read the diff against the base (`git diff <base>...HEAD` plus uncommitted
23
+ work) and list the routes and states the change can show: pages, dialogs,
24
+ empty and error states, desktop and mobile. A change with no visible effect
25
+ gets a report without screenshots; say so in the summary.
26
+ - Pick a slug such as `2026-10-24-listing-seo` and work in
27
+ `reports/fe-change/<slug>/` at the project root. Unless the project already
28
+ tracks reports, add `reports/fe-change/` to `.git/info/exclude` so the
29
+ report never lands in a commit by accident.
30
+ - Write `reports/fe-change/<slug>/shots/shots.json`. Everything the capture
31
+ needs or produces stays in `shots/`, so the dashboard lists only the report:
32
+
33
+ ```json
34
+ {
35
+ "viewports": [{ "name": "desktop", "width": 1440, "height": 1000 }, { "name": "mobile", "width": 390, "height": 844 }],
36
+ "mask": ["[data-testid=clock]"],
37
+ "pages": [
38
+ { "key": "listings", "title": "Listings page", "path": "/listings", "clipHeight": 900 },
39
+ { "key": "detail", "title": "Listing detail", "path": "/listings/demo", "scroll": true },
40
+ { "key": "market", "title": "Market section", "path": "/", "selector": "[data-testid=market-data-section]" }
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Page options:
46
+
47
+ | Option | Effect |
48
+ |---|---|
49
+ | `fullPage` | Whole page, default `true` |
50
+ | `clipHeight` | Only the top N pixels |
51
+ | `selector` | One element only |
52
+ | `waitFor` | A selector that must appear before the shot |
53
+ | `scroll` | Scroll through the page first so lazy sections render |
54
+ | `settleMs` | Extra wait before the shot, default 600 |
55
+ | `mask` | Selectors painted grey on both sides |
56
+ | `viewports` | Subset of viewport names for this page |
57
+
58
+ Top-level `mask`, `locale`, `colorScheme`, `ignoreHTTPSErrors`, and
59
+ `storageState` (a Playwright login state file, path relative to where you run
60
+ the script) apply to every page. A viewport narrower than 768 px is emulated
61
+ as a touch phone unless it sets `isMobile`. Keys and viewport names are
62
+ lowercase letters, digits and dashes.
63
+
64
+ Mask anything that changes on its own (clocks, relative times, random ids,
65
+ carousels, ads). Otherwise it shows up as a change you did not make.
66
+
67
+ ## 2. Run both sides on the same data
68
+
69
+ - **Before:** `git worktree add <tmp>/fe-report-base <base>` and install there
70
+ the way the project installs (a lockfile install, never an upgrade).
71
+ - **After:** the current working tree.
72
+ - Start each one on its own free port in the background, with output going to
73
+ a log file. Wait until each URL answers before capturing. Prefer the
74
+ project's own production-like E2E setup (built app plus mock API or seed
75
+ data) so both sides show the same records. Screenshots of two different
76
+ data sets are not a comparison. If live data was unavoidable, say so in
77
+ `lede`.
78
+
79
+ Run the scripts from `reports/fe-change/<slug>/`. Below, `scripts/` stands for
80
+ the absolute path of this skill's `scripts/` folder; `shots/` and the report
81
+ files are relative to the report folder.
82
+
83
+ ```bash
84
+ node scripts/capture.mjs --config shots/shots.json --side before --base-url http://127.0.0.1:<before-port> --out shots
85
+ node scripts/capture.mjs --config shots/shots.json --side after --base-url http://127.0.0.1:<after-port> --out shots
86
+ node scripts/diff-regions.mjs --dir shots
87
+ ```
88
+
89
+ `capture.mjs` exits non-zero and names the shot when a page fails to load, a
90
+ `waitFor` never appears, or a screenshot is over the 8 MB the dashboard
91
+ shows. Fix the cause and capture again; do not report around a missing shot.
92
+
93
+ `diff-regions.mjs` writes two things:
94
+ - `shots/regions.json`: proposed boxes per page and viewport, in image pixels.
95
+ - `shots/diff-<page>-<viewport>.png`: the new screenshot with changed pixels in red.
96
+
97
+ It also names any page that exists on one side only.
98
+
99
+ **Look before you frame.** Open the before, after and diff images and decide
100
+ the boxes yourself:
101
+ - Merge boxes that belong to one change.
102
+ - Drop boxes caused only by content moving down. When a new block pushes the
103
+ rest of the page down, frame only the new block.
104
+ - Use separate `before`/`after` coordinates when the same element moved.
105
+ - Write one plain sentence per box saying what changed for the user.
106
+
107
+ ## 3. Verify
108
+
109
+ Run the project's own checks after the last code change, headless and in the
110
+ background with output to a log file: typecheck, lint, unit tests, and the
111
+ full E2E suite. Never use `--headed`, `--ui`, `--debug` or `PWDEBUG`.
112
+ - Record the exact command, the counts, and the duration.
113
+ - A test that also fails on the base worktree is a baseline failure: say so in
114
+ `detail`, and do not call the check passed.
115
+ - A check you could not run is `skipped` with the reason.
116
+
117
+ ## 4. Write report.json and build
118
+
119
+ ```json
120
+ {
121
+ "kind": "fe-change-report",
122
+ "title": "Listing SEO: before and after",
123
+ "lede": "Production build with E2E mock data, 1440 px.",
124
+ "base": "main@ecc3944f", "head": "feature/seo@1a2b3c4d", "lang": "vi", "viewports": ["desktop", "mobile"],
125
+ "summary": ["One line per user-visible outcome"],
126
+ "business": [{ "title": "Listing heading", "before": "3 listings · Buy", "after": "Homes for sale in {ward}", "impact": "Area keyword in H1" }],
127
+ "logic": [{ "title": "Same-area links", "before": "None", "after": "Up to 6 from the same ward, then province", "files": ["src/features/listings/logic/index.ts"] }],
128
+ "sections": [{ "key": "listings", "title": "Listings page", "note": "Optional context", "viewport": "desktop",
129
+ "boxes": [{ "x": 70, "y": 92, "w": 520, "h": 56, "text": "New heading and live count" }],
130
+ "otherBoxes": { "mobile": [{ "x": 10, "y": 98, "w": 268, "h": 44 }] } }],
131
+ "e2e": { "command": "npm run test:e2e", "result": "passed", "passed": 214, "failed": 0, "skipped": 3, "duration": "6m12s" },
132
+ "checks": [{ "name": "typecheck", "result": "passed" }, { "name": "unit", "result": "failed", "detail": "1 baseline failure, also on main" }],
133
+ "files": [{ "path": "src/app/sitemap.ts", "kind": "logic", "summary": "Listing sitemap" }],
134
+ "risks": ["What was not verified and why"]
135
+ }
136
+ ```
137
+
138
+ - `kind` must be `fe-change-report`: it is how the dashboard knows to draw
139
+ the file as a report.
140
+ - `viewports` lists the sizes captured, with the primary one first.
141
+ - `business` is what a user or the business sees differently.
142
+ - `logic` is the rule or data flow behind it.
143
+ - `files` covers every changed file with a short reason.
144
+ - `result` is `passed`, `failed` or `skipped`.
145
+ - A box uses the same coordinates on both images unless it has its own
146
+ `before` or `after` box (`{ "x", "y", "w", "h" }`).
147
+ - `lang` is `vi` (default) or `en`.
148
+
149
+ Then build:
150
+
151
+ ```bash
152
+ node scripts/build-report.mjs --spec report.json --dir shots --out report.html
153
+ ```
154
+
155
+ The builder refuses a box outside its image or a key that is not a plain
156
+ name, and says which section. `report.html` is self-contained, has no
157
+ scripts, and opens in any browser, so it can be sent to someone. Keep it under
158
+ 15 MB: clip long pages with `clipHeight` or `selector`.
159
+
160
+ Read the result before you report it:
161
+ - open `report.html`, or screenshot it with Playwright;
162
+ - check that every frame sits on the change it describes, on both sides.
163
+
164
+ ## 5. Finish
165
+
166
+ 1. Stop both servers, remove the base worktree
167
+ (`git worktree remove <tmp>/fe-report-base`), and leave
168
+ `reports/fe-change/<slug>/` in place.
169
+ 2. Reply with:
170
+ - the report path;
171
+ - where to see it: the Piagent dashboard draws it under Documents from
172
+ `reports/fe-change/<slug>/report.json`, with a full-screen view;
173
+ - one line per check;
174
+ - anything not verified.
175
+ 3. Do not commit the report unless asked.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // Builds one self-contained HTML report (no scripts, images embedded once) from
3
+ // report.json and the screenshots captured by capture.mjs.
4
+ // node build-report.mjs --spec report.json --dir <shots dir> --out report.html
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { pathToFileURL } from "node:url";
8
+ import { parseArgs, pngSize, SAFE_NAME } from "./capture.mjs";
9
+
10
+ export const REPORT_MARKER = "piagent-fe-change-report-v1";
11
+
12
+ const TEXT = {
13
+ vi: {
14
+ summary: "Tóm tắt", business: "Business thay đổi", logic: "Logic thay đổi", ui: "UI/UX cũ và mới", tests: "Kiểm thử",
15
+ files: "File đã đổi", risks: "Rủi ro và việc còn lại", item: "Mục", before: "Cũ", after: "Mới", impact: "Ảnh hưởng",
16
+ where: "File", zoom: "Phóng to theo số", other: "Khổ khác", pixels: "Pixel khác nhau (tô đỏ trên ảnh mới)",
17
+ check: "Kiểm tra", result: "Kết quả", detail: "Chi tiết", passed: "Đạt", failed: "Lỗi", skipped: "Bỏ qua",
18
+ generated: "Tạo lúc", missing: "Không có ảnh", e2e: "E2E", noChanges: "Không có thay đổi giao diện được ghi nhận."
19
+ },
20
+ en: {
21
+ summary: "Summary", business: "Business changes", logic: "Logic changes", ui: "UI/UX before and after", tests: "Verification",
22
+ files: "Changed files", risks: "Risks and follow-ups", item: "Item", before: "Before", after: "After", impact: "Impact",
23
+ where: "Files", zoom: "Zoom by number", other: "Other sizes", pixels: "Changed pixels (red on the new screenshot)",
24
+ check: "Check", result: "Result", detail: "Detail", passed: "Passed", failed: "Failed", skipped: "Skipped",
25
+ generated: "Generated", missing: "No screenshot", e2e: "E2E", noChanges: "No visual change was recorded."
26
+ }
27
+ };
28
+
29
+ const escape = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
30
+ const percent = (value) => `${Number(value.toFixed(3))}%`;
31
+ const list = (value) => (Array.isArray(value) ? value : value ? [value] : []);
32
+
33
+ function checkBox(box, image, where) {
34
+ for (const key of ["x", "y", "w", "h"]) {
35
+ if (!Number.isFinite(box[key]) || box[key] < 0) throw new Error(`${where}: box.${key} must be a non-negative number`);
36
+ }
37
+ if (box.w === 0 || box.h === 0) throw new Error(`${where}: box must have a width and height`);
38
+ // Clamp rather than fail: a box drawn on the taller "after" page may run past a shorter "before" page.
39
+ const x = Math.min(box.x, image.width - 1), y = Math.min(box.y, image.height - 1);
40
+ return { x, y, w: Math.min(box.w, image.width - x), h: Math.min(box.h, image.height - y) };
41
+ }
42
+
43
+ class Images {
44
+ #dir; #classes = new Map(); rules = [];
45
+ constructor(dir) { this.#dir = dir; }
46
+ get(name) {
47
+ const file = path.join(this.#dir, `${name}.png`);
48
+ if (!fs.existsSync(file)) return undefined;
49
+ if (!this.#classes.has(name)) {
50
+ const bytes = fs.readFileSync(file), id = `i${this.#classes.size}`;
51
+ this.#classes.set(name, { id, ...pngSize(bytes) });
52
+ this.rules.push(`.${id}{background-image:url(data:image/png;base64,${bytes.toString("base64")})}`);
53
+ }
54
+ return this.#classes.get(name);
55
+ }
56
+ }
57
+
58
+ function shot(image, boxes, label, t) {
59
+ if (!image) return `<figure><figcaption>${escape(label)}</figcaption><p class="missing">${t.missing}</p></figure>`;
60
+ const frames = boxes.map(({ n, box }) => `<span class="box" style="left:${percent(box.x / image.width * 100)};top:${percent(box.y / image.height * 100)};width:${percent(box.w / image.width * 100)};height:${percent(box.h / image.height * 100)}"><b>${n}</b></span>`).join("");
61
+ return `<figure><figcaption>${escape(label)}</figcaption><div class="shot ${image.id}" role="img" aria-label="${escape(label)}" style="aspect-ratio:${image.width}/${image.height}">${frames}</div></figure>`;
62
+ }
63
+
64
+ function crop(image, box, label) {
65
+ if (!image) return "";
66
+ const size = image.width / box.w * 100;
67
+ const left = image.width === box.w ? 0 : box.x / (image.width - box.w) * 100;
68
+ const top = image.height === box.h ? 0 : box.y / (image.height - box.h) * 100;
69
+ // Past about 2.5x a screenshot only gets blurrier, so a small box stays small.
70
+ return `<div class="zoom"><span class="zl">${escape(label)}</span><div class="crop ${image.id}" style="max-width:${Math.round(box.w * 2.5)}px;aspect-ratio:${box.w}/${box.h};background-size:${percent(size)} auto;background-position:${percent(left)} ${percent(top)}"></div></div>`;
71
+ }
72
+
73
+ function sectionHtml(section, images, viewports, t) {
74
+ const viewport = section.viewport ?? viewports[0];
75
+ // Keys and viewport names become file names; anything else could name a file outside the shots folder.
76
+ for (const name of [section.key, viewport, ...Object.keys(section.otherBoxes ?? {})]) {
77
+ if (!SAFE_NAME.test(String(name))) throw new Error(`section key and viewport names must match ${SAFE_NAME}: ${name}`);
78
+ }
79
+ const before = images.get(`before-${section.key}-${viewport}`), after = images.get(`after-${section.key}-${viewport}`);
80
+ const boxes = list(section.boxes).map((box, index) => {
81
+ const n = box.n ?? index + 1, where = `section ${section.key} box ${n}`;
82
+ return {
83
+ n, text: box.text,
84
+ before: before && checkBox(box.before ?? box, before, where),
85
+ after: after && checkBox(box.after ?? box, after, where)
86
+ };
87
+ });
88
+ const pair = shot(before, boxes.map(({ n, before: box }) => ({ n, box })).filter(({ box }) => box), `${t.before} · ${viewport}`, t)
89
+ + shot(after, boxes.map(({ n, after: box }) => ({ n, box })).filter(({ box }) => box), `${t.after} · ${viewport}`, t);
90
+ const zooms = boxes.map((box) => `<div class="zrow"><p class="zt"><b class="num">${box.n}</b> ${escape(box.text)}</p><div class="pair">${box.before ? crop(before, box.before, t.before) : ""}${box.after ? crop(after, box.after, t.after) : ""}</div></div>`).join("");
91
+ const others = viewports.filter((name) => name !== viewport).map((name) => {
92
+ const oldShot = images.get(`before-${section.key}-${name}`), newShot = images.get(`after-${section.key}-${name}`);
93
+ if (!oldShot && !newShot) return "";
94
+ const otherBoxes = list(section.otherBoxes?.[name]).map((box, index) => ({ n: box.n ?? index + 1, box }));
95
+ const fit = (image) => otherBoxes.filter(() => image).map(({ n, box }) => ({ n, box: checkBox(box, image, `section ${section.key} ${name}`) }));
96
+ return `<details><summary>${t.other}: ${escape(name)}</summary><div class="pair narrow">${shot(oldShot, fit(oldShot), `${t.before} · ${name}`, t)}${shot(newShot, fit(newShot), `${t.after} · ${name}`, t)}</div></details>`;
97
+ }).join("");
98
+ const overlay = images.get(`diff-${section.key}-${viewport}`);
99
+ const pixels = overlay ? `<details><summary>${t.pixels}</summary><div class="shot ${overlay.id}" role="img" aria-label="${escape(t.pixels)}" style="aspect-ratio:${overlay.width}/${overlay.height}"></div></details>` : "";
100
+ return `<section class="card"><h3>${escape(section.title ?? section.key)}</h3>${section.note ? `<p class="note">${escape(section.note)}</p>` : ""}<div class="pair">${pair}</div>${zooms ? `<h4>${t.zoom}</h4>${zooms}` : ""}${others}${pixels}</section>`;
101
+ }
102
+
103
+ function table(headers, rows) {
104
+ if (!rows.length) return "";
105
+ return `<div class="scroll"><table><thead><tr>${headers.map((header) => `<th>${escape(header)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
106
+ }
107
+
108
+ function badge(result, t) {
109
+ const kind = ["passed", "failed", "skipped"].includes(result) ? result : "skipped";
110
+ return `<span class="badge ${kind}">${escape(t[kind])}</span>`;
111
+ }
112
+
113
+ export const REPORT_KIND = "fe-change-report";
114
+
115
+ export function buildReport({ spec, dir }) {
116
+ // The dashboard draws a report.json only when it says what it is.
117
+ if (spec.kind !== REPORT_KIND) throw new Error(`report.json must have "kind": "${REPORT_KIND}"`);
118
+ const t = TEXT[spec.lang === "en" ? "en" : "vi"];
119
+ const meta = fs.existsSync(path.join(dir, "meta.json")) ? JSON.parse(fs.readFileSync(path.join(dir, "meta.json"), "utf8")) : { shots: {} };
120
+ const viewports = list(spec.viewports).length ? list(spec.viewports) : [...new Set(Object.values(meta.shots).map((entry) => entry.viewport))];
121
+ for (const name of viewports) if (!SAFE_NAME.test(String(name))) throw new Error(`viewport names must match ${SAFE_NAME}: ${name}`);
122
+ if (!viewports.length) viewports.push("desktop");
123
+ const images = new Images(dir);
124
+ const block = (title, body) => (body ? `<section class="block"><h2>${escape(title)}</h2>${body}</section>` : "");
125
+ const changeRows = (items, last) => list(items).map((item) => [`<b>${escape(item.title)}</b>`, escape(item.before), escape(item.after), last(item)]);
126
+ const tests = [
127
+ ...(spec.e2e ? [[escape(`${t.e2e}${spec.e2e.command ? ` · ${spec.e2e.command}` : ""}`), badge(spec.e2e.result, t),
128
+ escape([spec.e2e.passed !== undefined && `${spec.e2e.passed} ${t.passed.toLowerCase()}`, spec.e2e.failed && `${spec.e2e.failed} ${t.failed.toLowerCase()}`,
129
+ spec.e2e.skipped && `${spec.e2e.skipped} ${t.skipped.toLowerCase()}`, spec.e2e.duration, ...list(spec.e2e.notes)].filter(Boolean).join(" · "))]] : []),
130
+ ...list(spec.checks).map((check) => [escape(check.name), badge(check.result, t), escape(check.detail)])
131
+ ];
132
+ const files = list(spec.files).map((file) => [`<code>${escape(file.path)}</code>`, escape(file.kind), escape(file.summary)]);
133
+ const sections = list(spec.sections).map((section) => sectionHtml(section, images, viewports, t)).join("");
134
+ const chips = [spec.base && `${t.before}: ${spec.base}`, spec.head && `${t.after}: ${spec.head}`, `${t.generated}: ${spec.generatedAt ?? new Date().toISOString().slice(0, 16).replace("T", " ")}`]
135
+ .filter(Boolean).map((chip) => `<span class="chip">${escape(chip)}</span>`).join("");
136
+ const body = [
137
+ `<header><h1>${escape(spec.title ?? "Front-end change report")}</h1>${spec.lede ? `<p class="lede">${escape(spec.lede)}</p>` : ""}<div class="chips">${chips}</div></header>`,
138
+ block(t.summary, list(spec.summary).length ? `<ul>${list(spec.summary).map((line) => `<li>${escape(line)}</li>`).join("")}</ul>` : ""),
139
+ block(t.business, table([t.item, t.before, t.after, t.impact], changeRows(spec.business, (item) => escape(item.impact)))),
140
+ block(t.logic, table([t.item, t.before, t.after, t.where], changeRows(spec.logic, (item) => list(item.files).map((file) => `<code>${escape(file)}</code>`).join("<br>")))),
141
+ block(t.ui, sections || `<p class="note">${t.noChanges}</p>`),
142
+ block(t.tests, table([t.check, t.result, t.detail], tests)),
143
+ block(t.files, table([t.where, t.item, t.detail], files)),
144
+ block(t.risks, list(spec.risks).length ? `<ul>${list(spec.risks).map((line) => `<li>${escape(line)}</li>`).join("")}</ul>` : "")
145
+ ].join("");
146
+ return `<!doctype html>
147
+ <html lang="${spec.lang === "en" ? "en" : "vi"}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
148
+ <meta name="generator" content="${REPORT_MARKER}"><title>${escape(spec.title ?? "Front-end change report")}</title>
149
+ <style>${STYLE}${images.rules.join("")}</style></head><body><main>${body}</main></body></html>
150
+ `;
151
+ }
152
+
153
+ const STYLE = `
154
+ :root{--bg:#f6f7f5;--fg:#1b1f1d;--muted:#5d6762;--card:#fff;--line:#d9dfdb;--mark:#d4202a;--on-mark:#fff;--ok:#0b7a3e;--bad:#c62828;--skip:#8a6d00;
155
+ --sans:system-ui,-apple-system,"Segoe UI",sans-serif;color-scheme:light}
156
+ @media (prefers-color-scheme:dark){:root{--bg:#141816;--fg:#e8ece9;--muted:#9fa9a4;--card:#1d2320;--line:#323a36;--mark:#ff5a5f;--ok:#5ccf8f;--bad:#ff7a7a;--skip:#e0c25c;color-scheme:dark}}
157
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 var(--sans);padding:24px 16px 48px}
158
+ main{max-width:1180px;margin:0 auto;display:grid;gap:28px}h1{font-size:1.6rem;margin:0 0 6px}h2{font-size:1.2rem;margin:0 0 12px}
159
+ h3{font-size:1.05rem;margin:0 0 4px}h4{font-size:.78rem;margin:18px 0 8px;color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
160
+ .lede,.note{color:var(--muted);max-width:75ch;margin:0 0 12px}.chips{display:flex;flex-wrap:wrap;gap:6px}
161
+ .chip{border:1px solid var(--line);border-radius:999px;padding:2px 10px;font-size:.82rem;color:var(--muted)}
162
+ .block>ul{margin:0;padding-left:20px}.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
163
+ .pair{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,400px),1fr));gap:16px}.pair.narrow{grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),390px))}
164
+ figure{margin:0;min-width:0}figcaption{font-weight:700;margin-bottom:6px}.missing{color:var(--muted);border:1px dashed var(--line);border-radius:8px;padding:24px;text-align:center}
165
+ .shot{position:relative;width:100%;border:1px solid var(--line);border-radius:8px;background-size:100% auto;background-repeat:no-repeat}
166
+ .box{position:absolute;border:3px solid var(--mark);border-radius:4px}
167
+ .box b,.num{display:inline-grid;place-items:center;width:24px;height:24px;border-radius:50%;background:var(--mark);color:var(--on-mark);font-size:.8rem;line-height:1;flex:none}
168
+ .box b{position:absolute;top:-12px;left:-12px;box-shadow:0 0 0 2px var(--card)}.zrow{display:grid;gap:8px;margin-bottom:14px}.zt{margin:0;display:flex;gap:8px;align-items:center}
169
+ .zoom{min-width:0}.zl{font-size:.8rem;color:var(--muted)}.crop{width:100%;border:3px solid var(--mark);border-radius:6px;background-repeat:no-repeat}
170
+ details{margin-top:14px}summary{cursor:pointer;font-weight:600}details>.shot,details>.pair{margin-top:10px}
171
+ .scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
172
+ th,td{text-align:left;vertical-align:top;padding:8px 10px;border-bottom:1px solid var(--line)}th{font-size:.82rem;color:var(--muted)}
173
+ code{font-size:.85em;word-break:break-all}.badge{font-weight:700;font-size:.82rem}.badge.passed{color:var(--ok)}.badge.failed{color:var(--bad)}.badge.skipped{color:var(--skip)}
174
+ `.trim();
175
+
176
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
177
+ const options = parseArgs(process.argv.slice(2));
178
+ if (!options.spec || !options.dir || !options.out) { console.error("usage: build-report.mjs --spec report.json --dir <shots dir> --out report.html"); process.exit(2); }
179
+ const html = buildReport({ spec: JSON.parse(fs.readFileSync(options.spec, "utf8")), dir: options.dir });
180
+ fs.mkdirSync(path.dirname(path.resolve(options.out)), { recursive: true });
181
+ fs.writeFileSync(options.out, html);
182
+ console.log(`${options.out} ${Math.round(html.length / 1024)} KB`);
183
+ }