@piagent/platform 1.40.0 → 1.42.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/CHANGELOG.md +1012 -2035
- package/CONTRIBUTING.md +2 -0
- package/README.md +128 -325
- package/README.vi.md +149 -143
- package/architecture/layers.json +10 -0
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/en/architecture.md +1 -0
- package/docs/languages.json +4 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +3 -3
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/architecture.md +1 -0
- package/docs-site/architecture.html +71 -63
- package/docs-site/assets/docs.css +2736 -1030
- package/docs-site/assets/docs.js +451 -43
- package/docs-site/assets/og.png +0 -0
- package/docs-site/assets/piagent-logo.svg +9 -31
- package/docs-site/assets/search-en.json +1 -0
- package/docs-site/assets/search-vi.json +1 -0
- package/docs-site/benchmark.html +71 -63
- package/docs-site/commands.html +72 -64
- package/docs-site/content/en/index.html +220 -131
- package/docs-site/content/en/quickstart.html +74 -90
- package/docs-site/content/en/update.html +80 -0
- package/docs-site/content/en/whats-new.html +1 -678
- package/docs-site/content/index.html +220 -131
- package/docs-site/content/quickstart.html +73 -108
- package/docs-site/content/update.html +80 -0
- package/docs-site/content/whats-new.html +1 -678
- package/docs-site/context-engine.html +73 -65
- package/docs-site/ecosystem.html +73 -65
- package/docs-site/en/architecture.html +67 -59
- package/docs-site/en/benchmark.html +67 -59
- package/docs-site/en/commands.html +68 -60
- package/docs-site/en/context-engine.html +68 -60
- package/docs-site/en/ecosystem.html +68 -60
- package/docs-site/en/index.html +281 -220
- package/docs-site/en/mcp-approval.html +67 -59
- package/docs-site/en/mcp-auth.html +67 -59
- package/docs-site/en/mcp-servers.html +68 -60
- package/docs-site/en/mcp.html +64 -56
- package/docs-site/en/permissions.html +68 -60
- package/docs-site/en/profiles.html +68 -60
- package/docs-site/en/quickstart.html +142 -150
- package/docs-site/en/runtime.html +67 -59
- package/docs-site/en/scope.html +68 -60
- package/docs-site/en/security.html +64 -56
- package/docs-site/en/team.html +67 -59
- package/docs-site/en/update.html +228 -0
- package/docs-site/en/whats-new.html +1601 -719
- package/docs-site/en/windows.html +71 -63
- package/docs-site/en/workflows.html +68 -60
- package/docs-site/favicon.svg +9 -14
- package/docs-site/index.html +281 -220
- package/docs-site/mcp-approval.html +70 -62
- package/docs-site/mcp-auth.html +70 -62
- package/docs-site/mcp-servers.html +71 -63
- package/docs-site/mcp.html +70 -62
- package/docs-site/permissions.html +72 -64
- package/docs-site/profiles.html +72 -64
- package/docs-site/quickstart.html +145 -172
- package/docs-site/releases.json +4449 -0
- package/docs-site/runtime.html +73 -65
- package/docs-site/scope.html +73 -65
- package/docs-site/security.html +73 -65
- package/docs-site/team.html +71 -63
- package/docs-site/update.html +228 -0
- package/docs-site/whats-new.html +1605 -723
- package/docs-site/windows.html +75 -67
- package/docs-site/workflows.html +73 -65
- package/evals/fixtures/piagent-webui/document-asset-v1.invalid.json +12 -0
- package/evals/fixtures/piagent-webui/document-asset-v1.valid.json +12 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +78 -15
- package/packages/piagent-core/skills/fe-change-report/SKILL.md +175 -0
- package/packages/piagent-core/skills/fe-change-report/scripts/build-report.mjs +183 -0
- package/packages/piagent-core/skills/fe-change-report/scripts/capture.mjs +150 -0
- package/packages/piagent-core/skills/fe-change-report/scripts/diff-regions.mjs +110 -0
- package/packages/piagent-webui/dist/client/assets/App-DBx4BU3F.js +5 -0
- package/packages/piagent-webui/dist/client/assets/LineReview-DzonwD-g.js +3 -0
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BpoF2k2-.js → MarkdownMessage-DchODuO7.js} +2 -2
- package/packages/piagent-webui/dist/client/assets/SessionInspectorDrawer-BHHNlct3.js +1 -0
- package/packages/piagent-webui/dist/client/assets/SettingsPage-DX28MsZA.js +1 -0
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-C--BiN0p.js +2 -0
- package/packages/piagent-webui/dist/client/assets/{TerminalView-Dw9xqakv.js → TerminalView-BML7uS6R.js} +3 -3
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-B7ieqDnt.js +2 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-BOafuPU-.js +5 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-CGoGfUPk.js +1 -0
- package/packages/piagent-webui/dist/client/assets/api-BUjrtT1B.js +1 -0
- package/packages/piagent-webui/dist/client/assets/index-C7Azrwpq.js +132 -0
- package/packages/piagent-webui/dist/client/assets/index-Dqb2hNan.css +1 -0
- package/packages/piagent-webui/dist/client/assets/tab-close-Crj8vsUq.js +1 -0
- package/packages/piagent-webui/dist/client/assets/ui-preferences-B3UfDp-5.js +1 -0
- package/packages/piagent-webui/dist/client/assets/vendor-Ch5hIe0W.js +136 -0
- package/packages/piagent-webui/dist/client/index.html +5 -8
- package/packages/piagent-webui/extension/piagent-webui.ts +1 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +5 -0
- package/packages/piagent-webui/server/ipc-read-model-client.ts +1 -0
- package/packages/piagent-webui/server/read-model-provider.ts +1 -0
- package/packages/piagent-webui/server/read-only-router.ts +6 -0
- package/schemas/piagent-webui/catalog-v1.json +9 -0
- package/schemas/piagent-webui/document-asset-v1.schema.json +57 -0
- package/scripts/build-docs-site.mjs +205 -348
- package/scripts/check-public-wording.mjs +2 -1
- package/scripts/docs-site-icons.mjs +84 -0
- package/scripts/docs-site-nav.mjs +302 -0
- package/scripts/docs-site-releases.mjs +227 -0
- package/packages/piagent-webui/dist/client/assets/Alert-DGWhP2MM.js +0 -1
- package/packages/piagent-webui/dist/client/assets/Button-QItfZUNj.js +0 -1
- package/packages/piagent-webui/dist/client/assets/DialogTitle-CyaFPaiW.js +0 -3
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-Dg3eghy4.js +0 -2
- package/packages/piagent-webui/dist/client/assets/Typography-B9om7Evq.js +0 -1
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-DtwXZAgK.js +0 -2
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-WS-yLlnb.js +0 -5
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-CYCY-s44.js +0 -1
- package/packages/piagent-webui/dist/client/assets/index-BgJgso6j.js +0 -102
- package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +0 -1
- package/packages/piagent-webui/dist/client/assets/tab-close-CqPDs-w5.js +0 -1
- package/packages/piagent-webui/dist/client/assets/ui-preferences-BO3AV7QE.js +0 -10
- package/packages/piagent-webui/dist/client/assets/useId-DEefKUAT.js +0 -124
|
@@ -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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
237
|
-
const entry = listing.documents.find((candidate) => candidate.documentRef ===
|
|
238
|
-
const absolute = paths.get(
|
|
239
|
-
if (!entry || !absolute) return
|
|
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
|
|
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
|
|
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) !==
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|