@penpair/core 0.1.3 → 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 (149) hide show
  1. package/dist/adapters.d.ts +16 -1
  2. package/dist/adapters.js +62 -19
  3. package/dist/approvals.d.ts +32 -0
  4. package/dist/approvals.js +213 -0
  5. package/dist/atomic-output.d.ts +5 -0
  6. package/dist/atomic-output.js +33 -0
  7. package/dist/audit-rules.d.ts +60 -0
  8. package/dist/audit-rules.js +443 -0
  9. package/dist/backup-note.d.ts +4 -0
  10. package/dist/backup-note.js +49 -0
  11. package/dist/baseline-coverage.d.ts +25 -0
  12. package/dist/baseline-coverage.js +248 -0
  13. package/dist/baselines-config.d.ts +25 -0
  14. package/dist/baselines-config.js +150 -11
  15. package/dist/baselines.d.ts +37 -8
  16. package/dist/baselines.js +362 -59
  17. package/dist/cli-arguments.d.ts +7 -0
  18. package/dist/cli-arguments.js +47 -0
  19. package/dist/cli-main.d.ts +13 -1
  20. package/dist/cli-main.js +2107 -247
  21. package/dist/client.d.ts +30 -6
  22. package/dist/client.js +160 -35
  23. package/dist/command-result.d.ts +159 -0
  24. package/dist/command-result.js +468 -0
  25. package/dist/coordination.d.ts +15 -0
  26. package/dist/coordination.js +184 -0
  27. package/dist/credentials.d.ts +21 -0
  28. package/dist/credentials.js +105 -0
  29. package/dist/deep-check.js +57 -9
  30. package/dist/design-spec.d.ts +55 -0
  31. package/dist/design-spec.js +275 -0
  32. package/dist/endpoint.d.ts +8 -1
  33. package/dist/endpoint.js +133 -21
  34. package/dist/errors.d.ts +2 -1
  35. package/dist/errors.js +8 -4
  36. package/dist/export.d.ts +18 -0
  37. package/dist/export.js +113 -10
  38. package/dist/file-archive.d.ts +16 -0
  39. package/dist/file-archive.js +58 -0
  40. package/dist/file-guard.d.ts +8 -0
  41. package/dist/file-guard.js +58 -0
  42. package/dist/formats.d.ts +4 -0
  43. package/dist/formats.js +229 -0
  44. package/dist/index.d.ts +34 -1
  45. package/dist/index.js +34 -1
  46. package/dist/inspection.d.ts +106 -0
  47. package/dist/inspection.js +657 -0
  48. package/dist/instance/config-schema.d.ts +422 -0
  49. package/dist/instance/config-schema.js +435 -0
  50. package/dist/instance/config.d.ts +29 -0
  51. package/dist/instance/config.js +191 -29
  52. package/dist/instance/connect.d.ts +10 -1
  53. package/dist/instance/connect.js +178 -30
  54. package/dist/instance/connection.d.ts +39 -4
  55. package/dist/instance/connection.js +198 -14
  56. package/dist/instance/gitignore.js +5 -2
  57. package/dist/instance/init.js +32 -7
  58. package/dist/instance/runner.js +32 -10
  59. package/dist/instance/setup.js +46 -13
  60. package/dist/instance/tty.js +28 -8
  61. package/dist/journal.d.ts +87 -0
  62. package/dist/journal.js +423 -0
  63. package/dist/kit-smoke.d.ts +31 -0
  64. package/dist/kit-smoke.js +145 -0
  65. package/dist/kit-source.d.ts +2 -0
  66. package/dist/kit-source.js +279 -0
  67. package/dist/kit.d.ts +25 -0
  68. package/dist/kit.js +158 -0
  69. package/dist/layout-policy.d.ts +2 -0
  70. package/dist/layout-policy.js +86 -0
  71. package/dist/layout.d.ts +21 -0
  72. package/dist/layout.js +36 -0
  73. package/dist/live-smoke.d.ts +8 -0
  74. package/dist/live-smoke.js +23 -0
  75. package/dist/local-profile.js +2 -1
  76. package/dist/mcp-server.d.ts +12 -0
  77. package/dist/mcp-server.js +415 -0
  78. package/dist/native-api.d.ts +15 -0
  79. package/dist/native-api.js +78 -0
  80. package/dist/onboard.d.ts +10 -0
  81. package/dist/onboard.js +41 -0
  82. package/dist/operation.d.ts +22 -3
  83. package/dist/operation.js +341 -31
  84. package/dist/page-session.d.ts +23 -0
  85. package/dist/page-session.js +142 -0
  86. package/dist/png.d.ts +5 -0
  87. package/dist/png.js +22 -0
  88. package/dist/portable-tokens.d.ts +22 -0
  89. package/dist/portable-tokens.js +331 -0
  90. package/dist/probe.d.ts +6 -2
  91. package/dist/probe.js +64 -11
  92. package/dist/project-input.d.ts +6 -0
  93. package/dist/project-input.js +65 -0
  94. package/dist/project-snippets.js +29 -12
  95. package/dist/project-status.d.ts +24 -0
  96. package/dist/project-status.js +188 -0
  97. package/dist/registry.d.ts +2 -1
  98. package/dist/registry.js +224 -53
  99. package/dist/run-file.d.ts +26 -0
  100. package/dist/run-file.js +199 -0
  101. package/dist/shots.d.ts +43 -0
  102. package/dist/shots.js +172 -0
  103. package/dist/skill-instructions.d.ts +13 -0
  104. package/dist/skill-instructions.js +144 -0
  105. package/dist/skills.d.ts +7 -0
  106. package/dist/skills.js +163 -46
  107. package/dist/snippet.d.ts +3 -2
  108. package/dist/snippet.js +33 -9
  109. package/dist/snippets/archive.d.ts +9 -0
  110. package/dist/snippets/archive.js +10 -4
  111. package/dist/snippets/read.d.ts +1 -1
  112. package/dist/snippets/read.js +78 -41
  113. package/dist/structure-snapshot.d.ts +85 -0
  114. package/dist/structure-snapshot.js +525 -0
  115. package/dist/token-edit.d.ts +53 -0
  116. package/dist/token-edit.js +352 -0
  117. package/dist/token-projection.d.ts +56 -0
  118. package/dist/token-projection.js +286 -0
  119. package/dist/tokens-diff.d.ts +19 -6
  120. package/dist/tokens-diff.js +182 -18
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
  124. package/skills/penpair-design/SKILL.md +20 -45
  125. package/skills/penpair-design/references/penpot-plugin-gotchas.md +58 -2
  126. package/skills/penpair-design/references/project-facts.md +3 -1
  127. package/skills/penpair-design/references/write-protocol.md +15 -3
  128. package/skills/penpair-handoff/SKILL.md +16 -0
  129. package/skills/penpair-handoff/references/comparison-companion.md +13 -0
  130. package/skills/penpair-handoff/references/handoff-flow.md +13 -0
  131. package/skills/penpair-recipes/SKILL.md +21 -0
  132. package/skills/penpair-recipes/references/build-flow.md +11 -0
  133. package/skills/penpair-setup/SKILL.md +8 -2
  134. package/dist/cli.d.ts +0 -2
  135. package/dist/cli.js +0 -3
  136. package/dist/docker/lifecycle.d.ts +0 -96
  137. package/dist/docker/lifecycle.js +0 -273
  138. package/dist/docker/secrets.d.ts +0 -11
  139. package/dist/docker/secrets.js +0 -38
  140. package/dist/docker/template.d.ts +0 -8
  141. package/dist/docker/template.js +0 -158
  142. package/dist/docker-profile.d.ts +0 -114
  143. package/dist/docker-profile.js +0 -18
  144. package/dist/instance/lifecycle.d.ts +0 -129
  145. package/dist/instance/lifecycle.js +0 -336
  146. package/dist/instance/secrets.d.ts +0 -11
  147. package/dist/instance/secrets.js +0 -38
  148. package/dist/instance/template.d.ts +0 -8
  149. package/dist/instance/template.js +0 -158
@@ -0,0 +1,248 @@
1
+ import { open } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { resolveProjectOutput } from "./project-input.js";
5
+ import { pngSize } from "./png.js";
6
+ import { validateBaselines, } from "./baselines-config.js";
7
+ import { observationHash } from "./journal.js";
8
+ export const designCaseKey = (route, value) => observationHash({
9
+ route,
10
+ viewport: {
11
+ width: value.viewport.width,
12
+ height: value.viewport.height,
13
+ },
14
+ theme: value.theme,
15
+ state: value.state,
16
+ fixture: value.fixture,
17
+ });
18
+ export function baselineCoverage(config, results = []) {
19
+ const rows = [];
20
+ for (const requirement of config.coverage?.required ?? []) {
21
+ const key = designCaseKey(requirement.route, requirement);
22
+ const unsupported = config.coverage?.unsupported?.find((value) => designCaseKey(value.route, value) === key);
23
+ const frames = config.frames.filter((frame) => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
24
+ const frameIds = frames.map((frame) => frame.id);
25
+ if (unsupported) {
26
+ rows.push({
27
+ requirement,
28
+ status: "unsupported",
29
+ frameIds,
30
+ reason: unsupported.reason,
31
+ });
32
+ continue;
33
+ }
34
+ if (!frames.length) {
35
+ rows.push({
36
+ requirement,
37
+ status: "missing-case",
38
+ frameIds,
39
+ });
40
+ continue;
41
+ }
42
+ const captures = frames.flatMap((frame) => {
43
+ const found = results.find((result) => result.id === frame.id);
44
+ return found
45
+ ? [
46
+ {
47
+ frame,
48
+ result: found,
49
+ },
50
+ ]
51
+ : [];
52
+ });
53
+ if (!captures.length || captures.every(({ result }) => !result.approved)) {
54
+ rows.push({
55
+ requirement,
56
+ status: "missing-capture",
57
+ frameIds,
58
+ });
59
+ continue;
60
+ }
61
+ const approved = captures.some(({ frame, result }) => {
62
+ const evidence = result.approved && "case" in result.approved ? result.approved : undefined;
63
+ return (evidence?.approval?.kind !== "forced-reference" &&
64
+ ![
65
+ "missing",
66
+ "unstable",
67
+ "case-mismatch",
68
+ ].includes(result.status) &&
69
+ evidence?.case &&
70
+ observationHash(evidence.case) === observationHash(frame.case) &&
71
+ result.sha256 === evidence.sha256 &&
72
+ evidence.frameId === result.frameId &&
73
+ evidence.file === result.file &&
74
+ (!result.captured ||
75
+ (evidence.fileId === result.captured.fileId &&
76
+ evidence.pageId === result.captured.pageId)) &&
77
+ evidence.designSize?.width === frame.case.viewport.width &&
78
+ evidence.designSize?.height === frame.case.viewport.height &&
79
+ result.imageWidth === Math.round(frame.case.viewport.width * evidence.scale) &&
80
+ result.imageHeight === Math.round(frame.case.viewport.height * evidence.scale) &&
81
+ (!frame.case.activeThemeIds ||
82
+ (evidence.activeThemeIds &&
83
+ observationHash([
84
+ ...frame.case.activeThemeIds,
85
+ ].sort()) ===
86
+ observationHash([
87
+ ...evidence.activeThemeIds,
88
+ ].sort()))));
89
+ });
90
+ const changed = approved &&
91
+ captures.some(({ result }) => result.status === "changed" &&
92
+ result.captured &&
93
+ result.approved &&
94
+ result.captured.sha256 !== result.approved.sha256);
95
+ rows.push({
96
+ requirement,
97
+ status: changed
98
+ ? "changed-unapproved"
99
+ : approved
100
+ ? "approved-design-intent"
101
+ : "unapproved-metadata",
102
+ frameIds,
103
+ ...(!approved
104
+ ? {
105
+ reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") ||
106
+ "Approved case/viewport/theme evidence is absent or mismatched",
107
+ }
108
+ : {}),
109
+ });
110
+ }
111
+ const unknown = config.frames.flatMap((frame) => {
112
+ const fields = [];
113
+ if (!frame.case) {
114
+ fields.push("viewport", "theme", "state", "fixture", "font environment");
115
+ }
116
+ else {
117
+ if (frame.case.fonts === undefined) {
118
+ fields.push("declared fonts absent");
119
+ }
120
+ else if (frame.case.fonts.length) {
121
+ fields.push("font loading/version not observed");
122
+ }
123
+ if (frame.case.activeThemeIds === undefined) {
124
+ fields.push("theme activation not asserted; label is design intent");
125
+ }
126
+ }
127
+ const result = results.find((result) => result.id === frame.id);
128
+ if (frame.case && !result?.captured?.renderEnvironment) {
129
+ fields.push("renderer environment not observed");
130
+ }
131
+ return fields.length
132
+ ? [
133
+ {
134
+ id: frame.id,
135
+ fields,
136
+ },
137
+ ]
138
+ : [];
139
+ });
140
+ return {
141
+ schemaVersion: 1,
142
+ kind: "baseline-coverage",
143
+ configured: Boolean(config.coverage),
144
+ designCasesComplete: rows.length > 0 && rows.every((row) => row.status === "approved-design-intent"),
145
+ rows,
146
+ unknown,
147
+ browserVerification: "not-run",
148
+ accessibility: "not-assessed",
149
+ };
150
+ }
151
+ async function boundedRead(file, maximum) {
152
+ const handle = await open(file, "r");
153
+ try {
154
+ const info = await handle.stat();
155
+ if (!info.isFile() || info.size > maximum) {
156
+ throw new Error("Artifact exceeds the coverage read budget or is not a regular file");
157
+ }
158
+ return await handle.readFile();
159
+ }
160
+ finally {
161
+ await handle.close();
162
+ }
163
+ }
164
+ /** Offline coverage validates the approved image bytes, never only a manifest's claims. */
165
+ export async function readBaselineCoverage(root, config, expectedFileId) {
166
+ config = structuredClone(config);
167
+ validateBaselines(config, (message) => {
168
+ throw new Error(message);
169
+ });
170
+ const file = await resolveProjectOutput(root, path.join(config.root, "penpair-baselines.json"));
171
+ let manifest;
172
+ try {
173
+ manifest = JSON.parse((await boundedRead(file, 16 * 1024 * 1024)).toString("utf8"));
174
+ }
175
+ catch (error) {
176
+ if (error.code !== "ENOENT") {
177
+ throw error;
178
+ }
179
+ }
180
+ if (!manifest) {
181
+ return baselineCoverage(config);
182
+ }
183
+ if (manifest.schemaVersion !== 2 ||
184
+ !Array.isArray(manifest.frames) ||
185
+ manifest.frames.length > 1000) {
186
+ throw new Error("Coverage needs a bounded schema-2 baseline manifest; regenerate a reviewed capture");
187
+ }
188
+ if (expectedFileId && manifest.fileId !== expectedFileId) {
189
+ throw new Error("Coverage manifest belongs to a different pinned file");
190
+ }
191
+ const ids = new Set();
192
+ for (const value of manifest.frames) {
193
+ if (!value ||
194
+ typeof value.id !== "string" ||
195
+ !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.id) ||
196
+ ids.has(value.id) ||
197
+ ![
198
+ "new",
199
+ "same",
200
+ "changed",
201
+ "missing",
202
+ "unstable",
203
+ "case-mismatch",
204
+ ].includes(value.status) ||
205
+ typeof value.file !== "string" ||
206
+ !value.file) {
207
+ throw new Error("Malformed baseline frame records");
208
+ }
209
+ ids.add(value.id);
210
+ }
211
+ const results = manifest.frames.map((value) => ({
212
+ ...value,
213
+ }));
214
+ for (const result of results) {
215
+ if (!result.approved) {
216
+ continue;
217
+ }
218
+ try {
219
+ if (typeof result.file !== "string" ||
220
+ path.isAbsolute(result.file) ||
221
+ result.file.split(/[\\/]/).includes("..")) {
222
+ throw new Error("Approved image path leaves its baseline folder");
223
+ }
224
+ if ("fileId" in result.approved && result.approved.fileId !== manifest.fileId) {
225
+ throw new Error("Approved image provenance belongs to a different file");
226
+ }
227
+ const target = await resolveProjectOutput(root, path.join(config.root, result.file));
228
+ const bytes = await boundedRead(target, 64 * 1024 * 1024);
229
+ const size = pngSize(bytes);
230
+ if (!size ||
231
+ size.width < 1 ||
232
+ size.height < 1 ||
233
+ createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 ||
234
+ result.sha256 !== result.approved.sha256) {
235
+ throw new Error("Approved PNG bytes do not match their manifest");
236
+ }
237
+ result.imageWidth = size.width;
238
+ result.imageHeight = size.height;
239
+ }
240
+ catch (error) {
241
+ result.status = "case-mismatch";
242
+ result.problems = [
243
+ error instanceof Error ? error.message : String(error),
244
+ ];
245
+ }
246
+ }
247
+ return baselineCoverage(config, results);
248
+ }
@@ -1,3 +1,25 @@
1
+ /** Declared design intent. It does not switch themes or establish browser behavior. */
2
+ export interface BaselineCase {
3
+ viewport: {
4
+ width: number;
5
+ height: number;
6
+ };
7
+ theme: string;
8
+ state: string;
9
+ fixture: string;
10
+ fonts?: string[];
11
+ /** Optional exact active native theme IDs expected during capture. */
12
+ activeThemeIds?: string[];
13
+ }
14
+ export interface CoverageRequirement extends Pick<BaselineCase, "viewport" | "theme" | "state" | "fixture"> {
15
+ route: string;
16
+ }
17
+ export interface CoverageConfig {
18
+ required: CoverageRequirement[];
19
+ unsupported?: (CoverageRequirement & {
20
+ reason: string;
21
+ })[];
22
+ }
1
23
  /** One frame of a Penpot page that becomes an approved reference image. */
2
24
  export interface BaselineFrame {
3
25
  /** The exact name of a top-level frame on the page. */
@@ -6,6 +28,7 @@ export interface BaselineFrame {
6
28
  id: string;
7
29
  /** The route in the implementation this frame is the design of. Frames without one are exported but not given a check. */
8
30
  route?: string;
31
+ case?: BaselineCase;
9
32
  }
10
33
  /** What `penpair baselines` exports: the frames of one page, as images in a folder of the project. */
11
34
  export interface BaselinesConfig {
@@ -15,5 +38,7 @@ export interface BaselinesConfig {
15
38
  frames: BaselineFrame[];
16
39
  /** Allowed share of differing pixels in the generated checks (default 0.05). */
17
40
  threshold?: number;
41
+ coverage?: CoverageConfig;
18
42
  }
19
43
  export declare function validateBaselines(value: unknown, fail: (message: string) => never): BaselinesConfig;
44
+ export declare function validateBaselineCase(value: unknown, fail: (message: string) => never, extraKeys?: string[]): BaselineCase;
@@ -1,31 +1,170 @@
1
1
  import path from "node:path";
2
2
  const idPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
3
3
  export function validateBaselines(value, fail) {
4
- if (typeof value !== "object" || value === null || Array.isArray(value))
4
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
5
5
  return fail("baselines must be an object");
6
+ }
6
7
  const v = value;
7
- if (typeof v.page !== "string" || v.page.length === 0)
8
+ if (typeof v.page !== "string" || v.page.length === 0) {
8
9
  fail("baselines.page must be the name of a page");
9
- if (typeof v.root !== "string" || v.root.length === 0 || path.isAbsolute(v.root) || v.root.split(/[\\/]/).includes(".."))
10
+ }
11
+ if (typeof v.root !== "string" ||
12
+ v.root.length === 0 ||
13
+ path.isAbsolute(v.root) ||
14
+ v.root.split(/[\\/]/).includes("..")) {
10
15
  fail("baselines.root must be a relative folder that stays inside the project");
11
- if (!Array.isArray(v.frames) || v.frames.length === 0)
12
- fail("baselines.frames must list at least one frame");
16
+ }
17
+ if (!Array.isArray(v.frames) || v.frames.length === 0 || v.frames.length > 1000) {
18
+ fail("baselines.frames must list at least one frame, at most 1000");
19
+ }
13
20
  const seen = new Set();
14
21
  for (const entry of v.frames) {
15
22
  const f = entry;
16
- if (typeof f !== "object" || f === null)
23
+ if (typeof f !== "object" || f === null) {
17
24
  fail("every entry in baselines.frames must be an object");
18
- if (typeof f.frame !== "string" || f.frame.length === 0)
25
+ }
26
+ if (typeof f.frame !== "string" || f.frame.length === 0) {
19
27
  fail("every baselines.frames entry needs a frame name");
20
- if (typeof f.id !== "string" || !idPattern.test(f.id))
28
+ }
29
+ if (typeof f.id !== "string" || !idPattern.test(f.id)) {
21
30
  fail(`baselines.frames id ${JSON.stringify(f.id)} must be lowercase letters, digits, hyphens or underscores`);
22
- if (seen.has(f.id))
31
+ }
32
+ if (seen.has(f.id)) {
23
33
  fail(`baselines.frames id ${JSON.stringify(f.id)} is used twice`);
34
+ }
24
35
  seen.add(f.id);
25
- if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0))
36
+ if (f.case !== undefined) {
37
+ validateBaselineCase(f.case, fail);
38
+ if (typeof f.route !== "string" || !f.route) {
39
+ fail("baseline case metadata requires an implementation route");
40
+ }
41
+ }
42
+ if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0)) {
26
43
  fail("baselines.frames route must be a non-empty string");
44
+ }
27
45
  }
28
- if (v.threshold !== undefined && (typeof v.threshold !== "number" || v.threshold < 0 || v.threshold > 1))
46
+ if (v.coverage !== undefined) {
47
+ const coverage = v.coverage;
48
+ if (!coverage ||
49
+ typeof coverage !== "object" ||
50
+ Array.isArray(coverage) ||
51
+ !Array.isArray(coverage.required) ||
52
+ !coverage.required.length ||
53
+ coverage.required.length > 1000 ||
54
+ (coverage.unsupported !== undefined &&
55
+ (!Array.isArray(coverage.unsupported) || coverage.unsupported.length > 1000))) {
56
+ fail("baselines.coverage needs 1–1000 required cases and bounded unsupported cases");
57
+ }
58
+ const required = new Set();
59
+ const key = (value) => JSON.stringify([
60
+ value.route,
61
+ value.viewport.width,
62
+ value.viewport.height,
63
+ value.theme,
64
+ value.state,
65
+ value.fixture,
66
+ ]);
67
+ for (const value of coverage.required) {
68
+ validateBaselineCase(value, fail, [
69
+ "route",
70
+ ]);
71
+ if (typeof value.route !== "string" || !value.route) {
72
+ fail("coverage cases need a route");
73
+ }
74
+ if (required.has(key(value))) {
75
+ fail("Duplicate required coverage case");
76
+ }
77
+ required.add(key(value));
78
+ }
79
+ const unsupported = new Set();
80
+ for (const value of coverage.unsupported ?? []) {
81
+ validateBaselineCase(value, fail, [
82
+ "route",
83
+ "reason",
84
+ ]);
85
+ if (typeof value.route !== "string" ||
86
+ !value.route ||
87
+ typeof value.reason !== "string" ||
88
+ !value.reason.trim()) {
89
+ fail("unsupported cases need route and reason");
90
+ }
91
+ if (!required.has(key(value)) || unsupported.has(key(value))) {
92
+ fail("unsupported cases must uniquely reference required cases");
93
+ }
94
+ unsupported.add(key(value));
95
+ }
96
+ }
97
+ if (v.threshold !== undefined &&
98
+ (typeof v.threshold !== "number" ||
99
+ !Number.isFinite(v.threshold) ||
100
+ v.threshold < 0 ||
101
+ v.threshold > 1)) {
29
102
  fail("baselines.threshold must be a number from 0 to 1");
103
+ }
30
104
  return v;
31
105
  }
106
+ export function validateBaselineCase(value, fail, extraKeys = []) {
107
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
108
+ return fail("baseline case must be an object");
109
+ }
110
+ const entry = value;
111
+ const allowed = new Set([
112
+ "viewport",
113
+ "theme",
114
+ "state",
115
+ "fixture",
116
+ ...(extraKeys.length
117
+ ? []
118
+ : [
119
+ "fonts",
120
+ "activeThemeIds",
121
+ ]),
122
+ ...extraKeys,
123
+ ]);
124
+ if (Object.keys(entry).some((key) => !allowed.has(key))) {
125
+ fail("Unknown baseline case field; capture constraints belong on frame cases");
126
+ }
127
+ if (entry.viewport &&
128
+ Object.keys(entry.viewport).some((key) => ![
129
+ "width",
130
+ "height",
131
+ ].includes(key))) {
132
+ fail("Viewport accepts width and height only");
133
+ }
134
+ for (const key of [
135
+ "theme",
136
+ "state",
137
+ "fixture",
138
+ ]) {
139
+ if (typeof entry[key] !== "string" ||
140
+ !entry[key].trim() ||
141
+ entry[key].length > 200 ||
142
+ /[\x00-\x1f]/.test(entry[key])) {
143
+ fail(`baseline case ${key} needs a short explicit label`);
144
+ }
145
+ }
146
+ for (const key of [
147
+ "width",
148
+ "height",
149
+ ]) {
150
+ if (!entry.viewport ||
151
+ !Number.isInteger(entry.viewport[key]) ||
152
+ entry.viewport[key] < 1 ||
153
+ entry.viewport[key] > 16384) {
154
+ fail("baseline case viewport dimensions must be integers from 1 to 16384");
155
+ }
156
+ }
157
+ for (const key of [
158
+ "fonts",
159
+ "activeThemeIds",
160
+ ]) {
161
+ if (entry[key] !== undefined &&
162
+ (!Array.isArray(entry[key]) ||
163
+ entry[key].length > 100 ||
164
+ entry[key].some((value) => typeof value !== "string" || !value.trim()) ||
165
+ new Set(entry[key]).size !== entry[key].length)) {
166
+ fail(`baseline case ${key} must contain at most 100 unique nonempty strings`);
167
+ }
168
+ }
169
+ return entry;
170
+ }
@@ -1,6 +1,9 @@
1
+ export { pngSize } from "./png.js";
1
2
  import type { PenpotToolClient } from "./client.js";
2
- import type { BaselinesConfig } from "./baselines-config.js";
3
- import { type ExportOptions } from "./export.js";
3
+ import { type BaselineCoverage } from "./baseline-coverage.js";
4
+ import { type BaselineCase, type BaselinesConfig } from "./baselines-config.js";
5
+ import type { LayoutResult } from "./layout.js";
6
+ import { type CaptureIdentity, type ExportOptions } from "./export.js";
4
7
  export * from "./baselines-config.js";
5
8
  export type BaselineStatus =
6
9
  /** No image in the folder yet. */
@@ -12,8 +15,31 @@ export type BaselineStatus =
12
15
  /** The export never settled (two identical renders in a row), so it is not trusted. */
13
16
  | "unstable"
14
17
  /** The page has no top-level frame with that name. */
15
- | "missing";
18
+ | "missing" | "case-mismatch";
19
+ export interface ArtifactProvenance extends CaptureIdentity {
20
+ approval?: {
21
+ kind: "owner-words";
22
+ id: string;
23
+ recordHash: string;
24
+ } | {
25
+ kind: "forced-reference";
26
+ reason: string;
27
+ };
28
+ case?: BaselineCase;
29
+ layout?: LayoutResult;
30
+ sha256: string;
31
+ capturedAt: string;
32
+ toolVersion: string;
33
+ file: string;
34
+ }
16
35
  export interface BaselineResult {
36
+ case?: BaselineCase;
37
+ problems?: string[];
38
+ captured?: ArtifactProvenance;
39
+ approved?: ArtifactProvenance | {
40
+ legacy: true;
41
+ sha256: string;
42
+ };
17
43
  id: string;
18
44
  frame: string;
19
45
  frameId?: string;
@@ -30,6 +56,10 @@ export interface BaselineResult {
30
56
  imageHeight?: number;
31
57
  }
32
58
  export interface BaselinesReport {
59
+ coverage: BaselineCoverage;
60
+ complete: boolean;
61
+ fileId: string;
62
+ pageId: string;
33
63
  page: string;
34
64
  penpotFile: string;
35
65
  revn: number;
@@ -38,16 +68,14 @@ export interface BaselinesReport {
38
68
  wrote: string[];
39
69
  }
40
70
  export interface BaselinesOptions extends ExportOptions {
71
+ signal?: AbortSignal;
41
72
  /** Write images that are new. Without it nothing is written, whatever is found. */
42
73
  apply?: boolean;
43
74
  /** Also replace images whose design has changed. This is the act of approving the new design as the reference; it is the owner's call. */
44
75
  update?: boolean;
76
+ /** Explicit waiver for a changed baseline; retained as a forced reference, never an owner approval. */
77
+ forceReason?: string;
45
78
  }
46
- /** Pixel size from a PNG's header (the IHDR chunk), or nothing when the bytes are not a PNG. */
47
- export declare function pngSize(buffer: Buffer): {
48
- width: number;
49
- height: number;
50
- } | undefined;
51
79
  /**
52
80
  * Exports the configured frames of a page as images into the project's folder: the approved design that an implementation is
53
81
  * checked against. Looks first; writes only with `apply`, and replaces an image that differs only with `update`, so a change of
@@ -69,4 +97,5 @@ export declare function reflectionCases(config: BaselinesConfig, results: Baseli
69
97
  }[];
70
98
  }>;
71
99
  visualSmoke: Record<string, unknown>[];
100
+ pendingCases: Record<string, unknown>[];
72
101
  };