@smallpen/core 0.1.0-alpha.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.
package/src/draft.mjs ADDED
@@ -0,0 +1,784 @@
1
+ import { createSemanticTree, diffSemanticTrees, projectDesignView, resolveDesignView } from "./design-read.mjs";
2
+ import { fail } from "./errors.mjs";
3
+
4
+ const SUPPORTED_FIGMA_NODE_TYPES = new Set([
5
+ "COMPONENT",
6
+ "COMPONENT_SET",
7
+ "FRAME",
8
+ "INSTANCE",
9
+ "RECTANGLE",
10
+ "ROUNDED_RECTANGLE",
11
+ "SYMBOL",
12
+ "TEXT",
13
+ ]);
14
+
15
+ const NON_VISUAL_FIGMA_NODE_TYPES = new Set([
16
+ "CANVAS",
17
+ "DOCUMENT",
18
+ "INTERNAL_ONLY_NODE",
19
+ "STYLE",
20
+ "STYLE_SET",
21
+ "VARIABLE",
22
+ "VARIABLE_COLLECTION",
23
+ "VARIABLE_SET",
24
+ ]);
25
+
26
+ const MERGEABLE_FIELDS = new Set([
27
+ "cornerRadius",
28
+ "fills",
29
+ "flipX",
30
+ "flipY",
31
+ "height",
32
+ "name",
33
+ "opacity",
34
+ "rotation",
35
+ "strokes",
36
+ "text",
37
+ "textBlocks",
38
+ "textStyle",
39
+ "visible",
40
+ "width",
41
+ "x",
42
+ "y",
43
+ ]);
44
+
45
+ const REQUIRED_MERGE_FIELDS = new Set(["height", "name", "text", "width", "x", "y"]);
46
+
47
+ function isRecord(value) {
48
+ return value !== null && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+
51
+ function stablePart(value) {
52
+ return String(value ?? "unknown")
53
+ .replace(/[^a-zA-Z0-9_-]/g, "_")
54
+ .replace(/^_+|_+$/g, "") || "unknown";
55
+ }
56
+
57
+ function jsonSafe(value, seen = new WeakSet()) {
58
+ if (value === null || typeof value === "boolean" || typeof value === "string") return value;
59
+ if (typeof value === "number") return Number.isFinite(value) ? value : String(value);
60
+ if (typeof value === "bigint") return value.toString();
61
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
62
+ return {
63
+ byteLength: value.byteLength,
64
+ omitted: true,
65
+ type: value.constructor.name,
66
+ };
67
+ }
68
+ if (Array.isArray(value)) {
69
+ if (seen.has(value)) return "[Circular]";
70
+ seen.add(value);
71
+ const result = value.map((item) => jsonSafe(item, seen));
72
+ seen.delete(value);
73
+ return result;
74
+ }
75
+ if (isRecord(value)) {
76
+ if (seen.has(value)) return "[Circular]";
77
+ seen.add(value);
78
+ const result = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ if (!["function", "symbol", "undefined"].includes(typeof item)) {
81
+ result[key] = jsonSafe(item, seen);
82
+ }
83
+ }
84
+ seen.delete(value);
85
+ return result;
86
+ }
87
+ return String(value);
88
+ }
89
+
90
+ function guid(value) {
91
+ if (!isRecord(value)) return undefined;
92
+ if (!Number.isFinite(value.sessionID) || !Number.isFinite(value.localID)) return undefined;
93
+ return `${value.sessionID}:${value.localID}`;
94
+ }
95
+
96
+ function nodeId(value) {
97
+ return `node_figma_${stablePart(value).replaceAll(":", "_")}`;
98
+ }
99
+
100
+ function componentSetId(value) {
101
+ return `cmp_figma_${stablePart(value).replaceAll(":", "_")}`;
102
+ }
103
+
104
+ function variantId(value) {
105
+ return `var_figma_${stablePart(value).replaceAll(":", "_")}`;
106
+ }
107
+
108
+ function axisId(value) {
109
+ return `axis_figma_${stablePart(value).replaceAll(":", "_").toLowerCase()}`;
110
+ }
111
+
112
+ function colorChannel(value) {
113
+ const bounded = Math.max(0, Math.min(1, Number(value) || 0));
114
+ return Math.round(bounded * 255).toString(16).padStart(2, "0");
115
+ }
116
+
117
+ function colorHex(value) {
118
+ return `#${colorChannel(value?.r)}${colorChannel(value?.g)}${colorChannel(value?.b)}`;
119
+ }
120
+
121
+ function finite(value, fallback) {
122
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
123
+ }
124
+
125
+ function normalizedRotation(value) {
126
+ const normalized = value % 360;
127
+ return normalized < 0 ? normalized + 360 : normalized;
128
+ }
129
+
130
+ function transform(node) {
131
+ const width = finite(node.size?.x, 100);
132
+ const height = finite(node.size?.y, 100);
133
+ const matrix = node.transform;
134
+ if (!isRecord(matrix)) {
135
+ return { flipX: false, flipY: false, height, rotation: 0, width, x: 0, y: 0 };
136
+ }
137
+ const m00 = finite(matrix.m00, 1);
138
+ const m01 = finite(matrix.m01, 0);
139
+ const m02 = finite(matrix.m02, 0);
140
+ const m10 = finite(matrix.m10, 0);
141
+ const m11 = finite(matrix.m11, 1);
142
+ const m12 = finite(matrix.m12, 0);
143
+ const flipX = m00 * m11 - m01 * m10 < 0;
144
+ const rotation = normalizedRotation(
145
+ Math.atan2(m10, flipX ? m11 : m00) * (180 / Math.PI),
146
+ );
147
+ const radians = rotation * (Math.PI / 180);
148
+ const cosine = Math.cos(radians);
149
+ const sine = Math.sin(radians);
150
+ const centerX = width / 2;
151
+ const centerY = height / 2;
152
+ const linear00 = flipX ? -cosine : cosine;
153
+ const linear01 = flipX ? sine : -sine;
154
+ return {
155
+ flipX,
156
+ flipY: false,
157
+ height,
158
+ rotation,
159
+ width,
160
+ x: m02 - centerX + linear00 * centerX + linear01 * centerY,
161
+ y: m12 - centerY + sine * centerX + cosine * centerY,
162
+ };
163
+ }
164
+
165
+ function componentPropertyValue(value) {
166
+ if (!isRecord(value)) return undefined;
167
+ if (typeof value.boolValue === "boolean") return String(value.boolValue);
168
+ if (typeof value.textValue === "string") return value.textValue;
169
+ if (typeof value.textValue?.characters === "string") return value.textValue.characters;
170
+ const reference = guid(value.guidValue);
171
+ return reference;
172
+ }
173
+
174
+ function variantNameValues(name) {
175
+ if (typeof name !== "string" || !name.includes("=")) return {};
176
+ return Object.fromEntries(
177
+ name.split(",").flatMap((part) => {
178
+ const separator = part.indexOf("=");
179
+ if (separator < 1) return [];
180
+ return [[part.slice(0, separator).trim(), part.slice(separator + 1).trim()]];
181
+ }),
182
+ );
183
+ }
184
+
185
+ function variantValues(node, definitions) {
186
+ const byDefinition = new Map(
187
+ definitions.map((definition) => [guid(definition.id), definition]),
188
+ );
189
+ const fromSpecs = {};
190
+ for (const spec of node.variantPropSpecs ?? []) {
191
+ const definition = byDefinition.get(guid(spec.propDefId));
192
+ if (definition && typeof spec.value === "string") {
193
+ fromSpecs[definition.name] = spec.value;
194
+ }
195
+ }
196
+ return Object.keys(fromSpecs).length > 0 ? fromSpecs : variantNameValues(node.name);
197
+ }
198
+
199
+ function textStyle(node) {
200
+ const result = {
201
+ fontFamily: node.fontName?.family ?? "Inter",
202
+ fontSize: finite(node.fontSize, 14),
203
+ fontStyle: String(node.fontName?.style ?? "").toLowerCase().includes("italic")
204
+ ? "italic"
205
+ : "normal",
206
+ fontWeight: /bold/i.test(node.fontName?.style ?? "") ? 700 : 400,
207
+ letterSpacing: finite(node.letterSpacing?.value, 0),
208
+ lineHeight: finite(node.lineHeight?.value, finite(node.fontSize, 14) * 1.2),
209
+ textAlign: String(node.textAlignHorizontal ?? "LEFT").toLowerCase(),
210
+ textDecoration: "none",
211
+ textDirection: "ltr",
212
+ textTransform: "none",
213
+ verticalAlign: String(node.textAlignVertical ?? "TOP").toLowerCase(),
214
+ };
215
+ if (!new Set(["center", "justify", "left", "right"]).has(result.textAlign)) {
216
+ result.textAlign = "left";
217
+ }
218
+ if (!new Set(["bottom", "center", "top"]).has(result.verticalAlign)) {
219
+ result.verticalAlign = "top";
220
+ }
221
+ return result;
222
+ }
223
+
224
+ function figmaMetadata(node) {
225
+ return {
226
+ ...(Array.isArray(node.componentPropAssignments)
227
+ ? { componentPropAssignments: jsonSafe(node.componentPropAssignments) }
228
+ : {}),
229
+ ...(Array.isArray(node.componentPropDefs)
230
+ ? { componentPropDefs: jsonSafe(node.componentPropDefs) }
231
+ : {}),
232
+ guid: guid(node.guid),
233
+ sourceType: node.type,
234
+ ...(Array.isArray(node.variantPropSpecs)
235
+ ? { variantPropSpecs: jsonSafe(node.variantPropSpecs) }
236
+ : {}),
237
+ };
238
+ }
239
+
240
+ function supportedFills(node, losses, stableNodeId) {
241
+ const fills = [];
242
+ for (const paint of node.fillPaints ?? []) {
243
+ if (paint.visible === false) continue;
244
+ if (paint.type === "SOLID") {
245
+ fills.push({
246
+ color: colorHex(paint.color),
247
+ ...(typeof paint.opacity === "number" ? { opacity: paint.opacity } : {}),
248
+ type: "solid",
249
+ });
250
+ } else {
251
+ losses.push({
252
+ code: "figma_fill_unsupported",
253
+ message: `Figma ${String(paint.type)} fill remains in read-only source metadata`,
254
+ nodeId: stableNodeId,
255
+ severity: "warning",
256
+ });
257
+ }
258
+ }
259
+ return fills;
260
+ }
261
+
262
+ function mappedNodeType(node) {
263
+ if (node.type === "SYMBOL") return "COMPONENT";
264
+ if (node.type === "ROUNDED_RECTANGLE") return "RECTANGLE";
265
+ if (SUPPORTED_FIGMA_NODE_TYPES.has(node.type)) return node.type;
266
+ return "FRAME";
267
+ }
268
+
269
+ function primitiveOverrides(node, idByGuid, losses, stableNodeId) {
270
+ const result = {};
271
+ for (const override of node.symbolData?.symbolOverrides ?? []) {
272
+ const overrideGuid = guid(override.guid ?? override.overrideGUID ?? override.targetGUID);
273
+ const target = overrideGuid ? idByGuid.get(overrideGuid) ?? nodeId(overrideGuid) : stableNodeId;
274
+ let value;
275
+ if (typeof override.value === "boolean" || typeof override.value === "number" || typeof override.value === "string") {
276
+ value = override.value;
277
+ } else if (typeof override.text === "string") {
278
+ value = override.text;
279
+ } else if (typeof override.textData?.characters === "string") {
280
+ value = override.textData.characters;
281
+ } else if (typeof override.visible === "boolean") {
282
+ value = override.visible;
283
+ }
284
+ if (value !== undefined) {
285
+ result[`${target}:${override.field ?? override.type ?? "value"}`] = value;
286
+ } else {
287
+ losses.push({
288
+ code: "instance_override_unsupported",
289
+ message: "A Figma instance override remains only in read-only source metadata",
290
+ nodeId: stableNodeId,
291
+ severity: "warning",
292
+ });
293
+ }
294
+ }
295
+ return result;
296
+ }
297
+
298
+ function createConverter(nodeChanges, packageId, losses, componentByNodeGuid) {
299
+ const byGuid = new Map();
300
+ const childrenByGuid = new Map();
301
+ const idByGuid = new Map();
302
+ for (const node of nodeChanges) {
303
+ const identity = guid(node.guid);
304
+ if (!identity) continue;
305
+ byGuid.set(identity, node);
306
+ idByGuid.set(identity, nodeId(identity));
307
+ const parent = guid(node.parentIndex?.guid);
308
+ if (parent) {
309
+ const children = childrenByGuid.get(parent) ?? [];
310
+ children.push(identity);
311
+ childrenByGuid.set(parent, children);
312
+ }
313
+ }
314
+
315
+ function convert(identity, scope = new Set()) {
316
+ const source = byGuid.get(identity);
317
+ if (!source) fail("missing_figma_node", `Figma node is missing: ${identity}`);
318
+ const stableNodeId = idByGuid.get(identity);
319
+ const type = mappedNodeType(source);
320
+ if (!SUPPORTED_FIGMA_NODE_TYPES.has(source.type)) {
321
+ losses.push({
322
+ code: "node_type_flattened",
323
+ message: `Converted unsupported Figma node type ${String(source.type)} to FRAME`,
324
+ nodeId: stableNodeId,
325
+ severity: "warning",
326
+ });
327
+ }
328
+ const node = {
329
+ children: (childrenByGuid.get(identity) ?? [])
330
+ .filter((child) => !scope.has(child))
331
+ .filter((child) => !NON_VISUAL_FIGMA_NODE_TYPES.has(byGuid.get(child)?.type))
332
+ .map((child) => idByGuid.get(child)),
333
+ fills: supportedFills(source, losses, stableNodeId),
334
+ id: stableNodeId,
335
+ name: typeof source.name === "string" ? source.name : String(source.type ?? "Figma Node"),
336
+ opacity: Math.max(0, Math.min(1, finite(source.opacity, 1))),
337
+ sourceMetadata: { figma: figmaMetadata(source) },
338
+ type,
339
+ visible: source.visible !== false,
340
+ ...transform(source),
341
+ };
342
+ if (typeof source.cornerRadius === "number" && source.cornerRadius >= 0) {
343
+ node.cornerRadius = source.cornerRadius;
344
+ }
345
+ if (source.type === "TEXT") {
346
+ node.text = source.textData?.characters ?? "";
347
+ node.textStyle = textStyle(source);
348
+ }
349
+ if ((source.strokePaints?.length ?? 0) > 0 || (source.effects?.length ?? 0) > 0) {
350
+ losses.push({
351
+ code: "visual_style_partial",
352
+ message: "Figma strokes/effects remain in read-only source metadata",
353
+ nodeId: stableNodeId,
354
+ severity: "warning",
355
+ });
356
+ }
357
+ if (source.stackMode === "HORIZONTAL" || source.stackMode === "VERTICAL") {
358
+ node.layoutMode = source.stackMode;
359
+ node.itemSpacing = finite(source.stackSpacing, 0);
360
+ const basePadding = finite(source.stackPadding, 0);
361
+ node.paddingTop = finite(source.stackVerticalPadding, basePadding);
362
+ node.paddingBottom = finite(source.stackPaddingBottom, basePadding);
363
+ node.paddingLeft = finite(source.stackHorizontalPadding, basePadding);
364
+ node.paddingRight = finite(source.stackPaddingRight, basePadding);
365
+ }
366
+ if (source.type === "INSTANCE") {
367
+ const sourceComponent = guid(source.symbolData?.symbolID);
368
+ const component = sourceComponent ? componentByNodeGuid.get(sourceComponent) : undefined;
369
+ if (component) {
370
+ const overrides = primitiveOverrides(source, idByGuid, losses, stableNodeId);
371
+ node.instance = {
372
+ component: { assetId: component.componentSetId, packageId },
373
+ ...(Object.keys(overrides).length > 0 ? { overrides } : {}),
374
+ variant: structuredClone(component.selection),
375
+ };
376
+ } else {
377
+ node.type = "FRAME";
378
+ losses.push({
379
+ code: "orphaned_instance",
380
+ message: "Figma instance source was not present; the Draft keeps it as a FRAME",
381
+ nodeId: stableNodeId,
382
+ severity: "warning",
383
+ });
384
+ }
385
+ }
386
+ return node;
387
+ }
388
+
389
+ function tree(rootIdentity) {
390
+ const nodes = {};
391
+ const visit = (identity) => {
392
+ const converted = convert(identity);
393
+ nodes[converted.id] = converted;
394
+ for (const child of childrenByGuid.get(identity) ?? []) {
395
+ if (!NON_VISUAL_FIGMA_NODE_TYPES.has(byGuid.get(child)?.type)) visit(child);
396
+ }
397
+ };
398
+ visit(rootIdentity);
399
+ return nodes;
400
+ }
401
+
402
+ return { byGuid, childrenByGuid, convert, idByGuid, tree };
403
+ }
404
+
405
+ function createComponents(nodeChanges, packageId, losses) {
406
+ const preliminary = createConverter(nodeChanges, packageId, losses, new Map());
407
+ const sets = [];
408
+ const componentByNodeGuid = new Map();
409
+ for (const [identity, sourceSet] of preliminary.byGuid) {
410
+ const isSet =
411
+ sourceSet.type === "COMPONENT_SET" ||
412
+ sourceSet.componentPropDefs?.some(({ type }) => type === "VARIANT");
413
+ if (!isSet) continue;
414
+ const componentIdentities = (preliminary.childrenByGuid.get(identity) ?? []).filter((child) =>
415
+ new Set(["COMPONENT", "SYMBOL"]).has(preliminary.byGuid.get(child)?.type),
416
+ );
417
+ if (componentIdentities.length === 0) continue;
418
+ const definitions = (sourceSet.componentPropDefs ?? []).filter(({ type }) => type === "VARIANT");
419
+ const domains = new Map(definitions.map((definition) => [definition.name, new Set(definition.preferredValues?.stringValues ?? [])]));
420
+ const valuesByComponent = new Map();
421
+ for (const componentIdentity of componentIdentities) {
422
+ const values = variantValues(preliminary.byGuid.get(componentIdentity), definitions);
423
+ valuesByComponent.set(componentIdentity, values);
424
+ for (const [name, value] of Object.entries(values)) {
425
+ const domain = domains.get(name) ?? new Set();
426
+ domain.add(value);
427
+ domains.set(name, domain);
428
+ }
429
+ }
430
+ const axes = definitions.map((definition) => {
431
+ const domain = [...(domains.get(definition.name) ?? [])].sort();
432
+ return {
433
+ ...(domain.length > 0 ? { domain } : {}),
434
+ id: axisId(guid(definition.id) ?? definition.name),
435
+ name: definition.name,
436
+ role: /state|status|interaction/i.test(definition.name) ? "state" : "configuration",
437
+ };
438
+ });
439
+ for (const axis of axes) {
440
+ if (axis.role === "state" && !axis.domain?.length) {
441
+ axis.domain = ["default"];
442
+ losses.push({
443
+ code: "variant_axis_domain_inferred",
444
+ message: `State Axis ${axis.name} had no explicit Figma domain; default was inferred`,
445
+ nodeId: preliminary.idByGuid.get(identity),
446
+ severity: "warning",
447
+ });
448
+ }
449
+ }
450
+ const setId = componentSetId(identity);
451
+ const variants = [];
452
+ for (const componentIdentity of componentIdentities) {
453
+ const source = preliminary.byGuid.get(componentIdentity);
454
+ const values = valuesByComponent.get(componentIdentity);
455
+ const selection = Object.fromEntries(
456
+ axes.map((axis) => [
457
+ axis.id,
458
+ values[axis.name] ?? axis.domain?.[0] ?? componentPropertyValue(
459
+ definitions.find(({ name }) => name === axis.name)?.initialValue,
460
+ ) ?? "default",
461
+ ]),
462
+ );
463
+ componentByNodeGuid.set(componentIdentity, { componentSetId: setId, selection });
464
+ variants.push({
465
+ id: variantId(componentIdentity),
466
+ nodes: {},
467
+ rootId: nodeId(componentIdentity),
468
+ selection,
469
+ source,
470
+ });
471
+ }
472
+ sets.push({ axes, id: setId, name: sourceSet.name ?? "Figma Component Set", variants, visibility: "public" });
473
+ }
474
+ const converter = createConverter(nodeChanges, packageId, losses, componentByNodeGuid);
475
+ for (const set of sets) {
476
+ for (const variant of set.variants) {
477
+ const identity = guid(variant.source.guid);
478
+ variant.nodes = converter.tree(identity);
479
+ delete variant.source;
480
+ }
481
+ }
482
+ return { componentByNodeGuid, converter, sets };
483
+ }
484
+
485
+ export function createFigmaDraftValues({ importedAt, inputHash, meta, nodeChanges, packageId }) {
486
+ if (!Array.isArray(nodeChanges) || nodeChanges.length === 0) {
487
+ fail("empty_figma_clipboard", "Figma structured clipboard contains no nodes");
488
+ }
489
+ const losses = [];
490
+ const { converter, sets } = createComponents(nodeChanges, packageId, losses);
491
+ const componentSetGuids = new Set(
492
+ [...converter.byGuid].filter(([, node]) =>
493
+ node.type === "COMPONENT_SET" || node.componentPropDefs?.some(({ type }) => type === "VARIANT"),
494
+ ).map(([identity]) => identity),
495
+ );
496
+ const topLevel = [...converter.byGuid].flatMap(([identity, node]) => {
497
+ if (NON_VISUAL_FIGMA_NODE_TYPES.has(node.type) || componentSetGuids.has(identity)) return [];
498
+ const parent = guid(node.parentIndex?.guid);
499
+ if (parent && converter.byGuid.has(parent) && !NON_VISUAL_FIGMA_NODE_TYPES.has(converter.byGuid.get(parent)?.type)) return [];
500
+ return [identity];
501
+ });
502
+ if (topLevel.length === 0) {
503
+ fail("empty_figma_clipboard", "Figma clipboard has no visual top-level nodes");
504
+ }
505
+ const nodes = {};
506
+ const visit = (identity) => {
507
+ if (componentSetGuids.has(identity)) return;
508
+ const converted = converter.convert(identity, componentSetGuids);
509
+ converted.children = converted.children.filter((childId) =>
510
+ ![...componentSetGuids].some((candidate) => converter.idByGuid.get(candidate) === childId),
511
+ );
512
+ nodes[converted.id] = converted;
513
+ for (const child of converter.childrenByGuid.get(identity) ?? []) visit(child);
514
+ };
515
+ for (const identity of topLevel) visit(identity);
516
+ const rootId = "node_import_root";
517
+ const bounds = topLevel.map((identity) => nodes[converter.idByGuid.get(identity)]);
518
+ const maximumX = Math.max(1, ...bounds.map((node) => node.x + node.width));
519
+ const maximumY = Math.max(1, ...bounds.map((node) => node.y + node.height));
520
+ const minimumX = Math.min(0, ...bounds.map((node) => node.x));
521
+ const minimumY = Math.min(0, ...bounds.map((node) => node.y));
522
+ nodes[rootId] = {
523
+ children: topLevel.map((identity) => converter.idByGuid.get(identity)),
524
+ height: maximumY - minimumY,
525
+ id: rootId,
526
+ name: "Figma Import",
527
+ type: "FRAME",
528
+ width: maximumX - minimumX,
529
+ x: minimumX,
530
+ y: minimumY,
531
+ };
532
+ const provenance = {
533
+ dataType: typeof meta?.dataType === "string" ? meta.dataType : "NODE_CHANGES",
534
+ importedAt,
535
+ inputHash,
536
+ ...(typeof meta?.fileKey === "string" ? { sourceFileId: meta.fileKey } : {}),
537
+ ...(Number.isSafeInteger(meta?.pasteID) ? { sourcePasteId: meta.pasteID } : {}),
538
+ sourceKind: "figma-structured",
539
+ };
540
+ const entries = {
541
+ assets: [],
542
+ components: sets.length > 0 ? ["components/imported.json"] : [],
543
+ contexts: [],
544
+ requirements: [],
545
+ scenarios: [],
546
+ screens: ["screens/imported.json"],
547
+ tokens: [],
548
+ };
549
+ const values = new Map([
550
+ [
551
+ "manifest.json",
552
+ {
553
+ defaultScreenId: "scr_figma_import",
554
+ draft: { losses, provenance },
555
+ entries,
556
+ formatVersion: 1,
557
+ name: "Imported Figma Draft",
558
+ packageId,
559
+ role: "foundation",
560
+ },
561
+ ],
562
+ [
563
+ "screens/imported.json",
564
+ {
565
+ basePresentationId: "pres_figma_import",
566
+ counterparts: [],
567
+ id: "scr_figma_import",
568
+ name: "Figma Import",
569
+ presentations: [
570
+ {
571
+ id: "pres_figma_import",
572
+ interactions: [],
573
+ name: "Imported",
574
+ nodes,
575
+ rootId,
576
+ viewport: { height: maximumY - minimumY, width: maximumX - minimumX },
577
+ },
578
+ ],
579
+ },
580
+ ],
581
+ ]);
582
+ if (sets.length > 0) values.set("components/imported.json", { componentSets: sets });
583
+ return { losses, provenance, values };
584
+ }
585
+
586
+ export function createFlatDraftValues({ bytes, height, importedAt, inputHash, mimeType, packageId, sourceKind, width }) {
587
+ const mediaId = `media_draft_${inputHash.slice(0, 24)}`;
588
+ const blob = `blobs/${inputHash}`;
589
+ const provenance = { importedAt, inputHash, sourceKind };
590
+ const losses = [
591
+ {
592
+ code: "flat_media_only",
593
+ message: `${sourceKind.toUpperCase()} fallback preserves pixels but has no editable design structure`,
594
+ severity: "warning",
595
+ },
596
+ ];
597
+ return {
598
+ losses,
599
+ provenance,
600
+ values: new Map([
601
+ [
602
+ "manifest.json",
603
+ {
604
+ defaultScreenId: "scr_flat_import",
605
+ draft: { losses, provenance },
606
+ entries: {
607
+ assets: ["assets/imported.json"],
608
+ components: [],
609
+ contexts: [],
610
+ requirements: [],
611
+ scenarios: [],
612
+ screens: ["screens/imported.json"],
613
+ tokens: [],
614
+ },
615
+ formatVersion: 1,
616
+ name: `${sourceKind.toUpperCase()} Fallback Draft`,
617
+ packageId,
618
+ role: "foundation",
619
+ },
620
+ ],
621
+ [
622
+ "assets/imported.json",
623
+ {
624
+ colors: [],
625
+ fonts: [],
626
+ id: "alib_draft_import",
627
+ media: [
628
+ {
629
+ blob,
630
+ byteLength: bytes.byteLength,
631
+ height,
632
+ id: mediaId,
633
+ mimeType,
634
+ name: `${sourceKind.toUpperCase()} Import`,
635
+ path: "Draft imports",
636
+ sha256: inputHash,
637
+ width,
638
+ },
639
+ ],
640
+ typographies: [],
641
+ },
642
+ ],
643
+ [
644
+ "screens/imported.json",
645
+ {
646
+ basePresentationId: "pres_flat_import",
647
+ counterparts: [],
648
+ id: "scr_flat_import",
649
+ name: `${sourceKind.toUpperCase()} Import`,
650
+ presentations: [
651
+ {
652
+ id: "pres_flat_import",
653
+ interactions: [],
654
+ name: "Imported",
655
+ nodes: {
656
+ node_flat_import: {
657
+ children: [],
658
+ height,
659
+ id: "node_flat_import",
660
+ mediaRef: mediaId,
661
+ name: `${sourceKind.toUpperCase()} Import`,
662
+ type: "IMAGE",
663
+ width,
664
+ x: 0,
665
+ y: 0,
666
+ },
667
+ },
668
+ rootId: "node_flat_import",
669
+ viewport: { height, width },
670
+ },
671
+ ],
672
+ },
673
+ ],
674
+ [blob, bytes],
675
+ ]),
676
+ };
677
+ }
678
+
679
+ export function draftFromSnapshot(snapshot) {
680
+ if (!isRecord(snapshot?.manifest?.draft)) {
681
+ fail("not_a_draft", "SmallPen Package is not an imported Draft");
682
+ }
683
+ return {
684
+ losses: structuredClone(snapshot.manifest.draft.losses),
685
+ provenance: structuredClone(snapshot.manifest.draft.provenance),
686
+ snapshot,
687
+ };
688
+ }
689
+
690
+ function lossDiff(before, after) {
691
+ const key = (loss) => `${loss.code}:${loss.nodeId ?? ""}`;
692
+ const beforeByKey = new Map(before.losses.map((loss) => [key(loss), loss]));
693
+ const afterByKey = new Map(after.losses.map((loss) => [key(loss), loss]));
694
+ const result = [];
695
+ for (const path of [...new Set([...beforeByKey.keys(), ...afterByKey.keys()])].sort()) {
696
+ const previous = beforeByKey.get(path);
697
+ const next = afterByKey.get(path);
698
+ if (!previous && next) result.push({ after: structuredClone(next), kind: "added", path });
699
+ else if (previous && !next) result.push({ before: structuredClone(previous), kind: "removed", path });
700
+ else if (JSON.stringify(previous) !== JSON.stringify(next)) {
701
+ result.push({ after: structuredClone(next), before: structuredClone(previous), kind: "changed", path });
702
+ }
703
+ }
704
+ return result;
705
+ }
706
+
707
+ function draftSemanticTree(draft) {
708
+ const resolved = resolveDesignView(draft.snapshot, {});
709
+ const projection = projectDesignView(draft.snapshot, resolved);
710
+ return createSemanticTree(draft.snapshot, projection, {
711
+ scenarioId: resolved.scenarioId,
712
+ selection: resolved.selection,
713
+ });
714
+ }
715
+
716
+ export function diffDrafts(beforeValue, afterValue) {
717
+ const before = beforeValue.snapshot ? beforeValue : draftFromSnapshot(beforeValue);
718
+ const after = afterValue.snapshot ? afterValue : draftFromSnapshot(afterValue);
719
+ return {
720
+ losses: lossDiff(before, after),
721
+ semantic: diffSemanticTrees(
722
+ draftSemanticTree(before),
723
+ draftSemanticTree(after),
724
+ ),
725
+ };
726
+ }
727
+
728
+ function presentation(snapshot, selection) {
729
+ const screenEntry = snapshot.manifest.entries.screens.find(
730
+ (entry) => snapshot.entries[entry].id === selection.screenId,
731
+ );
732
+ const screen = screenEntry ? snapshot.entries[screenEntry] : undefined;
733
+ const presentationId = selection.presentationId ?? screen?.basePresentationId;
734
+ return screen?.presentations.find(({ id }) => id === presentationId);
735
+ }
736
+
737
+ export function compileDraftMerge(canonical, draftValue, selections, options = {}) {
738
+ const draft = draftValue.snapshot ? draftValue : draftFromSnapshot(draftValue);
739
+ if (!Array.isArray(selections) || selections.length === 0) {
740
+ fail("missing_draft_selections", "Draft compile requires at least one explicit selection");
741
+ }
742
+ const operations = selections.map((selection, selectionIndex) => {
743
+ if (!isRecord(selection) || !Array.isArray(selection.fields) || selection.fields.length === 0) {
744
+ fail("invalid_draft_selection", `Draft selection ${selectionIndex} is invalid`);
745
+ }
746
+ if (new Set(selection.fields).size !== selection.fields.length) {
747
+ fail("invalid_draft_selection", `Draft selection ${selectionIndex} contains duplicate fields`);
748
+ }
749
+ const canonicalPresentation = presentation(canonical, selection);
750
+ const draftPresentation = presentation(draft.snapshot, selection);
751
+ const canonicalNode = canonicalPresentation?.nodes[selection.nodeId];
752
+ const draftNode = draftPresentation?.nodes[selection.nodeId];
753
+ if (!canonicalNode || !draftNode) {
754
+ fail(
755
+ "missing_draft_merge_node",
756
+ "Draft selection must reference matching owned Canonical and Draft nodes",
757
+ { nodeId: selection.nodeId, screenId: selection.screenId },
758
+ );
759
+ }
760
+ const changes = {};
761
+ for (const field of selection.fields) {
762
+ if (!MERGEABLE_FIELDS.has(field)) {
763
+ fail("unsupported_draft_merge_field", `Draft merge cannot change field: ${field}`, { field });
764
+ }
765
+ const value = draftNode[field];
766
+ if (value === undefined && REQUIRED_MERGE_FIELDS.has(field)) {
767
+ fail("missing_draft_merge_value", `Draft field is missing: ${selection.nodeId}.${field}`);
768
+ }
769
+ changes[field] = value === undefined ? null : structuredClone(value);
770
+ }
771
+ return {
772
+ changes,
773
+ nodeId: selection.nodeId,
774
+ presentationId: canonicalPresentation.id,
775
+ screenId: selection.screenId,
776
+ type: "update-presentation-node",
777
+ };
778
+ });
779
+ return {
780
+ baseRevision: canonical.revision,
781
+ batchId: options.batchId ?? `draft_${globalThis.crypto.randomUUID()}`,
782
+ operations,
783
+ };
784
+ }