@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,539 @@
1
+ /**
2
+ * @file figma/types.ts — the normalized Figma node tree (DDR-216).
3
+ * @scope apps/studio/figma/types.ts
4
+ * @purpose The ONE shape both ingestion doors emit and all three translators
5
+ * consume. `client.ts` (REST, Phase 1) produces it today; a future
6
+ * `fig-decode.ts` (Phase 6) only has to produce the same shape and
7
+ * it inherits `to-strokes` / `to-artboard` / `to-tokens` for free.
8
+ * That seam is also what makes Phase 6's Tier-2 differential smoke
9
+ * possible at all — the same document through both doors must
10
+ * normalize to the same tree.
11
+ *
12
+ * @invariant FIELD NAMES MIRROR THE REST API deliberately. Renaming them into
13
+ * a prettier house vocabulary would make the Phase-6 decoder's job
14
+ * "translate twice" instead of "emit this shape", and would make a
15
+ * differential diff read as a wall of false positives.
16
+ *
17
+ * @invariant EVERY STRING FROM A DOCUMENT IS UNTRUSTED. `name` and
18
+ * `characters` in particular: a TEXT node's layer name DEFAULTS to
19
+ * its own content, so `name` is user text on a real file. They are
20
+ * carried verbatim through normalization on purpose — sanitization
21
+ * belongs at the emission sinks (DDR-216 D6), where the target
22
+ * grammar is known — and are marked UNTRUSTED at every declaration
23
+ * so nobody interpolates one into JSX, a path or a shell argument
24
+ * on the way past.
25
+ *
26
+ * @invariant DEPENDENCY-FREE. No `node:*`, no network, no filesystem — the
27
+ * normalizer walks an already-parsed object and nothing else.
28
+ */
29
+
30
+ // ── Caps (DDR-216 D5) ───────────────────────────────────────────────────────
31
+ // Pre-translation HARD REFUSALS. Each fails with a clear, actionable message —
32
+ // never an OOM, never a truncated best-effort tree. Measured baseline: a real
33
+ // first-party page is 449 KB / 4 125 nodes / depth 13 in the metadata-only
34
+ // projection, and the full REST payload (fills, strokes, effects, typeStyle per
35
+ // node) is materially larger.
36
+
37
+ /** ~18× the measured real page's metadata projection. */
38
+ export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
39
+ /** ~5× the measured 4 125. */
40
+ export const MAX_NODE_COUNT = 20_000;
41
+ /** ~5× the measured 13. Also bounds every recursive walk structurally. */
42
+ export const MAX_TREE_DEPTH = 64;
43
+
44
+ /** Keys that must never be copied off an untrusted object (DDR-172 Decision 3). */
45
+ const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
46
+
47
+ // ── Node vocabulary ─────────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * The node types both doors can produce. Sourced from the `.fig` container's
51
+ * own embedded schema (`NodeType`, read out of the committed fixtures), so the
52
+ * Phase-6 decoder has nothing to guess and this union needs no widening later.
53
+ */
54
+ export type FigmaNodeType =
55
+ | 'DOCUMENT'
56
+ | 'CANVAS'
57
+ | 'FRAME'
58
+ | 'GROUP'
59
+ | 'SECTION'
60
+ | 'COMPONENT'
61
+ | 'COMPONENT_SET'
62
+ | 'INSTANCE'
63
+ | 'VECTOR'
64
+ | 'BOOLEAN_OPERATION'
65
+ | 'STAR'
66
+ | 'LINE'
67
+ | 'ELLIPSE'
68
+ | 'RECTANGLE'
69
+ | 'ROUNDED_RECTANGLE'
70
+ | 'REGULAR_POLYGON'
71
+ | 'TEXT'
72
+ | 'SLICE'
73
+ | 'STICKY'
74
+ | 'SHAPE_WITH_TEXT'
75
+ | 'CONNECTOR'
76
+ | 'CODE_BLOCK'
77
+ | 'WIDGET'
78
+ | 'STAMP'
79
+ | 'TABLE'
80
+ | 'MEDIA'
81
+ | 'EMBED'
82
+ | 'LINK_UNFURL'
83
+ | 'WASHI_TAPE'
84
+ | 'UNKNOWN';
85
+
86
+ const KNOWN_NODE_TYPES = new Set<string>([
87
+ 'DOCUMENT',
88
+ 'CANVAS',
89
+ 'FRAME',
90
+ 'GROUP',
91
+ 'SECTION',
92
+ 'COMPONENT',
93
+ 'COMPONENT_SET',
94
+ 'INSTANCE',
95
+ 'VECTOR',
96
+ 'BOOLEAN_OPERATION',
97
+ 'STAR',
98
+ 'LINE',
99
+ 'ELLIPSE',
100
+ 'RECTANGLE',
101
+ 'ROUNDED_RECTANGLE',
102
+ 'REGULAR_POLYGON',
103
+ 'TEXT',
104
+ 'SLICE',
105
+ 'STICKY',
106
+ 'SHAPE_WITH_TEXT',
107
+ 'CONNECTOR',
108
+ 'CODE_BLOCK',
109
+ 'WIDGET',
110
+ 'STAMP',
111
+ 'TABLE',
112
+ 'MEDIA',
113
+ 'EMBED',
114
+ 'LINK_UNFURL',
115
+ 'WASHI_TAPE',
116
+ ]);
117
+
118
+ export interface FigmaRect {
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ }
124
+
125
+ export interface FigmaColor {
126
+ r: number;
127
+ g: number;
128
+ b: number;
129
+ a: number;
130
+ }
131
+
132
+ export interface FigmaPaint {
133
+ type: string; // SOLID | GRADIENT_LINEAR | IMAGE | …
134
+ visible: boolean;
135
+ opacity?: number;
136
+ color?: FigmaColor;
137
+ /** Present on IMAGE paints — the handle `/v1/images` resolves. */
138
+ imageRef?: string;
139
+ gradientStops?: Array<{ position: number; color: FigmaColor }>;
140
+ gradientHandlePositions?: Array<{ x: number; y: number }>;
141
+ }
142
+
143
+ export interface FigmaEffect {
144
+ type: string; // DROP_SHADOW | INNER_SHADOW | LAYER_BLUR | BACKGROUND_BLUR
145
+ visible: boolean;
146
+ color?: FigmaColor;
147
+ offset?: { x: number; y: number };
148
+ radius?: number;
149
+ spread?: number;
150
+ }
151
+
152
+ /** Figma's `style` block on a TEXT node. */
153
+ export interface FigmaTypeStyle {
154
+ fontFamily?: string; // UNTRUSTED
155
+ fontPostScriptName?: string; // UNTRUSTED
156
+ fontWeight?: number;
157
+ fontSize?: number;
158
+ lineHeightPx?: number;
159
+ letterSpacing?: number;
160
+ textAlignHorizontal?: string;
161
+ textAlignVertical?: string;
162
+ textCase?: string;
163
+ textDecoration?: string;
164
+ }
165
+
166
+ /**
167
+ * One normalized node. Optional everywhere by design: both doors see partial
168
+ * documents (a `getFileNodes` projection carries less than `getFile`), and a
169
+ * translator that copes with an absent field copes with both doors.
170
+ */
171
+ export interface FigmaNode {
172
+ /** `^[0-9]+:[0-9]+$` in practice — the ONLY string safe to derive an identifier from. */
173
+ id: string;
174
+ type: FigmaNodeType;
175
+ /** UNTRUSTED — a TEXT node's layer name defaults to its own content. */
176
+ name: string;
177
+ /** False ⇒ the node is not emitted at all (DDR-216 D6b). */
178
+ visible: boolean;
179
+ absoluteBoundingBox?: FigmaRect;
180
+ /**
181
+ * What is actually DRAWN — geometry plus stroke weight, arrowheads and
182
+ * effects. Differs from `absoluteBoundingBox` by more than a rounding error
183
+ * on stroked paths: a horizontal arrow's geometric box has height 0.0001
184
+ * while its render bounds are 22.09. Placing such a node at its geometric
185
+ * box renders it into nothing, which is how nine flow arrows imported
186
+ * "successfully" and were invisible.
187
+ */
188
+ absoluteRenderBounds?: FigmaRect;
189
+ rotation?: number;
190
+ opacity?: number;
191
+ blendMode?: string;
192
+ clipsContent?: boolean;
193
+
194
+ // ── design-side layout ──
195
+ layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
196
+ itemSpacing?: number;
197
+ paddingLeft?: number;
198
+ paddingRight?: number;
199
+ paddingTop?: number;
200
+ paddingBottom?: number;
201
+ primaryAxisAlignItems?: string;
202
+ counterAxisAlignItems?: string;
203
+ layoutWrap?: string;
204
+
205
+ // ── paint ──
206
+ fills?: FigmaPaint[];
207
+ strokes?: FigmaPaint[];
208
+ strokeWeight?: number;
209
+ effects?: FigmaEffect[];
210
+ cornerRadius?: number;
211
+ rectangleCornerRadii?: number[];
212
+
213
+ // ── text ──
214
+ /** UNTRUSTED — the literal user text. */
215
+ characters?: string;
216
+ style?: FigmaTypeStyle;
217
+
218
+ // ── FigJam ──
219
+ shapeType?: string;
220
+ /** A real node id — the shape `ArrowBind.hostId` wants (DDR-216 D9). */
221
+ connectorStart?: string;
222
+ connectorEnd?: string;
223
+ connectorStartCap?: string;
224
+ connectorEndCap?: string;
225
+ connectorLineType?: string;
226
+
227
+ // ── component semantics (display-only; no runtime equivalent) ──
228
+ componentId?: string;
229
+
230
+ children?: FigmaNode[];
231
+ }
232
+
233
+ /** What either door hands the translators. */
234
+ export interface NormalizedDocument {
235
+ /** Charset-validated by `url.ts` — never free text. */
236
+ fileKey: string;
237
+ /** `design` | `board`, from the URL shape or the `.fig` prelude. */
238
+ surface: 'design' | 'board';
239
+ /** Which door produced this — recorded for the Tier-2 differential diff. */
240
+ origin: 'rest' | 'fig';
241
+ root: FigmaNode;
242
+ /** Post-normalization counts, so callers report rather than re-walk. */
243
+ nodeCount: number;
244
+ maxDepth: number;
245
+ }
246
+
247
+ export class FigmaCapError extends Error {
248
+ readonly cap: 'nodes' | 'depth' | 'bytes';
249
+ constructor(cap: 'nodes' | 'depth' | 'bytes', message: string) {
250
+ super(message);
251
+ this.name = 'FigmaCapError';
252
+ this.cap = cap;
253
+ }
254
+ }
255
+
256
+ // ── Normalization ───────────────────────────────────────────────────────────
257
+
258
+ function str(v: unknown): string | undefined {
259
+ return typeof v === 'string' ? v : undefined;
260
+ }
261
+
262
+ function num(v: unknown): number | undefined {
263
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
264
+ }
265
+
266
+ function bool(v: unknown, fallback: boolean): boolean {
267
+ return typeof v === 'boolean' ? v : fallback;
268
+ }
269
+
270
+ function rect(v: unknown): FigmaRect | undefined {
271
+ if (!v || typeof v !== 'object') return undefined;
272
+ const r = v as Record<string, unknown>;
273
+ const x = num(r.x);
274
+ const y = num(r.y);
275
+ const width = num(r.width);
276
+ const height = num(r.height);
277
+ if (x === undefined || y === undefined || width === undefined || height === undefined) {
278
+ return undefined;
279
+ }
280
+ return { x, y, width, height };
281
+ }
282
+
283
+ function color(v: unknown): FigmaColor | undefined {
284
+ if (!v || typeof v !== 'object') return undefined;
285
+ const c = v as Record<string, unknown>;
286
+ const r = num(c.r);
287
+ const g = num(c.g);
288
+ const b = num(c.b);
289
+ if (r === undefined || g === undefined || b === undefined) return undefined;
290
+ return { r, g, b, a: num(c.a) ?? 1 };
291
+ }
292
+
293
+ function paints(v: unknown): FigmaPaint[] | undefined {
294
+ if (!Array.isArray(v)) return undefined;
295
+ const out: FigmaPaint[] = [];
296
+ // Bounded: a node with thousands of paints is not a real node, and this walk
297
+ // runs once per node under MAX_NODE_COUNT.
298
+ for (const item of v.slice(0, 32)) {
299
+ if (!item || typeof item !== 'object') continue;
300
+ const p = item as Record<string, unknown>;
301
+ const type = str(p.type);
302
+ if (!type) continue;
303
+ const entry: FigmaPaint = { type, visible: bool(p.visible, true) };
304
+ const o = num(p.opacity);
305
+ if (o !== undefined) entry.opacity = o;
306
+ const col = color(p.color);
307
+ if (col) entry.color = col;
308
+ const ref = str(p.imageRef);
309
+ if (ref) entry.imageRef = ref;
310
+ if (Array.isArray(p.gradientStops)) {
311
+ const stops: Array<{ position: number; color: FigmaColor }> = [];
312
+ for (const s of p.gradientStops.slice(0, 32)) {
313
+ if (!s || typeof s !== 'object') continue;
314
+ const sr = s as Record<string, unknown>;
315
+ const pos = num(sr.position);
316
+ const sc = color(sr.color);
317
+ if (pos !== undefined && sc) stops.push({ position: pos, color: sc });
318
+ }
319
+ if (stops.length) entry.gradientStops = stops;
320
+ }
321
+ out.push(entry);
322
+ }
323
+ return out.length ? out : undefined;
324
+ }
325
+
326
+ function effects(v: unknown): FigmaEffect[] | undefined {
327
+ if (!Array.isArray(v)) return undefined;
328
+ const out: FigmaEffect[] = [];
329
+ for (const item of v.slice(0, 32)) {
330
+ if (!item || typeof item !== 'object') continue;
331
+ const e = item as Record<string, unknown>;
332
+ const type = str(e.type);
333
+ if (!type) continue;
334
+ const entry: FigmaEffect = { type, visible: bool(e.visible, true) };
335
+ const col = color(e.color);
336
+ if (col) entry.color = col;
337
+ const off = e.offset as Record<string, unknown> | undefined;
338
+ if (off && typeof off === 'object') {
339
+ const ox = num(off.x);
340
+ const oy = num(off.y);
341
+ if (ox !== undefined && oy !== undefined) entry.offset = { x: ox, y: oy };
342
+ }
343
+ const radius = num(e.radius);
344
+ if (radius !== undefined) entry.radius = radius;
345
+ const spread = num(e.spread);
346
+ if (spread !== undefined) entry.spread = spread;
347
+ out.push(entry);
348
+ }
349
+ return out.length ? out : undefined;
350
+ }
351
+
352
+ function typeStyle(v: unknown): FigmaTypeStyle | undefined {
353
+ if (!v || typeof v !== 'object') return undefined;
354
+ const s = v as Record<string, unknown>;
355
+ const out: FigmaTypeStyle = {};
356
+ const assignStr = (k: keyof FigmaTypeStyle, raw: unknown) => {
357
+ const val = str(raw);
358
+ if (val !== undefined) (out as Record<string, unknown>)[k] = val;
359
+ };
360
+ const assignNum = (k: keyof FigmaTypeStyle, raw: unknown) => {
361
+ const val = num(raw);
362
+ if (val !== undefined) (out as Record<string, unknown>)[k] = val;
363
+ };
364
+ assignStr('fontFamily', s.fontFamily);
365
+ assignStr('fontPostScriptName', s.fontPostScriptName);
366
+ assignNum('fontWeight', s.fontWeight);
367
+ assignNum('fontSize', s.fontSize);
368
+ assignNum('lineHeightPx', s.lineHeightPx);
369
+ assignNum('letterSpacing', s.letterSpacing);
370
+ assignStr('textAlignHorizontal', s.textAlignHorizontal);
371
+ assignStr('textAlignVertical', s.textAlignVertical);
372
+ assignStr('textCase', s.textCase);
373
+ assignStr('textDecoration', s.textDecoration);
374
+ return Object.keys(out).length ? out : undefined;
375
+ }
376
+
377
+ function nodeType(v: unknown): FigmaNodeType {
378
+ const t = str(v);
379
+ return t && KNOWN_NODE_TYPES.has(t) ? (t as FigmaNodeType) : 'UNKNOWN';
380
+ }
381
+
382
+ interface WalkState {
383
+ count: number;
384
+ maxDepth: number;
385
+ }
386
+
387
+ /**
388
+ * Recursively normalize one raw API node.
389
+ *
390
+ * Depth is checked BEFORE descent and the node count BEFORE each node is
391
+ * built, so a document engineered to be deep or wide trips a cap rather than
392
+ * exhausting the stack or the heap. `__proto__`/`constructor`/`prototype` keys
393
+ * are never read off the input — every field is pulled by explicit name, which
394
+ * is a stronger version of the DDR-172 Decision 3 guard: there is no generic
395
+ * key-copy loop here to pollute.
396
+ */
397
+ function normalizeNode(raw: unknown, depth: number, state: WalkState): FigmaNode | null {
398
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
399
+ if (depth > MAX_TREE_DEPTH) {
400
+ throw new FigmaCapError(
401
+ 'depth',
402
+ `Figma document nests deeper than ${MAX_TREE_DEPTH} levels — import a specific frame instead`
403
+ );
404
+ }
405
+ const r = raw as Record<string, unknown>;
406
+
407
+ const id = str(r.id);
408
+ if (!id) return null; // a node with no id cannot be referenced, stamped, or bound
409
+
410
+ state.count += 1;
411
+ if (state.count > MAX_NODE_COUNT) {
412
+ throw new FigmaCapError(
413
+ 'nodes',
414
+ `Figma document has more than ${MAX_NODE_COUNT} nodes — import a specific frame instead`
415
+ );
416
+ }
417
+ if (depth > state.maxDepth) state.maxDepth = depth;
418
+
419
+ const node: FigmaNode = {
420
+ id,
421
+ type: nodeType(r.type),
422
+ // UNTRUSTED, carried verbatim — see the file-level invariant.
423
+ name: str(r.name) ?? '',
424
+ visible: bool(r.visible, true),
425
+ };
426
+
427
+ const assign = <K extends keyof FigmaNode>(key: K, value: FigmaNode[K] | undefined) => {
428
+ if (value !== undefined) node[key] = value;
429
+ };
430
+
431
+ assign('absoluteBoundingBox', rect(r.absoluteBoundingBox));
432
+ assign('absoluteRenderBounds', rect(r.absoluteRenderBounds));
433
+ assign('rotation', num(r.rotation));
434
+ assign('opacity', num(r.opacity));
435
+ assign('blendMode', str(r.blendMode));
436
+ assign('clipsContent', typeof r.clipsContent === 'boolean' ? r.clipsContent : undefined);
437
+
438
+ const layoutMode = str(r.layoutMode);
439
+ if (layoutMode === 'HORIZONTAL' || layoutMode === 'VERTICAL' || layoutMode === 'NONE') {
440
+ node.layoutMode = layoutMode;
441
+ }
442
+ assign('itemSpacing', num(r.itemSpacing));
443
+ assign('paddingLeft', num(r.paddingLeft));
444
+ assign('paddingRight', num(r.paddingRight));
445
+ assign('paddingTop', num(r.paddingTop));
446
+ assign('paddingBottom', num(r.paddingBottom));
447
+ assign('primaryAxisAlignItems', str(r.primaryAxisAlignItems));
448
+ assign('counterAxisAlignItems', str(r.counterAxisAlignItems));
449
+ assign('layoutWrap', str(r.layoutWrap));
450
+
451
+ assign('fills', paints(r.fills));
452
+ assign('strokes', paints(r.strokes));
453
+ assign('strokeWeight', num(r.strokeWeight));
454
+ assign('effects', effects(r.effects));
455
+ assign('cornerRadius', num(r.cornerRadius));
456
+ if (Array.isArray(r.rectangleCornerRadii)) {
457
+ const radii = r.rectangleCornerRadii
458
+ .slice(0, 4)
459
+ .map((n) => num(n))
460
+ .filter((n): n is number => n !== undefined);
461
+ if (radii.length) node.rectangleCornerRadii = radii;
462
+ }
463
+
464
+ // UNTRUSTED, carried verbatim.
465
+ assign('characters', str(r.characters));
466
+ assign('style', typeStyle(r.style));
467
+
468
+ assign('shapeType', str(r.shapeType));
469
+ assign('connectorStart', connectorEndpointId(r.connectorStart));
470
+ assign('connectorEnd', connectorEndpointId(r.connectorEnd));
471
+ assign('connectorStartCap', str(r.connectorStartCap));
472
+ assign('connectorEndCap', str(r.connectorEndCap));
473
+ assign('connectorLineType', str(r.connectorLineType));
474
+ assign('componentId', str(r.componentId));
475
+
476
+ if (Array.isArray(r.children)) {
477
+ const kids: FigmaNode[] = [];
478
+ for (const child of r.children) {
479
+ const normalized = normalizeNode(child, depth + 1, state);
480
+ if (normalized) kids.push(normalized);
481
+ }
482
+ if (kids.length) node.children = kids;
483
+ }
484
+
485
+ return node;
486
+ }
487
+
488
+ /**
489
+ * A connector endpoint is `{ endpointNodeId, magnet }` (bound) or
490
+ * `{ position }` (free-floating). Only the bound form carries a host id — and
491
+ * a free endpoint must NOT be mistaken for one, or T5 mints a bind to nothing.
492
+ */
493
+ function connectorEndpointId(v: unknown): string | undefined {
494
+ if (!v || typeof v !== 'object') return undefined;
495
+ const e = v as Record<string, unknown>;
496
+ return str(e.endpointNodeId);
497
+ }
498
+
499
+ /**
500
+ * Normalize a raw `GET /v1/files/:key` (or `/nodes`) document into the shape
501
+ * both doors share. Throws `FigmaCapError` on a hard refusal — callers turn
502
+ * that into the user-facing "import a specific frame instead" message.
503
+ */
504
+ export function normalizeDocument(
505
+ raw: unknown,
506
+ meta: { fileKey: string; surface: 'design' | 'board'; origin?: 'rest' | 'fig' }
507
+ ): NormalizedDocument {
508
+ const state: WalkState = { count: 0, maxDepth: 0 };
509
+ const root = normalizeNode(raw, 0, state);
510
+ if (!root) throw new FigmaCapError('nodes', 'Figma document has no readable root node');
511
+ return {
512
+ fileKey: meta.fileKey,
513
+ surface: meta.surface,
514
+ origin: meta.origin ?? 'rest',
515
+ root,
516
+ nodeCount: state.count,
517
+ maxDepth: state.maxDepth,
518
+ };
519
+ }
520
+
521
+ /** Depth-first walk over a normalized tree. Iterative — no stack growth. */
522
+ export function walkNodes(root: FigmaNode, visit: (node: FigmaNode, depth: number) => void): void {
523
+ const stack: Array<{ node: FigmaNode; depth: number }> = [{ node: root, depth: 0 }];
524
+ while (stack.length > 0) {
525
+ const entry = stack.pop();
526
+ if (!entry) break;
527
+ visit(entry.node, entry.depth);
528
+ const kids = entry.node.children;
529
+ if (!kids) continue;
530
+ for (let i = kids.length - 1; i >= 0; i--) {
531
+ stack.push({ node: kids[i], depth: entry.depth + 1 });
532
+ }
533
+ }
534
+ }
535
+
536
+ /** Guard used by the normalizer's own tests and by the `.fig` door later. */
537
+ export function isPollutingKey(key: string): boolean {
538
+ return POLLUTING_KEYS.has(key);
539
+ }