@1agh/maude 0.30.0 → 0.31.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 (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,590 @@
1
+ // Phase 27 (epic E2) Task 7 — visual before/after diff + conflict resolver.
2
+ //
3
+ // Built from `.design/ui/DiffView.tsx` (side-by-side · overlay/slider · Keep
4
+ // mine/theirs/both). The Maude differentiator: a RENDERED before/after of the
5
+ // actual canvas, not a text diff. Each pane renders a CLEAN canvas (hide-chrome:
6
+ // no editor toolbar/minimap/devtools) — "after" = the working tree, "before" =
7
+ // a past version via `?sha=` — inside a SHARED, LOCKED zoom/pan view: scroll to
8
+ // zoom, drag to pan, both panes track the exact same region. Keep both is the
9
+ // default conflict resolution (zero data loss).
10
+
11
+ import { useEffect, useRef, useState } from 'react';
12
+
13
+ import { canvasUrl } from '../canvas-url.js';
14
+
15
+ const ICONS = {
16
+ x: (
17
+ <>
18
+ <line x1="4.3" y1="4.3" x2="11.7" y2="11.7" />
19
+ <line x1="11.7" y1="4.3" x2="4.3" y2="11.7" />
20
+ </>
21
+ ),
22
+ file: (
23
+ <>
24
+ <path d="M4 2h5l3 3v9H4z" />
25
+ <polyline points="9 2 9 5 12 5" />
26
+ </>
27
+ ),
28
+ check: <polyline points="3 8.2 6.4 11.5 13 4.2" />,
29
+ split: (
30
+ <>
31
+ <line x1="8" y1="2.5" x2="8" y2="13.5" />
32
+ <polyline points="5 6 2.5 8 5 10" />
33
+ <polyline points="11 6 13.5 8 11 10" />
34
+ </>
35
+ ),
36
+ user: (
37
+ <>
38
+ <circle cx="8" cy="5.5" r="2.6" />
39
+ <path d="M3.5 13a4.5 4.5 0 0 1 9 0" />
40
+ </>
41
+ ),
42
+ users: (
43
+ <>
44
+ <circle cx="6" cy="6" r="2.2" />
45
+ <path d="M2.5 12.5a3.6 3.6 0 0 1 7 0" />
46
+ <path d="M10.5 4.2a2.2 2.2 0 0 1 0 4.1" />
47
+ <path d="M11 12.5a3.6 3.6 0 0 0-1.2-2.7" />
48
+ </>
49
+ ),
50
+ copy: (
51
+ <>
52
+ <rect x="5" y="5" width="8" height="8" rx="1.2" />
53
+ <path d="M11 5V3.5a1 1 0 0 0-1-1H3.5a1 1 0 0 0-1 1V10a1 1 0 0 0 1 1H5" />
54
+ </>
55
+ ),
56
+ };
57
+ function Icon({ name, size = 16 }) {
58
+ return (
59
+ <svg
60
+ width={size}
61
+ height={size}
62
+ viewBox="0 0 16 16"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ strokeWidth="1.4"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ aria-hidden="true"
69
+ >
70
+ {ICONS[name]}
71
+ </svg>
72
+ );
73
+ }
74
+
75
+ /** The real app brand mark (same as the menubar — bordered message-bubble spark). */
76
+ function Brand() {
77
+ return (
78
+ <span className="st-brand">
79
+ <span className="st-brand-mark">
80
+ <svg viewBox="0 0 32 32" width="100%" height="100%" fill="none" aria-hidden="true">
81
+ <path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="currentColor" />
82
+ </svg>
83
+ </span>
84
+ <span className="st-brand-name">Maude</span>
85
+ </span>
86
+ );
87
+ }
88
+
89
+ function baseName(p) {
90
+ const s = (p || '').split('/').pop() || p || '';
91
+ return s.replace(/\.(tsx|html|meta\.json|css|svg|json)$/i, '');
92
+ }
93
+ function isRenderableCanvas(p) {
94
+ return /\.(tsx|html)$/i.test(p || '');
95
+ }
96
+ function timeAgo(iso) {
97
+ try {
98
+ const s = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
99
+ if (s < 60) return 'just now';
100
+ const m = Math.floor(s / 60);
101
+ if (m < 60) return `${m}m ago`;
102
+ const h = Math.floor(m / 60);
103
+ if (h < 24) return `${h}h ago`;
104
+ const d = Math.floor(h / 24);
105
+ if (d === 1) return 'yesterday';
106
+ if (d < 7) return `${d} days ago`;
107
+ return new Date(iso).toLocaleDateString();
108
+ } catch {
109
+ return '';
110
+ }
111
+ }
112
+ const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
113
+
114
+ /** A clean canvas iframe inside a zoom/pan viewport. `view` is the SHARED locked
115
+ * transform — both panes pass the same one, so dragging/zooming either moves
116
+ * both. The iframe is non-interactive (hide-chrome leaves nothing to click); the
117
+ * viewport owns scroll-to-zoom + drag-to-pan. */
118
+ function CanvasView({ src, view, setView, label }) {
119
+ const ref = useRef(null);
120
+ const drag = useRef(null);
121
+ const [panning, setPanning] = useState(false);
122
+
123
+ function onWheel(e) {
124
+ if (!src) return;
125
+ e.preventDefault();
126
+ const rect = ref.current.getBoundingClientRect();
127
+ const cx = e.clientX - rect.left;
128
+ const cy = e.clientY - rect.top;
129
+ setView((v) => {
130
+ const scale = clamp(v.scale * Math.exp(-e.deltaY * 0.0015), 0.25, 8);
131
+ const k = scale / v.scale;
132
+ return { scale, x: cx - (cx - v.x) * k, y: cy - (cy - v.y) * k };
133
+ });
134
+ }
135
+ function onPointerDown(e) {
136
+ if (!src || e.button !== 0) return;
137
+ ref.current.setPointerCapture(e.pointerId);
138
+ drag.current = { x: e.clientX, y: e.clientY };
139
+ setPanning(true);
140
+ }
141
+ function onPointerMove(e) {
142
+ if (!drag.current) return;
143
+ const dx = e.clientX - drag.current.x;
144
+ const dy = e.clientY - drag.current.y;
145
+ drag.current = { x: e.clientX, y: e.clientY };
146
+ setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
147
+ }
148
+ function endPan(e) {
149
+ if (!drag.current) return;
150
+ drag.current = null;
151
+ setPanning(false);
152
+ try {
153
+ ref.current.releasePointerCapture(e.pointerId);
154
+ } catch {
155
+ /* ignore */
156
+ }
157
+ }
158
+ return (
159
+ <div
160
+ ref={ref}
161
+ className={'dv-viewport' + (panning ? ' is-panning' : '')}
162
+ onWheel={onWheel}
163
+ onPointerDown={onPointerDown}
164
+ onPointerMove={onPointerMove}
165
+ onPointerUp={endPan}
166
+ onPointerLeave={endPan}
167
+ >
168
+ {src ? (
169
+ <div
170
+ className="dv-frame-wrap"
171
+ style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale})` }}
172
+ >
173
+ <iframe className="dv-frame" src={src} title={label} />
174
+ </div>
175
+ ) : (
176
+ <div className="dv-thumb-note">No saved version of this canvas to compare yet.</div>
177
+ )}
178
+ </div>
179
+ );
180
+ }
181
+
182
+ function ZoomBar({ view, setView }) {
183
+ const zoom = (factor) => setView((v) => ({ ...v, scale: clamp(v.scale * factor, 0.25, 8) }));
184
+ return (
185
+ <div className="dv-zoombar" role="group" aria-label="Zoom">
186
+ <button type="button" className="dv-zoom-btn" aria-label="Zoom out" onClick={() => zoom(0.8)}>
187
+
188
+ </button>
189
+ <button
190
+ type="button"
191
+ className="dv-zoom-val"
192
+ title="Reset to 100%"
193
+ aria-label="Reset zoom"
194
+ onClick={() => setView({ scale: 1, x: 0, y: 0 })}
195
+ >
196
+ {Math.round(view.scale * 100)}%
197
+ </button>
198
+ <button type="button" className="dv-zoom-btn" aria-label="Zoom in" onClick={() => zoom(1.25)}>
199
+ +
200
+ </button>
201
+ <span className="dv-zoom-sync" title="Both sides are locked together">
202
+ locked
203
+ </span>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ const CHOICES = [
209
+ { id: 'mine', icon: 'user', title: 'Keep mine', desc: 'Use your version. Their edits are set aside.' },
210
+ {
211
+ id: 'theirs',
212
+ icon: 'users',
213
+ title: 'Keep theirs',
214
+ desc: 'Use the published version. Your edits are set aside.',
215
+ },
216
+ {
217
+ id: 'both',
218
+ icon: 'copy',
219
+ title: 'Keep both',
220
+ rec: true,
221
+ desc: 'Keep theirs and save yours as a copy. Nothing is lost.',
222
+ },
223
+ ];
224
+
225
+ export default function DiffView({ target, cfg, loadLog, onResolve, onRestore, onClose }) {
226
+ const [mode, setMode] = useState('side'); // 'side' | 'overlay'
227
+ const [choice, setChoice] = useState('both');
228
+ const [view, setView] = useState({ scale: 1, x: 0, y: 0 });
229
+ const [split, setSplit] = useState(52);
230
+ const [busy, setBusy] = useState(false);
231
+ const sheetRef = useRef(null);
232
+ const overlayRef = useRef(null);
233
+
234
+ // phase-27.1 — which saved version the "before" pane renders. Seeded from the
235
+ // opener (History click passes a specific version; a plain compare passes
236
+ // HEAD), then re-pointable in-place via the "Saved version" picker.
237
+ const tgtFile = target?.file;
238
+ const tgtBeforeSha = target?.beforeSha;
239
+ const [beforeSha, setBeforeSha] = useState(tgtBeforeSha || 'HEAD');
240
+ const [versions, setVersions] = useState(null); // per-file saved versions, or null while loading
241
+
242
+ // Re-seed when the opener hands us a different file/version.
243
+ useEffect(() => {
244
+ setBeforeSha(tgtBeforeSha || 'HEAD');
245
+ }, [tgtFile, tgtBeforeSha]);
246
+
247
+ // Load the open file's saved versions for the picker (per-file History).
248
+ useEffect(() => {
249
+ if (!tgtFile || !loadLog) {
250
+ setVersions(null);
251
+ return;
252
+ }
253
+ let cancelled = false;
254
+ (async () => {
255
+ const entries = (await loadLog(tgtFile)) || [];
256
+ if (!cancelled) setVersions(entries);
257
+ })();
258
+ return () => {
259
+ cancelled = true;
260
+ };
261
+ }, [tgtFile, loadLog]);
262
+
263
+ useEffect(() => {
264
+ if (!target) return;
265
+ const opener = document.activeElement;
266
+ const onKey = (e) => {
267
+ if (e.key === 'Escape') {
268
+ e.preventDefault();
269
+ onClose();
270
+ }
271
+ };
272
+ window.addEventListener('keydown', onKey);
273
+ sheetRef.current?.focus();
274
+ return () => {
275
+ window.removeEventListener('keydown', onKey);
276
+ if (opener instanceof HTMLElement) opener.focus();
277
+ };
278
+ }, [target, onClose]);
279
+
280
+ if (!target) return null;
281
+ const { file, conflict } = target;
282
+ const renderable = isRenderableCanvas(file);
283
+ const opts = { thumbnail: true, hideChrome: true };
284
+ const afterSrc = renderable ? canvasUrl(file, cfg, opts) : null;
285
+ const beforeSrc = renderable ? canvasUrl(file, cfg, { ...opts, sha: beforeSha }) : null;
286
+ // The picker offers "Last saved" (HEAD) + each per-file saved version. Shown
287
+ // only in the compare view (not the conflict resolver) once history loads.
288
+ const showPicker = !conflict && renderable && !!loadLog && !!(versions && versions.length);
289
+ const selectedVersion =
290
+ beforeSha !== 'HEAD' && versions ? versions.find((v) => v.sha === beforeSha) : null;
291
+ const beforeWhen = selectedVersion ? timeAgo(selectedVersion.date) : 'last saved';
292
+
293
+ function splitDrag(e) {
294
+ const box = overlayRef.current?.getBoundingClientRect();
295
+ if (!box) return;
296
+ setSplit(clamp(((e.clientX - box.left) / box.width) * 100, 4, 96));
297
+ }
298
+ function onSplitDown(e) {
299
+ e.preventDefault();
300
+ const move = (ev) => splitDrag(ev);
301
+ const up = () => {
302
+ window.removeEventListener('pointermove', move);
303
+ window.removeEventListener('pointerup', up);
304
+ };
305
+ window.addEventListener('pointermove', move);
306
+ window.addEventListener('pointerup', up);
307
+ }
308
+
309
+ return (
310
+ <div
311
+ className="st-scrim dv-scrim"
312
+ role="presentation"
313
+ onMouseDown={(e) => {
314
+ if (e.target === e.currentTarget) onClose();
315
+ }}
316
+ >
317
+ <div
318
+ className="dv-sheet"
319
+ role="dialog"
320
+ aria-modal="true"
321
+ aria-label={`${conflict ? 'Resolve' : 'Compare'} ${baseName(file)}`}
322
+ ref={sheetRef}
323
+ tabIndex={-1}
324
+ >
325
+ <div className="dv-hd">
326
+ <Brand />
327
+ <span className="dv-title">
328
+ {conflict ? 'You both changed this canvas' : 'What changed'}
329
+ </span>
330
+ <span className="dv-file">
331
+ <Icon name="file" size={13} /> {baseName(file)}
332
+ </span>
333
+ <span className="dv-spacer" />
334
+ {showPicker && (
335
+ <label className="dv-verpick">
336
+ <span className="dv-verpick-lbl">Compare against</span>
337
+ <select
338
+ className="dv-verpick-sel"
339
+ value={beforeSha}
340
+ onChange={(e) => setBeforeSha(e.target.value)}
341
+ aria-label={`Saved version of ${baseName(file)} to compare against`}
342
+ >
343
+ <option value="HEAD">Last saved</option>
344
+ {versions.map((v) => (
345
+ <option key={v.sha} value={v.sha}>
346
+ {`${v.message || 'Saved version'} · ${timeAgo(v.date)}`}
347
+ </option>
348
+ ))}
349
+ </select>
350
+ </label>
351
+ )}
352
+ {!conflict && renderable && (
353
+ <div className="dv-seg" role="group" aria-label="Comparison mode">
354
+ <button
355
+ type="button"
356
+ className="dv-seg-btn"
357
+ aria-pressed={mode === 'side'}
358
+ onClick={() => setMode('side')}
359
+ >
360
+ Side by side
361
+ </button>
362
+ <button
363
+ type="button"
364
+ className="dv-seg-btn"
365
+ aria-pressed={mode === 'overlay'}
366
+ onClick={() => setMode('overlay')}
367
+ >
368
+ Overlay
369
+ </button>
370
+ </div>
371
+ )}
372
+ <button type="button" className="dv-close" aria-label="Close" onClick={onClose}>
373
+ <Icon name="x" size={14} />
374
+ </button>
375
+ </div>
376
+
377
+ {conflict ? (
378
+ <>
379
+ <div className="dv-pick-intro">
380
+ <div className="callout callout--info">
381
+ <span>
382
+ While you were working, someone published their own changes to{' '}
383
+ <strong style={{ color: 'var(--fg-0)' }}>{baseName(file)}</strong>. Pick which to
384
+ keep — <strong style={{ color: 'var(--fg-0)' }}>Keep both</strong> saves yours as a
385
+ copy so nothing is lost.
386
+ </span>
387
+ </div>
388
+ </div>
389
+ <div className="dv-stage">
390
+ <ZoomBar view={view} setView={setView} />
391
+ <div className="dv-pair">
392
+ <div className="dv-col">
393
+ <div className="dv-col-hd">
394
+ <span className="dv-col-tag">
395
+ <Icon name="user" size={12} /> Yours
396
+ </span>
397
+ <span className="dv-col-who">you · just now</span>
398
+ </div>
399
+ <div className="dv-thumb ring-mine">
400
+ <CanvasView src={afterSrc} view={view} setView={setView} label="Your version" />
401
+ </div>
402
+ </div>
403
+ <div className="dv-col">
404
+ <div className="dv-col-hd">
405
+ <span className="dv-col-tag">
406
+ <Icon name="users" size={12} /> Theirs
407
+ </span>
408
+ <span className="dv-col-who">published</span>
409
+ </div>
410
+ <div className="dv-thumb ring-theirs">
411
+ <CanvasView
412
+ src={beforeSrc}
413
+ view={view}
414
+ setView={setView}
415
+ label="Published version"
416
+ />
417
+ </div>
418
+ </div>
419
+ </div>
420
+ </div>
421
+ <div className="dv-picker" role="radiogroup" aria-label="How to resolve">
422
+ {CHOICES.map((c) => (
423
+ <button
424
+ key={c.id}
425
+ type="button"
426
+ className={'dv-pick' + (choice === c.id ? ' is-rec' : '')}
427
+ role="radio"
428
+ aria-checked={choice === c.id}
429
+ tabIndex={choice === c.id ? 0 : -1}
430
+ onClick={() => setChoice(c.id)}
431
+ onKeyDown={(e) => {
432
+ // Arrow-key navigation for the radiogroup (roving tabindex —
433
+ // only the checked radio is a Tab stop, so arrows must move it).
434
+ const dir =
435
+ e.key === 'ArrowRight' || e.key === 'ArrowDown'
436
+ ? 1
437
+ : e.key === 'ArrowLeft' || e.key === 'ArrowUp'
438
+ ? -1
439
+ : 0;
440
+ if (!dir) return;
441
+ e.preventDefault();
442
+ const i = CHOICES.findIndex((x) => x.id === choice);
443
+ const ni = (i + dir + CHOICES.length) % CHOICES.length;
444
+ setChoice(CHOICES[ni].id);
445
+ e.currentTarget.parentElement?.children[ni]?.focus();
446
+ }}
447
+ >
448
+ <span className="dv-pick-hd">
449
+ <Icon name={c.icon} size={14} />
450
+ <span className="dv-pick-title">{c.title}</span>
451
+ {c.rec && <span className="dv-pick-rec">Default</span>}
452
+ </span>
453
+ <span className="dv-pick-desc">{c.desc}</span>
454
+ </button>
455
+ ))}
456
+ </div>
457
+ <div className="dv-ft">
458
+ <span className="dv-note">
459
+ You can change your mind later — both versions stay in History.
460
+ </span>
461
+ <button
462
+ type="button"
463
+ className="btn btn--ghost btn--sm"
464
+ onClick={onClose}
465
+ disabled={busy}
466
+ >
467
+ Cancel
468
+ </button>
469
+ <button
470
+ type="button"
471
+ className="btn btn--primary btn--sm"
472
+ disabled={busy}
473
+ onClick={async () => {
474
+ setBusy(true);
475
+ try {
476
+ await onResolve?.(choice);
477
+ } finally {
478
+ setBusy(false);
479
+ }
480
+ }}
481
+ >
482
+ <Icon name="check" size={14} /> {CHOICES.find((c) => c.id === choice)?.title}
483
+ </button>
484
+ </div>
485
+ </>
486
+ ) : (
487
+ <>
488
+ <div className="dv-stage">
489
+ <ZoomBar view={view} setView={setView} />
490
+ {mode === 'side' ? (
491
+ <div className="dv-pair">
492
+ <div className="dv-col">
493
+ <div className="dv-col-hd">
494
+ <span className="dv-col-tag is-before">Saved version</span>
495
+ <span className="dv-col-who">{beforeWhen}</span>
496
+ </div>
497
+ <div className="dv-thumb">
498
+ <CanvasView
499
+ src={beforeSrc}
500
+ view={view}
501
+ setView={setView}
502
+ label="Saved version"
503
+ />
504
+ </div>
505
+ </div>
506
+ <div className="dv-col">
507
+ <div className="dv-col-hd">
508
+ <span className="dv-col-tag is-after">Your version</span>
509
+ <span className="dv-col-who">now · unsaved</span>
510
+ </div>
511
+ <div className="dv-thumb is-after">
512
+ <CanvasView
513
+ src={afterSrc}
514
+ view={view}
515
+ setView={setView}
516
+ label="Your version (now)"
517
+ />
518
+ </div>
519
+ </div>
520
+ </div>
521
+ ) : (
522
+ <div className="dv-overlay" ref={overlayRef} style={{ '--dv-split': `${split}%` }}>
523
+ <div className="dv-overlay-layer">
524
+ <CanvasView src={beforeSrc} view={view} setView={setView} label="Saved version" />
525
+ </div>
526
+ <div className="dv-overlay-after">
527
+ <CanvasView src={afterSrc} view={view} setView={setView} label="Your version" />
528
+ </div>
529
+ <span className="dv-overlay-tag l">Saved</span>
530
+ <span className="dv-overlay-tag r">Yours</span>
531
+ <div className="dv-split">
532
+ <span
533
+ className="dv-split-handle"
534
+ role="slider"
535
+ aria-label="Drag to wipe between saved and your version"
536
+ aria-valuemin={0}
537
+ aria-valuemax={100}
538
+ aria-valuenow={Math.round(split)}
539
+ tabIndex={0}
540
+ onPointerDown={onSplitDown}
541
+ onKeyDown={(e) => {
542
+ if (e.key === 'ArrowLeft') setSplit((s) => clamp(s - 4, 4, 96));
543
+ if (e.key === 'ArrowRight') setSplit((s) => clamp(s + 4, 4, 96));
544
+ }}
545
+ >
546
+ <Icon name="split" size={14} />
547
+ </span>
548
+ </div>
549
+ </div>
550
+ )}
551
+ </div>
552
+ <div className="dv-ft">
553
+ <span className="dv-note">
554
+ {mode === 'side'
555
+ ? 'A live rendered before & after — scroll to zoom, drag to pan. Both sides stay locked together.'
556
+ : 'Drag the handle to wipe between the saved version and yours.'}
557
+ </span>
558
+ {onRestore && (
559
+ <button
560
+ type="button"
561
+ className="btn btn--ghost btn--sm"
562
+ disabled={busy}
563
+ onClick={async () => {
564
+ if (
565
+ !window.confirm(
566
+ `Restore the saved version of “${baseName(file)}”? Your unsaved changes to it are discarded.`
567
+ )
568
+ )
569
+ return;
570
+ setBusy(true);
571
+ try {
572
+ await onRestore(file);
573
+ } finally {
574
+ setBusy(false);
575
+ }
576
+ }}
577
+ >
578
+ Restore saved version
579
+ </button>
580
+ )}
581
+ <button type="button" className="btn btn--primary btn--sm" onClick={onClose}>
582
+ Keep my version
583
+ </button>
584
+ </div>
585
+ </>
586
+ )}
587
+ </div>
588
+ </div>
589
+ );
590
+ }