@1agh/maude 0.41.0 → 0.42.0

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 (39) hide show
  1. package/apps/studio/api.ts +0 -41
  2. package/apps/studio/bin/_html-playwright.mjs +26 -4
  3. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  4. package/apps/studio/bin/_png-playwright.mjs +15 -2
  5. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  6. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  7. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  8. package/apps/studio/bin/screenshot.sh +53 -4
  9. package/apps/studio/client/app.jsx +25 -54
  10. package/apps/studio/client/export-center.jsx +426 -0
  11. package/apps/studio/client/styles/3-shell-maude.css +17 -0
  12. package/apps/studio/client/styles/4-components.css +150 -0
  13. package/apps/studio/config.schema.json +2 -2
  14. package/apps/studio/dist/client.bundle.js +17 -17
  15. package/apps/studio/dist/styles.css +1 -1
  16. package/apps/studio/export-dialog.tsx +18 -25
  17. package/apps/studio/exporters/_runtime.ts +104 -0
  18. package/apps/studio/exporters/html.ts +12 -20
  19. package/apps/studio/exporters/index.ts +14 -2
  20. package/apps/studio/exporters/jobs.ts +334 -0
  21. package/apps/studio/exporters/pdf.ts +16 -20
  22. package/apps/studio/exporters/png.ts +12 -20
  23. package/apps/studio/exporters/pptx.ts +22 -23
  24. package/apps/studio/exporters/scope.ts +1 -0
  25. package/apps/studio/exporters/svg.ts +14 -22
  26. package/apps/studio/exporters/video.ts +15 -17
  27. package/apps/studio/git/service.ts +3 -1
  28. package/apps/studio/http.ts +145 -50
  29. package/apps/studio/server.ts +3 -1
  30. package/apps/studio/test/canvas-origin-gate.test.ts +6 -0
  31. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  32. package/apps/studio/test/export-center.test.tsx +287 -0
  33. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  34. package/apps/studio/test/exporters/history.test.ts +32 -3
  35. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  36. package/apps/studio/whats-new.json +17 -0
  37. package/apps/studio/ws.ts +6 -0
  38. package/cli/lib/gitignore-block.mjs +1 -0
  39. package/package.json +8 -8
@@ -0,0 +1,426 @@
1
+ // export-center.jsx — background export notification center
2
+ // (feature-background-export-notification-center).
3
+ //
4
+ // useExportCenter() → headless handle (hydrate + WS upsert + toast/panel state)
5
+ // <ExportBadge> → menubar entry point (busy pulse + running/queued count)
6
+ // <ExportToast> → completion notice (bottom-left, mirrors WhatsNewToast)
7
+ // <ExportPanel> → job list (reuses the help-modal backdrop)
8
+ //
9
+ // Structurally a near-exact twin of whats-new.jsx, but driven by live WS
10
+ // `export:job` pushes (via `upsert`, called from app.jsx's WS message handler)
11
+ // instead of a static feed. Styling lives in client/styles/3-shell-maude.css
12
+ // (`.st-exports`) and 4-components.css (`.st-export-*`).
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { isNativeApp, saveExport } from './github.js';
16
+
17
+ const FORMAT_LABELS = {
18
+ png: 'PNG',
19
+ pdf: 'PDF',
20
+ svg: 'SVG',
21
+ html: 'HTML',
22
+ pptx: 'PPTX',
23
+ canva: 'Canva',
24
+ zip: 'ZIP',
25
+ mp4: 'MP4',
26
+ webm: 'WebM',
27
+ gif: 'GIF',
28
+ };
29
+ const SCOPE_LABELS = {
30
+ selection: 'Selection',
31
+ artboard: 'Artboard',
32
+ 'canvas-as-separate': 'Canvas → separate',
33
+ 'project-raw': 'Project (raw)',
34
+ };
35
+
36
+ async function fetchJobBytes(id) {
37
+ const r = await fetch(`/_api/export-jobs/download?id=${encodeURIComponent(id)}`);
38
+ if (!r.ok) return null;
39
+ const disp = r.headers.get('Content-Disposition') || '';
40
+ const filename = /filename="([^"]+)"/.exec(disp)?.[1] || null;
41
+ const blob = await r.blob();
42
+ return { blob, filename };
43
+ }
44
+
45
+ async function autoDownloadBlob(id, fallbackName) {
46
+ const got = await fetchJobBytes(id);
47
+ if (!got) return;
48
+ const filename = got.filename || fallbackName || 'export';
49
+ const url = URL.createObjectURL(got.blob);
50
+ const a = document.createElement('a');
51
+ a.href = url;
52
+ a.download = filename;
53
+ document.body.appendChild(a);
54
+ a.click();
55
+ a.remove();
56
+ URL.revokeObjectURL(url);
57
+ }
58
+
59
+ async function saveNative(id, fallbackName) {
60
+ const got = await fetchJobBytes(id);
61
+ if (!got) return null;
62
+ const filename = got.filename || fallbackName || 'export';
63
+ const bytes = Array.from(new Uint8Array(await got.blob.arrayBuffer()));
64
+ return saveExport(filename, bytes);
65
+ }
66
+
67
+ export function useExportCenter() {
68
+ const [jobs, setJobs] = useState(() => new Map());
69
+ const [panelOpen, setPanelOpen] = useState(false);
70
+ const [toastQueue, setToastQueue] = useState([]);
71
+ const [savingIds, setSavingIds] = useState(() => new Set());
72
+ const prevStatusRef = useRef(new Map());
73
+ // Dedup guards (not state — re-rendering on these changing would be
74
+ // pointless; they only gate one-shot side effects).
75
+ const finalizedRef = useRef(new Set()); // auto-download fires at most once per job
76
+ const startToastShownRef = useRef(new Set()); // "Exporting…" toast fires at most once per job
77
+
78
+ useEffect(() => {
79
+ let alive = true;
80
+ fetch('/_api/export-jobs')
81
+ .then((r) => r.json())
82
+ .then((j) => {
83
+ if (!alive) return;
84
+ const m = new Map();
85
+ for (const job of Array.isArray(j?.jobs) ? j.jobs : []) {
86
+ m.set(job.id, job);
87
+ // Hydration is a SNAPSHOT, not a live transition — seed both guards
88
+ // so a page (re)load never replays a toast / auto-download for a
89
+ // job that already reached this status before we opened.
90
+ prevStatusRef.current.set(job.id, job.status);
91
+ startToastShownRef.current.add(job.id);
92
+ }
93
+ setJobs(m);
94
+ })
95
+ .catch(() => {
96
+ /* offline / restart — the badge just stays empty until a push arrives */
97
+ });
98
+ return () => {
99
+ alive = false;
100
+ };
101
+ }, []);
102
+
103
+ const upsert = useCallback((job) => {
104
+ if (!job || typeof job.id !== 'string') return;
105
+ setJobs((prev) => {
106
+ const next = new Map(prev);
107
+ next.set(job.id, job);
108
+ return next;
109
+ });
110
+ }, []);
111
+
112
+ // One toast per job, morphing in place as the job's live status changes
113
+ // (queued/running → done/failed) rather than stacking a separate "started"
114
+ // and "finished" toast. Detects two kinds of transitions:
115
+ // - first time a job is seen active (queued/running) → surfaces it so the
116
+ // user knows it's running in the background right after the dialog closes.
117
+ // - a done/failed transition → surfaces it (or refreshes it in place if
118
+ // it's still the front-of-queue toast from the "started" step above).
119
+ useEffect(() => {
120
+ const prevStatus = prevStatusRef.current;
121
+ const toSurface = [];
122
+ const justFinished = [];
123
+ // Single pass — read `was` for every job BEFORE prevStatus gets mutated
124
+ // below, so both the toast + auto-download decisions see the real
125
+ // "did this change just now" transition, not an already-updated ref.
126
+ for (const job of jobs.values()) {
127
+ const was = prevStatus.get(job.id);
128
+ const isActive = job.status === 'queued' || job.status === 'running';
129
+ const isFinished = job.status === 'done' || job.status === 'failed';
130
+ if (was === undefined && isActive && !startToastShownRef.current.has(job.id)) {
131
+ startToastShownRef.current.add(job.id);
132
+ toSurface.push(job.id);
133
+ } else if (was !== job.status && isFinished) {
134
+ toSurface.push(job.id);
135
+ justFinished.push(job);
136
+ }
137
+ }
138
+ for (const job of jobs.values()) prevStatus.set(job.id, job.status);
139
+
140
+ if (toSurface.length) {
141
+ setToastQueue((q) => {
142
+ const merged = q.filter((id) => !toSurface.includes(id));
143
+ return [...toSurface, ...merged].slice(0, 8);
144
+ });
145
+ }
146
+
147
+ // Native (Tauri) never auto-pops the OS save dialog — it would interrupt
148
+ // whatever the user is doing elsewhere. Always an explicit "Save…" click.
149
+ if (isNativeApp() || !justFinished.length) return;
150
+ for (const job of justFinished) {
151
+ if (job.status !== 'done') continue;
152
+ if (finalizedRef.current.has(job.id)) continue;
153
+ // Focus-gated: avoids duplicate downloads when multiple tabs share one
154
+ // dev-server and both receive the same WS push.
155
+ if (!document.hasFocus()) continue;
156
+ finalizedRef.current.add(job.id);
157
+ void autoDownloadBlob(job.id, job.filename);
158
+ }
159
+ }, [jobs]);
160
+
161
+ const list = useMemo(
162
+ () =>
163
+ Array.from(jobs.values()).sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || '')),
164
+ [jobs]
165
+ );
166
+ const runningCount = list.filter((j) => j.status === 'running').length;
167
+ const queuedCount = list.filter((j) => j.status === 'queued').length;
168
+
169
+ const dismissToast = useCallback(() => setToastQueue((q) => q.slice(1)), []);
170
+ const openPanel = useCallback(() => setPanelOpen(true), []);
171
+ const closePanel = useCallback(() => setPanelOpen(false), []);
172
+
173
+ const withSaving = useCallback(async (job, fn) => {
174
+ setSavingIds((prev) => new Set(prev).add(job.id));
175
+ try {
176
+ await fn();
177
+ } finally {
178
+ setSavingIds((prev) => {
179
+ const next = new Set(prev);
180
+ next.delete(job.id);
181
+ return next;
182
+ });
183
+ }
184
+ }, []);
185
+
186
+ // Manual "Download" (web, unfocused-tab fallback) / "Save…" (native) — the
187
+ // toast/panel row action. Both mark the job finalized so the focus-gated
188
+ // auto-download effect above doesn't ALSO fire for it later, and both track
189
+ // a busy state (`savingIds`) so the button can show it's working instead of
190
+ // going silent while the bytes fetch + (for native) the OS dialog opens.
191
+ const download = useCallback(
192
+ async (job) => {
193
+ finalizedRef.current.add(job.id);
194
+ await withSaving(job, () => autoDownloadBlob(job.id, job.filename));
195
+ },
196
+ [withSaving]
197
+ );
198
+ const save = useCallback(
199
+ async (job) => {
200
+ finalizedRef.current.add(job.id);
201
+ await withSaving(job, () => saveNative(job.id, job.filename));
202
+ },
203
+ [withSaving]
204
+ );
205
+
206
+ const toastJobId = panelOpen ? null : (toastQueue[0] ?? null);
207
+
208
+ return {
209
+ jobs: list,
210
+ upsert,
211
+ runningCount,
212
+ queuedCount,
213
+ busyCount: runningCount + queuedCount,
214
+ panelOpen,
215
+ openPanel,
216
+ closePanel,
217
+ showToast: !!toastJobId,
218
+ toastJob: toastJobId ? (jobs.get(toastJobId) ?? null) : null,
219
+ dismissToast,
220
+ download,
221
+ save,
222
+ savingIds,
223
+ };
224
+ }
225
+
226
+ function jobLabel(job) {
227
+ const fmt = FORMAT_LABELS[job.format] || String(job.format || '').toUpperCase();
228
+ const scope = SCOPE_LABELS[job.scope] || job.scope;
229
+ return `${fmt} · ${scope}`;
230
+ }
231
+
232
+ // Native "Save…" opens the OS file picker; web "Download" streams a blob
233
+ // through <a download> — distinct verbs, so the busy label stays distinct too
234
+ // (the fetch-bytes step behind both can take several seconds on a large export).
235
+ function saveActionLabel(saving) {
236
+ if (isNativeApp()) return saving ? 'Saving…' : 'Save…';
237
+ return saving ? 'Downloading…' : 'Download';
238
+ }
239
+
240
+ function ProgressBar({ progress }) {
241
+ if (!progress || !progress.total) {
242
+ return <div className="st-export-progress st-export-progress--indeterminate" aria-hidden="true" />;
243
+ }
244
+ const pct = Math.max(0, Math.min(100, Math.round((progress.current / progress.total) * 100)));
245
+ return (
246
+ <div
247
+ className="st-export-progress"
248
+ role="progressbar"
249
+ aria-valuenow={pct}
250
+ aria-valuemin={0}
251
+ aria-valuemax={100}
252
+ >
253
+ <div className="st-export-progress-bar" style={{ width: `${pct}%` }} />
254
+ </div>
255
+ );
256
+ }
257
+
258
+ function StatusPill({ job }) {
259
+ const statusText =
260
+ job.status === 'queued'
261
+ ? 'Queued'
262
+ : job.status === 'running'
263
+ ? job.progress?.total
264
+ ? `${job.progress.current} of ${job.progress.total}`
265
+ : 'Rendering…'
266
+ : job.status === 'done'
267
+ ? 'Ready'
268
+ : 'Failed';
269
+ return <span className={`st-export-pill st-export-pill--${job.status}`}>{statusText}</span>;
270
+ }
271
+
272
+ export function ExportBadge({ center }) {
273
+ const busy = center.busyCount > 0;
274
+ return (
275
+ <button
276
+ type="button"
277
+ className="st-exports"
278
+ data-tour="exports"
279
+ data-busy={busy ? 'true' : 'false'}
280
+ aria-label={busy ? `Exports — ${center.busyCount} running` : 'Exports'}
281
+ data-tip="Exports"
282
+ onClick={center.openPanel}
283
+ >
284
+ <span aria-hidden="true">⬇</span>
285
+ {busy && (
286
+ <span className="st-exports__count" aria-hidden="true">
287
+ {center.busyCount}
288
+ </span>
289
+ )}
290
+ </button>
291
+ );
292
+ }
293
+
294
+ export function ExportToast({ center }) {
295
+ if (!center.showToast || !center.toastJob) return null;
296
+ const job = center.toastJob;
297
+ const pending = job.status === 'queued' || job.status === 'running';
298
+ const ok = job.status === 'done';
299
+ const saving = center.savingIds.has(job.id);
300
+ const finalize = () => {
301
+ if (isNativeApp()) void center.save(job);
302
+ else void center.download(job);
303
+ };
304
+ return (
305
+ <div className="st-toast" role="status" aria-live="polite">
306
+ <button type="button" className="st-toast-close" aria-label="Dismiss" onClick={center.dismissToast}>
307
+ ×
308
+ </button>
309
+ <div className="st-toast-hd">
310
+ <span aria-hidden="true">{pending ? '⋯' : ok ? '⬇' : '⚠'}</span>
311
+ {pending ? 'Exporting…' : ok ? 'Export ready' : 'Export failed'}
312
+ </div>
313
+ <div className="st-toast-title">{jobLabel(job)}</div>
314
+ {pending ? (
315
+ <>
316
+ <div className="st-toast-txt">Running in the background — you can keep working.</div>
317
+ <ProgressBar progress={job.progress} />
318
+ </>
319
+ ) : (
320
+ <div className="st-toast-txt">
321
+ {ok ? (job.filename ?? 'Ready to download.') : (job.error ?? 'Something went wrong.')}
322
+ </div>
323
+ )}
324
+ {ok && (
325
+ <div className="st-toast-actions">
326
+ <button
327
+ type="button"
328
+ className="btn btn--primary btn--sm"
329
+ onClick={finalize}
330
+ disabled={saving}
331
+ >
332
+ {saving && <span className="st-btn-spin" aria-hidden="true" />}
333
+ {saveActionLabel(saving)}
334
+ </button>
335
+ <button type="button" className="btn btn--ghost btn--sm" onClick={center.dismissToast}>
336
+ Dismiss
337
+ </button>
338
+ </div>
339
+ )}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ export function ExportPanel({ center }) {
345
+ const { panelOpen, closePanel } = center;
346
+ useEffect(() => {
347
+ if (!panelOpen) return;
348
+ function onKey(e) {
349
+ if (e.key === 'Escape') closePanel();
350
+ }
351
+ window.addEventListener('keydown', onKey);
352
+ return () => window.removeEventListener('keydown', onKey);
353
+ }, [panelOpen, closePanel]);
354
+
355
+ if (!panelOpen) return null;
356
+ return (
357
+ <div
358
+ className="help-modal-backdrop"
359
+ role="presentation"
360
+ onMouseDown={(e) => {
361
+ if (e.target === e.currentTarget) closePanel();
362
+ }}
363
+ >
364
+ <div
365
+ className="st-export-panel"
366
+ role="dialog"
367
+ aria-modal="true"
368
+ aria-labelledby="st-export-panel-title"
369
+ >
370
+ <header className="help-modal-hd">
371
+ <span className="title" id="st-export-panel-title">
372
+ Exports
373
+ </span>
374
+ <button
375
+ type="button"
376
+ className="help-modal-close"
377
+ aria-label="Close (Esc)"
378
+ onClick={closePanel}
379
+ >
380
+ ×
381
+ </button>
382
+ </header>
383
+ <div className="st-export-panel__body">
384
+ {center.jobs.length === 0 ? (
385
+ <p className="mdcc-wn-empty">No exports yet.</p>
386
+ ) : (
387
+ <ul className="st-export-list">
388
+ {center.jobs.map((job) => (
389
+ <li key={job.id} className="st-export-item">
390
+ <div className="st-export-item__hd">
391
+ <span className="st-export-item__label">{jobLabel(job)}</span>
392
+ <StatusPill job={job} />
393
+ </div>
394
+ {(job.status === 'queued' || job.status === 'running') && (
395
+ <ProgressBar progress={job.progress} />
396
+ )}
397
+ {job.status === 'done' && (
398
+ <div className="st-export-item__ft">
399
+ <span className="st-export-item__filename">{job.filename}</span>
400
+ <button
401
+ type="button"
402
+ className="btn btn--ghost btn--sm"
403
+ disabled={center.savingIds.has(job.id)}
404
+ onClick={() => (isNativeApp() ? center.save(job) : center.download(job))}
405
+ >
406
+ {center.savingIds.has(job.id) && (
407
+ <span className="st-btn-spin" aria-hidden="true" />
408
+ )}
409
+ {saveActionLabel(center.savingIds.has(job.id))}
410
+ </button>
411
+ </div>
412
+ )}
413
+ {job.status === 'failed' && (
414
+ <div className="st-export-item__ft">
415
+ <span className="st-export-item__error">{job.error ?? 'Export failed.'}</span>
416
+ </div>
417
+ )}
418
+ </li>
419
+ ))}
420
+ </ul>
421
+ )}
422
+ </div>
423
+ </div>
424
+ </div>
425
+ );
426
+ }
@@ -86,6 +86,23 @@
86
86
  border-radius: var(--radius-pill); background: var(--accent); border: 1.5px solid var(--bg-1);
87
87
  }
88
88
 
89
+ /* feature-background-export-notification-center — menubar Exports button.
90
+ Mirrors .st-whatsnew's attribute-driven state (data-busy instead of
91
+ data-unseen — running/queued exports, not an unread count). */
92
+ .st-exports {
93
+ position: relative; appearance: none; border: 0; background: transparent; cursor: pointer;
94
+ width: 26px; height: 26px; display: grid; place-items: center; border-radius: var(--radius-sm);
95
+ color: var(--fg-1); font-size: 13px;
96
+ }
97
+ .st-exports:hover { background: var(--bg-3); color: var(--fg-0); }
98
+ .st-exports[data-busy="true"] { color: var(--accent); }
99
+ .st-exports__count {
100
+ position: absolute; top: -2px; right: -2px; min-width: 14px; height: 14px; padding: 0 3px;
101
+ display: grid; place-items: center; border-radius: var(--radius-pill);
102
+ background: var(--accent); color: var(--accent-fg); font-family: var(--font-mono);
103
+ font-size: 9px; font-weight: 700; line-height: 1; border: 1.5px solid var(--bg-1);
104
+ }
105
+
89
106
  /* ─── Menubar dropdown ────────────────────────────────────────────────────── */
90
107
  .st-dropdown {
91
108
  position: absolute; top: 38px; min-width: 230px;
@@ -1306,6 +1306,156 @@
1306
1306
  }
1307
1307
  }
1308
1308
 
1309
+ /* ───────── Export notification center — feature-background-export-notification-center ─────────
1310
+ Panel shell reuses .help-modal-backdrop (above); list/item/progress are new
1311
+ — no prior job-row or progress-bar component existed in the client. */
1312
+ .st-export-panel {
1313
+ width: min(560px, 92vw);
1314
+ max-height: 80vh;
1315
+ display: flex;
1316
+ flex-direction: column;
1317
+ background: var(--u-bg-1);
1318
+ color: var(--u-fg-0);
1319
+ border: 1px solid var(--u-bg-3);
1320
+ border-radius: var(--radius-md);
1321
+ box-shadow: 0 12px 48px rgba(0, 0, 0, 0.3);
1322
+ overflow: hidden;
1323
+ }
1324
+ .st-export-panel__body {
1325
+ overflow-y: auto;
1326
+ padding: 8px 14px 16px;
1327
+ }
1328
+ .st-export-list {
1329
+ list-style: none;
1330
+ margin: 0;
1331
+ padding: 0;
1332
+ }
1333
+ .st-export-item {
1334
+ padding: 12px 0;
1335
+ border-bottom: 1px solid var(--u-bg-3);
1336
+ }
1337
+ .st-export-item:last-child {
1338
+ border-bottom: none;
1339
+ }
1340
+ .st-export-item__hd {
1341
+ display: flex;
1342
+ align-items: center;
1343
+ justify-content: space-between;
1344
+ gap: 8px;
1345
+ margin-bottom: 6px;
1346
+ }
1347
+ .st-export-item__label {
1348
+ font-weight: 600;
1349
+ flex: 1;
1350
+ min-width: 0;
1351
+ }
1352
+ .st-export-item__ft {
1353
+ display: flex;
1354
+ align-items: center;
1355
+ justify-content: space-between;
1356
+ gap: 8px;
1357
+ margin-top: 6px;
1358
+ }
1359
+ .st-export-item__filename {
1360
+ color: var(--u-fg-2);
1361
+ font-size: 12px;
1362
+ font-family: var(--font-mono, monospace);
1363
+ overflow: hidden;
1364
+ text-overflow: ellipsis;
1365
+ white-space: nowrap;
1366
+ }
1367
+ .st-export-item__error {
1368
+ color: var(--u-status-error);
1369
+ font-size: 12px;
1370
+ }
1371
+ .st-export-pill {
1372
+ text-transform: uppercase;
1373
+ font-size: 9px;
1374
+ font-weight: 700;
1375
+ letter-spacing: 0.04em;
1376
+ padding: 2px 6px;
1377
+ border-radius: var(--radius-pill, 999px);
1378
+ white-space: nowrap;
1379
+ }
1380
+ .st-export-pill--queued {
1381
+ background: var(--u-bg-3);
1382
+ color: var(--u-fg-2);
1383
+ }
1384
+ .st-export-pill--running {
1385
+ background: var(--u-accent-bg);
1386
+ color: var(--u-accent);
1387
+ }
1388
+ .st-export-pill--done {
1389
+ background: color-mix(in oklab, var(--u-status-success) 18%, transparent);
1390
+ color: var(--u-status-success);
1391
+ }
1392
+ .st-export-pill--failed {
1393
+ background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
1394
+ color: var(--u-status-error);
1395
+ }
1396
+ .st-export-progress {
1397
+ position: relative;
1398
+ height: 4px;
1399
+ border-radius: var(--radius-pill, 999px);
1400
+ background: var(--u-bg-3);
1401
+ overflow: hidden;
1402
+ }
1403
+ .st-export-progress-bar {
1404
+ height: 100%;
1405
+ background: var(--u-accent);
1406
+ transition: width 0.2s ease-out;
1407
+ }
1408
+ .st-export-progress--indeterminate::after {
1409
+ content: '';
1410
+ position: absolute;
1411
+ inset: 0;
1412
+ width: 40%;
1413
+ background: var(--u-accent);
1414
+ border-radius: var(--radius-pill, 999px);
1415
+ animation: st-export-indeterminate 1.2s ease-in-out infinite;
1416
+ }
1417
+ @keyframes st-export-indeterminate {
1418
+ 0% {
1419
+ transform: translateX(-100%);
1420
+ }
1421
+ 100% {
1422
+ transform: translateX(350%);
1423
+ }
1424
+ }
1425
+ @media (prefers-reduced-motion: reduce) {
1426
+ .st-export-progress--indeterminate::after {
1427
+ animation-duration: 1ms;
1428
+ }
1429
+ }
1430
+ .st-toast .st-export-progress {
1431
+ margin-top: var(--space-1, 4px);
1432
+ }
1433
+ /* Busy state for the Save…/Download button — the bytes fetch + (native) the
1434
+ OS save-dialog handoff can take several seconds on a large export; without
1435
+ this the button just goes silent after the click. */
1436
+ .st-btn-spin {
1437
+ display: inline-block;
1438
+ width: 11px;
1439
+ height: 11px;
1440
+ margin-right: 5px;
1441
+ vertical-align: -1px;
1442
+ border: 1.5px solid currentColor;
1443
+ border-top-color: transparent;
1444
+ border-radius: 50%;
1445
+ opacity: 0.75;
1446
+ animation: st-btn-spin 0.7s linear infinite;
1447
+ }
1448
+ @keyframes st-btn-spin {
1449
+ to {
1450
+ transform: rotate(360deg);
1451
+ }
1452
+ }
1453
+ @media (prefers-reduced-motion: reduce) {
1454
+ .st-btn-spin {
1455
+ animation-duration: 1ms;
1456
+ }
1457
+ }
1458
+
1309
1459
  /* ───────── Guided tour (overlay) — feature-in-app-whats-new-tour Phase 3 ───────── */
1310
1460
  .mdcc-tour {
1311
1461
  position: fixed;
@@ -47,7 +47,7 @@
47
47
  "themeDefault": {
48
48
  "type": "string",
49
49
  "enum": ["dark", "light"],
50
- "description": "Default theme attribute (data-theme) for new canvases.",
50
+ "description": "Default theme attribute (data-theme) for new canvases. Can only ever be a single theme name — it is NOT the signal for whether a DS ships more than one theme. For that, check 'designSystems[].themes'.",
51
51
  "default": "dark"
52
52
  },
53
53
  "tokensCssRel": {
@@ -187,7 +187,7 @@
187
187
  "themes": {
188
188
  "type": "array",
189
189
  "items": { "type": "string" },
190
- "description": "Optional list of theme names this DS supports (used by the System view's theme toggle when more than one theme is declared)."
190
+ "description": "Authoritative list of theme names this DS's tokens CSS actually ships (e.g. ['dark','light']). This is the ONLY field that should be checked to decide whether a DS is dual-theme — 'themeDefault' can never hold 'both' (enum is dark|light only), so a single-theme entry MUST still list its one theme (e.g. ['dark']), not omit the field. Bootstrap/re-bootstrap always populates this from the theme-default discovery answer. Drives the System view's theme toggle, design-system-completeness-critic V18/V18c, and /design:new step 9's per-theme reality-check capture."
191
191
  },
192
192
  "newCanvasDir": {
193
193
  "type": "string",