@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
@@ -29,6 +29,30 @@
29
29
 
30
30
  import { type RefObject, useEffect } from 'react';
31
31
 
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Drag-vs-click threshold (T25)
34
+ //
35
+ // 4 px screen-pixel hypot separates "click" from "drag" — Microsoft Win32
36
+ // canonical (`SM_CXDRAG`/`SM_CYDRAG` default), also d3-drag and tldraw default.
37
+ // Owned here so artboard-drag, artboard-marquee, element-marquee, annotation-
38
+ // drag-vs-tap, and any future drag-class gesture all read the same constant.
39
+ // Wheel + pinch-zoom are EXEMPT — threshold is for `pointerdown → pointermove`
40
+ // drag classification only.
41
+
42
+ export const DRAG_THRESHOLD_PX = 4;
43
+
44
+ /** True once the pointer has moved ≥ DRAG_THRESHOLD_PX from its start. */
45
+ export function crossedDragThreshold(
46
+ startX: number,
47
+ startY: number,
48
+ curX: number,
49
+ curY: number
50
+ ): boolean {
51
+ const dx = curX - startX;
52
+ const dy = curY - startY;
53
+ return Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX;
54
+ }
55
+
32
56
  // ─────────────────────────────────────────────────────────────────────────────
33
57
  // Types
34
58
 
@@ -68,7 +92,9 @@ export type RouterAction =
68
92
  | { kind: 'drop-comment'; clientX: number; clientY: number }
69
93
  | { kind: 'context-menu'; clientX: number; clientY: number }
70
94
  | { kind: 'tool'; tool: Tool }
71
- | { kind: 'escape' };
95
+ | { kind: 'escape' }
96
+ | { kind: 'undo' }
97
+ | { kind: 'redo' };
72
98
 
73
99
  export interface ClassifyInput {
74
100
  type: 'pointermove' | 'pointerdown' | 'contextmenu' | 'keydown';
@@ -101,6 +127,15 @@ export function classify(input: ClassifyInput): RouterAction {
101
127
  if (input.metaKey || input.ctrlKey || input.altKey) {
102
128
  // Esc with modifiers still dismisses.
103
129
  if (input.key === 'Escape') return { kind: 'escape' };
130
+ // Undo / redo (Phase 20). Alt is reserved — Cmd+Opt+Z is a browser
131
+ // text-input gesture we don't claim. `metaKey || ctrlKey` covers both
132
+ // mac and Windows / Linux without a platform sniff.
133
+ const k = (input.key || '').toLowerCase();
134
+ if (!input.altKey && (input.metaKey || input.ctrlKey)) {
135
+ if (k === 'z' && input.shiftKey) return { kind: 'redo' };
136
+ if (k === 'z') return { kind: 'undo' };
137
+ if (k === 'y' && !input.shiftKey) return { kind: 'redo' };
138
+ }
104
139
  return { kind: 'no-op' };
105
140
  }
106
141
  const k = (input.key || '').toLowerCase();
@@ -218,6 +253,10 @@ export interface RouterCallbacks {
218
253
  onContextMenu?: (a: Extract<RouterAction, { kind: 'context-menu' }>) => void;
219
254
  onTool?: (a: Extract<RouterAction, { kind: 'tool' }>) => void;
220
255
  onEscape?: () => void;
256
+ /** Phase 20 — Cmd+Z / Ctrl+Z. */
257
+ onUndo?: () => void;
258
+ /** Phase 20 — Cmd+Shift+Z / Ctrl+Y / Cmd+Y. */
259
+ onRedo?: () => void;
221
260
  }
222
261
 
223
262
  export interface UseInputRouterOptions {
@@ -283,6 +322,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
283
322
  case 'escape':
284
323
  callbacks.onEscape?.();
285
324
  break;
325
+ case 'undo':
326
+ callbacks.onUndo?.();
327
+ break;
328
+ case 'redo':
329
+ callbacks.onRedo?.();
330
+ break;
286
331
  case 'no-op':
287
332
  break;
288
333
  }
@@ -404,7 +449,12 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
404
449
  isEditable: isEditableTarget(e.target),
405
450
  activeTool: getActiveTool(),
406
451
  });
407
- if (action.kind === 'tool' || action.kind === 'escape') {
452
+ if (
453
+ action.kind === 'tool' ||
454
+ action.kind === 'escape' ||
455
+ action.kind === 'undo' ||
456
+ action.kind === 'redo'
457
+ ) {
408
458
  e.preventDefault();
409
459
  }
410
460
  dispatch(action);
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @file marquee-overlay.tsx — T26 (Wave 3)
3
+ * @scope plugins/design/dev-server/marquee-overlay.tsx
4
+ * @purpose Element-level marquee selection. Drag from empty padding inside
5
+ * an artboard body (NOT on top of a `[data-cd-id]` element) to
6
+ * lasso multiple stamped elements. Aseprite modifier vocabulary:
7
+ * bare = replace, Shift = add, Alt = subtract, Shift+Alt =
8
+ * intersect. Modifier state re-read at pointerup so the user can
9
+ * flip mid-drag.
10
+ *
11
+ * Co-exists with `artboard-marquee.tsx` — that overlay triggers
12
+ * when pointerdown lands OUTSIDE any artboard (empty world);
13
+ * this overlay triggers when pointerdown lands INSIDE an artboard
14
+ * body on padding (no cd-id under cursor). The two are mutually
15
+ * exclusive by start position.
16
+ *
17
+ * Renders the marquee rect per DDR-046 rev 2:
18
+ * 1 px solid accent + 8 % accent fill
19
+ * (same idiom as artboard marquee — active gesture is solid +
20
+ * tinted; dashed reserved for persistent group bbox.)
21
+ */
22
+
23
+ import { useEffect, useRef } from 'react';
24
+
25
+ import { DRAG_THRESHOLD_PX } from './input-router.tsx';
26
+ import { type Selection, useSelectionSet } from './use-selection-set.tsx';
27
+ import { useToolMode } from './use-tool-mode.tsx';
28
+
29
+ const ELEM_MARQUEE_CSS = `
30
+ .dc-cv-elem-marquee {
31
+ position: fixed;
32
+ pointer-events: none;
33
+ z-index: 5;
34
+ border: 1px solid var(--maude-hud-accent, #0d99ff);
35
+ background: color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 8%, transparent);
36
+ display: none;
37
+ }
38
+ `.trim();
39
+
40
+ function ensureMarqueeStyles(): void {
41
+ if (typeof document === 'undefined') return;
42
+ if (document.getElementById('dc-cv-elem-marquee-css')) return;
43
+ const s = document.createElement('style');
44
+ s.id = 'dc-cv-elem-marquee-css';
45
+ s.textContent = ELEM_MARQUEE_CSS;
46
+ document.head.appendChild(s);
47
+ }
48
+
49
+ // Skip the same chrome / overlay surfaces as artboard-marquee — the user is
50
+ // interacting with floating UI, not the world.
51
+ function isChromeTarget(t: EventTarget | null): boolean {
52
+ if (!t || !(t as Element).closest) return true;
53
+ const el = t as Element;
54
+ if (
55
+ el.closest(
56
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
57
+ )
58
+ ) {
59
+ return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Walk from `hit` upward and decide if the pointerdown landed on "empty body
66
+ * padding" (no `[data-cd-id]` encountered before reaching `.dc-artboard-body`).
67
+ * Returns the body element when yes; null when the pointerdown should not
68
+ * start an element marquee.
69
+ */
70
+ function resolveBodyOrNull(hit: Element): Element | null {
71
+ let cur: Element | null = hit;
72
+ while (cur) {
73
+ if (cur.hasAttribute?.('data-cd-id')) return null; // on user content
74
+ if ((cur as HTMLElement).classList?.contains('dc-artboard-body')) return cur;
75
+ cur = cur.parentElement;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Mode resolved from the current modifier state. Re-read at pointerup so a
82
+ * user can flip mid-drag — e.g. start without modifiers, press Shift before
83
+ * releasing, the gesture commits as `add` instead of `replace`.
84
+ */
85
+ type Mode = 'replace' | 'add' | 'subtract' | 'intersect';
86
+
87
+ function modeOf(e: { shiftKey: boolean; altKey: boolean }): Mode {
88
+ if (e.shiftKey && e.altKey) return 'intersect';
89
+ if (e.shiftKey) return 'add';
90
+ if (e.altKey) return 'subtract';
91
+ return 'replace';
92
+ }
93
+
94
+ /**
95
+ * Build a minimal `Selection` from a `[data-cd-id]` element — just the fields
96
+ * downstream consumers actually read for bulk marquee hits. The rich shape
97
+ * (html, dom_path, text) is only enriched when a single click sets focus
98
+ * through `hoverTargetToSelection`.
99
+ */
100
+ function elementToSelection(el: Element, bodyAncestor: Element): Selection | null {
101
+ const cdId = el.getAttribute('data-cd-id');
102
+ if (!cdId) return null;
103
+ const artboardEl = bodyAncestor.closest('[data-dc-screen]');
104
+ const artboardId = artboardEl?.getAttribute('data-dc-screen') ?? null;
105
+ const rect = (el as HTMLElement).getBoundingClientRect();
106
+ return {
107
+ id: cdId,
108
+ selector: `[data-cd-id="${cdId}"]`,
109
+ artboardId,
110
+ tag: el.tagName.toLowerCase(),
111
+ classes: (el.getAttribute('class') ?? '')
112
+ .trim()
113
+ .split(/\s+/)
114
+ .filter((c) => c && !c.startsWith('dgn-') && !c.startsWith('dc-cv-'))
115
+ .join(' '),
116
+ bounds: {
117
+ x: Math.round(rect.left),
118
+ y: Math.round(rect.top),
119
+ w: Math.round(rect.width),
120
+ h: Math.round(rect.height),
121
+ },
122
+ };
123
+ }
124
+
125
+ function rectsIntersect(
126
+ a: { left: number; top: number; right: number; bottom: number },
127
+ b: { left: number; top: number; right: number; bottom: number }
128
+ ): boolean {
129
+ if (a.right < b.left || a.left > b.right) return false;
130
+ if (a.bottom < b.top || a.top > b.bottom) return false;
131
+ return true;
132
+ }
133
+
134
+ export function ElementMarqueeOverlay() {
135
+ ensureMarqueeStyles();
136
+ const selSet = useSelectionSet();
137
+ const { tool } = useToolMode();
138
+ const overlayRef = useRef<HTMLDivElement | null>(null);
139
+ const stateRef = useRef<{
140
+ pointerId: number;
141
+ startX: number;
142
+ startY: number;
143
+ crossed: boolean;
144
+ bodyEl: Element;
145
+ /** Snapshot of `selSet.selected` at gesture start — needed for subtract /
146
+ * intersect / add to compute deltas against the original set. */
147
+ startSet: Selection[];
148
+ } | null>(null);
149
+
150
+ useEffect(() => {
151
+ if (tool !== 'move') return;
152
+ if (typeof document === 'undefined') return;
153
+
154
+ const onDown = (e: PointerEvent) => {
155
+ if (e.button !== 0) return;
156
+ // Cmd / Ctrl reserved for single-element select (Cmd-click). Never start
157
+ // an element marquee when those are held — that gesture means "select
158
+ // the single element under cursor."
159
+ if (e.metaKey || e.ctrlKey) return;
160
+ const target = e.target as Element | null;
161
+ if (!target || isChromeTarget(target)) return;
162
+ const bodyEl = resolveBodyOrNull(target);
163
+ if (!bodyEl) return; // either over user content or outside any artboard
164
+ stateRef.current = {
165
+ pointerId: e.pointerId,
166
+ startX: e.clientX,
167
+ startY: e.clientY,
168
+ crossed: false,
169
+ bodyEl,
170
+ startSet: selSet.selected.slice(),
171
+ };
172
+ };
173
+
174
+ const onMove = (e: PointerEvent) => {
175
+ const s = stateRef.current;
176
+ if (!s || e.pointerId !== s.pointerId) return;
177
+ const dx = e.clientX - s.startX;
178
+ const dy = e.clientY - s.startY;
179
+ if (!s.crossed) {
180
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
181
+ s.crossed = true;
182
+ }
183
+ const div = overlayRef.current;
184
+ if (!div) return;
185
+ const left = Math.min(s.startX, e.clientX);
186
+ const top = Math.min(s.startY, e.clientY);
187
+ div.style.display = 'block';
188
+ div.style.left = `${left}px`;
189
+ div.style.top = `${top}px`;
190
+ div.style.width = `${Math.abs(dx)}px`;
191
+ div.style.height = `${Math.abs(dy)}px`;
192
+ };
193
+
194
+ const finish = (e: PointerEvent) => {
195
+ const s = stateRef.current;
196
+ if (!s || e.pointerId !== s.pointerId) return;
197
+ stateRef.current = null;
198
+ const div = overlayRef.current;
199
+ if (div) div.style.display = 'none';
200
+ if (!s.crossed) return;
201
+
202
+ const left = Math.min(s.startX, e.clientX);
203
+ const top = Math.min(s.startY, e.clientY);
204
+ const right = Math.max(s.startX, e.clientX);
205
+ const bottom = Math.max(s.startY, e.clientY);
206
+ const marqueeBox = { left, top, right, bottom };
207
+
208
+ // Intersect against every stamped element inside the originating
209
+ // artboard body. Scoping to the start body is intentional — drags
210
+ // that extend across artboards still only capture from the artboard
211
+ // the gesture started in. Cross-artboard marquee is an unimplemented
212
+ // affordance (no peer tool ships it; users rely on multi-artboard
213
+ // distribute + per-artboard marquees).
214
+ const stamped = s.bodyEl.querySelectorAll('[data-cd-id]');
215
+ const hits: Selection[] = [];
216
+ for (const el of stamped) {
217
+ const b = (el as HTMLElement).getBoundingClientRect();
218
+ if (b.width === 0 && b.height === 0) continue;
219
+ if (!rectsIntersect(marqueeBox, b)) continue;
220
+ const sel = elementToSelection(el, s.bodyEl);
221
+ if (sel) hits.push(sel);
222
+ }
223
+
224
+ const mode = modeOf(e);
225
+ applyMarqueeMode(selSet, mode, s.startSet, hits);
226
+ };
227
+
228
+ document.addEventListener('pointerdown', onDown, true);
229
+ document.addEventListener('pointermove', onMove);
230
+ document.addEventListener('pointerup', finish);
231
+ document.addEventListener('pointercancel', finish);
232
+ return () => {
233
+ document.removeEventListener('pointerdown', onDown, true);
234
+ document.removeEventListener('pointermove', onMove);
235
+ document.removeEventListener('pointerup', finish);
236
+ document.removeEventListener('pointercancel', finish);
237
+ };
238
+ }, [tool, selSet]);
239
+
240
+ return <div ref={overlayRef} className="dc-cv-elem-marquee" aria-hidden="true" />;
241
+ }
242
+
243
+ /**
244
+ * Apply marquee hits to `selSet` per Aseprite modifier semantics. Pulled out
245
+ * of the hook for unit-test reachability.
246
+ */
247
+ export function applyMarqueeMode(
248
+ selSet: {
249
+ replace: (s: Selection | Selection[]) => void;
250
+ add: (s: Selection | Selection[]) => void;
251
+ },
252
+ mode: Mode,
253
+ startSet: Selection[],
254
+ hits: Selection[]
255
+ ): void {
256
+ if (mode === 'replace') {
257
+ if (hits.length === 0) return; // empty marquee preserves selection
258
+ selSet.replace(hits);
259
+ return;
260
+ }
261
+ if (mode === 'add') {
262
+ if (hits.length === 0) return;
263
+ selSet.add(hits);
264
+ return;
265
+ }
266
+ if (mode === 'subtract') {
267
+ const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
268
+ const removeKeys = new Set(hits.map(keyOf));
269
+ const next = startSet.filter((x) => !removeKeys.has(keyOf(x)));
270
+ selSet.replace(next);
271
+ return;
272
+ }
273
+ // intersect
274
+ const keyOf = (x: Selection) => (x.id ? `id:${x.id}` : `sel:${x.selector}`);
275
+ const hitKeys = new Set(hits.map(keyOf));
276
+ const next = startSet.filter((x) => hitKeys.has(keyOf(x)));
277
+ selSet.replace(next);
278
+ }
279
+
280
+ // Re-export for tests.
281
+ export { modeOf };
282
+ export type { Mode };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @file participants-chrome.tsx — top-right avatar stack of live peers
3
+ * @scope plugins/design/dev-server/participants-chrome.tsx
4
+ * @purpose Phase 8 Task 6 — show who's currently on the canvas. Colored
5
+ * initials in a circle per peer, with a popover for full name +
6
+ * "Follow" button. Follow mode publishes `followTarget` on my
7
+ * Awareness; a separate effect pulls the target's viewport on
8
+ * every update and calls controller.setViewport, snapping my pan
9
+ * + zoom to theirs in lockstep.
10
+ *
11
+ * Follow semantics — soft and one-way. The target peer doesn't know they're
12
+ * being followed (no UI, no Awareness echo). Either party panning the canvas
13
+ * breaks nothing on the other end; I just keep mirroring until I click my
14
+ * own avatar to release. tldraw-style.
15
+ */
16
+
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
18
+
19
+ import { useViewportControllerContext } from './canvas-lib.tsx';
20
+ import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
21
+
22
+ const CHROME_CSS = `
23
+ .dc-participants {
24
+ position: fixed;
25
+ top: 12px;
26
+ right: 12px;
27
+ z-index: 10000;
28
+ display: flex;
29
+ align-items: center;
30
+ pointer-events: auto;
31
+ font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
32
+ }
33
+ .dc-participant {
34
+ width: 26px;
35
+ height: 26px;
36
+ border-radius: 50%;
37
+ display: inline-flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ font-weight: 600;
41
+ font-size: 10px;
42
+ line-height: 1;
43
+ letter-spacing: 0.02em;
44
+ color: var(--maude-hud-accent-fg, #fff);
45
+ border: 1.5px solid var(--bg-0, #fff);
46
+ margin-left: -6px;
47
+ cursor: pointer;
48
+ position: relative;
49
+ box-shadow: 0 1px 2px rgba(0,0,0,0.08);
50
+ user-select: none;
51
+ transition: transform 120ms ease, z-index 0ms 120ms;
52
+ }
53
+ .dc-participant:first-child { margin-left: 0; }
54
+ .dc-participant:hover {
55
+ transform: scale(1.06);
56
+ z-index: 10;
57
+ transition-delay: 0ms;
58
+ }
59
+ .dc-participant:focus-visible {
60
+ outline: 2px solid var(--maude-hud-accent, oklch(56% 0.170 50));
61
+ outline-offset: 2px;
62
+ }
63
+ .dc-participant--following {
64
+ outline: 1.5px solid var(--maude-hud-accent, oklch(56% 0.170 50));
65
+ outline-offset: 1.5px;
66
+ }
67
+ .dc-participant-popover {
68
+ position: absolute;
69
+ top: calc(100% + 8px);
70
+ right: 0;
71
+ min-width: 168px;
72
+ padding: 10px 12px;
73
+ background: var(--bg-1, #fff);
74
+ color: var(--fg-0, #111);
75
+ border: 1px solid var(--border-default, rgba(0,0,0,0.16));
76
+ border-radius: var(--radius-md, 4px);
77
+ font-size: 12px;
78
+ line-height: 1.35;
79
+ white-space: nowrap;
80
+ box-shadow: 0 6px 18px rgba(0,0,0,0.10);
81
+ z-index: 11;
82
+ }
83
+ .dc-participant-popover__name {
84
+ font-weight: 600;
85
+ margin-bottom: 8px;
86
+ color: var(--fg-0, #111);
87
+ letter-spacing: 0.01em;
88
+ }
89
+ .dc-participant-popover__btn {
90
+ display: inline-block;
91
+ padding: 4px 10px;
92
+ background: var(--maude-hud-accent, oklch(56% 0.170 50));
93
+ color: var(--maude-hud-accent-fg, #fff);
94
+ border: none;
95
+ border-radius: var(--radius-sm, 2px);
96
+ font-family: inherit;
97
+ font-weight: 500;
98
+ font-size: 11px;
99
+ line-height: 1.3;
100
+ cursor: pointer;
101
+ letter-spacing: 0.02em;
102
+ }
103
+ .dc-participant-popover__btn:hover { background: var(--maude-hud-accent-hover, var(--maude-hud-accent, oklch(50% 0.170 50))); }
104
+ .dc-participant-popover__btn--stop {
105
+ background: var(--bg-3, #e5e5e5);
106
+ color: var(--fg-0, #111);
107
+ border: 1px solid var(--border-subtle, rgba(0,0,0,0.12));
108
+ }
109
+ .dc-participant-popover__btn--stop:hover { background: var(--bg-4, #d8d8d8); }
110
+ @media (prefers-reduced-motion: reduce) {
111
+ .dc-participant { transition: none; }
112
+ }
113
+ `.trim();
114
+
115
+ function ensureChromeStyles(): void {
116
+ if (typeof document === 'undefined') return;
117
+ if (document.getElementById('dc-participants-css')) return;
118
+ const s = document.createElement('style');
119
+ s.id = 'dc-participants-css';
120
+ s.textContent = CHROME_CSS;
121
+ document.head.appendChild(s);
122
+ }
123
+
124
+ function initialsFor(name: string): string {
125
+ const cleaned = name.trim();
126
+ if (!cleaned) return '?';
127
+ const parts = cleaned.split(/\s+/).filter(Boolean);
128
+ const first = parts[0] ?? '';
129
+ if (parts.length === 1) return first.slice(0, 2).toUpperCase();
130
+ const second = parts[1] ?? '';
131
+ return ((first[0] ?? '') + (second[0] ?? '')).toUpperCase();
132
+ }
133
+
134
+ interface AvatarProps {
135
+ peer: ForeignAwareness;
136
+ isFollowing: boolean;
137
+ onToggleFollow: (clientID: number) => void;
138
+ }
139
+
140
+ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element {
141
+ const [open, setOpen] = useState(false);
142
+ const rootRef = useRef<HTMLDivElement | null>(null);
143
+
144
+ useEffect(() => {
145
+ if (!open) return;
146
+ const onDocClick = (e: MouseEvent) => {
147
+ if (!rootRef.current) return;
148
+ if (!rootRef.current.contains(e.target as Node)) setOpen(false);
149
+ };
150
+ document.addEventListener('mousedown', onDocClick);
151
+ return () => document.removeEventListener('mousedown', onDocClick);
152
+ }, [open]);
153
+
154
+ return (
155
+ <div
156
+ ref={rootRef}
157
+ className={`dc-participant${isFollowing ? ' dc-participant--following' : ''}`}
158
+ style={{ background: peer.color }}
159
+ onClick={() => setOpen((v) => !v)}
160
+ onKeyDown={(e) => {
161
+ if (e.key === 'Enter' || e.key === ' ') {
162
+ e.preventDefault();
163
+ setOpen((v) => !v);
164
+ }
165
+ }}
166
+ // biome-ignore lint/a11y/useSemanticElements: 28px round chip needs raw <div> + inline bg + custom focus ring; <button> reset is more code than the rule saves.
167
+ role="button"
168
+ tabIndex={0}
169
+ title={peer.name}
170
+ aria-label={peer.name}
171
+ aria-expanded={open}
172
+ >
173
+ {initialsFor(peer.name)}
174
+ {open && (
175
+ // biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; keyboard focus stays in the button child.
176
+ // biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role; the inner <button> is the actionable element.
177
+ <div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
178
+ <div className="dc-participant-popover__name">{peer.name}</div>
179
+ <button
180
+ type="button"
181
+ className={`dc-participant-popover__btn${isFollowing ? ' dc-participant-popover__btn--stop' : ''}`}
182
+ onClick={() => {
183
+ onToggleFollow(peer.clientID);
184
+ setOpen(false);
185
+ }}
186
+ >
187
+ {isFollowing ? 'Stop following' : `Follow ${peer.name.split(/\s+/)[0]}`}
188
+ </button>
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
194
+
195
+ export function ParticipantsChrome(): JSX.Element | null {
196
+ ensureChromeStyles();
197
+ const collab = useCollab();
198
+ const peers = useForeignAwareness();
199
+ const controller = useViewportControllerContext();
200
+
201
+ const [followTarget, setFollowTarget] = useState<number | null>(null);
202
+
203
+ const onToggleFollow = useCallback(
204
+ (clientID: number) => {
205
+ setFollowTarget((prev) => (prev === clientID ? null : clientID));
206
+ if (collab) {
207
+ collab.publishAwareness({
208
+ selection: collab.awareness.getLocalState()?.selection ?? null,
209
+ });
210
+ // Tag follow target as an Awareness key so Phase 9 follow-back UIs
211
+ // can read it; not currently used by anything else.
212
+ const state = collab.awareness.getLocalState() ?? {};
213
+ collab.awareness.setLocalState({
214
+ ...state,
215
+ followTarget: clientID === followTarget ? null : clientID,
216
+ });
217
+ }
218
+ },
219
+ [collab, followTarget]
220
+ );
221
+
222
+ // Apply follow — when followTarget is set, mirror the target's viewport
223
+ // onto our local controller. Each peer-Awareness update re-fires this; we
224
+ // skip when the viewport hasn't actually changed (saves a controller write).
225
+ const lastAppliedRef = useRef<{ x: number; y: number; zoom: number } | null>(null);
226
+ useEffect(() => {
227
+ if (followTarget === null) {
228
+ lastAppliedRef.current = null;
229
+ return;
230
+ }
231
+ if (!controller) return;
232
+ const target = peers.find((p) => p.clientID === followTarget);
233
+ if (!target) {
234
+ // Target peer disconnected; release follow.
235
+ setFollowTarget(null);
236
+ return;
237
+ }
238
+ const v = target.viewport;
239
+ const last = lastAppliedRef.current;
240
+ if (last && last.x === v.x && last.y === v.y && last.zoom === v.zoom) return;
241
+ lastAppliedRef.current = { x: v.x, y: v.y, zoom: v.zoom };
242
+ controller.setViewport(v);
243
+ }, [controller, followTarget, peers]);
244
+
245
+ if (peers.length === 0) return null;
246
+
247
+ return (
248
+ <div className="dc-participants" aria-label="Active collaborators">
249
+ {peers.map((p) => (
250
+ <Avatar
251
+ key={p.clientID}
252
+ peer={p}
253
+ isFollowing={followTarget === p.clientID}
254
+ onToggleFollow={onToggleFollow}
255
+ />
256
+ ))}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ export { initialsFor };
@@ -53,6 +53,21 @@ export const RUNTIME_PACKAGES = [
53
53
  // (DDR-024 deferred path) and any high-end designer-tool overlays a canvas
54
54
  // wants to draw via WebGL.
55
55
  'pixi.js',
56
+ // Phase 3.7 / DDR-049 — Motion One (motion/react) is the canonical motion
57
+ // library for the canvas-lib + handoff pipeline. Externalised here so the
58
+ // canvas-lib motion helpers (<MotionDemo>, etc.) resolve through the same
59
+ // importmap path; consumers of /design:handoff still see a "motion" peer
60
+ // dep declaration on the registry-item.json output.
61
+ 'motion',
62
+ 'motion/react',
63
+ // Phase 8 / DDR-051 — Yjs + y-protocols for the canvas-shell collab client.
64
+ // Each canvas iframe opens a WS to /_ws/collab/:slug and owns a per-iframe
65
+ // Y.Doc + Awareness instance. Externalised here so the client-side
66
+ // use-collab.tsx + cursors-overlay.tsx resolve via the importmap; canvases
67
+ // that don't use collab pay zero bundle cost (tree-shaken).
68
+ 'yjs',
69
+ 'y-protocols/sync',
70
+ 'y-protocols/awareness',
56
71
  ] as const;
57
72
 
58
73
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];