@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
@@ -46,19 +46,124 @@ describe('bindCandidate', () => {
46
46
  );
47
47
  });
48
48
 
49
- test('only bindable tools host (no text/pen/arrow/link)', () => {
50
- const label: TextStroke = {
51
- id: 't',
52
- tool: 'text',
53
- color: '#1a1a1a',
54
- fontSize: 14,
55
- text: 'x',
56
- x: 0,
57
- y: 0,
58
- };
59
- expect(isBindable(label)).toBe(false);
60
- expect(bindCandidate(0, 0, [label], BIND_THRESHOLD_PX)).toBeNull();
49
+ test('pen / arrow / link never host', () => {
61
50
  expect(isBindable(rect('r'))).toBe(true);
51
+ expect(
52
+ isBindable({ id: 'p', tool: 'pen', color: '#000', width: 2, points: [[0, 0]] } as Stroke)
53
+ ).toBe(false);
54
+ expect(
55
+ isBindable({
56
+ id: 'l',
57
+ tool: 'link',
58
+ x: 0,
59
+ y: 0,
60
+ w: 10,
61
+ h: 10,
62
+ url: 'https://x.test',
63
+ title: 't',
64
+ domain: 'x.test',
65
+ } as Stroke)
66
+ ).toBe(false);
67
+ });
68
+ });
69
+
70
+ // DDR-216 D9 — the widening, and the three exclusions that keep it from
71
+ // regressing the NATIVE whiteboard. FigJam binds connectors to anything; on the
72
+ // only real board measured, 2/2 connectors had an endpoint Maude could not bind,
73
+ // so an imported connector degraded to a frozen line. Widening also closes a
74
+ // plain native gap (you could not attach an arrow to a text label or a section).
75
+ describe('isBindable widening — text + section (DDR-216 D9)', () => {
76
+ const standaloneText = (over: Partial<TextStroke> = {}): TextStroke => ({
77
+ id: 't',
78
+ tool: 'text',
79
+ color: '#1a1a1a',
80
+ fontSize: 14,
81
+ text: 'Archetyp',
82
+ x: 0,
83
+ y: 0,
84
+ ...over,
85
+ });
86
+
87
+ const section = (id: string, x = 0, y = 0, w = 400, h = 300): Stroke =>
88
+ ({ id, tool: 'section', x, y, w, h, label: 'Sekce', color: '#8b8b94' }) as Stroke;
89
+
90
+ test('standalone text IS bindable, and hosts a real bind', () => {
91
+ const label = standaloneText();
92
+ expect(isBindable(label)).toBe(true);
93
+ const bind = bindCandidate(1, 1, [label], BIND_THRESHOLD_PX);
94
+ expect(bind?.hostId).toBe('t');
95
+ });
96
+
97
+ test('ANCHORED text is NOT bindable — its bbox is unresolvable here', () => {
98
+ // `strokeBBox` returns null for anchored text without the anchors map, which
99
+ // every caller in this module omits. Admitting it would mint a bind that can
100
+ // never resolve — worse than not binding, because it never self-corrects.
101
+ const anchored = standaloneText({ anchorId: 'some-shape' });
102
+ expect(isBindable(anchored)).toBe(false);
103
+ expect(bindCandidate(0, 0, [anchored], BIND_THRESHOLD_PX)).toBeNull();
104
+ });
105
+
106
+ test('oversized text is NOT offered — no board-wide bind magnet', () => {
107
+ // A TextStroke has no stored w/h; strokeBBox synthesizes one from content,
108
+ // so 5 000 chars on one line projects a ~38 500 px-wide invisible strip.
109
+ const huge = standaloneText({ text: 'x'.repeat(5000) });
110
+ expect(isBindable(huge)).toBe(false);
111
+ // A rect under the cursor still wins, rather than the giant text stealing it.
112
+ const r = rect('r', 0, 0, 100, 100);
113
+ expect(bindCandidate(50, 50, [r, huge], BIND_THRESHOLD_PX)?.hostId).toBe('r');
114
+ });
115
+
116
+ test('a section binds on its BORDER, matching strokeHitTest', () => {
117
+ const s = section('sec');
118
+ expect(isBindable(s)).toBe(true);
119
+ expect(bindCandidate(0, 150, [s], BIND_THRESHOLD_PX)?.hostId).toBe('sec');
120
+ });
121
+
122
+ test('a section does NOT swallow binds through its click-through interior', () => {
123
+ // The natural gesture is to draw a section AROUND existing notes, which puts
124
+ // it later in the array = topmost. Testing its raw bbox would make every
125
+ // arrow drawn between two stickies inside it bind to the section instead.
126
+ const inner = rect('inner', 100, 100, 60, 60);
127
+ const s = section('sec', 0, 0, 400, 300);
128
+ const strokes: Stroke[] = [inner, s]; // section topmost
129
+ expect(bindCandidate(130, 130, strokes, BIND_THRESHOLD_PX)?.hostId).toBe('inner');
130
+ // Deep interior with nothing under it binds to nothing, not to the section.
131
+ expect(bindCandidate(300, 200, [s], BIND_THRESHOLD_PX)).toBeNull();
132
+ });
133
+
134
+ test('a bind whose anchorPoint cannot resolve is STRIPPED, not frozen-but-claimed', () => {
135
+ // Fix 2: `applyEnd` used to `return` silently when anchorPoint was null,
136
+ // which kept a bind that could never be honoured. Inert before the widening,
137
+ // load-bearing after it.
138
+ const ghost = standaloneText({ id: 'ghost', anchorId: 'gone' });
139
+ const ar = arrow('ar', {
140
+ x1: 0,
141
+ y1: 0,
142
+ x2: 100,
143
+ y2: 100,
144
+ startBind: { hostId: 'ghost', nx: 0.5, ny: 0.5 },
145
+ });
146
+ const out = recomputeBoundArrows([ghost, ar]);
147
+ const a = out.find((s) => s.id === 'ar') as ArrowStroke;
148
+ expect(a.startBind).toBeUndefined();
149
+ expect([a.x1, a.y1]).toEqual([0, 0]); // endpoint frozen in place, honestly
150
+ });
151
+
152
+ test('a bound arrow follows a text host as it moves', () => {
153
+ const label = standaloneText({ x: 0, y: 0 });
154
+ const ar = arrow('ar', {
155
+ x1: 0,
156
+ y1: 0,
157
+ x2: 300,
158
+ y2: 300,
159
+ startBind: { hostId: 't', nx: 0, ny: 0 },
160
+ });
161
+ const moved: Stroke[] = [{ ...label, x: 40, y: 60 }, ar];
162
+ const out = recomputeBoundArrows(moved);
163
+ const a = out.find((s) => s.id === 'ar') as ArrowStroke;
164
+ // The exact bbox is content-derived; what matters is that it TRACKED.
165
+ expect(a.startBind?.hostId).toBe('t');
166
+ expect(a.x1).not.toBe(0);
62
167
  });
63
168
  });
64
169
 
@@ -122,3 +227,36 @@ describe('facingAnchor (AI annotate / quick-create side pick)', () => {
122
227
  expect(facingAnchor(host, 50, -900)).toEqual({ hostId: 'h', nx: 0.5, ny: 0 });
123
228
  });
124
229
  });
230
+
231
+ describe('the text bind cap is PER AXIS, not an area product (review F8)', () => {
232
+ test('a long single line at the font-size floor is NOT a bind host', () => {
233
+ // The area cap it replaced was satisfiable by exactly this shape: 4 000
234
+ // chars at fontSize 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960, under a
235
+ // 640×480 product — i.e. a 17 600 px-wide invisible magnet.
236
+ const strip: TextStroke = {
237
+ id: 'strip',
238
+ tool: 'text',
239
+ color: '#1a1a1a',
240
+ fontSize: 8,
241
+ text: 'x'.repeat(4000),
242
+ x: 0,
243
+ y: 0,
244
+ };
245
+ expect(isBindable(strip)).toBe(false);
246
+ const r = rect('r', 100, 0, 100, 100);
247
+ expect(bindCandidate(150, 50, [r, strip], BIND_THRESHOLD_PX)?.hostId).toBe('r');
248
+ });
249
+
250
+ test('an ordinary label is still a bind host', () => {
251
+ const label: TextStroke = {
252
+ id: 't',
253
+ tool: 'text',
254
+ color: '#1a1a1a',
255
+ fontSize: 14,
256
+ text: 'Archetyp',
257
+ x: 0,
258
+ y: 0,
259
+ };
260
+ expect(isBindable(label)).toBe(true);
261
+ });
262
+ });
@@ -153,7 +153,10 @@ describe('/_api/canvas — POST round-trip', () => {
153
153
  expect(j.ok).toBe(true);
154
154
  const tsx = readFileSync(join(designRoot, 'ui', 'My Reel.tsx'), 'utf8');
155
155
  expect(tsx).toContain('<VideoComp component={Comp}');
156
- expect(tsx).toContain('<OffthreadVideo src="assets/a.mp4" />');
156
+ // <Video> from @remotion/media, not <OffthreadVideo> — the renderer
157
+ // rejects the latter and the export loses its audio (RCA
158
+ // issue-mp4-audio-export-html5audio-silent-degrade).
159
+ expect(tsx).toContain('<Video src="assets/a.mp4" />');
157
160
  expect(tsx).toContain('<Audio src="assets/music.mp3" />');
158
161
  expect(tsx).toContain('name="clip-1"');
159
162
  const m = JSON.parse(readFileSync(join(designRoot, 'ui', 'My Reel.meta.json'), 'utf8'));
@@ -214,6 +214,19 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
214
214
  '/_api/github/invite',
215
215
  '/_api/github/clone',
216
216
  '/_api/github/create-project',
217
+ // DDR-216 D3 — every /_api/figma/* route is MAIN-ORIGIN ONLY (absent from
218
+ // CANVAS_SAFE_API + startCanvasServer's `routes` map). A canvas-reachable
219
+ // Figma route would be a token-exfiltration primitive AND an SSRF
220
+ // primitive at once: `connect` stores the user's PAT, `probe` spends it,
221
+ // and `status` would leak whether one exists. A request from this origin
222
+ // 403s at the gate rather than reaching a handler. (The complementary
223
+ // cross-SITE guard — `isTrustedRequestHost` + `sameOriginWrite` on the
224
+ // MAIN origin, which allowlist omission does nothing about — lives in
225
+ // figma-routes.test.ts; both halves are required, neither is sufficient.)
226
+ '/_api/figma/status',
227
+ '/_api/figma/connect',
228
+ '/_api/figma/probe',
229
+ '/_api/figma/import',
217
230
  // Local-only project create (mkdir + git init + scaffold) — writes to disk,
218
231
  // no token; MAIN-ORIGIN ONLY, so the canvas origin must 403 at the gate.
219
232
  '/_api/project/create-local',
@@ -0,0 +1,135 @@
1
+ // Source-shape gates for capture determinism.
2
+ //
3
+ // These guard properties that only manifest in a browser under load, which CI
4
+ // has no way to run — `test/exporters/pw-launch.test.ts` says as much ("the
5
+ // launch path itself is integration-shape (needs a real browser)"), and
6
+ // `test/export-shim-multi-capture.test.ts` exists precisely because of it. So,
7
+ // in that same house style, the FIX SHAPE is asserted at the source level.
8
+ //
9
+ // Why these particular shapes: the plan's original premise was that frame-step
10
+ // capture is "deterministic by construction" — a doc comment in video-comp.tsx
11
+ // with nothing enforcing it. It was not true. A seek could fail silently, and
12
+ // the capture wait could give up on a wall-clock timer and screenshot a stale
13
+ // frame. Serially both were survivable; under any parallel capture neither is.
14
+ // If someone deletes these guards, the resulting bug is a valid-looking video
15
+ // with wrong frames — invisible to every other test we have.
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const STUDIO = join(import.meta.dir, '..');
22
+ const read = (rel: string) => readFileSync(join(STUDIO, rel), 'utf8');
23
+
24
+ describe('the seek bridge must not report a seek that did not happen', () => {
25
+ const src = () => read('video-comp.tsx');
26
+
27
+ test('no empty catch swallows a failed seekTo', () => {
28
+ // The exact regression: `catch { /* comment only */ }` around the seek.
29
+ const emptyCatch = /catch\s*\{\s*(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)?\s*\}/g;
30
+ const body = src();
31
+ const seekRegion = body.slice(
32
+ body.indexOf('__maude_seek__ = async'),
33
+ body.indexOf('Ordinary-artboard fallback')
34
+ );
35
+ expect(seekRegion.length).toBeGreaterThan(0);
36
+ expect(seekRegion.match(emptyCatch)).toBeNull();
37
+ });
38
+
39
+ test('an unlanded seek throws rather than resolving', () => {
40
+ expect(src()).toContain('refusing to report a seek that did not happen');
41
+ });
42
+
43
+ test('failures are counted where the shim can read them', () => {
44
+ expect(src()).toContain('__maude_seek_failures__');
45
+ });
46
+
47
+ test('a cold Player still gets bounded retries — the fix is not "fail faster"', () => {
48
+ expect(src()).toContain('SEEK_ATTEMPTS');
49
+ expect(src()).toContain('seekEntryWithRetry');
50
+ });
51
+ });
52
+
53
+ describe('the capture shim must refuse a capture built on stale frames', () => {
54
+ test('it reads the in-page failure count and throws on any', () => {
55
+ const shim = read('bin/_video-playwright.mjs');
56
+ expect(shim).toContain('__maude_seek_failures__');
57
+ expect(shim).toMatch(/seekFailures\s*>\s*0/);
58
+ expect(shim).toContain('Refusing to encode');
59
+ });
60
+
61
+ test('the refusal happens BEFORE the encoded output is written', () => {
62
+ const shim = read('bin/_video-playwright.mjs');
63
+ const guardAt = shim.indexOf('seekFailures');
64
+ const writeAt = shim.indexOf('writeFileSync(out, Buffer.from(enc.b64');
65
+ expect(guardAt).toBeGreaterThan(0);
66
+ expect(writeAt).toBeGreaterThan(0);
67
+ // Ordering is the whole point — a check after the write ships the bad file.
68
+ expect(guardAt).toBeLessThan(writeAt);
69
+ });
70
+ });
71
+
72
+ describe('encoder guards (unit-shape — these run without a browser)', () => {
73
+ const enc = () => read('exporters/video-encode-lib.ts');
74
+
75
+ test('a mismatched frame is rejected, never silently resampled', () => {
76
+ const src = enc();
77
+ expect(src).toContain('refusing to resample');
78
+ // The check must precede the draw, or the resample already happened.
79
+ const guardAt = src.indexOf('refusing to resample');
80
+ const drawAt = src.indexOf('vstate.ctx.drawImage(bmp, 0, 0');
81
+ expect(guardAt).toBeGreaterThan(0);
82
+ expect(guardAt).toBeLessThan(drawAt);
83
+ });
84
+
85
+ test("latencyMode is pinned to 'quality' — 'realtime' may drop frames", () => {
86
+ expect(enc()).toContain("latencyMode: 'quality'");
87
+ });
88
+
89
+ test('the encoder canvas is NOT cleared between frames — pinned deliberately', () => {
90
+ // Current behaviour: a transparent artboard ghosts the previous frame,
91
+ // because nothing clears the canvas before drawImage. That is a real defect
92
+ // for transparent comps, but changing it is a visual change to every export
93
+ // and belongs in its own change with its own before/after. Pinned here so
94
+ // the next person changes it ON PURPOSE rather than as a side effect.
95
+ expect(enc()).not.toContain('clearRect');
96
+ });
97
+ });
98
+
99
+ describe('bundle cache paths must be content-addressed', () => {
100
+ test('no fixed-name temp bundle two servers could race on', () => {
101
+ const src = read('exporters/_browser-bundles.ts');
102
+ expect(src).toContain('writeHashedBundle');
103
+ expect(src).not.toContain("'maude-video-encode-lib.mjs'");
104
+ expect(src).not.toContain("'maude-video-render-lib.mjs'");
105
+ });
106
+ });
107
+
108
+ describe('the video shim must guard its output size', () => {
109
+ test('it calls assertRenderOutputSizeOk like the pdf shim does', () => {
110
+ const shim = read('bin/_video-playwright.mjs');
111
+ expect(shim).toContain(
112
+ "assertRenderOutputSizeOk(clip.width, clip.height, deviceScaleFactor, '_video-playwright')"
113
+ );
114
+ });
115
+ });
116
+
117
+ describe('timing telemetry must not break the summary contract', () => {
118
+ test('MAUDE_TIMING is filtered in _runtime.ts, not in an adapter', () => {
119
+ const rt = read('exporters/_runtime.ts');
120
+ expect(rt).toContain('TIMING_LINE');
121
+ expect(rt).toContain('onTiming');
122
+ // video.ts parses stdoutLines.at(-1) as its summary JSON — an unfiltered
123
+ // trailing diagnostic line would break EVERY video export.
124
+ const video = read('exporters/video.ts');
125
+ expect(video).toContain('stdoutLines.at(-1)');
126
+ expect(video).not.toContain('MAUDE_TIMING');
127
+ });
128
+
129
+ test('both capture paths emit it, so they are comparable', () => {
130
+ const shim = read('bin/_video-playwright.mjs');
131
+ expect(shim).toContain("path: 'frame-step'");
132
+ expect(shim).toContain("path: 'renderer'");
133
+ expect(shim).toContain('stageMs');
134
+ });
135
+ });
@@ -708,7 +708,12 @@ describe('assembleCompSource — refs → comp (DDR-150 P4 Task 12)', () => {
708
708
  ]);
709
709
  expect(tsx).toContain('<Audio src="assets/music.mp3" />');
710
710
  expect(tsx).toContain(`import { TransitionSeries } from '@remotion/transitions';`);
711
- expect(tsx).toContain(`import { AbsoluteFill, OffthreadVideo, Audio } from 'remotion';`);
711
+ // Media elements MUST come from @remotion/media — importing them from
712
+ // 'remotion' is what made every assembled reel export muted (RCA
713
+ // issue-mp4-audio-export-html5audio-silent-degrade).
714
+ expect(tsx).toContain(`import { Video, Audio } from '@remotion/media';`);
715
+ expect(tsx).toContain(`import { AbsoluteFill } from 'remotion';`);
716
+ expect(tsx).not.toContain(`Audio } from 'remotion'`);
712
717
  // total driven by the video clip's duration (audio doesn't extend it).
713
718
  expect(enumerateClips('/abs/R.tsx', tsx, 'reel').durationInFrames).toBe(90);
714
719
  });
@@ -755,7 +755,11 @@ describe('applyEnsureVideoComp — kind="video" artboard upgrade', () => {
755
755
  expect(r.source).toContain('<VideoComp component={VideoCut}');
756
756
  expect(r.source).toContain('width={1280} height={800}');
757
757
  expect(r.source).toMatch(/import \{[^}]*AbsoluteFill[^}]*\} from 'remotion';/);
758
- expect(r.source).toContain('<OffthreadVideo src="assets/x.mp4" />');
758
+ // The canvas declares `export default function Video()`, so the inserted
759
+ // media element is ALIASED to dodge the collision — it is no longer
760
+ // rewritten to <OffthreadVideo>, which silently cost the audio track.
761
+ expect(r.source).toContain('<MaudeVideo src="assets/x.mp4" />');
762
+ expect(r.source).toContain(`import { Video as MaudeVideo } from '@remotion/media';`);
759
763
  const cc = enumerateClips('/abs/Video.tsx', r.source, 'brief');
760
764
  expect(cc.compName).toBe('VideoCut');
761
765
  expect(cc.clips.length).toBe(1);
@@ -12,6 +12,7 @@ import { join } from 'node:path';
12
12
  import {
13
13
  connectOutcomeNote,
14
14
  isDisplayableUrl,
15
+ isLinkedProjectRow,
15
16
  localIdentityHint,
16
17
  parseDeepLink,
17
18
  shareViewUrl,
@@ -187,6 +188,101 @@ describe('status — names the LOCAL half so a deep link can be read', () => {
187
188
  });
188
189
  });
189
190
 
191
+ describe('isLinkedProjectRow — Connected is a state only a credentialed link earns (fix 7)', () => {
192
+ const CLOUD = 'https://cloud.maude.sh';
193
+ const row = (over = {}) =>
194
+ isLinkedProjectRow({
195
+ projectUrl: 'https://alligators.cloud.maude.sh',
196
+ linkedHubUrl: 'https://alligators.cloud.maude.sh',
197
+ linkedHubCredentialed: true,
198
+ cloudUrl: CLOUD,
199
+ ...over,
200
+ });
201
+
202
+ test('linked + credentialed → the row is Connected', () => {
203
+ expect(row()).toBe(true);
204
+ });
205
+ test('linked but UNCREDENTIALED still offers Connect — config.json travels with the repo (B2)', () => {
206
+ expect(row({ linkedHubCredentialed: false })).toBe(false);
207
+ });
208
+ test('an unrelated project keeps its Connect button', () => {
209
+ expect(row({ projectUrl: 'https://dugmate.cloud.maude.sh' })).toBe(false);
210
+ });
211
+ test('unlinked folder: no row is Connected', () => {
212
+ expect(row({ linkedHubUrl: null, linkedHubCredentialed: false })).toBe(false);
213
+ });
214
+ test('self-host addresses carry no project label — exact origin decides', () => {
215
+ expect(
216
+ row({
217
+ projectUrl: 'http://127.0.0.1:8788',
218
+ linkedHubUrl: 'http://127.0.0.1:8788',
219
+ cloudUrl: 'http://127.0.0.1:8788',
220
+ })
221
+ ).toBe(true);
222
+ // Two stubs on different ports are two different workspaces.
223
+ expect(
224
+ row({
225
+ projectUrl: 'http://127.0.0.1:8788',
226
+ linkedHubUrl: 'http://127.0.0.1:9999',
227
+ cloudUrl: 'http://127.0.0.1:8788',
228
+ })
229
+ ).toBe(false);
230
+ });
231
+ test('a label on one side and none on the other never matches', () => {
232
+ expect(row({ projectUrl: 'http://127.0.0.1:8788' })).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe('detach — the in-app unlink (fix 7 Disconnect)', () => {
237
+ test('drops linkedHub + the hub credential, cycles the runtime to solo; idempotent', async () => {
238
+ const { createCloudEndpoints } = await import('../cloud/endpoints.ts');
239
+ const { mkdirSync, existsSync } = await import('node:fs');
240
+ const repoRoot = join(scratch, 'detach-me');
241
+ const designRoot = join(repoRoot, '.design');
242
+ mkdirSync(designRoot, { recursive: true });
243
+ const hubUrl = 'https://alligators.cloud.maude.sh';
244
+ writeFileSync(
245
+ join(designRoot, 'config.json'),
246
+ JSON.stringify({ designRoot: '.design', linkedHub: { url: hubUrl, linkedAt: 1 } })
247
+ );
248
+ writeFileSync(
249
+ join(scratch, 'hubs.json'),
250
+ JSON.stringify({ hubs: { [hubUrl]: { token: 'mau_x', linkedAt: 1 } }, trusted: [hubUrl] })
251
+ );
252
+
253
+ const restarts: unknown[] = [];
254
+ const api = createCloudEndpoints({
255
+ paths: { repoRoot, designRoot },
256
+ syncControl: {
257
+ restart: async (linkedHub) => {
258
+ restarts.push(linkedHub);
259
+ return { syncing: false, canvases: 0, reason: 'unlinked' };
260
+ },
261
+ },
262
+ });
263
+
264
+ const r = await api.detach();
265
+ expect(r.status).toBe(200);
266
+ expect((r.json as { detached?: boolean }).detached).toBe(true);
267
+ // The committed half is gone, the rest of config.json survives.
268
+ const cfg = JSON.parse(readFileSync(join(designRoot, 'config.json'), 'utf8'));
269
+ expect(cfg.linkedHub).toBeUndefined();
270
+ expect(cfg.designRoot).toBe('.design');
271
+ // The credential + trust entry are gone too (the CLI unlink default).
272
+ const hubs = JSON.parse(readFileSync(join(scratch, 'hubs.json'), 'utf8'));
273
+ expect(hubs.hubs[hubUrl]).toBeUndefined();
274
+ expect(hubs.trusted).not.toContain(hubUrl);
275
+ // The runtime was cycled with the explicit unlink sentinel, not undefined.
276
+ expect(restarts).toEqual([null]);
277
+ expect(existsSync(join(designRoot, 'config.json'))).toBe(true);
278
+
279
+ // Second detach: still ok, nothing to do.
280
+ const again = await api.detach();
281
+ expect(again.status).toBe(200);
282
+ expect((again.json as { detached?: boolean }).detached).toBe(false);
283
+ });
284
+ });
285
+
190
286
  describe('localIdentityHint — only an exact, corroborated agreement is silent', () => {
191
287
  const CLOUD = 'https://cloud.maude.sh';
192
288