@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,452 @@
1
+ /**
2
+ * @file figma/assets.ts — resolving image + vector fills (DDR-216 T8).
3
+ * @scope apps/studio/figma/assets.ts
4
+ * @purpose Turn `pendingImages` / `pendingExports` into real, local,
5
+ * content-addressed assets — batched, capped, and through the
6
+ * existing gates rather than a second download path.
7
+ *
8
+ * @invariant BATCH, NEVER ONE CALL PER NODE. `IMAGE_COST = 200` ⇒ ~30 req/min
9
+ * on the images endpoint — the tight one. Figma's own translator
10
+ * emitted 22 exports for a trivial 990×648 frame; a real page would
11
+ * be hundreds. `to-artboard`'s vector-cluster collapse is the other
12
+ * half of this mitigation.
13
+ *
14
+ * @invariant DOWNLOADS GO THROUGH `_fetch-asset.mjs`, WITH THE FIGMA LANE'S
15
+ * NARROWING. The URLs `/v1/images` returns are RESPONSE-CONTROLLED
16
+ * — Maude did not choose them. They get the full resolved-IP gate
17
+ * plus a host allowlist, a pinned port, and a tight byte cap. There
18
+ * is no second downloader here.
19
+ *
20
+ * @invariant FAIL CLOSED. A vector goes through TWO processes in TWO runtimes
21
+ * (node for the download, bun for the DDR-167 SVG lane). DDR-177
22
+ * documents that runtime-spawned helpers have shipped broken inside
23
+ * the packaged `.app` more than once. If the sanitize step is
24
+ * unavailable, the staged bytes are DELETED and the node is reported
25
+ * `asset-skipped` — never "we already have the bytes", which is the
26
+ * natural and wrong recovery.
27
+ *
28
+ * @invariant DOWNLOADS STAGE OUTSIDE THE DESIGN ROOT. The caller's per-run
29
+ * staging directory lives under the OS temp root, never under
30
+ * `<designRoot>/_history/` — "gitignored" is not "not replicated",
31
+ * and `~/git/.stignore` excludes neither.
32
+ *
33
+ * @limitation ASSETS PROMOTE PER-ASSET, NOT ONCE AT THE END. `deps.promote`
34
+ * writes into `<designRoot>/assets/` as each download completes, so
35
+ * a failure on frame 7 of 60 leaves earlier assets committed while
36
+ * the report says the import failed. D5 asked for a single
37
+ * directory rename; this is N renames. Stated as a KNOWN GAP rather
38
+ * than described as the guarantee it is not (post-implementation
39
+ * review F6) — content-addressing makes the residue harmless-but-
40
+ * untidy (orphan assets, no wrong content), which is why it is a
41
+ * limitation and not a blocker.
42
+ */
43
+
44
+ import { fetchImageUrls, MAX_IMAGE_BATCH } from './client.ts';
45
+ import type { Disposition, ImportReport } from './sanitize.ts';
46
+
47
+ /** Hosts the Figma image lane may reach. Exact-or-dotted-suffix, frozen. */
48
+ export const FIGMA_ASSET_HOSTS = Object.freeze([
49
+ 'figma.com',
50
+ 'figma-alpha-api.s3.us-west-2.amazonaws.com',
51
+ ]);
52
+
53
+ /**
54
+ * D5 — total DISTINCT assets per import (post-dedupe).
55
+ *
56
+ * Raised from 200 on measurement, not on request. A normal 6-page product file
57
+ * — the live StudyFi onboarding file — carries 984 vector clusters that dedupe
58
+ * to ~530 distinct component renders. At 200 it dropped a THIRD of the file's
59
+ * artwork, which is not a backstop against an engineered document, it is a
60
+ * refusal to import normal work.
61
+ *
62
+ * The bound that actually protects the tree is `MAX_ASSET_BYTES_PER_IMPORT`:
63
+ * 530 icons are a few MB, while one photo-heavy board is tens. A count cap is
64
+ * the wrong dimension for vector art and was calibrated before anyone had run
65
+ * the thing on a real file.
66
+ */
67
+ export const MAX_ASSETS_PER_IMPORT = 1500;
68
+ /** D5 — cumulative bytes, the cap the per-item ones do not give you. */
69
+ export const MAX_ASSET_BYTES_PER_IMPORT = 64 * 1024 * 1024;
70
+ /**
71
+ * A UI vector export is kilobytes. The shared helper's 10 MB default was sized
72
+ * for a hero photograph; this lane pins far below it, which is also what closes
73
+ * the "200 assets × 10 MB = 2 GB into a replicated tree" shape.
74
+ */
75
+ export const FIGMA_ASSET_MAX_BYTES = 2 * 1024 * 1024;
76
+ /** D11 — well below DDR-167's 5 MB, because these bytes are now REMOTE. */
77
+ export const FIGMA_SVG_MAX_BYTES = 1 * 1024 * 1024;
78
+ /** Politeness + bounded local work. */
79
+ export const MAX_CONCURRENT_DOWNLOADS = 4;
80
+
81
+ /**
82
+ * SVGs handed to ONE `promoteSvgBatch` call.
83
+ *
84
+ * The DDR-167 execution canary launches a browser per call, which is why the
85
+ * batch exists at all — a per-file promote made a real icon set a ~40-minute
86
+ * import. But the batch runs under `agent-browser`'s fixed 20 s spawn budget
87
+ * (`_import-asset.mjs`), so an UNBOUNDED batch trades one pathology for another:
88
+ * at 272 frame renders the canary timed out and the caller's fail-closed rule
89
+ * discarded every asset in one go.
90
+ *
91
+ * 24 is deliberately well under where it was measured to break (60 timed out;
92
+ * the same run had previously survived 272 by luck of timing) — the cost of a
93
+ * chunk too small is a few extra browser launches, and the cost of a chunk too
94
+ * large is losing the chunk.
95
+ */
96
+ export const MAX_SVG_PROMOTE_CHUNK = 24;
97
+
98
+ export interface AssetRequest {
99
+ nodeId: string;
100
+ format: 'svg' | 'png';
101
+ /** What the emitted source currently references. */
102
+ placeholder: string;
103
+ }
104
+
105
+ /**
106
+ * Per-call knobs for the render-first lane. Both default to the icon-export
107
+ * behaviour, so the existing fill/vector path is byte-identical without them.
108
+ */
109
+ export interface ResolveOptions {
110
+ /**
111
+ * `false` keeps real `<text>` runs in a rendered SVG instead of converting
112
+ * them to outlines. Whole-frame renders want that — an artboard whose text is
113
+ * curves is a picture of a screen, not a screen you can search.
114
+ */
115
+ outlineText?: boolean;
116
+ /**
117
+ * Overrides `FIGMA_SVG_MAX_BYTES`. A single icon is kilobytes, but a whole
118
+ * rendered frame carries its raster fills inline as data URIs, so the 1 MB
119
+ * icon ceiling would reject perfectly normal screens.
120
+ */
121
+ svgMaxBytes?: number;
122
+ }
123
+
124
+ /** D11 — the whole-frame ceiling. Still far under DDR-167's own 5 MB. */
125
+ export const FIGMA_RENDER_MAX_BYTES = 4 * 1024 * 1024;
126
+
127
+ export interface ResolvedAsset {
128
+ nodeId: string;
129
+ placeholder: string;
130
+ /** The canvas reference path, e.g. `/assets/<sha8>.png`. */
131
+ ref: string;
132
+ bytes: number;
133
+ }
134
+
135
+ export interface ResolveDeps {
136
+ /**
137
+ * Download to a staged path under the FULL gate. Injected so this module is
138
+ * testable without the network — the real implementation is
139
+ * `_fetch-asset.mjs`'s `fetchAsset({ rawOut })`.
140
+ */
141
+ stage(url: string, outPath: string, maxBytes: number): Promise<{ bytes: number; ext: string }>;
142
+ /**
143
+ * Sanitize + promote a staged file into `assets/`, returning the canvas ref.
144
+ * The real implementation routes SVG through `_import-asset.mjs`'s DDR-167
145
+ * lane and rasters through the content-addressed write.
146
+ */
147
+ promote(stagedPath: string, kind: 'svg' | 'png'): Promise<{ ref: string }>;
148
+ /**
149
+ * Optional: promote MANY svg files in one pass. The DDR-167 execution canary
150
+ * launches a browser per call, so a per-file promote made a real icon set a
151
+ * ~40-minute import. When present this is used for every staged SVG; when
152
+ * absent the per-file `promote` is, so a caller that has not been updated
153
+ * still works.
154
+ */
155
+ promoteSvgBatch?(stagedPaths: readonly string[]): Promise<Array<string | null>>;
156
+ /** Where staged bytes live — OUTSIDE the design root (D5). */
157
+ stagingPath(nodeId: string, ext: string): string;
158
+ /** Drop a staged file on any failure path. */
159
+ discard(path: string): void;
160
+ }
161
+
162
+ /**
163
+ * The caps, as ONE mutable budget for a whole import.
164
+ *
165
+ * They were function-locals of `resolveAssets`, which reads correctly and is
166
+ * wrong: `importFrames` calls it once PER FRAME, so the ceiling reset every
167
+ * frame and 60 frames reconstructed exactly the multi-GB, Syncthing-replicated
168
+ * shape D5 says it closed — while spending a browser launch per SVG canary.
169
+ * A budget you can only bound by asking "who owns the counter?" is not a bound
170
+ * (post-implementation review F4).
171
+ */
172
+ export interface AssetBudget {
173
+ count: number;
174
+ bytes: number;
175
+ }
176
+
177
+ export function makeAssetBudget(): AssetBudget {
178
+ return { count: 0, bytes: 0 };
179
+ }
180
+
181
+ export interface ResolveResult {
182
+ resolved: ResolvedAsset[];
183
+ /** placeholder → ref, for rewriting the emitted source in one pass. */
184
+ rewrites: Map<string, string>;
185
+ totalBytes: number;
186
+ }
187
+
188
+ /**
189
+ * The id to RENDER for a node — an instance renders as its component.
190
+ *
191
+ * Figma scopes a node inside a component instance as `I<instancePath>;<compId>`,
192
+ * so 40 placements of one icon are 40 distinct node ids that render to
193
+ * byte-identical SVG. Measured on a live StudyFi file: 984 vector clusters
194
+ * across 6 pages collapsed to a small fraction once keyed this way. Without it
195
+ * the 200-asset cap eats a real file's icon set alive, and every duplicate also
196
+ * costs a download AND a browser canary.
197
+ *
198
+ * Content-addressing already dedupes on DISK; this dedupes the WORK.
199
+ */
200
+ export function renderKey(nodeId: string): string {
201
+ const semi = nodeId.lastIndexOf(';');
202
+ return semi > 0 ? nodeId.slice(semi + 1) : nodeId;
203
+ }
204
+
205
+ /** Split ids into `/v1/images`-sized batches. Never one call per node. */
206
+ export function batchIds(ids: readonly string[], size = MAX_IMAGE_BATCH): string[][] {
207
+ const out: string[][] = [];
208
+ for (let i = 0; i < ids.length; i += size) out.push([...ids.slice(i, i + size)]);
209
+ return out;
210
+ }
211
+
212
+ /** Run `tasks` with bounded concurrency, preserving input order in the result. */
213
+ async function pooled<T, R>(
214
+ items: readonly T[],
215
+ limit: number,
216
+ run: (item: T, index: number) => Promise<R>
217
+ ): Promise<R[]> {
218
+ const out: R[] = new Array(items.length);
219
+ let cursor = 0;
220
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
221
+ while (true) {
222
+ const i = cursor++;
223
+ if (i >= items.length) return;
224
+ out[i] = await run(items[i], i);
225
+ }
226
+ });
227
+ await Promise.all(workers);
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Resolve every pending asset for one import.
233
+ *
234
+ * Caps are enforced BEFORE work is done where possible (asset count) and
235
+ * during it where they cannot be (cumulative bytes) — and a cap trip is a
236
+ * REPORTED bounded degradation (`asset-cap-reached`), not a silent stop: the
237
+ * import continues without that asset and the summary names it.
238
+ */
239
+ export async function resolveAssets(
240
+ fileKey: string,
241
+ requests: readonly AssetRequest[],
242
+ deps: ResolveDeps,
243
+ report: ImportReport,
244
+ budget: AssetBudget = makeAssetBudget(),
245
+ opts: ResolveOptions = {}
246
+ ): Promise<ResolveResult> {
247
+ const rewrites = new Map<string, string>();
248
+ const resolved: ResolvedAsset[] = [];
249
+ let totalBytes = 0;
250
+
251
+ if (requests.length === 0) return { resolved, rewrites, totalBytes };
252
+
253
+ // Dedupe by RENDER KEY first — the cap should bound distinct artwork, not
254
+ // repeated placements of the same icon.
255
+ const byKey = new Map<string, AssetRequest[]>();
256
+ for (const r of requests) {
257
+ const key = `${renderKey(r.nodeId)}:${r.format}`;
258
+ const list = byKey.get(key);
259
+ if (list) list.push(r);
260
+ else byKey.set(key, [r]);
261
+ }
262
+ const unique = [...byKey.values()].map((group) => group[0]);
263
+
264
+ const room = Math.max(0, MAX_ASSETS_PER_IMPORT - budget.count);
265
+ const accepted = unique.slice(0, room);
266
+ budget.count += accepted.length;
267
+ for (const dropped of unique.slice(room)) {
268
+ report.add(dropped.nodeId, 'ASSET', 'asset-cap-reached', `>${MAX_ASSETS_PER_IMPORT}`);
269
+ }
270
+
271
+ // ── Batched URL resolution, per format ──
272
+ const urlByNode = new Map<string, string>();
273
+ /** Effective format per node — an SVG that fell back to raster is tracked here. */
274
+ const formatByNode = new Map<string, 'svg' | 'png'>();
275
+ for (const format of ['svg', 'png'] as const) {
276
+ const ids = accepted.filter((r) => r.format === format).map((r) => r.nodeId);
277
+ if (ids.length === 0) continue;
278
+ for (const batch of batchIds(ids)) {
279
+ const { images } = await fetchImageUrls(
280
+ fileKey,
281
+ batch,
282
+ format,
283
+ 2,
284
+ opts.outlineText !== undefined ? { outlineText: opts.outlineText } : {}
285
+ );
286
+ for (const [nodeId, url] of Object.entries(images)) {
287
+ if (typeof url === 'string' && url.length > 0) {
288
+ urlByNode.set(nodeId, url);
289
+ formatByNode.set(nodeId, format);
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ // RASTER FALLBACK. Figma answers `null` — not an error — for nodes it will
296
+ // not vectorize, and a null with no retry is an artboard whose <img> points
297
+ // at a placeholder that never resolves: a broken image where a screen should
298
+ // be. Ask for the same node as PNG before giving up on it.
299
+ const missing = accepted
300
+ .filter((r) => r.format === 'svg' && !urlByNode.has(r.nodeId))
301
+ .map((r) => r.nodeId);
302
+ for (const batch of batchIds(missing)) {
303
+ const { images } = await fetchImageUrls(fileKey, batch, 'png', 2);
304
+ for (const [nodeId, url] of Object.entries(images)) {
305
+ if (typeof url === 'string' && url.length > 0) {
306
+ urlByNode.set(nodeId, url);
307
+ formatByNode.set(nodeId, 'png');
308
+ report.add(nodeId, 'ASSET', 'asset-degraded', 'vector unavailable — rasterized');
309
+ }
310
+ }
311
+ }
312
+
313
+ // ── Bounded-concurrency download; SVG promotes are batched afterwards ──
314
+ const stagedSvgs: Array<{ req: AssetRequest; staged: string }> = [];
315
+
316
+ await pooled(accepted, MAX_CONCURRENT_DOWNLOADS, async (req) => {
317
+ const url = urlByNode.get(req.nodeId);
318
+ if (!url) {
319
+ report.add(req.nodeId, 'ASSET', 'asset-skipped', 'figma declined to render');
320
+ return;
321
+ }
322
+ if (budget.bytes >= MAX_ASSET_BYTES_PER_IMPORT) {
323
+ report.add(req.nodeId, 'ASSET', 'asset-cap-reached', 'total bytes');
324
+ return;
325
+ }
326
+
327
+ // What we ACTUALLY got, which is not always what we asked for — see the
328
+ // raster fallback above.
329
+ const eff = formatByNode.get(req.nodeId) ?? req.format;
330
+ const staged = deps.stagingPath(req.nodeId, eff);
331
+ try {
332
+ const cap = eff === 'svg' ? (opts.svgMaxBytes ?? FIGMA_SVG_MAX_BYTES) : FIGMA_ASSET_MAX_BYTES;
333
+ const { bytes, ext } = await deps.stage(url, staged, cap);
334
+ // Counted HERE, not after a successful promote: bytes that crossed the
335
+ // network and landed on disk cost the same whether the promote succeeded.
336
+ budget.bytes += bytes;
337
+ // The staged kind must agree with what we asked Figma to render. A
338
+ // mismatch means the response is not what the request implied — refuse
339
+ // rather than promote something into a versioned, peer-synced tree.
340
+ const kindOk = eff === 'svg' ? ext === 'svg' : ext !== 'svg';
341
+ if (!kindOk) {
342
+ deps.discard(staged);
343
+ report.add(req.nodeId, 'ASSET', 'asset-skipped', 'format mismatch');
344
+ return;
345
+ }
346
+ if (eff === 'svg' && deps.promoteSvgBatch) {
347
+ // Defer — one browser session for all of them beats one each.
348
+ stagedSvgs.push({ req, staged });
349
+ totalBytes += bytes;
350
+ return;
351
+ }
352
+ const { ref } = await deps.promote(staged, eff);
353
+ totalBytes += bytes;
354
+ resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes });
355
+ // Every placement that shares this render key gets the same asset.
356
+ for (const sibling of byKey.get(`${renderKey(req.nodeId)}:${req.format}`) ?? [req]) {
357
+ rewrites.set(sibling.placeholder, ref);
358
+ }
359
+ } catch {
360
+ // FAIL CLOSED — including when the bun-side sanitizer is simply not
361
+ // available in a packaged app (DDR-177). Delete the staged bytes and
362
+ // report; never keep them and never reference them.
363
+ deps.discard(staged);
364
+ report.add(req.nodeId, 'ASSET', 'asset-skipped', 'download or sanitize failed');
365
+ }
366
+ });
367
+
368
+ if (stagedSvgs.length > 0 && deps.promoteSvgBatch) {
369
+ const refs: Array<string | null> = [];
370
+ // CHUNKED, because "fail closed for the whole batch" and "one batch for the
371
+ // whole import" together turn a single browser timeout into total asset
372
+ // loss. Measured on a live 6-page import: the DDR-167 execution canary
373
+ // spawns `agent-browser` with a fixed 20 s budget (`_import-asset.mjs`), one
374
+ // session for the whole array — at 272 frame renders it blew that budget,
375
+ // the promote threw, and every one of the 272 became `asset-skipped`. The
376
+ // import still exited 0, so it reported success while producing a folder of
377
+ // broken images. Reproduced at 60 SVGs, and it reproduces WITHOUT any of the
378
+ // Figma-lane changes, so this is the shared lane's shape, not the caller's.
379
+ //
380
+ // Fail-closed is KEPT — it is the DDR-177 rule and it is right. What changes
381
+ // is the blast radius: a timeout now costs its own chunk, and the rest of
382
+ // the artwork still lands.
383
+ for (let start = 0; start < stagedSvgs.length; start += MAX_SVG_PROMOTE_CHUNK) {
384
+ const chunk = stagedSvgs.slice(start, start + MAX_SVG_PROMOTE_CHUNK);
385
+ let got: Array<string | null> | null = null;
386
+ // ONE RETRY, because the canary's failure is measurably TRANSIENT rather
387
+ // than a property of the input. Measured on a cleaned machine: the same
388
+ // lane promoted 4/4 (30.7 s) and 24/24 (42.3 s) but threw on 12 (24.3 s) —
389
+ // the budget is per `agent-browser` invocation, and a big frame render
390
+ // flirts with it. A chunk lost to a coin-flip is a permanently broken
391
+ // image in a versioned artifact, and a second attempt is one browser
392
+ // launch. Bounded at one: a lane that is genuinely down must still fail
393
+ // fast rather than retry 12 chunks into a multi-minute stall.
394
+ for (let attempt = 0; attempt < 2 && got === null; attempt += 1) {
395
+ try {
396
+ got = await deps.promoteSvgBatch(chunk.map((x) => x.staged));
397
+ } catch {
398
+ got = null;
399
+ }
400
+ }
401
+ if (got === null) {
402
+ // FAIL CLOSED, same rule as the per-file path: the bun-side lane being
403
+ // unavailable must never become "we already have the bytes"
404
+ // (DDR-177's packaged-app failure mode).
405
+ for (let i = 0; i < chunk.length; i += 1) refs.push(null);
406
+ continue;
407
+ }
408
+ // A short return would silently shift every later ref onto the wrong
409
+ // node — pad rather than trust the length.
410
+ for (let i = 0; i < chunk.length; i += 1) refs.push(got[i] ?? null);
411
+ }
412
+ for (let i = 0; i < stagedSvgs.length; i += 1) {
413
+ const { req, staged } = stagedSvgs[i];
414
+ const ref = refs[i];
415
+ if (!ref) {
416
+ deps.discard(staged);
417
+ report.add(req.nodeId, 'ASSET', 'asset-skipped', 'sanitize refused');
418
+ continue;
419
+ }
420
+ resolved.push({ nodeId: req.nodeId, placeholder: req.placeholder, ref, bytes: 0 });
421
+ for (const sibling of byKey.get(`${renderKey(req.nodeId)}:svg`) ?? [req]) {
422
+ rewrites.set(sibling.placeholder, ref);
423
+ }
424
+ }
425
+ }
426
+
427
+ return { resolved, rewrites, totalBytes };
428
+ }
429
+
430
+ /**
431
+ * Rewrite placeholders in emitted source. A placeholder that never resolved is
432
+ * left in place deliberately — the canvas shows a visibly broken image, which
433
+ * is a far better failure than a silently-missing element, and the summary
434
+ * already names the node.
435
+ */
436
+ export function applyRewrites(source: string, rewrites: ReadonlyMap<string, string>): string {
437
+ let out = source;
438
+ for (const [placeholder, ref] of rewrites) {
439
+ out = out.split(placeholder).join(ref);
440
+ }
441
+ return out;
442
+ }
443
+
444
+ /** The disposition set this module can emit — kept in sync with `sanitize.ts`.
445
+ * `asset-degraded` was missing here as well as from the union; both halves of
446
+ * the drift are closed together (DDR-219 D9). */
447
+ export const ASSET_DISPOSITIONS: readonly Disposition[] = [
448
+ 'asset-pending',
449
+ 'asset-skipped',
450
+ 'asset-cap-reached',
451
+ 'asset-degraded',
452
+ ];