@1agh/maude 0.22.2 → 0.24.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 (125) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +84 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +135 -10
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +222 -11
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -36,6 +36,15 @@ export interface PersistenceDeps {
36
36
  fileForSlug: (slug: string) => Promise<string | null>;
37
37
  /** Best-effort cache primer — see fileForSlug above. */
38
38
  noteFile?: (file: string) => void;
39
+ /**
40
+ * Phase 9.2 (DDR-064) — when this returns false for a slug, `seed` is a no-op
41
+ * for it. The shared-doc path passes a predicate that returns false for
42
+ * pinned (provider-attached) slugs, so the local file-seed can't push fresh
43
+ * Y.Array items that would DUPLICATE the hub's canonical items on merge
44
+ * (Risk 1). The migrate-seed + provider own initial population for those
45
+ * slugs. Absent → always seed (flag-OFF behavior, unchanged).
46
+ */
47
+ shouldSeed?: (slug: string) => boolean;
39
48
  }
40
49
 
41
50
  /**
@@ -50,6 +59,16 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
50
59
  const { ctx, api, fileForSlug } = deps;
51
60
  const stateDir = ensureStateDir(ctx.paths.designRoot);
52
61
 
62
+ // Per-slug latch: have we ever projected a NON-empty comments array for this
63
+ // canvas? We write an empty `[]` to disk only AFTER content has been seen — a
64
+ // genuine delete-all — never from a doc that was never populated (cold start
65
+ // before seed/migrate completes), which would clobber a non-empty local file.
66
+ // This closes the receiving-peer gap where a delete-all on one peer left
67
+ // stragglers on the other: the old `arr.length > 0` guard skipped the write
68
+ // when the array emptied, so the deletion never reached the peer's JSON file
69
+ // (the file the sidebar reads), even though the Y.Doc had converged (DDR-064).
70
+ const seenComments = new Set<string>();
71
+
53
72
  function ydocBinPath(slug: string): string {
54
73
  return path.join(stateDir, `${slug}.ydoc.bin`);
55
74
  }
@@ -66,6 +85,13 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
66
85
  }
67
86
 
68
87
  async function seed(slug: string, doc: Y.Doc): Promise<void> {
88
+ // Phase 9.2 (DDR-064) — under sharedDoc the migrate-seed + hub provider own
89
+ // initial population for a pinned slug; a local file-seed here would push
90
+ // fresh Y.Array items that DUPLICATE the hub's canonical items on merge
91
+ // (Risk 1). Skip seeding when the predicate says so. Flag-OFF / unpinned →
92
+ // always seeds (predicate absent or returns true).
93
+ if (deps.shouldSeed && !deps.shouldSeed(slug)) return;
94
+
69
95
  // Step 1 — try the binary cache.
70
96
  const binary = await readBinary(slug);
71
97
  if (binary && binary.byteLength > 0) {
@@ -100,10 +126,15 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
100
126
  const file = await fileForSlug(slug);
101
127
  if (!file) return;
102
128
 
103
- // Comments — Y.Array projection back to JSON.
129
+ // Comments — Y.Array projection back to JSON. Write whenever the doc holds
130
+ // comments, OR when it just emptied after previously holding some (so a
131
+ // delete-all materializes on EVERY peer's disk, not just the originator's).
132
+ // The seenComments latch keeps a never-populated doc (cold start) from
133
+ // clobbering a non-empty local file with [].
104
134
  const arr = doc.getArray(Y_TYPES.comments);
105
- if (arr.length > 0) {
106
- const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
135
+ const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
136
+ if (list.length > 0) seenComments.add(slug);
137
+ if (list.length > 0 || seenComments.has(slug)) {
107
138
  await api.saveCommentsForFile(file, list);
108
139
  }
109
140
 
@@ -6,15 +6,39 @@
6
6
  // call into (Phase 8 Task 7) to force-snapshot every dirty room before a reload
7
7
  // prompt — see DDR-051 §3.
8
8
 
9
+ import type { Awareness } from 'y-protocols/awareness';
10
+
11
+ import { bridgeAwareness } from './awareness-bridge.ts';
9
12
  import { Y_TYPES } from './persistence.ts';
10
13
  import type { Room, RoomCallbacks } from './room.ts';
11
14
  import { createRoom } from './room.ts';
12
15
 
16
+ /** Structural equality via canonical JSON — used by the syncRoomFrom* no-op
17
+ * guards. Comment lists are small; stringify is cheap + dependency-free. */
18
+ function jsonEqual(a: unknown, b: unknown): boolean {
19
+ return JSON.stringify(a) === JSON.stringify(b);
20
+ }
21
+
13
22
  export interface Registry {
14
23
  /** Get-or-create. Reuses an existing room for the same slug. */
15
24
  get(slug: string): Room;
16
25
  /** Existence check — returns the live room if any, else null. NEVER creates. */
17
26
  peek(slug: string): Room | null;
27
+ /**
28
+ * Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug, creating the
29
+ * owning Room if necessary. This is the seam the shared-doc path attaches its
30
+ * hub-facing `HocuspocusProvider` to (Phase B): the doc must exist
31
+ * independent of a live browser connection so the provider can attach at
32
+ * serve start. Repeated calls return the SAME instance (the room's doc) —
33
+ * the y-websocket-server `getYDoc(name)` single-cache pattern.
34
+ *
35
+ * Phase A: defined but unused (the flag-OFF two-doc path never calls it), so
36
+ * adding it is behavior-neutral. NOTE: a room created via `getDoc` (no
37
+ * browser conn) is NOT auto-dropped by the `size()===0` check in `drop` until
38
+ * Phase B teaches the lifecycle that an attached provider keeps it alive;
39
+ * for Phase A nothing calls `getDoc`, so no room leaks.
40
+ */
41
+ getDoc(slug: string): import('yjs').Doc;
18
42
  /**
19
43
  * Phase 8 Task 3 bridge — inspector-channel writes (REST `/_api/comments*`
20
44
  * or the legacy WS comments-add path) call this so the live Y.Array sees
@@ -34,6 +58,32 @@ export interface Registry {
34
58
  * updated stroke set without waiting for a cold-open re-seed.
35
59
  */
36
60
  syncRoomFromAnnotations(slug: string, svg: string): void;
61
+ /**
62
+ * Phase 9 Task 5 — attach the hub-side Awareness (from a sync provider) for
63
+ * a slug so the Room's Awareness (browser peers) is bridged bidirectionally
64
+ * to the hub. While attached, cursors / selections / viewport relay
65
+ * cross-machine through Hocuspocus. Idempotent per slug; returns a detach
66
+ * fn. If a room is already live the bridge wires immediately, otherwise it
67
+ * wires when the room is next created. Awareness is ephemeral — this writes
68
+ * no files (see awareness-bridge.ts on why F14 is untouched).
69
+ */
70
+ attachHubAwareness(slug: string, awareness: Awareness): () => void;
71
+ /**
72
+ * Phase 9.2 (DDR-064) — pin a room so `drop` won't destroy it when the last
73
+ * browser leaves. The shared-doc path pins a slug while a hub
74
+ * `HocuspocusProvider` is attached to its `getDoc(slug)`: destroying the
75
+ * room (→ `doc.destroy()`) would pull the doc out from under the live
76
+ * provider. Self-gating — only the flag-ON sync runtime calls `pin`, so the
77
+ * flag-OFF `drop` lifecycle is byte-for-byte unchanged. Idempotent.
78
+ */
79
+ pin(slug: string): void;
80
+ /** Release a pin (provider detached on runtime stop). Idempotent. */
81
+ unpin(slug: string): void;
82
+ /** True when a slug is pinned (a shared-doc provider is attached). Used to
83
+ * disable the room's local file-seed for that slug under sharedDoc — the
84
+ * migrate-seed + provider own initial population, so a duplicate file-seed
85
+ * can't re-introduce items (DDR-064 Risk 1). */
86
+ isPinned(slug: string): boolean;
37
87
  /** Flush every dirty room synchronously. DDR-051 branch-switch path. */
38
88
  flushAll(): Promise<void>;
39
89
  /** Tear down everything (e.g. on server shutdown). */
@@ -46,25 +96,87 @@ export interface Registry {
46
96
 
47
97
  export function createRegistry(callbacks: RoomCallbacks): Registry {
48
98
  const rooms = new Map<string, Room>();
99
+ // Hub-side Awareness per slug (lives as long as the sync provider). Rooms
100
+ // churn as browser tabs come and go; the bridge is re-wired each time a room
101
+ // is (re)created for a slug that has an attached hub Awareness.
102
+ const hubAwareness = new Map<string, Awareness>();
103
+ const bridges = new Map<string, () => void>();
104
+ // Phase 9.2 (DDR-064) — slugs whose room must survive the last-browser-leaves
105
+ // drop because a shared-doc hub provider is attached to its doc. Only the
106
+ // flag-ON sync runtime ever adds to this; flag-OFF leaves it empty so `drop`
107
+ // is unchanged.
108
+ const pinned = new Set<string>();
109
+
110
+ function wireBridge(slug: string, room: Room): void {
111
+ if (bridges.has(slug)) return;
112
+ const hub = hubAwareness.get(slug);
113
+ if (!hub) return;
114
+ bridges.set(slug, bridgeAwareness(room.awareness, hub));
115
+ }
116
+
117
+ function teardownBridge(slug: string): void {
118
+ const detach = bridges.get(slug);
119
+ if (detach) {
120
+ detach();
121
+ bridges.delete(slug);
122
+ }
123
+ }
49
124
 
50
125
  function get(slug: string): Room {
51
126
  let room = rooms.get(slug);
52
127
  if (!room) {
53
128
  room = createRoom(slug, callbacks);
54
129
  rooms.set(slug, room);
130
+ wireBridge(slug, room);
55
131
  }
56
132
  return room;
57
133
  }
58
134
 
135
+ function attachHubAwareness(slug: string, awareness: Awareness): () => void {
136
+ hubAwareness.set(slug, awareness);
137
+ const room = rooms.get(slug);
138
+ if (room) wireBridge(slug, room);
139
+ return () => {
140
+ teardownBridge(slug);
141
+ if (hubAwareness.get(slug) === awareness) hubAwareness.delete(slug);
142
+ };
143
+ }
144
+
59
145
  function peek(slug: string): Room | null {
60
146
  return rooms.get(slug) ?? null;
61
147
  }
62
148
 
149
+ function getDoc(slug: string): import('yjs').Doc {
150
+ // Reuse get-or-create so the doc, awareness, persistence schedule, and
151
+ // (idempotent) awareness-bridge wiring are all set up exactly once. The
152
+ // room's doc IS the single shared doc for this slug.
153
+ return get(slug).doc;
154
+ }
155
+
156
+ function pin(slug: string): void {
157
+ pinned.add(slug);
158
+ }
159
+
160
+ function unpin(slug: string): void {
161
+ pinned.delete(slug);
162
+ }
163
+
164
+ function isPinned(slug: string): boolean {
165
+ return pinned.has(slug);
166
+ }
167
+
63
168
  function syncRoomFromComments(slug: string, comments: readonly unknown[]): void {
64
169
  const room = rooms.get(slug);
65
170
  if (!room) return;
171
+ const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
172
+ // No-op guard (load-bearing): skip when the room already holds this exact
173
+ // list. The wholesale delete+push always emits a doc update, which schedules
174
+ // a persist → file write → fs event → re-seed … so without this equality
175
+ // short-circuit, re-seeding the live room from a disk change (sync-agent or
176
+ // design:edit write — see createCollab's fs hook) would spin an 800ms
177
+ // persist storm. Equality breaks the loop after a single convergence.
178
+ if (jsonEqual(arr.toArray(), comments)) return;
66
179
  room.doc.transact(() => {
67
- const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
68
180
  if (arr.length > 0) arr.delete(0, arr.length);
69
181
  if (comments.length > 0) arr.push(comments as unknown[]);
70
182
  }, 'inspector-write');
@@ -73,8 +185,9 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
73
185
  function syncRoomFromAnnotations(slug: string, svg: string): void {
74
186
  const room = rooms.get(slug);
75
187
  if (!room) return;
188
+ const map = room.doc.getMap<string>(Y_TYPES.annotations);
189
+ if (map.get('svg') === svg) return; // no-op guard — same rationale as comments
76
190
  room.doc.transact(() => {
77
- const map = room.doc.getMap<string>(Y_TYPES.annotations);
78
191
  map.set('svg', svg);
79
192
  }, 'inspector-write');
80
193
  }
@@ -87,11 +200,22 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
87
200
  const room = rooms.get(slug);
88
201
  if (!room) return;
89
202
  if (room.size() > 0) return; // still active, leave it
203
+ // Phase 9.2 (DDR-064) — a pinned room has a shared-doc hub provider attached
204
+ // to its doc; destroying it would yank the doc out from under the provider.
205
+ // Leave it (with zero browser conns) until runtime stop / server shutdown.
206
+ // Empty in the flag-OFF path → no behavior change.
207
+ if (pinned.has(slug)) return;
208
+ // Tear the bridge down before room.destroy() runs awareness.destroy() —
209
+ // a late relay must not fire against a dead Awareness. The hub Awareness
210
+ // stays registered, so a reconnecting browser re-wires via get().
211
+ teardownBridge(slug);
90
212
  rooms.delete(slug);
91
213
  await room.destroy();
92
214
  }
93
215
 
94
216
  async function destroyAll(): Promise<void> {
217
+ for (const slug of Array.from(bridges.keys())) teardownBridge(slug);
218
+ hubAwareness.clear();
95
219
  const all = Array.from(rooms.values());
96
220
  rooms.clear();
97
221
  await Promise.all(all.map((r) => r.destroy()));
@@ -100,8 +224,13 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
100
224
  return {
101
225
  get,
102
226
  peek,
227
+ getDoc,
103
228
  syncRoomFromComments,
104
229
  syncRoomFromAnnotations,
230
+ attachHubAwareness,
231
+ pin,
232
+ unpin,
233
+ isPinned,
105
234
  flushAll,
106
235
  destroyAll,
107
236
  drop,
@@ -73,6 +73,14 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
73
73
  const awareness = new Awareness(doc);
74
74
 
75
75
  const conns = new Map<string, RoomConn>();
76
+ // clientID → owning conn.id, learned from the origin of each awareness update.
77
+ // Server-authoritative: we do NOT trust a client-supplied `__connId` (awareness
78
+ // is attacker-influenceable through a semi-trusted hub — DDR-054). On disconnect
79
+ // we remove exactly the clientIDs this conn published so other peers drop the
80
+ // avatar immediately, instead of waiting out the ~30s awareness timeout (which
81
+ // let phantom peers pile up on rapid reconnects — the cross-origin repro that
82
+ // surfaced this latent bug).
83
+ const connByClient = new Map<number, string>();
76
84
  let dirty = false;
77
85
  let flushTimer: ReturnType<typeof setTimeout> | null = null;
78
86
  let destroyed = false;
@@ -138,6 +146,13 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
138
146
  if (changed.length === 0) return;
139
147
  const except =
140
148
  origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
149
+ // Learn which clientIDs belong to which conn from the update origin, so
150
+ // disconnect() can evict them precisely.
151
+ if (except) {
152
+ for (const id of added) connByClient.set(id, except.id);
153
+ for (const id of updated) connByClient.set(id, except.id);
154
+ for (const id of removed) connByClient.delete(id);
155
+ }
141
156
  broadcast(encodeAwarenessFrame(awareness, changed), except);
142
157
  }
143
158
  );
@@ -161,16 +176,14 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
161
176
 
162
177
  function disconnect(conn: RoomConn): void {
163
178
  conns.delete(conn.id);
164
- // Awareness states keyed by the conn token; clean them up so other peers'
165
- // cursor renderers can drop the avatar.
166
- const states = awareness.getStates();
179
+ // Remove the awareness states this conn published (tracked by update origin
180
+ // in connByClient) so other peers' cursor renderers drop the avatar now,
181
+ // not after the ~30s awareness timeout.
167
182
  const stale: number[] = [];
168
- for (const clientId of states.keys()) {
169
- // Bridge: our Awareness state setters tag the state with `__connId`
170
- // matching conn.id; remove states whose owning conn just left.
171
- const state = states.get(clientId) as { __connId?: string } | undefined;
172
- if (state && state.__connId === conn.id) stale.push(clientId);
183
+ for (const [clientId, connId] of connByClient) {
184
+ if (connId === conn.id) stale.push(clientId);
173
185
  }
186
+ for (const clientId of stale) connByClient.delete(clientId);
174
187
  if (stale.length) removeAwarenessStates(awareness, stale, conn);
175
188
  }
176
189
 
@@ -180,6 +180,26 @@
180
180
  "type": ["string", "null"],
181
181
  "description": "Name of the DS used when a canvas's .meta.json has no 'designSystem' field. For single-DS projects, this is the only DS. For multi-DS, pick the most common.",
182
182
  "default": null
183
+ },
184
+ "linkedHub": {
185
+ "type": "object",
186
+ "description": "Pairs this repo with a Maude hub for cross-machine file sync (Phase 9 Task 3+4). Written by 'maude design link <url> --token <hex>'. Token NEVER lives in this committed config — it's stored per-machine in ~/.config/maude/hubs.json. When present, 'maude design serve' starts the bidirectional Yjs↔fs sync agent (Task 4).",
187
+ "required": ["url", "linkedAt"],
188
+ "properties": {
189
+ "url": {
190
+ "type": "string",
191
+ "description": "Hub base URL (normalized — trailing slash trimmed, scheme + host lowercased). The sync agent constructs ws(s)://<host> from this for the HocuspocusProvider connection."
192
+ },
193
+ "linkedAt": {
194
+ "type": "number",
195
+ "description": "Unix-ms timestamp from when 'maude design link' was first run on this repo."
196
+ },
197
+ "adopt": {
198
+ "type": "boolean",
199
+ "description": "When true, the first sync after boot pushes local disk state up to the hub unconditionally (use case: bootstrapping the hub from a populated repo, or hub-was-wiped recovery). Cleared after first successful adopt."
200
+ }
201
+ },
202
+ "additionalProperties": false
183
203
  }
184
204
  }
185
205
  }
@@ -55,6 +55,15 @@ export interface MenuItem {
55
55
  shortcut?: string;
56
56
  destructive?: boolean;
57
57
  disabled?: boolean;
58
+ /**
59
+ * Optional nested flyout (e.g. `Theme ▸ DS default / Light / Dark / Follow
60
+ * chrome`). When present the row opens a submenu on hover / ArrowRight /
61
+ * click and `onSelect` on THIS item is not invoked — only the chosen leaf's
62
+ * `onSelect` fires. A disabled leaf carries `disabledHint` for its title.
63
+ */
64
+ submenu?: MenuItem[];
65
+ /** Hover/title hint shown when the item is `disabled` (a11y affordance). */
66
+ disabledHint?: string;
58
67
  onSelect: (target: ContextTarget) => void;
59
68
  }
60
69
 
@@ -158,20 +167,20 @@ const MENU_CSS = `
158
167
  .dc-context-menu {
159
168
  position: fixed;
160
169
  z-index: 7;
161
- background: var(--u-bg-0, var(--bg-0, #fff));
162
- border: 1px solid var(--u-fg-0, #1c1917);
170
+ background: var(--maude-chrome-bg-0, #fff);
171
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
163
172
  border-radius: 8px;
164
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
173
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
165
174
  padding: 4px;
166
175
  min-width: 220px;
167
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
176
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
168
177
  font-size: 12px;
169
- color: var(--u-fg-0, var(--fg-0, rgba(20,15,10,0.92)));
178
+ color: var(--maude-chrome-fg-0, rgba(20,15,10,0.92));
170
179
  user-select: none;
171
180
  }
172
181
  .dc-context-menu .dc-menu-sep {
173
182
  height: 1px;
174
- background: var(--border-subtle, rgba(0,0,0,0.08));
183
+ background: var(--maude-chrome-border, rgba(0,0,0,0.08));
175
184
  margin: 4px -4px;
176
185
  }
177
186
  .dc-context-menu .dc-menu-item {
@@ -191,7 +200,7 @@ const MENU_CSS = `
191
200
  }
192
201
  .dc-context-menu .dc-menu-item:hover,
193
202
  .dc-context-menu .dc-menu-item:focus-visible {
194
- background: var(--u-bg-3, var(--bg-3, rgba(0,0,0,0.05)));
203
+ background: color-mix(in oklab, var(--maude-chrome-fg-0, #1c1917) 8%, transparent);
195
204
  outline: none;
196
205
  }
197
206
  .dc-context-menu .dc-menu-item[disabled] {
@@ -204,11 +213,28 @@ const MENU_CSS = `
204
213
  color: var(--accent-fg, #fff);
205
214
  }
206
215
  .dc-context-menu .dc-menu-shortcut {
207
- color: var(--fg-2, rgba(40,30,20,0.55));
216
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55));
208
217
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
209
218
  font-size: 10px;
210
219
  font-variant-numeric: tabular-nums;
211
220
  }
221
+ /* Submenu flyout (Theme ▸ …). Anchored to the right of its parent row; flips
222
+ left near the viewport edge via .is-flip. Reuses .dc-menu-item styling. */
223
+ .dc-context-menu .dc-menu-sub { position: relative; }
224
+ .dc-context-menu .dc-menu-caret { color: var(--maude-chrome-fg-1, rgba(40,30,20,0.55)); font-size: 11px; }
225
+ .dc-context-menu .dc-menu-flyout {
226
+ position: absolute;
227
+ top: -5px;
228
+ left: calc(100% + 3px);
229
+ min-width: 196px;
230
+ padding: 4px;
231
+ background: var(--maude-chrome-bg-0, #fff);
232
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
233
+ border-radius: 8px;
234
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
235
+ z-index: 8;
236
+ }
237
+ .dc-context-menu .dc-menu-flyout.is-flip { left: auto; right: calc(100% + 3px); }
212
238
  `.trim();
213
239
 
214
240
  function ensureMenuStyles(): void {
@@ -346,21 +372,7 @@ function ContextMenuView({
346
372
  <div key={sectionKey} role="group">
347
373
  {si > 0 ? <div className="dc-menu-sep" aria-hidden="true" /> : null}
348
374
  {section.map((item) => (
349
- <button
350
- key={item.id}
351
- type="button"
352
- role="menuitem"
353
- disabled={item.disabled}
354
- className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
355
- onClick={() => {
356
- if (item.disabled) return;
357
- item.onSelect(target);
358
- onClose();
359
- }}
360
- >
361
- <span>{item.label}</span>
362
- {item.shortcut ? <span className="dc-menu-shortcut">{item.shortcut}</span> : null}
363
- </button>
375
+ <MenuItemRow key={item.id} item={item} target={target} onClose={onClose} />
364
376
  ))}
365
377
  </div>
366
378
  );
@@ -368,3 +380,135 @@ function ContextMenuView({
368
380
  </div>
369
381
  );
370
382
  }
383
+
384
+ // ─────────────────────────────────────────────────────────────────────────────
385
+ // MenuItemRow — a single menu row. Plain rows render a button; rows with a
386
+ // `submenu` render a flyout (opens on hover / ArrowRight / click). Additive:
387
+ // existing flat registries (no `submenu`) take the plain-button path unchanged.
388
+
389
+ function MenuItemRow({
390
+ item,
391
+ target,
392
+ onClose,
393
+ }: {
394
+ item: MenuItem;
395
+ target: ContextTarget;
396
+ onClose: () => void;
397
+ }) {
398
+ const [subOpen, setSubOpen] = useState(false);
399
+ const [flip, setFlip] = useState(false);
400
+ const btnRef = useRef<HTMLButtonElement>(null);
401
+ const flyoutRef = useRef<HTMLDivElement>(null);
402
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
403
+
404
+ if (!item.submenu || item.submenu.length === 0) {
405
+ return (
406
+ <button
407
+ type="button"
408
+ role="menuitem"
409
+ disabled={item.disabled}
410
+ title={item.disabled ? item.disabledHint : undefined}
411
+ className={`dc-menu-item${item.destructive ? ' is-destructive' : ''}`}
412
+ onClick={() => {
413
+ if (item.disabled) return;
414
+ item.onSelect(target);
415
+ onClose();
416
+ }}
417
+ >
418
+ <span>{item.label}</span>
419
+ {item.shortcut ? <span className="dc-menu-shortcut">{item.shortcut}</span> : null}
420
+ </button>
421
+ );
422
+ }
423
+
424
+ const cancelClose = () => {
425
+ if (closeTimer.current) {
426
+ clearTimeout(closeTimer.current);
427
+ closeTimer.current = null;
428
+ }
429
+ };
430
+ const open = (focusFirst = false) => {
431
+ cancelClose();
432
+ const r = btnRef.current?.getBoundingClientRect();
433
+ if (r && typeof window !== 'undefined') setFlip(r.right + 200 > window.innerWidth);
434
+ setSubOpen(true);
435
+ if (focusFirst) {
436
+ setTimeout(() => {
437
+ flyoutRef.current
438
+ ?.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')
439
+ ?.focus();
440
+ }, 0);
441
+ }
442
+ };
443
+ const scheduleClose = () => {
444
+ cancelClose();
445
+ closeTimer.current = setTimeout(() => setSubOpen(false), 140);
446
+ };
447
+
448
+ return (
449
+ <div className="dc-menu-sub" onMouseEnter={() => open()} onMouseLeave={scheduleClose}>
450
+ <button
451
+ ref={btnRef}
452
+ type="button"
453
+ role="menuitem"
454
+ aria-haspopup="menu"
455
+ aria-expanded={subOpen}
456
+ disabled={item.disabled}
457
+ className="dc-menu-item"
458
+ onClick={() => (subOpen ? setSubOpen(false) : open())}
459
+ onKeyDown={(e) => {
460
+ if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ') {
461
+ e.preventDefault();
462
+ open(true);
463
+ } else if (e.key === 'ArrowLeft' || e.key === 'Escape') {
464
+ if (subOpen) {
465
+ e.stopPropagation();
466
+ setSubOpen(false);
467
+ }
468
+ }
469
+ }}
470
+ >
471
+ <span>{item.label}</span>
472
+ <span className="dc-menu-caret" aria-hidden="true">
473
+
474
+ </span>
475
+ </button>
476
+ {subOpen ? (
477
+ <div
478
+ ref={flyoutRef}
479
+ className={`dc-menu-flyout${flip ? ' is-flip' : ''}`}
480
+ role="menu"
481
+ onMouseEnter={cancelClose}
482
+ onMouseLeave={scheduleClose}
483
+ >
484
+ {item.submenu.map((sub) => (
485
+ <button
486
+ key={sub.id}
487
+ type="button"
488
+ role="menuitem"
489
+ disabled={sub.disabled}
490
+ title={sub.disabled ? sub.disabledHint : undefined}
491
+ className={`dc-menu-item${sub.destructive ? ' is-destructive' : ''}`}
492
+ onClick={() => {
493
+ if (sub.disabled) return;
494
+ sub.onSelect(target);
495
+ onClose();
496
+ }}
497
+ onKeyDown={(e) => {
498
+ if (e.key === 'ArrowLeft' || e.key === 'Escape') {
499
+ e.preventDefault();
500
+ e.stopPropagation();
501
+ setSubOpen(false);
502
+ btnRef.current?.focus();
503
+ }
504
+ }}
505
+ >
506
+ <span>{sub.label}</span>
507
+ {sub.shortcut ? <span className="dc-menu-shortcut">{sub.shortcut}</span> : null}
508
+ </button>
509
+ ))}
510
+ </div>
511
+ ) : null}
512
+ </div>
513
+ );
514
+ }
@@ -25,6 +25,12 @@ export interface DesignSystemEntry {
25
25
  newComponentDir?: string;
26
26
  }
27
27
 
28
+ export interface LinkedHub {
29
+ url: string;
30
+ linkedAt: number;
31
+ adopt?: boolean;
32
+ }
33
+
28
34
  export interface DevServerConfig {
29
35
  name: string;
30
36
  projectLabel: string | null;
@@ -39,6 +45,7 @@ export interface DevServerConfig {
39
45
  handoffTargets: unknown[];
40
46
  newCanvasDir: string;
41
47
  newComponentDir: string;
48
+ linkedHub?: LinkedHub;
42
49
  _source: ConfigSource;
43
50
  }
44
51
 
@@ -110,6 +117,30 @@ export interface Context {
110
117
  projectLabel: string;
111
118
  paths: Paths;
112
119
  bus: Bus;
120
+ /**
121
+ * T2 (9.1-A) — origin of the segregated canvas-content server (e.g.
122
+ * `http://localhost:51234`), set by server.ts once the second listener binds.
123
+ * The client reads it via /_config to build absolute, cross-origin iframe
124
+ * URLs. Undefined in tests / before the canvas listener boots.
125
+ */
126
+ canvasOrigin?: string;
127
+ /**
128
+ * T2 (9.1-A) — origin of the MAIN dev-server listener (e.g.
129
+ * `http://localhost:4399`), set by server.ts once the primary listener binds.
130
+ * Used by `cspForCanvasShell` to allowlist the legit embedder in the canvas
131
+ * origin's `frame-ancestors`. Undefined in tests / before boot.
132
+ */
133
+ mainOrigin?: string;
134
+ /**
135
+ * Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. When true, the
136
+ * collab room's Y.Doc becomes the SINGLE shared doc per canvas: the
137
+ * hub-facing HocuspocusProvider attaches to it directly (no second doc, no
138
+ * disk-mediated reconcile). Default `false`/undefined = the proven two-doc
139
+ * path = zero regression. Set by server.ts from the env; tests set it
140
+ * directly on the Context they construct. Threaded through Phase A; the flag
141
+ * gates behavior starting in Phase B.
142
+ */
143
+ sharedDoc?: boolean;
113
144
  }
114
145
 
115
146
  function resolveRepoRoot(): string {
@@ -41,14 +41,14 @@ const CTX_TOOLBAR_CSS = `
41
41
  align-items: stretch;
42
42
  gap: 2px;
43
43
  padding: 4px;
44
- background: var(--u-bg-0, var(--bg-0, #ffffff));
45
- border: 1px solid var(--u-fg-0, #1c1917);
44
+ background: var(--maude-chrome-bg-0, #ffffff);
45
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
46
46
  border-radius: 8px;
47
- box-shadow: 0 6px 24px color-mix(in oklab, var(--u-fg-0, #1c1917) 10%, transparent);
48
- font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
47
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
48
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
49
49
  font-size: 11px;
50
50
  letter-spacing: 0.02em;
51
- color: var(--u-fg-0, #1a1a1a);
51
+ color: var(--maude-chrome-fg-0, #1a1a1a);
52
52
  user-select: none;
53
53
  opacity: 0;
54
54
  transition: opacity 100ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -72,8 +72,8 @@ const CTX_TOOLBAR_CSS = `
72
72
  }
73
73
  .dc-elem-ctx-tb .dc-elem-ctx-count {
74
74
  padding: 4px 8px 4px 10px;
75
- color: var(--fg-1, rgba(40,30,20,0.7));
76
- border-right: 1px solid var(--u-border-subtle, rgba(0,0,0,0.08));
75
+ color: var(--maude-chrome-fg-1, rgba(40,30,20,0.7));
76
+ border-right: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.08));
77
77
  margin-right: 2px;
78
78
  font-variant-numeric: tabular-nums;
79
79
  }