@1agh/maude 1.0.6 → 1.0.8

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 (48) 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 +28 -8
  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 +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -124,6 +124,16 @@ 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';
128
+ import {
129
+ type DomToSvgApi,
130
+ pinArtboard,
131
+ rasterizeSvg,
132
+ resetWorldPlane,
133
+ restoreArtboard,
134
+ restoreWorldPlane,
135
+ svgForElement,
136
+ } from './exporters/capture-core.ts';
127
137
  // Photo editor (feature-photo-editor) — schema.ts is DEPENDENCY-FREE (no pixi),
128
138
  // so this static import is safe and adds zero bundle cost. The WebGL compositor
129
139
  // (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
@@ -1787,8 +1797,134 @@ export function DesignCanvas(props: DesignCanvasProps) {
1787
1797
  }
1788
1798
  DesignCanvas.displayName = 'DesignCanvas';
1789
1799
 
1800
+ // DDR-231 (hybrid export lanes) — the workspace `browser` export lane.
1801
+ //
1802
+ // In workspace mode the studio shell cannot capture the canvas itself: the
1803
+ // iframe is a separate (untrusted, DDR-054) origin, unreachable by DOM from
1804
+ // the shell. So the SHELL ASKS and the CANVAS CAPTURES — this bridge listens
1805
+ // for an `export-capture` postMessage from the embedding parent, renders the
1806
+ // requested artboards through the shared capture spine (capture-core.ts — the
1807
+ // same code the playwright SVG shim injects, DDR-231's single-spine rule) and
1808
+ // replies with per-artboard Blobs (structured-clonable across the origin
1809
+ // split). dom-to-svg loads LAZILY through the importmap (`dom-to-svg` runtime
1810
+ // bundle — the pixi.js pattern), so a canvas that never exports pays zero
1811
+ // bundle cost.
1812
+ //
1813
+ // Capture mutates live DOM (world zoom reset + artboard pinned to origin) and
1814
+ // restores everything afterwards — the member is looking at this plane. The
1815
+ // shell overlays a veil during capture to cover the brief reflow.
1816
+ function useExportCaptureBridge() {
1817
+ useEffect(() => {
1818
+ if (typeof window === 'undefined' || window.parent === window) return;
1819
+ let busy = false;
1820
+ const onMsg = (e: MessageEvent) => {
1821
+ // Only the embedding shell may drive capture — a message from any other
1822
+ // window (a nested iframe, an opened popup) is ignored outright.
1823
+ if (e.source !== window.parent) return;
1824
+ const m = e.data as {
1825
+ dgn?: string;
1826
+ id?: unknown;
1827
+ format?: unknown;
1828
+ artboardIds?: unknown;
1829
+ scale?: unknown;
1830
+ } | null;
1831
+ if (!m || m.dgn !== 'export-capture' || typeof m.id !== 'string') return;
1832
+ const requestId = m.id;
1833
+ // DDR-231 security (L1): reply to the ASKING origin, never `'*'`. The
1834
+ // done-message carries the rendered artboard blobs; a wildcard target
1835
+ // would leak them to whatever page happens to be the parent if the
1836
+ // shell's `frame-ancestors` CSP were ever absent (tests / misconfig).
1837
+ // `e.origin` here is the embedding shell (we already gated
1838
+ // `e.source === window.parent`); fall back to `'*'` only for an opaque
1839
+ // `"null"` origin where a targeted post is impossible.
1840
+ const replyOrigin = e.origin && e.origin !== 'null' ? e.origin : '*';
1841
+ const reply = (msg: Record<string, unknown>) => {
1842
+ try {
1843
+ window.parent.postMessage(msg, replyOrigin);
1844
+ } catch {
1845
+ /* parent gone — nothing to deliver to */
1846
+ }
1847
+ };
1848
+ if (busy) {
1849
+ reply({
1850
+ dgn: 'export-capture-error',
1851
+ id: requestId,
1852
+ message: 'a capture is already running on this canvas',
1853
+ });
1854
+ return;
1855
+ }
1856
+ busy = true;
1857
+ const format = m.format === 'svg' ? 'svg' : 'png';
1858
+ const scale = Math.max(1, Math.min(8, Number(m.scale) || 1));
1859
+ const wanted = Array.isArray(m.artboardIds)
1860
+ ? m.artboardIds.filter((s): s is string => typeof s === 'string')
1861
+ : null;
1862
+ void (async () => {
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);
1869
+ try {
1870
+ const all = Array.from(document.querySelectorAll('[data-dc-screen]'));
1871
+ const targets = wanted?.length
1872
+ ? all.filter((el) => wanted.includes(el.getAttribute('data-dc-screen') ?? ''))
1873
+ : all;
1874
+ if (!targets.length) throw new Error('no artboards matched the capture request');
1875
+ const api = (await import('dom-to-svg')) as unknown as DomToSvgApi;
1876
+ const items: { name: string; type: string; blob: Blob }[] = [];
1877
+ for (let i = 0; i < targets.length; i += 1) {
1878
+ const el = targets[i] as Element;
1879
+ const abId = el.getAttribute('data-dc-screen') || `artboard-${i + 1}`;
1880
+ const prevPos = pinArtboard(el);
1881
+ try {
1882
+ const svg = await svgForElement(el, api);
1883
+ if (format === 'svg') {
1884
+ items.push({
1885
+ name: `${abId}.svg`,
1886
+ type: 'image/svg+xml',
1887
+ blob: new Blob([svg], { type: 'image/svg+xml' }),
1888
+ });
1889
+ } else {
1890
+ items.push({
1891
+ name: `${abId}.png`,
1892
+ type: 'image/png',
1893
+ blob: await rasterizeSvg(svg, scale),
1894
+ });
1895
+ }
1896
+ } finally {
1897
+ restoreArtboard(el, prevPos);
1898
+ }
1899
+ reply({
1900
+ dgn: 'export-capture-progress',
1901
+ id: requestId,
1902
+ current: i + 1,
1903
+ total: targets.length,
1904
+ });
1905
+ }
1906
+ reply({ dgn: 'export-capture-done', id: requestId, items });
1907
+ } catch (err) {
1908
+ reply({
1909
+ dgn: 'export-capture-error',
1910
+ id: requestId,
1911
+ message: err instanceof Error ? err.message : String(err),
1912
+ });
1913
+ } finally {
1914
+ releaseChrome();
1915
+ restoreWorldPlane(savedWorld);
1916
+ busy = false;
1917
+ }
1918
+ })();
1919
+ };
1920
+ window.addEventListener('message', onMsg);
1921
+ return () => window.removeEventListener('message', onMsg);
1922
+ }, []);
1923
+ }
1924
+
1790
1925
  function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1791
1926
  ensureEngineStyles();
1927
+ useExportCaptureBridge();
1792
1928
 
1793
1929
  const hostRef = useRef<HTMLDivElement | null>(null);
1794
1930
  const worldRef = useRef<HTMLDivElement | null>(null);
@@ -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.
@@ -28,6 +33,15 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
28
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
29
34
  import { syncPresentation } from '../sync/presentation.ts';
30
35
  import { canvasUrl } from './canvas-url.js';
36
+ import {
37
+ BROWSER_CAPTURE_FORMATS,
38
+ BROWSER_SERVABLE_FORMATS,
39
+ browserCaptureEligible,
40
+ captureDeckViaBrowser,
41
+ captureScale,
42
+ sanitizeCapturedItems,
43
+ recordBrowserExport,
44
+ } from './export-lane.js';
31
45
  import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
32
46
  import { useTreeDrag } from './use-tree-drag.js';
33
47
  import ChatPanel from './panels/ChatPanel.jsx';
@@ -1167,18 +1181,6 @@ const EXPORT_SCOPE_LABELS = {
1167
1181
  'canvas-as-separate': 'Canvas · artboards separate',
1168
1182
  'project-raw': 'Whole project (raw)',
1169
1183
  };
1170
- const EXPORT_VALID_SCOPES = {
1171
- png: ['selection', 'artboard', 'canvas-as-separate'],
1172
- pdf: ['selection', 'artboard', 'canvas-as-separate'],
1173
- svg: ['selection', 'artboard', 'canvas-as-separate'],
1174
- html: ['artboard', 'canvas-as-separate'],
1175
- pptx: ['canvas-as-separate'],
1176
- mp4: ['artboard'],
1177
- gif: ['artboard'],
1178
- webm: ['artboard'],
1179
- canva: ['canvas-as-separate'],
1180
- zip: ['project-raw'],
1181
- };
1182
1184
  const PNG_SCALES = [
1183
1185
  { value: 1, label: '1× (native)' },
1184
1186
  { value: 2, label: '2× (retina)' },
@@ -1530,6 +1532,21 @@ function AssetPicker({
1530
1532
  );
1531
1533
  }
1532
1534
 
1535
+ // Direct download for a browser-lane capture. export-center's autoDownloadBlob
1536
+ // fetches a JOB's bytes — a browser capture has no job, just the Blob.
1537
+ function downloadCapturedBlob(name, blob) {
1538
+ const url = URL.createObjectURL(blob);
1539
+ const a = document.createElement('a');
1540
+ a.href = url;
1541
+ a.download = name || 'export';
1542
+ document.body.appendChild(a);
1543
+ a.click();
1544
+ a.remove();
1545
+ // Revoke on a delay — an immediate revoke can race the download start in
1546
+ // some engines (the click only queues the fetch of the object URL).
1547
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
1548
+ }
1549
+
1533
1550
  function ExportDialog({
1534
1551
  mode,
1535
1552
  initialScope,
@@ -1539,24 +1556,38 @@ function ExportDialog({
1539
1556
  activeArtboardId = null,
1540
1557
  selection = null,
1541
1558
  exportLane = 'local',
1559
+ onBrowserCapture = null,
1542
1560
  onClose,
1543
1561
  }) {
1544
1562
  // feature-cloud-export-render-workers — on a cell with no render service
1545
1563
  // (`lane === 'none'`), formats that render through a browser are offered
1546
1564
  // disabled-with-a-reason instead of firing a request the proxy 404s. ZIP
1547
1565
  // (JSZip over project files) and the AI-handoff card (clipboard copy) need
1548
- // no browser and stay live in every lane.
1566
+ // no browser — and DDR-231's browser lane keeps png/svg live in every
1567
+ // workspace lane too (captured by the member's own browser, no service).
1549
1568
  const laneBlocked = useCallback(
1550
1569
  (c) =>
1551
- (exportLane === 'none' && !c.handoff && c.format !== 'zip') ||
1570
+ (exportLane === 'none' &&
1571
+ !c.handoff &&
1572
+ c.format !== 'zip' &&
1573
+ !BROWSER_SERVABLE_FORMATS.has(c.format)) ||
1552
1574
  // Canva bundles project files the render service can't reach — desktop only
1553
1575
  // in every workspace lane (exporters/remote.ts REMOTE_UNSUPPORTED_FORMATS).
1554
1576
  (exportLane !== 'local' && c.format === 'canva'),
1555
1577
  [exportLane]
1556
1578
  );
1557
- const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : exportLane === 'none' ? 'zip' : 'png');
1558
- const [scope, setScope] = useState(
1559
- initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1579
+ const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
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')
1560
1591
  );
1561
1592
  const [scale, setScale] = useState(2);
1562
1593
  // feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
@@ -1580,7 +1611,7 @@ function ExportDialog({
1580
1611
  const [status, setStatus] = useState(null); // { ok, msg }
1581
1612
  const [recent, setRecent] = useState([]);
1582
1613
  const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
1583
- const validScopes = card.handoff ? [] : EXPORT_VALID_SCOPES[card.format] || ['artboard'];
1614
+ const validScopes = card.handoff ? [] : validScopesForFormat(card.format);
1584
1615
  // Long-comp tiers (feature-enhanced-video-editing Task 1). 900 frames is the
1585
1616
  // safe tier (the frame-step fallback runs ~2.5 s/frame and 1080p captures
1586
1617
  // have died from memory pressure above it); 3600 is the server's default cap
@@ -1601,10 +1632,22 @@ function ExportDialog({
1601
1632
  const exportOverCap = card.temporal && exportCompFrames > EXPORT_DEFAULT_CAP;
1602
1633
 
1603
1634
  // Keep the scope valid for the chosen format (pptx/zip etc. only allow a
1604
- // 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.
1605
1637
  useEffect(() => {
1606
- if (validScopes.length && !validScopes.includes(scope)) setScope(validScopes[0]);
1607
- }, [validScopes, scope]);
1638
+ if (validScopes.length && !validScopes.includes(scope)) {
1639
+ setScope(defaultScopeForFormat(card.format));
1640
+ }
1641
+ }, [validScopes, scope, card.format]);
1642
+
1643
+ // DDR-231 T7 — wake the render service the moment the dialog opens on a
1644
+ // remote-lane workspace: the multi-GB Chromium container starts booting
1645
+ // while the member is still picking format/scope, so a video/PDF job lands
1646
+ // on a warm (or at least warming) service instead of a cold one.
1647
+ useEffect(() => {
1648
+ if (exportLane !== 'remote') return;
1649
+ fetch('/_api/export-warmup').catch(() => {});
1650
+ }, [exportLane]);
1608
1651
 
1609
1652
  const loadRecent = useCallback(() => {
1610
1653
  fetch('/_api/export-history')
@@ -1678,6 +1721,119 @@ function ExportDialog({
1678
1721
  // the in-canvas dialog's captureScopeHints.
1679
1722
  if (activeArtboardId) options.artboardId = activeArtboardId;
1680
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;
1728
+ // DDR-231 — the browser lane: in a workspace, png/svg of the active
1729
+ // artboard is captured by the member's OWN browser (the canvas already
1730
+ // renders here) — instant, no fleet wake. Everything else continues to
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.
1740
+ const browserEligible =
1741
+ BROWSER_CAPTURE_FORMATS.has(card.format) &&
1742
+ browserCaptureEligible({
1743
+ exportLane,
1744
+ format: card.format,
1745
+ scope,
1746
+ artboardId: options.artboardId,
1747
+ }) &&
1748
+ typeof onBrowserCapture === 'function';
1749
+ if (browserEligible) {
1750
+ try {
1751
+ const capScale = captureScale(options);
1752
+ setStatus({ ok: true, msg: 'Capturing…' });
1753
+ const items = await onBrowserCapture({
1754
+ format: card.format,
1755
+ artboardIds: [options.artboardId],
1756
+ scale: capScale,
1757
+ onProgress: (current, total) =>
1758
+ setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
1759
+ });
1760
+ // Validate before writing to disk — the capture bridge shares a window
1761
+ // with tenant TSX, which can forge a reply (DDR-231 security pass, F1):
1762
+ // caps count/bytes, sniffs magic, forces name+MIME.
1763
+ const safeItems = await sanitizeCapturedItems(items, card.format);
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
+ }
1772
+ setBusy(false);
1773
+ setStatus({
1774
+ ok: true,
1775
+ msg: `Saved ${safeItems.map((it) => it.name).join(', ')} to your downloads.`,
1776
+ });
1777
+ return;
1778
+ } catch (err) {
1779
+ if (exportLane !== 'remote') {
1780
+ setStatus({ ok: false, msg: `Capture failed: ${(err && err.message) || err}` });
1781
+ setBusy(false);
1782
+ return;
1783
+ }
1784
+ // A render service exists — degrade to the slower jobs lane instead of
1785
+ // a dead end (and the fallback keeps the worker path exercised).
1786
+ }
1787
+ }
1788
+ // DDR-231 — the pptx deck: capture every artboard as PNG in THIS browser,
1789
+ // compose in-cell (/_api/export-assemble — the zip containment class).
1790
+ if (
1791
+ browserCaptureEligible({ exportLane, format: card.format, scope }) &&
1792
+ card.format === 'pptx' &&
1793
+ typeof onBrowserCapture === 'function'
1794
+ ) {
1795
+ try {
1796
+ setStatus({ ok: true, msg: 'Capturing artboards…' });
1797
+ const deckName =
1798
+ activePath && activePath !== SYSTEM_TAB
1799
+ ? basename(activePath).replace(/\.[^.]+$/, '')
1800
+ : 'export';
1801
+ const { filename, blob } = await captureDeckViaBrowser({
1802
+ capture: onBrowserCapture,
1803
+ name: deckName,
1804
+ onProgress: (current, total) =>
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…' }),
1812
+ });
1813
+ downloadCapturedBlob(filename, blob);
1814
+ await recordBrowserExport({ format: card.format, scope, filename });
1815
+ setBusy(false);
1816
+ setStatus({ ok: true, msg: `Saved ${filename} to your downloads.` });
1817
+ return;
1818
+ } catch (err) {
1819
+ if (exportLane !== 'remote') {
1820
+ setStatus({ ok: false, msg: `Deck export failed: ${(err && err.message) || err}` });
1821
+ setBusy(false);
1822
+ return;
1823
+ }
1824
+ // Render service available — degrade to the jobs lane below.
1825
+ }
1826
+ }
1827
+ if (exportLane === 'none' && BROWSER_CAPTURE_FORMATS.has(card.format)) {
1828
+ // Without a render service the browser can only capture what it renders:
1829
+ // the active artboard. Other scopes have nowhere to run.
1830
+ setStatus({
1831
+ ok: false,
1832
+ msg: 'Without the render service this workspace exports PNG/SVG of the active artboard only — switch Scope to “Active artboard”, or ask your admin to add maude-render.',
1833
+ });
1834
+ setBusy(false);
1835
+ return;
1836
+ }
1681
1837
  try {
1682
1838
  // feature-background-export-notification-center — enqueue and close
1683
1839
  // immediately; the menubar notification center owns status, progress,
@@ -1725,6 +1881,10 @@ function ExportDialog({
1725
1881
  <button
1726
1882
  type="button"
1727
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}`}
1728
1888
  className={'st-fmt' + (c.id === sel ? ' is-on' : '')}
1729
1889
  disabled={laneBlocked(c)}
1730
1890
  title={
@@ -1745,10 +1905,11 @@ function ExportDialog({
1745
1905
  </div>
1746
1906
  {exportLane === 'none' && (
1747
1907
  <div className="st-dialog-note" data-testid="export-lane-note">
1748
- Rendered exports (PNG, PDF, video…) need the render service, which this
1749
- workspace doesn&apos;t have configured. ZIP and AI handoff still work here —
1750
- for everything else use the desktop app, or ask your admin to add the
1751
- <code> maude-render</code> service (see the self-hosting docs).
1908
+ PNG and SVG of the active artboard — and the PPTX deck — export right
1909
+ here in your browser; ZIP and AI handoff work too. PDF, video and other
1910
+ multi-artboard exports need the render service, which this workspace
1911
+ doesn&apos;t have configured — use the desktop app, or ask your admin to
1912
+ add the <code> maude-render</code> service (see the self-hosting docs).
1752
1913
  </div>
1753
1914
  )}
1754
1915
  {!card.handoff && (
@@ -1758,6 +1919,7 @@ function ExportDialog({
1758
1919
  </label>
1759
1920
  <select
1760
1921
  id="st-export-scope"
1922
+ data-testid="export-scope"
1761
1923
  className="st-select"
1762
1924
  value={scope}
1763
1925
  onChange={(e) => setScope(e.target.value)}
@@ -1940,12 +2102,14 @@ function ExportDialog({
1940
2102
  <div
1941
2103
  className={'callout ' + (status.ok ? 'callout--success' : 'callout--error')}
1942
2104
  style={{ fontSize: 12 }}
2105
+ data-testid="export-status"
2106
+ data-ok={status.ok ? '1' : '0'}
1943
2107
  >
1944
2108
  {status.msg}
1945
2109
  </div>
1946
2110
  )}
1947
2111
  {recent.length > 0 && (
1948
- <div className="st-export-recent">
2112
+ <div className="st-export-recent" data-testid="export-recent">
1949
2113
  <div className="st-rp-hd">Recent</div>
1950
2114
  {recent.map((h, i) => (
1951
2115
  <div className="st-export-recent-row" key={i}>
@@ -1963,7 +2127,13 @@ function ExportDialog({
1963
2127
  <button type="button" className="btn btn--ghost" onClick={onClose}>
1964
2128
  Cancel
1965
2129
  </button>
1966
- <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
+ >
1967
2137
  <StIcon name="download" size={14} />
1968
2138
  {card.handoff
1969
2139
  ? 'Copy handoff command'
@@ -10286,6 +10456,58 @@ function App() {
10286
10456
  [activePath]
10287
10457
  );
10288
10458
 
10459
+ // DDR-231 (hybrid export lanes) — ask the active canvas iframe to capture
10460
+ // its own artboards (canvas-lib's useExportCaptureBridge) and hand back the
10461
+ // blobs. The shell can't reach the cross-origin canvas DOM, and the sandbox
10462
+ // omits allow-downloads, so this is the division of labour: the CANVAS
10463
+ // captures (it renders the pixels), the SHELL downloads. Resolves with
10464
+ // [{ name, type, blob }]; rejects on canvas error or timeout.
10465
+ const browserCaptureSeq = useRef(0);
10466
+ const captureFromCanvas = useCallback(
10467
+ ({ format, artboardIds = null, scale = 1, onProgress, timeoutMs = 120_000 }) =>
10468
+ new Promise((resolve, reject) => {
10469
+ const el = activePath ? iframesRef.current.get(activePath) : null;
10470
+ if (!el || !el.contentWindow) {
10471
+ reject(new Error('no active canvas to capture'));
10472
+ return;
10473
+ }
10474
+ const cw = el.contentWindow;
10475
+ browserCaptureSeq.current += 1;
10476
+ const id = `cap-${Date.now().toString(36)}-${browserCaptureSeq.current}`;
10477
+ let settled = false;
10478
+ const finish = (fn, arg) => {
10479
+ if (settled) return;
10480
+ settled = true;
10481
+ window.removeEventListener('message', onMsg);
10482
+ clearTimeout(timer);
10483
+ fn(arg);
10484
+ };
10485
+ const onMsg = (e) => {
10486
+ // Only answers from the iframe we asked — a message with a matching
10487
+ // id from any other window is ignored.
10488
+ if (e.source !== cw) return;
10489
+ const m = e.data;
10490
+ if (!m || m.id !== id) return;
10491
+ if (m.dgn === 'export-capture-progress') onProgress?.(m.current, m.total);
10492
+ else if (m.dgn === 'export-capture-done')
10493
+ finish(resolve, Array.isArray(m.items) ? m.items : []);
10494
+ else if (m.dgn === 'export-capture-error')
10495
+ finish(reject, new Error(m.message || 'capture failed'));
10496
+ };
10497
+ const timer = setTimeout(
10498
+ () => finish(reject, new Error('capture timed out — the canvas did not answer')),
10499
+ timeoutMs
10500
+ );
10501
+ window.addEventListener('message', onMsg);
10502
+ try {
10503
+ cw.postMessage({ dgn: 'export-capture', id, format, artboardIds, scale }, '*');
10504
+ } catch (err) {
10505
+ finish(reject, err);
10506
+ }
10507
+ }),
10508
+ [activePath]
10509
+ );
10510
+
10289
10511
  // ── feature-photo-editor — the Photo tab's three channels ─────────────────
10290
10512
  // (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
10291
10513
  // canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
@@ -12991,6 +13213,16 @@ function App() {
12991
13213
  // report status back to the iframe. Origin is already validated
12992
13214
  // (e.origin === expectedOrigin) above, so only the real canvas iframe
12993
13215
  // can ask — this is NOT a generic fetch proxy.
13216
+ //
13217
+ // DDR-231 security (M1): additionally require the request come from the
13218
+ // ACTIVE canvas — captureFromCanvas + the browser-lane branch capture
13219
+ // and auto-download `activePath`'s pixels regardless of who asked, so
13220
+ // WITHOUT this gate a background (untrusted, DDR-054) same-origin canvas
13221
+ // could `postMessage({dgn:'export-request'})` with no user gesture and
13222
+ // force a silent download of whatever the user is currently looking at.
13223
+ // Same one-liner every sibling mutating branch already uses.
13224
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
13225
+ if (e.source !== activeWin) return;
12994
13226
  void runBridgedExport(e.source, m.id, m.payload);
12995
13227
  } else if (m.dgn === 'export-history-request' && m.id) {
12996
13228
  // Same bridge for the dialog's Recent tab (/_api/export-history is
@@ -13001,11 +13233,65 @@ function App() {
13001
13233
  // Reply target for the export bridge: the canvas iframe's own origin.
13002
13234
  const replyOrigin = cfg?.canvasOrigin || window.location.origin;
13003
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
+ }
13004
13247
  const reply = (msg) => {
13005
13248
  try {
13006
13249
  if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
13007
13250
  } catch {}
13008
13251
  };
13252
+ // DDR-231 — the browser lane, mirrored for the IN-CANVAS dialog: png/svg
13253
+ // of a known artboard is captured by the asking canvas itself (the shell
13254
+ // relays the request back over the export-capture bridge and downloads
13255
+ // the blobs — the sandboxed iframe can't download, the shell can).
13256
+ const opts = payload?.options || {};
13257
+ const bridgedBrowserEligible = browserCaptureEligible({
13258
+ exportLane: cfg.exportLane || 'local',
13259
+ format: payload?.format,
13260
+ scope: payload?.scope,
13261
+ artboardId: opts.artboardId,
13262
+ });
13263
+ if (bridgedBrowserEligible) {
13264
+ try {
13265
+ if (payload.format === 'pptx') {
13266
+ const { filename, blob } = await captureDeckViaBrowser({
13267
+ capture: captureFromCanvas,
13268
+ name: activePath ? activePath.replace(/^.*\//, '').replace(/\.[^.]+$/, '') : 'export',
13269
+ });
13270
+ downloadCapturedBlob(filename, blob);
13271
+ } else {
13272
+ const capScale = captureScale(opts);
13273
+ const items = await captureFromCanvas({
13274
+ format: payload.format,
13275
+ artboardIds: [opts.artboardId],
13276
+ scale: capScale,
13277
+ });
13278
+ const safeItems = await sanitizeCapturedItems(items, payload.format);
13279
+ for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
13280
+ }
13281
+ reply({ ok: true, browser: true });
13282
+ return;
13283
+ } catch {
13284
+ if (cfg.exportLane !== 'remote') {
13285
+ reply({
13286
+ ok: false,
13287
+ error:
13288
+ 'Capturing in the browser failed and this workspace has no render service to fall back to — try again, or ask your admin to add maude-render.',
13289
+ });
13290
+ return;
13291
+ }
13292
+ // Render service available — degrade to the jobs lane below.
13293
+ }
13294
+ }
13009
13295
  // feature-cloud-export-render-workers — a workspace with no render
13010
13296
  // service can't produce browser-rendered formats; answer the in-canvas
13011
13297
  // dialog with the reason instead of relaying a request the proxy 404s.
@@ -13014,7 +13300,7 @@ function App() {
13014
13300
  reply({
13015
13301
  ok: false,
13016
13302
  error:
13017
- 'This format needs the render service, which this workspace doesn’t have configured. ZIP still works here; for rendered formats use the desktop app or ask your admin to add maude-render.',
13303
+ 'This format needs the render service, which this workspace doesn’t have configured. PNG/SVG of the active artboard and the PPTX deck export straight from the browser; ZIP works too. For the rest use the desktop app or ask your admin to add maude-render.',
13018
13304
  });
13019
13305
  return;
13020
13306
  }
@@ -13068,6 +13354,7 @@ function App() {
13068
13354
  broadcastChrome,
13069
13355
  activePath,
13070
13356
  postToActiveCanvas,
13357
+ captureFromCanvas,
13071
13358
  ]);
13072
13359
 
13073
13360
  // Tell the active canvas iframe to drop any persistent selection (canvas
@@ -15831,6 +16118,7 @@ function App() {
15831
16118
  activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
15832
16119
  selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
15833
16120
  exportLane={cfg.exportLane || 'local'}
16121
+ onBrowserCapture={captureFromCanvas}
15834
16122
  onClose={() => setExportDialog(null)}
15835
16123
  />
15836
16124
  )}