@1agh/maude 0.16.0 → 0.17.1

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 (44) hide show
  1. package/README.md +4 -0
  2. package/cli/commands/design.mjs +108 -2
  3. package/package.json +12 -18
  4. package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
  5. package/plugins/design/dev-server/annotations-layer.tsx +8 -10
  6. package/plugins/design/dev-server/api.ts +41 -0
  7. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
  8. package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
  9. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
  10. package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
  11. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
  12. package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
  13. package/plugins/design/dev-server/canvas-lib.tsx +12 -13
  14. package/plugins/design/dev-server/canvas-shell.tsx +32 -4
  15. package/plugins/design/dev-server/client/app.jsx +18 -1
  16. package/plugins/design/dev-server/context-menu.tsx +36 -9
  17. package/plugins/design/dev-server/dist/client.bundle.js +11 -3
  18. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  19. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  20. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  21. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  22. package/plugins/design/dev-server/exporters/html.ts +103 -0
  23. package/plugins/design/dev-server/exporters/index.ts +135 -0
  24. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  25. package/plugins/design/dev-server/exporters/png.ts +136 -0
  26. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  27. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  28. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  29. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  30. package/plugins/design/dev-server/http.ts +80 -0
  31. package/plugins/design/dev-server/inspect.ts +1 -1
  32. package/plugins/design/dev-server/server.mjs +1 -1
  33. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  34. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  35. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  36. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  37. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  39. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  40. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  41. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  42. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  43. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  44. package/plugins/design/templates/_shell.html +33 -0
@@ -0,0 +1,143 @@
1
+ // _png-playwright.mjs — playwright shim for the PNG exporter.
2
+ //
3
+ // Replaces the screenshot.sh / agent-browser path because agent-browser
4
+ // applies its own viewport sizing that doesn't honor our 1440x900 setup,
5
+ // producing clipped captures. With our own playwright we control the
6
+ // viewport exactly and crop to the target element's bounding box.
7
+
8
+ import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import { chromium } from 'playwright';
11
+
12
+ const args = Object.fromEntries(
13
+ process.argv.slice(2).reduce((acc, cur, i, all) => {
14
+ if (cur.startsWith('--')) acc.push([cur.slice(2), all[i + 1] ?? '1']);
15
+ return acc;
16
+ }, [])
17
+ );
18
+
19
+ const {
20
+ url,
21
+ selector,
22
+ out,
23
+ 'out-dir': outDir,
24
+ 'widen-to-artboard': widenFlag,
25
+ multi: multiFlag,
26
+ timeout = '12',
27
+ scale = '1',
28
+ } = args;
29
+
30
+ if (!url) {
31
+ console.error('usage: _png-playwright.mjs --url <url> --selector <css> --out <path>');
32
+ process.exit(2);
33
+ }
34
+
35
+ const widen = widenFlag !== undefined;
36
+ const multi = multiFlag !== undefined;
37
+ const timeoutMs = Number(timeout) * 1000;
38
+ const deviceScaleFactor = Math.max(1, Math.min(4, Number(scale) || 1));
39
+
40
+ const browser = await chromium.launch();
41
+ try {
42
+ // 1440x900 matches the canvas viewport the design tool uses everywhere;
43
+ // exporters resize per-target before each shot to fit the artboard exactly.
44
+ const ctx = await browser.newContext({
45
+ viewport: { width: 1440, height: 900 },
46
+ deviceScaleFactor,
47
+ });
48
+ const page = await ctx.newPage();
49
+ await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
50
+ await page.evaluate(() => document.fonts.ready);
51
+
52
+ const written = [];
53
+
54
+ const captureHandle = async (handle, target) => {
55
+ // Widen to artboard if requested — the selection's selector points at a
56
+ // descendant, but for "Export this artboard" we want the enclosing
57
+ // [data-dc-screen]. Done browser-side so the locator + bbox align.
58
+ const widenedHandle = widen
59
+ ? await handle.evaluateHandle((el) => el.closest('[data-dc-screen]') ?? el)
60
+ : handle;
61
+ // Reset the world plane's pan/zoom so every artboard renders at its
62
+ // declared native dimensions (1440×900 etc.). The dev-server uses CSS
63
+ // `zoom` (not `transform: scale`) on `.dc-world`, which actually shrinks
64
+ // layout — getBoundingClientRect returns 818×512 instead of 1440×900
65
+ // unless we zero both `zoom` and `transform` here.
66
+ await page.evaluate(
67
+ (sel) => {
68
+ const world = document.querySelector('.dc-world');
69
+ if (world) {
70
+ world.style.zoom = '1';
71
+ world.style.transform = 'none';
72
+ }
73
+ // Each artboard carries `style="left: …; top: …;"` so the world plane
74
+ // can position it as part of a multi-artboard layout. Pin the target
75
+ // to (0,0) so the screenshot clip starts at the viewport origin.
76
+ const ab = document.querySelector(sel);
77
+ if (ab) {
78
+ ab.style.left = '0px';
79
+ ab.style.top = '0px';
80
+ }
81
+ },
82
+ widen ? '[data-dc-screen]:first-of-type' : (selector ?? '[data-dc-screen]:first-of-type')
83
+ );
84
+ const rect = await widenedHandle.evaluate((el) => {
85
+ const r = el.getBoundingClientRect();
86
+ return { x: r.left, y: r.top, width: r.width, height: r.height };
87
+ });
88
+ // Resize the viewport to fit the artboard so the screenshot doesn't
89
+ // include the world plane's pan/zoom margin. The artboard then sits at
90
+ // (0,0) with its native dimensions.
91
+ await page.setViewportSize({
92
+ width: Math.max(1, Math.ceil(rect.width)),
93
+ height: Math.max(1, Math.ceil(rect.height)),
94
+ });
95
+ await widenedHandle.evaluate((el) => {
96
+ el.scrollIntoView({ block: 'start', inline: 'start' });
97
+ window.scrollTo(0, 0);
98
+ });
99
+ // After scroll, recompute rect — it's now anchored near (0,0).
100
+ const finalRect = await widenedHandle.evaluate((el) => {
101
+ const r = el.getBoundingClientRect();
102
+ return { x: r.left, y: r.top, width: r.width, height: r.height };
103
+ });
104
+ await page.screenshot({
105
+ path: target,
106
+ clip: {
107
+ x: Math.max(0, Math.floor(finalRect.x)),
108
+ y: Math.max(0, Math.floor(finalRect.y)),
109
+ width: Math.max(1, Math.ceil(finalRect.width)),
110
+ height: Math.max(1, Math.ceil(finalRect.height)),
111
+ },
112
+ });
113
+ written.push(target);
114
+ };
115
+
116
+ if (multi) {
117
+ if (!outDir) {
118
+ console.error('_png-playwright: --multi requires --out-dir');
119
+ process.exit(2);
120
+ }
121
+ mkdirSync(outDir, { recursive: true });
122
+ const screens = await page.locator(selector ?? '[data-dc-screen]').all();
123
+ for (let i = 0; i < screens.length; i += 1) {
124
+ const handle = screens[i];
125
+ const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
126
+ await captureHandle(handle, join(outDir, `${id}.png`));
127
+ }
128
+ } else {
129
+ if (!out) {
130
+ console.error('_png-playwright: --out required when --multi not set');
131
+ process.exit(2);
132
+ }
133
+ mkdirSync(dirname(out), { recursive: true });
134
+ const handle = page.locator(selector ?? '[data-dc-screen]:first-of-type').first();
135
+ await handle.waitFor({ state: 'visible', timeout: timeoutMs });
136
+ await captureHandle(handle, out);
137
+ }
138
+
139
+ for (const w of written) console.log(w);
140
+ console.error(`✓ playwright wrote ${written.length} png file(s)`);
141
+ } finally {
142
+ await browser.close();
143
+ }
@@ -0,0 +1,98 @@
1
+ // _pptx-playwright.mjs — playwright shim for the PPTX exporter.
2
+ //
3
+ // Drives `dom-to-pptx` (atharva9167j) — computed-style + getBoundingClientRect
4
+ // architecture that respects the browser's layout result. Replaces our hand-
5
+ // rolled DOM walker; see DDR-043. Per artboard → one slide. dom-to-pptx ships
6
+ // a UMD bundle at `dist/dom-to-pptx.bundle.js` that exposes `window.domToPptx`.
7
+ //
8
+ // Invocation (Bun.spawn from exporters/pptx.ts — not invoked directly):
9
+ // node _pptx-playwright.mjs --url <url> --selector <css> --out <pptx-path>
10
+ // --bundle-path <umd.js> [--multi] [--timeout 12]
11
+
12
+ import { mkdirSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ import { chromium } from 'playwright';
15
+
16
+ const args = Object.fromEntries(
17
+ process.argv.slice(2).reduce((acc, cur, i, all) => {
18
+ if (cur.startsWith('--')) acc.push([cur.slice(2), all[i + 1] ?? '1']);
19
+ return acc;
20
+ }, [])
21
+ );
22
+
23
+ const { url, selector, out, 'bundle-path': bundlePath, multi: multiFlag, timeout = '15' } = args;
24
+
25
+ if (!url || !out || !bundlePath) {
26
+ console.error(
27
+ 'usage: _pptx-playwright.mjs --url <url> --selector <css> --out <pptx-path> --bundle-path <umd.js>'
28
+ );
29
+ process.exit(2);
30
+ }
31
+
32
+ const multi = multiFlag !== undefined;
33
+ const timeoutMs = Number(timeout) * 1000;
34
+
35
+ mkdirSync(dirname(out), { recursive: true });
36
+
37
+ const browser = await chromium.launch();
38
+ try {
39
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
40
+ const page = await ctx.newPage();
41
+ await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
42
+ await page.evaluate(() => document.fonts.ready);
43
+ // Reset the world plane's CSS zoom + transform so dom-to-pptx reads the
44
+ // artboard at its declared 1440×900 instead of the pan-zoomed thumbnail.
45
+ await page.evaluate(() => {
46
+ const world = document.querySelector('.dc-world');
47
+ if (world) {
48
+ world.style.zoom = '1';
49
+ world.style.transform = 'none';
50
+ }
51
+ for (const el of document.querySelectorAll('[data-dc-screen]')) {
52
+ el.style.left = '0px';
53
+ el.style.top = '0px';
54
+ }
55
+ });
56
+ // dom-to-pptx ships a UMD bundle; addScriptTag exposes `window.domToPptx`.
57
+ await page.addScriptTag({ path: bundlePath });
58
+
59
+ // Resolve target handle(s). Multi-mode iterates every artboard; single-
60
+ // mode widens to the closest artboard ancestor of the supplied selector
61
+ // (matches the SVG/HTML shims' behaviour for --selector).
62
+ const handles = multi
63
+ ? await page.locator(selector ?? '[data-dc-screen]').all()
64
+ : [page.locator(selector ?? '[data-dc-screen]:first-of-type').first()];
65
+
66
+ // dom-to-pptx is designed to walk ONE root and emit ONE pptx. For multi,
67
+ // we serialize each artboard separately then merge in Node via pptxgenjs.
68
+ // For v1 we just emit the FIRST artboard if multi=true and warn — proper
69
+ // merge lives in exporters/pptx.ts (which receives back the byte array).
70
+ const handle = handles[0];
71
+ if (!handle) {
72
+ console.error('_pptx-playwright: no target found');
73
+ process.exit(2);
74
+ }
75
+ await handle.waitFor({ state: 'visible', timeout: timeoutMs });
76
+
77
+ // Run dom-to-pptx inside the page. It returns a Blob; we serialise to a
78
+ // byte array for transport across the playwright boundary.
79
+ const bytesArray = await handle.evaluate(async (el) => {
80
+ const target = el.closest('[data-dc-screen]') ?? el;
81
+ // window.domToPptx is the UMD-injected entry.
82
+ const { exportToPptx } = /** @type any */ (window).domToPptx;
83
+ if (typeof exportToPptx !== 'function') {
84
+ throw new Error('dom-to-pptx bundle did not expose exportToPptx');
85
+ }
86
+ const blob = await exportToPptx(target, { filename: 'export.pptx' });
87
+ const ab = await blob.arrayBuffer();
88
+ return Array.from(new Uint8Array(ab));
89
+ });
90
+
91
+ writeFileSync(out, new Uint8Array(bytesArray));
92
+ console.log(out);
93
+ console.error(
94
+ `✓ dom-to-pptx wrote ${out} (${bytesArray.length} bytes, ${handles.length} artboard${handles.length === 1 ? '' : 's available — first emitted'})`
95
+ );
96
+ } finally {
97
+ await browser.close();
98
+ }
@@ -0,0 +1,141 @@
1
+ // _svg-playwright.mjs — playwright shim for the SVG exporter.
2
+ //
3
+ // Uses `dom-to-svg` (felixfbecker/dom-to-svg) to walk the rendered DOM and
4
+ // emit real SVG primitives — <rect>, <text>, <path>, <image> — that vector
5
+ // editors like Affinity Designer / Illustrator / Inkscape decompose
6
+ // correctly. The previous `<foreignObject>`-wrapping approach (DDR-038)
7
+ // renders pixel-perfect in Chrome but Affinity refuses to import it; see
8
+ // DDR-042 for the swap rationale.
9
+ //
10
+ // Bundled IIFE: exporters/_browser-bundles.ts pre-bundles dom-to-svg via
11
+ // Bun.build, caches the result under /tmp, and passes the path via
12
+ // --bundle-path. The shim loads it with addScriptTag.
13
+ //
14
+ // Invocation (Bun.spawn from exporters/svg.ts — not invoked directly):
15
+ // node _svg-playwright.mjs --url <url> --selector <css> --out <path>
16
+ // --bundle-path <iife.js> [--widen-to-artboard] [--multi]
17
+ // [--out-dir <dir>] [--timeout 12]
18
+
19
+ import { mkdirSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { chromium } from 'playwright';
22
+
23
+ const args = Object.fromEntries(
24
+ process.argv.slice(2).reduce((acc, cur, i, all) => {
25
+ if (cur.startsWith('--')) acc.push([cur.slice(2), all[i + 1] ?? '1']);
26
+ return acc;
27
+ }, [])
28
+ );
29
+
30
+ const {
31
+ url,
32
+ selector,
33
+ out,
34
+ 'out-dir': outDir,
35
+ 'bundle-path': bundlePath,
36
+ 'widen-to-artboard': widenFlag,
37
+ multi: multiFlag,
38
+ timeout = '12',
39
+ } = args;
40
+
41
+ if (!url || !bundlePath) {
42
+ console.error(
43
+ 'usage: _svg-playwright.mjs --url <url> --selector <css> --out <path> --bundle-path <iife.js>'
44
+ );
45
+ process.exit(2);
46
+ }
47
+
48
+ const widen = widenFlag !== undefined;
49
+ const multi = multiFlag !== undefined;
50
+ const timeoutMs = Number(timeout) * 1000;
51
+
52
+ const browser = await chromium.launch();
53
+ try {
54
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
55
+ const page = await ctx.newPage();
56
+ await page.goto(url, { waitUntil: 'networkidle', timeout: timeoutMs });
57
+ await page.evaluate(() => document.fonts.ready);
58
+ // Reset the world plane's CSS zoom + transform so artboards render at
59
+ // declared dimensions before dom-to-svg walks the layout.
60
+ await page.evaluate(() => {
61
+ const world = document.querySelector('.dc-world');
62
+ if (world) {
63
+ world.style.zoom = '1';
64
+ world.style.transform = 'none';
65
+ }
66
+ for (const el of document.querySelectorAll('[data-dc-screen]')) {
67
+ el.style.left = '0px';
68
+ el.style.top = '0px';
69
+ }
70
+ });
71
+ // Inject dom-to-svg into the page. Bundle attaches its exports under
72
+ // `window.domToSvg`.
73
+ await page.addScriptTag({ path: bundlePath });
74
+
75
+ const written = [];
76
+
77
+ const serializeOne = async (handle) => {
78
+ return await handle.evaluate(
79
+ async (el, opts) => {
80
+ const target = opts.widenToArtboard ? (el.closest('[data-dc-screen]') ?? el) : el;
81
+ // window.domToSvg is the IIFE-injected entry.
82
+ const { elementToSVG, inlineResources } = /** @type any */ (window).domToSvg;
83
+ const svgDoc = elementToSVG(target);
84
+ // base64-embeds fonts + images so the SVG is portable outside the
85
+ // dev-server origin. Some external fetches fail silently — Affinity
86
+ // tolerates missing resources better than missing primitives.
87
+ try {
88
+ await inlineResources(svgDoc.documentElement);
89
+ } catch {
90
+ /* best-effort */
91
+ }
92
+ return new XMLSerializer().serializeToString(svgDoc);
93
+ },
94
+ { widenToArtboard: opts }
95
+ );
96
+ };
97
+ const opts = { widenToArtboard: widen };
98
+
99
+ if (multi) {
100
+ if (!outDir) {
101
+ console.error('_svg-playwright: --multi requires --out-dir');
102
+ process.exit(2);
103
+ }
104
+ mkdirSync(outDir, { recursive: true });
105
+ const screens = await page.locator(selector ?? '[data-dc-screen]').all();
106
+ for (let i = 0; i < screens.length; i += 1) {
107
+ const handle = screens[i];
108
+ const id = (await handle.getAttribute('data-dc-screen')) ?? `artboard-${i + 1}`;
109
+ const svg = await handle.evaluate(async (el) => {
110
+ // window.domToSvg is the IIFE-injected entry.
111
+ const { elementToSVG, inlineResources, formatXML } = /** @type any */ (window).domToSvg;
112
+ const svgDoc = elementToSVG(el);
113
+ try {
114
+ await inlineResources(svgDoc.documentElement);
115
+ } catch {
116
+ /* */
117
+ }
118
+ return formatXML(new XMLSerializer().serializeToString(svgDoc));
119
+ });
120
+ const target = join(outDir, `${id}.svg`);
121
+ writeFileSync(target, svg, 'utf8');
122
+ written.push(target);
123
+ }
124
+ } else {
125
+ if (!out) {
126
+ console.error('_svg-playwright: --out required when --multi not set');
127
+ process.exit(2);
128
+ }
129
+ mkdirSync(dirname(out), { recursive: true });
130
+ const handle = page.locator(selector ?? '[data-dc-screen]:first-of-type').first();
131
+ await handle.waitFor({ state: 'visible', timeout: timeoutMs });
132
+ const svg = await serializeOne(handle);
133
+ writeFileSync(out, svg, 'utf8');
134
+ written.push(out);
135
+ }
136
+
137
+ for (const w of written) console.log(w);
138
+ console.error(`✓ dom-to-svg wrote ${written.length} svg file(s)`);
139
+ } finally {
140
+ await browser.close();
141
+ }
@@ -1182,7 +1182,6 @@ function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
1182
1182
  );
1183
1183
 
1184
1184
  const showMiniMap = controls?.minimap !== false;
1185
- const showToolbar = controls?.toolbar !== false;
1186
1185
 
1187
1186
  // Drag-state bus (Phase 4.2). Single source of truth: only one artboard
1188
1187
  // drag is active at a time. DCArtboards write here when their local drag
@@ -1492,15 +1491,15 @@ const OVERLAY_CSS = `
1492
1491
  bottom: 16px;
1493
1492
  width: 196px;
1494
1493
  height: 132px;
1495
- background: var(--bg-1, rgba(255,255,255,0.98));
1496
- border: 1px solid var(--u-border-2, rgba(0,0,0,0.08));
1497
- border-radius: 8px;
1498
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1494
+ background: var(--bg-2, var(--bg-1, rgba(255,255,255,0.98)));
1495
+ border: 1px solid var(--fg-0, #1c1917);
1496
+ border-radius: 0;
1497
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1499
1498
  font-size: 10px;
1500
- color: rgba(40,30,20,0.7);
1499
+ color: var(--fg-1, rgba(40,30,20,0.7));
1501
1500
  z-index: 6;
1502
1501
  user-select: none;
1503
- box-shadow: 0 6px 24px rgba(0,0,0,0.08);
1502
+ box-shadow: 4px 4px 0 var(--fg-0, #1c1917);
1504
1503
  overflow: hidden;
1505
1504
  }
1506
1505
  .dc-mm-hd {
@@ -1534,15 +1533,15 @@ const OVERLAY_CSS = `
1534
1533
  transform: translateX(-50%);
1535
1534
  display: flex;
1536
1535
  align-items: stretch;
1537
- background: rgba(255,255,255,0.94);
1538
- border: 1px solid rgba(0,0,0,0.12);
1539
- border-radius: 6px;
1536
+ background: var(--bg-2, rgba(255,255,255,0.94));
1537
+ border: 1px solid var(--fg-0, #1c1917);
1538
+ border-radius: 0;
1540
1539
  overflow: hidden;
1541
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1540
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
1542
1541
  font-size: 11px;
1543
- color: rgba(40,30,20,0.85);
1542
+ color: var(--fg-1, rgba(40,30,20,0.85));
1544
1543
  z-index: 6;
1545
- box-shadow: 0 4px 16px rgba(0,0,0,0.06);
1544
+ box-shadow: 4px 4px 0 var(--fg-0, #1c1917);
1546
1545
  }
1547
1546
  .dc-zoom-tb button {
1548
1547
  appearance: none;
@@ -51,6 +51,7 @@ import {
51
51
  type MenuItem,
52
52
  useContextMenu,
53
53
  } from './context-menu.tsx';
54
+ import { ExportDialogProvider } from './export-dialog.tsx';
54
55
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
55
56
  import { ToolPalette } from './tool-palette.tsx';
56
57
  import {
@@ -203,9 +204,11 @@ function CanvasCore({
203
204
  );
204
205
 
205
206
  return (
206
- <ContextMenuProvider registry={registry}>
207
- <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
208
- </ContextMenuProvider>
207
+ <ExportDialogProvider>
208
+ <ContextMenuProvider registry={registry}>
209
+ <CanvasRouter hostRef={hostRef}>{children}</CanvasRouter>
210
+ </ContextMenuProvider>
211
+ </ExportDialogProvider>
209
212
  );
210
213
  }
211
214
 
@@ -263,6 +266,23 @@ function buildRegistry(deps: {
263
266
  onSelect: () => controller?.reset(),
264
267
  };
265
268
 
269
+ // Phase 6.5 — context-menu → ExportDialog. Each entry dispatches a custom
270
+ // event the dialog provider listens for; this avoids prop-drilling the
271
+ // dialog handle through every menu callback. The scope arg prefills the
272
+ // dialog's scope dropdown so the user lands on the right resolution.
273
+ const exportItem = (id: string, label: string, scope: string, shortcut?: string): MenuItem => ({
274
+ id,
275
+ label,
276
+ shortcut,
277
+ onSelect: () => {
278
+ try {
279
+ window.dispatchEvent(new CustomEvent('maude:open-export', { detail: { scope } }));
280
+ } catch {
281
+ /* non-window environments */
282
+ }
283
+ },
284
+ });
285
+
266
286
  return {
267
287
  element: [
268
288
  [
@@ -298,6 +318,7 @@ function buildRegistry(deps: {
298
318
  },
299
319
  },
300
320
  ],
321
+ [exportItem('export-selection', 'Export selection…', 'selection', '⌘E')],
301
322
  [
302
323
  {
303
324
  id: 'hide',
@@ -334,8 +355,15 @@ function buildRegistry(deps: {
334
355
  fitItem,
335
356
  resetItem,
336
357
  ],
358
+ [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
359
+ ],
360
+ world: [
361
+ [fitItem, resetItem],
362
+ [
363
+ exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
364
+ exportItem('export-project', 'Export project (ZIP)…', 'project-raw'),
365
+ ],
337
366
  ],
338
- world: [[fitItem, resetItem]],
339
367
  overlay: [],
340
368
  };
341
369
  }
@@ -1861,8 +1861,25 @@ function App() {
1861
1861
  const activeFileComments = (activePath && activePath !== SYSTEM_TAB) ? (commentsByFile[activePath] || []) : [];
1862
1862
  const totalOpen = totalCounts(commentsByFile).open;
1863
1863
 
1864
+ // Suppress the native browser context menu across the shell — the canvas
1865
+ // input-router already handles right-click inside the canvas host, but
1866
+ // sidebar / menubar / statusbar / floating chrome would otherwise leak the
1867
+ // native menu on top of our `.dc-context-menu` (or alone, outside canvas).
1868
+ // Editable fields (search box, future text inputs) keep the native menu so
1869
+ // copy/paste still works.
1870
+ const onShellContextMenu = useCallback((e) => {
1871
+ const t = e.target;
1872
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || (t.isContentEditable))) {
1873
+ return;
1874
+ }
1875
+ e.preventDefault();
1876
+ }, []);
1877
+
1864
1878
  return (
1865
- <div className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}>
1879
+ <div
1880
+ className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
1881
+ onContextMenu={onShellContextMenu}
1882
+ >
1866
1883
  <Sidebar
1867
1884
  groups={groups}
1868
1885
  activePath={activePath}
@@ -74,6 +74,27 @@ function noop(name: string) {
74
74
  };
75
75
  }
76
76
 
77
+ // Phase 6.5 T9 — export hooks. The default registry items use noop() so the
78
+ // menu still renders when the dialog provider isn't mounted; consumers wire
79
+ // real `openExport(scope)` callbacks by passing a custom registry to
80
+ // <ContextMenuProvider extra>. Pattern matches the existing Phase 5 noop
81
+ // affordances.
82
+ function defaultExportItem(label: string, scopeHint: string): MenuItem {
83
+ return {
84
+ id: `export-${scopeHint}`,
85
+ label,
86
+ shortcut: scopeHint === 'selection' ? '⌘E' : undefined,
87
+ onSelect: () => {
88
+ const detail = { scope: scopeHint };
89
+ try {
90
+ window.dispatchEvent(new CustomEvent('maude:open-export', { detail }));
91
+ } catch {
92
+ /* SSR / non-window environments */
93
+ }
94
+ },
95
+ };
96
+ }
97
+
77
98
  const DEFAULT_REGISTRY: ContextRegistry = {
78
99
  element: [
79
100
  [
@@ -82,6 +103,7 @@ const DEFAULT_REGISTRY: ContextRegistry = {
82
103
  { id: 'copy-id', label: 'Copy data-cd-id', onSelect: noop('copy-id') },
83
104
  { id: 'inspect', label: 'Inspect', shortcut: '⌥I', onSelect: noop('inspect') },
84
105
  ],
106
+ [defaultExportItem('Export selection…', 'selection')],
85
107
  [
86
108
  { id: 'hide', label: 'Hide', shortcut: '⌘⇧H', onSelect: noop('hide') },
87
109
  { id: 'lock', label: 'Lock', shortcut: '⌘⇧L', onSelect: noop('lock') },
@@ -92,6 +114,7 @@ const DEFAULT_REGISTRY: ContextRegistry = {
92
114
  { id: 'rename', label: 'Rename', shortcut: '↵', onSelect: noop('rename-artboard') },
93
115
  { id: 'duplicate', label: 'Duplicate', shortcut: '⌘D', onSelect: noop('duplicate-artboard') },
94
116
  ],
117
+ [defaultExportItem('Export this artboard…', 'artboard')],
95
118
  [
96
119
  { id: 'fit-one', label: 'Fit just this artboard', onSelect: noop('fit-one') },
97
120
  { id: 'reset-pos', label: 'Reset position', onSelect: noop('reset-artboard-pos') },
@@ -108,6 +131,10 @@ const DEFAULT_REGISTRY: ContextRegistry = {
108
131
  { id: 'fit-view', label: 'Fit to view', shortcut: '1', onSelect: noop('fit-view') },
109
132
  { id: 'reset-view', label: 'Reset view', shortcut: '⌘0', onSelect: noop('reset-view') },
110
133
  ],
134
+ [
135
+ defaultExportItem('Export project (ZIP)…', 'project-raw'),
136
+ defaultExportItem('Export canvas as separate…', 'canvas-as-separate'),
137
+ ],
111
138
  ],
112
139
  overlay: [],
113
140
  };
@@ -131,15 +158,15 @@ const MENU_CSS = `
131
158
  .dc-context-menu {
132
159
  position: fixed;
133
160
  z-index: 7;
134
- background: var(--bg-1, #fff);
135
- border: 1px solid var(--border-default, rgba(0,0,0,0.12));
136
- border-radius: var(--radius-md, 6px);
137
- box-shadow: var(--shadow-md, 0 8px 24px rgba(0,0,0,0.12));
161
+ background: var(--u-bg-2, var(--bg-1, #fff));
162
+ border: 1px solid var(--u-fg-0, #1c1917);
163
+ border-radius: 0;
164
+ box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
138
165
  padding: 4px;
139
166
  min-width: 220px;
140
- font: inherit;
167
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
141
168
  font-size: 12px;
142
- color: var(--fg-0, rgba(20,15,10,0.92));
169
+ color: var(--u-fg-0, var(--fg-0, rgba(20,15,10,0.92)));
143
170
  user-select: none;
144
171
  }
145
172
  .dc-context-menu .dc-menu-sep {
@@ -152,8 +179,8 @@ const MENU_CSS = `
152
179
  justify-content: space-between;
153
180
  align-items: center;
154
181
  gap: 16px;
155
- padding: 6px 10px;
156
- border-radius: var(--radius-sm, 4px);
182
+ padding: 5px 12px;
183
+ border-radius: 0;
157
184
  cursor: pointer;
158
185
  background: transparent;
159
186
  border: 0;
@@ -164,7 +191,7 @@ const MENU_CSS = `
164
191
  }
165
192
  .dc-context-menu .dc-menu-item:hover,
166
193
  .dc-context-menu .dc-menu-item:focus-visible {
167
- background: var(--bg-3, rgba(0,0,0,0.05));
194
+ background: var(--u-bg-3, var(--bg-3, rgba(0,0,0,0.05)));
168
195
  outline: none;
169
196
  }
170
197
  .dc-context-menu .dc-menu-item[disabled] {