@1agh/maude 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -103,10 +103,10 @@ function canvasUrl(p, cfg, opts) {
103
103
  if (tokens) params.set('tokens', tokens);
104
104
  if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
105
105
  if (specMatch) {
106
- const ds = specMatch[1];
107
- params.set('layout', `system/${ds}/preview/_layout.css`);
106
+ const dsName = specMatch[1];
107
+ params.set('layout', `system/${dsName}/preview/_layout.css`);
108
108
  if (!cfg?.componentsCssRel) {
109
- params.set('components', `system/${ds}/preview/_components.css`);
109
+ params.set('components', `system/${dsName}/preview/_components.css`);
110
110
  }
111
111
  } else if (ds0?.path) {
112
112
  // UI canvas — load the project DS's `_components.css` so the dc-canvas /
@@ -116,7 +116,11 @@ function canvasUrl(p, cfg, opts) {
116
116
  params.set('components', `${ds0.path}/preview/_components.css`);
117
117
  }
118
118
  }
119
- return `/_canvas-shell.html?${params.toString()}`;
119
+ // T2 (9.1-A) — load the iframe from the segregated canvas origin when the
120
+ // server advertises one; fall back to a same-origin relative URL otherwise
121
+ // (older server, tests). The trailing path + query are identical either way.
122
+ const origin = cfg?.canvasOrigin || '';
123
+ return `${origin}/_canvas-shell.html?${params.toString()}`;
120
124
  }
121
125
 
122
126
  function basename(p) {
@@ -912,7 +916,9 @@ function ToolsDropdown({ onAction, onClose }) {
912
916
  { id: 'pen', label: 'Pen', shortcut: 'B' },
913
917
  { id: 'rect', label: 'Rect', shortcut: 'R' },
914
918
  { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
919
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
915
920
  { id: 'arrow', label: 'Arrow', shortcut: 'A' },
921
+ { id: 'text', label: 'Text', shortcut: 'T' },
916
922
  { id: 'eraser', label: 'Eraser', shortcut: 'E' },
917
923
  ];
918
924
  return (
@@ -1100,6 +1106,14 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1100
1106
  src={canvasUrl(t.path, cfg)}
1101
1107
  className={t.path === activePath ? 'active' : ''}
1102
1108
  data-path={t.path}
1109
+ // T2 (9.1-A) — only sandbox + delegate clipboard when the canvas is
1110
+ // served cross-origin (canvasOrigin present = the split is on). In
1111
+ // the default same-origin mode these attrs are omitted so behavior
1112
+ // is identical to pre-9.1. allow-same-origin gives the cross-origin
1113
+ // frame its OWN origin (own WS/fetch/storage), NOT the parent's.
1114
+ {...(cfg?.canvasOrigin
1115
+ ? { sandbox: 'allow-scripts allow-same-origin', allow: 'clipboard-write' }
1116
+ : {})}
1103
1117
  />
1104
1118
  );
1105
1119
  })}
@@ -1310,7 +1324,7 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
1310
1324
  {items.map(p => (
1311
1325
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1312
1326
  <div className="sv-preview-frame">
1313
- <iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" />
1327
+ <iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" {...(cfg?.canvasOrigin ? { sandbox: 'allow-scripts allow-same-origin' } : {})} />
1314
1328
  </div>
1315
1329
  <div className="sv-preview-foot">
1316
1330
  <strong>{p.label}</strong>
@@ -1352,7 +1366,7 @@ function StatusBarSlot({ label, children, className = '' }) {
1352
1366
  );
1353
1367
  }
1354
1368
 
1355
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
1369
+ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, syncStatus }) {
1356
1370
  const isSystem = activePath === SYSTEM_TAB;
1357
1371
  const text = selected && selected.selector
1358
1372
  ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
@@ -1385,6 +1399,18 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1385
1399
  <span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
1386
1400
  </StatusBarSlot>
1387
1401
 
1402
+ {/* DDR-060 / 9.1-D — linked-to-hub-but-nothing-syncs state lives here in the
1403
+ status bar (DS-styled, hover for detail) instead of a floating off-brand
1404
+ pill. Only shown when the project is linked + has 0 syncable canvases. */}
1405
+ {syncStatus?.notSyncable && (
1406
+ <StatusBarSlot label="Hub sync" className="sb-sync">
1407
+ <span className="sb-sync-dot" aria-hidden="true" />
1408
+ <span className="sb-key" title={syncStatus.reason || 'Linked to a hub, but no canvases are syncable.'}>
1409
+ 0 syncable{syncStatus.tsxCount > 0 ? ` · ${syncStatus.tsxCount} tsx` : ''}
1410
+ </span>
1411
+ </StatusBarSlot>
1412
+ )}
1413
+
1388
1414
  <span className="sb-spacer" />
1389
1415
 
1390
1416
  <StatusBarSlot label="Theme" className="sb-theme">
@@ -1490,6 +1516,77 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
1490
1516
  );
1491
1517
  }
1492
1518
 
1519
+ // ---------- Sync banner (Phase 9 Task 8 — hub-down offline mode) ----------
1520
+
1521
+ // Renders nothing when online with no flash (the common case). Yellow strip
1522
+ // while offline (with queued-edit count), red when offline > 24h, green flash
1523
+ // for 3s right after a reconnect. Driven entirely by the 'sync:status' payload
1524
+ // the dev-server's linked-mode sync runtime broadcasts.
1525
+ function SyncBanner({ status }) {
1526
+ if (!status || status.linked === false) return null;
1527
+ // DDR-060 / 9.1-D — the "linked but 0 syncable" state is surfaced in the
1528
+ // status bar (sb-sync slot), NOT as a floating banner. This component owns
1529
+ // only the transient offline / reconnect-flash banner (Task 8).
1530
+ if (status.notSyncable) return null;
1531
+ const { state, queuedOps, flash, conflicts } = status;
1532
+ const showFlash = flash === 'synced';
1533
+ const offline = state === 'offline' || state === 'offline-long';
1534
+ if (!offline && !showFlash) return null;
1535
+
1536
+ let bg;
1537
+ let fg;
1538
+ let border;
1539
+ let text;
1540
+ if (showFlash) {
1541
+ bg = '#dcfce7';
1542
+ fg = '#166534';
1543
+ border = '#86efac';
1544
+ text = 'Synced with hub';
1545
+ } else if (state === 'offline-long') {
1546
+ bg = '#fee2e2';
1547
+ fg = '#991b1b';
1548
+ border = '#fca5a5';
1549
+ text = `Long offline — ${queuedOps} edit(s) queued. Consider \`git commit && git push\` as backup.`;
1550
+ } else {
1551
+ bg = '#fef9c3';
1552
+ fg = '#854d0e';
1553
+ border = '#fde047';
1554
+ text = `Working offline · ${queuedOps} edit(s) queued · will sync when the hub reconnects.`;
1555
+ }
1556
+ const conflictNote =
1557
+ conflicts && conflicts.length > 0 ? ` (${conflicts.length} conflict notice(s))` : '';
1558
+
1559
+ return (
1560
+ <div
1561
+ role="status"
1562
+ aria-live="polite"
1563
+ style={{
1564
+ position: 'fixed',
1565
+ top: 12,
1566
+ left: '50%',
1567
+ transform: 'translateX(-50%)',
1568
+ zIndex: 10003,
1569
+ display: 'flex',
1570
+ alignItems: 'center',
1571
+ gap: 10,
1572
+ padding: '8px 14px',
1573
+ background: bg,
1574
+ color: fg,
1575
+ border: `1px solid ${border}`,
1576
+ borderRadius: 999,
1577
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
1578
+ font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
1579
+ maxWidth: '80vw',
1580
+ }}
1581
+ >
1582
+ <span>
1583
+ {text}
1584
+ {conflictNote}
1585
+ </span>
1586
+ </div>
1587
+ );
1588
+ }
1589
+
1493
1590
  // ---------- App ----------
1494
1591
 
1495
1592
  function App() {
@@ -1503,6 +1600,9 @@ function App() {
1503
1600
  // every dirty Y.Doc to disk by the time this state populates, so accepting
1504
1601
  // the reload is data-loss-safe (DDR-051 §3).
1505
1602
  const [gitLifecycle, setGitLifecycle] = useState(null);
1603
+ // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
1604
+ // WS message the linked-mode sync runtime emits. null in solo mode.
1605
+ const [syncStatus, setSyncStatus] = useState(null);
1506
1606
  const [search, setSearch] = useState('');
1507
1607
  const [systemData, setSystemData] = useState(null);
1508
1608
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
@@ -1523,11 +1623,31 @@ function App() {
1523
1623
  // legacy default; designSystems[0].tokensCssRel is the project's
1524
1624
  // authoritative value (post DS-bootstrap).
1525
1625
  designSystems: data.designSystems,
1626
+ // T2 (9.1-A) — segregated canvas-content origin. canvasUrl() prepends
1627
+ // it so iframes load cross-origin (hub-pushed JSX is then walled off
1628
+ // from the main origin's /_api). Absent on older servers → relative
1629
+ // URL fallback keeps same-origin behavior.
1630
+ canvasOrigin: data.canvasOrigin,
1526
1631
  });
1527
1632
  })
1528
1633
  .catch(() => {});
1529
1634
  return () => { cancelled = true; };
1530
1635
  }, []);
1636
+ // Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
1637
+ // broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
1638
+ // tab that connects after boot would otherwise miss it. {linked:false} (solo)
1639
+ // leaves the banner null.
1640
+ useEffect(() => {
1641
+ let cancelled = false;
1642
+ fetch('/_sync-status')
1643
+ .then((r) => r.json())
1644
+ .then((data) => {
1645
+ if (cancelled || !data || data.linked === false) return;
1646
+ setSyncStatus(data);
1647
+ })
1648
+ .catch(() => {});
1649
+ return () => { cancelled = true; };
1650
+ }, []);
1531
1651
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1532
1652
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
1533
1653
  // a `draft` state. Mutations route through postMessage → WS instead.
@@ -1571,6 +1691,17 @@ function App() {
1571
1691
  document.documentElement.setAttribute('data-theme', theme);
1572
1692
  localStorage.setItem(THEME_STORE, theme);
1573
1693
  } catch {}
1694
+ // System-review D9 — the canvas-shell chrome (workspace plane, floating
1695
+ // toolbar, minimap, zoom HUD, halos) follows the Maude theme. Broadcast to
1696
+ // EVERY open canvas iframe (not just activePath — several may be open); the
1697
+ // iframe's canvas-shell sets `data-maude-theme` and re-themes its floating
1698
+ // chrome via the --maude-chrome-* family. Artboards keep their DS theme.
1699
+ // Mirrors the git-lifecycle broadcast-to-all loop below. On the initial
1700
+ // mount run iframesRef is empty (no canvas open yet) — a freshly-loaded
1701
+ // iframe instead gets the current theme from the `dgn:'loaded'` handler.
1702
+ for (const el of iframesRef.current.values()) {
1703
+ try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
1704
+ }
1574
1705
  }, [theme]);
1575
1706
 
1576
1707
  // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
@@ -1685,6 +1816,9 @@ function App() {
1685
1816
  for (const el of iframesRef.current.values()) {
1686
1817
  try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
1687
1818
  }
1819
+ } else if (m.type === 'sync:status' && m.payload) {
1820
+ // Phase 9 Task 8 — hub connection state for the offline banner.
1821
+ setSyncStatus(m.payload);
1688
1822
  } else if (m.type === 'git-lifecycle' && m.payload) {
1689
1823
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
1690
1824
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -1791,6 +1925,14 @@ function App() {
1791
1925
  // ----- Inbound messages from iframes -----
1792
1926
  useEffect(() => {
1793
1927
  function onMessage(e) {
1928
+ // Cross-origin hardening (DDR-054): only accept dgn control messages from
1929
+ // the canvas-content origin — the split origin when on, else our own origin
1930
+ // for the same-origin iframe. Drops spoofed messages from any other window.
1931
+ // The handlers below relay to inert stores (comments / selection — the
1932
+ // "safe to sync" set), so the blast radius was small, but unchecked inbound
1933
+ // postMessage is a confused-deputy seam the F1 hardening should close.
1934
+ const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
1935
+ if (e.origin !== expectedOrigin) return;
1794
1936
  const m = e.data;
1795
1937
  if (!m || typeof m !== 'object' || !m.dgn) return;
1796
1938
  if (m.dgn === 'select' && m.selection) {
@@ -1862,6 +2004,10 @@ function App() {
1862
2004
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
1863
2005
  if (el && el.contentWindow) {
1864
2006
  try { el.contentWindow.postMessage({ dgn: 'comments-set', comments: list }, '*'); } catch {}
2007
+ // System-review D9 — seed the just-loaded canvas with the current
2008
+ // chrome theme so a canvas opened AFTER a theme toggle starts
2009
+ // correct (no flash from the dark default).
2010
+ try { el.contentWindow.postMessage({ dgn: 'theme', theme }, '*'); } catch {}
1865
2011
  if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
1866
2012
  try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
1867
2013
  }
@@ -1870,7 +2016,7 @@ function App() {
1870
2016
  }
1871
2017
  window.addEventListener('message', onMessage);
1872
2018
  return () => window.removeEventListener('message', onMessage);
1873
- }, [commentsByFile, focusedCommentId]);
2019
+ }, [commentsByFile, focusedCommentId, cfg, theme]);
1874
2020
 
1875
2021
  // Tell the active canvas iframe to drop any persistent selection (canvas
1876
2022
  // SelectionSet) — used when the comment composer closes via submit /
@@ -2042,6 +2188,7 @@ function App() {
2042
2188
  className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2043
2189
  onContextMenu={onShellContextMenu}
2044
2190
  >
2191
+ <SyncBanner status={syncStatus} />
2045
2192
  {gitLifecycle && (
2046
2193
  <div
2047
2194
  role="status"
@@ -2147,6 +2294,7 @@ function App() {
2147
2294
  theme={theme}
2148
2295
  onToggleTheme={toggleTheme}
2149
2296
  onClearSelected={clearSelected}
2297
+ syncStatus={syncStatus}
2150
2298
  />
2151
2299
  </div>
2152
2300
  {commentsPanelOpen && (
@@ -799,6 +799,16 @@
799
799
  background: var(--u-status-error);
800
800
  }
801
801
  .sb-live .sb-key { color: var(--u-fg-1); }
802
+ /* DDR-060 / 9.1-D — linked-but-0-syncable indicator. Warn dot (heads-up, not
803
+ error), DS-tokened, hover the label for the full reason via the title attr. */
804
+ .sb-sync { cursor: help; }
805
+ .sb-sync-dot {
806
+ width: 7px; height: 7px;
807
+ background: var(--u-status-warn);
808
+ border-radius: 50%;
809
+ flex: none;
810
+ }
811
+ .sb-sync .sb-key { color: var(--u-fg-2); }
802
812
  .sb-spacer {
803
813
  flex: 1 0 auto;
804
814
  border-right: 1px solid var(--u-border-subtle);
@@ -1,6 +1,9 @@
1
1
  // Public surface of the collab module. Bundles the registry + persistence
2
2
  // wiring so ws.ts + server.ts don't need to know about the internal split.
3
3
 
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
4
7
  import type { Api } from '../api.ts';
5
8
  import type { Context } from '../context.ts';
6
9
 
@@ -14,6 +17,8 @@ export type { CollabConn } from './protocol.ts';
14
17
 
15
18
  export interface Collab {
16
19
  registry: Registry;
20
+ /** Tear down the fs-driven re-seed subscription (server shutdown). */
21
+ dispose(): void;
17
22
  }
18
23
 
19
24
  /**
@@ -24,15 +29,21 @@ export interface Collab {
24
29
  */
25
30
  export function createCollab(ctx: Context, api: Api): Collab {
26
31
  // Inverse of api.fileSlug — we have the URL slug, need the canonical
27
- // repo-relative path the api expects. The current fileSlug() is destructive
28
- // (loses the extension + the designRel prefix), so we round-trip by
29
- // scanning loadAllComments cheap enough at room-open time, and a fresh
30
- // canvas with no comments yet just returns null (an empty Y.Doc is correct
31
- // in that case).
32
+ // repo-relative path the api expects.
33
+ //
34
+ // Primary: api.fileForSlug scans the ACTUAL canvas files, so it resolves even
35
+ // when the canvas has no comments yet. This is the fix for the receiving-peer
36
+ // projection gap — a peer that hasn't yet got a comment for a canvas must
37
+ // still locate the file to MATERIALIZE the first hub-pushed comment to disk
38
+ // (DDR-064). The old comments-file scan was chicken-and-egg here: no comments
39
+ // → null → persistJson bailed → the incoming comment never hit disk.
32
40
  //
33
- // Task 3 will tighten this to a slug -> file lookup table maintained from
34
- // the index/file-tree state so we don't need to read every JSON file.
41
+ // Fallback: a canvas whose file the scan missed (renamed/moved with an orphan
42
+ // comments file) still resolves via its existing comments file, preserving
43
+ // the prior behavior for that edge.
35
44
  async function fileForSlug(slug: string): Promise<string | null> {
45
+ const byCanvas = await api.fileForSlug(slug);
46
+ if (byCanvas) return byCanvas;
36
47
  const all = await api.loadAllComments();
37
48
  for (const [file] of Object.entries(all)) {
38
49
  if (api.fileSlug(file) === slug) return file;
@@ -40,8 +51,75 @@ export function createCollab(ctx: Context, api: Api): Collab {
40
51
  return null;
41
52
  }
42
53
 
43
- const persistence = createPersistence({ ctx, api, fileForSlug });
54
+ // Phase 9.2 (DDR-064) under sharedDoc, disable the room's local file-seed
55
+ // for pinned (provider-attached) slugs so it can't push duplicate Y.Array
56
+ // items against the hub's canonical items (Risk 1). The registry is created
57
+ // from the persistence callbacks, so break the cycle with a late-bound ref
58
+ // (the predicate is only consulted at room-seed time, after `registry` is
59
+ // assigned). Flag-OFF → predicate returns true → seed unchanged.
60
+ let registryRef: Registry | null = null;
61
+ const persistence = createPersistence({
62
+ ctx,
63
+ api,
64
+ fileForSlug,
65
+ shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
66
+ });
44
67
  const registry = createRegistry(persistence);
68
+ registryRef = registry;
69
+
70
+ // File = truth (the file-sync collaboration model + DDR-051): when a synced
71
+ // file changes on disk from OUTSIDE the API path — the sync agent writing a
72
+ // hub-pushed diff, or `design:edit` editing a JSON/SVG directly — fan the
73
+ // change back out to the browser, since none of these go through the API's
74
+ // onCommentsChanged. Two consumers, two reasons:
75
+ // - the live collab room (canvas pins / annotation strokes) — re-seeded so
76
+ // its in-memory doc stops clobbering the external change on next persist
77
+ // (the cross-machine "comment reverts to []" bug). Only when a room is
78
+ // mounted (peek, never create).
79
+ // - the shell's comments SIDEBAR — driven solely by the 'comments' WS event
80
+ // (app.jsx), which otherwise fires only on API writes. Emit it here too so
81
+ // a hub-pushed comment shows up on the peer's sidebar without a reload.
82
+ // The registry's no-op guards make an identical re-seed free, so this can't
83
+ // loop against the room's own persist (which also writes the file).
84
+ const reseedFromDisk = async (rel: string): Promise<void> => {
85
+ const cm = /^_comments\/(.+)\.json$/.exec(rel);
86
+ const am = /^(.+)\.annotations\.svg$/.exec(rel);
87
+ const slug = cm?.[1] ?? am?.[1];
88
+ if (!slug) return;
89
+ const abs = path.join(ctx.paths.designRoot, rel);
90
+ try {
91
+ if (cm) {
92
+ // Prefer the canonical loader (validates + default-fills the Comment
93
+ // shape) keyed by the canvas file; fall back to the raw array when the
94
+ // slug isn't mapped yet (e.g. a freshly-synced file with no prior load).
95
+ const file = await fileForSlug(slug);
96
+ const parsed = file
97
+ ? await api.loadCommentsForFile(file)
98
+ : JSON.parse(readFileSync(abs, 'utf8'));
99
+ if (!Array.isArray(parsed)) return;
100
+ // Phase 9.2 (DDR-064): under sharedDoc the hub provider is attached to
101
+ // the single room doc, so a hub-pushed comment is ALREADY in the room —
102
+ // the wholesale re-seed (last-writer-wins blob copy) is the retired
103
+ // clobber path. The agent's diff-aware applyFromFs handles external
104
+ // file→doc imports instead. The sidebar still needs the 'comments' bus
105
+ // emit, so keep that unconditionally.
106
+ if (!ctx.sharedDoc && registry.peek(slug)) registry.syncRoomFromComments(slug, parsed);
107
+ if (file) ctx.bus.emit('comments', { file, comments: parsed });
108
+ } else if (!ctx.sharedDoc && registry.peek(slug)) {
109
+ registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
110
+ }
111
+ } catch {
112
+ /* file vanished mid-flight or unreadable — leave state as-is */
113
+ }
114
+ };
115
+ const unsubFs = ctx.bus.on('fs:any', (rel: string) => {
116
+ void reseedFromDisk(rel);
117
+ });
45
118
 
46
- return { registry };
119
+ return {
120
+ registry,
121
+ dispose() {
122
+ unsubFs?.();
123
+ },
124
+ };
47
125
  }
@@ -36,6 +36,15 @@ export interface PersistenceDeps {
36
36
  fileForSlug: (slug: string) => Promise<string | null>;
37
37
  /** Best-effort cache primer — see fileForSlug above. */
38
38
  noteFile?: (file: string) => void;
39
+ /**
40
+ * Phase 9.2 (DDR-064) — when this returns false for a slug, `seed` is a no-op
41
+ * for it. The shared-doc path passes a predicate that returns false for
42
+ * pinned (provider-attached) slugs, so the local file-seed can't push fresh
43
+ * Y.Array items that would DUPLICATE the hub's canonical items on merge
44
+ * (Risk 1). The migrate-seed + provider own initial population for those
45
+ * slugs. Absent → always seed (flag-OFF behavior, unchanged).
46
+ */
47
+ shouldSeed?: (slug: string) => boolean;
39
48
  }
40
49
 
41
50
  /**
@@ -50,6 +59,16 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
50
59
  const { ctx, api, fileForSlug } = deps;
51
60
  const stateDir = ensureStateDir(ctx.paths.designRoot);
52
61
 
62
+ // Per-slug latch: have we ever projected a NON-empty comments array for this
63
+ // canvas? We write an empty `[]` to disk only AFTER content has been seen — a
64
+ // genuine delete-all — never from a doc that was never populated (cold start
65
+ // before seed/migrate completes), which would clobber a non-empty local file.
66
+ // This closes the receiving-peer gap where a delete-all on one peer left
67
+ // stragglers on the other: the old `arr.length > 0` guard skipped the write
68
+ // when the array emptied, so the deletion never reached the peer's JSON file
69
+ // (the file the sidebar reads), even though the Y.Doc had converged (DDR-064).
70
+ const seenComments = new Set<string>();
71
+
53
72
  function ydocBinPath(slug: string): string {
54
73
  return path.join(stateDir, `${slug}.ydoc.bin`);
55
74
  }
@@ -66,6 +85,13 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
66
85
  }
67
86
 
68
87
  async function seed(slug: string, doc: Y.Doc): Promise<void> {
88
+ // Phase 9.2 (DDR-064) — under sharedDoc the migrate-seed + hub provider own
89
+ // initial population for a pinned slug; a local file-seed here would push
90
+ // fresh Y.Array items that DUPLICATE the hub's canonical items on merge
91
+ // (Risk 1). Skip seeding when the predicate says so. Flag-OFF / unpinned →
92
+ // always seeds (predicate absent or returns true).
93
+ if (deps.shouldSeed && !deps.shouldSeed(slug)) return;
94
+
69
95
  // Step 1 — try the binary cache.
70
96
  const binary = await readBinary(slug);
71
97
  if (binary && binary.byteLength > 0) {
@@ -100,10 +126,15 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
100
126
  const file = await fileForSlug(slug);
101
127
  if (!file) return;
102
128
 
103
- // Comments — Y.Array projection back to JSON.
129
+ // Comments — Y.Array projection back to JSON. Write whenever the doc holds
130
+ // comments, OR when it just emptied after previously holding some (so a
131
+ // delete-all materializes on EVERY peer's disk, not just the originator's).
132
+ // The seenComments latch keeps a never-populated doc (cold start) from
133
+ // clobbering a non-empty local file with [].
104
134
  const arr = doc.getArray(Y_TYPES.comments);
105
- if (arr.length > 0) {
106
- const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
135
+ const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
136
+ if (list.length > 0) seenComments.add(slug);
137
+ if (list.length > 0 || seenComments.has(slug)) {
107
138
  await api.saveCommentsForFile(file, list);
108
139
  }
109
140
 
@@ -13,11 +13,32 @@ import { Y_TYPES } from './persistence.ts';
13
13
  import type { Room, RoomCallbacks } from './room.ts';
14
14
  import { createRoom } from './room.ts';
15
15
 
16
+ /** Structural equality via canonical JSON — used by the syncRoomFrom* no-op
17
+ * guards. Comment lists are small; stringify is cheap + dependency-free. */
18
+ function jsonEqual(a: unknown, b: unknown): boolean {
19
+ return JSON.stringify(a) === JSON.stringify(b);
20
+ }
21
+
16
22
  export interface Registry {
17
23
  /** Get-or-create. Reuses an existing room for the same slug. */
18
24
  get(slug: string): Room;
19
25
  /** Existence check — returns the live room if any, else null. NEVER creates. */
20
26
  peek(slug: string): Room | null;
27
+ /**
28
+ * Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug, creating the
29
+ * owning Room if necessary. This is the seam the shared-doc path attaches its
30
+ * hub-facing `HocuspocusProvider` to (Phase B): the doc must exist
31
+ * independent of a live browser connection so the provider can attach at
32
+ * serve start. Repeated calls return the SAME instance (the room's doc) —
33
+ * the y-websocket-server `getYDoc(name)` single-cache pattern.
34
+ *
35
+ * Phase A: defined but unused (the flag-OFF two-doc path never calls it), so
36
+ * adding it is behavior-neutral. NOTE: a room created via `getDoc` (no
37
+ * browser conn) is NOT auto-dropped by the `size()===0` check in `drop` until
38
+ * Phase B teaches the lifecycle that an attached provider keeps it alive;
39
+ * for Phase A nothing calls `getDoc`, so no room leaks.
40
+ */
41
+ getDoc(slug: string): import('yjs').Doc;
21
42
  /**
22
43
  * Phase 8 Task 3 bridge — inspector-channel writes (REST `/_api/comments*`
23
44
  * or the legacy WS comments-add path) call this so the live Y.Array sees
@@ -47,6 +68,22 @@ export interface Registry {
47
68
  * no files (see awareness-bridge.ts on why F14 is untouched).
48
69
  */
49
70
  attachHubAwareness(slug: string, awareness: Awareness): () => void;
71
+ /**
72
+ * Phase 9.2 (DDR-064) — pin a room so `drop` won't destroy it when the last
73
+ * browser leaves. The shared-doc path pins a slug while a hub
74
+ * `HocuspocusProvider` is attached to its `getDoc(slug)`: destroying the
75
+ * room (→ `doc.destroy()`) would pull the doc out from under the live
76
+ * provider. Self-gating — only the flag-ON sync runtime calls `pin`, so the
77
+ * flag-OFF `drop` lifecycle is byte-for-byte unchanged. Idempotent.
78
+ */
79
+ pin(slug: string): void;
80
+ /** Release a pin (provider detached on runtime stop). Idempotent. */
81
+ unpin(slug: string): void;
82
+ /** True when a slug is pinned (a shared-doc provider is attached). Used to
83
+ * disable the room's local file-seed for that slug under sharedDoc — the
84
+ * migrate-seed + provider own initial population, so a duplicate file-seed
85
+ * can't re-introduce items (DDR-064 Risk 1). */
86
+ isPinned(slug: string): boolean;
50
87
  /** Flush every dirty room synchronously. DDR-051 branch-switch path. */
51
88
  flushAll(): Promise<void>;
52
89
  /** Tear down everything (e.g. on server shutdown). */
@@ -64,6 +101,11 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
64
101
  // is (re)created for a slug that has an attached hub Awareness.
65
102
  const hubAwareness = new Map<string, Awareness>();
66
103
  const bridges = new Map<string, () => void>();
104
+ // Phase 9.2 (DDR-064) — slugs whose room must survive the last-browser-leaves
105
+ // drop because a shared-doc hub provider is attached to its doc. Only the
106
+ // flag-ON sync runtime ever adds to this; flag-OFF leaves it empty so `drop`
107
+ // is unchanged.
108
+ const pinned = new Set<string>();
67
109
 
68
110
  function wireBridge(slug: string, room: Room): void {
69
111
  if (bridges.has(slug)) return;
@@ -104,11 +146,37 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
104
146
  return rooms.get(slug) ?? null;
105
147
  }
106
148
 
149
+ function getDoc(slug: string): import('yjs').Doc {
150
+ // Reuse get-or-create so the doc, awareness, persistence schedule, and
151
+ // (idempotent) awareness-bridge wiring are all set up exactly once. The
152
+ // room's doc IS the single shared doc for this slug.
153
+ return get(slug).doc;
154
+ }
155
+
156
+ function pin(slug: string): void {
157
+ pinned.add(slug);
158
+ }
159
+
160
+ function unpin(slug: string): void {
161
+ pinned.delete(slug);
162
+ }
163
+
164
+ function isPinned(slug: string): boolean {
165
+ return pinned.has(slug);
166
+ }
167
+
107
168
  function syncRoomFromComments(slug: string, comments: readonly unknown[]): void {
108
169
  const room = rooms.get(slug);
109
170
  if (!room) return;
171
+ const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
172
+ // No-op guard (load-bearing): skip when the room already holds this exact
173
+ // list. The wholesale delete+push always emits a doc update, which schedules
174
+ // a persist → file write → fs event → re-seed … so without this equality
175
+ // short-circuit, re-seeding the live room from a disk change (sync-agent or
176
+ // design:edit write — see createCollab's fs hook) would spin an 800ms
177
+ // persist storm. Equality breaks the loop after a single convergence.
178
+ if (jsonEqual(arr.toArray(), comments)) return;
110
179
  room.doc.transact(() => {
111
- const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
112
180
  if (arr.length > 0) arr.delete(0, arr.length);
113
181
  if (comments.length > 0) arr.push(comments as unknown[]);
114
182
  }, 'inspector-write');
@@ -117,8 +185,9 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
117
185
  function syncRoomFromAnnotations(slug: string, svg: string): void {
118
186
  const room = rooms.get(slug);
119
187
  if (!room) return;
188
+ const map = room.doc.getMap<string>(Y_TYPES.annotations);
189
+ if (map.get('svg') === svg) return; // no-op guard — same rationale as comments
120
190
  room.doc.transact(() => {
121
- const map = room.doc.getMap<string>(Y_TYPES.annotations);
122
191
  map.set('svg', svg);
123
192
  }, 'inspector-write');
124
193
  }
@@ -131,6 +200,11 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
131
200
  const room = rooms.get(slug);
132
201
  if (!room) return;
133
202
  if (room.size() > 0) return; // still active, leave it
203
+ // Phase 9.2 (DDR-064) — a pinned room has a shared-doc hub provider attached
204
+ // to its doc; destroying it would yank the doc out from under the provider.
205
+ // Leave it (with zero browser conns) until runtime stop / server shutdown.
206
+ // Empty in the flag-OFF path → no behavior change.
207
+ if (pinned.has(slug)) return;
134
208
  // Tear the bridge down before room.destroy() runs awareness.destroy() —
135
209
  // a late relay must not fire against a dead Awareness. The hub Awareness
136
210
  // stays registered, so a reconnecting browser re-wires via get().
@@ -150,9 +224,13 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
150
224
  return {
151
225
  get,
152
226
  peek,
227
+ getDoc,
153
228
  syncRoomFromComments,
154
229
  syncRoomFromAnnotations,
155
230
  attachHubAwareness,
231
+ pin,
232
+ unpin,
233
+ isPinned,
156
234
  flushAll,
157
235
  destroyAll,
158
236
  drop,