@1agh/maude 0.19.1 → 0.21.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 (105) 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 +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -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 +17 -9
  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 +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -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.
@@ -1143,14 +1172,22 @@ function computeThreadAnchor(comment: OverlayComment): { x: number; y: number }
1143
1172
  }
1144
1173
 
1145
1174
  function computeAnchor(state: ComposerState): { x: number; y: number } {
1146
- // Try the selected element first its live screen rect gives the most
1147
- // natural anchor (composer sits flush under the element the user clicked).
1175
+ // G4 anchor to the cursor click point first. Earlier versions anchored to
1176
+ // the selected element's bottom-left, which landed the composer flush in
1177
+ // the corner regardless of where the user clicked — surprising for the
1178
+ // common case of "I clicked the middle of an element, expecting the
1179
+ // composer to appear near my cursor". The element-rect path remains as a
1180
+ // fallback for entry points that don't carry a cursor (e.g. opening the
1181
+ // composer from a contextual toolbar button — those should set clientX/Y
1182
+ // to a sensible anchor before dispatching).
1183
+ if (state.clientX || state.clientY) {
1184
+ return { x: state.clientX, y: state.clientY + 8 };
1185
+ }
1148
1186
  if (state.selection.selector) {
1149
1187
  const rect = screenRectFor(state.selection.selector);
1150
1188
  if (rect) {
1151
1189
  return { x: rect.x, y: rect.y + rect.h + 8 };
1152
1190
  }
1153
1191
  }
1154
- // Fall back to the raw click point — composer drops 8px below the cursor.
1155
- return { x: state.clientX, y: state.clientY + 8 };
1192
+ return { x: 16, y: 16 };
1156
1193
  }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file contextual-toolbar.tsx — T30 (Wave 3)
3
+ * @scope plugins/design/dev-server/contextual-toolbar.tsx
4
+ * @purpose Selection-anchored floating chrome for ELEMENT selections
5
+ * (cd-id entries in the selection set). Mirrors the
6
+ * MultiArtboardToolbar pattern but scopes to user content inside
7
+ * artboards. Actions surface the existing right-click handlers
8
+ * one click closer:
9
+ *
10
+ * • Copy CSS path
11
+ * • Copy data-cd-id
12
+ * • Add comment
13
+ *
14
+ * Hides when:
15
+ * - no selection,
16
+ * - selection contains only artboards (MultiArtboardToolbar
17
+ * covers that),
18
+ * - selection contains only annotations (their own toolbar
19
+ * fires from `annotations-context-toolbar.tsx`).
20
+ *
21
+ * Anchored 14 px above the element selection union bbox; flips
22
+ * below when bbox top is < 60 px from the viewport edge — same
23
+ * contract as MultiArtboardToolbar so the two never collide.
24
+ *
25
+ * Selection-change tweens are NOT animated here in v1; the
26
+ * instant reposition is acceptable given the toolbar fades in
27
+ * only when stickily relevant. A future polish wave can add
28
+ * the 180 ms position tween per the plan note.
29
+ */
30
+
31
+ import { useEffect, useRef } from 'react';
32
+
33
+ import { useSelectionSet } from './use-selection-set.tsx';
34
+
35
+ const CTX_TOOLBAR_CSS = `
36
+ .dc-elem-ctx-tb {
37
+ position: fixed;
38
+ pointer-events: auto;
39
+ z-index: 6;
40
+ display: none;
41
+ align-items: stretch;
42
+ gap: 2px;
43
+ padding: 4px;
44
+ background: var(--u-bg-0, var(--bg-0, #ffffff));
45
+ border: 1px solid var(--u-fg-0, #1c1917);
46
+ border-radius: 8px;
47
+ box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
48
+ font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
49
+ font-size: 11px;
50
+ letter-spacing: 0.02em;
51
+ color: var(--u-fg-0, #1a1a1a);
52
+ user-select: none;
53
+ opacity: 0;
54
+ transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
55
+ }
56
+ .dc-elem-ctx-tb[data-on="true"] {
57
+ opacity: 1;
58
+ }
59
+ .dc-elem-ctx-tb button {
60
+ appearance: none;
61
+ background: transparent;
62
+ border: 0;
63
+ border-radius: 6px;
64
+ padding: 4px 10px;
65
+ font: inherit;
66
+ cursor: pointer;
67
+ color: inherit;
68
+ transition: background-color 80ms linear;
69
+ }
70
+ .dc-elem-ctx-tb button:hover {
71
+ background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 8%, transparent);
72
+ }
73
+ .dc-elem-ctx-tb .dc-elem-ctx-count {
74
+ padding: 4px 8px 4px 10px;
75
+ color: var(--fg-1, rgba(40,30,20,0.7));
76
+ border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
77
+ margin-right: 2px;
78
+ font-variant-numeric: tabular-nums;
79
+ }
80
+ @media (prefers-reduced-motion: reduce) {
81
+ .dc-elem-ctx-tb, .dc-elem-ctx-tb button { transition: none; }
82
+ }
83
+ `.trim();
84
+
85
+ function ensureStyles(): void {
86
+ if (typeof document === 'undefined') return;
87
+ if (document.getElementById('dc-elem-ctx-tb-css')) return;
88
+ const s = document.createElement('style');
89
+ s.id = 'dc-elem-ctx-tb-css';
90
+ s.textContent = CTX_TOOLBAR_CSS;
91
+ document.head.appendChild(s);
92
+ }
93
+
94
+ function copyText(text: string): void {
95
+ if (typeof navigator === 'undefined' || !navigator.clipboard) return;
96
+ void navigator.clipboard.writeText(text).catch(() => {
97
+ /* clipboard blocked */
98
+ });
99
+ }
100
+
101
+ function openComposerForSelection(
102
+ sel: { id?: string; selector: string },
103
+ anchorEl: Element | null
104
+ ): void {
105
+ if (typeof document === 'undefined') return;
106
+ // Anchor near the top-right of the targeted element so the composer drops
107
+ // into a clear area next to it. Matches the comment-tool click affordance
108
+ // post-G4 (cursor-anchored composer).
109
+ let clientX = 0;
110
+ let clientY = 0;
111
+ if (anchorEl) {
112
+ const r = (anchorEl as HTMLElement).getBoundingClientRect();
113
+ clientX = r.right - 8;
114
+ clientY = r.top + r.height / 2;
115
+ }
116
+ try {
117
+ document.dispatchEvent(
118
+ new CustomEvent('cm:open-composer', {
119
+ detail: { selection: sel, clientX, clientY },
120
+ })
121
+ );
122
+ } catch {
123
+ /* CustomEvent unsupported — fall through */
124
+ }
125
+ // Back-compat parent post for legacy mocks.
126
+ if (typeof window !== 'undefined') {
127
+ try {
128
+ window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
129
+ } catch {
130
+ /* parent detached */
131
+ }
132
+ }
133
+ }
134
+
135
+ export function ContextualToolbar() {
136
+ ensureStyles();
137
+ const { selected } = useSelectionSet();
138
+ const ref = useRef<HTMLDivElement | null>(null);
139
+ const rafRef = useRef<number | null>(null);
140
+
141
+ // Element selections = entries with `id` (data-cd-id) set. Pure artboard
142
+ // selections (artboardId-only, no id) and empty sets fall through.
143
+ const elementSelections = selected.filter((s) => !!s.id);
144
+ const visible = elementSelections.length > 0;
145
+
146
+ useEffect(() => {
147
+ const div = ref.current;
148
+ if (!div) return;
149
+ if (!visible) {
150
+ div.style.display = 'none';
151
+ div.setAttribute('data-on', 'false');
152
+ return;
153
+ }
154
+ const tick = () => {
155
+ rafRef.current = null;
156
+ let xMin = Number.POSITIVE_INFINITY;
157
+ let yMin = Number.POSITIVE_INFINITY;
158
+ let xMax = Number.NEGATIVE_INFINITY;
159
+ let yMax = Number.NEGATIVE_INFINITY;
160
+ let any = false;
161
+ for (const sel of elementSelections) {
162
+ const node = sel.id
163
+ ? document.querySelector(`[data-cd-id="${sel.id}"]`)
164
+ : document.querySelector(sel.selector);
165
+ if (!node) continue;
166
+ const r = (node as HTMLElement).getBoundingClientRect();
167
+ if (r.width === 0 && r.height === 0) continue;
168
+ any = true;
169
+ if (r.left < xMin) xMin = r.left;
170
+ if (r.top < yMin) yMin = r.top;
171
+ if (r.right > xMax) xMax = r.right;
172
+ if (r.bottom > yMax) yMax = r.bottom;
173
+ }
174
+ if (!any) {
175
+ div.style.display = 'none';
176
+ div.setAttribute('data-on', 'false');
177
+ rafRef.current = requestAnimationFrame(tick);
178
+ return;
179
+ }
180
+ div.style.display = 'flex';
181
+ const tw = div.offsetWidth || 0;
182
+ const centerX = (xMin + xMax) / 2;
183
+ const top = yMin;
184
+ const gap = 14;
185
+ let anchorY = top - div.offsetHeight - gap;
186
+ if (anchorY < 60) anchorY = yMax + gap;
187
+ div.style.left = `${Math.round(centerX - tw / 2)}px`;
188
+ div.style.top = `${Math.round(anchorY)}px`;
189
+ div.setAttribute('data-on', 'true');
190
+ rafRef.current = requestAnimationFrame(tick);
191
+ };
192
+ rafRef.current = requestAnimationFrame(tick);
193
+ return () => {
194
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
195
+ };
196
+ }, [visible, elementSelections]);
197
+
198
+ if (!visible) {
199
+ return <div ref={ref} className="dc-elem-ctx-tb" aria-hidden="true" />;
200
+ }
201
+
202
+ // For multi-selection, Copy actions operate on the FIRST element — same
203
+ // convention as the right-click menu (single-target). Future polish: a
204
+ // mini-menu that lets the user pick which selection's CSS to copy.
205
+ const primary = elementSelections[0];
206
+ const count = elementSelections.length;
207
+
208
+ return (
209
+ <div ref={ref} className="dc-elem-ctx-tb" role="toolbar" aria-label="Element actions">
210
+ <span className="dc-elem-ctx-count">{count === 1 ? '1 element' : `${count} elements`}</span>
211
+ <button
212
+ type="button"
213
+ title="Copy CSS selector"
214
+ onClick={() => primary && copyText(primary.selector)}
215
+ >
216
+ Copy CSS
217
+ </button>
218
+ <button
219
+ type="button"
220
+ title="Copy data-cd-id"
221
+ disabled={!primary?.id}
222
+ onClick={() => primary?.id && copyText(primary.id)}
223
+ >
224
+ Copy ID
225
+ </button>
226
+ <button
227
+ type="button"
228
+ title="Add comment on this element"
229
+ onClick={() => {
230
+ if (!primary) return;
231
+ const node = primary.id
232
+ ? document.querySelector(`[data-cd-id="${primary.id}"]`)
233
+ : document.querySelector(primary.selector);
234
+ openComposerForSelection(primary, node);
235
+ }}
236
+ >
237
+ Comment
238
+ </button>
239
+ </div>
240
+ );
241
+ }
@@ -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
+ }