@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
|
@@ -63,9 +63,33 @@ export function crossedDragThreshold(
|
|
|
63
63
|
* returns `no-op` for the corresponding pointer events so the SVG overlay
|
|
64
64
|
* can grab them natively.
|
|
65
65
|
*/
|
|
66
|
-
export type Tool =
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
export type Tool =
|
|
67
|
+
| 'move'
|
|
68
|
+
| 'hand'
|
|
69
|
+
| 'comment'
|
|
70
|
+
| 'pen'
|
|
71
|
+
// Phase 24 — `shape` is the single active draw tool that produces rect /
|
|
72
|
+
// ellipse / polygon strokes (the kind is chosen via the palette popover).
|
|
73
|
+
// `rect` / `ellipse` stay in the union as the stroke discriminants they
|
|
74
|
+
// always were, but are no longer directly selectable active tools.
|
|
75
|
+
| 'shape'
|
|
76
|
+
| 'rect'
|
|
77
|
+
| 'ellipse'
|
|
78
|
+
| 'arrow'
|
|
79
|
+
| 'sticky'
|
|
80
|
+
| 'text'
|
|
81
|
+
| 'eraser';
|
|
82
|
+
|
|
83
|
+
const ANNOTATION_TOOLS = new Set<Tool>([
|
|
84
|
+
'pen',
|
|
85
|
+
'shape',
|
|
86
|
+
'rect',
|
|
87
|
+
'ellipse',
|
|
88
|
+
'arrow',
|
|
89
|
+
'sticky',
|
|
90
|
+
'text',
|
|
91
|
+
'eraser',
|
|
92
|
+
]);
|
|
69
93
|
|
|
70
94
|
export function isAnnotationTool(t: Tool): boolean {
|
|
71
95
|
return ANNOTATION_TOOLS.has(t);
|
|
@@ -143,9 +167,15 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
143
167
|
if (k === 'h') return { kind: 'tool', tool: 'hand' };
|
|
144
168
|
if (k === 'c') return { kind: 'tool', tool: 'comment' };
|
|
145
169
|
if (k === 'b') return { kind: 'tool', tool: 'pen' };
|
|
146
|
-
|
|
147
|
-
|
|
170
|
+
// Phase 24 — R (and legacy O) both arm the single Shape tool; the specific
|
|
171
|
+
// primitive is picked from the palette's shape-kind popover.
|
|
172
|
+
if (k === 'r' || k === 'o') return { kind: 'tool', tool: 'shape' };
|
|
148
173
|
if (k === 'a') return { kind: 'tool', tool: 'arrow' };
|
|
174
|
+
// Phase 21 — N = sticky Note ('S' is taken by the shell Design-system view
|
|
175
|
+
// + Shift-marquee); T = standalone Text. Both are bare letters the shell
|
|
176
|
+
// yields when focus is inside the canvas iframe (app.jsx onKey bail).
|
|
177
|
+
if (k === 'n') return { kind: 'tool', tool: 'sticky' };
|
|
178
|
+
if (k === 't') return { kind: 'tool', tool: 'text' };
|
|
149
179
|
if (k === 'e') return { kind: 'tool', tool: 'eraser' };
|
|
150
180
|
if (input.key === 'Escape') return { kind: 'escape' };
|
|
151
181
|
return { kind: 'no-op' };
|
|
@@ -42,7 +42,7 @@ const CHROME_CSS = `
|
|
|
42
42
|
line-height: 1;
|
|
43
43
|
letter-spacing: 0.02em;
|
|
44
44
|
color: var(--maude-hud-accent-fg, #fff);
|
|
45
|
-
border: 1.5px solid var(--bg-0, #fff);
|
|
45
|
+
border: 1.5px solid var(--maude-chrome-bg-0, #fff);
|
|
46
46
|
margin-left: -6px;
|
|
47
47
|
cursor: pointer;
|
|
48
48
|
position: relative;
|
|
@@ -70,20 +70,20 @@ const CHROME_CSS = `
|
|
|
70
70
|
right: 0;
|
|
71
71
|
min-width: 168px;
|
|
72
72
|
padding: 10px 12px;
|
|
73
|
-
background: var(--bg-1, #fff);
|
|
74
|
-
color: var(--fg-0, #111);
|
|
75
|
-
border: 1px solid var(--border
|
|
73
|
+
background: var(--maude-chrome-bg-1, #fff);
|
|
74
|
+
color: var(--maude-chrome-fg-0, #111);
|
|
75
|
+
border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
|
|
76
76
|
border-radius: var(--radius-md, 4px);
|
|
77
77
|
font-size: 12px;
|
|
78
78
|
line-height: 1.35;
|
|
79
79
|
white-space: nowrap;
|
|
80
|
-
box-shadow: 0 6px 18px rgba(0,0,0,0.10);
|
|
80
|
+
box-shadow: 0 6px 18px var(--maude-chrome-shadow, rgba(0,0,0,0.10));
|
|
81
81
|
z-index: 11;
|
|
82
82
|
}
|
|
83
83
|
.dc-participant-popover__name {
|
|
84
84
|
font-weight: 600;
|
|
85
85
|
margin-bottom: 8px;
|
|
86
|
-
color: var(--fg-0, #111);
|
|
86
|
+
color: var(--maude-chrome-fg-0, #111);
|
|
87
87
|
letter-spacing: 0.01em;
|
|
88
88
|
}
|
|
89
89
|
.dc-participant-popover__btn {
|
|
@@ -102,11 +102,11 @@ const CHROME_CSS = `
|
|
|
102
102
|
}
|
|
103
103
|
.dc-participant-popover__btn:hover { background: var(--maude-hud-accent-hover, var(--maude-hud-accent, oklch(50% 0.170 50))); }
|
|
104
104
|
.dc-participant-popover__btn--stop {
|
|
105
|
-
background: var(--
|
|
106
|
-
color: var(--fg-0, #111);
|
|
107
|
-
border: 1px solid var(--border
|
|
105
|
+
background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 10%, transparent);
|
|
106
|
+
color: var(--maude-chrome-fg-0, #111);
|
|
107
|
+
border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.12));
|
|
108
108
|
}
|
|
109
|
-
.dc-participant-popover__btn--stop:hover { background: var(--
|
|
109
|
+
.dc-participant-popover__btn--stop:hover { background: color-mix(in oklab, var(--maude-chrome-fg-0, #111) 16%, transparent); }
|
|
110
110
|
@media (prefers-reduced-motion: reduce) {
|
|
111
111
|
.dc-participant { transition: none; }
|
|
112
112
|
}
|
|
@@ -36,6 +36,14 @@ await bootSelfHeal();
|
|
|
36
36
|
|
|
37
37
|
const ctx = createContext();
|
|
38
38
|
|
|
39
|
+
// Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. OPT-IN (default OFF),
|
|
40
|
+
// the inverse of MAUDE_CANVAS_ORIGIN_SPLIT's opt-out parsing: only an explicit
|
|
41
|
+
// truthy value enables the single-shared-doc path. OFF = the proven two-doc +
|
|
42
|
+
// disk-reconcile path = byte-for-byte current behavior. The flag is threaded
|
|
43
|
+
// onto ctx here (before createCollab / createSyncRuntime read it) so every
|
|
44
|
+
// downstream consumer sees one source of truth.
|
|
45
|
+
ctx.sharedDoc = /^(1|true|on|yes)$/i.test(process.env.MAUDE_SHARED_DOC ?? '');
|
|
46
|
+
|
|
39
47
|
// Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
|
|
40
48
|
// reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
|
|
41
49
|
// synchronously below; the callback only fires at runtime, by which point the
|
|
@@ -142,6 +150,87 @@ function startServer(port: number): BunServer {
|
|
|
142
150
|
});
|
|
143
151
|
}
|
|
144
152
|
|
|
153
|
+
// T2 (9.1-A) — the segregated canvas-content origin. A second Bun.serve on its
|
|
154
|
+
// own (OS-assigned) port, sharing this process's ctx / api / inspect / collab /
|
|
155
|
+
// ws. Canvas iframes load from here; hub-pushed JSX that executes in a canvas
|
|
156
|
+
// can therefore only reach THIS origin (locked further by the CSP on the shell
|
|
157
|
+
// + an iframe sandbox), never the main origin's /_api/export, /_config,
|
|
158
|
+
// /_sync-status, /_comments, or arbitrary repo files. Routes are a hard
|
|
159
|
+
// allowlist: Bun matches `routes` before `fetch`, so we expose ONLY the two
|
|
160
|
+
// gated API endpoints the runtime needs here and 403 everything else in fetch.
|
|
161
|
+
function startCanvasServer(port: number): BunServer {
|
|
162
|
+
return Bun.serve<WsData, never>({
|
|
163
|
+
port,
|
|
164
|
+
hostname: '127.0.0.1',
|
|
165
|
+
development: process.env.NODE_ENV !== 'production',
|
|
166
|
+
// Hard allowlist of route-table endpoints (Bun matches `routes` before
|
|
167
|
+
// `fetch`). Only the collab/display-data endpoints the canvas runtime needs
|
|
168
|
+
// — see http.isCanvasSafeRoute for the trust rationale. The dynamic
|
|
169
|
+
// /_api/comments/<id>/reply POST is fetch-handled + gated there.
|
|
170
|
+
routes: {
|
|
171
|
+
'/_health': http.routes['/_health'],
|
|
172
|
+
'/_api/git-user': http.routes['/_api/git-user'],
|
|
173
|
+
'/_api/canvas-meta': http.routes['/_api/canvas-meta'],
|
|
174
|
+
'/_api/annotations': http.routes['/_api/annotations'],
|
|
175
|
+
'/_api/git-committers': http.routes['/_api/git-committers'],
|
|
176
|
+
'/_api/ai': http.routes['/_api/ai'],
|
|
177
|
+
'/_comments': http.routes['/_comments'],
|
|
178
|
+
},
|
|
179
|
+
async fetch(req, srv) {
|
|
180
|
+
const pathname = new URL(req.url).pathname;
|
|
181
|
+
|
|
182
|
+
// Collab WS — shared registry, loopback-only (same gate as the main origin).
|
|
183
|
+
const collabSlug = parseCollabSlug(pathname);
|
|
184
|
+
if (collabSlug !== null) {
|
|
185
|
+
if (!isLoopbackHost(req.headers.get('host'))) {
|
|
186
|
+
return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
|
|
187
|
+
}
|
|
188
|
+
const ok = srv.upgrade(req, {
|
|
189
|
+
data: {
|
|
190
|
+
id: crypto.randomUUID(),
|
|
191
|
+
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
192
|
+
kind: 'collab',
|
|
193
|
+
slug: collabSlug,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
if (ok) return undefined as unknown as Response;
|
|
197
|
+
return new Response('Upgrade failed', { status: 400 });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// HMR-only socket — canvas iframes listen for `canvas-hmr` here. Carries
|
|
201
|
+
// NO privileged inspector feed and ignores inbound messages (ws.ts).
|
|
202
|
+
if (pathname.startsWith('/_ws')) {
|
|
203
|
+
const ok = srv.upgrade(req, {
|
|
204
|
+
data: {
|
|
205
|
+
id: crypto.randomUUID(),
|
|
206
|
+
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
207
|
+
kind: 'canvas-hmr',
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
if (ok) return undefined as unknown as Response;
|
|
211
|
+
return new Response('Upgrade failed', { status: 400 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Canvas mount harness with the strict CSP ALWAYS on (F1 gate).
|
|
215
|
+
if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
|
|
216
|
+
return http.serveCanvasShell(true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Allowlist gate — runtime bundles, comment-mount, transpiled .tsx + CSS/
|
|
220
|
+
// assets under designRoot. Everything else is refused at the door.
|
|
221
|
+
if (!http.isCanvasSafeRoute(pathname)) {
|
|
222
|
+
return new Response('Forbidden (canvas origin)', { status: 403 });
|
|
223
|
+
}
|
|
224
|
+
return http.fetch(req);
|
|
225
|
+
},
|
|
226
|
+
websocket: ws.handler,
|
|
227
|
+
error(e) {
|
|
228
|
+
console.error('[bun.serve canvas-origin error]', e);
|
|
229
|
+
return new Response('Server error', { status: 500 });
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
145
234
|
function isAddrInUse(e: unknown): boolean {
|
|
146
235
|
if (!e || typeof e !== 'object') return false;
|
|
147
236
|
const err = e as { code?: string; errno?: number };
|
|
@@ -181,6 +270,30 @@ let server: BunServer;
|
|
|
181
270
|
server = bound;
|
|
182
271
|
}
|
|
183
272
|
|
|
273
|
+
// T2 (9.1-A) — advertise the main origin so the canvas origin's CSP can
|
|
274
|
+
// allowlist it in `frame-ancestors` (the legit embedder). Must be set before
|
|
275
|
+
// the canvas listener serves its first shell.
|
|
276
|
+
ctx.mainOrigin = `http://localhost:${server.port}`;
|
|
277
|
+
|
|
278
|
+
// T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
|
|
279
|
+
// phase-9.1: a second listener binds an OS-assigned free port, advertised as
|
|
280
|
+
// `canvasOrigin`, and the client loads canvas iframes cross-origin under the
|
|
281
|
+
// strict CSP + sandbox + route-allowlist (the F1 containment). This is purely
|
|
282
|
+
// protective — for a solo user it sandboxes their OWN canvas code (no untrusted
|
|
283
|
+
// content, no exfil concern), and interactive-feature parity (selection,
|
|
284
|
+
// comments, presence, motion) is verified. It does NOT by itself enable
|
|
285
|
+
// untrusted `.tsx` sync — that still requires the per-canvas `syncable` opt-in
|
|
286
|
+
// (sync/index.ts), so the WebRTC/self-nav exfil residual only applies to a
|
|
287
|
+
// canvas explicitly opted into syncing (DDR-060 + the F1 re-audit report).
|
|
288
|
+
// Set `MAUDE_CANVAS_ORIGIN_SPLIT=0` (or false/off/no) to fall back to the legacy
|
|
289
|
+
// same-origin path.
|
|
290
|
+
const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
|
|
291
|
+
process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
|
|
292
|
+
);
|
|
293
|
+
const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
|
|
294
|
+
const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
|
|
295
|
+
if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
|
|
296
|
+
|
|
184
297
|
await Bun.write(
|
|
185
298
|
ctx.paths.serverInfoFile,
|
|
186
299
|
JSON.stringify(
|
|
@@ -188,6 +301,7 @@ await Bun.write(
|
|
|
188
301
|
pid: process.pid,
|
|
189
302
|
port: server.port,
|
|
190
303
|
url: `http://localhost:${server.port}`,
|
|
304
|
+
...(canvasOrigin ? { canvasOrigin } : {}),
|
|
191
305
|
started: new Date().toISOString(),
|
|
192
306
|
project: ctx.cfg.name,
|
|
193
307
|
config_source: ctx.cfg._source,
|
|
@@ -238,7 +352,10 @@ async function shutdown() {
|
|
|
238
352
|
/* best-effort — provider sockets will be closed by process exit anyway */
|
|
239
353
|
}
|
|
240
354
|
try {
|
|
241
|
-
if (collab)
|
|
355
|
+
if (collab) {
|
|
356
|
+
collab.dispose();
|
|
357
|
+
await collab.registry.destroyAll();
|
|
358
|
+
}
|
|
242
359
|
} catch {
|
|
243
360
|
/* best-effort flush; the JSON snapshot is the ground truth anyway */
|
|
244
361
|
}
|
|
@@ -261,6 +378,11 @@ async function shutdown() {
|
|
|
261
378
|
/* ignore */
|
|
262
379
|
}
|
|
263
380
|
server.stop();
|
|
381
|
+
try {
|
|
382
|
+
canvasServer?.stop();
|
|
383
|
+
} catch {
|
|
384
|
+
/* best-effort */
|
|
385
|
+
}
|
|
264
386
|
process.exit(0);
|
|
265
387
|
}
|
|
266
388
|
|
|
@@ -37,9 +37,14 @@ import {
|
|
|
37
37
|
annotationsFromDoc,
|
|
38
38
|
applyAnnotationsToDoc,
|
|
39
39
|
applyCommentsToDoc,
|
|
40
|
+
applyCssToDoc,
|
|
40
41
|
applyHtmlToDoc,
|
|
42
|
+
applyMetaToDoc,
|
|
41
43
|
commentsFromDoc,
|
|
44
|
+
cssFromDoc,
|
|
42
45
|
htmlFromDoc,
|
|
46
|
+
mergeSharedMetaIntoLocal,
|
|
47
|
+
metaFromDoc,
|
|
43
48
|
} from './codec.ts';
|
|
44
49
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
45
50
|
|
|
@@ -52,6 +57,14 @@ export interface CanvasSyncPaths {
|
|
|
52
57
|
comments: string;
|
|
53
58
|
/** Absolute path to <designRoot>/<slug>.annotations.svg. */
|
|
54
59
|
annotations: string;
|
|
60
|
+
/** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
|
|
61
|
+
* when set (always, in production wiring), shared meta keys (layout/artboards)
|
|
62
|
+
* sync while per-user viewport stays local (Phase 9.1 Gap 2). Omitted in
|
|
63
|
+
* older test constructions → meta sync is simply inert. */
|
|
64
|
+
meta?: string;
|
|
65
|
+
/** Absolute path to the canvas's sibling `.css` (Phase 9.1 Gap 3). Optional —
|
|
66
|
+
* inline-CSS canvases have none; omitted/absent → css sync is inert. */
|
|
67
|
+
css?: string;
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
export interface CanvasSyncAgentOptions {
|
|
@@ -66,6 +79,14 @@ export interface CanvasSyncAgentOptions {
|
|
|
66
79
|
flushMs?: number;
|
|
67
80
|
/** Injected for tests — defaults to atomicWrite. */
|
|
68
81
|
writer?: (path: string, bytes: string | Uint8Array) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Called when a non-adopt reconcile (cold-start / post-git-pull) overwrites
|
|
84
|
+
* differing, non-empty local content with hub state — i.e. the local peer
|
|
85
|
+
* had divergent work that hub-wins discarded. Lets the runtime surface a
|
|
86
|
+
* "hub overwrote your local changes" notification (Phase 9 Task 8). v1.1
|
|
87
|
+
* resolution is always hub-wins; the interactive 3-way prompt is deferred.
|
|
88
|
+
*/
|
|
89
|
+
onConflict?: (info: { slug: string; kind: 'cold-start-hub-wins' }) => void;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface CanvasSyncAgent {
|
|
@@ -105,6 +126,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
105
126
|
let lastHtml: string | null = null;
|
|
106
127
|
let lastComments: string | null = null;
|
|
107
128
|
let lastAnnotations: string | null = null;
|
|
129
|
+
let lastMeta: string | null = null;
|
|
130
|
+
let lastCss: string | null = null;
|
|
108
131
|
|
|
109
132
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
110
133
|
if (stopped) return;
|
|
@@ -141,6 +164,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
141
164
|
writeHtmlIfChanged();
|
|
142
165
|
writeCommentsIfChanged();
|
|
143
166
|
writeAnnotationsIfChanged();
|
|
167
|
+
writeMetaIfChanged();
|
|
168
|
+
writeCssIfChanged();
|
|
144
169
|
} catch (err) {
|
|
145
170
|
dirty = true;
|
|
146
171
|
console.error(`[sync/${slug}] flush failed:`, err);
|
|
@@ -186,6 +211,31 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
186
211
|
lastAnnotations = value;
|
|
187
212
|
}
|
|
188
213
|
|
|
214
|
+
function writeMetaIfChanged(): void {
|
|
215
|
+
if (!paths.meta) return;
|
|
216
|
+
const shared = metaFromDoc(doc);
|
|
217
|
+
if (shared === lastMeta) return;
|
|
218
|
+
lastMeta = shared;
|
|
219
|
+
if (shared === null) return; // doc carries no shared meta yet — nothing to merge down
|
|
220
|
+
const local = readLocal(paths.meta);
|
|
221
|
+
const merged = mergeSharedMetaIntoLocal(local, shared);
|
|
222
|
+
if (merged === null || merged === local) return; // unparseable, or disk already matches
|
|
223
|
+
const hash = hashBytes(merged);
|
|
224
|
+
echoGuard.record(paths.meta, hash);
|
|
225
|
+
writer(paths.meta, merged);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function writeCssIfChanged(): void {
|
|
229
|
+
if (!paths.css) return;
|
|
230
|
+
const next = cssFromDoc(doc);
|
|
231
|
+
if (next === lastCss) return;
|
|
232
|
+
lastCss = next;
|
|
233
|
+
if (next === null) return; // doc carries no css yet — nothing to write
|
|
234
|
+
const hash = hashBytes(next);
|
|
235
|
+
echoGuard.record(paths.css, hash);
|
|
236
|
+
writer(paths.css, next);
|
|
237
|
+
}
|
|
238
|
+
|
|
189
239
|
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
190
240
|
if (stopped) return false;
|
|
191
241
|
// Echo of our own atomicWrite — drop.
|
|
@@ -209,6 +259,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
209
259
|
if (changed) lastAnnotations = str;
|
|
210
260
|
return changed;
|
|
211
261
|
}
|
|
262
|
+
if (paths.meta && evt.path === paths.meta) {
|
|
263
|
+
// Local meta changed (canvas-meta PATCH / design:edit) → push its SHARED
|
|
264
|
+
// subset (layout/artboards, minus per-user viewport) into the doc.
|
|
265
|
+
const changed = applyMetaToDoc(doc, str, origin);
|
|
266
|
+
if (changed) lastMeta = metaFromDoc(doc);
|
|
267
|
+
return changed;
|
|
268
|
+
}
|
|
269
|
+
if (paths.css && evt.path === paths.css) {
|
|
270
|
+
const changed = applyCssToDoc(doc, str, origin);
|
|
271
|
+
if (changed) lastCss = str;
|
|
272
|
+
return changed;
|
|
273
|
+
}
|
|
212
274
|
return false;
|
|
213
275
|
}
|
|
214
276
|
|
|
@@ -217,12 +279,16 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
217
279
|
const localHtml = readLocal(paths.html);
|
|
218
280
|
const localComments = readLocal(paths.comments);
|
|
219
281
|
const localAnnotations = readLocal(paths.annotations);
|
|
282
|
+
const localMeta = paths.meta ? readLocal(paths.meta) : null;
|
|
283
|
+
const localCss = paths.css ? readLocal(paths.css) : null;
|
|
220
284
|
|
|
221
285
|
const docHtml = htmlFromDoc(doc);
|
|
222
286
|
const docComments = commentsFromDoc(doc);
|
|
223
287
|
const docCommentsStr =
|
|
224
288
|
docComments.length > 0 ? `${JSON.stringify(docComments, null, 2)}\n` : '';
|
|
225
289
|
const docAnnotations = annotationsFromDoc(doc) ?? '';
|
|
290
|
+
const docMeta = metaFromDoc(doc);
|
|
291
|
+
const docCss = cssFromDoc(doc);
|
|
226
292
|
|
|
227
293
|
if (adopt) {
|
|
228
294
|
// Push local up: doc takes its values from disk. Hub becomes our
|
|
@@ -233,9 +299,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
233
299
|
if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
|
|
234
300
|
}
|
|
235
301
|
if (localAnnotations !== null) applyAnnotationsToDoc(doc, localAnnotations, origin);
|
|
302
|
+
if (paths.meta && localMeta !== null) applyMetaToDoc(doc, localMeta, origin);
|
|
303
|
+
if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, origin);
|
|
236
304
|
lastHtml = localHtml ?? '';
|
|
237
305
|
lastComments = localComments ?? '';
|
|
238
306
|
lastAnnotations = localAnnotations ?? '';
|
|
307
|
+
lastMeta = metaFromDoc(doc);
|
|
308
|
+
lastCss = cssFromDoc(doc);
|
|
239
309
|
adopt = false;
|
|
240
310
|
return;
|
|
241
311
|
}
|
|
@@ -244,7 +314,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
244
314
|
lastHtml = docHtml;
|
|
245
315
|
lastComments = docCommentsStr;
|
|
246
316
|
lastAnnotations = docAnnotations;
|
|
317
|
+
lastMeta = docMeta;
|
|
318
|
+
lastCss = docCss;
|
|
247
319
|
if (localHtml !== docHtml) {
|
|
320
|
+
// Local had divergent, non-empty content that hub-wins is discarding —
|
|
321
|
+
// notify so the user knows their local edits were overwritten (Task 8).
|
|
322
|
+
// An absent/empty local file is a clean first-sync, not a conflict.
|
|
323
|
+
if (localHtml !== null && localHtml.trim() !== '') {
|
|
324
|
+
opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
|
|
325
|
+
}
|
|
248
326
|
const hash = hashBytes(docHtml);
|
|
249
327
|
echoGuard.record(paths.html, hash);
|
|
250
328
|
writer(paths.html, docHtml);
|
|
@@ -259,6 +337,23 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
259
337
|
echoGuard.record(paths.annotations, hash);
|
|
260
338
|
writer(paths.annotations, docAnnotations);
|
|
261
339
|
}
|
|
340
|
+
// Meta: merge the doc's shared subset (layout/artboards) into local,
|
|
341
|
+
// preserving this machine's viewport + syncable. Only writes when the merge
|
|
342
|
+
// actually changes the file (a fresh peer with no local viewport, or an
|
|
343
|
+
// artboard layout the hub carries that local lacks).
|
|
344
|
+
if (paths.meta && docMeta !== null) {
|
|
345
|
+
const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
|
|
346
|
+
if (merged !== null && merged !== localMeta) {
|
|
347
|
+
const hash = hashBytes(merged);
|
|
348
|
+
echoGuard.record(paths.meta, hash);
|
|
349
|
+
writer(paths.meta, merged);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (paths.css && docCss !== null && localCss !== docCss) {
|
|
353
|
+
const hash = hashBytes(docCss);
|
|
354
|
+
echoGuard.record(paths.css, hash);
|
|
355
|
+
writer(paths.css, docCss);
|
|
356
|
+
}
|
|
262
357
|
}
|
|
263
358
|
|
|
264
359
|
return {
|
|
@@ -33,6 +33,16 @@ import { Y_TYPES } from '../collab/persistence.ts';
|
|
|
33
33
|
export const Y_SYNC_TYPES = {
|
|
34
34
|
/** The canvas HTML body, as opaque Y.Text. */
|
|
35
35
|
html: 'html',
|
|
36
|
+
/** The canvas's sibling `.css` (e.g. `Kanban App.css`), as opaque Y.Text.
|
|
37
|
+
* Edited via files (design:edit), not a live browser surface, so it mirrors
|
|
38
|
+
* wholesale like the body — no per-user keys, no room-clobber. Phase 9.1
|
|
39
|
+
* Gap 3. */
|
|
40
|
+
css: 'css',
|
|
41
|
+
/** The canvas `.meta.json` SHARED subset (layout/artboards/structure) as an
|
|
42
|
+
* opaque canonical-JSON Y.Text. Per-user keys (viewport pan/zoom) + the
|
|
43
|
+
* security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
|
|
44
|
+
* + sharedMetaCanonical. Phase 9.1 Gap 2. */
|
|
45
|
+
meta: 'meta',
|
|
36
46
|
} as const;
|
|
37
47
|
|
|
38
48
|
/**
|
|
@@ -44,11 +54,26 @@ export const Y_SYNC_TYPES = {
|
|
|
44
54
|
export const MAX_HTML_BYTES = 4 * 1024 * 1024;
|
|
45
55
|
export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
|
|
46
56
|
export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
|
|
57
|
+
export const MAX_META_BYTES = 1 * 1024 * 1024;
|
|
58
|
+
export const MAX_CSS_BYTES = 4 * 1024 * 1024;
|
|
47
59
|
|
|
48
60
|
function byteLengthUtf8(s: string): number {
|
|
49
61
|
return Buffer.byteLength(s, 'utf8');
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
// DDR-054 §2g — strip dangerous keys at parse time so a hostile hub-pushed (or
|
|
65
|
+
// planted-commit) payload can't seed `__proto__` / `constructor` / `prototype`
|
|
66
|
+
// own-properties that yjs then serializes to other peers / writes to disk that
|
|
67
|
+
// Claude reads. Mirrors the agent's comments reviver; applied to the `.meta.json`
|
|
68
|
+
// parse paths (Phase D security re-audit finding A3 — the reviver had been
|
|
69
|
+
// applied only to the comments lane, not the symmetric meta lane).
|
|
70
|
+
function parseJsonSafe(s: string): unknown {
|
|
71
|
+
return JSON.parse(s, (key, value) => {
|
|
72
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
|
|
73
|
+
return value;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
52
77
|
/* ---------------------------------------------------------------- HTML */
|
|
53
78
|
|
|
54
79
|
export function htmlFromDoc(doc: Y.Doc): string {
|
|
@@ -167,3 +192,133 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
|
|
|
167
192
|
}, origin);
|
|
168
193
|
return true;
|
|
169
194
|
}
|
|
195
|
+
|
|
196
|
+
/* ---------------------------------------------------------------- meta */
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Keys of a canvas `.meta.json` that are PER-MACHINE and MUST NOT cross the hub:
|
|
200
|
+
* - `viewport` — this user's pan/zoom; syncing it would yank a
|
|
201
|
+
* collaborator's camera around on every pan.
|
|
202
|
+
* - `last_modified` — a local write timestamp; syncing it churns with no signal.
|
|
203
|
+
* - `syncable` — the security opt-in (DDR-054). A peer/hub must NEVER be
|
|
204
|
+
* able to flip another repo's sync gate, so it stays local
|
|
205
|
+
* and human-edited — same rationale that keeps it out of
|
|
206
|
+
* the /_api/canvas-meta PATCH whitelist.
|
|
207
|
+
* Everything else (title, sections/artboards, `layout` rects, css_mode, …) is
|
|
208
|
+
* shared canvas structure and DOES sync — that's how an artboard move on one
|
|
209
|
+
* machine reaches the other's disk.
|
|
210
|
+
*/
|
|
211
|
+
export const META_LOCAL_KEYS = ['viewport', 'last_modified', 'syncable'] as const;
|
|
212
|
+
|
|
213
|
+
/** Canonical JSON of the SHARED subset of a parsed meta object: local keys
|
|
214
|
+
* dropped, remaining keys sorted so equal content always serializes byte-equal
|
|
215
|
+
* (stable → the no-op guards stay quiet and sync doesn't churn). */
|
|
216
|
+
function sharedMetaCanonical(meta: Record<string, unknown>): string {
|
|
217
|
+
const out: Record<string, unknown> = {};
|
|
218
|
+
for (const k of Object.keys(meta).sort()) {
|
|
219
|
+
if ((META_LOCAL_KEYS as readonly string[]).includes(k)) continue;
|
|
220
|
+
out[k] = meta[k];
|
|
221
|
+
}
|
|
222
|
+
return JSON.stringify(out);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The synced shared-meta JSON string held in the doc, or null when unset. */
|
|
226
|
+
export function metaFromDoc(doc: Y.Doc): string | null {
|
|
227
|
+
const s = doc.getText(Y_SYNC_TYPES.meta).toString();
|
|
228
|
+
return s.length > 0 ? s : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Apply a FULL `.meta.json` string to the doc as its shared subset: parse, strip
|
|
233
|
+
* per-user/security keys, store the canonical shared JSON in a Y.Text. Returns
|
|
234
|
+
* false on parse error / over-cap / no change.
|
|
235
|
+
*/
|
|
236
|
+
export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknown): boolean {
|
|
237
|
+
let obj: unknown;
|
|
238
|
+
try {
|
|
239
|
+
obj = parseJsonSafe(fullMetaJson);
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
|
|
244
|
+
const shared = sharedMetaCanonical(obj as Record<string, unknown>);
|
|
245
|
+
if (byteLengthUtf8(shared) > MAX_META_BYTES) {
|
|
246
|
+
console.warn(
|
|
247
|
+
`[sync/codec] refusing meta apply > ${MAX_META_BYTES} bytes (got ${byteLengthUtf8(shared)}). DDR-054 §2d.`
|
|
248
|
+
);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
const t = doc.getText(Y_SYNC_TYPES.meta);
|
|
252
|
+
if (t.toString() === shared) return false;
|
|
253
|
+
doc.transact(() => {
|
|
254
|
+
if (t.length > 0) t.delete(0, t.length);
|
|
255
|
+
t.insert(0, shared);
|
|
256
|
+
}, origin);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Merge the synced shared-meta into a local `.meta.json` string, PRESERVING the
|
|
262
|
+
* local per-user/security keys (META_LOCAL_KEYS). Shared keys become exactly
|
|
263
|
+
* what the doc holds (so a deletion on the source propagates); the local keys
|
|
264
|
+
* are layered back on top. Returns the merged JSON (2-space, trailing newline)
|
|
265
|
+
* ready to write, or null when the shared payload is unparseable.
|
|
266
|
+
*/
|
|
267
|
+
export function mergeSharedMetaIntoLocal(
|
|
268
|
+
localMetaJson: string | null,
|
|
269
|
+
sharedJson: string
|
|
270
|
+
): string | null {
|
|
271
|
+
let shared: unknown;
|
|
272
|
+
try {
|
|
273
|
+
shared = parseJsonSafe(sharedJson);
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
|
|
278
|
+
let local: Record<string, unknown> = {};
|
|
279
|
+
if (localMetaJson) {
|
|
280
|
+
try {
|
|
281
|
+
const parsed = parseJsonSafe(localMetaJson);
|
|
282
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
283
|
+
local = parsed as Record<string, unknown>;
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
/* unparseable local meta — treat as empty; the shared subset becomes the base */
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const merged: Record<string, unknown> = { ...(shared as Record<string, unknown>) };
|
|
290
|
+
for (const k of META_LOCAL_KEYS) {
|
|
291
|
+
if (k in local) merged[k] = local[k];
|
|
292
|
+
}
|
|
293
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ---------------------------------------------------------------- css */
|
|
297
|
+
|
|
298
|
+
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
299
|
+
export function cssFromDoc(doc: Y.Doc): string | null {
|
|
300
|
+
const s = doc.getText(Y_SYNC_TYPES.css).toString();
|
|
301
|
+
return s.length > 0 ? s : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Apply the canvas's `.css` to the doc as opaque Y.Text (wholesale replace).
|
|
306
|
+
* CSS round-trips byte-identically (we write exactly the doc string back), so a
|
|
307
|
+
* wholesale delete+insert can't churn; the equality short-circuit keeps it quiet
|
|
308
|
+
* when unchanged. Returns false on over-cap / no change.
|
|
309
|
+
*/
|
|
310
|
+
export function applyCssToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
|
|
311
|
+
if (byteLengthUtf8(next) > MAX_CSS_BYTES) {
|
|
312
|
+
console.warn(
|
|
313
|
+
`[sync/codec] refusing CSS apply > ${MAX_CSS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
|
|
314
|
+
);
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
const t = doc.getText(Y_SYNC_TYPES.css);
|
|
318
|
+
if (t.toString() === next) return false;
|
|
319
|
+
doc.transact(() => {
|
|
320
|
+
if (t.length > 0) t.delete(0, t.length);
|
|
321
|
+
t.insert(0, next);
|
|
322
|
+
}, origin);
|
|
323
|
+
return true;
|
|
324
|
+
}
|