@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,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
+ }
@@ -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,6 +68,8 @@ 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;
@@ -267,6 +270,7 @@ try {
267
270
  mode,
268
271
  deviceScaleFactor,
269
272
  timeoutMs,
273
+ frameFormat,
270
274
  fallbackReason: renderFallbackReason,
271
275
  });
272
276
  } catch (fallbackErr) {
@@ -308,6 +312,10 @@ async function frameStepCapture({
308
312
  mode,
309
313
  deviceScaleFactor,
310
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',
311
319
  // Set (to the renderer's error message) only when this run is the
312
320
  // graceful-degradation fallback for a failed renderMediaOnWeb path; stamps
313
321
  // `degraded`/`audioDropped` onto the stdout summary so video.ts can warn.
@@ -438,6 +446,11 @@ async function frameStepCapture({
438
446
  // Per-stage accumulators. "The export takes 15 minutes" was, for a long time,
439
447
  // answerable only by guessing which stage owned the second — so every
440
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';
441
454
  const stageMs = { seek: 0, settle: 0, screenshot: 0, encode: 0 };
442
455
  for (let f = 0; f < frameCount; f += 1) {
443
456
  const tSeek = Date.now();
@@ -447,7 +460,7 @@ async function frameStepCapture({
447
460
  await page.waitForTimeout(SETTLE_MS);
448
461
  stageMs.settle += Date.now() - tSettle;
449
462
  const tShot = Date.now();
450
- const shot = await page.screenshot({ clip });
463
+ const shot = await page.screenshot(useJpeg ? { clip, type: 'jpeg', quality: 90 } : { clip });
451
464
  stageMs.screenshot += Date.now() - tShot;
452
465
  if (dump) {
453
466
  const p = join(dump, `frame-${String(f).padStart(5, '0')}.png`);
@@ -458,11 +471,11 @@ async function frameStepCapture({
458
471
  const tEnc = Date.now();
459
472
  const b64 = shot.toString('base64');
460
473
  await page.evaluate(
461
- async ({ b64, isGif }) => {
474
+ async ({ b64, isGif, format }) => {
462
475
  if (isGif) return window.__maudeEnc.addGifFrame(b64);
463
- return window.__maudeEnc.addVideoFrame(b64);
476
+ return window.__maudeEnc.addVideoFrame(b64, format);
464
477
  },
465
- { b64, isGif }
478
+ { b64, isGif, format: shotFormat }
466
479
  );
467
480
  // NB: this stage is the double CDP crossing — the screenshot bytes go out
468
481
  // to node as base64 and straight back into the same page, to an encoder
@@ -15,18 +15,27 @@
15
15
  # Usage:
16
16
  # import-figma.sh --board <figjam-url> --root <repo> [--design-root .design]
17
17
  # [--slug <name>] [--dry-run] [--json]
18
+ # import-figma.sh --pages <figma-url> --root <repo> [--folder <name>] [--editable]
18
19
  # import-figma.sh --frames <figma-url> --root <repo> (Phase 3)
19
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)
20
23
  #
21
24
  # Needs a Figma personal access token with the `file_content:read` scope, added
22
25
  # once in Settings.
23
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
+ #
24
33
  # Exit: 0 ok · 1 other · 2 usage · 3 validation reject · 4 fetch/parse error ·
25
34
  # 5 no token configured · 6 write/containment error.
26
35
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
27
36
 
28
37
  case "$1" in
29
- --help|-h) sed -n '2,25p' "$0" | sed 's/^# \?//'; exit 0 ;;
38
+ --help|-h) sed -n '2,34p' "$0" | sed 's/^# \?//'; exit 0 ;;
30
39
  esac
31
40
 
32
41
  if ! command -v bun >/dev/null 2>&1; then
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env bash
2
+ # perf.sh — render-performance benchmark for a canvas (`maude design perf`).
3
+ #
4
+ # Drives a scripted pan + zoom against a live canvas and reports frame-time
5
+ # percentiles, long-task count and the React render count during the gesture,
6
+ # with a DELTA against the previous run of the same (canvas, engine) pair. The
7
+ # history file makes "before and after" a property of the tool rather than
8
+ # something the operator has to remember to write down.
9
+ #
10
+ # Deliberately NOT a CI gate. Headless frame timings swing with GPU load and
11
+ # machine state, so an absolute threshold would go red for reasons unrelated to
12
+ # the code and train everyone to ignore it. The gate is the delta, read by a
13
+ # human, on one machine. (Plan: .ai/plans/feature-canvas-render-performance.md)
14
+ #
15
+ # Usage:
16
+ # perf.sh [--root <repo>] [--canvas <rel-path>] [--fixture]
17
+ # [--boards N] [--strokes N]
18
+ # [--pan N] [--zoom N] [--timeout S] [--repeat N]
19
+ # [--engine chromium|safari]
20
+ # [--history <path>] [--json]
21
+ #
22
+ # --engine safari measures WebKit through safaridriver (one-time
23
+ # `safaridriver --enable`, needs admin auth). Use it for anything the Tauri
24
+ # desktop shell has to be fast at: the desktop is WKWebView, and Chromium does
25
+ # NOT reproduce WebKit's compositing / re-raster behaviour — a canvas can sit
26
+ # at a warm 60fps in headless Chromium while being unusable in the .app.
27
+ #
28
+ # --canvas Canvas to measure, relative to <designRoot> (default: _active.json).
29
+ # --fixture Generate + measure the synthetic fixture instead (--boards/--strokes).
30
+ # --json Raw JSON (current + previous run) instead of the human report.
31
+ #
32
+ # Reads: $DESIGN_ROOT/_server.json (must exist — run `maude design server-up` first)
33
+ # Writes: $DESIGN_ROOT/_smoke/perf/history.jsonl (append-only, per-machine runtime state)
34
+ # $DESIGN_ROOT/ui/perf-fixture.tsx (only with --fixture)
35
+ #
36
+ # Exit: 0 measured / 1 missing dep or capture failure / 2 bad args.
37
+
38
+ REPO=""
39
+ CANVAS=""
40
+ FIXTURE=0
41
+ BOARDS=128
42
+ STROKES=150
43
+ PAN=60
44
+ ZOOM=40
45
+ REPEAT=3
46
+ PROBE_ENGINE="chromium"
47
+ FIT_ALL=0
48
+ STUDIO=0
49
+ TIMEOUT=30
50
+ HISTORY=""
51
+ JSON=0
52
+
53
+ while [ $# -gt 0 ]; do
54
+ case "$1" in
55
+ --root) REPO="$2"; shift 2 ;;
56
+ --canvas) CANVAS="$2"; shift 2 ;;
57
+ --fixture) FIXTURE=1; shift ;;
58
+ --boards) BOARDS="$2"; shift 2 ;;
59
+ --strokes) STROKES="$2"; shift 2 ;;
60
+ --pan) PAN="$2"; shift 2 ;;
61
+ --zoom) ZOOM="$2"; shift 2 ;;
62
+ --timeout) TIMEOUT="$2"; shift 2 ;;
63
+ --repeat) REPEAT="$2"; shift 2 ;;
64
+ --engine) PROBE_ENGINE="$2"; shift 2 ;;
65
+ --fit-all) FIT_ALL=1; shift ;;
66
+ --studio) STUDIO=1; shift ;;
67
+ --history) HISTORY="$2"; shift 2 ;;
68
+ --json) JSON=1; shift ;;
69
+ --help|-h)
70
+ sed -n '2,30p' "$0" | sed 's/^# \?//'
71
+ exit 0
72
+ ;;
73
+ *)
74
+ echo "perf.sh: unknown arg '$1' (try --help)" >&2
75
+ exit 2
76
+ ;;
77
+ esac
78
+ done
79
+
80
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
81
+
82
+ # JS runtime. The packaged desktop app ships no user `node` (DDR-177) but always
83
+ # has a `bun` on PATH (real, or the compiled-sidecar shim `maude` stages), so
84
+ # prefer bun and keep node as the developer-machine fallback.
85
+ if command -v bun >/dev/null 2>&1; then JS_RUNTIME="bun"; else JS_RUNTIME="node"; fi
86
+
87
+ # ---------- resolve repo + design root (mirrors smoke.sh) ----------
88
+ if [ -z "$REPO" ]; then
89
+ REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
90
+ fi
91
+ DESIGN_ROOT="$REPO/.design"
92
+ [ -d "$DESIGN_ROOT" ] || { echo "perf.sh: no .design/ under $REPO" >&2; exit 1; }
93
+
94
+ STATE="$DESIGN_ROOT/_server.json"
95
+ [ -f "$STATE" ] || { echo "perf.sh: $STATE missing — run 'maude design server-up' first" >&2; exit 1; }
96
+
97
+ if command -v jq >/dev/null 2>&1; then
98
+ PORT=$(jq -r .port "$STATE" 2>/dev/null)
99
+ else
100
+ PORT=$(sed -nE 's/.*"port"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p' "$STATE" | head -n1)
101
+ fi
102
+ [ -n "$PORT" ] || { echo "perf.sh: no port in $STATE" >&2; exit 1; }
103
+
104
+ # ---------- fixture generation ----------
105
+ if [ "$FIXTURE" = "1" ]; then
106
+ # Counts are validated as plain integers and paths travel via argv, never
107
+ # interpolated into the script body: `--boards "1); <js>; ("` or a design root
108
+ # containing a quote would otherwise execute inside this node process, which
109
+ # runs with the user's full privileges. `maude design perf` is reachable from
110
+ # an agent session, so its arguments are not automatically trustworthy.
111
+ case "$BOARDS" in ''|*[!0-9]*) echo "perf.sh: --boards must be a positive integer" >&2; exit 2 ;; esac
112
+ case "$STROKES" in ''|*[!0-9]*) echo "perf.sh: --strokes must be a non-negative integer" >&2; exit 2 ;; esac
113
+ # Upper bound too: the guards above accept any magnitude, and `--boards 99999999`
114
+ # is a cheap way to make an auto-approved verb fill a disk.
115
+ [ "$BOARDS" -le 2000 ] || { echo "perf.sh: --boards must be <= 2000" >&2; exit 2; }
116
+ [ "$STROKES" -le 5000 ] || { echo "perf.sh: --strokes must be <= 5000" >&2; exit 2; }
117
+ "$JS_RUNTIME" -e '
118
+ const [modPath, designRoot, boards, strokes] = process.argv.slice(1);
119
+ import(modPath).then((m) => {
120
+ const r = m.writePerfCanvas({
121
+ designRoot,
122
+ boards: Number(boards),
123
+ strokes: Number(strokes),
124
+ });
125
+ console.error("→ fixture written: " + r.canvasPath);
126
+ }).catch((e) => {
127
+ console.error("perf.sh: fixture generation failed: " + e.message);
128
+ process.exit(1);
129
+ });
130
+ ' "$SCRIPT_DIR/../test/fixtures/perf-canvas.mjs" "$DESIGN_ROOT" "$BOARDS" "$STROKES" || exit 1
131
+ CANVAS="ui/perf-fixture.tsx"
132
+ fi
133
+
134
+ # ---------- resolve canvas ----------
135
+ if [ -z "$CANVAS" ]; then
136
+ ACTIVE_FILE="$DESIGN_ROOT/_active.json"
137
+ if [ -f "$ACTIVE_FILE" ] && command -v jq >/dev/null 2>&1; then
138
+ CANVAS=$(jq -r '.active // empty' "$ACTIVE_FILE" 2>/dev/null)
139
+ fi
140
+ fi
141
+ [ -n "$CANVAS" ] || { echo "perf.sh: no --canvas and no active canvas in _active.json" >&2; exit 2; }
142
+
143
+ # `_active.json` stores the path relative to the REPO (".design/ui/x.tsx"); the
144
+ # canvas-shell query param wants it the same way, so normalize either input
145
+ # shape to the repo-relative form rather than guessing at call sites.
146
+ DESIGN_REL="${DESIGN_ROOT#"$REPO"/}"
147
+ case "$CANVAS" in
148
+ "$DESIGN_REL"/*) REL_FULL="$CANVAS" ;;
149
+ *) REL_FULL="$DESIGN_REL/$CANVAS" ;;
150
+ esac
151
+ # Containment: an existence check alone would accept `--canvas ../../../x.tsx`
152
+ # and leave the boundary entirely to the dev-server route. A benchmark has no
153
+ # business probing that gate.
154
+ case "$REL_FULL" in
155
+ *..*) echo "perf.sh: --canvas must not contain '..'" >&2; exit 2 ;;
156
+ esac
157
+ [ -f "$REPO/$REL_FULL" ] || { echo "perf.sh: canvas not found: $REPO/$REL_FULL" >&2; exit 2; }
158
+
159
+ # Percent-encode the whole value, not just spaces: a canvas named with `#`, `&`
160
+ # or `%` would otherwise truncate or rewrite the query string.
161
+ REL_ENC=$("$JS_RUNTIME" -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$REL_FULL")
162
+ URL="http://localhost:${PORT}/_canvas-shell.html?canvas=${REL_ENC}"
163
+
164
+ # ---------- history path ----------
165
+ # `_smoke/` is already IGNORED runtime state (DDR-115), so the benchmark log
166
+ # inherits the right gitignore posture without touching any of the three lists.
167
+ if [ -z "$HISTORY" ]; then
168
+ HISTORY="$DESIGN_ROOT/_smoke/perf/history.jsonl"
169
+ fi
170
+ # Containment: `perf` is auto-approved in ACP sessions (`Bash(maude:*)`), so an
171
+ # unconstrained --history would be an arbitrary mkdir + append outside every
172
+ # write-scope gate. The default already lives under the design root; require it.
173
+ HISTORY_ABS=$("$JS_RUNTIME" -e 'const p=require("path");process.stdout.write(p.resolve(process.argv[1]))' "$HISTORY")
174
+ DESIGN_ABS=$("$JS_RUNTIME" -e 'const p=require("path");process.stdout.write(p.resolve(process.argv[1]))' "$DESIGN_ROOT")
175
+ case "$HISTORY_ABS" in
176
+ "$DESIGN_ABS"/*) ;;
177
+ *) echo "perf.sh: --history must resolve under $DESIGN_ABS (got $HISTORY_ABS)" >&2; exit 2 ;;
178
+ esac
179
+ HISTORY="$HISTORY_ABS"
180
+ mkdir -p "$(dirname "$HISTORY")"
181
+
182
+ # ---------- engine tag ----------
183
+ # The tag is part of the history KEY, not decoration: a WKWebView number and a
184
+ # headless-Chromium number are not comparable, and silently averaging them would
185
+ # make every delta meaningless.
186
+ ENGINE_TAG="${MAUDE_PERF_ENGINE_TAG:-}"
187
+ if [ -z "$ENGINE_TAG" ]; then
188
+ if [ -n "$MAUDE_DESKTOP" ]; then ENGINE_TAG="webkit-desktop"; else ENGINE_TAG="blink-headless"; fi
189
+ fi
190
+
191
+ if [ "$PROBE_ENGINE" = "safari" ]; then
192
+ command -v safaridriver >/dev/null 2>&1 || {
193
+ echo "perf.sh: safaridriver not found (macOS only)" >&2
194
+ exit 1
195
+ }
196
+ PROBE_URL="$URL"
197
+ if [ "$STUDIO" = "1" ]; then
198
+ # Studio mode drives the real app shell (root URL + a file-tree click),
199
+ # not the bare canvas page — see _perf-probe-safari.mjs for why that
200
+ # difference changes the numbers.
201
+ PROBE_URL="http://localhost:${PORT}/"
202
+ SLUG=$(printf '%s' "${REL_FULL#"$DESIGN_REL"/}" \
203
+ | sed -E 's/\.[A-Za-z]+$//' | tr '[:upper:]' '[:lower:]' \
204
+ | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')
205
+ fi
206
+ echo "→ perf: $REL_FULL | port: $PORT | engine: webkit-safari${STUDIO:+ | studio}" >&2
207
+ ARGS=(--url "$PROBE_URL" --label "$REL_FULL$([ "$STUDIO" = "1" ] && echo " (studio)")" --history "$HISTORY"
208
+ --timeout "$TIMEOUT" --pan "$PAN" --zoom "$ZOOM" --repeat "$REPEAT")
209
+ [ "$STUDIO" = "1" ] && ARGS+=(--studio "$SLUG")
210
+ [ "$FIT_ALL" = "1" ] && ARGS+=(--fit-all)
211
+ [ "$JSON" = "1" ] && ARGS+=(--json)
212
+ exec "$JS_RUNTIME" "$SCRIPT_DIR/_perf-probe-safari.mjs" "${ARGS[@]}"
213
+ fi
214
+
215
+ AB="${MAUDE_AGENT_BROWSER:-agent-browser}"
216
+ command -v "$AB" >/dev/null 2>&1 || {
217
+ echo "perf.sh: agent-browser not on PATH (required — the probe needs scripted gestures + rAF timing)" >&2
218
+ exit 1
219
+ }
220
+
221
+ echo "→ perf: $REL_FULL | port: $PORT | engine: $ENGINE_TAG" >&2
222
+
223
+ ARGS=(--url "$URL" --label "$REL_FULL" --history "$HISTORY" --engine-tag "$ENGINE_TAG"
224
+ --timeout "$TIMEOUT" --pan "$PAN" --zoom "$ZOOM" --repeat "$REPEAT")
225
+ [ "$FIT_ALL" = "1" ] && ARGS+=(--fit-all)
226
+ [ "$JSON" = "1" ] && ARGS+=(--json)
227
+
228
+ exec "$JS_RUNTIME" "$SCRIPT_DIR/_perf-probe.mjs" "${ARGS[@]}"