@1agh/maude 1.2.0 → 1.3.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 (83) 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 +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
@@ -33,6 +33,8 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
33
33
  // above (a type-only SyncStatusSnapshot import that Bun erases).
34
34
  import { syncPresentation } from '../sync/presentation.ts';
35
35
  import { canvasUrl } 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"
@@ -5620,6 +5679,11 @@ const CSS_JUSTIFY = [
5620
5679
  'space-evenly',
5621
5680
  ];
5622
5681
  const CSS_WEIGHTS = ['300', '400', '500', '600', '700', '800'];
5682
+ /** What an edit route says the write replaced (`previous`, read under the file
5683
+ * lock) — the value an undo must restore. `fallback` only when the route could
5684
+ * not name it (an expression, or an older server). */
5685
+ const replacedValue = (j, fallback) =>
5686
+ j && Object.hasOwn(j, 'previous') ? j.previous : fallback;
5623
5687
  const CSS_FONTS = [
5624
5688
  'inherit',
5625
5689
  'system-ui',
@@ -6512,12 +6576,16 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6512
6576
  // no longer triggers a reselect that would re-post fresh `authored` values — so
6513
6577
  // the panel must reflect its own commits immediately or it shows the stale
6514
6578
  // 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.
6579
+ // `null` marks a removed key. Cleared when a different element is selected —
6580
+ // and whenever the canvas re-posts this selection's maps. A re-post carries
6581
+ // the source as it is NOW (a teammate's change reloads the canvas); keeping
6582
+ // our older commit over it showed a stale value and recorded it as the next
6583
+ // undo's `before`, so Cmd+Z restored a value nobody had on screen.
6516
6584
  const [overlay, setOverlay] = useState({ a: {}, c: {}, t: {} });
6517
- // biome-ignore lint/correctness/useExhaustiveDependencies: clear only on element change.
6585
+ // biome-ignore lint/correctness/useExhaustiveDependencies: clear on a fresh selection payload only.
6518
6586
  useEffect(() => {
6519
6587
  setOverlay({ a: {}, c: {}, t: {} });
6520
- }, [el.id]);
6588
+ }, [el.id, el.authored, el.customStyles, el.attrs]);
6521
6589
  const mergeOverlay = (base, ov) => {
6522
6590
  const out = { ...(base || {}) };
6523
6591
  for (const [k, v] of Object.entries(ov)) {
@@ -6602,10 +6670,27 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6602
6670
  ...s,
6603
6671
  [key]: !res.ok || !j.ok ? `err:${(j && j.error) || `HTTP ${res.status}`}` : 'saved',
6604
6672
  }));
6673
+ return res.ok ? j : null;
6605
6674
  } catch (err) {
6606
6675
  setStatus((s) => ({ ...s, [key]: `err:${err && err.message ? err.message : String(err)}` }));
6676
+ return null;
6607
6677
  }
6608
6678
  }
6679
+ // Write, THEN record the undo entry from what the write actually replaced
6680
+ // (`previous`, read server-side under the file lock). The panel's own value
6681
+ // can be a teammate's edit old — it is not re-posted when a peer changes the
6682
+ // source — and recording it made Cmd+Z restore a value nobody had on screen.
6683
+ // Serialized so the stack keeps edit order; a failed write records nothing.
6684
+ const writeChainRef = useRef(Promise.resolve());
6685
+ const writeAndRecord = (url, payload, key, op, prop, fallbackBefore, after) => {
6686
+ writeChainRef.current = writeChainRef.current.then(async () => {
6687
+ const j = await post(url, payload, key);
6688
+ if (!j?.ok) return;
6689
+ const before = Object.hasOwn(j, 'previous') ? j.previous : fallbackBefore;
6690
+ if ((before ?? null) === (after ?? null)) return; // nothing changed to undo
6691
+ record(op, prop, before, after);
6692
+ });
6693
+ };
6609
6694
  // Optimistic preview: nudge the live element so the change shows before the
6610
6695
  // edit → HMR reload lands. `value` null = remove (reset path). No-op when the
6611
6696
  // selection has no stable id (can't be resolved in the canvas).
@@ -6639,8 +6724,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6639
6724
  if (value === (before ?? '').trim()) return; // no-op
6640
6725
  optimistic(property, value);
6641
6726
  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);
6727
+ writeAndRecord(
6728
+ '/_api/edit-css',
6729
+ { canvas: el.file, id: el.id, property, value },
6730
+ property,
6731
+ 'css',
6732
+ property,
6733
+ before,
6734
+ value
6735
+ );
6644
6736
  };
6645
6737
  // A custom CSS property (Advanced) — same write, but the panel surfaces it from
6646
6738
  // the customStyles map, so overlay THERE.
@@ -6651,8 +6743,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6651
6743
  const before = customStyles[prop] ?? null;
6652
6744
  optimistic(prop, value);
6653
6745
  setC(prop, value);
6654
- post('/_api/edit-css', { canvas: el.file, id: el.id, property: prop, value }, prop);
6655
- record('css', prop, before, value);
6746
+ writeAndRecord(
6747
+ '/_api/edit-css',
6748
+ { canvas: el.file, id: el.id, property: prop, value },
6749
+ prop,
6750
+ 'css',
6751
+ prop,
6752
+ before,
6753
+ value
6754
+ );
6656
6755
  };
6657
6756
  const commitAttr = (attr, raw) => {
6658
6757
  const a = (attr || '').trim();
@@ -6660,8 +6759,15 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6660
6759
  if (!editable || !a || !value) return;
6661
6760
  const before = attrs[a] ?? null;
6662
6761
  setT(a, value);
6663
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr: a, value }, `@${a}`);
6664
- record('attr', a, before, value);
6762
+ writeAndRecord(
6763
+ '/_api/edit-attr',
6764
+ { canvas: el.file, id: el.id, attr: a, value },
6765
+ `@${a}`,
6766
+ 'attr',
6767
+ a,
6768
+ before,
6769
+ value
6770
+ );
6665
6771
  };
6666
6772
  // Phase 12.3 — reset (remove the inline prop / attr → back to class/inherited).
6667
6773
  const reset = (property) => {
@@ -6669,23 +6775,44 @@ function CssKnobs({ el, cfg, onOptimistic, onRecordEdit, onReplaceMedia, onUndoR
6669
6775
  const before = authored[property] ?? null;
6670
6776
  optimistic(property, null);
6671
6777
  setA(property, null);
6672
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6673
- record('css', property, before, null);
6778
+ writeAndRecord(
6779
+ '/_api/edit-css',
6780
+ { canvas: el.file, id: el.id, property, reset: true },
6781
+ property,
6782
+ 'css',
6783
+ property,
6784
+ before,
6785
+ null
6786
+ );
6674
6787
  };
6675
6788
  const resetCustom = (property) => {
6676
6789
  if (!editable) return;
6677
6790
  const before = customStyles[property] ?? null;
6678
6791
  optimistic(property, null);
6679
6792
  setC(property, null);
6680
- post('/_api/edit-css', { canvas: el.file, id: el.id, property, reset: true }, property);
6681
- record('css', property, before, null);
6793
+ writeAndRecord(
6794
+ '/_api/edit-css',
6795
+ { canvas: el.file, id: el.id, property, reset: true },
6796
+ property,
6797
+ 'css',
6798
+ property,
6799
+ before,
6800
+ null
6801
+ );
6682
6802
  };
6683
6803
  const resetAttr = (attr) => {
6684
6804
  if (!editable) return;
6685
6805
  const before = attrs[attr] ?? null;
6686
6806
  setT(attr, null);
6687
- post('/_api/edit-attr', { canvas: el.file, id: el.id, attr, reset: true }, `@${attr}`);
6688
- record('attr', attr, before, null);
6807
+ writeAndRecord(
6808
+ '/_api/edit-attr',
6809
+ { canvas: el.file, id: el.id, attr, reset: true },
6810
+ `@${attr}`,
6811
+ 'attr',
6812
+ attr,
6813
+ before,
6814
+ null
6815
+ );
6689
6816
  };
6690
6817
  // Stage M1 — apply a Fixed / Hug / Fill sizing mode to one axis. The pure
6691
6818
  // `sizingModePatch` returns the exact writes (context-aware Fill: flex main axis
@@ -9714,6 +9841,9 @@ function InspectorPanel({
9714
9841
 
9715
9842
  function App() {
9716
9843
  const [groups, setGroups] = useState([]);
9844
+ const [treeLoaded, setTreeLoaded] = useState(false);
9845
+ const addressMode = useRef('push');
9846
+ const previousAddressPath = useRef(null);
9717
9847
  const [project, setProject] = useState('Design');
9718
9848
  const [tabs, setTabs] = useState([]);
9719
9849
  const [activePath, setActivePath] = useState(null);
@@ -9723,6 +9853,7 @@ function App() {
9723
9853
  // state, or iframe registration that openTab()/openSystem() drive.
9724
9854
  const [previewPath, setPreviewPath] = useState(null);
9725
9855
  const onPreview = useCallback((path) => {
9856
+ addressMode.current = 'push';
9726
9857
  setPreviewPath(path);
9727
9858
  }, []);
9728
9859
  const [selected, setSelected] = useState(null);
@@ -9829,6 +9960,30 @@ function App() {
9829
9960
  // after it) renumbers. Stash the re-settle target { file, movedId, artboardId }
9830
9961
  // so the dgn:'loaded' handler re-selects the moved element by its NEW id.
9831
9962
  const pendingReorderRef = useRef(null);
9963
+ // The last layers tree the canvas posted, and its id signature. A duplicate
9964
+ // or insert renumbers every later positional id, so the copy's NEW id already
9965
+ // names another element (the next sibling) in the pre-write tree: settling
9966
+ // on the first tree that merely contains it selected the wrong node. The
9967
+ // pending entry records the signature at request time and waits for a tree
9968
+ // that differs from it.
9969
+ const lastLayersTreeRef = useRef(null);
9970
+ const settlePendingSelectionRef = useRef(null);
9971
+ settlePendingSelectionRef.current = (tree, sig, artboardId) => {
9972
+ const pend = pendingReorderRef.current;
9973
+ if (!pend || !pend.movedId) return;
9974
+ if (pend.staleSig != null && sig === pend.staleSig) return; // still the pre-write DOM
9975
+ const has = (function find(nodes) {
9976
+ return (nodes || []).some((n) => n.id === pend.movedId || find(n.children));
9977
+ })(tree);
9978
+ if (!has) return;
9979
+ pendingReorderRef.current = null;
9980
+ const win = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
9981
+ if (win) {
9982
+ try {
9983
+ win.postMessage({ dgn: 'select-by-id', id: pend.movedId, artboardId, index: 0 }, '*');
9984
+ } catch {}
9985
+ }
9986
+ };
9832
9987
  // Latest `reorderLayer` (defined far below), read from the stale-closure
9833
9988
  // onMessage handler when the in-canvas grip posts dgn:'reorder-request'
9834
9989
  // (same freshness idiom as selectedRef; avoids a render-time TDZ on the
@@ -9924,6 +10079,7 @@ function App() {
9924
10079
  // CloudBar (status resolve / attach / detach). Gates the GitPanel's
9925
10080
  // cloud-managed posture; linked-but-uncredentialed keeps the full panel.
9926
10081
  const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
10082
+ const [localProjectName, setLocalProjectName] = useState(null);
9927
10083
  // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
9928
10084
  // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
9929
10085
  // `diffTarget` opens the before/after DiffView ({ file, conflict }).
@@ -10247,6 +10403,7 @@ function App() {
10247
10403
  const [paletteOpen, setPaletteOpen] = useState(false);
10248
10404
  // T5/T6 (Plan C) — shell-level export/handoff dialog + inspector panel state.
10249
10405
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
10406
+ const [shareDialog, setShareDialog] = useState(null);
10250
10407
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
10251
10408
  // feature-ai-media-generation (DDR-16x) — BYOK provider-key Settings modal +
10252
10409
  // the AI generate action.
@@ -11589,37 +11746,47 @@ function App() {
11589
11746
  }, []);
11590
11747
 
11591
11748
  // ----- 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
-
11749
+ const treeLoaderRef = useRef(null);
11750
+ const loadTree = useCallback(() => treeLoaderRef.current?.reload() ?? Promise.resolve(), []);
11621
11751
  useEffect(() => {
11752
+ const loader = createIndexLoader({
11753
+ read: async (signal) => {
11754
+ const r = await fetch('/_index-data', { signal, cache: 'no-store' });
11755
+ if (!r.ok) throw Object.assign(new Error(`Project index request failed: ${r.status}`), { status: r.status });
11756
+ const data = await r.json();
11757
+ const built = data.groups.map((g) => ({
11758
+ ...g,
11759
+ tree: buildTree(g.paths, g.stripPrefix, g.dirs),
11760
+ }));
11761
+ return { data, built };
11762
+ },
11763
+ apply: ({ data, built }) => {
11764
+ setProject(data.project || 'Design');
11765
+ setGroups(built);
11766
+ setTreeLoaded(true);
11767
+ // DDR-093 — fold the server-resolved per-canvas DS map into cfg so
11768
+ // canvasUrl() injects each UI canvas's OWN design-system tokens instead of
11769
+ // always designSystems[0]. Functional merge to coexist with the /_config
11770
+ // fetch (either may land first). `?? {}` keeps older servers (no map) on
11771
+ // the ds0 fallback. Re-runs on every tree reload, so adding/retargeting a
11772
+ // canvas refreshes the map.
11773
+ setCfg((prev) => ({
11774
+ ...prev,
11775
+ canvasDesignSystems: data.canvasDesignSystems ?? {},
11776
+ // DDR-174 (T15) — per-canvas notable `.meta.json` `kind` values (today:
11777
+ // only `reconstructed-experimental`), folded in the same way + for the
11778
+ // same reason as canvasDesignSystems above.
11779
+ canvasKinds: data.canvasKinds ?? {},
11780
+ }));
11781
+ },
11782
+ onError: (error) => console.error('failed to load tree', error),
11783
+ });
11784
+ treeLoaderRef.current = loader;
11622
11785
  loadTree();
11786
+ return () => {
11787
+ treeLoaderRef.current = null;
11788
+ loader.dispose();
11789
+ };
11623
11790
  }, [loadTree]);
11624
11791
 
11625
11792
  // ----- System data (lazy) -----
@@ -11668,6 +11835,7 @@ function App() {
11668
11835
  }, [loadAllComments]);
11669
11836
 
11670
11837
  // ----- WebSocket -----
11838
+ const canvasListChangeRef = useRef(() => {});
11671
11839
  useEffect(() => {
11672
11840
  // KEEPALIVE. The inspector feed only pushes on events (a comment, a
11673
11841
  // selection, sync:status), so an idle designer's socket exchanges nothing
@@ -11779,26 +11947,8 @@ function App() {
11779
11947
  // Phase 9 Task 8 — hub connection state for the offline banner.
11780
11948
  setSyncStatus(m.payload);
11781
11949
  } 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
11950
  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
- }
11951
+ canvasListChangeRef.current(m.payload);
11802
11952
  } else if (m.type === 'config-updated') {
11803
11953
  // Server hot-reloaded .design/config.json (/design:setup-ds rewrote
11804
11954
  // it) — refetch /_config so designSystems / tokensCssRel / groups
@@ -12001,6 +12151,39 @@ function App() {
12001
12151
  }
12002
12152
  }, []);
12003
12153
 
12154
+ // ACCEPTED REVISIONS — the project's own history (DDR-241, T27). When the
12155
+ // project saves through accepted revisions the History tab lists logical
12156
+ // actions (who did what, when), not Git commits; a row previews as `r<rev>`
12157
+ // through the same version preview. `'legacy'` means "use Git history".
12158
+ const [projectHistoryOn, setProjectHistoryOn] = useState(false);
12159
+ const loadAcceptedLog = useCallback(async (path) => {
12160
+ try {
12161
+ const qs =
12162
+ '/_api/project/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
12163
+ const r = await fetch(qs);
12164
+ if (!r.ok) return 'legacy';
12165
+ const data = await r.json();
12166
+ if (!data?.ok) return data?.reason === 'legacy' ? 'legacy' : null;
12167
+ return (data.history || []).map((a) => {
12168
+ const where = path ? '' : (a.canvases || []).map((c) => c.split('/').pop()).join(', ');
12169
+ return {
12170
+ sha: `r${a.revision}`,
12171
+ message: [a.label || a.kind, where].filter(Boolean).join(' · '),
12172
+ author: a.actor,
12173
+ date: new Date(a.committedAt).toISOString(),
12174
+ accepted: {
12175
+ revision: a.revision,
12176
+ actionId: a.actionId,
12177
+ mine: !!a.mine,
12178
+ undo: a.kind === 'undo' || a.kind === 'redo',
12179
+ },
12180
+ };
12181
+ });
12182
+ } catch {
12183
+ return null;
12184
+ }
12185
+ }, []);
12186
+
12004
12187
  // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
12005
12188
  // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
12006
12189
  // live with the WS broadcast.
@@ -12044,20 +12227,85 @@ function App() {
12044
12227
  // (iframesRef, comments push, WS `tabs` message) doesn't need refactoring.
12045
12228
  // ARTBOARDS slot in the menubar reads `tabs.length` and reports 0 or 1.
12046
12229
  const openTab = useCallback((path) => {
12230
+ addressMode.current = 'push';
12231
+ setFocusedCommentId(null);
12232
+ setPreviewPath(null);
12233
+ // The same path keeps its mounted iframe. It will not emit a new loaded
12234
+ // event, so keep its current loading/error state until an actual retry.
12235
+ if (path === activePath) return;
12047
12236
  setTabs((prev) => {
12048
12237
  // Drop the previously-open iframe so we don't leak DOM nodes.
12049
12238
  for (const t of prev) if (t.path !== path) iframesRef.current.delete(t.path);
12050
12239
  return [{ path }];
12051
12240
  });
12052
12241
  setActivePath(path);
12053
- setFocusedCommentId(null);
12054
- setPreviewPath(null);
12055
12242
  setCanvasError(null);
12056
12243
  setLoadedPath(null);
12057
12244
  // Canvas-compile skeleton — cleared by the iframe's dgn:'loaded' message,
12058
12245
  // the onLoad fallback timer (legacy .html), or a hard 15s cap.
12059
12246
  if (path !== SYSTEM_TAB) setLoadingPath(path);
12060
- }, []);
12247
+ }, [activePath]);
12248
+
12249
+ // Resolve URL identities against the loaded tree, including non-canvas previews.
12250
+ const openLinkedFile = useCallback((rel, mode = 'push') => {
12251
+ const path = groups.flatMap((g) => g.paths || []).find((p) => normalizeOpenPath(p, cfg.designRel) === rel);
12252
+ if (path && CANVAS_EXT_RE.test(path)) openTab(path);
12253
+ else if (path && previewKind(basename(path))) onPreview(path);
12254
+ else {
12255
+ setTabs([]);
12256
+ setActivePath(null);
12257
+ setPreviewPath(null);
12258
+ notify({ title: 'Not here yet', description: `${rel} is not in this project (not synced yet?)`, kind: 'info' });
12259
+ }
12260
+ addressMode.current = mode;
12261
+ }, [groups, cfg.designRel, openTab, onPreview]);
12262
+
12263
+ const addressBooted = useRef(false);
12264
+ useEffect(() => {
12265
+ if (!treeLoaded || cfg.cloud === undefined || addressBooted.current) return;
12266
+ addressBooted.current = true;
12267
+ const rel = readOpenParam(location, cfg.designRel);
12268
+ if (rel) openLinkedFile(rel, 'none');
12269
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12270
+
12271
+ useEffect(() => {
12272
+ if (!treeLoaded || cfg.cloud === undefined) return;
12273
+ const navigate = () => {
12274
+ const rel = readOpenParam(location, cfg.designRel);
12275
+ if (rel) openLinkedFile(rel, 'none');
12276
+ else {
12277
+ addressMode.current = 'none';
12278
+ setTabs([]);
12279
+ setActivePath(null);
12280
+ setPreviewPath(null);
12281
+ }
12282
+ };
12283
+ window.addEventListener('popstate', navigate);
12284
+ return () => window.removeEventListener('popstate', navigate);
12285
+ }, [treeLoaded, cfg.cloud, cfg.designRel, openLinkedFile]);
12286
+
12287
+ useEffect(() => {
12288
+ const visible = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12289
+ if (visible === previousAddressPath.current) return;
12290
+ previousAddressPath.current = visible;
12291
+ const rel = normalizeOpenPath(visible, cfg.designRel);
12292
+ if (addressMode.current !== 'none' && readOpenParam(location, cfg.designRel) !== rel) {
12293
+ history[rel ? 'pushState' : 'replaceState'](rel ? { open: rel } : null, '', withOpenParam(location, rel));
12294
+ }
12295
+ addressMode.current = 'push';
12296
+ }, [activePath, previewPath, cfg.designRel]);
12297
+
12298
+ const sharePath = previewPath || (activePath === SYSTEM_TAB ? null : activePath);
12299
+ const shareShell = cfg.cloud ? 'cloud' : isNativeApp() ? 'native' : 'local';
12300
+ const shareLinksFor = (path) => buildShareLinks({
12301
+ rel: normalizeOpenPath(path, cfg.designRel), shell: shareShell, location,
12302
+ linkedHubUrl: cloudLinkedHub?.url, project: cfg.cloud ? project : localProjectName,
12303
+ localUrl: location.origin,
12304
+ });
12305
+ const showShare = (path = sharePath) => {
12306
+ const rel = normalizeOpenPath(path, cfg.designRel);
12307
+ if (rel) setShareDialog({ path, rel, label: basename(path) });
12308
+ };
12061
12309
 
12062
12310
  // Retry from the #115 error panel: re-read /_config FIRST (the stale
12063
12311
  // `canvasOrigin` is the likeliest reason we are here at all), then remount the
@@ -12089,6 +12337,7 @@ function App() {
12089
12337
  useEffect(() => {
12090
12338
  if (autoOpened.current) return;
12091
12339
  if (!cfg.cloud) return; // desktop / unknown-yet
12340
+ if (new URLSearchParams(location.search).has('open')) { autoOpened.current = true; return; }
12092
12341
  if (tabs.length > 0) return;
12093
12342
  if (!groups.length) return; // tree not loaded
12094
12343
  // The first canvas that is somebody's WORK — a design-system specimen is a
@@ -12149,6 +12398,23 @@ function App() {
12149
12398
  [activePath]
12150
12399
  );
12151
12400
 
12401
+ // The socket stays connected while this ref follows the current config and
12402
+ // active tab. Structural events apply to local and remote users alike.
12403
+ canvasListChangeRef.current = (change) => {
12404
+ if (!change?.rel) return;
12405
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '');
12406
+ const file = `${designRel}/${change.rel}`;
12407
+ if (change.action === 'removed') {
12408
+ closeTab(file);
12409
+ } else if (change.action === 'moved' && change.fromRel) {
12410
+ const fromFile = `${designRel}/${change.fromRel}`;
12411
+ setTabs((prev) => prev.map((t) => t.path === fromFile ? { ...t, path: file } : t));
12412
+ setActivePath((prev) => prev === fromFile ? file : prev);
12413
+ setLoadingPath((prev) => prev === fromFile ? null : prev);
12414
+ iframesRef.current.delete(fromFile);
12415
+ }
12416
+ };
12417
+
12152
12418
  const reloadActive = useCallback(() => {
12153
12419
  if (!activePath || activePath === SYSTEM_TAB) {
12154
12420
  if (activePath === SYSTEM_TAB) loadSystemData();
@@ -12533,6 +12799,100 @@ function App() {
12533
12799
  [loadTree]
12534
12800
  );
12535
12801
 
12802
+ // Plan T17 — rename a folder: a move to a new name in the same parent (one
12803
+ // dir.move action in accepted mode, carrying every canvas inside).
12804
+ const renameFolderReq = useCallback(
12805
+ async (dirPath, name) => {
12806
+ const parent = dirPath.split('/').slice(0, -1).join('/');
12807
+ try {
12808
+ const r = await fetch('/_api/fs-move', {
12809
+ method: 'POST',
12810
+ headers: { 'Content-Type': 'application/json' },
12811
+ body: JSON.stringify({ file: dirPath, toDir: parent, toName: name }),
12812
+ });
12813
+ const j = await r.json().catch(() => ({}));
12814
+ if (!r.ok || !j.ok) {
12815
+ shellToast(`Could not rename folder: ${j.error || `error ${r.status}`}`);
12816
+ return;
12817
+ }
12818
+ await loadTree();
12819
+ } catch (e) {
12820
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12821
+ }
12822
+ },
12823
+ [loadTree]
12824
+ );
12825
+
12826
+ // Plan T25/L04 — rename a canvas in place (its sidecars follow; an open tab
12827
+ // follows the new path) and duplicate one beside itself.
12828
+ const renameCanvasReq = useCallback(
12829
+ async (filePath, name) => {
12830
+ try {
12831
+ const r = await fetch('/_api/fs-move', {
12832
+ method: 'POST',
12833
+ headers: { 'Content-Type': 'application/json' },
12834
+ body: JSON.stringify({ file: filePath, toName: name }),
12835
+ });
12836
+ const j = await r.json().catch(() => ({}));
12837
+ if (!r.ok || !j.ok) {
12838
+ shellToast(`Could not rename: ${j.error || `error ${r.status}`}`);
12839
+ return;
12840
+ }
12841
+ const designRel = (cfg?.designRel || cfg?.designRoot || '.design').replace(
12842
+ /^\/+|\/+$/g,
12843
+ ''
12844
+ );
12845
+ const fromFile = `${designRel}/${j.fromRel}`;
12846
+ const toFile = `${designRel}/${j.toRel}`;
12847
+ await loadTree();
12848
+ setTabs((prev) => prev.map((t) => (t.path === fromFile ? { path: toFile } : t)));
12849
+ setActivePath((prev) => (prev === fromFile ? toFile : prev));
12850
+ } catch (e) {
12851
+ shellToast(`Rename failed: ${e instanceof Error ? e.message : 'network error'}`);
12852
+ }
12853
+ },
12854
+ [loadTree, cfg]
12855
+ );
12856
+ const duplicateCanvasReq = useCallback(
12857
+ async (filePath) => {
12858
+ try {
12859
+ const r = await fetch('/_api/canvas', {
12860
+ method: 'POST',
12861
+ headers: { 'Content-Type': 'application/json' },
12862
+ body: JSON.stringify({ duplicateOf: filePath }),
12863
+ });
12864
+ const j = await r.json().catch(() => ({}));
12865
+ if (!r.ok || !j.ok) {
12866
+ shellToast(`Could not duplicate: ${j.error || `error ${r.status}`}`);
12867
+ return;
12868
+ }
12869
+ await loadTree();
12870
+ shellToast(`Duplicated as ${j.rel.split('/').pop().replace(/\.tsx$/i, '')}`, true);
12871
+ } catch (e) {
12872
+ shellToast(`Duplicate failed: ${e instanceof Error ? e.message : 'network error'}`);
12873
+ }
12874
+ },
12875
+ [loadTree]
12876
+ );
12877
+
12878
+ const deleteFileReq = useCallback(
12879
+ async (filePath, name) => {
12880
+ if (!window.confirm(`Move “${name}” to trash?\n\nYou can restore it from .design/_trash/.`)) return;
12881
+ try {
12882
+ const r = await fetch(`/_api/canvas?file=${encodeURIComponent(filePath)}`, { method: 'DELETE' });
12883
+ const j = await r.json().catch(() => ({}));
12884
+ if (!r.ok || !j.ok) {
12885
+ shellToast(`Could not delete: ${j.error || `error ${r.status}`}`);
12886
+ return;
12887
+ }
12888
+ await loadTree();
12889
+ } catch (e) {
12890
+ shellToast(`Delete failed: ${e instanceof Error ? e.message : 'network error'}`);
12891
+ }
12892
+ },
12893
+ [loadTree]
12894
+ );
12895
+
12536
12896
  const clearSelected = useCallback(() => {
12537
12897
  wsSend({ type: 'clear-select' });
12538
12898
  setSelected(null);
@@ -12582,6 +12942,13 @@ function App() {
12582
12942
  if (e.origin !== expectedOrigin) return;
12583
12943
  const m = e.data;
12584
12944
  if (!m || typeof m !== 'object' || !m.dgn) return;
12945
+ if (m.dgn === 'canvas-notice') {
12946
+ const activeWin = activePath && activePath !== SYSTEM_TAB
12947
+ ? iframesRef.current.get(activePath)?.contentWindow : null;
12948
+ const notice = acceptCanvasNotice(e, expectedOrigin, activeWin);
12949
+ if (notice) notifyCanvasText(notice.title, notice.kind);
12950
+ return;
12951
+ }
12585
12952
  if (m.dgn === 'tool-cursor') {
12586
12953
  // Phase 24 — show the active canvas tool's cursor across the WHOLE app
12587
12954
  // shell (sidebar, top bar, everything) so the custom cursor is visible
@@ -12723,55 +13090,48 @@ function App() {
12723
13090
  // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
12724
13091
  // `edit-source` command can't call the main-origin-only `/_api/edit-*`
12725
13092
  // 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;
13093
+ // `value` null = reset (remove the inline prop / attr). The css/attr
13094
+ // request carries the command's expected current value, so an undo can
13095
+ // never overwrite a teammate's newer value (audit 2026-09-13 P1 #5);
13096
+ // the outcome goes back to the canvas so a refused undo/redo does not
13097
+ // advance its stack.
13098
+ const req = applyEditRequest(m);
13099
+ if (!req) return;
13100
+ const replyTo = e.source;
13101
+ const reply = (result) => {
13102
+ if (typeof m.requestId !== 'string') return;
13103
+ try {
13104
+ replyTo?.postMessage({ dgn: 'apply-edit-result', requestId: m.requestId, ...result }, '*');
13105
+ } catch {}
13106
+ };
12729
13107
  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
13108
  editApplyChainRef.current = editApplyChainRef.current
12755
13109
  .catch(() => {})
12756
13110
  .then(() =>
12757
- fetch(url, {
13111
+ fetch(req.url, {
12758
13112
  method: 'POST',
12759
13113
  headers: { 'content-type': 'application/json' },
12760
- body: JSON.stringify(body),
13114
+ body: JSON.stringify(req.body),
12761
13115
  })
12762
13116
  .then((r) => r.json().catch(() => ({})))
12763
13117
  .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');
13118
+ if (j.ok) {
13119
+ // Repaint only once the source accepted it: a refused undo
13120
+ // must not show a value the file does not hold.
13121
+ if (req.op === 'css') applyOptimisticStyle({ id: m.id, prop: m.key, value });
13122
+ reply({ ok: true });
13123
+ return;
13124
+ }
13125
+ console.warn('[apply-edit]', req.op, j.error || 'failed');
13126
+ reply({ ok: false, error: j.error || 'failed', conflict: j.conflict === true });
12769
13127
  })
12770
- .catch(() => {})
13128
+ .catch(() => reply({ ok: false, error: 'network error' }))
12771
13129
  );
12772
13130
  } else if (m.dgn === 'layers-tree') {
12773
13131
  // Phase 12 Task 4 — browsable layers tree for the active artboard.
12774
13132
  setLayersTree({ artboardId: m.artboardId, nodes: Array.isArray(m.tree) ? m.tree : [] });
13133
+ const sig = layersTreeSig(m.tree);
13134
+ lastLayersTreeRef.current = { tree: m.tree, sig, artboardId: m.artboardId ?? null };
12775
13135
  // fresh tree (correct ids) landed — drags OK again
12776
13136
  layersBusyRef.current = false;
12777
13137
  if (layersBusyTimerRef.current) {
@@ -12787,24 +13147,7 @@ function App() {
12787
13147
  // actually CONTAINS movedId — an in-canvas drag posts a PREVIEW tree
12788
13148
  // first (old ids, pre-write); consuming then would re-select nothing and
12789
13149
  // 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
- }
13150
+ settlePendingSelectionRef.current?.(m.tree, sig, m.artboardId ?? null);
12808
13151
  } else if (m.dgn === 'reorder-revert') {
12809
13152
  // Phase 12.1 follow-up — Cmd+Z/Cmd+Shift+Z on a reorder. The canvas undo
12810
13153
  // stack (untrusted iframe) can't reach the main-origin-only
@@ -13128,6 +13471,23 @@ function App() {
13128
13471
  if (e.source === activeWin && typeof m.artboardId === 'string') {
13129
13472
  deleteArtboardShellRef.current?.(m.artboardId);
13130
13473
  }
13474
+ } else if (m.dgn === 'rename-artboard-request') {
13475
+ // Plan T25/L08 — double-click an artboard's name to rename it. Same
13476
+ // confused-deputy guard: the canvas asks, the shell writes the active
13477
+ // canvas only.
13478
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
13479
+ if (
13480
+ e.source === activeWin &&
13481
+ typeof m.artboardId === 'string' &&
13482
+ typeof m.label === 'string' &&
13483
+ m.label.trim()
13484
+ ) {
13485
+ structuralWriteRef.current?.(
13486
+ '/_api/set-artboard-label',
13487
+ { artboardId: m.artboardId, label: m.label.slice(0, 80) },
13488
+ { label: 'rename artboard' }
13489
+ );
13490
+ }
13131
13491
  } else if (m.dgn === 'set-artboard-kind-request') {
13132
13492
  // feature-1-artboard-kinds-foundation, T8 — context-menu "Artboard
13133
13493
  // kind" submenu (inside the untrusted iframe). `kind: null` clears
@@ -13774,6 +14134,7 @@ function App() {
13774
14134
  // fix as resizeElement; also covers the keyboard nudge (L1) which reuses this.
13775
14135
  applyOptimisticStyle({ id, prop: 'left', value: `${left}px` });
13776
14136
  applyOptimisticStyle({ id, prop: 'top', value: `${top}px` });
14137
+ let j1ref = null;
13777
14138
  const writeProp = (property, value) =>
13778
14139
  fetch('/_api/edit-css', {
13779
14140
  method: 'POST',
@@ -13785,6 +14146,7 @@ function App() {
13785
14146
  .then(() => writeProp('left', left))
13786
14147
  .then((j1) => {
13787
14148
  if (!j1.ok) throw new Error(j1.error || 'left write failed');
14149
+ j1ref = j1;
13788
14150
  return writeProp('top', top);
13789
14151
  })
13790
14152
  .then((j2) => {
@@ -13796,7 +14158,7 @@ function App() {
13796
14158
  canvas,
13797
14159
  id,
13798
14160
  key: 'left',
13799
- before: `${beforeLeft}px`,
14161
+ before: replacedValue(j1ref, `${beforeLeft}px`),
13800
14162
  after: `${left}px`,
13801
14163
  });
13802
14164
  recordSourceEdit({
@@ -13804,7 +14166,7 @@ function App() {
13804
14166
  canvas,
13805
14167
  id,
13806
14168
  key: 'top',
13807
- before: `${beforeTop}px`,
14169
+ before: replacedValue(j2, `${beforeTop}px`),
13808
14170
  after: `${top}px`,
13809
14171
  });
13810
14172
  })
@@ -13892,11 +14254,15 @@ function App() {
13892
14254
  headers: { 'content-type': 'application/json' },
13893
14255
  body: JSON.stringify({ canvas, id, property, value, idIndex: occ }),
13894
14256
  }).then((r) => r.json().catch(() => ({})));
14257
+ const replies = {};
13895
14258
  let chain = editApplyChainRef.current.catch(() => {});
13896
14259
  for (const p of props) {
13897
14260
  chain = chain.then((prev) => {
13898
14261
  if (prev && prev.ok === false) throw new Error(prev.error || `${p} write failed`);
13899
- return writeProp(p, patch[p]);
14262
+ return writeProp(p, patch[p]).then((j) => {
14263
+ replies[p] = j;
14264
+ return j;
14265
+ });
13900
14266
  });
13901
14267
  }
13902
14268
  editApplyChainRef.current = chain
@@ -13904,7 +14270,14 @@ function App() {
13904
14270
  if (last && last.ok === false) throw new Error(last.error || 'resize write failed');
13905
14271
  // Record one undo entry per written property (each Cmd+Z reverts one).
13906
14272
  for (const p of props) {
13907
- recordSourceEdit({ op: 'css', canvas, id, key: p, before: b[p] ?? null, after: patch[p] });
14273
+ recordSourceEdit({
14274
+ op: 'css',
14275
+ canvas,
14276
+ id,
14277
+ key: p,
14278
+ before: replacedValue(replies[p], b[p] ?? null),
14279
+ after: patch[p],
14280
+ });
13908
14281
  }
13909
14282
  // Dogfood 2026-07-07 — belt-and-suspenders reselect, mirroring the
13910
14283
  // structural ops' `pendingReorderRef` re-settle. The DDR-105 suppression
@@ -13936,9 +14309,18 @@ function App() {
13936
14309
  // inverse descriptor goes stale (same reason reorder uses the server seq log).
13937
14310
  const structuralWriteRef = useRef(null);
13938
14311
  const structuralWrite = useCallback(
13939
- (route, body, { label, onOk, onFail } = {}) => {
14312
+ (route, body, { label, onOk, onFail: failed } = {}) => {
13940
14313
  if (!activePath) return;
13941
14314
  const canvas = activePath;
14315
+ // A refused structural edit used to vanish into the console: the
14316
+ // designer pressed Delete and nothing happened. Say so in the canvas.
14317
+ const onFail =
14318
+ failed ??
14319
+ ((j) =>
14320
+ postToActiveCanvas({
14321
+ dgn: 'op-toast',
14322
+ message: `Couldn't ${label || 'apply the edit'}${j?.error ? ` — ${j.error}` : ''}.`,
14323
+ }));
13942
14324
  editApplyChainRef.current = editApplyChainRef.current
13943
14325
  .catch(() => {})
13944
14326
  .then(() =>
@@ -13985,6 +14367,7 @@ function App() {
13985
14367
 
13986
14368
  const insertElementShell = useCallback(
13987
14369
  (refId, position, kind, opts = {}) => {
14370
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
13988
14371
  structuralWrite(
13989
14372
  '/_api/insert-element',
13990
14373
  {
@@ -14000,7 +14383,10 @@ function App() {
14000
14383
  // Select the new element once the HMR reload lands (its id is stamped
14001
14384
  // on transpile — best-effort, like reorder's pendingReorderRef).
14002
14385
  onOk: (j, canvas) => {
14003
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14386
+ if (!j.newId) return;
14387
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14388
+ const last = lastLayersTreeRef.current;
14389
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
14004
14390
  },
14005
14391
  }
14006
14392
  );
@@ -14064,14 +14450,19 @@ function App() {
14064
14450
 
14065
14451
  const duplicateElementShell = useCallback(
14066
14452
  (id, idIndex) => {
14453
+ const staleSig = lastLayersTreeRef.current?.sig ?? null;
14067
14454
  structuralWrite(
14068
14455
  '/_api/duplicate-element',
14069
14456
  { id, idIndex: Number.isInteger(idIndex) ? idIndex : undefined },
14070
14457
  {
14071
14458
  label: 'duplicate element',
14072
- // Select the copy once the HMR reload lands (best-effort, like insert).
14459
+ // Select the copy once the post-write tree lands — not the pre-write
14460
+ // one, where the copy's id still names the next sibling.
14073
14461
  onOk: (j, canvas) => {
14074
- if (j.newId) pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null };
14462
+ if (!j.newId) return;
14463
+ pendingReorderRef.current = { file: canvas, movedId: j.newId, artboardId: null, staleSig };
14464
+ const last = lastLayersTreeRef.current;
14465
+ if (last) settlePendingSelectionRef.current?.(last.tree, last.sig, last.artboardId);
14075
14466
  },
14076
14467
  }
14077
14468
  );
@@ -14141,7 +14532,14 @@ function App() {
14141
14532
  .then((r) => r.json().catch(() => ({})))
14142
14533
  .then((j) => {
14143
14534
  if (j.ok)
14144
- recordSourceEdit({ op: 'css', canvas, id, key: property, before: null, after: value });
14535
+ recordSourceEdit({
14536
+ op: 'css',
14537
+ canvas,
14538
+ id,
14539
+ key: property,
14540
+ before: replacedValue(j, null),
14541
+ after: value,
14542
+ });
14145
14543
  })
14146
14544
  .catch(() => {})
14147
14545
  );
@@ -14481,7 +14879,7 @@ function App() {
14481
14879
  canvas,
14482
14880
  id: req.id,
14483
14881
  key: 'src',
14484
- before: req.before ?? null,
14882
+ before: replacedValue(j, req.before ?? null),
14485
14883
  after: pickedPath,
14486
14884
  });
14487
14885
  } else {
@@ -14945,11 +15343,18 @@ function App() {
14945
15343
  kbd: '⇧⌘E',
14946
15344
  run: () => setExportDialog({ mode: 'export' }),
14947
15345
  },
15346
+ {
15347
+ id: 'share-link',
15348
+ group: 'Canvas',
15349
+ label: 'Copy share link',
15350
+ icon: 'link',
15351
+ run: () => { const links = shareLinksFor(sharePath); const link = links.web ?? links.app ?? links.local; if (link) copyShareLink(link); },
15352
+ },
14948
15353
  {
14949
15354
  id: 'handoff',
14950
15355
  group: 'Canvas',
14951
15356
  label: 'Handoff to production',
14952
- icon: 'share',
15357
+ icon: 'external',
14953
15358
  kbd: '⇧⌘H',
14954
15359
  run: () => setExportDialog({ mode: 'handoff' }),
14955
15360
  },
@@ -15054,7 +15459,7 @@ function App() {
15054
15459
  run: () => setReportBugOpen(true),
15055
15460
  },
15056
15461
  ],
15057
- [openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
15462
+ [openSystem, toggleTheme, reloadActive, whatsNew, createVideo, sharePath, shareShell, cloudLinkedHub, localProjectName, cfg.designRel]
15058
15463
  );
15059
15464
 
15060
15465
  // feature-configurable-panel-docking — resolve, for each slot, the panels
@@ -15133,6 +15538,9 @@ function App() {
15133
15538
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
15134
15539
  onOpen={openTab}
15135
15540
  onPreview={onPreview}
15541
+ onOpenLinkedFile={openLinkedFile}
15542
+ filesReady={treeLoaded}
15543
+ onShare={showShare}
15136
15544
  onOpenSystem={openSystem}
15137
15545
  wsConnected={wsConnected}
15138
15546
  search={search}
@@ -15146,6 +15554,10 @@ function App() {
15146
15554
  onMoveCanvas={moveCanvasReq}
15147
15555
  onNewFolder={newFolderReq}
15148
15556
  onDeleteFolder={deleteFolderReq}
15557
+ onRenameFolder={renameFolderReq}
15558
+ onRenameCanvas={renameCanvasReq}
15559
+ onDuplicateCanvas={duplicateCanvasReq}
15560
+ onDeleteFile={deleteFileReq}
15149
15561
  onRefresh={refreshTree}
15150
15562
  refreshing={treeRefreshing}
15151
15563
  collapsed={false}
@@ -15159,6 +15571,7 @@ function App() {
15159
15571
  canvasKinds={cfg?.canvasKinds}
15160
15572
  syncStatus={syncStatus}
15161
15573
  onLinkedHub={setCloudLinkedHub}
15574
+ onLocalProject={setLocalProjectName}
15162
15575
  savingIsManaged={savingIsManaged}
15163
15576
  />
15164
15577
  );
@@ -15199,8 +15612,35 @@ function App() {
15199
15612
  // WHICH HISTORY, decided at the ONE place the posture is named — not
15200
15613
  // inside the panel, which would be a second derivation of the rule
15201
15614
  // `cloud-managed-save-surfaces.test.ts` exists to keep singular.
15202
- loadLog={cloudManaged ? gitLoadCloudLog : gitLoadLog}
15203
- historySource={cloudManaged ? 'cloud' : 'local'}
15615
+ loadLog={async (path) => {
15616
+ // The project's accepted history when it has one; Git otherwise.
15617
+ const accepted = await loadAcceptedLog(path);
15618
+ setProjectHistoryOn(accepted !== 'legacy');
15619
+ if (accepted !== 'legacy') return accepted;
15620
+ return (cloudManaged ? gitLoadCloudLog : gitLoadLog)(path);
15621
+ }}
15622
+ historySource={projectHistoryOn ? 'project' : cloudManaged ? 'cloud' : 'local'}
15623
+ onRestoreVersion={async (revision) => {
15624
+ const r = await fetch('/_api/project/restore', {
15625
+ method: 'POST',
15626
+ headers: { 'Content-Type': 'application/json' },
15627
+ body: JSON.stringify({ path: activePath, revision }),
15628
+ }).catch(() => null);
15629
+ const j = r ? await r.json().catch(() => null) : null;
15630
+ return !!j?.ok;
15631
+ }}
15632
+ // Not through a cell's door: every browser editor proposes under the
15633
+ // studio's one credential there, so "your own action" is not
15634
+ // knowable and the hub refuses the route (studio-manifest.mjs).
15635
+ onUndoAction={cellManaged ? undefined : async (actionId) => {
15636
+ const r = await fetch('/_api/project/undo', {
15637
+ method: 'POST',
15638
+ headers: { 'Content-Type': 'application/json' },
15639
+ body: JSON.stringify({ actionId }),
15640
+ }).catch(() => null);
15641
+ const j = r ? await r.json().catch(() => null) : null;
15642
+ return !!j?.ok;
15643
+ }}
15204
15644
  // What the cloud half of the header names. The cell reports its own
15205
15645
  // project and branch with the log; the hub host is the fallback,
15206
15646
  // because a header that names the LOCAL folder while listing the
@@ -15317,8 +15757,10 @@ function App() {
15317
15757
  only appears if you happen to open the Sync panel is not consent.
15318
15758
  Skipped during first-run onboarding so two flows don't stack. */}
15319
15759
  {!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
15320
- {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
15321
- {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
15760
+ <NotificationHost paused={!!usageNudge || !!tourSteps}
15761
+ hiddenGroups={exportCenter.panelOpen ? ['exports'] : []} />
15762
+ <WhatsNewToast wn={whatsNew} />
15763
+ <ExportToast center={exportCenter} />
15322
15764
  {gitLifecycle && (
15323
15765
  <div role="status" aria-live="polite" className="st-banner st-banner--info">
15324
15766
  <span className="st-banner-dot" aria-hidden="true" />
@@ -15341,6 +15783,8 @@ function App() {
15341
15783
  )}
15342
15784
  <div className="st-shell">
15343
15785
  <Menubar
15786
+ sharePath={sharePath}
15787
+ onShare={() => showShare()}
15344
15788
  readOnly={viewerMode}
15345
15789
  // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
15346
15790
  // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
@@ -16353,6 +16797,7 @@ function App() {
16353
16797
  </div>
16354
16798
  </div>
16355
16799
  )}
16800
+ {shareDialog && <ShareDialog target={shareDialog} links={shareLinksFor(shareDialog.path)} shell={shareShell} onClose={() => setShareDialog(null)} Icon={StIcon} />}
16356
16801
  {exportDialog && (
16357
16802
  <ExportDialog
16358
16803
  mode={exportDialog.mode}