@1agh/maude 0.40.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 (102) hide show
  1. package/apps/studio/ai-banner.tsx +2 -2
  2. package/apps/studio/annotations-layer.tsx +53 -2
  3. package/apps/studio/api.ts +504 -44
  4. package/apps/studio/artboard-marquee.tsx +1 -1
  5. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  6. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  7. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  8. package/apps/studio/bin/_html-playwright.mjs +26 -4
  9. package/apps/studio/bin/_pdf-playwright.mjs +13 -2
  10. package/apps/studio/bin/_png-playwright.mjs +15 -2
  11. package/apps/studio/bin/_pptx-playwright.mjs +17 -4
  12. package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
  13. package/apps/studio/bin/_svg-playwright.mjs +26 -4
  14. package/apps/studio/bin/annotate.mjs +576 -34
  15. package/apps/studio/bin/canvas-rects.sh +152 -0
  16. package/apps/studio/bin/fetch-asset.sh +34 -0
  17. package/apps/studio/bin/read-annotations.mjs +138 -7
  18. package/apps/studio/bin/screenshot.sh +53 -4
  19. package/apps/studio/bin/smoke.sh +42 -6
  20. package/apps/studio/build.ts +21 -0
  21. package/apps/studio/canvas-comment-mount.tsx +138 -4
  22. package/apps/studio/canvas-edit.ts +744 -11
  23. package/apps/studio/canvas-lib.tsx +219 -2
  24. package/apps/studio/canvas-shell.tsx +487 -20
  25. package/apps/studio/client/app.jsx +1476 -76
  26. package/apps/studio/client/comments-overlay.css +130 -126
  27. package/apps/studio/client/export-center.jsx +426 -0
  28. package/apps/studio/client/github.js +8 -0
  29. package/apps/studio/client/styles/3-shell-maude.css +65 -0
  30. package/apps/studio/client/styles/4-components.css +150 -0
  31. package/apps/studio/comments-overlay.tsx +148 -41
  32. package/apps/studio/config.schema.json +2 -2
  33. package/apps/studio/context-menu.tsx +15 -5
  34. package/apps/studio/contextual-toolbar.tsx +262 -4
  35. package/apps/studio/cursors-overlay.tsx +4 -4
  36. package/apps/studio/dist/client.bundle.js +20 -20
  37. package/apps/studio/dist/comment-mount.js +59 -1
  38. package/apps/studio/dist/styles.css +1 -1
  39. package/apps/studio/dom-selection.ts +127 -1
  40. package/apps/studio/drag-state.ts +24 -0
  41. package/apps/studio/equal-spacing-detector.ts +205 -0
  42. package/apps/studio/export-dialog.tsx +19 -26
  43. package/apps/studio/exporters/_runtime.ts +104 -0
  44. package/apps/studio/exporters/html.ts +12 -20
  45. package/apps/studio/exporters/index.ts +14 -2
  46. package/apps/studio/exporters/jobs.ts +334 -0
  47. package/apps/studio/exporters/pdf.ts +16 -20
  48. package/apps/studio/exporters/png.ts +12 -20
  49. package/apps/studio/exporters/pptx.ts +22 -23
  50. package/apps/studio/exporters/scope.ts +1 -0
  51. package/apps/studio/exporters/svg.ts +14 -22
  52. package/apps/studio/exporters/video.ts +15 -17
  53. package/apps/studio/git/service.ts +3 -1
  54. package/apps/studio/history.ts +47 -1
  55. package/apps/studio/http.ts +368 -50
  56. package/apps/studio/input-router.tsx +12 -0
  57. package/apps/studio/marquee-overlay.tsx +1 -1
  58. package/apps/studio/measure-overlay.tsx +241 -0
  59. package/apps/studio/participants-chrome.tsx +3 -3
  60. package/apps/studio/server.ts +3 -1
  61. package/apps/studio/sizing-mode.ts +117 -0
  62. package/apps/studio/spacing-handles.ts +166 -0
  63. package/apps/studio/test/annotate-write.test.ts +890 -0
  64. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  65. package/apps/studio/test/canvas-edit.test.ts +50 -0
  66. package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
  67. package/apps/studio/test/canvas-rects.test.ts +198 -0
  68. package/apps/studio/test/comments-overlay.test.ts +117 -0
  69. package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
  70. package/apps/studio/test/dom-selection.test.ts +130 -0
  71. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  72. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  73. package/apps/studio/test/element-resize.test.ts +136 -0
  74. package/apps/studio/test/element-structural-api.test.ts +360 -0
  75. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  76. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  77. package/apps/studio/test/export-center.test.tsx +287 -0
  78. package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
  79. package/apps/studio/test/exporters/history.test.ts +32 -3
  80. package/apps/studio/test/exporters/jobs.test.ts +263 -0
  81. package/apps/studio/test/history-rollback.test.ts +26 -0
  82. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  83. package/apps/studio/test/read-annotations.test.ts +154 -0
  84. package/apps/studio/test/sizing-mode.test.ts +102 -0
  85. package/apps/studio/test/spacing-handles.test.ts +138 -0
  86. package/apps/studio/test/specimen-select.test.ts +88 -0
  87. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  88. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  89. package/apps/studio/tool-palette.tsx +122 -2
  90. package/apps/studio/undo-hud.tsx +2 -2
  91. package/apps/studio/use-element-resize.tsx +732 -0
  92. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  93. package/apps/studio/use-selection-set.tsx +14 -0
  94. package/apps/studio/use-spacing-handles.tsx +388 -0
  95. package/apps/studio/whats-new.json +45 -0
  96. package/apps/studio/ws.ts +6 -0
  97. package/cli/commands/design.mjs +6 -1
  98. package/cli/commands/design.test.mjs +49 -1
  99. package/cli/lib/fetch-asset.test.mjs +213 -0
  100. package/cli/lib/gitignore-block.mjs +1 -0
  101. package/package.json +8 -8
  102. package/plugins/design/dependencies.json +10 -2
@@ -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
+ }
@@ -95,6 +95,14 @@ export const openLocalProject = (path) => invoke('open_local_project', { path })
95
95
  */
96
96
  export const saveExport = (filename, bytes) => invoke('save_export', { filename, bytes });
97
97
 
98
+ /**
99
+ * Native "open file" for media upload — the read counterpart to saveExport.
100
+ * WKWebView won't present the file panel for an HTML <input type=file>, so the
101
+ * AssetPicker uses this in the desktop app. Returns { name, bytes:[...] } or null
102
+ * (cancelled). Browser build uses the <input type=file> path instead.
103
+ */
104
+ export const pickMediaFile = () => invoke('pick_media_file');
105
+
98
106
  // ── Tauri shell: auto-update (Phase 32 / Task 1) ────────────────────────────────
99
107
  // The shell downloads + stages a newer build in the background and emits
100
108
  // `update-ready` with { version, notes }. The client shows a banner; clicking
@@ -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;
@@ -353,6 +370,13 @@
353
370
  .st-rp-tab .st-rp-tabct { font-family: var(--font-mono); font-size: 10px; color: var(--fg-3); }
354
371
  .st-rp-body { flex: 1; overflow-y: auto; padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); }
355
372
  .st-rp-hd { font-family: var(--font-mono); font-size: var(--type-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--fg-2); }
373
+ /* Stage H (INV-3) edit-scope strip — local (this element) vs shared (edits N places). */
374
+ .st-scope { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); font-size: var(--type-xs); border-bottom: 1px solid var(--border-subtle); flex: none; animation: st-pop var(--dur-soft) var(--ease-out); }
375
+ .st-scope-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
376
+ .st-scope--local { color: var(--fg-2); }
377
+ .st-scope--local .st-scope-dot { background: var(--fg-3); }
378
+ .st-scope--shared { color: var(--accent); }
379
+ .st-scope--shared .st-scope-dot { background: var(--accent); }
356
380
  .st-rp-empty { color: var(--fg-2); font-size: var(--type-sm); line-height: 1.6; display: flex; flex-direction: column; gap: var(--space-4); }
357
381
 
358
382
  /* comment pin (inline badge in the panel) */
@@ -476,6 +500,24 @@
476
500
  .st-export-recent-row:hover { background: var(--bg-2); }
477
501
  .st-export-recent-row .st-mono { color: var(--fg-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 50%; }
478
502
 
503
+ /* ─── AssetPicker (feature-element-editing-robustness Stage F1) ───────────── */
504
+ .st-btn { appearance: none; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; min-height: 28px; border: 1px solid var(--border-default); border-radius: var(--radius-sm); background: var(--bg-2); color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); padding: 0 12px; transition: background var(--dur-soft) var(--ease-out), border-color var(--dur-soft) var(--ease-out); }
505
+ .st-btn:hover { background: var(--bg-3); border-color: var(--border-strong, var(--border-default)); }
506
+ .st-btn:disabled { opacity: 0.55; cursor: default; }
507
+ .st-asset-picker { width: 620px; }
508
+ .st-ap-toolbar { display: flex; align-items: center; gap: var(--space-3); }
509
+ .st-ap-err { color: var(--danger, #e5484d); font-family: var(--font-mono); font-size: var(--type-xs); }
510
+ .st-ap-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: var(--space-3); max-height: 52vh; overflow-y: auto; padding: 2px; }
511
+ .st-ap-empty { grid-column: 1 / -1; color: var(--fg-2); font-family: var(--font-mono); font-size: var(--type-xs); padding: var(--space-6) 0; text-align: center; }
512
+ .st-ap-cell { appearance: none; cursor: pointer; display: flex; flex-direction: column; gap: 4px; padding: 6px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-2); text-align: left; transition: border-color var(--dur-soft) var(--ease-out), background var(--dur-soft) var(--ease-out); }
513
+ .st-ap-cell:hover { border-color: var(--accent, var(--border-strong, var(--border-default))); background: var(--bg-3); }
514
+ .st-ap-thumb { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius-xs); background: var(--bg-4, var(--bg-3)); display: block; }
515
+ .st-ap-name { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
516
+ .st-cp-replace { width: 100%; justify-content: center; }
517
+ /* Media "Replace…" — a full-width button, NOT inside the 3-col .st-cp-row grid
518
+ (which would drop it into the 10px provenance-dot column and clip it). */
519
+ .st-cp-mediabtn { padding: var(--space-1) 0 var(--space-2); }
520
+
479
521
  /* ─── Inspector panel fields / layers / css (Plan C P11–P13) ───────────────
480
522
  * Lifted from Studio.css; complements the already-present `.st-insp-row`,
481
523
  * `.st-insp-label`, `.st-rp-*`. */
@@ -716,6 +758,15 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
716
758
  .st-cp-boxv--mb, .st-cp-boxv--pb { bottom: 1px; left: 50%; transform: translateX(-50%); }
717
759
  .st-cp-boxv--ml, .st-cp-boxv--pl { left: 0; top: 50%; transform: translateY(-50%); }
718
760
  .st-cp-boxv--mr, .st-cp-boxv--pr { right: 0; top: 50%; transform: translateY(-50%); }
761
+ /* feature-element-editing-robustness Stage B — Position inset widget. One ring
762
+ (no nested padding box): the 4 inset inputs sit on the outer box edges around
763
+ a centered core showing the current `position` keyword (Figma constraints box). */
764
+ .st-cp-boxv--it { top: 1px; left: 50%; transform: translateX(-50%); }
765
+ .st-cp-boxv--ib { bottom: 1px; left: 50%; transform: translateX(-50%); }
766
+ .st-cp-boxv--il { left: 0; top: 50%; transform: translateY(-50%); }
767
+ .st-cp-boxv--ir { right: 0; top: 50%; transform: translateY(-50%); }
768
+ .st-cp-boxtag--i { color: var(--accent); }
769
+ .st-cp-boxcore--pos { text-transform: none; letter-spacing: 0; }
719
770
  .st-cp-boxhint { display: flex; flex-wrap: wrap; gap: var(--space-1); }
720
771
  .st-cp-boxhint + .st-cp-row { margin-top: var(--space-1); }
721
772
  .st-cp-chip { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); background: var(--bg-2); border: 1px solid var(--border-default); border-radius: var(--radius-xs); padding: 1px 6px; }
@@ -762,6 +813,20 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
762
813
  .st-cp-add:hover { border-color: var(--accent); color: var(--fg-0); }
763
814
  .st-cp-note { font-size: 10px; color: var(--fg-2); font-style: italic; }
764
815
 
816
+ /* Stage M — Fixed / Hug / Fill sizing-mode segmented control (per axis). */
817
+ .st-cp-modes { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-1); }
818
+ .st-cp-modeseg { display: flex; align-items: stretch; gap: var(--space-1); }
819
+ .st-cp-modeax { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }
820
+ .st-cp-modebtn { flex: 1; appearance: none; cursor: pointer; border: 1px solid var(--border-default); background: var(--bg-3); color: var(--fg-1); font-family: var(--font-mono); font-size: 10px; min-height: 22px; border-radius: var(--radius-xs); padding: 0 var(--space-1); }
821
+ .st-cp-modebtn:hover:not(.is-active):not(:disabled) { background: var(--bg-4); color: var(--fg-0); }
822
+ .st-cp-modebtn.is-active { background: var(--accent-tint); border-color: var(--accent); color: var(--accent); }
823
+ .st-cp-modebtn:disabled { opacity: 0.5; cursor: default; }
824
+ /* Stage M — sub-header for the flex-child row group + the make-flex CTA. */
825
+ .st-cp-subhd { font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); text-transform: uppercase; letter-spacing: 0.04em; margin-top: var(--space-2); }
826
+ .st-cp-makeflex { align-self: flex-start; appearance: none; cursor: pointer; border: 1px dashed var(--border-default); background: transparent; color: var(--fg-1); font-family: var(--font-mono); font-size: var(--type-xs); border-radius: var(--radius-xs); padding: 4px var(--space-2); }
827
+ .st-cp-makeflex:hover:not(:disabled) { border-color: var(--accent); color: var(--fg-0); }
828
+ .st-cp-makeflex:disabled { opacity: 0.5; cursor: default; }
829
+
765
830
  /* provenance legend */
766
831
  .st-cp-legend { display: flex; flex-wrap: wrap; gap: var(--space-3); padding-top: var(--space-1); border-top: 1px solid var(--border-subtle); }
767
832
  .st-cp-legend span { display: inline-flex; align-items: center; gap: var(--space-1); font-family: var(--font-mono); font-size: 10px; color: var(--fg-2); }