@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.
- package/README.md +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +32 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +126 -2
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +74 -10
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- 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.
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// canvas
|
|
31
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
//
|
|
165
|
-
// cursor renderers
|
|
166
|
-
|
|
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
|
|
169
|
-
|
|
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(--
|
|
162
|
-
border: 1px solid var(--
|
|
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,
|
|
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(--
|
|
176
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
168
177
|
font-size: 12px;
|
|
169
|
-
color: var(--
|
|
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
|
|
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:
|
|
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-
|
|
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
|
-
<
|
|
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 {
|