@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
@@ -0,0 +1,478 @@
1
+ /**
2
+ * @file use-collab.tsx — client-side Yjs collab provider for canvas iframes
3
+ * @scope plugins/design/dev-server/use-collab.tsx
4
+ * @purpose Mounts a single Y.Doc + Awareness per canvas iframe. Opens a
5
+ * WebSocket to `/_ws/collab/:slug`, speaks the y-websocket binary
6
+ * protocol, exposes hooks for the cursor overlay + Task 3 comments
7
+ * binding.
8
+ *
9
+ * Boundary:
10
+ * - Server-side equivalent is `collab/protocol.ts` + `collab/room.ts`.
11
+ * - This file mirrors the message framing (varint-prefixed sync + awareness
12
+ * frames) so the two sides converge over a binary WS without intermediate
13
+ * JSON.
14
+ * - Imports `yjs` + `y-protocols/{sync,awareness}` via the canvas-shell
15
+ * importmap (RUNTIME_PACKAGES additions). Canvas bundles that don't mount
16
+ * <CollabProvider> never resolve these specifiers and pay zero bundle cost.
17
+ */
18
+
19
+ import {
20
+ type ReactNode,
21
+ createContext,
22
+ useCallback,
23
+ useContext,
24
+ useEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState,
28
+ } from 'react';
29
+
30
+ import * as decoding from 'lib0/decoding';
31
+ import * as encoding from 'lib0/encoding';
32
+ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
33
+ import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync';
34
+ import * as Y from 'yjs';
35
+
36
+ const MESSAGE_SYNC = 0;
37
+ const MESSAGE_AWARENESS = 1;
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // Color hash — stable per peer identity.
41
+
42
+ /**
43
+ * djb2 string hash → 0xRRGGBB color in a curated palette. Determinism per
44
+ * input name is the load-bearing property: every peer hashing "Alice" must
45
+ * land on the SAME color. 12 hues spread evenly around the wheel; saturation
46
+ * + lightness fixed so all colors stay readable on light + dark surfaces.
47
+ */
48
+ const COLOR_PALETTE = [
49
+ '#e91e63', // pink
50
+ '#f44336', // red
51
+ '#ff9800', // orange
52
+ '#ffc107', // amber
53
+ '#cddc39', // lime
54
+ '#4caf50', // green
55
+ '#009688', // teal
56
+ '#00bcd4', // cyan
57
+ '#03a9f4', // light blue
58
+ '#3f51b5', // indigo
59
+ '#673ab7', // deep purple
60
+ '#9c27b0', // purple
61
+ ] as const;
62
+
63
+ export function colorForName(name: string): string {
64
+ // COLOR_PALETTE is a non-empty const tuple; the explicit `?? '#000'`
65
+ // fallback is unreachable but satisfies `noUncheckedIndexedAccess`.
66
+ const FALLBACK = '#000000';
67
+ if (!name) return COLOR_PALETTE[0] ?? FALLBACK;
68
+ let hash = 5381;
69
+ for (let i = 0; i < name.length; i++) {
70
+ hash = ((hash << 5) + hash + name.charCodeAt(i)) | 0;
71
+ }
72
+ const idx = ((hash % COLOR_PALETTE.length) + COLOR_PALETTE.length) % COLOR_PALETTE.length;
73
+ return COLOR_PALETTE[idx] ?? FALLBACK;
74
+ }
75
+
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+ // Awareness state shape.
78
+
79
+ export interface CollabAwarenessState {
80
+ name: string;
81
+ color: string;
82
+ /**
83
+ * Cursor position in **world coords** (canvas-lib viewport space) so foreign
84
+ * peers see the same conceptual point even when their local viewport is
85
+ * panned/zoomed differently. Null = peer is not over the canvas surface.
86
+ */
87
+ cursor: { x: number; y: number } | null;
88
+ /**
89
+ * Most-recently selected element. `cssPath` is the locator id chain the
90
+ * canvas-shell already uses; `bounds` are screen-px rect at the moment of
91
+ * publish (so it's a hint, not a live ref). Null when nothing selected.
92
+ */
93
+ selection: { cssPath: string; bounds: { x: number; y: number; w: number; h: number } } | null;
94
+ /**
95
+ * Currently-selected annotation stroke IDs (Phase 5). Strokes are addressed
96
+ * by their stable `data-id` attribute, so peers can resolve halos via
97
+ * `document.querySelectorAll('[data-id="<id>"]')`. Empty when nothing
98
+ * annotation-shaped is selected.
99
+ */
100
+ annotationSelection: string[];
101
+ viewport: { x: number; y: number; zoom: number };
102
+ /**
103
+ * Server-side `disconnect` matches awareness states to outgoing peers by
104
+ * this token (must equal the ws.data.id the server assigns at upgrade).
105
+ * Until the server pushes the assigned id back to the client, we use a
106
+ * client-generated UUID — collisions are negligible and disconnect cleanup
107
+ * tolerates a stale state (the next awareness GC pass drops it).
108
+ */
109
+ __connId: string;
110
+ }
111
+
112
+ export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Context.
116
+
117
+ interface CollabValue {
118
+ doc: Y.Doc;
119
+ awareness: Awareness;
120
+ /** Local peer's session-stable color (derived from git user.name). */
121
+ myColor: string;
122
+ /** Local peer's display name (git user.name or anonymous fallback). */
123
+ myName: string;
124
+ /** Local peer's connection id (matches server-side ws.data.id pattern). */
125
+ myConnId: string;
126
+ /** True when the WS is OPEN. Cursor overlay can use this to gate rendering. */
127
+ connected: boolean;
128
+ /** Publish (debounce-coalesced) an updated local awareness state. */
129
+ publishAwareness: (patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => void;
130
+ }
131
+
132
+ const CollabContext = createContext<CollabValue | null>(null);
133
+
134
+ export function useCollab(): CollabValue | null {
135
+ return useContext(CollabContext);
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Hook: foreign awareness peers (the cursor overlay subscribes to this).
140
+
141
+ /**
142
+ * Returns the current set of foreign peers (excludes the local client). The
143
+ * returned array is stable-reference between awareness updates — useful for
144
+ * downstream React.memo cursor components.
145
+ */
146
+ export function useForeignAwareness(): ForeignAwareness[] {
147
+ const collab = useCollab();
148
+ const [peers, setPeers] = useState<ForeignAwareness[]>([]);
149
+
150
+ useEffect(() => {
151
+ if (!collab) {
152
+ setPeers([]);
153
+ return;
154
+ }
155
+ const { awareness } = collab;
156
+ function compute(): ForeignAwareness[] {
157
+ const out: ForeignAwareness[] = [];
158
+ const myId = awareness.clientID;
159
+ for (const [clientID, state] of awareness.getStates() as Map<number, unknown>) {
160
+ if (clientID === myId) continue;
161
+ if (!state || typeof state !== 'object') continue;
162
+ const s = state as Partial<CollabAwarenessState>;
163
+ // Guard against partial states from stale peers / older protocol versions.
164
+ if (typeof s.name !== 'string' || typeof s.color !== 'string') continue;
165
+ out.push({
166
+ clientID,
167
+ name: s.name,
168
+ color: s.color,
169
+ cursor: s.cursor ?? null,
170
+ selection: s.selection ?? null,
171
+ annotationSelection: Array.isArray(s.annotationSelection) ? s.annotationSelection : [],
172
+ viewport: s.viewport ?? { x: 0, y: 0, zoom: 1 },
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+ setPeers(compute());
178
+ const onChange = () => setPeers(compute());
179
+ awareness.on('change', onChange);
180
+ return () => {
181
+ awareness.off('change', onChange);
182
+ };
183
+ }, [collab]);
184
+
185
+ return peers;
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // Slug derivation — must match `api.fileSlug` server-side.
190
+
191
+ /**
192
+ * Mirror of server-side `api.fileSlug`. The input is the canvas path as the
193
+ * shell stored it on `window.__canvas_meta_file__` (e.g. `.design/ui/Foo.tsx`).
194
+ * Strip the designRel prefix (read from `window.__canvas_design_rel__`, set
195
+ * by _shell.html) so both sides land on the same slug — without this both
196
+ * tabs open a `design-ui-foo` room while the server's inspector bridge
197
+ * pushes into `ui-foo`, and the rooms never converge.
198
+ */
199
+ export function canvasSlugFromPath(canvasRel: string | null | undefined): string | null {
200
+ if (!canvasRel) return null;
201
+ let p = canvasRel.replace(/^\/+|\/+$/g, '');
202
+ if (typeof window !== 'undefined') {
203
+ const w = window as unknown as { __canvas_design_rel__?: string };
204
+ const designRel = (w.__canvas_design_rel__ ?? '').replace(/^\/+|\/+$/g, '');
205
+ if (designRel && p.startsWith(`${designRel}/`)) p = p.slice(designRel.length + 1);
206
+ }
207
+ const slug = p
208
+ .replace(/\//g, '-')
209
+ .replace(/\s+/g, '_')
210
+ .replace(/\.(tsx|html)$/i, '')
211
+ .replace(/^\.+/, '')
212
+ .toLowerCase();
213
+ return /^[a-z0-9_-]+$/.test(slug) ? slug : null;
214
+ }
215
+
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+ // Provider — opens WS, owns Y.Doc + Awareness lifecycle.
218
+
219
+ interface CollabProviderProps {
220
+ /** Canvas slug — must match server-side `parseCollabSlug`. */
221
+ slug: string;
222
+ children: ReactNode;
223
+ }
224
+
225
+ const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
226
+
227
+ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
228
+ // Y.Doc + Awareness are recreated whenever the slug changes (switching
229
+ // canvases tears down the prior session cleanly). The useMemo factory
230
+ // bodies don't read `slug` — slug IS the cache key, intentionally.
231
+ // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
232
+ const doc = useMemo(() => new Y.Doc(), [slug]);
233
+ const awareness = useMemo(() => new Awareness(doc), [doc]);
234
+ // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
235
+ const myConnId = useMemo(() => crypto.randomUUID(), [slug]);
236
+
237
+ const [myName, setMyName] = useState('anonymous');
238
+ const [myColor, setMyColor] = useState(colorForName('anonymous'));
239
+ const [connected, setConnected] = useState(false);
240
+
241
+ // Fetch git user.name once per slug; falls back to anonymous-<short id>.
242
+ useEffect(() => {
243
+ let cancelled = false;
244
+ fetch('/_api/git-user')
245
+ .then((r) => r.json())
246
+ .then((j) => {
247
+ if (cancelled) return;
248
+ const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
249
+ const finalName = n ?? `anonymous-${myConnId.slice(0, 6)}`;
250
+ setMyName(finalName);
251
+ setMyColor(colorForName(finalName));
252
+ })
253
+ .catch(() => {
254
+ if (cancelled) return;
255
+ const fallback = `anonymous-${myConnId.slice(0, 6)}`;
256
+ setMyName(fallback);
257
+ setMyColor(colorForName(fallback));
258
+ });
259
+ return () => {
260
+ cancelled = true;
261
+ };
262
+ }, [myConnId]);
263
+
264
+ // Seed local awareness state immediately so foreign peers see our name even
265
+ // before the cursor moves. Update when myName/myColor settles from the fetch.
266
+ useEffect(() => {
267
+ awareness.setLocalState({
268
+ name: myName,
269
+ color: myColor,
270
+ cursor: null,
271
+ selection: null,
272
+ annotationSelection: [],
273
+ viewport: { x: 0, y: 0, zoom: 1 },
274
+ __connId: myConnId,
275
+ } satisfies CollabAwarenessState);
276
+ }, [awareness, myName, myColor, myConnId]);
277
+
278
+ // ── WebSocket lifecycle ──────────────────────────────────────────────────
279
+ const wsRef = useRef<WebSocket | null>(null);
280
+
281
+ useEffect(() => {
282
+ let cancelled = false;
283
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
284
+
285
+ function sendFrame(ws: WebSocket, payload: Uint8Array) {
286
+ try {
287
+ ws.send(payload);
288
+ } catch {
289
+ /* dead socket — close handler will reconnect */
290
+ }
291
+ }
292
+
293
+ function broadcastAwareness(ws: WebSocket, changed: number[]) {
294
+ if (changed.length === 0) return;
295
+ const encoder = encoding.createEncoder();
296
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
297
+ encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
298
+ sendFrame(ws, encoding.toUint8Array(encoder));
299
+ }
300
+
301
+ function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
302
+ const encoder = encoding.createEncoder();
303
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
304
+ writeUpdate(encoder, update);
305
+ sendFrame(ws, encoding.toUint8Array(encoder));
306
+ }
307
+
308
+ function connect() {
309
+ if (cancelled) return;
310
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
311
+ const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
312
+ ws.binaryType = 'arraybuffer';
313
+ wsRef.current = ws;
314
+
315
+ ws.addEventListener('open', () => {
316
+ setConnected(true);
317
+ // Sync step 1 — announce our state vector so the server can send the
318
+ // missing pieces (matches the encodeHandshake server path).
319
+ const encoder = encoding.createEncoder();
320
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
321
+ writeSyncStep1(encoder, doc);
322
+ sendFrame(ws, encoding.toUint8Array(encoder));
323
+ // Awareness initial state — fire our local state to the room.
324
+ broadcastAwareness(ws, [awareness.clientID]);
325
+ });
326
+
327
+ ws.addEventListener('close', () => {
328
+ setConnected(false);
329
+ wsRef.current = null;
330
+ if (cancelled) return;
331
+ reconnectTimer = setTimeout(connect, 1000);
332
+ });
333
+
334
+ ws.addEventListener('error', () => {
335
+ // Let close handler do the reconnect; error events without a close
336
+ // would just retry-spam.
337
+ });
338
+
339
+ ws.addEventListener('message', (evt) => {
340
+ const payload =
341
+ evt.data instanceof ArrayBuffer
342
+ ? new Uint8Array(evt.data)
343
+ : evt.data instanceof Uint8Array
344
+ ? evt.data
345
+ : null;
346
+ if (!payload) return;
347
+ const decoder = decoding.createDecoder(payload);
348
+ const messageType = decoding.readVarUint(decoder);
349
+ switch (messageType) {
350
+ case MESSAGE_SYNC: {
351
+ const encoder = encoding.createEncoder();
352
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
353
+ readSyncMessage(decoder, encoder, doc, ws);
354
+ if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
355
+ break;
356
+ }
357
+ case MESSAGE_AWARENESS: {
358
+ applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
359
+ break;
360
+ }
361
+ default:
362
+ break;
363
+ }
364
+ });
365
+ }
366
+
367
+ // Wire doc updates → broadcast to server. Origin tagged with the ws ref so
368
+ // server-side updates we receive don't echo back.
369
+ const onDocUpdate = (update: Uint8Array, origin: unknown) => {
370
+ const ws = wsRef.current;
371
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
372
+ if (origin === ws) return; // came from server, don't echo
373
+ broadcastSyncUpdate(ws, update);
374
+ };
375
+ doc.on('update', onDocUpdate);
376
+
377
+ // Wire awareness changes → broadcast. Same origin guard.
378
+ const onAwarenessUpdate = (
379
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
380
+ origin: unknown
381
+ ) => {
382
+ const ws = wsRef.current;
383
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
384
+ if (origin === ws) return;
385
+ const changed = added.concat(updated, removed);
386
+ broadcastAwareness(ws, changed);
387
+ };
388
+ awareness.on('update', onAwarenessUpdate);
389
+
390
+ connect();
391
+
392
+ return () => {
393
+ cancelled = true;
394
+ if (reconnectTimer) clearTimeout(reconnectTimer);
395
+ doc.off('update', onDocUpdate);
396
+ awareness.off('update', onAwarenessUpdate);
397
+ const ws = wsRef.current;
398
+ if (ws && ws.readyState === WebSocket.OPEN) {
399
+ try {
400
+ ws.close();
401
+ } catch {
402
+ /* ignore */
403
+ }
404
+ }
405
+ // Don't destroy doc/awareness here — the useMemo-tied lifetime handles
406
+ // that when the slug changes.
407
+ };
408
+ }, [slug, doc, awareness]);
409
+
410
+ // Per-slug cleanup of doc/awareness when slug changes (or provider unmounts).
411
+ useEffect(
412
+ () => () => {
413
+ try {
414
+ awareness.destroy();
415
+ } catch {
416
+ /* ignore */
417
+ }
418
+ try {
419
+ doc.destroy();
420
+ } catch {
421
+ /* ignore */
422
+ }
423
+ },
424
+ [doc, awareness]
425
+ );
426
+
427
+ // ── Throttled awareness publish ─────────────────────────────────────────
428
+ const pendingRef = useRef<Partial<Omit<CollabAwarenessState, '__connId'>> | null>(null);
429
+ const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
430
+
431
+ const publishAwareness = useCallback(
432
+ (patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => {
433
+ pendingRef.current = { ...(pendingRef.current ?? {}), ...patch };
434
+ if (throttleTimerRef.current) return;
435
+ throttleTimerRef.current = setTimeout(() => {
436
+ throttleTimerRef.current = null;
437
+ const next = pendingRef.current;
438
+ pendingRef.current = null;
439
+ if (!next) return;
440
+ const current = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
441
+ awareness.setLocalState({
442
+ name: current.name ?? myName,
443
+ color: current.color ?? myColor,
444
+ cursor: current.cursor ?? null,
445
+ selection: current.selection ?? null,
446
+ annotationSelection: current.annotationSelection ?? [],
447
+ viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
448
+ __connId: myConnId,
449
+ ...next,
450
+ } satisfies CollabAwarenessState);
451
+ }, AWARENESS_THROTTLE_MS);
452
+ },
453
+ [awareness, myName, myColor, myConnId]
454
+ );
455
+
456
+ // ── Cleanup throttle timer on unmount ───────────────────────────────────
457
+ useEffect(
458
+ () => () => {
459
+ if (throttleTimerRef.current) clearTimeout(throttleTimerRef.current);
460
+ },
461
+ []
462
+ );
463
+
464
+ const value = useMemo<CollabValue>(
465
+ () => ({
466
+ doc,
467
+ awareness,
468
+ myColor,
469
+ myName,
470
+ myConnId,
471
+ connected,
472
+ publishAwareness,
473
+ }),
474
+ [doc, awareness, myColor, myName, myConnId, connected, publishAwareness]
475
+ );
476
+
477
+ return <CollabContext.Provider value={value}>{children}</CollabContext.Provider>;
478
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @file use-cursor-modifiers.tsx — T28 (Wave 3)
3
+ * @scope plugins/design/dev-server/use-cursor-modifiers.tsx
4
+ * @purpose Modifier-aware cursor state machine. Tracks Alt / Shift / Meta
5
+ * modifier state globally and reflects it as `data-mod-*`
6
+ * attributes on `.dc-canvas`. CSS rules consume those attrs to
7
+ * flip cursors live:
8
+ *
9
+ * • Alt-held over a `[data-cd-id]` in Move tool → cursor: copy
10
+ * (preview of "Alt-drag duplicates this element").
11
+ * • Shift-held in Move tool over empty body padding → crosshair
12
+ * (preview of "marquee add mode").
13
+ *
14
+ * Per the research finding (Excalidraw #250): cursor changes on
15
+ * HOVER, not on drag-start. We track modifier state at the
16
+ * document level so the cursor updates the moment the user
17
+ * presses/releases without waiting for a pointermove.
18
+ *
19
+ * Resize-handle cursors (nw / ne / ns / ew) live next to the
20
+ * handle DOM in `use-annotation-resize.tsx` — those don't need
21
+ * modifier coupling; they fire on hover via their own CSS rules.
22
+ */
23
+
24
+ import { type RefObject, useEffect } from 'react';
25
+
26
+ const STYLES = `
27
+ /* Alt + cd-id in move tool → copy cursor. The Alt-drag-duplicates affordance
28
+ is partial in v1 (no drag handler yet) — paint the cursor anyway so the
29
+ user discovers the gesture, and a future wave wires the actual duplication. */
30
+ .dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-cd-id],
31
+ .dc-canvas[data-active-tool="move"][data-mod-alt="true"] [data-dc-screen] {
32
+ cursor: copy !important;
33
+ }
34
+
35
+ /* Shift over empty body padding in move tool → crosshair (marquee add mode
36
+ preview). Body padding = the .dc-artboard-body direct background, not
37
+ user content. */
38
+ .dc-canvas[data-active-tool="move"][data-mod-shift="true"] .dc-artboard-body {
39
+ cursor: crosshair;
40
+ }
41
+ `.trim();
42
+
43
+ function ensureStyles(): void {
44
+ if (typeof document === 'undefined') return;
45
+ if (document.getElementById('dc-cv-cursor-modifiers-css')) return;
46
+ const s = document.createElement('style');
47
+ s.id = 'dc-cv-cursor-modifiers-css';
48
+ s.textContent = STYLES;
49
+ document.head.appendChild(s);
50
+ }
51
+
52
+ export interface ModifierState {
53
+ alt: boolean;
54
+ shift: boolean;
55
+ meta: boolean;
56
+ }
57
+
58
+ /**
59
+ * Reduce a key event into the modifier slice. Pulled out for unit tests —
60
+ * the actual hook is DOM-bound and harder to test directly.
61
+ */
62
+ export function reduceModifiers(
63
+ prev: ModifierState,
64
+ e: { altKey: boolean; shiftKey: boolean; metaKey: boolean; ctrlKey: boolean }
65
+ ): ModifierState {
66
+ // Treat Ctrl as Meta on non-mac so the cursor preview stays consistent with
67
+ // the input-router's `metaOrCtrl` gate.
68
+ const meta = e.metaKey || e.ctrlKey;
69
+ if (prev.alt === e.altKey && prev.shift === e.shiftKey && prev.meta === meta) {
70
+ return prev;
71
+ }
72
+ return { alt: e.altKey, shift: e.shiftKey, meta };
73
+ }
74
+
75
+ /**
76
+ * Attach modifier tracking to the canvas host element. Reflects the modifier
77
+ * state as `data-mod-alt` / `data-mod-shift` / `data-mod-meta` so cursor CSS
78
+ * rules can act on combinations without re-rendering React.
79
+ *
80
+ * Cleans up on unmount + on window blur (so leaving the tab with Alt held
81
+ * doesn't strand the cursor in copy mode).
82
+ */
83
+ export function useCursorModifiers(hostRef: RefObject<HTMLElement | null>): void {
84
+ useEffect(() => {
85
+ if (typeof document === 'undefined') return;
86
+ const host = hostRef.current;
87
+ if (!host) return;
88
+ ensureStyles();
89
+
90
+ let state: ModifierState = { alt: false, shift: false, meta: false };
91
+
92
+ const apply = (next: ModifierState) => {
93
+ if (next === state) return;
94
+ state = next;
95
+ host.setAttribute('data-mod-alt', next.alt ? 'true' : 'false');
96
+ host.setAttribute('data-mod-shift', next.shift ? 'true' : 'false');
97
+ host.setAttribute('data-mod-meta', next.meta ? 'true' : 'false');
98
+ };
99
+
100
+ const onKeyChange = (e: KeyboardEvent) => apply(reduceModifiers(state, e));
101
+ const onBlur = () => apply({ alt: false, shift: false, meta: false });
102
+ // pointermove carries the modifier state too — handles the case where
103
+ // the user presses a modifier outside the focused window and then mouses
104
+ // back in (keydown/keyup never fires for the press).
105
+ const onPointer = (e: PointerEvent) => apply(reduceModifiers(state, e));
106
+
107
+ document.addEventListener('keydown', onKeyChange, true);
108
+ document.addEventListener('keyup', onKeyChange, true);
109
+ document.addEventListener('pointermove', onPointer, { passive: true });
110
+ window.addEventListener('blur', onBlur);
111
+
112
+ return () => {
113
+ document.removeEventListener('keydown', onKeyChange, true);
114
+ document.removeEventListener('keyup', onKeyChange, true);
115
+ document.removeEventListener('pointermove', onPointer);
116
+ window.removeEventListener('blur', onBlur);
117
+ host.removeAttribute('data-mod-alt');
118
+ host.removeAttribute('data-mod-shift');
119
+ host.removeAttribute('data-mod-meta');
120
+ };
121
+ }, [hostRef]);
122
+ }