@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
@@ -0,0 +1,132 @@
1
+ // `designRel` and the rels built on it, validated before they reach git —
2
+ // F-13 / B12 of the v1.0.0 gate set.
3
+ //
4
+ // The finding was NOT "argv injection": every `runGit` / `run` call that carries
5
+ // a dynamic path already puts it after a `--` separator, so a leading `-` is a
6
+ // filename to git rather than a flag. The finding is CONTAINMENT, which `--`
7
+ // does nothing about. A rel carrying `..` is a perfectly well-formed pathspec
8
+ // that names a file outside the design root — and the autocommit path feeds
9
+ // exactly such rels into `git add -- <paths>` inside a workspace cell, with no
10
+ // human at the keyboard to notice `.github/workflows/release.yml` acquiring a
11
+ // commit.
12
+ //
13
+ // So the shapes below are the hostile ones, and the two consumers that matter
14
+ // are pinned: the containment PREFIX (which must never silently degrade to
15
+ // "no filter") and the staging BATCH (which must drop the poisoned rel and keep
16
+ // committing everybody else's work).
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+
20
+ import { isSafeGitRel, partitionSafeGitRels, safeGitPrefix } from '../git/safe-rel.ts';
21
+
22
+ const HOSTILE: [string, string][] = [
23
+ ['../escape', 'parent traversal — the whole point'],
24
+ ['a/../../b', 'traversal in the middle, where a prefix check would not see it'],
25
+ ['..', 'bare parent'],
26
+ ['.', 'bare current — a pathspec meaning "everything"'],
27
+ ['a/./b', 'a no-op segment that normalises differently in two places'],
28
+ ['/etc/passwd', 'absolute'],
29
+ ['C:/Windows/System32', 'Windows-absolute'],
30
+ ['a\\..\\b', 'backslash separators — traversal that survives a forward-slash check'],
31
+ ['--upload-pack=/bin/sh', 'the flag-shaped rel the `--` separator exists for'],
32
+ ['-rf', 'a shorter one'],
33
+ ['a\u0000/../../b', 'NUL — truncates at the syscall boundary, so it is two paths'],
34
+ ['a\u001f/b', 'other control characters'],
35
+ ['a /b', 'trailing space in a segment — invisible in every UI that shows it'],
36
+ ['', 'empty'],
37
+ [`${'x/'.repeat(300)}y`, 'past the length cap'],
38
+ ];
39
+
40
+ const BENIGN = [
41
+ '.design/ui/screen.tsx',
42
+ '.design/system/ds/README.md',
43
+ '.design/assets/a1b2c3d4.png',
44
+ 'assets/logo.svg',
45
+ '.design/preview/_layout.css',
46
+ '.design/ui/My Canvas.tsx',
47
+ '.design/ui/-not-leading.tsx'.replace('/-not', '/not'),
48
+ ];
49
+
50
+ describe('isSafeGitRel', () => {
51
+ for (const [rel, why] of HOSTILE) {
52
+ test(`refuses ${JSON.stringify(rel)} — ${why}`, () => {
53
+ expect(isSafeGitRel(rel)).toBe(false);
54
+ });
55
+ }
56
+
57
+ for (const rel of BENIGN) {
58
+ test(`admits ${JSON.stringify(rel)}`, () => {
59
+ expect(isSafeGitRel(rel)).toBe(true);
60
+ });
61
+ }
62
+
63
+ test('a non-string is refused rather than coerced', () => {
64
+ for (const v of [null, undefined, 42, {}, ['a']]) {
65
+ expect(isSafeGitRel(v)).toBe(false);
66
+ }
67
+ });
68
+ });
69
+
70
+ describe('safeGitPrefix — an absent prefix and a REFUSED one are different answers', () => {
71
+ test('absent is the empty prefix (no containment filter), and that is legitimate', () => {
72
+ expect(safeGitPrefix(undefined)).toBe('');
73
+ expect(safeGitPrefix('')).toBe('');
74
+ expect(safeGitPrefix(null)).toBe('');
75
+ });
76
+
77
+ test('a normal design root normalises', () => {
78
+ expect(safeGitPrefix('.design')).toBe('.design');
79
+ expect(safeGitPrefix('/.design/')).toBe('.design');
80
+ expect(safeGitPrefix('design\\root')).toBe('design/root');
81
+ });
82
+
83
+ test('a hostile prefix is null — NOT the empty string', () => {
84
+ // This distinction is the whole reason the function returns `string | null`.
85
+ // Empty means "match everything", so a caller that mapped a refusal onto ''
86
+ // would answer a traversal attempt by WIDENING the scope to the whole
87
+ // repository. The caller (`normPrefix`) falls back to '.design' instead.
88
+ //
89
+ // A LEADING SLASH IS NOT HOSTILE HERE, and this is the one place the prefix
90
+ // reading differs from the pathspec reading. `context.ts` already strips
91
+ // `^/+` off `cfg.designRoot`, i.e. a configured `/.design` has always meant
92
+ // "`.design`, relative to the repo root" — so `/etc/passwd` reads as the
93
+ // prefix `etc/passwd`, which names a directory that does not exist and
94
+ // therefore matches nothing. Harmless as a CONTAINMENT FILTER; still
95
+ // refused by `isSafeGitRel` where it would be used as a PATHSPEC.
96
+ const rootRelative = new Set(['/etc/passwd']);
97
+ for (const [rel] of HOSTILE) {
98
+ if (rel === '') continue; // '' is the absent case, tested above
99
+ if (rootRelative.has(rel)) {
100
+ expect(safeGitPrefix(rel)).toBe('etc/passwd');
101
+ continue;
102
+ }
103
+ expect(safeGitPrefix(rel)).toBeNull();
104
+ }
105
+ });
106
+ });
107
+
108
+ describe('partitionSafeGitRels — one poisoned rel must not wedge the batch', () => {
109
+ test('splits a mixed batch, keeping the order of each side', () => {
110
+ const { safe, refused } = partitionSafeGitRels([
111
+ '.design/ui/a.tsx',
112
+ '../../.github/workflows/release.yml',
113
+ '.design/ui/b.tsx',
114
+ '-rf',
115
+ '.design/system/ds/README.md',
116
+ ]);
117
+ expect(safe).toEqual(['.design/ui/a.tsx', '.design/ui/b.tsx', '.design/system/ds/README.md']);
118
+ expect(refused).toEqual(['../../.github/workflows/release.yml', '-rf']);
119
+ });
120
+
121
+ test('an all-clean batch is unchanged', () => {
122
+ const { safe, refused } = partitionSafeGitRels(BENIGN);
123
+ expect(safe).toEqual(BENIGN);
124
+ expect(refused).toEqual([]);
125
+ });
126
+
127
+ test('an all-hostile batch stages nothing rather than throwing', () => {
128
+ const { safe, refused } = partitionSafeGitRels(['../a', '/b', '..']);
129
+ expect(safe).toEqual([]);
130
+ expect(refused).toHaveLength(3);
131
+ });
132
+ });
@@ -4,6 +4,7 @@ import { describe, expect, test } from 'bun:test';
4
4
 
5
5
  import { type Context, createBus } from '../context.ts';
6
6
  import {
7
+ classifyChange,
7
8
  createContainerWriteBridge,
8
9
  createHmrBroadcaster,
9
10
  HMR_DEBOUNCE_MS,
@@ -208,3 +209,28 @@ describe('container write bridge — synthesises fs:any the container fs.watch m
208
209
  expect(got).toHaveLength(0);
209
210
  });
210
211
  });
212
+
213
+ describe('the PhotoEdit sidecar reaches open canvases', () => {
214
+ // `assets/<sha8>.photo.json` changing on disk is a peer's photo adjustment
215
+ // arriving. It used to classify as null — no message, no re-bake, and the
216
+ // edit appeared only after a manual reload.
217
+ const noSibling = () => false;
218
+
219
+ test('a photo sidecar classifies as an asset heal', () => {
220
+ const msg = classifyChange('assets/3631ac58.photo.json', noSibling);
221
+ expect(msg?.mode).toBe('asset');
222
+ expect(msg?.file).toBe('assets/3631ac58.photo.json');
223
+ });
224
+
225
+ test('a DS-tree photo sidecar too', () => {
226
+ expect(classifyChange('system/brand/assets/aabbccdd.photo.json', noSibling)?.mode).toBe(
227
+ 'asset'
228
+ );
229
+ });
230
+
231
+ test('an ordinary json outside assets/ still classifies as before', () => {
232
+ // .meta.json keeps its dedicated lane; other json (module-ish) unchanged.
233
+ expect(classifyChange('ui/home.meta.json', noSibling)?.mode).toBe('meta');
234
+ expect(classifyChange('notes/data.photo.json', noSibling)).toBe(null);
235
+ });
236
+ });
@@ -0,0 +1,131 @@
1
+ // peer-selection-follows-camera — the collaboration counterpart of the
2
+ // annotation-resize-handle fix (9ba40b96). A peer's selection frame is measured
3
+ // off the LOCAL DOM every render, so it only tracks a pan/zoom if the component
4
+ // actually re-renders while the camera moves. Both halos are `memo`, and through
5
+ // a gesture the peer's awareness is unchanged — so without a viewport prop that
6
+ // ticks with the camera, memo bails out and the frame sits frozen in screen
7
+ // space while the artboard it outlines slides away underneath it.
8
+ //
9
+ // Needs a live DOM (memo bailout + a stubbed getBoundingClientRect) — register
10
+ // happy-dom + drive a createRoot, same convention as mode-toggle.test.tsx.
11
+
12
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
13
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
14
+
15
+ beforeAll(() => {
16
+ GlobalRegistrator.register();
17
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18
+ });
19
+ afterAll(() => {
20
+ GlobalRegistrator.unregister();
21
+ });
22
+
23
+ import { act, type ReactNode } from 'react';
24
+ import { createRoot, type Root } from 'react-dom/client';
25
+ import { PeerAnnotationSelection, PeerSelection } from '../cursors-overlay.tsx';
26
+ import type { ForeignAwareness } from '../use-collab.tsx';
27
+
28
+ // The camera the stubbed measurements project through — stands in for the world
29
+ // plane's CSS transform, which is what really moves the target under a pan.
30
+ let camera = { x: 0, y: 0, zoom: 1 };
31
+
32
+ /** A target element whose screen rect follows `camera`, like real canvas content. */
33
+ function target(
34
+ attr: string,
35
+ value: string,
36
+ world: { x: number; y: number; w: number; h: number }
37
+ ) {
38
+ const el = document.createElement('div');
39
+ el.setAttribute(attr, value);
40
+ el.getBoundingClientRect = () =>
41
+ ({
42
+ left: world.x * camera.zoom + camera.x,
43
+ top: world.y * camera.zoom + camera.y,
44
+ width: world.w * camera.zoom,
45
+ height: world.h * camera.zoom,
46
+ }) as DOMRect;
47
+ document.body.appendChild(el);
48
+ return el;
49
+ }
50
+
51
+ let root: Root | null = null;
52
+ let host: HTMLElement | null = null;
53
+
54
+ function mount(ui: ReactNode): void {
55
+ host = document.createElement('div');
56
+ document.body.appendChild(host);
57
+ root = createRoot(host);
58
+ act(() => {
59
+ root?.render(ui);
60
+ });
61
+ }
62
+
63
+ function rerender(ui: ReactNode): void {
64
+ act(() => {
65
+ root?.render(ui);
66
+ });
67
+ }
68
+
69
+ function frameTransform(): string {
70
+ const el = host?.querySelector('.dc-peer-selection') as HTMLElement | null;
71
+ return el?.style.transform ?? '';
72
+ }
73
+
74
+ afterEach(() => {
75
+ act(() => {
76
+ root?.unmount();
77
+ });
78
+ host?.remove();
79
+ root = null;
80
+ host = null;
81
+ document.body.innerHTML = '';
82
+ camera = { x: 0, y: 0, zoom: 1 };
83
+ });
84
+
85
+ // One stable object across the whole test — a real pan/zoom does not touch the
86
+ // peer's awareness, and that identity is exactly what makes memo bail out.
87
+ const peer = {
88
+ clientID: 7,
89
+ name: 'you@local.test',
90
+ color: 'oklch(56% 0.17 50)',
91
+ cursor: null,
92
+ selection: { cssPath: '[data-cd-id="hero"]' },
93
+ annotationSelection: ['stroke-1'],
94
+ } as unknown as ForeignAwareness;
95
+
96
+ describe('cursors-overlay / PeerSelection', () => {
97
+ test('the frame follows a pan', () => {
98
+ target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
99
+ mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
100
+ expect(frameTransform()).toBe('translate(100px, 60px)');
101
+
102
+ camera = { x: -40, y: -25, zoom: 1 };
103
+ rerender(<PeerSelection peer={peer} viewport={camera} />);
104
+ expect(frameTransform()).toBe('translate(60px, 35px)');
105
+ });
106
+
107
+ test('the frame follows a zoom (position AND size)', () => {
108
+ target('data-cd-id', 'hero', { x: 100, y: 60, w: 200, h: 120 });
109
+ mount(<PeerSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
110
+
111
+ camera = { x: 0, y: 0, zoom: 2 };
112
+ rerender(<PeerSelection peer={peer} viewport={camera} />);
113
+ const el = host?.querySelector('.dc-peer-selection') as HTMLElement;
114
+ expect(el.style.transform).toBe('translate(200px, 120px)');
115
+ expect(el.style.width).toBe('400px');
116
+ expect(el.style.height).toBe('240px');
117
+ });
118
+ });
119
+
120
+ describe('cursors-overlay / PeerAnnotationSelection', () => {
121
+ test('the stroke halo follows a pan', () => {
122
+ target('data-id', 'stroke-1', { x: 100, y: 60, w: 200, h: 120 });
123
+ mount(<PeerAnnotationSelection peer={peer} viewport={{ x: 0, y: 0, zoom: 1 }} />);
124
+ // Padded 3px so the halo sits OUTSIDE the stroke.
125
+ expect(frameTransform()).toBe('translate(97px, 57px)');
126
+
127
+ camera = { x: -40, y: -25, zoom: 1 };
128
+ rerender(<PeerAnnotationSelection peer={peer} viewport={camera} />);
129
+ expect(frameTransform()).toBe('translate(57px, 32px)');
130
+ });
131
+ });
@@ -240,8 +240,11 @@ describe('Task 1 — the workspace-mode guard opens only for loopback + commit-d
240
240
  );
241
241
  });
242
242
 
243
- test('refuses when shared-doc is off — pairing IS the single-doc model', () => {
244
- expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: undefined })).refusal).toBe(
243
+ test('refuses when shared-doc is explicitly off — pairing IS the single-doc model', () => {
244
+ // Increment 7 flipped the default ON, so an UNSET flag now pairs; only the
245
+ // explicit opt-out (the rollback flip) refuses.
246
+ expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: undefined })).pairing).not.toBeNull();
247
+ expect(resolveCellPairing(pairingEnv({ MAUDE_SHARED_DOC: '0' })).refusal).toBe(
245
248
  'shared-doc-off'
246
249
  );
247
250
  });
@@ -580,3 +580,81 @@ describe('CanvasSyncAgent — fail-closed on snapshot failure (DDR-102 F1)', ()
580
580
  expect(conflicts[0].snapshotFailed).toBeUndefined();
581
581
  });
582
582
  });
583
+
584
+ describe('cold-start meta — an empty doc is not a canvas with no title', () => {
585
+ // A canvas created on a PEER used to reach the hub as a body with no meta:
586
+ // no title, no kind, no design-system binding. It stayed that way until
587
+ // somebody moved an artboard, because a meta EDIT was the only thing that
588
+ // ever pushed meta into the doc.
589
+ //
590
+ // The cloud hid it. A cell's studio child arms `activity:suppress` on create
591
+ // and the container write bridge turns that into an `fs:any` a quarter-second
592
+ // later — by which time the new canvas has an agent to receive it. A desktop
593
+ // has no bridge: its real `fs.watch` fires immediately, before the agent
594
+ // exists, and the event lands nowhere. One side won the race, the other lost
595
+ // it, and the gap underneath (cold-start meta was doc→file only) was
596
+ // invisible from the winning end.
597
+
598
+ const metaPaths = () => ({ ...paths(), meta: join(dir, 'screen.meta.json') });
599
+
600
+ function metaAgent(extra: Record<string, unknown> = {}): CanvasSyncAgent {
601
+ const a = createCanvasSyncAgent({
602
+ slug: 'screen',
603
+ doc: docB,
604
+ paths: metaPaths(),
605
+ echoGuard: createEchoGuard(),
606
+ flushMs: 0,
607
+ ...extra,
608
+ });
609
+ a.start();
610
+ return a;
611
+ }
612
+
613
+ test('local meta seeds the doc when the doc has none', async () => {
614
+ writeFileSync(
615
+ metaPaths().meta,
616
+ JSON.stringify({ title: 'Kanban', kind: 'web', designSystem: 'project' })
617
+ );
618
+ agent = metaAgent();
619
+ await agent.reconcile();
620
+
621
+ // The other peer must be able to SEE it — that is the whole point.
622
+ const seen = JSON.parse(docA.getText('meta').toString());
623
+ expect(seen.title).toBe('Kanban');
624
+ expect(seen.kind).toBe('web');
625
+ });
626
+
627
+ test('it does NOT overwrite meta the doc already carries', async () => {
628
+ // The safety property. Seeding runs only into a vacuum, so it can never be
629
+ // a peer quietly winning a disagreement about a shared key.
630
+ docA.getText('meta').insert(0, JSON.stringify({ title: 'From the hub', kind: 'web' }));
631
+ writeFileSync(metaPaths().meta, JSON.stringify({ title: 'From my disk', kind: 'mobile' }));
632
+
633
+ agent = metaAgent();
634
+ await agent.reconcile();
635
+
636
+ expect(JSON.parse(docA.getText('meta').toString()).title).toBe('From the hub');
637
+ // …and the hub's opinion lands on disk, which is the pre-existing behaviour.
638
+ expect(JSON.parse(readFileSync(metaPaths().meta, 'utf8')).title).toBe('From the hub');
639
+ });
640
+
641
+ test('no local meta and no doc meta is simply nothing to do', async () => {
642
+ agent = metaAgent();
643
+ await agent.reconcile();
644
+ expect(docA.getText('meta').toString()).toBe('');
645
+ });
646
+
647
+ test('per-user keys never leave the machine', async () => {
648
+ // `viewport` is the per-user camera (DDR-115). Seeding must go through the
649
+ // same shared-subset filter as every other meta write, or the fix would
650
+ // start syncing somebody's scroll position.
651
+ writeFileSync(
652
+ metaPaths().meta,
653
+ JSON.stringify({ title: 'Kanban', viewport: { x: 12, y: 34, zoom: 2 } })
654
+ );
655
+ agent = metaAgent();
656
+ await agent.reconcile();
657
+
658
+ expect(docA.getText('meta').toString()).not.toContain('viewport');
659
+ });
660
+ });
@@ -14,7 +14,6 @@ import {
14
14
  isPushableAssetRel,
15
15
  listPushableAssets,
16
16
  pushAssets,
17
- pushOneAsset,
18
17
  putTimeoutMs,
19
18
  } from '../sync/asset-push.ts';
20
19
 
@@ -43,12 +42,22 @@ describe('listPushableAssets — classifier membership over the whole design roo
43
42
  mkdirSync(join(designRoot, '_history/assets'), { recursive: true });
44
43
  writeFileSync(join(designRoot, '_history/assets/old.png'), 'x');
45
44
 
46
- expect(listPushableAssets(designRoot)).toEqual([
45
+ // `syncFiles: false` is now stated rather than assumed: the flag defaults
46
+ // ON from Increment 4, so a test that meant "the narrow media sweep" has
47
+ // to say so. What it pins is unchanged.
48
+ expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([
47
49
  'assets/a1b2c3d4.png',
48
50
  'system/alligators/assets/fonts/Gators-Bold.woff2',
49
51
  'system/alligators/assets/logos/horizontal-green.svg',
50
52
  'system/alligators/assets/photos/P1020428.JPG',
51
53
  ]);
54
+
55
+ // With the plane on, the photo sidecar travels too — it is the
56
+ // non-destructive edit state for that image, and an image whose edits stay
57
+ // behind arrives looking wrong rather than arriving late.
58
+ expect(listPushableAssets(designRoot, { syncFiles: true })).toContain(
59
+ 'assets/a1b2c3d4.photo.json'
60
+ );
52
61
  });
53
62
 
54
63
  test('non-asset extensions and dotfiles are filtered; missing root is []', () => {
@@ -89,9 +98,10 @@ describe('listPushableAssets — classifier membership over the whole design roo
89
98
  'system/ds/preview/_brand-css.ts',
90
99
  'system/ds/preview/_layout.css',
91
100
  ]);
92
- // Flag OFF ⇒ today's reach, byte-for-byte: none of these are binary
93
- // media under an assets/ dir, so none of them move.
94
- expect(listPushableAssets(designRoot)).toEqual([]);
101
+ // Explicitly OFF ⇒ the pre-Sync-v2 reach, byte-for-byte: none of these are
102
+ // binary media under an assets/ dir, so none of them move. This is the
103
+ // documented per-project rollback, so it keeps its own test.
104
+ expect(listPushableAssets(designRoot, { syncFiles: false })).toEqual([]);
95
105
  });
96
106
 
97
107
  test('declared canvasGroups decide what a canvas body IS', () => {
@@ -832,137 +842,90 @@ describe('isPushableAssetRel — the on-change trigger', () => {
832
842
  // is the half that was missing. A behavioural test needs a live runtime plus a
833
843
  // 1.5 s debounce and destabilises `sync-runtime.test.ts`'s shared fixtures, so
834
844
  // the invariant is pinned where it is cheap and unambiguous instead.
835
- describe('the runtime sweeps on change, not only at boot', () => {
845
+ describe('the runtime schedules the legacy push on change, not only at boot', () => {
836
846
  const SYNC = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');
837
847
 
838
- test('the fs watcher decides with isPushableAssetRel and schedules a sweep', () => {
848
+ test('the fs watcher decides with isPushableAssetRel and schedules a pass', () => {
839
849
  // The predicate gets the project's OWN canvas groups — with the default
840
850
  // set a custom-group project's shared module would answer canvas-owned
841
- // and silently never schedule the sweep that uploads it. (Since the
842
- // 2026-08-16 latency fix the same gate also feeds the fast lane; the
843
- // sweep stays the reconciler.)
851
+ // and silently never schedule the pass that uploads it.
844
852
  expect(SYNC).toContain('if (isPushableAssetRel(rel, ctx.cfg.canvasGroups)) {');
845
- expect(SYNC).toContain('scheduleAssetSweep(linkedHub.url);');
853
+ expect(SYNC).toContain('scheduleLegacyPush(linkedHub.url);');
846
854
  });
847
855
 
848
- test('bursts coalesce, and a change DURING a sweep is not lost', () => {
849
- // Dragging six images on is six events; each sweep costs a probe over the
850
- // wire. And a file written while a sweep runs was not in that sweep's list.
856
+ test('bursts coalesce, and a change DURING a pass is not lost', () => {
857
+ // Dragging six images on is six events; each pass costs a probe over the
858
+ // wire. And a file written while a pass runs was not in that pass's list.
851
859
  expect(SYNC).toMatch(/ASSET_SWEEP_DEBOUNCE_MS = [\d_]+/);
852
- expect(SYNC).toContain('assetSweepAgain');
860
+ expect(SYNC).toContain('legacyPushAgain');
853
861
  });
854
862
 
855
- test('only one sweep runs at a time, and stop() clears the pending one', () => {
856
- expect(SYNC).toMatch(/if \(stopped \|\| assetSweep\) return;/);
857
- expect(SYNC).toContain('if (assetSweepTimer !== null) clearTimeout(assetSweepTimer);');
863
+ test('the lane is decided once by the capability probe, and a journal hub never sees the legacy client', () => {
864
+ // Sync v2 Increment 5 — the burn-down's one invariant: exactly ONE push
865
+ // lane per boot. A journal hub gets the plane; only a journal-less hub
866
+ // (or an unreachable/opted-out probe) gets the legacy client.
867
+ expect(SYNC).toContain('decidePushLane(filePlane === null);');
868
+ expect(SYNC).toMatch(
869
+ /if \(stopped \|\| cellPairing \|\| legacyLane !== true\) return; \/\/ the cell never pushes/
870
+ );
871
+ expect(SYNC).toContain(
872
+ 'if (legacyLane === false) return; // the plane owns pushes on this hub'
873
+ );
858
874
  });
859
- });
860
875
 
861
- describe('pushOneAsset — the fast lane (2026-08-16 latency fix)', () => {
862
- test('probes once via /_asset-probe, then PUTs the single file', async () => {
863
- const designRoot = scratchDesignRoot();
864
- mkdirSync(join(designRoot, 'assets'), { recursive: true });
865
- writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'fresh sticker');
866
-
867
- const calls: Array<{ method: string; url: string }> = [];
868
- const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
869
- const url = String(input);
870
- const method = init?.method ?? 'GET';
871
- calls.push({ method, url });
872
- if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
873
- return new Response('{"ok":true}', { status: 200 });
874
- }) as typeof fetch;
875
-
876
- const r = await pushOneAsset({
877
- designRoot,
878
- rel: 'assets/aaaaaaaa.png',
879
- hubUrl: 'https://alligators.cloud.maude.sh/',
880
- token: () => 'mau_tok',
881
- fetchImpl,
882
- });
883
- expect(r).toEqual({ ok: true });
884
- expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
885
- 'POST https://alligators.cloud.maude.sh/_asset-probe',
886
- 'PUT https://alligators.cloud.maude.sh/assets/aaaaaaaa.png',
887
- ]);
876
+ test('stop() clears the pending pass and cancels a running one', () => {
877
+ expect(SYNC).toContain('if (legacyPushTimer !== null) clearTimeout(legacyPushTimer);');
878
+ expect(SYNC).toContain('legacyPushCancel = true;');
888
879
  });
880
+ });
889
881
 
890
- test('a file the hub already holds is skipped — the pull-echo guard', async () => {
891
- // The fast lane fires off fs:any, and the asset PULL writes into assets/
892
- // too — without this skip the lane would upload the hub's own bytes back.
882
+ describe('pushAssets — the cancel hook (the Sync panel button, legacy window)', () => {
883
+ test('cancelled() abandons the rest of the pass', async () => {
893
884
  const designRoot = scratchDesignRoot();
894
885
  mkdirSync(join(designRoot, 'assets'), { recursive: true });
895
- writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'pulled bytes');
886
+ writeFileSync(join(designRoot, 'assets/aaaaaaaa.png'), 'a');
887
+ writeFileSync(join(designRoot, 'assets/bbbbbbbb.png'), 'b');
888
+ writeFileSync(join(designRoot, 'assets/cccccccc.png'), 'c');
896
889
 
897
890
  let puts = 0;
898
891
  const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
899
- const url = String(input);
900
- if (url.endsWith('/_asset-probe')) return Response.json({ present: ['assets/aaaaaaaa.png'] });
901
892
  if (init?.method === 'PUT') puts += 1;
902
- return new Response('{"ok":true}', { status: 200 });
903
- }) as typeof fetch;
893
+ return new Response('not found', { status: 404 });
894
+ }) as unknown as typeof fetch;
904
895
 
905
- const r = await pushOneAsset({
896
+ const out = await pushAssets({
906
897
  designRoot,
907
- rel: 'assets/aaaaaaaa.png',
908
- hubUrl: 'https://alligators.cloud.maude.sh',
909
- token: () => 'mau_tok',
910
- fetchImpl,
911
- });
912
- expect(r).toEqual({ ok: true, skipped: true });
913
- expect(puts).toBe(0);
914
- });
915
-
916
- test('DS-tree asset routes to /_asset-file/; non-pushable rels refuse; failures report', async () => {
917
- const designRoot = scratchDesignRoot();
918
- mkdirSync(join(designRoot, 'system/ds/assets'), { recursive: true });
919
- writeFileSync(join(designRoot, 'system/ds/assets/mark.svg'), 'brand');
920
-
921
- const urls: string[] = [];
922
- const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
923
- const url = String(input);
924
- if (url.endsWith('/_asset-probe')) return Response.json({ present: [] });
925
- if (init?.method === 'PUT') {
926
- urls.push(url);
927
- return new Response('refused', { status: 403 });
928
- }
929
- return new Response(null, { status: 404 });
930
- }) as typeof fetch;
931
-
932
- const r = await pushOneAsset({
933
- designRoot,
934
- rel: 'system/ds/assets/mark.svg',
935
898
  hubUrl: 'https://hub.example.com',
936
899
  token: () => 't',
937
900
  fetchImpl,
901
+ log: { log: () => {}, warn: () => {} },
902
+ // Cancel after the first upload settles.
903
+ cancelled: () => puts >= 1,
938
904
  });
939
- expect(urls).toEqual(['https://hub.example.com/_asset-file/system/ds/assets/mark.svg']);
940
- expect(r.ok).toBe(false);
941
- expect(r.reason).toContain('403');
905
+ expect(puts).toBe(1);
906
+ // The rest of the set was neither pushed nor reported failed — it is the
907
+ // next pass's work, exactly like a budget wall.
908
+ expect(out.pushed.length + out.failed.length + out.skipped).toBeLessThan(3);
909
+ });
910
+ });
942
911
 
943
- // Runtime state / config never enter the fast lane.
944
- const refused = await pushOneAsset({
945
- designRoot,
946
- rel: '_history/x/old.png',
947
- hubUrl: 'https://hub.example.com',
948
- token: () => 't',
949
- fetchImpl,
950
- });
951
- expect(refused.ok).toBe(false);
952
- expect(refused.reason).toBe('not a pushable asset');
912
+ describe('the syncFiles default — Increment 4 flipped it ON', () => {
913
+ // The flip is one boolean in two places (`sync/index.ts` and here), and a
914
+ // drift between them means the sweep and the plane disagree about which
915
+ // files exist — the jurisdiction overlap Sync v2 exists to end. So the
916
+ // default is asserted, not assumed.
917
+ const designRoot = () => {
918
+ const root = scratchDesignRoot();
919
+ mkdirSync(join(root, 'system/ds'), { recursive: true });
920
+ writeFileSync(join(root, 'system/ds/brand.css'), 'x');
921
+ return root;
922
+ };
923
+
924
+ test('absent config ⇒ the plane is on', () => {
925
+ expect(listPushableAssets(designRoot())).toEqual(['system/ds/brand.css']);
953
926
  });
954
927
 
955
- test('the runtime wires the fast lanes: push off fs:any, pull off reference files', () => {
956
- // Same source-pin style as the sweep-trigger suite above.
957
- expect(SYNC2).toContain('queueFastPush(rel');
958
- expect(SYNC2).toMatch(/REFERENCE_FILE_RE\.test\(rel\)/);
959
- expect(SYNC2).toContain('requestFastPull');
960
- // stop() tears the lanes down.
961
- expect(SYNC2).toContain('for (const t of fastPushTimers.values()) clearTimeout(t);');
962
- expect(SYNC2).toContain('requestFastPull = null;');
963
- // The cell never pushes — same guard as the sweep.
964
- expect(SYNC2).toMatch(/if \(stopped \|\| cellPairing\) return; \/\/ the cell never pushes/);
928
+ test('`linkedHub.syncFiles: false` is the per-project rollback and still works', () => {
929
+ expect(listPushableAssets(designRoot(), { syncFiles: false })).toEqual([]);
965
930
  });
966
931
  });
967
-
968
- const SYNC2 = readFileSync(join(import.meta.dir, '..', 'sync', 'index.ts'), 'utf8');