@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
@@ -1163,7 +1163,7 @@
1163
1163
  overflow: hidden;
1164
1164
  }
1165
1165
 
1166
- .sb-clear-sel, .sb-add-comment {
1166
+ .sb-clear-sel {
1167
1167
  color: var(--u-fg-2);
1168
1168
  border: 1px solid var(--u-border);
1169
1169
  border-radius: var(--u-r-xs);
@@ -1177,22 +1177,12 @@
1177
1177
  line-height: 1.4;
1178
1178
  }
1179
1179
 
1180
- .sb-add-comment {
1181
- color: var(--u-accent);
1182
- border-color: var(--u-accent);
1183
- }
1184
-
1185
1180
  .sb-clear-sel:hover {
1186
1181
  color: var(--u-fg-0);
1187
1182
  background: var(--u-bg-2);
1188
1183
  border-color: var(--u-border-strong);
1189
1184
  }
1190
1185
 
1191
- .sb-add-comment:hover {
1192
- background: var(--u-accent);
1193
- color: var(--u-accent-fg);
1194
- }
1195
-
1196
1186
  .sb-unread .sb-count {
1197
1187
  font-family: var(--u-font-mono);
1198
1188
  color: var(--u-fg-0);
@@ -1866,74 +1856,6 @@
1866
1856
  display: flex;
1867
1857
  }
1868
1858
 
1869
- .composer {
1870
- gap: var(--u-s-2);
1871
- padding: var(--u-s-3);
1872
- background: var(--u-bg-2);
1873
- border: 1px solid var(--u-accent-line);
1874
- border-radius: var(--u-r-md);
1875
- flex-direction: column;
1876
- margin: 4px 0;
1877
- display: flex;
1878
- }
1879
-
1880
- .composer-head {
1881
- align-items: baseline;
1882
- gap: var(--u-s-2);
1883
- color: var(--u-fg-3);
1884
- font-size: 11px;
1885
- display: flex;
1886
- }
1887
-
1888
- .composer-selector {
1889
- font-family: var(--u-font-mono);
1890
- color: var(--u-fg-1);
1891
- background: var(--u-bg-1);
1892
- border-radius: var(--u-r-sm);
1893
- border: 1px solid var(--u-border);
1894
- text-overflow: ellipsis;
1895
- white-space: nowrap;
1896
- flex: 1;
1897
- min-width: 0;
1898
- padding: 2px 8px;
1899
- font-size: 11px;
1900
- overflow: hidden;
1901
- }
1902
-
1903
- .comment-bar textarea.composer-textarea {
1904
- background: var(--u-bg-1);
1905
- border: 1px solid var(--u-border);
1906
- border-radius: var(--u-r-sm);
1907
- width: 100%;
1908
- color: var(--u-fg-0);
1909
- padding: var(--u-s-3);
1910
- resize: vertical;
1911
- outline: none;
1912
- min-height: 100px;
1913
- font-family: inherit;
1914
- font-size: 13px;
1915
- line-height: 1.5;
1916
- }
1917
-
1918
- .comment-bar textarea.composer-textarea:focus {
1919
- border-color: var(--u-accent-line);
1920
- }
1921
-
1922
- .comment-bar textarea.composer-textarea::placeholder {
1923
- color: var(--u-fg-3);
1924
- }
1925
-
1926
- .composer-actions {
1927
- justify-content: flex-end;
1928
- gap: 6px;
1929
- display: flex;
1930
- }
1931
-
1932
- .cb-row.composer {
1933
- flex-wrap: nowrap;
1934
- align-items: stretch;
1935
- }
1936
-
1937
1859
  .cb-label {
1938
1860
  color: var(--u-fg-3);
1939
1861
  flex: none;
@@ -1941,150 +1863,11 @@
1941
1863
  font-size: 11px;
1942
1864
  }
1943
1865
 
1944
- .cb-label code {
1945
- font-family: var(--u-font-mono);
1946
- background: var(--u-bg-2);
1947
- border-radius: var(--u-r-xs);
1948
- color: var(--u-fg-1);
1949
- padding: 1px 4px;
1950
- font-size: 10px;
1951
- }
1952
-
1953
- .comment-bar textarea {
1954
- background: var(--u-bg-2);
1955
- border: 1px solid var(--u-border);
1956
- border-radius: var(--u-r-sm);
1957
- color: var(--u-fg-0);
1958
- padding: var(--u-s-2);
1959
- resize: vertical;
1960
- outline: none;
1961
- flex: 1;
1962
- min-height: 40px;
1963
- font-family: inherit;
1964
- font-size: 12px;
1965
- }
1966
-
1967
- .comment-bar textarea:focus {
1968
- border-color: var(--u-accent-line);
1969
- }
1970
-
1971
- .cb-actions {
1972
- align-self: flex-end;
1973
- gap: 4px;
1974
- display: flex;
1975
- }
1976
-
1977
- .cb-primary, .cb-secondary {
1978
- border: 1px solid var(--u-border);
1979
- background: var(--u-bg-2);
1980
- color: var(--u-fg-1);
1981
- border-radius: var(--u-r-sm);
1982
- padding: 4px var(--u-s-3);
1983
- font-family: var(--u-font-mono);
1984
- cursor: pointer;
1985
- font-size: 11px;
1986
- }
1987
-
1988
- .cb-primary {
1989
- background: var(--u-accent);
1990
- color: var(--u-bg-0);
1991
- border-color: var(--u-accent);
1992
- font-weight: 600;
1993
- }
1994
-
1995
- .cb-primary:hover {
1996
- background: var(--u-accent-strong);
1997
- border-color: var(--u-accent-strong);
1998
- }
1999
-
2000
- .cb-primary:disabled {
2001
- opacity: .5;
2002
- cursor: not-allowed;
2003
- }
2004
-
2005
- .cb-secondary:hover {
2006
- background: var(--u-bg-3);
2007
- color: var(--u-fg-0);
2008
- }
2009
-
2010
- .cb-row.focused {
2011
- background: var(--u-bg-2);
2012
- border-radius: var(--u-r-sm);
2013
- padding: 4px var(--u-s-2);
2014
- }
2015
-
2016
- .cb-pinno {
2017
- font-family: var(--u-font-mono);
2018
- background: var(--u-accent);
2019
- color: var(--u-accent-fg);
2020
- border-radius: var(--u-r-sm);
2021
- letter-spacing: var(--tracking-sku);
2022
- flex: none;
2023
- padding: 1px 6px;
2024
- font-size: 11px;
2025
- font-weight: 700;
2026
- }
2027
-
2028
- .cb-text {
2029
- color: var(--u-fg-0);
2030
- text-overflow: ellipsis;
2031
- white-space: nowrap;
2032
- flex: 1;
2033
- font-size: 12px;
2034
- overflow: hidden;
2035
- }
2036
-
2037
- .cb-target code {
2038
- font-family: var(--u-font-mono);
2039
- background: var(--u-bg-3);
2040
- border-radius: var(--u-r-xs);
2041
- color: var(--u-fg-2);
2042
- text-overflow: ellipsis;
2043
- white-space: nowrap;
2044
- max-width: 240px;
2045
- padding: 1px 6px;
2046
- font-size: 10px;
2047
- display: inline-block;
2048
- overflow: hidden;
2049
- }
2050
-
2051
1866
  .cb-row.strip {
2052
1867
  color: var(--u-fg-3);
2053
1868
  font-size: 11px;
2054
1869
  }
2055
1870
 
2056
- .cb-pin-strip {
2057
- flex-wrap: wrap;
2058
- flex: 1;
2059
- gap: 4px;
2060
- display: flex;
2061
- }
2062
-
2063
- .cb-pin-chip {
2064
- font-family: var(--u-font-mono);
2065
- background: var(--u-accent);
2066
- color: var(--u-accent-fg);
2067
- border-radius: var(--u-r-sm);
2068
- cursor: pointer;
2069
- letter-spacing: var(--tracking-sku);
2070
- border: 0;
2071
- padding: 2px 7px;
2072
- font-size: 10px;
2073
- font-weight: 600;
2074
- transition: transform .1s;
2075
- }
2076
-
2077
- .cb-pin-chip:hover {
2078
- transform: scale(1.1);
2079
- }
2080
-
2081
- .cb-more {
2082
- font-family: var(--u-font-mono);
2083
- color: var(--u-fg-3);
2084
- align-self: center;
2085
- font-size: 10px;
2086
- }
2087
-
2088
1871
  .rsidebar {
2089
1872
  background: var(--u-bg-1);
2090
1873
  border-left: 1px solid var(--u-border);
@@ -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
+ })();