@1agh/maude 0.29.0 → 0.31.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 (162) hide show
  1. package/README.md +6 -6
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -1,15 +1,21 @@
1
1
  // Phase 4 T5 — `/_api/canvas-meta` GET/PATCH endpoint round-trip.
2
+ // DDR-115 — per-user camera split: `viewport` lives in a gitignored per-machine
3
+ // view file (`_canvas-state/<slug>.view.json`), NEVER inline in the versioned
4
+ // `.meta.json`. PATCH splits the lanes; GET merges them back.
2
5
  //
3
6
  // Verifies:
4
- // - PATCH merges `viewport` into an existing `<canvas>.meta.json`
5
- // - PATCH preserves other top-level keys (title, sections, ai_context …)
6
- // - PATCH clamps zoom to [0.1, 4.0]
7
- // - PATCH rejects non-finite viewport coords (no write, returns prior)
8
- // - GET returns the merged meta
9
- // - Paths that escape repoRoot are 400
7
+ // - PATCH `viewport` leaves `.meta.json` BYTE-UNCHANGED + writes the view file
8
+ // (the mouse-move churn killer) and a viewport-only patch on a meta-less
9
+ // canvas still succeeds
10
+ // - PATCH clamps zoom to [0.1, 4.0] / rejects non-finite coords (view file)
11
+ // - GET merges the view file's viewport into the returned meta
12
+ // - GET strips a stale inline viewport from a legacy meta
13
+ // - PATCH `layout` writes meta AND bumps `last_modified` (a real shared change)
14
+ // - PATCH layout persists position-only entries (DDR-027)
15
+ // - Paths that escape repoRoot are 404
10
16
 
11
17
  import { describe, expect, test } from 'bun:test';
12
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
19
  import { join } from 'node:path';
14
20
 
15
21
  import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
@@ -29,8 +35,13 @@ function repoRel(designRoot: string, abs: string): string {
29
35
  return abs.startsWith(`${repoRoot}/`) ? abs.slice(repoRoot.length + 1) : abs;
30
36
  }
31
37
 
32
- describe('/_api/canvas-meta GET/PATCH', () => {
33
- test('PATCH merges viewport onto existing meta and preserves other keys', async () => {
38
+ /** `_canvas-state/<slug>.view.json` for a `<designRoot>/ui/<Name>.tsx` canvas. */
39
+ function viewPath(designRoot: string, slug: string): string {
40
+ return join(designRoot, '_canvas-state', `${slug}.view.json`);
41
+ }
42
+
43
+ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
44
+ test('PATCH viewport leaves meta byte-unchanged + writes the view file (churn killer)', async () => {
34
45
  const { root, designRoot } = makeSandbox();
35
46
  const port = nextPort();
36
47
  const proc = await bootServer(root, port);
@@ -47,6 +58,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
47
58
  ai_context: { pinned_decisions: ['keep dc-* classes'] },
48
59
  })
49
60
  );
61
+ const before = readFileSync(metaAbs, 'utf8');
50
62
  const file = repoRel(designRoot, tsxAbs);
51
63
 
52
64
  const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
@@ -56,16 +68,48 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
56
68
  });
57
69
  expect(r.status).toBe(200);
58
70
  const merged = (await r.json()) as MetaShape;
71
+ // The returned object is GET-shaped: shared keys + the camera merged in.
59
72
  expect(merged.title).toBe('Phase 4');
60
73
  expect(merged.sections).toBeDefined();
61
74
  expect(merged.ai_context).toBeDefined();
62
75
  expect(merged.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
63
- expect(typeof merged.last_modified).toBe('string');
64
76
 
65
- // On-disk reflects the merge.
66
- const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
67
- expect(onDisk.viewport?.zoom).toBe(1.5);
68
- expect(onDisk.title).toBe('Phase 4');
77
+ // KEYSTONE the versioned meta is byte-identical; no viewport, no
78
+ // last_modified bump on a viewport-only patch.
79
+ expect(readFileSync(metaAbs, 'utf8')).toBe(before);
80
+
81
+ // The camera landed in the gitignored per-machine view file.
82
+ const vAbs = viewPath(designRoot, 'ui-phase4');
83
+ expect(existsSync(vAbs)).toBe(true);
84
+ const view = JSON.parse(readFileSync(vAbs, 'utf8'));
85
+ expect(view.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
86
+ } finally {
87
+ await killProc(proc);
88
+ }
89
+ });
90
+
91
+ test('PATCH viewport succeeds on a canvas with no meta yet (writes only the view file)', async () => {
92
+ const { root, designRoot } = makeSandbox();
93
+ const port = nextPort();
94
+ const proc = await bootServer(root, port);
95
+ try {
96
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
97
+ const tsxAbs = join(designRoot, 'ui', 'NoMeta.tsx');
98
+ writeFileSync(tsxAbs, 'export default function N(){return <main/>}\n');
99
+ const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
100
+ const file = repoRel(designRoot, tsxAbs);
101
+
102
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
103
+ method: 'PATCH',
104
+ headers: { 'content-type': 'application/json' },
105
+ body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 2 } } }),
106
+ });
107
+ expect(r.status).toBe(200);
108
+ const merged = (await r.json()) as MetaShape;
109
+ expect(merged.viewport).toEqual({ x: 1, y: 2, zoom: 2 });
110
+ // No meta was conjured into existence.
111
+ expect(existsSync(metaAbs)).toBe(false);
112
+ expect(existsSync(viewPath(designRoot, 'ui-nometa'))).toBe(true);
69
113
  } finally {
70
114
  await killProc(proc);
71
115
  }
@@ -102,7 +146,7 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
102
146
  }
103
147
  });
104
148
 
105
- test('PATCH ignores non-finite viewport (NaN/Infinity)', async () => {
149
+ test('PATCH ignores non-finite viewport (NaN/Infinity) — keeps the prior camera', async () => {
106
150
  const { root, designRoot } = makeSandbox();
107
151
  const port = nextPort();
108
152
  const proc = await bootServer(root, port);
@@ -110,36 +154,44 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
110
154
  mkdirSync(join(designRoot, 'ui'), { recursive: true });
111
155
  const tsxAbs = join(designRoot, 'ui', 'Bad.tsx');
112
156
  writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
113
- writeFileSync(
114
- tsxAbs.replace(/\.tsx$/, '.meta.json'),
115
- '{"title":"Bad","viewport":{"x":1,"y":2,"zoom":1}}'
116
- );
157
+ writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"Bad"}');
117
158
  const file = repoRel(designRoot, tsxAbs);
118
159
 
160
+ // Seed a valid camera (writes the view file).
161
+ await fetch(`http://localhost:${port}/_api/canvas-meta`, {
162
+ method: 'PATCH',
163
+ headers: { 'content-type': 'application/json' },
164
+ body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
165
+ });
166
+
119
167
  const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
120
168
  method: 'PATCH',
121
169
  headers: { 'content-type': 'application/json' },
122
170
  body: JSON.stringify({ file, patch: { viewport: { x: 'nope', y: null, zoom: 1 } } }),
123
171
  });
124
172
  const m = (await r.json()) as MetaShape;
125
- // Prior value preserved.
173
+ // Prior valid camera preserved (the bad patch was a no-op for the view file).
126
174
  expect(m.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
175
+ const view = JSON.parse(readFileSync(viewPath(designRoot, 'ui-bad'), 'utf8'));
176
+ expect(view.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
127
177
  } finally {
128
178
  await killProc(proc);
129
179
  }
130
180
  });
131
181
 
132
- test('GET returns the meta document', async () => {
182
+ test('GET merges the view file viewport into the returned meta', async () => {
133
183
  const { root, designRoot } = makeSandbox();
134
184
  const port = nextPort();
135
185
  const proc = await bootServer(root, port);
136
186
  try {
137
187
  mkdirSync(join(designRoot, 'ui'), { recursive: true });
138
- const tsxAbs = join(designRoot, 'ui', 'Read.tsx');
139
- writeFileSync(tsxAbs, 'export default function R(){return <main/>}\n');
188
+ const tsxAbs = join(designRoot, 'ui', 'Merge.tsx');
189
+ writeFileSync(tsxAbs, 'export default function M(){return <main/>}\n');
190
+ writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), JSON.stringify({ title: 'Merge' }));
191
+ mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
140
192
  writeFileSync(
141
- tsxAbs.replace(/\.tsx$/, '.meta.json'),
142
- JSON.stringify({ title: 'Read', viewport: { x: 5, y: 6, zoom: 0.5 } })
193
+ viewPath(designRoot, 'ui-merge'),
194
+ JSON.stringify({ viewport: { x: 7, y: 8, zoom: 0.5 } })
143
195
  );
144
196
  const file = repoRel(designRoot, tsxAbs);
145
197
 
@@ -148,8 +200,69 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
148
200
  );
149
201
  expect(r.status).toBe(200);
150
202
  const m = (await r.json()) as MetaShape;
151
- expect(m.title).toBe('Read');
152
- expect(m.viewport?.zoom).toBe(0.5);
203
+ expect(m.title).toBe('Merge');
204
+ expect(m.viewport).toEqual({ x: 7, y: 8, zoom: 0.5 });
205
+ } finally {
206
+ await killProc(proc);
207
+ }
208
+ });
209
+
210
+ test('GET strips a stale inline viewport from a legacy meta (no view file)', async () => {
211
+ const { root, designRoot } = makeSandbox();
212
+ const port = nextPort();
213
+ const proc = await bootServer(root, port);
214
+ try {
215
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
216
+ const tsxAbs = join(designRoot, 'ui', 'Legacy.tsx');
217
+ writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
218
+ // A pre-DDR-115 meta with the camera baked inline + a stale last_modified.
219
+ writeFileSync(
220
+ tsxAbs.replace(/\.tsx$/, '.meta.json'),
221
+ JSON.stringify({
222
+ title: 'Legacy',
223
+ viewport: { x: 5, y: 6, zoom: 0.5 },
224
+ last_modified: '2020-01-01T00:00:00.000Z',
225
+ })
226
+ );
227
+ const file = repoRel(designRoot, tsxAbs);
228
+
229
+ const r = await fetch(
230
+ `http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
231
+ );
232
+ const m = (await r.json()) as MetaShape;
233
+ expect(m.title).toBe('Legacy');
234
+ // The stale inline camera + timestamp are NOT surfaced (no view file → none).
235
+ expect(m.viewport).toBeUndefined();
236
+ expect(m.last_modified).toBeUndefined();
237
+ } finally {
238
+ await killProc(proc);
239
+ }
240
+ });
241
+
242
+ test('PATCH layout writes meta AND bumps last_modified (a real shared change)', async () => {
243
+ const { root, designRoot } = makeSandbox();
244
+ const port = nextPort();
245
+ const proc = await bootServer(root, port);
246
+ try {
247
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
248
+ const tsxAbs = join(designRoot, 'ui', 'Layout.tsx');
249
+ writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
250
+ const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
251
+ writeFileSync(metaAbs, JSON.stringify({ title: 'L', sections: [] }));
252
+ const file = repoRel(designRoot, tsxAbs);
253
+
254
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
255
+ method: 'PATCH',
256
+ headers: { 'content-type': 'application/json' },
257
+ body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
258
+ });
259
+ expect(r.status).toBe(200);
260
+ const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
261
+ expect(onDisk.layout?.artboards).toBeDefined();
262
+ expect(typeof onDisk.last_modified).toBe('string');
263
+ expect(onDisk.title).toBe('L');
264
+ // The camera never leaks into the versioned meta.
265
+ expect(onDisk.viewport).toBeUndefined();
153
266
  } finally {
154
267
  await killProc(proc);
155
268
  }
@@ -204,6 +317,27 @@ describe('/_api/canvas-meta — GET/PATCH', () => {
204
317
  }
205
318
  });
206
319
 
320
+ test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
321
+ const { root, designRoot } = makeSandbox();
322
+ const port = nextPort();
323
+ const proc = await bootServer(root, port);
324
+ try {
325
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
326
+ // No .tsx written — the canvas does not exist.
327
+ const file = '.design/ui/Ghost.tsx';
328
+ const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
329
+ method: 'PATCH',
330
+ headers: { 'content-type': 'application/json' },
331
+ body: JSON.stringify({ file, patch: { viewport: { x: 1, y: 2, zoom: 1 } } }),
332
+ });
333
+ expect(r.status).toBe(404);
334
+ // The untrusted-origin write was refused — no per-machine file minted.
335
+ expect(existsSync(viewPath(designRoot, 'ui-ghost'))).toBe(false);
336
+ } finally {
337
+ await killProc(proc);
338
+ }
339
+ });
340
+
207
341
  test('PATCH rejects paths that escape repoRoot', async () => {
208
342
  const { root } = makeSandbox();
209
343
  const port = nextPort();
@@ -54,11 +54,54 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
54
54
  // /_api/canvas (Phase 22 file-write) is deliberately NOT in the canvas
55
55
  // origin's route allowlist — an untrusted canvas iframe must never create
56
56
  // arbitrary .tsx files. It is reachable ONLY from the main origin.
57
+ // Phase 12 (DDR-103) — the in-canvas direct-edit write routes are
58
+ // MAIN-ORIGIN ONLY: absent from CANVAS_SAFE_API + startCanvasServer's
59
+ // route map, so the untrusted canvas iframe origin must never reach a
60
+ // source-write endpoint. A GET here 403s at the gate (not 405 from the
61
+ // handler), proving the route is unreachable on this origin.
57
62
  for (const p of [
58
63
  '/_config',
59
64
  '/_sync-status',
60
65
  '/_api/export',
61
66
  '/_api/canvas',
67
+ '/_api/edit-css',
68
+ '/_api/edit-text',
69
+ '/_api/edit-attr',
70
+ // Phase 27 (E2) — every /_api/git/* route is MAIN-ORIGIN ONLY: absent from
71
+ // CANVAS_SAFE_API + startCanvasServer's `routes` map. A GET from the
72
+ // canvas origin must 403 at the gate (not 405 from a reached handler),
73
+ // proving the route is unreachable on this origin. Guards the dual-
74
+ // allowlist invariant for the token-bearing publish/get-latest endpoints.
75
+ '/_api/git/status',
76
+ '/_api/git/log',
77
+ '/_api/git/diff',
78
+ '/_api/git/commit',
79
+ '/_api/git/discard',
80
+ '/_api/git/push',
81
+ '/_api/git/pull',
82
+ '/_api/git/resolve',
83
+ // Phase 29 (E4) — drafts: list + create + switch + fold are MAIN-ORIGIN ONLY.
84
+ '/_api/git/branches',
85
+ '/_api/git/branch',
86
+ '/_api/git/checkout',
87
+ '/_api/git/fold',
88
+ // Phase 28 (E3) — every /_api/github/* route is MAIN-ORIGIN ONLY (absent
89
+ // from CANVAS_SAFE_API + startCanvasServer's `routes` map) and token-bearing.
90
+ // The untrusted canvas iframe origin must never reach identity/create-repo/
91
+ // invite/repos — a request from this origin 403s at the gate. This is also
92
+ // the guard that the GitHub token (server-held, keychain) can never be
93
+ // exfiltrated to the canvas realm.
94
+ '/_api/github/identity',
95
+ '/_api/github/repos',
96
+ '/_api/github/create-repo',
97
+ '/_api/github/invite',
98
+ '/_api/github/clone',
99
+ '/_api/github/create-project',
100
+ '/_api/design/init',
101
+ // Phase 29 (E4) Door C — the hub-link credential write is MAIN-ORIGIN ONLY;
102
+ // the untrusted canvas origin must never reach it (it writes the global
103
+ // ~/.config/maude/hubs.json token store).
104
+ '/_api/hub/link',
62
105
  '/package.json',
63
106
  ]) {
64
107
  expect(await code(p)).toBe(403);
@@ -0,0 +1,58 @@
1
+ // chat-markdown — Phase 31. The ACP chat feed's compact, XSS-safe markdown
2
+ // renderer. Builds React nodes (never innerHTML), so we assert the rendered
3
+ // markup + that unsafe link schemes are dropped.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+ import { renderToStaticMarkup } from 'react-dom/server';
7
+
8
+ import { Markdown } from '../client/panels/chat-markdown.jsx';
9
+
10
+ const html = (text: string) => renderToStaticMarkup(<Markdown text={text} />);
11
+
12
+ describe('chat-markdown', () => {
13
+ test('inline bold / italic / code', () => {
14
+ const out = html('Use **bold**, *italic* and `code` here.');
15
+ expect(out).toContain('<strong>bold</strong>');
16
+ expect(out).toContain('<em>italic</em>');
17
+ expect(out).toContain('<code>code</code>');
18
+ });
19
+
20
+ test('fenced code block', () => {
21
+ const out = html('text\n```\nconst x = 1;\n```\nmore');
22
+ expect(out).toContain('class="chat-code"');
23
+ expect(out).toContain('const x = 1;');
24
+ });
25
+
26
+ test('bullet + numbered lists', () => {
27
+ const bullet = html('- one\n- two');
28
+ expect(bullet).toContain('<ul class="chat-md-list">');
29
+ expect(bullet).toContain('<li>one</li>');
30
+ const ordered = html('1. first\n2. second');
31
+ expect(ordered).toContain('<ol class="chat-md-list">');
32
+ expect(ordered).toContain('<li>first</li>');
33
+ });
34
+
35
+ test('headings render as styled paragraphs (no giant h1)', () => {
36
+ const out = html('## Section');
37
+ expect(out).toContain('class="chat-md-h"');
38
+ expect(out).toContain('Section');
39
+ expect(out).not.toContain('<h1');
40
+ });
41
+
42
+ test('http(s) links are kept, unsafe schemes are dropped to text', () => {
43
+ const safe = html('see [Anthropic](https://anthropic.com)');
44
+ expect(safe).toContain('href="https://anthropic.com"');
45
+ expect(safe).toContain('rel="noreferrer noopener"');
46
+
47
+ const unsafe = html('[click](javascript:alert(1))');
48
+ expect(unsafe).not.toContain('javascript:');
49
+ expect(unsafe).not.toContain('<a ');
50
+ expect(unsafe).toContain('click'); // falls back to plain text
51
+ });
52
+
53
+ test('plain text passes through as a paragraph', () => {
54
+ const out = html('just a line');
55
+ expect(out).toContain('class="chat-md-p"');
56
+ expect(out).toContain('just a line');
57
+ });
58
+ });
@@ -4,8 +4,6 @@
4
4
 
5
5
  import { describe, expect, test } from 'bun:test';
6
6
 
7
- import * as Y from 'yjs';
8
-
9
7
  import { Y_TYPES } from '../collab/persistence.ts';
10
8
  import { createRegistry } from '../collab/registry.ts';
11
9
  import type { RoomCallbacks } from '../collab/room.ts';
@@ -1,17 +1,12 @@
1
1
  // Unit: Room behavior — two in-memory peers see each other, debounced flush
2
2
  // fires, flush is idempotent, room teardown is clean.
3
3
 
4
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
4
+ import { describe, expect, test } from 'bun:test';
5
5
 
6
6
  import { Awareness } from 'y-protocols/awareness';
7
7
  import * as Y from 'yjs';
8
8
 
9
- import {
10
- encodeAwarenessFrame,
11
- encodeHandshake,
12
- encodeSyncUpdate,
13
- handleMessage,
14
- } from '../collab/protocol.ts';
9
+ import { encodeAwarenessFrame, handleMessage } from '../collab/protocol.ts';
15
10
  import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
16
11
 
17
12
  function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
@@ -0,0 +1,176 @@
1
+ // Collab session survives a canvas hot-swap remount — F4 regression.
2
+ //
3
+ // RCA: `.ai/logs/rca/issue-collab-observer-tsx-reload-flicker.md`. A cross-peer
4
+ // synced edit hot-swaps the canvas module in place, which REMOUNTS the whole
5
+ // canvas subtree — including <CollabProvider>. If the Y.Doc + Awareness + socket
6
+ // were per-component, that remount would close + re-handshake the awareness
7
+ // socket and every peer's cursor/avatar would blink. The fix makes the session
8
+ // a module-level, refcounted singleton keyed by slug, so a same-slug remount
9
+ // reuses the SAME live socket + Awareness (stable clientID → the server never
10
+ // sees the peer leave + rejoin → no blink).
11
+ //
12
+ // We register happy-dom (error boundaries / real createRoot need a live DOM,
13
+ // like canvas-hmr-runtime.test.tsx) and stub WebSocket + fetch.
14
+
15
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
16
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
17
+
18
+ beforeAll(() => {
19
+ GlobalRegistrator.register();
20
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
21
+ });
22
+ afterAll(() => {
23
+ GlobalRegistrator.unregister();
24
+ });
25
+
26
+ import { act, createElement, useEffect } from 'react';
27
+ import { createRoot, type Root } from 'react-dom/client';
28
+ import type * as Y from 'yjs';
29
+
30
+ import { CollabProvider, useCollab } from '../use-collab.tsx';
31
+
32
+ // ── Fake WebSocket: count constructions + closes; never touches the network ──
33
+ let wsConstructed = 0;
34
+ let wsClosed = 0;
35
+ class FakeWS {
36
+ static OPEN = 1;
37
+ static CLOSING = 2;
38
+ static CLOSED = 3;
39
+ readyState = 1; // OPEN immediately — the provider only broadcasts when OPEN
40
+ url: string;
41
+ private handlers: Record<string, Array<(e: unknown) => void>> = {};
42
+ constructor(url: string) {
43
+ this.url = url;
44
+ wsConstructed++;
45
+ // Fire `open` on the next microtask so the provider's open handler runs.
46
+ queueMicrotask(() => this.dispatch('open', {}));
47
+ }
48
+ addEventListener(type: string, fn: (e: unknown) => void) {
49
+ const bucket = this.handlers[type] ?? [];
50
+ bucket.push(fn);
51
+ this.handlers[type] = bucket;
52
+ }
53
+ removeEventListener() {}
54
+ send() {}
55
+ close() {
56
+ if (this.readyState === FakeWS.CLOSED) return;
57
+ this.readyState = FakeWS.CLOSED;
58
+ wsClosed++;
59
+ this.dispatch('close', {});
60
+ }
61
+ private dispatch(type: string, e: unknown) {
62
+ for (const fn of this.handlers[type] ?? []) fn(e);
63
+ }
64
+ }
65
+
66
+ const realWebSocket = globalThis.WebSocket;
67
+ const realFetch = globalThis.fetch;
68
+
69
+ beforeAll(() => {
70
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = FakeWS;
71
+ (globalThis as unknown as { fetch: unknown }).fetch = () =>
72
+ Promise.resolve({ json: () => Promise.resolve({ name: 'Tester' }) });
73
+ });
74
+ afterAll(() => {
75
+ (globalThis as unknown as { WebSocket: unknown }).WebSocket = realWebSocket;
76
+ (globalThis as unknown as { fetch: unknown }).fetch = realFetch;
77
+ });
78
+
79
+ afterEach(() => {
80
+ wsConstructed = 0;
81
+ wsClosed = 0;
82
+ });
83
+
84
+ // Captures the live doc + awareness clientID for the mounted provider.
85
+ let captured: { doc: Y.Doc; clientID: number } | null = null;
86
+ function Probe() {
87
+ const collab = useCollab();
88
+ useEffect(() => {
89
+ if (collab) captured = { doc: collab.doc, clientID: collab.awareness.clientID };
90
+ });
91
+ return null;
92
+ }
93
+
94
+ function mount(el: HTMLElement): Root {
95
+ let root!: Root;
96
+ act(() => {
97
+ root = createRoot(el);
98
+ });
99
+ return root;
100
+ }
101
+
102
+ function renderProvider(root: Root, slug: string) {
103
+ act(() => {
104
+ root.render(createElement(CollabProvider, { slug }, createElement(Probe)));
105
+ });
106
+ }
107
+
108
+ describe('collab session survival across a hot-swap remount', () => {
109
+ test('a same-slug remount reuses the live socket + Awareness (no reconnect)', () => {
110
+ captured = null;
111
+ const host = document.createElement('div');
112
+ const root = mount(host);
113
+
114
+ renderProvider(root, 'survival-slug');
115
+ expect(wsConstructed).toBe(1);
116
+ const first = captured;
117
+ expect(first).not.toBeNull();
118
+
119
+ // Hot-swap = unmount the canvas subtree, then immediately remount the SAME
120
+ // slug (what CanvasHmrRuntime's key=attempt does on a synced module swap).
121
+ act(() => {
122
+ root.render(createElement('div', null));
123
+ });
124
+ renderProvider(root, 'survival-slug');
125
+
126
+ // The session was reused within the grace window — NO second socket, and the
127
+ // SAME doc + Awareness clientID (the server never saw a leave/rejoin).
128
+ expect(wsConstructed).toBe(1);
129
+ expect(wsClosed).toBe(0);
130
+ expect(captured?.doc).toBe(first?.doc);
131
+ expect(captured?.clientID).toBe(first?.clientID);
132
+
133
+ act(() => root.unmount());
134
+ });
135
+
136
+ test('the session registry is anchored on window under a non-enumerable Symbol', () => {
137
+ // A hot-swap re-imports the canvas bundle with a `?v=` cache-buster, and
138
+ // use-collab is INLINED into that bundle — so a module-scoped `const
139
+ // SESSIONS = new Map()` would be FRESH (empty) on every hot-swap and spin a
140
+ // new clientID each time (phantom self-avatars pile up). Anchoring the map
141
+ // on `window` (under a global Symbol so the re-imported module resolves the
142
+ // same key) is what makes the session survive the swap. This pins that
143
+ // invariant — the registry holds the slug — AND the security posture: the
144
+ // key is a NON-ENUMERABLE Symbol so untrusted same-realm canvas script can't
145
+ // harvest the live network handles via a for-in / Object.keys sweep.
146
+ captured = null;
147
+ const host = document.createElement('div');
148
+ const root = mount(host);
149
+ renderProvider(root, 'window-anchored-slug');
150
+ const w = window as unknown as Record<symbol, Map<string, unknown> | undefined>;
151
+ const reg = w[Symbol.for('maude.collab.sessions.v1')];
152
+ expect(reg).toBeInstanceOf(Map);
153
+ expect(reg?.has('window-anchored-slug')).toBe(true);
154
+ // Not reachable via string-key enumeration (defense in depth).
155
+ expect(Object.keys(window)).not.toContain('__maudeCollabSessions');
156
+ act(() => root.unmount());
157
+ });
158
+
159
+ test('a different slug opens its own session (no cross-slug sharing)', () => {
160
+ captured = null;
161
+ const host = document.createElement('div');
162
+ const root = mount(host);
163
+
164
+ renderProvider(root, 'slug-a');
165
+ const a = captured;
166
+ renderProvider(root, 'slug-b');
167
+ const b = captured;
168
+
169
+ // slug-b reuses slug-a's <CollabProvider> instance but a NEW session →
170
+ // a second socket + a distinct doc.
171
+ expect(wsConstructed).toBe(2);
172
+ expect(b?.doc).not.toBe(a?.doc);
173
+
174
+ act(() => root.unmount());
175
+ });
176
+ });
@@ -39,6 +39,11 @@ describe('cspForCanvasShell — A6 hardening', () => {
39
39
  expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
40
40
  });
41
41
 
42
+ test('frame-ancestors carries every advertised loopback spelling (localhost + 127.0.0.1)', () => {
43
+ const csp = cspForCanvasShell(SHELL, 'http://localhost:4399 http://127.0.0.1:4399');
44
+ expect(csp).toContain("frame-ancestors 'self' http://localhost:4399 http://127.0.0.1:4399");
45
+ });
46
+
42
47
  test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
43
48
  const csp = cspForCanvasShell(SHELL);
44
49
  expect(csp).not.toContain('frame-ancestors');