@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.
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -44
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1476 -76
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/export-center.jsx +426 -0
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +65 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +19 -26
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +334 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +15 -17
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +368 -50
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/server.ts +3 -1
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +24 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/package.json +8 -8
- 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); }
|