@penpair/web 0.3.5 → 0.3.7

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/niklas-westman/penpair/main/docs/assets/logo.png" alt="Penpair: a pen nib between blue code brackets" width="120" height="120">
2
+ <img src="https://raw.githubusercontent.com/niklas-westman/penpair/main/docs/assets/banner.png" alt="Penpair: Design together. Build together. A shared workflow from design and contracts to build, verification and human review." width="100%">
3
3
  </p>
4
4
 
5
5
  # penpair
package/docs/adapters.md CHANGED
@@ -87,4 +87,4 @@ The design-only workflow can start with one command package. Frontend projects s
87
87
 
88
88
  The shipped setup/handoff skills should recommend the appropriate companion, preserve existing adapter configuration and report readiness at each stage. They must keep missing references or provider work visible and never invent owner approval.
89
89
 
90
- Use the shipped [real-browser provider starter](../examples/reflection-browser/README.md) to supply capture plumbing while keeping route/theme/state/fixture hooks project-owned. It includes measured font, pointer, scroll and crop evidence; a browser dependency is not added to the core.
90
+ Use the shipped [real-browser provider starter](../examples/reflection-browser/README.md) to supply capture plumbing while keeping route/theme/state/fixture hooks project-owned. It includes measured font, pointer, scroll and crop evidence; a browser dependency is not added to the core. The starter saves static lossless WebP with a project-supplied encoder; the adapter also reads legacy PNG evidence without converting approved references.
package/docs/coverage.md CHANGED
@@ -20,7 +20,7 @@ A baseline frame may declare an explicit viewport, theme label, UI state, fixtur
20
20
 
21
21
  `theme`, `state` and `fixture` are declared design intent. A label alone does not prove actual theme activation, application state or data loading. Optional `activeThemeIds` asserts the complete exact active native theme-ID set at capture; omit it for a manually styled frame and retain that uncertainty. Baseline capture remains read-only toward the Penpot document: it never switches themes. Use the separate [mode trial](mode-verification.md) for temporary theme switching.
22
22
 
23
- Frame geometry and PNG dimensions must match the declared CSS viewport and observed export scale. A failed viewport/theme assertion marks `case-mismatch`, exits incomplete and preserves the previous generation. New provenance records exact design dimensions, active native theme IDs where available, API/tool version, renderer description and capture time. Font names are declarations; loaded font versions remain unobserved.
23
+ Frame geometry must match the declared CSS viewport within less than 0.01 CSS px per axis, allowing floating-point noise from Penpot transforms and auto sizing. Baseline capture, case approval and offline coverage use the same tolerance. Exported PNG dimensions must still exactly equal the declared viewport multiplied by the observed export scale and rounded to whole pixels; the geometry tolerance never applies to PNG dimensions. A failed viewport/theme assertion marks `case-mismatch`, exits incomplete and preserves the previous generation. New provenance retains raw, unrounded design dimensions, active native theme IDs where available, API/tool version, renderer description and capture time. Font names are declarations; loaded font versions remain unobserved.
24
24
 
25
25
  Case metadata is part of approval identity. Changing a fixture/state/theme/viewport declaration requires `--apply --update` even if the PNG bytes happen to match. Moving a matching image to a different file/page/frame identity also requires review. Recapture preserves the original approval until that explicit update. Metadata is pinned before asynchronous capture.
26
26
 
package/docs/guide.md CHANGED
@@ -169,6 +169,6 @@ Use `inspect <shape-id> --page <name> --all` for bounded structured evidence wit
169
169
 
170
170
  ## State, theme and responsive coverage
171
171
 
172
- Add explicit `frames[].case` metadata and a required matrix under `baselines.coverage`. `baselines coverage --json` validates approved images offline and exposes missing/unsupported slots. Rich cases remain pending browser setup mappings; see [coverage semantics and approval](coverage.md).
172
+ Add explicit `frames[].case` metadata and a required matrix under `baselines.coverage`. Case geometry allows less than 0.01 CSS px of Penpot floating-point noise per axis; exported PNG dimensions remain exact at the capture scale, and raw geometry stays in provenance. `baselines coverage --json` validates approved images offline and exposes missing/unsupported slots. Rich cases remain pending browser setup mappings; see [coverage semantics and approval](coverage.md).
173
173
 
174
174
  For project-specific agent tools, use the [explicit-root stdio server and client examples](mcp.md). It exposes typed guarded services and defaults to omitting design-write tools.
package/docs/releasing.md CHANGED
@@ -8,7 +8,7 @@ All packages in this repository are released **together, at one version, even a
8
8
  | `pnpm version-packages` | One Changesets fixed group versions every package, synchronizes `version.ts` and refreshes the lockfile |
9
9
  | `pnpm check:versions` | Missing, duplicate, ignored or separately grouped release packages, including packages added later |
10
10
  | `prepublishOnly` in every package | `pnpm publish` inside one package: it refuses unless `pnpm release` started it, and checks the versions again |
11
- | `pnpm release` | A half release: it checks, publishes the whole set in dependency order, looks each one up on npm, and reports anything missing |
11
+ | `pnpm release` | A half release: it checks, uploads the whole set with at most three workers, verifies registry visibility concurrently, and reports anything missing |
12
12
 
13
13
  Publishing cannot be undone: a version, once published, cannot be replaced. Check first, publish once.
14
14
 
@@ -34,10 +34,11 @@ Publishing cannot be undone: a version, once published, cannot be replaced. Chec
34
34
  publishing. The protected `npm-release` environment may require an additional
35
35
  owner approval, depending on its configured protection.
36
36
  5. The existing `pnpm release` entrypoint reruns checks and all package smoke
37
- installs, publishes packed tarballs in dependency order with npm OIDC and
38
- provenance, and verifies every required registry version (all six by default). A moved main branch stops
39
- the job instead of publishing a different commit. Do not merge another main
40
- change until a release finishes; use manual recovery if it has moved.
37
+ installs, uploads packed tarballs with at most three workers using npm OIDC and
38
+ provenance, then verifies every required registry version concurrently (all six by default).
39
+ It checks out the exact reviewed release SHA and verifies that it belongs to main's
40
+ history. Feature PRs may merge while that immutable snapshot publishes; newer
41
+ feature commits and their pending Changesets cannot alter the release artifacts.
41
42
 
42
43
  Automation is opt-in through the activation switches below. Merging a feature PR
43
44
  creates or updates the version PR; publishing starts only after that reviewed
@@ -134,20 +135,36 @@ pnpm release # dry run; publishes nothing
134
135
  pnpm release --publish --confirm-version X.Y.Z
135
136
  ```
136
137
 
138
+ Local OTP publication uploads one package at a time to keep interactive prompts
139
+ separate, then uses the same concurrent family visibility barrier.
140
+
137
141
  The manual GitHub **Publish reviewed Penpair release** dispatch also remains:
138
- enter the exact version already on main. It rejects mismatches or pending notes;
139
- set `continue_release` only after inspecting an interrupted release. The dispatch
140
- still passes through the protected environment and all unchanged release guards.
142
+ enter the exact reviewed version. Optionally enter its full 40-character `release_sha`
143
+ when main has advanced; omitting it selects the dispatch commit. Both automatic
144
+ and manual releases require the selected SHA to match a merged, same-repository
145
+ release PR, contain the increased shared version, and have no pending Changesets
146
+ in that snapshot. Set `continue_release` only after inspecting an interrupted
147
+ release. The dispatch still passes through the protected environment and release guards.
148
+ Historical recovery requires the selected commit's `scripts/release-protocol.json`
149
+ to declare the controller's supported protocol (currently version 1). Missing or
150
+ unsupported protocols are rejected before checkout or publication approval; releases
151
+ predating this tooling must use their original recovery process. Incompatible future
152
+ publisher safeguards must increment both the marker and the supported protocol so
153
+ older publisher scripts cannot bypass them.
141
154
 
142
155
  ## If a release stops half way
143
156
 
144
157
  After a successful upload, npm may still be processing the version. The release
145
- checks registry visibility immediately and then once per minute within a
146
- ten-minute deadline **for each uploaded package**, printing progress while it
147
- waits. Each metadata query has a 30-second maximum (or the remaining deadline,
158
+ does not block the remaining uploads on that package's registry visibility.
159
+ Up to three uploads run together. After all started uploads settle, every accepted
160
+ upload and already published required package is checked concurrently under one
161
+ shared ten-minute visibility deadline, with immediate checks followed by minute
162
+ polls. Each metadata query has a 30-second maximum (or the remaining deadline,
148
163
  whichever is smaller), with npm's internal retries disabled. Query time counts
149
- toward the deadline. A registry error still stops the release; only an actual
150
- not-found result waits for propagation. The visibility wait never republishes.
164
+ toward the deadline. Registry errors remain unknown failures rather than evidence
165
+ of absence. Visibility checks never republish. A failed upload stops new scheduling;
166
+ already started uploads finish and their results are retained before the command exits.
167
+ A partial release still requires explicit recovery.
151
168
 
152
169
  A publish can fail after some packages are out (a network error, a wrong one-time code, a name npm refuses). The version is then on npm for some packages and not for others, which must not stay that way. Fix the cause and run:
153
170
 
@@ -158,18 +175,26 @@ pnpm release --publish --continue # publishes only what is missing at this ve
158
175
  Without `--continue`, `pnpm release` refuses to run while only some packages are at the version, so a partial release cannot be forgotten.
159
176
 
160
177
  For GitHub recovery, run **Publish reviewed Penpair release** on `main`, enter
161
- the exact interrupted version and set `continue_release=true`. Approve
162
- `npm-release` when requested. This checks the registry again, skips versions
163
- already present and publishes only missing required packages. Inspect the final
164
- package/provenance verification before counting the release complete.
165
-
166
- Finish an interrupted release before merging unrelated changes or a tooling fix
167
- with new pending Changesets. The manual release gate requires a reviewed version
168
- with no pending notes; merging another feature can prevent continuation until
169
- that release state is reconciled. Do not discard pending package-release notes
170
- or weaken guards to force recovery through.
171
-
172
- An empty, tooling-only Changeset contains no package release entries. Changesets
178
+ the exact interrupted version, its full `release_sha` when main has advanced, and
179
+ set `continue_release=true`. Approve `npm-release` when requested. This checks
180
+ the registry again, skips versions already present and publishes only missing
181
+ required packages from that reviewed snapshot. If an accepted upload is still
182
+ propagating, wait and independently inspect the registry before requesting recovery.
183
+ A newer published `latest` version on any required package refuses the older
184
+ release rather than moving that tag backward. Tags are checked both during
185
+ preflight and immediately before uploads. Inspect final package/provenance
186
+ verification before counting the release complete.
187
+
188
+ Feature merges may continue during publication or recovery: pending Changesets
189
+ on newer main commits do not change the reviewed release snapshot. Keep release
190
+ versions in sequence and inspect a failed family before approving a newer release.
191
+ Only actual publish jobs enter the `penpair-package-release` concurrency group;
192
+ `queue: max` retains up to 100 pending jobs rather than replacing the previous one.
193
+ Queue arrival order can differ from version order, so the latest-version rollback
194
+ check remains required. See [GitHub concurrency documentation](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency).
195
+ Do not discard pending package-release notes or weaken guards to force recovery through.
196
+
197
+ For legacy releases predating snapshot-aware recovery, an empty, tooling-only Changeset contains no package release entries. Changesets
173
198
  can consume that metadata without changing versions. If such a note blocks an
174
199
  interrupted release, handle it in a reviewed metadata-only cleanup, preserve its
175
200
  summary in the completion report and verify unchanged package/source versions
@@ -185,7 +210,7 @@ the release's own evidence; never relax the existing release guards.
185
210
  pnpm release --publish --except penpair
186
211
  ```
187
212
 
188
- The release is then reported as `INCOMPLETE` (exit code 3) on purpose, and `pnpm release --publish --continue` finishes it the day npm allows the name. Nothing else can be left out, and only by naming it.
213
+ The release is then reported as `INCOMPLETE` (exit code 3) on purpose. Recovery at that version requires explicit continuation and refuses if a newer required package version has already taken `latest`. Nothing else can be left out, and only by naming it.
189
214
 
190
215
  ## Verify
191
216
 
@@ -217,10 +242,15 @@ See [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) and
217
242
  [provenance requirements](https://docs.npmjs.com/generating-provenance-statements/).
218
243
  Publishing validates the workflow context and rejects long-lived npm token
219
244
  fallback, packs with pnpm to rewrite `workspace:*` to exact versions, and publishes
220
- single tarballs with npm and provenance. These local checks are operational guards;
245
+ single tarballs with npm and provenance. For SHA-selected recovery, only the npm
246
+ publish subprocess receives `GITHUB_SHA` set to the verified source SHA, so npm's
247
+ provenance source digest identifies the artifact checkout. Workflow identity/ref,
248
+ OIDC issuance and run invocation retain the actual dispatch context. These local checks are operational guards;
221
249
  npm authenticates the actual identity against owner-managed publisher settings.
222
250
 
223
- npm cannot atomically publish multiple packages. A network/permission failure can
251
+ npm cannot atomically publish multiple packages. With concurrent uploads a dependent
252
+ package may briefly appear before its exact dependency has propagated. Consumers
253
+ should use a version after final family verification succeeds. A network/permission failure can
224
254
  leave some published and others missing; the release reports failure and requires
225
255
  explicit continuation at the same version. A rerun never silently resumes.
226
256
 
@@ -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.5",
3
+ "version": "0.3.7",
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.5"
51
+ "@penpair/core": "0.3.7"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "tsc -p tsconfig.json",