@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" "$@"
|