@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,345 @@
1
+ // _perf-shared.mjs — the measurement contract shared by every `maude design perf`
2
+ // engine lane (Chromium via agent-browser, WebKit via safaridriver).
3
+ //
4
+ // It lives in one file on purpose. Two lanes with two copies of the gesture
5
+ // script would drift, and the moment they drift the engines stop being
6
+ // comparable — which is the only thing this benchmark is for.
7
+
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+
10
+ // ── The in-page harness ──────────────────────────────────────────────────────
11
+ //
12
+ // Synthesizes the same wheel events the viewport controller listens for
13
+ // (`canvas-lib.tsx` onWheel: plain wheel = 2D pan, ctrlKey wheel = pinch-zoom —
14
+ // a macOS trackpad pinch arrives as ctrlKey:true, so this is the real gesture
15
+ // path, not a side door).
16
+ //
17
+ // Runs async and parks its result on `window.__maudePerfResult`, which the
18
+ // caller polls: both driver protocols return one synchronous value per eval and
19
+ // cannot await.
20
+ export function harnessSource({ pan, zoom, injectCss, fitAll, setFlags }) {
21
+ const flagBlock = (setFlags || []).map((f) => `\n win[${JSON.stringify(f)}] = true;`).join('');
22
+ const cssBlock = injectCss
23
+ ? `
24
+ {
25
+ const st = doc.createElement('style');
26
+ st.id = 'maude-perf-variant';
27
+ st.textContent = ${JSON.stringify(injectCss)};
28
+ doc.head.appendChild(st);
29
+ }`
30
+ : '';
31
+ return `(() => {
32
+ const host = document.querySelector('.dc-canvas') ||
33
+ (document.querySelector('iframe') &&
34
+ document.querySelector('iframe').contentDocument &&
35
+ document.querySelector('iframe').contentDocument.querySelector('.dc-canvas'));
36
+ if (!host) return 'NO_HOST';
37
+ const doc = host.ownerDocument;
38
+ const win = doc.defaultView;
39
+ win.__maudePerfResult = null;${flagBlock}${cssBlock}
40
+
41
+ // Render counter, installed by canvas-lib when this object exists. Absent
42
+ // instrumentation reports null rather than zero — "not measured" and
43
+ // "measured zero" must never look alike in a baseline.
44
+ win.__dcPerf = { artboardRenders: 0, annotationRenders: 0 };
45
+
46
+ const frames = [];
47
+ let longtasks = 0;
48
+ let po = null;
49
+ try {
50
+ po = new win.PerformanceObserver((list) => { longtasks += list.getEntries().length; });
51
+ po.observe({ entryTypes: ['longtask'] });
52
+ } catch { /* longtask unsupported (WebKit) — count stays 0, frames still tell the story */ }
53
+
54
+ let last = 0;
55
+ let running = true;
56
+ const tick = (t) => {
57
+ if (last) frames.push(t - last);
58
+ last = t;
59
+ if (running) win.requestAnimationFrame(tick);
60
+ };
61
+ win.requestAnimationFrame(tick);
62
+
63
+ const rect = host.getBoundingClientRect();
64
+ const cx = rect.left + rect.width / 2;
65
+ const cy = rect.top + rect.height / 2;
66
+
67
+ const wheel = (dx, dy, ctrl) => host.dispatchEvent(new win.WheelEvent('wheel', {
68
+ deltaX: dx, deltaY: dy, ctrlKey: !!ctrl,
69
+ clientX: cx, clientY: cy, bubbles: true, cancelable: true,
70
+ }));
71
+
72
+ const nextFrame = () => new Promise((r) => win.requestAnimationFrame(() => r()));
73
+
74
+ // The world transform is the ground truth that the gesture LANDED. Without
75
+ // this check a probe whose synthetic events get swallowed reports a beautiful
76
+ // 60fps idle page as a baseline — the exact way a benchmark lies.
77
+ const world = doc.querySelector('.dc-world');
78
+ const transformOf = () => (world ? win.getComputedStyle(world).transform + '|' + (world.style.zoom || '') : '');
79
+
80
+ (async () => {
81
+ // Warm-up: one frame so the first measured delta isn't the install cost.
82
+ await nextFrame();
83
+
84
+ // FIT-ALL first when asked. This is the state the moodboard RCA identified
85
+ // as the painful one and the state a probe most easily misses: at the
86
+ // canvas's stored viewport only a handful of boards are on screen, so
87
+ // content-visibility culls the rest and the measurement flatters the
88
+ // engine. Zoomed out to the whole plane nothing culls, every board paints,
89
+ // and that is where the user says it stops being usable. Cmd+0 is the
90
+ // canvas's own fit shortcut (canvas-lib keydown, case 0).
91
+ // NB: no backticks in this comment — it lives inside a template literal.
92
+ if (${fitAll ? 'true' : 'false'}) {
93
+ doc.dispatchEvent(new win.KeyboardEvent('keydown', {
94
+ key: '0', code: 'Digit0', metaKey: true, bubbles: true, cancelable: true,
95
+ }));
96
+ host.dispatchEvent(new win.KeyboardEvent('keydown', {
97
+ key: '0', code: 'Digit0', metaKey: true, bubbles: true, cancelable: true,
98
+ }));
99
+ // Let the fit animation finish before the measured gesture starts.
100
+ await new Promise((r) => win.setTimeout(r, 800));
101
+ }
102
+ // Baseline for the did-it-move check is taken HERE, after any fit, not at
103
+ // harness install. Repeated passes are deterministic: pass N ends exactly
104
+ // where "fit + the same pan" lands, so an install-time baseline compares two
105
+ // identical states and rejects a gesture that ran perfectly.
106
+ const t0 = transformOf();
107
+ const panStart = frames.length;
108
+ for (let i = 0; i < ${pan}; i++) { wheel(-12, -9, false); await nextFrame(); }
109
+ const panEnd = frames.length;
110
+ const tPan = transformOf();
111
+
112
+ // Zoom: alternate in/out so the run neither drifts to the zoom clamp nor
113
+ // ends somewhere a follow-up run would start from differently.
114
+ // Zoom IN first, then back out — symmetric (net scale change ~0) but it
115
+ // starts in the direction that always has headroom. Starting outward dies
116
+ // at fit-all, where the viewport already sits on ZOOM_MIN: the clamp makes
117
+ // the first half a no-op, the transform never moves, and the validity gate
118
+ // correctly rejects the whole run.
119
+ // Sampled at the TURNAROUND, not at the end: the run is symmetric, so its
120
+ // endpoint transform equals its start. Comparing endpoints would report
121
+ // "zoom never happened" for a zoom that happened twice.
122
+ let tZoomMid = '';
123
+ for (let i = 0; i < ${zoom}; i++) {
124
+ wheel(0, i < ${zoom} / 2 ? -8 : 8, true);
125
+ await nextFrame();
126
+ if (i === Math.floor(${zoom} / 2) - 1) tZoomMid = transformOf();
127
+ }
128
+ const zoomEnd = frames.length;
129
+ const tZoom = tZoomMid;
130
+
131
+ // Settle window — the tail spike after the gesture is its own symptom
132
+ // ("seká po dojezdu"), so it is measured separately, not averaged away.
133
+ const settleStart = frames.length;
134
+ await new Promise((r) => win.setTimeout(r, 900));
135
+ running = false;
136
+ if (po) { try { po.disconnect(); } catch {} }
137
+
138
+ const slice = (a, b) => frames.slice(a, b).filter((n) => n > 0);
139
+ const pct = (arr, p) => {
140
+ if (!arr.length) return null;
141
+ const s = arr.slice().sort((a, b) => a - b);
142
+ return Math.round(s[Math.min(s.length - 1, Math.floor(s.length * p))] * 100) / 100;
143
+ };
144
+ const stat = (arr) => ({
145
+ frames: arr.length,
146
+ p50: pct(arr, 0.5),
147
+ p95: pct(arr, 0.95),
148
+ max: arr.length ? Math.round(Math.max(...arr) * 100) / 100 : null,
149
+ });
150
+
151
+ const gestureFrames = slice(panStart, zoomEnd);
152
+ win.__maudePerfResult = {
153
+ pan: stat(slice(panStart, panEnd)),
154
+ zoom: stat(slice(panEnd, zoomEnd)),
155
+ gesture: stat(gestureFrames),
156
+ settle: stat(slice(settleStart, frames.length)),
157
+ longtasks,
158
+ artboardRenders: win.__dcPerf ? win.__dcPerf.artboardRenders : null,
159
+ annotationRenders: win.__dcPerf ? win.__dcPerf.annotationRenders : null,
160
+ instrumented: !!(win.__dcPerf && win.__dcPerf.instrumented),
161
+ panApplied: tPan !== t0,
162
+ zoomApplied: tZoom !== tPan,
163
+ };
164
+ })();
165
+ return 'STARTED';
166
+ })()`;
167
+ }
168
+
169
+ /**
170
+ * Shape check behind `parseAndValidateResult` — validates a harness result
171
+ * before ANY of it is printed or recorded.
172
+ *
173
+ * The harness parks its result on `window.__maudePerfResult` — a global inside
174
+ * the canvas origin, which DDR-054 treats as untrusted. A canvas from a cloned
175
+ * repo can define that global itself, ahead of our IIFE, and hand back whatever
176
+ * shape it likes. The numbers would be laundered downstream by Math.round, but
177
+ * the per-pass progress line and the refusal message print fields verbatim, so
178
+ * without this gate a hostile canvas gets attacker-authored text into the
179
+ * invoking agent's transcript wearing a first-party tool's voice.
180
+ *
181
+ * Returns the value only when every leaf is the primitive it claims to be.
182
+ */
183
+ export function parseAndValidateResult(raw) {
184
+ let decoded;
185
+ try {
186
+ decoded = typeof raw === 'string' ? JSON.parse(raw) : raw;
187
+ } catch {
188
+ // The PARSE is part of the trust boundary, not a step before it. A canvas
189
+ // owns its window and can replace JSON.stringify, so `raw` is arbitrary
190
+ // text — and a JSON.parse SyntaxError quotes a snippet of its input, which
191
+ // would carry attacker-authored text into the invoking agent's transcript
192
+ // through the generic error handler.
193
+ return null;
194
+ }
195
+ return validateResult(decoded);
196
+ }
197
+
198
+ function validateResult(r) {
199
+ const num = (v) => v === null || (typeof v === 'number' && Number.isFinite(v));
200
+ const stat = (o) => o && typeof o === 'object' && num(o.p50) && num(o.p95) && num(o.max);
201
+ if (!r || typeof r !== 'object') return null;
202
+ if (!stat(r.gesture) || !stat(r.pan) || !stat(r.zoom) || !stat(r.settle)) return null;
203
+ if (!num(r.longtasks) || !num(r.artboardRenders) || !num(r.annotationRenders)) return null;
204
+ if (typeof r.panApplied !== 'boolean' || typeof r.zoomApplied !== 'boolean') return null;
205
+ if (typeof r.instrumented !== 'boolean') return null;
206
+ return r;
207
+ }
208
+
209
+ /** Median across kept passes — robust to the one pass that hit a GC. */
210
+ export function medianOf(passes) {
211
+ const med = (pick) => {
212
+ const vals = passes.map(pick).filter((v) => v != null);
213
+ if (!vals.length) return null;
214
+ const s = vals.slice().sort((a, b) => a - b);
215
+ return Math.round(s[Math.floor(s.length / 2)] * 100) / 100;
216
+ };
217
+ return {
218
+ pan: { p95: med((p) => p.pan.p95) },
219
+ zoom: { p95: med((p) => p.zoom.p95) },
220
+ gesture: {
221
+ p50: med((p) => p.gesture.p50),
222
+ p95: med((p) => p.gesture.p95),
223
+ max: med((p) => p.gesture.max),
224
+ },
225
+ settle: { max: med((p) => p.settle.max) },
226
+ longtasks: med((p) => p.longtasks),
227
+ artboardRenders: med((p) => p.artboardRenders),
228
+ annotationRenders: med((p) => p.annotationRenders),
229
+ instrumented: passes.some((p) => p.instrumented),
230
+ passes: passes.length,
231
+ // The spread across kept passes IS a result: a delta smaller than this is
232
+ // noise, and the report says so rather than leaving the reader to guess.
233
+ p95Spread:
234
+ passes.length > 1
235
+ ? Math.round(
236
+ (Math.max(...passes.map((p) => p.gesture.p95)) -
237
+ Math.min(...passes.map((p) => p.gesture.p95))) *
238
+ 100
239
+ ) / 100
240
+ : null,
241
+ };
242
+ }
243
+
244
+ export function buildRow({ result, label, engineTag, opts }) {
245
+ return {
246
+ date: new Date().toISOString(),
247
+ canvas: label,
248
+ engine: engineTag,
249
+ url: opts.url,
250
+ panFrames: opts.pan,
251
+ zoomFrames: opts.zoom,
252
+ p50FrameMs: result.gesture.p50,
253
+ p95FrameMs: result.gesture.p95,
254
+ maxFrameMs: result.gesture.max,
255
+ panP95Ms: result.pan.p95,
256
+ zoomP95Ms: result.zoom.p95,
257
+ settleMaxMs: result.settle.max,
258
+ longtasks: result.longtasks,
259
+ artboardRenders: result.artboardRenders,
260
+ annotationRenders: result.annotationRenders,
261
+ instrumented: result.instrumented,
262
+ passes: result.passes,
263
+ p95SpreadMs: result.p95Spread,
264
+ };
265
+ }
266
+
267
+ /** Delta vs the previous run of the same (canvas, engine) pair. */
268
+ export function deltaLine(label, cur, prev, unit = 'ms') {
269
+ if (cur == null) return ` ${label.padEnd(22)} —`;
270
+ if (prev == null) return ` ${label.padEnd(22)} ${cur}${unit} (no prior run)`;
271
+ const d = Math.round((cur - prev) * 100) / 100;
272
+ const pctChange = prev === 0 ? null : Math.round((d / prev) * 1000) / 10;
273
+ const arrow = d === 0 ? '=' : d < 0 ? '▼' : '▲';
274
+ const pctTxt = pctChange == null ? '' : ` ${pctChange > 0 ? '+' : ''}${pctChange}%`;
275
+ return ` ${label.padEnd(22)} ${cur}${unit} ${arrow} ${d > 0 ? '+' : ''}${d}${unit}${pctTxt} (was ${prev}${unit})`;
276
+ }
277
+
278
+ export function readHistory(path, label, engineTag) {
279
+ if (!path || !existsSync(path)) return null;
280
+ let prev = null;
281
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
282
+ if (!line.trim()) continue;
283
+ try {
284
+ const row = JSON.parse(line);
285
+ if (row.canvas === label && row.engine === engineTag) prev = row;
286
+ } catch {
287
+ /* a corrupt line must not sink the run — history is a convenience, not a source of truth */
288
+ }
289
+ }
290
+ return prev;
291
+ }
292
+
293
+ /**
294
+ * One-line, control-character-free rendering of a caller-supplied string.
295
+ * A canvas filename comes from the repo and POSIX allows newlines in it, so
296
+ * echoing one raw lets a hostile repo inject its own line into a report an
297
+ * agent reads.
298
+ */
299
+ function safeLabel(v) {
300
+ let out = '';
301
+ for (const ch of String(v).slice(0, 200)) {
302
+ const code = ch.codePointAt(0);
303
+ const unsafe =
304
+ code < 0x20 || // C0 — newline and friends: the line-injection case
305
+ code === 0x7f || // DEL
306
+ (code >= 0x80 && code <= 0x9f) || // C1 — some terminals still act on these
307
+ (code >= 0x202a && code <= 0x202e) || // bidi embedding / override
308
+ (code >= 0x2066 && code <= 0x2069); // bidi isolates
309
+ // Bidi controls can't break a line, but they can visually reorder one, so a
310
+ // filename could make a report read differently than it is.
311
+ out += unsafe ? ' ' : ch;
312
+ }
313
+ return out;
314
+ }
315
+
316
+ export function renderReport({ row, prev, opts, label, engineTag }) {
317
+ const out = [];
318
+ out.push(`\n canvas: ${safeLabel(label)} engine: ${safeLabel(engineTag)}`);
319
+ out.push(` gesture: ${opts.pan} pan frames + ${opts.zoom} zoom frames\n`);
320
+ out.push(deltaLine('frame p50', row.p50FrameMs, prev?.p50FrameMs));
321
+ out.push(deltaLine('frame p95', row.p95FrameMs, prev?.p95FrameMs));
322
+ out.push(deltaLine('frame max', row.maxFrameMs, prev?.maxFrameMs));
323
+ out.push(deltaLine('pan p95', row.panP95Ms, prev?.panP95Ms));
324
+ out.push(deltaLine('zoom p95', row.zoomP95Ms, prev?.zoomP95Ms));
325
+ out.push(deltaLine('settle max', row.settleMaxMs, prev?.settleMaxMs));
326
+ out.push(deltaLine('long tasks', row.longtasks, prev?.longtasks, ''));
327
+ out.push(deltaLine('artboard renders', row.artboardRenders, prev?.artboardRenders, ''));
328
+ out.push(deltaLine('annotation renders', row.annotationRenders, prev?.annotationRenders, ''));
329
+ if (!row.instrumented) {
330
+ out.push(
331
+ '\n note: render counters are not instrumented in this build — frame timings are still valid.'
332
+ );
333
+ }
334
+ if (row.p95SpreadMs != null) {
335
+ out.push(
336
+ `\n p95 spread across ${row.passes} kept passes: ${row.p95SpreadMs}ms ` +
337
+ '— treat any delta smaller than this as noise.'
338
+ );
339
+ }
340
+ if (opts.history) out.push(`\n history: ${opts.history}`);
341
+ out.push(
342
+ ` ${prev ? 'compared against the previous run' : 'first run — this IS the baseline'}\n`
343
+ );
344
+ return out.join('\n');
345
+ }
@@ -25,7 +25,7 @@
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { launchChromium } from './_pw-launch.mjs';
28
+ import { assertRenderOutputSizeOk, launchChromium } from './_pw-launch.mjs';
29
29
 
30
30
  const args = Object.fromEntries(
31
31
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -47,6 +47,7 @@ const {
47
47
  out, // encoded file destination
48
48
  mode = 'comp', // 'comp' | 'ordinary'
49
49
  audio, // '1' | '0' — include audio in the render-lib path (default on; presence-checked below)
50
+ 'frame-format': frameFormatArg, // 'jpeg' | 'png' (default) — the frame-step screenshot intermediate, Task 9
50
51
  'license-key': licenseKeyArg,
51
52
  timeout = '60',
52
53
  scale = '1',
@@ -67,11 +68,32 @@ const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
67
68
  // always passes an explicit '0'/'1'.
68
69
  const wantAudio = audio !== '0';
69
70
  const licenseKey = licenseKeyArg || 'free-license';
71
+ // Task 9 — opt-in JPEG frame-step intermediate; unrecognized/absent value is PNG.
72
+ const frameFormat = frameFormatArg === 'jpeg' ? 'jpeg' : 'png';
70
73
 
71
74
  /** Wait a real turn of the event loop + a frame so a seek settles before shot. */
72
75
  const SETTLE_MS = 16;
73
76
 
74
- const browser = await launchChromium();
77
+ // GPU opt-in, measured not assumed (2026-08-09, M-series mac, 1920x1080):
78
+ //
79
+ // config h264 prefer-hardware png jpeg q90
80
+ // headless-shell (default) FALSE 51ms/187KB 33ms/43KB
81
+ // headless-shell --enable-gpu true 34ms/84KB 33ms/43KB
82
+ // full chromium / real Chrome true 34-49ms 17-33ms
83
+ //
84
+ // So today's engine is the ONE configuration that cannot reach a hardware
85
+ // encoder at all — `chrome-headless-shell` forces ANGLE/SwiftShader, and
86
+ // Chromium then reports ACCELERATED_VIDEO_ENCODE as disabled. `--enable-gpu`
87
+ // flips that.
88
+ //
89
+ // It is NOT on by default, deliberately: with the GPU the same page rasterizes
90
+ // to a materially different PNG (84KB vs 187KB), i.e. it is a VISUAL change to
91
+ // every export, and encode is not the bottleneck anyway (~50ms of a ~1050ms
92
+ // frame — see the MAUDE_TIMING line). Changing how a design tool renders is not
93
+ // something to smuggle in behind a perf flag. Opt in with MAUDE_CAPTURE_GPU=1.
94
+ const gpuArgs =
95
+ process.env.MAUDE_CAPTURE_GPU === '1' ? ['--enable-gpu', '--ignore-gpu-blocklist'] : [];
96
+ const browser = await launchChromium(gpuArgs.length ? { args: gpuArgs } : undefined);
75
97
  try {
76
98
  const ctx = await browser.newContext({
77
99
  viewport: { width: 1440, height: 900 },
@@ -199,6 +221,19 @@ try {
199
221
  audioCodec: rendered.audioCodec,
200
222
  };
201
223
  const ms = Date.now() - t0;
224
+ // Same shape as the frame-step line, so the two paths are directly
225
+ // comparable — that comparison is the whole speed story (~45 s vs ~37 min
226
+ // on the same canvas, per RCA issue-mp4-audio-export-html5audio-silent-degrade).
227
+ console.log(
228
+ `MAUDE_TIMING ${JSON.stringify({
229
+ path: 'renderer',
230
+ frames: frameCount,
231
+ totalMs: ms,
232
+ msPerFrame: Math.round((ms / Math.max(1, frameCount)) * 10) / 10,
233
+ scale: deviceScaleFactor,
234
+ audioCodec: rendered.audioCodec,
235
+ })}`
236
+ );
202
237
  console.error(
203
238
  `✓ rendered ${rendered.container}/${rendered.videoCodec}` +
204
239
  `${rendered.audioCodec ? `+${rendered.audioCodec}` : ' (muted)'} → ${out} (${rendered.bytes} B) in ${ms}ms`
@@ -235,6 +270,7 @@ try {
235
270
  mode,
236
271
  deviceScaleFactor,
237
272
  timeoutMs,
273
+ frameFormat,
238
274
  fallbackReason: renderFallbackReason,
239
275
  });
240
276
  } catch (fallbackErr) {
@@ -276,6 +312,10 @@ async function frameStepCapture({
276
312
  mode,
277
313
  deviceScaleFactor,
278
314
  timeoutMs,
315
+ // Task 9 — 'jpeg' | 'png' (default). Only ever affects the encode transport,
316
+ // never a --dump-frames debug dump (that always stays lossless PNG) and
317
+ // never gif (video.ts refuses to pass 'jpeg' through for gif).
318
+ frameFormat = 'png',
279
319
  // Set (to the renderer's error message) only when this run is the
280
320
  // graceful-degradation fallback for a failed renderMediaOnWeb path; stamps
281
321
  // `degraded`/`audioDropped` onto the stdout summary so video.ts can warn.
@@ -368,6 +408,11 @@ async function frameStepCapture({
368
408
  // frame is drawn down to the native artboard size (the "tiny resolution" bug:
369
409
  // scale was accepted but never applied to the encode). scale=2 → 960×540 comp
370
410
  // exports at 1920×1080. Even dims (H.264 requires width/height divisible by 2).
411
+ // The video path is the ONE capture path that holds a full-resolution surface
412
+ // across thousands of frames, and it was the only one with no output-size
413
+ // guard — _pdf-playwright.mjs has enforced this since it existed. A scale-3
414
+ // 1080p comp allocates ~74MB per frame here.
415
+ assertRenderOutputSizeOk(clip.width, clip.height, deviceScaleFactor, '_video-playwright');
371
416
  const outW = Math.max(2, Math.round((clip.width * deviceScaleFactor) / 2) * 2);
372
417
  const outH = Math.max(2, Math.round((clip.height * deviceScaleFactor) / 2) * 2);
373
418
 
@@ -398,24 +443,45 @@ async function frameStepCapture({
398
443
  console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
399
444
  }
400
445
 
446
+ // Per-stage accumulators. "The export takes 15 minutes" was, for a long time,
447
+ // answerable only by guessing which stage owned the second — so every
448
+ // optimization was aimed at a suspicion. One line at the end fixes that.
449
+ // Task 9 — a debug dump must stay lossless (it's for human inspection), and
450
+ // gif never gets a lossy intermediate stacked under its own quantization —
451
+ // so the knob only actually applies to a plain video encode.
452
+ const useJpeg = frameFormat === 'jpeg' && !isGif && !dump;
453
+ const shotFormat = useJpeg ? 'jpeg' : 'png';
454
+ const stageMs = { seek: 0, settle: 0, screenshot: 0, encode: 0 };
401
455
  for (let f = 0; f < frameCount; f += 1) {
456
+ const tSeek = Date.now();
402
457
  await seekFrame(page, f, fps, mode);
458
+ stageMs.seek += Date.now() - tSeek;
459
+ const tSettle = Date.now();
403
460
  await page.waitForTimeout(SETTLE_MS);
404
- const shot = await page.screenshot({ clip });
461
+ stageMs.settle += Date.now() - tSettle;
462
+ const tShot = Date.now();
463
+ const shot = await page.screenshot(useJpeg ? { clip, type: 'jpeg', quality: 90 } : { clip });
464
+ stageMs.screenshot += Date.now() - tShot;
405
465
  if (dump) {
406
466
  const p = join(dump, `frame-${String(f).padStart(5, '0')}.png`);
407
467
  writeFileSync(p, shot);
408
468
  framePaths.push(p);
409
469
  }
410
470
  if (encoding) {
471
+ const tEnc = Date.now();
411
472
  const b64 = shot.toString('base64');
412
473
  await page.evaluate(
413
- async ({ b64, isGif }) => {
474
+ async ({ b64, isGif, format }) => {
414
475
  if (isGif) return window.__maudeEnc.addGifFrame(b64);
415
- return window.__maudeEnc.addVideoFrame(b64);
476
+ return window.__maudeEnc.addVideoFrame(b64, format);
416
477
  },
417
- { b64, isGif }
478
+ { b64, isGif, format: shotFormat }
418
479
  );
480
+ // NB: this stage is the double CDP crossing — the screenshot bytes go out
481
+ // to node as base64 and straight back into the same page, to an encoder
482
+ // that lives in that page. Its share of the total is what decides whether
483
+ // transport work is worth doing at all.
484
+ stageMs.encode += Date.now() - tEnc;
419
485
  }
420
486
  // Machine-readable progress (stdout) — spawnShim parses `MAUDE_PROGRESS`
421
487
  // lines → onProgress → job.progress → the live bar in the Exports panel, so
@@ -426,6 +492,18 @@ async function frameStepCapture({
426
492
  if (f % 30 === 0) console.error(`frame ${f + 1}/${frameCount}`);
427
493
  }
428
494
 
495
+ // A seek that never landed means at least one encoded frame shows the wrong
496
+ // content. There is no way to tell WHICH from the outside and no way for the
497
+ // user to notice, so the export dies here rather than shipping a file that
498
+ // looks fine. Counted in-page by the seek bridge (video-comp.tsx).
499
+ const seekFailures = await page.evaluate(() => window.__maude_seek_failures__ ?? 0);
500
+ if (seekFailures > 0) {
501
+ throw new Error(
502
+ `${seekFailures} frame seek(s) never landed — the capture would contain stale ` +
503
+ 'frames. Refusing to encode. This usually means the comp failed to mount.'
504
+ );
505
+ }
506
+
429
507
  let result = { fps, frameCount, width: outW, height: outH, framePaths };
430
508
  // Degradation marker — this run only encodes video (no audio), so when we're
431
509
  // standing in for a failed audio renderer, tell the exporter the export is
@@ -448,6 +526,19 @@ async function frameStepCapture({
448
526
  console.error(
449
527
  `✓ captured ${frameCount} frames @ ${fps}fps (${clip.width}×${clip.height}) in ${ms}ms`
450
528
  );
529
+ // Machine-readable, filtered out of stdoutLines by _runtime.ts (the adapters
530
+ // parse the LAST stdout line as their summary, so this must never be it).
531
+ console.log(
532
+ `MAUDE_TIMING ${JSON.stringify({
533
+ path: 'frame-step',
534
+ frames: frameCount,
535
+ totalMs: ms,
536
+ msPerFrame: Math.round((ms / Math.max(1, frameCount)) * 10) / 10,
537
+ stageMs,
538
+ scale: deviceScaleFactor,
539
+ out: { width: outW, height: outH },
540
+ })}`
541
+ );
451
542
  // stdout = machine-readable summary for the exporter.
452
543
  console.log(JSON.stringify(result));
453
544
  }
@@ -486,6 +577,10 @@ async function seekFrame(page, frame, fps, mode) {
486
577
  return;
487
578
  }
488
579
  // comp mode — the seek bridge pauses + seeks the Player and resolves post-paint.
580
+ // It now REJECTS when a seek never lands (video-comp.tsx), instead of resolving
581
+ // as if it had; that rejection propagates out of this evaluate and fails the
582
+ // export, which is the point — a stale frame is a valid-looking file with the
583
+ // wrong pixels, and no downstream check would ever catch it.
489
584
  // A comp with classic remotion <Video>/<OffthreadVideo> renders a real <video>
490
585
  // whose seek is ASYNC — 2 rAF isn't enough, so wait for every video to land on
491
586
  // its frame (`seeked` / readyState) before the screenshot, else a stale frame
@@ -494,7 +589,8 @@ async function seekFrame(page, frame, fps, mode) {
494
589
  // on (verified across mid-clip + mid-transition frames — DDR-148 addendum).
495
590
  await page.evaluate(async (frame) => {
496
591
  if (typeof window.__maude_seek__ === 'function') {
497
- await window.__maude_seek__(frame);
592
+ // strict: a capture must never proceed on a seek that did not land.
593
+ await window.__maude_seek__(frame, { strict: true });
498
594
  }
499
595
  const vids = Array.from(document.querySelectorAll('video'));
500
596
  if (vids.length) {
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # import-figma.sh — Figma / FigJam import (REST door). Thin shim over
3
+ # _import-figma.mjs; reached via `maude design import-figma` (DDR-062), never a
4
+ # raw bin path. See _import-figma.mjs + DDR-216 for the full architecture +
5
+ # security rationale. Requires bun — _import-figma.mjs imports the `.ts`
6
+ # annotation model and figma modules directly, same constraint as
7
+ # import-tokens.sh.
8
+ #
9
+ # All args are forwarded quoted ("$@") — never unquoted or eval-expanded. This
10
+ # matters more than usual here: a Figma URL is caller-supplied and a node id
11
+ # rides in its query string. The charset/shape validation itself happens in
12
+ # figma/url.ts, not in this shell (DDR-216 D4); the wrapper's ONLY job is safe
13
+ # argv passthrough.
14
+ #
15
+ # Usage:
16
+ # import-figma.sh --board <figjam-url> --root <repo> [--design-root .design]
17
+ # [--slug <name>] [--dry-run] [--json]
18
+ # import-figma.sh --pages <figma-url> --root <repo> [--folder <name>] [--editable]
19
+ # import-figma.sh --frames <figma-url> --root <repo> (Phase 3)
20
+ # import-figma.sh --tokens <figma-url> --root <repo> (Phase 4)
21
+ # import-figma.sh --explode <artboard-id> --canvas <rel-path> --root <repo>
22
+ # [--confirm-document] [--dry-run] [--json] (Phase 7)
23
+ #
24
+ # Needs a Figma personal access token with the `file_content:read` scope, added
25
+ # once in Settings.
26
+ #
27
+ # `--explode` needs NO token — it reads the Figma DESKTOP app's local Dev Mode
28
+ # MCP server over loopback (DDR-219 D2) rather than the REST API. It does need
29
+ # that app running, in Dev Mode, with the same file as the active tab, on a Dev
30
+ # or Full seat. It is not an import route: the artboard must already exist on an
31
+ # imported canvas.
32
+ #
33
+ # Exit: 0 ok · 1 other · 2 usage · 3 validation reject · 4 fetch/parse error ·
34
+ # 5 no token configured · 6 write/containment error.
35
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
36
+
37
+ case "$1" in
38
+ --help|-h) sed -n '2,34p' "$0" | sed 's/^# \?//'; exit 0 ;;
39
+ esac
40
+
41
+ if ! command -v bun >/dev/null 2>&1; then
42
+ echo "import-figma.sh: bun is required (hard dependency — see plugins/design/dependencies.json)." >&2
43
+ echo " Install: curl -fsSL https://bun.sh/install | bash" >&2
44
+ exit 1
45
+ fi
46
+
47
+ exec bun run "$SCRIPT_DIR/_import-figma.mjs" "$@"