@piagent/platform 1.40.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 (86) hide show
  1. package/CHANGELOG.md +9 -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 +1 -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 +6 -0
  19. package/docs-site/content/index.html +4 -4
  20. package/docs-site/content/whats-new.html +6 -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 +7 -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 +7 -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/package.json +1 -1
  63. package/packages/piagent-core/runtime/inspection/document-workspace-projection.ts +78 -15
  64. package/packages/piagent-core/skills/fe-change-report/SKILL.md +175 -0
  65. package/packages/piagent-core/skills/fe-change-report/scripts/build-report.mjs +183 -0
  66. package/packages/piagent-core/skills/fe-change-report/scripts/capture.mjs +150 -0
  67. package/packages/piagent-core/skills/fe-change-report/scripts/diff-regions.mjs +110 -0
  68. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-BpoF2k2-.js → MarkdownMessage-DeLo-xiz.js} +1 -1
  69. package/packages/piagent-webui/dist/client/assets/{TerminalPanel-Dg3eghy4.js → TerminalPanel-BG-DP_qm.js} +2 -2
  70. package/packages/piagent-webui/dist/client/assets/{TerminalView-Dw9xqakv.js → TerminalView-r9rG24IO.js} +1 -1
  71. package/packages/piagent-webui/dist/client/assets/{WorkspaceCodeEditor-DtwXZAgK.js → WorkspaceCodeEditor-BnqaR3pK.js} +1 -1
  72. package/packages/piagent-webui/dist/client/assets/{WorkspaceExplorer-WS-yLlnb.js → WorkspaceExplorer-BIxwiGtl.js} +3 -3
  73. package/packages/piagent-webui/dist/client/assets/{WorkspaceQuickOpen-CYCY-s44.js → WorkspaceQuickOpen-CK3dILhQ.js} +1 -1
  74. package/packages/piagent-webui/dist/client/assets/index-DBUTrEXi.css +1 -0
  75. package/packages/piagent-webui/dist/client/assets/index-rlyRccut.js +102 -0
  76. package/packages/piagent-webui/dist/client/assets/{tab-close-CqPDs-w5.js → tab-close-BoJRyBO_.js} +1 -1
  77. package/packages/piagent-webui/dist/client/index.html +2 -2
  78. package/packages/piagent-webui/extension/piagent-webui.ts +1 -0
  79. package/packages/piagent-webui/server/core-inspection-provider.ts +5 -0
  80. package/packages/piagent-webui/server/ipc-read-model-client.ts +1 -0
  81. package/packages/piagent-webui/server/read-model-provider.ts +1 -0
  82. package/packages/piagent-webui/server/read-only-router.ts +6 -0
  83. package/schemas/piagent-webui/catalog-v1.json +9 -0
  84. package/schemas/piagent-webui/document-asset-v1.schema.json +57 -0
  85. package/packages/piagent-webui/dist/client/assets/index-BgJgso6j.js +0 -102
  86. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +0 -1
@@ -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
+ }
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // Headless screenshots of one side (before or after) of a front-end change.
3
+ // Playwright comes from the project under test, so the skill adds no dependency.
4
+ // node capture.mjs --config shots.json --side before --base-url http://127.0.0.1:3001 --out <dir>
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { createRequire } from "node:module";
8
+ import { pathToFileURL } from "node:url";
9
+
10
+ export function parseArgs(argv) {
11
+ const options = {};
12
+ for (let index = 0; index < argv.length; index += 1) {
13
+ const match = /^--([a-z-]+)$/.exec(argv[index]);
14
+ if (!match || argv[index + 1] === undefined) throw new Error(`unexpected argument: ${argv[index]}`);
15
+ options[match[1]] = argv[++index];
16
+ }
17
+ return options;
18
+ }
19
+
20
+ export async function loadChromium(projectDir) {
21
+ const require = createRequire(path.join(path.resolve(projectDir), "package.json"));
22
+ for (const name of ["playwright", "@playwright/test", "playwright-core"]) {
23
+ let module;
24
+ try { module = await import(pathToFileURL(require.resolve(name)).href); } catch { continue; }
25
+ // A CommonJS package imported from ESM keeps its exports under `default`.
26
+ const chromium = module.chromium ?? module.default?.chromium;
27
+ if (chromium) return chromium;
28
+ }
29
+ throw new Error(`Playwright was not found from ${projectDir}; install it in the project or pass --project <dir>`);
30
+ }
31
+
32
+ // Width and height sit at fixed offsets of the IHDR chunk that opens every PNG.
33
+ export function pngSize(bytes) {
34
+ if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) throw new Error("not a PNG file");
35
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
36
+ }
37
+
38
+ const DEFAULT_VIEWPORTS = [{ name: "desktop", width: 1440, height: 1000 }, { name: "mobile", width: 390, height: 844 }];
39
+ export const SAFE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
40
+ // The dashboard refuses a screenshot larger than this; say so at capture time.
41
+ export const MAX_SHOT_BYTES = 8 * 1024 * 1024;
42
+
43
+ export function readConfig(file) {
44
+ const config = JSON.parse(fs.readFileSync(file, "utf8"));
45
+ const viewports = config.viewports ?? DEFAULT_VIEWPORTS;
46
+ if (!Array.isArray(config.pages) || config.pages.length === 0) throw new Error("config.pages must list at least one page");
47
+ for (const entry of [...config.pages.map((page) => page.key), ...viewports.map((viewport) => viewport.name)]) {
48
+ if (!SAFE_NAME.test(String(entry))) throw new Error(`page keys and viewport names must match ${SAFE_NAME}: ${entry}`);
49
+ }
50
+ // A login state that is named but missing would silently capture the logged-out page on both sides.
51
+ if (config.storageState && !fs.existsSync(config.storageState)) throw new Error(`storageState not found: ${config.storageState}`);
52
+ return { ...config, viewports };
53
+ }
54
+
55
+ async function settle(page, entry) {
56
+ await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
57
+ if (entry.waitFor) await page.locator(entry.waitFor).first().waitFor({ timeout: 30_000 });
58
+ if (entry.scroll) {
59
+ // Lazy sections render only after they enter the viewport.
60
+ await page.evaluate(async () => {
61
+ for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight / 2) {
62
+ window.scrollTo(0, y); await new Promise((resolve) => setTimeout(resolve, 120));
63
+ }
64
+ window.scrollTo(0, 0);
65
+ });
66
+ }
67
+ await page.evaluate(() => document.fonts?.ready);
68
+ await page.waitForTimeout(entry.settleMs ?? 600);
69
+ }
70
+
71
+ // Where the masked elements sit in the screenshot. A masked clock is grey on
72
+ // both sides, but its width still follows the time it showed, so the diff has
73
+ // to skip these areas rather than compare two grey boxes of different sizes.
74
+ async function maskRects(page, mask, target) {
75
+ const scroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY }));
76
+ const origin = target ? await target.boundingBox() : { x: -scroll.x, y: -scroll.y };
77
+ const rects = [];
78
+ for (const locator of mask) {
79
+ for (const element of await locator.all()) {
80
+ const box = await element.boundingBox();
81
+ if (box && origin) rects.push({ x: Math.floor(box.x - origin.x), y: Math.floor(box.y - origin.y), w: Math.ceil(box.width), h: Math.ceil(box.height) });
82
+ }
83
+ }
84
+ return rects;
85
+ }
86
+
87
+ export async function capture({ config, side, baseUrl, out, chromium }) {
88
+ fs.mkdirSync(out, { recursive: true });
89
+ const metaFile = path.join(out, "meta.json");
90
+ const meta = fs.existsSync(metaFile) ? JSON.parse(fs.readFileSync(metaFile, "utf8")) : { shots: {} };
91
+ meta[`${side}BaseUrl`] = baseUrl;
92
+ const browser = await chromium.launch({ headless: true });
93
+ const failures = [];
94
+ try {
95
+ for (const viewport of config.viewports) {
96
+ const context = await browser.newContext({
97
+ viewport: { width: viewport.width, height: viewport.height }, deviceScaleFactor: 1,
98
+ // A phone-sized viewport without a touch device still renders desktop hover and meta-viewport behaviour.
99
+ isMobile: viewport.isMobile ?? viewport.width < 768, hasTouch: viewport.isMobile ?? viewport.width < 768,
100
+ reducedMotion: "reduce", locale: config.locale, colorScheme: config.colorScheme ?? "light",
101
+ ignoreHTTPSErrors: config.ignoreHTTPSErrors === true, storageState: config.storageState || undefined
102
+ });
103
+ for (const entry of config.pages) {
104
+ if (entry.viewports && !entry.viewports.includes(viewport.name)) continue;
105
+ const name = `${side}-${entry.key}-${viewport.name}`;
106
+ const page = await context.newPage();
107
+ try {
108
+ await page.goto(new URL(entry.path, baseUrl).href, { waitUntil: "load", timeout: 60_000 });
109
+ await settle(page, entry);
110
+ const file = path.join(out, `${name}.png`);
111
+ // Masked regions (clocks, random ids, ads) are painted the same colour on both sides, so they never count as a change.
112
+ const mask = [...(config.mask ?? []), ...(entry.mask ?? [])].map((selector) => page.locator(selector));
113
+ const shot = { path: file, animations: "disabled", caret: "hide", mask, maskColor: "#c8c8c8" };
114
+ const target = entry.selector ? page.locator(entry.selector).first() : null;
115
+ if (target) await target.scrollIntoViewIfNeeded();
116
+ const masks = await maskRects(page, mask, target);
117
+ if (target) await target.screenshot(shot);
118
+ else if (entry.clipHeight) {
119
+ // fullPage lets the clip reach below the first screen.
120
+ const height = Math.min(entry.clipHeight, await page.evaluate(() => document.documentElement.scrollHeight));
121
+ await page.screenshot({ ...shot, fullPage: true, clip: { x: 0, y: 0, width: viewport.width, height } });
122
+ } else await page.screenshot({ ...shot, fullPage: entry.fullPage !== false });
123
+ const bytes = fs.readFileSync(file), size = pngSize(bytes);
124
+ meta.shots[name] = { key: entry.key, title: entry.title ?? entry.key, viewport: viewport.name, url: page.url(), ...size, bytes: bytes.length, masks };
125
+ console.log(`captured ${name} ${size.width}x${size.height}`);
126
+ if (bytes.length > MAX_SHOT_BYTES) failures.push(`${name}: ${Math.round(bytes.length / 1048576)} MB is over the 8 MB the dashboard shows; use clipHeight or selector`);
127
+ } catch (error) {
128
+ failures.push(`${name}: ${error.message.split("\n")[0]}`);
129
+ } finally { await page.close(); }
130
+ }
131
+ await context.close();
132
+ }
133
+ } finally { await browser.close(); }
134
+ fs.writeFileSync(metaFile, `${JSON.stringify(meta, null, 2)}\n`);
135
+ return failures;
136
+ }
137
+
138
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
139
+ const options = parseArgs(process.argv.slice(2));
140
+ if (!options.config || !["before", "after"].includes(options.side) || !options["base-url"] || !options.out) {
141
+ console.error("usage: capture.mjs --config shots.json --side before|after --base-url <url> --out <dir> [--project <dir>]");
142
+ process.exit(2);
143
+ }
144
+ const failures = await capture({
145
+ config: readConfig(options.config), side: options.side, baseUrl: options["base-url"], out: options.out,
146
+ chromium: await loadChromium(options.project ?? process.cwd())
147
+ });
148
+ for (const failure of failures) console.error(`failed ${failure}`);
149
+ process.exit(failures.length ? 1 : 0);
150
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Finds where each before/after screenshot pair differs and proposes numbered
3
+ // red boxes. Pixels are compared on a canvas inside headless Chromium, so no
4
+ // image library is needed; grouping the changed cells into boxes runs here.
5
+ // node diff-regions.mjs --dir <shots dir> [--project <dir>] [--cell 8] [--gap 3]
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { loadChromium, parseArgs } from "./capture.mjs";
10
+
11
+ // cells: Uint8Array-like grid, 1 where the cell changed. Cells closer than
12
+ // `gap` cells join one box so a changed paragraph becomes one frame, not forty.
13
+ export function groupCells({ cells, columns, rows, cell, gap = 3, pad = 6, width, height, limit = 12 }) {
14
+ const seen = new Uint8Array(columns * rows), boxes = [];
15
+ for (let start = 0; start < cells.length; start += 1) {
16
+ if (!cells[start] || seen[start]) continue;
17
+ let minX = Infinity, minY = Infinity, maxX = -1, maxY = -1, count = 0;
18
+ const queue = [start]; seen[start] = 1;
19
+ while (queue.length) {
20
+ const index = queue.pop(), x = index % columns, y = Math.floor(index / columns);
21
+ count += 1; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
22
+ for (let dy = -gap; dy <= gap; dy += 1) {
23
+ for (let dx = -gap; dx <= gap; dx += 1) {
24
+ const nx = x + dx, ny = y + dy, next = ny * columns + nx;
25
+ if (nx < 0 || ny < 0 || nx >= columns || ny >= rows || seen[next] || !cells[next]) continue;
26
+ seen[next] = 1; queue.push(next);
27
+ }
28
+ }
29
+ }
30
+ const x = Math.max(0, minX * cell - pad), y = Math.max(0, minY * cell - pad);
31
+ boxes.push({ x, y, w: Math.min(width, (maxX + 1) * cell + pad) - x, h: Math.min(height, (maxY + 1) * cell + pad) - y, cells: count });
32
+ }
33
+ return boxes.sort((a, b) => b.cells - a.cells).slice(0, limit)
34
+ .sort((a, b) => a.y - b.y || a.x - b.x)
35
+ .map(({ cells: _cells, ...box }, index) => ({ n: index + 1, ...box }));
36
+ }
37
+
38
+ async function comparePair(page, beforeFile, afterFile, cell, masks) {
39
+ return page.evaluate(async ({ before, after, cell, masks }) => {
40
+ const masked = (x, y) => masks.some((rect) => x >= rect.x - 2 && x < rect.x + rect.w + 2 && y >= rect.y - 2 && y < rect.y + rect.h + 2);
41
+ const load = async (source) => {
42
+ const bitmap = await createImageBitmap(await (await fetch(source)).blob());
43
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height), context = canvas.getContext("2d");
44
+ context.drawImage(bitmap, 0, 0);
45
+ return { width: bitmap.width, height: bitmap.height, data: context.getImageData(0, 0, bitmap.width, bitmap.height).data, bitmap };
46
+ };
47
+ const [a, b] = await Promise.all([load(before), load(after)]);
48
+ const width = Math.max(a.width, b.width), height = Math.max(a.height, b.height);
49
+ const columns = Math.ceil(width / cell), rows = Math.ceil(height / cell), cells = new Array(columns * rows).fill(0);
50
+ const overlay = new OffscreenCanvas(b.width, b.height), draw = overlay.getContext("2d");
51
+ draw.globalAlpha = 0.35; draw.drawImage(b.bitmap, 0, 0); draw.globalAlpha = 1;
52
+ const mark = draw.createImageData(b.width, b.height);
53
+ let changed = 0;
54
+ for (let y = 0; y < height; y += 1) {
55
+ for (let x = 0; x < width; x += 1) {
56
+ const inA = x < a.width && y < a.height, inB = x < b.width && y < b.height;
57
+ let differs = inA !== inB;
58
+ if (inA && inB) {
59
+ const i = (y * a.width + x) * 4, j = (y * b.width + x) * 4;
60
+ differs = Math.max(Math.abs(a.data[i] - b.data[j]), Math.abs(a.data[i + 1] - b.data[j + 1]), Math.abs(a.data[i + 2] - b.data[j + 2])) > 40;
61
+ }
62
+ if (!differs || masked(x, y)) continue;
63
+ changed += 1; cells[Math.floor(y / cell) * columns + Math.floor(x / cell)] += 1;
64
+ if (inB) { const j = (y * b.width + x) * 4; mark.data[j] = 220; mark.data[j + 1] = 30; mark.data[j + 2] = 40; mark.data[j + 3] = 255; }
65
+ }
66
+ }
67
+ const markCanvas = new OffscreenCanvas(b.width, b.height); markCanvas.getContext("2d").putImageData(mark, 0, 0);
68
+ draw.drawImage(markCanvas, 0, 0);
69
+ const blob = await overlay.convertToBlob({ type: "image/png" });
70
+ const bytes = new Uint8Array(await blob.arrayBuffer());
71
+ let binary = ""; for (let i = 0; i < bytes.length; i += 32_768) binary += String.fromCharCode(...bytes.subarray(i, i + 32_768));
72
+ // A cell counts once a few pixels changed, so antialiasing noise stays out.
73
+ return { width, height, columns, rows, changed, total: width * height, cells: cells.map((count) => (count > 3 ? 1 : 0)), overlay: btoa(binary) };
74
+ }, { before: dataUrl(beforeFile), after: dataUrl(afterFile), cell, masks });
75
+ }
76
+
77
+ const dataUrl = (file) => `data:image/png;base64,${fs.readFileSync(file).toString("base64")}`;
78
+
79
+ export async function diffRegions({ dir, chromium, cell = 8, gap = 3 }) {
80
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, "meta.json"), "utf8"));
81
+ const names = Object.keys(meta.shots);
82
+ const pairs = names.filter((name) => name.startsWith("after-")).map((name) => name.slice("after-".length)).filter((name) => meta.shots[`before-${name}`]);
83
+ // A page captured on one side only is a new or removed page, or a failed capture: say which shots have no partner.
84
+ for (const name of names) {
85
+ const [side, rest] = [name.slice(0, name.indexOf("-")), name.slice(name.indexOf("-") + 1)];
86
+ if (!pairs.includes(rest)) console.log(`${rest}: only the ${side} screenshot exists, nothing to compare`);
87
+ }
88
+ const browser = await chromium.launch({ headless: true });
89
+ const regions = {};
90
+ try {
91
+ const page = await browser.newPage();
92
+ for (const name of pairs) {
93
+ const masks = [...(meta.shots[`before-${name}`].masks ?? []), ...(meta.shots[`after-${name}`].masks ?? [])];
94
+ const result = await comparePair(page, path.join(dir, `before-${name}.png`), path.join(dir, `after-${name}.png`), cell, masks);
95
+ fs.writeFileSync(path.join(dir, `diff-${name}.png`), Buffer.from(result.overlay, "base64"));
96
+ const boxes = groupCells({ ...result, cell, gap });
97
+ regions[name] = { changedRatio: Number((result.changed / result.total).toFixed(5)), boxes };
98
+ console.log(`${name}: ${(regions[name].changedRatio * 100).toFixed(2)}% changed, ${boxes.length} box(es)`);
99
+ }
100
+ } finally { await browser.close(); }
101
+ fs.writeFileSync(path.join(dir, "regions.json"), `${JSON.stringify(regions, null, 2)}\n`);
102
+ return regions;
103
+ }
104
+
105
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
106
+ const options = parseArgs(process.argv.slice(2));
107
+ if (!options.dir) { console.error("usage: diff-regions.mjs --dir <shots dir> [--project <dir>] [--cell 8] [--gap 3]"); process.exit(2); }
108
+ await diffRegions({ dir: options.dir, chromium: await loadChromium(options.project ?? process.cwd()),
109
+ cell: Number(options.cell ?? 8), gap: Number(options.gap ?? 3) });
110
+ }