@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
@@ -25,7 +25,7 @@
25
25
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
 
28
- import { launchChromium } from './_pw-launch.mjs';
28
+ import { assertRenderOutputSizeOk, launchChromium } from './_pw-launch.mjs';
29
29
 
30
30
  const args = Object.fromEntries(
31
31
  process.argv.slice(2).reduce((acc, cur, i, all) => {
@@ -71,7 +71,26 @@ const licenseKey = licenseKeyArg || 'free-license';
71
71
  /** Wait a real turn of the event loop + a frame so a seek settles before shot. */
72
72
  const SETTLE_MS = 16;
73
73
 
74
- const browser = await launchChromium();
74
+ // GPU opt-in, measured not assumed (2026-08-09, M-series mac, 1920x1080):
75
+ //
76
+ // config h264 prefer-hardware png jpeg q90
77
+ // headless-shell (default) FALSE 51ms/187KB 33ms/43KB
78
+ // headless-shell --enable-gpu true 34ms/84KB 33ms/43KB
79
+ // full chromium / real Chrome true 34-49ms 17-33ms
80
+ //
81
+ // So today's engine is the ONE configuration that cannot reach a hardware
82
+ // encoder at all — `chrome-headless-shell` forces ANGLE/SwiftShader, and
83
+ // Chromium then reports ACCELERATED_VIDEO_ENCODE as disabled. `--enable-gpu`
84
+ // flips that.
85
+ //
86
+ // It is NOT on by default, deliberately: with the GPU the same page rasterizes
87
+ // to a materially different PNG (84KB vs 187KB), i.e. it is a VISUAL change to
88
+ // every export, and encode is not the bottleneck anyway (~50ms of a ~1050ms
89
+ // frame — see the MAUDE_TIMING line). Changing how a design tool renders is not
90
+ // something to smuggle in behind a perf flag. Opt in with MAUDE_CAPTURE_GPU=1.
91
+ const gpuArgs =
92
+ process.env.MAUDE_CAPTURE_GPU === '1' ? ['--enable-gpu', '--ignore-gpu-blocklist'] : [];
93
+ const browser = await launchChromium(gpuArgs.length ? { args: gpuArgs } : undefined);
75
94
  try {
76
95
  const ctx = await browser.newContext({
77
96
  viewport: { width: 1440, height: 900 },
@@ -199,6 +218,19 @@ try {
199
218
  audioCodec: rendered.audioCodec,
200
219
  };
201
220
  const ms = Date.now() - t0;
221
+ // Same shape as the frame-step line, so the two paths are directly
222
+ // comparable — that comparison is the whole speed story (~45 s vs ~37 min
223
+ // on the same canvas, per RCA issue-mp4-audio-export-html5audio-silent-degrade).
224
+ console.log(
225
+ `MAUDE_TIMING ${JSON.stringify({
226
+ path: 'renderer',
227
+ frames: frameCount,
228
+ totalMs: ms,
229
+ msPerFrame: Math.round((ms / Math.max(1, frameCount)) * 10) / 10,
230
+ scale: deviceScaleFactor,
231
+ audioCodec: rendered.audioCodec,
232
+ })}`
233
+ );
202
234
  console.error(
203
235
  `✓ rendered ${rendered.container}/${rendered.videoCodec}` +
204
236
  `${rendered.audioCodec ? `+${rendered.audioCodec}` : ' (muted)'} → ${out} (${rendered.bytes} B) in ${ms}ms`
@@ -368,6 +400,11 @@ async function frameStepCapture({
368
400
  // frame is drawn down to the native artboard size (the "tiny resolution" bug:
369
401
  // scale was accepted but never applied to the encode). scale=2 → 960×540 comp
370
402
  // exports at 1920×1080. Even dims (H.264 requires width/height divisible by 2).
403
+ // The video path is the ONE capture path that holds a full-resolution surface
404
+ // across thousands of frames, and it was the only one with no output-size
405
+ // guard — _pdf-playwright.mjs has enforced this since it existed. A scale-3
406
+ // 1080p comp allocates ~74MB per frame here.
407
+ assertRenderOutputSizeOk(clip.width, clip.height, deviceScaleFactor, '_video-playwright');
371
408
  const outW = Math.max(2, Math.round((clip.width * deviceScaleFactor) / 2) * 2);
372
409
  const outH = Math.max(2, Math.round((clip.height * deviceScaleFactor) / 2) * 2);
373
410
 
@@ -398,16 +435,27 @@ async function frameStepCapture({
398
435
  console.error(`encoder: ${started.container} / ${started.codec} @ ${outW}×${outH}`);
399
436
  }
400
437
 
438
+ // Per-stage accumulators. "The export takes 15 minutes" was, for a long time,
439
+ // answerable only by guessing which stage owned the second — so every
440
+ // optimization was aimed at a suspicion. One line at the end fixes that.
441
+ const stageMs = { seek: 0, settle: 0, screenshot: 0, encode: 0 };
401
442
  for (let f = 0; f < frameCount; f += 1) {
443
+ const tSeek = Date.now();
402
444
  await seekFrame(page, f, fps, mode);
445
+ stageMs.seek += Date.now() - tSeek;
446
+ const tSettle = Date.now();
403
447
  await page.waitForTimeout(SETTLE_MS);
448
+ stageMs.settle += Date.now() - tSettle;
449
+ const tShot = Date.now();
404
450
  const shot = await page.screenshot({ clip });
451
+ stageMs.screenshot += Date.now() - tShot;
405
452
  if (dump) {
406
453
  const p = join(dump, `frame-${String(f).padStart(5, '0')}.png`);
407
454
  writeFileSync(p, shot);
408
455
  framePaths.push(p);
409
456
  }
410
457
  if (encoding) {
458
+ const tEnc = Date.now();
411
459
  const b64 = shot.toString('base64');
412
460
  await page.evaluate(
413
461
  async ({ b64, isGif }) => {
@@ -416,6 +464,11 @@ async function frameStepCapture({
416
464
  },
417
465
  { b64, isGif }
418
466
  );
467
+ // NB: this stage is the double CDP crossing — the screenshot bytes go out
468
+ // to node as base64 and straight back into the same page, to an encoder
469
+ // that lives in that page. Its share of the total is what decides whether
470
+ // transport work is worth doing at all.
471
+ stageMs.encode += Date.now() - tEnc;
419
472
  }
420
473
  // Machine-readable progress (stdout) — spawnShim parses `MAUDE_PROGRESS`
421
474
  // lines → onProgress → job.progress → the live bar in the Exports panel, so
@@ -426,6 +479,18 @@ async function frameStepCapture({
426
479
  if (f % 30 === 0) console.error(`frame ${f + 1}/${frameCount}`);
427
480
  }
428
481
 
482
+ // A seek that never landed means at least one encoded frame shows the wrong
483
+ // content. There is no way to tell WHICH from the outside and no way for the
484
+ // user to notice, so the export dies here rather than shipping a file that
485
+ // looks fine. Counted in-page by the seek bridge (video-comp.tsx).
486
+ const seekFailures = await page.evaluate(() => window.__maude_seek_failures__ ?? 0);
487
+ if (seekFailures > 0) {
488
+ throw new Error(
489
+ `${seekFailures} frame seek(s) never landed — the capture would contain stale ` +
490
+ 'frames. Refusing to encode. This usually means the comp failed to mount.'
491
+ );
492
+ }
493
+
429
494
  let result = { fps, frameCount, width: outW, height: outH, framePaths };
430
495
  // Degradation marker — this run only encodes video (no audio), so when we're
431
496
  // standing in for a failed audio renderer, tell the exporter the export is
@@ -448,6 +513,19 @@ async function frameStepCapture({
448
513
  console.error(
449
514
  `✓ captured ${frameCount} frames @ ${fps}fps (${clip.width}×${clip.height}) in ${ms}ms`
450
515
  );
516
+ // Machine-readable, filtered out of stdoutLines by _runtime.ts (the adapters
517
+ // parse the LAST stdout line as their summary, so this must never be it).
518
+ console.log(
519
+ `MAUDE_TIMING ${JSON.stringify({
520
+ path: 'frame-step',
521
+ frames: frameCount,
522
+ totalMs: ms,
523
+ msPerFrame: Math.round((ms / Math.max(1, frameCount)) * 10) / 10,
524
+ stageMs,
525
+ scale: deviceScaleFactor,
526
+ out: { width: outW, height: outH },
527
+ })}`
528
+ );
451
529
  // stdout = machine-readable summary for the exporter.
452
530
  console.log(JSON.stringify(result));
453
531
  }
@@ -486,6 +564,10 @@ async function seekFrame(page, frame, fps, mode) {
486
564
  return;
487
565
  }
488
566
  // comp mode — the seek bridge pauses + seeks the Player and resolves post-paint.
567
+ // It now REJECTS when a seek never lands (video-comp.tsx), instead of resolving
568
+ // as if it had; that rejection propagates out of this evaluate and fails the
569
+ // export, which is the point — a stale frame is a valid-looking file with the
570
+ // wrong pixels, and no downstream check would ever catch it.
489
571
  // A comp with classic remotion <Video>/<OffthreadVideo> renders a real <video>
490
572
  // whose seek is ASYNC — 2 rAF isn't enough, so wait for every video to land on
491
573
  // its frame (`seeked` / readyState) before the screenshot, else a stale frame
@@ -494,7 +576,8 @@ async function seekFrame(page, frame, fps, mode) {
494
576
  // on (verified across mid-clip + mid-transition frames — DDR-148 addendum).
495
577
  await page.evaluate(async (frame) => {
496
578
  if (typeof window.__maude_seek__ === 'function') {
497
- await window.__maude_seek__(frame);
579
+ // strict: a capture must never proceed on a seek that did not land.
580
+ await window.__maude_seek__(frame, { strict: true });
498
581
  }
499
582
  const vids = Array.from(document.querySelectorAll('video'));
500
583
  if (vids.length) {
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ # import-figma.sh — Figma / FigJam import (REST door). Thin shim over
3
+ # _import-figma.mjs; reached via `maude design import-figma` (DDR-062), never a
4
+ # raw bin path. See _import-figma.mjs + DDR-216 for the full architecture +
5
+ # security rationale. Requires bun — _import-figma.mjs imports the `.ts`
6
+ # annotation model and figma modules directly, same constraint as
7
+ # import-tokens.sh.
8
+ #
9
+ # All args are forwarded quoted ("$@") — never unquoted or eval-expanded. This
10
+ # matters more than usual here: a Figma URL is caller-supplied and a node id
11
+ # rides in its query string. The charset/shape validation itself happens in
12
+ # figma/url.ts, not in this shell (DDR-216 D4); the wrapper's ONLY job is safe
13
+ # argv passthrough.
14
+ #
15
+ # Usage:
16
+ # import-figma.sh --board <figjam-url> --root <repo> [--design-root .design]
17
+ # [--slug <name>] [--dry-run] [--json]
18
+ # import-figma.sh --frames <figma-url> --root <repo> (Phase 3)
19
+ # import-figma.sh --tokens <figma-url> --root <repo> (Phase 4)
20
+ #
21
+ # Needs a Figma personal access token with the `file_content:read` scope, added
22
+ # once in Settings.
23
+ #
24
+ # Exit: 0 ok · 1 other · 2 usage · 3 validation reject · 4 fetch/parse error ·
25
+ # 5 no token configured · 6 write/containment error.
26
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
27
+
28
+ case "$1" in
29
+ --help|-h) sed -n '2,25p' "$0" | sed 's/^# \?//'; exit 0 ;;
30
+ esac
31
+
32
+ if ! command -v bun >/dev/null 2>&1; then
33
+ echo "import-figma.sh: bun is required (hard dependency — see plugins/design/dependencies.json)." >&2
34
+ echo " Install: curl -fsSL https://bun.sh/install | bash" >&2
35
+ exit 1
36
+ fi
37
+
38
+ exec bun run "$SCRIPT_DIR/_import-figma.mjs" "$@"
@@ -106,6 +106,9 @@ Args:
106
106
 
107
107
  FigJam v3 additive fields per annotation: z (render order), groupIds (deepest →
108
108
  shallowest), author ("ai" for annotate-verb strokes), authorName/authorId
109
+ // (an IMPORTED stroke carries authorName "imported-figma" — DDR-216 D7, so a
110
+ // consuming skill can tell third-party content from what the user drew), the
111
+ // rendered fontSize for text/sticky (so 8 px is distinguishable from a heading)
109
112
  (human-drawn stickies), and from/to host ids on bound arrows.
110
113
 
111
114
  Section annotations additionally carry members: an array of every OTHER
@@ -210,6 +213,10 @@ function readText(attrs, inner) {
210
213
  h: null,
211
214
  text: decodeEntities(inner).trim(),
212
215
  color: attr(attrs, 'fill') || attr(attrs, 'stroke') || null,
216
+ // DDR-216 D6b / review F5 — a consuming skill must be able to see HOW BIG
217
+ // this renders. Without it, text at the 8 px floor is indistinguishable
218
+ // from a heading in the JSON an agent is handed.
219
+ fontSize: num(attr(attrs, 'font-size')),
213
220
  };
214
221
  if (anchorId) o.anchorId = anchorId;
215
222
  return o;
@@ -235,7 +242,8 @@ function readSticky(attrs, inner) {
235
242
  // Geometry off the inner <rect>; paper tint off the group fill; body off the
236
243
  // inner <text>. (annotations-layer.tsx strokeToSvgEl 'sticky' branch.)
237
244
  const rectAttrs = inner.match(/<rect\b([^>]*?)\/?>/)?.[1] ?? '';
238
- const body = inner.match(/<text\b[^>]*>([\s\S]*?)<\/text>/)?.[1] ?? '';
245
+ const textEl = inner.match(/<text\b([^>]*)>([\s\S]*?)<\/text>/);
246
+ const body = textEl?.[2] ?? '';
239
247
  return {
240
248
  tool: 'sticky',
241
249
  id: attr(attrs, 'data-id') || '',
@@ -245,6 +253,8 @@ function readSticky(attrs, inner) {
245
253
  h: num(attr(rectAttrs, 'height')),
246
254
  text: decodeEntities(body).trim(),
247
255
  color: attr(attrs, 'fill') || null,
256
+ // See readText — a consuming skill needs the rendered size.
257
+ fontSize: num(attr(textEl?.[1] ?? '', 'font-size')),
248
258
  };
249
259
  }
250
260
 
@@ -14,10 +14,10 @@
14
14
  "@mediabunny/aac-encoder": "1.50.7",
15
15
  "@mediabunny/flac-encoder": "1.50.7",
16
16
  "@mediabunny/mp3-encoder": "1.50.7",
17
- "@remotion/media": "4.0.486",
18
- "@remotion/player": "4.0.486",
19
- "@remotion/transitions": "4.0.486",
20
- "@remotion/web-renderer": "4.0.486",
17
+ "@remotion/media": "4.0.507",
18
+ "@remotion/player": "4.0.507",
19
+ "@remotion/transitions": "4.0.507",
20
+ "@remotion/web-renderer": "4.0.507",
21
21
  "dom-to-pptx": "^1.1.10",
22
22
  "dom-to-svg": "^0.12.2",
23
23
  "gifenc": "^1.0.3",
@@ -37,7 +37,7 @@
37
37
  "pptxgenjs": "^4.0.1",
38
38
  "react": "^19.2.7",
39
39
  "react-dom": "^19.2.7",
40
- "remotion": "4.0.486",
40
+ "remotion": "4.0.507",
41
41
  "svgo": "^4.0.1",
42
42
  "y-protocols": "^1.0.7",
43
43
  "yjs": "^13.6.31",
@@ -323,19 +323,19 @@
323
323
 
324
324
  "@radix-ui/rect": ["@radix-ui/rect@1.1.2", "", {}, "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="],
325
325
 
326
- "@remotion/licensing": ["@remotion/licensing@4.0.486", "", {}, "sha512-9viDTCmthh0LntxGQxKaArHPVW6wAc6FmoZfBNdIHfXoE9FCfhb+VXeJVhfx2Z6awPgu7vn93wstGBMussODOw=="],
326
+ "@remotion/licensing": ["@remotion/licensing@4.0.507", "", {}, "sha512-fdods4YD9c8Ww69wHdnmXpjx7W0rvct6B7gLb92cWsd+ITSUG3hKoh+NqSWXF0GK5bk8IFRmTtBAwJObxow9wA=="],
327
327
 
328
- "@remotion/media": ["@remotion/media@4.0.486", "", { "dependencies": { "mediabunny": "1.50.3", "remotion": "4.0.486", "zod": "4.3.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-vRglyMIvLDuVkj6r1miIcWDoVOoTHsWig4dMLM+F6Z5Z9BxVC95Vtlu8HjrtAvpV6K0AZ1uK3RhXemI+HybvCQ=="],
328
+ "@remotion/media": ["@remotion/media@4.0.507", "", { "dependencies": { "mediabunny": "1.50.8", "remotion": "4.0.507", "zod": "4.4.3" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-PpSbLq0x2E/yZGHyYcGsFpUWcocy+w2uV64qcTgASSN3Vd2ZSwNAaAGrNCUD9nN3EUaHjsoqheL/o8cRTZZ06g=="],
329
329
 
330
- "@remotion/paths": ["@remotion/paths@4.0.486", "", {}, "sha512-/qhmgyQ7/xUxbxnuqtZqfAjBzkV15Tvw73r0wKS82uVE+pBzlS14aucgoJcfksDv26VwCXn6BdHpUXUjXFCjhA=="],
330
+ "@remotion/paths": ["@remotion/paths@4.0.507", "", {}, "sha512-/2vXtdZDvpicamS30CmW+qYkA/yWhTUCk/5MysLExZYll8pqCR+BTlluRwpkfqhH0H4VJ4LwDoY1ZRd3MQ1HeQ=="],
331
331
 
332
- "@remotion/player": ["@remotion/player@4.0.486", "", { "dependencies": { "remotion": "4.0.486" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-S8wh+bS695gidwXYNnCjwZzJG9oPzyYBFMjpO0rlTbrNc3naIKT8UP443J0opitP4g8VuXnoIKObF7JzAfpe5A=="],
332
+ "@remotion/player": ["@remotion/player@4.0.507", "", { "dependencies": { "remotion": "4.0.507" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-ZAunyyCfOXSYh5sZrp+vFxZlSPatg5evmh7fHVCndAOWBUPIDVjdUWEM2D4i132MsN6GXasXDByi+6H0H+IkhQ=="],
333
333
 
334
- "@remotion/shapes": ["@remotion/shapes@4.0.486", "", { "dependencies": { "@remotion/paths": "4.0.486", "remotion": "4.0.486" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-k+2EiXVd6heKVnGACfSnFF5Ox46lvsq6RFtHvPc3CunSMJa04Ui1Z4vNQDcddCSXoHnT/+vht6bjZNlSk36OrQ=="],
334
+ "@remotion/shapes": ["@remotion/shapes@4.0.507", "", { "dependencies": { "@remotion/paths": "4.0.507", "remotion": "4.0.507" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-PinB0Va4FRdv9OeaoIxikPqius9XQj5S5xruQEg/Y1CX+1Eu9KOVXqPOrMgELjkfPLLQ0rA/I4gmKoVxhaoYDQ=="],
335
335
 
336
- "@remotion/transitions": ["@remotion/transitions@4.0.486", "", { "dependencies": { "@remotion/paths": "4.0.486", "@remotion/shapes": "4.0.486", "remotion": "4.0.486" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-U7K21B2NsPie3AE4vrbX7Th46Nw1dFvjs5smYLjuYRaSWPO6vTbNbPQn+pQFXf/dzTjygTNUU9Co5s3oZZNz9A=="],
336
+ "@remotion/transitions": ["@remotion/transitions@4.0.507", "", { "dependencies": { "@remotion/paths": "4.0.507", "@remotion/shapes": "4.0.507", "remotion": "4.0.507" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-d0rtF2gVdSxR4Q35kjSSEdQ1ji0WoKFRaeoNgx/7NO13c/3gDSECAg9m2LoP+fN5pkZFdfL8z+yhM16rL7rWjg=="],
337
337
 
338
- "@remotion/web-renderer": ["@remotion/web-renderer@4.0.486", "", { "dependencies": { "@mediabunny/aac-encoder": "1.50.3", "@mediabunny/flac-encoder": "1.50.3", "@mediabunny/mp3-encoder": "1.50.3", "@remotion/licensing": "4.0.486", "mediabunny": "1.50.3", "remotion": "4.0.486" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RbG4qFmg6paFDDc/rNL/3hooDPEWPnHu8MtDJB5axD/TiXFsaNR009L9dP610b9hPGwlykIOooSlD7INzo0Gqw=="],
338
+ "@remotion/web-renderer": ["@remotion/web-renderer@4.0.507", "", { "dependencies": { "@mediabunny/aac-encoder": "1.50.8", "@mediabunny/flac-encoder": "1.50.8", "@mediabunny/mp3-encoder": "1.50.8", "@remotion/licensing": "4.0.507", "mediabunny": "1.50.8", "remotion": "4.0.507" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-fH8iZX1e0hmeJ+hPqn3RfqrDBNxMGsZZzfdyX2QpTDZ9F2kKqOkh0dQsRqaU8o+YoE8JNV/+FyYm4yyDAh9o6Q=="],
339
339
 
340
340
  "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
341
341
 
@@ -757,7 +757,7 @@
757
757
 
758
758
  "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
759
759
 
760
- "remotion": ["remotion@4.0.486", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-njrfAmfr+fJCr4NXY6hlbzWpIRkfnF0lPdRGZ4DkQao3/XRUtuB166GcVuaVnUpwa8NP+W+Q6TczOPveUpr0pw=="],
760
+ "remotion": ["remotion@4.0.507", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-g/NrnRZKJyh0isE+/0sIxKlgJl6BH5v/CJ35W4TGYR1obQZf60fHkf9CGxSBHJEw27dG1PJCgKT75YGfoCZ7tg=="],
761
761
 
762
762
  "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
763
763
 
@@ -881,17 +881,11 @@
881
881
 
882
882
  "@pdf-lib/upng/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
883
883
 
884
- "@remotion/media/mediabunny": ["mediabunny@1.50.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-lw6NjC6++/p6MzHC3u7RbkpM0dnukEQcAAXzZwXlmmx9y8n6132DXUMer2WaNn8RPuGgUpnemi/QIVNpEnONhQ=="],
884
+ "@remotion/web-renderer/@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.50.8", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-A5Se/LZd6RmYq/h36lBMSEsHvsyW8d0toR7FrAwpsFYbK+DVQYf90KiBT1Aw/mzLXx8/ypIOORJnd1sVZqOvJQ=="],
885
885
 
886
- "@remotion/media/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
886
+ "@remotion/web-renderer/@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.50.8", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-4cfN03SbEoQaG+eBeYFUAb1R1ALaAHzf43dXFzQTr/oO5ueqzTgBoG3coKrIvBaAMU7Vv36mrBKLX8bvTppdAQ=="],
887
887
 
888
- "@remotion/web-renderer/@mediabunny/aac-encoder": ["@mediabunny/aac-encoder@1.50.3", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-rVPnhJbn+7BUsu09sEl/bee/DkzmAc33oErJ250RPlCqrPndx61DSb2GLvVmmSx6L2LU2WyCnvJIMUrGPJEXPA=="],
889
-
890
- "@remotion/web-renderer/@mediabunny/flac-encoder": ["@mediabunny/flac-encoder@1.50.3", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-aIEHE3eQ+hG35rRvtNNYO6ufTiGkZpzgOBjcqK8CQuRAMtUY9/DzV12jj52L0nSj2zmBKXQQ6dWBWsusigV/dQ=="],
891
-
892
- "@remotion/web-renderer/@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.50.3", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-dyjWOhG27+ctlejfjY4vFy0g8wzo0NCRTNQ/GNsCaHxWkduaq0GI2BWKXm6UFCsfnUKgtDWMEu4zOPl9fKpbsA=="],
893
-
894
- "@remotion/web-renderer/mediabunny": ["mediabunny@1.50.3", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-lw6NjC6++/p6MzHC3u7RbkpM0dnukEQcAAXzZwXlmmx9y8n6132DXUMer2WaNn8RPuGgUpnemi/QIVNpEnONhQ=="],
888
+ "@remotion/web-renderer/@mediabunny/mp3-encoder": ["@mediabunny/mp3-encoder@1.50.8", "", { "peerDependencies": { "mediabunny": "^1.0.0" } }, "sha512-eBT/H30tTu8AmZXqZ5RTpJ9VmwVlwednajuOrrWp0GS6cbGXsGMQ60Qvr3ZMIE0QDiadlEzb8/ozR+doP+MC1g=="],
895
889
 
896
890
  "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
897
891
 
@@ -1641,7 +1641,21 @@ const CLIP_TAGS = new Set(['Sequence', 'Series.Sequence', 'TransitionSeries.Sequ
1641
1641
  /** Transition tags (occupy a slot between clips inside a TransitionSeries). */
1642
1642
  const TRANSITION_TAGS = new Set(['Series.Transition', 'TransitionSeries.Transition']);
1643
1643
  /** Media tags that carry a `src` (the replace-media + drop targets). */
1644
- const MEDIA_TAGS = new Set(['Video', 'OffthreadVideo', 'Audio', 'Img', 'Image']);
1644
+ // `Maude*` spellings are the collision-avoidance ALIASES clip-ops emits when a
1645
+ // canvas already declares a component of that name (e.g. `export default
1646
+ // function Video()`). They are the same elements and must tokenize the same,
1647
+ // or the Timeline silently loses the clip's media (RCA
1648
+ // issue-mp4-audio-export-html5audio-silent-degrade follow-up).
1649
+ const MEDIA_TAGS = new Set([
1650
+ 'Video',
1651
+ 'OffthreadVideo',
1652
+ 'Audio',
1653
+ 'Img',
1654
+ 'Image',
1655
+ 'MaudeVideo',
1656
+ 'MaudeAudio',
1657
+ 'MaudeImg',
1658
+ ]);
1645
1659
  /** Remotion/layout primitives that are structural, not their own timeline layer. */
1646
1660
  const LAYER_SKIP_TAGS = new Set([
1647
1661
  'AbsoluteFill',
@@ -4534,7 +4548,7 @@ export function assembleCompSource(
4534
4548
  const dur = Math.max(1, Math.round(c.durationInFrames ?? defDur));
4535
4549
  seqLines.push(
4536
4550
  ` <TransitionSeries.Sequence name="clip-${i + 1}" durationInFrames={${dur}}>`,
4537
- ` <OffthreadVideo src="${escapeAttr(c.src)}" />`,
4551
+ ` <Video src="${escapeAttr(c.src)}" />`,
4538
4552
  ` </TransitionSeries.Sequence>`
4539
4553
  );
4540
4554
  cursor += dur;
@@ -4557,16 +4571,26 @@ export function assembleCompSource(
4557
4571
  // the cursor (with the historical defDur floor for degenerate inputs).
4558
4572
  const total = videos.length || audios.length ? Math.max(cursor, defDur) : fps * 5;
4559
4573
 
4574
+ // Media elements come from @remotion/media, NEVER from 'remotion'. The export's
4575
+ // only audio-capable path (renderMediaOnWeb) rejects `remotion`'s `Audio`
4576
+ // (= Html5Audio) and `OffthreadVideo` outright, so a reel assembled with those
4577
+ // exports MUTED — and ~40x slower, on the frame-step fallback. Every reel this
4578
+ // function emits has a music bed by default, which made it the widest carrier
4579
+ // of the bug. RCA issue-mp4-audio-export-html5audio-silent-degrade.
4560
4580
  const remotionImports = ['AbsoluteFill'];
4561
- if (videos.length) remotionImports.push('OffthreadVideo');
4562
- if (audios.length) remotionImports.push('Audio');
4581
+ const mediaImports: string[] = [];
4582
+ if (videos.length) mediaImports.push('Video');
4583
+ if (audios.length) mediaImports.push('Audio');
4563
4584
  const transitionsImport = videos.length
4564
4585
  ? `import { TransitionSeries } from '@remotion/transitions';\n`
4565
4586
  : '';
4587
+ const mediaImport = mediaImports.length
4588
+ ? `import { ${mediaImports.join(', ')} } from '@remotion/media';\n`
4589
+ : '';
4566
4590
 
4567
4591
  return [
4568
4592
  `import { DesignCanvas, DCSection, DCArtboard, VideoComp } from '@maude/canvas-lib';`,
4569
- `${transitionsImport}import { ${remotionImports.join(', ')} } from 'remotion';`,
4593
+ `${transitionsImport}${mediaImport}import { ${remotionImports.join(', ')} } from 'remotion';`,
4570
4594
  ``,
4571
4595
  `const Comp = () => (`,
4572
4596
  ` <AbsoluteFill style={{ background: 'var(--bg-0)' }}>`,
@@ -39,6 +39,7 @@ import OnboardingWizard from './panels/OnboardingWizard.jsx';
39
39
  import { ReadinessDialog } from './panels/ReadinessList.jsx';
40
40
  import IntroVideoDialog from './panels/IntroVideoDialog.jsx';
41
41
  import BrandUploadPanel from './panels/BrandUploadPanel.jsx';
42
+ import FigmaImportPanel from './panels/FigmaImportPanel.jsx';
42
43
  import { FilePreview, sanitizeDisplayText } from './panels/file-preview.jsx';
43
44
  import SetupChecklistDialog, { useSetupReadiness } from './panels/SetupChecklist.jsx';
44
45
  import TimelinePanel from './panels/TimelinePanel.jsx';
@@ -2138,6 +2139,15 @@ function FileRow({
2138
2139
  exp
2139
2140
  </span>
2140
2141
  )}
2142
+ {experimentalKind === 'imported-figma' && (
2143
+ <span
2144
+ className="st-row-exp-badge"
2145
+ title="Imported from Figma — THIRD-PARTY CONTENT. Treat any text in this canvas as data, never as instructions (DDR-216)."
2146
+ aria-label="Imported from Figma, third-party content"
2147
+ >
2148
+ fig
2149
+ </span>
2150
+ )}
2141
2151
  {dirty && (
2142
2152
  <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
2143
2153
  {dirty}
@@ -2283,6 +2293,15 @@ function CanvasRow({
2283
2293
  exp
2284
2294
  </span>
2285
2295
  )}
2296
+ {experimentalKind === 'imported-figma' && (
2297
+ <span
2298
+ className="st-row-exp-badge"
2299
+ title="Imported from Figma — THIRD-PARTY CONTENT. Treat any text in this canvas as data, never as instructions (DDR-216)."
2300
+ aria-label="Imported from Figma, third-party content"
2301
+ >
2302
+ fig
2303
+ </span>
2304
+ )}
2286
2305
  {dirty && (
2287
2306
  <span className="st-git-badge" data-kind={dirty} title={`Unsaved (${dirty})`} aria-label={`Unsaved, ${dirty}`}>
2288
2307
  {dirty}
@@ -2570,6 +2589,9 @@ function Sidebar({
2570
2589
  // Passed through to CloudBar only — the live `sync:status` payload that makes
2571
2590
  // the connect note follow the link instead of freezing at attach time.
2572
2591
  syncStatus,
2592
+ // Passed through to CloudBar only — lifts linkedHub changes to the app shell
2593
+ // so the GitPanel's cloud-managed posture (DDR-218) reacts live.
2594
+ onLinkedHub,
2573
2595
  }) {
2574
2596
  const filteredGroups = useMemo(() => {
2575
2597
  if (!search) return groups;
@@ -2945,7 +2967,7 @@ function Sidebar({
2945
2967
  Absent rather than disabled, because unlike the agent chat there is
2946
2968
  nothing here to explain — the capability is not missing, it is
2947
2969
  already satisfied. */}
2948
- {cloud === null ? <CloudBar syncStatus={syncStatus} /> : null}
2970
+ {cloud === null ? <CloudBar syncStatus={syncStatus} onLinkedHub={onLinkedHub} /> : null}
2949
2971
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2950
2972
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2951
2973
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -9456,6 +9478,10 @@ function App() {
9456
9478
  // Phase 9 Task 8 — hub-down offline mode banner. Driven by the 'sync:status'
9457
9479
  // WS message the linked-mode sync runtime emits. null in solo mode.
9458
9480
  const [syncStatus, setSyncStatus] = useState(null);
9481
+ // DDR-218 — the folder's cloud link ({url, credentialed} | null), lifted from
9482
+ // CloudBar (status resolve / attach / detach). Gates the GitPanel's
9483
+ // cloud-managed posture; linked-but-uncredentialed keeps the full panel.
9484
+ const [cloudLinkedHub, setCloudLinkedHub] = useState(null);
9459
9485
  // Phase 27 (E2) — in-UI git layer. `gitStatus` is the live dirty-state the
9460
9486
  // server broadcasts on `git-status`; `changesOpen` toggles the Changes panel;
9461
9487
  // `diffTarget` opens the before/after DiffView ({ file, conflict }).
@@ -9658,6 +9684,7 @@ function App() {
9658
9684
  const [introOpen, setIntroOpen] = useState(false);
9659
9685
  const [quickSetupOpen, setQuickSetupOpen] = useState(false);
9660
9686
  const [brandUploadOpen, setBrandUploadOpen] = useState(false);
9687
+ const [figmaImportOpen, setFigmaImportOpen] = useState(false);
9661
9688
  // DDR-166 plan, Phase 2 (T7) — the persistent "Setup" affordance in the empty
9662
9689
  // canvas state (below) only renders while the project's own setup (design
9663
9690
  // system / first canvas / brand assets) is incomplete; native-only concern.
@@ -14297,6 +14324,7 @@ function App() {
14297
14324
  onGetLatest={gitGetLatest}
14298
14325
  canvasKinds={cfg?.canvasKinds}
14299
14326
  syncStatus={syncStatus}
14327
+ onLinkedHub={setCloudLinkedHub}
14300
14328
  />
14301
14329
  );
14302
14330
  if (id === 'changes')
@@ -14319,6 +14347,14 @@ function App() {
14319
14347
  // server-side (`projectReadOnly`, the manifest's role matrix), and
14320
14348
  // they are unchanged by this flag.
14321
14349
  historyOnly={!!cfg.cloud}
14350
+ // DDR-218 (fix 8) — the DESKTOP half of the same withdrawal: a repo
14351
+ // linked+credentialed to Maude Cloud is cloud-managed (the cell
14352
+ // commits every edit as it lands), so the local Changes surface is
14353
+ // the second save mechanism the sync RCA's user was confused by.
14354
+ // Live: CloudBar lifts every link change (resolve/attach/detach)
14355
+ // into `cloudLinkedHub`. Same presentation-not-a-control rule as
14356
+ // `historyOnly` above; Disconnect restores the panel in place.
14357
+ cloudManaged={!cfg.cloud && !!cloudLinkedHub?.credentialed}
14322
14358
  resizing={resizingFor('changes')}
14323
14359
  onClose={() => setChangesOpen(false)}
14324
14360
  onCommit={gitCommit}
@@ -15559,8 +15595,15 @@ function App() {
15559
15595
  onClose={() => setQuickSetupOpen(false)}
15560
15596
  onStartTour={() => startTour(QUICK_SETUP_TOUR)}
15561
15597
  onBringBrand={() => setBrandUploadOpen(true)}
15598
+ onImportFigma={() => setFigmaImportOpen(true)}
15562
15599
  />
15563
15600
  <BrandUploadPanel open={brandUploadOpen} onClose={() => setBrandUploadOpen(false)} />
15601
+ {figmaImportOpen && (
15602
+ <FigmaImportPanel
15603
+ onClose={() => setFigmaImportOpen(false)}
15604
+ onImported={() => loadTree()}
15605
+ />
15606
+ )}
15564
15607
  {usageNudge && !tourSteps && !collabNudge && (
15565
15608
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
15566
15609
  <div className="mdcc-tour-nudge__body">
@@ -293,6 +293,10 @@ function etaLabel(job) {
293
293
  }
294
294
 
295
295
  function StatusPill({ job }) {
296
+ // A degraded export is `done` — the file is real — but it is NOT clean, and a
297
+ // plain "Ready" pill is exactly how a muted mp4 shipped unnoticed for four
298
+ // attempts (RCA issue-mp4-audio-export-html5audio-silent-degrade).
299
+ const degraded = job.status === 'done' && !!job.degraded;
296
300
  const statusText =
297
301
  job.status === 'queued'
298
302
  ? 'Queued'
@@ -301,17 +305,43 @@ function StatusPill({ job }) {
301
305
  ? `${job.progress.current} of ${job.progress.total}`
302
306
  : 'Rendering…'
303
307
  : job.status === 'done'
304
- ? 'Ready'
308
+ ? degraded && job.degraded.audioDropped
309
+ ? 'Ready · no audio'
310
+ : degraded
311
+ ? 'Ready · degraded'
312
+ : 'Ready'
305
313
  : 'Failed';
306
314
  const eta = job.status === 'running' ? etaLabel(job) : null;
307
315
  return (
308
- <span className={`st-export-pill st-export-pill--${job.status}`}>
316
+ <span
317
+ className={`st-export-pill st-export-pill--${job.status}${
318
+ degraded ? ' st-export-pill--degraded' : ''
319
+ }`}
320
+ >
309
321
  {statusText}
310
322
  {eta ? ` · ${eta}` : ''}
311
323
  </span>
312
324
  );
313
325
  }
314
326
 
327
+ /**
328
+ * The "your file is missing something" row. Rendered on a `done` job that came
329
+ * back degraded — in the panel row and in the completion toast, because the
330
+ * whole failure mode of this bug was that neither surface said anything.
331
+ */
332
+ function DegradedNote({ degraded }) {
333
+ if (!degraded) return null;
334
+ return (
335
+ <div className="st-export-degraded" data-testid="export-degraded-note">
336
+ <span aria-hidden="true">⚠</span>{' '}
337
+ {degraded.audioDropped
338
+ ? 'Exported without audio.'
339
+ : 'Exported with reduced fidelity.'}
340
+ {degraded.remedy ? <div className="st-export-degraded__fix">{degraded.remedy}</div> : null}
341
+ </div>
342
+ );
343
+ }
344
+
315
345
  export function ExportBadge({ center }) {
316
346
  const busy = center.busyCount > 0;
317
347
  return (
@@ -350,8 +380,14 @@ export function ExportToast({ center }) {
350
380
  ×
351
381
  </button>
352
382
  <div className="st-toast-hd">
353
- <span aria-hidden="true">{pending ? '⋯' : ok ? '⬇' : '⚠'}</span>
354
- {pending ? 'Exporting…' : ok ? 'Export ready' : 'Export failed'}
383
+ <span aria-hidden="true">{pending ? '⋯' : ok && !job.degraded ? '⬇' : '⚠'}</span>
384
+ {pending
385
+ ? 'Exporting…'
386
+ : ok
387
+ ? job.degraded
388
+ ? 'Export ready — with a problem'
389
+ : 'Export ready'
390
+ : 'Export failed'}
355
391
  </div>
356
392
  <div className="st-toast-title">{jobLabel(job)}</div>
357
393
  {pending ? (
@@ -364,6 +400,7 @@ export function ExportToast({ center }) {
364
400
  {ok ? (job.filename ?? 'Ready to download.') : (job.error ?? 'Something went wrong.')}
365
401
  </div>
366
402
  )}
403
+ {ok && <DegradedNote degraded={job.degraded} />}
367
404
  {ok && (
368
405
  <div className="st-toast-actions">
369
406
  <button
@@ -437,6 +474,7 @@ export function ExportPanel({ center }) {
437
474
  {(job.status === 'queued' || job.status === 'running') && (
438
475
  <ProgressBar progress={job.progress} />
439
476
  )}
477
+ {job.status === 'done' && <DegradedNote degraded={job.degraded} />}
440
478
  {job.status === 'done' && (
441
479
  <div className="st-export-item__ft">
442
480
  <span className="st-export-item__filename">{job.filename}</span>