@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.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +333 -19
- package/plugins/design/dev-server/client/app.jsx +155 -7
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +126 -13
- package/plugins/design/dev-server/dist/comment-mount.js +78 -11
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +26 -3
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +49 -1
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- 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
|
|
107
|
-
params.set('layout', `system/${
|
|
106
|
+
const dsName = specMatch[1];
|
|
107
|
+
params.set('layout', `system/${dsName}/preview/_layout.css`);
|
|
108
108
|
if (!cfg?.componentsCssRel) {
|
|
109
|
-
params.set('components', `system/${
|
|
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
|
-
|
|
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.
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// canvas
|
|
31
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
106
|
-
|
|
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,
|