@1agh/maude 0.23.0 → 0.25.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 (127) 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 +32 -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 +126 -2
  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 +74 -10
  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/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dependencies.json +30 -2
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
  27. package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
  28. package/plugins/design/dev-server/api.ts +123 -4
  29. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  30. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  31. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  32. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  33. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  34. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  35. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  36. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  37. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  38. package/plugins/design/dev-server/bin/prep.sh +211 -0
  39. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  40. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  41. package/plugins/design/dev-server/bin/smoke.sh +208 -23
  42. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  43. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  44. package/plugins/design/dev-server/canvas-cursors.ts +173 -0
  45. package/plugins/design/dev-server/canvas-icons.tsx +299 -0
  46. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  47. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  48. package/plugins/design/dev-server/canvas-shell.tsx +404 -66
  49. package/plugins/design/dev-server/client/app.jsx +1231 -416
  50. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  51. package/plugins/design/dev-server/collab/index.ts +87 -9
  52. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  53. package/plugins/design/dev-server/collab/registry.ts +80 -2
  54. package/plugins/design/dev-server/collab/room.ts +21 -8
  55. package/plugins/design/dev-server/context-menu.tsx +167 -23
  56. package/plugins/design/dev-server/context.ts +24 -0
  57. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  58. package/plugins/design/dev-server/dist/client.bundle.js +364 -29
  59. package/plugins/design/dev-server/dist/comment-mount.js +78 -33
  60. package/plugins/design/dev-server/dist/styles.css +16 -0
  61. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  62. package/plugins/design/dev-server/export-dialog.tsx +208 -18
  63. package/plugins/design/dev-server/exporters/html.ts +4 -1
  64. package/plugins/design/dev-server/exporters/index.ts +4 -1
  65. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  66. package/plugins/design/dev-server/exporters/png.ts +21 -2
  67. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  68. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  69. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  70. package/plugins/design/dev-server/fs-watch.ts +1 -0
  71. package/plugins/design/dev-server/http.ts +299 -28
  72. package/plugins/design/dev-server/input-router.tsx +35 -5
  73. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  74. package/plugins/design/dev-server/server.ts +123 -1
  75. package/plugins/design/dev-server/sync/agent.ts +95 -0
  76. package/plugins/design/dev-server/sync/codec.ts +155 -0
  77. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  78. package/plugins/design/dev-server/sync/index.ts +479 -35
  79. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  80. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  81. package/plugins/design/dev-server/sync/origins.ts +57 -0
  82. package/plugins/design/dev-server/sync/projection.ts +368 -0
  83. package/plugins/design/dev-server/sync/status.ts +115 -0
  84. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  85. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  86. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  87. package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
  88. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
  89. package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
  90. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  91. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  92. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  93. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  94. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  95. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  96. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  97. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  98. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  99. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  100. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  101. package/plugins/design/dev-server/test/input-router.test.ts +32 -4
  102. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
  103. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  104. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  105. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  106. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  107. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  108. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  109. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  110. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  111. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  112. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  113. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
  114. package/plugins/design/dev-server/tool-palette.tsx +155 -24
  115. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  116. package/plugins/design/dev-server/undo-stack.ts +20 -4
  117. package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
  118. package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
  119. package/plugins/design/dev-server/ws.ts +40 -1
  120. package/plugins/design/templates/_shell.html +85 -10
  121. package/plugins/design/templates/canvas.tsx.template +13 -0
  122. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  123. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  124. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  125. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  127. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -799,6 +799,16 @@
799
799
  background: var(--u-status-error);
800
800
  }
801
801
  .sb-live .sb-key { color: var(--u-fg-1); }
802
+ /* DDR-060 / 9.1-D — linked-but-0-syncable indicator. Warn dot (heads-up, not
803
+ error), DS-tokened, hover the label for the full reason via the title attr. */
804
+ .sb-sync { cursor: help; }
805
+ .sb-sync-dot {
806
+ width: 7px; height: 7px;
807
+ background: var(--u-status-warn);
808
+ border-radius: 50%;
809
+ flex: none;
810
+ }
811
+ .sb-sync .sb-key { color: var(--u-fg-2); }
802
812
  .sb-spacer {
803
813
  flex: 1 0 auto;
804
814
  border-right: 1px solid var(--u-border-subtle);
@@ -1,6 +1,9 @@
1
1
  // Public surface of the collab module. Bundles the registry + persistence
2
2
  // wiring so ws.ts + server.ts don't need to know about the internal split.
3
3
 
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+
4
7
  import type { Api } from '../api.ts';
5
8
  import type { Context } from '../context.ts';
6
9
 
@@ -14,6 +17,8 @@ export type { CollabConn } from './protocol.ts';
14
17
 
15
18
  export interface Collab {
16
19
  registry: Registry;
20
+ /** Tear down the fs-driven re-seed subscription (server shutdown). */
21
+ dispose(): void;
17
22
  }
18
23
 
19
24
  /**
@@ -24,15 +29,21 @@ export interface Collab {
24
29
  */
25
30
  export function createCollab(ctx: Context, api: Api): Collab {
26
31
  // Inverse of api.fileSlug — we have the URL slug, need the canonical
27
- // repo-relative path the api expects. The current fileSlug() is destructive
28
- // (loses the extension + the designRel prefix), so we round-trip by
29
- // scanning loadAllComments cheap enough at room-open time, and a fresh
30
- // canvas with no comments yet just returns null (an empty Y.Doc is correct
31
- // in that case).
32
+ // repo-relative path the api expects.
33
+ //
34
+ // Primary: api.fileForSlug scans the ACTUAL canvas files, so it resolves even
35
+ // when the canvas has no comments yet. This is the fix for the receiving-peer
36
+ // projection gap — a peer that hasn't yet got a comment for a canvas must
37
+ // still locate the file to MATERIALIZE the first hub-pushed comment to disk
38
+ // (DDR-064). The old comments-file scan was chicken-and-egg here: no comments
39
+ // → null → persistJson bailed → the incoming comment never hit disk.
32
40
  //
33
- // Task 3 will tighten this to a slug -> file lookup table maintained from
34
- // the index/file-tree state so we don't need to read every JSON file.
41
+ // Fallback: a canvas whose file the scan missed (renamed/moved with an orphan
42
+ // comments file) still resolves via its existing comments file, preserving
43
+ // the prior behavior for that edge.
35
44
  async function fileForSlug(slug: string): Promise<string | null> {
45
+ const byCanvas = await api.fileForSlug(slug);
46
+ if (byCanvas) return byCanvas;
36
47
  const all = await api.loadAllComments();
37
48
  for (const [file] of Object.entries(all)) {
38
49
  if (api.fileSlug(file) === slug) return file;
@@ -40,8 +51,75 @@ export function createCollab(ctx: Context, api: Api): Collab {
40
51
  return null;
41
52
  }
42
53
 
43
- const persistence = createPersistence({ ctx, api, fileForSlug });
54
+ // Phase 9.2 (DDR-064) under sharedDoc, disable the room's local file-seed
55
+ // for pinned (provider-attached) slugs so it can't push duplicate Y.Array
56
+ // items against the hub's canonical items (Risk 1). The registry is created
57
+ // from the persistence callbacks, so break the cycle with a late-bound ref
58
+ // (the predicate is only consulted at room-seed time, after `registry` is
59
+ // assigned). Flag-OFF → predicate returns true → seed unchanged.
60
+ let registryRef: Registry | null = null;
61
+ const persistence = createPersistence({
62
+ ctx,
63
+ api,
64
+ fileForSlug,
65
+ shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
66
+ });
44
67
  const registry = createRegistry(persistence);
68
+ registryRef = registry;
69
+
70
+ // File = truth (the file-sync collaboration model + DDR-051): when a synced
71
+ // file changes on disk from OUTSIDE the API path — the sync agent writing a
72
+ // hub-pushed diff, or `design:edit` editing a JSON/SVG directly — fan the
73
+ // change back out to the browser, since none of these go through the API's
74
+ // onCommentsChanged. Two consumers, two reasons:
75
+ // - the live collab room (canvas pins / annotation strokes) — re-seeded so
76
+ // its in-memory doc stops clobbering the external change on next persist
77
+ // (the cross-machine "comment reverts to []" bug). Only when a room is
78
+ // mounted (peek, never create).
79
+ // - the shell's comments SIDEBAR — driven solely by the 'comments' WS event
80
+ // (app.jsx), which otherwise fires only on API writes. Emit it here too so
81
+ // a hub-pushed comment shows up on the peer's sidebar without a reload.
82
+ // The registry's no-op guards make an identical re-seed free, so this can't
83
+ // loop against the room's own persist (which also writes the file).
84
+ const reseedFromDisk = async (rel: string): Promise<void> => {
85
+ const cm = /^_comments\/(.+)\.json$/.exec(rel);
86
+ const am = /^(.+)\.annotations\.svg$/.exec(rel);
87
+ const slug = cm?.[1] ?? am?.[1];
88
+ if (!slug) return;
89
+ const abs = path.join(ctx.paths.designRoot, rel);
90
+ try {
91
+ if (cm) {
92
+ // Prefer the canonical loader (validates + default-fills the Comment
93
+ // shape) keyed by the canvas file; fall back to the raw array when the
94
+ // slug isn't mapped yet (e.g. a freshly-synced file with no prior load).
95
+ const file = await fileForSlug(slug);
96
+ const parsed = file
97
+ ? await api.loadCommentsForFile(file)
98
+ : JSON.parse(readFileSync(abs, 'utf8'));
99
+ if (!Array.isArray(parsed)) return;
100
+ // Phase 9.2 (DDR-064): under sharedDoc the hub provider is attached to
101
+ // the single room doc, so a hub-pushed comment is ALREADY in the room —
102
+ // the wholesale re-seed (last-writer-wins blob copy) is the retired
103
+ // clobber path. The agent's diff-aware applyFromFs handles external
104
+ // file→doc imports instead. The sidebar still needs the 'comments' bus
105
+ // emit, so keep that unconditionally.
106
+ if (!ctx.sharedDoc && registry.peek(slug)) registry.syncRoomFromComments(slug, parsed);
107
+ if (file) ctx.bus.emit('comments', { file, comments: parsed });
108
+ } else if (!ctx.sharedDoc && registry.peek(slug)) {
109
+ registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
110
+ }
111
+ } catch {
112
+ /* file vanished mid-flight or unreadable — leave state as-is */
113
+ }
114
+ };
115
+ const unsubFs = ctx.bus.on('fs:any', (rel: string) => {
116
+ void reseedFromDisk(rel);
117
+ });
45
118
 
46
- return { registry };
119
+ return {
120
+ registry,
121
+ dispose() {
122
+ unsubFs?.();
123
+ },
124
+ };
47
125
  }
@@ -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
 
@@ -13,11 +13,32 @@ import { Y_TYPES } from './persistence.ts';
13
13
  import type { Room, RoomCallbacks } from './room.ts';
14
14
  import { createRoom } from './room.ts';
15
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
+
16
22
  export interface Registry {
17
23
  /** Get-or-create. Reuses an existing room for the same slug. */
18
24
  get(slug: string): Room;
19
25
  /** Existence check — returns the live room if any, else null. NEVER creates. */
20
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;
21
42
  /**
22
43
  * Phase 8 Task 3 bridge — inspector-channel writes (REST `/_api/comments*`
23
44
  * or the legacy WS comments-add path) call this so the live Y.Array sees
@@ -47,6 +68,22 @@ export interface Registry {
47
68
  * no files (see awareness-bridge.ts on why F14 is untouched).
48
69
  */
49
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;
50
87
  /** Flush every dirty room synchronously. DDR-051 branch-switch path. */
51
88
  flushAll(): Promise<void>;
52
89
  /** Tear down everything (e.g. on server shutdown). */
@@ -64,6 +101,11 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
64
101
  // is (re)created for a slug that has an attached hub Awareness.
65
102
  const hubAwareness = new Map<string, Awareness>();
66
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>();
67
109
 
68
110
  function wireBridge(slug: string, room: Room): void {
69
111
  if (bridges.has(slug)) return;
@@ -104,11 +146,37 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
104
146
  return rooms.get(slug) ?? null;
105
147
  }
106
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
+
107
168
  function syncRoomFromComments(slug: string, comments: readonly unknown[]): void {
108
169
  const room = rooms.get(slug);
109
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;
110
179
  room.doc.transact(() => {
111
- const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
112
180
  if (arr.length > 0) arr.delete(0, arr.length);
113
181
  if (comments.length > 0) arr.push(comments as unknown[]);
114
182
  }, 'inspector-write');
@@ -117,8 +185,9 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
117
185
  function syncRoomFromAnnotations(slug: string, svg: string): void {
118
186
  const room = rooms.get(slug);
119
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
120
190
  room.doc.transact(() => {
121
- const map = room.doc.getMap<string>(Y_TYPES.annotations);
122
191
  map.set('svg', svg);
123
192
  }, 'inspector-write');
124
193
  }
@@ -131,6 +200,11 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
131
200
  const room = rooms.get(slug);
132
201
  if (!room) return;
133
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;
134
208
  // Tear the bridge down before room.destroy() runs awareness.destroy() —
135
209
  // a late relay must not fire against a dead Awareness. The hub Awareness
136
210
  // stays registered, so a reconnecting browser re-wires via get().
@@ -150,9 +224,13 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
150
224
  return {
151
225
  get,
152
226
  peek,
227
+ getDoc,
153
228
  syncRoomFromComments,
154
229
  syncRoomFromAnnotations,
155
230
  attachHubAwareness,
231
+ pin,
232
+ unpin,
233
+ isPinned,
156
234
  flushAll,
157
235
  destroyAll,
158
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
 
@@ -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
+ }
@@ -117,6 +117,30 @@ export interface Context {
117
117
  projectLabel: string;
118
118
  paths: Paths;
119
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;
120
144
  }
121
145
 
122
146
  function resolveRepoRoot(): string {