@penpair/web 0.2.0 → 0.3.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 (60) hide show
  1. package/README.md +12 -2
  2. package/docs/adapters.md +83 -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/tradient-feedback-0.2.0.md +209 -0
  28. package/docs/plans/tradient-feedback-review.md +83 -0
  29. package/docs/recovery.md +8 -0
  30. package/docs/releasing.md +34 -4
  31. package/docs/snapshots.md +1 -1
  32. package/docs/theme-previews.md +18 -0
  33. package/examples/component-pilot/renderer.mjs +100 -0
  34. package/examples/coverage-matrix.json +126 -0
  35. package/examples/hello-card/README.md +18 -0
  36. package/examples/hello-card/build.js +24 -0
  37. package/examples/hello-card/helpers.js +27 -0
  38. package/examples/hello-card/inspect.js +4 -0
  39. package/examples/hello-card/readback.js +3 -0
  40. package/examples/reflection-browser/README.md +49 -0
  41. package/examples/reflection-browser/capture.mjs +311 -0
  42. package/examples/reflection-browser/fixture/Abel-Regular.ttf +0 -0
  43. package/examples/reflection-browser/fixture/OFL.txt +93 -0
  44. package/examples/reflection-browser/fixture/index.html +11 -0
  45. package/examples/reflection-browser/fixture-hooks.mjs +24 -0
  46. package/examples/reflection-browser/provider.mjs +90 -0
  47. package/examples/reflection-browser/serve.mjs +37 -0
  48. package/examples/theme-preview/README.md +21 -0
  49. package/examples/theme-preview/apply.js +8 -0
  50. package/examples/theme-preview/helpers.js +12 -0
  51. package/examples/theme-preview/inspect.js +3 -0
  52. package/examples/theme-preview/readback.js +7 -0
  53. package/examples/trials/hold-inspect.js +5 -0
  54. package/examples/trials/hold-readback.js +8 -0
  55. package/examples/trials/hold-writer.js +16 -0
  56. package/examples/trials/recovery-build.js +23 -0
  57. package/examples/trials/recovery-inspect.js +10 -0
  58. package/examples/trials/recovery-readback.js +7 -0
  59. package/examples/trials/wrong-file-read.js +3 -0
  60. package/package.json +3 -2
@@ -0,0 +1,24 @@
1
+ export const hooks = {
2
+ route: (test) => test.route,
3
+ async setup(page) {
4
+ await page.locator("#capture").scrollIntoViewIfNeeded();
5
+ },
6
+ async prepareCapture(page) {
7
+ await page.evaluate(() => window.scrollTo(0, 0));
8
+ },
9
+ async observe(page) {
10
+ return page.evaluate(() => ({
11
+ theme: document.documentElement.dataset.theme,
12
+ state: document.querySelector("main").dataset.state,
13
+ fixture: document.querySelector("main").dataset.fixture,
14
+ }));
15
+ },
16
+ captureRoot: () => "#capture",
17
+ expectedFonts: () => [
18
+ {
19
+ family: "Fixture",
20
+ weight: "400",
21
+ style: "normal",
22
+ },
23
+ ],
24
+ };
@@ -0,0 +1,90 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { captureCase, comparePng } from "./capture.mjs";
5
+
6
+ /** Pass a browser from the adopting project's pinned Playwright; this example installs nothing. */
7
+ export function createBrowserProvider({
8
+ browser,
9
+ baseURL,
10
+ projectRoot,
11
+ hooks,
12
+ deviceScaleFactor = 1,
13
+ timeoutMs = 10000,
14
+ io,
15
+ }) {
16
+ if (!browser || !projectRoot || !baseURL || typeof hooks?.route !== "function") {
17
+ throw new Error("Browser, project root, app origin and explicit route hook required");
18
+ }
19
+ return {
20
+ async runCase(test) {
21
+ const { atomicWriteFile, resolveProjectInput, resolveProjectOutput } =
22
+ io ?? (await import("@penpair/core"));
23
+ const target = new URL(await hooks.route(test), baseURL);
24
+ const expected = new URL(test.route, baseURL);
25
+ if (target.origin !== new URL(baseURL).origin || target.href !== expected.href) {
26
+ throw new Error(
27
+ "Route hook must open the exact intended route on the configured app origin",
28
+ );
29
+ }
30
+ const before = await readFile(await resolveProjectInput(projectRoot, test.baseline.path));
31
+ const hash = createHash("sha256").update(before).digest("hex");
32
+ if (hash !== test.baseline.sha256) {
33
+ throw new Error("Baseline hash changed");
34
+ }
35
+ const context = await browser.newContext({
36
+ viewport: test.intent.viewport,
37
+ deviceScaleFactor,
38
+ });
39
+ try {
40
+ const page = await context.newPage();
41
+ await page.goto(target.href, {
42
+ waitUntil: "domcontentloaded",
43
+ timeout: timeoutMs,
44
+ });
45
+ const capture = await captureCase(page, test, hooks, {
46
+ timeoutMs,
47
+ });
48
+ if (page.url() !== target.href) {
49
+ throw new Error("Application navigated away from the intended route");
50
+ }
51
+ const diffPixelRatio = await comparePng(page, before, capture.screenshot);
52
+ const screenshot = path.join(
53
+ ".penpair/reflection",
54
+ createHash("sha256")
55
+ .update(test.id + ":" + test.setup)
56
+ .digest("hex") + ".png",
57
+ );
58
+ const output = await resolveProjectOutput(projectRoot, screenshot);
59
+ if (output === (await resolveProjectInput(projectRoot, test.baseline.path))) {
60
+ throw new Error("Capture cannot overwrite its baseline");
61
+ }
62
+ await mkdir(path.dirname(output), {
63
+ recursive: true,
64
+ });
65
+ await atomicWriteFile(output, capture.screenshot, {
66
+ replace: true,
67
+ });
68
+ return {
69
+ schemaVersion: 1,
70
+ id: test.id,
71
+ route: test.route,
72
+ setup: test.setup,
73
+ baselineHash: hash,
74
+ observed: capture.observed,
75
+ fonts: capture.fonts,
76
+ fontsReady: capture.fontsReady,
77
+ screenshot,
78
+ diffPixelRatio,
79
+ capture: capture.capture,
80
+ provider: {
81
+ name: "penpair-browser-starter-rgba-exact",
82
+ version: "1",
83
+ },
84
+ };
85
+ } finally {
86
+ await context.close();
87
+ }
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,37 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ const files = new Map([
4
+ [
5
+ "/",
6
+ [
7
+ "index.html",
8
+ "text/html",
9
+ ],
10
+ ],
11
+ [
12
+ "/Abel-Regular.ttf",
13
+ [
14
+ "Abel-Regular.ttf",
15
+ "font/ttf",
16
+ ],
17
+ ],
18
+ ]);
19
+ const server = createServer(async (request, response) => {
20
+ const entry = files.get(new URL(request.url, "http://localhost").pathname);
21
+ if (!entry) {
22
+ response.writeHead(404).end();
23
+ return;
24
+ }
25
+ try {
26
+ response.writeHead(200, {
27
+ "Content-Type": entry[1],
28
+ "Cache-Control": "no-store",
29
+ });
30
+ response.end(await readFile(new URL(`fixture/${entry[0]}`, import.meta.url)));
31
+ } catch {
32
+ response.writeHead(500).end();
33
+ }
34
+ });
35
+ server.listen(Number(process.env.PENPAIR_EXAMPLE_PORT ?? 4174), "127.0.0.1", () =>
36
+ console.log(`Browser fixture: http://127.0.0.1:${server.address().port}`),
37
+ );
@@ -0,0 +1,21 @@
1
+ # Projected theme scratch example
2
+
3
+ Generate independent light/dark artifacts with `penpair marwes tokens preview --from SNAPSHOT --theme NAME --out NEW_FILE`. The selected theme is resolved offline; no active flags are changed. Inspect each artifact's `complete`, `unknowns`, source hash, theme and ordered sets first.
4
+
5
+ These reviewed example bodies accept literal `CFG.preview` JSON, `CFG.color` token name, `CFG.rootId` and `CFG.marker`. Each target must already be a confirmed disposable, owned scratch root with `getSharedPluginData("penpair","owner") === marker`. Do not adopt an existing design root implicitly. Prepare separate light and dark roots and inspect/pin the source before any apply.
6
+
7
+ Use the normal `prepareFileScript`/`runFileScript` API with:
8
+
9
+ ```js
10
+ const prepared = await prepareFileScript(projectRoot, 'preview/apply.js', {
11
+ page: 'Scratch', include: ['preview/helpers.js'],
12
+ inspect: 'preview/inspect.js', readback: 'preview/readback.js',
13
+ params: { preview: JSON.stringify(previewArtifact), color: 'surface', rootId, marker },
14
+ });
15
+ await runFileScript(client, prepared); // review inspection first
16
+ // Within the authorized scratch scope, apply once with a private journal and independently verify.
17
+ ```
18
+
19
+ The example sets one opaque projected fill directly and records `kind`, source hash, theme/sets and projected property provenance under a separate `projection` metadata key. This **removes any fill binding** and must not be reported as native per-frame theme binding. Typography/spacing/radius bindings remain real, independently inspected bindings. The example refuses unsupported colors and partial projections; it does not guess gradients, opacity or token semantics.
20
+
21
+ After applying, compare catalog definitions/order, global theme/set selections and unrelated objects against the pinned pre-write inspection. Look at two stable exports. Repeating must preserve IDs, bindings outside fill and the same provenance. Live acceptance of this example remains pending; it is covered by sandbox-body tests only.
@@ -0,0 +1,8 @@
1
+ // penpair: write
2
+ // penpair: idempotent
3
+ const input = previewInput();
4
+ if (!CFG.inspection || CFG.inspection.rootId !== input.root.id || CFG.inspection.provenance !== input.provenance || CFG.inspection.preserved !== input.preserved) throw new Error("Projection inputs or preserved bindings changed since inspection");
5
+ const fills = [{ fillColor: input.value, fillOpacity: 1 }];
6
+ if (JSON.stringify(input.root.fills) !== JSON.stringify(fills)) input.root.fills = fills;
7
+ if (input.root.getSharedPluginData("penpair", "projection") !== input.provenance) input.root.setSharedPluginData("penpair", "projection", input.provenance);
8
+ return {ok:true,rootId:input.root.id};
@@ -0,0 +1,12 @@
1
+ // penpair: read
2
+ const previewInput = () => {
3
+ const preview = JSON.parse(CFG.preview);
4
+ if (preview.schemaVersion !== 1 || preview.kind !== "projected-theme-preview" || preview.nativeBindings !== false || !/^[a-f0-9]{64}$/.test(preview.sourceHash) || !preview.complete || !Array.isArray(preview.resolved) || !Array.isArray(preview.unknowns) || preview.unknowns.length) throw new Error("Complete explicit projected preview required");
5
+ const matches = preview.resolved.filter(token => token.name === CFG.color && token.type === "color");
6
+ if (matches.length !== 1 || typeof matches[0].value !== "string" || !/^#[a-f0-9]{6}$/i.test(matches[0].value)) throw new Error("Example requires one literal opaque hex color");
7
+ const root = penpot.currentPage.getShapeById(CFG.rootId);
8
+ if (!root || !CFG.marker || root.getSharedPluginData("penpair", "owner") !== CFG.marker) throw new Error("Authorized owned scratch root required");
9
+ const preserved = JSON.stringify(Object.fromEntries(Object.entries(root.tokens || {}).filter(([property]) => property !== "fill")));
10
+ const provenance = JSON.stringify({ kind: preview.kind, sourceHash: preview.sourceHash, theme: preview.theme, sets: preview.sets, projectedProperties: { fill: CFG.color }, preservedBindings: preserved, nativeBindings: false });
11
+ return { root, value: matches[0].value, provenance, preserved };
12
+ };
@@ -0,0 +1,3 @@
1
+ // penpair: read
2
+ const input = previewInput();
3
+ return {ok:true,rootId:input.root.id,desiredColor:input.value,provenance:input.provenance,preserved:input.preserved};
@@ -0,0 +1,7 @@
1
+ // penpair: read
2
+ const input = previewInput();
3
+ const fill = input.root.fills && input.root.fills[0];
4
+ return {ok:true,rootId:input.root.id,verification:{
5
+ expected:{color:input.value,opacity:1,fillBinding:null,provenance:input.provenance},
6
+ actual:{color:fill && fill.fillColor,opacity:fill && fill.fillOpacity,fillBinding:input.root.tokens.fill || null,provenance:input.root.getSharedPluginData("penpair","projection")}
7
+ }};
@@ -0,0 +1,5 @@
1
+ // penpair: read
2
+ if (penpot.currentFile.id !== CFG.fileId || penpot.currentPage.id !== CFG.pageId) throw new Error("Confirm the owner-authorized scratch file/page IDs");
3
+ if (penpot.currentPage.root.children.length !== 0) throw new Error("Use the dedicated empty scratch page for the writer exercise");
4
+ if (typeof CFG.marker !== "string" || !CFG.marker) throw new Error("A fresh trial marker is required");
5
+ return { ok: true, fileId: penpot.currentFile.id, pageId: penpot.currentPage.id, children: 0 };
@@ -0,0 +1,8 @@
1
+ // penpair: read
2
+ const raw = penpot.currentFile.getSharedPluginData("penpair", "trial-hold-" + CFG.marker);
3
+ let record = null;
4
+ try { record = raw ? JSON.parse(raw) : null; } catch {}
5
+ return { ok: true, verification: {
6
+ expected: { fileId: CFG.fileId, pageId: CFG.pageId, children: 0, marker: CFG.marker, holdMs: Number(CFG.holdMs), state: "complete" },
7
+ actual: { fileId: penpot.currentFile.id, pageId: penpot.currentPage.id, children: penpot.currentPage.root.children.length, marker: record?.marker ?? null, holdMs: record?.holdMs ?? null, state: record?.state ?? null }
8
+ } };
@@ -0,0 +1,16 @@
1
+ // penpair: write
2
+ // penpair: idempotent
3
+ const milliseconds = Number(CFG.holdMs);
4
+ if (!Number.isInteger(milliseconds) || milliseconds < 1 || milliseconds > 30000) throw new Error("holdMs must be 1–30000");
5
+ if (!CFG.inspection || penpot.currentFile.id !== CFG.inspection.fileId || penpot.currentPage.id !== CFG.inspection.pageId || penpot.currentPage.root.children.length !== 0) throw new Error("Scratch source changed before the hold");
6
+ if (!CFG.marker) throw new Error("A fresh trial marker is required");
7
+ const key = "trial-hold-" + CFG.marker;
8
+ const prior = penpot.currentFile.getSharedPluginData("penpair", key);
9
+ if (prior) throw new Error("Trial marker already exists; inspect it before recovery");
10
+ const record = { schemaVersion: 1, state: "holding", marker: CFG.marker, fileId: CFG.fileId, pageId: CFG.pageId, holdMs: milliseconds };
11
+ penpot.currentFile.setSharedPluginData("penpair", key, JSON.stringify(record));
12
+ // This deliberate delay opens a controlled contention window. It is not layout settling.
13
+ // The operation runner owns the advisory writer lease; this body changes no design objects.
14
+ await new Promise(resolve => setTimeout(resolve, milliseconds));
15
+ penpot.currentFile.setSharedPluginData("penpair", key, JSON.stringify({ ...record, state: "complete" }));
16
+ return { ok: true, heldMilliseconds: milliseconds };
@@ -0,0 +1,23 @@
1
+ // penpair: write
2
+ // penpair: idempotent
3
+ if (!CFG.inspection || penpot.currentFile.id !== CFG.fileId || penpot.currentPage.id !== CFG.pageId) throw new Error("Scratch identity changed");
4
+ if (JSON.stringify(penpot.currentPage.root.children.map(shape => shape.id).sort()) !== JSON.stringify(CFG.inspection.rootIds)) throw new Error("Scratch source changed before the recovery exercise");
5
+ if (CFG.inspection.existingId) {
6
+ const frame = penpot.currentPage.getShapeById(CFG.inspection.existingId);
7
+ if (!frame || frame.name !== CFG.name || frame.width !== 160 || frame.height !== 80 || frame.getSharedPluginData("penpair", "trial") !== CFG.marker) throw new Error("Owned frame has an unexplained third state");
8
+ return { ok: true, alreadyBuilt: true, frameId: frame.id };
9
+ }
10
+ let frame = null, configured = false;
11
+ try {
12
+ frame = penpot.createBoard();
13
+ frame.name = CFG.name;
14
+ frame.setSharedPluginData("penpair", "trial", CFG.marker);
15
+ frame.fills = [];
16
+ frame.resize(160, 80);
17
+ configured = true;
18
+ } finally {
19
+ if (!configured && frame) frame.remove();
20
+ }
21
+ // Deliberate fault: the owned write exists, but the normal acknowledgment is not returned.
22
+ if (CFG.failAfterWrite === "true") throw new Error("Controlled trial failure after the owned write; inspect before explicit resume");
23
+ return { ok: true, frameId: frame.id };
@@ -0,0 +1,10 @@
1
+ // penpair: read
2
+ if (penpot.currentFile.id !== CFG.fileId || penpot.currentPage.id !== CFG.pageId) throw new Error("Confirm the authorized scratch file/page IDs");
3
+ if (typeof CFG.name !== "string" || !CFG.name || typeof CFG.marker !== "string" || !CFG.marker) throw new Error("A unique trial name and ownership marker are required");
4
+ const matches = penpot.currentPage.root.children.filter(shape => shape.name === CFG.name);
5
+ if (matches.length > 1) throw new Error("Recovery trial name is ambiguous");
6
+ const frame = matches[0];
7
+ const marked = penpot.currentPage.root.children.filter(shape => typeof shape.getSharedPluginData === "function" && shape.getSharedPluginData("penpair", "trial") === CFG.marker);
8
+ if (marked.length > 1 || (marked.length === 1 && marked[0] !== frame)) throw new Error("Trial ownership marker moved or duplicated; inspect before recovery");
9
+ if (frame && (typeof frame.getSharedPluginData !== "function" || frame.getSharedPluginData("penpair", "trial") !== CFG.marker)) throw new Error("Existing frame is not this trial's owned object");
10
+ return { ok: true, existingId: frame?.id ?? null, width: frame?.width ?? null, height: frame?.height ?? null, rootIds: penpot.currentPage.root.children.map(shape => shape.id).sort() };
@@ -0,0 +1,7 @@
1
+ // penpair: read
2
+ const matches = penpot.currentPage.root.children.filter(shape => shape.name === CFG.name);
3
+ const frame = matches.length === 1 ? matches[0] : null;
4
+ return { ok: true, frameId: frame?.id ?? null, verification: {
5
+ expected: { fileId: CFG.fileId, pageId: CFG.pageId, name: CFG.name, marker: CFG.marker, width: 160, height: 80 },
6
+ actual: frame ? { fileId: penpot.currentFile.id, pageId: penpot.currentPage.id, name: frame.name, marker: typeof frame.getSharedPluginData === "function" ? frame.getSharedPluginData("penpair", "trial") : null, width: frame.width, height: frame.height } : { missingOrAmbiguous: true }
7
+ } };
@@ -0,0 +1,3 @@
1
+ // penpair: read
2
+ // A benign read: the configured same-execution file guard must block this body on the other file.
3
+ return { ok: true, actualFileId: penpot.currentFile.id, actualPageId: penpot.currentPage.id };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@penpair/web",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "dist",
34
34
  "docs",
35
+ "examples",
35
36
  "LICENSE",
36
37
  "README.md"
37
38
  ],
@@ -47,7 +48,7 @@
47
48
  "access": "public"
48
49
  },
49
50
  "dependencies": {
50
- "@penpair/core": "0.2.0"
51
+ "@penpair/core": "0.3.0"
51
52
  },
52
53
  "scripts": {
53
54
  "build": "tsc -p tsconfig.json",