@1agh/maude 0.60.7 → 1.0.3

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +201 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -71,10 +71,12 @@ describe('checkContainment — what a cell may serve', () => {
71
71
 
72
72
  test('a VARIANT of a forbidden route is still forbidden', () => {
73
73
  // The realistic way this gets reintroduced: not `/_api/export` verbatim, but
74
- // a sibling that does the same thing under a new name.
74
+ // a sibling that does the same thing under a new name. (`/_api/export-jobs`
75
+ // moved OUT of this list when render-workers excepted it explicitly — an
76
+ // except-listed exact path is a classified decision, not a variant.)
75
77
  for (const route of [
76
- '/_api/export-jobs',
77
78
  '/_api/export/png',
79
+ '/_api/export-jobs-v2',
78
80
  '/_api/github/repos',
79
81
  '/_api/claude/signin',
80
82
  '/_ws/acp',
@@ -237,7 +239,10 @@ describe('the vocabulary itself', () => {
237
239
  '/_api/generate',
238
240
  '/_api/github',
239
241
  '/_api/hub',
240
- '/_api/photo-edit',
242
+ // `/_api/photo-edit` left this list on purpose: it stores a validated
243
+ // JSON sidecar and evaluates nothing — the decode happens in the
244
+ // member's browser. Withholding it made photo edits unsavable in the
245
+ // cloud. See workspace-mode.ts's header for the fuller account.
241
246
  // Spawns a headless browser against the studio — same evaluation as
242
247
  // `/_api/export`, and there is no browser in a cell image.
243
248
  '/_api/shell-shot',
@@ -281,15 +286,16 @@ describe('pruneForWorkspace — how a cell can boot at all', () => {
281
286
  expect(Object.keys(routes).sort()).toEqual([
282
287
  '/_api/asset',
283
288
  '/_api/comments',
284
- '/_api/git-committers',
285
- '/_health',
286
- ]);
287
- expect(removed).toEqual([
288
- '/_api/export',
289
+ // Kept since render-workers — the job lane enqueues/streams, it never
290
+ // evaluates (the `except` escape on the `/_api/export` prefix).
289
291
  '/_api/export-jobs',
290
- '/_api/generate/keys',
292
+ '/_api/git-committers',
293
+ // Kept since the unwithhold — a photo edit is a stored sidecar, and a
294
+ // cell that cannot store it cannot save photo edits at all.
291
295
  '/_api/photo-edit',
296
+ '/_health',
292
297
  ]);
298
+ expect(removed).toEqual(['/_api/export', '/_api/generate/keys']);
293
299
  // Handlers are carried through by reference — pruning must not rebuild them.
294
300
  expect(routes['/_api/asset']).toBe('a');
295
301
  });
@@ -310,6 +316,36 @@ describe('pruneForWorkspace — how a cell can boot at all', () => {
310
316
  expect(() => assertContainment(Object.keys(table), { env: CELL })).toThrow(/REFUSING TO START/);
311
317
  });
312
318
 
319
+ test('the export JOB lane survives the prune; the synchronous render does not (render-workers)', () => {
320
+ // feature-cloud-export-render-workers: enqueue/list/download evaluate
321
+ // nothing — the render happens in the maude-render service (or refuses,
322
+ // lane `none`). The synchronous `/_api/export` stays forbidden, and so
323
+ // does any FUTURE `/_api/export-*` variant nobody has classified — the
324
+ // escape is an explicit exact-path allowlist, not a loosened prefix.
325
+ const table = {
326
+ '/_api/export': 'x',
327
+ '/_api/export-jobs': 'j',
328
+ '/_api/export-jobs/download': 'd',
329
+ '/_api/export-history': 'h',
330
+ '/_api/export-next-phase-surprise': 'f',
331
+ '/_health': 'ok',
332
+ };
333
+ const { routes, removed } = pruneForWorkspace(table);
334
+ expect(Object.keys(routes).sort()).toEqual([
335
+ '/_api/export-history',
336
+ '/_api/export-jobs',
337
+ '/_api/export-jobs/download',
338
+ '/_health',
339
+ ]);
340
+ expect(removed).toEqual(['/_api/export', '/_api/export-next-phase-surprise']);
341
+ // The single predicate agrees (the fetch fall-through gates on it too).
342
+ expect(isForbiddenRoute('/_api/export')).toBe(true);
343
+ expect(isForbiddenRoute('/_api/export-jobs')).toBe(false);
344
+ expect(isForbiddenRoute('/_api/export-jobs/download')).toBe(false);
345
+ expect(isForbiddenRoute('/_api/export-history')).toBe(false);
346
+ expect(isForbiddenRoute('/_api/export-next-phase-surprise')).toBe(true);
347
+ });
348
+
313
349
  test('pruning an already-clean table is a no-op', () => {
314
350
  const table = Object.fromEntries(ALLOWED_ROUTES.map((r) => [r, () => {}]));
315
351
  const { routes, removed } = pruneForWorkspace(table);
@@ -19,15 +19,14 @@
19
19
  "noEmit": true,
20
20
  "allowJs": true,
21
21
  "forceConsistentCasingInFileNames": true,
22
- "allowImportingTsExtensions": true
22
+ "allowImportingTsExtensions": true,
23
+ // @maude/canvas-lib is the virtual specifier canvases import; the dev-server's
24
+ // Bun.build plugin resolves it to this same file (DDR-025).
25
+ "paths": {
26
+ "@maude/canvas-lib": ["./canvas-lib.tsx"]
27
+ }
23
28
  },
24
- "include": [
25
- "*.ts",
26
- "draw/**/*.ts",
27
- "client/**/*.ts",
28
- "client/**/*.tsx",
29
- "client/**/*.jsx",
30
- "test/**/*.ts"
31
- ],
32
- "exclude": ["node_modules", "dist", "**/*.test.ts"]
29
+ "//include": "Broad by design. An explicit root list silently orphaned 52 tracked files (canvas-lib.tsx, every overlay, commands/**) because it lacked '*.tsx' — reached by no checker at all. scripts/check-tsc-coverage.sh pins this: every tracked non-test .ts/.tsx here must appear in `tsc --listFiles`.",
30
+ "include": ["**/*.ts", "**/*.tsx", "**/*.jsx"],
31
+ "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
33
32
  }
@@ -39,7 +39,7 @@ import {
39
39
  strokeCenter,
40
40
  strokeRotation,
41
41
  } from './annotations-layer.tsx';
42
- import { useViewportControllerContext } from './canvas-lib.tsx';
42
+ import { getLiveViewport, useViewportControllerContext } from './canvas-lib.tsx';
43
43
  import { useAnnotationSelection } from './use-annotation-selection.tsx';
44
44
 
45
45
  const RESIZE_CSS = `
@@ -583,9 +583,12 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
583
583
  return store.strokes.find((s) => s.id === selectedId) ?? null;
584
584
  }, [selectedId, store]);
585
585
 
586
+ // Live for the same reason the rAF loop is: a trackpad pinch mid-drag moves
587
+ // the camera without republishing, and unprojecting the pointer through a
588
+ // stale viewport would resize the stroke to the wrong world size.
586
589
  const screenToWorld = useCallback(
587
590
  (cx: number, cy: number): [number, number] => {
588
- const v = vp ?? { x: 0, y: 0, zoom: 1 };
591
+ const v = getLiveViewport() ?? vp ?? { x: 0, y: 0, zoom: 1 };
589
592
  const z = v.zoom || 1;
590
593
  return [(cx - v.x) / z, (cy - v.y) / z];
591
594
  },
@@ -595,6 +598,14 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
595
598
  // rAF loop — repositions handles on every frame while a single resizable
596
599
  // stroke is selected. Cheaper than wiring pan/zoom observers because the
597
600
  // halo overlays already follow the same pattern.
601
+ //
602
+ // It MUST read `getLiveViewport()` per tick, not the `vp` captured in this
603
+ // effect's closure: the published viewport is settle-cadence (SETTLE_MS), so
604
+ // through a pan/zoom the closure holds one frozen value and the loop spends
605
+ // the whole gesture recomputing the SAME stale screen positions. The handles
606
+ // sat where the selection used to be and only snapped onto it ~half a second
607
+ // after the gesture ended. `liveViewport` is written on every applyViewport
608
+ // with no publish lag, and equals `vp` once settled.
598
609
  useEffect(() => {
599
610
  if (!selectedStroke || !isResizable(selectedStroke)) {
600
611
  const c = containerRef.current;
@@ -609,7 +620,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
609
620
  rafRef.current = null;
610
621
  const c = containerRef.current;
611
622
  if (!c) return;
612
- const v = vp ?? { x: 0, y: 0, zoom: 1 };
623
+ const v = getLiveViewport() ?? vp ?? { x: 0, y: 0, zoom: 1 };
613
624
  const z = v.zoom || 1;
614
625
  // Wave H — screen-constant halo offset (HALO_PAD_PX at any zoom).
615
626
  const positions = handlePositions(selectedStroke, HALO_PAD_PX / z);
@@ -18,6 +18,7 @@
18
18
 
19
19
  import * as decoding from 'lib0/decoding';
20
20
  import * as encoding from 'lib0/encoding';
21
+ import type { JSX } from 'react';
21
22
  import {
22
23
  createContext,
23
24
  type ReactNode,
@@ -602,7 +603,8 @@ function createSession(slug: string): CollabSession {
602
603
 
603
604
  function sendFrame(ws: WebSocket, payload: Uint8Array) {
604
605
  try {
605
- ws.send(payload);
606
+ // Uint8Array<ArrayBufferLike> vs BufferSource — see saveAsset in api.ts.
607
+ ws.send(payload as Uint8Array<ArrayBuffer>);
606
608
  } catch {
607
609
  /* dead socket — close handler will reconnect */
608
610
  }
@@ -1,6 +1,96 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "export-from-the-cloud-studio",
6
+ "version": "1.0.1",
7
+ "date": "2026-08-20",
8
+ "kind": "feature",
9
+ "surface": "design-ui",
10
+ "title": "Export works in the cloud studio now",
11
+ "summary": "Exporting a canvas to PNG, PDF, PPTX, ZIP or video from a project open in your browser used to fail. Now it runs as a background job — kick it off, keep working, and download it from the notifications when it's done. Where a workspace has no render service configured, the formats that need one say so and point you at the desktop app; ZIP always works."
12
+ },
13
+ {
14
+ "id": "your-sync-controls-are-in-the-app-now",
15
+ "version": "1.0.1",
16
+ "date": "2026-08-20",
17
+ "kind": "feature",
18
+ "surface": "design-ui",
19
+ "title": "Sync settings, consent and a Trash you can find",
20
+ "summary": "The Sync panel now holds the controls that used to mean editing a config file by hand: turn file sync or delete propagation on and off, choose how first-link conflicts resolve, and see per-file delivery. A first-time notice asks before a workspace starts mirroring your whole design folder, and deleted or replaced files land in a Trash section you can restore from or clear — no more hunting for a hidden folder."
21
+ },
22
+ {
23
+ "id": "moving-a-canvas-reaches-every-machine",
24
+ "version": "1.0.0",
25
+ "date": "2026-08-20",
26
+ "kind": "fix",
27
+ "surface": "design-ui",
28
+ "title": "Moving a canvas reaches every machine",
29
+ "summary": "Moving a canvas into a folder now shows up everywhere, instead of only on the machine that moved it. Canvases made on one machine also stop arriving blank or reverting when you edit them somewhere else."
30
+ },
31
+ {
32
+ "id": "durable-backups-for-every-file",
33
+ "version": "1.0.0",
34
+ "date": "2026-08-20",
35
+ "kind": "improvement",
36
+ "surface": "design-ui",
37
+ "title": "Every file in a cloud project is backed up now",
38
+ "summary": "Cloud-hosted projects now keep a durable copy of every file — design-system stylesheets, shared modules, fonts, and images anywhere in the folder, not just top-level assets. A project that restarts in the cloud comes back whole instead of missing its styling."
39
+ },
40
+ {
41
+ "id": "the-whole-design-folder-syncs",
42
+ "version": "1.0.0",
43
+ "date": "2026-08-20",
44
+ "kind": "feature",
45
+ "surface": "design-ui",
46
+ "title": "Your whole design folder syncs now",
47
+ "summary": "A linked project mirrors everything in .design/ both ways — design-system stylesheets, READMEs, fonts, images — not just the canvases. Before, a teammate opening the project got every canvas and none of the styling it depended on."
48
+ },
49
+ {
50
+ "id": "deleting-a-file-deletes-it-everywhere",
51
+ "version": "1.0.0",
52
+ "date": "2026-08-20",
53
+ "kind": "feature",
54
+ "surface": "design-ui",
55
+ "title": "Delete a file and it stays deleted",
56
+ "summary": "Removing a file now removes it for everyone, instead of it quietly coming back on the next sync. If more than a handful go at once — a branch switch, a bad restore — sync pauses and the Sync panel says what it was about to do; nothing is removed until you agree. Anything it does remove is moved to a trash folder on both machines rather than deleted."
57
+ },
58
+ {
59
+ "id": "repo-owned-or-hub-owned",
60
+ "version": "1.0.0",
61
+ "date": "2026-08-20",
62
+ "kind": "improvement",
63
+ "surface": "design-ui",
64
+ "title": "One owner for your design folder",
65
+ "summary": "A project is either committed to your repo or mirrored by your hub — never both, which used to let a git pull and a sync undo each other. Switching either way takes one command and never moves a file off your disk. Linking also asks, once, whether that hub may send shared code to this machine."
66
+ },
67
+ {
68
+ "id": "history-follows-the-cloud",
69
+ "version": "1.0.0",
70
+ "date": "2026-08-20",
71
+ "kind": "fix",
72
+ "title": "History shows what the cloud is saving",
73
+ "summary": "When a folder is connected to Maude Cloud, the History tab now lists the cloud's saved versions instead of your machine's — headed by the cloud project's name, and you can open a canvas at any of them, including versions that only ever existed in the cloud. If the cloud can't be reached it says so, with a Retry, rather than looking empty. While the cloud is saving, the app also stops keeping a separate account of the folder: no unsaved marks in the tree, no draft switcher. Disconnect and it all returns live. Your own git is untouched.",
74
+ "surface": "design-ui"
75
+ },
76
+ {
77
+ "id": "canvas-zoom-and-selection-feel",
78
+ "version": "1.0.0",
79
+ "date": "2026-08-20",
80
+ "kind": "improvement",
81
+ "title": "Zooming and drawing feel right",
82
+ "summary": "The mouse wheel and trackpad pinch now move the zoom about twice as far per notch, closer to the canvas tools you already use. Ink you draw scales with the zoom in the browser the way it always did in the desktop app, and the resize handles around a selection now follow a pan or zoom live instead of sitting where the shape used to be until you let go.",
83
+ "surface": "design-ui"
84
+ },
85
+ {
86
+ "id": "cloud-canvases-heal-themselves",
87
+ "version": "1.0.0",
88
+ "date": "2026-08-20",
89
+ "kind": "fix",
90
+ "title": "The cloud stops needing a reload",
91
+ "summary": "A file that arrives in the cloud now shows up in an open tab by itself. Before, the bytes landed but nothing told the page, so a picture stayed a broken frame until you reloaded by hand — it looked like sync had failed when it had already finished.",
92
+ "surface": "design-ui"
93
+ },
4
94
  {
5
95
  "id": "instant-asset-sync",
6
96
  "version": "0.60.7",
@@ -35,79 +35,118 @@ export function isWorkspaceMode(env: NodeJS.ProcessEnv = process.env): boolean {
35
35
  return env.MAUDE_WORKSPACE_MODE === '1';
36
36
  }
37
37
 
38
+ /**
39
+ * Where an export gets rendered — the one question every export surface asks.
40
+ *
41
+ * `local` this process spawns the capture browser (desktop / plain server;
42
+ * pre-render-worker behavior, unchanged).
43
+ * `remote` a workspace cell with a render service configured: browser-needing
44
+ * formats are dispatched to `MAUDE_RENDER_URL` as jobs; the browser
45
+ * never enters THIS image (the containment invariant holds).
46
+ * `none` a workspace cell with no render service: browser-needing formats
47
+ * refuse with a remedy, browser-free formats (zip) still run here.
48
+ *
49
+ * Resolved once per boot-shaped question (env does not change while a cell
50
+ * runs — env applies at container start, see apps/cells/wrangler.toml).
51
+ */
52
+ export type RenderLane = 'local' | 'remote' | 'none';
53
+
54
+ export function resolveRenderLane(env: NodeJS.ProcessEnv = process.env): RenderLane {
55
+ if (!isWorkspaceMode(env)) return 'local';
56
+ return env.MAUDE_RENDER_URL ? 'remote' : 'none';
57
+ }
58
+
38
59
  /**
39
60
  * Route paths a workspace cell must not expose, with the reason each one
40
61
  * violates containment. The reason travels with the entry so the error message
41
62
  * teaches rather than just refusing.
42
63
  *
43
- * These are prefixes: `/_api/export-jobs` is caught by `/_api/export` and that
44
- * is intended — a variant of a forbidden surface is still forbidden.
64
+ * These are prefixes: a variant of a forbidden surface is still forbidden.
65
+ * The ONE escape is an entry's `except` list — EXACT paths that leave the
66
+ * prefix's shadow because they hold none of what the prefix is forbidden for.
67
+ * feature-cloud-export-render-workers used it first: `/_api/export-jobs` +
68
+ * `/_api/export-history` enqueue, list and stream — the EVALUATION the
69
+ * `/_api/export` prefix names happens in the maude-render service (or is
70
+ * refused, lane `none`), never here. An unlisted future `/_api/export-*`
71
+ * route stays forbidden by default, which is the property that makes an
72
+ * exact-path allowlist safer than narrowing the prefix.
45
73
  *
46
74
  * TWO KINDS OF ENTRY LIVE HERE, and both belong:
47
75
  *
48
- * 1. surfaces that EVALUATE tenant content on our compute (export, photo-edit);
76
+ * 1. surfaces that EVALUATE tenant content on our compute (export, shell-shot);
77
+ * — NOT `/_api/photo-edit`, which was withheld here for a while under
78
+ * that rationale and never fit it: the route validates and stores a JSON
79
+ * sidecar (`assets/<sha8>.photo.json`), and the DECODING it was blamed
80
+ * for happens in the member's own browser (PhotoPreviewBridge) — the
81
+ * exact same division of labour that makes `/_canvas-shell` servable.
82
+ * The withhold's real effect was that photo edits could not be saved in
83
+ * the cloud at all, reported as "photo editing doesn't work";
49
84
  * 2. surfaces that hold, spend or reveal a SECRET on our compute — a provider
50
85
  * key, the operator's GitHub token, the user's own `claude` session, the
51
86
  * process log. DDR-123's "claude never on our infra" is only a fact while
52
87
  * these are unreachable, and Cloud Phase 27 D1 is where they stop being in
53
88
  * the image at all. Until then, unreachable is the floor, not the ceiling.
54
89
  */
55
- export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{ prefix: string; why: string }> =
56
- Object.freeze([
57
- {
58
- prefix: '/_api/export',
59
- why: 'export renders the canvas through a headless browser — it EVALUATES tenant TSX',
60
- },
61
- {
62
- prefix: '/_api/photo-edit',
63
- why: 'photo editing decodes and processes tenant-supplied media in-process',
64
- },
65
- {
66
- prefix: '/_api/generate',
67
- why: 'media generation runs tenant-authored prompts against a provider key held here',
68
- },
69
- {
70
- prefix: '/_api/figma',
71
- why: 'the Figma lane STORES and SPENDS a user personal access token, and imports write the design root — a cell must hold no provider credential (DDR-216 D2/D3, same class as /_api/generate)',
72
- },
73
- {
74
- prefix: '/_api/shell-shot',
75
- why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',
76
- },
77
- {
78
- prefix: '/_ws/acp',
79
- why: 'the ACP bridge spawns the user’s own `claude` and can drive file edits (DDR-123 is desktop-only)',
80
- },
81
- // ---- Cloud Phase 27 D1 — the secret-bearing surfaces, named at last ----
82
- {
83
- prefix: '/_api/acp',
84
- why: 'the ACP surface drives the user’s own `claude` session; a cell must not hold one (DDR-123)',
85
- },
86
- {
87
- prefix: '/_api/claude',
88
- why: 'installing or signing in to `claude` from a cell would put a user’s subscription on our infra (DDR-123)',
89
- },
90
- {
91
- prefix: '/_api/cloud',
92
- why: 'cloud sign-in mints and stores an account session — a cell IS the cloud, it must not be a client of it',
93
- },
94
- {
95
- prefix: '/_api/github',
96
- why: 'the GitHub lane spends an operator credential; a cell asks the control plane, it never holds one (DDR-201)',
97
- },
98
- {
99
- prefix: '/_api/hub',
100
- why: 'hub linking rewrites which server owns this project — a cell must not repoint itself',
101
- },
102
- {
103
- prefix: '/_api/debug-bundle',
104
- why: 'the diagnostic bundle reads this process’s logs, where every cell secret has had a chance to appear',
105
- },
106
- {
107
- prefix: '/_api/design',
108
- why: 'design-system init shells out to the CLI on our compute against tenant-chosen input',
109
- },
110
- ]);
90
+ export const FORBIDDEN_ROUTE_PREFIXES: ReadonlyArray<{
91
+ prefix: string;
92
+ why: string;
93
+ except?: readonly string[];
94
+ }> = Object.freeze([
95
+ {
96
+ prefix: '/_api/export',
97
+ why: 'export renders the canvas through a headless browser — it EVALUATES tenant TSX',
98
+ // The job lane: enqueue / list+stream / history. No evaluation happens
99
+ // in-cell — browser formats dispatch to maude-render (exporters/jobs.ts
100
+ // render lane), zip runs browser-free, and lane `none` refuses with a
101
+ // remedy. DDR amending DDR-209 A′1 records the contract.
102
+ except: ['/_api/export-jobs', '/_api/export-jobs/download', '/_api/export-history'],
103
+ },
104
+ {
105
+ prefix: '/_api/generate',
106
+ why: 'media generation runs tenant-authored prompts against a provider key held here',
107
+ },
108
+ {
109
+ prefix: '/_api/figma',
110
+ why: 'the Figma lane STORES and SPENDS a user personal access token, and imports write the design root — a cell must hold no provider credential (DDR-216 D2/D3, same class as /_api/generate)',
111
+ },
112
+ {
113
+ prefix: '/_api/shell-shot',
114
+ why: 'the shell screenshot spawns a headless browser against the studio — the same evaluation `/_api/export` is forbidden for',
115
+ },
116
+ {
117
+ prefix: '/_ws/acp',
118
+ why: 'the ACP bridge spawns the user’s own `claude` and can drive file edits (DDR-123 is desktop-only)',
119
+ },
120
+ // ---- Cloud Phase 27 D1 — the secret-bearing surfaces, named at last ----
121
+ {
122
+ prefix: '/_api/acp',
123
+ why: 'the ACP surface drives the user’s own `claude` session; a cell must not hold one (DDR-123)',
124
+ },
125
+ {
126
+ prefix: '/_api/claude',
127
+ why: 'installing or signing in to `claude` from a cell would put a user’s subscription on our infra (DDR-123)',
128
+ },
129
+ {
130
+ prefix: '/_api/cloud',
131
+ why: 'cloud sign-in mints and stores an account session — a cell IS the cloud, it must not be a client of it',
132
+ },
133
+ {
134
+ prefix: '/_api/github',
135
+ why: 'the GitHub lane spends an operator credential; a cell asks the control plane, it never holds one (DDR-201)',
136
+ },
137
+ {
138
+ prefix: '/_api/hub',
139
+ why: 'hub linking rewrites which server owns this project — a cell must not repoint itself',
140
+ },
141
+ {
142
+ prefix: '/_api/debug-bundle',
143
+ why: 'the diagnostic bundle reads this process’s logs, where every cell secret has had a chance to appear',
144
+ },
145
+ {
146
+ prefix: '/_api/design',
147
+ why: 'design-system init shells out to the CLI on our compute against tenant-chosen input',
148
+ },
149
+ ]);
111
150
 
112
151
  /**
113
152
  * Routes a cell MAY serve, but only while the contract that makes them safe is
@@ -168,9 +207,18 @@ function matches(route: string, prefix: string): boolean {
168
207
  return route === prefix || route.startsWith(prefix);
169
208
  }
170
209
 
171
- /** True when `route` is (or starts with) a forbidden prefix. */
210
+ /** The forbidden entry shadowing `route`, honoring exact-path `except` escapes. */
211
+ function forbiddenEntryFor(
212
+ route: string
213
+ ): { prefix: string; why: string; except?: readonly string[] } | undefined {
214
+ return FORBIDDEN_ROUTE_PREFIXES.find(
215
+ (f) => matches(route, f.prefix) && !f.except?.includes(route)
216
+ );
217
+ }
218
+
219
+ /** True when `route` is (or starts with) a forbidden prefix and is not excepted. */
172
220
  export function isForbiddenRoute(route: string): boolean {
173
- return FORBIDDEN_ROUTE_PREFIXES.some((f) => matches(route, f.prefix));
221
+ return forbiddenEntryFor(route) !== undefined;
174
222
  }
175
223
 
176
224
  /** True when `route` is one a cell may serve only while the sandbox is armed. */
@@ -234,7 +282,7 @@ export function checkContainment(
234
282
  const routes: ContainmentReport['routes'] = [];
235
283
  const unattested: ContainmentReport['unattested'] = [];
236
284
  for (const route of routeNames) {
237
- const forbidden = FORBIDDEN_ROUTE_PREFIXES.find((f) => matches(route, f.prefix));
285
+ const forbidden = forbiddenEntryFor(route);
238
286
  if (forbidden) {
239
287
  routes.push({ route, prefix: forbidden.prefix, why: forbidden.why });
240
288
  continue;
package/apps/studio/ws.ts CHANGED
@@ -10,6 +10,7 @@ import type { Collab, RoomConn } from './collab/index.ts';
10
10
  import type { Context } from './context.ts';
11
11
  import { createContainerWriteBridge, createHmrBroadcaster } from './hmr-broadcast.ts';
12
12
  import type { InspectRegistry } from './inspect.ts';
13
+ import { startCellFileEvents } from './sync/cell-file-events.ts';
13
14
  import { isWorkspaceMode } from './workspace-mode.ts';
14
15
 
15
16
  /**
@@ -274,7 +275,27 @@ export function createWs(
274
275
  // iframe stays stale until a manual reload (inspector-edits-live-render RCA).
275
276
  // Synthesise the fs:any the watcher owes us from each write's activity:suppress
276
277
  // arm. Workspace-mode only — locally fs.watch fires and this would double-load.
277
- if (isWorkspaceMode()) createContainerWriteBridge(ctx);
278
+ if (isWorkspaceMode()) {
279
+ createContainerWriteBridge(ctx);
280
+ // Sync v2 Increment 2/3 (DDR-226 §4) — the OTHER half of the same gap, and
281
+ // now both ways across it. The bridge above covers writes THIS process
282
+ // makes; a desktop asset PUT or a bucket→checkout refill happens in the HUB
283
+ // process, and nothing crossed that boundary.
284
+ //
285
+ // hub → child the hub pokes over the reserved `maude.files` channel and
286
+ // this turns the poke into the `fs:any` the watcher owed us
287
+ // child → hub the same `fs:any` (from the bridge above) is named to the
288
+ // hub, which journals it at once instead of finding it on
289
+ // the 15-minute walk-import belt
290
+ //
291
+ // Without the second direction the two sync directions have wildly
292
+ // different latency — seconds one way, up to a quarter of an hour the other
293
+ // — which is indistinguishable from "cloud → desktop is broken".
294
+ //
295
+ // Deliberately NOT gated on cell pairing — that is a one-tenant pilot and
296
+ // the gap is fleet-wide. See sync/ctl-provider.ts.
297
+ startCellFileEvents(ctx);
298
+ }
278
299
 
279
300
  // Phase 13 / DDR-029 — canvas activity overlay. activity.ts emits
280
301
  // `activity:change` per file as edits land + go idle. The overlay renders
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // `claude-design-server` — legacy direct alias for the design dev server.
3
+ // Historically this bin WAS the zero-dep Node server (apps/studio/server.mjs);
4
+ // that server is gone (Bun authoritative since DDR-009, deleted in the post-1.0
5
+ // hardening pass), so the alias now forwards to `maude design serve`, which owns
6
+ // binary resolution, the RCA G3 bun shim, and the loud no-runtime refusal.
7
+ import { spawn } from 'node:child_process';
8
+ import { resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const maude = resolve(fileURLToPath(import.meta.url), '..', 'maude.mjs');
12
+ const child = spawn(process.execPath, [maude, 'design', 'serve', ...process.argv.slice(2)], {
13
+ stdio: 'inherit',
14
+ });
15
+ child.on('exit', (code) => process.exit(code ?? 0));
16
+ child.on('error', (err) => {
17
+ process.stderr.write(`claude-design-server: ${err.message}\n`);
18
+ process.exit(1);
19
+ });
@@ -13,7 +13,7 @@ import { createRequire } from 'node:module';
13
13
  import { homedir } from 'node:os';
14
14
  import { basename, dirname, join, resolve } from 'node:path';
15
15
  import { parseArgs } from '../lib/argv.mjs';
16
- import { runAdopt, runLink, runStatus, runUnlink } from '../lib/design-link.mjs';
16
+ import { runAdopt, runDetach, runLink, runStatus, runUnlink } from '../lib/design-link.mjs';
17
17
  import { writeGitignoreBlock } from '../lib/gitignore-block.mjs';
18
18
  import { isCompiledBinary } from '../lib/pkg-root.mjs';
19
19
 
@@ -23,6 +23,7 @@ const SUBCOMMANDS = new Set([
23
23
  'export',
24
24
  'link',
25
25
  'unlink',
26
+ 'detach',
26
27
  'status',
27
28
  'adopt',
28
29
  'help',
@@ -171,6 +172,7 @@ export async function run({ args, pkgRoot }) {
171
172
  if (sub === 'export') return runExport({ args });
172
173
  if (sub === 'link') return runLink({ args });
173
174
  if (sub === 'unlink') return runUnlink({ args });
175
+ if (sub === 'detach') return runDetach({ args });
174
176
  if (sub === 'status') return runStatus({ args });
175
177
  if (sub === 'adopt') return runAdopt({ args });
176
178
  }
@@ -323,7 +325,7 @@ function usage() {
323
325
  return `maude design <verb> [options]
324
326
 
325
327
  Lifecycle:
326
- serve · init · export · link · adopt · unlink · status
328
+ serve · init · export · link · adopt · detach · unlink · status
327
329
 
328
330
  Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
329
331
  screenshot · server-up · prep · slug · bootstrap-check · runtime-health
@@ -380,6 +382,12 @@ Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
380
382
  Alias of 'link --adopt'. Shipped as a discoverable subcommand so
381
383
  first-time bootstrap reads like 'git remote add' + 'git push -f'.
382
384
 
385
+ detach [--yes] [--keep-token]
386
+ Take .design/ back into this repo: unlink the hub and remove the
387
+ hub-owned ignore, so git sees the folder again. Nothing is
388
+ downloaded — the local mirror is already complete — and nothing
389
+ is committed for you. The reverse of 'adopt' (DDR-228).
390
+
383
391
  unlink [--keep-token]
384
392
  Drop linkedHub from .design/config.json + remove the per-machine
385
393
  token (unless --keep-token). Repo returns to solo mode. Local
@@ -466,7 +474,6 @@ async function runServe({ args, pkgRoot }) {
466
474
  }
467
475
 
468
476
  const tsEntry = resolve(pkgRoot, 'apps', 'studio', 'server.ts');
469
- const mjsEntry = resolve(pkgRoot, 'apps', 'studio', 'server.mjs');
470
477
 
471
478
  const hasBun = await new Promise((res) => {
472
479
  const probe = spawn('bun', ['--version'], { stdio: 'ignore' });
@@ -474,9 +481,21 @@ async function runServe({ args, pkgRoot }) {
474
481
  probe.on('exit', (code) => res(code === 0));
475
482
  });
476
483
 
477
- const child = hasBun
478
- ? spawn('bun', ['run', tsEntry, ...forwarded], { stdio: 'inherit', env: process.env })
479
- : spawn(process.execPath, [mjsEntry, ...forwarded], { stdio: 'inherit', env: process.env });
484
+ if (!hasBun) {
485
+ // The pre-DDR-009 zero-dep Node server used to be the silent fallback here.
486
+ // It was deleted (post-1.0 hardening, T0b): it served a years-stale feature
487
+ // surface, so "works without bun" really meant "silently degrades to a
488
+ // different product". Bun is authoritative (DDR-009) — refuse loud instead.
489
+ process.stderr.write(
490
+ `maude design serve: running from source requires bun (DDR-009 — the dev\nserver is Bun-authoritative; the old Node fallback served a stale feature\nsurface and was removed).\n\n Install bun: curl -fsSL https://bun.sh/install | bash\n Or use the shipped platform binary instead of the source tree:\n npm i -g @1agh/maude && maude design serve\n\nRepo: ${pkgRoot}\n`
491
+ );
492
+ process.exit(1);
493
+ }
494
+
495
+ const child = spawn('bun', ['run', tsEntry, ...forwarded], {
496
+ stdio: 'inherit',
497
+ env: process.env,
498
+ });
480
499
  child.on('exit', (code) => process.exit(code ?? 0));
481
500
  child.on('error', (err) => {
482
501
  process.stderr.write(`maude design serve: ${err.message}\n`);