@1agh/maude 0.58.1 → 0.58.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 (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,465 @@
1
+ // Pulling a project into an EMPTY folder, through the real sync runtime.
2
+ //
3
+ // The unit tests around `canvas-path.ts` prove the rules; `apps/hub/test/`
4
+ // proves the two receivers. This proves the wiring between them on the desktop
5
+ // side — that the path is read from the DOCUMENT (not the listing, which cannot
6
+ // carry it), that the agent is built around the corrected path rather than the
7
+ // provisional one, that the canvas is written where the tree will find it, and
8
+ // that a folder with no `config.json` ends up with one describing what arrived.
9
+
10
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
11
+ import {
12
+ chmodSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ symlinkSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { Awareness } from 'y-protocols/awareness';
24
+ import * as Y from 'yjs';
25
+
26
+ import type { Context, DevServerConfig } from '../context.ts';
27
+ import { createBus } from '../context.ts';
28
+ import { createSyncRuntime, type SyncProvider } from '../sync/index.ts';
29
+
30
+ const URL_ = 'https://hub.example.com';
31
+ const NESTED_REL = 'ui/2026/social/summer-camp.tsx';
32
+ const NESTED_SLUG = 'ui-2026-social-summer-camp';
33
+ const BODY = 'export default () => <main>summer camp</main>;\n';
34
+
35
+ let dir: string;
36
+ let cfgPathEnv: string | undefined;
37
+ let realFetch: typeof fetch;
38
+
39
+ beforeEach(() => {
40
+ dir = mkdtempSync(join(tmpdir(), 'sync-path-pull-'));
41
+ cfgPathEnv = process.env.HUBS_CONFIG_PATH;
42
+ process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
43
+ writeFileSync(
44
+ process.env.HUBS_CONFIG_PATH,
45
+ JSON.stringify({ hubs: { [URL_]: { token: 'mau_test', linkedAt: 1 } } })
46
+ );
47
+ chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
48
+ realFetch = globalThis.fetch;
49
+ });
50
+
51
+ afterEach(() => {
52
+ globalThis.fetch = realFetch;
53
+ if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
54
+ else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
55
+ rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ /** The hub's `GET /api/documents` — names and byte counts, never a path. */
59
+ function hubListing(documents: Array<{ name: string; bytes: number }>): void {
60
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
61
+ if (String(input).endsWith('/api/documents')) {
62
+ return new Response(JSON.stringify({ documents }), {
63
+ status: 200,
64
+ headers: { 'content-type': 'application/json' },
65
+ });
66
+ }
67
+ return new Response('nope', { status: 404 });
68
+ }) as typeof fetch;
69
+ }
70
+
71
+ function makeCtx(canvasGroups?: DevServerConfig['canvasGroups']): Context {
72
+ const designRoot = join(dir, 'design');
73
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
74
+ return {
75
+ canvasOrigin: 'http://canvas.localhost:1234',
76
+ cfg: {
77
+ name: 'test',
78
+ projectLabel: null,
79
+ designRoot: 'design',
80
+ canvasGroups: canvasGroups ?? [
81
+ { label: 'Design system', path: 'system' },
82
+ { label: 'Canvases', path: 'ui' },
83
+ ],
84
+ rootClass: 'app',
85
+ themeDefault: 'dark',
86
+ tokensCssRel: 'system/colors.css',
87
+ teamAccentDefault: null,
88
+ handoffTargets: [],
89
+ newCanvasDir: 'ui',
90
+ newComponentDir: 'ui/components',
91
+ linkedHub: { url: URL_, linkedAt: 1 },
92
+ _source: 'defaults',
93
+ },
94
+ projectLabel: 'test',
95
+ paths: {
96
+ repoRoot: dir,
97
+ designRel: 'design',
98
+ designRoot,
99
+ serverInfoFile: join(designRoot, '_server.json'),
100
+ activeFile: join(designRoot, '_active.json'),
101
+ commentsDir: join(designRoot, '_comments'),
102
+ canvasStateDir: join(designRoot, '_canvas-state'),
103
+ historyDir: join(designRoot, '_history'),
104
+ tokensUrlRel: 'design/system/colors.css',
105
+ systemDirRel: 'system',
106
+ },
107
+ bus: createBus(),
108
+ } as unknown as Context;
109
+ }
110
+
111
+ /**
112
+ * A provider whose document ALREADY holds what the hub holds — the body plus
113
+ * whatever `syncMeta.path` the peer that authored it stamped. That is the shape
114
+ * a real pull sees at `onceSynced`, and the reason the target cannot be decided
115
+ * from the listing.
116
+ */
117
+ function hubDocProviderFactory(
118
+ seed: Record<string, { body: string; path?: string; css?: string }>,
119
+ docs?: Map<string, Y.Doc>
120
+ ) {
121
+ return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
122
+ const doc = args.document ?? new Y.Doc();
123
+ const slug = args.documentName.split('/').pop() as string;
124
+ const entry = seed[slug];
125
+ if (entry) {
126
+ doc.transact(() => {
127
+ doc.getText('html').insert(0, entry.body);
128
+ if (entry.path) doc.getMap('syncMeta').set('path', entry.path);
129
+ if (entry.css) doc.getText('css').insert(0, entry.css);
130
+ }, 'hub');
131
+ }
132
+ docs?.set(slug, doc);
133
+ return {
134
+ document: doc,
135
+ awareness: new Awareness(doc),
136
+ async onceSynced() {},
137
+ destroy() {},
138
+ };
139
+ };
140
+ }
141
+
142
+ describe('a peer with an empty design root pulls the project down whole', () => {
143
+ test('the canvas lands at the path its author gave it', async () => {
144
+ hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
145
+ const ctx = makeCtx();
146
+ const runtime = createSyncRuntime(ctx, {
147
+ providerFactory: hubDocProviderFactory({
148
+ [NESTED_SLUG]: { body: BODY, path: NESTED_REL },
149
+ }),
150
+ });
151
+ await runtime?.start();
152
+ await runtime?.stop();
153
+
154
+ const wanted = join(ctx.paths.designRoot, NESTED_REL);
155
+ expect(existsSync(wanted)).toBe(true);
156
+ expect(readFileSync(wanted, 'utf8')).toBe(BODY);
157
+ // And NOT flat at the design root, where nothing would list it.
158
+ expect(existsSync(join(ctx.paths.designRoot, `${NESTED_SLUG}.tsx`))).toBe(false);
159
+ });
160
+
161
+ test('a document with NO path still arrives, inside a canvas group', async () => {
162
+ hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
163
+ const ctx = makeCtx();
164
+ const runtime = createSyncRuntime(ctx, {
165
+ providerFactory: hubDocProviderFactory({
166
+ 'ui-legacy': { body: 'export default () => null;\n' },
167
+ }),
168
+ });
169
+ await runtime?.start();
170
+ await runtime?.stop();
171
+
172
+ // `ui/legacy.tsx`, not `ui/ui-legacy.tsx` — the latter would be visible and
173
+ // would slug to a DIFFERENT document.
174
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
175
+ });
176
+
177
+ test('a hostile path is refused and the canvas still arrives', async () => {
178
+ hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
179
+ const ctx = makeCtx();
180
+ const runtime = createSyncRuntime(ctx, {
181
+ providerFactory: hubDocProviderFactory({
182
+ 'ui-legacy': { body: 'x\n', path: '../../../../tmp/pwned.tsx' },
183
+ }),
184
+ });
185
+ await runtime?.start();
186
+ await runtime?.stop();
187
+
188
+ expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
189
+ expect(existsSync(join(dir, '..', 'tmp', 'pwned.tsx'))).toBe(false);
190
+ });
191
+
192
+ test('a fresh link accepts the author’s OWN group, and writes it down', async () => {
193
+ // `config.json` is not part of the sync lane, so a bare folder runs on the
194
+ // DEFAULT groups. Without the fresh-link relaxation a project whose author
195
+ // calls their group `screens` would have every path refused and arrive
196
+ // invisible — the empty-folder case, in the shape it actually takes.
197
+ hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
198
+ const ctx = makeCtx();
199
+ const runtime = createSyncRuntime(ctx, {
200
+ providerFactory: hubDocProviderFactory({
201
+ 'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
202
+ }),
203
+ });
204
+ await runtime?.start();
205
+ await runtime?.stop();
206
+
207
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(true);
208
+ // …and the project now declares itself, so the next boot needs no relaxation.
209
+ const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
210
+ expect(cfg.canvasGroups.map((g: { path: string }) => g.path)).toContain('screens');
211
+ });
212
+
213
+ test('a project that already declared itself is NEVER overwritten', async () => {
214
+ const ctx = makeCtx();
215
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
216
+ const cfgFile = join(ctx.paths.designRoot, 'config.json');
217
+ const mine = `${JSON.stringify({ name: 'mine', canvasGroups: [{ label: 'UI', path: 'ui' }] })}\n`;
218
+ writeFileSync(cfgFile, mine);
219
+ hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
220
+
221
+ const runtime = createSyncRuntime(ctx, {
222
+ providerFactory: hubDocProviderFactory({
223
+ 'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
224
+ }),
225
+ });
226
+ await runtime?.start();
227
+ await runtime?.stop();
228
+
229
+ expect(readFileSync(cfgFile, 'utf8')).toBe(mine);
230
+ // The undeclared group is refused, so the canvas falls back — still arriving.
231
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(false);
232
+ expect(existsSync(join(ctx.paths.designRoot, 'screens-home.tsx'))).toBe(true);
233
+ });
234
+ });
235
+
236
+ describe('boot runs the flat-fallback quarantine BEFORE the scan (fix 4)', () => {
237
+ test('a pre-fix-5 flat twin is in _trash by the time the runtime settles', async () => {
238
+ const ctx = makeCtx();
239
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
240
+ writeFileSync(join(ctx.paths.designRoot, 'ui/welcome.tsx'), 'grouped\n');
241
+ writeFileSync(join(ctx.paths.designRoot, 'ui-welcome.tsx'), 'flat stub\n');
242
+ hubListing([]);
243
+
244
+ const runtime = createSyncRuntime(ctx, { providerFactory: hubDocProviderFactory({}) });
245
+ await runtime?.start();
246
+ await runtime?.stop();
247
+
248
+ expect(existsSync(join(ctx.paths.designRoot, 'ui-welcome.tsx'))).toBe(false);
249
+ expect(existsSync(join(ctx.paths.designRoot, 'ui/welcome.tsx'))).toBe(true);
250
+ expect(existsSync(join(ctx.paths.designRoot, '_trash'))).toBe(true);
251
+ });
252
+ });
253
+
254
+ describe('the stamp race — the path is on the doc BEFORE the handshake (fix 5)', () => {
255
+ test('a local canvas stamps syncMeta.path before onceSynced, so the hub’s first store sees it', async () => {
256
+ // The RCA bug: the stamp lived only in the post-reconcile tail, so the
257
+ // hub's FIRST onDocumentStored saw a bare body, memoised the flat fallback
258
+ // in its pathIndex, and the real nested path could never win — a stub was
259
+ // born whose dynamic import failed forever.
260
+ const ctx = makeCtx();
261
+ const bodyAbs = join(ctx.paths.designRoot, NESTED_REL);
262
+ mkdirSync(join(ctx.paths.designRoot, 'ui/2026/social'), { recursive: true });
263
+ writeFileSync(bodyAbs, BODY);
264
+ hubListing([]);
265
+
266
+ let pathAtHandshake: unknown = 'never-called';
267
+ const runtime = createSyncRuntime(ctx, {
268
+ canvases: [
269
+ {
270
+ slug: NESTED_SLUG,
271
+ html: bodyAbs,
272
+ comments: join(ctx.paths.commentsDir, `${NESTED_SLUG}.json`),
273
+ annotations: join(ctx.paths.designRoot, `${NESTED_SLUG}.annotations.svg`),
274
+ meta: bodyAbs.replace(/\.tsx$/, '.meta.json'),
275
+ css: bodyAbs.replace(/\.tsx$/, '.css'),
276
+ },
277
+ ],
278
+ providerFactory: (args) => {
279
+ const doc = args.document ?? new Y.Doc();
280
+ return {
281
+ document: doc,
282
+ awareness: new Awareness(doc),
283
+ async onceSynced() {
284
+ // What the hub can see by the time the handshake settles — the
285
+ // stamp must ALREADY be there, not arrive with the reconcile tail.
286
+ pathAtHandshake = doc.getMap('syncMeta').get('path');
287
+ },
288
+ destroy() {},
289
+ };
290
+ },
291
+ });
292
+ await runtime?.start();
293
+ await runtime?.stop();
294
+
295
+ expect(pathAtHandshake).toBe(NESTED_REL);
296
+ });
297
+ });
298
+
299
+ describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
300
+ test('a pulled canvas never lands on a file that already exists locally', async () => {
301
+ // `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
302
+ // a security opt-out a hub must not be able to flip. Omitted means absent
303
+ // from `localCanvases`, which means the hub's document for that slug is
304
+ // classified hub-ONLY and pulled. Before the path lane that was benign: the
305
+ // body landed flat at the design root, loaded by nothing. Honouring a remote
306
+ // path would land it on the real file.
307
+ const ctx = makeCtx();
308
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
309
+ const guarded = join(ctx.paths.designRoot, 'ui', 'Card.tsx');
310
+ writeFileSync(guarded, 'export default () => <b>MINE</b>;\n');
311
+ writeFileSync(
312
+ join(ctx.paths.designRoot, 'ui', 'Card.meta.json'),
313
+ JSON.stringify({ syncable: false })
314
+ );
315
+ hubListing([{ name: 'ws/acme/main/ui-card', bytes: 10 }]);
316
+
317
+ const docs = new Map<string, Y.Doc>();
318
+ const runtime = createSyncRuntime(ctx, {
319
+ providerFactory: hubDocProviderFactory(
320
+ { 'ui-card': { body: 'export default () => <b>HUB</b>;\n', path: 'ui/Card.tsx' } },
321
+ docs
322
+ ),
323
+ });
324
+ await runtime?.start();
325
+
326
+ // The boot moment is not the whole story — the provider now OWNS a path, so
327
+ // every later hub edit flows to it. Push one, the way a hub actually would.
328
+ const doc = docs.get('ui-card');
329
+ if (doc) {
330
+ const text = doc.getText('html');
331
+ doc.transact(() => {
332
+ text.delete(0, text.length);
333
+ text.insert(0, 'export default () => <b>PWNED</b>;\n');
334
+ }, 'hub');
335
+ }
336
+ await new Promise((r) => setTimeout(r, 250));
337
+ await runtime?.stop();
338
+
339
+ expect(readFileSync(guarded, 'utf8')).toContain('MINE');
340
+ });
341
+
342
+ test('a pulled canvas cannot overwrite the served token stylesheet', async () => {
343
+ // `system` is a DEFAULT canvas group and the `.css` sibling is derived from
344
+ // the body path, so `system/colors_and_type.tsx` satisfies every rule and
345
+ // writes its css lane exactly where `tokensCssRel` is served from.
346
+ const ctx = makeCtx();
347
+ mkdirSync(join(ctx.paths.designRoot, 'system'), { recursive: true });
348
+ const tokens = join(ctx.paths.designRoot, 'system', 'colors.css');
349
+ writeFileSync(tokens, ':root{--bg-0:#fff}\n');
350
+ hubListing([{ name: 'ws/acme/main/system-colors', bytes: 10 }]);
351
+
352
+ const runtime = createSyncRuntime(ctx, {
353
+ providerFactory: hubDocProviderFactory({
354
+ 'system-colors': {
355
+ body: 'x\n',
356
+ path: 'system/colors.tsx',
357
+ // The lane that does the damage — it is written to the body path's
358
+ // `.css` sibling, which here IS the served `tokensCssRel`.
359
+ css: ':root{--bg-0:url("https://evil.example/?x")}\n',
360
+ },
361
+ }),
362
+ });
363
+ await runtime?.start();
364
+ await runtime?.stop();
365
+
366
+ expect(readFileSync(tokens, 'utf8')).toBe(':root{--bg-0:#fff}\n');
367
+ });
368
+
369
+ test('containment is checked THROUGH symlinks, which resolve() cannot see', async () => {
370
+ const ctx = makeCtx();
371
+ const outside = mkdtempSync(join(tmpdir(), 'sync-path-outside-'));
372
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
373
+ symlinkSync(outside, join(ctx.paths.designRoot, 'ui', 'escape'), 'dir');
374
+ hubListing([{ name: 'ws/acme/main/ui-escape-pwned', bytes: 10 }]);
375
+
376
+ const runtime = createSyncRuntime(ctx, {
377
+ providerFactory: hubDocProviderFactory({
378
+ 'ui-escape-pwned': { body: 'x\n', path: 'ui/escape/pwned.tsx' },
379
+ }),
380
+ });
381
+ await runtime?.start();
382
+ await runtime?.stop();
383
+
384
+ expect(existsSync(join(outside, 'pwned.tsx'))).toBe(false);
385
+ rmSync(outside, { recursive: true, force: true });
386
+ });
387
+ });
388
+
389
+ describe('the untrusted markers name the path the body actually lands at', () => {
390
+ test('a nested pulled canvas is marked at its REAL path, not the provisional one', async () => {
391
+ // The markers are computed from the descriptor set BEFORE any document has
392
+ // synced — so every pulled entry is still the fallback — and the descriptors
393
+ // are then mutated in place by `relocatePulled`. Marking once left
394
+ // `_untrusted/INDEX.json` and the `.claudeignore` block naming a file that
395
+ // is never created, while the genuinely hub-pushed body sat elsewhere,
396
+ // listed nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
397
+ hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
398
+ const ctx = makeCtx();
399
+ const runtime = createSyncRuntime(ctx, {
400
+ providerFactory: hubDocProviderFactory({
401
+ [NESTED_SLUG]: { body: BODY, path: NESTED_REL },
402
+ }),
403
+ });
404
+ await runtime?.start();
405
+ await runtime?.stop();
406
+
407
+ const index = readFileSync(join(ctx.paths.designRoot, '_untrusted', 'INDEX.json'), 'utf8');
408
+ expect(index).toContain('ui/2026/social/summer-camp.tsx');
409
+ // The provisional fallback was never created, so naming it is naming nothing.
410
+ expect(index).not.toContain('ui/2026-social-summer-camp.tsx');
411
+ });
412
+ });
413
+
414
+ describe('the fresh-link relaxation closes behind itself', () => {
415
+ test('only ONE undeclared group is ever learned', async () => {
416
+ // `freshLink` was a const, so after the first config was written every
417
+ // FURTHER undeclared group was accepted and appended — the relaxation
418
+ // perpetuating itself, and a hub free to plant directories all session.
419
+ hubListing([
420
+ { name: 'ws/acme/main/screens-a', bytes: 10 },
421
+ { name: 'ws/acme/main/vendor-b', bytes: 10 },
422
+ ]);
423
+ const ctx = makeCtx();
424
+ const runtime = createSyncRuntime(ctx, {
425
+ providerFactory: hubDocProviderFactory({
426
+ 'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
427
+ 'vendor-b': { body: 'x\n', path: 'vendor/b.tsx' },
428
+ }),
429
+ });
430
+ await runtime?.start();
431
+ await runtime?.stop();
432
+
433
+ const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
434
+ const learned = cfg.canvasGroups
435
+ .map((g: { path: string }) => g.path)
436
+ .filter((p: string) => p !== 'system' && p !== 'ui');
437
+ expect(learned.length).toBe(1);
438
+ });
439
+
440
+ test('a design root with work in it is NOT a fresh link', async () => {
441
+ // `localCanvases.length === 0` is a fact about the SCAN, which walks only
442
+ // declared groups and applies the syncable + sandbox gates — so a project
443
+ // with real work scans to zero for several innocent reasons, and a hub must
444
+ // not get to author a config.json into it.
445
+ const ctx = makeCtx();
446
+ mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
447
+ writeFileSync(join(ctx.paths.designRoot, 'ui', 'Existing.tsx'), 'x\n');
448
+ writeFileSync(
449
+ join(ctx.paths.designRoot, 'ui', 'Existing.meta.json'),
450
+ JSON.stringify({ syncable: false })
451
+ );
452
+ hubListing([{ name: 'ws/acme/main/screens-a', bytes: 10 }]);
453
+
454
+ const runtime = createSyncRuntime(ctx, {
455
+ providerFactory: hubDocProviderFactory({
456
+ 'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
457
+ }),
458
+ });
459
+ await runtime?.start();
460
+ await runtime?.stop();
461
+
462
+ expect(existsSync(join(ctx.paths.designRoot, 'config.json'))).toBe(false);
463
+ expect(existsSync(join(ctx.paths.designRoot, 'screens', 'a.tsx'))).toBe(false);
464
+ });
465
+ });
@@ -183,3 +183,80 @@ describe('hub-supplied text is untrusted (DDR-054)', () => {
183
183
  expect(p?.names).toEqual([]);
184
184
  });
185
185
  });
186
+
187
+ describe('zero progress has a deadline — the stalled phase', () => {
188
+ const NOW = 10 * 60_000; // 10 minutes after the epoch-zero base
189
+
190
+ test('connecting past the ceiling with nothing synced becomes stalled, with the move named', () => {
191
+ const p = syncPresentation(
192
+ { ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 73, rejected: 0 } },
193
+ { project: 'alligators', now: NOW }
194
+ );
195
+ expect(p?.phase).toBe('stalled');
196
+ expect(p?.online).toBe(false);
197
+ expect(p?.title).toContain('Nothing has synced');
198
+ expect(p?.next).toContain('Reconnect');
199
+ });
200
+
201
+ test('a fresh connect is NOT stalled — the handshake gets its moment', () => {
202
+ const p = syncPresentation(
203
+ { ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 73, rejected: 0 } },
204
+ { project: 'alligators', now: 30_000 }
205
+ );
206
+ expect(p?.phase).toBe('connecting');
207
+ });
208
+
209
+ test('any progress at all cancels the stall — syncing is not stalling', () => {
210
+ const p = syncPresentation(
211
+ { ...base, startedAt: 1, docs: { synced: 1, pending: 72, rejected: 0 } },
212
+ { project: 'alligators', now: NOW }
213
+ );
214
+ expect(p?.phase).toBe('syncing');
215
+ });
216
+
217
+ test('zero known documents past the ceiling also stalls', () => {
218
+ const p = syncPresentation(
219
+ { ...base, state: 'connecting', startedAt: 1, docs: { synced: 0, pending: 0, rejected: 0 } },
220
+ { project: 'alligators', now: NOW }
221
+ );
222
+ expect(p?.phase).toBe('stalled');
223
+ });
224
+
225
+ test('an old payload without startedAt can never stall (fail open to connecting)', () => {
226
+ const p = syncPresentation(
227
+ { ...base, state: 'connecting' },
228
+ { project: 'alligators', now: NOW }
229
+ );
230
+ expect(p?.phase).toBe('connecting');
231
+ });
232
+
233
+ test('a refusal still outranks a stall — the more specific cause wins', () => {
234
+ const p = syncPresentation(
235
+ { ...base, startedAt: 1, docs: { synced: 0, pending: 0, rejected: 73 } },
236
+ { project: 'alligators', now: NOW }
237
+ );
238
+ expect(p?.phase).toBe('refused');
239
+ });
240
+
241
+ test('an unreachable hub still outranks a stall — offline explains itself', () => {
242
+ const p = syncPresentation(
243
+ { ...base, state: 'offline', startedAt: 1, docs: { synced: 0, pending: 3, rejected: 0 } },
244
+ { project: 'alligators', now: NOW }
245
+ );
246
+ expect(p?.phase).toBe('offline');
247
+ });
248
+
249
+ test('a garbage startedAt fails open, never into a false alarm', () => {
250
+ const p = syncPresentation(
251
+ // biome-ignore lint/suspicious/noExplicitAny: hostile-payload shape test
252
+ {
253
+ ...base,
254
+ state: 'connecting',
255
+ startedAt: 'yes' as any,
256
+ docs: { synced: 0, pending: 1, rejected: 0 },
257
+ },
258
+ { project: 'alligators', now: NOW }
259
+ );
260
+ expect(p?.phase).toBe('connecting');
261
+ });
262
+ });
@@ -5,6 +5,7 @@
5
5
  // on the hub and no local counter could ever show it.
6
6
 
7
7
  import { describe, expect, test } from 'bun:test';
8
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
8
9
 
9
10
  import {
10
11
  describeRemoteDiff,
@@ -135,7 +136,11 @@ describe('pulling hub-only documents down', () => {
135
136
  expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
136
137
  });
137
138
 
138
- test('lands hub-only canvases flat under the design root', () => {
139
+ test('lands hub-only canvases where the tree can SEE them', () => {
140
+ // Flat, because a slug cannot be un-flattened — but inside the canvas group
141
+ // the slug came from, not at the design root. The tree and `scanCanvases`
142
+ // enumerate `canvasGroups`, so the old design-root target produced a file
143
+ // nobody could see and that never synced onward.
139
144
  const targets = pullTargets(
140
145
  [
141
146
  { name: 'ui-welcome', bytes: 1 },
@@ -147,12 +152,59 @@ describe('pulling hub-only documents down', () => {
147
152
  '/'
148
153
  );
149
154
  expect(targets.map((t) => t.bodyAbs)).toEqual([
150
- '/p/.design/ui-welcome.tsx',
151
- '/p/.design/ui-home.tsx',
155
+ '/p/.design/ui/welcome.tsx',
156
+ '/p/.design/ui/home.tsx',
152
157
  ]);
158
+ // …and each still slugs back to the document it came from. `ui/ui-welcome.tsx`
159
+ // would be visible AND a different document.
160
+ expect(canvasSlugFromRel('ui/welcome.tsx', '.design')).toBe('ui-welcome');
153
161
  // The wire name is kept — the provider must open the document the HUB has,
154
162
  // not a name re-derived from the local path.
155
163
  expect(targets[1].docName).toBe('ws/acme/main/ui-home');
164
+ // No path arrived, so nothing was believed.
165
+ expect(targets.every((t) => t.fromPath === false)).toBe(true);
166
+ });
167
+
168
+ test('uses the path a document carries, once it is known', () => {
169
+ // The listing cannot supply this — it carries names and byte counts only.
170
+ // The runtime re-resolves per document after that document has synced;
171
+ // `pathFor` is that hop, made testable.
172
+ const targets = pullTargets(
173
+ [{ name: 'ws/acme/main/ui-2026-social-summer-camp', bytes: 1 }],
174
+ '/p/.design',
175
+ join,
176
+ resolve,
177
+ '/',
178
+ {
179
+ designRel: '.design',
180
+ canvasGroups: [{ path: 'system' }, { path: 'ui' }],
181
+ pathFor: () => 'ui/2026/social/summer-camp.tsx',
182
+ }
183
+ );
184
+ expect(targets[0].bodyAbs).toBe('/p/.design/ui/2026/social/summer-camp.tsx');
185
+ expect(targets[0].fromPath).toBe(true);
186
+ });
187
+
188
+ test('a refused path degrades to the fallback and says why', () => {
189
+ const refusals: string[] = [];
190
+ const targets = pullTargets(
191
+ [{ name: 'ui-welcome', bytes: 1 }],
192
+ '/p/.design',
193
+ join,
194
+ resolve,
195
+ '/',
196
+ {
197
+ designRel: '.design',
198
+ canvasGroups: [{ path: 'ui' }],
199
+ // A perfectly well-formed path that simply is not this document's —
200
+ // the case rule 7 exists for.
201
+ pathFor: () => 'ui/somebody-elses-canvas.tsx',
202
+ onRefused: (slug, reason) => refusals.push(`${slug}: ${reason}`),
203
+ }
204
+ );
205
+ expect(targets[0].bodyAbs).toBe('/p/.design/ui/welcome.tsx');
206
+ expect(targets[0].fromPath).toBe(false);
207
+ expect(refusals.length).toBe(1);
156
208
  });
157
209
 
158
210
  test('a document NAME can never write outside the design root', () => {