@1agh/maude 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +361 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +245 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +16 -8
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
- package/plugins/design/dev-server/annotations-layer.tsx +52 -12
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +33 -7
- package/plugins/design/dev-server/canvas-shell.tsx +127 -9
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/comments-overlay.tsx +29 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +64 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +19 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +37 -1
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- 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
|
-
|
|
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,25 @@ 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',
|
|
71
|
+
// Phase 8 follow-up — lib0 sub-paths are imported DIRECTLY by use-collab.tsx
|
|
72
|
+
// (a transitive dep of canvas-lib.tsx via canvas-shell). Without these in
|
|
73
|
+
// RUNTIME_PACKAGES the canvas Bun.build at request time tries to resolve
|
|
74
|
+
// `lib0/decoding` from the user's project node_modules and fails — lib0 is
|
|
75
|
+
// only a transitive dep of yjs and pnpm/npm don't hoist it to a path
|
|
76
|
+
// reachable from a user canvas entrypoint. Externalising routes the import
|
|
77
|
+
// through the importmap to a dedicated /_canvas-runtime/lib0_*.js bundle.
|
|
78
|
+
// Bug shipped in v0.21.0; every canvas that uses @maude/canvas-lib (i.e.
|
|
79
|
+
// every real canvas) returned HTTP 500 "Bundle failed".
|
|
80
|
+
'lib0/decoding',
|
|
81
|
+
'lib0/encoding',
|
|
63
82
|
] as const;
|
|
64
83
|
|
|
65
84
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
45
|
-
const
|
|
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<
|
|
89
|
+
type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
|
|
63
90
|
|
|
64
91
|
function startServer(port: number): BunServer {
|
|
65
|
-
return Bun.serve<
|
|
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
|
-
|
|
72
|
-
|
|
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.
|