@1agh/maude 0.22.2 → 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 (125) 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 +84 -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 +135 -10
  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 +222 -11
  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/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -64,7 +64,7 @@ function urlOf(p) {
64
64
  // canvases keep the legacy "serve the file with inspector + Babel injected"
65
65
  // path. Phase 3.6 contract; the path argument is repo-root-relative
66
66
  // (e.g. ".design/ui/Foo.tsx").
67
- function canvasUrl(p, cfg) {
67
+ function canvasUrl(p, cfg, opts) {
68
68
  if (!p.endsWith('.tsx')) return urlOf(p);
69
69
  const designRel = (cfg?.designRel || '.design').replace(/^\/+|\/+$/g, '');
70
70
  // Path under designRoot.
@@ -77,22 +77,36 @@ function canvasUrl(p, cfg) {
77
77
  const params = new URLSearchParams();
78
78
  params.set('canvas', rel);
79
79
  params.set('designRel', designRel);
80
- // Resolve tokens path. Prefer the first designSystem's tokensCssRel — that's
81
- // the project's authoritative tokens file (e.g. `system/project/colors_and_type.css`).
82
- // The top-level cfg.tokensCssRel is the legacy default (`system/colors_and_type.css`)
83
- // and points to a file that usually doesn't exist in DS-bootstrapped projects.
80
+ // Gallery thumbnails suppress the shell-owned comment layer (`?comments=0`)
81
+ // so previews stay non-interactive; opening the same canvas as a real tab
82
+ // omits the flag and gets comments. See canvas-comment-mount.tsx.
83
+ if (opts?.thumbnail) params.set('comments', '0');
84
84
  const ds0 = cfg?.designSystems?.[0];
85
- const tokens = ds0?.tokensCssRel || cfg?.tokensCssRel;
85
+ // Specimen detection: anything under `system/<ds>/preview/` belongs to that
86
+ // specific DS, so it must render with *that* DS's tokens — not always the
87
+ // first one. In a multi-DS project this is what keeps each design system's
88
+ // preview distinct (beta previews use beta tokens, not alpha's).
89
+ const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
90
+ const specDsEntry = specMatch
91
+ ? cfg?.designSystems?.find(
92
+ (d) => d.path === `system/${specMatch[1]}` || d.path.endsWith(`/${specMatch[1]}`)
93
+ )
94
+ : null;
95
+ // Resolve tokens path. For a specimen, prefer the matching DS's tokensCssRel
96
+ // (fall back to the `system/<ds>/colors_and_type.css` convention). Otherwise
97
+ // prefer the first designSystem's tokensCssRel — the project's authoritative
98
+ // tokens file. The top-level cfg.tokensCssRel is the legacy default
99
+ // (`system/colors_and_type.css`) and usually doesn't exist post-bootstrap.
100
+ const tokens = specMatch
101
+ ? (specDsEntry?.tokensCssRel || `system/${specMatch[1]}/colors_and_type.css`)
102
+ : (ds0?.tokensCssRel || cfg?.tokensCssRel);
86
103
  if (tokens) params.set('tokens', tokens);
87
104
  if (cfg?.componentsCssRel) params.set('components', cfg.componentsCssRel);
88
- // Specimen detection: anything under `system/<ds>/preview/` gets the layout
89
- // chrome CSS so its `.specimen-hd` / `_layout.css`-baked treatment renders.
90
- const specMatch = rel.match(/^system\/([^/]+)\/preview\//);
91
105
  if (specMatch) {
92
- const ds = specMatch[1];
93
- params.set('layout', `system/${ds}/preview/_layout.css`);
106
+ const dsName = specMatch[1];
107
+ params.set('layout', `system/${dsName}/preview/_layout.css`);
94
108
  if (!cfg?.componentsCssRel) {
95
- params.set('components', `system/${ds}/preview/_components.css`);
109
+ params.set('components', `system/${dsName}/preview/_components.css`);
96
110
  }
97
111
  } else if (ds0?.path) {
98
112
  // UI canvas — load the project DS's `_components.css` so the dc-canvas /
@@ -102,7 +116,11 @@ function canvasUrl(p, cfg) {
102
116
  params.set('components', `${ds0.path}/preview/_components.css`);
103
117
  }
104
118
  }
105
- 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()}`;
106
124
  }
107
125
 
108
126
  function basename(p) {
@@ -418,7 +436,7 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
418
436
  );
419
437
  }
420
438
 
421
- function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, onOpenSystem }) {
439
+ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, activeDsName, onOpenSystem }) {
422
440
  const dirs = Object.keys(node).filter(k => k !== '_files').sort();
423
441
  const files = node._files || [];
424
442
  // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
@@ -480,6 +498,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
480
498
  kind={kind}
481
499
  showHidden={showHidden}
482
500
  search={search}
501
+ activeDsName={activeDsName}
483
502
  onOpenSystem={onOpenSystem}
484
503
  />
485
504
  );
@@ -491,7 +510,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
491
510
  dsName={dsMatch.name}
492
511
  depth={depth}
493
512
  defaultOpen={true}
494
- active={activePath === SYSTEM_TAB}
513
+ active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
495
514
  onOpenSystem={onOpenSystem}
496
515
  >
497
516
  {childTree}
@@ -529,7 +548,7 @@ function sectionMetaFor(g) {
529
548
  return { title: g.label.toUpperCase(), pillFromCount: true };
530
549
  }
531
550
 
532
- function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
551
+ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
533
552
  const filteredGroups = useMemo(() => {
534
553
  if (!search) return groups;
535
554
  return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
@@ -624,6 +643,7 @@ function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search
624
643
  showHidden={showHidden}
625
644
  search={search}
626
645
  dsFolders={g.dsFolders}
646
+ activeDsName={activeDsName}
627
647
  onOpenSystem={isDs ? onOpenSystem : undefined}
628
648
  />
629
649
  ) : (
@@ -896,7 +916,9 @@ function ToolsDropdown({ onAction, onClose }) {
896
916
  { id: 'pen', label: 'Pen', shortcut: 'B' },
897
917
  { id: 'rect', label: 'Rect', shortcut: 'R' },
898
918
  { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
919
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
899
920
  { id: 'arrow', label: 'Arrow', shortcut: 'A' },
921
+ { id: 'text', label: 'Text', shortcut: 'T' },
900
922
  { id: 'eraser', label: 'Eraser', shortcut: 'E' },
901
923
  ];
902
924
  return (
@@ -1084,6 +1106,14 @@ function Viewport({ tabs, activePath, registerIframe, systemData, onOpenFromSyst
1084
1106
  src={canvasUrl(t.path, cfg)}
1085
1107
  className={t.path === activePath ? 'active' : ''}
1086
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
+ : {})}
1087
1117
  />
1088
1118
  );
1089
1119
  })}
@@ -1294,7 +1324,7 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
1294
1324
  {items.map(p => (
1295
1325
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1296
1326
  <div className="sv-preview-frame">
1297
- <iframe src={canvasUrl(p.path, cfg)} 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' } : {})} />
1298
1328
  </div>
1299
1329
  <div className="sv-preview-foot">
1300
1330
  <strong>{p.label}</strong>
@@ -1336,7 +1366,7 @@ function StatusBarSlot({ label, children, className = '' }) {
1336
1366
  );
1337
1367
  }
1338
1368
 
1339
- function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected }) {
1369
+ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onToggleTheme, onClearSelected, syncStatus }) {
1340
1370
  const isSystem = activePath === SYSTEM_TAB;
1341
1371
  const text = selected && selected.selector
1342
1372
  ? selected.selector + (selected.text ? ` — "${selected.text.slice(0, 60)}"` : '')
@@ -1369,6 +1399,18 @@ function StatusBar({ activePath, selected, wsConnected, openCount, theme, onTogg
1369
1399
  <span className="sb-key">{wsConnected ? 'live' : 'reconnecting'}</span>
1370
1400
  </StatusBarSlot>
1371
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
+
1372
1414
  <span className="sb-spacer" />
1373
1415
 
1374
1416
  <StatusBarSlot label="Theme" className="sb-theme">
@@ -1474,6 +1516,77 @@ function CommentsPanel({ commentsByFile, filter, setFilter, activePath, focusedI
1474
1516
  );
1475
1517
  }
1476
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
+
1477
1590
  // ---------- App ----------
1478
1591
 
1479
1592
  function App() {
@@ -1487,6 +1600,9 @@ function App() {
1487
1600
  // every dirty Y.Doc to disk by the time this state populates, so accepting
1488
1601
  // the reload is data-loss-safe (DDR-051 §3).
1489
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);
1490
1606
  const [search, setSearch] = useState('');
1491
1607
  const [systemData, setSystemData] = useState(null);
1492
1608
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
@@ -1507,11 +1623,31 @@ function App() {
1507
1623
  // legacy default; designSystems[0].tokensCssRel is the project's
1508
1624
  // authoritative value (post DS-bootstrap).
1509
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,
1510
1631
  });
1511
1632
  })
1512
1633
  .catch(() => {});
1513
1634
  return () => { cancelled = true; };
1514
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
+ }, []);
1515
1651
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
1516
1652
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
1517
1653
  // a `draft` state. Mutations route through postMessage → WS instead.
@@ -1555,6 +1691,17 @@ function App() {
1555
1691
  document.documentElement.setAttribute('data-theme', theme);
1556
1692
  localStorage.setItem(THEME_STORE, theme);
1557
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
+ }
1558
1705
  }, [theme]);
1559
1706
 
1560
1707
  // Persist sidebar / hidden-files / DS-body toggles. Mirror theme pattern.
@@ -1669,6 +1816,9 @@ function App() {
1669
1816
  for (const el of iframesRef.current.values()) {
1670
1817
  try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
1671
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);
1672
1822
  } else if (m.type === 'git-lifecycle' && m.payload) {
1673
1823
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
1674
1824
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -1707,8 +1857,13 @@ function App() {
1707
1857
  setFocusedCommentId(null);
1708
1858
  }, []);
1709
1859
 
1710
- const openSystem = useCallback(() => {
1711
- if (!systemData) loadSystemData();
1860
+ const openSystem = useCallback((dsName) => {
1861
+ // DsFolderRow passes the clicked DS name → scope the System view to it so
1862
+ // each folder shows its own tokens + previews. The no-arg callers (menubar,
1863
+ // keyboard reopen) only load default data on first open.
1864
+ const ds = typeof dsName === 'string' ? dsName : undefined;
1865
+ if (ds) loadSystemData(ds);
1866
+ else if (!systemData) loadSystemData();
1712
1867
  openTab(SYSTEM_TAB);
1713
1868
  }, [systemData, loadSystemData, openTab]);
1714
1869
 
@@ -1770,6 +1925,14 @@ function App() {
1770
1925
  // ----- Inbound messages from iframes -----
1771
1926
  useEffect(() => {
1772
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;
1773
1936
  const m = e.data;
1774
1937
  if (!m || typeof m !== 'object' || !m.dgn) return;
1775
1938
  if (m.dgn === 'select' && m.selection) {
@@ -1841,6 +2004,10 @@ function App() {
1841
2004
  const el = [...iframesRef.current.entries()].find(([k]) => k === m.file)?.[1];
1842
2005
  if (el && el.contentWindow) {
1843
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 {}
1844
2011
  if (focusedCommentId && list.some(c => c.id === focusedCommentId)) {
1845
2012
  try { el.contentWindow.postMessage({ dgn: 'comment-focus', id: focusedCommentId }, '*'); } catch {}
1846
2013
  }
@@ -1849,7 +2016,7 @@ function App() {
1849
2016
  }
1850
2017
  window.addEventListener('message', onMessage);
1851
2018
  return () => window.removeEventListener('message', onMessage);
1852
- }, [commentsByFile, focusedCommentId]);
2019
+ }, [commentsByFile, focusedCommentId, cfg, theme]);
1853
2020
 
1854
2021
  // Tell the active canvas iframe to drop any persistent selection (canvas
1855
2022
  // SelectionSet) — used when the comment composer closes via submit /
@@ -2021,6 +2188,7 @@ function App() {
2021
2188
  className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2022
2189
  onContextMenu={onShellContextMenu}
2023
2190
  >
2191
+ <SyncBanner status={syncStatus} />
2024
2192
  {gitLifecycle && (
2025
2193
  <div
2026
2194
  role="status"
@@ -2075,6 +2243,7 @@ function App() {
2075
2243
  <Sidebar
2076
2244
  groups={groups}
2077
2245
  activePath={activePath}
2246
+ activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
2078
2247
  onOpen={openTab}
2079
2248
  onOpenSystem={openSystem}
2080
2249
  wsConnected={wsConnected}
@@ -2125,6 +2294,7 @@ function App() {
2125
2294
  theme={theme}
2126
2295
  onToggleTheme={toggleTheme}
2127
2296
  onClearSelected={clearSelected}
2297
+ syncStatus={syncStatus}
2128
2298
  />
2129
2299
  </div>
2130
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);
@@ -0,0 +1,77 @@
1
+ // Bidirectional Awareness relay — Phase 9 Task 5 (awareness over WSS).
2
+ //
3
+ // In linked mode each canvas has TWO Awareness instances in this process:
4
+ //
5
+ // - the collab Room's Awareness (browser tabs ↔ dev-server, loopback WS)
6
+ // - the sync provider's Awareness (dev-server ↔ hub, HocuspocusProvider)
7
+ //
8
+ // Hocuspocus relays awareness frames between connected peers on a document out
9
+ // of the box, so the provider's Awareness already reaches cross-continent
10
+ // peers. The only missing link is in-process: this bridge wires the Room's
11
+ // Awareness to the provider's Awareness so a browser cursor published into the
12
+ // Room reaches the hub (and back) without either side knowing about the other.
13
+ //
14
+ // Awareness is EPHEMERAL — it is never persisted to disk. That is why this
15
+ // bridge is decoupled from the file-ownership question (DDR-054 F14): relaying
16
+ // cursors/selections/viewport touches no `.json` / `.svg` / `.html` file, so
17
+ // it neither introduces nor resolves the comments/annotations write race. The
18
+ // doc-content bridge (Room doc ↔ provider doc) is intentionally NOT built here
19
+ // — disk stays the medium between the two docs (Task 4), and F14 remains a
20
+ // documented risk for the doc-content bridge work.
21
+ //
22
+ // Echo prevention: every cross relay applies the update to the far side tagged
23
+ // with a shared BRIDGE origin. Each direction's listener skips updates carrying
24
+ // that origin, so a relayed state never bounces back to where it came from.
25
+ // This is the same pattern y-websocket uses to relay awareness across a fan-out
26
+ // hub. State identity is preserved because awareness updates are keyed by the
27
+ // originating clientID — the random 32-bit ids make local↔remote collisions
28
+ // negligible.
29
+
30
+ import { applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
31
+ import type { Awareness } from 'y-protocols/awareness';
32
+
33
+ interface AwarenessChange {
34
+ added: number[];
35
+ updated: number[];
36
+ removed: number[];
37
+ }
38
+
39
+ /**
40
+ * Wire `a` ↔ `b` bidirectionally. Existing states on each side are exchanged
41
+ * immediately so a peer that connected before the bridge existed still shows
42
+ * up. Returns a detach fn that removes both listeners — call it BEFORE either
43
+ * Awareness is destroyed, otherwise a late relay would apply to a dead
44
+ * instance.
45
+ */
46
+ export function bridgeAwareness(a: Awareness, b: Awareness): () => void {
47
+ // A single shared origin for both directions. A genuine local change carries
48
+ // some other origin (a browser conn, the provider's internal token, or null),
49
+ // so it relays; a change this bridge applied carries BRIDGE, so it stops.
50
+ const BRIDGE = { awarenessBridge: true };
51
+
52
+ function makeRelay(from: Awareness, to: Awareness) {
53
+ return ({ added, updated, removed }: AwarenessChange, origin: unknown) => {
54
+ if (origin === BRIDGE) return;
55
+ const changed = added.concat(updated, removed);
56
+ if (changed.length === 0) return;
57
+ applyAwarenessUpdate(to, encodeAwarenessUpdate(from, changed), BRIDGE);
58
+ };
59
+ }
60
+
61
+ const aToB = makeRelay(a, b);
62
+ const bToA = makeRelay(b, a);
63
+
64
+ // Initial state exchange — push each side's current states to the other.
65
+ const aClients = Array.from(a.getStates().keys());
66
+ if (aClients.length > 0) applyAwarenessUpdate(b, encodeAwarenessUpdate(a, aClients), BRIDGE);
67
+ const bClients = Array.from(b.getStates().keys());
68
+ if (bClients.length > 0) applyAwarenessUpdate(a, encodeAwarenessUpdate(b, bClients), BRIDGE);
69
+
70
+ a.on('update', aToB);
71
+ b.on('update', bToA);
72
+
73
+ return () => {
74
+ a.off('update', aToB);
75
+ b.off('update', bToA);
76
+ };
77
+ }
@@ -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
  }