@1agh/maude 1.0.7 → 1.0.9

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 (34) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +23 -6
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +45 -12
  7. package/apps/studio/bin/_video-playwright.mjs +135 -12
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +7 -0
  10. package/apps/studio/client/app.jsx +84 -23
  11. package/apps/studio/client/export-lane.js +37 -2
  12. package/apps/studio/dist/client.bundle.js +601 -601
  13. package/apps/studio/export-dialog.tsx +24 -19
  14. package/apps/studio/exporters/_runtime.ts +31 -1
  15. package/apps/studio/exporters/capture-chrome.ts +111 -0
  16. package/apps/studio/exporters/capture-core.ts +659 -3
  17. package/apps/studio/exporters/format-scopes.ts +88 -0
  18. package/apps/studio/exporters/html.ts +3 -2
  19. package/apps/studio/exporters/jobs.ts +190 -18
  20. package/apps/studio/exporters/pdf.ts +21 -1
  21. package/apps/studio/exporters/remote.ts +73 -4
  22. package/apps/studio/exporters/scope.ts +14 -3
  23. package/apps/studio/exporters/video.ts +75 -8
  24. package/apps/studio/http.ts +74 -7
  25. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  26. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  27. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  28. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  29. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  30. package/apps/studio/test/exporters/scope.test.ts +33 -0
  31. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  32. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  33. package/apps/studio/whats-new.json +9 -0
  34. package/package.json +8 -8
@@ -25,7 +25,7 @@
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { assertRenderOutputSizeOk, launchChromium } from './_pw-launch.mjs';
28
+ import { addScriptCspSafe, 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) => {
@@ -63,6 +63,24 @@ if (!url) {
63
63
 
64
64
  const timeoutMs = Number(timeout) * 1000;
65
65
  const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
66
+
67
+ // Hard self-kill — the whole shim, not just individual Playwright calls, has a
68
+ // wall-clock ceiling. `launchChromium` has NO timeout, and on headless Linux
69
+ // `--enable-gpu` can hang Chromium at GPU-process init before a single log line
70
+ // — a silent wedge that ate the full 600s render budget with nothing to show.
71
+ // This guarantees the process EXITS with a diagnostic instead of hanging, so
72
+ // the render worker (and the export gate) fail fast and legibly. Generous
73
+ // enough for a slow software encode; well under any job/test budget.
74
+ const HARD_KILL_MS = Math.max(timeoutMs, 150_000);
75
+ const hardKill = setTimeout(() => {
76
+ console.error(
77
+ `_video-playwright: HARD TIMEOUT after ${HARD_KILL_MS}ms — exiting. The render did not ` +
78
+ `complete (see the last stage logged above; on headless Linux this is usually the video ` +
79
+ `encoder or GPU init). gpu=${process.env.MAUDE_CAPTURE_GPU === '1' ? 'on' : 'off'}.`
80
+ );
81
+ process.exit(7);
82
+ }, HARD_KILL_MS);
83
+ hardKill.unref?.();
66
84
  // `--audio` is presence-checked (Object.fromEntries maps a bare flag to '1'), so
67
85
  // absence of the flag entirely (not passed) still defaults to audio ON — video.ts
68
86
  // always passes an explicit '0'/'1'.
@@ -74,6 +92,35 @@ const frameFormat = frameFormatArg === 'jpeg' ? 'jpeg' : 'png';
74
92
  /** Wait a real turn of the event loop + a frame so a seek settles before shot. */
75
93
  const SETTLE_MS = 16;
76
94
 
95
+ /**
96
+ * Run a `page.evaluate` with a hard wall-clock cap. A `page.evaluate` awaits its
97
+ * in-page promise with NO Playwright timeout, so a WebCodecs encoder that never
98
+ * resolves (headless Linux without a working encoder) hangs the whole render
99
+ * indefinitely. This races the evaluate against a timer and throws a NAMED
100
+ * error on overrun, so the shim exits (fail-fast + diagnostic) instead of
101
+ * wedging the render worker until the job timeout.
102
+ */
103
+ async function evalWithTimeout(page, fn, arg, ms, stage) {
104
+ let timer;
105
+ const timeout = new Promise((_, reject) => {
106
+ timer = setTimeout(
107
+ () =>
108
+ reject(
109
+ new Error(
110
+ `encode stage "${stage}" did not resolve within ${ms}ms — headless WebCodecs ` +
111
+ `likely has no working video encoder (see the "webcodecs caps" line above).`
112
+ )
113
+ ),
114
+ ms
115
+ );
116
+ });
117
+ try {
118
+ return await Promise.race([page.evaluate(fn, arg), timeout]);
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
77
124
  // GPU opt-in, measured not assumed (2026-08-09, M-series mac, 1920x1080):
78
125
  //
79
126
  // config h264 prefer-hardware png jpeg q90
@@ -93,15 +140,58 @@ const SETTLE_MS = 16;
93
140
  // something to smuggle in behind a perf flag. Opt in with MAUDE_CAPTURE_GPU=1.
94
141
  const gpuArgs =
95
142
  process.env.MAUDE_CAPTURE_GPU === '1' ? ['--enable-gpu', '--ignore-gpu-blocklist'] : [];
143
+ console.error(
144
+ `_video-playwright: launching chromium (gpu=${gpuArgs.length ? 'on' : 'off'}, format=${format})`
145
+ );
96
146
  const browser = await launchChromium(gpuArgs.length ? { args: gpuArgs } : undefined);
147
+ console.error('_video-playwright: browser launched');
97
148
  try {
98
149
  const ctx = await browser.newContext({
150
+ serviceWorkers: 'block',
99
151
  viewport: { width: 1440, height: 900 },
100
152
  deviceScaleFactor,
101
153
  });
102
154
  const page = await ctx.newPage();
103
155
  await page.goto(url, { waitUntil: 'load', timeout: timeoutMs });
104
156
  await page.evaluate(() => document.fonts.ready);
157
+ console.error('_video-playwright: page loaded');
158
+
159
+ // UNCONDITIONAL WebCodecs capability probe — logged for every video render
160
+ // regardless of which encode path runs, so the render worker's log always
161
+ // names what this headless box can actually encode. This is THE fact that
162
+ // differs between a mac dev machine and headless Linux/the cloud worker, and
163
+ // the answer decides whether video needs a different encoder entirely. Bounded
164
+ // so a hung isConfigSupported (seen on some headless builds) can't wedge here.
165
+ try {
166
+ const caps = await evalWithTimeout(
167
+ page,
168
+ async () => {
169
+ const probe = async (codec) => {
170
+ try {
171
+ if (typeof VideoEncoder?.isConfigSupported !== 'function') return 'no-webcodecs';
172
+ const r = await VideoEncoder.isConfigSupported({ codec, width: 320, height: 180 });
173
+ return r?.supported ? 'yes' : 'no';
174
+ } catch (e) {
175
+ return `err:${e?.name || 'x'}`;
176
+ }
177
+ };
178
+ return {
179
+ avc: await probe('avc1.42001f'),
180
+ vp9: await probe('vp09.00.10.08'),
181
+ vp8: await probe('vp8'),
182
+ av1: await probe('av01.0.04M.08'),
183
+ };
184
+ },
185
+ undefined,
186
+ 15_000,
187
+ 'isConfigSupported probe'
188
+ );
189
+ console.error(
190
+ `_video-playwright: webcodecs caps avc=${caps.avc} vp9=${caps.vp9} vp8=${caps.vp8} av1=${caps.av1}`
191
+ );
192
+ } catch (e) {
193
+ console.error(`_video-playwright: cap probe failed: ${e instanceof Error ? e.message : e}`);
194
+ }
105
195
 
106
196
  // Resolve the target artboard handle. Priority: explicit --selector (from the
107
197
  // export scope Target) → --artboard id → first artboard. `--widen` climbs to
@@ -147,7 +237,18 @@ try {
147
237
  // no renderMediaOnWeb container support (and no audio need); an artboard with
148
238
  // no registered comp (ordinary/CSS) has nothing for renderMediaOnWeb to render
149
239
  // — both fall through to the frame-step path below.
150
- const useRenderer = !!renderLib && format !== 'gif' && !!compId;
240
+ //
241
+ // MAUDE_RENDER_SKIP_WHOLE_COMP=1 forces the frame-step path even for a
242
+ // registered comp. @remotion/web-renderer's renderMediaOnWeb never RESOLVES on
243
+ // the headless Linux render worker (measured on the cloud fleet AND CI — even
244
+ // a 12-frame comp and even muted; WebCodecs itself works, per the caps probe,
245
+ // so it is something in web-renderer's whole-comp pipeline that wedges). On
246
+ // that host it only ever burned the renderMediaOnWeb timeout and then fell to
247
+ // frame-step anyway — so the worker skips it and goes straight to the path
248
+ // that WORKS, reclaiming those minutes for the actual (muted) render. The
249
+ // desktop, where renderMediaOnWeb works and carries audio, leaves it on.
250
+ const skipWholeComp = process.env.MAUDE_RENDER_SKIP_WHOLE_COMP === '1';
251
+ const useRenderer = !skipWholeComp && !!renderLib && format !== 'gif' && !!compId;
151
252
 
152
253
  // When the whole-comp renderer path throws we DON'T fail the export. The most
153
254
  // common cause is a `RangeError: Maximum call stack size exceeded` from
@@ -166,7 +267,7 @@ try {
166
267
  try {
167
268
  const t0 = Date.now();
168
269
  const libSrc = readFileSync(renderLib, 'utf8');
169
- await page.addScriptTag({ content: libSrc, type: 'module' });
270
+ await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'render-lib' });
170
271
  await page.waitForFunction(() => typeof window.__maudeRenderVideo__ === 'function', {
171
272
  timeout: timeoutMs,
172
273
  });
@@ -175,7 +276,14 @@ try {
175
276
  const frameRange =
176
277
  Number.isFinite(explicitFrames) && explicitFrames > 0 ? [0, explicitFrames - 1] : null;
177
278
 
178
- const rendered = await page.evaluate(
279
+ // Bound the whole-comp render: @remotion/web-renderer drives WebCodecs,
280
+ // which on headless Linux without a working encoder never resolves — a
281
+ // hang, not a throw, so the graceful frame-step fallback below never
282
+ // fires. A wall-clock cap turns that hang INTO a throw, which the catch
283
+ // converts into exactly that fallback (frame-step, muted) — the video
284
+ // still exports instead of wedging the render worker.
285
+ const rendered = await evalWithTimeout(
286
+ page,
179
287
  async ({ compId, container, scale, muted, frameRange, licenseKey }) => {
180
288
  return window.__maude_render_video__(compId, {
181
289
  container,
@@ -192,7 +300,9 @@ try {
192
300
  muted: !wantAudio,
193
301
  frameRange,
194
302
  licenseKey,
195
- }
303
+ },
304
+ Math.min(timeoutMs, 90_000),
305
+ `renderMediaOnWeb(${format})`
196
306
  );
197
307
 
198
308
  writeFileSync(out, Buffer.from(rendered.b64, 'base64'));
@@ -421,9 +531,14 @@ async function frameStepCapture({
421
531
  const isGif = format === 'gif';
422
532
  if (encoding) {
423
533
  const libSrc = readFileSync(encodeLib, 'utf8');
424
- await page.addScriptTag({ content: libSrc, type: 'module' });
534
+ await addScriptCspSafe(page, { content: libSrc, type: 'module', name: 'encode-lib' });
425
535
  await page.waitForFunction(() => typeof window.__maudeEnc === 'object', { timeout: timeoutMs });
426
- const started = await page.evaluate(
536
+ // Bound the encoder start: on headless Linux without a working WebCodecs
537
+ // encoder this promise never resolves, so a wall-clock cap converts the hang
538
+ // into a fast, diagnostic failure instead of consuming the whole render
539
+ // budget in silence.
540
+ const started = await evalWithTimeout(
541
+ page,
427
542
  async ({ width, height, fps, isGif, gifColors, fmt }) => {
428
543
  if (isGif) {
429
544
  window.__maudeEnc.startGif({ width, height, fps, maxColors: gifColors });
@@ -438,7 +553,9 @@ async function frameStepCapture({
438
553
  isGif,
439
554
  gifColors: Number(args.gifColors) || 256,
440
555
  fmt: format,
441
- }
556
+ },
557
+ Math.min(timeoutMs, 45_000),
558
+ `startVideo(${format})`
442
559
  );
443
560
  console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
444
561
  }
@@ -470,12 +587,15 @@ async function frameStepCapture({
470
587
  if (encoding) {
471
588
  const tEnc = Date.now();
472
589
  const b64 = shot.toString('base64');
473
- await page.evaluate(
590
+ await evalWithTimeout(
591
+ page,
474
592
  async ({ b64, isGif, format }) => {
475
593
  if (isGif) return window.__maudeEnc.addGifFrame(b64);
476
594
  return window.__maudeEnc.addVideoFrame(b64, format);
477
595
  },
478
- { b64, isGif, format: shotFormat }
596
+ { b64, isGif, format: shotFormat },
597
+ 30_000,
598
+ `addFrame#${f}`
479
599
  );
480
600
  // NB: this stage is the double CDP crossing — the screenshot bytes go out
481
601
  // to node as base64 and straight back into the same page, to an encoder
@@ -512,10 +632,13 @@ async function frameStepCapture({
512
632
  result = { ...result, degraded: true, audioDropped: true, fallbackReason };
513
633
  }
514
634
  if (encoding) {
515
- const enc = await page.evaluate(
635
+ const enc = await evalWithTimeout(
636
+ page,
516
637
  async ({ isGif }) =>
517
638
  isGif ? window.__maudeEnc.finishGif() : window.__maudeEnc.finishVideo(),
518
- { isGif }
639
+ { isGif },
640
+ Math.min(timeoutMs, 45_000),
641
+ `finish(${format})`
519
642
  );
520
643
  writeFileSync(out, Buffer.from(enc.b64, 'base64'));
521
644
  result = { ...result, out, bytes: enc.bytes, container: enc.container, codec: enc.codec };
@@ -4180,6 +4180,39 @@ export function readArtboardPrintProp(
4180
4180
  return objectExpressionToPlain(resolved);
4181
4181
  }
4182
4182
 
4183
+ /**
4184
+ * Every print artboard's `print` prop in a canvas, keyed by artboard id.
4185
+ *
4186
+ * The per-id reader above is what the PDF adapter uses when it has the checkout
4187
+ * on disk. A render worker (DDR-230) does NOT — it holds no tenant files by
4188
+ * design — so the cell resolves these up front and ships them inside the job
4189
+ * (`options.printProps`, exporters/jobs.ts). Until it did, a cloud print PDF
4190
+ * silently came back with no BleedBox/TrimBox and no marks: the adapter's
4191
+ * `readFileSync` failed against the worker's empty scratch root and the
4192
+ * fall-through was "not a print artboard" (DDR-232 follow-up, found by the
4193
+ * worker-lane export E2E).
4194
+ */
4195
+ export function readAllArtboardPrintProps(
4196
+ canvasAbsPath: string,
4197
+ source: string
4198
+ ): Record<string, Record<string, unknown>> {
4199
+ const out: Record<string, Record<string, unknown>> = {};
4200
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4201
+ if (parsed.errors && parsed.errors.length > 0) return out;
4202
+ for (const ab of collectJsxByTag(parsed.program, 'DCArtboard')) {
4203
+ const id = getStringAttr(ab.openingElement, 'id');
4204
+ if (!id || getStringAttr(ab.openingElement, 'kind') !== 'print') continue;
4205
+ const attr = findAttribute(ab.openingElement, 'print');
4206
+ if (attr?.value?.type !== 'JSXExpressionContainer') continue;
4207
+ const expr = attr.value.expression as AnyNode | undefined;
4208
+ if (!expr) continue;
4209
+ const resolved = resolveValueNode(parsed.program, expr);
4210
+ if (resolved?.type !== 'ObjectExpression') continue;
4211
+ out[id] = objectExpressionToPlain(resolved);
4212
+ }
4213
+ return out;
4214
+ }
4215
+
4183
4216
  /**
4184
4217
  * Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
4185
4218
  * artboard counterpart of applyDeleteElement (an artboard is addressed by its id
@@ -124,6 +124,7 @@ import {
124
124
  diffLayoutPositions,
125
125
  } from './commands/move-artboards-command.ts';
126
126
  import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
127
+ import { applyCaptureChrome } from './exporters/capture-chrome.ts';
127
128
  import {
128
129
  type DomToSvgApi,
129
130
  pinArtboard,
@@ -1860,6 +1861,11 @@ function useExportCaptureBridge() {
1860
1861
  : null;
1861
1862
  void (async () => {
1862
1863
  const savedWorld = resetWorldPlane();
1864
+ // Hold the chrome suppression across the WHOLE batch. `svgForElement`
1865
+ // applies it per target too (ref-counted), but a 10-artboard deck would
1866
+ // otherwise add and remove the style ten times — ten forced reflows,
1867
+ // and the member watching the canvas sees the chrome flicker.
1868
+ const releaseChrome = applyCaptureChrome(document);
1863
1869
  try {
1864
1870
  const all = Array.from(document.querySelectorAll('[data-dc-screen]'));
1865
1871
  const targets = wanted?.length
@@ -1905,6 +1911,7 @@ function useExportCaptureBridge() {
1905
1911
  message: err instanceof Error ? err.message : String(err),
1906
1912
  });
1907
1913
  } finally {
1914
+ releaseChrome();
1908
1915
  restoreWorldPlane(savedWorld);
1909
1916
  busy = false;
1910
1917
  }
@@ -12,6 +12,11 @@ import { createRoot } from 'react-dom/client';
12
12
  // import that Bun erases), so this pulls only string constants into the client
13
13
  // bundle — no React, no input-router. See the tool-cursor handler below.
14
14
  import { resolveToolCursor } from '../canvas-cursors.ts';
15
+ import {
16
+ defaultScopeForFormat,
17
+ isScopeValidForFormat,
18
+ validScopesForFormat,
19
+ } from '../exporters/format-scopes.ts';
15
20
  // feature-3-web-artboards T5 — the single source (grid-track-handles.ts) for
16
21
  // the Inspector's Grid-section track parser/serializer, shared with the
17
22
  // on-canvas gutter-drag overlay so both edit the SAME track-list shape.
@@ -35,6 +40,7 @@ import {
35
40
  captureDeckViaBrowser,
36
41
  captureScale,
37
42
  sanitizeCapturedItems,
43
+ recordBrowserExport,
38
44
  } from './export-lane.js';
39
45
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
40
46
  import { useTreeDrag } from './use-tree-drag.js';
@@ -1175,18 +1181,6 @@ const EXPORT_SCOPE_LABELS = {
1175
1181
  'canvas-as-separate': 'Canvas · artboards separate',
1176
1182
  'project-raw': 'Whole project (raw)',
1177
1183
  };
1178
- const EXPORT_VALID_SCOPES = {
1179
- png: ['selection', 'artboard', 'canvas-as-separate'],
1180
- pdf: ['selection', 'artboard', 'canvas-as-separate'],
1181
- svg: ['selection', 'artboard', 'canvas-as-separate'],
1182
- html: ['artboard', 'canvas-as-separate'],
1183
- pptx: ['canvas-as-separate'],
1184
- mp4: ['artboard'],
1185
- gif: ['artboard'],
1186
- webm: ['artboard'],
1187
- canva: ['canvas-as-separate'],
1188
- zip: ['project-raw'],
1189
- };
1190
1184
  const PNG_SCALES = [
1191
1185
  { value: 1, label: '1× (native)' },
1192
1186
  { value: 2, label: '2× (retina)' },
@@ -1583,8 +1577,17 @@ function ExportDialog({
1583
1577
  [exportLane]
1584
1578
  );
1585
1579
  const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
1586
- const [scope, setScope] = useState(
1587
- initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1580
+ // DDR-231 Phase 2 T4 — seed from the shared table, and only honour an
1581
+ // incoming hint the CHOSEN FORMAT can actually render. A hint the format
1582
+ // can't take (a context menu's `project-raw`, a re-run of a zip history
1583
+ // entry) used to survive into the request and reach the render service as
1584
+ // an unrenderable file-tree target: "invalid render job".
1585
+ // The dialog opens on the PNG card; the format-change effect below re-seeds
1586
+ // the scope whenever the member picks another one.
1587
+ const [scope, setScope] = useState(() =>
1588
+ initialScope && isScopeValidForFormat('png', initialScope)
1589
+ ? initialScope
1590
+ : defaultScopeForFormat('png')
1588
1591
  );
1589
1592
  const [scale, setScale] = useState(2);
1590
1593
  // feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
@@ -1608,7 +1611,7 @@ function ExportDialog({
1608
1611
  const [status, setStatus] = useState(null); // { ok, msg }
1609
1612
  const [recent, setRecent] = useState([]);
1610
1613
  const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
1611
- const validScopes = card.handoff ? [] : EXPORT_VALID_SCOPES[card.format] || ['artboard'];
1614
+ const validScopes = card.handoff ? [] : validScopesForFormat(card.format);
1612
1615
  // Long-comp tiers (feature-enhanced-video-editing Task 1). 900 frames is the
1613
1616
  // safe tier (the frame-step fallback runs ~2.5 s/frame and 1080p captures
1614
1617
  // have died from memory pressure above it); 3600 is the server's default cap
@@ -1629,10 +1632,13 @@ function ExportDialog({
1629
1632
  const exportOverCap = card.temporal && exportCompFrames > EXPORT_DEFAULT_CAP;
1630
1633
 
1631
1634
  // Keep the scope valid for the chosen format (pptx/zip etc. only allow a
1632
- // subset) — mirrors VALID_SCOPES_PER_FORMAT in the in-canvas dialog.
1635
+ // subset). Both dialogs and the server now read one table
1636
+ // (exporters/format-scopes.ts) — see DDR-231 Phase 2 T4.
1633
1637
  useEffect(() => {
1634
- if (validScopes.length && !validScopes.includes(scope)) setScope(validScopes[0]);
1635
- }, [validScopes, scope]);
1638
+ if (validScopes.length && !validScopes.includes(scope)) {
1639
+ setScope(defaultScopeForFormat(card.format));
1640
+ }
1641
+ }, [validScopes, scope, card.format]);
1636
1642
 
1637
1643
  // DDR-231 T7 — wake the render service the moment the dialog opens on a
1638
1644
  // remote-lane workspace: the multi-GB Chromium container starts booting
@@ -1715,17 +1721,31 @@ function ExportDialog({
1715
1721
  // the in-canvas dialog's captureScopeHints.
1716
1722
  if (activeArtboardId) options.artboardId = activeArtboardId;
1717
1723
  if (selection?.selector) options.selection = selection;
1724
+ // Which canvas FILE this dialog is exporting — the server's `_active.json`
1725
+ // lags a tab switch, and a job resolved against the stale file renders the
1726
+ // wrong canvas (with this dialog's artboardId, which then never matches).
1727
+ if (activePath && activePath !== SYSTEM_TAB) options.canvasFile = activePath;
1718
1728
  // DDR-231 — the browser lane: in a workspace, png/svg of the active
1719
1729
  // artboard is captured by the member's OWN browser (the canvas already
1720
1730
  // renders here) — instant, no fleet wake. Everything else continues to
1721
1731
  // the jobs lane below.
1732
+ // NOTE the format gate. `browserCaptureEligible` answers for the whole
1733
+ // browser lane, pptx included — but this branch is the SINGLE-ARTBOARD
1734
+ // capture that downloads what the bridge returns verbatim. Without the
1735
+ // gate, a pptx export fell in here, asked the bridge for a "pptx" (which it
1736
+ // renders as PNG), and died in `sanitizeCapturedItems` on "not a valid
1737
+ // pptx" — silently degrading to the worker lane, which is why the deck
1738
+ // arrived minutes later with none of the browser lane's fixes in it. The
1739
+ // dedicated deck branch below was unreachable. Found by the T7 export E2E.
1722
1740
  const browserEligible =
1741
+ BROWSER_CAPTURE_FORMATS.has(card.format) &&
1723
1742
  browserCaptureEligible({
1724
1743
  exportLane,
1725
1744
  format: card.format,
1726
1745
  scope,
1727
1746
  artboardId: options.artboardId,
1728
- }) && typeof onBrowserCapture === 'function';
1747
+ }) &&
1748
+ typeof onBrowserCapture === 'function';
1729
1749
  if (browserEligible) {
1730
1750
  try {
1731
1751
  const capScale = captureScale(options);
@@ -1742,8 +1762,18 @@ function ExportDialog({
1742
1762
  // caps count/bytes, sniffs magic, forces name+MIME.
1743
1763
  const safeItems = await sanitizeCapturedItems(items, card.format);
1744
1764
  for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
1765
+ // DDR-231 Phase 2 T6 — the export is DONE and the member should see
1766
+ // that, in the dialog and in the ledger. Phase 1 closed the modal the
1767
+ // instant the download was handed off, so a browser-lane export left
1768
+ // no trace anywhere ("v exports dialog nic nevidim").
1769
+ for (const it of safeItems) {
1770
+ await recordBrowserExport({ format: card.format, scope, filename: it.name });
1771
+ }
1745
1772
  setBusy(false);
1746
- onClose();
1773
+ setStatus({
1774
+ ok: true,
1775
+ msg: `Saved ${safeItems.map((it) => it.name).join(', ')} to your downloads.`,
1776
+ });
1747
1777
  return;
1748
1778
  } catch (err) {
1749
1779
  if (exportLane !== 'remote') {
@@ -1773,10 +1803,17 @@ function ExportDialog({
1773
1803
  name: deckName,
1774
1804
  onProgress: (current, total) =>
1775
1805
  setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
1806
+ // Composition happens in-cell AFTER the last capture, and it is not
1807
+ // instant for a 10-slide deck. Without this the status sat on
1808
+ // "Capturing 10/10…" through the whole assemble step and the deck
1809
+ // then appeared out of nowhere — the reported "modal closes, nothing
1810
+ // happens, and after a while it downloads by itself".
1811
+ onAssemble: () => setStatus({ ok: true, msg: 'Assembling deck…' }),
1776
1812
  });
1777
1813
  downloadCapturedBlob(filename, blob);
1814
+ await recordBrowserExport({ format: card.format, scope, filename });
1778
1815
  setBusy(false);
1779
- onClose();
1816
+ setStatus({ ok: true, msg: `Saved ${filename} to your downloads.` });
1780
1817
  return;
1781
1818
  } catch (err) {
1782
1819
  if (exportLane !== 'remote') {
@@ -1844,6 +1881,10 @@ function ExportDialog({
1844
1881
  <button
1845
1882
  type="button"
1846
1883
  key={c.id}
1884
+ // Stable hooks for the export E2E harnesses (agent-browser web
1885
+ // + desktop-e2e native) — data-testid convention, DDR-231
1886
+ // Phase 2 T7/T8.
1887
+ data-testid={`export-format-${c.id}`}
1847
1888
  className={'st-fmt' + (c.id === sel ? ' is-on' : '')}
1848
1889
  disabled={laneBlocked(c)}
1849
1890
  title={
@@ -1878,6 +1919,7 @@ function ExportDialog({
1878
1919
  </label>
1879
1920
  <select
1880
1921
  id="st-export-scope"
1922
+ data-testid="export-scope"
1881
1923
  className="st-select"
1882
1924
  value={scope}
1883
1925
  onChange={(e) => setScope(e.target.value)}
@@ -2060,12 +2102,14 @@ function ExportDialog({
2060
2102
  <div
2061
2103
  className={'callout ' + (status.ok ? 'callout--success' : 'callout--error')}
2062
2104
  style={{ fontSize: 12 }}
2105
+ data-testid="export-status"
2106
+ data-ok={status.ok ? '1' : '0'}
2063
2107
  >
2064
2108
  {status.msg}
2065
2109
  </div>
2066
2110
  )}
2067
2111
  {recent.length > 0 && (
2068
- <div className="st-export-recent">
2112
+ <div className="st-export-recent" data-testid="export-recent">
2069
2113
  <div className="st-rp-hd">Recent</div>
2070
2114
  {recent.map((h, i) => (
2071
2115
  <div className="st-export-recent-row" key={i}>
@@ -2083,7 +2127,13 @@ function ExportDialog({
2083
2127
  <button type="button" className="btn btn--ghost" onClick={onClose}>
2084
2128
  Cancel
2085
2129
  </button>
2086
- <button type="button" className="btn btn--primary" disabled={busy} onClick={doExport}>
2130
+ <button
2131
+ type="button"
2132
+ className="btn btn--primary"
2133
+ data-testid="export-submit"
2134
+ disabled={busy}
2135
+ onClick={doExport}
2136
+ >
2087
2137
  <StIcon name="download" size={14} />
2088
2138
  {card.handoff
2089
2139
  ? 'Copy handoff command'
@@ -13183,6 +13233,17 @@ function App() {
13183
13233
  // Reply target for the export bridge: the canvas iframe's own origin.
13184
13234
  const replyOrigin = cfg?.canvasOrigin || window.location.origin;
13185
13235
  async function runBridgedExport(source, id, payload) {
13236
+ // The in-canvas dialog exports the canvas it LIVES IN — which the M1
13237
+ // gate above just proved is `activePath`. Stamp it so scope resolution
13238
+ // never falls back to the server's (asynchronously written, possibly
13239
+ // stale) `_active.json` — same rule as the shell dialog's canvasFile.
13240
+ // OVERWRITE, not fill-if-absent: the M1 gate above proved the sender IS
13241
+ // the active canvas iframe (untrusted, DDR-054), so a canvasFile IT
13242
+ // supplied must not survive — the bridged export renders the canvas it
13243
+ // lives in, full stop (security review W2).
13244
+ if (payload?.options && activePath && activePath !== SYSTEM_TAB) {
13245
+ payload.options.canvasFile = activePath;
13246
+ }
13186
13247
  const reply = (msg) => {
13187
13248
  try {
13188
13249
  if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
@@ -42,10 +42,17 @@ export function browserCaptureEligible({ exportLane, format, scope, artboardId }
42
42
  * 3× mirrors the desktop PNG-deck fallback's FALLBACK_SCALE (crisp in
43
43
  * non-vector viewers).
44
44
  *
45
- * @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function }} args
45
+ * @param {{ capture: Function, name?: string, scale?: number, onProgress?: Function,
46
+ * onAssemble?: Function }} args
46
47
  * @returns {Promise<{ filename: string, blob: Blob }>}
47
48
  */
48
- export async function captureDeckViaBrowser({ capture, name = 'export', scale = 3, onProgress }) {
49
+ export async function captureDeckViaBrowser({
50
+ capture,
51
+ name = 'export',
52
+ scale = 3,
53
+ onProgress,
54
+ onAssemble,
55
+ }) {
49
56
  const items = await capture({
50
57
  format: 'png',
51
58
  artboardIds: null,
@@ -59,6 +66,9 @@ export async function captureDeckViaBrowser({ capture, name = 'export', scale =
59
66
  fd.set('scale', String(scale));
60
67
  fd.set('name', name);
61
68
  for (const it of items) fd.append('image', it.blob, it.name);
69
+ // Composition is a distinct, non-instant phase — tell the caller so the
70
+ // status stops claiming the capture is still running.
71
+ onAssemble?.();
62
72
  const r = await fetch('/_api/export-assemble', { method: 'POST', body: fd });
63
73
  if (!r.ok) throw new Error((await r.text()) || `deck assembly failed (${r.status})`);
64
74
  const disp = r.headers.get('content-disposition') || '';
@@ -154,3 +164,28 @@ export async function sanitizeCapturedItems(items, format) {
154
164
  export function captureScale(options = {}) {
155
165
  return Math.max(1, Math.min(8, options.dpi ? options.dpi / 96 : options.scale || 1));
156
166
  }
167
+
168
+ /**
169
+ * Record a browser-lane export in the cell's ledger so it shows up in the
170
+ * Recent tab and the notification center like every other export.
171
+ *
172
+ * DDR-231 Phase 2 T6: the browser lane produces the file in the member's own
173
+ * browser, so there is no job to complete and nothing wrote a history row —
174
+ * the reported symptom was "the modal just closes and I see nothing in the
175
+ * export dialog". Best-effort by design: the member already HAS the file, so a
176
+ * failed ledger write must never surface as a failed export.
177
+ *
178
+ * @param {{ format: string, scope: string, filename: string }} entry
179
+ * @returns {Promise<void>}
180
+ */
181
+ export async function recordBrowserExport(entry) {
182
+ try {
183
+ await fetch('/_api/export-history', {
184
+ method: 'POST',
185
+ headers: { 'content-type': 'application/json' },
186
+ body: JSON.stringify(entry),
187
+ });
188
+ } catch {
189
+ /* the file is already saved — the ledger is a convenience, not the export */
190
+ }
191
+ }