@1agh/maude 0.58.3 → 0.60.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 (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -0,0 +1,839 @@
1
+ /**
2
+ * @file figma/fig-decode.ts — the local `.fig` / `.jam` ingestion door.
3
+ * @scope apps/studio/figma/fig-decode.ts
4
+ * @purpose ZIP → `canvas.fig` → container → the two decompressors → Kiwi →
5
+ * a REST-SHAPED raw tree → `normalizeDocument(raw, {origin:'fig'})`.
6
+ * The second door onto the tree the translators already consume
7
+ * (DDR-221). No network, no token, no SSRF: the whole door is local.
8
+ *
9
+ * @invariant EMIT REST-SHAPED RAW, DO NOT BUILD A NormalizedDocument (DDR-221
10
+ * A5). Handing raw to the EXISTING normalizer makes the node/depth
11
+ * caps and the prototype-pollution guard literally the same code as
12
+ * the REST door, rather than a parallel implementation that drifts.
13
+ *
14
+ * @invariant FRAMING ERRORS REFUSE THE FILE; VOCABULARY GAPS DEGRADE AND
15
+ * REPORT (DDR-221 D3). An unknown prelude or container version is
16
+ * a refusal — never a best-effort decode, because a design importer
17
+ * that guesses produces wrong geometry that looks right.
18
+ *
19
+ * @invariant EVERY SCHEMA-SOURCED STRING IS ATTACKER-CHOSEN (DDR-221 A8/F1).
20
+ * Enum member names come from the file's own schema, so node
21
+ * `type` is attacker-controlled. Anything reaching a report goes
22
+ * through `reportToken` + a count, never verbatim — `attrValue`
23
+ * is NOT enough, it maps rejected characters to spaces and a
24
+ * bounded label can still read as prose.
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+ import { inflateRawSync, zstdDecompressSync } from 'node:zlib';
29
+
30
+ import { styleToWeight } from './codegen-fonts.ts';
31
+ import { decodeKiwi, findRootDefinition, type KiwiSchema, parseKiwiSchema } from './fig-kiwi.ts';
32
+ import { pathFromBlob, type VectorPath } from './fig-vector.ts';
33
+ import { FigZipError, readFigZip } from './fig-zip.ts';
34
+ import { reportToken } from './sanitize.ts';
35
+ import {
36
+ KNOWN_NODE_TYPES,
37
+ MAX_NODE_COUNT,
38
+ type NormalizedDocument,
39
+ normalizeDocument,
40
+ } from './types.ts';
41
+
42
+ // ── Container constants (measured on the committed fixtures) ────────────────
43
+
44
+ /** The 8-byte ASCII prelude is the ONLY editor discriminator. */
45
+ const PRELUDES: Record<string, 'design' | 'board'> = {
46
+ 'fig-kiwi': 'design',
47
+ 'fig-jam.': 'board',
48
+ };
49
+
50
+ /**
51
+ * Container versions we have actually observed. INFORMATIONAL — not a gate.
52
+ *
53
+ * DDR-221 D3 originally REFUSED an unrecognised version, on the assumption that
54
+ * the number predicts framing compatibility. Measured on a real third-party
55
+ * export (2026-08-12): it does not. That file is version **101** — LOWER than
56
+ * the fixtures' 106 despite being exported nine days later — carries a
57
+ * DIFFERENT schema (`7ae1921b` vs `c22712ff`), and decodes perfectly under the
58
+ * same code, because the framing is byte-identical and the file brings its own
59
+ * schema (D1's whole thesis).
60
+ *
61
+ * So the version was refusing valid files while predicting nothing. What
62
+ * actually gates correctness is STRUCTURE, all of it still enforced: the
63
+ * prelude, exactly two chunks with zero trailing bytes, a raw-deflate schema
64
+ * that parses and consumes every byte, the zstd magic, STRICT root resolution
65
+ * (exactly one `Message` of kind MESSAGE with a `nodeChanges: NodeChange[]`),
66
+ * and a data chunk that decodes with nothing left over. A file passing all of
67
+ * those is a Figma document whatever integer sits in bytes 8–11.
68
+ *
69
+ * The version is reported instead, and an unobserved one is worth noticing —
70
+ * see `FigDecodeReport.containerVersion` and the schema-hash alarm (D8).
71
+ */
72
+ export const OBSERVED_CONTAINER_VERSIONS: ReadonlySet<number> = new Set([101, 106]);
73
+
74
+ /** ~1 500x the measured 42 KB. */
75
+ export const MAX_CANVAS_FIG_BYTES = 64 * 1024 * 1024;
76
+ /** 2 observed. */
77
+ export const MAX_CHUNKS = 8;
78
+ /** ~117x the measured 71 777 B. */
79
+ export const MAX_SCHEMA_BYTES = 8 * 1024 * 1024;
80
+ /** ~1 000x the measured 67 207 B. */
81
+ export const MAX_DATA_BYTES = 64 * 1024 * 1024;
82
+
83
+ const CANVAS_ENTRY = 'canvas.fig';
84
+ const META_ENTRY = 'meta.json';
85
+
86
+ export class FigDecodeError extends Error {
87
+ constructor(message: string) {
88
+ super(message);
89
+ this.name = 'FigDecodeError';
90
+ }
91
+ }
92
+
93
+ export interface FigContainer {
94
+ surface: 'design' | 'board';
95
+ prelude: string;
96
+ version: number;
97
+ schema: Uint8Array;
98
+ data: Uint8Array;
99
+ /**
100
+ * `sha256` of the COMPRESSED schema chunk. Stable and shared across editor
101
+ * types, so a change is an early drift warning that fires before anything
102
+ * breaks (DDR-221 D8).
103
+ */
104
+ schemaSha256: string;
105
+ }
106
+
107
+ export interface FigDecodeReport {
108
+ /** Observed, not validated. See `OBSERVED_CONTAINER_VERSIONS`. */
109
+ containerVersion: number;
110
+ /** True when this version is one we have seen before — a soft drift signal. */
111
+ containerVersionKnown: boolean;
112
+ schemaSha256: string;
113
+ /** From `meta.json`. Makes a dated fixture corpus self-labelling. */
114
+ exportedAt?: string;
115
+ nodeCount: number;
116
+ /**
117
+ * Node types the file carried that `FigmaNodeType` has no member for.
118
+ * `reportToken`-bounded: one lowercase token, never spaces (DDR-221 A8/F1).
119
+ */
120
+ unmappedTypes: Array<{ type: string; count: number }>;
121
+ /** Nodes dropped because Figma marks them internal (e.g. the hidden canvas). */
122
+ internalNodesSkipped: number;
123
+ /**
124
+ * Fields this door CANNOT reproduce from a local file, with how many nodes
125
+ * each affected. Reported rather than left implicit: the plan's bar is that
126
+ * known-lossy fields are named, never silently degraded.
127
+ */
128
+ lossyFields: Array<{ field: string; count: number; why: string }>;
129
+ }
130
+
131
+ export interface FigDecodeResult {
132
+ document: NormalizedDocument;
133
+ report: FigDecodeReport;
134
+ /**
135
+ * Vector geometry the archive carries, by node id. Kept OUT of the normalized
136
+ * tree on purpose: REST has no equivalent, so putting it there would make the
137
+ * Tier-2 differential diverge for a field the REST door cannot produce. The
138
+ * local importer reads this to build SVGs without asking Figma to render.
139
+ */
140
+ vectors: Map<string, VectorPath>;
141
+ }
142
+
143
+ function u32le(b: Uint8Array, o: number): number {
144
+ return (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24)) >>> 0;
145
+ }
146
+
147
+ function decompress(chunk: Uint8Array, kind: 'schema' | 'data', max: number): Uint8Array {
148
+ try {
149
+ // maxOutputLength is enforced BY the codec, so a bomb never allocates
150
+ // (DDR-221 D4, measured: a 28 533:1 zstd bomb throws instead of expanding).
151
+ return kind === 'schema'
152
+ ? inflateRawSync(chunk, { maxOutputLength: max })
153
+ : zstdDecompressSync(chunk, { maxOutputLength: max });
154
+ } catch (err) {
155
+ if ((err as { code?: string }).code === 'ERR_BUFFER_TOO_LARGE') {
156
+ throw new FigDecodeError(`the ${kind} chunk decompresses past the ${max}-byte limit`);
157
+ }
158
+ throw new FigDecodeError(
159
+ kind === 'schema'
160
+ ? 'the schema chunk is not raw-deflate data'
161
+ : 'the data chunk is not zstd data'
162
+ );
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Read `canvas.fig`'s framing. Every failure here refuses the file and names
168
+ * the observed value, so diagnosis is one line rather than an afternoon.
169
+ */
170
+ export function readFigContainer(bytes: Uint8Array): FigContainer {
171
+ if (bytes.length > MAX_CANVAS_FIG_BYTES) {
172
+ throw new FigDecodeError(
173
+ `canvas.fig is ${bytes.length} bytes, over the ${MAX_CANVAS_FIG_BYTES}-byte limit`
174
+ );
175
+ }
176
+ if (bytes.length < 12)
177
+ throw new FigDecodeError('canvas.fig is too short to be a Figma container');
178
+
179
+ const prelude = new TextDecoder('latin1').decode(bytes.subarray(0, 8));
180
+ const surface = PRELUDES[prelude];
181
+ if (!surface) {
182
+ // The observed bytes are attacker-chosen; hex keeps them inert in a report.
183
+ const hex = Array.from(bytes.subarray(0, 8), (b) => b.toString(16).padStart(2, '0')).join(' ');
184
+ throw new FigDecodeError(`unrecognised Figma container prelude (bytes: ${hex})`);
185
+ }
186
+
187
+ // Read, reported, NOT gated — see OBSERVED_CONTAINER_VERSIONS for why the
188
+ // allowlist was removed. Structure gates; the integer does not.
189
+ const version = u32le(bytes, 8);
190
+
191
+ const chunks: Uint8Array[] = [];
192
+ let offset = 12;
193
+ while (offset + 4 <= bytes.length) {
194
+ const len = u32le(bytes, offset);
195
+ offset += 4;
196
+ if (offset + len > bytes.length) {
197
+ throw new FigDecodeError(
198
+ `chunk ${chunks.length} declares ${len} bytes past the end of canvas.fig`
199
+ );
200
+ }
201
+ chunks.push(bytes.subarray(offset, offset + len));
202
+ offset += len;
203
+ if (chunks.length > MAX_CHUNKS) {
204
+ throw new FigDecodeError(`canvas.fig declares more than ${MAX_CHUNKS} chunks`);
205
+ }
206
+ }
207
+
208
+ if (chunks.length !== 2) {
209
+ throw new FigDecodeError(`canvas.fig has ${chunks.length} chunks, expected exactly 2`);
210
+ }
211
+ if (offset !== bytes.length) {
212
+ throw new FigDecodeError(`canvas.fig has ${bytes.length - offset} trailing bytes`);
213
+ }
214
+
215
+ // The zstd magic is checked up front so a swapped chunk order refuses with a
216
+ // clear message instead of a confusing inflate failure.
217
+ const d = chunks[1];
218
+ if (!(d.length >= 4 && d[0] === 0x28 && d[1] === 0xb5 && d[2] === 0x2f && d[3] === 0xfd)) {
219
+ throw new FigDecodeError('the data chunk is missing its zstd magic');
220
+ }
221
+
222
+ return {
223
+ surface,
224
+ prelude,
225
+ version,
226
+ schema: decompress(chunks[0], 'schema', MAX_SCHEMA_BYTES),
227
+ data: decompress(chunks[1], 'data', MAX_DATA_BYTES),
228
+ schemaSha256: createHash('sha256').update(chunks[0]).digest('hex'),
229
+ };
230
+ }
231
+
232
+ // ── Tree reconstruction ─────────────────────────────────────────────────────
233
+
234
+ interface Matrix {
235
+ m00: number;
236
+ m01: number;
237
+ m02: number;
238
+ m10: number;
239
+ m11: number;
240
+ m12: number;
241
+ }
242
+
243
+ const IDENTITY: Matrix = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 };
244
+
245
+ function num(v: unknown): number | undefined {
246
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
247
+ }
248
+
249
+ function str(v: unknown): string | undefined {
250
+ return typeof v === 'string' ? v : undefined;
251
+ }
252
+
253
+ function obj(v: unknown): Record<string, unknown> | undefined {
254
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
255
+ ? (v as Record<string, unknown>)
256
+ : undefined;
257
+ }
258
+
259
+ /**
260
+ * ABSENT is not the same as NaN. A missing `transform` legitimately means the
261
+ * identity, but a component that is PRESENT and non-finite is a crafted or
262
+ * corrupt file: defaulting it to the identity yields plausible-but-wrong
263
+ * geometry with no error signal, which is the exact failure D3 exists to
264
+ * prevent (and the same shape as the A4 float trap). Refuse instead.
265
+ */
266
+ function component(v: unknown, fallback: number, name: string): number {
267
+ if (v === undefined) return fallback;
268
+ const n = num(v);
269
+ if (n === undefined)
270
+ throw new FigDecodeError(`transform component ${name} is not a finite number`);
271
+ return n;
272
+ }
273
+
274
+ function matrixOf(v: unknown): Matrix {
275
+ const m = obj(v);
276
+ if (!m) return IDENTITY;
277
+ return {
278
+ m00: component(m.m00, 1, 'm00'),
279
+ m01: component(m.m01, 0, 'm01'),
280
+ m02: component(m.m02, 0, 'm02'),
281
+ m10: component(m.m10, 0, 'm10'),
282
+ m11: component(m.m11, 1, 'm11'),
283
+ m12: component(m.m12, 0, 'm12'),
284
+ };
285
+ }
286
+
287
+ function compose(p: Matrix, c: Matrix): Matrix {
288
+ return {
289
+ m00: p.m00 * c.m00 + p.m01 * c.m10,
290
+ m01: p.m00 * c.m01 + p.m01 * c.m11,
291
+ m02: p.m00 * c.m02 + p.m01 * c.m12 + p.m02,
292
+ m10: p.m10 * c.m00 + p.m11 * c.m10,
293
+ m11: p.m10 * c.m01 + p.m11 * c.m11,
294
+ m12: p.m10 * c.m02 + p.m11 * c.m12 + p.m12,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * `.fig` geometry is a parent-relative affine transform plus a size; REST
300
+ * reports an absolute axis-aligned box. Composing down the parent chain is one
301
+ * of the two places the two doors can legitimately disagree, so it is also one
302
+ * of the two things the Tier-2 differential exists to check (DDR-221 A3).
303
+ */
304
+ function absoluteBox(m: Matrix, w: number, h: number) {
305
+ const xs = [m.m02, m.m00 * w + m.m02, m.m01 * h + m.m02, m.m00 * w + m.m01 * h + m.m02];
306
+ const ys = [m.m12, m.m10 * w + m.m12, m.m11 * h + m.m12, m.m10 * w + m.m11 * h + m.m12];
307
+ const x = Math.min(...xs);
308
+ const y = Math.min(...ys);
309
+ return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y };
310
+ }
311
+
312
+ function guidOf(v: unknown): string | undefined {
313
+ const g = obj(v);
314
+ if (!g) return undefined;
315
+ const s = num(g.sessionID);
316
+ const l = num(g.localID);
317
+ return s === undefined || l === undefined ? undefined : `${s}:${l}`;
318
+ }
319
+
320
+ /**
321
+ * Map ONE decoded `NodeChange` onto REST-shaped fields.
322
+ *
323
+ * Reads a HARDCODED list of field names and never iterates the decoded
324
+ * object's own keys (DDR-221 A8/F5) — field names come from the attacker's
325
+ * schema, so a generic copy would let them choose which REST field each value
326
+ * lands in (`absoluteBoundingBox`, `characters`, `children`).
327
+ */
328
+ /**
329
+ * `.fig` carries Figma's INTERNAL node vocabulary; REST reports the public one,
330
+ * and the translators are written against REST. Measured by the Tier-2
331
+ * differential on the fixtures — the unit tests could not have found this,
332
+ * because both sides of an internal-vocabulary mismatch look perfectly valid.
333
+ *
334
+ * A GROUP is internally a FRAME with `resizeToFit` (shrink-wrap to children);
335
+ * that flag is the discriminator, 5/5 on the design fixture and 1/1 on the
336
+ * board. `ROUNDED_RECTANGLE` collapses to `RECTANGLE` (the radius survives in
337
+ * `cornerRadius`), and `SYMBOL` is Figma's internal name for a `COMPONENT`.
338
+ */
339
+ function restType(change: Record<string, unknown>): string | undefined {
340
+ const type = str(change.type);
341
+ if (type === 'FRAME' && change.resizeToFit === true) return 'GROUP';
342
+ if (type === 'ROUNDED_RECTANGLE') return 'RECTANGLE';
343
+ if (type === 'SYMBOL') return 'COMPONENT';
344
+ return type;
345
+ }
346
+
347
+ /**
348
+ * Pull a FigJam node's text out of its template overrides. Reads only the
349
+ * hardcoded `nodeGenerationData.overrides[].textData.characters` path (A8/F5) —
350
+ * the override list is attacker-controlled, so it is walked, never spread — and
351
+ * takes the first entry that carries characters, which is what REST reports as
352
+ * the node's own `characters`.
353
+ */
354
+ /**
355
+ * `.fig` identifies an image fill by a 20-byte `image.hash`; REST calls the same
356
+ * thing `imageRef` and states it as hex. Measured on a real export: the hex IS
357
+ * the archive entry name (`images/<hex>`), which is what makes D6's "images
358
+ * travel inside the file" resolvable with no network at all.
359
+ *
360
+ * Only that one field is added — the paint is otherwise passed through for
361
+ * `normalizeDocument` to sanitize, exactly like the REST door's paints.
362
+ */
363
+ function toRestPaint(paint: unknown): unknown {
364
+ const p = obj(paint);
365
+ if (!p || p.imageRef !== undefined) return paint;
366
+ const hash = obj(p.image)?.hash;
367
+ if (!Array.isArray(hash) || hash.length === 0 || hash.length > 64) return paint;
368
+ let hex = '';
369
+ for (const byte of hash) {
370
+ const n = num(byte);
371
+ if (n === undefined || n < 0 || n > 255 || !Number.isInteger(n)) return paint;
372
+ hex += n.toString(16).padStart(2, '0');
373
+ }
374
+ return { ...p, imageRef: hex };
375
+ }
376
+
377
+ function overrides(change: Record<string, unknown>): unknown[] {
378
+ const list = obj(change.nodeGenerationData)?.overrides;
379
+ return Array.isArray(list) ? list : [];
380
+ }
381
+
382
+ /**
383
+ * First override carrying `key`. The template's own root comes first, so this
384
+ * resolves to the node's own paint/text rather than a sub-part's.
385
+ *
386
+ * The ordering is an OBSERVED property, not a documented one — which is exactly
387
+ * why the Tier-3 comparison is the guard: if Figma ever reorders these, the
388
+ * translator diff against REST fails loudly instead of quietly picking the
389
+ * wrong colour. Only hardcoded keys are read (A8/F5).
390
+ */
391
+ function fromOverrides(change: Record<string, unknown>, key: string): unknown {
392
+ for (const entry of overrides(change)) {
393
+ const value = obj(entry)?.[key];
394
+ if (value !== undefined) return value;
395
+ }
396
+ return undefined;
397
+ }
398
+
399
+ function overrideText(change: Record<string, unknown>): string | undefined {
400
+ for (const entry of overrides(change)) {
401
+ const characters = str(obj(obj(entry)?.textData)?.characters);
402
+ if (characters !== undefined) return characters;
403
+ }
404
+ return undefined;
405
+ }
406
+
407
+ function toRestNode(
408
+ change: Record<string, unknown>,
409
+ absolute: Matrix,
410
+ lossy: { lineHeights: number }
411
+ ): Record<string, unknown> {
412
+ const size = obj(change.size);
413
+ const w = num(size?.x) ?? 0;
414
+ const h = num(size?.y) ?? 0;
415
+
416
+ const raw: Record<string, unknown> = {
417
+ id: guidOf(change.guid),
418
+ type: restType(change),
419
+ name: str(change.name) ?? '',
420
+ visible: change.visible !== false,
421
+ absoluteBoundingBox: absoluteBox(absolute, w, h),
422
+ };
423
+
424
+ const opacity = num(change.opacity);
425
+ if (opacity !== undefined) raw.opacity = opacity;
426
+ const blend = str(change.blendMode);
427
+ if (blend) raw.blendMode = blend;
428
+ const cornerRadius = num(change.cornerRadius);
429
+ if (cornerRadius !== undefined) raw.cornerRadius = cornerRadius;
430
+ const strokeWeight = num(change.strokeWeight);
431
+ if (strokeWeight !== undefined) raw.strokeWeight = strokeWeight;
432
+ // A FigJam STICKY / SHAPE_WITH_TEXT is a template instance: its own paint is
433
+ // an override, not a top-level field. Reading only the top level left every
434
+ // board node on the translator's default colour (found by Tier 3).
435
+ const fills = change.fillPaints ?? fromOverrides(change, 'fillPaints');
436
+ if (Array.isArray(fills)) raw.fills = fills.map(toRestPaint);
437
+ const strokes = change.strokePaints ?? fromOverrides(change, 'strokePaints');
438
+ if (Array.isArray(strokes)) raw.strokes = strokes;
439
+ if (Array.isArray(change.effects)) raw.effects = change.effects;
440
+
441
+ // Auto-layout. `.fig` calls it `stack*`; REST calls it `layout*`.
442
+ const stackMode = str(change.stackMode);
443
+ if (stackMode === 'HORIZONTAL' || stackMode === 'VERTICAL') {
444
+ raw.layoutMode = stackMode;
445
+ const spacing = num(change.stackSpacing);
446
+ if (spacing !== undefined) raw.itemSpacing = spacing;
447
+ const hPad = num(change.stackHorizontalPadding);
448
+ const vPad = num(change.stackVerticalPadding);
449
+ if (hPad !== undefined) {
450
+ raw.paddingLeft = hPad;
451
+ raw.paddingRight = hPad;
452
+ }
453
+ if (vPad !== undefined) {
454
+ raw.paddingTop = vPad;
455
+ raw.paddingBottom = vPad;
456
+ }
457
+ const primary = str(change.stackPrimaryAlignItems);
458
+ if (primary) raw.primaryAxisAlignItems = primary;
459
+ const counter = str(change.stackCounterAlignItems);
460
+ if (counter) raw.counterAxisAlignItems = counter;
461
+ }
462
+
463
+ // Text. Two storage locations, and the second one is not optional:
464
+ // - a design-file TEXT node carries `textData.characters` directly;
465
+ // - a FigJam STICKY / SHAPE_WITH_TEXT is an instance of an internal
466
+ // template, and its text is an OVERRIDE on a sub-node, under
467
+ // `nodeGenerationData.overrides[].textData.characters`.
468
+ // Missing the second path lost the text of every sticky and every shape on
469
+ // the board while the tree still looked perfect — found by the Tier-2
470
+ // differential (20 nodes), invisible to every unit test.
471
+ const characters = str(obj(change.textData)?.characters) ?? overrideText(change);
472
+ if (characters !== undefined) raw.characters = characters;
473
+ const fontSize = num(change.fontSize);
474
+ const fontName = obj(change.fontName);
475
+ if (fontSize !== undefined || fontName || characters !== undefined) {
476
+ // `.fig` encodes typography sparsely and semantically; REST reports it
477
+ // resolved. Three deltas, all found by the Tier-3 translator comparison:
478
+ // - weight lives in the font STYLE name ("Bold"), not a number — reuse the
479
+ // codegen lane's map rather than keeping a second one;
480
+ // - a default alignment is OMITTED, where REST always states it;
481
+ // - lineHeight is authored ({value, units}), and REST reports the RESOLVED
482
+ // pixel value. A PERCENT line-height cannot be resolved to px without
483
+ // font metrics we do not have offline, so it is carried only when the
484
+ // file already states pixels. This is the local door's one genuinely
485
+ // lossy typography field and it is asserted as lossy in the Tier-3 test.
486
+ const lineHeight = obj(change.lineHeight);
487
+ const lineHeightPx = str(lineHeight?.units) === 'PIXELS' ? num(lineHeight?.value) : undefined;
488
+ if (lineHeight && lineHeightPx === undefined) lossy.lineHeights++;
489
+ raw.style = {
490
+ fontSize,
491
+ fontFamily: str(fontName?.family),
492
+ fontPostScriptName: str(fontName?.postscript),
493
+ fontWeight: styleToWeight(str(fontName?.style) ?? null) ?? undefined,
494
+ textAlignHorizontal: str(change.textAlignHorizontal) ?? 'LEFT',
495
+ ...(lineHeightPx !== undefined ? { lineHeightPx } : {}),
496
+ };
497
+ }
498
+
499
+ // FigJam.
500
+ const shapeType = str(change.shapeWithTextType);
501
+ if (shapeType) raw.shapeType = shapeType;
502
+ // REST wraps an endpoint as `{ endpointNodeId }` (lowercase d) and the
503
+ // normalizer unwraps it; `.fig` carries a GUID struct under `endpointNodeID`
504
+ // (uppercase D). Emit REST's shape so the SHARED normalizer resolves it —
505
+ // handing it a bare string silently yields an unbound connector.
506
+ const start = guidOf(obj(change.connectorStart)?.endpointNodeID);
507
+ if (start) raw.connectorStart = { endpointNodeId: start };
508
+ const end = guidOf(obj(change.connectorEnd)?.endpointNodeID);
509
+ if (end) raw.connectorEnd = { endpointNodeId: end };
510
+ const startCap = str(change.connectorStartCap);
511
+ if (startCap) raw.connectorStartCap = startCap;
512
+ const endCap = str(change.connectorEndCap);
513
+ if (endCap) raw.connectorEndCap = endCap;
514
+ const lineStyle = str(change.connectorLineStyle);
515
+ if (lineStyle) raw.connectorLineType = lineStyle;
516
+
517
+ return raw;
518
+ }
519
+
520
+ /**
521
+ * A PERCENT line-height cannot be resolved to pixels without font metrics, and
522
+ * REST reports the resolved value. Counted per node so the import summary can
523
+ * say so out loud (the only lossy typography field — Tier 3 asserts it is).
524
+ */
525
+ const LOSSY_LINE_HEIGHT = {
526
+ field: 'style.lineHeightPx',
527
+ why: 'a percent line-height needs font metrics the local file does not carry',
528
+ };
529
+
530
+ /**
531
+ * Read one node's own path geometry, if it has any.
532
+ *
533
+ * A decode failure DEGRADES to "no vector" rather than refusing the document:
534
+ * a single unreadable icon should not cost the whole import, and the caller
535
+ * reports the absence. That is the one place the fail-loud rule bends, and it
536
+ * bends toward reporting rather than toward guessing at the shape.
537
+ */
538
+ function vectorOf(change: Record<string, unknown>, blobs: readonly unknown[]): VectorPath | null {
539
+ const geometry = change.fillGeometry;
540
+ if (!Array.isArray(geometry) || geometry.length === 0) return null;
541
+ const first = obj(geometry[0]);
542
+ const index = num(first?.commandsBlob);
543
+ if (index === undefined || index < 0 || index >= blobs.length) return null;
544
+
545
+ const raw = obj(blobs[index])?.bytes;
546
+ const bytes =
547
+ raw instanceof Uint8Array ? raw : Array.isArray(raw) ? Uint8Array.from(raw as number[]) : null;
548
+ if (!bytes) return null;
549
+
550
+ let d: string;
551
+ try {
552
+ d = pathFromBlob(bytes);
553
+ } catch {
554
+ return null;
555
+ }
556
+
557
+ const paint = (Array.isArray(change.fillPaints) ? change.fillPaints : []).find(
558
+ (p) => obj(p)?.type === 'SOLID' && obj(p)?.visible !== false
559
+ );
560
+ const colour = obj(obj(paint)?.color);
561
+ const hex =
562
+ colour === undefined
563
+ ? null
564
+ : `#${(['r', 'g', 'b'] as const)
565
+ .map((k) => {
566
+ const v = num(colour[k]) ?? 0;
567
+ return Math.max(0, Math.min(255, Math.round(v * 255)))
568
+ .toString(16)
569
+ .padStart(2, '0');
570
+ })
571
+ .join('')}`;
572
+
573
+ return {
574
+ d,
575
+ fill: hex,
576
+ fillOpacity: num(obj(paint)?.opacity) ?? 1,
577
+ fillRule: str(first?.windingRule) === 'EVENODD' ? 'evenodd' : 'nonzero',
578
+ x: 0,
579
+ y: 0,
580
+ };
581
+ }
582
+
583
+ interface RebuildResult {
584
+ root: Record<string, unknown>;
585
+ unmapped: Map<string, number>;
586
+ internalSkipped: number;
587
+ nodeCount: number;
588
+ lossyLineHeights: number;
589
+ vectors: Map<string, VectorPath>;
590
+ }
591
+
592
+ /**
593
+ * Rebuild the tree from `parentIndex`. A `.fig` is a FLAT `nodeChanges[]`, not
594
+ * a nested document (DDR-221 A3), and this reconstruction is a SECOND recursion
595
+ * that the Kiwi decode-depth cap does not bound (A8/F4) — hence the explicit
596
+ * cycle, duplicate, orphan and single-root controls here.
597
+ */
598
+ function rebuildTree(changes: unknown[], blobs: readonly unknown[]): RebuildResult {
599
+ if (changes.length > MAX_NODE_COUNT) {
600
+ throw new FigDecodeError(
601
+ `file carries ${changes.length} nodes, over the ${MAX_NODE_COUNT} limit. Import a specific frame instead.`
602
+ );
603
+ }
604
+
605
+ const byId = new Map<string, Record<string, unknown>>();
606
+ const order: string[] = [];
607
+
608
+ for (const entry of changes) {
609
+ const change = obj(entry);
610
+ if (!change) throw new FigDecodeError('a node change is not an object');
611
+ const id = guidOf(change.guid);
612
+ if (!id) throw new FigDecodeError('a node change has no usable guid');
613
+ if (byId.has(id)) throw new FigDecodeError(`duplicate node guid ${id}`);
614
+ // Internal-only nodes are NOT removed here. Dropping them before parentage
615
+ // is resolved would orphan their children and refuse a legitimate file —
616
+ // the fixtures' internal canvas happens to be childless, which is why this
617
+ // looked safe. They are pruned as whole subtrees during the walk instead.
618
+ byId.set(id, change);
619
+ order.push(id);
620
+ }
621
+
622
+ const childIds = new Map<string, string[]>();
623
+ const roots: string[] = [];
624
+ for (const id of order) {
625
+ const change = byId.get(id) as Record<string, unknown>;
626
+ const parentIndex = obj(change.parentIndex);
627
+ const parent = guidOf(parentIndex?.guid);
628
+ if (parent === undefined) {
629
+ roots.push(id);
630
+ continue;
631
+ }
632
+ if (parent === id) throw new FigDecodeError(`node ${id} is its own parent`);
633
+ if (!byId.has(parent)) {
634
+ // Not silently dropped: an orphan means we misread the file or the file
635
+ // is crafted, and either way a partial tree is the wrong outcome.
636
+ throw new FigDecodeError(`node ${id} references parent ${parent}, which is not in the file`);
637
+ }
638
+ const siblings = childIds.get(parent);
639
+ if (siblings) siblings.push(id);
640
+ else childIds.set(parent, [id]);
641
+ }
642
+
643
+ if (roots.length !== 1) {
644
+ throw new FigDecodeError(`file has ${roots.length} root nodes, expected exactly 1`);
645
+ }
646
+
647
+ // Fractional-index ordering. Plain comparison, not `localeCompare`: the
648
+ // strings are attacker-controlled and locale collation on 20k items is both
649
+ // slow and locale-dependent.
650
+ for (const siblings of childIds.values()) {
651
+ siblings.sort((a, b) => {
652
+ const pa = str(obj(byId.get(a)?.parentIndex)?.position) ?? '';
653
+ const pb = str(obj(byId.get(b)?.parentIndex)?.position) ?? '';
654
+ return pa < pb ? -1 : pa > pb ? 1 : 0;
655
+ });
656
+ }
657
+
658
+ const unmapped = new Map<string, number>();
659
+ const onStack = new Set<string>();
660
+ const vectors = new Map<string, VectorPath>();
661
+ const lossy = { lineHeights: 0 };
662
+ let nodeCount = 0;
663
+ let internalSkipped = 0;
664
+
665
+ /** Count a pruned internal subtree so the report says how much was dropped. */
666
+ const countSubtree = (id: string, seen: Set<string>): number => {
667
+ if (seen.has(id)) return 0;
668
+ seen.add(id);
669
+ let n = 1;
670
+ for (const kid of childIds.get(id) ?? []) n += countSubtree(kid, seen);
671
+ return n;
672
+ };
673
+
674
+ const build = (id: string, parentAbsolute: Matrix, depth: number): Record<string, unknown> => {
675
+ // Cycle detection. A→B→A would otherwise recurse until the stack dies.
676
+ if (onStack.has(id)) throw new FigDecodeError(`node parentage forms a cycle at ${id}`);
677
+ onStack.add(id);
678
+
679
+ const change = byId.get(id) as Record<string, unknown>;
680
+ const absolute = compose(parentAbsolute, matrixOf(change.transform));
681
+ const node = toRestNode(change, absolute, lossy);
682
+ nodeCount++;
683
+
684
+ const art = vectorOf(change, blobs);
685
+ if (art) vectors.set(String(node.id), art);
686
+
687
+ const type = node.type;
688
+ if (typeof type !== 'string' || !KNOWN_NODE_TYPES.has(type)) {
689
+ // Vocabulary gap: degrade and report (D3). The raw string is left in
690
+ // place so the SHARED normalizer maps it to 'UNKNOWN' — this door does
691
+ // not get its own opinion about the vocabulary (A5). Only the REPORT
692
+ // label is produced here, and it is BOUNDED because the type name comes
693
+ // from the attacker's schema (A8/F1).
694
+ // `reportToken`, NOT `attrValue`: attrValue maps rejected characters to
695
+ // SPACES, so a 32-char bound still yields readable prose — measured, the
696
+ // test for this control failed against attrValue first. One token, no
697
+ // spaces, or the fixed word `unrecognized`.
698
+ const label = reportToken(typeof type === 'string' ? type : '');
699
+ unmapped.set(label, (unmapped.get(label) ?? 0) + 1);
700
+ }
701
+
702
+ const kids = childIds.get(id);
703
+ if (kids?.length) {
704
+ const kept: Array<Record<string, unknown>> = [];
705
+ for (const kid of kids) {
706
+ // Prune Figma's internal-only nodes as WHOLE SUBTREES here, where the
707
+ // parentage is already resolved, rather than dropping them up front.
708
+ if (byId.get(kid)?.internalOnly === true) {
709
+ internalSkipped += countSubtree(kid, new Set());
710
+ continue;
711
+ }
712
+ kept.push(build(kid, absolute, depth + 1));
713
+ }
714
+ if (kept.length > 0) node.children = kept;
715
+ }
716
+
717
+ onStack.delete(id);
718
+ return node;
719
+ };
720
+
721
+ if (byId.get(roots[0])?.internalOnly === true) {
722
+ throw new FigDecodeError('the document root is marked internal-only');
723
+ }
724
+ const root = build(roots[0], IDENTITY, 0);
725
+ return {
726
+ root,
727
+ unmapped,
728
+ internalSkipped,
729
+ nodeCount,
730
+ lossyLineHeights: lossy.lineHeights,
731
+ vectors,
732
+ };
733
+ }
734
+
735
+ // ── The door ────────────────────────────────────────────────────────────────
736
+
737
+ export interface DecodeFigOptions {
738
+ /** Charset-validated upstream. Provenance only — never derived from the file. */
739
+ fileKey: string;
740
+ }
741
+
742
+ /**
743
+ * Decode a `.fig` / `.jam` archive into the normalized tree both doors share.
744
+ */
745
+ export function decodeFigArchive(archive: Uint8Array, opts: DecodeFigOptions): FigDecodeResult {
746
+ // Both the directory parse AND the lazy per-entry read can refuse, and a
747
+ // caller of this door should only ever have to catch FigDecodeError. Wrapping
748
+ // just the first call let a FigZipError escape from `get()` — caught by the
749
+ // fuzz corpus, which is exactly the class of leak it exists to find.
750
+ let zip: ReturnType<typeof readFigZip>;
751
+ let canvas: Uint8Array | undefined;
752
+ try {
753
+ zip = readFigZip(archive);
754
+ canvas = zip.get(CANVAS_ENTRY);
755
+ } catch (err) {
756
+ if (err instanceof FigZipError) throw new FigDecodeError(err.message);
757
+ throw err;
758
+ }
759
+
760
+ if (!canvas) throw new FigDecodeError(`archive has no ${CANVAS_ENTRY} entry`);
761
+
762
+ const container = readFigContainer(canvas);
763
+
764
+ let schema: KiwiSchema;
765
+ let message: Record<string, unknown> | undefined;
766
+ try {
767
+ schema = parseKiwiSchema(container.schema);
768
+ const rootIndex = findRootDefinition(schema, 'Message', 'nodeChanges', 'NodeChange');
769
+ message = obj(decodeKiwi(container.data, schema, rootIndex));
770
+ } catch (err) {
771
+ throw new FigDecodeError(`could not decode canvas.fig: ${(err as Error).message}`);
772
+ }
773
+
774
+ const changes = message?.nodeChanges;
775
+ if (!Array.isArray(changes) || changes.length === 0) {
776
+ throw new FigDecodeError('canvas.fig carries no node changes');
777
+ }
778
+
779
+ const blobs = Array.isArray(message?.blobs) ? message.blobs : [];
780
+ const { root, unmapped, internalSkipped, nodeCount, lossyLineHeights, vectors } = rebuildTree(
781
+ changes,
782
+ blobs
783
+ );
784
+
785
+ const document = normalizeDocument(root, {
786
+ fileKey: opts.fileKey,
787
+ surface: container.surface,
788
+ origin: 'fig',
789
+ });
790
+
791
+ return {
792
+ document,
793
+ vectors,
794
+ report: {
795
+ containerVersion: container.version,
796
+ containerVersionKnown: OBSERVED_CONTAINER_VERSIONS.has(container.version),
797
+ schemaSha256: container.schemaSha256,
798
+ exportedAt: readExportedAt(safeEntry(zip, META_ENTRY)),
799
+ nodeCount,
800
+ unmappedTypes: [...unmapped.entries()]
801
+ .map(([type, count]) => ({ type, count }))
802
+ .sort((a, b) => b.count - a.count),
803
+ internalNodesSkipped: internalSkipped,
804
+ lossyFields: lossyLineHeights > 0 ? [{ ...LOSSY_LINE_HEIGHT, count: lossyLineHeights }] : [],
805
+ },
806
+ };
807
+ }
808
+
809
+ /**
810
+ * `meta.json` is optional and its bytes are lazily verified, so a corrupt entry
811
+ * must not turn a decodable document into a hard failure — nor let a
812
+ * FigZipError escape this door's error type.
813
+ */
814
+ function safeEntry(zip: ReturnType<typeof readFigZip>, name: string): Uint8Array | undefined {
815
+ try {
816
+ return zip.get(name);
817
+ } catch {
818
+ return undefined;
819
+ }
820
+ }
821
+
822
+ /**
823
+ * Read `exported_at` (Tier 4 needs it) and DELIBERATELY DROP `file_name`, which
824
+ * sits in the same object: DDR-216 D7 forbids recording the Figma file NAME
825
+ * anywhere an agent later reads (DDR-221 A6). The REST door never had it to
826
+ * hand; this one must decline it explicitly.
827
+ */
828
+ function readExportedAt(metaBytes: Uint8Array | undefined): string | undefined {
829
+ if (!metaBytes) return undefined;
830
+ try {
831
+ const meta = obj(JSON.parse(new TextDecoder().decode(metaBytes)));
832
+ const exportedAt = str(meta?.exported_at);
833
+ if (!exportedAt) return undefined;
834
+ // Bounded and charset-checked: it is untrusted text from the archive.
835
+ return /^[0-9TZ:.-]{1,32}$/.test(exportedAt) ? exportedAt : undefined;
836
+ } catch {
837
+ return undefined;
838
+ }
839
+ }