@1agh/maude 0.20.0 → 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 (69) 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 +16 -8
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  43. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  44. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  45. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  46. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  47. package/plugins/design/dev-server/http.ts +64 -1
  48. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  49. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  50. package/plugins/design/dev-server/runtime-bundle.ts +8 -0
  51. package/plugins/design/dev-server/server.ts +78 -11
  52. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  53. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  54. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  55. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  56. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  57. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  58. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  59. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  60. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  61. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  62. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  63. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  64. package/plugins/design/dev-server/use-collab.tsx +478 -0
  65. package/plugins/design/dev-server/ws.ts +123 -7
  66. package/plugins/design/templates/_shell.html +35 -1
  67. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  68. package/plugins/flow/dependencies.json +143 -0
  69. package/plugins/flow/dependencies.schema.json +107 -0
@@ -139,7 +139,7 @@ const DIALOG_CSS = `
139
139
  .dc-export-dialog .dc-ed-recent button:hover { background: rgba(0,0,0,0.04); border-color: rgba(0,0,0,0.06); }
140
140
  .dc-export-dialog footer { padding: 12px 20px; border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); display: flex; justify-content: flex-end; gap: 8px; }
141
141
  .dc-export-dialog footer button { padding: 8px 14px; border-radius: 0; border: 1px solid var(--u-fg-0, rgba(0,0,0,0.12)); background: var(--u-bg-1, var(--bg-0, #fafafa)); font: inherit; font-size: 12px; cursor: pointer; color: inherit; }
142
- .dc-export-dialog footer button.dc-ed-primary { background: var(--accent, #1a1a1a); color: var(--accent-fg, #fff); border-color: transparent; }
142
+ .dc-export-dialog footer button.dc-ed-primary { background: var(--maude-hud-accent, #1a1a1a); color: var(--maude-hud-accent-fg, #fff); border-color: transparent; }
143
143
  .dc-export-dialog footer button:disabled { opacity: 0.4; cursor: not-allowed; }
144
144
  .dc-export-dialog .dc-ed-status { padding: 8px 20px; font-size: 12px; color: var(--fg-1, rgba(40,30,20,0.65)); border-top: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08)); }
145
145
  .dc-export-dialog .dc-ed-status.is-error { color: var(--status-error, #c0392b); }
@@ -25,10 +25,13 @@ const DEBOUNCE_MS = 50;
25
25
 
26
26
  export interface HmrMessage {
27
27
  type: 'canvas-hmr';
28
- mode: 'css' | 'module' | 'hard';
28
+ mode: 'css' | 'module' | 'hard' | 'meta';
29
29
  /**
30
30
  * Canvas-relative path of the file that changed, slash-normalised. Absent
31
31
  * when mode === 'hard' (the change is global — every canvas reloads).
32
+ *
33
+ * For mode === 'meta' this is the `<base>.meta.json` path; iframe peels off
34
+ * the `.meta.json` suffix to compare against its own `canvasRel`.
32
35
  */
33
36
  file?: string;
34
37
  /** Cache-bust token — etag-like. Caller appends to <link> href. */
@@ -70,6 +73,13 @@ export function createHmrBroadcaster(
70
73
  if (ext === '.css') {
71
74
  return { type: 'canvas-hmr', mode: 'css', file: rel, version, scope: 'canvas' };
72
75
  }
76
+ // Phase 8 — canvas-meta sidecar (`<base>.meta.json`) carries the
77
+ // artboard layout / viewport. Emit a `meta` mode so foreign tabs can
78
+ // re-fetch + re-apply the layout WITHOUT a full reload (which would
79
+ // lose React state like tool mode, undo stack, scroll position).
80
+ if (rel.endsWith('.meta.json')) {
81
+ return { type: 'canvas-hmr', mode: 'meta', file: rel, version, scope: 'canvas' };
82
+ }
73
83
  if (ext === '.tsx' || ext === '.jsx' || ext === '.ts' || ext === '.js') {
74
84
  return { type: 'canvas-hmr', mode: 'module', file: rel, version, scope: 'canvas' };
75
85
  }
@@ -80,7 +90,10 @@ export function createHmrBroadcaster(
80
90
  // Coalesce: hard > module > css. If a hard reload is queued, ignore any
81
91
  // softer follow-up within the debounce window.
82
92
  if (pendingMsg) {
83
- const rank: Record<HmrMessage['mode'], number> = { css: 0, module: 1, hard: 2 };
93
+ // meta is the lightest signal it doesn't trigger a reload, just
94
+ // re-fetches the sidecar. CSS still ranks above it so a same-window
95
+ // CSS write wins over a meta echo.
96
+ const rank: Record<HmrMessage['mode'], number> = { meta: 0, css: 1, module: 2, hard: 3 };
84
97
  if (rank[msg.mode] < rank[pendingMsg.mode]) {
85
98
  // Keep the existing (harder) message; just refresh the timer.
86
99
  } else {
@@ -11,6 +11,7 @@ import type { Api } from './api.ts';
11
11
  import { buildCanvasModule } from './canvas-build.ts';
12
12
  import { canvasLibPath } from './canvas-lib-resolver.ts';
13
13
  import { TranspileError } from './canvas-pipeline.ts';
14
+ import type { AiActivity } from './collab/ai-activity.ts';
14
15
  import type { Context } from './context.ts';
15
16
  import { isFormat, isScope, runExport } from './exporters/index.ts';
16
17
  import type { ActiveJsonShape } from './exporters/scope.ts';
@@ -157,7 +158,7 @@ export interface Http {
157
158
  fetch(req: Request): Promise<Response>;
158
159
  }
159
160
 
160
- export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
161
+ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActivity): Http {
161
162
  // Cache invalidation — when canvas-lib changes, every cached canvas bundle
162
163
  // is stale because canvas-lib is inlined into each one via the resolver
163
164
  // plugin. Drop the whole cache so the next request rebuilds with the fresh
@@ -306,6 +307,68 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
306
307
  return Response.json({ committers }, { headers: { 'Cache-Control': 'no-store' } });
307
308
  },
308
309
 
310
+ '/_api/git-user': async (req: Request) => {
311
+ // Phase 8 — local `git config user.name` for the collab Awareness peer
312
+ // identity. Color-hash derives from this; falls back to anonymous-<pid>
313
+ // client-side when empty.
314
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
315
+ const name = await api.gitCurrentUser();
316
+ return Response.json({ name }, { headers: { 'Cache-Control': 'no-store' } });
317
+ },
318
+
319
+ '/_api/ai': async (req: Request) => {
320
+ // Phase 8 Task 4 — read-only snapshot of the current AI activity map.
321
+ // GET → { entries: [{ file, author, startedAt, lastHeartbeat }, …] }
322
+ // Clients use this on mount to backfill the banner state without
323
+ // waiting for the next bus event.
324
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
325
+ return Response.json({ entries: ai.list() }, { headers: { 'Cache-Control': 'no-store' } });
326
+ },
327
+
328
+ '/_api/ai/start': async (req: Request) => {
329
+ // Phase 8 Task 4 — `/design:edit` (or any external slash command driving
330
+ // Claude work) POSTs here when work begins. body = { file, author }.
331
+ // Replaces any prior entry for the file.
332
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
333
+ const body = await readJson<{ file?: string; author?: string }>(req);
334
+ if (!body || typeof body.file !== 'string' || !body.file.trim()) {
335
+ return new Response('body.file required', { status: 400 });
336
+ }
337
+ const author =
338
+ typeof body.author === 'string' && body.author.trim()
339
+ ? body.author.trim().slice(0, 120)
340
+ : 'Claude';
341
+ const entry = ai.start(body.file.trim(), author);
342
+ return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
343
+ },
344
+
345
+ '/_api/ai/heartbeat': async (req: Request) => {
346
+ // Refresh the lastHeartbeat. Returns 404 if no entry — slash command
347
+ // can treat that as "the server bounced; re-issue /start".
348
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
349
+ const body = await readJson<{ file?: string }>(req);
350
+ if (!body || typeof body.file !== 'string' || !body.file.trim()) {
351
+ return new Response('body.file required', { status: 400 });
352
+ }
353
+ const entry = ai.heartbeat(body.file.trim());
354
+ if (!entry) return new Response('no active entry', { status: 404 });
355
+ return Response.json(entry, { headers: { 'Cache-Control': 'no-store' } });
356
+ },
357
+
358
+ '/_api/ai/end': async (req: Request) => {
359
+ // Explicit completion (normal or error). Banner clears immediately.
360
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
361
+ const body = await readJson<{ file?: string }>(req);
362
+ if (!body || typeof body.file !== 'string' || !body.file.trim()) {
363
+ return new Response('body.file required', { status: 400 });
364
+ }
365
+ const cleared = ai.end(body.file.trim());
366
+ return Response.json(
367
+ { cleared },
368
+ { status: cleared ? 200 : 404, headers: { 'Cache-Control': 'no-store' } }
369
+ );
370
+ },
371
+
309
372
  '/_api/annotations': async (req: Request) => {
310
373
  // Phase 5 — `<designRoot>/<slug>.annotations.svg` read / overwrite.
311
374
  // GET ?file=<repo-relative-canvas-path> → SVG text (empty if absent)
@@ -31,8 +31,8 @@ const ELEM_MARQUEE_CSS = `
31
31
  position: fixed;
32
32
  pointer-events: none;
33
33
  z-index: 5;
34
- border: 1px solid var(--accent, #0d99ff);
35
- background: color-mix(in oklab, var(--accent, #0d99ff) 8%, transparent);
34
+ border: 1px solid var(--maude-hud-accent, #0d99ff);
35
+ background: color-mix(in oklab, var(--maude-hud-accent, #0d99ff) 8%, transparent);
36
36
  display: none;
37
37
  }
38
38
  `.trim();
@@ -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 };
@@ -60,6 +60,14 @@ export const RUNTIME_PACKAGES = [
60
60
  // dep declaration on the registry-item.json output.
61
61
  'motion',
62
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',
63
71
  ] as const;
64
72
 
65
73
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -18,12 +18,15 @@ import { spawn } from 'node:child_process';
18
18
 
19
19
  import { createApi } from './api.ts';
20
20
  import { bootSelfHeal } from './boot-self-heal.ts';
21
+ import { createAiActivity } from './collab/ai-activity.ts';
22
+ import { createGitLifecycle } from './collab/git-lifecycle.ts';
23
+ import { createCollab } from './collab/index.ts';
21
24
  import { createContext } from './context.ts';
22
25
  import { createFsWatch } from './fs-watch.ts';
23
26
  import { createHttp } from './http.ts';
24
27
  import { createInspect } from './inspect.ts';
25
28
  import { startHeapWatch } from './mem.ts';
26
- import { createWs } from './ws.ts';
29
+ import { type WsData, createWs, isLoopbackHost, parseCollabSlug } from './ws.ts';
27
30
 
28
31
  // Phase 19 / DDR-044 — covers the marketplace-cache-install gap where
29
32
  // node_modules/ ships empty (git clone honors .gitignore). Auto-installs +
@@ -32,17 +35,41 @@ await bootSelfHeal();
32
35
 
33
36
  const ctx = createContext();
34
37
 
35
- const api = createApi(ctx, async (file) => {
36
- // After every comments mutation, re-broadcast the updated list.
37
- const comments = await api.loadCommentsForFile(file);
38
- ctx.bus.emit('comments', { file, comments });
38
+ // Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
39
+ // reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
40
+ // synchronously below; the callback only fires at runtime, by which point the
41
+ // binding is set.
42
+ let collab: ReturnType<typeof createCollab> | null = null;
43
+
44
+ const api = createApi(ctx, {
45
+ onCommentsChanged: async (file) => {
46
+ // After every comments mutation, re-broadcast the updated list.
47
+ const comments = await api.loadCommentsForFile(file);
48
+ ctx.bus.emit('comments', { file, comments });
49
+ // Phase 8 Task 3 — bridge into the live Y.Array so collab peers see the
50
+ // change without waiting for cold-open re-seeding. No-op when no room is
51
+ // live for this canvas slug.
52
+ if (collab) {
53
+ collab.registry.syncRoomFromComments(api.fileSlug(file), comments);
54
+ }
55
+ },
56
+ // Phase 8 Task 5 — same bridge for annotations. PUT /_api/annotations writes
57
+ // the SVG blob to disk; we mirror it into the live Y.Map for collab peers.
58
+ onAnnotationsChanged: (file, svg) => {
59
+ if (collab) {
60
+ collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg);
61
+ }
62
+ },
39
63
  });
40
64
 
41
65
  const inspect = createInspect(ctx, (file) => api.loadCommentsForFile(file));
42
66
  await inspect.load();
43
67
 
44
- const ws = createWs(ctx, api, inspect);
45
- const http = createHttp(ctx, api, inspect);
68
+ collab = createCollab(ctx, api);
69
+ const aiActivity = createAiActivity(ctx);
70
+ const gitLifecycle = createGitLifecycle(ctx, collab.registry);
71
+ const ws = createWs(ctx, api, inspect, collab);
72
+ const http = createHttp(ctx, api, inspect, aiActivity);
46
73
  const fsWatch = createFsWatch(ctx);
47
74
 
48
75
  // Port: --port arg > $PORT > $MDCC_DEV_PORT > 4399.
@@ -59,21 +86,46 @@ function resolvePort(): { port: number; explicit: boolean } {
59
86
 
60
87
  const { port: BASE_PORT, explicit: PORT_EXPLICIT } = resolvePort();
61
88
 
62
- type BunServer = ReturnType<typeof Bun.serve<{ id: string; remote: string }, never>>;
89
+ type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
63
90
 
64
91
  function startServer(port: number): BunServer {
65
- return Bun.serve<{ id: string; remote: string }, never>({
92
+ return Bun.serve<WsData, never>({
66
93
  port,
67
94
  hostname: '127.0.0.1',
68
95
  development: process.env.NODE_ENV !== 'production',
69
96
  routes: http.routes,
70
97
  async fetch(req, srv) {
71
- // WebSocket upgrade.
72
- if (new URL(req.url).pathname.startsWith('/_ws')) {
98
+ const pathname = new URL(req.url).pathname;
99
+
100
+ // Phase 8 — collab WS, binary y-websocket protocol. Loopback-only;
101
+ // DDR-047 makes cross-machine collab a Phase 9 hub-deploy story, not
102
+ // a `--bind 0.0.0.0` flag on this server.
103
+ const collabSlug = parseCollabSlug(pathname);
104
+ if (collabSlug !== null) {
105
+ if (!isLoopbackHost(req.headers.get('host'))) {
106
+ return new Response('cross-machine collab requires Phase 9 hub deploy', {
107
+ status: 403,
108
+ });
109
+ }
73
110
  const ok = srv.upgrade(req, {
74
111
  data: {
75
112
  id: crypto.randomUUID(),
76
113
  remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
114
+ kind: 'collab',
115
+ slug: collabSlug,
116
+ },
117
+ });
118
+ if (ok) return undefined as unknown as Response;
119
+ return new Response('Upgrade failed', { status: 400 });
120
+ }
121
+
122
+ // Legacy inspector WS — JSON frames, designer-facing live tab state.
123
+ if (pathname.startsWith('/_ws')) {
124
+ const ok = srv.upgrade(req, {
125
+ data: {
126
+ id: crypto.randomUUID(),
127
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
128
+ kind: 'inspector',
77
129
  },
78
130
  });
79
131
  if (ok) return undefined as unknown as Response;
@@ -167,6 +219,21 @@ if (!process.env.NO_OPEN) {
167
219
  async function shutdown() {
168
220
  console.log('\n Stopping…');
169
221
  fsWatch.stop();
222
+ try {
223
+ if (collab) await collab.registry.destroyAll();
224
+ } catch {
225
+ /* best-effort flush; the JSON snapshot is the ground truth anyway */
226
+ }
227
+ try {
228
+ aiActivity.stop();
229
+ } catch {
230
+ /* janitor cleanup is best-effort */
231
+ }
232
+ try {
233
+ gitLifecycle.stop();
234
+ } catch {
235
+ /* watcher cleanup is best-effort */
236
+ }
170
237
  try {
171
238
  await Bun.write(ctx.paths.serverInfoFile, '').catch(() => {});
172
239
  // Remove the file by writing empty then unlinking.
@@ -0,0 +1,113 @@
1
+ // Unit: AI activity tracker (Phase 8 Task 4).
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { HEARTBEAT_GRACE_MS, createAiActivity } from '../collab/ai-activity.ts';
6
+ import { type Context, createBus } from '../context.ts';
7
+
8
+ function makeCtx(): { ctx: Context; events: { file: string; entry: unknown }[] } {
9
+ const events: { file: string; entry: unknown }[] = [];
10
+ const bus = createBus();
11
+ bus.on('ai-activity', (p: { file: string; entry: unknown }) => events.push(p));
12
+ const ctx = {
13
+ cfg: {} as Context['cfg'],
14
+ projectLabel: 'test',
15
+ paths: {} as Context['paths'],
16
+ bus,
17
+ };
18
+ return { ctx, events };
19
+ }
20
+
21
+ describe('AiActivity', () => {
22
+ test('start adds an entry and emits bus event', () => {
23
+ const { ctx, events } = makeCtx();
24
+ const clock = 1000;
25
+ const ai = createAiActivity(ctx, () => clock);
26
+ const entry = ai.start('ui/Foo.tsx', 'Claude');
27
+ expect(entry.file).toBe('ui/Foo.tsx');
28
+ expect(entry.author).toBe('Claude');
29
+ expect(entry.startedAt).toBe(1000);
30
+ expect(entry.lastHeartbeat).toBe(1000);
31
+ expect(events).toHaveLength(1);
32
+ expect(events[0]?.entry).toBe(entry);
33
+ ai.stop();
34
+ });
35
+
36
+ test('start preserves startedAt across restart for the same file', () => {
37
+ const { ctx } = makeCtx();
38
+ let clock = 1000;
39
+ const ai = createAiActivity(ctx, () => clock);
40
+ ai.start('ui/Foo.tsx', 'Claude');
41
+ clock = 5000;
42
+ const restarted = ai.start('ui/Foo.tsx', 'Claude');
43
+ expect(restarted.startedAt).toBe(1000);
44
+ expect(restarted.lastHeartbeat).toBe(5000);
45
+ ai.stop();
46
+ });
47
+
48
+ test('heartbeat refreshes lastHeartbeat + emits', () => {
49
+ const { ctx, events } = makeCtx();
50
+ let clock = 1000;
51
+ const ai = createAiActivity(ctx, () => clock);
52
+ ai.start('ui/Foo.tsx', 'Claude');
53
+ clock = 2000;
54
+ const refreshed = ai.heartbeat('ui/Foo.tsx');
55
+ expect(refreshed?.lastHeartbeat).toBe(2000);
56
+ expect(refreshed?.startedAt).toBe(1000);
57
+ // Two bus events — one for start, one for heartbeat.
58
+ expect(events.length).toBe(2);
59
+ ai.stop();
60
+ });
61
+
62
+ test('heartbeat returns null when no entry exists', () => {
63
+ const { ctx } = makeCtx();
64
+ const ai = createAiActivity(ctx);
65
+ expect(ai.heartbeat('ui/Absent.tsx')).toBeNull();
66
+ ai.stop();
67
+ });
68
+
69
+ test('end clears the entry and emits a null', () => {
70
+ const { ctx, events } = makeCtx();
71
+ const clock = 1000;
72
+ const ai = createAiActivity(ctx, () => clock);
73
+ ai.start('ui/Foo.tsx', 'Claude');
74
+ const ok = ai.end('ui/Foo.tsx');
75
+ expect(ok).toBe(true);
76
+ expect(ai.get('ui/Foo.tsx')).toBeNull();
77
+ const last = events.at(-1);
78
+ expect(last?.entry).toBeNull();
79
+ ai.stop();
80
+ });
81
+
82
+ test('end on absent entry returns false (no emit)', () => {
83
+ const { ctx, events } = makeCtx();
84
+ const ai = createAiActivity(ctx);
85
+ expect(ai.end('ui/Absent.tsx')).toBe(false);
86
+ expect(events.length).toBe(0);
87
+ ai.stop();
88
+ });
89
+
90
+ test('list returns all live entries', () => {
91
+ const { ctx } = makeCtx();
92
+ const clock = 1000;
93
+ const ai = createAiActivity(ctx, () => clock);
94
+ ai.start('ui/Foo.tsx', 'Claude');
95
+ ai.start('ui/Bar.tsx', 'Claude');
96
+ expect(ai.list().length).toBe(2);
97
+ ai.end('ui/Foo.tsx');
98
+ expect(ai.list().length).toBe(1);
99
+ ai.stop();
100
+ });
101
+
102
+ test('grace period — entry stays inside HEARTBEAT_GRACE_MS', () => {
103
+ const { ctx } = makeCtx();
104
+ let clock = 1000;
105
+ const ai = createAiActivity(ctx, () => clock);
106
+ ai.start('ui/Foo.tsx', 'Claude');
107
+ // 29 s later — still within grace.
108
+ clock = 1000 + HEARTBEAT_GRACE_MS - 1000;
109
+ // We don't auto-tick the janitor in tests; just confirm entry still exists.
110
+ expect(ai.get('ui/Foo.tsx')).not.toBeNull();
111
+ ai.stop();
112
+ });
113
+ });