@penpair/core 0.3.7 → 0.4.1

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 (142) hide show
  1. package/dist/adapters.d.ts +1 -1
  2. package/dist/adapters.js +4 -0
  3. package/dist/approvals.d.ts +4 -1
  4. package/dist/approvals.js +7 -4
  5. package/dist/baseline-coverage.js +3 -3
  6. package/dist/baselines-config.d.ts +3 -0
  7. package/dist/baselines-config.js +3 -0
  8. package/dist/baselines.d.ts +7 -0
  9. package/dist/baselines.js +90 -21
  10. package/dist/bounded-file.d.ts +2 -0
  11. package/dist/bounded-file.js +38 -0
  12. package/dist/browser-wake.d.ts +22 -0
  13. package/dist/browser-wake.js +241 -0
  14. package/dist/cli-main.js +193 -18
  15. package/dist/design-commands/context.d.ts +9 -0
  16. package/dist/design-commands/context.js +1 -0
  17. package/dist/design-commands/index.d.ts +3 -0
  18. package/dist/design-commands/index.js +7 -0
  19. package/dist/design-commands/map-command.d.ts +4 -0
  20. package/dist/design-commands/map-command.js +685 -0
  21. package/dist/design-commands/release-command.d.ts +3 -0
  22. package/dist/design-commands/release-command.js +239 -0
  23. package/dist/design-releases/artifact-paths.d.ts +6 -0
  24. package/dist/design-releases/artifact-paths.js +82 -0
  25. package/dist/design-releases/assemble.d.ts +3 -0
  26. package/dist/design-releases/assemble.js +176 -0
  27. package/dist/design-releases/check.d.ts +16 -0
  28. package/dist/design-releases/check.js +323 -0
  29. package/dist/design-releases/context.d.ts +2 -0
  30. package/dist/design-releases/context.js +119 -0
  31. package/dist/design-releases/contract-graph.d.ts +13 -0
  32. package/dist/design-releases/contract-graph.js +75 -0
  33. package/dist/design-releases/fidelity.d.ts +23 -0
  34. package/dist/design-releases/fidelity.js +328 -0
  35. package/dist/design-releases/finalize.d.ts +4 -0
  36. package/dist/design-releases/finalize.js +201 -0
  37. package/dist/design-releases/index.d.ts +8 -0
  38. package/dist/design-releases/index.js +7 -0
  39. package/dist/design-releases/live.d.ts +22 -0
  40. package/dist/design-releases/live.js +97 -0
  41. package/dist/design-releases/snippets.d.ts +5 -0
  42. package/dist/design-releases/snippets.js +89 -0
  43. package/dist/design-releases/store.d.ts +10 -0
  44. package/dist/design-releases/store.js +205 -0
  45. package/dist/design-releases/types.d.ts +161 -0
  46. package/dist/design-releases/types.js +1 -0
  47. package/dist/design-releases/validation.d.ts +6 -0
  48. package/dist/design-releases/validation.js +384 -0
  49. package/dist/design-releases/verify.d.ts +3 -0
  50. package/dist/design-releases/verify.js +148 -0
  51. package/dist/errors.js +1 -1
  52. package/dist/image-codec.d.ts +3 -0
  53. package/dist/image-codec.js +78 -0
  54. package/dist/image-evidence.d.ts +15 -0
  55. package/dist/image-evidence.js +121 -0
  56. package/dist/index.d.ts +5 -0
  57. package/dist/index.js +5 -0
  58. package/dist/inspection.js +13 -5
  59. package/dist/instance/config-schema.d.ts +4 -0
  60. package/dist/instance/config-schema.js +7 -0
  61. package/dist/instance/connection.d.ts +2 -0
  62. package/dist/instance/connection.js +15 -3
  63. package/dist/kit-source.js +1 -1
  64. package/dist/layout-policy.js +21 -11
  65. package/dist/layout-settling.d.ts +1 -0
  66. package/dist/layout-settling.js +51 -17
  67. package/dist/layout.d.ts +7 -1
  68. package/dist/layout.js +19 -3
  69. package/dist/map-penpot/content-validation.d.ts +3 -0
  70. package/dist/map-penpot/content-validation.js +57 -0
  71. package/dist/map-penpot/content.d.ts +22 -0
  72. package/dist/map-penpot/content.js +173 -0
  73. package/dist/map-penpot/details.d.ts +4 -0
  74. package/dist/map-penpot/details.js +84 -0
  75. package/dist/map-penpot/evidence-error.d.ts +5 -0
  76. package/dist/map-penpot/evidence-error.js +9 -0
  77. package/dist/map-penpot/icons.d.ts +5 -0
  78. package/dist/map-penpot/icons.js +79 -0
  79. package/dist/map-penpot/import-content.d.ts +2 -0
  80. package/dist/map-penpot/import-content.js +150 -0
  81. package/dist/map-penpot/import.d.ts +5 -0
  82. package/dist/map-penpot/import.js +255 -0
  83. package/dist/map-penpot/index.d.ts +11 -0
  84. package/dist/map-penpot/index.js +10 -0
  85. package/dist/map-penpot/path-geometry.d.ts +5 -0
  86. package/dist/map-penpot/path-geometry.js +51 -0
  87. package/dist/map-penpot/presentation.d.ts +20 -0
  88. package/dist/map-penpot/presentation.js +205 -0
  89. package/dist/map-penpot/projection.d.ts +8 -0
  90. package/dist/map-penpot/projection.js +176 -0
  91. package/dist/map-penpot/publish.d.ts +4 -0
  92. package/dist/map-penpot/publish.js +301 -0
  93. package/dist/map-penpot/release-maps.d.ts +21 -0
  94. package/dist/map-penpot/release-maps.js +104 -0
  95. package/dist/map-penpot/snippets.d.ts +4 -0
  96. package/dist/map-penpot/snippets.js +164 -0
  97. package/dist/map-penpot/status-labels.d.ts +7 -0
  98. package/dist/map-penpot/status-labels.js +7 -0
  99. package/dist/map-penpot/types.d.ts +161 -0
  100. package/dist/map-penpot/types.js +1 -0
  101. package/dist/maps/annotation-layout.d.ts +24 -0
  102. package/dist/maps/annotation-layout.js +85 -0
  103. package/dist/maps/context.d.ts +13 -0
  104. package/dist/maps/context.js +207 -0
  105. package/dist/maps/index.d.ts +9 -0
  106. package/dist/maps/index.js +9 -0
  107. package/dist/maps/layout.d.ts +35 -0
  108. package/dist/maps/layout.js +364 -0
  109. package/dist/maps/proposal.d.ts +5 -0
  110. package/dist/maps/proposal.js +67 -0
  111. package/dist/maps/render.d.ts +4 -0
  112. package/dist/maps/render.js +331 -0
  113. package/dist/maps/review.d.ts +16 -0
  114. package/dist/maps/review.js +90 -0
  115. package/dist/maps/store.d.ts +21 -0
  116. package/dist/maps/store.js +222 -0
  117. package/dist/maps/types.d.ts +206 -0
  118. package/dist/maps/types.js +1 -0
  119. package/dist/maps/validation.d.ts +10 -0
  120. package/dist/maps/validation.js +375 -0
  121. package/dist/plugin-health.d.ts +13 -0
  122. package/dist/plugin-health.js +39 -0
  123. package/dist/skill-routing.js +4 -0
  124. package/dist/version.d.ts +1 -1
  125. package/dist/version.js +1 -1
  126. package/package.json +2 -1
  127. package/skills/penpair-design-release/SKILL.md +55 -0
  128. package/skills/penpair-design-release/agents/openai.yaml +6 -0
  129. package/skills/penpair-design-release/references/workflow.md +55 -0
  130. package/skills/penpair-diagrams/SKILL.md +52 -0
  131. package/skills/penpair-diagrams/agents/openai.yaml +6 -0
  132. package/skills/penpair-diagrams/references/workflow.md +84 -0
  133. package/skills/penpair-handoff/SKILL.md +7 -0
  134. package/skills/penpair-handoff/references/handoff-flow.md +7 -1
  135. package/skills/penpair-integration-map/SKILL.md +44 -0
  136. package/skills/penpair-integration-map/agents/openai.yaml +6 -0
  137. package/skills/penpair-integration-map/references/workflow.md +33 -0
  138. package/skills/penpair-journey-map/SKILL.md +42 -0
  139. package/skills/penpair-journey-map/agents/openai.yaml +6 -0
  140. package/skills/penpair-journey-map/references/workflow.md +35 -0
  141. package/skills/penpair-verify/SKILL.md +7 -0
  142. package/skills/penpair-verify/references/browser-evidence.md +10 -0
@@ -0,0 +1,121 @@
1
+ import { open } from "node:fs/promises";
2
+ import { resolveProjectInput } from "./project-input.js";
3
+ export const maximumImageBytes = 64 * 1024 * 1024;
4
+ function losslessWebpSize(bytes) {
5
+ const malformed = () => new Error("Malformed static lossless WebP evidence");
6
+ if (bytes.readUInt32LE(4) + 8 !== bytes.length) {
7
+ throw malformed();
8
+ }
9
+ let imageSize;
10
+ let canvasSize;
11
+ let offset = 12;
12
+ while (offset < bytes.length) {
13
+ if (offset + 8 > bytes.length) {
14
+ throw malformed();
15
+ }
16
+ const chunk = bytes.subarray(offset, offset + 4).toString("latin1");
17
+ const size = bytes.readUInt32LE(offset + 4);
18
+ const payload = offset + 8;
19
+ const end = payload + size;
20
+ const paddedEnd = end + (size % 2);
21
+ if (paddedEnd > bytes.length || (size % 2 !== 0 && bytes[end] !== 0)) {
22
+ throw malformed();
23
+ }
24
+ if (chunk === "VP8 " || chunk === "ANIM" || chunk === "ANMF" || chunk === "ALPH") {
25
+ throw new Error("Provider evidence must reference PNG or static lossless WebP bytes");
26
+ }
27
+ if (chunk === "VP8X") {
28
+ if (offset !== 12 ||
29
+ canvasSize ||
30
+ size !== 10 ||
31
+ (bytes[payload] & 0xc3) !== 0 ||
32
+ bytes.readUIntLE(payload + 1, 3) !== 0) {
33
+ throw malformed();
34
+ }
35
+ canvasSize = {
36
+ width: bytes.readUIntLE(payload + 4, 3) + 1,
37
+ height: bytes.readUIntLE(payload + 7, 3) + 1,
38
+ };
39
+ }
40
+ else if (chunk === "VP8L") {
41
+ if (imageSize || size <= 5 || bytes[payload] !== 0x2f) {
42
+ throw malformed();
43
+ }
44
+ const dimensions = bytes.readUInt32LE(payload + 1);
45
+ if (dimensions >>> 29 !== 0) {
46
+ throw malformed();
47
+ }
48
+ imageSize = {
49
+ width: (dimensions & 0x3fff) + 1,
50
+ height: ((dimensions >>> 14) & 0x3fff) + 1,
51
+ };
52
+ }
53
+ else if (offset === 12) {
54
+ throw malformed();
55
+ }
56
+ offset = paddedEnd;
57
+ }
58
+ if (!imageSize ||
59
+ (canvasSize && (canvasSize.width !== imageSize.width || canvasSize.height !== imageSize.height))) {
60
+ throw malformed();
61
+ }
62
+ return imageSize;
63
+ }
64
+ /** Header/container assurance only; this does not decode compressed pixels. */
65
+ export function inspectImageEvidence(bytes) {
66
+ if (bytes.length > maximumImageBytes) {
67
+ throw new Error("Image exceeds 64 MiB");
68
+ }
69
+ if (bytes.length >= 24 &&
70
+ bytes.subarray(0, 8).toString("hex") === "89504e470d0a1a0a" &&
71
+ bytes.subarray(12, 16).toString("latin1") === "IHDR") {
72
+ const width = bytes.readUInt32BE(16);
73
+ const height = bytes.readUInt32BE(20);
74
+ if (width > 0 && height > 0) {
75
+ return {
76
+ format: "png",
77
+ width,
78
+ height,
79
+ };
80
+ }
81
+ }
82
+ else if (bytes.length >= 12 &&
83
+ bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
84
+ bytes.subarray(8, 12).toString("latin1") === "WEBP") {
85
+ return {
86
+ format: "webp",
87
+ ...losslessWebpSize(bytes),
88
+ };
89
+ }
90
+ throw new Error("Provider evidence must reference PNG or static lossless WebP bytes");
91
+ }
92
+ export async function readImageEvidence(root, file) {
93
+ const input = await resolveProjectInput(root, file);
94
+ const handle = await open(input, "r");
95
+ try {
96
+ const info = await handle.stat();
97
+ if (!info.isFile() || info.size > maximumImageBytes) {
98
+ throw new Error("Image exceeds 64 MiB or is not a regular file");
99
+ }
100
+ const buffer = Buffer.alloc(info.size + 1);
101
+ let length = 0;
102
+ while (length < buffer.length) {
103
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
104
+ if (bytesRead === 0) {
105
+ break;
106
+ }
107
+ length += bytesRead;
108
+ }
109
+ if (length > info.size) {
110
+ throw new Error("Image changed during bounded read");
111
+ }
112
+ const bytes = buffer.subarray(0, length);
113
+ return {
114
+ bytes,
115
+ ...inspectImageEvidence(bytes),
116
+ };
117
+ }
118
+ finally {
119
+ await handle.close();
120
+ }
121
+ }
package/dist/index.d.ts CHANGED
@@ -56,3 +56,8 @@ export * from "./project-status.js";
56
56
  export * from "./audit-rules.js";
57
57
  export * from "./credentials.js";
58
58
  export * from "./token-projection.js";
59
+ export * from "./image-evidence.js";
60
+ export * from "./image-codec.js";
61
+ export * from "./maps/index.js";
62
+ export * from "./map-penpot/index.js";
63
+ export * from "./design-releases/index.js";
package/dist/index.js CHANGED
@@ -55,3 +55,8 @@ export * from "./project-status.js";
55
55
  export * from "./audit-rules.js";
56
56
  export * from "./credentials.js";
57
57
  export * from "./token-projection.js";
58
+ export * from "./image-evidence.js";
59
+ export * from "./image-codec.js";
60
+ export * from "./maps/index.js";
61
+ export * from "./map-penpot/index.js";
62
+ export * from "./design-releases/index.js";
@@ -79,6 +79,7 @@ const clone = (value, unknowns, key) => {
79
79
  } catch { unknowns.push(key); return null; }
80
80
  };
81
81
  const read = (object,key,unknowns,label=key) => { try { return clone(object[key],unknowns,label); } catch { unknowns.push(label); return null; } };
82
+ const optional = (object,key,unknowns,label=key) => { try { const value=object[key]; return value===undefined?null:clone(value,unknowns,label); } catch {unknowns.push(label);return null;} };
82
83
  const pick = (object,keys,unknowns,prefix) => Object.fromEntries(keys.map(key=>[key,read(object,key,unknowns,prefix+"."+key)]));
83
84
  let estimatedBytes = 1024;
84
85
  if (CFG.kind === "shape") {
@@ -97,11 +98,11 @@ if (CFG.kind === "shape") {
97
98
  if (item.depth <= CFG.depth) {
98
99
  const unknowns = [], row = {id:s.id,parentId:item.parentId,index:item.index,depth:item.depth,type:read(s,"type",unknowns),name:read(s,"name",unknowns),childIds:children.slice(0,CFG.maxNodes).map(c=>c.id),childrenTotal:children.length,unknowns};
99
100
  if (children.length > CFG.maxNodes) reasons.add("node-budget");
100
- if (CFG.fields.includes("geometry")) row.geometry = pick(s,["x","y","width","height","rotation","hidden","blocked"],unknowns,"geometry");
101
- if (CFG.fields.includes("text")) row.text = s.type === "text" ? {characters:read(s,"characters",unknowns),typography:pick(s,["fontId","fontFamily","fontVariantId","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","growType","direction","align","verticalAlign"],unknowns,"typography")} : null;
101
+ if (CFG.fields.includes("geometry")) row.geometry = pick(s,["x","y","width","height","rotation","hidden","blocked","flipX","flipY","proportionLock"],unknowns,"geometry");
102
+ if (CFG.fields.includes("text")) row.text = s.type === "text" ? {characters:read(s,"characters",unknowns),typography:pick(s,["fontId","fontFamily","fontVariantId","fontSize","fontWeight","fontStyle","lineHeight","letterSpacing","growType","direction","align","verticalAlign","textTransform","textDecoration"],unknowns,"typography")} : null;
102
103
  if (row.text && Object.values(row.text.typography).includes("mixed")) unknowns.push("text.mixedRangeStyles");
103
104
  if (CFG.fields.includes("layout")) {
104
- row.layout = {};
105
+ row.layout = {constraints:pick(s,["constraintsHorizontal","constraintsVertical","fixedWhenScrolling"],unknowns,"layout.constraints")};
105
106
  for (const [key,keys] of [["flex",["dir","wrap","alignItems","alignContent","justifyContent","rowGap","columnGap","topPadding","rightPadding","bottomPadding","leftPadding","horizontalSizing","verticalSizing"]],["layoutChild",["absolute","zIndex","horizontalSizing","verticalSizing","alignSelf","topMargin","rightMargin","bottomMargin","leftMargin","minWidth","maxWidth","minHeight","maxHeight"]]]) {
106
107
  try { row.layout[key] = s[key] ? pick(s[key],keys,unknowns,"layout."+key) : null; } catch { row.layout[key]=null; unknowns.push("layout."+key); }
107
108
  }
@@ -111,10 +112,17 @@ if (CFG.kind === "shape") {
111
112
  if(grid)for(const key of ["rows","columns"])row.layout.grid[key]=(grid[key]||[]).map((track,index)=>pick(track,["type","value"],unknowns,"layout.grid."+key+"."+index));
112
113
  } catch {row.layout.grid=null;unknowns.push("layout.grid");}
113
114
  try {row.layout.cell=s.layoutCell?pick(s.layoutCell,["row","rowSpan","column","columnSpan","areaName","position"],unknowns,"layout.cell"):null;} catch {row.layout.cell=null;unknowns.push("layout.cell");}
114
- row.layout.clipContent = read(s,"clipContent",unknowns,"layout.clipContent");
115
+ row.layout.clipContent = s.type === "board" ? read(s,"clipContent",unknowns,"layout.clipContent") : null;
116
+ row.layout.showInViewMode = s.type === "board" ? read(s,"showInViewMode",unknowns,"layout.showInViewMode") : null;
117
+ row.layout.boardSizing = s.type === "board" ? Object.fromEntries(["horizontalSizing","verticalSizing"].map(key=>[key,optional(s,key,unknowns,"layout.boardSizing."+key)])) : null;
115
118
  }
116
119
  if (CFG.fields.includes("tokens")) row.tokens = read(s,"tokens",unknowns);
117
- if (CFG.fields.includes("style")) row.style = pick(s,["fills","strokes","shadows","opacity","blendMode","borderRadiusTopLeft","borderRadiusTopRight","borderRadiusBottomRight","borderRadiusBottomLeft"],unknowns,"style");
120
+ if (CFG.fields.includes("style")) {
121
+ row.style = pick(s,["fills","strokes","shadows","opacity","blendMode","borderRadiusTopLeft","borderRadiusTopRight","borderRadiusBottomRight","borderRadiusBottomLeft"],unknowns,"style");
122
+ for (const key of ["blur","backgroundBlur"]) {
123
+ row.style[key]=optional(s,key,unknowns,"style."+key);
124
+ }
125
+ }
118
126
  if (CFG.fields.includes("component")) {
119
127
  row.component = {};
120
128
  for (const key of ["isComponentInstance","isComponentMainInstance","isComponentCopyInstance","isComponentRoot"]) {
@@ -160,6 +160,10 @@ export declare const projectConfigSchema: {
160
160
  readonly type: "object";
161
161
  readonly required: readonly ["page", "root", "frames"];
162
162
  readonly properties: {
163
+ readonly format: {
164
+ readonly enum: readonly ["png", "webp"];
165
+ readonly description: "Format for new references; existing approved formats are preserved unless explicitly migrated.";
166
+ };
163
167
  readonly page: {
164
168
  readonly type: "string";
165
169
  readonly minLength: 1;
@@ -280,6 +280,13 @@ export const projectConfigSchema = {
280
280
  "frames",
281
281
  ],
282
282
  properties: {
283
+ format: {
284
+ enum: [
285
+ "png",
286
+ "webp",
287
+ ],
288
+ description: "Format for new references; existing approved formats are preserved unless explicitly migrated.",
289
+ },
283
290
  page: {
284
291
  type: "string",
285
292
  minLength: 1,
@@ -1,3 +1,4 @@
1
+ import { type PluginHealth } from "../plugin-health.js";
1
2
  import { type ExternalConfig, type InstanceConfig, type ProjectConfig } from "./config.js";
2
3
  /** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
3
4
  export declare class LifecycleError extends Error {
@@ -18,6 +19,7 @@ export interface DoctorCheck extends Check {
18
19
  pageId?: string;
19
20
  apiVersion?: string;
20
21
  heartbeat?: "unknown";
22
+ pluginHealth?: PluginHealth;
21
23
  };
22
24
  remedy?: string;
23
25
  file?: {
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { commandSignal, withCommandSignal } from "../command-result.js";
3
3
  import { redactEndpoint } from "../endpoint.js";
4
+ import { pluginHealthFromError, responsivePluginHealth, } from "../plugin-health.js";
4
5
  import { isExternal, } from "./config.js";
5
6
  /** What Penpot itself, the connection and the checks need, whichever way Penpot is run. Nothing here knows about Docker. */
6
7
  export class LifecycleError extends Error {
@@ -105,7 +106,7 @@ export async function doctorEditor(config, options) {
105
106
  check: "editor and plugin",
106
107
  layer: "execution",
107
108
  ok: true,
108
- detail: `Fresh read from file "${actual.file}"`,
109
+ detail: `Fresh read from file "${actual.file}"; heartbeat age unavailable; keep-alive unknown`,
109
110
  observedAt: new Date().toISOString(),
110
111
  durationMs: Date.now() - started,
111
112
  evidence: {
@@ -117,11 +118,16 @@ export async function doctorEditor(config, options) {
117
118
  }
118
119
  : {}),
119
120
  heartbeat: "unknown",
121
+ pluginHealth: responsivePluginHealth(),
120
122
  },
121
123
  });
122
124
  out.push(fileIdentityCheck(config, actual));
123
125
  }
124
126
  catch (error) {
127
+ const pluginHealth = pluginHealthFromError(error);
128
+ const heartbeatDetail = pluginHealth.heartbeatAgeMs === null
129
+ ? "heartbeat age unavailable"
130
+ : `last heartbeat ${pluginHealth.heartbeatAgeMs / 1000}s ago (reported by upstream)`;
125
131
  if (!connected) {
126
132
  out.push({
127
133
  check: "MCP transport",
@@ -136,10 +142,16 @@ export async function doctorEditor(config, options) {
136
142
  check: "editor and plugin",
137
143
  layer: "execution",
138
144
  ok: false,
139
- detail: redactEndpoint(error instanceof Error ? error.message : String(error)),
145
+ detail: `${redactEndpoint(error instanceof Error ? error.message : String(error))}; ${heartbeatDetail}; keep-alive unknown`,
140
146
  observedAt: new Date().toISOString(),
141
147
  durationMs: Date.now() - started,
142
- remedy: "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
148
+ evidence: {
149
+ heartbeat: "unknown",
150
+ pluginHealth,
151
+ },
152
+ remedy: pluginHealth.state === "suspended"
153
+ ? "Bring the intended Penpot tab to the foreground, then rerun doctor for a fresh read. Reconcile any uncertain run before replaying a write."
154
+ : "Open the intended file, connect the MCP plugin and keep the editor responsive; rerun doctor for a fresh read.",
143
155
  });
144
156
  }
145
157
  finished = true;
@@ -225,7 +225,7 @@ const pp = (() => {
225
225
  try { return await settleLayout(async nativeMs => {
226
226
  if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed between samples");
227
227
  return observeLayout(penpot, root, { nativeMs, maxNodes: options.maxNodes });
228
- }, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, sleep: wait }); }
228
+ }, { timeoutMs: options.timeoutMs === undefined ? 10000 : options.timeoutMs, intervalMs: options.intervalMs === undefined ? 100 : options.intervalMs, strategy: options.strategy === undefined ? "auto" : options.strategy, maxNodes: options.maxNodes, sleep: wait }); }
229
229
  catch (error) { return { stable: false, method: "unavailable", samples: 0, reason: String(error && error.message || error) }; }
230
230
  };
231
231
  const flush = async (options = {}) => {
@@ -2,21 +2,26 @@ import { settleLayout } from "./layout-settling.js";
2
2
  /** Trusted, dependency-free observation source for the Penpot sandbox. */
3
3
  const layoutObservationSource = String.raw `
4
4
  const observeLayout = async (penpot, root, options) => {
5
- if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 10000))) throw new Error("Invalid settling node budget");
5
+ if (!Number.isInteger(options.maxNodes === undefined ? 10000 : options.maxNodes) || (options.maxNodes !== undefined && (options.maxNodes < 1 || options.maxNodes > 100000))) throw new Error("Invalid settling node budget");
6
+ const roots = Array.isArray(root) ? root : [root];
7
+ if (!roots.length || roots.some(shape => !shape)) throw new Error("layout target does not exist");
6
8
  const fileId = penpot.currentFile.id, pageId = penpot.currentPage.id;
7
9
  const validTarget = () => {
8
10
  if (penpot.currentFile.id !== fileId || penpot.currentPage.id !== pageId) throw new Error("Layout target changed while waiting");
9
- if (root.id !== penpot.currentPage.root.id && typeof penpot.currentPage.getShapeById === "function" && !penpot.currentPage.getShapeById(root.id)) throw new Error("Layout target no longer exists");
11
+ for (const shape of roots) {
12
+ if (shape.id !== penpot.currentPage.root.id && typeof penpot.currentPage.getShapeById === "function" && !penpot.currentPage.getShapeById(shape.id)) throw new Error("Layout target no longer exists");
13
+ }
10
14
  };
11
- if (!root) throw new Error("layout target does not exist");
15
+
12
16
  let nativeOutcome = "skipped", nativeError;
13
- const target = typeof root.waitForLayoutUpdate === "function" ? root : penpot;
17
+ validTarget();
18
+ const nativeTargets = roots.every(shape => typeof shape.waitForLayoutUpdate === "function") ? roots : [penpot];
14
19
  if (options.nativeMs > 0) {
15
20
  nativeOutcome = "unsupported";
16
- if (typeof target.waitForLayoutUpdate === "function") {
21
+ if (nativeTargets.every(target => typeof target.waitForLayoutUpdate === "function")) {
17
22
  let timer;
18
23
  try {
19
- await Promise.race([target.waitForLayoutUpdate(options.nativeMs), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
24
+ await Promise.race([Promise.all(nativeTargets.map(target => target.waitForLayoutUpdate(options.nativeMs))), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Native layout attempt timed out")), options.nativeMs); })]);
20
25
  nativeOutcome = "completed";
21
26
  } catch (error) { nativeOutcome = "failed"; nativeError = String(error && error.message || error); }
22
27
  finally { clearTimeout(timer); }
@@ -24,20 +29,25 @@ const observeLayout = async (penpot, root, options) => {
24
29
  }
25
30
  validTarget();
26
31
  // Keep hierarchy and sibling order as well as geometry; repeated IDs include cycles.
27
- const geometry = [], stack = [[root, null, 0]], seen = new Set();
32
+ const geometry = [], stack = roots.map(shape => [shape, null, 0]).reverse(), seen = new Set();
33
+ let truncated = false;
28
34
  while (stack.length) {
29
- if (geometry.length >= (options.maxNodes || 10000)) throw new Error("Layout sample exceeded node budget");
35
+ if (geometry.length >= (options.maxNodes || 10000)) { truncated = true; break; }
30
36
  const [shape, parent, index] = stack.pop();
31
37
  if (!shape || typeof shape.id !== "string" || !shape.id || seen.has(shape.id) || ![shape.x, shape.y, shape.width, shape.height].every(Number.isFinite)) throw new Error("Malformed layout geometry or duplicate IDs");
32
38
  seen.add(shape.id);
33
39
  geometry.push([shape.id, shape.x, shape.y, shape.width, shape.height, parent, index]);
34
40
  const children = shape.children || [];
35
41
  if (!Array.isArray(children)) throw new Error("Malformed layout hierarchy");
36
- if (geometry.length + stack.length + children.length > (options.maxNodes || 10000)) throw new Error("Layout sample exceeded node budget");
37
- for (let index = children.length - 1; index >= 0; index--) stack.push([children[index], shape.id, index]);
42
+ const available = (options.maxNodes || 10000) - geometry.length;
43
+ if (stack.length + children.length > available) truncated = true;
44
+ // Only retain the bounded depth-first prefix; avoid allocating a page-sized traversal stack.
45
+ if (children.length >= available) stack.length = 0;
46
+ else if (stack.length > available - children.length) stack.splice(0, stack.length - (available - children.length));
47
+ for (let index = Math.min(children.length, available) - 1; index >= 0; index--) stack.push([children[index], shape.id, index]);
38
48
  }
39
49
  validTarget();
40
- return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, truncated: false };
50
+ return { ok: true, fileId, pageId, native: nativeOutcome === "completed", nativeOutcome, nativeError, geometry, rootIds: roots.map(shape => shape.id), truncated };
41
51
  };
42
52
  `;
43
53
  export const layoutPolicySource = `${layoutObservationSource}\nconst settleLayout = ${settleLayout.toString()};`;
@@ -1,5 +1,6 @@
1
1
  import type { LayoutResult, LayoutStrategy } from "./layout.js";
2
2
  interface LayoutSettlingOptions {
3
+ maxNodes?: number;
3
4
  timeoutMs: number;
4
5
  intervalMs: number;
5
6
  strategy: LayoutStrategy;
@@ -1,11 +1,14 @@
1
1
  /** Self-contained policy: its JavaScript source also runs inside the Penpot sandbox. */
2
2
  export async function settleLayout(observe, options) {
3
- const { timeoutMs, intervalMs, strategy = "auto", sleep } = options;
4
- if (![
5
- "auto",
6
- "native",
7
- "geometry",
8
- ].includes(strategy) ||
3
+ const { timeoutMs, intervalMs, strategy = "auto", maxNodes = 10000, sleep } = options;
4
+ if (!Number.isInteger(maxNodes) ||
5
+ maxNodes < 1 ||
6
+ maxNodes > 100000 ||
7
+ ![
8
+ "auto",
9
+ "native",
10
+ "geometry",
11
+ ].includes(strategy) ||
9
12
  !Number.isFinite(timeoutMs) ||
10
13
  timeoutMs < 1 ||
11
14
  timeoutMs > 30000 ||
@@ -17,6 +20,9 @@ export async function settleLayout(observe, options) {
17
20
  const started = Date.now();
18
21
  const deadline = started + timeoutMs;
19
22
  let samples = 0;
23
+ let nodeCount = 0;
24
+ let rootIds = [];
25
+ let truncated = false;
20
26
  let nativeOutcome = "skipped";
21
27
  let fallbackReason;
22
28
  let targetIdentity;
@@ -24,8 +30,12 @@ export async function settleLayout(observe, options) {
24
30
  let matchingSamples = 0;
25
31
  const createResult = (stable, method, reason) => ({
26
32
  stable,
27
- method,
33
+ method: truncated ? "partial" : method,
28
34
  samples,
35
+ rootIds,
36
+ nodeCount,
37
+ maxNodes,
38
+ truncated,
29
39
  strategy,
30
40
  nativeOutcome,
31
41
  ...(fallbackReason
@@ -34,9 +44,10 @@ export async function settleLayout(observe, options) {
34
44
  }
35
45
  : {}),
36
46
  elapsedMs: Date.now() - started,
37
- ...(reason
47
+ ...(reason || truncated
38
48
  ? {
39
- reason,
49
+ reason: reason ??
50
+ "Only the bounded layout sample was observed; independent readback is required",
40
51
  }
41
52
  : {}),
42
53
  });
@@ -74,20 +85,34 @@ export async function settleLayout(observe, options) {
74
85
  const nextTargetIdentity = JSON.stringify([
75
86
  observation.fileId,
76
87
  observation.pageId,
77
- observation.geometry?.[0]?.[0],
88
+ observation.rootIds ?? [
89
+ observation.geometry?.[0]?.[0],
90
+ ],
78
91
  ]);
79
92
  if (targetIdentity && targetIdentity !== nextTargetIdentity) {
80
93
  throw new Error("Layout target changed between samples");
81
94
  }
82
95
  targetIdentity = nextTargetIdentity;
83
- if (observation.truncated ||
84
- !Array.isArray(observation.geometry) ||
96
+ if (!Array.isArray(observation.geometry) ||
85
97
  !observation.geometry.length ||
86
- observation.geometry.length > 10000) {
98
+ observation.geometry.length > maxNodes) {
87
99
  throw new Error("Incomplete layout geometry evidence");
88
100
  }
101
+ const observedRoots = observation.rootIds ?? [
102
+ observation.geometry[0][0],
103
+ ];
104
+ if (!Array.isArray(observedRoots) ||
105
+ !observedRoots.length ||
106
+ observedRoots.some((id) => typeof id !== "string" || !id) ||
107
+ new Set(observedRoots).size !== observedRoots.length) {
108
+ throw new Error("Malformed layout root evidence");
109
+ }
110
+ rootIds = observedRoots;
111
+ nodeCount = observation.geometry.length;
112
+ truncated = observation.truncated;
89
113
  const observedShapeIds = new Set();
90
114
  const siblingCountByParent = new Map();
115
+ let rootIndex = 0;
91
116
  for (const geometryRow of observation.geometry) {
92
117
  if (!Array.isArray(geometryRow) ||
93
118
  geometryRow.length !== 7 ||
@@ -102,9 +127,12 @@ export async function settleLayout(observe, options) {
102
127
  geometryRow[6] < 0) {
103
128
  throw new Error("Layout API returned no geometry evidence or malformed hierarchy");
104
129
  }
105
- if (!observedShapeIds.size
106
- ? geometryRow[5] !== null || geometryRow[6] !== 0
107
- : geometryRow[5] === null || typeof geometryRow[5] !== "string") {
130
+ if (geometryRow[5] === null) {
131
+ if (geometryRow[0] !== rootIds[rootIndex++] || geometryRow[6] !== 0) {
132
+ throw new Error("Malformed layout root hierarchy");
133
+ }
134
+ }
135
+ else if (typeof geometryRow[5] !== "string") {
108
136
  throw new Error("Malformed layout root hierarchy");
109
137
  }
110
138
  if (geometryRow[5] !== null) {
@@ -116,6 +144,9 @@ export async function settleLayout(observe, options) {
116
144
  }
117
145
  observedShapeIds.add(geometryRow[0]);
118
146
  }
147
+ if (!truncated && rootIndex !== rootIds.length) {
148
+ throw new Error("Incomplete layout root evidence");
149
+ }
119
150
  if (sampleIndex === 0) {
120
151
  nativeOutcome =
121
152
  observation.nativeOutcome ||
@@ -143,7 +174,10 @@ export async function settleLayout(observe, options) {
143
174
  if (Date.now() >= deadline) {
144
175
  return createResult(false, "geometry", "Layout observation exceeded its deadline");
145
176
  }
146
- const currentGeometry = JSON.stringify(observation.geometry);
177
+ const currentGeometry = JSON.stringify([
178
+ observation.geometry,
179
+ observation.truncated,
180
+ ]);
147
181
  matchingSamples = currentGeometry === previousGeometry ? matchingSamples + 1 : 1;
148
182
  if (matchingSamples >= 3) {
149
183
  return createResult(true, "geometry");
package/dist/layout.d.ts CHANGED
@@ -2,6 +2,8 @@ import type { PenpotToolClient } from "./client.js";
2
2
  export type LayoutStrategy = "auto" | "native" | "geometry";
3
3
  export interface LayoutOptions {
4
4
  shapeId?: string;
5
+ shapeIds?: string[];
6
+ maxNodes?: number;
5
7
  strategy?: LayoutStrategy;
6
8
  timeoutMs?: number;
7
9
  intervalMs?: number;
@@ -9,7 +11,11 @@ export interface LayoutOptions {
9
11
  }
10
12
  export interface LayoutResult {
11
13
  stable: boolean;
12
- method: "native" | "geometry" | "unavailable";
14
+ method: "native" | "geometry" | "partial" | "unavailable";
15
+ rootIds?: string[];
16
+ nodeCount?: number;
17
+ maxNodes?: number;
18
+ truncated?: boolean;
13
19
  samples: number;
14
20
  reason?: string;
15
21
  strategy?: LayoutStrategy;
package/dist/layout.js CHANGED
@@ -5,10 +5,25 @@ import { layoutPolicySource } from "./layout-policy.js";
5
5
  import { settleLayout } from "./layout-settling.js";
6
6
  /** Layout evidence is independent of render equality and visual approval. Transport failures propagate without fallback. */
7
7
  export async function waitForStableLayout(client, options = {}) {
8
- const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, } = options;
8
+ const { timeoutMs = 30000, intervalMs = 200, strategy = "auto", sleep = commandSleep, shapeId, shapeIds, maxNodes = 10000, } = options;
9
+ const roots = shapeIds ??
10
+ (shapeId
11
+ ? [
12
+ shapeId,
13
+ ]
14
+ : []);
15
+ if (!Array.isArray(roots) ||
16
+ roots.some((id) => typeof id !== "string" || !id.trim()) ||
17
+ new Set(roots).size !== roots.length) {
18
+ throw new Error("Layout roots must be distinct nonempty shape IDs");
19
+ }
20
+ if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > 100000) {
21
+ throw new Error("Layout node budget must be an integer from 1 to 100000");
22
+ }
9
23
  try {
10
- return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst shape = CFG.shapeId ? penpot.currentPage.getShapeById(CFG.shapeId) : penpot.currentPage.root;\nreturn await observeLayout(penpot, shape, CFG);`, {
11
- shapeId,
24
+ return await settleLayout(async (nativeMs, remaining) => runSnippet(client, snippet("layout-state", `${layoutPolicySource}\nconst roots = CFG.shapeIds.length ? CFG.shapeIds.map(id => penpot.currentPage.getShapeById(id)) : [penpot.currentPage.root];\nreturn await observeLayout(penpot, roots, CFG);`, {
25
+ shapeIds: roots,
26
+ maxNodes,
12
27
  nativeMs,
13
28
  }, "read"), {
14
29
  retries: 0,
@@ -17,6 +32,7 @@ export async function waitForStableLayout(client, options = {}) {
17
32
  timeoutMs,
18
33
  intervalMs,
19
34
  strategy,
35
+ maxNodes,
20
36
  sleep,
21
37
  });
22
38
  }
@@ -0,0 +1,3 @@
1
+ import type { MapDrawing } from "./types.js";
2
+ /** Reject malformed content geometry before a publication can contact Penpot. */
3
+ export declare function validateMapDrawingContent(drawing: MapDrawing): void;
@@ -0,0 +1,57 @@
1
+ function text(value, maximum) {
2
+ return value === undefined || (typeof value === "string" && value.length <= maximum);
3
+ }
4
+ function box(value) {
5
+ if (!value || typeof value !== "object") {
6
+ return false;
7
+ }
8
+ const bounds = value;
9
+ return ([
10
+ "x",
11
+ "y",
12
+ "width",
13
+ "height",
14
+ ].every((key) => Number.isFinite(bounds[key])) &&
15
+ Number(bounds.x) >= 0 &&
16
+ Number(bounds.y) >= 0 &&
17
+ Number(bounds.width) >= 64 &&
18
+ Number(bounds.height) > 0);
19
+ }
20
+ /** Reject malformed content geometry before a publication can contact Penpot. */
21
+ export function validateMapDrawingContent(drawing) {
22
+ if (!text(drawing.goal, 8000) ||
23
+ !text(drawing.summary, 240) ||
24
+ !text(drawing.description, 8000)) {
25
+ throw new Error("Map descriptions exceed their supported text budgets");
26
+ }
27
+ if (drawing.header &&
28
+ (!box(drawing.header.titleBox) ||
29
+ (drawing.header.summaryBox !== undefined && !box(drawing.header.summaryBox)) ||
30
+ !Number.isFinite(drawing.header.legendY) ||
31
+ drawing.header.legendY < 0 ||
32
+ !Number.isFinite(drawing.header.height) ||
33
+ drawing.header.height <= 0)) {
34
+ throw new Error("Map header requires finite positive geometry");
35
+ }
36
+ if (drawing.annotations === undefined) {
37
+ return;
38
+ }
39
+ const nodeIds = new Set(drawing.nodes.map((node) => node.id));
40
+ if (!Array.isArray(drawing.annotations) ||
41
+ drawing.annotations.length > 1001 ||
42
+ new Set(drawing.annotations.map((annotation) => annotation?.id)).size !==
43
+ drawing.annotations.length ||
44
+ drawing.annotations.some((annotation) => !annotation ||
45
+ typeof annotation.id !== "string" ||
46
+ !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/.test(annotation.id) ||
47
+ !box(annotation) ||
48
+ (annotation.nodeId !== undefined && !nodeIds.has(annotation.nodeId)) ||
49
+ typeof annotation.title !== "string" ||
50
+ !annotation.title.trim() ||
51
+ annotation.title.length > 4000 ||
52
+ !text(annotation.goal, 8000) ||
53
+ !text(annotation.summary, 240) ||
54
+ !text(annotation.description, annotation.nodeId ? 4000 : 8000))) {
55
+ throw new Error("Map annotations require unique identities, bounded text and finite geometry");
56
+ }
57
+ }
@@ -0,0 +1,22 @@
1
+ import type { MapDrawing, ProjectionShape } from "./types.js";
2
+ export declare const nativeMapRoles: readonly ["node", "label", "edge", "edge-label", "title", "badge", "legend", "map-summary", "map-full-summary", "map-goal", "map-description", "summary", "full-label", "full-summary", "description", "annotation-panel", "annotation-id", "annotation-heading", "icon-well", "icon", "port-in", "port-out"];
3
+ export interface NativeMapTextEntry {
4
+ role: ProjectionShape["role"];
5
+ logicalId: string;
6
+ text: string;
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ fontSize: number;
12
+ color: string;
13
+ strong?: boolean;
14
+ semantic?: {
15
+ scope: "map" | "node";
16
+ field: "title" | "goal" | "label" | "summary" | "description";
17
+ nodeId?: string;
18
+ compact?: boolean;
19
+ };
20
+ }
21
+ export declare function nativeMapTextEntries(drawing: MapDrawing): NativeMapTextEntry[];
22
+ export declare function nativeMapShapeKeys(drawing: MapDrawing): Set<string>;