@1agh/maude 0.60.6 → 1.0.2

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 +190 -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 -6
  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 +919 -214
  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 +90 -16
  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 +99 -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
@@ -394,6 +394,11 @@ export function applyInsertClipAt(
394
394
  }
395
395
  const indent = `${lineStartInfo(source, rootClose).indent} `;
396
396
  const s2 = new MagicString(source);
397
+ // Placeholder wins over media (same precedence as the append path below),
398
+ // then media, then nothing. The old two-arm form read `media.tag` in the
399
+ // else, where only the `needs media` throw above ruled out null — a
400
+ // disjunction the checker cannot carry. Spelled as three arms it needs no
401
+ // assertion, and the unreachable third arm is honest rather than absent.
397
402
  const gfChild = opts.placeholder
398
403
  ? placeholderChildText(
399
404
  canvasAbsPath,
@@ -402,7 +407,9 @@ export function applyInsertClipAt(
402
407
  `${indent} `,
403
408
  dur
404
409
  ).trimEnd()
405
- : `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />`;
410
+ : media
411
+ ? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />`
412
+ : '';
406
413
  s2.appendLeft(
407
414
  rootClose,
408
415
  ` <TransitionSeries>\n${indent} <TransitionSeries.Sequence durationInFrames={${dur}}>` +
@@ -25,7 +25,7 @@ import {
25
25
  writeFileSync,
26
26
  } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
- import { basename, dirname, join } from 'node:path';
28
+ import { basename, dirname, join, relative, sep } from 'node:path';
29
29
 
30
30
  import { deleteHubCredential, saveHubCredential } from '../sync/hub-link.ts';
31
31
  import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
@@ -48,6 +48,26 @@ export interface CloudEndpointResult {
48
48
  json: unknown;
49
49
  }
50
50
 
51
+ /**
52
+ * Why a historical blob could not be produced — or the blob.
53
+ *
54
+ * `not-found` is the only AUTHORITATIVE negative: a commit's content never
55
+ * changes, so the answer is stable and a caller may cache it. `unreachable`,
56
+ * `not-linked` and the two argument refusals are all transient or caller-side,
57
+ * and caching any of them turns an outage into a permanent 404.
58
+ */
59
+ export type HistoryFileResult =
60
+ | { ok: true; source: string }
61
+ | { ok: false; reason: 'not-found' | 'unreachable' | 'not-linked' | 'bad-sha' | 'bad-path' };
62
+
63
+ /**
64
+ * Ceiling on ONE response from a linked cell.
65
+ *
66
+ * Comfortably above the hub's own 2 MiB blob cap, so a legitimate answer is
67
+ * never refused, and far below "buffer whatever arrives for eight seconds".
68
+ */
69
+ const MAX_HUB_RESPONSE_BYTES = 8 * 1024 * 1024;
70
+
51
71
  /** Whatever the control plane answered. Read defensively at every use. */
52
72
  type CloudBody = Record<string, unknown> & {
53
73
  token?: string;
@@ -123,6 +143,13 @@ interface Ctx {
123
143
  * missing supervisor degrades to exactly the old behaviour — linked on disk,
124
144
  * syncing after the next start.
125
145
  */
146
+ /**
147
+ * The link changed (attach / detach). Lets the server drop anything it
148
+ * cached about "what a past version of this file looked like" — those
149
+ * answers were produced under the OLD link and are keyed by nothing that
150
+ * would distinguish them.
151
+ */
152
+ onLinkChanged?: () => void;
126
153
  syncControl?: {
127
154
  /** `null` = unlink: clear the runtime's in-memory link, cycle to solo. */
128
155
  restart(
@@ -266,7 +293,10 @@ export function createCloudEndpoints(ctx: Ctx) {
266
293
  headers: { authorization: `Bearer ${file.token}`, 'content-type': 'application/json' },
267
294
  body: JSON.stringify({ project: projectId }),
268
295
  });
269
- if (opened.status !== 200 || !opened.body?.token) {
296
+ // `url` is checked alongside `token`: linkToWorkspace requires a
297
+ // workspace URL, and a 200 body missing it would have linked against
298
+ // `undefined` rather than refusing.
299
+ if (opened.status !== 200 || !opened.body?.token || !opened.body.url) {
270
300
  return {
271
301
  status: 502,
272
302
  json: { ok: false, error: 'The project could not be opened with this account.' },
@@ -295,7 +325,7 @@ export function createCloudEndpoints(ctx: Ctx) {
295
325
  headers: { 'content-type': 'application/json' },
296
326
  body: JSON.stringify({ code }),
297
327
  });
298
- if (exchanged.status !== 200 || !exchanged.body?.token) {
328
+ if (exchanged.status !== 200 || !exchanged.body?.token || !exchanged.body.url) {
299
329
  return {
300
330
  status: 410,
301
331
  json: {
@@ -331,6 +361,93 @@ export function createCloudEndpoints(ctx: Ctx) {
331
361
  });
332
362
  },
333
363
 
364
+ /**
365
+ * THE HISTORY THAT IS ACTUALLY BEING WRITTEN — feature-cloud-managed-git-
366
+ * posture.
367
+ *
368
+ * In cloud-managed posture the cell is the sole committer (DDR-198/209/213)
369
+ * and the local repo has no commits at all, so the desktop's History tab
370
+ * read a repo nobody writes and reported "No saved versions yet" directly
371
+ * under "Cloud is saving". This asks the cell instead.
372
+ *
373
+ * SERVER-SIDE, ALWAYS. The hub credential resolved here never reaches the
374
+ * browser — the client talks only to the loopback `/_api/cloud/history`,
375
+ * exactly as it does for every other call in this module.
376
+ */
377
+ async history(pathRaw?: string | null, limitRaw?: string | null): Promise<CloudEndpointResult> {
378
+ const hub = credentialedHub();
379
+ if (!hub) return { status: 200, json: { ok: false, reason: 'not-linked' } };
380
+
381
+ const qs = new URLSearchParams();
382
+ const rel = designRelative(pathRaw);
383
+ if (rel) qs.set('path', rel);
384
+ const limit = Number(limitRaw);
385
+ if (Number.isFinite(limit) && limit > 0)
386
+ qs.set('limit', String(Math.min(100, Math.trunc(limit))));
387
+
388
+ const r = await hubFetch(hub, `/api/history?${qs.toString()}`);
389
+ if (!r.ok) return { status: 200, json: { ok: false, reason: 'unreachable' } };
390
+ // Clamp what the cell says before it reaches a renderer. The hub caps its
391
+ // own page at 100 rows, but a compromised cell does not run that code.
392
+ const clamp = (v: unknown, n: number) => (typeof v === 'string' ? v.slice(0, n) : '');
393
+ const body = (r.body ?? {}) as { entries?: unknown; branch?: unknown; project?: unknown };
394
+ const rows = Array.isArray(body.entries) ? body.entries.slice(0, 100) : [];
395
+ return {
396
+ status: 200,
397
+ json: {
398
+ ok: true,
399
+ entries: rows.map((e) => {
400
+ const r0 = (e ?? {}) as Record<string, unknown>;
401
+ return {
402
+ sha: clamp(r0.sha, 40),
403
+ message: clamp(r0.message, 500),
404
+ author: clamp(r0.author, 120),
405
+ date: clamp(r0.date, 40),
406
+ };
407
+ }),
408
+ branch: typeof body.branch === 'string' ? body.branch.slice(0, 200) : null,
409
+ project: typeof body.project === 'string' ? body.project.slice(0, 200) : null,
410
+ hubHost: hubHost(hub.url),
411
+ },
412
+ };
413
+ },
414
+
415
+ /**
416
+ * One file's source at one cloud commit — what the version preview builds
417
+ * from when the sha exists only on the cell.
418
+ *
419
+ * `sha` is validated HERE as well as on the hub. It reaches the preview
420
+ * route from the UNTRUSTED canvas origin (DDR-054), and "the other side
421
+ * checks it" is how a guard ends up existing on neither side.
422
+ */
423
+ async historyFile(sha: string, pathRaw: string): Promise<HistoryFileResult> {
424
+ // WHY THIS REPORTS A REASON INSTEAD OF JUST `null`.
425
+ //
426
+ // The caller caches a miss so a repeated bad sha costs nothing. If every
427
+ // failure looked alike, an UNREACHABLE cell (or an unlinked project)
428
+ // would be remembered as "this version does not exist" — and the entry
429
+ // outlives the outage, so a version stays permanently unpreviewable. That
430
+ // is the same collapse of "could not reach" into "is not there" this
431
+ // whole feature exists to undo; the panel is careful about it one layer
432
+ // up, and it would be silly to re-introduce it one layer down.
433
+ //
434
+ // Only `not-found` is authoritative. Everything else is transient.
435
+ if (!/^[0-9a-f]{7,40}$/.test(String(sha ?? ''))) return { ok: false, reason: 'bad-sha' };
436
+ const rel = designRelative(pathRaw);
437
+ if (!rel) return { ok: false, reason: 'bad-path' };
438
+ const hub = credentialedHub();
439
+ if (!hub) return { ok: false, reason: 'not-linked' };
440
+
441
+ const qs = new URLSearchParams({ sha, path: rel });
442
+ const r = await hubFetch(hub, `/api/history/file?${qs.toString()}`, { text: true });
443
+ if (r.ok && typeof r.text === 'string') return { ok: true, source: r.text };
444
+ // A 404 is the cell's ONE indistinguishable refusal (out-of-scope,
445
+ // out-of-tree, wrong class, absent). Treating it as authoritative is
446
+ // correct for caching: none of those become true later for the same
447
+ // (sha, path), because a commit's content never changes.
448
+ return { ok: false, reason: r.status === 404 ? 'not-found' : 'unreachable' };
449
+ },
450
+
334
451
  /**
335
452
  * Detach THIS project from its workspace — the in-app `maude design
336
453
  * unlink` (fix 7's Disconnect, sync RCA 2026-08-10). Drops the committed
@@ -372,6 +489,7 @@ export function createCloudEndpoints(ctx: Ctx) {
372
489
  } catch {
373
490
  /* best-effort — the link is gone on disk either way */
374
491
  }
492
+ ctx.onLinkChanged?.();
375
493
  return { status: 200, json: { ok: true, detached: !!linked } };
376
494
  },
377
495
  };
@@ -404,6 +522,148 @@ export function createCloudEndpoints(ctx: Ctx) {
404
522
  }
405
523
  }
406
524
 
525
+ /**
526
+ * The linked hub AND its credential, or null.
527
+ *
528
+ * "Linked" alone is not enough to make a network call on: `config.json` is
529
+ * COMMITTED and travels with the repo, so a `linkedHub` in it is
530
+ * attacker-authorable (B2). A stored credential is the corroboration — and
531
+ * it is also the thing we would be sending, so resolving both together is
532
+ * what keeps a no-credential case from becoming an unauthenticated request
533
+ * to an address a repo named.
534
+ */
535
+ function credentialedHub(): { url: string; token: string } | null {
536
+ const linked = readLinkedHub();
537
+ if (!linked?.credentialed) return null;
538
+ try {
539
+ const url = normalizeUrl(linked.url);
540
+ const token = getHubToken(url);
541
+ return token ? { url, token } : null;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ /**
548
+ * One authenticated GET against the linked cell.
549
+ *
550
+ * NEITHER THE TOKEN NOR THE HUB'S OWN ERROR BODY EVER LEAVES THIS FUNCTION.
551
+ * Callers get `{ ok }` plus the parsed payload on success, so a hub message
552
+ * cannot be relayed into the browser (or into a log) by accident.
553
+ *
554
+ * A 401 IS A PLAIN READ FAILURE HERE. `/_api/cloud/status`'s 401 path
555
+ * DELETES the stored credential (confused-deputy F6, and correct there: the
556
+ * control plane is the authority on whether this device is still trusted).
557
+ * A History poll is not that authority — a cell restarting mid-token-renewal
558
+ * would otherwise silently unlink a working project. Read fails; credential
559
+ * stands.
560
+ */
561
+ async function hubFetch(
562
+ hub: { url: string; token: string },
563
+ path: string,
564
+ { text = false, timeoutMs = 8_000, maxBytes = MAX_HUB_RESPONSE_BYTES } = {}
565
+ ): Promise<{ ok: boolean; status: number; body?: unknown; text?: string }> {
566
+ const ctl = new AbortController();
567
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
568
+ try {
569
+ const res = await fetch(`${hub.url}${path}`, {
570
+ headers: { authorization: `Bearer ${hub.token}` },
571
+ signal: ctl.signal,
572
+ });
573
+ if (!res.ok) return { ok: false, status: res.status };
574
+ // THE CELL IS NOT TRUSTED TO RESPECT ITS OWN LIMITS. The hub caps a blob
575
+ // at 2 MiB and checks `cat-file -s` before reading — but a compromised
576
+ // cell, or anything MITM-ing a self-hosted `http://` hub, simply does
577
+ // not run that code. Without a cap here the desktop buffers an endless
578
+ // stream into one JS string for the whole timeout, and the trigger is
579
+ // reachable from the UNTRUSTED canvas origin (a canvas picks the `?sha=`
580
+ // on the version-preview route). Read through a counted stream and abort
581
+ // past the cap. (Security re-review of 8134ca8f, finding F2.)
582
+ const body = await readCapped(res, ctl, maxBytes);
583
+ if (body == null) return { ok: false, status: 0 };
584
+ if (text) return { ok: true, status: res.status, text: body };
585
+ try {
586
+ return { ok: true, status: res.status, body: JSON.parse(body) };
587
+ } catch {
588
+ return { ok: true, status: res.status, body: {} };
589
+ }
590
+ } catch {
591
+ return { ok: false, status: 0 };
592
+ } finally {
593
+ clearTimeout(timer);
594
+ }
595
+ }
596
+
597
+ /** Read a response body, aborting past `maxBytes`. Null = refused/failed. */
598
+ async function readCapped(
599
+ res: Response,
600
+ ctl: AbortController,
601
+ maxBytes: number
602
+ ): Promise<string | null> {
603
+ const declared = Number(res.headers.get('content-length'));
604
+ if (Number.isFinite(declared) && declared > maxBytes) {
605
+ ctl.abort();
606
+ return null;
607
+ }
608
+ if (!res.body) {
609
+ const t = await res.text();
610
+ return t.length > maxBytes ? null : t;
611
+ }
612
+ const reader = res.body.getReader();
613
+ const dec = new TextDecoder('utf8');
614
+ let out = '';
615
+ let seen = 0;
616
+ for (;;) {
617
+ const { done, value } = await reader.read();
618
+ if (done) break;
619
+ seen += value?.byteLength ?? 0;
620
+ if (seen > maxBytes) {
621
+ // Content-Length is a claim; this is the measurement.
622
+ try {
623
+ await reader.cancel();
624
+ } catch {
625
+ /* already gone */
626
+ }
627
+ return null;
628
+ }
629
+ out += dec.decode(value, { stream: true });
630
+ }
631
+ return out + dec.decode();
632
+ }
633
+
634
+ /**
635
+ * repo-relative (`.design/ui/Card.tsx`) → design-root-relative (`ui/Card.tsx`).
636
+ *
637
+ * The hub speaks design-root-relative on every file surface it has, and the
638
+ * cell's design root need not share this machine's folder name — so the
639
+ * translation belongs on this side, where both roots are known. A path
640
+ * outside the design tree returns null rather than being passed through: the
641
+ * cell would refuse it anyway, and sending it would make this a probe.
642
+ */
643
+ function designRelative(raw?: string | null): string | null {
644
+ if (typeof raw !== 'string' || raw === '') return null;
645
+ const p = raw.replace(/\\/g, '/');
646
+ if (p.split('/').includes('..')) return null;
647
+ const designRel = relative(ctx.paths.repoRoot, ctx.paths.designRoot).split(sep).join('/');
648
+ if (!designRel || designRel.startsWith('..')) return null;
649
+ if (p === designRel) return null;
650
+ if (p.startsWith(`${designRel}/`)) return p.slice(designRel.length + 1) || null;
651
+ // Already design-root-relative (the History scope the panel holds for a
652
+ // canvas opened from the cell) — accept it as-is rather than refusing a
653
+ // caller for using the hub's own vocabulary.
654
+ return p.startsWith('/') ? null : p;
655
+ }
656
+
657
+ /** The cell's host, for a header that must name SOMETHING when the project
658
+ * name is unknown. Address only — already-public, and never the token. */
659
+ function hubHost(url: string): string | null {
660
+ try {
661
+ return new URL(url).host;
662
+ } catch {
663
+ return null;
664
+ }
665
+ }
666
+
407
667
  /** The shared tail of every attach: cell exchange → credential + linkedHub. */
408
668
  async function linkToWorkspace({
409
669
  workspaceUrl,
@@ -499,6 +759,8 @@ export function createCloudEndpoints(ctx: Ctx) {
499
759
  };
500
760
  }
501
761
 
762
+ ctx.onLinkChanged?.();
763
+
502
764
  return {
503
765
  status: 200,
504
766
  json: {
@@ -129,7 +129,9 @@ export const SYNC_AGENT_ORIGIN: object = markTrustedOrigin(
129
129
  export function rootTypesTouched(transaction: Y.Transaction): Set<string> {
130
130
  const roots = new Set<string>();
131
131
  for (const type of transaction.changed.keys()) {
132
- let t: Y.AbstractType<unknown> = type;
132
+ // `transaction.changed` keys are AbstractType<YEvent<any>>; the parent walk
133
+ // below is event-type agnostic, so widen once here rather than at each step.
134
+ let t: Y.AbstractType<unknown> = type as unknown as Y.AbstractType<unknown>;
133
135
  // biome-ignore lint/suspicious/noExplicitAny: `_item` is yjs-internal but stable API surface for parent walks.
134
136
  while ((t as any)._item != null) t = (t as any)._item.parent;
135
137
  try {
@@ -73,6 +73,11 @@ interface ComposeSelection {
73
73
  id?: string;
74
74
  selector: string;
75
75
  artboardId?: string | null;
76
+ /** Occurrence index within `selector` — always set by buildComposeSelection,
77
+ * read into the comment payload below. Missing from this local mirror of
78
+ * dom-selection's `Selection`, so the payload was silently sending
79
+ * `undefined` to the checker's eyes and the real value at runtime. */
80
+ index: number;
76
81
  tag: string;
77
82
  classes: string;
78
83
  text: string;
@@ -228,6 +228,30 @@
228
228
  "type": "boolean",
229
229
  "default": true,
230
230
  "description": "DDR-079 (supersedes DDR-072) — project-level TSX sync, ON BY DEFAULT. Omit the field to sync ALL .tsx canvases to this hub (the common case: a linked teammate sees the project's TSX without a hidden opt-in). Set false to opt the whole project OUT. A per-canvas .meta.json \"syncable\": false still excludes one canvas (the sidecar always wins). Inert unless the cross-origin sandbox is active (MAUDE_CANVAS_ORIGIN_SPLIT != 0) — the DDR-060 Lock-2 coupling is preserved. TSX bodies execute, so syncing broadens a WebRTC/self-nav exfil residual to every synced canvas — link only hubs you operate or trust; the dev-server prints a loud boot banner against non-loopback hubs."
231
+ },
232
+ "workspaceId": {
233
+ "type": "string",
234
+ "description": "DDR-192 §5 — stable workspace identity for the hub document namespace (ws/<workspace-id>/<branch>/<slug>). Authoritative when present; the cloud control plane sets it at provisioning time. Absent, the sync runtime derives it from the git origin remote."
235
+ },
236
+ "syncFiles": {
237
+ "type": "boolean",
238
+ "default": true,
239
+ "description": "Sync v2 — the journal-driven file plane (Plane B): the downward project-file pull AND the widened upward sweep. Default ON; set false to fall back to the pre-Sync-v2 reach (binary media under assets/ only). Settable from the Sync panel's Settings section."
240
+ },
241
+ "propagateDeletes": {
242
+ "type": "boolean",
243
+ "default": true,
244
+ "description": "Sync v2 Increment 6 — a deletion here becomes a deletion everywhere (losers quarantined into _trash/, never unlinked; cumulative breakers hold bulk removals for confirmation). Default ON; set false to hold every absence instead. Settable from the Sync panel's Settings section."
245
+ },
246
+ "resolveFirstAnchor": {
247
+ "type": "string",
248
+ "enum": ["keep-local", "keep-cloud"],
249
+ "description": "How to settle a FIRST-ANCHOR hold for the whole set at once: keep-local pushes this machine's copies up; keep-cloud takes the project's and parks yours beside them. ABSENT means keep asking — the hold is not an error. Settable from the Sync panel's Settings section."
250
+ },
251
+ "fileEvents": {
252
+ "type": "boolean",
253
+ "default": true,
254
+ "description": "Sync v2 Increment 2 (DDR-226 §4) — the file-event control channel (poke). Default ON where the hub advertises ledger support; set false to fall back to the 20 s poll cadence. The documented rollback for the poke — a config key because the target user has no terminal (DDR-177)."
231
255
  }
232
256
  },
233
257
  "additionalProperties": false
@@ -371,7 +371,11 @@ export function ContextMenuView({
371
371
  el.querySelectorAll<HTMLButtonElement>('button.dc-menu-item:not([disabled])')
372
372
  );
373
373
  if (items.length === 0) return;
374
- const idx = items.indexOf(document.activeElement);
374
+ // `document.activeElement` is Element | null; anything that is not one
375
+ // of these buttons behaves as "no current item" (-1), which is what
376
+ // indexOf returned before and what the wrap-around arithmetic expects.
377
+ const active = document.activeElement;
378
+ const idx = active instanceof HTMLButtonElement ? items.indexOf(active) : -1;
375
379
  const nextIdx =
376
380
  e.key === 'ArrowDown'
377
381
  ? (idx + 1) % items.length
@@ -526,32 +530,41 @@ function MenuItemRow({
526
530
  onMouseEnter={cancelClose}
527
531
  onMouseLeave={scheduleClose}
528
532
  >
529
- {submenuItems.map((sub) => (
530
- <button
531
- key={sub.id}
532
- type="button"
533
- role="menuitem"
534
- disabled={sub.disabled}
535
- title={sub.disabled ? sub.disabledHint : undefined}
536
- className={`dc-menu-item${sub.destructive ? ' is-destructive' : ''}`}
537
- onClick={() => {
538
- if (sub.disabled) return;
539
- sub.onSelect(target);
540
- onClose();
541
- }}
542
- onKeyDown={(e) => {
543
- if (e.key === 'ArrowLeft' || e.key === 'Escape') {
544
- e.preventDefault();
545
- e.stopPropagation();
546
- setSubOpen(false);
547
- btnRef.current?.focus();
548
- }
549
- }}
550
- >
551
- <span>{sub.label}</span>
552
- {sub.shortcut ? <span className="dc-menu-shortcut">{sub.shortcut}</span> : null}
553
- </button>
554
- ))}
533
+ {submenuItems.map((sub) => {
534
+ // Same per-click resolution the leaf branch does at `disabled`
535
+ // above. Passing the raw value here meant a FUNCTION-form
536
+ // `disabled` — always truthy — greyed the item out permanently and
537
+ // swallowed every click, whatever the predicate said about this
538
+ // target. Submenus simply never got the resolution leaves had.
539
+ const subDisabled =
540
+ typeof sub.disabled === 'function' ? sub.disabled(target) : sub.disabled;
541
+ return (
542
+ <button
543
+ key={sub.id}
544
+ type="button"
545
+ role="menuitem"
546
+ disabled={subDisabled}
547
+ title={subDisabled ? sub.disabledHint : undefined}
548
+ className={`dc-menu-item${sub.destructive ? ' is-destructive' : ''}`}
549
+ onClick={() => {
550
+ if (subDisabled) return;
551
+ sub.onSelect(target);
552
+ onClose();
553
+ }}
554
+ onKeyDown={(e) => {
555
+ if (e.key === 'ArrowLeft' || e.key === 'Escape') {
556
+ e.preventDefault();
557
+ e.stopPropagation();
558
+ setSubOpen(false);
559
+ btnRef.current?.focus();
560
+ }
561
+ }}
562
+ >
563
+ <span>{sub.label}</span>
564
+ {sub.shortcut ? <span className="dc-menu-shortcut">{sub.shortcut}</span> : null}
565
+ </button>
566
+ );
567
+ })}
555
568
  </div>
556
569
  ) : null}
557
570
  </div>
@@ -48,14 +48,59 @@ export interface LinkedHub {
48
48
  */
49
49
  workspaceId?: string;
50
50
  /**
51
- * feature-sync-file-plane — opt-in for the manifest-driven file plane
52
- * (Plane B): the downward project-file pull AND the widened upward sweep
53
- * (stylesheets, docs, code modules — classifier membership, see
54
- * sync/file-membership.ts). Default OFF this release; `MAUDE_SYNC_FILES=1`
55
- * forces it on for a session. The flag gates ONLY the new plane — the
56
- * canvas CRDT lanes and the DDR-217 asset lanes run regardless.
51
+ * Sync v2 — the journal-driven file plane (Plane B): the downward
52
+ * project-file pull AND the widened upward sweep (stylesheets, docs, code
53
+ * modules — classifier membership, see sync/file-membership.ts).
54
+ *
55
+ * **Default ON from Increment 4**, once its security gate closed. Set to
56
+ * `false` per project to fall back to the pre-Sync-v2 reach (binary media
57
+ * under `assets/` only) — a config key rather than a terminal command,
58
+ * because the target user has no terminal (DDR-177). `MAUDE_SYNC_FILES=0`
59
+ * forces it off for one session.
60
+ *
61
+ * The flag gates ONLY this plane — the canvas CRDT lanes and the DDR-217
62
+ * asset lanes run regardless.
57
63
  */
58
64
  syncFiles?: boolean;
65
+ /**
66
+ * Sync v2 Increment 6 — does a deletion here become a deletion everywhere?
67
+ *
68
+ * **Default ON.** A hub-owned mirror that ignores deletes is not a mirror:
69
+ * you remove a file, it comes back on the next pass, and the model the
70
+ * product describes stops being true.
71
+ *
72
+ * What makes that safe is not caution but the breakers: past ten files, or a
73
+ * quarter of what this machine tracks, in one pass, nothing is removed in
74
+ * EITHER direction and the pass says what it was about to do — a branch
75
+ * switch and a deliberate purge look identical until somebody confirms which
76
+ * one it was. Losers are quarantined into `_trash/`, never unlinked, on both
77
+ * ends. Set `false` per project to hold every absence instead.
78
+ */
79
+ propagateDeletes?: boolean;
80
+ /**
81
+ * How to settle a FIRST-ANCHOR hold, for the whole set at once.
82
+ *
83
+ * When a project is linked for the first time both sides usually have
84
+ * content and neither has been reconciled here, so sync holds rather than
85
+ * writing a conflict copy per file. `keep-local` pushes this machine's
86
+ * copies up; `keep-cloud` takes the project's and parks yours beside them.
87
+ * Absent means keep asking — the hold is not an error and waiting costs
88
+ * nothing but time.
89
+ */
90
+ resolveFirstAnchor?: 'keep-local' | 'keep-cloud';
91
+ /**
92
+ * Sync v2 Increment 2 (DDR-226 §4) — the file-event control channel.
93
+ *
94
+ * Default ON where the hub advertises `ledger`; set to `false` to fall back
95
+ * to exactly today's 20 s poll cadence. A CONFIG KEY rather than an env var
96
+ * on purpose: this is the documented rollback for the poke, and the target
97
+ * user has no terminal to set an env var in (DDR-177).
98
+ *
99
+ * Absence means ON. A committed `false` can only ever make sync SLOWER, so
100
+ * unlike `syncTsx` it carries no trust weight and needs no restore-proof
101
+ * absence-means-on gymnastics.
102
+ */
103
+ fileEvents?: boolean;
59
104
  }
60
105
 
61
106
  export interface DevServerConfig {
@@ -209,8 +254,12 @@ export interface Context {
209
254
  }>;
210
255
  /** A cycle is in flight — Resync refuses early rather than queueing. */
211
256
  busy?(): boolean;
212
- /** The live runtime, for the sweep-scoped cancel. Null in solo mode. */
213
- current?(): { cancelAssetSweep(): boolean } | null;
257
+ /** The live runtime, for the sweep-scoped cancel + the move protocol's
258
+ * retire step. Null in solo mode. Structural on purpose — see above. */
259
+ current?(): {
260
+ cancelAssetSweep(): boolean;
261
+ retireForMove?(fromSlug: string, toRel: string): Promise<boolean>;
262
+ } | null;
214
263
  };
215
264
  }
216
265
 
@@ -17,9 +17,10 @@
17
17
  * 30 Hz publish cadence keeps it smooth enough for the multiplayer demo.
18
18
  */
19
19
 
20
+ import type { JSX } from 'react';
20
21
  import { memo, useEffect, useState } from 'react';
21
22
 
22
- import { useViewportControllerContext } from './canvas-lib.tsx';
23
+ import { useLiveViewport } from './canvas-lib.tsx';
23
24
  import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx';
24
25
 
25
26
  const CURSOR_CSS = `
@@ -164,9 +165,17 @@ const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Elemen
164
165
  */
165
166
  interface PeerAnnotationSelectionProps {
166
167
  peer: ForeignAwareness;
168
+ /**
169
+ * Unused in the math below — the halo is measured straight off the DOM — but
170
+ * load-bearing as a PROP: `memo` compares props, and through a pan/zoom the
171
+ * peer never changes, so without a value that ticks with the camera this
172
+ * component would bail out of every re-render and leave the halo frozen in
173
+ * screen space while the stroke it outlines moves away underneath it.
174
+ */
175
+ viewport: ViewportSnapshot;
167
176
  }
168
177
 
169
- const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
178
+ export const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
170
179
  peer,
171
180
  }: PeerAnnotationSelectionProps): JSX.Element | null {
172
181
  if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null;
@@ -218,9 +227,11 @@ const PeerAnnotationSelection = memo(function PeerAnnotationSelection({
218
227
  */
219
228
  interface PeerSelectionProps {
220
229
  peer: ForeignAwareness;
230
+ /** See PeerAnnotationSelectionProps.viewport — the memo-invalidation tick. */
231
+ viewport: ViewportSnapshot;
221
232
  }
222
233
 
223
- const PeerSelection = memo(function PeerSelection({
234
+ export const PeerSelection = memo(function PeerSelection({
224
235
  peer,
225
236
  }: PeerSelectionProps): JSX.Element | null {
226
237
  if (!peer.selection) return null;
@@ -267,14 +278,15 @@ const PeerSelection = memo(function PeerSelection({
267
278
  export function CursorsOverlay(): JSX.Element {
268
279
  ensureCursorStyles();
269
280
  const peers = useForeignAwareness();
270
- const controller = useViewportControllerContext();
271
- const liveVp = controller?.viewport ?? { x: 0, y: 0, zoom: 1 };
272
- // Take a snapshot per render — controller.viewport already changes on
273
- // throttle, so this stays smooth enough at 30 Hz.
274
- const [vp, setVp] = useState<ViewportSnapshot>(liveVp);
275
- useEffect(() => {
276
- setVp({ x: liveVp.x, y: liveVp.y, zoom: liveVp.zoom });
277
- }, [liveVp.x, liveVp.y, liveVp.zoom]);
281
+ // LIVE, not the published viewport. `controller.viewport` republishes only on
282
+ // the settle (SETTLE_MS), so through a pan/zoom it is one frozen value: every
283
+ // peer cursor and every peer selection frame sat at the screen position the
284
+ // camera had when the gesture STARTED and only snapped onto the content half
285
+ // a second after it ended. Same root cause, same fix as the annotation resize
286
+ // handles — read the camera that is written on every applyViewport. Settled,
287
+ // `useLiveViewport()` IS the published value, and it only subscribes to the
288
+ // shared rAF ticker while a gesture is in flight.
289
+ const vp = useLiveViewport();
278
290
 
279
291
  // Bump a render tick whenever annotations change so PeerAnnotationSelection
280
292
  // re-resolves [data-id] bounds. Without this, a peer resizing a stroke
@@ -299,10 +311,10 @@ export function CursorsOverlay(): JSX.Element {
299
311
  return (
300
312
  <div className="dc-cursor-overlay" aria-hidden="true">
301
313
  {peers.map((peer) => (
302
- <PeerSelection key={`sel-${peer.clientID}`} peer={peer} />
314
+ <PeerSelection key={`sel-${peer.clientID}`} peer={peer} viewport={vp} />
303
315
  ))}
304
316
  {peers.map((peer) => (
305
- <PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} />
317
+ <PeerAnnotationSelection key={`asel-${peer.clientID}`} peer={peer} viewport={vp} />
306
318
  ))}
307
319
  {peers.map((peer) => (
308
320
  <Cursor key={peer.clientID} peer={peer} viewport={vp} />