@penpair/web 0.3.6 → 0.4.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.
@@ -0,0 +1,187 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "journey-map",
4
+ "id": "reset-password-journey",
5
+ "title": "Reset password",
6
+ "goal": "Request password-reset instructions and understand the next step.",
7
+ "sources": [
8
+ {
9
+ "id": "sample-brief",
10
+ "kind": "other",
11
+ "path": "examples/design-release-maps/sample-source.md",
12
+ "sha256": "ab97f0a30f066e9a9f2689d1624608ea91287751cbc6a613542c0e97de187a96"
13
+ }
14
+ ],
15
+ "nodes": [
16
+ {
17
+ "id": "address-entry",
18
+ "label": "Enter email",
19
+ "kind": "screen",
20
+ "origin": "observed",
21
+ "sourceIds": [
22
+ "sample-brief"
23
+ ],
24
+ "requirementIds": [
25
+ "reset-entry"
26
+ ],
27
+ "summary": "Provide an address for reset instructions.",
28
+ "description": "The synthetic brief includes an address-entry screen. No Penpot frame or application has been inspected. Field validation, submission behavior and the final wording still require project evidence or review.",
29
+ "icon": "screen"
30
+ },
31
+ {
32
+ "id": "confirmation",
33
+ "label": "Check your email",
34
+ "kind": "screen",
35
+ "origin": "observed",
36
+ "sourceIds": [
37
+ "sample-brief"
38
+ ],
39
+ "requirementIds": [
40
+ "reset-confirmation"
41
+ ],
42
+ "summary": "Explain the next step after the request.",
43
+ "description": "The synthetic brief includes a confirmation screen. Its success transition and response messaging are proposed, not observed. Confirm the actual request and account-disclosure policy before implementing this state.",
44
+ "icon": "screen"
45
+ },
46
+ {
47
+ "id": "recoverable-error",
48
+ "label": "Retry request",
49
+ "kind": "state",
50
+ "origin": "proposed",
51
+ "requirementIds": [
52
+ "reset-error-retry"
53
+ ],
54
+ "summary": "Keep the address and offer a retry.",
55
+ "description": "Proposed recovery preserves the entered address after a failed request, explains the failure and allows another attempt. No inspected design or implementation establishes this behavior yet. Review it, then verify a real failure and retry.",
56
+ "icon": "action"
57
+ },
58
+ {
59
+ "id": "expiry-policy",
60
+ "label": "Request policy",
61
+ "kind": "decision",
62
+ "origin": "unknown",
63
+ "requirementIds": [
64
+ "reset-expiry"
65
+ ],
66
+ "summary": "Decide expiry and repeat-request behavior.",
67
+ "description": "The source provides no expiry, repeat-request or disclosure policy. Obtain the actual product and service contract before deciding what the interface should say or do. This is an unresolved requirement, not an inferred security rule.",
68
+ "icon": "decision"
69
+ },
70
+ {
71
+ "id": "focus-recovery",
72
+ "label": "Accessible recovery",
73
+ "kind": "behavior",
74
+ "origin": "proposed",
75
+ "requirementIds": [
76
+ "reset-focus"
77
+ ],
78
+ "summary": "Announce errors and guide keyboard focus.",
79
+ "description": "Proposed accessible recovery should explain failures to screen-reader users and support keyboard correction and retry. Exact focus destinations, announcement behavior and implementation checks remain to be defined and tested.",
80
+ "icon": "check"
81
+ }
82
+ ],
83
+ "edges": [
84
+ {
85
+ "id": "request-success",
86
+ "from": "address-entry",
87
+ "to": "confirmation",
88
+ "label": "Success",
89
+ "origin": "proposed",
90
+ "requirementIds": [
91
+ "reset-confirmation"
92
+ ]
93
+ },
94
+ {
95
+ "id": "request-failure",
96
+ "from": "address-entry",
97
+ "to": "recoverable-error",
98
+ "label": "Failure",
99
+ "origin": "proposed",
100
+ "requirementIds": [
101
+ "reset-error-retry"
102
+ ]
103
+ },
104
+ {
105
+ "id": "retry",
106
+ "from": "recoverable-error",
107
+ "to": "address-entry",
108
+ "label": "Retry",
109
+ "origin": "proposed",
110
+ "requirementIds": [
111
+ "reset-error-retry"
112
+ ]
113
+ },
114
+ {
115
+ "id": "expiry-decision",
116
+ "from": "address-entry",
117
+ "to": "expiry-policy",
118
+ "label": "Resolve policy",
119
+ "origin": "unknown",
120
+ "requirementIds": [
121
+ "reset-expiry"
122
+ ]
123
+ },
124
+ {
125
+ "id": "focus-requirement",
126
+ "from": "recoverable-error",
127
+ "to": "focus-recovery",
128
+ "label": "Accessible feedback",
129
+ "origin": "proposed",
130
+ "requirementIds": [
131
+ "reset-focus"
132
+ ]
133
+ }
134
+ ],
135
+ "requirements": [
136
+ {
137
+ "id": "reset-entry",
138
+ "text": "The synthetic source includes an address-entry screen.",
139
+ "origin": "observed",
140
+ "sourceIds": [
141
+ "sample-brief"
142
+ ]
143
+ },
144
+ {
145
+ "id": "reset-confirmation",
146
+ "text": "The synthetic source includes a confirmation screen; its actual transition is proposed.",
147
+ "origin": "observed",
148
+ "sourceIds": [
149
+ "sample-brief"
150
+ ]
151
+ },
152
+ {
153
+ "id": "reset-error-retry",
154
+ "text": "Proposed: preserve the entered address after a failed request and allow retry.",
155
+ "origin": "proposed"
156
+ },
157
+ {
158
+ "id": "reset-expiry",
159
+ "text": "Unknown: expiry policy and treatment of repeated requests require a product/security decision.",
160
+ "origin": "unknown"
161
+ },
162
+ {
163
+ "id": "reset-focus",
164
+ "text": "Proposed: focus and announced errors must support keyboard and screen-reader recovery.",
165
+ "origin": "proposed"
166
+ }
167
+ ],
168
+ "findings": [
169
+ {
170
+ "id": "decide-request-contract",
171
+ "message": "No actual API contract or account-disclosure policy has been supplied.",
172
+ "severity": "blocking",
173
+ "status": "open"
174
+ },
175
+ {
176
+ "id": "design-error-state",
177
+ "message": "Recovery and accessible error presentation are proposals; no inspected frame supports them.",
178
+ "severity": "warning",
179
+ "status": "open"
180
+ }
181
+ ],
182
+ "presentation": {
183
+ "fontFamily": "Inter"
184
+ },
185
+ "summary": "Request reset instructions and recover from failure.",
186
+ "description": "This unapproved planning fixture is grounded only in the synthetic brief. It illustrates the proposed reset flow without claiming inspected Penpot frames, actual application behavior or owner agreement. Request policy, recovery and accessibility behavior remain explicit decisions and verification work."
187
+ }
@@ -0,0 +1,8 @@
1
+ # Synthetic reset-password source
2
+
3
+ This is illustrative fixture text, not a Penpot inspection or approved product
4
+ requirement. It supplies reproducible local source bytes for the map examples.
5
+
6
+ The sample includes an address-entry screen and a confirmation screen.
7
+ The intended user goal is to request password-reset instructions.
8
+ No API, expiry policy, error recovery or focus behavior is established here.
@@ -8,12 +8,13 @@ In your trusted project provider:
8
8
 
9
9
  ```js
10
10
  import { chromium } from 'playwright'; // the adopter's pinned version
11
+ import sharp from 'sharp'; // declare the adopter's reviewed, pinned codec
11
12
  import { createBrowserProvider } from './provider.mjs';
12
13
  import { hooks } from './project-hooks.mjs';
13
14
  export async function runCase(test) {
14
15
  const browser = await chromium.launch();
15
16
  try {
16
- return await createBrowserProvider({ browser, baseURL: 'http://127.0.0.1:5173', projectRoot: process.cwd(), hooks }).runCase(test);
17
+ return await createBrowserProvider({ browser, baseURL: 'http://127.0.0.1:5173', projectRoot: process.cwd(), hooks, encodeLosslessWebp: (bytes) => sharp(bytes).webp({ lossless: true, exact: true, effort: 6 }).toBuffer() }).runCase(test);
17
18
  } finally { await browser.close(); }
18
19
  }
19
20
  ```
@@ -47,3 +48,19 @@ From this repository, run `pnpm smoke:browser` using a locally installed pinned
47
48
  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
 
49
50
  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).
51
+
52
+ The starter captures a temporary PNG through Playwright and writes static lossless
53
+ WebP evidence by default. `encodeLosslessWebp` is an explicit project-owned codec
54
+ hook; declare Sharp directly in the adopting project when using the example above.
55
+ `lossless: true, exact: true` preserves image pixels, including RGB under transparent
56
+ pixels; `quality: 100` alone does not select lossless WebP. The provider compares
57
+ the encoded image with the capture before saving and refuses changed decoded pixels.
58
+ See [Sharp WebP output options](https://sharp.pixelplumbing.com/api-output#webp).
59
+
60
+ Comparison reads both PNG and WebP references by content. Existing approved PNG
61
+ references keep their original hashes. `imageFormat: "png"` explicitly selects the
62
+ legacy output path without requiring an encoder. Browser capture metadata, exact
63
+ scale/viewport checks, independent evidence paths and owner review remain unchanged.
64
+ The adapter rejects lossy/animated WebP and validates container metadata without
65
+ adding a native decoder dependency. The repository's pinned Sharp dev dependency
66
+ exists only for smoke validation; it is not shipped as an adapter runtime dependency.
@@ -264,12 +264,12 @@ export async function captureCase(page, test, hooks, { timeoutMs = 10000 } = {})
264
264
  }
265
265
 
266
266
  /** Exact decoded RGBA pixel difference, without resizing or threshold-based approval. */
267
- export async function comparePng(page, baseline, screenshot) {
267
+ export async function compareImages(page, baseline, screenshot) {
268
268
  return page.evaluate(
269
269
  async (images) => {
270
- const decode = async (base64) => {
270
+ const decode = async ({ mime, base64 }) => {
271
271
  const image = new Image();
272
- image.src = "data:image/png;base64," + base64;
272
+ image.src = "data:" + mime + ";base64," + base64;
273
273
  await image.decode();
274
274
  return image;
275
275
  };
@@ -304,8 +304,26 @@ export async function comparePng(page, baseline, screenshot) {
304
304
  return changed / (canvas.width * canvas.height);
305
305
  },
306
306
  [
307
- baseline.toString("base64"),
308
- screenshot.toString("base64"),
307
+ imageData(baseline),
308
+ imageData(screenshot),
309
309
  ],
310
310
  );
311
311
  }
312
+
313
+ function imageData(bytes) {
314
+ const png = bytes.length >= 8 && bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a";
315
+ const webp =
316
+ bytes.length >= 12 &&
317
+ bytes.toString("ascii", 0, 4) === "RIFF" &&
318
+ bytes.toString("ascii", 8, 12) === "WEBP";
319
+ if (!png && !webp) {
320
+ throw new Error("Comparison requires PNG or WebP bytes");
321
+ }
322
+ return {
323
+ mime: png ? "image/png" : "image/webp",
324
+ base64: bytes.toString("base64"),
325
+ };
326
+ }
327
+
328
+ /** Existing PNG callers retain the same decoded-pixel comparison. */
329
+ export const comparePng = compareImages;
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { mkdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { captureCase, comparePng } from "./capture.mjs";
4
+ import { captureCase, compareImages } from "./capture.mjs";
5
5
 
6
6
  /** Pass a browser from the adopting project's pinned Playwright; this example installs nothing. */
7
7
  export function createBrowserProvider({
@@ -12,10 +12,25 @@ export function createBrowserProvider({
12
12
  deviceScaleFactor = 1,
13
13
  timeoutMs = 10000,
14
14
  io,
15
+ imageFormat = "webp",
16
+ encodeLosslessWebp,
15
17
  }) {
16
18
  if (!browser || !projectRoot || !baseURL || typeof hooks?.route !== "function") {
17
19
  throw new Error("Browser, project root, app origin and explicit route hook required");
18
20
  }
21
+ if (
22
+ ![
23
+ "webp",
24
+ "png",
25
+ ].includes(imageFormat)
26
+ ) {
27
+ throw new Error("Evidence imageFormat must be webp or png");
28
+ }
29
+ if (imageFormat === "webp" && typeof encodeLosslessWebp !== "function") {
30
+ throw new Error(
31
+ "Lossless WebP evidence requires encodeLosslessWebp from the project's pinned image codec",
32
+ );
33
+ }
19
34
  return {
20
35
  async runCase(test) {
21
36
  const { atomicWriteFile, resolveProjectInput, resolveProjectOutput } =
@@ -48,12 +63,27 @@ export function createBrowserProvider({
48
63
  if (page.url() !== target.href) {
49
64
  throw new Error("Application navigated away from the intended route");
50
65
  }
51
- const diffPixelRatio = await comparePng(page, before, capture.screenshot);
66
+ const diffPixelRatio = await compareImages(page, before, capture.screenshot);
67
+ const imageBytes =
68
+ imageFormat === "webp"
69
+ ? Buffer.from(await encodeLosslessWebp(capture.screenshot))
70
+ : capture.screenshot;
71
+ if (imageFormat === "webp") {
72
+ if (
73
+ imageBytes.toString("ascii", 0, 4) !== "RIFF" ||
74
+ imageBytes.toString("ascii", 8, 12) !== "WEBP"
75
+ ) {
76
+ throw new Error("Lossless WebP encoder returned a different image format");
77
+ }
78
+ if ((await compareImages(page, capture.screenshot, imageBytes)) !== 0) {
79
+ throw new Error("Lossless WebP encoding changed captured pixels");
80
+ }
81
+ }
52
82
  const screenshot = path.join(
53
83
  ".penpair/reflection",
54
84
  createHash("sha256")
55
85
  .update(test.id + ":" + test.setup)
56
- .digest("hex") + ".png",
86
+ .digest("hex") + `.${imageFormat}`,
57
87
  );
58
88
  const output = await resolveProjectOutput(projectRoot, screenshot);
59
89
  if (output === (await resolveProjectInput(projectRoot, test.baseline.path))) {
@@ -62,7 +92,7 @@ export function createBrowserProvider({
62
92
  await mkdir(path.dirname(output), {
63
93
  recursive: true,
64
94
  });
65
- await atomicWriteFile(output, capture.screenshot, {
95
+ await atomicWriteFile(output, imageBytes, {
66
96
  replace: true,
67
97
  });
68
98
  return {
@@ -79,7 +109,7 @@ export function createBrowserProvider({
79
109
  capture: capture.capture,
80
110
  provider: {
81
111
  name: "penpair-browser-starter-rgba-exact",
82
- version: "1",
112
+ version: "2",
83
113
  },
84
114
  };
85
115
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/web",
3
- "version": "0.3.6",
3
+ "version": "0.4.0",
4
4
  "description": "The penpair command for Penpot on the web (design.penpot.app or your team's own): guided setup, a safe write protocol, skills for AI agents. Includes everything in @penpair/core.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,7 +48,7 @@
48
48
  "access": "public"
49
49
  },
50
50
  "dependencies": {
51
- "@penpair/core": "0.3.6"
51
+ "@penpair/core": "0.4.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "tsc -p tsconfig.json",