@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.
- package/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +8 -8
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- 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 {
|
|
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
|
-
<
|
|
322
|
+
<MaybeSelectionSetProvider>
|
|
308
323
|
<AnnotationSelectionProvider>
|
|
309
324
|
<AnnotationsVisibilityProvider>
|
|
310
325
|
<CanvasCore hostRef={hostRef}>{children}</CanvasCore>
|
|
311
326
|
</AnnotationsVisibilityProvider>
|
|
312
327
|
</AnnotationSelectionProvider>
|
|
313
|
-
</
|
|
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
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
81
|
-
// the
|
|
82
|
-
//
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|