@1agh/maude 0.58.2 → 0.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,544 @@
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
+ /**
87
+ * Exported so the `.fig` door can tell a vocabulary gap from a mapped type
88
+ * WITHOUT keeping a second copy of the list (DDR-221 D3 degrade-and-report).
89
+ * `nodeType()` below still owns the mapping; this is read-only.
90
+ */
91
+ export const KNOWN_NODE_TYPES: ReadonlySet<string> = new Set<string>([
92
+ 'DOCUMENT',
93
+ 'CANVAS',
94
+ 'FRAME',
95
+ 'GROUP',
96
+ 'SECTION',
97
+ 'COMPONENT',
98
+ 'COMPONENT_SET',
99
+ 'INSTANCE',
100
+ 'VECTOR',
101
+ 'BOOLEAN_OPERATION',
102
+ 'STAR',
103
+ 'LINE',
104
+ 'ELLIPSE',
105
+ 'RECTANGLE',
106
+ 'ROUNDED_RECTANGLE',
107
+ 'REGULAR_POLYGON',
108
+ 'TEXT',
109
+ 'SLICE',
110
+ 'STICKY',
111
+ 'SHAPE_WITH_TEXT',
112
+ 'CONNECTOR',
113
+ 'CODE_BLOCK',
114
+ 'WIDGET',
115
+ 'STAMP',
116
+ 'TABLE',
117
+ 'MEDIA',
118
+ 'EMBED',
119
+ 'LINK_UNFURL',
120
+ 'WASHI_TAPE',
121
+ ]);
122
+
123
+ export interface FigmaRect {
124
+ x: number;
125
+ y: number;
126
+ width: number;
127
+ height: number;
128
+ }
129
+
130
+ export interface FigmaColor {
131
+ r: number;
132
+ g: number;
133
+ b: number;
134
+ a: number;
135
+ }
136
+
137
+ export interface FigmaPaint {
138
+ type: string; // SOLID | GRADIENT_LINEAR | IMAGE | …
139
+ visible: boolean;
140
+ opacity?: number;
141
+ color?: FigmaColor;
142
+ /** Present on IMAGE paints — the handle `/v1/images` resolves. */
143
+ imageRef?: string;
144
+ gradientStops?: Array<{ position: number; color: FigmaColor }>;
145
+ gradientHandlePositions?: Array<{ x: number; y: number }>;
146
+ }
147
+
148
+ export interface FigmaEffect {
149
+ type: string; // DROP_SHADOW | INNER_SHADOW | LAYER_BLUR | BACKGROUND_BLUR
150
+ visible: boolean;
151
+ color?: FigmaColor;
152
+ offset?: { x: number; y: number };
153
+ radius?: number;
154
+ spread?: number;
155
+ }
156
+
157
+ /** Figma's `style` block on a TEXT node. */
158
+ export interface FigmaTypeStyle {
159
+ fontFamily?: string; // UNTRUSTED
160
+ fontPostScriptName?: string; // UNTRUSTED
161
+ fontWeight?: number;
162
+ fontSize?: number;
163
+ lineHeightPx?: number;
164
+ letterSpacing?: number;
165
+ textAlignHorizontal?: string;
166
+ textAlignVertical?: string;
167
+ textCase?: string;
168
+ textDecoration?: string;
169
+ }
170
+
171
+ /**
172
+ * One normalized node. Optional everywhere by design: both doors see partial
173
+ * documents (a `getFileNodes` projection carries less than `getFile`), and a
174
+ * translator that copes with an absent field copes with both doors.
175
+ */
176
+ export interface FigmaNode {
177
+ /** `^[0-9]+:[0-9]+$` in practice — the ONLY string safe to derive an identifier from. */
178
+ id: string;
179
+ type: FigmaNodeType;
180
+ /** UNTRUSTED — a TEXT node's layer name defaults to its own content. */
181
+ name: string;
182
+ /** False ⇒ the node is not emitted at all (DDR-216 D6b). */
183
+ visible: boolean;
184
+ absoluteBoundingBox?: FigmaRect;
185
+ /**
186
+ * What is actually DRAWN — geometry plus stroke weight, arrowheads and
187
+ * effects. Differs from `absoluteBoundingBox` by more than a rounding error
188
+ * on stroked paths: a horizontal arrow's geometric box has height 0.0001
189
+ * while its render bounds are 22.09. Placing such a node at its geometric
190
+ * box renders it into nothing, which is how nine flow arrows imported
191
+ * "successfully" and were invisible.
192
+ */
193
+ absoluteRenderBounds?: FigmaRect;
194
+ rotation?: number;
195
+ opacity?: number;
196
+ blendMode?: string;
197
+ clipsContent?: boolean;
198
+
199
+ // ── design-side layout ──
200
+ layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
201
+ itemSpacing?: number;
202
+ paddingLeft?: number;
203
+ paddingRight?: number;
204
+ paddingTop?: number;
205
+ paddingBottom?: number;
206
+ primaryAxisAlignItems?: string;
207
+ counterAxisAlignItems?: string;
208
+ layoutWrap?: string;
209
+
210
+ // ── paint ──
211
+ fills?: FigmaPaint[];
212
+ strokes?: FigmaPaint[];
213
+ strokeWeight?: number;
214
+ effects?: FigmaEffect[];
215
+ cornerRadius?: number;
216
+ rectangleCornerRadii?: number[];
217
+
218
+ // ── text ──
219
+ /** UNTRUSTED — the literal user text. */
220
+ characters?: string;
221
+ style?: FigmaTypeStyle;
222
+
223
+ // ── FigJam ──
224
+ shapeType?: string;
225
+ /** A real node id — the shape `ArrowBind.hostId` wants (DDR-216 D9). */
226
+ connectorStart?: string;
227
+ connectorEnd?: string;
228
+ connectorStartCap?: string;
229
+ connectorEndCap?: string;
230
+ connectorLineType?: string;
231
+
232
+ // ── component semantics (display-only; no runtime equivalent) ──
233
+ componentId?: string;
234
+
235
+ children?: FigmaNode[];
236
+ }
237
+
238
+ /** What either door hands the translators. */
239
+ export interface NormalizedDocument {
240
+ /** Charset-validated by `url.ts` — never free text. */
241
+ fileKey: string;
242
+ /** `design` | `board`, from the URL shape or the `.fig` prelude. */
243
+ surface: 'design' | 'board';
244
+ /** Which door produced this — recorded for the Tier-2 differential diff. */
245
+ origin: 'rest' | 'fig';
246
+ root: FigmaNode;
247
+ /** Post-normalization counts, so callers report rather than re-walk. */
248
+ nodeCount: number;
249
+ maxDepth: number;
250
+ }
251
+
252
+ export class FigmaCapError extends Error {
253
+ readonly cap: 'nodes' | 'depth' | 'bytes';
254
+ constructor(cap: 'nodes' | 'depth' | 'bytes', message: string) {
255
+ super(message);
256
+ this.name = 'FigmaCapError';
257
+ this.cap = cap;
258
+ }
259
+ }
260
+
261
+ // ── Normalization ───────────────────────────────────────────────────────────
262
+
263
+ function str(v: unknown): string | undefined {
264
+ return typeof v === 'string' ? v : undefined;
265
+ }
266
+
267
+ function num(v: unknown): number | undefined {
268
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
269
+ }
270
+
271
+ function bool(v: unknown, fallback: boolean): boolean {
272
+ return typeof v === 'boolean' ? v : fallback;
273
+ }
274
+
275
+ function rect(v: unknown): FigmaRect | undefined {
276
+ if (!v || typeof v !== 'object') return undefined;
277
+ const r = v as Record<string, unknown>;
278
+ const x = num(r.x);
279
+ const y = num(r.y);
280
+ const width = num(r.width);
281
+ const height = num(r.height);
282
+ if (x === undefined || y === undefined || width === undefined || height === undefined) {
283
+ return undefined;
284
+ }
285
+ return { x, y, width, height };
286
+ }
287
+
288
+ function color(v: unknown): FigmaColor | undefined {
289
+ if (!v || typeof v !== 'object') return undefined;
290
+ const c = v as Record<string, unknown>;
291
+ const r = num(c.r);
292
+ const g = num(c.g);
293
+ const b = num(c.b);
294
+ if (r === undefined || g === undefined || b === undefined) return undefined;
295
+ return { r, g, b, a: num(c.a) ?? 1 };
296
+ }
297
+
298
+ function paints(v: unknown): FigmaPaint[] | undefined {
299
+ if (!Array.isArray(v)) return undefined;
300
+ const out: FigmaPaint[] = [];
301
+ // Bounded: a node with thousands of paints is not a real node, and this walk
302
+ // runs once per node under MAX_NODE_COUNT.
303
+ for (const item of v.slice(0, 32)) {
304
+ if (!item || typeof item !== 'object') continue;
305
+ const p = item as Record<string, unknown>;
306
+ const type = str(p.type);
307
+ if (!type) continue;
308
+ const entry: FigmaPaint = { type, visible: bool(p.visible, true) };
309
+ const o = num(p.opacity);
310
+ if (o !== undefined) entry.opacity = o;
311
+ const col = color(p.color);
312
+ if (col) entry.color = col;
313
+ const ref = str(p.imageRef);
314
+ if (ref) entry.imageRef = ref;
315
+ if (Array.isArray(p.gradientStops)) {
316
+ const stops: Array<{ position: number; color: FigmaColor }> = [];
317
+ for (const s of p.gradientStops.slice(0, 32)) {
318
+ if (!s || typeof s !== 'object') continue;
319
+ const sr = s as Record<string, unknown>;
320
+ const pos = num(sr.position);
321
+ const sc = color(sr.color);
322
+ if (pos !== undefined && sc) stops.push({ position: pos, color: sc });
323
+ }
324
+ if (stops.length) entry.gradientStops = stops;
325
+ }
326
+ out.push(entry);
327
+ }
328
+ return out.length ? out : undefined;
329
+ }
330
+
331
+ function effects(v: unknown): FigmaEffect[] | undefined {
332
+ if (!Array.isArray(v)) return undefined;
333
+ const out: FigmaEffect[] = [];
334
+ for (const item of v.slice(0, 32)) {
335
+ if (!item || typeof item !== 'object') continue;
336
+ const e = item as Record<string, unknown>;
337
+ const type = str(e.type);
338
+ if (!type) continue;
339
+ const entry: FigmaEffect = { type, visible: bool(e.visible, true) };
340
+ const col = color(e.color);
341
+ if (col) entry.color = col;
342
+ const off = e.offset as Record<string, unknown> | undefined;
343
+ if (off && typeof off === 'object') {
344
+ const ox = num(off.x);
345
+ const oy = num(off.y);
346
+ if (ox !== undefined && oy !== undefined) entry.offset = { x: ox, y: oy };
347
+ }
348
+ const radius = num(e.radius);
349
+ if (radius !== undefined) entry.radius = radius;
350
+ const spread = num(e.spread);
351
+ if (spread !== undefined) entry.spread = spread;
352
+ out.push(entry);
353
+ }
354
+ return out.length ? out : undefined;
355
+ }
356
+
357
+ function typeStyle(v: unknown): FigmaTypeStyle | undefined {
358
+ if (!v || typeof v !== 'object') return undefined;
359
+ const s = v as Record<string, unknown>;
360
+ const out: FigmaTypeStyle = {};
361
+ const assignStr = (k: keyof FigmaTypeStyle, raw: unknown) => {
362
+ const val = str(raw);
363
+ if (val !== undefined) (out as Record<string, unknown>)[k] = val;
364
+ };
365
+ const assignNum = (k: keyof FigmaTypeStyle, raw: unknown) => {
366
+ const val = num(raw);
367
+ if (val !== undefined) (out as Record<string, unknown>)[k] = val;
368
+ };
369
+ assignStr('fontFamily', s.fontFamily);
370
+ assignStr('fontPostScriptName', s.fontPostScriptName);
371
+ assignNum('fontWeight', s.fontWeight);
372
+ assignNum('fontSize', s.fontSize);
373
+ assignNum('lineHeightPx', s.lineHeightPx);
374
+ assignNum('letterSpacing', s.letterSpacing);
375
+ assignStr('textAlignHorizontal', s.textAlignHorizontal);
376
+ assignStr('textAlignVertical', s.textAlignVertical);
377
+ assignStr('textCase', s.textCase);
378
+ assignStr('textDecoration', s.textDecoration);
379
+ return Object.keys(out).length ? out : undefined;
380
+ }
381
+
382
+ function nodeType(v: unknown): FigmaNodeType {
383
+ const t = str(v);
384
+ return t && KNOWN_NODE_TYPES.has(t) ? (t as FigmaNodeType) : 'UNKNOWN';
385
+ }
386
+
387
+ interface WalkState {
388
+ count: number;
389
+ maxDepth: number;
390
+ }
391
+
392
+ /**
393
+ * Recursively normalize one raw API node.
394
+ *
395
+ * Depth is checked BEFORE descent and the node count BEFORE each node is
396
+ * built, so a document engineered to be deep or wide trips a cap rather than
397
+ * exhausting the stack or the heap. `__proto__`/`constructor`/`prototype` keys
398
+ * are never read off the input — every field is pulled by explicit name, which
399
+ * is a stronger version of the DDR-172 Decision 3 guard: there is no generic
400
+ * key-copy loop here to pollute.
401
+ */
402
+ function normalizeNode(raw: unknown, depth: number, state: WalkState): FigmaNode | null {
403
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
404
+ if (depth > MAX_TREE_DEPTH) {
405
+ throw new FigmaCapError(
406
+ 'depth',
407
+ `Figma document nests deeper than ${MAX_TREE_DEPTH} levels — import a specific frame instead`
408
+ );
409
+ }
410
+ const r = raw as Record<string, unknown>;
411
+
412
+ const id = str(r.id);
413
+ if (!id) return null; // a node with no id cannot be referenced, stamped, or bound
414
+
415
+ state.count += 1;
416
+ if (state.count > MAX_NODE_COUNT) {
417
+ throw new FigmaCapError(
418
+ 'nodes',
419
+ `Figma document has more than ${MAX_NODE_COUNT} nodes — import a specific frame instead`
420
+ );
421
+ }
422
+ if (depth > state.maxDepth) state.maxDepth = depth;
423
+
424
+ const node: FigmaNode = {
425
+ id,
426
+ type: nodeType(r.type),
427
+ // UNTRUSTED, carried verbatim — see the file-level invariant.
428
+ name: str(r.name) ?? '',
429
+ visible: bool(r.visible, true),
430
+ };
431
+
432
+ const assign = <K extends keyof FigmaNode>(key: K, value: FigmaNode[K] | undefined) => {
433
+ if (value !== undefined) node[key] = value;
434
+ };
435
+
436
+ assign('absoluteBoundingBox', rect(r.absoluteBoundingBox));
437
+ assign('absoluteRenderBounds', rect(r.absoluteRenderBounds));
438
+ assign('rotation', num(r.rotation));
439
+ assign('opacity', num(r.opacity));
440
+ assign('blendMode', str(r.blendMode));
441
+ assign('clipsContent', typeof r.clipsContent === 'boolean' ? r.clipsContent : undefined);
442
+
443
+ const layoutMode = str(r.layoutMode);
444
+ if (layoutMode === 'HORIZONTAL' || layoutMode === 'VERTICAL' || layoutMode === 'NONE') {
445
+ node.layoutMode = layoutMode;
446
+ }
447
+ assign('itemSpacing', num(r.itemSpacing));
448
+ assign('paddingLeft', num(r.paddingLeft));
449
+ assign('paddingRight', num(r.paddingRight));
450
+ assign('paddingTop', num(r.paddingTop));
451
+ assign('paddingBottom', num(r.paddingBottom));
452
+ assign('primaryAxisAlignItems', str(r.primaryAxisAlignItems));
453
+ assign('counterAxisAlignItems', str(r.counterAxisAlignItems));
454
+ assign('layoutWrap', str(r.layoutWrap));
455
+
456
+ assign('fills', paints(r.fills));
457
+ assign('strokes', paints(r.strokes));
458
+ assign('strokeWeight', num(r.strokeWeight));
459
+ assign('effects', effects(r.effects));
460
+ assign('cornerRadius', num(r.cornerRadius));
461
+ if (Array.isArray(r.rectangleCornerRadii)) {
462
+ const radii = r.rectangleCornerRadii
463
+ .slice(0, 4)
464
+ .map((n) => num(n))
465
+ .filter((n): n is number => n !== undefined);
466
+ if (radii.length) node.rectangleCornerRadii = radii;
467
+ }
468
+
469
+ // UNTRUSTED, carried verbatim.
470
+ assign('characters', str(r.characters));
471
+ assign('style', typeStyle(r.style));
472
+
473
+ assign('shapeType', str(r.shapeType));
474
+ assign('connectorStart', connectorEndpointId(r.connectorStart));
475
+ assign('connectorEnd', connectorEndpointId(r.connectorEnd));
476
+ assign('connectorStartCap', str(r.connectorStartCap));
477
+ assign('connectorEndCap', str(r.connectorEndCap));
478
+ assign('connectorLineType', str(r.connectorLineType));
479
+ assign('componentId', str(r.componentId));
480
+
481
+ if (Array.isArray(r.children)) {
482
+ const kids: FigmaNode[] = [];
483
+ for (const child of r.children) {
484
+ const normalized = normalizeNode(child, depth + 1, state);
485
+ if (normalized) kids.push(normalized);
486
+ }
487
+ if (kids.length) node.children = kids;
488
+ }
489
+
490
+ return node;
491
+ }
492
+
493
+ /**
494
+ * A connector endpoint is `{ endpointNodeId, magnet }` (bound) or
495
+ * `{ position }` (free-floating). Only the bound form carries a host id — and
496
+ * a free endpoint must NOT be mistaken for one, or T5 mints a bind to nothing.
497
+ */
498
+ function connectorEndpointId(v: unknown): string | undefined {
499
+ if (!v || typeof v !== 'object') return undefined;
500
+ const e = v as Record<string, unknown>;
501
+ return str(e.endpointNodeId);
502
+ }
503
+
504
+ /**
505
+ * Normalize a raw `GET /v1/files/:key` (or `/nodes`) document into the shape
506
+ * both doors share. Throws `FigmaCapError` on a hard refusal — callers turn
507
+ * that into the user-facing "import a specific frame instead" message.
508
+ */
509
+ export function normalizeDocument(
510
+ raw: unknown,
511
+ meta: { fileKey: string; surface: 'design' | 'board'; origin?: 'rest' | 'fig' }
512
+ ): NormalizedDocument {
513
+ const state: WalkState = { count: 0, maxDepth: 0 };
514
+ const root = normalizeNode(raw, 0, state);
515
+ if (!root) throw new FigmaCapError('nodes', 'Figma document has no readable root node');
516
+ return {
517
+ fileKey: meta.fileKey,
518
+ surface: meta.surface,
519
+ origin: meta.origin ?? 'rest',
520
+ root,
521
+ nodeCount: state.count,
522
+ maxDepth: state.maxDepth,
523
+ };
524
+ }
525
+
526
+ /** Depth-first walk over a normalized tree. Iterative — no stack growth. */
527
+ export function walkNodes(root: FigmaNode, visit: (node: FigmaNode, depth: number) => void): void {
528
+ const stack: Array<{ node: FigmaNode; depth: number }> = [{ node: root, depth: 0 }];
529
+ while (stack.length > 0) {
530
+ const entry = stack.pop();
531
+ if (!entry) break;
532
+ visit(entry.node, entry.depth);
533
+ const kids = entry.node.children;
534
+ if (!kids) continue;
535
+ for (let i = kids.length - 1; i >= 0; i--) {
536
+ stack.push({ node: kids[i], depth: entry.depth + 1 });
537
+ }
538
+ }
539
+ }
540
+
541
+ /** Guard used by the normalizer's own tests and by the `.fig` door later. */
542
+ export function isPollutingKey(key: string): boolean {
543
+ return POLLUTING_KEYS.has(key);
544
+ }