@1agh/maude 0.15.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 (53) hide show
  1. package/README.md +4 -2
  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 +227 -3
  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 +111 -9
  15. package/plugins/design/dev-server/client/app.jsx +71 -143
  16. package/plugins/design/dev-server/client/comments-overlay.css +381 -0
  17. package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
  18. package/plugins/design/dev-server/client/styles/4-components.css +5 -161
  19. package/plugins/design/dev-server/client/styles.css +5 -160
  20. package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
  21. package/plugins/design/dev-server/context-menu.tsx +36 -9
  22. package/plugins/design/dev-server/dist/client.bundle.js +52 -211
  23. package/plugins/design/dev-server/dist/styles.css +1 -218
  24. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  25. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  26. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  27. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  28. package/plugins/design/dev-server/exporters/html.ts +103 -0
  29. package/plugins/design/dev-server/exporters/index.ts +135 -0
  30. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  31. package/plugins/design/dev-server/exporters/png.ts +136 -0
  32. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  33. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  34. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  35. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  36. package/plugins/design/dev-server/http.ts +109 -0
  37. package/plugins/design/dev-server/input-router.tsx +21 -0
  38. package/plugins/design/dev-server/inspect.ts +1 -1
  39. package/plugins/design/dev-server/server.mjs +1 -1
  40. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
  41. package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
  42. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  43. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  44. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  45. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  46. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  47. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  48. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  49. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  50. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  51. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  52. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  53. package/plugins/design/templates/_shell.html +33 -0
@@ -0,0 +1,89 @@
1
+ // Lazy IIFE bundling of ESM libraries that need to run inside `page.evaluate`.
2
+ //
3
+ // `dom-to-svg` and `dom-to-pptx` ship as Node ESM only — they can't be
4
+ // `<script>`-loaded into a browser context as-is. Bun.build turns them into
5
+ // single-file IIFE bundles cached under the OS temp dir so the playwright
6
+ // shims can `addScriptTag({ path })` without re-bundling per request.
7
+
8
+ import { existsSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ interface CachedBundle {
13
+ path: string;
14
+ ready: Promise<string>;
15
+ }
16
+
17
+ const bundles = new Map<string, CachedBundle>();
18
+
19
+ async function buildIife(entry: string, globalName: string, cachePath: string): Promise<string> {
20
+ if (existsSync(cachePath)) return cachePath;
21
+ // Bun.build doesn't expose IIFE directly — wrap a generated ESM bundle with
22
+ // a tiny shim that exposes its exports as a window global.
23
+ const built = await Bun.build({
24
+ entrypoints: [entry],
25
+ target: 'browser',
26
+ format: 'esm',
27
+ minify: true,
28
+ });
29
+ if (!built.success) {
30
+ throw new Error(`bundle ${entry} failed: ${built.logs.map((l) => l.message).join('; ')}`);
31
+ }
32
+ const firstOutput = built.outputs[0];
33
+ if (!firstOutput) throw new Error(`bundle ${entry} produced no outputs`);
34
+ const esm = await firstOutput.text();
35
+ // ESM → IIFE wrapper: evaluate the module as a Function body, then attach
36
+ // its exports to `window[globalName]`. We can't use top-level `import`
37
+ // inside a Function, so we transform `export {` to assignments via regex
38
+ // — the Bun-emitted bundle is consistent (`export { a as foo, b as bar };`).
39
+ const exportsMatch = esm.match(/export\s*\{([^}]+)\}\s*;?\s*$/);
40
+ let body = esm;
41
+ let exportsBlock = '';
42
+ if (exportsMatch) {
43
+ body = esm.slice(0, exportsMatch.index);
44
+ const captured = exportsMatch[1] ?? '';
45
+ const entries = captured
46
+ .split(',')
47
+ .map((s) => s.trim())
48
+ .filter(Boolean)
49
+ .map((s) => {
50
+ // `localName as exportedName` | bare `name`
51
+ const m = s.match(/^(.+?)\s+as\s+(.+)$/);
52
+ if (m?.[1] && m[2]) return { local: m[1].trim(), exported: m[2].trim() };
53
+ return { local: s, exported: s };
54
+ });
55
+ exportsBlock = entries
56
+ .map(
57
+ (e) =>
58
+ `globalThis[${JSON.stringify(globalName)}][${JSON.stringify(e.exported)}] = ${e.local};`
59
+ )
60
+ .join('\n');
61
+ }
62
+ const iife = `(function(){
63
+ globalThis[${JSON.stringify(globalName)}] = globalThis[${JSON.stringify(globalName)}] || {};
64
+ ${body}
65
+ ${exportsBlock}
66
+ })();`;
67
+ await Bun.write(cachePath, iife);
68
+ return cachePath;
69
+ }
70
+
71
+ /**
72
+ * Returns the path to an IIFE bundle for the given npm package, attaching its
73
+ * exports under `window[globalName]`. Caches under the OS temp dir so a long-
74
+ * running dev server pays the build cost once.
75
+ */
76
+ export function getBrowserBundle(packageName: string, globalName: string): Promise<string> {
77
+ const key = `${packageName}::${globalName}`;
78
+ const existing = bundles.get(key);
79
+ if (existing) return existing.ready;
80
+
81
+ const entry = require.resolve(packageName);
82
+ const cachePath = path.join(
83
+ tmpdir(),
84
+ `maude-${packageName.replace(/[^a-z0-9]/gi, '_')}-${globalName}.iife.js`
85
+ );
86
+ const ready = buildIife(entry, globalName, cachePath);
87
+ bundles.set(key, { path: cachePath, ready });
88
+ return ready;
89
+ }
@@ -0,0 +1,74 @@
1
+ // Phase 6.5 T6c — `.canva-handoff.md` artifact builder.
2
+ //
3
+ // Emits a self-contained markdown file with three sections:
4
+ // 1. Human-readable summary (artboard count, fidelity caveats)
5
+ // 2. Drag-drop instructions for the universal Canva web path
6
+ // 3. Fenced text-block prompt ready for the user's Canva MCP / agentic tool
7
+ //
8
+ // The prompt block is the load-bearing piece — anyone with a Canva MCP
9
+ // configured in Claude Code / Cursor / equivalent can paste it and let the
10
+ // MCP handle auth + import. Maude never touches credentials.
11
+
12
+ export interface HandoffSummary {
13
+ /** Filename of the .pptx that ships next to this markdown (e.g. `home.pptx`). */
14
+ pptxFilename: string;
15
+ /** Absolute path to the .pptx after the user unzips. Resolved at render time. */
16
+ absolutePath: string;
17
+ /** Canvas slug — used as the Canva design title and as the prompt's anchor. */
18
+ canvasSlug: string;
19
+ /** Total artboard count (= PPTX slide count = expected Canva page count). */
20
+ artboardCount: number;
21
+ /** Optional list of artboard titles, in render order. */
22
+ artboardTitles?: string[];
23
+ }
24
+
25
+ const CAVEATS = `
26
+ - **Fonts:** if your Canva Brand Kit doesn't include the source fonts, Canva substitutes its defaults. Text remains editable.
27
+ - **Gradients:** CSS gradients translate to native PPT gradients. Multi-stop fidelity is approximate.
28
+ - **Shadows, blend modes:** advanced effects rasterize on import.
29
+ - **Layouts:** flex / grid get flattened to absolute coordinates at export-time viewport (1440 × declared artboard height).
30
+ `.trim();
31
+
32
+ export function buildHandoffMarkdown(s: HandoffSummary): string {
33
+ const plural = s.artboardCount === 1 ? 'artboard' : 'artboards';
34
+ const titles = s.artboardTitles?.length
35
+ ? s.artboardTitles.map((t, i) => `${i + 1}. ${t}`).join('\n')
36
+ : `${s.artboardCount} ${plural}`;
37
+
38
+ return `# Canva handoff — ${s.canvasSlug}
39
+
40
+ Editable handoff bundle exported from Maude. The companion file \`${s.pptxFilename}\` is a native PowerPoint document — Canva imports it as editable text, shapes, and images, **not** a flat raster.
41
+
42
+ ## What's inside
43
+
44
+ - **${s.artboardCount}** artboard${s.artboardCount === 1 ? '' : 's'} → ${s.artboardCount} Canva page${s.artboardCount === 1 ? '' : 's'} on import.
45
+ ${titles}
46
+
47
+ ## Option A — drag-drop (works on any Canva tier)
48
+
49
+ 1. Open https://www.canva.com in your browser.
50
+ 2. Drag \`${s.pptxFilename}\` from this folder into the Canva home screen, or use **Create a design → Upload media → Import file**.
51
+ 3. Canva opens the imported design. Text, shapes, and images are individually selectable and editable.
52
+
53
+ ## Option B — automate via your Canva MCP
54
+
55
+ If you have a Canva MCP server configured in Claude Code / Cursor / Goose / any agentic tool, paste the prompt below into a fresh chat:
56
+
57
+ \`\`\`text
58
+ Use my Canva MCP to import the PowerPoint file at the path below into a new Canva design titled "${s.canvasSlug}". Preserve text editability, shape fills and strokes, image swappability, and the artboard-to-page mapping (one PPTX slide = one Canva page). After the import job completes, return the Canva design URL.
59
+
60
+ File path: ${s.absolutePath}
61
+ Slides expected: ${s.artboardCount}
62
+ \`\`\`
63
+
64
+ Maude doesn't see your Canva credentials — your MCP handles auth and the import call itself.
65
+
66
+ ## Fidelity caveats
67
+
68
+ ${CAVEATS}
69
+
70
+ ---
71
+
72
+ Generated by [Maude](https://github.com/1aGh/maude).
73
+ `;
74
+ }
@@ -0,0 +1,126 @@
1
+ // Phase 6.5 T6c — Canva handoff adapter.
2
+ //
3
+ // Wraps T6b's PPTX bytes with a sibling `.canva-handoff.md` artifact and
4
+ // ZIPs both into a single bundle. The user unzips, then either drag-drops
5
+ // the PPTX into Canva web (universal path) or feeds the markdown prompt
6
+ // to their own Canva MCP (one-click handoff for users who've configured
7
+ // one). Maude never touches Canva credentials — see DDR.
8
+ //
9
+ // `--canva=raster` legacy bundle (T6d) is the opt-out for users who want a
10
+ // flat reference image set instead of the editable handoff. Routed via
11
+ // `options.mode = 'raster'`.
12
+
13
+ import { tmpdir } from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ import JSZip from 'jszip';
17
+
18
+ import { buildHandoffMarkdown } from './canva-handoff-prompt.ts';
19
+ import type { ExportContext, ExportOptions, ExportResult } from './index.ts';
20
+ import { run as runPng } from './png.ts';
21
+ import { run as runPptx } from './pptx.ts';
22
+ import type { Target } from './scope.ts';
23
+
24
+ async function buildRasterBundle(
25
+ elementTargets: Array<Extract<Target, { kind: 'element' }>>,
26
+ options: ExportOptions,
27
+ ctx: ExportContext
28
+ ): Promise<ExportResult> {
29
+ // T6d — legacy PNG+CSV+README handoff. Reuses the PNG adapter for capture
30
+ // then assembles a ZIP with a manifest CSV + a README pointing the user
31
+ // at the raster files as reference imagery (no editable Canva path).
32
+ const pngResult = await runPng(elementTargets, options, ctx);
33
+ const zip = new JSZip();
34
+ const rows: string[] = ['index,filename,canvas_slug'];
35
+
36
+ if (pngResult.contentType === 'image/png') {
37
+ zip.file(pngResult.filename, pngResult.body);
38
+ rows.push(`1,${pngResult.filename},${elementTargets[0]?.canvasSlug ?? 'export'}`);
39
+ } else if (pngResult.contentType === 'application/zip' && pngResult.body.byteLength) {
40
+ const inner = await JSZip.loadAsync(pngResult.body);
41
+ let i = 0;
42
+ for (const fname of Object.keys(inner.files)) {
43
+ const file = inner.file(fname);
44
+ if (!file) continue;
45
+ const bytes = await file.async('uint8array');
46
+ zip.file(fname, bytes);
47
+ i += 1;
48
+ rows.push(`${i},${fname},${elementTargets[0]?.canvasSlug ?? 'export'}`);
49
+ }
50
+ }
51
+
52
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
53
+ zip.file('manifest.csv', rows.join('\n'));
54
+ zip.file(
55
+ 'README.md',
56
+ '# Canva raster bundle\n\n' +
57
+ 'Legacy reference-only handoff. PNGs in this folder are NOT editable in Canva — they import as flat images.\n\n' +
58
+ 'For an **editable** Canva design (text, shapes, images), re-export without `--canva=raster` to get the PPTX + MCP-prompt bundle instead.\n'
59
+ );
60
+ const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
61
+ return {
62
+ filename: `${baseSlug}.canva-raster.zip`,
63
+ contentType: 'application/zip',
64
+ body: zipBytes,
65
+ };
66
+ }
67
+
68
+ export async function run(
69
+ targets: Target[],
70
+ options: ExportOptions,
71
+ ctx: ExportContext
72
+ ): Promise<ExportResult> {
73
+ if (!targets.length) {
74
+ return {
75
+ filename: 'export.canva.zip',
76
+ contentType: 'application/zip',
77
+ body: new Uint8Array(0),
78
+ };
79
+ }
80
+ const elementTargets = targets.filter(
81
+ (t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
82
+ );
83
+ if (!elementTargets.length) {
84
+ throw new Error('canva adapter requires element targets (got file-tree)');
85
+ }
86
+
87
+ if (options.mode === 'raster') {
88
+ return buildRasterBundle(elementTargets, options, ctx);
89
+ }
90
+
91
+ // Editable handoff — delegate to the pptx adapter (which now runs
92
+ // dom-to-pptx) for the payload, then wrap with handoff markdown. We pass
93
+ // the full targets through so multi-artboard canvases produce one slide
94
+ // per artboard inside the pptx.
95
+ const pptxResult = await runPptx(elementTargets, options, ctx);
96
+ const pptxBytes = pptxResult.body;
97
+
98
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
99
+ const pptxName = `${baseSlug}.pptx`;
100
+ // dom-to-pptx doesn't surface artboard count from outside; we infer it
101
+ // from the target's multi-ness. For single-artboard exports the count is
102
+ // 1; for canvas-as-separate we report "all artboards" plainly — Canva
103
+ // splits them on import regardless of what we claim here.
104
+ const artboardCount = elementTargets[0]?.multi ? -1 : 1;
105
+ const markdown = buildHandoffMarkdown({
106
+ pptxFilename: pptxName,
107
+ absolutePath: path.join('<your-unzip-location>', pptxName),
108
+ canvasSlug: baseSlug,
109
+ artboardCount: artboardCount > 0 ? artboardCount : 1,
110
+ });
111
+
112
+ const zip = new JSZip();
113
+ zip.file(pptxName, pptxBytes);
114
+ zip.file(`${baseSlug}.canva-handoff.md`, markdown);
115
+ const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
116
+
117
+ return {
118
+ filename: `${baseSlug}.canva.zip`,
119
+ contentType: 'application/zip',
120
+ body: zipBytes,
121
+ };
122
+ }
123
+
124
+ // Suppress unused-import for `tmpdir` — kept for future raster-bundle
125
+ // branches that may need a temp dir.
126
+ void tmpdir;
@@ -0,0 +1,103 @@
1
+ // Phase 6.5 T5 — HTML adapter (standalone bundler).
2
+ //
3
+ // Renders the target through Playwright, emits a self-contained `index.html`
4
+ // with all stylesheets inlined (fonts + remote images still referenced by
5
+ // origin — full asset inlining is a follow-up). Output is always a ZIP per
6
+ // plan T5 ("Always zipped (multi-file)"), even for a single artboard,
7
+ // because the consuming workflow typically wants one bundle per export.
8
+
9
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import JSZip from 'jszip';
14
+
15
+ import {
16
+ type ExportContext,
17
+ type ExportOptions,
18
+ type ExportResult,
19
+ canvasShellUrl,
20
+ } from './index.ts';
21
+ import type { Target } from './scope.ts';
22
+
23
+ const HTML_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_html-playwright.mjs');
24
+
25
+ async function captureHtml(
26
+ target: Extract<Target, { kind: 'element' }>,
27
+ ctx: ExportContext,
28
+ outDir: string,
29
+ timeoutSec: number
30
+ ): Promise<string[]> {
31
+ const args = [
32
+ HTML_PLAYWRIGHT,
33
+ '--url',
34
+ canvasShellUrl(ctx, target.file),
35
+ '--selector',
36
+ target.cssPath,
37
+ '--timeout',
38
+ String(timeoutSec),
39
+ ];
40
+ if (target.multi) {
41
+ args.push('--multi', '1', '--out-dir', outDir);
42
+ } else {
43
+ args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.html`));
44
+ }
45
+ const proc = Bun.spawn(['node', ...args], {
46
+ cwd: path.dirname(HTML_PLAYWRIGHT),
47
+ stdout: 'pipe',
48
+ stderr: 'pipe',
49
+ });
50
+ const [stdout, stderr] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ ]);
54
+ const code = await proc.exited;
55
+ if (code !== 0) {
56
+ throw new Error(`_html-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
57
+ }
58
+ return stdout
59
+ .split('\n')
60
+ .map((s) => s.trim())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ export async function run(
65
+ targets: Target[],
66
+ options: ExportOptions,
67
+ ctx: ExportContext
68
+ ): Promise<ExportResult> {
69
+ if (!targets.length) {
70
+ return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
71
+ }
72
+ const elementTargets = targets.filter(
73
+ (t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
74
+ );
75
+ if (!elementTargets.length) {
76
+ throw new Error('html adapter requires element targets (got file-tree)');
77
+ }
78
+ const timeoutSec = (options.timeoutSec as number | undefined) ?? 8;
79
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-html-'));
80
+ try {
81
+ const written: string[] = [];
82
+ for (const t of elementTargets) {
83
+ const paths = await captureHtml(t, ctx, tmp, timeoutSec);
84
+ written.push(...paths);
85
+ }
86
+ if (!written.length) {
87
+ return { filename: 'export.zip', contentType: 'application/zip', body: new Uint8Array(0) };
88
+ }
89
+ const zip = new JSZip();
90
+ for (const p of written) {
91
+ zip.file(path.basename(p), new Uint8Array(readFileSync(p)));
92
+ }
93
+ const zipBytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
94
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
95
+ return {
96
+ filename: `${baseSlug}.html.zip`,
97
+ contentType: 'application/zip',
98
+ body: zipBytes,
99
+ };
100
+ } finally {
101
+ rmSync(tmp, { recursive: true, force: true });
102
+ }
103
+ }
@@ -0,0 +1,135 @@
1
+ // Phase 6.5 T1 — adapter registry + dispatch.
2
+ //
3
+ // Each format adapter exports a single `run(targets, options, ctx) → Result`
4
+ // function. This module owns the (format → adapter) lookup and the shared
5
+ // `Result` envelope. Stub adapters live in `./{png,pdf,svg,html,pptx,canva,zip}.ts`
6
+ // and currently return zero-byte payloads with valid MIME types — T2…T7
7
+ // replace them with real renderers.
8
+
9
+ import path from 'node:path';
10
+
11
+ import * as canva from './canva.ts';
12
+ import * as html from './html.ts';
13
+ import * as pdf from './pdf.ts';
14
+ import * as png from './png.ts';
15
+ import * as pptx from './pptx.ts';
16
+ import { type ResolveScopeArgs, type Scope, type Target, resolveScope } from './scope.ts';
17
+ import * as svg from './svg.ts';
18
+ import * as zip from './zip.ts';
19
+
20
+ export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
21
+
22
+ export const FORMATS: readonly Format[] = ['png', 'pdf', 'svg', 'html', 'pptx', 'canva', 'zip'];
23
+
24
+ /** Options bag forwarded to the adapter. Format-specific keys are validated by each adapter. */
25
+ export type ExportOptions = Record<string, unknown>;
26
+
27
+ export interface ExportContext {
28
+ /** Absolute design root (e.g. /abs/.design). */
29
+ designRoot: string;
30
+ /** Absolute repo root. */
31
+ repoRoot: string;
32
+ /** Dev-server origin including port (`http://localhost:NNNN`) — adapters that render via Playwright resolve canvas URLs against this. */
33
+ serverOrigin: string;
34
+ /**
35
+ * designRoot-relative path to the tokens CSS file (e.g. `system/colors_and_type.css`).
36
+ * Threaded into the `?tokens=` query param on the canvas-shell URL so the
37
+ * standalone shell actually loads the project's design tokens — without it
38
+ * `var(--bg-0)` resolves to nothing and renders blank.
39
+ */
40
+ tokensCssRel?: string;
41
+ /** designRoot-relative path to a shared components CSS file (optional). */
42
+ componentsCssRel?: string;
43
+ }
44
+
45
+ export interface ExportResult {
46
+ /** Final filename (no path). E.g. `mockup.pdf` or `mockup.zip`. */
47
+ filename: string;
48
+ /** MIME type for the Content-Disposition / Content-Type header. */
49
+ contentType: string;
50
+ /** Payload bytes. T1 stubs return zero-byte placeholders. */
51
+ body: Uint8Array;
52
+ }
53
+
54
+ export interface Adapter {
55
+ run(targets: Target[], options: ExportOptions, ctx: ExportContext): Promise<ExportResult>;
56
+ }
57
+
58
+ const REGISTRY: Record<Format, Adapter> = {
59
+ png,
60
+ pdf,
61
+ svg,
62
+ html,
63
+ pptx,
64
+ canva,
65
+ zip,
66
+ };
67
+
68
+ export function getAdapter(format: Format): Adapter | null {
69
+ return REGISTRY[format] ?? null;
70
+ }
71
+
72
+ export function isFormat(s: unknown): s is Format {
73
+ return typeof s === 'string' && (FORMATS as readonly string[]).includes(s);
74
+ }
75
+
76
+ export function isScope(s: unknown): s is Scope {
77
+ return s === 'selection' || s === 'artboard' || s === 'canvas-as-separate' || s === 'project-raw';
78
+ }
79
+
80
+ /**
81
+ * One-shot dispatch — resolve scope, look up adapter, run it. The HTTP layer
82
+ * is a thin shell over this so CLI/slash can call the same path without
83
+ * re-implementing the orchestration.
84
+ */
85
+ export async function runExport(args: {
86
+ format: Format;
87
+ scope: Scope;
88
+ options: ExportOptions;
89
+ resolve: Omit<ResolveScopeArgs, 'scope'>;
90
+ ctx: ExportContext;
91
+ }): Promise<ExportResult> {
92
+ const targets = await resolveScope({ ...args.resolve, scope: args.scope });
93
+ const adapter = getAdapter(args.format);
94
+ if (!adapter) {
95
+ throw new Error(`unknown format: ${args.format}`);
96
+ }
97
+ return adapter.run(targets, args.options, args.ctx);
98
+ }
99
+
100
+ export { resolveScope };
101
+ export type { Scope, Target };
102
+
103
+ /**
104
+ * Build the `_canvas-shell.html?canvas=…&tokens=…&components=…` URL the
105
+ * playwright shims navigate to. Three normalisations the shell expects:
106
+ *
107
+ * 1. `canvas=` is a **designRoot-relative** path (`ui/Foo.tsx`), NOT a
108
+ * repo-relative one (`.design/ui/Foo.tsx`). Without the strip the shell
109
+ * builds `/.design/.design/ui/Foo.tsx` and 404s.
110
+ * 2. `tokens=` is the designRoot-relative path to the project's tokens CSS.
111
+ * The shell only loads `<link id="canvas-tokens">` when this is set —
112
+ * missing it means every `var(--bg-0)` etc. resolves to undefined and
113
+ * the rendered DOM looks blank in screenshots.
114
+ * 3. `components=` (optional) — designRoot-relative path to a shared
115
+ * components CSS file. Same shell behaviour as tokens.
116
+ *
117
+ * `_active.json.active` leaks the prefixed form straight through; this helper
118
+ * is the single normalization point. `ctx.tokensCssRel` is supplied by the
119
+ * HTTP handler from `cfg.tokensCssRel`.
120
+ */
121
+ export function canvasShellUrl(ctx: ExportContext, file: string): string {
122
+ const designRel = path.basename(ctx.designRoot);
123
+ const stripPrefix = `${designRel}/`;
124
+ const rel = file.startsWith(stripPrefix) ? file.slice(stripPrefix.length) : file;
125
+ const params = new URLSearchParams();
126
+ params.set('canvas', rel);
127
+ if (ctx.tokensCssRel) params.set('tokens', ctx.tokensCssRel);
128
+ if (ctx.componentsCssRel) params.set('components', ctx.componentsCssRel);
129
+ // Suppress dev-server overlays (tool palette, mini-map, halos, pins, snap
130
+ // guides, annotation chrome) so the export captures only the artboard
131
+ // content. The shell's tiny <style id="canvas-hide-chrome"> rule flips
132
+ // active when this is set.
133
+ params.set('hide-chrome', '1');
134
+ return `${ctx.serverOrigin}/_canvas-shell.html?${params.toString()}`;
135
+ }
@@ -0,0 +1,109 @@
1
+ // Phase 6.5 T3 — PDF adapter (Playwright page.pdf, vector-faithful).
2
+ //
3
+ // Uses Chromium's print-to-PDF pipeline so the output is a TRUE vector PDF —
4
+ // selectable text, web fonts embedded, SVG primitives kept as paths. The old
5
+ // pdf-lib-over-PNG approach (which the user correctly complained was raster)
6
+ // is gone; this adapter is now ~50 lines because Chromium does the work.
7
+ //
8
+ // Multi-target rendering: one PDF per target via the playwright shim, then
9
+ // pdf-lib concatenates pages into a single document.
10
+
11
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ import { PDFDocument } from 'pdf-lib';
16
+
17
+ import {
18
+ type ExportContext,
19
+ type ExportOptions,
20
+ type ExportResult,
21
+ canvasShellUrl,
22
+ } from './index.ts';
23
+ import type { Target } from './scope.ts';
24
+
25
+ const PDF_PLAYWRIGHT = path.join(import.meta.dir, '..', 'bin', '_pdf-playwright.mjs');
26
+
27
+ async function capturePdf(
28
+ target: Extract<Target, { kind: 'element' }>,
29
+ ctx: ExportContext,
30
+ outDir: string,
31
+ timeoutSec: number
32
+ ): Promise<string[]> {
33
+ const args = [
34
+ PDF_PLAYWRIGHT,
35
+ '--url',
36
+ canvasShellUrl(ctx, target.file),
37
+ '--selector',
38
+ target.cssPath,
39
+ '--timeout',
40
+ String(timeoutSec),
41
+ ];
42
+ if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
43
+ else args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
44
+
45
+ const proc = Bun.spawn(['node', ...args], {
46
+ cwd: path.dirname(PDF_PLAYWRIGHT),
47
+ stdout: 'pipe',
48
+ stderr: 'pipe',
49
+ });
50
+ const [stdout, stderr] = await Promise.all([
51
+ new Response(proc.stdout).text(),
52
+ new Response(proc.stderr).text(),
53
+ ]);
54
+ const code = await proc.exited;
55
+ if (code !== 0) {
56
+ throw new Error(`_pdf-playwright exited ${code}: ${stderr.trim() || stdout.trim()}`);
57
+ }
58
+ return stdout
59
+ .split('\n')
60
+ .map((s) => s.trim())
61
+ .filter(Boolean);
62
+ }
63
+
64
+ export async function run(
65
+ targets: Target[],
66
+ options: ExportOptions,
67
+ ctx: ExportContext
68
+ ): Promise<ExportResult> {
69
+ if (!targets.length) {
70
+ return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
71
+ }
72
+ const elementTargets = targets.filter(
73
+ (t): t is Extract<Target, { kind: 'element' }> => t.kind === 'element'
74
+ );
75
+ if (!elementTargets.length) {
76
+ throw new Error('pdf adapter requires element targets (got file-tree)');
77
+ }
78
+ const timeoutSec = (options.timeoutSec as number | undefined) ?? 12;
79
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-'));
80
+ try {
81
+ const written: string[] = [];
82
+ for (const t of elementTargets) {
83
+ const paths = await capturePdf(t, ctx, tmp, timeoutSec);
84
+ written.push(...paths);
85
+ }
86
+ if (!written.length) {
87
+ return { filename: 'export.pdf', contentType: 'application/pdf', body: new Uint8Array(0) };
88
+ }
89
+ if (written.length === 1) {
90
+ const bytes = new Uint8Array(readFileSync(written[0]));
91
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
92
+ return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
93
+ }
94
+ // Multi-target → concatenate via pdf-lib `copyPages`. The vector
95
+ // primitives produced by Chromium copy through losslessly — we never
96
+ // re-render or rasterize.
97
+ const out = await PDFDocument.create();
98
+ for (const p of written) {
99
+ const src = await PDFDocument.load(new Uint8Array(readFileSync(p)));
100
+ const pages = await out.copyPages(src, src.getPageIndices());
101
+ for (const page of pages) out.addPage(page);
102
+ }
103
+ const bytes = await out.save();
104
+ const baseSlug = elementTargets[0]?.canvasSlug ?? 'export';
105
+ return { filename: `${baseSlug}.pdf`, contentType: 'application/pdf', body: bytes };
106
+ } finally {
107
+ rmSync(tmp, { recursive: true, force: true });
108
+ }
109
+ }