@1agh/maude 0.20.0 → 0.22.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 (71) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +361 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +245 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
  43. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
  44. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
  45. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
  46. package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
  47. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  48. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  49. package/plugins/design/dev-server/http.ts +64 -1
  50. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  51. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  52. package/plugins/design/dev-server/runtime-bundle.ts +19 -0
  53. package/plugins/design/dev-server/server.ts +78 -11
  54. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  55. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  56. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  57. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  58. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  59. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  60. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  61. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  62. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  63. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  64. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  65. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  66. package/plugins/design/dev-server/use-collab.tsx +478 -0
  67. package/plugins/design/dev-server/ws.ts +123 -7
  68. package/plugins/design/templates/_shell.html +37 -1
  69. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  70. package/plugins/flow/dependencies.json +143 -0
  71. package/plugins/flow/dependencies.schema.json +107 -0
@@ -0,0 +1,215 @@
1
+ // Per-canvas Y.Doc room — registry, connection set, persistence schedule.
2
+ //
3
+ // One Room per canvas slug. Holds the Y.Doc + Awareness + the set of connected
4
+ // peers, broadcasts updates, debounces JSON snapshot writes to disk
5
+ // (DDR-051 — JSON is canonical, `.ydoc.bin` is a cache).
6
+ //
7
+ // The Room is transport-agnostic — it accepts CollabConn objects from the WS
8
+ // layer (ws.ts). Tests construct rooms directly without booting Bun.serve.
9
+
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
14
+ import * as Y from 'yjs';
15
+
16
+ import {
17
+ type CollabConn,
18
+ encodeAwarenessFrame,
19
+ encodeHandshake,
20
+ encodeSyncUpdate,
21
+ handleMessage,
22
+ } from './protocol.ts';
23
+
24
+ export interface RoomConn extends CollabConn {
25
+ /** Stable id for this connection (UUID from ws.data.id). */
26
+ id: string;
27
+ }
28
+
29
+ export interface RoomCallbacks {
30
+ /**
31
+ * Persist the JSON projection of `doc` for this slug. Called debounced — the
32
+ * Room batches Y.Doc updates within 800 ms windows and fires this once at
33
+ * quiescence. Implementations MUST be idempotent.
34
+ */
35
+ persistJson(slug: string, doc: Y.Doc): Promise<void> | void;
36
+
37
+ /**
38
+ * Persist the binary Y.Doc state cache (`.ydoc.bin`). Called on the same
39
+ * debounce schedule as persistJson. Separate hook so tests can stub one
40
+ * without the other.
41
+ */
42
+ persistBinary(slug: string, state: Uint8Array): Promise<void> | void;
43
+
44
+ /**
45
+ * Seed the Y.Doc from on-disk persistence — `.ydoc.bin` first, JSON
46
+ * snapshots second. Called once at room construction. MUST be synchronous
47
+ * with respect to the Y.Doc passed in so the first peer's handshake sees
48
+ * the seeded state.
49
+ */
50
+ seed(slug: string, doc: Y.Doc): Promise<void> | void;
51
+ }
52
+
53
+ export interface Room {
54
+ readonly slug: string;
55
+ readonly doc: Y.Doc;
56
+ readonly awareness: Awareness;
57
+ connect(conn: RoomConn): Promise<void>;
58
+ disconnect(conn: RoomConn): void;
59
+ receive(conn: RoomConn, payload: Uint8Array): void;
60
+ /** Force the debounced flush to fire now. DDR-051 §3 (branch-switch). */
61
+ flush(): Promise<void>;
62
+ /** Tear down — clears timers + removes awareness. */
63
+ destroy(): Promise<void>;
64
+ /** Test/inspection: connection count. */
65
+ size(): number;
66
+ }
67
+
68
+ const DEBOUNCE_MS = 800;
69
+
70
+ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
71
+ const doc = new Y.Doc();
72
+ // Awareness needs a clientID; reuse the Y.Doc's so peers can attribute updates.
73
+ const awareness = new Awareness(doc);
74
+
75
+ const conns = new Map<string, RoomConn>();
76
+ let dirty = false;
77
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
78
+ let destroyed = false;
79
+ let seedPromise: Promise<void> | null = null;
80
+
81
+ function scheduleFlush() {
82
+ dirty = true;
83
+ if (flushTimer) clearTimeout(flushTimer);
84
+ flushTimer = setTimeout(() => {
85
+ flushTimer = null;
86
+ void flush();
87
+ }, DEBOUNCE_MS);
88
+ }
89
+
90
+ async function flush(): Promise<void> {
91
+ if (!dirty || destroyed) return;
92
+ dirty = false;
93
+ if (flushTimer) {
94
+ clearTimeout(flushTimer);
95
+ flushTimer = null;
96
+ }
97
+ try {
98
+ await callbacks.persistJson(slug, doc);
99
+ await callbacks.persistBinary(slug, Y.encodeStateAsUpdate(doc));
100
+ } catch (err) {
101
+ // Re-arm so the next mutation retries. Loud-log; persistence loss is a
102
+ // user-visible bug if it stays silent.
103
+ dirty = true;
104
+ console.error(`[collab/${slug}] flush failed:`, err);
105
+ }
106
+ }
107
+
108
+ function broadcast(payload: Uint8Array, except?: RoomConn) {
109
+ for (const c of conns.values()) {
110
+ if (except && c.id === except.id) continue;
111
+ try {
112
+ c.send(payload);
113
+ } catch {
114
+ /* close handler will clean up dead sockets */
115
+ }
116
+ }
117
+ }
118
+
119
+ // Y.Doc update -> broadcast to all peers + schedule debounced flush.
120
+ doc.on('update', (update: Uint8Array, origin: unknown) => {
121
+ if (destroyed) return;
122
+ // Don't echo back to the origin — they already have it; reduces noise.
123
+ const except =
124
+ origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
125
+ broadcast(encodeSyncUpdate(update), except);
126
+ scheduleFlush();
127
+ });
128
+
129
+ // Awareness changes -> broadcast (NOT persisted; ephemeral by design).
130
+ awareness.on(
131
+ 'update',
132
+ (
133
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
134
+ origin: unknown
135
+ ) => {
136
+ if (destroyed) return;
137
+ const changed = added.concat(updated, removed);
138
+ if (changed.length === 0) return;
139
+ const except =
140
+ origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
141
+ broadcast(encodeAwarenessFrame(awareness, changed), except);
142
+ }
143
+ );
144
+
145
+ function getOrStartSeed(): Promise<void> {
146
+ if (!seedPromise) {
147
+ const result = callbacks.seed(slug, doc);
148
+ seedPromise = Promise.resolve(result).catch((err) => {
149
+ console.error(`[collab/${slug}] seed failed:`, err);
150
+ });
151
+ }
152
+ return seedPromise;
153
+ }
154
+
155
+ async function connect(conn: RoomConn): Promise<void> {
156
+ await getOrStartSeed();
157
+ conns.set(conn.id, conn);
158
+ const frames = encodeHandshake(doc, awareness);
159
+ for (const frame of frames) conn.send(frame);
160
+ }
161
+
162
+ function disconnect(conn: RoomConn): void {
163
+ conns.delete(conn.id);
164
+ // Awareness states keyed by the conn token; clean them up so other peers'
165
+ // cursor renderers can drop the avatar.
166
+ const states = awareness.getStates();
167
+ const stale: number[] = [];
168
+ for (const clientId of states.keys()) {
169
+ // Bridge: our Awareness state setters tag the state with `__connId`
170
+ // matching conn.id; remove states whose owning conn just left.
171
+ const state = states.get(clientId) as { __connId?: string } | undefined;
172
+ if (state && state.__connId === conn.id) stale.push(clientId);
173
+ }
174
+ if (stale.length) removeAwarenessStates(awareness, stale, conn);
175
+ }
176
+
177
+ function receive(conn: RoomConn, payload: Uint8Array): void {
178
+ const reply = handleMessage(payload, doc, awareness, conn);
179
+ if (reply) conn.send(reply);
180
+ }
181
+
182
+ async function destroy(): Promise<void> {
183
+ destroyed = true;
184
+ if (flushTimer) {
185
+ clearTimeout(flushTimer);
186
+ flushTimer = null;
187
+ }
188
+ if (dirty) await flush();
189
+ conns.clear();
190
+ awareness.destroy();
191
+ doc.destroy();
192
+ }
193
+
194
+ return {
195
+ slug,
196
+ doc,
197
+ awareness,
198
+ connect,
199
+ disconnect,
200
+ receive,
201
+ flush,
202
+ destroy,
203
+ size: () => conns.size,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Ensure `<designRoot>/_state/` exists. Idempotent; safe to call on every
209
+ * room construction (mkdir recursive returns the path or noop).
210
+ */
211
+ export function ensureStateDir(designRoot: string): string {
212
+ const dir = path.join(designRoot, '_state');
213
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
214
+ return dir;
215
+ }
@@ -34,6 +34,7 @@
34
34
 
35
35
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
36
36
 
37
+ import { useCollab } from './use-collab.tsx';
37
38
  import { useSelectionSetOptional } from './use-selection-set.tsx';
38
39
 
39
40
  // ─────────────────────────────────────────────────────────────────────────────
@@ -230,6 +231,34 @@ export function CommentsOverlay(): React.ReactNode {
230
231
  const commentsRef = useRef<OverlayComment[]>(comments);
231
232
  commentsRef.current = comments;
232
233
 
234
+ // Phase 8 Task 3 — when a collab room is connected, the Y.Array of comments
235
+ // is the live source of truth. observe() fires on every remote mutation
236
+ // (added pins from another tab, resolved-from-inspector via the registry
237
+ // bridge, etc.) and on the local seed. Both paths converge on the same
238
+ // JSON projection — last-write-wins between Y.Array and postMessage is
239
+ // safe because they carry identical content; the Y.Array path just
240
+ // reaches us first (no 800 ms debounce delay).
241
+ const collab = useCollab();
242
+ useEffect(() => {
243
+ if (!collab) return;
244
+ const arr = collab.doc.getArray<OverlayComment>('comments');
245
+ const sync = () => {
246
+ // toArray() snapshot the current Y.Array into a plain JS list.
247
+ setComments(arr.toArray() as OverlayComment[]);
248
+ };
249
+ // Initial fill — covers the case where Y.Doc was already seeded by the
250
+ // time this overlay mounted.
251
+ if (arr.length > 0) sync();
252
+ arr.observe(sync);
253
+ return () => {
254
+ try {
255
+ arr.unobserve(sync);
256
+ } catch {
257
+ /* doc destroyed before unmount — observer already gone */
258
+ }
259
+ };
260
+ }, [collab]);
261
+
233
262
  // Listen for the shell's broadcast channels. Schema matches the legacy
234
263
  // overlay so the shell-side glue in client/app.jsx (~line 1672) keeps
235
264
  // working without modification.
@@ -68,7 +68,7 @@ const CTX_TOOLBAR_CSS = `
68
68
  transition: background-color 80ms linear;
69
69
  }
70
70
  .dc-elem-ctx-tb button:hover {
71
- background: color-mix(in oklab, var(--accent, #d63b1f) 8%, transparent);
71
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
72
72
  }
73
73
  .dc-elem-ctx-tb .dc-elem-ctx-count {
74
74
  padding: 4px 8px 4px 10px;
@@ -0,0 +1,296 @@
1
+ /**
2
+ * @file cursors-overlay.tsx — foreign peer cursors rendered on the canvas
3
+ * @scope plugins/design/dev-server/cursors-overlay.tsx
4
+ * @purpose Renders one colored SVG arrow + name label per foreign peer at
5
+ * their published world coords, transformed through the local
6
+ * viewport so the cursor anchors to canvas content (not to my
7
+ * screen) — the standard Excalidraw / tldraw multiplayer model.
8
+ *
9
+ * Render shape:
10
+ * <div position:fixed top:0 left:0 pointer-events:none>
11
+ * <Cursor for each foreign peer />
12
+ * </div>
13
+ *
14
+ * Each cursor is positioned via `transform: translate(screenX px, screenY px)`
15
+ * where (screenX, screenY) = world * viewport.zoom + viewport.{x,y}. No
16
+ * react-spring / lerp in this ship (just last-known-position render); the
17
+ * 30 Hz publish cadence keeps it smooth enough for the multiplayer demo.
18
+ */
19
+
20
+ import { memo, useEffect, useState } from 'react';
21
+
22
+ import { useViewportControllerContext } from './canvas-lib.tsx';
23
+ import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
24
+
25
+ const CURSOR_CSS = `
26
+ .dc-cursor-overlay {
27
+ position: fixed;
28
+ top: 0;
29
+ left: 0;
30
+ width: 100%;
31
+ height: 100%;
32
+ pointer-events: none;
33
+ z-index: 10000;
34
+ }
35
+ .dc-cursor {
36
+ position: absolute;
37
+ top: 0;
38
+ left: 0;
39
+ pointer-events: none;
40
+ will-change: transform;
41
+ /* The arrow tip is the (0,0) anchor of the SVG; the label hangs to the
42
+ bottom-right so it never occludes the tip itself. */
43
+ }
44
+ .dc-cursor-arrow {
45
+ display: block;
46
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.25));
47
+ }
48
+ .dc-cursor-label {
49
+ position: absolute;
50
+ top: 18px;
51
+ left: 14px;
52
+ padding: 2px 6px;
53
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
54
+ font-weight: 500;
55
+ font-size: 11px;
56
+ line-height: 1.2;
57
+ color: var(--maude-hud-accent-fg, #fff);
58
+ border-radius: var(--radius-sm, 2px);
59
+ white-space: nowrap;
60
+ max-width: 180px;
61
+ overflow: hidden;
62
+ text-overflow: ellipsis;
63
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
64
+ }
65
+ .dc-peer-selection {
66
+ position: absolute;
67
+ top: 0;
68
+ left: 0;
69
+ pointer-events: none;
70
+ border: 2px solid;
71
+ border-radius: var(--radius-sm, 2px);
72
+ box-sizing: border-box;
73
+ will-change: transform, width, height;
74
+ }
75
+ .dc-peer-selection__label {
76
+ position: absolute;
77
+ top: -18px;
78
+ left: -2px;
79
+ padding: 1px 5px;
80
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
81
+ font-weight: 600;
82
+ font-size: 10px;
83
+ line-height: 1.3;
84
+ color: var(--maude-hud-accent-fg, #fff);
85
+ border-radius: var(--radius-sm, 2px) var(--radius-sm, 2px) 0 0;
86
+ white-space: nowrap;
87
+ }
88
+ @media (prefers-reduced-motion: reduce) {
89
+ .dc-cursor { transition: none !important; }
90
+ }
91
+ `.trim();
92
+
93
+ function ensureCursorStyles(): void {
94
+ if (typeof document === 'undefined') return;
95
+ if (document.getElementById('dc-cursor-overlay-css')) return;
96
+ const s = document.createElement('style');
97
+ s.id = 'dc-cursor-overlay-css';
98
+ s.textContent = CURSOR_CSS;
99
+ document.head.appendChild(s);
100
+ }
101
+
102
+ interface ViewportSnapshot {
103
+ x: number;
104
+ y: number;
105
+ zoom: number;
106
+ }
107
+
108
+ interface CursorProps {
109
+ peer: ForeignAwareness;
110
+ viewport: ViewportSnapshot;
111
+ }
112
+
113
+ const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Element | null {
114
+ if (!peer.cursor) return null;
115
+ // world → screen: screen = world * zoom + viewport.{x,y}
116
+ const screenX = peer.cursor.x * viewport.zoom + viewport.x;
117
+ const screenY = peer.cursor.y * viewport.zoom + viewport.y;
118
+ return (
119
+ <div className="dc-cursor" style={{ transform: `translate(${screenX}px, ${screenY}px)` }}>
120
+ <svg
121
+ className="dc-cursor-arrow"
122
+ width="20"
123
+ height="22"
124
+ viewBox="0 0 20 22"
125
+ aria-hidden="true"
126
+ >
127
+ <path
128
+ d="M 2 2 L 2 18 L 6 14 L 9 21 L 12 20 L 9 13 L 14 13 Z"
129
+ fill={peer.color}
130
+ stroke="#fff"
131
+ strokeWidth="1.2"
132
+ strokeLinejoin="round"
133
+ />
134
+ </svg>
135
+ <div className="dc-cursor-label" style={{ background: peer.color }}>
136
+ {peer.name}
137
+ </div>
138
+ </div>
139
+ );
140
+ });
141
+
142
+ /**
143
+ * Foreign-selection halos for stamped annotation strokes. Each peer publishes
144
+ * `annotationSelection: string[]` — stroke `data-id` values from Phase 5.
145
+ * We resolve each via `[data-id="<id>"]` and render an outlined rect at the
146
+ * element's current screen bounds. Re-runs every render so geometry changes
147
+ * (resize, move) stay in sync without manual re-publish.
148
+ */
149
+ interface PeerAnnotationSelectionProps {
150
+ peer: ForeignAwareness;
151
+ }
152
+
153
+ const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
154
+ peer,
155
+ }: PeerAnnotationSelectionProps): JSX.Element | null {
156
+ if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
157
+ if (typeof document === 'undefined') return null;
158
+ const rects: { id: string; x: number; y: number; w: number; h: number }[] = [];
159
+ for (const id of peer.annotationSelection) {
160
+ try {
161
+ const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
162
+ if (!el) continue;
163
+ const r = (el as Element).getBoundingClientRect();
164
+ if (r.width <= 0 || r.height <= 0) continue;
165
+ // Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it.
166
+ rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 });
167
+ } catch {
168
+ /* invalid id token — skip */
169
+ }
170
+ }
171
+ if (rects.length === 0) return null;
172
+ return (
173
+ <>
174
+ {rects.map((r, i) => (
175
+ <div
176
+ key={r.id}
177
+ className="dc-peer-selection"
178
+ style={{
179
+ transform: `translate(${r.x}px, ${r.y}px)`,
180
+ width: r.w,
181
+ height: r.h,
182
+ borderColor: peer.color,
183
+ }}
184
+ >
185
+ {i === 0 && (
186
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
187
+ {peer.name}
188
+ </div>
189
+ )}
190
+ </div>
191
+ ))}
192
+ </>
193
+ );
194
+ });
195
+
196
+ /**
197
+ * Foreign-selection halo for canvas-shell elements (cdId-based selSet).
198
+ * The peer publishes selection.cssPath; we re-resolve in the local DOM
199
+ * each render so pan / zoom / hydration changes don't desync. Falls back
200
+ * to the published `bounds` (screen-px at publish time) when cssPath
201
+ * can't be resolved locally.
202
+ */
203
+ interface PeerSelectionProps {
204
+ peer: ForeignAwareness;
205
+ }
206
+
207
+ const PeerSelection = memo(function PeerSelection({
208
+ peer,
209
+ }: PeerSelectionProps): JSX.Element | null {
210
+ if (!peer.selection) return null;
211
+ const { cssPath, bounds } = peer.selection;
212
+
213
+ let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null;
214
+ if (cssPath && typeof document !== 'undefined') {
215
+ try {
216
+ const el = document.querySelector(cssPath);
217
+ if (el && el instanceof Element) {
218
+ const r = el.getBoundingClientRect();
219
+ if (r.width > 0 && r.height > 0) {
220
+ rect = { x: r.left, y: r.top, w: r.width, h: r.height };
221
+ }
222
+ }
223
+ } catch {
224
+ /* invalid selector — fall through to published bounds */
225
+ }
226
+ }
227
+ if (!rect) return null;
228
+
229
+ return (
230
+ <div
231
+ className="dc-peer-selection"
232
+ style={{
233
+ transform: `translate(${rect.x}px, ${rect.y}px)`,
234
+ width: rect.w,
235
+ height: rect.h,
236
+ borderColor: peer.color,
237
+ }}
238
+ >
239
+ <div className="dc-peer-selection__label" style={{ background: peer.color }}>
240
+ {peer.name}
241
+ </div>
242
+ </div>
243
+ );
244
+ });
245
+
246
+ /**
247
+ * Subscribes to foreign awareness + the local viewport. Renders one Cursor per
248
+ * peer. Reads viewport from `useViewportControllerContext` — if the canvas
249
+ * doesn't expose a controller (rare edge case), falls back to identity.
250
+ */
251
+ export function CursorsOverlay(): JSX.Element {
252
+ ensureCursorStyles();
253
+ const peers = useForeignAwareness();
254
+ const controller = useViewportControllerContext();
255
+ const liveVp = controller?.viewport ?? { x: 0, y: 0, zoom: 1 };
256
+ // Take a snapshot per render — controller.viewport already changes on
257
+ // throttle, so this stays smooth enough at 30 Hz.
258
+ const [vp, setVp] = useState<ViewportSnapshot>(liveVp);
259
+ useEffect(() => {
260
+ setVp({ x: liveVp.x, y: liveVp.y, zoom: liveVp.zoom });
261
+ }, [liveVp.x, liveVp.y, liveVp.zoom]);
262
+
263
+ // Bump a render tick whenever annotations change so PeerAnnotationSelection
264
+ // re-resolves [data-id] bounds. Without this, a peer resizing a stroke
265
+ // would propagate via Y.Map → local annotations-layer re-render — but the
266
+ // sibling CursorsOverlay wouldn't re-render, so the annotation halo would
267
+ // sit on the OLD bounds until awareness independently changed.
268
+ const collab = useCollab();
269
+ const [, bumpAnnotTick] = useState(0);
270
+ useEffect(() => {
271
+ if (!collab) return;
272
+ const map = collab.doc.getMap('annotations');
273
+ const onChange = () => bumpAnnotTick((n) => n + 1);
274
+ map.observe(onChange);
275
+ return () => {
276
+ try {
277
+ map.unobserve(onChange);
278
+ } catch {
279
+ /* doc destroyed */
280
+ }
281
+ };
282
+ }, [collab]);
283
+ return (
284
+ <div className="dc-cursor-overlay" aria-hidden="true">
285
+ {peers.map((peer) => (
286
+ <PeerSelection key={`sel-${peer.clientID}`} peer={peer} />
287
+ ))}
288
+ {peers.map((peer) => (
289
+ <PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} />
290
+ ))}
291
+ {peers.map((peer) => (
292
+ <Cursor key={peer.clientID} peer={peer} viewport={vp} />
293
+ ))}
294
+ </div>
295
+ );
296
+ }