@penpair/core 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/adapters.d.ts +1 -1
  2. package/dist/adapters.js +3 -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/cli-main.js +64 -3
  13. package/dist/design-commands/context.d.ts +9 -0
  14. package/dist/design-commands/context.js +1 -0
  15. package/dist/design-commands/index.d.ts +3 -0
  16. package/dist/design-commands/index.js +7 -0
  17. package/dist/design-commands/map-command.d.ts +4 -0
  18. package/dist/design-commands/map-command.js +685 -0
  19. package/dist/design-commands/release-command.d.ts +3 -0
  20. package/dist/design-commands/release-command.js +239 -0
  21. package/dist/design-releases/artifact-paths.d.ts +6 -0
  22. package/dist/design-releases/artifact-paths.js +82 -0
  23. package/dist/design-releases/assemble.d.ts +3 -0
  24. package/dist/design-releases/assemble.js +176 -0
  25. package/dist/design-releases/check.d.ts +16 -0
  26. package/dist/design-releases/check.js +323 -0
  27. package/dist/design-releases/context.d.ts +2 -0
  28. package/dist/design-releases/context.js +119 -0
  29. package/dist/design-releases/contract-graph.d.ts +13 -0
  30. package/dist/design-releases/contract-graph.js +75 -0
  31. package/dist/design-releases/fidelity.d.ts +23 -0
  32. package/dist/design-releases/fidelity.js +328 -0
  33. package/dist/design-releases/finalize.d.ts +4 -0
  34. package/dist/design-releases/finalize.js +201 -0
  35. package/dist/design-releases/index.d.ts +8 -0
  36. package/dist/design-releases/index.js +7 -0
  37. package/dist/design-releases/live.d.ts +22 -0
  38. package/dist/design-releases/live.js +97 -0
  39. package/dist/design-releases/snippets.d.ts +5 -0
  40. package/dist/design-releases/snippets.js +89 -0
  41. package/dist/design-releases/store.d.ts +10 -0
  42. package/dist/design-releases/store.js +205 -0
  43. package/dist/design-releases/types.d.ts +161 -0
  44. package/dist/design-releases/types.js +1 -0
  45. package/dist/design-releases/validation.d.ts +6 -0
  46. package/dist/design-releases/validation.js +384 -0
  47. package/dist/design-releases/verify.d.ts +3 -0
  48. package/dist/design-releases/verify.js +148 -0
  49. package/dist/image-codec.d.ts +3 -0
  50. package/dist/image-codec.js +78 -0
  51. package/dist/image-evidence.d.ts +15 -0
  52. package/dist/image-evidence.js +121 -0
  53. package/dist/index.d.ts +5 -0
  54. package/dist/index.js +5 -0
  55. package/dist/inspection.js +13 -5
  56. package/dist/instance/config-schema.d.ts +4 -0
  57. package/dist/instance/config-schema.js +7 -0
  58. package/dist/map-penpot/content-validation.d.ts +3 -0
  59. package/dist/map-penpot/content-validation.js +57 -0
  60. package/dist/map-penpot/content.d.ts +22 -0
  61. package/dist/map-penpot/content.js +173 -0
  62. package/dist/map-penpot/details.d.ts +4 -0
  63. package/dist/map-penpot/details.js +84 -0
  64. package/dist/map-penpot/evidence-error.d.ts +5 -0
  65. package/dist/map-penpot/evidence-error.js +9 -0
  66. package/dist/map-penpot/icons.d.ts +5 -0
  67. package/dist/map-penpot/icons.js +79 -0
  68. package/dist/map-penpot/import-content.d.ts +2 -0
  69. package/dist/map-penpot/import-content.js +150 -0
  70. package/dist/map-penpot/import.d.ts +5 -0
  71. package/dist/map-penpot/import.js +255 -0
  72. package/dist/map-penpot/index.d.ts +11 -0
  73. package/dist/map-penpot/index.js +10 -0
  74. package/dist/map-penpot/path-geometry.d.ts +5 -0
  75. package/dist/map-penpot/path-geometry.js +51 -0
  76. package/dist/map-penpot/presentation.d.ts +20 -0
  77. package/dist/map-penpot/presentation.js +205 -0
  78. package/dist/map-penpot/projection.d.ts +8 -0
  79. package/dist/map-penpot/projection.js +176 -0
  80. package/dist/map-penpot/publish.d.ts +4 -0
  81. package/dist/map-penpot/publish.js +301 -0
  82. package/dist/map-penpot/release-maps.d.ts +21 -0
  83. package/dist/map-penpot/release-maps.js +104 -0
  84. package/dist/map-penpot/snippets.d.ts +4 -0
  85. package/dist/map-penpot/snippets.js +164 -0
  86. package/dist/map-penpot/status-labels.d.ts +7 -0
  87. package/dist/map-penpot/status-labels.js +7 -0
  88. package/dist/map-penpot/types.d.ts +161 -0
  89. package/dist/map-penpot/types.js +1 -0
  90. package/dist/maps/annotation-layout.d.ts +24 -0
  91. package/dist/maps/annotation-layout.js +85 -0
  92. package/dist/maps/context.d.ts +13 -0
  93. package/dist/maps/context.js +207 -0
  94. package/dist/maps/index.d.ts +9 -0
  95. package/dist/maps/index.js +9 -0
  96. package/dist/maps/layout.d.ts +35 -0
  97. package/dist/maps/layout.js +364 -0
  98. package/dist/maps/proposal.d.ts +5 -0
  99. package/dist/maps/proposal.js +67 -0
  100. package/dist/maps/render.d.ts +4 -0
  101. package/dist/maps/render.js +331 -0
  102. package/dist/maps/review.d.ts +16 -0
  103. package/dist/maps/review.js +90 -0
  104. package/dist/maps/store.d.ts +21 -0
  105. package/dist/maps/store.js +222 -0
  106. package/dist/maps/types.d.ts +206 -0
  107. package/dist/maps/types.js +1 -0
  108. package/dist/maps/validation.d.ts +10 -0
  109. package/dist/maps/validation.js +375 -0
  110. package/dist/skill-routing.js +4 -0
  111. package/dist/version.d.ts +1 -1
  112. package/dist/version.js +1 -1
  113. package/package.json +2 -1
  114. package/skills/penpair-design-release/SKILL.md +55 -0
  115. package/skills/penpair-design-release/agents/openai.yaml +6 -0
  116. package/skills/penpair-design-release/references/workflow.md +55 -0
  117. package/skills/penpair-diagrams/SKILL.md +52 -0
  118. package/skills/penpair-diagrams/agents/openai.yaml +6 -0
  119. package/skills/penpair-diagrams/references/workflow.md +84 -0
  120. package/skills/penpair-handoff/SKILL.md +7 -0
  121. package/skills/penpair-handoff/references/handoff-flow.md +7 -1
  122. package/skills/penpair-integration-map/SKILL.md +44 -0
  123. package/skills/penpair-integration-map/agents/openai.yaml +6 -0
  124. package/skills/penpair-integration-map/references/workflow.md +33 -0
  125. package/skills/penpair-journey-map/SKILL.md +42 -0
  126. package/skills/penpair-journey-map/agents/openai.yaml +6 -0
  127. package/skills/penpair-journey-map/references/workflow.md +35 -0
  128. package/skills/penpair-verify/SKILL.md +7 -0
  129. package/skills/penpair-verify/references/browser-evidence.md +11 -1
@@ -0,0 +1,301 @@
1
+ import { nativeMapShapeKeys, nativeMapTextEntries } from "./content.js";
2
+ import { defaultMapIcon, identifyMapIcon } from "./icons.js";
3
+ import { mapOriginLabels, mapOriginLegend } from "./status-labels.js";
4
+ import { redactEndpoint } from "../endpoint.js";
5
+ import { MapExportEvidenceError } from "./evidence-error.js";
6
+ import { resolveMapDetails } from "./details.js";
7
+ import { exportShapePng } from "../export.js";
8
+ import { runOperation } from "../operation.js";
9
+ import { routeMapEdges, mapDrawingGeometryProblems } from "../maps/layout.js";
10
+ import { preserveMapPresentation } from "./presentation.js";
11
+ import { withPageSession } from "../page-session.js";
12
+ import { readMapPathPoints } from "./path-geometry.js";
13
+ import { inspectMapSnippet, publishMapSnippet } from "./snippets.js";
14
+ import { validateDrawing, projection, readProjection } from "./projection.js";
15
+ export function verificationProblems(drawing, observed) {
16
+ const problems = [
17
+ ...observed.conflicts,
18
+ ];
19
+ if (!observed.boardId || observed.contractHash !== drawing.contractHash) {
20
+ problems.push("Map board or contract identity differs");
21
+ }
22
+ const expectedKeys = nativeMapShapeKeys(drawing);
23
+ for (const shape of observed.shapes) {
24
+ if (!expectedKeys.delete(`${shape.role}:${shape.logicalId}`)) {
25
+ problems.push(`Unexpected shape ${shape.shapeId}`);
26
+ }
27
+ }
28
+ if (expectedKeys.size) {
29
+ problems.push("Managed shapes are missing");
30
+ }
31
+ if (drawing.nodes.some((node) => node.origin) &&
32
+ observed.shapes.find((shape) => shape.role === "legend")?.label !== mapOriginLegend) {
33
+ problems.push("Origin legend differs");
34
+ }
35
+ for (const shape of observed.shapes) {
36
+ const style = shape.appearance;
37
+ const radius = shape.role === "node" || shape.role === "annotation-panel"
38
+ ? 12
39
+ : shape.role === "icon-well"
40
+ ? 8
41
+ : undefined;
42
+ if (radius !== undefined &&
43
+ [
44
+ "borderRadiusTopLeft",
45
+ "borderRadiusTopRight",
46
+ "borderRadiusBottomLeft",
47
+ "borderRadiusBottomRight",
48
+ ].some((corner) => typeof style?.[corner] === "number" && style[corner] !== radius)) {
49
+ problems.push(`Native rounded corners differ: ${shape.role}:${shape.logicalId}`);
50
+ }
51
+ if ((typeof style?.opacity === "number" && style.opacity !== 1) ||
52
+ (typeof style?.blendMode === "string" && style.blendMode !== "normal")) {
53
+ problems.push(`Native decorative opacity/blend differs: ${shape.role}:${shape.logicalId}`);
54
+ }
55
+ }
56
+ const bounds = observed.boardBounds;
57
+ if (bounds) {
58
+ for (const shape of observed.shapes.filter((shape) => shape.role !== "edge")) {
59
+ if (shape.x < bounds.x ||
60
+ shape.y < bounds.y ||
61
+ shape.x + shape.width > bounds.x + bounds.width + 0.01 ||
62
+ shape.y + shape.height > bounds.y + bounds.height + 0.01) {
63
+ problems.push(`Map content exceeds board bounds: ${shape.logicalId}`);
64
+ }
65
+ }
66
+ }
67
+ for (const node of drawing.nodes) {
68
+ const label = observed.shapes.find((shape) => shape.role === "label" && shape.logicalId === node.id);
69
+ for (const clickable of observed.shapes.filter((shape) => shape.logicalId === node.id &&
70
+ [
71
+ "node",
72
+ "label",
73
+ "badge",
74
+ "summary",
75
+ "icon-well",
76
+ "icon",
77
+ "port-in",
78
+ "port-out",
79
+ ].includes(shape.role))) {
80
+ if (node.detail &&
81
+ (clickable.interactions?.length !== 1 ||
82
+ clickable.interactions[0]?.trigger !== "click" ||
83
+ clickable.interactions[0]?.type !== "navigate-to" ||
84
+ clickable.interactions[0]?.destinationId !== node.detail.frameId ||
85
+ clickable.interactions[0]?.destinationPageId !== node.detail.pageId ||
86
+ clickable.interactions[0]?.destinationMapHash !== node.detail.contractHash ||
87
+ clickable.interactions[0]?.destinationMapId !== node.detail.mapId)) {
88
+ problems.push(`Detail link destination differs or is unobservable: ${node.id}`);
89
+ }
90
+ if (!node.detail && clickable.interactions?.length) {
91
+ problems.push(`Unexpected native interaction: ${node.id}`);
92
+ }
93
+ }
94
+ if (node.origin) {
95
+ const status = mapOriginLabels[node.origin];
96
+ if (observed.shapes.find((shape) => shape.role === "badge" && shape.logicalId === node.id)
97
+ ?.label !== status) {
98
+ problems.push(`Origin badge differs: ${node.id}`);
99
+ }
100
+ }
101
+ const icon = observed.shapes.find((shape) => shape.role === "icon" && shape.logicalId === node.id);
102
+ if (!icon || identifyMapIcon(icon) !== (node.icon ?? defaultMapIcon(node.kind))) {
103
+ problems.push(`Native icon differs: ${node.id}`);
104
+ }
105
+ const card = observed.shapes.find((shape) => shape.role === "node" && shape.logicalId === node.id);
106
+ if (card &&
107
+ label &&
108
+ (label.x < card.x ||
109
+ label.y < card.y ||
110
+ label.x + label.width > card.x + card.width + 0.01 ||
111
+ label.y + label.height > card.y + card.height + 0.01)) {
112
+ problems.push(`Node label exceeds card bounds: ${node.id}`);
113
+ }
114
+ }
115
+ if (observed.shapes.find((shape) => shape.role === "title")?.label !== drawing.title) {
116
+ problems.push("Map title differs");
117
+ }
118
+ for (const entry of nativeMapTextEntries(drawing)) {
119
+ const actual = observed.shapes.find((shape) => shape.role === entry.role && shape.logicalId === entry.logicalId);
120
+ if (actual?.label !== entry.text) {
121
+ problems.push(`Native text differs: ${entry.role}:${entry.logicalId}`);
122
+ }
123
+ if (actual && actual.height > entry.height + 0.01) {
124
+ problems.push(`Native text exceeds reserved space: ${entry.role}:${entry.logicalId}`);
125
+ }
126
+ }
127
+ const title = observed.shapes.find((shape) => shape.role === "title");
128
+ const legend = observed.shapes.find((shape) => shape.role === "legend");
129
+ if (title &&
130
+ (title.height > (drawing.header?.titleBox.height ?? 48) ||
131
+ (legend && title.y + title.height > legend.y))) {
132
+ problems.push("Map title exceeds the reserved header space");
133
+ }
134
+ for (const edge of drawing.edges) {
135
+ const path = observed.shapes.find((shape) => shape.role === "edge" && shape.logicalId === edge.id);
136
+ if (path?.from !== edge.from || path.to !== edge.to) {
137
+ problems.push(`Relationship differs: ${edge.id}`);
138
+ }
139
+ const edgeLabel = observed.shapes.find((shape) => shape.role === "edge-label" && shape.logicalId === edge.id);
140
+ if (edge.label && edgeLabel?.label !== edge.label) {
141
+ problems.push(`Relationship label differs: ${edge.id}`);
142
+ }
143
+ if (edge.labelBox && edgeLabel && edgeLabel.height > edge.labelBox.height + 0.01) {
144
+ problems.push(`Relationship label exceeds reserved space: ${edge.id}`);
145
+ }
146
+ const from = observed.shapes.find((shape) => shape.role === "node" && shape.logicalId === edge.from);
147
+ const to = observed.shapes.find((shape) => shape.role === "node" && shape.logicalId === edge.to);
148
+ const vertices = readMapPathPoints(path?.content);
149
+ const start = vertices?.[0];
150
+ const end = vertices?.at(-1);
151
+ const close = (left, right) => left !== undefined && Number.isFinite(left) && Math.abs(left - right) < 0.01;
152
+ if (!from ||
153
+ !to ||
154
+ !vertices ||
155
+ !(close(start?.x, from.x + from.width) || close(start?.x, from.x)) ||
156
+ !close(start?.y, from.y + from.height / 2) ||
157
+ !((close(end?.x, to.x) && close(end?.y, to.y + to.height / 2)) ||
158
+ (edge.from === edge.to && close(end?.x, to.x + to.width / 2) && close(end?.y, to.y)))) {
159
+ problems.push(`Relationship native geometry differs: ${edge.id}`);
160
+ }
161
+ }
162
+ if (bounds) {
163
+ const nodes = observed.shapes
164
+ .filter((shape) => shape.role === "node")
165
+ .map((shape) => ({
166
+ id: shape.logicalId,
167
+ x: shape.x - bounds.x,
168
+ y: shape.y - bounds.y,
169
+ width: shape.width,
170
+ height: shape.height,
171
+ }));
172
+ const edges = drawing.edges.flatMap((edge) => {
173
+ const path = observed.shapes.find((shape) => shape.role === "edge" && shape.logicalId === edge.id);
174
+ const points = readMapPathPoints(path?.content);
175
+ const label = observed.shapes.find((shape) => shape.role === "edge-label" && shape.logicalId === edge.id);
176
+ return points && label
177
+ ? [
178
+ {
179
+ id: edge.id,
180
+ points: points.map((point) => ({
181
+ x: point.x - bounds.x,
182
+ y: point.y - bounds.y,
183
+ })),
184
+ labelBox: {
185
+ x: label.x - bounds.x,
186
+ y: label.y - bounds.y,
187
+ width: label.width,
188
+ height: label.height,
189
+ },
190
+ },
191
+ ]
192
+ : [];
193
+ });
194
+ problems.push(...mapDrawingGeometryProblems(nodes, edges));
195
+ }
196
+ return problems;
197
+ }
198
+ export async function publishMap(client, target, drawing, options = {}) {
199
+ const configuredRedact = options.redact ?? options.journal?.redact;
200
+ const redact = (text) => redactEndpoint(configuredRedact ? configuredRedact(text) : text);
201
+ const requireProtectedValuesAbsent = (value) => {
202
+ const encoded = JSON.stringify(value);
203
+ if (redact(encoded) !== encoded) {
204
+ throw new Error("Native map contains protected values; publication refused");
205
+ }
206
+ };
207
+ requireProtectedValuesAbsent(drawing);
208
+ validateDrawing(drawing);
209
+ if (drawing.nodes.some((node) => node.detail)) {
210
+ if (!options.journal?.root) {
211
+ throw new Error("Pinned detail publication needs its project root");
212
+ }
213
+ drawing = await resolveMapDetails(options.journal.root, drawing);
214
+ }
215
+ drawing = {
216
+ ...drawing,
217
+ edges: drawing.edges.every((edge) => edge.points && edge.labelBox)
218
+ ? drawing.edges
219
+ : routeMapEdges(drawing.nodes, drawing.edges),
220
+ };
221
+ requireProtectedValuesAbsent(drawing);
222
+ const geometryProblems = mapDrawingGeometryProblems(drawing.nodes, drawing.edges);
223
+ if (geometryProblems.length) {
224
+ throw new Error(`Map layout requires review: ${geometryProblems.join("; ")}`);
225
+ }
226
+ if (options.apply && !options.journal) {
227
+ throw new Error("Map publication requires a durable operation journal");
228
+ }
229
+ return withPageSession(client, async () => {
230
+ const before = await readProjection(client, target, drawing.mapId, options);
231
+ requireProtectedValuesAbsent(before);
232
+ if (before.conflicts.length) {
233
+ throw new Error(before.conflicts.join("; "));
234
+ }
235
+ if (options.apply &&
236
+ before.boardId &&
237
+ !options.journal?.resume &&
238
+ (!options.expected ||
239
+ options.expected.fingerprint !== before.fingerprint ||
240
+ options.expected.mapId !== drawing.mapId ||
241
+ options.expected.fileId !== target.fileId ||
242
+ options.expected.pageId !== target.pageId)) {
243
+ throw new Error("Existing map requires matching prior projection; import and reconcile edits first");
244
+ }
245
+ // Build the full write during preview too; size limits fail before a journal or remote mutation.
246
+ publishMapSnippet(target, before.boardId ? preserveMapPresentation(drawing, before) : drawing, before);
247
+ const operation = {
248
+ name: "publish-map",
249
+ page: () => undefined,
250
+ inspect: () => inspectMapSnippet(target, drawing.mapId),
251
+ apply: (_drawing, inspected) => {
252
+ const observed = inspected
253
+ ? projection(target, drawing.mapId, inspected)
254
+ : {
255
+ ...before,
256
+ boardId: null,
257
+ boardBounds: null,
258
+ contractHash: null,
259
+ shapes: [],
260
+ conflicts: [],
261
+ };
262
+ const projectedDrawing = observed.boardId
263
+ ? preserveMapPresentation(drawing, observed)
264
+ : drawing;
265
+ requireProtectedValuesAbsent(projectedDrawing);
266
+ requireProtectedValuesAbsent(observed);
267
+ return publishMapSnippet(target, projectedDrawing, observed);
268
+ },
269
+ readback: () => inspectMapSnippet(target, drawing.mapId),
270
+ verify: ({ readback }) => verificationProblems(drawing, projection(target, drawing.mapId, readback)),
271
+ };
272
+ const report = await runOperation(client, operation, drawing, options);
273
+ const observed = report.readback ? projection(target, drawing.mapId, report.readback) : before;
274
+ let image;
275
+ if (options.apply && report.verified && options.export !== false && observed.boardId) {
276
+ try {
277
+ image = await exportShapePng(client, observed.boardId, {
278
+ ...options,
279
+ stable: true,
280
+ });
281
+ }
282
+ catch (error) {
283
+ const detail = redact(error instanceof Error ? error.message : String(error));
284
+ if (report.journal?.runId) {
285
+ throw new MapExportEvidenceError(report.journal.runId, detail);
286
+ }
287
+ throw error;
288
+ }
289
+ }
290
+ return {
291
+ report,
292
+ projection: observed,
293
+ ...(image
294
+ ? {
295
+ image,
296
+ }
297
+ : {}),
298
+ visualReviewRequired: Boolean(options.apply),
299
+ };
300
+ });
301
+ }
@@ -0,0 +1,21 @@
1
+ import type { PenpotToolClient } from "../client.js";
2
+ import type { MapContract } from "../maps/types.js";
3
+ import type { MapTarget, PublishMapOptions, PublishMapResult } from "./types.js";
4
+ export interface ReleaseMapPublication {
5
+ mapKind: MapContract["kind"];
6
+ mapId: string;
7
+ contractHash: string;
8
+ publication: PublishMapResult;
9
+ }
10
+ /** Each contract has its own recoverable journal; an interrupted batch is never replayed blindly. */
11
+ export declare function assembleReleaseMaps(client: PenpotToolClient, root: string, target: MapTarget, contracts: MapContract[], options?: Omit<PublishMapOptions, "journal" | "expected"> & {
12
+ origin?: {
13
+ x: number;
14
+ y: number;
15
+ };
16
+ journals?: Record<string, {
17
+ runId: string;
18
+ resume?: boolean;
19
+ }>;
20
+ projections?: Record<string, NonNullable<PublishMapOptions["expected"]>>;
21
+ }): Promise<ReleaseMapPublication[]>;
@@ -0,0 +1,104 @@
1
+ import { observeMapSources } from "../maps/store.js";
2
+ import { buildMapDrawingPlan } from "../maps/render.js";
3
+ import { resolveMapDetails } from "./details.js";
4
+ import { drawingFromPlan } from "./projection.js";
5
+ import { publishMap } from "./publish.js";
6
+ /** Each contract has its own recoverable journal; an interrupted batch is never replayed blindly. */
7
+ export async function assembleReleaseMaps(client, root, target, contracts, options = {}) {
8
+ if (contracts.length > 100 ||
9
+ new Set(contracts.map((contract) => `${contract.kind}:${contract.id}`)).size !==
10
+ contracts.length) {
11
+ throw new Error("Release maps require at most 100 unique contract identities");
12
+ }
13
+ const prepared = new Map();
14
+ for (const contract of contracts) {
15
+ const observation = await observeMapSources(root, contract);
16
+ const plan = buildMapDrawingPlan(contract, observation.detailMaps);
17
+ const drawing = await resolveMapDetails(root, drawingFromPlan(plan));
18
+ prepared.set(drawing.mapId, {
19
+ contract,
20
+ drawing,
21
+ width: plan.width,
22
+ });
23
+ }
24
+ const ordered = [];
25
+ const visiting = new Set();
26
+ const completed = new Set();
27
+ const visit = (id) => {
28
+ if (visiting.has(id)) {
29
+ throw new Error("Release detail-map dependencies contain a cycle");
30
+ }
31
+ if (completed.has(id)) {
32
+ return;
33
+ }
34
+ const current = prepared.get(id);
35
+ visiting.add(id);
36
+ for (const node of current.drawing.nodes.filter((node) => node.detail)) {
37
+ const detail = node.detail;
38
+ const child = prepared.get(detail.mapId);
39
+ if (!child || child.drawing.contractHash !== detail.contractHash) {
40
+ throw new Error("Every detail contract must be explicitly included at its pinned revision in the release");
41
+ }
42
+ visit(child.drawing.mapId);
43
+ }
44
+ visiting.delete(id);
45
+ completed.add(id);
46
+ ordered.push(id);
47
+ };
48
+ for (const id of prepared.keys()) {
49
+ visit(id);
50
+ }
51
+ const results = [];
52
+ const published = new Map();
53
+ let nextX = options.origin?.x ?? 0;
54
+ for (const id of ordered) {
55
+ const entry = prepared.get(id);
56
+ const contract = entry.contract;
57
+ const drawing = {
58
+ ...entry.drawing,
59
+ origin: {
60
+ x: nextX,
61
+ y: options.origin?.y ?? 0,
62
+ },
63
+ nodes: entry.drawing.nodes.map((node) => {
64
+ if (!node.detail) {
65
+ return node;
66
+ }
67
+ const child = published.get(node.detail.mapId);
68
+ if (options.apply && !child?.projection.boardId) {
69
+ throw new Error("Release child map has no verified native target");
70
+ }
71
+ return child?.projection.boardId
72
+ ? {
73
+ ...node,
74
+ detail: {
75
+ ...node.detail,
76
+ fileId: target.fileId,
77
+ pageId: target.pageId,
78
+ frameId: child.projection.boardId,
79
+ },
80
+ }
81
+ : node;
82
+ }),
83
+ };
84
+ const recovery = options.journals?.[drawing.mapId];
85
+ const publication = await publishMap(client, target, drawing, {
86
+ ...options,
87
+ expected: options.projections?.[drawing.mapId],
88
+ journal: {
89
+ root,
90
+ redact: options.redact,
91
+ ...recovery,
92
+ },
93
+ });
94
+ nextX += Math.max(entry.width, publication.projection.boardBounds?.width ?? 964) + 160;
95
+ published.set(drawing.mapId, publication);
96
+ results.push({
97
+ mapKind: contract.kind,
98
+ mapId: contract.id,
99
+ contractHash: drawing.contractHash,
100
+ publication,
101
+ });
102
+ }
103
+ return results;
104
+ }
@@ -0,0 +1,4 @@
1
+ import { type Snippet } from "../snippet.js";
2
+ import type { MapDrawing, MapTarget, NativeMapProjection } from "./types.js";
3
+ export declare function inspectMapSnippet(target: MapTarget, mapId: string): Snippet;
4
+ export declare function publishMapSnippet(target: MapTarget, drawing: MapDrawing, expected: NativeMapProjection): Snippet;
@@ -0,0 +1,164 @@
1
+ import { nativeMapRoles, nativeMapTextEntries } from "./content.js";
2
+ import { nativeMapIcons, defaultMapIcon } from "./icons.js";
3
+ import { mapOriginLabels, mapOriginLegend } from "./status-labels.js";
4
+ import { snippet } from "../snippet.js";
5
+ const inspectSource = `
6
+ if (penpot.currentFile.id !== CFG.target.fileId || penpot.currentPage.id !== CFG.target.pageId) throw new Error("Map file/page identity changed");
7
+ const readMeta = shape => {
8
+ if (typeof shape.getSharedPluginData !== "function") throw new Error("Map metadata API unavailable");
9
+ const raw = shape.getSharedPluginData("penpair", "map");
10
+ if (!raw) return null;
11
+ try { return JSON.parse(raw); } catch { throw new Error("Malformed managed map metadata"); }
12
+ };
13
+ const readInteractions = shape => {
14
+ if (!Array.isArray(shape.interactions)) return null;
15
+ return shape.interactions.map(interaction => {
16
+ const destination = interaction.action?.destination;
17
+ const destinationId = destination?.id || null;
18
+ const destinationPageId = destinationId ? penpot.currentFile.pages.find(page => page.getShapeById(destinationId)?.id === destinationId)?.id || null : null;
19
+ const destinationMeta = destination ? readMeta(destination) : null;
20
+ return {trigger:interaction.trigger,type:interaction.action?.type || "unknown",destinationId,destinationPageId,destinationMapHash:destinationMeta?.contractHash || null,destinationMapId:destinationMeta?.mapId || null};
21
+ });
22
+ };
23
+ const all = [], queue = [...penpot.currentPage.root.children], visited = new Set();
24
+ while (queue.length) {
25
+ const shape = queue.shift();
26
+ if (visited.has(shape.id)) throw new Error("Cyclic native hierarchy");
27
+ visited.add(shape.id); all.push(shape);
28
+ if (all.length > 20000) throw new Error("Page traversal exceeds map inspection limit");
29
+ if (shape.children) queue.push(...shape.children);
30
+ }
31
+ const boards = all.filter(shape => { const meta = readMeta(shape); return meta && meta.mapId === CFG.mapId && meta.role === "board"; });
32
+ if (boards.length > 1) throw new Error("Duplicate managed map boards");
33
+ const board = boards[0] || null;
34
+ if (CFG.target.boardId && (!board || board.id !== CFG.target.boardId)) throw new Error("Managed map board identity changed");
35
+ const conflicts = [], shapes = [], keys = new Set();
36
+ if (board) {
37
+ const meta = readMeta(board);
38
+ if (meta.schemaVersion !== 1 || meta.owner !== "penpair") conflicts.push("Edited board ownership");
39
+ for (const shape of board.children) {
40
+ const child = readMeta(shape);
41
+ if (!child || child.owner !== "penpair" || child.schemaVersion !== 1 || child.mapId !== CFG.mapId) { conflicts.push("Unknown or edited ownership: " + shape.id); continue; }
42
+ if (!${JSON.stringify(nativeMapRoles)}.includes(child.role) || typeof child.logicalId !== "string" || !child.logicalId) { conflicts.push("Unknown managed role or identity: " + shape.id); continue; }
43
+ const key = child.role + ":" + child.logicalId;
44
+ if (keys.has(key)) conflicts.push("Duplicate logical ID: " + key);
45
+ keys.add(key);
46
+ if (shape.children && shape.children.length) conflicts.push("Unexpected nested content: " + shape.id);
47
+ shapes.push({shapeId:shape.id,logicalId:child.logicalId,role:child.role,iconKey:child.iconKey || null,interactions:readInteractions(shape),label:shape.type === "text" ? shape.characters : null,x:shape.x,y:shape.y,width:shape.width,height:shape.height,content:shape.type === "path" ? shape.content : null,appearance:{type:shape.type,name:shape.name,fills:shape.fills || [],strokes:shape.strokes || [],fontId:shape.fontId || null,fontFamily:shape.fontFamily || null,fontVariantId:shape.fontVariantId || null,fontSize:shape.fontSize || null,growType:shape.growType || null,borderRadiusTopLeft:shape.borderRadiusTopLeft ?? shape.borderRadius ?? null,borderRadiusTopRight:shape.borderRadiusTopRight ?? shape.borderRadius ?? null,borderRadiusBottomLeft:shape.borderRadiusBottomLeft ?? shape.borderRadius ?? null,borderRadiusBottomRight:shape.borderRadiusBottomRight ?? shape.borderRadius ?? null,opacity:shape.opacity ?? null,blendMode:shape.blendMode ?? null,blur:shape.blur || null},...(child.from ? {from:child.from,to:child.to} : {})});
48
+ }
49
+ const childIds = new Set(board.children.map(shape => shape.id));
50
+ for (const shape of all) { const child = readMeta(shape); if (shape.id !== board.id && child && child.mapId === CFG.mapId && !childIds.has(shape.id)) conflicts.push("Reparented managed shape: " + shape.id); }
51
+ }
52
+ const observation = {boardId:board ? board.id : null,boardBounds:board ? {x:board.x,y:board.y,width:board.width,height:board.height} : null,contractHash:board ? readMeta(board).contractHash : null,shapes,conflicts};
53
+ `;
54
+ export function inspectMapSnippet(target, mapId) {
55
+ return snippet("map-projection-inspect", `${inspectSource}\nreturn {ok:true,...observation};`, {
56
+ target,
57
+ mapId,
58
+ }, "read");
59
+ }
60
+ export function publishMapSnippet(target, drawing, expected) {
61
+ return snippet("map-projection-publish", `${inspectSource}
62
+ if (conflicts.length || JSON.stringify(observation) !== JSON.stringify(CFG.expected)) throw new Error("Map changed after inspection; reconcile before publishing");
63
+ const fontName = CFG.drawing.fontFamily || "Inter";
64
+ const exactFonts = Array.isArray(penpot.fonts.all) ? penpot.fonts.all.filter(font => font.name === fontName) : [];
65
+ if (exactFonts.length !== 1) throw new Error("Map font unavailable or ambiguous: " + fontName);
66
+ const font = exactFonts[0];
67
+ if (typeof font.applyToText !== "function" || !Array.isArray(font.variants)) throw new Error("Map font unavailable");
68
+ const variants = font.variants.filter(variant => String(variant.fontWeight) === "400" && (variant.fontStyle || "normal") === "normal");
69
+ if (variants.length !== 1) throw new Error("Map regular font variant unavailable or ambiguous");
70
+ if (typeof penpot.currentPage.root.setSharedPluginData !== "function" || ["createBoard","createRectangle","createEllipse","createPath","createText"].some(method => typeof penpot[method] !== "function")) throw new Error("Required native map creation/metadata APIs unavailable");
71
+ const detailBoards = new Map();
72
+ for (const node of CFG.drawing.nodes.filter(node => node.detail)) {
73
+ const detail = node.detail;
74
+ if (detail.fileId !== CFG.target.fileId) throw new Error("Detail map target must be in the same pinned file");
75
+ const pages = penpot.currentFile.pages.filter(page => page.id === detail.pageId);
76
+ if (pages.length !== 1 || typeof pages[0].getShapeById !== "function") throw new Error("Detail map target page is missing or cannot be inspected");
77
+ const target = pages[0].getShapeById(detail.frameId);
78
+ const targetMeta = target ? readMeta(target) : null;
79
+ if (!target || target.type !== "board" || !targetMeta || targetMeta.owner !== "penpair" || targetMeta.role !== "board" || !detail.mapId || targetMeta.mapId !== detail.mapId || !detail.contractHash || targetMeta.contractHash !== detail.contractHash) throw new Error("Detail map target is not the existing pinned owned contract board");
80
+ if (typeof target.addInteraction !== "function" || typeof target.removeInteraction !== "function") throw new Error("Native detail interaction API is unavailable");
81
+ detailBoards.set(node.id,target);
82
+ }
83
+ let root = board;
84
+ if (!root) { root = penpot.createBoard(); root.name = CFG.drawing.title; root.fills = [{fillColor:"#F8FAFC",fillOpacity:1}]; root.clipContent = false; root.x = CFG.drawing.origin?.x || 0; root.y = CFG.drawing.origin?.y || 0; root.resize(1600,1200); }
85
+ const metadata = (shape, role, logicalId, extra = {}) => {
86
+ if (typeof shape.setSharedPluginData !== "function") throw new Error("Map metadata write API unavailable");
87
+ shape.setSharedPluginData("penpair", "map", JSON.stringify({schemaVersion:1,owner:"penpair",mapId:CFG.mapId,role,logicalId,...extra}));
88
+ };
89
+ metadata(root,"board",CFG.mapId,{contractHash:CFG.drawing.contractHash});
90
+ const previous = new Map(shapes.map(shape => [shape.role + ":" + shape.logicalId,shape]));
91
+ const retained = new Set();
92
+ const get = (role,id,create) => { const prior = previous.get(role + ":" + id); const shape = prior ? penpot.currentPage.getShapeById(prior.shapeId) : create(); root.appendChild(shape); retained.add(shape.id);shape.opacity=1;shape.blendMode="normal"; metadata(shape,role,id); return shape; };
93
+ const strongVariants = font.variants.filter(variant => ["600","700"].includes(String(variant.fontWeight)) && (variant.fontStyle || "normal") === "normal").sort((left,right)=>Number(left.fontWeight)-Number(right.fontWeight));
94
+ const textShape = entry => {
95
+ const shape = get(entry.role,entry.logicalId,() => penpot.createText(entry.text || " "));
96
+ shape.characters = entry.text || " ";
97
+ font.applyToText(shape,entry.strong && strongVariants.length ? strongVariants[0] : variants[0]);
98
+ shape.fontSize = entry.fontSize; shape.resize(entry.width,entry.height); shape.growType = "auto-height";
99
+ shape.x=root.x+entry.x; shape.y=root.y+entry.y; shape.fills=[{fillColor:entry.color,fillOpacity:1}];
100
+ return shape;
101
+ };
102
+ const plannedNodes = CFG.drawing.nodes;
103
+ const nodes = new Map(plannedNodes.map(node => [node.id,node]));
104
+ for (const edge of CFG.drawing.edges) {
105
+ const from = nodes.get(edge.from), to = nodes.get(edge.to);
106
+ const shape = get("edge",edge.id,() => penpot.createPath());
107
+ const points = edge.points.map(point => ({x:root.x+point.x,y:root.y+point.y}));
108
+ shape.content = points.map((point,index) => (index ? "L" : "M")+point.x+" "+point.y).join(" ");
109
+ shape.fills=[]; shape.strokes=[{strokeColor:"#98A2B3",strokeWidth:1.25,strokeCapEnd:"line-arrow"}];
110
+ metadata(shape,"edge",edge.id,{from:edge.from,to:edge.to});
111
+ }
112
+ for (const node of plannedNodes) {
113
+ const shape = get("node",node.id,() => penpot.createRectangle());
114
+ shape.name=node.kind+": "+node.label;shape.resize(node.width,node.height);shape.x=root.x+node.x;shape.y=root.y+node.y;
115
+ shape.fills=[{fillColor:"#FFFFFF",fillOpacity:1}];shape.strokes=[{strokeColor:"#D0D5DD",strokeWidth:1}];shape.borderRadius=12;
116
+ const well = get("icon-well",node.id,() => penpot.createRectangle());well.resize(32,32);well.x=shape.x+16;well.y=shape.y+16;well.borderRadius=8;well.fills=[{fillColor:"#EEF2FF",fillOpacity:1}];well.strokes=[];
117
+ const iconKey=node.icon || CFG.iconDefaults[node.id];
118
+ const glyph=get("icon",node.id,()=>penpot.createPath());
119
+ glyph.content=CFG.iconPaths[iconKey].replace(/([ML])(-?[0-9.]+) (-?[0-9.]+)/g,(_all,command,x,y)=>command+(shape.x+24+Number(x)*16/24)+" "+(shape.y+24+Number(y)*16/24));
120
+ glyph.resize(16,16);glyph.x=shape.x+24;glyph.y=shape.y+24;
121
+ glyph.fills=[];glyph.strokes=[{strokeColor:"#5B5BD6",strokeWidth:1.6,strokeCapStart:"round",strokeCapEnd:"round",strokeJoinStyle:"round"}];metadata(glyph,"icon",node.id,{iconKey});
122
+ for(const [role,x] of [["port-in",shape.x],["port-out",shape.x+shape.width]]){
123
+ const port=get(role,node.id,()=>penpot.createEllipse());port.resize(8,8);port.x=x-4;port.y=shape.y+shape.height/2-4;port.fills=[{fillColor:"#FFFFFF",fillOpacity:1}];port.strokes=[{strokeColor:"#98A2B3",strokeWidth:1.25}];
124
+ }
125
+ }
126
+ for(const annotation of CFG.drawing.annotations || []){
127
+ const panel=get("annotation-panel",annotation.id,()=>penpot.createRectangle());panel.resize(annotation.width,annotation.height);panel.x=root.x+annotation.x;panel.y=root.y+annotation.y;panel.borderRadius=12;panel.fills=[{fillColor:"#FFFFFF",fillOpacity:1}];panel.strokes=[{strokeColor:"#E4E7EC",strokeWidth:1}];
128
+ }
129
+ for(const entry of CFG.textEntries) textShape(entry);
130
+ for(const node of plannedNodes){
131
+ const clickableRoles=new Set(["node","label","summary","badge","icon-well","icon","port-in","port-out"]);
132
+ for(const id of retained){
133
+ const shape=penpot.currentPage.getShapeById(id);const info=readMeta(shape);
134
+ if(info.logicalId!==node.id || !clickableRoles.has(info.role))continue;
135
+ if(Array.isArray(shape.interactions))for(const interaction of [...shape.interactions])shape.removeInteraction(interaction);
136
+ if(node.detail)shape.addInteraction("click",{type:"navigate-to",destination:detailBoards.get(node.id)});
137
+ }
138
+ }
139
+ if (typeof penpot.waitForLayoutUpdate === "function") await penpot.waitForLayoutUpdate();
140
+ const right = Math.max(900,...root.children.map(shape => shape.x-root.x+shape.width)) + 64;
141
+ const bottom = Math.max(160,...root.children.map(shape => shape.y-root.y+shape.height)) + 64;
142
+ root.resize(right,bottom);
143
+ for (const prior of shapes) if (!retained.has(prior.shapeId)) penpot.currentPage.getShapeById(prior.shapeId).remove();
144
+ return {ok:true,boardId:root.id};`, {
145
+ target,
146
+ mapId: drawing.mapId,
147
+ drawing,
148
+ textEntries: nativeMapTextEntries(drawing),
149
+ iconPaths: nativeMapIcons,
150
+ iconDefaults: Object.fromEntries(drawing.nodes.map((node) => [
151
+ node.id,
152
+ defaultMapIcon(node.kind),
153
+ ])),
154
+ originLabels: mapOriginLabels,
155
+ originLegend: mapOriginLegend,
156
+ expected: {
157
+ boardId: expected.boardId,
158
+ boardBounds: expected.boardBounds,
159
+ contractHash: expected.contractHash,
160
+ shapes: expected.shapes,
161
+ conflicts: expected.conflicts,
162
+ },
163
+ }, "write");
164
+ }
@@ -0,0 +1,7 @@
1
+ export declare const mapOriginLabels: {
2
+ observed: string;
3
+ proposed: string;
4
+ "owner-decision": string;
5
+ unknown: string;
6
+ };
7
+ export declare const mapOriginLegend = "Origin: Observed \u00B7 Proposed \u00B7 Owner decision \u00B7 Unknown. Status labels describe evidence, not implementation acceptance.";
@@ -0,0 +1,7 @@
1
+ export const mapOriginLabels = {
2
+ observed: "Observed",
3
+ proposed: "Proposed",
4
+ "owner-decision": "Owner decision",
5
+ unknown: "Unknown",
6
+ };
7
+ export const mapOriginLegend = "Origin: Observed · Proposed · Owner decision · Unknown. Status labels describe evidence, not implementation acceptance.";