@penpair/core 0.4.1 → 0.5.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.
package/dist/cli-main.js CHANGED
@@ -61,6 +61,7 @@ Design agreements and release pages:
61
61
  penpair journey-map check|render|publish|import|accept|approve|context --from FILE
62
62
  penpair integration-map check|render|publish|import|accept|approve|context --from FILE
63
63
  penpair design-release plan|assemble|verify|finalize|check|context --from FILE
64
+ penpair design-release copy --source-page PAGE --frame ID --release VERSION [--debug-port PORT --apply]
64
65
  (publication previews by default; --apply writes Penpot, --write records local acceptance/review)
65
66
  Start here (guided, plain language; safe to run again):
66
67
  penpair setup asks where your Penpot is (web is the default), sets everything up and checks it
@@ -2296,9 +2297,19 @@ async function runCliInternal(argv, options = {}) {
2296
2297
  argv[0] === "integration-map" ||
2297
2298
  argv[0] === "design-release") {
2298
2299
  const { runDesignCommand } = await import("./design-commands/index.js");
2300
+ const copyConfig = argv[0] === "design-release" && argv[1] === "copy"
2301
+ ? await loadCommandConfig(root)
2302
+ : undefined;
2299
2303
  let designRedactor = (text) => redactEndpoint(text);
2300
2304
  return await runDesignCommand(argv[0], argv.slice(1), {
2301
2305
  root,
2306
+ copyTarget: copyConfig?.file?.id
2307
+ ? {
2308
+ fileId: copyConfig.file.id,
2309
+ origin: isExternal(copyConfig) ? copyConfig.profile.origin : frontendUrl(copyConfig),
2310
+ allowLoopbackAlias: !isExternal(copyConfig),
2311
+ }
2312
+ : undefined,
2302
2313
  output,
2303
2314
  redact: (text) => designRedactor(text),
2304
2315
  async withPenpot(work) {
@@ -0,0 +1,2 @@
1
+ /** A bounded identity check, evaluated inside the browser; clipboard text never leaves that page. */
2
+ export declare function nativeClipboardPayloadMatches(text: string, fileId: string, frameIds: string[]): boolean;
@@ -0,0 +1,89 @@
1
+ /** A bounded identity check, evaluated inside the browser; clipboard text never leaves that page. */
2
+ export function nativeClipboardPayloadMatches(text, fileId, frameIds) {
3
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
4
+ const identity = (value) => {
5
+ if (typeof value !== "string") {
6
+ return undefined;
7
+ }
8
+ const plain = value.startsWith("~u") ? value.slice(2) : value;
9
+ return uuid.test(plain) ? plain.toLowerCase() : undefined;
10
+ };
11
+ const field = (record, name) => {
12
+ const ordinary = Object.hasOwn(record, name);
13
+ const encoded = Object.hasOwn(record, `~:${name}`);
14
+ return ordinary === encoded ? undefined : record[ordinary ? name : `~:${name}`];
15
+ };
16
+ const object = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
17
+ if (typeof text !== "string" ||
18
+ text.length > 16 * 1024 * 1024 ||
19
+ !uuid.test(fileId) ||
20
+ !Array.isArray(frameIds) ||
21
+ !frameIds.length ||
22
+ frameIds.length > 100 ||
23
+ frameIds.some((id) => typeof id !== "string" || !uuid.test(id)) ||
24
+ new Set(frameIds.map((id) => id.toLowerCase())).size !== frameIds.length) {
25
+ return false;
26
+ }
27
+ let raw;
28
+ try {
29
+ raw = JSON.parse(text);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ if (!object(raw)) {
35
+ return false;
36
+ }
37
+ const type = field(raw, "type");
38
+ if ((type !== "copied-shapes" && type !== "~:copied-shapes") ||
39
+ identity(field(raw, "file-id")) !== fileId.toLowerCase()) {
40
+ return false;
41
+ }
42
+ let selected = field(raw, "selected");
43
+ if (object(selected)) {
44
+ const tags = Object.keys(selected);
45
+ if (tags.length !== 1 ||
46
+ ![
47
+ "~#set",
48
+ "~#ordered-set",
49
+ ].includes(tags[0])) {
50
+ return false;
51
+ }
52
+ selected = selected[tags[0]];
53
+ }
54
+ else if (Array.isArray(selected) &&
55
+ selected.length === 2 &&
56
+ typeof selected[0] === "string" &&
57
+ [
58
+ "~#set",
59
+ "~#ordered-set",
60
+ ].includes(selected[0])) {
61
+ selected = selected[1];
62
+ }
63
+ if (!Array.isArray(selected) || selected.length !== frameIds.length) {
64
+ return false;
65
+ }
66
+ const selectedIds = selected.map(identity);
67
+ if (selectedIds.some((id) => !id) ||
68
+ new Set(selectedIds).size !== frameIds.length ||
69
+ frameIds.some((id) => !selectedIds.includes(id.toLowerCase()))) {
70
+ return false;
71
+ }
72
+ const objects = field(raw, "objects");
73
+ if (!object(objects)) {
74
+ return false;
75
+ }
76
+ const entries = Object.entries(objects);
77
+ if (entries.length > 20000 || entries.length < frameIds.length) {
78
+ return false;
79
+ }
80
+ const objectIds = new Set();
81
+ for (const [key, value] of entries) {
82
+ const id = identity(key);
83
+ if (!id || objectIds.has(id) || !object(value) || identity(field(value, "id")) !== id) {
84
+ return false;
85
+ }
86
+ objectIds.add(id);
87
+ }
88
+ return frameIds.every((id) => objectIds.has(id.toLowerCase()));
89
+ }
@@ -5,5 +5,10 @@ export interface DesignCommandContext {
5
5
  root: string;
6
6
  output: CommandOutput;
7
7
  redact(value: string): string;
8
+ copyTarget?: {
9
+ fileId: string;
10
+ origin: string;
11
+ allowLoopbackAlias: boolean;
12
+ };
8
13
  withPenpot<T>(work: (client: PenpotToolClient) => Promise<T>): Promise<T>;
9
14
  }
@@ -0,0 +1,3 @@
1
+ import type { DesignCommandContext } from "./context.js";
2
+ /** Preview resolves native boards; only explicit apply obtains clipboard control. */
3
+ export declare function runDesignReleaseCopyCommand(args: string[], context: DesignCommandContext): Promise<number>;
@@ -0,0 +1,101 @@
1
+ import { CommandArgumentError, parseCommandArgs } from "../cli-arguments.js";
2
+ import { commandSignal } from "../command-result.js";
3
+ import { copyReleaseBoards } from "../design-releases/copy.js";
4
+ import { connectNativeClipboard } from "../native-editor-clipboard.js";
5
+ const copyUsage = "design-release copy --source-page PAGE --frame ID [--frame ID ...] --release VERSION [--debug-port PORT --apply] [--resume RUN_ID]";
6
+ function boundedText(value) {
7
+ return (typeof value === "string" &&
8
+ !!value.trim() &&
9
+ value.length <= 1000 &&
10
+ !/[\x00-\x1f]/.test(value));
11
+ }
12
+ /** Preview resolves native boards; only explicit apply obtains clipboard control. */
13
+ export async function runDesignReleaseCopyCommand(args, context) {
14
+ const { values, positionals } = parseCommandArgs({
15
+ args,
16
+ allowPositionals: true,
17
+ options: {
18
+ "source-page": {
19
+ type: "string",
20
+ },
21
+ frame: {
22
+ type: "string",
23
+ multiple: true,
24
+ },
25
+ release: {
26
+ type: "string",
27
+ },
28
+ "debug-port": {
29
+ type: "string",
30
+ },
31
+ apply: {
32
+ type: "boolean",
33
+ },
34
+ resume: {
35
+ type: "string",
36
+ },
37
+ json: {
38
+ type: "boolean",
39
+ },
40
+ help: {
41
+ type: "boolean",
42
+ },
43
+ },
44
+ });
45
+ if (values.help) {
46
+ context.output.log(copyUsage);
47
+ return 0;
48
+ }
49
+ const frameIds = values.frame ?? [];
50
+ if (positionals.length ||
51
+ !boundedText(values["source-page"]) ||
52
+ !boundedText(values.release) ||
53
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(values.release) ||
54
+ frameIds.length < 1 ||
55
+ frameIds.length > 100 ||
56
+ frameIds.some((frameId) => !boundedText(frameId)) ||
57
+ new Set(frameIds).size !== frameIds.length) {
58
+ throw new CommandArgumentError(copyUsage);
59
+ }
60
+ if (values.resume && (!values.apply || !/^[a-zA-Z0-9-]{1,100}$/.test(values.resume))) {
61
+ throw new CommandArgumentError("Copy recovery requires --apply and the original --resume RUN_ID");
62
+ }
63
+ if ((values.apply && !values["debug-port"]) ||
64
+ (values["debug-port"] !== undefined &&
65
+ (!/^\d+$/.test(values["debug-port"]) ||
66
+ Number(values["debug-port"]) < 1 ||
67
+ Number(values["debug-port"]) > 65535))) {
68
+ throw new CommandArgumentError("Copy --apply requires --debug-port with an integer from 1 to 65535");
69
+ }
70
+ const target = context.copyTarget;
71
+ if (!target?.fileId) {
72
+ throw new CommandArgumentError("Native copy requires the project's owner-confirmed file pin");
73
+ }
74
+ const result = await context.withPenpot((client) => copyReleaseBoards(client, context.root, {
75
+ fileId: target.fileId,
76
+ sourcePage: values["source-page"],
77
+ frameIds,
78
+ releaseVersion: values.release,
79
+ }, {
80
+ apply: values.apply === true,
81
+ resume: !!values.resume,
82
+ runId: values.resume,
83
+ redact: (text) => context.redact(text),
84
+ onStep: (message) => context.output.error(`… ${message}`),
85
+ ...(values.apply
86
+ ? {
87
+ clipboard: () => connectNativeClipboard({
88
+ origin: target.origin,
89
+ fileId: target.fileId,
90
+ allowLoopbackAlias: target.allowLoopbackAlias,
91
+ port: Number(values["debug-port"]),
92
+ timeoutMs: 30000,
93
+ signal: commandSignal(),
94
+ }),
95
+ }
96
+ : {}),
97
+ }));
98
+ context.output.result(result);
99
+ context.output.log(JSON.stringify(result, null, 2));
100
+ return result.status === "incomplete" ? 3 : 0;
101
+ }
@@ -6,7 +6,9 @@ import { readProjectBytes } from "../bounded-file.js";
6
6
  import { resolveProjectOutput } from "../project-input.js";
7
7
  import { createArtifactReview } from "../maps/index.js";
8
8
  import { planRelease, assembleRelease, verifyRelease, finalizeRelease, checkRelease, releaseContext, releaseDesignHash, readDesignRelease, writeDesignRelease, } from "../design-releases/index.js";
9
- const releaseUsage = "design-release plan|assemble|verify|finalize|check|context --from FILE [--out FILE]";
9
+ import { runDesignReleaseCopyCommand } from "./copy-command.js";
10
+ const releaseUsage = "design-release plan|assemble|verify|finalize|check|context --from FILE [--out FILE]\n" +
11
+ "design-release copy --source-page PAGE --frame ID [--frame ID ...] --release VERSION [--debug-port PORT --apply] [--resume RUN_ID]";
10
12
  async function readReleaseJson(context, file) {
11
13
  return JSON.parse((await readProjectBytes(context.root, file, 16 * 1024 * 1024)).toString("utf8"));
12
14
  }
@@ -45,6 +47,9 @@ function validateReceiptInput(raw) {
45
47
  }
46
48
  /** CLI entrypoint: local planning stays offline and all live work uses a strictly pinned connection. */
47
49
  export async function runDesignReleaseCommand(args, context) {
50
+ if (args[0] === "copy") {
51
+ return runDesignReleaseCopyCommand(args.slice(1), context);
52
+ }
48
53
  const parsed = parseCommandArgs({
49
54
  args,
50
55
  allowPositionals: true,
@@ -0,0 +1,33 @@
1
+ import type { PenpotToolClient } from "../client.js";
2
+ import type { InspectionCapture } from "../inspection.js";
3
+ import type { ReleaseArtifactRef } from "./types.js";
4
+ export interface CopyFrameEvidence {
5
+ frameId: string;
6
+ name: string;
7
+ inspection: InspectionCapture;
8
+ inspectionRef: ReleaseArtifactRef;
9
+ render: ReleaseArtifactRef;
10
+ pixels: {
11
+ hash: string;
12
+ width: number;
13
+ height: number;
14
+ };
15
+ }
16
+ export interface CopiedFramePair {
17
+ sourceFrameId: string;
18
+ targetFrameId: string;
19
+ }
20
+ export interface CopyEvidenceComparison {
21
+ nativeVerified: boolean;
22
+ pixelsMatch: boolean;
23
+ nodeMap: Record<string, string>;
24
+ problems: string[];
25
+ }
26
+ /** Capture immutable native observations and a stable render; this does not approve a release. */
27
+ export declare function captureCopyEvidence(client: PenpotToolClient, root: string, fileId: string, pageId: string, frameIds: string[], prefix: string, redact?: (text: string) => string): Promise<CopyFrameEvidence[]>;
28
+ /** Matching identifies newly observed boards; names alone never establish a transfer identity. */
29
+ export declare function matchCopiedFrames(sources: CopyFrameEvidence[], targets: CopyFrameEvidence[]): CopiedFramePair[];
30
+ /** Validate observable structure and exact pixels separately; unknown native content never passes. */
31
+ export declare function compareCopyEvidence(sources: CopyFrameEvidence[], targets: CopyFrameEvidence[], pairs: CopiedFramePair[]): CopyEvidenceComparison;
32
+ /** A source preservation hash excludes capture revisions, timestamps and transport metrics. */
33
+ export declare function sourceEvidenceHash(evidence: CopyFrameEvidence[]): string;
@@ -0,0 +1,278 @@
1
+ import { exportShapePng } from "../export.js";
2
+ import { observationHash } from "../journal.js";
3
+ import { saveArtifact } from "./artifact-paths.js";
4
+ import { captureReleaseScreen } from "./live.js";
5
+ import { compareNativeCopy, decodedPixels, deriveNodeMap } from "./fidelity.js";
6
+ /** Capture immutable native observations and a stable render; this does not approve a release. */
7
+ export async function captureCopyEvidence(client, root, fileId, pageId, frameIds, prefix, redact) {
8
+ if (!frameIds.length ||
9
+ frameIds.length > 100 ||
10
+ new Set(frameIds).size !== frameIds.length ||
11
+ frameIds.some((frameId) => typeof frameId !== "string" || !frameId || frameId.length > 256)) {
12
+ throw new Error("Copy evidence needs one to 100 distinct frame IDs");
13
+ }
14
+ const evidence = [];
15
+ for (const [index, frameId] of frameIds.entries()) {
16
+ const { inspection, image } = await captureFramePair(client, fileId, pageId, frameId);
17
+ const first = orderedNodes(inspection, frameId)[0].node;
18
+ if (typeof first.name !== "string") {
19
+ throw new Error("Copy frame name was not observed");
20
+ }
21
+ const pixels = await decodedPixels(image.png);
22
+ const stem = `${prefix}/frame-${index}`;
23
+ const inspectionRef = await saveArtifact(root, `${stem}.json`, JSON.stringify(inspection), redact);
24
+ const render = await saveArtifact(root, `${stem}.png`, image.png, redact);
25
+ evidence.push({
26
+ frameId,
27
+ name: first.name,
28
+ inspection,
29
+ inspectionRef,
30
+ render,
31
+ pixels,
32
+ });
33
+ }
34
+ return evidence;
35
+ }
36
+ /** Retry only an inconsistent read pair, never a mutation or an identity/stability failure. */
37
+ async function captureFramePair(client, fileId, pageId, frameId) {
38
+ for (let attempt = 0; attempt < 2; attempt++) {
39
+ const inspection = await captureReleaseScreen(client, fileId, pageId, frameId);
40
+ orderedNodes(inspection, frameId);
41
+ const image = await exportShapePng(client, frameId, {
42
+ stable: true,
43
+ });
44
+ if (!image.stable ||
45
+ image.capture.fileId !== fileId ||
46
+ image.capture.pageId !== pageId ||
47
+ image.capture.frameId !== frameId) {
48
+ throw new Error("Copy source changed or stable render identity was not established");
49
+ }
50
+ if (image.capture.revision === inspection.revision) {
51
+ return {
52
+ inspection,
53
+ image,
54
+ };
55
+ }
56
+ }
57
+ throw new Error("Copy source changed during both bounded inspection/render attempts");
58
+ }
59
+ /** Observe topology without claiming complete native fidelity for unsupported shape content. */
60
+ function orderedNodes(inspection, frameId) {
61
+ if (inspection.kind !== "shape-inspection" ||
62
+ !inspection.complete ||
63
+ !Array.isArray(inspection.nodes) ||
64
+ !inspection.nodes.length ||
65
+ inspection.nodes.length > 20000) {
66
+ throw new Error("Copy matching requires a complete bounded shape inspection");
67
+ }
68
+ const nodes = new Map();
69
+ for (const node of inspection.nodes) {
70
+ if (typeof node.id !== "string" || !node.id || nodes.has(node.id)) {
71
+ throw new Error("Copy matching encountered duplicate or missing shape IDs");
72
+ }
73
+ nodes.set(node.id, node);
74
+ }
75
+ const root = nodes.get(frameId);
76
+ if (!root || root.parentId !== null || root.type !== "board") {
77
+ throw new Error("Copy matching requires the requested native board root");
78
+ }
79
+ const ordered = [];
80
+ const visited = new Set();
81
+ const pending = [
82
+ {
83
+ node: root,
84
+ path: "root",
85
+ parent: null,
86
+ },
87
+ ];
88
+ while (pending.length) {
89
+ const entry = pending.pop();
90
+ const id = String(entry.node.id);
91
+ const childIds = entry.node.childIds;
92
+ if (visited.has(id) ||
93
+ entry.node.parentId !== entry.parent ||
94
+ !Array.isArray(childIds) ||
95
+ entry.node.childrenTotal !== childIds.length) {
96
+ throw new Error("Copy matching encountered incomplete or inconsistent hierarchy");
97
+ }
98
+ visited.add(id);
99
+ ordered.push({
100
+ path: entry.path,
101
+ node: entry.node,
102
+ });
103
+ for (let index = childIds.length - 1; index >= 0; index--) {
104
+ const childId = childIds[index];
105
+ const child = typeof childId === "string" ? nodes.get(childId) : undefined;
106
+ if (!child || child.index !== index) {
107
+ throw new Error("Copy matching encountered an unknown or reordered child");
108
+ }
109
+ pending.push({
110
+ node: child,
111
+ path: `${entry.path}/${index}`,
112
+ parent: id,
113
+ });
114
+ }
115
+ }
116
+ if (visited.size !== nodes.size) {
117
+ throw new Error("Copy matching includes detached shape observations");
118
+ }
119
+ return ordered;
120
+ }
121
+ function matchingFingerprint(evidence) {
122
+ const ordered = orderedNodes(evidence.inspection, evidence.frameId);
123
+ const rootGeometry = ordered[0].node.geometry;
124
+ if (!rootGeometry) {
125
+ throw new Error("Copy root geometry is unavailable");
126
+ }
127
+ const fingerprint = ordered.map(({ path, node }) => {
128
+ const geometry = node.geometry;
129
+ if (!geometry || typeof node.name !== "string" || typeof node.type !== "string") {
130
+ throw new Error("Copy matching needs observed names, types and geometry");
131
+ }
132
+ const normalized = {};
133
+ for (const axis of [
134
+ "x",
135
+ "y",
136
+ "width",
137
+ "height",
138
+ "rotation",
139
+ ]) {
140
+ const value = geometry[axis];
141
+ if (typeof value !== "number" || !Number.isFinite(value)) {
142
+ throw new Error("Copy matching geometry must be finite and observable");
143
+ }
144
+ const origin = axis === "x" || axis === "y" ? rootGeometry[axis] : 0;
145
+ if (typeof origin !== "number" || !Number.isFinite(origin)) {
146
+ throw new Error("Copy matching root position must be observable");
147
+ }
148
+ normalized[axis] = Math.round((value - origin) * 1e6) / 1e6;
149
+ }
150
+ return {
151
+ path,
152
+ name: node.name,
153
+ type: node.type,
154
+ geometry: normalized,
155
+ };
156
+ });
157
+ return observationHash(fingerprint);
158
+ }
159
+ /** Matching identifies newly observed boards; names alone never establish a transfer identity. */
160
+ export function matchCopiedFrames(sources, targets) {
161
+ if (!sources.length || sources.length > 100 || sources.length !== targets.length) {
162
+ throw new Error("Copied frame count does not match the requested sources");
163
+ }
164
+ if (new Set(sources.map((source) => source.frameId)).size !== sources.length ||
165
+ new Set(targets.map((target) => target.frameId)).size !== targets.length ||
166
+ targets.some((target) => sources.some((source) => source.frameId === target.frameId))) {
167
+ throw new Error("Source and copied frame identities must be distinct");
168
+ }
169
+ const sourceFingerprints = sources.map(matchingFingerprint);
170
+ const targetFingerprints = targets.map(matchingFingerprint);
171
+ if (new Set(sourceFingerprints).size !== sourceFingerprints.length) {
172
+ throw new Error("Source boards are ambiguous; copy matching cannot assign unique identities");
173
+ }
174
+ return sources.map((source, index) => {
175
+ const matches = targets.filter((_target, targetIndex) => targetFingerprints[targetIndex] === sourceFingerprints[index]);
176
+ if (matches.length !== 1) {
177
+ throw new Error("Copied board content is missing, changed or ambiguous");
178
+ }
179
+ return {
180
+ sourceFrameId: source.frameId,
181
+ targetFrameId: matches[0].frameId,
182
+ };
183
+ });
184
+ }
185
+ /** Validate observable structure and exact pixels separately; unknown native content never passes. */
186
+ export function compareCopyEvidence(sources, targets, pairs) {
187
+ if (!sources.length ||
188
+ sources.length > 100 ||
189
+ pairs.length !== sources.length ||
190
+ targets.length !== sources.length ||
191
+ new Set(pairs.map((pair) => pair.sourceFrameId)).size !== pairs.length ||
192
+ new Set(pairs.map((pair) => pair.targetFrameId)).size !== pairs.length ||
193
+ new Set(sources.map((source) => source.frameId)).size !== sources.length ||
194
+ new Set(targets.map((target) => target.frameId)).size !== targets.length) {
195
+ throw new Error("Copy verification requires complete distinct source-to-target pairs");
196
+ }
197
+ const observations = pairs.map((pair) => {
198
+ const source = sources.find((item) => item.frameId === pair.sourceFrameId);
199
+ const target = targets.find((item) => item.frameId === pair.targetFrameId);
200
+ if (!source ||
201
+ !target ||
202
+ source.frameId === target.frameId ||
203
+ source.inspection.fileId !== target.inspection.fileId) {
204
+ throw new Error("Copy verification pair refers to an unknown or original frame");
205
+ }
206
+ return {
207
+ source,
208
+ target,
209
+ };
210
+ });
211
+ const nodeMap = {};
212
+ const mappedTargets = new Set();
213
+ for (const { source, target } of observations) {
214
+ const before = orderedNodes(source.inspection, source.frameId);
215
+ const after = orderedNodes(target.inspection, target.frameId);
216
+ if (before.length !== after.length) {
217
+ throw new Error("Copy hierarchy node counts differ");
218
+ }
219
+ for (const [index, entry] of before.entries()) {
220
+ const copied = after[index];
221
+ const from = String(entry.node.id);
222
+ const to = String(copied.node.id);
223
+ if (entry.path !== copied.path ||
224
+ entry.node.type !== copied.node.type ||
225
+ from === to ||
226
+ Object.hasOwn(nodeMap, from) ||
227
+ mappedTargets.has(to)) {
228
+ throw new Error("Copy topology or independent shape identity differs");
229
+ }
230
+ nodeMap[from] = to;
231
+ mappedTargets.add(to);
232
+ }
233
+ }
234
+ if (Object.keys(nodeMap).some((id) => mappedTargets.has(id))) {
235
+ throw new Error("Copied descendants overlap original source identities");
236
+ }
237
+ const problems = [];
238
+ let nativeVerified = true;
239
+ let pixelsMatch = true;
240
+ for (const { source, target } of observations) {
241
+ try {
242
+ deriveNodeMap(source.inspection, target.inspection);
243
+ compareNativeCopy(source.inspection, target.inspection, nodeMap);
244
+ const before = orderedNodes(source.inspection, source.frameId);
245
+ const after = orderedNodes(target.inspection, target.frameId);
246
+ if (before.some((entry, index) => entry.node.name !== after[index].node.name)) {
247
+ throw new Error("Native copy changed a board or descendant name");
248
+ }
249
+ }
250
+ catch (error) {
251
+ nativeVerified = false;
252
+ problems.push(`Native fidelity for ${source.frameId}: ${error instanceof Error ? error.message : "unavailable"}`);
253
+ }
254
+ if (source.pixels.hash !== target.pixels.hash ||
255
+ source.pixels.width !== target.pixels.width ||
256
+ source.pixels.height !== target.pixels.height) {
257
+ pixelsMatch = false;
258
+ problems.push(`Exported pixels differ for ${source.frameId}`);
259
+ }
260
+ }
261
+ return {
262
+ nativeVerified,
263
+ pixelsMatch,
264
+ nodeMap,
265
+ problems,
266
+ };
267
+ }
268
+ /** A source preservation hash excludes capture revisions, timestamps and transport metrics. */
269
+ export function sourceEvidenceHash(evidence) {
270
+ return observationHash(evidence.map((frame) => ({
271
+ frameId: frame.frameId,
272
+ name: frame.name,
273
+ fileId: frame.inspection.fileId,
274
+ pageId: frame.inspection.pageId,
275
+ nodes: frame.inspection.nodes,
276
+ pixels: frame.pixels,
277
+ })));
278
+ }
@@ -0,0 +1,49 @@
1
+ export interface CopyRequest {
2
+ fileId: string;
3
+ sourcePage: string;
4
+ frameIds: string[];
5
+ releaseVersion: string;
6
+ }
7
+ export interface CopyPreview {
8
+ arranged?: boolean;
9
+ destinationNamed?: boolean;
10
+ fileId: string;
11
+ revision: number;
12
+ sourcePageId: string;
13
+ sourcePageName: string;
14
+ destinationPageId: string | null;
15
+ destinationName: string;
16
+ destinationRootIds: string[];
17
+ destinationBounds: {
18
+ bottom: number;
19
+ };
20
+ sourceFrames: Array<{
21
+ id: string;
22
+ name: string;
23
+ x: number;
24
+ y: number;
25
+ width: number;
26
+ height: number;
27
+ nodeCount: number;
28
+ }>;
29
+ createdPageOwner: string | null;
30
+ }
31
+ export interface CopyObservation {
32
+ pageId: string;
33
+ rootIds: string[];
34
+ selectionIds: string[];
35
+ rootFrames: Array<{
36
+ id: string;
37
+ name: string;
38
+ type: string;
39
+ x: number;
40
+ y: number;
41
+ width: number;
42
+ height: number;
43
+ nodeCount: number;
44
+ copyRunId: string | null;
45
+ }>;
46
+ }
47
+ export declare function validateCopyRequest(request: CopyRequest): void;
48
+ export declare function readCopyPreview(raw: Record<string, unknown>, request: CopyRequest): CopyPreview;
49
+ export declare function copyRequestHash(request: CopyRequest): string;
@@ -0,0 +1,55 @@
1
+ import { observationHash } from "../journal.js";
2
+ export function validateCopyRequest(request) {
3
+ const text = (value) => typeof value === "string" &&
4
+ value.trim().length > 0 &&
5
+ value.length <= 256 &&
6
+ !/[\x00-\x1f\x7f]/.test(value);
7
+ if (!text(request.fileId) ||
8
+ !text(request.sourcePage) ||
9
+ !Array.isArray(request.frameIds) ||
10
+ !request.frameIds.length ||
11
+ request.frameIds.length > 100 ||
12
+ request.frameIds.some((id) => !text(id)) ||
13
+ new Set(request.frameIds).size !== request.frameIds.length) {
14
+ throw new Error("Copy requires a pinned file, source page and 1–100 distinct board IDs");
15
+ }
16
+ if (!text(request.releaseVersion) ||
17
+ !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request.releaseVersion)) {
18
+ throw new Error("Copy release version must be a safe semantic version, for example 1.2.0");
19
+ }
20
+ }
21
+ export function readCopyPreview(raw, request) {
22
+ if (raw.ok !== true ||
23
+ raw.fileId !== request.fileId ||
24
+ !Number.isInteger(raw.revision) ||
25
+ typeof raw.sourcePageId !== "string" ||
26
+ typeof raw.sourcePageName !== "string" ||
27
+ !(raw.destinationPageId === null || typeof raw.destinationPageId === "string") ||
28
+ raw.destinationName !== `release/${request.releaseVersion}` ||
29
+ !Array.isArray(raw.destinationRootIds) ||
30
+ raw.destinationRootIds.some((id) => typeof id !== "string") ||
31
+ !Array.isArray(raw.sourceFrames) ||
32
+ raw.sourceFrames.length !== request.frameIds.length ||
33
+ !raw.destinationBounds ||
34
+ typeof raw.destinationBounds !== "object" ||
35
+ !Number.isFinite(raw.destinationBounds.bottom)) {
36
+ throw new Error("Copy inspection did not establish the complete source and destination identities");
37
+ }
38
+ const frames = raw.sourceFrames;
39
+ if (frames.some((frame, index) => !frame ||
40
+ frame.id !== request.frameIds[index] ||
41
+ typeof frame.name !== "string" ||
42
+ ![
43
+ frame.x,
44
+ frame.y,
45
+ frame.width,
46
+ frame.height,
47
+ frame.nodeCount,
48
+ ].every(Number.isFinite))) {
49
+ throw new Error("Copy inspection returned incomplete board evidence");
50
+ }
51
+ return raw;
52
+ }
53
+ export function copyRequestHash(request) {
54
+ return observationHash(request);
55
+ }