@1agh/maude 0.22.2 → 0.23.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 (44) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/build.ts +69 -3
  7. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  9. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  10. package/plugins/design/dev-server/client/app.jsx +37 -15
  11. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  12. package/plugins/design/dev-server/collab/registry.ts +51 -0
  13. package/plugins/design/dev-server/config.schema.json +20 -0
  14. package/plugins/design/dev-server/context.ts +7 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  16. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  17. package/plugins/design/dev-server/dom-selection.ts +156 -0
  18. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  19. package/plugins/design/dev-server/input-router.tsx +99 -61
  20. package/plugins/design/dev-server/server.ts +18 -0
  21. package/plugins/design/dev-server/sync/agent.ts +323 -0
  22. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  23. package/plugins/design/dev-server/sync/codec.ts +169 -0
  24. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  25. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  26. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  27. package/plugins/design/dev-server/sync/index.ts +474 -0
  28. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  29. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  30. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  31. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  32. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  33. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  34. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  35. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  36. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  37. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  38. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  39. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  40. package/plugins/design/dev-server/use-collab.tsx +157 -13
  41. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  42. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  43. package/plugins/design/templates/_shell.html +15 -5
  44. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -48,7 +48,6 @@ import {
48
48
  useViewportControllerContext,
49
49
  } from './canvas-lib.tsx';
50
50
  import { type AlignMode, alignLabel, equalSpacingLabel } from './commands/equal-spacing-command.ts';
51
- import { CommentsOverlay } from './comments-overlay.tsx';
52
51
  import {
53
52
  ContextMenuProvider,
54
53
  type ContextRegistry,
@@ -59,6 +58,15 @@ import {
59
58
  } from './context-menu.tsx';
60
59
  import { ContextualToolbar } from './contextual-toolbar.tsx';
61
60
  import { CursorsOverlay } from './cursors-overlay.tsx';
61
+ import {
62
+ cssEscape,
63
+ cssPath,
64
+ deriveFile,
65
+ domPath,
66
+ hoverTargetToSelection,
67
+ realClasses,
68
+ shortText,
69
+ } from './dom-selection.ts';
62
70
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
63
71
  import { ExportDialogProvider } from './export-dialog.tsx';
64
72
  import { type HoverTarget, resolveHoverTarget, useInputRouter } from './input-router.tsx';
@@ -75,7 +83,11 @@ import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx'
75
83
  import { useCollab } from './use-collab.tsx';
76
84
  import { useCursorModifiers } from './use-cursor-modifiers.tsx';
77
85
  import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
78
- import { type Selection, SelectionSetProvider, useSelectionSet } from './use-selection-set.tsx';
86
+ import {
87
+ MaybeSelectionSetProvider,
88
+ type Selection,
89
+ useSelectionSet,
90
+ } from './use-selection-set.tsx';
79
91
  import { useToolMode } from './use-tool-mode.tsx';
80
92
  import { useUndoStack } from './use-undo-stack.tsx';
81
93
 
@@ -303,14 +315,17 @@ export function CanvasShell({
303
315
  ensureHaloStyles();
304
316
  // ToolProvider is mounted by DesignCanvas one level up (so the viewport
305
317
  // controller's `isPanDragActive` predicate can read the live tool state).
318
+ // SelectionSetProvider is mounted via MaybeSelectionSetProvider — the shell-
319
+ // owned comment mount layer provides one, in which case CanvasShell consumes
320
+ // that single instance so the comment router + halos share one selection set.
306
321
  return (
307
- <SelectionSetProvider>
322
+ <MaybeSelectionSetProvider>
308
323
  <AnnotationSelectionProvider>
309
324
  <AnnotationsVisibilityProvider>
310
325
  <CanvasCore hostRef={hostRef}>{children}</CanvasCore>
311
326
  </AnnotationsVisibilityProvider>
312
327
  </AnnotationSelectionProvider>
313
- </SelectionSetProvider>
328
+ </MaybeSelectionSetProvider>
314
329
  );
315
330
  }
316
331
 
@@ -1020,103 +1035,18 @@ function CanvasRouter({
1020
1035
  annotSel.clear();
1021
1036
  setHoverEl(null);
1022
1037
  },
1023
- onDropComment: ({ clientX, clientY }) => {
1024
- // First try deep mode preferred when the user clicks exactly on
1025
- // a stamped element. When the deep hit lands on an element with
1026
- // `pointer-events: none` (decorative <svg> children, overlay icons),
1027
- // elementFromPoint propagates past it and `resolveHoverTarget`
1028
- // returns null because the next-closest hit is `.dc-artboard-body`
1029
- // itself.
1030
- let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
1031
- if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
1032
- // Phase 6 fallback — when both resolveHoverTarget passes bail (the
1033
- // `hit === bodyEl` early-exit triggers on `pointer-events: none`
1034
- // decorations), enumerate every element under the click point and
1035
- // climb the first one that has `data-cd-id`. This is how clicks on
1036
- // SVG logos / icon glyphs land on the actual stamped wrapper.
1037
- if (!target && typeof document.elementsFromPoint === 'function') {
1038
- const stack = document.elementsFromPoint(clientX, clientY);
1039
- for (const candidate of stack) {
1040
- const stamped = (candidate as Element).closest?.('[data-cd-id]') as HTMLElement | null;
1041
- if (!stamped) continue;
1042
- if (!stamped.closest('.dc-artboard-body')) continue;
1043
- const artboardEl = stamped.closest('[data-dc-screen]');
1044
- target = {
1045
- el: stamped,
1046
- cdId: stamped.getAttribute('data-cd-id'),
1047
- artboardId: artboardEl?.getAttribute('data-dc-screen') ?? null,
1048
- };
1049
- break;
1050
- }
1051
- }
1052
- if (!target) {
1053
- // Floating comment fallback — no element anchor, just a click
1054
- // point. The overlay still renders a pin at the stored bounds.
1055
- if (typeof window === 'undefined' || typeof document === 'undefined') return;
1056
- const floatingSel: Selection = {
1057
- file: deriveFile(),
1058
- id: undefined,
1059
- selector: '',
1060
- artboardId: null,
1061
- tag: '',
1062
- classes: '',
1063
- text: '',
1064
- dom_path: [],
1065
- bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
1066
- html: '',
1067
- };
1068
- try {
1069
- document.dispatchEvent(
1070
- new CustomEvent('cm:open-composer', {
1071
- detail: { selection: floatingSel, clientX, clientY },
1072
- })
1073
- );
1074
- } catch {
1075
- /* ignore */
1076
- }
1077
- try {
1078
- window.parent.postMessage({ dgn: 'comment-compose', selection: floatingSel }, '*');
1079
- } catch {
1080
- /* parent detached */
1081
- }
1082
- return;
1083
- }
1084
- const sel = hoverTargetToSelection(target);
1085
- // Commit the target to the selection set so the halo persists while
1086
- // the composer is open. The user clears by:
1087
- // - submit / cancel on the composer (overlay dispatches force-clear)
1088
- // - pressing Esc inside the canvas (router's onEscape → clear)
1089
- // - clicking another element in comment mode (this handler runs
1090
- // again and replaces)
1091
- selSet.replace(sel);
1092
- if (typeof window === 'undefined' || typeof document === 'undefined') return;
1093
- // Phase 6 — open the in-place composer inside the iframe at the click
1094
- // point. Custom event is iframe-local so the overlay can subscribe
1095
- // without round-tripping through the parent shell.
1096
- try {
1097
- document.dispatchEvent(
1098
- new CustomEvent('cm:open-composer', {
1099
- detail: { selection: sel, clientX, clientY },
1100
- })
1101
- );
1102
- } catch {
1103
- /* CustomEvent absent — fall through to legacy parent path */
1104
- }
1105
- // Still post to parent for back-compat with any legacy `.html` mocks
1106
- // whose inspector script consumes `comment-compose`.
1107
- try {
1108
- window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
1109
- } catch {
1110
- /* parent detached */
1111
- }
1112
- },
1038
+ // onDropComment is intentionally absent the comment drop is owned by
1039
+ // the shell-owned comment mount layer's router (canvas-comment-mount.tsx),
1040
+ // which sits as an ancestor capture-listener over this canvas. In comment
1041
+ // mode that ancestor claims `drop-comment` before this router sees it.
1113
1042
  },
1114
1043
  });
1115
1044
 
1116
1045
  return (
1117
1046
  <>
1118
1047
  {children}
1119
- <CommentsOverlay />
1048
+ {/* CommentsOverlay is mounted ONCE by the shell-owned comment mount layer
1049
+ (canvas-comment-mount.tsx), not here — single instance per surface. */}
1120
1050
  <AnnotationsLayer />
1121
1051
  <ToolPalette />
1122
1052
  <ArtboardMarqueeOverlay />
@@ -1669,137 +1599,6 @@ function classifyContextKind(target: HoverTarget | null): ContextTargetKind {
1669
1599
  return 'world';
1670
1600
  }
1671
1601
 
1672
- function hoverTargetToSelection(target: HoverTarget): Selection {
1673
- const el = target.el;
1674
- const rect =
1675
- el && (el as HTMLElement).getBoundingClientRect
1676
- ? (el as HTMLElement).getBoundingClientRect()
1677
- : null;
1678
- // `cdId` is the hit element's OWN data-cd-id (deep mode); resolver never
1679
- // climbs to an ancestor. Falls back to cssPath of the hit when no stable
1680
- // anchor exists.
1681
- const cdId = target.cdId;
1682
- // Selector resolution order:
1683
- // 1. data-cd-id anchor — stable pipeline-stamped id (preferred).
1684
- // 2. data-dc-screen — chrome click promoted to whole-artboard select
1685
- // (T24.5 G8 multi-artboard gesture).
1686
- // 3. cssPath of the hit — last-resort path string.
1687
- const selector = cdId
1688
- ? `[data-cd-id="${cdId}"]`
1689
- : !cdId && target.artboardId
1690
- ? `[data-dc-screen="${target.artboardId}"]`
1691
- : cssPath(el);
1692
- return {
1693
- file: typeof window !== 'undefined' ? deriveFile() : undefined,
1694
- id: cdId ?? undefined,
1695
- selector,
1696
- artboardId: target.artboardId,
1697
- tag: el?.tagName.toLowerCase() ?? '',
1698
- classes: realClasses(el),
1699
- text: shortText(el, 240),
1700
- dom_path: domPath(el),
1701
- bounds: rect
1702
- ? {
1703
- x: Math.round(rect.left),
1704
- y: Math.round(rect.top),
1705
- w: Math.round(rect.width),
1706
- h: Math.round(rect.height),
1707
- }
1708
- : null,
1709
- html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
1710
- };
1711
- }
1712
-
1713
- function deriveFile(): string | undefined {
1714
- try {
1715
- const p = window.location.pathname;
1716
- if (p === '/_canvas-shell.html' || p === '/_canvas-shell') {
1717
- const qs = new URLSearchParams(window.location.search);
1718
- const canvas = qs.get('canvas') ?? '';
1719
- const designRel = (qs.get('designRel') ?? '.design').replace(/^\/+|\/+$/g, '');
1720
- return `${designRel}/${canvas}`;
1721
- }
1722
- return decodeURIComponent(p).replace(/^\//, '');
1723
- } catch {
1724
- return undefined;
1725
- }
1726
- }
1727
-
1728
- function realClasses(el: Element | null): string {
1729
- if (!el) return '';
1730
- return (el.getAttribute('class') ?? '')
1731
- .trim()
1732
- .split(/\s+/)
1733
- .filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
1734
- .join(' ');
1735
- }
1736
-
1737
- function shortText(el: Element | null, max: number): string {
1738
- if (!el) return '';
1739
- const t = ((el as HTMLElement).innerText || el.textContent || '').replace(/\s+/g, ' ').trim();
1740
- return t.length > max ? `${t.slice(0, max - 1)}…` : t;
1741
- }
1742
-
1743
- function cssPath(el: Element | null): string {
1744
- if (!el) return '';
1745
- const path: string[] = [];
1746
- let cur: Element | null = el;
1747
- while (cur && cur.nodeType === 1 && path.length < 8) {
1748
- const dscEl = cur.getAttribute?.('data-dc-element');
1749
- if (dscEl) {
1750
- path.unshift(`[data-dc-element="${dscEl}"]`);
1751
- break;
1752
- }
1753
- const dscSc = cur.getAttribute?.('data-dc-screen');
1754
- if (dscSc) {
1755
- path.unshift(`[data-dc-screen="${dscSc}"]`);
1756
- break;
1757
- }
1758
- let sel = cur.nodeName.toLowerCase();
1759
- if (cur.id) {
1760
- sel = `#${cur.id}`;
1761
- path.unshift(sel);
1762
- break;
1763
- }
1764
- const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
1765
- if (cls.length) sel += `.${cls.join('.')}`;
1766
- let sib = 1;
1767
- let n: Element | null = cur.previousElementSibling;
1768
- while (n) {
1769
- sib++;
1770
- n = n.previousElementSibling;
1771
- }
1772
- sel += `:nth-child(${sib})`;
1773
- path.unshift(sel);
1774
- cur = cur.parentElement;
1775
- }
1776
- return path.join(' > ');
1777
- }
1778
-
1779
- function domPath(el: Element | null): string[] {
1780
- const hops: string[] = [];
1781
- let cur = el;
1782
- while (cur && cur.nodeType === 1 && hops.length < 8) {
1783
- let label = cur.nodeName.toLowerCase();
1784
- const dEl = cur.getAttribute?.('data-dc-element');
1785
- const dSc = cur.getAttribute?.('data-dc-screen');
1786
- if (dEl) label += `[data-dc-element="${dEl}"]`;
1787
- else if (dSc) label += `[data-dc-screen="${dSc}"]`;
1788
- else if (cur.id) label += `#${cur.id}`;
1789
- const cls = realClasses(cur).split(/\s+/).filter(Boolean).slice(0, 2);
1790
- if (cls.length && !dEl && !dSc) label += `.${cls.join('.')}`;
1791
- hops.unshift(label);
1792
- cur = cur.parentElement;
1793
- }
1794
- return hops;
1795
- }
1796
-
1797
- function cssEscape(s: string): string {
1798
- // Minimal CSS.escape polyfill — only handles chars actually present in
1799
- // pipeline-stamped IDs (alphanumerics + `-` + `_`).
1800
- return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
1801
- }
1802
-
1803
1602
  function safeQuery(selector: string): Element | null {
1804
1603
  try {
1805
1604
  return document.querySelector(selector);
@@ -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,17 +77,31 @@ 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
106
  const ds = specMatch[1];
93
107
  params.set('layout', `system/${ds}/preview/_layout.css`);
@@ -418,7 +432,7 @@ function CanvasRow({ primary, sidecars, depth, kind, activePath, onOpen, openCou
418
432
  );
419
433
  }
420
434
 
421
- function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, onOpenSystem }) {
435
+ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showHidden, search, dsFolders, activeDsName, onOpenSystem }) {
422
436
  const dirs = Object.keys(node).filter(k => k !== '_files').sort();
423
437
  const files = node._files || [];
424
438
  // VS Code-style sidecar grouping. Canvas (`.tsx`/`.html`) becomes the primary
@@ -480,6 +494,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
480
494
  kind={kind}
481
495
  showHidden={showHidden}
482
496
  search={search}
497
+ activeDsName={activeDsName}
483
498
  onOpenSystem={onOpenSystem}
484
499
  />
485
500
  );
@@ -491,7 +506,7 @@ function Tree({ node, activePath, onOpen, commentsByFile, depth = 1, kind, showH
491
506
  dsName={dsMatch.name}
492
507
  depth={depth}
493
508
  defaultOpen={true}
494
- active={activePath === SYSTEM_TAB}
509
+ active={activePath === SYSTEM_TAB && dsMatch.name === activeDsName}
495
510
  onOpenSystem={onOpenSystem}
496
511
  >
497
512
  {childTree}
@@ -529,7 +544,7 @@ function sectionMetaFor(g) {
529
544
  return { title: g.label.toUpperCase(), pillFromCount: true };
530
545
  }
531
546
 
532
- function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
547
+ function Sidebar({ groups, activePath, activeDsName, onOpen, onOpenSystem, wsConnected, search, setSearch, commentsByFile, showHidden, sectionsExpanded, onToggleSection }) {
533
548
  const filteredGroups = useMemo(() => {
534
549
  if (!search) return groups;
535
550
  return groups.map(g => ({ ...g, tree: filterTree(g.tree, search), filtered: !!search }));
@@ -624,6 +639,7 @@ function Sidebar({ groups, activePath, onOpen, onOpenSystem, wsConnected, search
624
639
  showHidden={showHidden}
625
640
  search={search}
626
641
  dsFolders={g.dsFolders}
642
+ activeDsName={activeDsName}
627
643
  onOpenSystem={isDs ? onOpenSystem : undefined}
628
644
  />
629
645
  ) : (
@@ -1294,7 +1310,7 @@ function Gallery({ title, items, onOpen, kind, cfg }) {
1294
1310
  {items.map(p => (
1295
1311
  <article key={p.path} className="sv-preview-card" onClick={() => onOpen(p.path)}>
1296
1312
  <div className="sv-preview-frame">
1297
- <iframe src={canvasUrl(p.path, cfg)} title={p.label} scrolling="no" />
1313
+ <iframe src={canvasUrl(p.path, cfg, { thumbnail: true })} title={p.label} scrolling="no" />
1298
1314
  </div>
1299
1315
  <div className="sv-preview-foot">
1300
1316
  <strong>{p.label}</strong>
@@ -1707,8 +1723,13 @@ function App() {
1707
1723
  setFocusedCommentId(null);
1708
1724
  }, []);
1709
1725
 
1710
- const openSystem = useCallback(() => {
1711
- if (!systemData) loadSystemData();
1726
+ const openSystem = useCallback((dsName) => {
1727
+ // DsFolderRow passes the clicked DS name → scope the System view to it so
1728
+ // each folder shows its own tokens + previews. The no-arg callers (menubar,
1729
+ // keyboard reopen) only load default data on first open.
1730
+ const ds = typeof dsName === 'string' ? dsName : undefined;
1731
+ if (ds) loadSystemData(ds);
1732
+ else if (!systemData) loadSystemData();
1712
1733
  openTab(SYSTEM_TAB);
1713
1734
  }, [systemData, loadSystemData, openTab]);
1714
1735
 
@@ -2075,6 +2096,7 @@ function App() {
2075
2096
  <Sidebar
2076
2097
  groups={groups}
2077
2098
  activePath={activePath}
2099
+ activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
2078
2100
  onOpen={openTab}
2079
2101
  onOpenSystem={openSystem}
2080
2102
  wsConnected={wsConnected}
@@ -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
+ }
@@ -6,6 +6,9 @@
6
6
  // call into (Phase 8 Task 7) to force-snapshot every dirty room before a reload
7
7
  // prompt — see DDR-051 §3.
8
8
 
9
+ import type { Awareness } from 'y-protocols/awareness';
10
+
11
+ import { bridgeAwareness } from './awareness-bridge.ts';
9
12
  import { Y_TYPES } from './persistence.ts';
10
13
  import type { Room, RoomCallbacks } from './room.ts';
11
14
  import { createRoom } from './room.ts';
@@ -34,6 +37,16 @@ export interface Registry {
34
37
  * updated stroke set without waiting for a cold-open re-seed.
35
38
  */
36
39
  syncRoomFromAnnotations(slug: string, svg: string): void;
40
+ /**
41
+ * Phase 9 Task 5 — attach the hub-side Awareness (from a sync provider) for
42
+ * a slug so the Room's Awareness (browser peers) is bridged bidirectionally
43
+ * to the hub. While attached, cursors / selections / viewport relay
44
+ * cross-machine through Hocuspocus. Idempotent per slug; returns a detach
45
+ * fn. If a room is already live the bridge wires immediately, otherwise it
46
+ * wires when the room is next created. Awareness is ephemeral — this writes
47
+ * no files (see awareness-bridge.ts on why F14 is untouched).
48
+ */
49
+ attachHubAwareness(slug: string, awareness: Awareness): () => void;
37
50
  /** Flush every dirty room synchronously. DDR-051 branch-switch path. */
38
51
  flushAll(): Promise<void>;
39
52
  /** Tear down everything (e.g. on server shutdown). */
@@ -46,16 +59,47 @@ export interface Registry {
46
59
 
47
60
  export function createRegistry(callbacks: RoomCallbacks): Registry {
48
61
  const rooms = new Map<string, Room>();
62
+ // Hub-side Awareness per slug (lives as long as the sync provider). Rooms
63
+ // churn as browser tabs come and go; the bridge is re-wired each time a room
64
+ // is (re)created for a slug that has an attached hub Awareness.
65
+ const hubAwareness = new Map<string, Awareness>();
66
+ const bridges = new Map<string, () => void>();
67
+
68
+ function wireBridge(slug: string, room: Room): void {
69
+ if (bridges.has(slug)) return;
70
+ const hub = hubAwareness.get(slug);
71
+ if (!hub) return;
72
+ bridges.set(slug, bridgeAwareness(room.awareness, hub));
73
+ }
74
+
75
+ function teardownBridge(slug: string): void {
76
+ const detach = bridges.get(slug);
77
+ if (detach) {
78
+ detach();
79
+ bridges.delete(slug);
80
+ }
81
+ }
49
82
 
50
83
  function get(slug: string): Room {
51
84
  let room = rooms.get(slug);
52
85
  if (!room) {
53
86
  room = createRoom(slug, callbacks);
54
87
  rooms.set(slug, room);
88
+ wireBridge(slug, room);
55
89
  }
56
90
  return room;
57
91
  }
58
92
 
93
+ function attachHubAwareness(slug: string, awareness: Awareness): () => void {
94
+ hubAwareness.set(slug, awareness);
95
+ const room = rooms.get(slug);
96
+ if (room) wireBridge(slug, room);
97
+ return () => {
98
+ teardownBridge(slug);
99
+ if (hubAwareness.get(slug) === awareness) hubAwareness.delete(slug);
100
+ };
101
+ }
102
+
59
103
  function peek(slug: string): Room | null {
60
104
  return rooms.get(slug) ?? null;
61
105
  }
@@ -87,11 +131,17 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
87
131
  const room = rooms.get(slug);
88
132
  if (!room) return;
89
133
  if (room.size() > 0) return; // still active, leave it
134
+ // Tear the bridge down before room.destroy() runs awareness.destroy() —
135
+ // a late relay must not fire against a dead Awareness. The hub Awareness
136
+ // stays registered, so a reconnecting browser re-wires via get().
137
+ teardownBridge(slug);
90
138
  rooms.delete(slug);
91
139
  await room.destroy();
92
140
  }
93
141
 
94
142
  async function destroyAll(): Promise<void> {
143
+ for (const slug of Array.from(bridges.keys())) teardownBridge(slug);
144
+ hubAwareness.clear();
95
145
  const all = Array.from(rooms.values());
96
146
  rooms.clear();
97
147
  await Promise.all(all.map((r) => r.destroy()));
@@ -102,6 +152,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
102
152
  peek,
103
153
  syncRoomFromComments,
104
154
  syncRoomFromAnnotations,
155
+ attachHubAwareness,
105
156
  flushAll,
106
157
  destroyAll,
107
158
  drop,
@@ -180,6 +180,26 @@
180
180
  "type": ["string", "null"],
181
181
  "description": "Name of the DS used when a canvas's .meta.json has no 'designSystem' field. For single-DS projects, this is the only DS. For multi-DS, pick the most common.",
182
182
  "default": null
183
+ },
184
+ "linkedHub": {
185
+ "type": "object",
186
+ "description": "Pairs this repo with a Maude hub for cross-machine file sync (Phase 9 Task 3+4). Written by 'maude design link <url> --token <hex>'. Token NEVER lives in this committed config — it's stored per-machine in ~/.config/maude/hubs.json. When present, 'maude design serve' starts the bidirectional Yjs↔fs sync agent (Task 4).",
187
+ "required": ["url", "linkedAt"],
188
+ "properties": {
189
+ "url": {
190
+ "type": "string",
191
+ "description": "Hub base URL (normalized — trailing slash trimmed, scheme + host lowercased). The sync agent constructs ws(s)://<host> from this for the HocuspocusProvider connection."
192
+ },
193
+ "linkedAt": {
194
+ "type": "number",
195
+ "description": "Unix-ms timestamp from when 'maude design link' was first run on this repo."
196
+ },
197
+ "adopt": {
198
+ "type": "boolean",
199
+ "description": "When true, the first sync after boot pushes local disk state up to the hub unconditionally (use case: bootstrapping the hub from a populated repo, or hub-was-wiped recovery). Cleared after first successful adopt."
200
+ }
201
+ },
202
+ "additionalProperties": false
183
203
  }
184
204
  }
185
205
  }
@@ -25,6 +25,12 @@ export interface DesignSystemEntry {
25
25
  newComponentDir?: string;
26
26
  }
27
27
 
28
+ export interface LinkedHub {
29
+ url: string;
30
+ linkedAt: number;
31
+ adopt?: boolean;
32
+ }
33
+
28
34
  export interface DevServerConfig {
29
35
  name: string;
30
36
  projectLabel: string | null;
@@ -39,6 +45,7 @@ export interface DevServerConfig {
39
45
  handoffTargets: unknown[];
40
46
  newCanvasDir: string;
41
47
  newComponentDir: string;
48
+ linkedHub?: LinkedHub;
42
49
  _source: ConfigSource;
43
50
  }
44
51