@1agh/maude 0.30.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 (90) hide show
  1. package/README.md +5 -5
  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/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,322 @@
1
+ // canvas-list-watch — external-canvas list watcher. Emits `canvas-list-update`
2
+ // when a canvas file appears/disappears on disk from OUTSIDE the dev-server
3
+ // (the ACP agent's `/design:new`, agent Write, git checkout), so the browser
4
+ // file tree refreshes without a reload.
5
+ // RCA: .ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import {
13
+ CANVAS_LIST_DEBOUNCE_MS,
14
+ createCanvasListWatch,
15
+ isCanvasCandidate,
16
+ } from '../canvas-list-watch.ts';
17
+ import { type Context, createBus } from '../context.ts';
18
+ import { createFsWatch } from '../fs-watch.ts';
19
+
20
+ const GROUPS = [
21
+ { label: 'System', path: 'system' },
22
+ { label: 'UI', path: 'ui' },
23
+ ];
24
+
25
+ function sandbox(): { root: string; designRoot: string } {
26
+ const root = mkdtempSync(join(tmpdir(), 'clw-test-'));
27
+ const designRoot = join(root, '.design');
28
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
29
+ return { root, designRoot };
30
+ }
31
+
32
+ function mkCtx(designRoot: string): Context {
33
+ return {
34
+ cfg: { canvasGroups: GROUPS } as Context['cfg'],
35
+ projectLabel: '',
36
+ paths: { designRoot, designRel: '.design' } as Context['paths'],
37
+ bus: createBus(),
38
+ };
39
+ }
40
+
41
+ const TSX = 'export default function C(){return null}';
42
+
43
+ interface Update {
44
+ action?: string;
45
+ rel?: string;
46
+ slug?: string;
47
+ }
48
+
49
+ function collect(ctx: Context): Update[] {
50
+ const got: Update[] = [];
51
+ ctx.bus.on('canvas-list-update', (p) => got.push(p as Update));
52
+ return got;
53
+ }
54
+
55
+ describe('canvas-list-watch / isCanvasCandidate (pure gate)', () => {
56
+ const groups = ['system', 'ui'];
57
+ test('accepts openable canvases under a configured group', () => {
58
+ expect(isCanvasCandidate('ui/Pricing.tsx', groups)).toBe(true);
59
+ expect(isCanvasCandidate('ui/project/Pricing.tsx', groups)).toBe(true);
60
+ expect(isCanvasCandidate('ui/Smoke.html', groups)).toBe(true);
61
+ // DS preview specimens DO show in the tree, so they must trigger a refresh.
62
+ expect(isCanvasCandidate('system/project/preview/colors.tsx', groups)).toBe(true);
63
+ });
64
+
65
+ test('rejects non-canvas extensions (sidecars, tokens, docs)', () => {
66
+ expect(isCanvasCandidate('ui/Pricing.meta.json', groups)).toBe(false);
67
+ expect(isCanvasCandidate('ui/Pricing.css', groups)).toBe(false);
68
+ expect(isCanvasCandidate('system/project/README.md', groups)).toBe(false);
69
+ });
70
+
71
+ test('rejects runtime-state (`_`-prefixed segment) + SKIP_DIRS', () => {
72
+ expect(isCanvasCandidate('_history/ui-x/00-screen.tsx', groups)).toBe(false);
73
+ expect(isCanvasCandidate('ui/_draft.tsx', groups)).toBe(false);
74
+ expect(isCanvasCandidate('_active.json', groups)).toBe(false);
75
+ expect(isCanvasCandidate('node_modules/pkg/index.tsx', groups)).toBe(false);
76
+ });
77
+
78
+ test('rejects canvases outside any configured group', () => {
79
+ expect(isCanvasCandidate('docs/Thing.tsx', groups)).toBe(false);
80
+ expect(isCanvasCandidate('Thing.tsx', groups)).toBe(false);
81
+ });
82
+
83
+ test('non-string / empty input is rejected, not thrown', () => {
84
+ expect(isCanvasCandidate('', groups)).toBe(false);
85
+ expect(isCanvasCandidate(undefined as unknown as string, groups)).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('canvas-list-watch / diff + emit', () => {
90
+ test('seeding existing canvases emits nothing', async () => {
91
+ const { root, designRoot } = sandbox();
92
+ writeFileSync(join(designRoot, 'ui', 'Existing.tsx'), TSX);
93
+ const ctx = mkCtx(designRoot);
94
+ const got = collect(ctx);
95
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
96
+ await w.ready;
97
+ try {
98
+ expect(got).toHaveLength(0);
99
+ expect(w.known.has('.design/ui/Existing.tsx')).toBe(true);
100
+ } finally {
101
+ w.stop();
102
+ rmSync(root, { recursive: true, force: true });
103
+ }
104
+ });
105
+
106
+ test('a canvas written straight to disk emits action:added (the bug)', async () => {
107
+ const { root, designRoot } = sandbox();
108
+ const ctx = mkCtx(designRoot);
109
+ const got = collect(ctx);
110
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
111
+ await w.ready;
112
+ try {
113
+ // Simulate `/design:new` writing the canvas (and its sidecar) directly —
114
+ // NO API call, so api.ts never emits.
115
+ writeFileSync(join(designRoot, 'ui', 'Pricing.tsx'), TSX);
116
+ writeFileSync(join(designRoot, 'ui', 'Pricing.meta.json'), '{}');
117
+ await w.refresh();
118
+ expect(got).toHaveLength(1);
119
+ expect(got[0]?.action).toBe('added');
120
+ expect(got[0]?.slug).toBe('ui-pricing');
121
+ } finally {
122
+ w.stop();
123
+ rmSync(root, { recursive: true, force: true });
124
+ }
125
+ });
126
+
127
+ test('deleting a canvas on disk emits action:removed', async () => {
128
+ const { root, designRoot } = sandbox();
129
+ const ctx = mkCtx(designRoot);
130
+ const got = collect(ctx);
131
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
132
+ await w.ready;
133
+ try {
134
+ const abs = join(designRoot, 'ui', 'Gone.tsx');
135
+ writeFileSync(abs, TSX);
136
+ await w.refresh();
137
+ got.length = 0; // drop the 'added'
138
+ rmSync(abs);
139
+ await w.refresh();
140
+ expect(got).toHaveLength(1);
141
+ expect(got[0]?.action).toBe('removed');
142
+ expect(got[0]?.slug).toBe('ui-gone');
143
+ } finally {
144
+ w.stop();
145
+ rmSync(root, { recursive: true, force: true });
146
+ }
147
+ });
148
+
149
+ test('editing an existing canvas body emits nothing (no tree thrash)', async () => {
150
+ const { root, designRoot } = sandbox();
151
+ const abs = join(designRoot, 'ui', 'Edited.tsx');
152
+ writeFileSync(abs, TSX);
153
+ const ctx = mkCtx(designRoot);
154
+ const got = collect(ctx);
155
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
156
+ await w.ready;
157
+ try {
158
+ writeFileSync(abs, `${TSX}\n// edited`);
159
+ writeFileSync(join(designRoot, 'ui', 'Edited.meta.json'), '{"x":1}');
160
+ await w.refresh();
161
+ expect(got).toHaveLength(0);
162
+ } finally {
163
+ w.stop();
164
+ rmSync(root, { recursive: true, force: true });
165
+ }
166
+ });
167
+
168
+ test('runtime-state + sidecar writes never emit', async () => {
169
+ const { root, designRoot } = sandbox();
170
+ const ctx = mkCtx(designRoot);
171
+ const got = collect(ctx);
172
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
173
+ await w.ready;
174
+ try {
175
+ writeFileSync(join(designRoot, '_active.json'), '{}');
176
+ mkdirSync(join(designRoot, '_canvas-state'), { recursive: true });
177
+ writeFileSync(join(designRoot, '_canvas-state', 'ui-x.view.json'), '{}');
178
+ writeFileSync(join(designRoot, 'config.json'), '{}');
179
+ await w.refresh();
180
+ expect(got).toHaveLength(0);
181
+ } finally {
182
+ w.stop();
183
+ rmSync(root, { recursive: true, force: true });
184
+ }
185
+ });
186
+ });
187
+
188
+ describe('canvas-list-watch / live fs:any path', () => {
189
+ test('a candidate fs:any event schedules a debounced refresh that emits', async () => {
190
+ const { root, designRoot } = sandbox();
191
+ const ctx = mkCtx(designRoot);
192
+ const got = collect(ctx);
193
+ const w = createCanvasListWatch(ctx, { debounceMs: 20 });
194
+ await w.ready;
195
+ try {
196
+ writeFileSync(join(designRoot, 'ui', 'Live.tsx'), TSX);
197
+ // The fs-watch would emit this; assert the subscriber wires through.
198
+ ctx.bus.emit('fs:any', 'ui/Live.tsx');
199
+ await new Promise((r) => setTimeout(r, 20 + 80));
200
+ await w.refresh(); // flush the chain
201
+ const added = got.find((m) => m.action === 'added' && m.slug === 'ui-live');
202
+ expect(added).toBeTruthy();
203
+ // Exactly one 'added' — the timer + the explicit flush must not double-emit.
204
+ expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-live')).toHaveLength(1);
205
+ } finally {
206
+ w.stop();
207
+ rmSync(root, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('a non-candidate fs:any event does not schedule a refresh', async () => {
212
+ const { root, designRoot } = sandbox();
213
+ const ctx = mkCtx(designRoot);
214
+ const got = collect(ctx);
215
+ const w = createCanvasListWatch(ctx, { debounceMs: 20 });
216
+ await w.ready;
217
+ try {
218
+ // Create a canvas but only announce a NON-candidate path. The gate must
219
+ // skip the recompute, so the new canvas stays unseen until a real event.
220
+ writeFileSync(join(designRoot, 'ui', 'Sneaky.tsx'), TSX);
221
+ ctx.bus.emit('fs:any', 'ui/Sneaky.meta.json');
222
+ ctx.bus.emit('fs:any', '_active.json');
223
+ await new Promise((r) => setTimeout(r, 20 + 80));
224
+ expect(got).toHaveLength(0);
225
+ } finally {
226
+ w.stop();
227
+ rmSync(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
231
+ test('stop() unsubscribes — later events are ignored', async () => {
232
+ const { root, designRoot } = sandbox();
233
+ const ctx = mkCtx(designRoot);
234
+ const got = collect(ctx);
235
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
236
+ await w.ready;
237
+ w.stop();
238
+ try {
239
+ writeFileSync(join(designRoot, 'ui', 'After.tsx'), TSX);
240
+ ctx.bus.emit('fs:any', 'ui/After.tsx');
241
+ await new Promise((r) => setTimeout(r, CANVAS_LIST_DEBOUNCE_MS + 40));
242
+ expect(got).toHaveLength(0);
243
+ } finally {
244
+ rmSync(root, { recursive: true, force: true });
245
+ }
246
+ });
247
+ });
248
+
249
+ describe('canvas-list-watch / serialization + multi-remove', () => {
250
+ test('overlapping refresh() calls serialize — a create between them emits exactly once', async () => {
251
+ const { root, designRoot } = sandbox();
252
+ const ctx = mkCtx(designRoot);
253
+ const got = collect(ctx);
254
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
255
+ await w.ready;
256
+ try {
257
+ writeFileSync(join(designRoot, 'ui', 'Pair.tsx'), TSX);
258
+ // Fire two refreshes WITHOUT awaiting the first. The promise-chain must
259
+ // serialize them: the first diffs the create against the pre-create set
260
+ // and updates `known`; the second sees no change. Without the chain both
261
+ // would snapshot concurrently against the stale `known` and emit twice.
262
+ const a = w.refresh();
263
+ const b = w.refresh();
264
+ await Promise.all([a, b]);
265
+ expect(got.filter((m) => m.action === 'added' && m.slug === 'ui-pair')).toHaveLength(1);
266
+ } finally {
267
+ w.stop();
268
+ rmSync(root, { recursive: true, force: true });
269
+ }
270
+ });
271
+
272
+ test('removing a whole group subdir emits one removed per canvas (git-checkout shape)', async () => {
273
+ const { root, designRoot } = sandbox();
274
+ mkdirSync(join(designRoot, 'ui', 'project'), { recursive: true });
275
+ writeFileSync(join(designRoot, 'ui', 'project', 'A.tsx'), TSX);
276
+ writeFileSync(join(designRoot, 'ui', 'project', 'B.tsx'), TSX);
277
+ const ctx = mkCtx(designRoot);
278
+ const got = collect(ctx);
279
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
280
+ await w.ready;
281
+ try {
282
+ rmSync(join(designRoot, 'ui', 'project'), { recursive: true, force: true });
283
+ await w.refresh();
284
+ const removed = got.filter((m) => m.action === 'removed').map((m) => m.slug);
285
+ expect(removed.sort()).toEqual(['ui-project-a', 'ui-project-b']);
286
+ } finally {
287
+ w.stop();
288
+ rmSync(root, { recursive: true, force: true });
289
+ }
290
+ });
291
+ });
292
+
293
+ describe('canvas-list-watch / real fs-watch end-to-end', () => {
294
+ test('a raw disk write through createFsWatch emits added (the actual bug path)', async () => {
295
+ const { root, designRoot } = sandbox();
296
+ const ctx = mkCtx(designRoot);
297
+ const got = collect(ctx);
298
+ const fsWatch = createFsWatch(ctx);
299
+ const w = createCanvasListWatch(ctx, { debounceMs: 30 });
300
+ await w.ready;
301
+ fsWatch.start();
302
+ try {
303
+ // Let the OS watcher arm, then write straight to disk — NO manual bus
304
+ // emit. This exercises the genuine ACP-agent path: disk write →
305
+ // fs.watch → fs:any → gate → debounce → snapshot diff → canvas-list-update.
306
+ await new Promise((r) => setTimeout(r, 60));
307
+ writeFileSync(join(designRoot, 'ui', 'Real.tsx'), TSX);
308
+ const deadline = Date.now() + 3000;
309
+ while (
310
+ Date.now() < deadline &&
311
+ !got.some((m) => m.action === 'added' && m.slug === 'ui-real')
312
+ ) {
313
+ await new Promise((r) => setTimeout(r, 50));
314
+ }
315
+ expect(got.some((m) => m.action === 'added' && m.slug === 'ui-real')).toBe(true);
316
+ } finally {
317
+ fsWatch.stop();
318
+ w.stop();
319
+ rmSync(root, { recursive: true, force: true });
320
+ }
321
+ }, 10_000);
322
+ });
@@ -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();
@@ -67,6 +67,41 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
67
67
  '/_api/edit-css',
68
68
  '/_api/edit-text',
69
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',
70
105
  '/package.json',
71
106
  ]) {
72
107
  expect(await code(p)).toBe(403);