@penpair/web 0.2.0 → 0.3.1

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 (63) hide show
  1. package/README.md +18 -3
  2. package/docs/adapters.md +90 -0
  3. package/docs/build-kit.md +4 -2
  4. package/docs/conventions/README.md +63 -0
  5. package/docs/conventions/completion-2026-10-22.md +73 -0
  6. package/docs/conventions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  7. package/docs/conventions/plugin-code.md +23 -0
  8. package/docs/conventions/structure.md +48 -0
  9. package/docs/conventions/templates/adapter-command.ts +28 -0
  10. package/docs/conventions/templates/conventions.test.ts +81 -0
  11. package/docs/conventions/templates/frame-inspection.ts +27 -0
  12. package/docs/conventions/templates/frame-label.ts +15 -0
  13. package/docs/conventions/tsconfig.json +13 -0
  14. package/docs/conventions/typescript.md +59 -0
  15. package/docs/conventions/vitest.config.ts +11 -0
  16. package/docs/design-workflow.md +3 -1
  17. package/docs/fixtures/browser-capture-2026-10-22.json +217 -0
  18. package/docs/fixtures/feedback-reproductions.md +12 -0
  19. package/docs/fixtures/feedback-scratch.template.json +9 -0
  20. package/docs/formats.md +2 -0
  21. package/docs/getting-started.md +2 -0
  22. package/docs/guide.md +2 -2
  23. package/docs/handoff.md +2 -0
  24. package/docs/marwes-tokens.md +13 -0
  25. package/docs/marwes.md +2 -0
  26. package/docs/plans/registry-polling-fix.md +101 -0
  27. package/docs/plans/skill-workflow-readiness.md +178 -0
  28. package/docs/plans/tradient-feedback-0.2.0.md +209 -0
  29. package/docs/plans/tradient-feedback-review.md +83 -0
  30. package/docs/recovery.md +8 -0
  31. package/docs/releasing.md +34 -4
  32. package/docs/skill-installation.md +21 -0
  33. package/docs/skill-validation.md +86 -0
  34. package/docs/snapshots.md +1 -1
  35. package/docs/theme-previews.md +18 -0
  36. package/examples/component-pilot/renderer.mjs +100 -0
  37. package/examples/coverage-matrix.json +126 -0
  38. package/examples/hello-card/README.md +18 -0
  39. package/examples/hello-card/build.js +24 -0
  40. package/examples/hello-card/helpers.js +27 -0
  41. package/examples/hello-card/inspect.js +4 -0
  42. package/examples/hello-card/readback.js +3 -0
  43. package/examples/reflection-browser/README.md +49 -0
  44. package/examples/reflection-browser/capture.mjs +311 -0
  45. package/examples/reflection-browser/fixture/Abel-Regular.ttf +0 -0
  46. package/examples/reflection-browser/fixture/OFL.txt +93 -0
  47. package/examples/reflection-browser/fixture/index.html +11 -0
  48. package/examples/reflection-browser/fixture-hooks.mjs +24 -0
  49. package/examples/reflection-browser/provider.mjs +90 -0
  50. package/examples/reflection-browser/serve.mjs +37 -0
  51. package/examples/theme-preview/README.md +21 -0
  52. package/examples/theme-preview/apply.js +8 -0
  53. package/examples/theme-preview/helpers.js +12 -0
  54. package/examples/theme-preview/inspect.js +3 -0
  55. package/examples/theme-preview/readback.js +7 -0
  56. package/examples/trials/hold-inspect.js +5 -0
  57. package/examples/trials/hold-readback.js +8 -0
  58. package/examples/trials/hold-writer.js +16 -0
  59. package/examples/trials/recovery-build.js +23 -0
  60. package/examples/trials/recovery-inspect.js +10 -0
  61. package/examples/trials/recovery-readback.js +7 -0
  62. package/examples/trials/wrong-file-read.js +3 -0
  63. package/package.json +3 -2
@@ -0,0 +1,100 @@
1
+ // Copy into the adopting project; Playwright and its browser are optional project dependencies.
2
+ import { chromium } from "playwright";
3
+ const properties = [
4
+ "width",
5
+ "height",
6
+ "paddingTop",
7
+ "paddingRight",
8
+ "paddingBottom",
9
+ "paddingLeft",
10
+ "rowGap",
11
+ "columnGap",
12
+ "borderRadius",
13
+ "borderWidth",
14
+ "borderColor",
15
+ "backgroundColor",
16
+ "color",
17
+ "fontFamily",
18
+ "fontSize",
19
+ "fontWeight",
20
+ "lineHeight",
21
+ "boxShadow",
22
+ ];
23
+ export async function measure(cases) {
24
+ const browser = await chromium.launch();
25
+ try {
26
+ const measurements = [];
27
+ for (const c of cases) {
28
+ const context = await browser.newContext({
29
+ viewport: c.viewport,
30
+ reducedMotion: "reduce",
31
+ });
32
+ try {
33
+ const page = await context.newPage();
34
+ // The project fixture must render the real pinned components with its actual theme.
35
+ // It must expose the observed context and ready marker AFTER activating the requested state.
36
+ const url = new URL("http://127.0.0.1:4173/component-pilot");
37
+ url.searchParams.set("theme", c.theme);
38
+ url.searchParams.set("state", c.state);
39
+ await page.goto(url.href, {
40
+ waitUntil: "networkidle",
41
+ timeout: 15000,
42
+ });
43
+ await page.locator('[data-pilot-ready="true"]').waitFor({
44
+ timeout: 10000,
45
+ });
46
+ const observed = await page.evaluate(
47
+ async ({ c, properties }) => {
48
+ await document.fonts.ready;
49
+ const root = document.querySelector(c.selector);
50
+ if (!root || document.querySelectorAll(c.selector).length !== 1) {
51
+ throw new Error("Fixture selector missing or ambiguous");
52
+ }
53
+ if (
54
+ root.getAttribute("data-pilot-theme") !== c.theme ||
55
+ root.getAttribute("data-pilot-state") !== c.state
56
+ ) {
57
+ throw new Error("Fixture context was not activated");
58
+ }
59
+ const nodes = [
60
+ root,
61
+ ...root.querySelectorAll("[data-pilot-part]"),
62
+ ];
63
+ const parts = Object.create(null);
64
+ for (const node of nodes) {
65
+ const name = node === root ? "root" : node.getAttribute("data-pilot-part");
66
+ if (!name || parts[name]) {
67
+ throw new Error("Duplicate anatomy part");
68
+ }
69
+ const style = getComputedStyle(node);
70
+ parts[name] = Object.fromEntries(
71
+ properties.map((key) => [
72
+ key,
73
+ style[key] || null,
74
+ ]),
75
+ );
76
+ }
77
+ return {
78
+ id: c.id,
79
+ parts,
80
+ environment: {
81
+ browser: navigator.userAgent,
82
+ fontsReady: document.fonts.status === "loaded",
83
+ },
84
+ };
85
+ },
86
+ {
87
+ c,
88
+ properties,
89
+ },
90
+ );
91
+ measurements.push(observed);
92
+ } finally {
93
+ await context.close();
94
+ }
95
+ }
96
+ return measurements;
97
+ } finally {
98
+ await browser.close();
99
+ }
100
+ }
@@ -0,0 +1,126 @@
1
+ {
2
+ "baselines": {
3
+ "page": "Candidate",
4
+ "root": "design/baselines",
5
+ "frames": [
6
+ {
7
+ "frame": "Home / Default / Desktop / Light",
8
+ "id": "home-desktop-light",
9
+ "route": "home",
10
+ "case": {
11
+ "viewport": {
12
+ "width": 1440,
13
+ "height": 900
14
+ },
15
+ "theme": "light",
16
+ "state": "default",
17
+ "fixture": "home-populated-v1",
18
+ "fonts": [
19
+ "Your pinned font family and weight"
20
+ ]
21
+ }
22
+ },
23
+ {
24
+ "frame": "Home / Default / Mobile / Light",
25
+ "id": "home-mobile-light",
26
+ "route": "home",
27
+ "case": {
28
+ "viewport": {
29
+ "width": 390,
30
+ "height": 844
31
+ },
32
+ "theme": "light",
33
+ "state": "default",
34
+ "fixture": "home-populated-v1",
35
+ "fonts": [
36
+ "Your pinned font family and weight"
37
+ ]
38
+ }
39
+ },
40
+ {
41
+ "frame": "Home / Empty / Mobile / Light",
42
+ "id": "home-mobile-empty",
43
+ "route": "home",
44
+ "case": {
45
+ "viewport": {
46
+ "width": 390,
47
+ "height": 844
48
+ },
49
+ "theme": "light",
50
+ "state": "empty",
51
+ "fixture": "home-empty-v1",
52
+ "fonts": [
53
+ "Your pinned font family and weight"
54
+ ]
55
+ }
56
+ }
57
+ ],
58
+ "coverage": {
59
+ "required": [
60
+ {
61
+ "route": "home",
62
+ "viewport": {
63
+ "width": 1440,
64
+ "height": 900
65
+ },
66
+ "theme": "light",
67
+ "state": "default",
68
+ "fixture": "home-populated-v1"
69
+ },
70
+ {
71
+ "route": "home",
72
+ "viewport": {
73
+ "width": 1440,
74
+ "height": 900
75
+ },
76
+ "theme": "dark",
77
+ "state": "default",
78
+ "fixture": "home-populated-v1"
79
+ },
80
+ {
81
+ "route": "home",
82
+ "viewport": {
83
+ "width": 390,
84
+ "height": 844
85
+ },
86
+ "theme": "light",
87
+ "state": "default",
88
+ "fixture": "home-populated-v1"
89
+ },
90
+ {
91
+ "route": "home",
92
+ "viewport": {
93
+ "width": 390,
94
+ "height": 844
95
+ },
96
+ "theme": "light",
97
+ "state": "empty",
98
+ "fixture": "home-empty-v1"
99
+ },
100
+ {
101
+ "route": "home",
102
+ "viewport": {
103
+ "width": 390,
104
+ "height": 844
105
+ },
106
+ "theme": "dark",
107
+ "state": "empty",
108
+ "fixture": "home-empty-v1"
109
+ }
110
+ ],
111
+ "unsupported": [
112
+ {
113
+ "route": "home",
114
+ "viewport": {
115
+ "width": 390,
116
+ "height": 844
117
+ },
118
+ "theme": "dark",
119
+ "state": "empty",
120
+ "fixture": "home-empty-v1",
121
+ "reason": "This case still needs a reviewed design and deterministic fixture"
122
+ }
123
+ ]
124
+ }
125
+ }
126
+ }
@@ -0,0 +1,18 @@
1
+ # Hello, card
2
+
3
+ Copy these four JavaScript bodies into the configured scratch project. Use existing active typography, color, spacing and radius token names. The files create one named root, record an explicit ownership marker, bind its card/text and independently read the result. Repeating a completed build preserves IDs. A changed, renamed, ambiguous or differently owned example refuses a fresh build rather than deleting content.
4
+
5
+ From that project's root (adjust the copied paths):
6
+
7
+ ```sh
8
+ penpair run-file examples/hello-card/build.js \
9
+ --include @kit --include examples/hello-card/helpers.js \
10
+ --inspect examples/hello-card/inspect.js --readback examples/hello-card/readback.js \
11
+ --page Candidate --param name=HelloExample --param marker=hello-example-1 \
12
+ --param typo=YOUR_TYPOGRAPHY_TOKEN --param color=YOUR_COLOR_TOKEN \
13
+ --param space=YOUR_SPACING_TOKEN --param radius=YOUR_RADIUS_TOKEN
14
+ ```
15
+
16
+ Review inspection, then repeat with `--apply`. The marker is ownership bookkeeping, not authorization or authentication. Save the returned `rootId`; export it twice and look at the resulting image. Independent token/structure verification does not approve visual parity. Use a separate reviewed operation for later edits or removal.
17
+
18
+ For a temporary example that is built, exported and removed automatically, use the opt-in `probe --deep --kit --write` on an authorized scratch file. Read [kit contracts and limits](../../docs/build-kit.md).
@@ -0,0 +1,24 @@
1
+ // penpair: write
2
+ // penpair: idempotent
3
+ const observation = inspectHelloExample();
4
+ if (!CFG.inspection || JSON.stringify(observation.rootIds) !== JSON.stringify(CFG.inspection.rootIds)) throw new Error("Page source changed before creating the example");
5
+ if (observation.satisfied) return { ok: true, alreadyBuilt: true, rootId: observation.id };
6
+ if (observation.id) throw new Error("Existing example needs a separate reviewed edit");
7
+ let root = null, built = false;
8
+ try {
9
+ root = penpot.createBoard();
10
+ root.name = CFG.name;
11
+ root.setSharedPluginData("penpair", "example", CFG.marker);
12
+ root.fills = [];
13
+ root.resize(300, 180);
14
+ pp.recipe("hello-card", root, {
15
+ box: { name: "Hello card", w: 240, gap: CFG.space, pad: [CFG.space, CFG.space, CFG.space, CFG.space], stroke: CFG.color, radius: CFG.radius },
16
+ text: { typo: CFG.typo, color: CFG.color },
17
+ label: "Hello, card",
18
+ });
19
+ await pp.flush({ root });
20
+ built = true;
21
+ return { ok: true, rootId: root.id };
22
+ } finally {
23
+ if (!built && root) root.remove();
24
+ }
@@ -0,0 +1,27 @@
1
+ // penpair: read
2
+ function inspectHelloExample() {
3
+ for (const key of ["name", "marker", "typo", "color", "space", "radius"]) {
4
+ if (typeof CFG[key] !== "string" || !CFG[key]) throw new Error("Required example parameter: " + key);
5
+ }
6
+ const roots = penpot.currentPage.root.children.filter(shape => shape.name === CFG.name);
7
+ if (roots.length > 1) throw new Error("Example root name is ambiguous");
8
+ const expected = { name: CFG.name, marker: CFG.marker, title: "Hello, card", typo: CFG.typo, color: CFG.color, space: CFG.space, radius: CFG.radius, count: 3 };
9
+ const root = roots[0];
10
+ const owned = penpot.currentPage.root.children.filter(shape => typeof shape.getSharedPluginData === "function" && shape.getSharedPluginData("penpair", "example") === CFG.marker);
11
+ if (owned.length > 1 || owned.length === 1 && owned[0] !== root) throw new Error("The owned marker moved or became ambiguous; inspect it before creating another example");
12
+ if (!root) return { expected, actual: { missing: true }, id: null, satisfied: false, rootIds: penpot.currentPage.root.children.map(shape => shape.id).sort() };
13
+ if (typeof root.getSharedPluginData !== "function" || root.getSharedPluginData("penpair", "example") !== CFG.marker) throw new Error("Existing root is not owned by this example marker");
14
+ const nodes = [], stack = [root], seen = new Set();
15
+ while (stack.length) {
16
+ if (nodes.length >= 20) throw new Error("Example subtree is unexpectedly large");
17
+ const shape = stack.pop();
18
+ if (!shape.id || seen.has(shape.id)) throw new Error("Invalid example identity");
19
+ seen.add(shape.id); nodes.push(shape);
20
+ for (const child of shape.children || []) stack.push(child);
21
+ }
22
+ const cards = nodes.filter(shape => shape.type === "board" && shape.name === "Hello card");
23
+ const texts = nodes.filter(shape => shape.type === "text");
24
+ const card = cards.length === 1 ? cards[0] : null, text = texts.length === 1 ? texts[0] : null;
25
+ const actual = { name: root.name, marker: root.getSharedPluginData("penpair", "example"), title: text?.characters ?? null, typo: text?.tokens?.typography ?? null, color: text?.tokens?.fill ?? null, space: card?.tokens?.rowGap ?? null, radius: card?.tokens?.borderRadiusTopLeft ?? null, count: nodes.length };
26
+ return { expected, actual, id: root.id, satisfied: JSON.stringify(expected) === JSON.stringify(actual), rootIds: penpot.currentPage.root.children.map(shape => shape.id).sort() };
27
+ }
@@ -0,0 +1,4 @@
1
+ // penpair: read
2
+ const observation = inspectHelloExample();
3
+ if (observation.id && !observation.satisfied) throw new Error("Owned example differs; inspect it before preparing a separate edit");
4
+ return { ok: true, ...observation };
@@ -0,0 +1,3 @@
1
+ // penpair: read
2
+ const observation = inspectHelloExample();
3
+ return { ok: true, rootId: observation.id, verification: { expected: observation.expected, actual: observation.actual } };
@@ -0,0 +1,49 @@
1
+ # Real browser comparison starter
2
+
3
+ Copy `capture.mjs` and `provider.mjs` into the adopting project, or use the shipped copies under `node_modules/@penpair/web/examples/reflection-browser/`. This is a starting provider for the existing `ImplementationProvider.runCase` contract. It does not install a browser, change your application or approve a design.
4
+
5
+ Use your project's pinned Playwright and matching browser installation. A copied provider resolves `@penpair/core` from its project; declare it at the same version as the other Penpair packages, or pass its three IO helpers explicitly as `io`. Keep all Penpair versions aligned. Do not use the fixture hooks as application evidence.
6
+
7
+ In your trusted project provider:
8
+
9
+ ```js
10
+ import { chromium } from 'playwright'; // the adopter's pinned version
11
+ import { createBrowserProvider } from './provider.mjs';
12
+ import { hooks } from './project-hooks.mjs';
13
+ export async function runCase(test) {
14
+ const browser = await chromium.launch();
15
+ try {
16
+ return await createBrowserProvider({ browser, baseURL: 'http://127.0.0.1:5173', projectRoot: process.cwd(), hooks }).runCase(test);
17
+ } finally { await browser.close(); }
18
+ }
19
+ ```
20
+
21
+ Required hooks:
22
+
23
+ | Hook | Contract |
24
+ | --- | --- |
25
+ | `route(test)` | Return the exact intended route; must match `test.route` on the configured app origin. |
26
+ | `setup(page, test)` | Explicitly establish application fixture, theme and state using your app's controls or test APIs. |
27
+ | `observe(page, test)` | Independently read **actual** `{theme,state,fixture}` from rendered DOM/application evidence. Never return the requested fields directly. |
28
+ | `captureRoot(test)` | Return one unique CSS selector for the design region. |
29
+ | `expectedFonts(test)` | Return explicit webfont `{family,weight,style}` faces; each must be observed loaded in `document.fonts`. System-font fallback cannot stand in for webfont evidence. |
30
+ | `prepareCapture(page, test, root)` | Optional explicit scroll/sticky setup after automatic scroll. Use it when a shell would cover the root. Occlusion checks refuse covered captures; the starter does not hide your shell. |
31
+
32
+ The provider measures the viewport and device scale (default 1), waits for font faces/weights and three equal root boxes, parks the pointer outside the viewport, checks hover and sampled occlusion, and rounds the document box **outward to whole CSS pixels**. It captures that clip against the full document so viewport trimming cannot silently remove a pixel. Root dimensions must match the design case. It never resizes a screenshot to improve the comparison. Metadata retains root/clip, browser version, actual fonts, scroll, pointer and layout evidence. Occlusion checks sample points; inspect images for unsampled overlays and animation differences.
33
+
34
+ The comparison uses exact decoded RGBA pixel differences in a browser canvas. The numeric ratio is evidence; inspect image differences and retain `owner-review-required`. Baseline hashes, paths and PNG dimensions are still independently checked by the adapter. Old schema-1 providers remain readable; this starter requires its complete capture metadata.
35
+
36
+ ```sh
37
+ penpair reflection plan --map design/implementation-cases.json
38
+ penpair reflection check --map design/implementation-cases.json --provider design/browser/run.mjs
39
+ ```
40
+
41
+ These commands require registered `@penpair/reflection-check` and current exact approved design cases. See [adapter setup](../../docs/adapters.md). `runCase` must close its browser/context as above. Do not store credentials in hooks or evidence.
42
+
43
+ ## Local plumbing trial
44
+
45
+ From this repository, run `pnpm smoke:browser` using a locally installed pinned Playwright and browser. Optional `PENPAIR_PLAYWRIGHT_MODULE` selects an absolute module path; `PENPAIR_BROWSER_EXECUTABLE` or `PENPAIR_BROWSER_CHANNEL` selects an existing test browser. No absolute machine paths are committed to the example.
46
+
47
+ The script starts/stops a loopback fixture, captures desktop/mobile × light/dark × loaded/empty, tests wrong theme, missing face/weight, delayed font loading, scale mismatch, hover and sticky occlusion, and verifies unchanged baseline bytes. Its browser references are deliberately **unapproved synthetic fixtures**. They exercise plumbing; they are not Penpot acceptance or owner-approved design baselines.
48
+
49
+ The fixture's Abel Regular font is redistributed under [SIL OFL](fixture/OFL.txt), retrieved from [Google Fonts](https://github.com/google/fonts/tree/main/ofl/abel), SHA-256 `8809dcad25318225052f88333e208c5aad4adcb7b2c934c135735ec19aa410b4`. Browser APIs follow [Playwright screenshot documentation](https://playwright.dev/docs/api/class-page#page-screenshot) and [font-loading evidence](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet).
@@ -0,0 +1,311 @@
1
+ /** Browser plumbing only. Project hooks supply real setup and independent DOM readback. */
2
+ export function outwardClip(box) {
3
+ if (
4
+ !box ||
5
+ ![
6
+ box.x,
7
+ box.y,
8
+ box.width,
9
+ box.height,
10
+ ].every(Number.isFinite) ||
11
+ box.x < 0 ||
12
+ box.y < 0 ||
13
+ box.width <= 0 ||
14
+ box.height <= 0
15
+ ) {
16
+ throw new Error("Capture root needs a finite, positive document box");
17
+ }
18
+ const x = Math.floor(box.x);
19
+ const y = Math.floor(box.y);
20
+ return {
21
+ x,
22
+ y,
23
+ width: Math.ceil(box.x + box.width) - x,
24
+ height: Math.ceil(box.y + box.height) - y,
25
+ };
26
+ }
27
+ export async function captureCase(page, test, hooks, { timeoutMs = 10000 } = {}) {
28
+ for (const name of [
29
+ "setup",
30
+ "observe",
31
+ "captureRoot",
32
+ "expectedFonts",
33
+ ]) {
34
+ if (typeof hooks[name] !== "function") {
35
+ throw new Error(`Required project hook: ${name}`);
36
+ }
37
+ }
38
+ page.setDefaultTimeout(timeoutMs);
39
+ await hooks.setup(page, test);
40
+ const selector = await hooks.captureRoot(test);
41
+ const root = page.locator(selector);
42
+ if ((await root.count()) !== 1) {
43
+ throw new Error("Capture root must resolve uniquely");
44
+ }
45
+ const expectedFonts = await hooks.expectedFonts(test);
46
+ if (
47
+ !Array.isArray(expectedFonts) ||
48
+ !expectedFonts.length ||
49
+ expectedFonts.some((font) => !font.family || !font.weight || !font.style)
50
+ ) {
51
+ throw new Error("Explicit font family/weight/style evidence is required");
52
+ }
53
+ await page.waitForFunction(
54
+ (fonts) => {
55
+ const clean = (value) => value.replace(/["']/g, "");
56
+ return (
57
+ document.fonts.status === "loaded" &&
58
+ fonts.every((wanted) =>
59
+ [
60
+ ...document.fonts,
61
+ ].some(
62
+ (face) =>
63
+ clean(face.family) === wanted.family &&
64
+ face.weight === wanted.weight &&
65
+ face.style === wanted.style &&
66
+ face.status === "loaded",
67
+ ),
68
+ )
69
+ );
70
+ },
71
+ expectedFonts,
72
+ {
73
+ timeout: timeoutMs,
74
+ },
75
+ );
76
+ await root.scrollIntoViewIfNeeded();
77
+ if (hooks.prepareCapture) {
78
+ await hooks.prepareCapture(page, test, root);
79
+ }
80
+ // Observe three complete stable boxes across animation frames, after scroll/sticky layout.
81
+ const rootBox = await root.evaluate(async (element, timeout) => {
82
+ let previous;
83
+ let equal = 0;
84
+ const deadline = performance.now() + timeout;
85
+ for (let count = 0; count < 120; count++) {
86
+ await new Promise((resolve, reject) => {
87
+ const timer = setTimeout(
88
+ () => reject(new Error("Capture layout deadline exceeded")),
89
+ Math.max(1, deadline - performance.now()),
90
+ );
91
+ requestAnimationFrame(() => {
92
+ clearTimeout(timer);
93
+ resolve();
94
+ });
95
+ });
96
+ const rect = element.getBoundingClientRect();
97
+ const box = {
98
+ x: rect.x + scrollX,
99
+ y: rect.y + scrollY,
100
+ width: rect.width,
101
+ height: rect.height,
102
+ };
103
+ const current = JSON.stringify(box);
104
+ equal = current === previous ? equal + 1 : 1;
105
+ if (equal === 3) {
106
+ return box;
107
+ }
108
+ previous = current;
109
+ }
110
+ throw new Error("Capture root did not settle");
111
+ }, timeoutMs);
112
+ const clip = outwardClip(rootBox);
113
+ const occlusion = await root.evaluate((element) => {
114
+ const r = element.getBoundingClientRect();
115
+ const left = Math.max(0, r.left);
116
+ const top = Math.max(0, r.top);
117
+ const right = Math.min(innerWidth, r.right);
118
+ const bottom = Math.min(innerHeight, r.bottom);
119
+ if (right - left < 2 || bottom - top < 2) {
120
+ throw new Error("Capture root has no observable viewport area");
121
+ }
122
+ const points = [
123
+ [
124
+ left + 1,
125
+ top + 1,
126
+ ],
127
+ [
128
+ right - 1,
129
+ top + 1,
130
+ ],
131
+ [
132
+ (left + right) / 2,
133
+ (top + bottom) / 2,
134
+ ],
135
+ [
136
+ left + 1,
137
+ bottom - 1,
138
+ ],
139
+ [
140
+ right - 1,
141
+ bottom - 1,
142
+ ],
143
+ ];
144
+ return {
145
+ samples: points.length,
146
+ blocked: points.filter(([x, y]) => {
147
+ const hit = document.elementFromPoint(x, y);
148
+ return !hit || !element.contains(hit);
149
+ }).length,
150
+ };
151
+ });
152
+ if (occlusion.blocked) {
153
+ throw new Error(
154
+ "Capture root is occluded by sticky shell or external content; use prepareCapture to establish the intended scroll state",
155
+ );
156
+ }
157
+ // Move outside the viewport; a corner inside a full-screen root is not a safe parking place.
158
+ await page.mouse.move(-10, -10);
159
+ const pointer = await root.evaluate((element) => ({
160
+ x: -10,
161
+ y: -10,
162
+ outsideRoot: !element.matches(":hover"),
163
+ hoveredControls: element.querySelectorAll(
164
+ "button:hover,a:hover,input:hover,select:hover,textarea:hover,[role=button]:hover",
165
+ ).length,
166
+ }));
167
+ if (!pointer.outsideRoot || pointer.hoveredControls !== 0) {
168
+ throw new Error("Hover contamination in capture root");
169
+ }
170
+ const observed = await hooks.observe(page, test);
171
+ const measured = await page.evaluate(() => ({
172
+ viewport: {
173
+ width: innerWidth,
174
+ height: innerHeight,
175
+ },
176
+ deviceScaleFactor: devicePixelRatio,
177
+ scroll: {
178
+ x: scrollX,
179
+ y: scrollY,
180
+ },
181
+ fonts: [
182
+ ...document.fonts,
183
+ ]
184
+ .filter((face) => face.status === "loaded")
185
+ .map((face) => ({
186
+ family: face.family.replace(/["']/g, ""),
187
+ weight: face.weight,
188
+ style: face.style,
189
+ status: face.status,
190
+ })),
191
+ }));
192
+ const actual = {
193
+ viewport: measured.viewport,
194
+ theme: observed.theme,
195
+ state: observed.state,
196
+ fixture: observed.fixture,
197
+ };
198
+ const desired = {
199
+ viewport: test.intent.viewport,
200
+ theme: test.intent.theme,
201
+ state: test.intent.state,
202
+ fixture: test.intent.fixture,
203
+ };
204
+ if (JSON.stringify(actual) !== JSON.stringify(desired)) {
205
+ throw new Error("Observed browser state/theme/fixture/viewport differs from requested case");
206
+ }
207
+ if (
208
+ clip.width !== desired.viewport.width ||
209
+ clip.height !== desired.viewport.height ||
210
+ measured.deviceScaleFactor !== test.baseline.scale
211
+ ) {
212
+ throw new Error(
213
+ "Rounded root dimensions or device scale differ from design case; do not resize the image",
214
+ );
215
+ }
216
+ const screenshot = await page.screenshot({
217
+ type: "png",
218
+ fullPage: true,
219
+ clip,
220
+ scale: "device",
221
+ animations: "disabled",
222
+ caret: "hide",
223
+ });
224
+ const after = await hooks.observe(page, test);
225
+ const afterBox = await root.boundingBox();
226
+ if (
227
+ JSON.stringify(after) !== JSON.stringify(observed) ||
228
+ !afterBox ||
229
+ JSON.stringify(
230
+ outwardClip({
231
+ ...afterBox,
232
+ x: afterBox.x + measured.scroll.x,
233
+ y: afterBox.y + measured.scroll.y,
234
+ }),
235
+ ) !== JSON.stringify(clip)
236
+ ) {
237
+ throw new Error("Browser source changed during capture");
238
+ }
239
+ return {
240
+ screenshot,
241
+ observed: actual,
242
+ fonts: [
243
+ ...new Set(measured.fonts.map((font) => font.family)),
244
+ ],
245
+ fontsReady: true,
246
+ capture: {
247
+ schemaVersion: 1,
248
+ root: selector,
249
+ rootBox,
250
+ clip,
251
+ rounding: "outward-css-pixels",
252
+ deviceScaleFactor: measured.deviceScaleFactor,
253
+ browser: {
254
+ name: page.context().browser().browserType().name(),
255
+ version: page.context().browser().version(),
256
+ },
257
+ fonts: measured.fonts,
258
+ pointer,
259
+ occlusion,
260
+ scroll: measured.scroll,
261
+ layoutSamples: 3,
262
+ },
263
+ };
264
+ }
265
+
266
+ /** Exact decoded RGBA pixel difference, without resizing or threshold-based approval. */
267
+ export async function comparePng(page, baseline, screenshot) {
268
+ return page.evaluate(
269
+ async (images) => {
270
+ const decode = async (base64) => {
271
+ const image = new Image();
272
+ image.src = "data:image/png;base64," + base64;
273
+ await image.decode();
274
+ return image;
275
+ };
276
+ const [before, after] = await Promise.all(images.map(decode));
277
+ if (before.width !== after.width || before.height !== after.height) {
278
+ throw new Error("Comparison dimensions differ");
279
+ }
280
+ const canvas = document.createElement("canvas");
281
+ canvas.width = before.width;
282
+ canvas.height = before.height;
283
+ const context = canvas.getContext("2d", {
284
+ willReadFrequently: true,
285
+ });
286
+ context.drawImage(before, 0, 0);
287
+ const a = context.getImageData(0, 0, canvas.width, canvas.height).data;
288
+ context.clearRect(0, 0, canvas.width, canvas.height);
289
+ context.drawImage(after, 0, 0);
290
+ const b = context.getImageData(0, 0, canvas.width, canvas.height).data;
291
+ let changed = 0;
292
+ for (let index = 0; index < a.length; index += 4) {
293
+ if (
294
+ [
295
+ 0,
296
+ 1,
297
+ 2,
298
+ 3,
299
+ ].some((channel) => a[index + channel] !== b[index + channel])
300
+ ) {
301
+ changed++;
302
+ }
303
+ }
304
+ return changed / (canvas.width * canvas.height);
305
+ },
306
+ [
307
+ baseline.toString("base64"),
308
+ screenshot.toString("base64"),
309
+ ],
310
+ );
311
+ }