@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
@@ -713,6 +713,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
713
713
  decision?: unknown;
714
714
  action?: unknown;
715
715
  content?: unknown;
716
+ seq?: unknown;
716
717
  };
717
718
 
718
719
  const chatId = typeof frame.chat === 'string' && frame.chat ? frame.chat : 'default';
@@ -11,6 +11,7 @@
11
11
  * `/design:edit` that's about to rewrite their HTML.
12
12
  */
13
13
 
14
+ import type { JSX } from 'react';
14
15
  import { useEffect, useState } from 'react';
15
16
 
16
17
  function deriveFile(): string | null {
@@ -1008,7 +1008,9 @@ export function AnnotationContextToolbar({
1008
1008
  const canAlign = selectedStrokes.length >= 2;
1009
1009
 
1010
1010
  // Active values for swatch highlighting — when uniform across selection.
1011
- const uniqColor = uniformValue(selectedStrokes.map((s) => s.color));
1011
+ // Image / link / mediaref strokes carry no `color` — `uniformValue` already
1012
+ // filters undefined out, the `in` check just says so to the checker.
1013
+ const uniqColor = uniformValue(selectedStrokes.map((s) => ('color' in s ? s.color : undefined)));
1012
1014
  const uniqFill = caps.fill
1013
1015
  ? uniformValue(
1014
1016
  selectedStrokes.map((s) =>
@@ -21,6 +21,7 @@
21
21
  * on commit, debounced 200 ms.
22
22
  */
23
23
 
24
+ import type { JSX } from 'react';
24
25
  import {
25
26
  type CSSProperties,
26
27
  createContext,
@@ -72,6 +73,7 @@ import {
72
73
  type DrawMods,
73
74
  defaultFillFor,
74
75
  type EditorFmt,
76
+ type EllipseStroke,
75
77
  FILL_PALETTE,
76
78
  fmtEqual,
77
79
  HALO_PAD_PX,
@@ -90,6 +92,7 @@ import {
90
92
  LINK_GLYPH_D2,
91
93
  LINK_GLYPH_STROKE,
92
94
  LINK_TITLE_FILL,
95
+ type LinkStroke,
93
96
  type ListType,
94
97
  linkCardLayout,
95
98
  listPrefixedBody,
@@ -104,6 +107,7 @@ import {
104
107
  normalizeRect,
105
108
  normalizeSticky,
106
109
  normFmt,
110
+ type PenStroke,
107
111
  penPathD,
108
112
  polygonPoints,
109
113
  resolveDefaultInk,
@@ -726,9 +730,8 @@ const ANNOT_CSS = `
726
730
  * positioning. An SVG inside with width:100%/height:100% resolves to 0 px
727
731
  * and Chrome clips children even under overflow:visible. We hardcode a
728
732
  * very large width/height instead so the SVG viewport easily covers any
729
- * world-coord stroke. vector-effect="non-scaling-stroke" on every stroke
730
- * keeps thickness px-constant under CSS zoom; overflow:visible covers the
731
- * rare edge case of a stroke straying outside this 200k box.
733
+ * world-coord stroke; overflow:visible covers the rare edge case of a
734
+ * stroke straying outside this 200k box.
732
735
  */
733
736
  width: 200000px;
734
737
  height: 200000px;
@@ -2220,15 +2223,17 @@ export function AnnotationsLayer() {
2220
2223
  // — byte-identical to the old hardcode — browse in preview, so drawing an
2221
2224
  // annotation never silently exits the alive posture). Sticky lets the user
2222
2225
  // draw many shapes in a row (canonical pattern: tldraw double-click to
2223
- // lock). Eraser stays armed by default — that tool is destructive, not
2224
- // constructive. Map a highlighter pen (a 'pen' stroke with the flag) back
2225
- // to the 'highlighter' tool id so its sticky-lock check matches the
2226
- // active tool.
2226
+ // lock). Map a highlighter pen (a 'pen' stroke with the flag) back to the
2227
+ // 'highlighter' tool id so its sticky-lock check matches the active tool.
2228
+ //
2229
+ // No eraser case here: an eraser commits no stroke, so `cur.tool` (a stroke
2230
+ // KIND, off `drawingRef`) can never be 'eraser' — erase leaves through the
2231
+ // `isErase` branch above. It stays armed by being unreachable from here,
2232
+ // not by a guard. The typecheck surface completion (A2) proved the old
2233
+ // `!== 'eraser'` test was dead.
2227
2234
  const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
2228
- if (toolJustUsed !== 'eraser') {
2229
- const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
2230
- if (!stickyOnThis) resetTool();
2231
- }
2235
+ const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
2236
+ if (!stickyOnThis) resetTool();
2232
2237
  drawAnchorRef.current = null;
2233
2238
  lastDrawPointRef.current = null;
2234
2239
  setDrawing(null);
@@ -3840,8 +3845,10 @@ function AnnotationsInput({
3840
3845
 
3841
3846
  // ─────────────────────────────────────────────────────────────────────────────
3842
3847
  // SVG — portaled INTO `.dc-world` so the world's CSS zoom + translate apply
3843
- // natively. `vector-effect="non-scaling-stroke"` keeps stroke px-thick at any
3844
- // zoom level. `pointer-events: none` on the container — strokes are decorative
3848
+ // natively, which is what makes drawn ink scale with the zoom. Selection chrome
3849
+ // and card hairlines opt into `vector-effect="non-scaling-stroke"` per element
3850
+ // to stay px-thick; drawn ink deliberately does not (see `common` in
3851
+ // `renderStroke`). `pointer-events: none` on the container — strokes are decorative
3845
3852
  // for now (Phase 5.1 Task 6 will reintroduce hit-test via the selection store).
3846
3853
 
3847
3854
  function AnnotationsSvg({
@@ -4192,11 +4199,15 @@ function MediaRefPlayers({
4192
4199
  strokes,
4193
4200
  visible,
4194
4201
  }: {
4195
- worldRef: React.RefObject<HTMLElement | null>;
4202
+ // Same shape the context actually hands out (nullable OUTSIDE a world
4203
+ // provider, exactly like the sibling overlay at MediaRefChrome) — the old
4204
+ // `RefObject<HTMLElement | null>` both mis-typed the caller and let
4205
+ // `worldRef.current` be dereferenced on a null context.
4206
+ worldRef: ReturnType<typeof useWorldRefContext>;
4196
4207
  strokes: readonly Stroke[];
4197
4208
  visible: boolean;
4198
4209
  }) {
4199
- const target = worldRef.current;
4210
+ const target = worldRef?.current ?? null;
4200
4211
  if (!target || !visible) return null;
4201
4212
  const HEADER = 26;
4202
4213
  const refs = strokes.filter(
@@ -5768,6 +5779,13 @@ function StrokeNodeBase({
5768
5779
  </g>
5769
5780
  );
5770
5781
  }
5782
+ // Deliberately NO `vector-effect="non-scaling-stroke"` here: drawn ink is
5783
+ // world-space content and must thicken/thin with the zoom, the way it does in
5784
+ // Figma/FigJam. The attribute also rendered differently per engine — Blink
5785
+ // honours it under the world's CSS `zoom` (ink stayed a fixed screen width at
5786
+ // every zoom level), WebKit ignores it under `transform: scale`, so the same
5787
+ // board drew differently in Chrome than in the desktop shell. Card hairlines
5788
+ // and selection chrome below still opt in — those ARE fixed-px by intent.
5771
5789
  const common = {
5772
5790
  'data-id': stroke.id,
5773
5791
  'data-tool': stroke.tool,
@@ -5775,7 +5793,6 @@ function StrokeNodeBase({
5775
5793
  strokeWidth: stroke.width,
5776
5794
  strokeLinecap: 'round' as const,
5777
5795
  strokeLinejoin: 'round' as const,
5778
- vectorEffect: 'non-scaling-stroke' as const,
5779
5796
  };
5780
5797
  if (stroke.tool === 'pen') {
5781
5798
  // Highlighter (item 8) — overlaps darken via multiply; the translucent hue
@@ -99,6 +99,7 @@ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
99
99
  import { STICKERS_DIR } from './paths.ts';
100
100
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
101
101
  import { sessionDir } from './session-scope.ts';
102
+ import { isWorkspaceMode } from './workspace-mode.ts';
102
103
 
103
104
  // Directories that never hold user-facing canvases. Exported so the
104
105
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -728,6 +729,16 @@ export interface ApiHooks {
728
729
  * move rather than rename a file out from under a live hub session.
729
730
  */
730
731
  isRoomPinned?: (slug: string) => boolean;
732
+ /**
733
+ * The MOVE protocol (codec `stampMovedTo`): stamp the slug's shared document
734
+ * retired-to-`toRel`, push the stamp, and detach the sync provider — so the
735
+ * move can proceed instead of being refused. On a cell EVERY canvas is
736
+ * pinned (the studio child's own runtime holds the doc), which made the
737
+ * pinned refusal a universal "cannot move anything in the cloud".
738
+ * Returns false when no runtime carries the slug — the caller keeps the
739
+ * refusal for that case (an unretired pinned room is still unsafe to move).
740
+ */
741
+ retireCanvasForMove?: (fromSlug: string, toRel: string) => Promise<boolean>;
731
742
  /** Flush + force-tear-down a canvas's collab room ahead of a move (best
732
743
  * effort — a room may not be live for the slug at all). */
733
744
  flushAndDropRoom?: (slug: string) => Promise<void>;
@@ -1877,6 +1888,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1877
1888
  // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
1878
1889
  // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
1879
1890
  await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
1891
+ // Same reason as the annotations sidecar below, and the same bug: this
1892
+ // lane writes the FILE and nothing else, so in a cell — where there is no
1893
+ // `fs.watch` — the layout never entered the doc and never reached a peer.
1894
+ // Not "late", NEVER: a canvas `.meta.json` is `canvas-owned`, so it is
1895
+ // excluded from the journal by design and the walk-import belt behind
1896
+ // every other class does not cover it either. Dragging an artboard in the
1897
+ // cloud left every other machine showing the old position indefinitely,
1898
+ // while the same drag on a laptop crossed in seconds (a laptop watcher
1899
+ // fires) — which reads as "sync is broken one way" rather than as a
1900
+ // missing announce.
1901
+ announceWritten(path.relative(paths.designRoot, metaAbs));
1880
1902
  }
1881
1903
 
1882
1904
  // Return the merged view (shared meta + camera) — identical to GET, so the
@@ -1925,6 +1947,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1925
1947
  const clean = sanitizeAnnotationSvg(svg);
1926
1948
  await Bun.write(annotationsPath(file), clean);
1927
1949
  onAnnotationsChanged?.(file, clean);
1950
+ // Annotations reach OTHER VIEWERS over the collab room, which is why this
1951
+ // never needed an `fs:any`. But the file is also a versioned, file-plane
1952
+ // sidecar (DDR-115), and the file plane learns about a cell's own writes
1953
+ // through exactly this event — so without it, a sticky note drawn in the
1954
+ // cloud crossed to open browsers instantly and to a peer's DISK a quarter
1955
+ // of an hour later.
1956
+ announceWritten(`${fileSlug(file)}.annotations.svg`);
1928
1957
  return true;
1929
1958
  }
1930
1959
 
@@ -1961,6 +1990,30 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1961
1990
  * ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
1962
1991
  * both the route and any programmatic caller share ONE tested path.
1963
1992
  */
1993
+ /**
1994
+ * Say that a file this process wrote has landed.
1995
+ *
1996
+ * Most write paths get this for free: they arm `activity:suppress` before
1997
+ * writing, and `createContainerWriteBridge` turns that into the `fs:any` a
1998
+ * cell's `fs.watch` never fires. The asset writer does not — it streams to a
1999
+ * temp file and content-addresses the name, so there is no `rel` to suppress
2000
+ * until after the rename, by which point suppression means nothing.
2001
+ *
2002
+ * That gap was invisible while `fs:any` only drove hot-reload (the uploader
2003
+ * already knew, and other viewers reloaded eventually). It stopped being
2004
+ * invisible when the same event became how a cell tells its hub to journal a
2005
+ * write: an image dropped in the cloud got no row until the 15-minute
2006
+ * walk-import belt found it, so it reached a peer's laptop up to fifteen
2007
+ * minutes later while every other kind of edit crossed in seconds.
2008
+ *
2009
+ * WORKSPACE-MODE ONLY, on the same reasoning as the bridge: locally
2010
+ * `fs.watch` fires for this rename and a second source would double-load.
2011
+ */
2012
+ function announceWritten(rel: string): void {
2013
+ if (!isWorkspaceMode()) return;
2014
+ ctx.bus.emit('fs:any', rel);
2015
+ }
2016
+
1964
2017
  async function saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult> {
1965
2018
  const assetsDir = path.join(paths.designRoot, 'assets');
1966
2019
  const tmpName = `.tmp-${crypto.randomBytes(8).toString('hex')}`;
@@ -2081,6 +2134,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2081
2134
  await rename(tmpAbs, fileAbs);
2082
2135
  assetBytesWritten += total;
2083
2136
  const rel = `assets/${name}`;
2137
+ announceWritten(rel);
2084
2138
  // S3/R2 lane (Cloud Phase 3) — mirror the bytes so a second machine can
2085
2139
  // resolve them without the file riding git. Deliberately awaited but
2086
2140
  // never able to fail the save: the asset is already on disk, the mirror
@@ -2105,7 +2159,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2105
2159
 
2106
2160
  async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
2107
2161
  if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
2108
- return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
2162
+ // TS 5.7 widens a plain Uint8Array to Uint8Array<ArrayBufferLike>, which
2163
+ // BodyInit rejects (it excludes SharedArrayBuffer-backed views). These
2164
+ // bytes never are one.
2165
+ return saveAssetFromStream(
2166
+ new Response(bytes as Uint8Array<ArrayBuffer>).body as ReadableStream<Uint8Array>
2167
+ );
2109
2168
  }
2110
2169
 
2111
2170
  // Stage F1 (feature-element-editing-robustness) — list the versioned content-
@@ -3030,15 +3089,29 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3030
3089
  }
3031
3090
  }
3032
3091
 
3033
- // Collab guard — refuse a move while a shared-doc hub provider is pinned
3034
- // to this slug's room (DDR-064); otherwise flush + force-drop so the room
3035
- // isn't left keyed to a slug that no longer resolves to a file.
3092
+ // Collab guard — a pinned room (shared-doc hub provider attached, DDR-064)
3093
+ // must not have its file renamed out from under it. The old behaviour was
3094
+ // to REFUSE, which on a cell meant moving was impossible outright: cell
3095
+ // pairing pins every canvas, so a guard written for a rare local state
3096
+ // fired on 100% of cloud moves. Now the move COORDINATES: the sync runtime
3097
+ // stamps the document retired-to-the-new-path (the move protocol, codec
3098
+ // stampMovedTo) and detaches — after which the rename is exactly as safe
3099
+ // as on an unpinned desktop. The refusal remains only for the case where
3100
+ // no runtime can do that, because then the pin really is unownable here.
3036
3101
  if (hooks.isRoomPinned?.(fromSlug)) {
3037
- return {
3038
- ok: false,
3039
- status: 409,
3040
- error: 'canvas has a live shared session — cannot move while pinned to the hub',
3041
- };
3102
+ const retired = (await hooks.retireCanvasForMove?.(fromSlug, toRel)) ?? false;
3103
+ if (!retired) {
3104
+ return {
3105
+ ok: false,
3106
+ status: 409,
3107
+ error: 'canvas has a live shared session — cannot move while pinned to the hub',
3108
+ };
3109
+ }
3110
+ } else {
3111
+ // An unpinned canvas may still have a live synced document (the desktop
3112
+ // agent lane pins nothing) — retire it too, or the old document lives on
3113
+ // and re-materialises the canvas at its old path on every machine.
3114
+ await hooks.retireCanvasForMove?.(fromSlug, toRel);
3042
3115
  }
3043
3116
  await hooks.flushAndDropRoom?.(fromSlug);
3044
3117
 
@@ -3054,6 +3127,16 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3054
3127
  const moved: string[] = [path.relative(paths.repoRoot, toAbs)];
3055
3128
  for (const artifact of canvasArtifacts({ rel, paths })) {
3056
3129
  if (artifact.kind === 'primary') continue; // already moved above
3130
+ if (artifact.carryOnMove === false) {
3131
+ // The old slug's CRDT cache — carrying it would hand the new document
3132
+ // the retirement stamp we just wrote. Drop it; the canvas is on disk.
3133
+ // A FILE, not a tree: `recursive` would turn a future slug regression
3134
+ // into a directory delete, and this path is only ever one cache file.
3135
+ await rm(artifact.abs, { force: true }).catch((err) => {
3136
+ console.warn(`[move] could not drop the stale doc cache: ${(err as Error).message}`);
3137
+ });
3138
+ continue;
3139
+ }
3057
3140
  const dest = relocatedName(artifact, rel, toRel, paths);
3058
3141
  if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
3059
3142
  try {
@@ -3255,11 +3338,15 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3255
3338
  }
3256
3339
  }
3257
3340
 
3258
- // Collab guard for every canvas found, BEFORE any disk mutation — refuse
3259
- // the whole batch if even one room is pinned (a shared-doc hub provider
3260
- // attached, DDR-064).
3341
+ // Collab guard for every canvas found, BEFORE any disk mutation. Same
3342
+ // coordinated retire as moveCanvas — refuse only when a pinned room could
3343
+ // not be retired, and retire even unpinned synced documents so none of
3344
+ // them lives on to resurrect its canvas at the old path.
3261
3345
  for (const r of canvasRels) {
3262
- if (hooks.isRoomPinned?.(fileSlug(r))) {
3346
+ const slug = fileSlug(r);
3347
+ const toRel = toRelDir + r.slice(relDir.length);
3348
+ const retired = (await hooks.retireCanvasForMove?.(slug, toRel)) ?? false;
3349
+ if (!retired && hooks.isRoomPinned?.(slug)) {
3263
3350
  return {
3264
3351
  ok: false,
3265
3352
  status: 409,
@@ -3962,7 +4049,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3962
4049
  const hash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
3963
4050
  const verb = typeof input.verb === 'string' ? input.verb : '';
3964
4051
  const abs = r.abs;
3965
- let run: ((source: string) => { source: string } & Record<string, unknown>) | null = null;
4052
+ // Just `{ source: string }`: the `& Record<string, unknown>` was there for
4053
+ // the rest-spread below, but an INTERFACE return (SpeedResult and friends)
4054
+ // has no implicit index signature, so no verb's function was actually
4055
+ // assignable — every branch failed the check the same way.
4056
+ let run: ((source: string) => { source: string }) | null = null;
3966
4057
  switch (verb) {
3967
4058
  case 'speed': {
3968
4059
  const rate = Number(input.rate);
@@ -4059,6 +4150,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4059
4150
  ctx.bus.emit('activity:suppress', rel);
4060
4151
  try {
4061
4152
  const before = await Bun.file(abs).text();
4153
+ // The switch above either assigned `run` or returned; say so to the checker.
4154
+ if (!run) return { ok: false, status: 400, error: `unknown clip verb "${verb}"` };
4062
4155
  const result = await applyOnDisk(abs, run);
4063
4156
  const after = await Bun.file(abs).text();
4064
4157
  let seq: number | undefined;
@@ -5634,10 +5727,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5634
5727
  for (const m of matches) {
5635
5728
  const files = await findFiles(m.abs, m.rel, ['.tsx', '.html']);
5636
5729
  for (const f of files) {
5637
- const fname = f
5638
- .split('/')
5639
- .pop()
5640
- ?.replace(/\.(tsx|html)$/i, '');
5730
+ const fname = (f.split('/').pop() ?? '').replace(/\.(tsx|html)$/i, '');
5641
5731
  const group = f.split('/').slice(-2, -1)[0] || folderName;
5642
5732
  const label = fname.toLowerCase() === 'index' ? group : fname;
5643
5733
  items.push({ label, path: f, group });
@@ -75,7 +75,11 @@ export interface KindOverlayProps {
75
75
  visibility: OverlayVisibility;
76
76
  }
77
77
 
78
- export type KindOverlayRenderFn = (props: KindOverlayProps) => ReactNode;
78
+ export type KindOverlayRenderFn = ((props: KindOverlayProps) => ReactNode) & {
79
+ /** React DevTools label. Every registered renderer sets it; the registry
80
+ * holds plain functions, so it is not inherited from a component type. */
81
+ displayName?: string;
82
+ };
79
83
 
80
84
  // ─────────────────────────────────────────────────────────────────────────────
81
85
  // Per-kind content registry. Foundation registers nothing here — the generic
@@ -230,7 +230,12 @@ export function createAssetMirror(
230
230
 
231
231
  const send = (method: string, key: string, body: Uint8Array | null = null) => {
232
232
  const { url, headers } = signRequest(cfg, { method, key, body });
233
- return fetch(url, { method, headers, ...(body === null ? {} : { body }) });
233
+ // Uint8Array<ArrayBufferLike> vs BodyInit — see saveAsset in api.ts.
234
+ return fetch(url, {
235
+ method,
236
+ headers,
237
+ ...(body === null ? {} : { body: body as Uint8Array<ArrayBuffer> }),
238
+ });
234
239
  };
235
240
 
236
241
  return {
@@ -372,7 +372,10 @@ export async function importBoard({
372
372
  url,
373
373
  root,
374
374
  designRootRel = '.design',
375
- slug,
375
+ // `= undefined` is the default it already had implicitly — spelled out so the
376
+ // parameter reads as OPTIONAL (the studio's panel route passes no slug and
377
+ // relies on the `slug ?? figjam-<key>` fallback below).
378
+ slug = undefined,
376
379
  dryRun = false,
377
380
  confirmLarge = false,
378
381
  local = null,
@@ -1145,7 +1148,7 @@ export async function importFrames({
1145
1148
  url,
1146
1149
  root,
1147
1150
  designRootRel = '.design',
1148
- slug,
1151
+ slug = undefined,
1149
1152
  dryRun = false,
1150
1153
  kind = 'digital',
1151
1154
  local = null,
@@ -1322,7 +1325,9 @@ export async function explodeArtboard({
1322
1325
  artboardId,
1323
1326
  confirmDocument = false,
1324
1327
  dryRun = false,
1325
- session,
1328
+ // Optional: falls back to a fresh CodegenSession below. The studio route
1329
+ // never supplies one.
1330
+ session = undefined,
1326
1331
  // Injected for the same reason `assets.ts` injects `ResolveDeps`: so this can
1327
1332
  // be exercised without the network. A test that used the real one would spend
1328
1333
  // the developer's actual PAT against a fixture file key.
@@ -354,7 +354,7 @@ export function writeCompileEntry(target: PlatformTarget): string {
354
354
  import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
355
355
  process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
356
356
  // DDR-177 — a compiled binary must be self-contained: every runtime-spawned
357
- // child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
357
+ // child (the canvas-build worker) resolves its JS runtime via
358
358
  // resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
359
359
  // binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
360
360
  // so the asset sweep died with "Executable not found in $PATH" on machines
@@ -30,6 +30,14 @@ export interface CanvasArtifact {
30
30
  * see DDR-115). Informational only; callers don't need to branch on it
31
31
  * today, but it keeps the taxonomy legible at the one place it's listed. */
32
32
  versioned: boolean;
33
+ /**
34
+ * May this artifact follow the canvas to a new path? Absent means yes.
35
+ *
36
+ * Only the `.ydoc.bin` cache says no, and for a specific reason — see the
37
+ * comment at its entry. A `false` here means the mover DELETES it rather than
38
+ * renaming it: it belongs to the slug that is going away.
39
+ */
40
+ carryOnMove?: boolean;
33
41
  }
34
42
 
35
43
  /**
@@ -85,10 +93,23 @@ export function canvasArtifacts(input: { rel: string; paths: Paths }): CanvasArt
85
93
  });
86
94
  out.push({
87
95
  // collab/persistence.ts ydocBinPath — the `.ydoc.bin` cache under `_state/`.
96
+ //
97
+ // DROPPED ON A MOVE, NEVER CARRIED (`carryOnMove: false`). This file is the
98
+ // OLD document's CRDT state, and a move's last act before the rename is to
99
+ // stamp that document retired (`retireForMove` → `movedTo`) and flush it —
100
+ // so carrying it to the new slug hands the NEW document a cache whose first
101
+ // word is "I have moved away". Every peer that opened it released the canvas
102
+ // as retired, the destination path therefore never appeared anywhere else,
103
+ // and the move looked like "folders don't sync": each machine showed its own
104
+ // move and the other's canvas still sitting at the root.
105
+ //
106
+ // Nothing is lost by dropping it. `flushAndDropRoom` has already written the
107
+ // canvas to disk, and the new document is seeded from those bytes.
88
108
  abs: path.join(paths.designRoot, '_state', `${slug}.ydoc.bin`),
89
109
  kind: 'slug-keyed',
90
110
  rekey: true,
91
111
  versioned: false,
112
+ carryOnMove: false,
92
113
  });
93
114
  out.push({
94
115
  // Versioned — a real git rename on move, which is correct and expected.
@@ -255,15 +255,10 @@ function buildCssInjector(slug: string, css: string): string {
255
255
  function importAllowlist(
256
256
  designRoot: string,
257
257
  denials: Array<{ specifier: string; reason: string }>
258
- ): {
259
- name: string;
260
- setup: (builder: {
261
- onResolve: (
262
- opts: { filter: RegExp },
263
- cb: (args: { path: string; importer: string }) => unknown
264
- ) => void;
265
- }) => void;
266
- } {
258
+ // Bun's own plugin type, not a hand-rolled structural twin: the local shape
259
+ // described a narrower `setup` than Bun.build's `plugins` accepts, so the
260
+ // value was not assignable at the one place it is used.
261
+ ): import('bun').BunPlugin {
267
262
  const root = path.resolve(designRoot);
268
263
  const inRoot = (p: string) => p === root || p.startsWith(root + path.sep);
269
264
  const deny = (specifier: string, reason: string): never => {
@@ -274,6 +269,16 @@ function importAllowlist(
274
269
  name: 'maude-import-allowlist',
275
270
  setup(builder) {
276
271
  builder.onResolve({ filter: /.*/ }, (args: { path: string; importer: string }) => {
272
+ // `data:` / `blob:` are SELF-CONTAINED — no request leaves the page, so
273
+ // the sandbox has nothing to say about them. They reach this hook
274
+ // because bundled CSS runs every `url()` through onResolve, and
275
+ // `url("data:image/svg+xml,…")` — the standard idiom for grain,
276
+ // textures and tiny inline icons — is neither relative nor absolute,
277
+ // so the bare-specifier branch below used to reject it with the
278
+ // npm-packages message (spike finding M9: a design system's always-on
279
+ // film-grain took the whole canvas build down). The denial is for
280
+ // schemes that go to the NETWORK; these never do.
281
+ if (/^(data|blob):/i.test(args.path)) return { path: args.path, external: true };
277
282
  const importer = args.importer ? path.resolve(args.importer) : '';
278
283
  // Our own modules (canvas-lib and its graph) resolve normally.
279
284
  if (importer && !inRoot(importer)) return null;
@@ -30,7 +30,6 @@ import {
30
30
  Component,
31
31
  type ComponentType,
32
32
  createElement,
33
- Fragment,
34
33
  type ReactNode,
35
34
  useCallback,
36
35
  useEffect,
@@ -494,20 +493,20 @@ export interface MountCanvasOptions {
494
493
  // from the one DCArtboard (canvas-lib) consumes. Same reasoning the real
495
494
  // ToolProvider lives in DesignCanvas, not in this layer's MaybeToolProvider.
496
495
  function buildCanvasTree(Canvas: ComponentType, file: string | undefined): ReactNode {
497
- return createElement(
498
- MaybeToolProvider,
499
- // DDR-223 — this layer's provider instance keeps the DDR-187 posture:
500
- // bare DS specimens stay ALIVE with native cursors (there is no palette,
501
- // no editing, nothing to boot into edit for). On a UI canvas the inner
502
- // canvas-lib ToolProvider (a separate bundle, separate context) owns the
503
- // real boot posture (edit/`move`); this ancestor instance only carries the
504
- // comment tool + claim layer and converges on the first tool keydown.
505
- { initial: 'browse' as const },
506
- createElement(
507
- MaybeSelectionSetProvider,
508
- null,
509
- createElement(CommentHost, { file }, createElement(Canvas))
510
- )
496
+ // DDR-223 — this layer's provider instance keeps the DDR-187 posture:
497
+ // bare DS specimens stay ALIVE with native cursors (there is no palette,
498
+ // no editing, nothing to boot into edit for). On a UI canvas the inner
499
+ // canvas-lib ToolProvider (a separate bundle, separate context) owns the
500
+ // real boot posture (edit/`move`); this ancestor instance only carries the
501
+ // comment tool + claim layer and converges on the first tool keydown.
502
+ return (
503
+ <MaybeToolProvider initial="browse">
504
+ <MaybeSelectionSetProvider>
505
+ <CommentHost file={file}>
506
+ <Canvas />
507
+ </CommentHost>
508
+ </MaybeSelectionSetProvider>
509
+ </MaybeToolProvider>
511
510
  );
512
511
  }
513
512
 
@@ -576,20 +575,20 @@ export class CanvasErrorBoundary extends Component<
576
575
  },
577
576
  { hasError: boolean }
578
577
  > {
579
- state = { hasError: false };
578
+ override state = { hasError: false };
580
579
  static getDerivedStateFromError(): { hasError: boolean } {
581
580
  return { hasError: true };
582
581
  }
583
- componentDidCatch(): void {
582
+ override componentDidCatch(): void {
584
583
  this.props.onError();
585
584
  }
586
- componentDidUpdate(prev: { attempt: number }): void {
585
+ override componentDidUpdate(prev: { attempt: number }): void {
587
586
  // A new canvas (new attempt) arrived → clear the error and try rendering it.
588
587
  if (prev.attempt !== this.props.attempt && this.state.hasError) {
589
588
  this.setState({ hasError: false });
590
589
  }
591
590
  }
592
- render(): ReactNode {
591
+ override render(): ReactNode {
593
592
  if (this.state.hasError) return this.props.fallback();
594
593
  return this.props.children;
595
594
  }
@@ -648,16 +647,14 @@ function CanvasHmrRuntime({
648
647
  return LG ? buildCanvasTree(LG, file) : null;
649
648
  }, [file]);
650
649
 
651
- return createElement(
652
- Fragment,
653
- null,
654
- createElement(
655
- CanvasErrorBoundary,
656
- { attempt, onError: handleError, fallback },
657
- // key=attempt → each soft-reload remounts a fresh subtree (and resets the
658
- // boundary's child), matching the clean-slate semantics of a full reload.
659
- createElement(OkSignal, { key: attempt, Canvas: canvas, file, onOk: handleOk })
660
- ),
661
- holding.on ? createElement(HmrHoldingToast, { message: holding.message }) : null
650
+ return (
651
+ <>
652
+ <CanvasErrorBoundary attempt={attempt} onError={handleError} fallback={fallback}>
653
+ {/* key=attempt → each soft-reload remounts a fresh subtree (and resets the
654
+ boundary's child), matching the clean-slate semantics of a full reload. */}
655
+ <OkSignal key={attempt} Canvas={canvas} file={file} onOk={handleOk} />
656
+ </CanvasErrorBoundary>
657
+ {holding.on ? <HmrHoldingToast message={holding.message} /> : null}
658
+ </>
662
659
  );
663
660
  }
@@ -8,7 +8,7 @@
8
8
  * 32 × 32, leaving 8 px of optical padding on every side.
9
9
  */
10
10
 
11
- import type { SVGProps } from 'react';
11
+ import type { JSX, SVGProps } from 'react';
12
12
 
13
13
  type IconProps = SVGProps<SVGSVGElement> & { size?: number };
14
14