@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
@@ -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.
@@ -11794,13 +11821,23 @@ function App() {
11794
11821
 
11795
11822
  // ----- Inbound messages from iframes -----
11796
11823
  useEffect(() => {
11824
+ // Does this comment id belong to the canvas the user is actually looking
11825
+ // at? Scopes the patch/delete relays below — see the SECURITY note there.
11826
+ //
11827
+ // The leading `!!activePath` is load-bearing, not defensive noise: with no
11828
+ // active canvas the branches' `activeWin` is `null`, and `e.source` is also
11829
+ // `null` for a message whose source context was discarded before dispatch —
11830
+ // so `e.source === activeWin` can pass as `null === null`. This is the
11831
+ // second conjunct that closes that path. Don't "simplify" it away.
11832
+ const ownsActiveComment = (id) =>
11833
+ !!activePath && (commentsByFile[activePath] || []).some((c) => c && c.id === id);
11797
11834
  function onMessage(e) {
11798
11835
  // Cross-origin hardening (DDR-054): only accept dgn control messages from
11799
11836
  // the canvas-content origin — the split origin when on, else our own origin
11800
11837
  // for the same-origin iframe. Drops spoofed messages from any other window.
11801
- // The handlers below relay to inert stores (comments / selection — the
11802
- // "safe to sync" set), so the blast radius was small, but unchecked inbound
11803
- // postMessage is a confused-deputy seam the F1 hardening should close.
11838
+ // Every canvas iframe shares that origin, so the check below proves only
11839
+ // "a canvas said this", NOT "the canvas the user is looking at said it" —
11840
+ // each mutating branch additionally gates on `e.source === activeWin`.
11804
11841
  const expectedOrigin = cfg?.canvasOrigin || window.location.origin;
11805
11842
  if (e.origin !== expectedOrigin) return;
11806
11843
  const m = e.data;
@@ -12408,13 +12445,26 @@ function App() {
12408
12445
  // Phase 6 — iframe overlay finished composing. Relay through the
12409
12446
  // existing WS `comments-add` channel; server-side persistence +
12410
12447
  // broadcast back are identical to the legacy shell-composer flow.
12448
+ //
12449
+ // SECURITY — gated on `activeWin` and PINNED to `activePath`, the same
12450
+ // shape as every other mutating branch in this handler. The origin
12451
+ // check at the top passes for EVERY canvas iframe (they all share
12452
+ // `canvasOrigin`), so without this a canvas sitting in a BACKGROUND
12453
+ // tab could post comments onto any file it cared to name, with no user
12454
+ // gesture. This handler's preamble waived that on the grounds that
12455
+ // comments are an "inert store" — the ACP panel's one-click
12456
+ // "Implement N comments" action retires that premise, because open
12457
+ // comments are now a feed the agent acts on. `p.file` is ignored
12458
+ // rather than validated: the active canvas is the only file the user
12459
+ // can actually see themselves commenting on.
12411
12460
  const p = m.payload;
12412
12461
  const txt = String(p.text).trim();
12413
- if (txt) {
12462
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12463
+ if (e.source === activeWin && txt && activePath && activePath !== SYSTEM_TAB) {
12414
12464
  wsSend({
12415
12465
  type: 'comments-add',
12416
12466
  payload: {
12417
- file: p.file,
12467
+ file: activePath,
12418
12468
  selector: p.selector,
12419
12469
  index: p.index,
12420
12470
  dom_path: p.dom_path,
@@ -12428,10 +12478,21 @@ function App() {
12428
12478
  }
12429
12479
  } else if (m.dgn === 'comment-patch' && m.id && m.patch && typeof m.patch === 'object') {
12430
12480
  // Phase 6 — thread popover routes resolve / reopen through here.
12431
- wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
12481
+ // SECURITY — same gate as comment-submit, plus an ownership check: a
12482
+ // bare id would otherwise reach ANY comment in the project. Patch and
12483
+ // delete change or remove somebody else's note, which is exactly why
12484
+ // ws.ts refuses both for a viewer session; the iframe lane needs the
12485
+ // matching restriction.
12486
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12487
+ if (e.source === activeWin && ownsActiveComment(m.id)) {
12488
+ wsSend({ type: 'comments-patch', id: m.id, patch: m.patch });
12489
+ }
12432
12490
  } else if (m.dgn === 'comment-delete' && m.id) {
12433
- wsSend({ type: 'comments-delete', id: m.id });
12434
- setFocusedCommentId((prev) => (prev === m.id ? null : prev));
12491
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12492
+ if (e.source === activeWin && ownsActiveComment(m.id)) {
12493
+ wsSend({ type: 'comments-delete', id: m.id });
12494
+ setFocusedCommentId((prev) => (prev === m.id ? null : prev));
12495
+ }
12435
12496
  } else if (m.dgn === 'comment-click' && m.id) {
12436
12497
  setFocusedCommentId(m.id);
12437
12498
  } else if (m.dgn === 'artboards' && typeof m.count === 'number') {
@@ -12518,27 +12579,48 @@ function App() {
12518
12579
  } else if (m.dgn === 'shell-shortcut') {
12519
12580
  // Same forwarding lane for the other shell chords (inspect.ts) — so
12520
12581
  // ⌘R / ⌘⇧I / ⌘⇧M / ⌘⇧E / ⌘⇧H behave identically wherever focus is.
12521
- if (m.id === 'reload') reloadActive();
12522
- else if (m.id === 'inspector') toggleRightPanel('inspector');
12523
- else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
12524
- else if (m.id === 'comments') toggleRightPanel('comments');
12525
- else if (m.id === 'changes') toggleRightPanel('changes');
12526
- else if (m.id === 'timeline') toggleTimeline();
12527
- else if (m.id === 'export') setExportDialog({ mode: 'export' });
12528
- else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
12582
+ //
12583
+ // SECURITY — every one of these is a chord the user pressed INSIDE the
12584
+ // canvas they are looking at, so it gets the same `activeWin` gate as
12585
+ // its siblings. Ungated, a background canvas could reload the active
12586
+ // file out from under an edit or pop the Export/Handoff dialog on
12587
+ // demand — a modal-timing primitive, and the mirror image of the
12588
+ // present-enter branch that was already hardened against modal HIDING.
12589
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12590
+ if (e.source === activeWin) {
12591
+ if (m.id === 'reload') reloadActive();
12592
+ else if (m.id === 'inspector') toggleRightPanel('inspector');
12593
+ else if (m.id === 'assistant' && isNativeApp()) toggleRightPanel('assistant');
12594
+ else if (m.id === 'comments') toggleRightPanel('comments');
12595
+ else if (m.id === 'changes') toggleRightPanel('changes');
12596
+ else if (m.id === 'timeline') toggleTimeline();
12597
+ else if (m.id === 'export') setExportDialog({ mode: 'export' });
12598
+ else if (m.id === 'handoff') setExportDialog({ mode: 'handoff' });
12599
+ }
12529
12600
  } else if (m.dgn === 'open-export') {
12530
12601
  // Plan C — the in-canvas toolbar / context menu route here so they open
12531
12602
  // the SAME shell Export dialog as the menubar (one look, all settings).
12532
12603
  // Carry the context-menu's scope hint (e.g. "Export selection").
12533
- setExportDialog({
12534
- mode: 'export',
12535
- scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
12536
- });
12604
+ //
12605
+ // SECURITY — gated with `shell-shortcut` above rather than separately:
12606
+ // both reach the same `setExportDialog`, so leaving this one open would
12607
+ // hand back the capability the other now refuses.
12608
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12609
+ if (e.source === activeWin) {
12610
+ setExportDialog({
12611
+ mode: 'export',
12612
+ scope: m.detail && typeof m.detail.scope === 'string' ? m.detail.scope : undefined,
12613
+ });
12614
+ }
12537
12615
  } else if (m.dgn === 'open-timeline-request') {
12538
12616
  // Artboard-chrome context menu's "Open Timeline" (video-comp artboards
12539
12617
  // only). Scope the Timeline to the right-clicked artboard, then open it.
12540
- if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
12541
- setTimelineOpen(true);
12618
+ // SECURITY — same class: a context-menu action from the canvas in view.
12619
+ const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
12620
+ if (e.source === activeWin) {
12621
+ if (typeof m.artboardId === 'string') setCanvasActiveArtboard(m.artboardId.slice(0, 120));
12622
+ setTimelineOpen(true);
12623
+ }
12542
12624
  } else if (m.dgn === 'loaded' && m.file) {
12543
12625
  // iframe finished loading — drop the compile skeleton, push current
12544
12626
  // comments + carry over focused pin if any
@@ -14190,6 +14272,12 @@ function App() {
14190
14272
  (panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
14191
14273
  const activeCanvasFile =
14192
14274
  activePath && activePath !== SYSTEM_TAB && /\.(tsx|html)$/i.test(activePath) ? activePath : null;
14275
+ // Issue #74 — drives the chat panel's "Implement N comments" quick action.
14276
+ // Canvas-wide on purpose: the verb operates on every open comment of the
14277
+ // active canvas, so it is deliberately NOT scoped to the current selection.
14278
+ const activeOpenComments = activeCanvasFile
14279
+ ? openCount(commentsByFile[activeCanvasFile])
14280
+ : 0;
14193
14281
 
14194
14282
  // Render a panel body by id (width undefined ⇒ fills the .st-dockslot wrapper,
14195
14283
  // which owns the resizable width). Assistant is handled separately below as an
@@ -14236,6 +14324,7 @@ function App() {
14236
14324
  onGetLatest={gitGetLatest}
14237
14325
  canvasKinds={cfg?.canvasKinds}
14238
14326
  syncStatus={syncStatus}
14327
+ onLinkedHub={setCloudLinkedHub}
14239
14328
  />
14240
14329
  );
14241
14330
  if (id === 'changes')
@@ -14258,6 +14347,14 @@ function App() {
14258
14347
  // server-side (`projectReadOnly`, the manifest's role matrix), and
14259
14348
  // they are unchanged by this flag.
14260
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}
14261
14358
  resizing={resizingFor('changes')}
14262
14359
  onClose={() => setChangesOpen(false)}
14263
14360
  onCommit={gitCommit}
@@ -14512,6 +14609,7 @@ function App() {
14512
14609
  hidden={leftActive !== 'assistant'}
14513
14610
  activeCanvas={activeCanvasFile}
14514
14611
  selected={selected}
14612
+ openComments={activeOpenComments}
14515
14613
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14516
14614
  resizing={resizingFor('assistant')}
14517
14615
  onClose={() => setAssistantOpen(false)}
@@ -14586,6 +14684,7 @@ function App() {
14586
14684
  hidden={rightActive !== 'assistant'}
14587
14685
  activeCanvas={activeCanvasFile}
14588
14686
  selected={selected}
14687
+ openComments={activeOpenComments}
14589
14688
  designRel={(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}
14590
14689
  resizing={resizingFor('assistant')}
14591
14690
  onClose={() => setAssistantOpen(false)}
@@ -15496,8 +15595,15 @@ function App() {
15496
15595
  onClose={() => setQuickSetupOpen(false)}
15497
15596
  onStartTour={() => startTour(QUICK_SETUP_TOUR)}
15498
15597
  onBringBrand={() => setBrandUploadOpen(true)}
15598
+ onImportFigma={() => setFigmaImportOpen(true)}
15499
15599
  />
15500
15600
  <BrandUploadPanel open={brandUploadOpen} onClose={() => setBrandUploadOpen(false)} />
15601
+ {figmaImportOpen && (
15602
+ <FigmaImportPanel
15603
+ onClose={() => setFigmaImportOpen(false)}
15604
+ onImported={() => loadTree()}
15605
+ />
15606
+ )}
15501
15607
  {usageNudge && !tourSteps && !collabNudge && (
15502
15608
  <div className="mdcc-tour-nudge" role="status" aria-live="polite">
15503
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>
@@ -1058,9 +1058,29 @@ function ChatEmpty() {
1058
1058
  );
1059
1059
  }
1060
1060
 
1061
- function QuickActions() {
1061
+ function QuickActions({ openComments = 0 }) {
1062
1062
  return (
1063
1063
  <div className="chat-quick">
1064
+ {/* Issue #74 — the reporter asked for a button that IMPLEMENTS the
1065
+ comments they already dropped on the canvas, not one that adds a new
1066
+ one. `/design:edit` already owns the whole behaviour (edit.md §3: a
1067
+ generic feedback string + open comments → iterate each as a scoped
1068
+ edit, resolve each after it lands), so this is purely a shortcut to
1069
+ the phrasing that triggers it. Shown only when there IS something to
1070
+ implement — a permanently-disabled verb would just be noise.
1071
+ Prefill, never fire blind: same rule as QUICK_ACTIONS above, so the
1072
+ user can narrow the ask ("…but skip the one about the colour")
1073
+ before sending. */}
1074
+ {openComments > 0 ? (
1075
+ <ThreadPrimitive.Suggestion
1076
+ prompt="/design:edit fix open comments"
1077
+ send={false}
1078
+ className="btn btn--ghost btn--sm chat-qa chat-qa--comments"
1079
+ data-testid="chat-qa-implement-comments"
1080
+ >
1081
+ {`✓ Implement ${openComments} comment${openComments === 1 ? '' : 's'}`}
1082
+ </ThreadPrimitive.Suggestion>
1083
+ ) : null}
1064
1084
  {QUICK_ACTIONS.map((a) => (
1065
1085
  <ThreadPrimitive.Suggestion
1066
1086
  key={a.label}
@@ -1495,6 +1515,7 @@ function ChatThread({
1495
1515
  activeCanvas,
1496
1516
  selected,
1497
1517
  designRel,
1518
+ openComments,
1498
1519
  transcriptView,
1499
1520
  onSetTranscriptView,
1500
1521
  onPermissionRequest,
@@ -1735,7 +1756,7 @@ function ChatThread({
1735
1756
  ) : (
1736
1757
  <ErrorCard error={turnError} onRetry={retryLastTurn} />
1737
1758
  )}
1738
- <QuickActions />
1759
+ <QuickActions openComments={openComments} />
1739
1760
  <Composer
1740
1761
  activeCanvas={activeCanvas}
1741
1762
  chatCtx={ctxDismissed ? null : chatCtx}
@@ -1766,6 +1787,7 @@ export default function ChatPanel({
1766
1787
  activeCanvas,
1767
1788
  selected,
1768
1789
  designRel,
1790
+ openComments = 0,
1769
1791
  width,
1770
1792
  resizing,
1771
1793
  onClose,
@@ -2288,6 +2310,7 @@ export default function ChatPanel({
2288
2310
  activeCanvas={activeCanvas}
2289
2311
  selected={selected}
2290
2312
  designRel={designRel}
2313
+ openComments={openComments}
2291
2314
  transcriptView={transcriptView}
2292
2315
  onSetTranscriptView={setTranscriptView}
2293
2316
  onPermissionRequest={onPermissionRequest}
@@ -189,6 +189,34 @@ function projectFromHubUrl(hubUrl, cloudHost) {
189
189
  return norm(host.slice(0, -(cloudHost.length + 1)).replace(/^view-/, ''));
190
190
  }
191
191
 
192
+ /**
193
+ * Is this project row the one THIS folder is already linked to?
194
+ *
195
+ * Fix 7 of the 2026-08-10 sync RCA: every row used to render the same
196
+ * `Connect <name>` button, including the project this folder was already
197
+ * syncing with — the UI lying about an established link. Only a CREDENTIALED
198
+ * link counts (the `linkedHub` half alone comes from a committed config.json —
199
+ * attacker-authorable, B2), so a linked-but-uncredentialed row still offers
200
+ * Connect: it genuinely needs the sign-in.
201
+ *
202
+ * Addresses inside the configured cloud zone compare by the project label they
203
+ * carry (`projectFromHubUrl`, NFKD-folded like the platform); addresses that
204
+ * carry none (self-hosts, e2e stubs at `http://127.0.0.1:<port>`) fall back to
205
+ * an exact origin match, so two stubs on different ports never collapse.
206
+ */
207
+ export function isLinkedProjectRow({ projectUrl, linkedHubUrl, linkedHubCredentialed, cloudUrl }) {
208
+ if (!linkedHubUrl || !linkedHubCredentialed) return false;
209
+ const cloudHost = hostOf(cloudUrl);
210
+ const linked = projectFromHubUrl(linkedHubUrl, cloudHost);
211
+ const row = projectFromHubUrl(projectUrl, cloudHost);
212
+ if (linked && row) return linked === row;
213
+ try {
214
+ return new URL(String(projectUrl)).origin === new URL(String(linkedHubUrl)).origin;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
192
220
  /**
193
221
  * Does this folder look like the cloud project a link is naming?
194
222
  *
@@ -326,6 +354,7 @@ function Spark({ size = 15 }) {
326
354
  function Icon({ name, size = 15 }) {
327
355
  const p = {
328
356
  'chevron-up': <polyline points="3.5 10 8 5.5 12.5 10" />,
357
+ check: <polyline points="3 8.5 6.5 12 13 4.5" />,
329
358
  external: (
330
359
  <>
331
360
  <path d="M6 3.5H3.2A.7.7 0 0 0 2.5 4.2v8.6a.7.7 0 0 0 .7.7h8.6a.7.7 0 0 0 .7-.7V10" />
@@ -369,7 +398,11 @@ function Icon({ name, size = 15 }) {
369
398
  );
370
399
  }
371
400
 
372
- export default function CloudBar({ syncStatus }) {
401
+ // `onLinkedHub` lifts the resolved link state to the app shell (DDR-218): the
402
+ // GitPanel's cloud-managed posture turns on the same `linkedHub.credentialed`
403
+ // fact this panel's Connected row does, and CloudBar owns every action that
404
+ // changes it (status resolve, attach, detach) — so it reports, the app decides.
405
+ export default function CloudBar({ syncStatus, onLinkedHub }) {
373
406
  const [state, setState] = useState('loading'); // loading | out | in
374
407
  const [email, setEmail] = useState(null);
375
408
  const [menuOpen, setMenuOpen] = useState(false);
@@ -417,6 +450,7 @@ export default function CloudBar({ syncStatus }) {
417
450
  linkedHub: r.json?.linkedHub ?? null,
418
451
  resolved: true,
419
452
  });
453
+ onLinkedHub?.(r.json?.linkedHub ?? null);
420
454
  if (r.ok && r.json?.connected) {
421
455
  setEmail(r.json.email);
422
456
  setState('in');
@@ -476,11 +510,22 @@ export default function CloudBar({ syncStatus }) {
476
510
  setPending(null);
477
511
  if (r.ok && r.json?.ok) {
478
512
  setConnected({ project: r.json.project ?? pending.project, sync: r.json.sync });
513
+ adoptLink(r.json.url);
479
514
  } else {
480
515
  setError(r.json?.error || 'The workspace could not be connected.');
481
516
  }
482
517
  }
483
518
 
519
+ // A successful attach IS a credentialed link — mirror it into `local` so the
520
+ // project list flips to Connected without a second status fetch (the server
521
+ // just wrote both halves: linkedHub in config.json + the hub credential).
522
+ function adoptLink(url) {
523
+ if (typeof url === 'string' && url) {
524
+ setLocal((l) => ({ ...l, linkedHub: { url, credentialed: true } }));
525
+ onLinkedHub?.({ url, credentialed: true });
526
+ }
527
+ }
528
+
484
529
  // Escape answers the decision modal the same way the scrim does: Not now.
485
530
  // Declining must always be the cheap move — the dashboard re-mints a code on
486
531
  // the next "Open in Maude".
@@ -562,12 +607,29 @@ export default function CloudBar({ syncStatus }) {
562
607
  setBusy('');
563
608
  if (r.ok && r.json?.ok) {
564
609
  setConnected({ project: r.json.project ?? projectId, sync: r.json.sync });
610
+ adoptLink(r.json.url);
565
611
  } else {
566
612
  setError(r.json?.error || 'The workspace could not be connected.');
567
613
  }
568
614
  setMenuOpen(false);
569
615
  }
570
616
 
617
+ /** The in-app `maude design unlink` — drops the link AND stops syncing now. */
618
+ async function disconnect() {
619
+ setBusy('disconnect');
620
+ setError('');
621
+ const r = await api('/_api/cloud/detach', { method: 'POST' });
622
+ setBusy('');
623
+ setMenuOpen(false);
624
+ if (r.ok && r.json?.ok) {
625
+ setLocal((l) => ({ ...l, linkedHub: null }));
626
+ setConnected(null);
627
+ onLinkedHub?.(null);
628
+ } else {
629
+ setError(r.json?.error || 'The workspace could not be disconnected.');
630
+ }
631
+ }
632
+
571
633
  async function signOut() {
572
634
  setMenuOpen(false);
573
635
  await api('/_api/cloud/signout', { method: 'POST' });
@@ -801,6 +863,35 @@ export default function CloudBar({ syncStatus }) {
801
863
  <Icon name="external" size={15} /> View {p.name || p.id} in the browser
802
864
  <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>viewer</span>
803
865
  </button>
866
+ ) : isLinkedProjectRow({
867
+ projectUrl: p.url,
868
+ linkedHubUrl: local.linkedHub?.url,
869
+ linkedHubCredentialed: local.linkedHub?.credentialed,
870
+ cloudUrl,
871
+ }) ? (
872
+ // The project this folder is ALREADY linked to. Connected is
873
+ // a state, not an action — the one action left is leaving.
874
+ // (Same wrapper shape as the "Loading projects…" row; the
875
+ // button is the focusable menu entry.)
876
+ <div
877
+ key={p.id}
878
+ className="gi-menu-item is-connected"
879
+ data-testid={`cloud-project-${p.id}`}
880
+ >
881
+ <Icon name="check" size={15} /> {p.name || p.id}
882
+ <span className="gi-menu-login" style={{ marginLeft: 'auto' }}>Connected</span>
883
+ <button
884
+ type="button"
885
+ className="btn btn--ghost btn--sm"
886
+ role="menuitem"
887
+ disabled={busy === 'disconnect'}
888
+ onClick={disconnect}
889
+ aria-label={`Disconnect this folder from ${p.name || p.id}`}
890
+ data-testid={`cloud-disconnect-${p.id}`}
891
+ >
892
+ {busy === 'disconnect' ? 'Disconnecting…' : 'Disconnect'}
893
+ </button>
894
+ </div>
804
895
  ) : (
805
896
  <button
806
897
  key={p.id}