@1agh/maude 0.58.2 → 0.58.3

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 (105) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/api.ts +6 -1
  3. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  4. package/apps/studio/bin/_import-asset.mjs +72 -0
  5. package/apps/studio/bin/_import-figma.mjs +1121 -0
  6. package/apps/studio/bin/_video-playwright.mjs +86 -3
  7. package/apps/studio/bin/import-figma.sh +38 -0
  8. package/apps/studio/bin/read-annotations.mjs +11 -1
  9. package/apps/studio/bun.lock +16 -22
  10. package/apps/studio/canvas-edit.ts +29 -5
  11. package/apps/studio/client/app.jsx +44 -1
  12. package/apps/studio/client/export-center.jsx +42 -4
  13. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  14. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  15. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  16. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  17. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  18. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  19. package/apps/studio/client/panels/timeline-parse.js +3 -3
  20. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  21. package/apps/studio/client/styles/4-components.css +134 -0
  22. package/apps/studio/clip-ops.ts +93 -17
  23. package/apps/studio/cloud/endpoints.ts +78 -10
  24. package/apps/studio/cloud/renew.ts +183 -0
  25. package/apps/studio/context.ts +2 -1
  26. package/apps/studio/dist/client.bundle.js +1261 -1261
  27. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  28. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  29. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  30. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  31. package/apps/studio/dist/runtime/remotion.js +12 -12
  32. package/apps/studio/dist/styles.css +1 -1
  33. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  34. package/apps/studio/exporters/_runtime.ts +19 -0
  35. package/apps/studio/exporters/degraded.ts +92 -0
  36. package/apps/studio/exporters/index.ts +5 -0
  37. package/apps/studio/exporters/jobs.ts +19 -0
  38. package/apps/studio/exporters/unsupported-media.ts +170 -0
  39. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  40. package/apps/studio/exporters/video-render-lib.ts +6 -0
  41. package/apps/studio/exporters/video.ts +62 -1
  42. package/apps/studio/figma/assets.test.ts +372 -0
  43. package/apps/studio/figma/assets.ts +398 -0
  44. package/apps/studio/figma/client.test.ts +395 -0
  45. package/apps/studio/figma/client.ts +513 -0
  46. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  47. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  48. package/apps/studio/figma/endpoints.ts +200 -0
  49. package/apps/studio/figma/sanitize.test.ts +256 -0
  50. package/apps/studio/figma/sanitize.ts +315 -0
  51. package/apps/studio/figma/style-map.ts +352 -0
  52. package/apps/studio/figma/to-artboard.test.ts +808 -0
  53. package/apps/studio/figma/to-artboard.ts +701 -0
  54. package/apps/studio/figma/to-render.test.ts +180 -0
  55. package/apps/studio/figma/to-render.ts +306 -0
  56. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  57. package/apps/studio/figma/to-strokes.test.ts +705 -0
  58. package/apps/studio/figma/to-strokes.ts +749 -0
  59. package/apps/studio/figma/to-tokens.test.ts +321 -0
  60. package/apps/studio/figma/to-tokens.ts +305 -0
  61. package/apps/studio/figma/types.ts +539 -0
  62. package/apps/studio/figma/url.test.ts +167 -0
  63. package/apps/studio/figma/url.ts +160 -0
  64. package/apps/studio/http.ts +129 -0
  65. package/apps/studio/sync/asset-push.ts +124 -0
  66. package/apps/studio/sync/connection-state.ts +11 -0
  67. package/apps/studio/sync/hub-link.ts +63 -7
  68. package/apps/studio/sync/hubs-config.ts +31 -3
  69. package/apps/studio/sync/index.ts +276 -26
  70. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  71. package/apps/studio/sync/presentation.ts +45 -1
  72. package/apps/studio/sync/supervisor.ts +5 -1
  73. package/apps/studio/sync/workspace-signin.ts +7 -3
  74. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  75. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  76. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  77. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  78. package/apps/studio/test/clip-addressing.test.ts +6 -1
  79. package/apps/studio/test/clip-ops.test.ts +5 -1
  80. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  81. package/apps/studio/test/cloud-renew.test.ts +205 -0
  82. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  83. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  84. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  85. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  86. package/apps/studio/test/figma-provenance.test.ts +108 -0
  87. package/apps/studio/test/figma-routes.test.ts +294 -0
  88. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  89. package/apps/studio/test/hub-link.test.ts +11 -0
  90. package/apps/studio/test/import-figma.test.ts +479 -0
  91. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  92. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  93. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  94. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  95. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  96. package/apps/studio/test/sync-presentation.test.ts +77 -0
  97. package/apps/studio/test/sync-runtime.test.ts +316 -1
  98. package/apps/studio/test/video-comp.test.ts +23 -1
  99. package/apps/studio/test/workspace-containment.test.ts +1 -0
  100. package/apps/studio/video-comp.tsx +70 -6
  101. package/apps/studio/whats-new.json +27 -0
  102. package/apps/studio/workspace-mode.ts +4 -0
  103. package/cli/commands/design.mjs +8 -0
  104. package/package.json +8 -8
  105. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,749 @@
1
+ /**
2
+ * @file figma/to-strokes.ts — FigJam board → the whiteboard Stroke model.
3
+ * @scope apps/studio/figma/to-strokes.ts
4
+ * @purpose The flagship mapping: a FigJam document (normalized by
5
+ * `types.ts`) becomes `Stroke[]` that `strokesToSvg` serializes into
6
+ * `<slug>.annotations.svg`. Maude's whiteboard vocabulary is a close
7
+ * match for FigJam's primitives, and this is the piece no competitor
8
+ * ships.
9
+ *
10
+ * @invariant IMPORT THE CANONICAL MODEL, NEVER HAND-WRITE SVG. Same discipline
11
+ * as `annotate.mjs`: every stroke goes through the real `Stroke`
12
+ * types and the real serializer, so this translator can never emit
13
+ * a shape the canvas wouldn't accept.
14
+ *
15
+ * @invariant THE OUTPUT IS A VERSIONED, PEER-SYNCED, AGENT-READ ARTIFACT.
16
+ * `*.annotations.svg` is VERSIONED (DDR-115), commits and syncs
17
+ * (DDR-054), and `maude design read-annotations` parses it into JSON
18
+ * EXPRESSLY to put in a model's context. DDR-216 D1 calls this the
19
+ * sharpest consumption sink in the feature. So every string here
20
+ * goes through `sanitize.ts` (D6a character classes + D6b
21
+ * normalization) — and residual 1 still applies: none of that stops
22
+ * an instruction from reading like an instruction.
23
+ *
24
+ * @invariant DEPENDENCY-FREE beyond the model. No fs, no network — the caller
25
+ * (`_import-figma.mjs`) owns writes and asset downloads.
26
+ */
27
+
28
+ import {
29
+ type ArrowBind,
30
+ type ArrowStroke,
31
+ DEFAULT_SECTION_COLOR,
32
+ type EllipseStroke,
33
+ type ImageStroke,
34
+ type PolygonShape,
35
+ type PolygonStroke,
36
+ type RectStroke,
37
+ type SectionStroke,
38
+ STICKY_PALETTE,
39
+ type StickyStroke,
40
+ type Stroke,
41
+ type TextStroke,
42
+ } from '../annotations-model.ts';
43
+ import {
44
+ attrValue,
45
+ type Bounds,
46
+ clampIntoBounds,
47
+ cleanText,
48
+ ensureContrast,
49
+ ensureFontSize,
50
+ hexToRgb01,
51
+ ImportReport,
52
+ rgb01ToHex,
53
+ } from './sanitize.ts';
54
+ import type { FigmaColor, FigmaNode, NormalizedDocument } from './types.ts';
55
+
56
+ /** Per-sink text capacities. Overflow is truncated AND reported, never silent. */
57
+ const STICKY_TEXT_CAP = 1200;
58
+ const SHAPE_LABEL_CAP = 400;
59
+ const TEXT_CAP = 4000;
60
+ const SECTION_LABEL_CAP = 120;
61
+
62
+ /**
63
+ * Above this, a board import needs explicit confirmation.
64
+ *
65
+ * D6b's board-side control: on a board the payload does NOT have to hide. A
66
+ * 300-sticky workshop board is imported wholesale, no human reads all 300, and
67
+ * a fully visible sticky is completely effective — so no content rule helps.
68
+ * What helps is bounding how much arrives unreviewed in one gesture
69
+ * (post-implementation review F5: the DDR asserted this control and the code
70
+ * did not have it).
71
+ */
72
+ export const BOARD_STROKE_CEILING = 250;
73
+
74
+ /** Stamped on every imported stroke so a reader can see where it came from. */
75
+ export const FIGMA_AUTHOR_NAME = 'imported-figma';
76
+
77
+ /** Ink default when a node carries no usable stroke colour. */
78
+ const DEFAULT_INK = '#1a1a1a';
79
+ const DEFAULT_STROKE_WIDTH = 2;
80
+
81
+ /**
82
+ * FigJam `shapeType` → Maude primitive. The five that land natively plus the
83
+ * two triangles; everything else (parallelogram, the `ENG_*` engineering set,
84
+ * etc.) has NO Maude equivalent and is skipped AND REPORTED — never silently
85
+ * dropped, and never approximated into a shape that means something different.
86
+ */
87
+ const SHAPE_MAP: Readonly<Record<string, 'rect' | 'ellipse' | PolygonShape>> = Object.assign(
88
+ Object.create(null),
89
+ {
90
+ SQUARE: 'rect',
91
+ ROUNDED_RECTANGLE: 'rect',
92
+ ELLIPSE: 'ellipse',
93
+ DIAMOND: 'diamond',
94
+ TRIANGLE_UP: 'triangle',
95
+ TRIANGLE_DOWN: 'triangle-down',
96
+ }
97
+ );
98
+
99
+ /** FigJam connector caps → the `canvas-arrowheads` vocabulary. */
100
+ const CAP_MAP: Readonly<Record<string, 'none' | 'triangle' | 'line'>> = Object.assign(
101
+ Object.create(null),
102
+ {
103
+ NONE: 'none',
104
+ ARROW_LINES: 'line',
105
+ ARROW_EQUILATERAL: 'triangle',
106
+ TRIANGLE_FILLED: 'triangle',
107
+ }
108
+ );
109
+
110
+ const LINE_TYPE_MAP: Readonly<Record<string, 'straight' | 'elbow' | 'curve'>> = Object.assign(
111
+ Object.create(null),
112
+ {
113
+ STRAIGHT: 'straight',
114
+ ELBOWED: 'elbow',
115
+ CURVED: 'curve',
116
+ }
117
+ );
118
+
119
+ export interface PendingImage {
120
+ /** The stroke whose `href` must be rewritten once the asset lands. */
121
+ strokeId: string;
122
+ nodeId: string;
123
+ /**
124
+ * Figma's image handle for a raster fill — resolved via `/v1/images`,
125
+ * downloaded by T8. `null` for loose vector artwork, which has no handle:
126
+ * there the NODE itself is what gets rendered.
127
+ */
128
+ imageRef: string | null;
129
+ /**
130
+ * What to ask Figma for. Absent ⇒ `png` (the raster-fill case, unchanged).
131
+ * Vector artwork ALSO asks for png — `ASSET_IMAGE_HREF_RE` admits only raster
132
+ * on an `<image>`, and an svg href is silently stripped by the sanitizer
133
+ * rather than rejected loudly.
134
+ */
135
+ format?: 'png' | 'svg';
136
+ }
137
+
138
+ export interface ToStrokesResult {
139
+ strokes: Stroke[];
140
+ report: ImportReport;
141
+ /** Images the caller must resolve + download through `fetch-asset` (T8). */
142
+ pendingImages: PendingImage[];
143
+ /** The translation origin, so a caller can report what it shifted by. */
144
+ origin: { x: number; y: number };
145
+ }
146
+
147
+ function figmaColorToHex(c: FigmaColor | undefined): string | null {
148
+ if (!c) return null;
149
+ return rgb01ToHex({ r: c.r, g: c.g, b: c.b });
150
+ }
151
+
152
+ /** First visible SOLID fill, as hex. */
153
+ function solidFillHex(node: FigmaNode): string | null {
154
+ for (const p of node.fills ?? []) {
155
+ if (!p.visible) continue;
156
+ if (p.type === 'SOLID') return figmaColorToHex(p.color);
157
+ }
158
+ return null;
159
+ }
160
+
161
+ function solidStrokeHex(node: FigmaNode): string | null {
162
+ for (const p of node.strokes ?? []) {
163
+ if (!p.visible) continue;
164
+ if (p.type === 'SOLID') return figmaColorToHex(p.color);
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** First visible IMAGE fill's handle. */
170
+ function imageRef(node: FigmaNode): string | null {
171
+ for (const p of node.fills ?? []) {
172
+ if (!p.visible) continue;
173
+ if (p.type === 'IMAGE' && p.imageRef) return p.imageRef;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * Snap an arbitrary sticky colour onto the nearest `STICKY_PALETTE` tint.
180
+ * `StickyStroke.color` is a free-form string so a raw hex WOULD round-trip —
181
+ * but a FigJam board's named tints (`STICKY_GRAY`, `…_UI3`, `CUSTOM`) should
182
+ * read as Maude paper, not as arbitrary ink.
183
+ */
184
+ export function nearestStickyColor(hex: string | null): string {
185
+ const target = hex ? hexToRgb01(hex) : null;
186
+ if (!target) return STICKY_PALETTE[0];
187
+ let best = STICKY_PALETTE[0];
188
+ let bestDist = Number.POSITIVE_INFINITY;
189
+ for (const candidate of STICKY_PALETTE) {
190
+ const c = hexToRgb01(candidate);
191
+ if (!c) continue;
192
+ const d = (c.r - target.r) ** 2 + (c.g - target.g) ** 2 + (c.b - target.b) ** 2;
193
+ if (d < bestDist) {
194
+ bestDist = d;
195
+ best = candidate;
196
+ }
197
+ }
198
+ return best;
199
+ }
200
+
201
+ /** World bounds of every node carrying geometry — the translation origin. */
202
+ function documentBounds(root: FigmaNode): Bounds {
203
+ let minX = Number.POSITIVE_INFINITY;
204
+ let minY = Number.POSITIVE_INFINITY;
205
+ let maxX = Number.NEGATIVE_INFINITY;
206
+ let maxY = Number.NEGATIVE_INFINITY;
207
+ const visit = (n: FigmaNode) => {
208
+ const b = n.absoluteBoundingBox;
209
+ if (b) {
210
+ minX = Math.min(minX, b.x);
211
+ minY = Math.min(minY, b.y);
212
+ maxX = Math.max(maxX, b.x + b.width);
213
+ maxY = Math.max(maxY, b.y + b.height);
214
+ }
215
+ for (const c of n.children ?? []) visit(c);
216
+ };
217
+ visit(root);
218
+ if (!Number.isFinite(minX)) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
219
+ return { minX, minY, maxX, maxY };
220
+ }
221
+
222
+ let idCounter = 0;
223
+ /** Deterministic per-run stroke ids — derived from the NODE ID, never text. */
224
+ function strokeId(nodeId: string, suffix = ''): string {
225
+ idCounter += 1;
226
+ return `fig_${nodeId.replace(/[^0-9]+/g, '_')}${suffix ? `_${suffix}` : ''}_${idCounter}`;
227
+ }
228
+
229
+ export interface ToStrokesOptions {
230
+ /** Reset the id counter — tests want deterministic ids. */
231
+ resetIds?: boolean;
232
+ /**
233
+ * Shift by THIS origin instead of the document's own bounding box.
234
+ *
235
+ * A design page's annotation layer has to line up with the artboards the
236
+ * canvas already positioned, and those were placed against the PAGE origin —
237
+ * which includes the frames this call does not see. Without the override the
238
+ * strokes get their own origin and every note lands offset by the distance
239
+ * between the two.
240
+ */
241
+ originOverride?: { x: number; y: number };
242
+ /** Caller confirmed a board above `BOARD_STROKE_CEILING`. */
243
+ confirmLarge?: boolean;
244
+ }
245
+
246
+ /** Thrown when a board exceeds the ceiling and the caller has not confirmed. */
247
+ export class BoardTooLargeError extends Error {
248
+ readonly strokeCount: number;
249
+ constructor(strokeCount: number) {
250
+ super(
251
+ `board translates to ${strokeCount} strokes (ceiling ${BOARD_STROKE_CEILING}) — re-run with --confirm-large to import it all`
252
+ );
253
+ this.name = 'BoardTooLargeError';
254
+ this.strokeCount = strokeCount;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Translate a normalized FigJam document into strokes.
260
+ *
261
+ * Coordinates: FigJam is absolute-canvas and a real board spans roughly
262
+ * 14 000 × 30 000 units starting deep in negative space (measured: x ≈ −3 244…
263
+ * +11 037, y ≈ −6 272…+23 488). An untranslated import lands tens of thousands
264
+ * of px off-screen, so everything is shifted by the document's own bounding-box
265
+ * origin. **Absolute SIZES are preserved** — FigJam's sticky default is 240×240
266
+ * against Maude's `STICKY_DEFAULT_W` 200, and normalising to Maude's default
267
+ * collapses every layout.
268
+ */
269
+ export function toStrokes(doc: NormalizedDocument, opts: ToStrokesOptions = {}): ToStrokesResult {
270
+ if (opts.resetIds) idCounter = 0;
271
+ const report = new ImportReport();
272
+ const pendingImages: PendingImage[] = [];
273
+ const bounds = documentBounds(doc.root);
274
+ const origin = opts.originOverride ?? { x: bounds.minX, y: bounds.minY };
275
+ // Post-shift bounds, for D6b's geometry clamp.
276
+ const shifted: Bounds = {
277
+ minX: 0,
278
+ minY: 0,
279
+ maxX: bounds.maxX - bounds.minX,
280
+ maxY: bounds.maxY - bounds.minY,
281
+ };
282
+
283
+ const strokes: Stroke[] = [];
284
+ /** Figma node id → the stroke id it produced, for connector binding. */
285
+ const nodeToStroke = new Map<string, string>();
286
+ /** Figma node id → its shifted bbox, for the group-endpoint fallback. */
287
+ const nodeBBox = new Map<string, { x: number; y: number; w: number; h: number }>();
288
+ /** Connectors are resolved in a second pass — their hosts must exist first. */
289
+ const connectors: Array<{ node: FigmaNode; groupIds: string[] }> = [];
290
+
291
+ const shift = (x: number, y: number) => ({ x: x - origin.x, y: y - origin.y });
292
+
293
+ const emitText = (
294
+ node: FigmaNode,
295
+ groupIds: string[],
296
+ box: { x: number; y: number; w: number; h: number }
297
+ ): void => {
298
+ const raw = node.characters ?? node.name;
299
+ const cleaned = cleanText(raw, TEXT_CAP);
300
+ if (cleaned.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
301
+ if (cleaned.truncated) report.add(node.id, node.type, 'truncated-text');
302
+ if (!cleaned.text.trim()) {
303
+ report.add(node.id, node.type, 'hidden-node-skipped', 'empty after sanitize');
304
+ return;
305
+ }
306
+ const size = ensureFontSize(node.style?.fontSize ?? 16);
307
+ if (size.changed) report.add(node.id, node.type, 'text-normalized', 'font-size floor');
308
+ // Contrast is against the board paper, which is what a standalone FigJam
309
+ // text sits on — there is no parent fill to resolve.
310
+ const ink = ensureContrast(solidFillHex(node) ?? DEFAULT_INK, '#ffffff');
311
+ if (ink.changed) report.add(node.id, node.type, 'text-normalized', 'contrast floor');
312
+ const id = strokeId(node.id);
313
+ const stroke: TextStroke = {
314
+ id,
315
+ tool: 'text',
316
+ color: ink.hex,
317
+ fontSize: size.size,
318
+ text: cleaned.text,
319
+ x: box.x,
320
+ y: box.y,
321
+ ...(groupIds.length ? { groupIds } : {}),
322
+ ...(node.rotation ? { rotation: -node.rotation } : {}),
323
+ };
324
+ strokes.push(stroke);
325
+ nodeToStroke.set(node.id, id);
326
+ report.add(node.id, node.type, 'imported');
327
+ };
328
+
329
+ const visit = (node: FigmaNode, groupIds: string[], silent = false): void => {
330
+ // D6b: an explicitly hidden node is not emitted at all.
331
+ if (!node.visible) {
332
+ if (!silent) report.add(node.id, node.type, 'hidden-node-skipped', 'visible:false');
333
+ return;
334
+ }
335
+
336
+ const bb = node.absoluteBoundingBox;
337
+ let box = { x: 0, y: 0, w: 0, h: 0 };
338
+ if (bb) {
339
+ const p = shift(bb.x, bb.y);
340
+ const clamped = clampIntoBounds(p.x, p.y, shifted);
341
+ if (clamped.changed) report.add(node.id, node.type, 'geometry-clamped');
342
+ box = { x: clamped.x, y: clamped.y, w: bb.width, h: bb.height };
343
+ nodeBBox.set(node.id, box);
344
+ }
345
+
346
+ switch (node.type) {
347
+ case 'DOCUMENT':
348
+ case 'CANVAS':
349
+ for (const c of node.children ?? []) visit(c, groupIds, silent);
350
+ return;
351
+
352
+ case 'GROUP':
353
+ case 'FRAME': {
354
+ // A FigJam group becomes a flat `groupIds[]` TAG on its members — the
355
+ // Excalidraw tag model. Deepest-first, so nested groups nest correctly.
356
+ const gid = strokeId(node.id, 'g');
357
+ const nextGroups = [gid, ...groupIds];
358
+ for (const c of node.children ?? []) visit(c, nextGroups, silent);
359
+ report.add(node.id, node.type, 'imported', 'group → groupIds tag');
360
+ return;
361
+ }
362
+
363
+ case 'SECTION': {
364
+ // SectionStroke is FLAT — no parent field. Nesting survives as
365
+ // GEOMETRIC CONTAINMENT (dragging carries every stroke whose centre is
366
+ // inside), which is how Maude sections already work. The sample board
367
+ // has a section with 12 child sections; that reads correctly here.
368
+ const label = cleanText(node.name, SECTION_LABEL_CAP);
369
+ if (label.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
370
+ const id = strokeId(node.id);
371
+ const stroke: SectionStroke = {
372
+ id,
373
+ tool: 'section',
374
+ x: box.x,
375
+ y: box.y,
376
+ w: box.w,
377
+ h: box.h,
378
+ label: label.text,
379
+ color: solidFillHex(node) ?? DEFAULT_SECTION_COLOR,
380
+ ...(groupIds.length ? { groupIds } : {}),
381
+ };
382
+ strokes.push(stroke);
383
+ nodeToStroke.set(node.id, id);
384
+ report.add(node.id, node.type, 'imported');
385
+ for (const c of node.children ?? []) visit(c, groupIds, silent);
386
+ return;
387
+ }
388
+
389
+ case 'STICKY': {
390
+ const body = cleanText(node.characters ?? '', STICKY_TEXT_CAP);
391
+ if (body.strippedHidden) report.add(node.id, node.type, 'hidden-chars-dropped');
392
+ if (body.truncated) report.add(node.id, node.type, 'truncated-text');
393
+ const paper = nearestStickyColor(solidFillHex(node));
394
+ const size = ensureFontSize(node.style?.fontSize ?? 14);
395
+ if (size.changed) report.add(node.id, node.type, 'text-normalized', 'font-size floor');
396
+ const id = strokeId(node.id);
397
+ const stroke: StickyStroke = {
398
+ id,
399
+ tool: 'sticky',
400
+ color: paper,
401
+ // Absolute geometry PRESERVED — FigJam's 240×240 default (and the
402
+ // 416×240 wide variant) must not collapse to Maude's 200.
403
+ x: box.x,
404
+ y: box.y,
405
+ w: box.w || 240,
406
+ h: box.h || 240,
407
+ text: body.text,
408
+ fontSize: size.size,
409
+ ...(groupIds.length ? { groupIds } : {}),
410
+ ...(node.rotation ? { rotation: -node.rotation } : {}),
411
+ };
412
+ strokes.push(stroke);
413
+ nodeToStroke.set(node.id, id);
414
+ report.add(node.id, node.type, 'imported');
415
+ return;
416
+ }
417
+
418
+ case 'SHAPE_WITH_TEXT': {
419
+ const kind = node.shapeType ? SHAPE_MAP[node.shapeType] : undefined;
420
+ if (!kind) {
421
+ report.add(node.id, node.type, 'unmappable-shape', node.shapeType ?? 'unknown');
422
+ return;
423
+ }
424
+ const ink = solidStrokeHex(node) ?? DEFAULT_INK;
425
+ const fill = solidFillHex(node);
426
+ const id = strokeId(node.id);
427
+ const base = {
428
+ id,
429
+ color: ink,
430
+ width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
431
+ ...(fill ? { fill } : {}),
432
+ ...(groupIds.length ? { groupIds } : {}),
433
+ ...(node.rotation ? { rotation: -node.rotation } : {}),
434
+ };
435
+ if (kind === 'ellipse') {
436
+ const stroke: EllipseStroke = {
437
+ ...base,
438
+ tool: 'ellipse',
439
+ cx: box.x + box.w / 2,
440
+ cy: box.y + box.h / 2,
441
+ rx: box.w / 2,
442
+ ry: box.h / 2,
443
+ };
444
+ strokes.push(stroke);
445
+ } else if (kind === 'rect') {
446
+ const stroke: RectStroke = {
447
+ ...base,
448
+ tool: 'rect',
449
+ x: box.x,
450
+ y: box.y,
451
+ w: box.w,
452
+ h: box.h,
453
+ ...(node.cornerRadius ? { cornerRadius: node.cornerRadius } : {}),
454
+ };
455
+ strokes.push(stroke);
456
+ } else {
457
+ const stroke: PolygonStroke = {
458
+ ...base,
459
+ tool: 'polygon',
460
+ shape: kind,
461
+ x: box.x,
462
+ y: box.y,
463
+ w: box.w,
464
+ h: box.h,
465
+ };
466
+ strokes.push(stroke);
467
+ }
468
+ nodeToStroke.set(node.id, id);
469
+ report.add(node.id, node.type, 'imported');
470
+
471
+ // The shape's label becomes ANCHORED text on it — the same shape a
472
+ // double-click produces natively.
473
+ const label = cleanText(node.characters ?? '', SHAPE_LABEL_CAP);
474
+ if (label.text.trim()) {
475
+ const size = ensureFontSize(node.style?.fontSize ?? 14);
476
+ const ink2 = ensureContrast(DEFAULT_INK, fill ?? '#ffffff');
477
+ if (ink2.changed) report.add(node.id, node.type, 'text-normalized', 'contrast floor');
478
+ strokes.push({
479
+ id: strokeId(node.id, 'label'),
480
+ tool: 'text',
481
+ color: ink2.hex,
482
+ fontSize: size.size,
483
+ text: label.text,
484
+ anchorId: id,
485
+ ...(groupIds.length ? { groupIds } : {}),
486
+ } as TextStroke);
487
+ }
488
+ return;
489
+ }
490
+
491
+ case 'TEXT':
492
+ emitText(node, groupIds, box);
493
+ return;
494
+
495
+ case 'CONNECTOR':
496
+ // Deferred — hosts must exist before endpoints can bind.
497
+ connectors.push({ node, groupIds });
498
+ return;
499
+
500
+ case 'VECTOR':
501
+ case 'LINE':
502
+ case 'STAR':
503
+ case 'REGULAR_POLYGON':
504
+ case 'BOOLEAN_OPERATION': {
505
+ // LOOSE VECTOR ARTWORK IS CONTENT, NOT NOISE.
506
+ //
507
+ // These used to fall through to `unmappable-type` and vanish. On the
508
+ // live StudyFi file that dropped the NINE red flow arrows drawn between
509
+ // the onboarding screens on Phase 0 — hand-drawn `VECTOR` nodes named
510
+ // "Arrow 35/37/38/…", not CONNECTORs, so the connector path never saw
511
+ // them. Side by side against Figma, the screens were right and the flow
512
+ // between them was simply gone.
513
+ //
514
+ // There is no stroke tool that reproduces an arbitrary path, and there
515
+ // does not need to be: the same renderer that draws the artboards draws
516
+ // these. Ask Figma for the node and place it as an image.
517
+ //
518
+ // RASTER, NOT VECTOR — and that is a security boundary, not a taste
519
+ // call. `ASSET_IMAGE_HREF_RE` admits only png/jpeg/webp/gif on an
520
+ // `<image>`, because an annotation SVG is PERSISTED AND SYNCED TO PEERS
521
+ // (DDR-054/060) and an `<image href="…svg">` pulls in a nested SVG
522
+ // document — a script-execution vector. Asking for svg here does not
523
+ // fail loudly: the sanitizer keeps the element and strips the href, so
524
+ // the arrow renders as nothing at all. Widening that allowlist to suit
525
+ // an importer would weaken every peer-synced board, so the import bends
526
+ // instead. The artboard renders stay svg — those are `<img src>` in a
527
+ // TSX canvas, a different surface with its own containment (D12).
528
+ // GEOMETRY COMES FROM THE RENDER BOUNDS, NOT THE GEOMETRIC BOX.
529
+ //
530
+ // A stroked path is drawn wider than its geometry. The nine Phase-0
531
+ // arrows are horizontal, so `absoluteBoundingBox.height` is 0.0001
532
+ // while `absoluteRenderBounds.height` is 22.09 (3px stroke plus the
533
+ // arrowhead). Placed at the geometric box the image is 121 × 0.00005
534
+ // px — present in the file, referenced correctly, and invisible.
535
+ const rb = node.absoluteRenderBounds;
536
+ const geo = rb ? { ...shift(rb.x, rb.y), w: rb.width, h: rb.height } : box;
537
+ const id = strokeId(node.id);
538
+ const stroke: ImageStroke = {
539
+ id,
540
+ tool: 'image',
541
+ x: geo.x,
542
+ y: geo.y,
543
+ w: Math.max(1, geo.w),
544
+ h: Math.max(1, geo.h),
545
+ href: '',
546
+ alt: attrValue(node.name) || 'vector',
547
+ ...(groupIds.length ? { groupIds } : {}),
548
+ ...(node.rotation ? { rotation: -node.rotation } : {}),
549
+ };
550
+ strokes.push(stroke);
551
+ nodeToStroke.set(node.id, id);
552
+ pendingImages.push({ strokeId: id, nodeId: node.id, imageRef: null, format: 'png' });
553
+ report.add(node.id, node.type, 'asset-pending');
554
+ return;
555
+ }
556
+
557
+ case 'RECTANGLE':
558
+ case 'ROUNDED_RECTANGLE':
559
+ case 'ELLIPSE': {
560
+ // A plain shape with an image fill is the board's picture case.
561
+ const ref = imageRef(node);
562
+ if (ref) {
563
+ const id = strokeId(node.id);
564
+ const stroke: ImageStroke = {
565
+ id,
566
+ tool: 'image',
567
+ x: box.x,
568
+ y: box.y,
569
+ w: box.w,
570
+ h: box.h,
571
+ // Rewritten by the caller once `fetch-asset` lands the bytes. Until
572
+ // then it is a placeholder, never an external URL — a hotlink is
573
+ // CSP-blocked in the canvas anyway (DDR-216 D4).
574
+ href: '',
575
+ ...(groupIds.length ? { groupIds } : {}),
576
+ ...(node.rotation ? { rotation: -node.rotation } : {}),
577
+ };
578
+ strokes.push(stroke);
579
+ nodeToStroke.set(node.id, id);
580
+ pendingImages.push({ strokeId: id, nodeId: node.id, imageRef: ref });
581
+ report.add(node.id, node.type, 'asset-pending');
582
+ return;
583
+ }
584
+ // Otherwise it is an ordinary geometric shape.
585
+ const ink = solidStrokeHex(node) ?? DEFAULT_INK;
586
+ const fill = solidFillHex(node);
587
+ const id = strokeId(node.id);
588
+ if (node.type === 'ELLIPSE') {
589
+ strokes.push({
590
+ id,
591
+ tool: 'ellipse',
592
+ color: ink,
593
+ width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
594
+ cx: box.x + box.w / 2,
595
+ cy: box.y + box.h / 2,
596
+ rx: box.w / 2,
597
+ ry: box.h / 2,
598
+ ...(fill ? { fill } : {}),
599
+ ...(groupIds.length ? { groupIds } : {}),
600
+ } as EllipseStroke);
601
+ } else {
602
+ strokes.push({
603
+ id,
604
+ tool: 'rect',
605
+ color: ink,
606
+ width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
607
+ x: box.x,
608
+ y: box.y,
609
+ w: box.w,
610
+ h: box.h,
611
+ ...(fill ? { fill } : {}),
612
+ ...(node.cornerRadius ? { cornerRadius: node.cornerRadius } : {}),
613
+ ...(groupIds.length ? { groupIds } : {}),
614
+ } as RectStroke);
615
+ }
616
+ nodeToStroke.set(node.id, id);
617
+ report.add(node.id, node.type, 'imported');
618
+ return;
619
+ }
620
+
621
+ default: {
622
+ // WIDGET, STAMP, TABLE, CODE_BLOCK, EMBED, LINK_UNFURL, MEDIA, and a
623
+ // FigJam sticker (an INSTANCE) … have no Maude equivalent. Skipped AND
624
+ // REPORTED — the summary is what makes "never silently dropped" true.
625
+ //
626
+ // But report the SUBTREE ONCE, not every leaf. Measured on a real retro
627
+ // board: 16 stickers carried 136 VECTOR children, so per-leaf reporting
628
+ // produced 152 lines of noise around 102 stickies that actually
629
+ // mattered. A summary nobody reads is not an honesty mechanism, and
630
+ // "this sticker didn't come through" is the fact — its 9 internal paths
631
+ // are not.
632
+ let descendants = 0;
633
+ const count = (n: FigmaNode) => {
634
+ for (const c of n.children ?? []) {
635
+ descendants += 1;
636
+ count(c);
637
+ }
638
+ };
639
+ count(node);
640
+ if (!silent) {
641
+ report.add(
642
+ node.id,
643
+ node.type,
644
+ 'unmappable-type',
645
+ descendants > 0 ? `+${descendants} nested` : undefined
646
+ );
647
+ }
648
+ // STILL RECURSE. Quieting the report must not quiet the IMPORT: a
649
+ // sticker's subtree can hold a real photo, and on the first live board
650
+ // an earlier version of this collapse silently dropped 2 images and 6
651
+ // ellipses along with the 136 vector leaves it was meant to stop
652
+ // listing. Losing content is a worse failure than a noisy summary, and
653
+ // it is the harder one to notice. `silent` suppresses the per-descendant
654
+ // REPORT only.
655
+ for (const c of node.children ?? []) visit(c, groupIds, true);
656
+ return;
657
+ }
658
+ }
659
+ };
660
+
661
+ visit(doc.root, []);
662
+
663
+ // PROVENANCE (review F5). Every imported stroke carries an author marker, so
664
+ // `read-annotations` — which parses this file expressly to put it in a
665
+ // model's context — can tell "the user drew this" from "a third party's Figma
666
+ // file did". Deliberately NOT `author: 'ai'`: the whiteboard trust model says
667
+ // in as many words that `author:'ai'` is not a trust signal, and the blank
668
+ // human default is exactly what this needs to stop looking like.
669
+ for (const stroke of strokes) {
670
+ stroke.authorName = FIGMA_AUTHOR_NAME;
671
+ }
672
+
673
+ // ── Connector pass ────────────────────────────────────────────────────────
674
+ const strokeById = new Map(strokes.map((s) => [s.id, s]));
675
+ for (const { node, groupIds } of connectors) {
676
+ const startNode = node.connectorStart;
677
+ const endNode = node.connectorEnd;
678
+
679
+ // A degenerate self-connector (start id == end id) was observed on the real
680
+ // board. Emitting it as a bound arrow yields a zero-length shape that reads
681
+ // as a rendering bug; report and skip.
682
+ if (startNode && endNode && startNode === endNode) {
683
+ report.add(node.id, node.type, 'bind-dropped-self-connector');
684
+ continue;
685
+ }
686
+
687
+ const resolve = (
688
+ figmaId: string | undefined
689
+ ): { bind?: ArrowBind; point: [number, number] } | null => {
690
+ if (!figmaId) return null;
691
+ const sid = nodeToStroke.get(figmaId);
692
+ const bbox = nodeBBox.get(figmaId);
693
+ if (sid) {
694
+ const host = strokeById.get(sid);
695
+ if (host) {
696
+ // `isBindable` decides — widened to text + section by DDR-216 D9, and
697
+ // deliberately still excluding groups (Maude groups are tags, not
698
+ // addressable objects) and anchored text (no resolvable bbox here).
699
+ const b = bbox ?? { x: 0, y: 0, w: 0, h: 0 };
700
+ return {
701
+ bind: { hostId: sid, nx: 0.5, ny: 0.5 },
702
+ point: [b.x + b.w / 2, b.y + b.h / 2],
703
+ };
704
+ }
705
+ }
706
+ if (bbox) {
707
+ // A GROUP-targeted endpoint: fall back to the group's geometric bbox,
708
+ // unbound, and report the degradation. Deliberately does NOT invent a
709
+ // group stroke — that would add an addressable object to a model which
710
+ // by design has none (DDR-216 D9).
711
+ report.add(node.id, node.type, 'bind-degraded-to-bbox', 'group endpoint');
712
+ return { point: [bbox.x + bbox.w / 2, bbox.y + bbox.h / 2] };
713
+ }
714
+ return null;
715
+ };
716
+
717
+ const from = resolve(startNode);
718
+ const to = resolve(endNode);
719
+ const bb = node.absoluteBoundingBox;
720
+ const fallback = bb ? shift(bb.x, bb.y) : { x: 0, y: 0 };
721
+ const p1 = from?.point ?? [fallback.x, fallback.y];
722
+ const p2 = to?.point ?? [fallback.x + (bb?.width ?? 100), fallback.y + (bb?.height ?? 0)];
723
+
724
+ const arrow: ArrowStroke = {
725
+ id: strokeId(node.id),
726
+ tool: 'arrow',
727
+ color: solidStrokeHex(node) ?? DEFAULT_INK,
728
+ width: node.strokeWeight ?? DEFAULT_STROKE_WIDTH,
729
+ x1: p1[0],
730
+ y1: p1[1],
731
+ x2: p2[0],
732
+ y2: p2[1],
733
+ startHead: CAP_MAP[node.connectorStartCap ?? 'NONE'] ?? 'none',
734
+ endHead: CAP_MAP[node.connectorEndCap ?? 'NONE'] ?? 'none',
735
+ lineType: LINE_TYPE_MAP[node.connectorLineType ?? 'STRAIGHT'] ?? 'straight',
736
+ ...(from?.bind ? { startBind: from.bind } : {}),
737
+ ...(to?.bind ? { endBind: to.bind } : {}),
738
+ ...(groupIds.length ? { groupIds } : {}),
739
+ };
740
+ strokes.push(arrow);
741
+ report.add(node.id, node.type, 'imported');
742
+ }
743
+
744
+ if (strokes.length > BOARD_STROKE_CEILING && !opts.confirmLarge) {
745
+ throw new BoardTooLargeError(strokes.length);
746
+ }
747
+
748
+ return { strokes, report, pendingImages, origin };
749
+ }