@1agh/maude 0.58.2 → 0.59.0

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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,98 @@
1
+ // Fix 4 of the 2026-08-10 sync RCA — the one-shot quarantine of pre-fix-5
2
+ // flat fallback twins. The rules under test: a flat body moves ONLY when a
3
+ // grouped twin of the same slug exists; siblings ride along; annotations stay
4
+ // (they serve the surviving twin); a lone flat canvas is somebody's work and
5
+ // never moves; a second run is a no-op; nothing ever throws into boot.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { migrateFlatFallback } from '../sync/migrate-flat-fallback.ts';
13
+
14
+ let designRoot: string;
15
+
16
+ beforeEach(() => {
17
+ designRoot = mkdtempSync(join(tmpdir(), 'migrate-flat-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(designRoot, { recursive: true, force: true });
22
+ });
23
+
24
+ const run = () => migrateFlatFallback({ designRoot, designRel: '.design', log: () => {} });
25
+
26
+ describe('migrateFlatFallback — quarantine the redundant flat copy, never work', () => {
27
+ test('a collision pair: the flat copy (+ siblings) moves to _trash, the grouped twin stays', () => {
28
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
29
+ writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
30
+ writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
31
+ writeFileSync(join(designRoot, 'ui-welcome.meta.json'), '{}\n');
32
+ writeFileSync(join(designRoot, 'ui-welcome.css'), '.x{}\n');
33
+ // Annotations are keyed by the flat slug and serve the SURVIVING twin.
34
+ writeFileSync(join(designRoot, 'ui-welcome.annotations.svg'), '<svg/>\n');
35
+
36
+ const moves = run();
37
+
38
+ expect(moves).toHaveLength(1);
39
+ expect(moves[0].slug).toBe('ui-welcome');
40
+ expect(moves[0].from).toBe('ui-welcome.tsx');
41
+ expect(moves[0].keptTwin).toBe('ui/welcome.tsx');
42
+
43
+ expect(existsSync(join(designRoot, 'ui/welcome.tsx'))).toBe(true);
44
+ expect(existsSync(join(designRoot, 'ui-welcome.tsx'))).toBe(false);
45
+ expect(existsSync(join(designRoot, 'ui-welcome.meta.json'))).toBe(false);
46
+ expect(existsSync(join(designRoot, 'ui-welcome.css'))).toBe(false);
47
+ expect(existsSync(join(designRoot, 'ui-welcome.annotations.svg'))).toBe(true);
48
+
49
+ const trashed = readdirSync(join(designRoot, moves[0].trashedTo)).sort();
50
+ expect(trashed).toEqual(['ui-welcome.css', 'ui-welcome.meta.json', 'ui-welcome.tsx']);
51
+ });
52
+
53
+ test('a lone flat canvas has no twin — it is somebody’s work and stays put', () => {
54
+ writeFileSync(join(designRoot, 'scratch-pad.tsx'), 'mine\n');
55
+ expect(run()).toEqual([]);
56
+ expect(existsSync(join(designRoot, 'scratch-pad.tsx'))).toBe(true);
57
+ });
58
+
59
+ test('a grouped file with no flat twin is untouched', () => {
60
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
61
+ writeFileSync(join(designRoot, 'ui/card.tsx'), 'x\n');
62
+ expect(run()).toEqual([]);
63
+ expect(existsSync(join(designRoot, 'ui/card.tsx'))).toBe(true);
64
+ });
65
+
66
+ test('the second run is a no-op — the quarantine dir is runtime state the scan skips', () => {
67
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
68
+ writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
69
+ writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
70
+
71
+ expect(run()).toHaveLength(1);
72
+ expect(run()).toEqual([]);
73
+ // The quarantined copy is still there — moved, never deleted.
74
+ const trash = readdirSync(join(designRoot, '_trash'));
75
+ expect(trash).toHaveLength(1);
76
+ });
77
+
78
+ test('nested twins at any depth count', () => {
79
+ mkdirSync(join(designRoot, 'ui/2026/social'), { recursive: true });
80
+ writeFileSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'), 'deep\n');
81
+ writeFileSync(join(designRoot, 'ui-2026-social-summer-camp.tsx'), 'flat stub\n');
82
+
83
+ const moves = run();
84
+ expect(moves).toHaveLength(1);
85
+ expect(moves[0].keptTwin).toBe('ui/2026/social/summer-camp.tsx');
86
+ expect(existsSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'))).toBe(true);
87
+ });
88
+
89
+ test('a missing design root is a quiet no-op, not a throw', () => {
90
+ expect(
91
+ migrateFlatFallback({
92
+ designRoot: join(designRoot, 'does-not-exist'),
93
+ designRel: '.design',
94
+ log: () => {},
95
+ })
96
+ ).toEqual([]);
97
+ });
98
+ });
@@ -0,0 +1,90 @@
1
+ // feature-sync-progress-modal — the Sync panel + HUB SYNC chip surface.
2
+ //
3
+ // Source-level assertions (cloud-shell-surfaces style): what these pin is
4
+ // exactly what would regress — someone dropping the linked-only gate (a solo
5
+ // project would get a dock tab that opens onto nothing), rewiring the chip
6
+ // away from the dock helpers (breaking the one-panel-per-side invariant), or
7
+ // the panel inventing sync vocabulary / dropping the a11y live region the
8
+ // CloudBar note established.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ const STUDIO = join(import.meta.dir, '..');
15
+ const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
16
+ const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'SyncPanel.jsx'), 'utf8');
17
+
18
+ describe('the Sync panel exists only where a hub link exists', () => {
19
+ test('sync is a dock panel with a right-side default', () => {
20
+ expect(APP).toContain("{ id: 'sync', label: 'Sync' }");
21
+ expect(APP).toMatch(/sync: 'right'/);
22
+ });
23
+
24
+ test('panelAvailable gates sync on a live syncStatus — solo projects get no tab', () => {
25
+ expect(APP).toContain("if (id === 'sync') return !!syncStatus;");
26
+ });
27
+
28
+ test('the chip toggles through the dock helpers, not a raw setState', () => {
29
+ // toggleRightPanel keeps the one-panel-per-side invariant; a bare
30
+ // setSyncPanelOpen(true) here would let Sync render behind an open sibling.
31
+ expect(APP).toMatch(/onOpenSync=\{syncStatus \? \(\) => toggleRightPanel\('sync'\)/);
32
+ });
33
+
34
+ test('the chip is a real button with pressed-state semantics', () => {
35
+ expect(APP).toContain('data-testid="open-sync"');
36
+ expect(APP).toMatch(/aria-pressed=\{syncOpen\}/);
37
+ });
38
+
39
+ test('the span fallback remains for sessions without a handler', () => {
40
+ // Older wiring / no-handler renders must keep the passive chip rather
41
+ // than a dead button.
42
+ expect(APP).toMatch(/onOpenSync \? \(/);
43
+ });
44
+ });
45
+
46
+ describe('the Sync panel speaks the presentation vocabulary, safely', () => {
47
+ test('the header sentence comes from syncPresentation — the one shared rule', () => {
48
+ expect(PANEL).toContain("from '../../sync/presentation.ts'");
49
+ expect(PANEL).toContain('syncPresentation(status');
50
+ });
51
+
52
+ test('rows map DocSyncState onto existing words, never new ones', () => {
53
+ expect(PANEL).toMatch(/pending: 'syncing'/);
54
+ expect(PANEL).toMatch(/connected: 'synced'/);
55
+ expect(PANEL).toMatch(/'auth-rejected': 'refused'/);
56
+ });
57
+
58
+ test('every name that reaches the DOM goes through safeName', () => {
59
+ // Slugs and asset keys are local, but the payload is read back off disk —
60
+ // bounded text-only rendering is the house rule (DDR-054).
61
+ expect(PANEL).toContain('import { safeName, syncPresentation }');
62
+ expect(PANEL).not.toMatch(/dangerouslySetInnerHTML/);
63
+ });
64
+
65
+ test('the live header is a polite status region (the CloudBar a11y pattern)', () => {
66
+ expect(PANEL).toMatch(/role="status" aria-live="polite"/);
67
+ });
68
+
69
+ test('the asset lane renders from the payload, with the retry promise', () => {
70
+ expect(PANEL).toContain('data-testid="sync-assets"');
71
+ expect(PANEL).toContain('retry on the next launch');
72
+ });
73
+ });
74
+
75
+ describe('the panel reads the off-disk payload fail-closed (DDR-102 discipline)', () => {
76
+ test('items, assets and the header counts all pass a readCounts-style gate', () => {
77
+ // `_sync.json` is JSON.parse with no schema — a partial write or older
78
+ // producer must degrade to "nothing to show", never crash or print NaN
79
+ // (security review 2026-08-11, defender W1).
80
+ expect(PANEL).toContain('function readItems(');
81
+ expect(PANEL).toContain('function readAssets(');
82
+ expect(PANEL).toContain('readItems(status?.items)');
83
+ expect(PANEL).toContain('readAssets(status?.assets)');
84
+ expect(PANEL).toMatch(/Number\.isInteger/);
85
+ // The header chip validates the same counts the note fails closed on.
86
+ expect(PANEL).toMatch(
87
+ /\[rawDocs\.synced, rawDocs\.pending, rawDocs\.rejected\]\.every\(isCount\)/
88
+ );
89
+ });
90
+ });
@@ -233,6 +233,69 @@ describe('a peer with an empty design root pulls the project down whole', () =>
233
233
  });
234
234
  });
235
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
+
236
299
  describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
237
300
  test('a pulled canvas never lands on a file that already exists locally', async () => {
238
301
  // `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
@@ -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
+ });