@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
@@ -80,7 +80,7 @@ function StatusIcon({ present }) {
80
80
  );
81
81
  }
82
82
 
83
- export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand }) {
83
+ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringBrand, onImportFigma }) {
84
84
  return (
85
85
  <div className="setup-cl">
86
86
  <ul className="setup-cl-list">
@@ -131,6 +131,16 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
131
131
  Bring my existing brand
132
132
  </a>
133
133
  )}
134
+ {onImportFigma && (
135
+ <button
136
+ type="button"
137
+ data-testid="onboarding-import-figma"
138
+ className="btn btn--ghost btn--sm"
139
+ onClick={onImportFigma}
140
+ >
141
+ Import from Figma
142
+ </button>
143
+ )}
134
144
  {refresh && (
135
145
  <button
136
146
  type="button"
@@ -148,7 +158,13 @@ export function SetupChecklist({ report, loading, refresh, onStartTour, onBringB
148
158
 
149
159
  // Standalone modal, reachable from Help ▸ Quick setup… — reuses the shared
150
160
  // help-modal chrome (backdrop + header + body), same shape as ReadinessDialog.
151
- export default function SetupChecklistDialog({ open, onClose, onStartTour, onBringBrand }) {
161
+ export default function SetupChecklistDialog({
162
+ open,
163
+ onClose,
164
+ onStartTour,
165
+ onBringBrand,
166
+ onImportFigma,
167
+ }) {
152
168
  const { report, loading, refresh } = useSetupReadiness(open);
153
169
  useEffect(() => {
154
170
  if (!open) return undefined;
@@ -210,6 +226,14 @@ export default function SetupChecklistDialog({ open, onClose, onStartTour, onBri
210
226
  }
211
227
  : undefined
212
228
  }
229
+ onImportFigma={
230
+ onImportFigma
231
+ ? () => {
232
+ onClose();
233
+ onImportFigma();
234
+ }
235
+ : undefined
236
+ }
213
237
  />
214
238
  ) : (
215
239
  <p className="setup-cl-note">
@@ -44,7 +44,7 @@ import { ContextMenuView } from '../../context-menu.tsx';
44
44
  function rowKind(seq) {
45
45
  if (seq.placeholder) return 'placeholder';
46
46
  const t = seq.mediaTag;
47
- if (t === 'Video' || t === 'OffthreadVideo') return 'video';
47
+ if (t === 'Video' || t === 'OffthreadVideo' || t === 'MaudeVideo') return 'video';
48
48
  if (t === 'Img') return 'image';
49
49
  if (t === 'Audio') return 'audio';
50
50
  return 'jsx';
@@ -981,7 +981,7 @@ export default function TimelinePanel({
981
981
  // bare tag name as the clip's identity — "Clip N" beats "bca08b1e.mov";
982
982
  // the real src stays in the tooltip.
983
983
  const rawName =
984
- ['Video', 'OffthreadVideo', 'Audio', 'Img'].includes(seq.label) && seq.mediaSrc
984
+ ['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg'].includes(seq.label) && seq.mediaSrc
985
985
  ? String(seq.mediaSrc).split('/').pop()
986
986
  : seq.label;
987
987
  const isHashName = /^[a-f0-9]{6,16}\.\w+$/i.test(rawName || '');
@@ -123,7 +123,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
123
123
  if (usages.length) {
124
124
  const target =
125
125
  (selectedArtboardId && usages.find((u) => u.artboardId === selectedArtboardId)) ||
126
- usages.find((u) => /<(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
126
+ usages.find((u) => /<(?:Maude)?(?:Audio|OffthreadVideo|Video)\b/.test(u.body)) ||
127
127
  usages.find((u) => u.duration != null && u.duration === totalFrames) ||
128
128
  usages[0];
129
129
  scope = target.body;
@@ -162,7 +162,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
162
162
  const lm = after.match(/<([A-Z][A-Za-z0-9]*)\b/);
163
163
  // Media child sniff (badge + replace affordance): the first
164
164
  // <Video|OffthreadVideo|Audio|Img src="…"> DIRECTLY inside the clip body.
165
- const mm = after.match(/<(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
165
+ const mm = after.match(/<(?:Maude)?(Video|OffthreadVideo|Audio|Img)\b[^>]*src=["']([^"']+)["']/);
166
166
  const nameM = attrs.match(/name=["']([^"']+)["']/);
167
167
  items.push({
168
168
  type: 'seq',
@@ -241,7 +241,7 @@ export function parseCompTimeline(source, totalFrames, selectedArtboardId) {
241
241
  // every clip span. Remotion renders it from frame 0 for the whole comp, so
242
242
  // show it as a read-only overlay row (`loose: true` — no clip ops; it has no
243
243
  // enumerator identity). Dogfood 2026-07-30: these were invisible before.
244
- for (const lm of scope.matchAll(/<(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
244
+ for (const lm of scope.matchAll(/<(?:Maude)?(OffthreadVideo|Video|Img)\b([^>]*?)\/?>/g)) {
245
245
  const at = lm.index ?? 0;
246
246
  if (clipSpans.some(([s2, e2]) => at >= s2 && at <= e2)) continue;
247
247
  const srcM = lm[2].match(/src=["']([^"']+)["']/);
@@ -2120,6 +2120,9 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2120
2120
  .gp-save { flex: 1; justify-content: center; }
2121
2121
  .gp-save:disabled, .gp-save[aria-disabled="true"], .gp-publish:disabled, .gp-publish[aria-disabled="true"] { background: var(--bg-3); color: var(--fg-3); border-color: var(--border-default); cursor: not-allowed; }
2122
2122
  .gp-hint { font-size: var(--type-xs); color: var(--fg-2); }
2123
+ /* cloud-managed posture (DDR-218) — the one save mechanism, named */
2124
+ .gp-cloud-note { display: flex; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-1) 0; font-size: var(--type-xs); color: var(--fg-2); line-height: var(--lh-base); }
2125
+ .gp-cloud-note svg { color: var(--accent); flex: 0 0 auto; }
2123
2126
 
2124
2127
  /* publish / get-latest bar */
2125
2128
  .gp-publishbar { flex: none; border-top: 1px solid var(--border-default); padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); background: var(--bg-1); }
@@ -2318,6 +2321,10 @@ body.st-scrubbing, body.st-scrubbing * { cursor: ew-resize !important; user-sele
2318
2321
  .gi-menu-item:hover { background: var(--bg-2); color: var(--fg-0); }
2319
2322
  .gi-menu-item:hover svg { color: var(--fg-1); }
2320
2323
  .gi-menu-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
2324
+ /* the already-linked project's row — a state, not an action (fix 7, sync RCA) */
2325
+ .gi-menu-item.is-connected { cursor: default; }
2326
+ .gi-menu-item.is-connected:hover { background: transparent; color: var(--fg-1); }
2327
+ .gi-menu-item.is-connected:hover svg { color: var(--fg-2); }
2321
2328
  .gi-menu-item--danger, .gi-menu-item--danger svg { color: var(--status-error); }
2322
2329
  .gi-menu-item--danger:hover { background: color-mix(in oklab, var(--status-error) 14%, transparent); color: var(--status-error); }
2323
2330
  .gi-menu-sep { height: 1px; background: var(--border-subtle); margin: var(--space-1) 0; }
@@ -1399,6 +1399,27 @@
1399
1399
  background: color-mix(in oklab, var(--u-status-error) 18%, transparent);
1400
1400
  color: var(--u-status-error);
1401
1401
  }
1402
+ /* A degraded export is `done` but not clean — it must never read as success.
1403
+ Overrides --done, so declaration order matters. */
1404
+ .st-export-pill--degraded {
1405
+ background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 18%, transparent);
1406
+ color: var(--u-status-warning, var(--u-accent));
1407
+ }
1408
+ .st-export-degraded {
1409
+ font-size: 12px;
1410
+ line-height: 1.45;
1411
+ color: var(--u-status-warning, var(--u-accent));
1412
+ padding: 6px 8px;
1413
+ border-radius: var(--radius-sm, 6px);
1414
+ background: color-mix(in oklab, var(--u-status-warning, var(--u-accent)) 10%, transparent);
1415
+ }
1416
+ .st-export-degraded__fix {
1417
+ margin-top: 4px;
1418
+ color: var(--u-fg-2);
1419
+ font-family: var(--font-mono, ui-monospace, monospace);
1420
+ font-size: 11px;
1421
+ overflow-wrap: anywhere;
1422
+ }
1402
1423
  .st-export-progress {
1403
1424
  position: relative;
1404
1425
  height: 4px;
@@ -2023,3 +2044,116 @@
2023
2044
  color: var(--u-fg-0);
2024
2045
  background: var(--u-bg-2);
2025
2046
  }
2047
+
2048
+ /* ── Figma import panel (DDR-216 / T11) ─────────────────────────────────────
2049
+ * Rides the shared .st-scrim / .st-dialog shell (3-shell-maude.css). Tokens
2050
+ * only — no hardcoded colour, and no inline `style=` in the JSX either, because
2051
+ * the canvas CSP (`style-src 'self'`) drops inline styles SILENTLY. */
2052
+ .st-figma-dialog {
2053
+ width: min(560px, 92vw);
2054
+ padding-bottom: var(--space-4);
2055
+ }
2056
+ .st-figma-label {
2057
+ display: block;
2058
+ padding: var(--space-3) var(--space-5) 4px;
2059
+ font-size: var(--type-xs, 12px);
2060
+ color: var(--fg-2);
2061
+ }
2062
+ .st-figma-input {
2063
+ display: block;
2064
+ width: calc(100% - (2 * var(--space-5)));
2065
+ margin: 0 var(--space-5);
2066
+ padding: 8px 10px;
2067
+ border: 1px solid var(--border-default);
2068
+ border-radius: var(--radius-sm);
2069
+ background: var(--bg-1);
2070
+ color: var(--fg-0);
2071
+ font-family: var(--font-mono);
2072
+ font-size: var(--type-sm, 13px);
2073
+ }
2074
+ .st-figma-modes {
2075
+ border: 0;
2076
+ margin: 0;
2077
+ padding: 0 var(--space-5);
2078
+ }
2079
+ .st-figma-mode {
2080
+ display: flex;
2081
+ align-items: baseline;
2082
+ gap: 8px;
2083
+ padding: 4px 0;
2084
+ font-size: var(--type-sm, 13px);
2085
+ color: var(--fg-1);
2086
+ }
2087
+ .st-figma-mode-hint {
2088
+ color: var(--fg-3);
2089
+ font-size: var(--type-xs, 12px);
2090
+ }
2091
+ .st-figma-actions {
2092
+ display: flex;
2093
+ justify-content: flex-end;
2094
+ gap: 8px;
2095
+ padding: var(--space-4) var(--space-5) 0;
2096
+ }
2097
+ .st-figma-notice {
2098
+ margin: var(--space-3) var(--space-5) 0;
2099
+ padding: 8px 10px;
2100
+ border: 1px solid var(--border-subtle);
2101
+ border-radius: var(--radius-sm);
2102
+ background: var(--bg-2);
2103
+ color: var(--fg-2);
2104
+ font-size: var(--type-xs, 12px);
2105
+ }
2106
+ /* The third-party-content banner. Deliberately reads as a caution, not as a
2107
+ * provenance badge — an "imported from Figma" stamp otherwise makes the most
2108
+ * attacker-influenced artifact in the tree look like verified design work. */
2109
+ .st-figma-notice-warn {
2110
+ border-color: var(--status-warn, var(--border-default));
2111
+ color: var(--fg-1);
2112
+ }
2113
+ .st-figma-error {
2114
+ margin: var(--space-3) var(--space-5) 0;
2115
+ color: var(--status-danger, var(--fg-0));
2116
+ font-size: var(--type-xs, 12px);
2117
+ }
2118
+ .st-figma-summary {
2119
+ margin: var(--space-3) var(--space-5) 0;
2120
+ padding: 10px;
2121
+ border: 1px solid var(--border-subtle);
2122
+ border-radius: var(--radius-sm);
2123
+ background: var(--bg-2);
2124
+ font-size: var(--type-xs, 12px);
2125
+ color: var(--fg-2);
2126
+ }
2127
+ .st-figma-summary-hd {
2128
+ color: var(--fg-1);
2129
+ font-weight: 600;
2130
+ margin-bottom: 6px;
2131
+ }
2132
+ .st-figma-summary-row {
2133
+ margin: var(--space-2) var(--space-5) 0;
2134
+ font-size: var(--type-xs, 12px);
2135
+ color: var(--fg-2);
2136
+ }
2137
+ .st-figma-summary .st-figma-summary-row {
2138
+ margin: 4px 0 0;
2139
+ }
2140
+ .st-figma-summary-list {
2141
+ list-style: none;
2142
+ margin: 0;
2143
+ padding: 0;
2144
+ display: grid;
2145
+ gap: 4px;
2146
+ }
2147
+ .st-figma-summary-kind {
2148
+ color: var(--fg-1);
2149
+ }
2150
+ .st-figma-summary-count {
2151
+ font-family: var(--font-mono);
2152
+ color: var(--fg-2);
2153
+ }
2154
+ .st-figma-summary-ids {
2155
+ display: block;
2156
+ font-family: var(--font-mono);
2157
+ color: var(--fg-3);
2158
+ overflow-wrap: anywhere;
2159
+ }
@@ -341,16 +341,24 @@ export function applyInsertClipAt(
341
341
  }
342
342
  const dur = Math.max(1, Math.round(opts.durationInFrames));
343
343
  const srcTrim = (opts.src ?? '').trim();
344
- const media =
344
+ const media: { tag: string; src: string; importName: string; specifier: string } | null =
345
345
  opts.mediaTag && INSERTABLE_MEDIA.has(opts.mediaTag) && srcTrim
346
- ? { tag: opts.mediaTag, src: srcTrim }
346
+ ? { tag: opts.mediaTag, src: srcTrim, importName: opts.mediaTag, specifier: 'remotion' }
347
347
  : null;
348
348
  if (media) {
349
349
  assertContainedSrc(canvasAbsPath, media.src, artboardId ?? '');
350
- // House spelling: <OffthreadVideo> for video (frame-accurate export, and it
351
- // can never collide with a canvas component named `Video` — the brief-board
352
- // upgrade bug where the emitted tag resolved to the canvas itself).
353
- if (media.tag === 'Video') media.tag = 'OffthreadVideo';
350
+ // This used to rewrite <Video> → <OffthreadVideo> as "house spelling", to
351
+ // dodge a collision with a canvas component named `Video`. That trade was
352
+ // wrong in one direction nobody noticed: <OffthreadVideo> is rejected by
353
+ // @remotion/web-renderer, so the editor was silently converting the
354
+ // audio-capable element into the audio-killing one behind the author's
355
+ // back — every inserted clip exported muted (RCA
356
+ // issue-mp4-audio-export-html5audio-silent-degrade). The collision is real,
357
+ // so it is now solved where it belongs: in the import (see resolveMediaTag).
358
+ const resolved = resolveMediaTag(source, media.tag);
359
+ media.tag = resolved.emitTag;
360
+ media.importName = resolved.importName;
361
+ media.specifier = resolved.specifier;
354
362
  }
355
363
 
356
364
  const seqs = cc.clips.filter((c) => c.kind === 'sequence');
@@ -403,7 +411,7 @@ export function applyInsertClipAt(
403
411
  );
404
412
  ensureImportPublic(s2, source, 'TransitionSeries', '@remotion/transitions');
405
413
  if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
406
- if (media) ensureNamedImport(s2, source, media.tag, 'remotion');
414
+ if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
407
415
  const out2 = s2.toString();
408
416
  assertParses(canvasAbsPath, out2, artboardId ?? '');
409
417
  assertCompSemantics(canvasAbsPath, out2);
@@ -426,7 +434,7 @@ export function applyInsertClipAt(
426
434
  ? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
427
435
  : '';
428
436
  if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
429
- if (media) ensureNamedImport(s, source, media.tag, 'remotion');
437
+ if (media) ensureNamedImport(s, source, media.importName, media.specifier);
430
438
  const beatText = `<${seriesPrefix}.Sequence durationInFrames={${dur}}>${mediaText}</${seriesPrefix}.Sequence>`;
431
439
  if (index === 0) {
432
440
  // Head insert: B (T) before the first beat.
@@ -514,7 +522,7 @@ export function applyInsertClipAt(
514
522
  ? `\n${indent} <${media.tag} src="${escapeAttr(media.src)}" />\n${indent}`
515
523
  : '';
516
524
  if (opts.placeholder) ensureNamedImport(s, source, 'AIPlaceholder', '@maude/canvas-lib');
517
- if (media) ensureNamedImport(s, source, media.tag, 'remotion');
525
+ if (media) ensureNamedImport(s, source, media.importName, media.specifier);
518
526
  if (titleText) ensureNamedImport(s, source, 'AbsoluteFill', 'remotion');
519
527
  ensureNamedImport(s, source, 'Sequence', 'remotion');
520
528
  const clipText = `\n${indent}<Sequence from={${from}} durationInFrames={${dur}}>${mediaText}</Sequence>`;
@@ -549,7 +557,7 @@ export function applyInsertClipAt(
549
557
  s2.appendLeft(openStart, '<>');
550
558
  s2.appendRight(insertAt, `${clipText}\n${indent}</>`);
551
559
  if (opts.placeholder) ensureNamedImport(s2, source, 'AIPlaceholder', '@maude/canvas-lib');
552
- if (media) ensureNamedImport(s2, source, media.tag, 'remotion');
560
+ if (media) ensureNamedImport(s2, source, media.importName, media.specifier);
553
561
  if (titleText) ensureNamedImport(s2, source, 'AbsoluteFill', 'remotion');
554
562
  ensureNamedImport(s2, source, 'Sequence', 'remotion');
555
563
  out = s2.toString();
@@ -675,7 +683,7 @@ function setTagAttr(
675
683
  function directMediaTagStart(
676
684
  source: string,
677
685
  clip: ClipInfo,
678
- tags = ['Video', 'OffthreadVideo', 'Audio', 'Img']
686
+ tags = ['Video', 'OffthreadVideo', 'Audio', 'Img', 'MaudeVideo', 'MaudeAudio', 'MaudeImg']
679
687
  ): number | null {
680
688
  const span = source.slice(clip.start, clip.end);
681
689
  const m = span.match(new RegExp(`<(${tags.join('|')})\\b`));
@@ -737,7 +745,13 @@ export function applySetPlaybackRate(
737
745
  }
738
746
  const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
739
747
  const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
740
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
748
+ const mediaStart = directMediaTagStart(source, clip, [
749
+ 'Video',
750
+ 'OffthreadVideo',
751
+ 'Audio',
752
+ 'MaudeVideo',
753
+ 'MaudeAudio',
754
+ ]);
741
755
  if (mediaStart == null) {
742
756
  throw new CanvasEditError(
743
757
  `clip "${stableId}" has no direct media element — speed applies to <Video>/<Audio> clips (shared/wrapper media would change every user)`,
@@ -780,7 +794,13 @@ export function applyTrimIn(
780
794
  const delta = Math.round(deltaFrames);
781
795
  const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
782
796
  const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
783
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
797
+ const mediaStart = directMediaTagStart(source, clip, [
798
+ 'Video',
799
+ 'OffthreadVideo',
800
+ 'Audio',
801
+ 'MaudeVideo',
802
+ 'MaudeAudio',
803
+ ]);
784
804
  if (mediaStart == null) {
785
805
  throw new CanvasEditError(
786
806
  `clip "${stableId}" has no direct media element — in-point trim applies to media clips`,
@@ -843,7 +863,13 @@ export function applyClipAudio(
843
863
  ): { source: string } {
844
864
  const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
845
865
  const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
846
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Audio']);
866
+ const mediaStart = directMediaTagStart(source, clip, [
867
+ 'Video',
868
+ 'OffthreadVideo',
869
+ 'Audio',
870
+ 'MaudeVideo',
871
+ 'MaudeAudio',
872
+ ]);
847
873
  if (mediaStart == null) {
848
874
  throw new CanvasEditError(`clip "${stableId}" has no direct media element to set audio on`, {
849
875
  canvas: canvasAbsPath,
@@ -882,7 +908,7 @@ export function applyDetachAudio(
882
908
  ): { source: string; audioStableId: string | null } {
883
909
  const cc = enumerateClips(canvasAbsPath, source, artboardId);
884
910
  const { clip } = mustResolve(canvasAbsPath, cc.clips, stableId, expectedHash);
885
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo']);
911
+ const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'MaudeVideo']);
886
912
  if (mediaStart == null || !clip.mediaSrc) {
887
913
  throw new CanvasEditError(
888
914
  `clip "${stableId}" has no direct video media with a literal src to detach`,
@@ -1038,7 +1064,13 @@ export function applyClipGrade(
1038
1064
  ): { source: string; filter: string } {
1039
1065
  const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1040
1066
  const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1041
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Img']);
1067
+ const mediaStart = directMediaTagStart(source, clip, [
1068
+ 'Video',
1069
+ 'OffthreadVideo',
1070
+ 'Img',
1071
+ 'MaudeVideo',
1072
+ 'MaudeImg',
1073
+ ]);
1042
1074
  if (mediaStart == null) {
1043
1075
  throw new CanvasEditError(`clip "${stableId}" has no direct media element to grade`, {
1044
1076
  canvas: canvasAbsPath,
@@ -1096,7 +1128,13 @@ export function applyClipFraming(
1096
1128
  ): { source: string } {
1097
1129
  const { clips } = enumerateClips(canvasAbsPath, source, artboardId);
1098
1130
  const { clip } = mustResolve(canvasAbsPath, clips, stableId, expectedHash);
1099
- const mediaStart = directMediaTagStart(source, clip, ['Video', 'OffthreadVideo', 'Img']);
1131
+ const mediaStart = directMediaTagStart(source, clip, [
1132
+ 'Video',
1133
+ 'OffthreadVideo',
1134
+ 'Img',
1135
+ 'MaudeVideo',
1136
+ 'MaudeImg',
1137
+ ]);
1100
1138
  if (mediaStart == null) {
1101
1139
  throw new CanvasEditError(`clip "${stableId}" has no direct media element to frame`, {
1102
1140
  canvas: canvasAbsPath,
@@ -1410,6 +1448,44 @@ export function applySplitClip(
1410
1448
 
1411
1449
  /** Ensure `name` is among the named imports from `specifier` (extends an
1412
1450
  * existing import line, else adds one after the last import). */
1451
+ /**
1452
+ * Where an inserted media element must come from.
1453
+ *
1454
+ * `Video` and `Audio` MUST resolve to `@remotion/media` — those are the only
1455
+ * media elements `@remotion/web-renderer` can render, and importing them from
1456
+ * `remotion` (where `Audio` IS `Html5Audio`) makes the export drop its audio
1457
+ * track and fall back to a ~40× slower capture. RCA
1458
+ * `issue-mp4-audio-export-html5audio-silent-degrade`.
1459
+ */
1460
+ const MEDIA_SPECIFIER: Record<string, string> = {
1461
+ Video: '@remotion/media',
1462
+ Audio: '@remotion/media',
1463
+ Img: 'remotion',
1464
+ OffthreadVideo: 'remotion',
1465
+ };
1466
+
1467
+ /**
1468
+ * Import an inserted media element from the right package, aliasing it when the
1469
+ * canvas already declares a component of that name.
1470
+ *
1471
+ * The collision is real — a canvas with its own `const Video = …` would have the
1472
+ * emitted `<Video>` resolve to itself. The old fix was to emit
1473
+ * `<OffthreadVideo>` instead, which silently cost the audio track. Aliasing
1474
+ * keeps the collision solved AND the audio intact. Returns the tag to emit.
1475
+ */
1476
+ function resolveMediaTag(
1477
+ source: string,
1478
+ tag: string
1479
+ ): { emitTag: string; importName: string; specifier: string } {
1480
+ const specifier = MEDIA_SPECIFIER[tag] ?? 'remotion';
1481
+ const declaresLocally = new RegExp(
1482
+ `(?:const|let|var|function|class)\\s+${tag}\\b|\\b${tag}\\s*[:=]\\s*\\(`
1483
+ ).test(source);
1484
+ if (!declaresLocally) return { emitTag: tag, importName: tag, specifier };
1485
+ const alias = `Maude${tag}`;
1486
+ return { emitTag: alias, importName: `${tag} as ${alias}`, specifier };
1487
+ }
1488
+
1413
1489
  function ensureNamedImport(s: MagicString, source: string, name: string, specifier: string): void {
1414
1490
  const esc = specifier.replace(/[/]/g, '\\/');
1415
1491
  const line = source.match(new RegExp(`import \\{([^}]*)\\} from ['"]${esc}['"]`));
@@ -15,11 +15,19 @@
15
15
  // user token → saveHubCredential() + linkedHub in config.json —
16
16
  // the exact state `maude design link` would have written by hand.
17
17
 
18
- import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
18
+ import {
19
+ chmodSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from 'node:fs';
19
27
  import { homedir } from 'node:os';
20
28
  import { basename, dirname, join } from 'node:path';
21
29
 
22
- import { saveHubCredential } from '../sync/hub-link.ts';
30
+ import { deleteHubCredential, saveHubCredential } from '../sync/hub-link.ts';
23
31
  import { getHubToken, normalizeUrl } from '../sync/hubs-config.ts';
24
32
 
25
33
  /**
@@ -116,11 +124,14 @@ interface Ctx {
116
124
  * syncing after the next start.
117
125
  */
118
126
  syncControl?: {
119
- restart(linkedHub?: {
120
- url: string;
121
- linkedAt: number;
122
- syncTsx?: boolean;
123
- }): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
127
+ /** `null` = unlink: clear the runtime's in-memory link, cycle to solo. */
128
+ restart(
129
+ linkedHub?: {
130
+ url: string;
131
+ linkedAt: number;
132
+ syncTsx?: boolean;
133
+ } | null
134
+ ): Promise<{ syncing: boolean; canvases: number; reason?: string; detail?: string }>;
124
135
  };
125
136
  }
126
137
 
@@ -319,6 +330,50 @@ export function createCloudEndpoints(ctx: Ctx) {
319
330
  project: exchanged.body.project,
320
331
  });
321
332
  },
333
+
334
+ /**
335
+ * Detach THIS project from its workspace — the in-app `maude design
336
+ * unlink` (fix 7's Disconnect, sync RCA 2026-08-10). Drops the committed
337
+ * `linkedHub` AND the stored hub credential for that address (the CLI
338
+ * default), then cycles the runtime back to solo so syncing stops NOW
339
+ * rather than at the next restart. Idempotent: disconnecting an unlinked
340
+ * project answers ok, not an error.
341
+ */
342
+ async detach(): Promise<CloudEndpointResult> {
343
+ const cfgPath = join(ctx.paths.designRoot, 'config.json');
344
+ let cfg: Record<string, unknown> = {};
345
+ try {
346
+ cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
347
+ } catch {
348
+ /* absent/malformed → nothing linked */
349
+ }
350
+ const linked = (cfg as { linkedHub?: { url?: unknown } }).linkedHub;
351
+ const url = typeof linked?.url === 'string' ? linked.url : null;
352
+ if (linked) {
353
+ delete cfg.linkedHub;
354
+ // Temp + rename (security review 2026-08-10, F5) — config.json is the
355
+ // SERVED project config (tokensCssRel, canvasGroups); a crash mid-write
356
+ // would corrupt it. Same atomic posture as `hub-link.ts`, but WITHOUT
357
+ // the credential store's 0o600 (this file is committed + world-readable
358
+ // by design), so it inherits the umask like the plain write it replaces.
359
+ const tmp = `${cfgPath}.${process.pid}.tmp`;
360
+ writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
361
+ renameSync(tmp, cfgPath);
362
+ }
363
+ if (url) {
364
+ try {
365
+ deleteHubCredential(normalizeUrl(url));
366
+ } catch {
367
+ /* an unparseable stored URL has no credential entry to drop */
368
+ }
369
+ }
370
+ try {
371
+ await ctx.syncControl?.restart(null);
372
+ } catch {
373
+ /* best-effort — the link is gone on disk either way */
374
+ }
375
+ return { status: 200, json: { ok: true, detached: !!linked } };
376
+ },
322
377
  };
323
378
 
324
379
  /**
@@ -363,14 +418,27 @@ export function createCloudEndpoints(ctx: Ctx) {
363
418
  }): Promise<CloudEndpointResult> {
364
419
  // The cell is a different host than the control plane — one direct call.
365
420
  let hubToken: string | null = null;
421
+ let hubTokenExpiresAt: number | undefined;
422
+ let vouchedRole: string | undefined;
366
423
  try {
367
424
  const res = await fetch(`${workspaceUrl}/auth/login`, {
368
425
  method: 'POST',
369
426
  headers: { 'content-type': 'application/json' },
370
427
  body: JSON.stringify({ token: projectToken }),
371
428
  });
372
- const body = (await res.json().catch(() => ({}))) as CloudBody;
373
- if (res.ok && body?.token) hubToken = body.token;
429
+ const body = (await res.json().catch(() => ({}))) as CloudBody & {
430
+ expiresAt?: unknown;
431
+ user?: { role?: unknown };
432
+ };
433
+ if (res.ok && body?.token) {
434
+ hubToken = body.token;
435
+ // The cell reports when this session dies (≤ the 12 h project-token
436
+ // cap). Persisted so the sync runtime can renew BEFORE the deadline —
437
+ // discarding it here is what made every link silently expire.
438
+ if (typeof body.expiresAt === 'number' && Number.isFinite(body.expiresAt))
439
+ hubTokenExpiresAt = body.expiresAt;
440
+ if (typeof body.user?.role === 'string' && body.user.role) vouchedRole = body.user.role;
441
+ }
374
442
  } catch {
375
443
  /* handled below */
376
444
  }
@@ -385,7 +453,7 @@ export function createCloudEndpoints(ctx: Ctx) {
385
453
  }
386
454
 
387
455
  const norm = normalizeUrl(workspaceUrl);
388
- saveHubCredential(norm, hubToken);
456
+ saveHubCredential(norm, hubToken, vouchedRole, hubTokenExpiresAt);
389
457
 
390
458
  // Project side — linkedHub in .design/config.json (committed; no token).
391
459
  const cfgPath = join(ctx.paths.designRoot, 'config.json');