@1agh/maude 0.58.1 → 0.58.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -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
+ }
@@ -1235,6 +1235,18 @@
1235
1235
  .chat-qa {
1236
1236
  font-family: var(--font-mono);
1237
1237
  }
1238
+ /* Issue #74 — the "Implement N comments" action. Its siblings are literal
1239
+ slash commands (hence mono); this one is prose ABOUT the canvas's current
1240
+ state and only appears when there is something to act on, so it reads as
1241
+ the row's contextual action: proportional type + a light accent tint. */
1242
+ .chat-qa--comments {
1243
+ font-family: inherit;
1244
+ border-color: var(--accent-muted);
1245
+ color: var(--accent);
1246
+ }
1247
+ .chat-qa--comments:hover {
1248
+ background: var(--accent-tint);
1249
+ }
1238
1250
 
1239
1251
  /* Composer */
1240
1252
  .chat-composer {
@@ -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');
@@ -0,0 +1,183 @@
1
+ // Silent hub-credential renewal — the missing half of the cell-token model.
2
+ //
3
+ // The cell session token dies with the 12 h project token that minted it
4
+ // (Phase 23 B2 — deliberate: that cap IS the revocation window, and it stays).
5
+ // The account/device token next to it never expires. Nothing connected the
6
+ // two: the desktop held a forever credential and a 12 h credential side by
7
+ // side, and when the short one died the user was told to press Connect again —
8
+ // or, worse, told nothing (the alligators incident: 73 canvases permanently
9
+ // `connecting…`).
10
+ //
11
+ // This module replays the attach lane's credential tail WITHOUT the ceremony:
12
+ // GET /api/projects (account token) → which project owns this hub
13
+ // POST /projects/open (account token) → fresh 12 h project token
14
+ // POST <cell>/auth/login (project token) → fresh cell session
15
+ // saveHubCredential(…) → hubs.json, role preserved
16
+ //
17
+ // It deliberately does NOT touch `.design/config.json` (the link is already
18
+ // recorded; renewal is not a re-link) and does NOT cycle the sync runtime (the
19
+ // caller swaps the token in place). A renewal fails only on a REAL event —
20
+ // signed out, token revoked on the Account page, removed from the project —
21
+ // and that failure, not a clock, is what should reach the user.
22
+ //
23
+ // Trust posture: every hop talks only to the CONFIGURED cloud address or to
24
+ // the workspace URL the cloud's own /api/projects listing names for the
25
+ // matched project — never to an address read from repo-committed state.
26
+
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { join } from 'node:path';
30
+
31
+ import { saveHubCredential } from '../sync/hub-link.ts';
32
+ import { getHubRecord, normalizeUrl } from '../sync/hubs-config.ts';
33
+
34
+ export interface RenewedCredential {
35
+ token: string;
36
+ expiresAt: number | null;
37
+ }
38
+
39
+ export type RenewFailureReason =
40
+ | 'not-signed-in'
41
+ | 'account-revoked'
42
+ | 'no-matching-project'
43
+ | 'open-refused'
44
+ | 'cell-refused'
45
+ | 'save-failed'
46
+ | 'unreachable';
47
+
48
+ export type RenewResult =
49
+ | ({ ok: true } & RenewedCredential)
50
+ | { ok: false; reason: RenewFailureReason };
51
+
52
+ /** Mirrors cloud/endpoints.ts — resolved per call so tests/desktop can set it late. */
53
+ function cloudUrl(): string {
54
+ return (process.env.MAUDE_CLOUD_URL ?? 'https://cloud.maude.sh').replace(/\/+$/, '');
55
+ }
56
+
57
+ function cloudConfigPath(): string {
58
+ return process.env.MAUDE_CLOUD_CONFIG ?? join(homedir(), '.config', 'maude', 'cloud.json');
59
+ }
60
+
61
+ /** The account credential, or null when this machine is signed out. */
62
+ function readAccountToken(): string | null {
63
+ const p = cloudConfigPath();
64
+ if (!existsSync(p)) return null;
65
+ try {
66
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
67
+ return parsed && typeof parsed.token === 'string' && parsed.token ? parsed.token : null;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ const FETCH_TIMEOUT_MS = 15_000;
74
+
75
+ async function jsonFetch(
76
+ doFetch: typeof fetch,
77
+ url: string,
78
+ init: RequestInit = {}
79
+ ): Promise<{ status: number; body: Record<string, unknown> }> {
80
+ const ctl = new AbortController();
81
+ const timer = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
82
+ try {
83
+ const res = await doFetch(url, { ...init, signal: ctl.signal });
84
+ const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
85
+ return { status: res.status, body };
86
+ } catch {
87
+ return { status: 0, body: {} };
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Mint a fresh cell session for `hubUrl` from the signed-in Maude account.
95
+ *
96
+ * Single network path, no interaction. Callers treat any `ok:false` as "keep
97
+ * the old behaviour" — the stored (possibly dead) credential stays in place so
98
+ * a failed renewal never makes things worse than before it ran.
99
+ */
100
+ export async function renewHubCredential(
101
+ hubUrl: string,
102
+ doFetch: typeof fetch = fetch
103
+ ): Promise<RenewResult> {
104
+ const account = readAccountToken();
105
+ if (!account) return { ok: false, reason: 'not-signed-in' };
106
+
107
+ let norm: string;
108
+ try {
109
+ norm = normalizeUrl(hubUrl);
110
+ } catch {
111
+ return { ok: false, reason: 'no-matching-project' };
112
+ }
113
+
114
+ // Which cloud project opens onto this hub? The listing is the only mapping
115
+ // that exists (the hub URL is derived from the project id at open time), and
116
+ // asking it fresh means a renamed/removed project fails here — the right
117
+ // place — instead of minting for a project this account no longer has.
118
+ const listed = await jsonFetch(doFetch, `${cloudUrl()}/api/projects`, {
119
+ headers: { authorization: `Bearer ${account}` },
120
+ });
121
+ if (listed.status === 401) return { ok: false, reason: 'account-revoked' };
122
+ if (listed.status !== 200) return { ok: false, reason: 'unreachable' };
123
+ const projects = Array.isArray(listed.body.projects) ? listed.body.projects : [];
124
+ const match = projects.find((p) => {
125
+ const url = (p as { url?: unknown })?.url;
126
+ if (typeof url !== 'string' || !url) return false;
127
+ try {
128
+ return normalizeUrl(url) === norm;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }) as { id?: unknown; role?: unknown } | undefined;
133
+ // A viewer's Connect is a different lane (share-view); renewing an editor
134
+ // credential for one would mint capability the panel never offered.
135
+ if (!match || typeof match.id !== 'string' || match.role === 'viewer') {
136
+ return { ok: false, reason: 'no-matching-project' };
137
+ }
138
+
139
+ const opened = await jsonFetch(doFetch, `${cloudUrl()}/projects/open`, {
140
+ method: 'POST',
141
+ headers: { authorization: `Bearer ${account}`, 'content-type': 'application/json' },
142
+ body: JSON.stringify({ project: match.id }),
143
+ });
144
+ if (opened.status === 0) return { ok: false, reason: 'unreachable' };
145
+ if (opened.status !== 200 || typeof opened.body.token !== 'string' || !opened.body.token) {
146
+ // This is where a removed member's renewal dies — exactly where it should.
147
+ return { ok: false, reason: 'open-refused' };
148
+ }
149
+
150
+ const cellLogin = await jsonFetch(doFetch, `${norm}/auth/login`, {
151
+ method: 'POST',
152
+ headers: { 'content-type': 'application/json' },
153
+ body: JSON.stringify({ token: opened.body.token }),
154
+ });
155
+ if (cellLogin.status === 0) return { ok: false, reason: 'unreachable' };
156
+ if (
157
+ cellLogin.status !== 200 ||
158
+ typeof cellLogin.body.token !== 'string' ||
159
+ !cellLogin.body.token
160
+ ) {
161
+ return { ok: false, reason: 'cell-refused' };
162
+ }
163
+ const expiresAt =
164
+ typeof cellLogin.body.expiresAt === 'number' && Number.isFinite(cellLogin.body.expiresAt)
165
+ ? cellLogin.body.expiresAt
166
+ : null;
167
+
168
+ // The upsert replaces the whole record, so the role must be re-supplied. F5:
169
+ // prefer the role the cell JUST vouched (its /auth/login echoes `user.role`)
170
+ // over the stored one — this is the one moment the code learns the current
171
+ // role, and carrying the stale value forward would discard exactly that.
172
+ // Fall back to the prior role only when the cell didn't say (older cell).
173
+ const freshRoleRaw = (cellLogin.body.user as { role?: unknown } | undefined)?.role;
174
+ const freshRole = typeof freshRoleRaw === 'string' && freshRoleRaw ? freshRoleRaw : undefined;
175
+ const role = freshRole ?? getHubRecord(norm)?.role;
176
+ try {
177
+ saveHubCredential(norm, cellLogin.body.token, role, expiresAt ?? undefined);
178
+ } catch {
179
+ return { ok: false, reason: 'save-failed' };
180
+ }
181
+
182
+ return { ok: true, token: cellLogin.body.token, expiresAt };
183
+ }