@1agh/maude 0.58.2 → 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 (105) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/api.ts +6 -1
  3. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  4. package/apps/studio/bin/_import-asset.mjs +72 -0
  5. package/apps/studio/bin/_import-figma.mjs +1121 -0
  6. package/apps/studio/bin/_video-playwright.mjs +86 -3
  7. package/apps/studio/bin/import-figma.sh +38 -0
  8. package/apps/studio/bin/read-annotations.mjs +11 -1
  9. package/apps/studio/bun.lock +16 -22
  10. package/apps/studio/canvas-edit.ts +29 -5
  11. package/apps/studio/client/app.jsx +44 -1
  12. package/apps/studio/client/export-center.jsx +42 -4
  13. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  14. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  15. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  16. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  17. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  18. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  19. package/apps/studio/client/panels/timeline-parse.js +3 -3
  20. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  21. package/apps/studio/client/styles/4-components.css +134 -0
  22. package/apps/studio/clip-ops.ts +93 -17
  23. package/apps/studio/cloud/endpoints.ts +78 -10
  24. package/apps/studio/cloud/renew.ts +183 -0
  25. package/apps/studio/context.ts +2 -1
  26. package/apps/studio/dist/client.bundle.js +1261 -1261
  27. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  28. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  29. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  30. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  31. package/apps/studio/dist/runtime/remotion.js +12 -12
  32. package/apps/studio/dist/styles.css +1 -1
  33. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  34. package/apps/studio/exporters/_runtime.ts +19 -0
  35. package/apps/studio/exporters/degraded.ts +92 -0
  36. package/apps/studio/exporters/index.ts +5 -0
  37. package/apps/studio/exporters/jobs.ts +19 -0
  38. package/apps/studio/exporters/unsupported-media.ts +170 -0
  39. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  40. package/apps/studio/exporters/video-render-lib.ts +6 -0
  41. package/apps/studio/exporters/video.ts +62 -1
  42. package/apps/studio/figma/assets.test.ts +372 -0
  43. package/apps/studio/figma/assets.ts +398 -0
  44. package/apps/studio/figma/client.test.ts +395 -0
  45. package/apps/studio/figma/client.ts +513 -0
  46. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  47. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  48. package/apps/studio/figma/endpoints.ts +200 -0
  49. package/apps/studio/figma/sanitize.test.ts +256 -0
  50. package/apps/studio/figma/sanitize.ts +315 -0
  51. package/apps/studio/figma/style-map.ts +352 -0
  52. package/apps/studio/figma/to-artboard.test.ts +808 -0
  53. package/apps/studio/figma/to-artboard.ts +701 -0
  54. package/apps/studio/figma/to-render.test.ts +180 -0
  55. package/apps/studio/figma/to-render.ts +306 -0
  56. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  57. package/apps/studio/figma/to-strokes.test.ts +705 -0
  58. package/apps/studio/figma/to-strokes.ts +749 -0
  59. package/apps/studio/figma/to-tokens.test.ts +321 -0
  60. package/apps/studio/figma/to-tokens.ts +305 -0
  61. package/apps/studio/figma/types.ts +539 -0
  62. package/apps/studio/figma/url.test.ts +167 -0
  63. package/apps/studio/figma/url.ts +160 -0
  64. package/apps/studio/http.ts +129 -0
  65. package/apps/studio/sync/asset-push.ts +124 -0
  66. package/apps/studio/sync/connection-state.ts +11 -0
  67. package/apps/studio/sync/hub-link.ts +63 -7
  68. package/apps/studio/sync/hubs-config.ts +31 -3
  69. package/apps/studio/sync/index.ts +276 -26
  70. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  71. package/apps/studio/sync/presentation.ts +45 -1
  72. package/apps/studio/sync/supervisor.ts +5 -1
  73. package/apps/studio/sync/workspace-signin.ts +7 -3
  74. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  75. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  76. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  77. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  78. package/apps/studio/test/clip-addressing.test.ts +6 -1
  79. package/apps/studio/test/clip-ops.test.ts +5 -1
  80. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  81. package/apps/studio/test/cloud-renew.test.ts +205 -0
  82. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  83. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  84. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  85. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  86. package/apps/studio/test/figma-provenance.test.ts +108 -0
  87. package/apps/studio/test/figma-routes.test.ts +294 -0
  88. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  89. package/apps/studio/test/hub-link.test.ts +11 -0
  90. package/apps/studio/test/import-figma.test.ts +479 -0
  91. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  92. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  93. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  94. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  95. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  96. package/apps/studio/test/sync-presentation.test.ts +77 -0
  97. package/apps/studio/test/sync-runtime.test.ts +316 -1
  98. package/apps/studio/test/video-comp.test.ts +23 -1
  99. package/apps/studio/test/workspace-containment.test.ts +1 -0
  100. package/apps/studio/video-comp.tsx +70 -6
  101. package/apps/studio/whats-new.json +27 -0
  102. package/apps/studio/workspace-mode.ts +4 -0
  103. package/cli/commands/design.mjs +8 -0
  104. package/package.json +8 -8
  105. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,121 @@
1
+ // One-shot quarantine of the pre-fix-5 flat fallback twins — fix 4 of the
2
+ // 2026-08-10 sync RCA (v0.58.2's `f2a38c46` changed the fallback rule with no
3
+ // migration of what the old rule had already written).
4
+ //
5
+ // Until then the no-path fallback wrote a pulled canvas FLAT at the design root
6
+ // (`ui-welcome.tsx`); the group-aware fallback plus the stamp-race fix (phase
7
+ // sync-2) stopped producing new ones, but whatever the old rule wrote is still
8
+ // on disk NEXT TO the real grouped file — two files, one slug, one document
9
+ // (DDR-064 A4's collision class), and the tree lists both.
10
+ //
11
+ // The rules, in migrate-seed.ts's posture (idempotent, best-effort, never
12
+ // throws into boot):
13
+ //
14
+ // - Only a flat file WITH a grouped twin of the same slug moves — the flat
15
+ // copy is provably the redundant one. A flat file with NO twin is a
16
+ // genuinely-flat canvas somebody authored; moving it would lose work, so
17
+ // it stays (fix 5 + a future re-sync relocates it).
18
+ // - Quarantine, never delete: the loser goes to `_trash/<slug>-flat-<ts>/`
19
+ // (the established runtime quarantine, DDR-115), `.meta.json`/`.css`
20
+ // siblings riding along. The `<slug>.annotations.svg` sidecar does NOT
21
+ // move — annotations are keyed by the flat slug at the design root and
22
+ // serve the surviving grouped twin.
23
+ // - One log line per move, so a tenant can find their bytes.
24
+
25
+ import { type Dirent, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
29
+
30
+ export interface FlatFallbackMove {
31
+ slug: string;
32
+ /** Design-root-relative flat body that moved. */
33
+ from: string;
34
+ /** Design-root-relative grouped body that made it redundant. */
35
+ keptTwin: string;
36
+ /** Design-root-relative quarantine dir the flat set landed in. */
37
+ trashedTo: string;
38
+ }
39
+
40
+ /**
41
+ * Scan the design root, quarantine every flat body whose slug a grouped file
42
+ * also claims. Returns what moved (empty on a clean tree — the idempotent
43
+ * steady state). Never throws.
44
+ */
45
+ export function migrateFlatFallback(opts: {
46
+ designRoot: string;
47
+ /** Design root relative to the repo root (`.design`) — for slug derivation. */
48
+ designRel: string;
49
+ log?: (line: string) => void;
50
+ }): FlatFallbackMove[] {
51
+ const { designRoot, designRel } = opts;
52
+ const log = opts.log ?? ((line: string) => console.log(line));
53
+ const moves: FlatFallbackMove[] = [];
54
+ try {
55
+ // slug → every body path claiming it, design-root-relative.
56
+ const claims = new Map<string, string[]>();
57
+ for (const rel of walk(designRoot)) {
58
+ if (!/\.tsx$/i.test(rel)) continue;
59
+ const slug = canvasSlugFromRel(rel, designRel);
60
+ const list = claims.get(slug);
61
+ if (list) list.push(rel);
62
+ else claims.set(slug, [rel]);
63
+ }
64
+
65
+ for (const [slug, rels] of claims) {
66
+ const flat = rels.find((rel) => !rel.includes('/'));
67
+ const twin = rels.find((rel) => rel.includes('/'));
68
+ if (!flat || !twin) continue;
69
+
70
+ const trashedTo = `_trash/${slug}-flat-${Date.now()}`;
71
+ const base = flat.replace(/\.tsx$/i, '');
72
+ const movedLanes: string[] = [];
73
+ try {
74
+ mkdirSync(path.join(designRoot, trashedTo), { recursive: true });
75
+ for (const lane of [flat, `${base}.meta.json`, `${base}.css`]) {
76
+ const src = path.join(designRoot, lane);
77
+ if (!existsSync(src)) continue;
78
+ renameSync(src, path.join(designRoot, trashedTo, path.basename(lane)));
79
+ movedLanes.push(lane);
80
+ }
81
+ } catch (err) {
82
+ log(
83
+ `[sync/migrate-flat-fallback] could not quarantine ${flat}: ${(err as Error).message} — leaving it in place`
84
+ );
85
+ continue;
86
+ }
87
+ moves.push({ slug, from: flat, keptTwin: twin, trashedTo });
88
+ log(
89
+ `[sync/migrate-flat-fallback] ${flat} duplicated ${twin} (slug ${slug}) — moved ${movedLanes.join(
90
+ ', '
91
+ )} to ${trashedTo}/`
92
+ );
93
+ }
94
+ } catch {
95
+ /* best-effort — a scan failure must never cost the boot */
96
+ }
97
+ return moves;
98
+ }
99
+
100
+ /** Files under `dir`, relative to it, skipping runtime-state (`_*`) and repo
101
+ * internals — the same exclusions the hub's checkout scan applies. */
102
+ function walk(dir: string, prefix = '', out: string[] = []): string[] {
103
+ let entries: Dirent[];
104
+ try {
105
+ entries = readdirSync(dir, { withFileTypes: true });
106
+ } catch {
107
+ return out;
108
+ }
109
+ for (const entry of entries) {
110
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
111
+ if (entry.isDirectory()) {
112
+ if (entry.name.startsWith('_') || entry.name === '.git' || entry.name === 'node_modules') {
113
+ continue;
114
+ }
115
+ walk(path.join(dir, entry.name), rel, out);
116
+ } else if (entry.isFile()) {
117
+ out.push(rel);
118
+ }
119
+ }
120
+ return out;
121
+ }
@@ -22,10 +22,22 @@ export type SyncPhase =
22
22
  | 'connecting'
23
23
  | 'syncing'
24
24
  | 'synced'
25
+ | 'stalled'
25
26
  | 'refused'
26
27
  | 'offline'
27
28
  | 'nothing-syncable';
28
29
 
30
+ /**
31
+ * How long `connecting…` may honestly stay on screen with ZERO documents
32
+ * synced before it becomes a different sentence. A real handshake settles in
33
+ * seconds; five minutes of nothing is not a connection in progress, it is a
34
+ * link that needs a person (the alligators incident sat in `connecting…` for
35
+ * DAYS while every document was being refused). Matches the auth re-probe
36
+ * cadence so the claim "nothing is moving" has had at least one full retry
37
+ * cycle behind it.
38
+ */
39
+ export const STALL_AFTER_MS = 5 * 60 * 1000;
40
+
29
41
  export interface SyncPresentation {
30
42
  phase: SyncPhase;
31
43
  /** Green dot — reserved for a link that is genuinely carrying edits. */
@@ -119,10 +131,39 @@ function readCounts(
119
131
  */
120
132
  export function syncPresentation(
121
133
  status: SyncStatusLike | null | undefined,
122
- opts: { project?: string | null } = {}
134
+ opts: { project?: string | null; now?: number } = {}
123
135
  ): SyncPresentation | null {
124
136
  if (!status || status.linked === false) return null;
125
137
  const project = safeName(opts.project, 'the workspace');
138
+ const now = typeof opts.now === 'number' ? opts.now : Date.now();
139
+
140
+ // ZERO PROGRESS HAS A DEADLINE. Every `connecting…` below is honest for a
141
+ // handshake and a lie after five minutes of it — the phase reads as "in
142
+ // progress" while nothing is moving and nothing will. Once the link has
143
+ // been trying since `startedAt` with not one document synced, the sentence
144
+ // changes to the one that names the move. Validated like everything else
145
+ // read off disk; an absent/garbage stamp simply never stalls (old payloads).
146
+ const startedAt =
147
+ typeof status.startedAt === 'number' &&
148
+ Number.isFinite(status.startedAt) &&
149
+ status.startedAt > 0
150
+ ? status.startedAt
151
+ : null;
152
+ const stalled = (): SyncPresentation => {
153
+ // `?? now` rather than a cast: every call site is behind `isStalled`, which
154
+ // has already proved `startedAt` is a number — and a future one that isn't
155
+ // reads "1 minute" instead of "NaN minutes".
156
+ const min = Math.max(1, Math.round((now - (startedAt ?? now)) / 60_000));
157
+ return {
158
+ phase: 'stalled',
159
+ online: false,
160
+ label: 'stalled',
161
+ title: `Nothing has synced with ${project} in the ${min} minute${min === 1 ? '' : 's'} since Maude started trying.`,
162
+ next: 'Reconnect the workspace — the sign-in may have expired.',
163
+ names: [],
164
+ };
165
+ };
166
+ const isStalled = startedAt !== null && now - startedAt > STALL_AFTER_MS;
126
167
 
127
168
  if (status.notSyncable) {
128
169
  const tsx = status.tsxCount ?? 0;
@@ -203,6 +244,7 @@ export function syncPresentation(
203
244
  // Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
204
245
  // evidence it is, rather than promoting it to a document-level claim.
205
246
  const online = status.state === 'online' || status.flash === 'synced';
247
+ if (!online && isStalled) return stalled();
206
248
  return {
207
249
  phase: online ? 'synced' : 'connecting',
208
250
  online,
@@ -218,6 +260,7 @@ export function syncPresentation(
218
260
  const total = docs.synced + docs.pending + docs.rejected;
219
261
 
220
262
  if (total === 0) {
263
+ if (isStalled) return stalled();
221
264
  return {
222
265
  phase: 'connecting',
223
266
  online: false,
@@ -232,6 +275,7 @@ export function syncPresentation(
232
275
  // Zero settled yet is a different fact from some settled: one is a
233
276
  // handshake in flight, the other is visible progress.
234
277
  const started = docs.synced > 0;
278
+ if (!started && isStalled) return stalled();
235
279
  return {
236
280
  phase: started ? 'syncing' : 'connecting',
237
281
  online: false,
@@ -52,8 +52,11 @@ export interface SyncSupervisor {
52
52
  /**
53
53
  * Adopt a just-authorized link and cycle the runtime. `linkedHub` is passed
54
54
  * BY VALUE from the attach lane on purpose — never re-read from disk.
55
+ * `null` is the detach lane: clear the in-memory link (the config write on
56
+ * disk is invisible to a cfg captured at boot) and cycle back to solo.
57
+ * `undefined` keeps whatever the config already holds.
55
58
  */
56
- restart(linkedHub?: LinkedHub): Promise<SyncStartOutcome>;
59
+ restart(linkedHub?: LinkedHub | null): Promise<SyncStartOutcome>;
57
60
  stop(): Promise<void>;
58
61
  /** The live runtime, or null in solo mode. Test/inspection surface. */
59
62
  current(): SyncRuntime | null;
@@ -166,6 +169,7 @@ export function createSyncSupervisor(
166
169
  }
167
170
  runtime = null;
168
171
  if (linkedHub) adoptLinkedHub(ctx, linkedHub);
172
+ else if (linkedHub === null) delete ctx.cfg.linkedHub;
169
173
  return boot();
170
174
  }),
171
175
  stop: () =>
@@ -67,7 +67,7 @@ export interface SignInInput {
67
67
  /** Injected in tests. */
68
68
  export interface SignInDeps {
69
69
  fetchImpl?: typeof fetch;
70
- save?: (normUrl: string, token: string, role?: string) => void;
70
+ save?: (normUrl: string, token: string, role?: string, expiresAt?: number) => void;
71
71
  }
72
72
 
73
73
  /**
@@ -186,8 +186,12 @@ export async function signInToWorkspace(
186
186
  // viewer is known to be one at BOOT. Learning it from the first refusal
187
187
  // instead means showing somebody an editor and then taking it away.
188
188
  const vouchedRole = typeof body.user?.role === 'string' ? body.user.role : 'member';
189
+ // …and WHEN it dies. The expiry was already read (it is echoed to the UI
190
+ // below) — discarding it from the store is what left the sync runtime
191
+ // with no deadline to renew against.
192
+ const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : undefined;
189
193
  try {
190
- save(norm, body.token, vouchedRole);
194
+ save(norm, body.token, vouchedRole, expiresAt);
191
195
  } catch {
192
196
  return fail(500, "Couldn't save the workspace connection on this computer.", 'save-failed');
193
197
  }
@@ -201,7 +205,7 @@ export async function signInToWorkspace(
201
205
  email: typeof body.user?.email === 'string' ? body.user.email : String(input.email).trim(),
202
206
  role: vouchedRole,
203
207
  },
204
- expiresAt: typeof body.expiresAt === 'number' ? body.expiresAt : null,
208
+ expiresAt: expiresAt ?? null,
205
209
  version: probe.version,
206
210
  },
207
211
  };
@@ -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);