@1agh/maude 0.16.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/cli/cli-wrapper.cjs +0 -0
- package/cli/commands/design.mjs +264 -16
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +41 -0
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/build.ts +118 -6
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-pipeline.ts +5 -0
- package/plugins/design/dev-server/canvas-shell.tsx +32 -4
- package/plugins/design/dev-server/client/app.jsx +18 -1
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +11 -3
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +80 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/compile-entry.test.ts +134 -0
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- package/plugins/design/templates/_shell.html +33 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file export-dialog.tsx — Phase 6.5 T8 export dialog.
|
|
3
|
+
* @scope plugins/design/dev-server/export-dialog.tsx
|
|
4
|
+
* @purpose Native `<dialog>`-based export modal. Three controls — format,
|
|
5
|
+
* scope, per-format options — plus a Recent tab populated by
|
|
6
|
+
* `/_api/export-history`. Submit fires `POST /_api/export`, the
|
|
7
|
+
* response is piped to a Blob URL anchor download. `⌘E` opens
|
|
8
|
+
* the dialog from anywhere inside the canvas; `⌘⇧E` re-runs the
|
|
9
|
+
* most recent export without opening (T10 fast path).
|
|
10
|
+
*
|
|
11
|
+
* Mounts inside the canvas runtime alongside tool-palette and
|
|
12
|
+
* context-menu; consumer wraps the canvas with
|
|
13
|
+
* `<ExportDialogProvider>` to make `useExportDialog()` available.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type ReactNode,
|
|
18
|
+
createContext,
|
|
19
|
+
useCallback,
|
|
20
|
+
useContext,
|
|
21
|
+
useEffect,
|
|
22
|
+
useMemo,
|
|
23
|
+
useRef,
|
|
24
|
+
useState,
|
|
25
|
+
} from 'react';
|
|
26
|
+
|
|
27
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
|
+
// Types
|
|
29
|
+
|
|
30
|
+
export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
|
|
31
|
+
export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
|
|
32
|
+
|
|
33
|
+
const FORMAT_META: Record<Format, { label: string; description: string; defaultExt: string }> = {
|
|
34
|
+
png: { label: 'PNG', description: 'Raster image, one per artboard.', defaultExt: '.png' },
|
|
35
|
+
pdf: { label: 'PDF', description: 'Multi-page PDF, one page per artboard.', defaultExt: '.pdf' },
|
|
36
|
+
svg: {
|
|
37
|
+
label: 'SVG',
|
|
38
|
+
description: 'Vector wrapper over rendered HTML. Editable in Illustrator.',
|
|
39
|
+
defaultExt: '.svg',
|
|
40
|
+
},
|
|
41
|
+
html: {
|
|
42
|
+
label: 'HTML',
|
|
43
|
+
description: 'Standalone runnable bundle. Drop into a static host.',
|
|
44
|
+
defaultExt: '.zip',
|
|
45
|
+
},
|
|
46
|
+
pptx: {
|
|
47
|
+
label: 'PPTX',
|
|
48
|
+
description: 'Editable PowerPoint. Opens in Keynote, Google Slides.',
|
|
49
|
+
defaultExt: '.pptx',
|
|
50
|
+
},
|
|
51
|
+
canva: {
|
|
52
|
+
label: 'Canva',
|
|
53
|
+
description: 'PPTX + handoff prompt. Drag into Canva or feed to your Canva MCP.',
|
|
54
|
+
defaultExt: '.zip',
|
|
55
|
+
},
|
|
56
|
+
zip: {
|
|
57
|
+
label: 'ZIP (source)',
|
|
58
|
+
description: 'Entire .design/ as raw source files. No renders.',
|
|
59
|
+
defaultExt: '.zip',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const SCOPE_META: Record<Scope, { label: string; description: string }> = {
|
|
64
|
+
selection: { label: 'Selection', description: 'Just the currently-selected element.' },
|
|
65
|
+
artboard: { label: 'Artboard', description: 'The single artboard containing the selection.' },
|
|
66
|
+
'canvas-as-separate': {
|
|
67
|
+
label: 'Canvas → separate',
|
|
68
|
+
description: 'Every artboard on the active canvas as N files.',
|
|
69
|
+
},
|
|
70
|
+
'project-raw': {
|
|
71
|
+
label: 'Project (raw)',
|
|
72
|
+
description: 'The entire `.design/` tree, minus runtime files.',
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const VALID_SCOPES_PER_FORMAT: Record<Format, Scope[]> = {
|
|
77
|
+
png: ['selection', 'artboard', 'canvas-as-separate'],
|
|
78
|
+
pdf: ['selection', 'artboard', 'canvas-as-separate'],
|
|
79
|
+
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
80
|
+
html: ['artboard', 'canvas-as-separate'],
|
|
81
|
+
pptx: ['canvas-as-separate'],
|
|
82
|
+
canva: ['canvas-as-separate'],
|
|
83
|
+
zip: ['project-raw'],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export interface ExportHistoryEntry {
|
|
87
|
+
format: Format;
|
|
88
|
+
scope: Scope;
|
|
89
|
+
options?: Record<string, unknown>;
|
|
90
|
+
filename: string;
|
|
91
|
+
at: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface OpenOptions {
|
|
95
|
+
/** Pre-fills the scope dropdown (e.g. from context-menu "Export this artboard"). */
|
|
96
|
+
scope?: Scope;
|
|
97
|
+
/** Pre-fills format. */
|
|
98
|
+
format?: Format;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface ExportDialogValue {
|
|
102
|
+
open(opts?: OpenOptions): void;
|
|
103
|
+
close(): void;
|
|
104
|
+
/** Re-run the most recent export without opening the dialog. */
|
|
105
|
+
rerunLast(): Promise<void>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const ExportDialogContext = createContext<ExportDialogValue | null>(null);
|
|
109
|
+
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
// CSS — visual language mirrors tool-palette + context-menu (8 px radius,
|
|
112
|
+
// hairline border, soft shadow). Scoped to .dc-export-dialog.
|
|
113
|
+
|
|
114
|
+
const DIALOG_CSS = `
|
|
115
|
+
.dc-export-dialog {
|
|
116
|
+
border: 1px solid var(--u-fg-0, #1c1917);
|
|
117
|
+
padding: 0;
|
|
118
|
+
border-radius: 0;
|
|
119
|
+
background: var(--u-bg-2, var(--bg-1, #fff));
|
|
120
|
+
box-shadow: 4px 4px 0 var(--u-fg-0, #1c1917);
|
|
121
|
+
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
122
|
+
color: var(--u-fg-0, var(--fg-0, #1a1a1a));
|
|
123
|
+
width: min(640px, 100vw - 48px);
|
|
124
|
+
max-height: min(560px, 100vh - 48px);
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
}
|
|
127
|
+
.dc-export-dialog::backdrop { background: rgba(20, 20, 30, 0.32); }
|
|
128
|
+
.dc-export-dialog header { padding: 16px 20px; border-bottom: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: space-between; align-items: center; }
|
|
129
|
+
.dc-export-dialog header h2 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.005em; }
|
|
130
|
+
.dc-export-dialog header .dc-ed-close { background: transparent; border: 0; cursor: pointer; padding: 4px 8px; color: var(--fg-1, rgba(40,30,20,0.6)); font: inherit; font-size: 12px; }
|
|
131
|
+
.dc-export-dialog .dc-ed-body { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px 20px; }
|
|
132
|
+
.dc-export-dialog label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin-bottom: 6px; }
|
|
133
|
+
.dc-export-dialog select { width: 100%; padding: 8px 10px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 13px; color: inherit; }
|
|
134
|
+
.dc-export-dialog .dc-ed-desc { font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); margin-top: 6px; line-height: 1.4; }
|
|
135
|
+
.dc-export-dialog .dc-ed-recent { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); background: var(--bg-2, rgba(0,0,0,0.02)); }
|
|
136
|
+
.dc-export-dialog .dc-ed-recent h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-2, rgba(40,30,20,0.5)); margin: 0 0 8px; }
|
|
137
|
+
.dc-export-dialog .dc-ed-recent ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 120px; overflow-y: auto; }
|
|
138
|
+
.dc-export-dialog .dc-ed-recent button { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; background: transparent; border: 1px solid transparent; border-radius: 0; cursor: pointer; font: inherit; font-size: 12px; color: inherit; width: 100%; text-align: left; }
|
|
139
|
+
.dc-export-dialog .dc-ed-recent button:hover { background: rgba(0,0,0,0.04); border-color: rgba(0,0,0,0.06); }
|
|
140
|
+
.dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
|
|
141
|
+
.dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
|
|
142
|
+
.dc-export-dialog footer button.dc-ed-primary { background: var(--accent, #1a1a1a); color: var(--accent-fg, #fff); border-color: transparent; }
|
|
143
|
+
.dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
144
|
+
.dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); }
|
|
145
|
+
.dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
// Provider
|
|
150
|
+
|
|
151
|
+
export function ExportDialogProvider({ children }: { children: ReactNode }): ReactNode {
|
|
152
|
+
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
153
|
+
const [openState, setOpenState] = useState<OpenOptions | null>(null);
|
|
154
|
+
const [history, setHistory] = useState<ExportHistoryEntry[]>([]);
|
|
155
|
+
const [submitting, setSubmitting] = useState(false);
|
|
156
|
+
const [status, setStatus] = useState<{ text: string; isError: boolean } | null>(null);
|
|
157
|
+
|
|
158
|
+
const open = useCallback((opts?: OpenOptions) => {
|
|
159
|
+
setStatus(null);
|
|
160
|
+
setOpenState(opts ?? {});
|
|
161
|
+
}, []);
|
|
162
|
+
const close = useCallback(() => {
|
|
163
|
+
setOpenState(null);
|
|
164
|
+
dialogRef.current?.close();
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
// Pre-load history when the dialog opens; refresh after each export.
|
|
168
|
+
const loadHistory = useCallback(async () => {
|
|
169
|
+
try {
|
|
170
|
+
const r = await fetch('/_api/export-history');
|
|
171
|
+
if (!r.ok) return;
|
|
172
|
+
const data = (await r.json()) as { history: ExportHistoryEntry[] };
|
|
173
|
+
setHistory(Array.isArray(data.history) ? data.history : []);
|
|
174
|
+
} catch {
|
|
175
|
+
/* ignore — history is best-effort */
|
|
176
|
+
}
|
|
177
|
+
}, []);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!openState) return;
|
|
181
|
+
void loadHistory();
|
|
182
|
+
dialogRef.current?.showModal();
|
|
183
|
+
}, [openState, loadHistory]);
|
|
184
|
+
|
|
185
|
+
// ⌘E / Ctrl+E to open; ⌘⇧E / Ctrl+Shift+E to re-run last.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
function onKey(e: KeyboardEvent) {
|
|
188
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
189
|
+
if (!mod || e.key.toLowerCase() !== 'e') return;
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
if (e.shiftKey) void rerunLast();
|
|
192
|
+
else open();
|
|
193
|
+
}
|
|
194
|
+
window.addEventListener('keydown', onKey);
|
|
195
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
}, [open]);
|
|
198
|
+
|
|
199
|
+
// Phase 6.5 T9 — context-menu entries dispatch `maude:open-export` so they
|
|
200
|
+
// don't have to prop-drill the dialog handle through every consumer.
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
function onCustom(e: Event) {
|
|
203
|
+
const detail = (e as CustomEvent<{ scope?: Scope; format?: Format }>).detail ?? {};
|
|
204
|
+
open(detail);
|
|
205
|
+
}
|
|
206
|
+
window.addEventListener('maude:open-export', onCustom as EventListener);
|
|
207
|
+
return () => window.removeEventListener('maude:open-export', onCustom as EventListener);
|
|
208
|
+
}, [open]);
|
|
209
|
+
|
|
210
|
+
// ─── submit handlers ─────────────────────────────────────────────────────
|
|
211
|
+
const submit = useCallback(
|
|
212
|
+
async (format: Format, scope: Scope, options: Record<string, unknown>) => {
|
|
213
|
+
setSubmitting(true);
|
|
214
|
+
setStatus(null);
|
|
215
|
+
try {
|
|
216
|
+
const r = await fetch('/_api/export', {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'content-type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ format, scope, options }),
|
|
220
|
+
});
|
|
221
|
+
if (!r.ok) {
|
|
222
|
+
const text = await r.text();
|
|
223
|
+
setStatus({ text: `Export failed: ${text || r.status}`, isError: true });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const disp = r.headers.get('Content-Disposition') ?? '';
|
|
227
|
+
const filename =
|
|
228
|
+
/filename="([^"]+)"/.exec(disp)?.[1] ?? `export${FORMAT_META[format].defaultExt}`;
|
|
229
|
+
const blob = await r.blob();
|
|
230
|
+
const url = URL.createObjectURL(blob);
|
|
231
|
+
const a = document.createElement('a');
|
|
232
|
+
a.href = url;
|
|
233
|
+
a.download = filename;
|
|
234
|
+
document.body.appendChild(a);
|
|
235
|
+
a.click();
|
|
236
|
+
a.remove();
|
|
237
|
+
URL.revokeObjectURL(url);
|
|
238
|
+
setStatus({ text: `Saved ${filename}`, isError: false });
|
|
239
|
+
void loadHistory();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
+
setStatus({ text: `Export failed: ${msg}`, isError: true });
|
|
243
|
+
} finally {
|
|
244
|
+
setSubmitting(false);
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
[loadHistory]
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const rerunLast = useCallback(async () => {
|
|
251
|
+
await loadHistory();
|
|
252
|
+
const last = history[0];
|
|
253
|
+
if (!last) return;
|
|
254
|
+
await submit(last.format, last.scope, last.options ?? {});
|
|
255
|
+
}, [history, loadHistory, submit]);
|
|
256
|
+
|
|
257
|
+
const ctxValue = useMemo<ExportDialogValue>(
|
|
258
|
+
() => ({ open, close, rerunLast }),
|
|
259
|
+
[open, close, rerunLast]
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<ExportDialogContext.Provider value={ctxValue}>
|
|
264
|
+
<style>{DIALOG_CSS}</style>
|
|
265
|
+
{children}
|
|
266
|
+
<DialogShell
|
|
267
|
+
ref={dialogRef}
|
|
268
|
+
openState={openState}
|
|
269
|
+
onClose={close}
|
|
270
|
+
onSubmit={submit}
|
|
271
|
+
history={history}
|
|
272
|
+
submitting={submitting}
|
|
273
|
+
status={status}
|
|
274
|
+
/>
|
|
275
|
+
</ExportDialogContext.Provider>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function useExportDialog(): ExportDialogValue | null {
|
|
280
|
+
return useContext(ExportDialogContext);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
284
|
+
// Inner shell — pure form. Pulled out so the provider stays focused on state.
|
|
285
|
+
|
|
286
|
+
const DialogShell = (() => {
|
|
287
|
+
function Shell(props: {
|
|
288
|
+
ref: React.Ref<HTMLDialogElement>;
|
|
289
|
+
openState: OpenOptions | null;
|
|
290
|
+
onClose: () => void;
|
|
291
|
+
onSubmit: (format: Format, scope: Scope, options: Record<string, unknown>) => void;
|
|
292
|
+
history: ExportHistoryEntry[];
|
|
293
|
+
submitting: boolean;
|
|
294
|
+
status: { text: string; isError: boolean } | null;
|
|
295
|
+
}) {
|
|
296
|
+
const { ref, openState, onClose, onSubmit, history, submitting, status } = props;
|
|
297
|
+
const [format, setFormat] = useState<Format>('png');
|
|
298
|
+
const [scope, setScope] = useState<Scope>('artboard');
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!openState) return;
|
|
302
|
+
if (openState.format) setFormat(openState.format);
|
|
303
|
+
if (openState.scope) setScope(openState.scope);
|
|
304
|
+
}, [openState]);
|
|
305
|
+
|
|
306
|
+
// Keep the scope valid against the chosen format.
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
const valid = VALID_SCOPES_PER_FORMAT[format];
|
|
309
|
+
if (!valid.includes(scope)) {
|
|
310
|
+
setScope(valid[0] ?? 'artboard');
|
|
311
|
+
}
|
|
312
|
+
}, [format, scope]);
|
|
313
|
+
|
|
314
|
+
if (!openState) {
|
|
315
|
+
return <dialog ref={ref} className="dc-export-dialog" onClose={onClose} />;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<dialog ref={ref} className="dc-export-dialog" onClose={onClose}>
|
|
320
|
+
<header>
|
|
321
|
+
<h2>Export</h2>
|
|
322
|
+
<button type="button" className="dc-ed-close" onClick={onClose}>
|
|
323
|
+
Esc
|
|
324
|
+
</button>
|
|
325
|
+
</header>
|
|
326
|
+
<div className="dc-ed-body">
|
|
327
|
+
<div>
|
|
328
|
+
<label htmlFor="dc-ed-format">Format</label>
|
|
329
|
+
<select
|
|
330
|
+
id="dc-ed-format"
|
|
331
|
+
value={format}
|
|
332
|
+
onChange={(e) => setFormat(e.target.value as Format)}
|
|
333
|
+
>
|
|
334
|
+
{(Object.keys(FORMAT_META) as Format[]).map((f) => (
|
|
335
|
+
<option key={f} value={f}>
|
|
336
|
+
{FORMAT_META[f].label}
|
|
337
|
+
</option>
|
|
338
|
+
))}
|
|
339
|
+
</select>
|
|
340
|
+
<p className="dc-ed-desc">{FORMAT_META[format].description}</p>
|
|
341
|
+
</div>
|
|
342
|
+
<div>
|
|
343
|
+
<label htmlFor="dc-ed-scope">Scope</label>
|
|
344
|
+
<select
|
|
345
|
+
id="dc-ed-scope"
|
|
346
|
+
value={scope}
|
|
347
|
+
onChange={(e) => setScope(e.target.value as Scope)}
|
|
348
|
+
>
|
|
349
|
+
{VALID_SCOPES_PER_FORMAT[format].map((s) => (
|
|
350
|
+
<option key={s} value={s}>
|
|
351
|
+
{SCOPE_META[s].label}
|
|
352
|
+
</option>
|
|
353
|
+
))}
|
|
354
|
+
</select>
|
|
355
|
+
<p className="dc-ed-desc">{SCOPE_META[scope].description}</p>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
{history.length > 0 && (
|
|
359
|
+
<div className="dc-ed-recent">
|
|
360
|
+
<h3>Recent</h3>
|
|
361
|
+
<ul>
|
|
362
|
+
{history.slice(0, 5).map((h, i) => (
|
|
363
|
+
<li key={`${h.at}-${i}`}>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => {
|
|
367
|
+
setFormat(h.format);
|
|
368
|
+
setScope(h.scope);
|
|
369
|
+
}}
|
|
370
|
+
>
|
|
371
|
+
<span>
|
|
372
|
+
{FORMAT_META[h.format].label} · {SCOPE_META[h.scope].label}
|
|
373
|
+
</span>
|
|
374
|
+
<span style={{ color: 'var(--fg-2, rgba(40,30,20,0.5))' }}>{h.filename}</span>
|
|
375
|
+
</button>
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
</div>
|
|
380
|
+
)}
|
|
381
|
+
{status && (
|
|
382
|
+
<div className={`dc-ed-status${status.isError ? ' is-error' : ''}`}>{status.text}</div>
|
|
383
|
+
)}
|
|
384
|
+
<footer>
|
|
385
|
+
<button type="button" onClick={onClose} disabled={submitting}>
|
|
386
|
+
Cancel
|
|
387
|
+
</button>
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
className="dc-ed-primary"
|
|
391
|
+
disabled={submitting}
|
|
392
|
+
onClick={() => onSubmit(format, scope, {})}
|
|
393
|
+
>
|
|
394
|
+
{submitting ? 'Exporting…' : 'Export'}
|
|
395
|
+
</button>
|
|
396
|
+
</footer>
|
|
397
|
+
</dialog>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
return Shell;
|
|
401
|
+
})();
|
|
@@ -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;
|