@1agh/maude 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -32,7 +32,9 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
32
32
  // same "pull only pure logic into the client bundle" shape as the imports
33
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
34
34
  import { syncPresentation } from '../sync/presentation.ts';
35
- import { canvasUrl } from './canvas-url.js';
35
+ import { canvasTokenRefreshDelay, canvasUrl, setLiveCanvasToken } from './canvas-url.js';
36
+ import { applyEditRequest } from './apply-edit-request.ts';
37
+ import { createIndexLoader } from './index-loader.ts';
36
38
  import {
37
39
  BROWSER_CAPTURE_FORMATS,
38
40
  BROWSER_SERVABLE_FORMATS,
@@ -50,6 +52,8 @@ import GitPanel from './panels/GitPanel.jsx';
50
52
  import SyncConsentDialog from './panels/SyncConsentDialog.jsx';
51
53
  import SyncPanel from './panels/SyncPanel.jsx';
52
54
  import CloudBar from './panels/CloudBar.jsx';
55
+ import { buildShareLinks, normalizeOpenPath, readOpenParam, withOpenParam } from './share-link.js';
56
+ import { ShareDialog, copyShareLink } from './share-dialog.jsx';
53
57
  import IdentityBar from './panels/IdentityBar.jsx';
54
58
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
55
59
  import { ReadinessDialog } from './panels/ReadinessList.jsx';
@@ -119,6 +123,8 @@ import { COLLAB_TOUR } from './tour/collab-tour.js';
119
123
  import { TourOverlay } from './tour/overlay.jsx';
120
124
  import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
121
125
  import { USAGE_TOUR } from './tour/usage-tour.js';
126
+ import { dismissNotice, NotificationHost, notify, notifyCanvasText } from '../notifications.tsx';
127
+ import { acceptCanvasNotice } from '../canvas-notice-message.ts';
122
128
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
123
129
  import { ReportBugDialog } from './report-bug.jsx';
124
130
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
@@ -202,6 +208,7 @@ function DockSlot({ side, width, open, ids, activeId, onPick, children, labels =
202
208
  key={id}
203
209
  type="button"
204
210
  role="tab"
211
+ data-testid={`dock-tab-${id}`}
205
212
  aria-selected={activeId === id}
206
213
  className={'st-docktab' + (activeId === id ? ' is-active' : '')}
207
214
  onClick={() => onPick(id)}
@@ -245,6 +252,18 @@ function pathTestIdSlug(p) {
245
252
  .toLowerCase()
246
253
  .replace(/^-+|-+$/g, '');
247
254
  }
255
+ // A layers tree's identity: its element ids in document order. Two trees with
256
+ // the same signature are the same DOM as far as positional ids go.
257
+ function layersTreeSig(nodes) {
258
+ const ids = [];
259
+ (function walk(list) {
260
+ for (const n of list || []) {
261
+ ids.push(n?.id ?? '');
262
+ walk(n?.children);
263
+ }
264
+ })(nodes);
265
+ return ids.join(',');
266
+ }
248
267
  // Bun's `define` substitutes this at build time (see build.ts); falls back when
249
268
  // the bundle is consumed in a context that hasn't run the build.
250
269
  const MDCC_VERSION = typeof __MDCC_VERSION__ !== 'undefined' ? __MDCC_VERSION__ : 'dev';
@@ -331,58 +350,9 @@ function basename(p) {
331
350
  return p.split('/').pop();
332
351
  }
333
352
 
334
- // DDR-150 dogfood — a transient shell toast for timeline/inline-edit op results.
335
- // The clip ops used to console.warn their failures (a 422 "series clip can't
336
- // move" looked like "the button does nothing"). Self-contained DOM (no React
337
- // state), mirrors the canvas-side showCanvasToast.
338
- // `action` (feature-file-tree-drag-drop-folders, Task 10) — optional
339
- // `{ label, onClick }` for an inline undo affordance (the move toast). Absent
340
- // for every other caller, so their toast stays the plain auto-dismissing text
341
- // bubble this always was.
353
+ // Keep call sites (including Undo actions) on the shared notification stack.
342
354
  function shellToast(message, ok = false, action) {
343
- if (typeof document === 'undefined') return;
344
- let el = document.getElementById('st-op-toast');
345
- if (!el) {
346
- el = document.createElement('div');
347
- el.id = 'st-op-toast';
348
- el.setAttribute('role', 'status');
349
- el.style.cssText =
350
- 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
351
- 'display:flex;align-items:center;gap:10px;max-width:440px;padding:8px 14px;' +
352
- 'border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
353
- 'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
354
- document.body.appendChild(el);
355
- }
356
- el.style.background = ok ? '#1d3524' : '#3a1d1d';
357
- el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
358
- el.textContent = '';
359
- const text = document.createElement('span');
360
- text.textContent = message;
361
- el.appendChild(text);
362
- if (action) {
363
- const btn = document.createElement('button');
364
- btn.type = 'button';
365
- btn.textContent = action.label;
366
- btn.style.cssText =
367
- 'pointer-events:auto;background:none;border:0;text-decoration:underline;' +
368
- 'color:inherit;font:inherit;cursor:pointer;padding:0;';
369
- btn.onclick = () => {
370
- action.onClick();
371
- el.style.opacity = '0';
372
- el.style.pointerEvents = 'none';
373
- };
374
- el.appendChild(btn);
375
- }
376
- el.style.opacity = '1';
377
- el.style.pointerEvents = action ? 'auto' : 'none';
378
- clearTimeout(shellToast._t);
379
- shellToast._t = setTimeout(
380
- () => {
381
- el.style.opacity = '0';
382
- el.style.pointerEvents = 'none';
383
- },
384
- action ? 6000 : 3200
385
- );
355
+ notify({ title: message, kind: ok ? 'success' : 'error', action });
386
356
  }
387
357
 
388
358
  // DDR-223 (issue #93, supersedes DDR-187's boot half) — one-time first-run
@@ -402,48 +372,24 @@ function browseFirstRunHint(readOnly = false) {
402
372
  } catch {
403
373
  return;
404
374
  }
405
- if (document.getElementById('st-browse-hint')) return;
406
- const el = document.createElement('div');
407
- el.id = 'st-browse-hint';
408
- el.setAttribute('role', 'status');
409
- el.style.cssText =
410
- 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:81;' +
411
- 'display:flex;align-items:center;gap:10px;max-width:520px;padding:10px 14px;border-radius:10px;' +
412
- 'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
413
- 'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
414
- 'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
415
- const kbd =
416
- '<kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
417
- 'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd>';
418
- el.innerHTML =
419
- // Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
420
- // hint must not promise an editor the role doesn't have.
421
- (readOnly
422
- ? `<span>Your mock is <strong>live</strong> — click things to try it. Press ${kbd} to select &amp; inspect.</span>`
423
- : '<span>You’re in <strong>Edit</strong> — click selects, like Figma. ' +
424
- 'Switch to <strong>Preview</strong> in the toolbar to use the live mock.</span>') +
425
- '<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
426
- 'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
375
+ if (browseFirstRunHint.active) return;
376
+ browseFirstRunHint.active = true;
427
377
  const dismiss = () => {
428
- try {
429
- localStorage.setItem(MODE_HINT_SEEN, '1');
430
- } catch {
431
- /* private mode */
432
- }
433
- el.style.opacity = '0';
434
- setTimeout(() => el.remove(), 200);
378
+ browseFirstRunHint.active = false;
379
+ try { localStorage.setItem(MODE_HINT_SEEN, '1'); } catch {}
435
380
  document.removeEventListener('keydown', onV, true);
436
381
  };
437
382
  const onV = (e) => {
438
- if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
383
+ if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) {
384
+ dismissNotice('mode-hint');
385
+ dismiss();
386
+ }
439
387
  };
440
- el.querySelector('button')?.addEventListener('click', dismiss);
441
388
  if (readOnly) document.addEventListener('keydown', onV, true);
442
- document.body.appendChild(el);
443
- requestAnimationFrame(() => {
444
- el.style.opacity = '1';
445
- });
446
- setTimeout(dismiss, 9000);
389
+ notify({ id: 'mode-hint', title: readOnly ? 'Your mock is live' : 'You’re in Edit',
390
+ description: readOnly ? 'Click things to try it. Press V to select & inspect.'
391
+ : 'Click selects, like Figma. Switch to Preview in the toolbar to use the live mock.',
392
+ onDismiss: dismiss });
447
393
  }
448
394
 
449
395
  // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
@@ -2368,6 +2314,10 @@ function FileRow({
2368
2314
  // feature-file-tree-drag-drop-folders (Task 9) — the KEYBOARD path for
2369
2315
  // "Move to…" (drag-only fails WCAG 2.1.1). Same eligibility as dragging.
2370
2316
  const canMove = draggableRow && typeof menu !== 'undefined';
2317
+ // Plan T17/L03 — a supporting file (notes, styles, images, media) in a
2318
+ // canvas folder gets the same ⋯ menu: rename, move, delete.
2319
+ const supporting = !isCanvas && !!pKind && !sidecar && typeof menu !== 'undefined';
2320
+ const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
2371
2321
  const fileDir = file.path.split('/').slice(0, -1).join('/');
2372
2322
  // Stable hook for the desktop E2E harness (data-testid convention — see the
2373
2323
  // `desktop-e2e` skill): canvas rows only, slug derived from the relative path
@@ -2385,7 +2335,7 @@ function FileRow({
2385
2335
  <button
2386
2336
  type="button"
2387
2337
  role="treeitem"
2388
- data-testid={testId}
2338
+ data-testid={testId ?? (supporting ? `file-row-${pathTestIdSlug(file.path)}` : undefined)}
2389
2339
  aria-selected={isSel}
2390
2340
  aria-disabled={inert ? 'true' : undefined}
2391
2341
  aria-busy={isBusy || undefined}
@@ -2404,7 +2354,17 @@ function FileRow({
2404
2354
  else if (pKind) onPreview?.(file.path);
2405
2355
  }}
2406
2356
  onContextMenu={
2407
- canMove ? (e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir }) : undefined
2357
+ canShare
2358
+ ? (e) =>
2359
+ menu.openAt(e, {
2360
+ kind: 'file',
2361
+ path: file.path,
2362
+ dir: fileDir,
2363
+ canMove: canMove || supporting,
2364
+ supporting,
2365
+ name: file.name,
2366
+ })
2367
+ : undefined
2408
2368
  }
2409
2369
  {...dragHandlers}
2410
2370
  >
@@ -2438,21 +2398,30 @@ function FileRow({
2438
2398
  {oc > 0 && <span className="st-row-badge">{oc}</span>}
2439
2399
  </button>
2440
2400
  );
2441
- if (!canDelete && !canMove) return row;
2401
+ if (!canDelete && !canShare) return row;
2442
2402
  // Sibling menu/delete buttons (can't nest a button in the row button). The
2443
2403
  // wrapper is presentational so the treeitem stays the tree's child for a11y.
2444
2404
  return (
2445
2405
  <div className="st-row-wrap" role="none">
2446
2406
  {row}
2447
- {canMove && (
2407
+ {canShare && (
2448
2408
  <button
2449
2409
  type="button"
2450
2410
  className={'st-row-menu-btn' + (canDelete ? ' has-delete-sibling' : '')}
2451
2411
  data-testid={`tree-row-menu-${pathTestIdSlug(file.path)}`}
2452
- title={`Move ${label}…`}
2412
+ title={`Actions for ${label}`}
2453
2413
  aria-label={`Actions for ${label}`}
2454
2414
  aria-haspopup="menu"
2455
- onClick={(e) => menu.openAt(e, { kind: 'file', path: file.path, dir: fileDir })}
2415
+ onClick={(e) =>
2416
+ menu.openAt(e, {
2417
+ kind: 'file',
2418
+ path: file.path,
2419
+ dir: fileDir,
2420
+ canMove: canMove || supporting,
2421
+ supporting,
2422
+ name: file.name,
2423
+ })
2424
+ }
2456
2425
  >
2457
2426
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2458
2427
  </button>
@@ -2526,6 +2495,7 @@ function CanvasRow({
2526
2495
  const isDragging = drag?.draggedPath === primary.path;
2527
2496
  const isBusy = drag?.busyPath === primary.path;
2528
2497
  const canMove = draggableRow && typeof menu !== 'undefined';
2498
+ const canShare = typeof menu !== 'undefined' && kind !== 'runtime';
2529
2499
  const primaryDir = primary.path.split('/').slice(0, -1).join('/');
2530
2500
  const row = (
2531
2501
  <button
@@ -2552,7 +2522,7 @@ function CanvasRow({
2552
2522
  onOpen(primary.path);
2553
2523
  }}
2554
2524
  onContextMenu={
2555
- canMove ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir }) : undefined
2525
+ canShare ? (e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove }) : undefined
2556
2526
  }
2557
2527
  {...dragHandlers}
2558
2528
  >
@@ -2594,17 +2564,17 @@ function CanvasRow({
2594
2564
  );
2595
2565
  return (
2596
2566
  <Fragment>
2597
- {canMove ? (
2567
+ {canShare ? (
2598
2568
  <div className="st-row-wrap" role="none">
2599
2569
  {row}
2600
2570
  <button
2601
2571
  type="button"
2602
2572
  className="st-row-menu-btn"
2603
2573
  data-testid={`tree-row-menu-${pathTestIdSlug(primary.path)}`}
2604
- title={`Move ${displayName(primary.name)}…`}
2574
+ title={`Actions for ${displayName(primary.name)}`}
2605
2575
  aria-label={`Actions for ${displayName(primary.name)}`}
2606
2576
  aria-haspopup="menu"
2607
- onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir })}
2577
+ onClick={(e) => menu.openAt(e, { kind: 'file', path: primary.path, dir: primaryDir, canMove })}
2608
2578
  >
2609
2579
  <Icon d="M12 6a1 1 0 100-2 1 1 0 000 2zM12 13a1 1 0 100-2 1 1 0 000 2zM12 20a1 1 0 100-2 1 1 0 000 2z" size={12} />
2610
2580
  </button>
@@ -2617,6 +2587,7 @@ function CanvasRow({
2617
2587
  <FileRow
2618
2588
  key={sc.path}
2619
2589
  file={sc}
2590
+ menu={menu}
2620
2591
  activePath={activePath}
2621
2592
  previewPath={previewPath}
2622
2593
  onOpen={onOpen}
@@ -2723,6 +2694,7 @@ function Tree({
2723
2694
  <FileRow
2724
2695
  key={entry.primary.path}
2725
2696
  file={entry.primary}
2697
+ menu={menu}
2726
2698
  activePath={activePath}
2727
2699
  previewPath={previewPath}
2728
2700
  onOpen={onOpen}
@@ -2737,6 +2709,7 @@ function Tree({
2737
2709
  <FileRow
2738
2710
  key={entry.primary.path}
2739
2711
  file={entry.primary}
2712
+ menu={menu}
2740
2713
  activePath={activePath}
2741
2714
  previewPath={previewPath}
2742
2715
  onOpen={onOpen}
@@ -2795,7 +2768,7 @@ function Tree({
2795
2768
  defaultOpen={true}
2796
2769
  dirPath={childPath}
2797
2770
  drag={drag}
2798
- menu={menu}
2771
+ menu={drag ? menu : undefined}
2799
2772
  >
2800
2773
  {childTree}
2801
2774
  </DirRow>
@@ -2868,12 +2841,20 @@ function Sidebar({
2868
2841
  onMoveCanvas,
2869
2842
  onNewFolder,
2870
2843
  onDeleteFolder,
2844
+ onRenameFolder,
2845
+ onRenameCanvas,
2846
+ onDuplicateCanvas,
2847
+ onDeleteFile,
2871
2848
  // Passed through to CloudBar only — the live `sync:status` payload that makes
2872
2849
  // the connect note follow the link instead of freezing at attach time.
2873
2850
  syncStatus,
2874
2851
  // Passed through to CloudBar only — lifts linkedHub changes to the app shell
2875
2852
  // so the GitPanel's cloud-managed posture (DDR-218) reacts live.
2876
2853
  onLinkedHub,
2854
+ onLocalProject,
2855
+ onOpenLinkedFile,
2856
+ filesReady,
2857
+ onShare,
2877
2858
  // feature-cloud-managed-git-posture — the widened DDR-218 gate, resolved once
2878
2859
  // in App and handed down. Withdraws the drafts switcher: a local branch
2879
2860
  // switch moves a HEAD the cell knows nothing about.
@@ -2909,7 +2890,67 @@ function Sidebar({
2909
2890
  const menuExtra = rowMenu.state?.extra;
2910
2891
  const rowMenuRootItems =
2911
2892
  menuExtra?.kind === 'file'
2912
- ? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }]
2893
+ ? [
2894
+ { id: 'share', label: 'Share…', onSelect: () => { rowMenu.close(); onShare(menuExtra.path); } },
2895
+ ...(onRenameCanvas && /\.tsx$/i.test(menuExtra.path)
2896
+ ? [
2897
+ {
2898
+ id: 'rename-canvas',
2899
+ label: 'Rename…',
2900
+ onSelect: () => {
2901
+ rowMenu.close();
2902
+ const current = displayName(menuExtra.path.split('/').pop());
2903
+ const name = window.prompt('Rename canvas to:', current);
2904
+ if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
2905
+ },
2906
+ },
2907
+ ]
2908
+ : []),
2909
+ ...(onDuplicateCanvas && /\.tsx$/i.test(menuExtra.path)
2910
+ ? [
2911
+ {
2912
+ id: 'duplicate-canvas',
2913
+ label: 'Duplicate',
2914
+ onSelect: () => {
2915
+ rowMenu.close();
2916
+ onDuplicateCanvas(menuExtra.path);
2917
+ },
2918
+ },
2919
+ ]
2920
+ : []),
2921
+ // Plan T17/L03 — a supporting file renames as itself (the extension
2922
+ // stays: a rename is not a conversion).
2923
+ ...(menuExtra.supporting && onRenameCanvas
2924
+ ? [
2925
+ {
2926
+ id: 'rename-file',
2927
+ label: 'Rename…',
2928
+ onSelect: () => {
2929
+ rowMenu.close();
2930
+ const n = menuExtra.name ?? menuExtra.path.split('/').pop();
2931
+ const ext = n.includes('.') ? n.slice(n.lastIndexOf('.')) : '';
2932
+ const current = ext ? n.slice(0, -ext.length) : n;
2933
+ const name = window.prompt(`Rename ${n} to:`, current);
2934
+ if (name?.trim() && name.trim() !== current) onRenameCanvas(menuExtra.path, name.trim());
2935
+ },
2936
+ },
2937
+ ]
2938
+ : []),
2939
+ ...(menuExtra.canMove ? [{ id: 'move-to', label: 'Move to…', onSelect: () => rowMenu.showMoveTo() }] : []),
2940
+ ...(menuExtra.supporting && onDeleteFile
2941
+ ? [
2942
+ {
2943
+ id: 'delete-file',
2944
+ label: 'Delete',
2945
+ destructive: true,
2946
+ onSelect: () => {
2947
+ rowMenu.close();
2948
+ onDeleteFile(menuExtra.path, menuExtra.name ?? menuExtra.path.split('/').pop());
2949
+ },
2950
+ },
2951
+ ]
2952
+ : []),
2953
+ ]
2913
2954
  : menuExtra?.kind === 'dir'
2914
2955
  ? [
2915
2956
  {
@@ -2921,6 +2962,16 @@ function Sidebar({
2921
2962
  if (name?.trim()) onNewFolder(menuExtra.dirPath, name.trim());
2922
2963
  },
2923
2964
  },
2965
+ {
2966
+ id: 'rename-folder',
2967
+ label: 'Rename folder',
2968
+ onSelect: () => {
2969
+ rowMenu.close();
2970
+ const current = menuExtra.dirPath.split('/').pop();
2971
+ const name = window.prompt('Rename folder to:', current);
2972
+ if (name?.trim() && name.trim() !== current) onRenameFolder(menuExtra.dirPath, name.trim());
2973
+ },
2974
+ },
2924
2975
  {
2925
2976
  id: 'delete-folder',
2926
2977
  label: 'Delete folder',
@@ -2933,7 +2984,7 @@ function Sidebar({
2933
2984
  ]
2934
2985
  : [];
2935
2986
  const rowMenuDestinations =
2936
- menuExtra?.kind === 'file'
2987
+ menuExtra?.kind === 'file' || menuExtra?.kind === 'supporting'
2937
2988
  ? destinations.filter((d) => d.path !== menuExtra.dir)
2938
2989
  : [];
2939
2990
 
@@ -3225,7 +3276,7 @@ function Sidebar({
3225
3276
  canvasKinds={canvasKinds}
3226
3277
  dirPath={g.fullPath}
3227
3278
  drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
3228
- menu={!isDs && g.kind === 'canvas' && !readOnly ? rowMenu : undefined}
3279
+ menu={rowMenu}
3229
3280
  />
3230
3281
  ) : (
3231
3282
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -3253,9 +3304,9 @@ function Sidebar({
3253
3304
  and `/_api/git/checkout` keep exactly their old gates, and a terminal
3254
3305
  `git checkout` still works (and still flushes into Yjs via DDR-051's
3255
3306
  watcher). Absent, not disabled — there is nothing to explain here. */}
3256
- {!savingIsManaged && (
3257
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
3258
- )}
3307
+ {/* Plan T22 — withdrawn means the BRANCH half. A managed project still
3308
+ needs the way to another project, so the dock stays, project-only. */}
3309
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} projectOnly={savingIsManaged} />
3259
3310
  {/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
3260
3311
  above the GitHub identity. Dev-server-backed, so it works in the desktop
3261
3312
  shell AND a plain browser. */}
@@ -3264,7 +3315,7 @@ function Sidebar({
3264
3315
  Absent rather than disabled, because unlike the agent chat there is
3265
3316
  nothing here to explain — the capability is not missing, it is
3266
3317
  already satisfied. */}
3267
- {cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} /> : null}
3318
+ {cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} onLocalProject={onLocalProject} onOpenFile={onOpenLinkedFile} filesReady={filesReady} /> : null}
3268
3319
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
3269
3320
  sign in, connected account + New/Pull/Share, sign out. Self-contained
3270
3321
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3973,12 +4024,13 @@ function ToolsDropdown({ onAction, onClose, readOnly = false }) {
3973
4024
 
3974
4025
  // Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
3975
4026
  // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
3976
- function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
4027
+ function FileDropdown({ onAction, onClose, hasCanvas, hasSharePath, readOnly = false }) {
3977
4028
  // Cloud Phase 25 C2 — a viewer keeps the reads (export, handoff, reload,
3978
4029
  // close); create / assemble / generate / settings are absent.
3979
4030
  const items = readOnly
3980
4031
  ? [
3981
4032
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
4033
+ { id: 'share', label: 'Share link…', disabled: !hasSharePath },
3982
4034
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
3983
4035
  { sep: true },
3984
4036
  { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
@@ -3991,6 +4043,7 @@ function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3991
4043
  // dropped as reference chips on the active canvas.
3992
4044
  { id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
3993
4045
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
4046
+ { id: 'share', label: 'Share link…', disabled: !hasSharePath },
3994
4047
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
3995
4048
  { sep: true },
3996
4049
  // feature-ai-media-generation (DDR-16x) — BYOK generate action + settings.
@@ -4052,6 +4105,8 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
4052
4105
 
4053
4106
  function Menubar({
4054
4107
  activePath,
4108
+ sharePath,
4109
+ onShare,
4055
4110
  project,
4056
4111
  /** `{ dashboardUrl?, projectName }` when this is a cloud tab, else null. */
4057
4112
  // Tri-state (see the note where this value is created): `undefined` until
@@ -4390,6 +4445,7 @@ function Menubar({
4390
4445
  type="button"
4391
4446
  className="st-menu"
4392
4447
  role="menuitem"
4448
+ data-testid={`menu-${key}`}
4393
4449
  data-tour={key === 'help' ? 'help' : undefined}
4394
4450
  aria-haspopup={hasDropdown ? 'menu' : undefined}
4395
4451
  aria-expanded={hasDropdown ? open : undefined}
@@ -4409,10 +4465,12 @@ function Menubar({
4409
4465
  <FileDropdown
4410
4466
  readOnly={readOnly}
4411
4467
  hasCanvas={!!activePath}
4468
+ hasSharePath={!!sharePath}
4412
4469
  onAction={(id) => {
4413
4470
  if (id === 'new') onNewCanvas?.();
4414
4471
  else if (id === 'assemble') onAssembleVideo?.();
4415
4472
  else if (id === 'export') onOpenExport?.('export');
4473
+ else if (id === 'share') { document.querySelector('[data-testid="menu-file"]')?.focus(); onShare?.(); }
4416
4474
  else if (id === 'handoff') onOpenExport?.('handoff');
4417
4475
  else if (id === 'generate') onOpenGenerate?.();
4418
4476
  else if (id === 'settings') onOpenSettings?.();
@@ -4556,6 +4614,7 @@ function Menubar({
4556
4614
  <StIcon name="sparkle" size={15} />
4557
4615
  </button>
4558
4616
  )}
4617
+ <button type="button" className="st-mb-icon st-share-btn" data-testid="share-btn" data-tip={sharePath ? 'Share link' : 'Open a canvas to share it'} aria-label="Share link" disabled={!sharePath} onClick={onShare}><StIcon name="share" size={15} /></button>
4559
4618
  {exportCenter && <ExportBadge center={exportCenter} />}
4560
4619
  <button
4561
4620
  type="button"
@@ -4625,6 +4684,28 @@ function Viewport({
4625
4684
  useEffect(() => {
4626
4685
  if (previewPath) previewRef.current?.focus();
4627
4686
  }, [previewPath]);
4687
+ // An open canvas keeps the URL it was opened with. The capability in it is
4688
+ // re-minted on a timer (canvas-url.js); letting that change the `src` of a
4689
+ // live iframe would reload every open canvas every few minutes. The fresh
4690
+ // capability reaches an open canvas by message instead, and a frame that is
4691
+ // (re)created — a new tab, a Retry, a config change — is built with it.
4692
+ // Only while the frame is open: a closed tab reopened later is a new frame
4693
+ // and must not inherit a capability that may have expired meanwhile.
4694
+ const srcCache = useRef(new Map());
4695
+ const openPaths = new Set(tabs.map((t) => t.path));
4696
+ for (const [key, entry] of srcCache.current) {
4697
+ if (!openPaths.has(entry.path)) srcCache.current.delete(key);
4698
+ }
4699
+ const stableSrc = (path) => {
4700
+ const bare = canvasUrl(path, { ...cfg, canvasToken: undefined });
4701
+ const key = `${path}#${canvasReloadNonce}|${bare}`;
4702
+ let entry = srcCache.current.get(key);
4703
+ if (!entry) {
4704
+ entry = { path, src: canvasUrl(path, cfg) };
4705
+ srcCache.current.set(key, entry);
4706
+ }
4707
+ return entry.src;
4708
+ };
4628
4709
  // One observable word for "what is the canvas pane actually doing" — the
4629
4710
  // top-frame signal the #115 E2E scenario waits on, and the only place these
4630
4711
  // three states are named together. `ready` is `dgn:'loaded'`-backed, so it
@@ -4717,7 +4798,7 @@ function Viewport({
4717
4798
  // path exactly as before.
4718
4799
  key={`${t.path}#${canvasReloadNonce}`}
4719
4800
  ref={(el) => registerIframe(t.path, el)}
4720
- src={canvasUrl(t.path, cfg)}
4801
+ src={stableSrc(t.path)}
4721
4802
  className={t.path === activePath ? 'active' : ''}
4722
4803
  data-path={t.path}
4723
4804
  data-testid={t.path === activePath ? 'canvas-frame' : undefined}
@@ -5294,6 +5375,7 @@ function CommentsPanel({
5294
5375
  type="button"
5295
5376
  className={'st-cm-filter' + (filter === 'all' ? ' is-active' : '')}
5296
5377
  role="tab"
5378
+ data-testid="comment-filter-all"
5297
5379
  aria-selected={filter === 'all'}
5298
5380
  onClick={() => setFilter('all')}
5299
5381
  >
@@ -5303,6 +5385,7 @@ function CommentsPanel({
5303
5385
  type="button"
5304
5386
  className={'st-cm-filter' + (filter === 'open' ? ' is-active' : '')}
5305
5387
  role="tab"
5388
+ data-testid="comment-filter-open"
5306
5389
  aria-selected={filter === 'open'}
5307
5390
  onClick={() => setFilter('open')}
5308
5391
  >
@@ -5312,6 +5395,7 @@ function CommentsPanel({
5312
5395
  type="button"
5313
5396
  className={'st-cm-filter' + (filter === 'resolved' ? ' is-active' : '')}
5314
5397
  role="tab"
5398
+ data-testid="comment-filter-resolved"
5315
5399
  aria-selected={filter === 'resolved'}
5316
5400
  onClick={() => setFilter('resolved')}
5317
5401
  >
@@ -5343,6 +5427,7 @@ function CommentsPanel({
5343
5427
  {g.comments.map((c) => (
5344
5428
  <div
5345
5429
  key={c.id}
5430
+ data-testid={`comment-item-${c.id}`}
5346
5431
  className={
5347
5432
  'st-comment' +
5348
5433
  (c.status === 'resolved' ? ' is-resolved' : '') +
@@ -5620,6 +5705,11 @@ const CSS_JUSTIFY = [
5620
5705
  'space-evenly',
5621
5706
  ];
5622
5707
  const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
5708
+ /** What an edit route says the write replaced (`previous`, read under the file
5709
+ * lock) — the value an undo must restore. `fallback` only when the route could
5710
+ * not name it (an expression, or an older server). */
5711
+ const replacedValue = (j, fallback) =>
5712
+ j && Object.hasOwn(j, 'previous') ? j.previous : fallback;
5623
5713
  const CSS_FONTS = [
5624
5714
  'inherit',
5625
5715
  'system-ui',
@@ -6512,12 +6602,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6512
6602
  // no longer triggers a reselect that would re-post fresh `authored` values — so
6513
6603
  // the panel must reflect its own commits immediately or it shows the stale
6514
6604
  // pre-edit value until the user re-selects. Each commit/reset writes here;
6515
- // `null` marks a removed key. Cleared when a different element is selected.
6605
+ // `null` marks a removed key. Cleared when a different element is selected —
6606
+ // and whenever the canvas re-posts this selection's maps. A re-post carries
6607
+ // the source as it is NOW (a teammate's change reloads the canvas); keeping
6608
+ // our older commit over it showed a stale value and recorded it as the next
6609
+ // undo's `before`, so Cmd+Z restored a value nobody had on screen.
6516
6610
  const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
6517
- // biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
6611
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear on a fresh selection payload only.
6518
6612
  useEffect(() => {
6519
6613
  setOverlay({ a: {}, c: {}, t: {} });
6520
- }, [el.id]);
6614
+ }, [el.id, el.authored, el.customStyles, el.attrs]);
6521
6615
  const mergeOverlay = (base, ov) => {
6522
6616
  const out = { ...(base || {}) };
6523
6617
  for (const [k, v] of Object.entries(ov)) {
@@ -6602,10 +6696,27 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6602
6696
  ...s,
6603
6697
  [key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
6604
6698
  }));
6699
+ return res.ok ? j : null;
6605
6700
  } catch (err) {
6606
6701
  setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
6702
+ return null;
6607
6703
  }
6608
6704
  }
6705
+ // Write, THEN record the undo entry from what the write actually replaced
6706
+ // (`previous`, read server-side under the file lock). The panel's own value
6707
+ // can be a teammate's edit old — it is not re-posted when a peer changes the
6708
+ // source — and recording it made Cmd+Z restore a value nobody had on screen.
6709
+ // Serialized so the stack keeps edit order; a failed write records nothing.
6710
+ const writeChainRef = useRef(Promise.resolve());
6711
+ const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
6712
+ writeChainRef.current = writeChainRef.current.then(async () => {
6713
+ const j = await post(url, payload, key);
6714
+ if (!j?.ok) return;
6715
+ const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
6716
+ if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
6717
+ record(op, prop, before, after);
6718
+ });
6719
+ };
6609
6720
  // Optimistic preview: nudge the live element so the change shows before the
6610
6721
  // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
6611
6722
  // selection has no stable id (can't be resolved in the canvas).
@@ -6639,8 +6750,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6639
6750
  if (value === (before ?? '').trim()) return; // no-op
6640
6751
  optimistic(property, value);
6641
6752
  setA(property, value); // reflect in the panel immediately (no reload → no reselect)
6642
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, value }, property);
6643
- record('css', property, before, value);
6753
+ writeAndRecord(
6754
+ '/_api/edit-css',
6755
+ { canvas: el.file, id: el.id, property, value },
6756
+ property,
6757
+ 'css',
6758
+ property,
6759
+ before,
6760
+ value
6761
+ );
6644
6762
  };
6645
6763
  // A custom CSS property (Advanced) — same write, but the panel surfaces it from
6646
6764
  // the customStyles map, so overlay THERE.
@@ -6651,8 +6769,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6651
6769
  const before = customStyles[prop] ?? null;
6652
6770
  optimistic(prop, value);
6653
6771
  setC(prop, value);
6654
- post('/_api/edit-css', { canvas: el.file, id: el.id, property: prop, value }, prop);
6655
- record('css', prop, before, value);
6772
+ writeAndRecord(
6773
+ '/_api/edit-css',
6774
+ { canvas: el.file, id: el.id, property: prop, value },
6775
+ prop,
6776
+ 'css',
6777
+ prop,
6778
+ before,
6779
+ value
6780
+ );
6656
6781
  };
6657
6782
  const commitAttr = (attr, raw) => {
6658
6783
  const a = (attr || '').trim();
@@ -6660,8 +6785,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6660
6785
  if (!editable || !a || !value) return;
6661
6786
  const before = attrs[a] ?? null;
6662
6787
  setT(a, value);
6663
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
6664
- record('attr', a, before, value);
6788
+ writeAndRecord(
6789
+ '/_api/edit-attr',
6790
+ { canvas: el.file, id: el.id, attr: a, value },
6791
+ `@${a}`,
6792
+ 'attr',
6793
+ a,
6794
+ before,
6795
+ value
6796
+ );
6665
6797
  };
6666
6798
  // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
6667
6799
  const reset = (property) => {
@@ -6669,23 +6801,44 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6669
6801
  const before = authored[property] ?? null;
6670
6802
  optimistic(property, null);
6671
6803
  setA(property, null);
6672
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6673
- record('css', property, before, null);
6804
+ writeAndRecord(
6805
+ '/_api/edit-css',
6806
+ { canvas: el.file, id: el.id, property, reset: true },
6807
+ property,
6808
+ 'css',
6809
+ property,
6810
+ before,
6811
+ null
6812
+ );
6674
6813
  };
6675
6814
  const resetCustom = (property) => {
6676
6815
  if (!editable) return;
6677
6816
  const before = customStyles[property] ?? null;
6678
6817
  optimistic(property, null);
6679
6818
  setC(property, null);
6680
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6681
- record('css', property, before, null);
6819
+ writeAndRecord(
6820
+ '/_api/edit-css',
6821
+ { canvas: el.file, id: el.id, property, reset: true },
6822
+ property,
6823
+ 'css',
6824
+ property,
6825
+ before,
6826
+ null
6827
+ );
6682
6828
  };
6683
6829
  const resetAttr = (attr) => {
6684
6830
  if (!editable) return;
6685
6831
  const before = attrs[attr] ?? null;
6686
6832
  setT(attr, null);
6687
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
6688
- record('attr', attr, before, null);
6833
+ writeAndRecord(
6834
+ '/_api/edit-attr',
6835
+ { canvas: el.file, id: el.id, attr, reset: true },
6836
+ `@${attr}`,
6837
+ 'attr',
6838
+ attr,
6839
+ before,
6840
+ null
6841
+ );
6689
6842
  };
6690
6843
  // Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
6691
6844
  // `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
@@ -9451,8 +9604,10 @@ function InspectorPanel({
9451
9604
  };
9452
9605
  // An annotation-image has no element tabs; force Photo. Otherwise honor the
9453
9606
  // requested tab, but drop off a stale 'photo' tab when the new selection isn't
9454
- // photo-eligible (so switching from a photo to a normal element lands sanely).
9455
- const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'inspect' : tab;
9607
+ // photo-eligible — onto CSS, the editing tab a fresh selection opens on (Stage
9608
+ // C). Landing on read-only Inspect meant that after touching a sticker, the
9609
+ // next element you clicked showed facts instead of the controls to change it.
9610
+ const effTab = photoOnly ? 'photo' : tab === 'photo' && !photoTarget ? 'css' : tab;
9456
9611
  const tabBtn = (id, label, icon) => (
9457
9612
  <button
9458
9613
  type="button"
@@ -9714,6 +9869,9 @@ function InspectorPanel({
9714
9869
 
9715
9870
  function App() {
9716
9871
  const [groups, setGroups] = useState([]);
9872
+ const [treeLoaded, setTreeLoaded] = useState(false);
9873
+ const addressMode = useRef('push');
9874
+ const previousAddressPath = useRef(null);
9717
9875
  const [project, setProject] = useState('Design');
9718
9876
  const [tabs, setTabs] = useState([]);
9719
9877
  const [activePath, setActivePath] = useState(null);
@@ -9723,6 +9881,7 @@ function App() {
9723
9881
  // state, or iframe registration that openTab()/openSystem() drive.
9724
9882
  const [previewPath, setPreviewPath] = useState(null);
9725
9883
  const onPreview = useCallback((path) => {
9884
+ addressMode.current = 'push';
9726
9885
  setPreviewPath(path);
9727
9886
  }, []);
9728
9887
  const [selected, setSelected] = useState(null);
@@ -9829,6 +9988,30 @@ function App() {
9829
9988
  // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
9830
9989
  // so the dgn:'loaded' handler re-selects the moved element by its NEW id.
9831
9990
  const pendingReorderRef = useRef(null);
9991
+ // The last layers tree the canvas posted, and its id signature. A duplicate
9992
+ // or insert renumbers every later positional id, so the copy's NEW id already
9993
+ // names another element (the next sibling) in the pre-write tree: settling
9994
+ // on the first tree that merely contains it selected the wrong node. The
9995
+ // pending entry records the signature at request time and waits for a tree
9996
+ // that differs from it.
9997
+ const lastLayersTreeRef = useRef(null);
9998
+ const settlePendingSelectionRef = useRef(null);
9999
+ settlePendingSelectionRef.current = (tree, sig, artboardId) => {
10000
+ const pend = pendingReorderRef.current;
10001
+ if (!pend || !pend.movedId) return;
10002
+ if (pend.staleSig != null && sig === pend.staleSig) return; // still the pre-write DOM
10003
+ const has = (function find(nodes) {
10004
+ return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
10005
+ })(tree);
10006
+ if (!has) return;
10007
+ pendingReorderRef.current = null;
10008
+ const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
10009
+ if (win) {
10010
+ try {
10011
+ win.postMessage({ dgn: 'select-by-id', id: pend.movedId, artboardId, index: 0 }, '*');
10012
+ } catch {}
10013
+ }
10014
+ };
9832
10015
  // Latest `reorderLayer` (defined far below), read from the stale-closure
9833
10016
  // onMessage handler when the in-canvas grip posts dgn:'reorder-request'
9834
10017
  // (same freshness idiom as selectedRef; avoids a render-time TDZ on the
@@ -9924,6 +10107,7 @@ function App() {
9924
10107
  // CloudBar (status resolve / attach / detach). Gates the GitPanel's
9925
10108
  // cloud-managed posture; linked-but-uncredentialed keeps the full panel.
9926
10109
  const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
10110
+ const [localProjectName, setLocalProjectName] = useState(null);
9927
10111
  // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
9928
10112
  // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
9929
10113
  // `diffTarget` opens the before/after DiffView ({ file, conflict }).
@@ -10247,6 +10431,7 @@ function App() {
10247
10431
  const [paletteOpen, setPaletteOpen] = useState(false);
10248
10432
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
10249
10433
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
10434
+ const [shareDialog, setShareDialog] = useState(null);
10250
10435
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
10251
10436
  // feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
10252
10437
  // the AI generate action.
@@ -10649,6 +10834,71 @@ function App() {
10649
10834
  const wsRef = useRef(null);
10650
10835
  const iframesRef = useRef(new Map());
10651
10836
 
10837
+ // THE CANVAS CAPABILITY OUTLIVES NO TAB (cloud only). It expires 15 minutes
10838
+ // after the proxy minted it (render-token.mjs) and cannot be revoked, so it
10839
+ // stays short — which left every cloud tab open longer than that with
10840
+ // canvases that stopped updating: a teammate's edit re-imports the canvas
10841
+ // module, and the canvas origin answered 401. Re-mint it well inside the
10842
+ // lifetime (every `/_config` answer carries a fresh one), hand it to each
10843
+ // open canvas, and let new frames be built with it. On waking a tab that
10844
+ // slept past the cadence, do it at once. Desktops have no capability and
10845
+ // never start the timer.
10846
+ useEffect(() => {
10847
+ if (!cfg?.canvasToken) return undefined;
10848
+ let stopped = false;
10849
+ let timer = null;
10850
+ let dueAt = 0;
10851
+ const schedule = (token) => {
10852
+ clearTimeout(timer);
10853
+ const delay = canvasTokenRefreshDelay(token);
10854
+ dueAt = Date.now() + delay;
10855
+ timer = setTimeout(refresh, delay);
10856
+ };
10857
+ async function refresh() {
10858
+ let token = null;
10859
+ try {
10860
+ const res = await fetch('/_config', { cache: 'no-store', credentials: 'same-origin' });
10861
+ if (res.ok) token = (await res.json())?.canvasToken;
10862
+ } catch {
10863
+ /* retried below; the current capability may still be valid */
10864
+ }
10865
+ if (stopped) return;
10866
+ if (typeof token !== 'string' || !token) {
10867
+ clearTimeout(timer);
10868
+ dueAt = Date.now() + 30_000;
10869
+ timer = setTimeout(refresh, 30_000);
10870
+ return;
10871
+ }
10872
+ setLiveCanvasToken(token);
10873
+ // To the canvas origin only, never '*': a frame the canvas content
10874
+ // navigated elsewhere must not be handed the capability.
10875
+ let target = null;
10876
+ try {
10877
+ target = new URL(cfg.canvasOrigin, location.href).origin;
10878
+ } catch {}
10879
+ if (target) {
10880
+ for (const el of iframesRef.current.values()) {
10881
+ try {
10882
+ el.contentWindow?.postMessage({ dgn: 'canvas-cap', t: token }, target);
10883
+ } catch {}
10884
+ }
10885
+ }
10886
+ schedule(token);
10887
+ }
10888
+ schedule(cfg.canvasToken);
10889
+ // A background tab's timers are throttled; one woken past its due time
10890
+ // re-mints at once rather than on the throttled tick.
10891
+ const onVisible = () => {
10892
+ if (document.visibilityState === 'visible' && Date.now() >= dueAt) refresh();
10893
+ };
10894
+ document.addEventListener('visibilitychange', onVisible);
10895
+ return () => {
10896
+ stopped = true;
10897
+ clearTimeout(timer);
10898
+ document.removeEventListener('visibilitychange', onVisible);
10899
+ };
10900
+ }, [cfg?.canvasToken, cfg?.canvasOrigin]);
10901
+
10652
10902
  // Phase 5.1 — postMessage bridge from menubar dropdowns to the canvas iframe.
10653
10903
  // The canvas-shell listens for these `dgn:*` messages and dispatches into the
10654
10904
  // matching local provider (annotations visibility / both selection stores /
@@ -11589,37 +11839,47 @@ function App() {
11589
11839
  }, []);
11590
11840
 
11591
11841
  // ----- Tree -----
11592
- const loadTree = useCallback(async () => {
11593
- try {
11594
- const r = await fetch('/_index-data');
11595
- const data = await r.json();
11596
- setProject(data.project || 'Design');
11597
- const built = data.groups.map((g) => ({
11598
- ...g,
11599
- tree: buildTree(g.paths, g.stripPrefix, g.dirs),
11600
- }));
11601
- setGroups(built);
11602
- // DDR-093 — fold the server-resolved per-canvas DS map into cfg so
11603
- // canvasUrl() injects each UI canvas's OWN design-system tokens instead of
11604
- // always designSystems[0]. Functional merge to coexist with the /_config
11605
- // fetch (either may land first). `?? {}` keeps older servers (no map) on
11606
- // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
11607
- // canvas refreshes the map.
11608
- setCfg((prev) => ({
11609
- ...prev,
11610
- canvasDesignSystems: data.canvasDesignSystems ?? {},
11611
- // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
11612
- // only `reconstructed-experimental`), folded in the same way + for the
11613
- // same reason as canvasDesignSystems above.
11614
- canvasKinds: data.canvasKinds ?? {},
11615
- }));
11616
- } catch (e) {
11617
- console.error('failed to load tree', e);
11618
- }
11619
- }, []);
11620
-
11842
+ const treeLoaderRef = useRef(null);
11843
+ const loadTree = useCallback(() => treeLoaderRef.current?.reload() ?? Promise.resolve(), []);
11621
11844
  useEffect(() => {
11845
+ const loader = createIndexLoader({
11846
+ read: async (signal) => {
11847
+ const r = await fetch('/_index-data', { signal, cache: 'no-store' });
11848
+ if (!r.ok) throw Object.assign(new Error(`Project index request failed: ${r.status}`), { status: r.status });
11849
+ const data = await r.json();
11850
+ const built = data.groups.map((g) => ({
11851
+ ...g,
11852
+ tree: buildTree(g.paths, g.stripPrefix, g.dirs),
11853
+ }));
11854
+ return { data, built };
11855
+ },
11856
+ apply: ({ data, built }) => {
11857
+ setProject(data.project || 'Design');
11858
+ setGroups(built);
11859
+ setTreeLoaded(true);
11860
+ // DDR-093 — fold the server-resolved per-canvas DS map into cfg so
11861
+ // canvasUrl() injects each UI canvas's OWN design-system tokens instead of
11862
+ // always designSystems[0]. Functional merge to coexist with the /_config
11863
+ // fetch (either may land first). `?? {}` keeps older servers (no map) on
11864
+ // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
11865
+ // canvas refreshes the map.
11866
+ setCfg((prev) => ({
11867
+ ...prev,
11868
+ canvasDesignSystems: data.canvasDesignSystems ?? {},
11869
+ // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
11870
+ // only `reconstructed-experimental`), folded in the same way + for the
11871
+ // same reason as canvasDesignSystems above.
11872
+ canvasKinds: data.canvasKinds ?? {},
11873
+ }));
11874
+ },
11875
+ onError: (error) => console.error('failed to load tree', error),
11876
+ });
11877
+ treeLoaderRef.current = loader;
11622
11878
  loadTree();
11879
+ return () => {
11880
+ treeLoaderRef.current = null;
11881
+ loader.dispose();
11882
+ };
11623
11883
  }, [loadTree]);
11624
11884
 
11625
11885
  // ----- System data (lazy) -----
@@ -11668,6 +11928,7 @@ function App() {
11668
11928
  }, [loadAllComments]);
11669
11929
 
11670
11930
  // ----- WebSocket -----
11931
+ const canvasListChangeRef = useRef(() => {});
11671
11932
  useEffect(() => {
11672
11933
  // KEEPALIVE. The inspector feed only pushes on events (a comment, a
11673
11934
  // selection, sync:status), so an idle designer's socket exchanges nothing
@@ -11779,26 +12040,8 @@ function App() {
11779
12040
  // Phase 9 Task 8 — hub connection state for the offline banner.
11780
12041
  setSyncStatus(m.payload);
11781
12042
  } else if (m.type === 'canvas-list-update') {
11782
- // Phase 30 — a canvas was created/deleted on THIS dev-server; re-read
11783
- // the branch-scoped tree so other open tabs reflect it without a
11784
- // reload. Cross-machine peers get a new canvas via git "Get latest".
11785
12043
  loadTree();
11786
- // feature-file-tree-drag-drop-folders (Task 10) — a canvas moved.
11787
- // moveCanvasReq already retargets the INITIATING tab locally (no
11788
- // need to wait for this broadcast to round-trip); this branch is
11789
- // for every OTHER open tab on the same dev-server, so a canvas
11790
- // that was open there doesn't go dead pointing at a path that no
11791
- // longer exists.
11792
- if (m.payload?.action === 'moved' && m.payload.fromRel && m.payload.rel) {
11793
- const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
11794
- /^\/+|\/+$/g,
11795
- ''
11796
- );
11797
- const fromFile = `${designRel}/${m.payload.fromRel}`;
11798
- const toFile = `${designRel}/${m.payload.rel}`;
11799
- setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
11800
- setActivePath((prev) => (prev === fromFile ? toFile : prev));
11801
- }
12044
+ canvasListChangeRef.current(m.payload);
11802
12045
  } else if (m.type === 'config-updated') {
11803
12046
  // Server hot-reloaded .design/config.json (/design:setup-ds rewrote
11804
12047
  // it) — refetch /_config so designSystems / tokensCssRel / groups
@@ -12001,6 +12244,39 @@ function App() {
12001
12244
  }
12002
12245
  }, []);
12003
12246
 
12247
+ // ACCEPTED REVISIONS — the project's own history (DDR-241, T27). When the
12248
+ // project saves through accepted revisions the History tab lists logical
12249
+ // actions (who did what, when), not Git commits; a row previews as `r<rev>`
12250
+ // through the same version preview. `'legacy'` means "use Git history".
12251
+ const [projectHistoryOn, setProjectHistoryOn] = useState(false);
12252
+ const loadAcceptedLog = useCallback(async (path) => {
12253
+ try {
12254
+ const qs =
12255
+ '/_api/project/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
12256
+ const r = await fetch(qs);
12257
+ if (!r.ok) return 'legacy';
12258
+ const data = await r.json();
12259
+ if (!data?.ok) return data?.reason === 'legacy' ? 'legacy' : null;
12260
+ return (data.history || []).map((a) => {
12261
+ const where = path ? '' : (a.canvases || []).map((c) => c.split('/').pop()).join(', ');
12262
+ return {
12263
+ sha: `r${a.revision}`,
12264
+ message: [a.label || a.kind, where].filter(Boolean).join(' · '),
12265
+ author: a.actor,
12266
+ date: new Date(a.committedAt).toISOString(),
12267
+ accepted: {
12268
+ revision: a.revision,
12269
+ actionId: a.actionId,
12270
+ mine: !!a.mine,
12271
+ undo: a.kind === 'undo' || a.kind === 'redo',
12272
+ },
12273
+ };
12274
+ });
12275
+ } catch {
12276
+ return null;
12277
+ }
12278
+ }, []);
12279
+
12004
12280
  // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
12005
12281
  // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
12006
12282
  // live with the WS broadcast.
@@ -12044,20 +12320,85 @@ function App() {
12044
12320
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
12045
12321
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
12046
12322
  const openTab = useCallback((path) => {
12323
+ addressMode.current = 'push';
12324
+ setFocusedCommentId(null);
12325
+ setPreviewPath(null);
12326
+ // The same path keeps its mounted iframe. It will not emit a new loaded
12327
+ // event, so keep its current loading/error state until an actual retry.
12328
+ if (path === activePath) return;
12047
12329
  setTabs((prev) => {
12048
12330
  // Drop the previously-open iframe so we don't leak DOM nodes.
12049
12331
  for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
12050
12332
  return [{ path }];
12051
12333
  });
12052
12334
  setActivePath(path);
12053
- setFocusedCommentId(null);
12054
- setPreviewPath(null);
12055
12335
  setCanvasError(null);
12056
12336
  setLoadedPath(null);
12057
12337
  // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
12058
12338
  // the onLoad fallback timer (legacy .html), or a hard 15s cap.
12059
12339
  if (path !== SYSTEM_TAB) setLoadingPath(path);
12060
- }, []);
12340
+ }, [activePath]);
12341
+
12342
+ // Resolve URL identities against the loaded tree, including non-canvas previews.
12343
+ const openLinkedFile = useCallback((rel, mode = 'push') => {
12344
+ const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
12345
+ if (path && CANVAS_EXT_RE.test(path)) openTab(path);
12346
+ else if (path && previewKind(basename(path))) onPreview(path);
12347
+ else {
12348
+ setTabs([]);
12349
+ setActivePath(null);
12350
+ setPreviewPath(null);
12351
+ notify({ title: 'Not here yet', description: `${rel} is not in this project (not synced yet?)`, kind: 'info' });
12352
+ }
12353
+ addressMode.current = mode;
12354
+ }, [groups, cfg.designRel, openTab, onPreview]);
12355
+
12356
+ const addressBooted = useRef(false);
12357
+ useEffect(() => {
12358
+ if (!treeLoaded || cfg.cloud === undefined || addressBooted.current) return;
12359
+ addressBooted.current = true;
12360
+ const rel = readOpenParam(location, cfg.designRel);
12361
+ if (rel) openLinkedFile(rel, 'none');
12362
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12363
+
12364
+ useEffect(() => {
12365
+ if (!treeLoaded || cfg.cloud === undefined) return;
12366
+ const navigate = () => {
12367
+ const rel = readOpenParam(location, cfg.designRel);
12368
+ if (rel) openLinkedFile(rel, 'none');
12369
+ else {
12370
+ addressMode.current = 'none';
12371
+ setTabs([]);
12372
+ setActivePath(null);
12373
+ setPreviewPath(null);
12374
+ }
12375
+ };
12376
+ window.addEventListener('popstate', navigate);
12377
+ return () => window.removeEventListener('popstate', navigate);
12378
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12379
+
12380
+ useEffect(() => {
12381
+ const visible = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12382
+ if (visible === previousAddressPath.current) return;
12383
+ previousAddressPath.current = visible;
12384
+ const rel = normalizeOpenPath(visible, cfg.designRel);
12385
+ if (addressMode.current !== 'none' && readOpenParam(location, cfg.designRel) !== rel) {
12386
+ history[rel ? 'pushState' : 'replaceState'](rel ? { open: rel } : null, '', withOpenParam(location, rel));
12387
+ }
12388
+ addressMode.current = 'push';
12389
+ }, [activePath, previewPath, cfg.designRel]);
12390
+
12391
+ const sharePath = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12392
+ const shareShell = cfg.cloud ? 'cloud' : isNativeApp() ? 'native' : 'local';
12393
+ const shareLinksFor = (path) => buildShareLinks({
12394
+ rel: normalizeOpenPath(path, cfg.designRel), shell: shareShell, location,
12395
+ linkedHubUrl: cloudLinkedHub?.url, project: cfg.cloud ? project : localProjectName,
12396
+ localUrl: location.origin,
12397
+ });
12398
+ const showShare = (path = sharePath) => {
12399
+ const rel = normalizeOpenPath(path, cfg.designRel);
12400
+ if (rel) setShareDialog({ path, rel, label: basename(path) });
12401
+ };
12061
12402
 
12062
12403
  // Retry from the #115 error panel: re-read /_config FIRST (the stale
12063
12404
  // `canvasOrigin` is the likeliest reason we are here at all), then remount the
@@ -12089,6 +12430,7 @@ function App() {
12089
12430
  useEffect(() => {
12090
12431
  if (autoOpened.current) return;
12091
12432
  if (!cfg.cloud) return; // desktop / unknown-yet
12433
+ if (new URLSearchParams(location.search).has('open')) { autoOpened.current = true; return; }
12092
12434
  if (tabs.length > 0) return;
12093
12435
  if (!groups.length) return; // tree not loaded
12094
12436
  // The first canvas that is somebody's WORK — a design-system specimen is a
@@ -12149,6 +12491,23 @@ function App() {
12149
12491
  [activePath]
12150
12492
  );
12151
12493
 
12494
+ // The socket stays connected while this ref follows the current config and
12495
+ // active tab. Structural events apply to local and remote users alike.
12496
+ canvasListChangeRef.current = (change) => {
12497
+ if (!change?.rel) return;
12498
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
12499
+ const file = `${designRel}/${change.rel}`;
12500
+ if (change.action === 'removed') {
12501
+ closeTab(file);
12502
+ } else if (change.action === 'moved' && change.fromRel) {
12503
+ const fromFile = `${designRel}/${change.fromRel}`;
12504
+ setTabs((prev) => prev.map((t) => t.path === fromFile ? { ...t, path: file } : t));
12505
+ setActivePath((prev) => prev === fromFile ? file : prev);
12506
+ setLoadingPath((prev) => prev === fromFile ? null : prev);
12507
+ iframesRef.current.delete(fromFile);
12508
+ }
12509
+ };
12510
+
12152
12511
  const reloadActive = useCallback(() => {
12153
12512
  if (!activePath || activePath === SYSTEM_TAB) {
12154
12513
  if (activePath === SYSTEM_TAB) loadSystemData();
@@ -12533,6 +12892,100 @@ function App() {
12533
12892
  [loadTree]
12534
12893
  );
12535
12894
 
12895
+ // Plan T17 — rename a folder: a move to a new name in the same parent (one
12896
+ // dir.move action in accepted mode, carrying every canvas inside).
12897
+ const renameFolderReq = useCallback(
12898
+ async (dirPath, name) => {
12899
+ const parent = dirPath.split('/').slice(0, -1).join('/');
12900
+ try {
12901
+ const r = await fetch('/_api/fs-move', {
12902
+ method: 'POST',
12903
+ headers: { 'Content-Type': 'application/json' },
12904
+ body: JSON.stringify({ file: dirPath, toDir: parent, toName: name }),
12905
+ });
12906
+ const j = await r.json().catch(() => ({}));
12907
+ if (!r.ok || !j.ok) {
12908
+ shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
12909
+ return;
12910
+ }
12911
+ await loadTree();
12912
+ } catch (e) {
12913
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12914
+ }
12915
+ },
12916
+ [loadTree]
12917
+ );
12918
+
12919
+ // Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
12920
+ // follows the new path) and duplicate one beside itself.
12921
+ const renameCanvasReq = useCallback(
12922
+ async (filePath, name) => {
12923
+ try {
12924
+ const r = await fetch('/_api/fs-move', {
12925
+ method: 'POST',
12926
+ headers: { 'Content-Type': 'application/json' },
12927
+ body: JSON.stringify({ file: filePath, toName: name }),
12928
+ });
12929
+ const j = await r.json().catch(() => ({}));
12930
+ if (!r.ok || !j.ok) {
12931
+ shellToast(`Could not rename: ${j.error || `error ${r.status}`}`);
12932
+ return;
12933
+ }
12934
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
12935
+ /^\/+|\/+$/g,
12936
+ ''
12937
+ );
12938
+ const fromFile = `${designRel}/${j.fromRel}`;
12939
+ const toFile = `${designRel}/${j.toRel}`;
12940
+ await loadTree();
12941
+ setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
12942
+ setActivePath((prev) => (prev === fromFile ? toFile : prev));
12943
+ } catch (e) {
12944
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12945
+ }
12946
+ },
12947
+ [loadTree, cfg]
12948
+ );
12949
+ const duplicateCanvasReq = useCallback(
12950
+ async (filePath) => {
12951
+ try {
12952
+ const r = await fetch('/_api/canvas', {
12953
+ method: 'POST',
12954
+ headers: { 'Content-Type': 'application/json' },
12955
+ body: JSON.stringify({ duplicateOf: filePath }),
12956
+ });
12957
+ const j = await r.json().catch(() => ({}));
12958
+ if (!r.ok || !j.ok) {
12959
+ shellToast(`Could not duplicate: ${j.error || `error ${r.status}`}`);
12960
+ return;
12961
+ }
12962
+ await loadTree();
12963
+ shellToast(`Duplicated as ${j.rel.split('/').pop().replace(/\.tsx$/i, '')}`, true);
12964
+ } catch (e) {
12965
+ shellToast(`Duplicate failed: ${e instanceof Error ? e.message : 'network error'}`);
12966
+ }
12967
+ },
12968
+ [loadTree]
12969
+ );
12970
+
12971
+ const deleteFileReq = useCallback(
12972
+ async (filePath, name) => {
12973
+ if (!window.confirm(`Move “${name}” to trash?\n\nYou can restore it from .design/_trash/.`)) return;
12974
+ try {
12975
+ const r = await fetch(`/_api/canvas?file=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
12976
+ const j = await r.json().catch(() => ({}));
12977
+ if (!r.ok || !j.ok) {
12978
+ shellToast(`Could not delete: ${j.error || `error ${r.status}`}`);
12979
+ return;
12980
+ }
12981
+ await loadTree();
12982
+ } catch (e) {
12983
+ shellToast(`Delete failed: ${e instanceof Error ? e.message : 'network error'}`);
12984
+ }
12985
+ },
12986
+ [loadTree]
12987
+ );
12988
+
12536
12989
  const clearSelected = useCallback(() => {
12537
12990
  wsSend({ type: 'clear-select' });
12538
12991
  setSelected(null);
@@ -12582,6 +13035,13 @@ function App() {
12582
13035
  if (e.origin !== expectedOrigin) return;
12583
13036
  const m = e.data;
12584
13037
  if (!m || typeof m !== 'object' || !m.dgn) return;
13038
+ if (m.dgn === 'canvas-notice') {
13039
+ const activeWin = activePath && activePath !== SYSTEM_TAB
13040
+ ? iframesRef.current.get(activePath)?.contentWindow : null;
13041
+ const notice = acceptCanvasNotice(e, expectedOrigin, activeWin);
13042
+ if (notice) notifyCanvasText(notice.title, notice.kind);
13043
+ return;
13044
+ }
12585
13045
  if (m.dgn === 'tool-cursor') {
12586
13046
  // Phase 24 — show the active canvas tool's cursor across the WHOLE app
12587
13047
  // shell (sidebar, top bar, everything) so the custom cursor is visible
@@ -12723,55 +13183,48 @@ function App() {
12723
13183
  // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
12724
13184
  // `edit-source` command can't call the main-origin-only `/_api/edit-*`
12725
13185
  // routes (DDR-054), so it asks us to re-apply the before/after value.
12726
- // `value` null = reset (remove the inline prop / attr). For CSS we also
12727
- // optimistically repaint so the revert shows before the HMR reload.
12728
- const op = m.op;
13186
+ // `value` null = reset (remove the inline prop / attr). The css/attr
13187
+ // request carries the command's expected current value, so an undo can
13188
+ // never overwrite a teammate's newer value (audit 2026-09-13 P1 #5);
13189
+ // the outcome goes back to the canvas so a refused undo/redo does not
13190
+ // advance its stack.
13191
+ const req = applyEditRequest(m);
13192
+ if (!req) return;
13193
+ const replyTo = e.source;
13194
+ const reply = (result) => {
13195
+ if (typeof m.requestId !== 'string') return;
13196
+ try {
13197
+ replyTo?.postMessage({ dgn: 'apply-edit-result', requestId: m.requestId, ...result }, '*');
13198
+ } catch {}
13199
+ };
12729
13200
  const value = typeof m.value === 'string' ? m.value : null;
12730
- let url;
12731
- let body;
12732
- if (op === 'css') {
12733
- url = '/_api/edit-css';
12734
- body =
12735
- value == null
12736
- ? { canvas: m.canvas, id: m.id, property: m.key, reset: true }
12737
- : { canvas: m.canvas, id: m.id, property: m.key, value };
12738
- applyOptimisticStyle({ id: m.id, prop: m.key, value });
12739
- } else if (op === 'attr') {
12740
- url = '/_api/edit-attr';
12741
- body =
12742
- value == null
12743
- ? { canvas: m.canvas, id: m.id, attr: m.key, reset: true }
12744
- : { canvas: m.canvas, id: m.id, attr: m.key, value };
12745
- } else {
12746
- url = '/_api/edit-text';
12747
- body = { canvas: m.canvas, id: m.id, text: value ?? '' };
12748
- // Undo/redo of a `{variable}` text edit needs to re-target the source
12749
- // string: the occurrence + the value currently on disk (`from` — the
12750
- // side we're replacing FROM). Harmless for literal text.
12751
- if (typeof m.occurrence === 'number') body.occurrence = m.occurrence;
12752
- if (typeof m.from === 'string') body.before = m.from;
12753
- }
12754
13201
  editApplyChainRef.current = editApplyChainRef.current
12755
13202
  .catch(() => {})
12756
13203
  .then(() =>
12757
- fetch(url, {
13204
+ fetch(req.url, {
12758
13205
  method: 'POST',
12759
13206
  headers: { 'content-type': 'application/json' },
12760
- body: JSON.stringify(body),
13207
+ body: JSON.stringify(req.body),
12761
13208
  })
12762
13209
  .then((r) => r.json().catch(() => ({})))
12763
13210
  .then((j) => {
12764
- // Undo/redo lane (Cmd+Z/Cmd+Shift+Z re-application). The primary
12765
- // edit-text lane above owns the user-facing revert + toast; a
12766
- // refusal here is logged. (DDR-150 P1 kept this a warn after the
12767
- // setStatus-scope fix — setStatus is not in App's scope.)
12768
- if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
13211
+ if (j.ok) {
13212
+ // Repaint only once the source accepted it: a refused undo
13213
+ // must not show a value the file does not hold.
13214
+ if (req.op === 'css') applyOptimisticStyle({ id: m.id, prop: m.key, value });
13215
+ reply({ ok: true });
13216
+ return;
13217
+ }
13218
+ console.warn('[apply-edit]', req.op, j.error || 'failed');
13219
+ reply({ ok: false, error: j.error || 'failed', conflict: j.conflict === true });
12769
13220
  })
12770
- .catch(() => {})
13221
+ .catch(() => reply({ ok: false, error: 'network error' }))
12771
13222
  );
12772
13223
  } else if (m.dgn === 'layers-tree') {
12773
13224
  // Phase 12 Task 4 — browsable layers tree for the active artboard.
12774
13225
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
13226
+ const sig = layersTreeSig(m.tree);
13227
+ lastLayersTreeRef.current = { tree: m.tree, sig, artboardId: m.artboardId ?? null };
12775
13228
  // fresh tree (correct ids) landed — drags OK again
12776
13229
  layersBusyRef.current = false;
12777
13230
  if (layersBusyTimerRef.current) {
@@ -12787,24 +13240,7 @@ function App() {
12787
13240
  // actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
12788
13241
  // first (old ids, pre-write); consuming then would re-select nothing and
12789
13242
  // burn the pending ref before the real post-HMR tree lands.
12790
- const pend = pendingReorderRef.current;
12791
- if (pend && pend.movedId) {
12792
- const has = (function find(nodes) {
12793
- return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
12794
- })(m.tree);
12795
- if (has) {
12796
- pendingReorderRef.current = null;
12797
- const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12798
- if (win) {
12799
- try {
12800
- win.postMessage(
12801
- { dgn: 'select-by-id', id: pend.movedId, artboardId: m.artboardId ?? null, index: 0 },
12802
- '*'
12803
- );
12804
- } catch {}
12805
- }
12806
- }
12807
- }
13243
+ settlePendingSelectionRef.current?.(m.tree, sig, m.artboardId ?? null);
12808
13244
  } else if (m.dgn === 'reorder-revert') {
12809
13245
  // Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
12810
13246
  // stack (untrusted iframe) can't reach the main-origin-only
@@ -13128,6 +13564,23 @@ function App() {
13128
13564
  if (e.source === activeWin && typeof m.artboardId === 'string') {
13129
13565
  deleteArtboardShellRef.current?.(m.artboardId);
13130
13566
  }
13567
+ } else if (m.dgn === 'rename-artboard-request') {
13568
+ // Plan T25/L08 — double-click an artboard's name to rename it. Same
13569
+ // confused-deputy guard: the canvas asks, the shell writes the active
13570
+ // canvas only.
13571
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
13572
+ if (
13573
+ e.source === activeWin &&
13574
+ typeof m.artboardId === 'string' &&
13575
+ typeof m.label === 'string' &&
13576
+ m.label.trim()
13577
+ ) {
13578
+ structuralWriteRef.current?.(
13579
+ '/_api/set-artboard-label',
13580
+ { artboardId: m.artboardId, label: m.label.slice(0, 80) },
13581
+ { label: 'rename artboard' }
13582
+ );
13583
+ }
13131
13584
  } else if (m.dgn === 'set-artboard-kind-request') {
13132
13585
  // feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
13133
13586
  // kind" submenu (inside the untrusted iframe). `kind: null` clears
@@ -13774,6 +14227,7 @@ function App() {
13774
14227
  // fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
13775
14228
  applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
13776
14229
  applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
14230
+ let j1ref = null;
13777
14231
  const writeProp = (property, value) =>
13778
14232
  fetch('/_api/edit-css', {
13779
14233
  method: 'POST',
@@ -13785,6 +14239,7 @@ function App() {
13785
14239
  .then(() => writeProp('left', left))
13786
14240
  .then((j1) => {
13787
14241
  if (!j1.ok) throw new Error(j1.error || 'left write failed');
14242
+ j1ref = j1;
13788
14243
  return writeProp('top', top);
13789
14244
  })
13790
14245
  .then((j2) => {
@@ -13796,7 +14251,7 @@ function App() {
13796
14251
  canvas,
13797
14252
  id,
13798
14253
  key: 'left',
13799
- before: `${beforeLeft}px`,
14254
+ before: replacedValue(j1ref, `${beforeLeft}px`),
13800
14255
  after: `${left}px`,
13801
14256
  });
13802
14257
  recordSourceEdit({
@@ -13804,7 +14259,7 @@ function App() {
13804
14259
  canvas,
13805
14260
  id,
13806
14261
  key: 'top',
13807
- before: `${beforeTop}px`,
14262
+ before: replacedValue(j2, `${beforeTop}px`),
13808
14263
  after: `${top}px`,
13809
14264
  });
13810
14265
  })
@@ -13892,11 +14347,15 @@ function App() {
13892
14347
  headers: { 'content-type': 'application/json' },
13893
14348
  body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
13894
14349
  }).then((r) => r.json().catch(() => ({})));
14350
+ const replies = {};
13895
14351
  let chain = editApplyChainRef.current.catch(() => {});
13896
14352
  for (const p of props) {
13897
14353
  chain = chain.then((prev) => {
13898
14354
  if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
13899
- return writeProp(p, patch[p]);
14355
+ return writeProp(p, patch[p]).then((j) => {
14356
+ replies[p] = j;
14357
+ return j;
14358
+ });
13900
14359
  });
13901
14360
  }
13902
14361
  editApplyChainRef.current = chain
@@ -13904,7 +14363,14 @@ function App() {
13904
14363
  if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
13905
14364
  // Record one undo entry per written property (each Cmd+Z reverts one).
13906
14365
  for (const p of props) {
13907
- recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
14366
+ recordSourceEdit({
14367
+ op: 'css',
14368
+ canvas,
14369
+ id,
14370
+ key: p,
14371
+ before: replacedValue(replies[p], b[p] ?? null),
14372
+ after: patch[p],
14373
+ });
13908
14374
  }
13909
14375
  // Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
13910
14376
  // structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
@@ -13936,9 +14402,18 @@ function App() {
13936
14402
  // inverse descriptor goes stale (same reason reorder uses the server seq log).
13937
14403
  const structuralWriteRef = useRef(null);
13938
14404
  const structuralWrite = useCallback(
13939
- (route, body, { label, onOk, onFail } = {}) => {
14405
+ (route, body, { label, onOk, onFail: failed } = {}) => {
13940
14406
  if (!activePath) return;
13941
14407
  const canvas = activePath;
14408
+ // A refused structural edit used to vanish into the console: the
14409
+ // designer pressed Delete and nothing happened. Say so in the canvas.
14410
+ const onFail =
14411
+ failed ??
14412
+ ((j) =>
14413
+ postToActiveCanvas({
14414
+ dgn: 'op-toast',
14415
+ message: `Couldn't ${label || 'apply the edit'}${j?.error ? ` — ${j.error}` : ''}.`,
14416
+ }));
13942
14417
  editApplyChainRef.current = editApplyChainRef.current
13943
14418
  .catch(() => {})
13944
14419
  .then(() =>
@@ -13985,6 +14460,7 @@ function App() {
13985
14460
 
13986
14461
  const insertElementShell = useCallback(
13987
14462
  (refId, position, kind, opts = {}) => {
14463
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
13988
14464
  structuralWrite(
13989
14465
  '/_api/insert-element',
13990
14466
  {
@@ -14000,7 +14476,10 @@ function App() {
14000
14476
  // Select the new element once the HMR reload lands (its id is stamped
14001
14477
  // on transpile — best-effort, like reorder's pendingReorderRef).
14002
14478
  onOk: (j, canvas) => {
14003
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14479
+ if (!j.newId) return;
14480
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14481
+ const last = lastLayersTreeRef.current;
14482
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
14004
14483
  },
14005
14484
  }
14006
14485
  );
@@ -14064,14 +14543,19 @@ function App() {
14064
14543
 
14065
14544
  const duplicateElementShell = useCallback(
14066
14545
  (id, idIndex) => {
14546
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
14067
14547
  structuralWrite(
14068
14548
  '/_api/duplicate-element',
14069
14549
  { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
14070
14550
  {
14071
14551
  label: 'duplicate element',
14072
- // Select the copy once the HMR reload lands (best-effort, like insert).
14552
+ // Select the copy once the post-write tree lands — not the pre-write
14553
+ // one, where the copy's id still names the next sibling.
14073
14554
  onOk: (j, canvas) => {
14074
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14555
+ if (!j.newId) return;
14556
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14557
+ const last = lastLayersTreeRef.current;
14558
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
14075
14559
  },
14076
14560
  }
14077
14561
  );
@@ -14141,7 +14625,14 @@ function App() {
14141
14625
  .then((r) => r.json().catch(() => ({})))
14142
14626
  .then((j) => {
14143
14627
  if (j.ok)
14144
- recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
14628
+ recordSourceEdit({
14629
+ op: 'css',
14630
+ canvas,
14631
+ id,
14632
+ key: property,
14633
+ before: replacedValue(j, null),
14634
+ after: value,
14635
+ });
14145
14636
  })
14146
14637
  .catch(() => {})
14147
14638
  );
@@ -14481,7 +14972,7 @@ function App() {
14481
14972
  canvas,
14482
14973
  id: req.id,
14483
14974
  key: 'src',
14484
- before: req.before ?? null,
14975
+ before: replacedValue(j, req.before ?? null),
14485
14976
  after: pickedPath,
14486
14977
  });
14487
14978
  } else {
@@ -14945,11 +15436,18 @@ function App() {
14945
15436
  kbd: '⇧⌘E',
14946
15437
  run: () => setExportDialog({ mode: 'export' }),
14947
15438
  },
15439
+ {
15440
+ id: 'share-link',
15441
+ group: 'Canvas',
15442
+ label: 'Copy share link',
15443
+ icon: 'link',
15444
+ run: () => { const links = shareLinksFor(sharePath); const link = links.web ?? links.app ?? links.local; if (link) copyShareLink(link); },
15445
+ },
14948
15446
  {
14949
15447
  id: 'handoff',
14950
15448
  group: 'Canvas',
14951
15449
  label: 'Handoff to production',
14952
- icon: 'share',
15450
+ icon: 'external',
14953
15451
  kbd: '⇧⌘H',
14954
15452
  run: () => setExportDialog({ mode: 'handoff' }),
14955
15453
  },
@@ -15054,7 +15552,7 @@ function App() {
15054
15552
  run: () => setReportBugOpen(true),
15055
15553
  },
15056
15554
  ],
15057
- [openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
15555
+ [openSystem, toggleTheme, reloadActive, whatsNew, createVideo, sharePath, shareShell, cloudLinkedHub, localProjectName, cfg.designRel]
15058
15556
  );
15059
15557
 
15060
15558
  // feature-configurable-panel-docking — resolve, for each slot, the panels
@@ -15133,6 +15631,9 @@ function App() {
15133
15631
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
15134
15632
  onOpen={openTab}
15135
15633
  onPreview={onPreview}
15634
+ onOpenLinkedFile={openLinkedFile}
15635
+ filesReady={treeLoaded}
15636
+ onShare={showShare}
15136
15637
  onOpenSystem={openSystem}
15137
15638
  wsConnected={wsConnected}
15138
15639
  search={search}
@@ -15146,6 +15647,10 @@ function App() {
15146
15647
  onMoveCanvas={moveCanvasReq}
15147
15648
  onNewFolder={newFolderReq}
15148
15649
  onDeleteFolder={deleteFolderReq}
15650
+ onRenameFolder={renameFolderReq}
15651
+ onRenameCanvas={renameCanvasReq}
15652
+ onDuplicateCanvas={duplicateCanvasReq}
15653
+ onDeleteFile={deleteFileReq}
15149
15654
  onRefresh={refreshTree}
15150
15655
  refreshing={treeRefreshing}
15151
15656
  collapsed={false}
@@ -15159,6 +15664,7 @@ function App() {
15159
15664
  canvasKinds={cfg?.canvasKinds}
15160
15665
  syncStatus={syncStatus}
15161
15666
  onLinkedHub={setCloudLinkedHub}
15667
+ onLocalProject={setLocalProjectName}
15162
15668
  savingIsManaged={savingIsManaged}
15163
15669
  />
15164
15670
  );
@@ -15199,8 +15705,35 @@ function App() {
15199
15705
  // WHICH HISTORY, decided at the ONE place the posture is named — not
15200
15706
  // inside the panel, which would be a second derivation of the rule
15201
15707
  // `cloud-managed-save-surfaces.test.ts` exists to keep singular.
15202
- loadLog={cloudManaged ? gitLoadCloudLog : gitLoadLog}
15203
- historySource={cloudManaged ? 'cloud' : 'local'}
15708
+ loadLog={async (path) => {
15709
+ // The project's accepted history when it has one; Git otherwise.
15710
+ const accepted = await loadAcceptedLog(path);
15711
+ setProjectHistoryOn(accepted !== 'legacy');
15712
+ if (accepted !== 'legacy') return accepted;
15713
+ return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
15714
+ }}
15715
+ historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
15716
+ onRestoreVersion={async (revision) => {
15717
+ const r = await fetch('/_api/project/restore', {
15718
+ method: 'POST',
15719
+ headers: { 'Content-Type': 'application/json' },
15720
+ body: JSON.stringify({ path: activePath, revision }),
15721
+ }).catch(() => null);
15722
+ const j = r ? await r.json().catch(() => null) : null;
15723
+ return !!j?.ok;
15724
+ }}
15725
+ // Not through a cell's door: every browser editor proposes under the
15726
+ // studio's one credential there, so "your own action" is not
15727
+ // knowable and the hub refuses the route (studio-manifest.mjs).
15728
+ onUndoAction={cellManaged ? undefined : async (actionId) => {
15729
+ const r = await fetch('/_api/project/undo', {
15730
+ method: 'POST',
15731
+ headers: { 'Content-Type': 'application/json' },
15732
+ body: JSON.stringify({ actionId }),
15733
+ }).catch(() => null);
15734
+ const j = r ? await r.json().catch(() => null) : null;
15735
+ return !!j?.ok;
15736
+ }}
15204
15737
  // What the cloud half of the header names. The cell reports its own
15205
15738
  // project and branch with the log; the hub host is the fallback,
15206
15739
  // because a header that names the LOCAL folder while listing the
@@ -15317,8 +15850,10 @@ function App() {
15317
15850
  only appears if you happen to open the Sync panel is not consent.
15318
15851
  Skipped during first-run onboarding so two flows don't stack. */}
15319
15852
  {!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
15320
- {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
15321
- {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
15853
+ <NotificationHost paused={!!usageNudge || !!tourSteps}
15854
+ hiddenGroups={exportCenter.panelOpen ? ['exports'] : []} />
15855
+ <WhatsNewToast wn={whatsNew} />
15856
+ <ExportToast center={exportCenter} />
15322
15857
  {gitLifecycle && (
15323
15858
  <div role="status" aria-live="polite" className="st-banner st-banner--info">
15324
15859
  <span className="st-banner-dot" aria-hidden="true" />
@@ -15341,6 +15876,8 @@ function App() {
15341
15876
  )}
15342
15877
  <div className="st-shell">
15343
15878
  <Menubar
15879
+ sharePath={sharePath}
15880
+ onShare={() => showShare()}
15344
15881
  readOnly={viewerMode}
15345
15882
  // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
15346
15883
  // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
@@ -16353,6 +16890,7 @@ function App() {
16353
16890
  </div>
16354
16891
  </div>
16355
16892
  )}
16893
+ {shareDialog && <ShareDialog target={shareDialog} links={shareLinksFor(shareDialog.path)} shell={shareShell} onClose={() => setShareDialog(null)} Icon={StIcon} />}
16356
16894
  {exportDialog && (
16357
16895
  <ExportDialog
16358
16896
  mode={exportDialog.mode}