@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.
- package/README.md +1 -1
- package/docs/adapters.md +2 -2
- package/docs/approvals.md +33 -2
- package/docs/artifacts.md +23 -3
- package/docs/coverage.md +20 -4
- package/docs/design-releases.md +162 -0
- package/docs/guide.md +20 -3
- package/docs/handoff.md +1 -1
- package/docs/integration-maps.md +171 -0
- package/docs/journey-maps.md +139 -0
- package/docs/releasing.md +58 -28
- package/examples/design-release-maps/README.md +72 -0
- package/examples/design-release-maps/integration.json +181 -0
- package/examples/design-release-maps/journey.json +187 -0
- package/examples/design-release-maps/sample-source.md +8 -0
- package/examples/reflection-browser/README.md +18 -1
- package/examples/reflection-browser/capture.mjs +23 -5
- package/examples/reflection-browser/provider.mjs +35 -5
- package/package.json +2 -2
|
@@ -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
|
|
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:
|
|
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
|
|
308
|
-
screenshot
|
|
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,
|
|
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
|
|
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") +
|
|
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,
|
|
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: "
|
|
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
|
+
"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.
|
|
51
|
+
"@penpair/core": "0.4.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "tsc -p tsconfig.json",
|