@1agh/maude 0.60.6 → 1.0.2

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -33,6 +33,7 @@ import { useTreeDrag } from './use-tree-drag.js';
33
33
  import ChatPanel from './panels/ChatPanel.jsx';
34
34
  import DiffView from './panels/DiffView.jsx';
35
35
  import GitPanel from './panels/GitPanel.jsx';
36
+ import SyncConsentDialog from './panels/SyncConsentDialog.jsx';
36
37
  import SyncPanel from './panels/SyncPanel.jsx';
37
38
  import CloudBar from './panels/CloudBar.jsx';
38
39
  import IdentityBar from './panels/IdentityBar.jsx';
@@ -1537,9 +1538,23 @@ function ExportDialog({
1537
1538
  comps = [],
1538
1539
  activeArtboardId = null,
1539
1540
  selection = null,
1541
+ exportLane = 'local',
1540
1542
  onClose,
1541
1543
  }) {
1542
- const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
1544
+ // feature-cloud-export-render-workers — on a cell with no render service
1545
+ // (`lane === 'none'`), formats that render through a browser are offered
1546
+ // disabled-with-a-reason instead of firing a request the proxy 404s. ZIP
1547
+ // (JSZip over project files) and the AI-handoff card (clipboard copy) need
1548
+ // no browser and stay live in every lane.
1549
+ const laneBlocked = useCallback(
1550
+ (c) =>
1551
+ (exportLane === 'none' && !c.handoff && c.format !== 'zip') ||
1552
+ // Canva bundles project files the render service can't reach — desktop only
1553
+ // in every workspace lane (exporters/remote.ts REMOTE_UNSUPPORTED_FORMATS).
1554
+ (exportLane !== 'local' && c.format === 'canva'),
1555
+ [exportLane]
1556
+ );
1557
+ const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : exportLane === 'none' ? 'zip' : 'png');
1543
1558
  const [scope, setScope] = useState(
1544
1559
  initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
1545
1560
  );
@@ -1622,6 +1637,12 @@ function ExportDialog({
1622
1637
  setStatus({ ok: true, msg: `Copied: ${cmd} — run it in Claude Code.` });
1623
1638
  return;
1624
1639
  }
1640
+ if (laneBlocked(card)) {
1641
+ // Belt to the disabled-card braces — a stale selection can't submit a
1642
+ // format this workspace cannot render.
1643
+ setStatus({ ok: false, msg: 'This format needs the render service, which this workspace doesn’t have configured.' });
1644
+ return;
1645
+ }
1625
1646
  setBusy(true);
1626
1647
  setStatus(null);
1627
1648
  // `scale` drives video resolution (deviceScaleFactor → encoder dims);
@@ -1705,6 +1726,12 @@ function ExportDialog({
1705
1726
  type="button"
1706
1727
  key={c.id}
1707
1728
  className={'st-fmt' + (c.id === sel ? ' is-on' : '')}
1729
+ disabled={laneBlocked(c)}
1730
+ title={
1731
+ laneBlocked(c)
1732
+ ? 'Needs the render service — not configured on this workspace'
1733
+ : undefined
1734
+ }
1708
1735
  onClick={() => {
1709
1736
  setSel(c.id);
1710
1737
  setStatus(null);
@@ -1716,6 +1743,14 @@ function ExportDialog({
1716
1743
  </button>
1717
1744
  ))}
1718
1745
  </div>
1746
+ {exportLane === 'none' && (
1747
+ <div className="st-dialog-note" data-testid="export-lane-note">
1748
+ Rendered exports (PNG, PDF, video…) need the render service, which this
1749
+ workspace doesn&apos;t have configured. ZIP and AI handoff still work here —
1750
+ for everything else use the desktop app, or ask your admin to add the
1751
+ <code> maude-render</code> service (see the self-hosting docs).
1752
+ </div>
1753
+ )}
1719
1754
  {!card.handoff && (
1720
1755
  <div className="st-dialog-row">
1721
1756
  <label className="st-dialog-lbl" htmlFor="st-export-scope">
@@ -2568,7 +2603,7 @@ function Sidebar({
2568
2603
  // Cloud Phase 25 C2 — viewer role: create / delete / move / rename
2569
2604
  // affordances are absent (buttons, composer, row menus, drag & drop).
2570
2605
  readOnly = false,
2571
- /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
2606
+ /** `{ dashboardUrl?, projectName }` when this is a cloud tab, else null. */
2572
2607
  // Tri-state (see the note where this value is created): `undefined` until
2573
2608
  // the server config answers, `null` for the desktop, an object for a cloud
2574
2609
  // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
@@ -2612,6 +2647,10 @@ function Sidebar({
2612
2647
  // Passed through to CloudBar only — lifts linkedHub changes to the app shell
2613
2648
  // so the GitPanel's cloud-managed posture (DDR-218) reacts live.
2614
2649
  onLinkedHub,
2650
+ // feature-cloud-managed-git-posture — the widened DDR-218 gate, resolved once
2651
+ // in App and handed down. Withdraws the drafts switcher: a local branch
2652
+ // switch moves a HEAD the cell knows nothing about.
2653
+ savingIsManaged = false,
2615
2654
  }) {
2616
2655
  const filteredGroups = useMemo(() => {
2617
2656
  if (!search) return groups;
@@ -2977,8 +3016,19 @@ function Sidebar({
2977
3016
  />
2978
3017
  {/* Phase 29 (E4) — the project + draft switcher: a compact one-line dock that
2979
3018
  opens UPWARD, sitting directly above the GitHub identity avatar so the two
2980
- form one bottom dock. Renders nothing until the project is a git repo. */}
2981
- <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
3019
+ form one bottom dock. Renders nothing until the project is a git repo.
3020
+
3021
+ WITHDRAWN while somebody else is committing (feature-cloud-managed-git-
3022
+ posture). A draft is a local branch, and switching one moves a HEAD the
3023
+ cell knows nothing about — it would rewrite the working tree under a
3024
+ project whose history is being written elsewhere. Same
3025
+ presentation-not-a-control rule as the rest of DDR-218: `/_api/git/branch`
3026
+ and `/_api/git/checkout` keep exactly their old gates, and a terminal
3027
+ `git checkout` still works (and still flushes into Yjs via DDR-051's
3028
+ watcher). Absent, not disabled — there is nothing to explain here. */}
3029
+ {!savingIsManaged && (
3030
+ <RepoBranchSwitcher project={project} liveBranch={gitBranch} remoteSync={remoteSync} onGetLatest={onGetLatest} />
3031
+ )}
2982
3032
  {/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
2983
3033
  above the GitHub identity. Dev-server-backed, so it works in the desktop
2984
3034
  shell AND a plain browser. */}
@@ -3776,7 +3826,7 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3776
3826
  function Menubar({
3777
3827
  activePath,
3778
3828
  project,
3779
- /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
3829
+ /** `{ dashboardUrl?, projectName }` when this is a cloud tab, else null. */
3780
3830
  // Tri-state (see the note where this value is created): `undefined` until
3781
3831
  // the server config answers, `null` for the desktop, an object for a cloud
3782
3832
  // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
@@ -4078,13 +4128,22 @@ function Menubar({
4078
4128
  component, which is how the two shells started diverging last time. */}
4079
4129
  {cloud ? (
4080
4130
  <span className="st-cloudback" data-testid="cloud-back">
4081
- <a
4082
- className="st-cloudback-link"
4083
- href={cloud.dashboardUrl || 'https://cloud.maude.sh'}
4084
- title="Back to your projects"
4085
- >
4086
- ← Dashboard
4087
- </a>
4131
+ {/* Only when there IS somewhere to go back TO. `dashboardUrl` is
4132
+ absent on a self-hosted hub (one hub serves one project, so there
4133
+ is no dashboard), and a hardcoded fallback here quietly re-created
4134
+ the exact bug the server side just stopped: the link is the ONE
4135
+ way out this tab offers, and it pointed at cloud.maude.sh. The
4136
+ project name below stays either way — a tab must still say which
4137
+ project it is showing. */}
4138
+ {cloud.dashboardUrl ? (
4139
+ <a
4140
+ className="st-cloudback-link"
4141
+ href={cloud.dashboardUrl}
4142
+ title="Back to your projects"
4143
+ >
4144
+ ← Dashboard
4145
+ </a>
4146
+ ) : null}
4088
4147
  {cloud.projectName ? (
4089
4148
  <span className="st-cloudback-project" data-testid="cloud-project-name">
4090
4149
  {cloud.projectName}
@@ -4218,8 +4277,8 @@ function Menubar({
4218
4277
  data-testid="view-only-stamp"
4219
4278
  data-tip={
4220
4279
  cloud?.user
4221
- ? `${cloud.user} is a viewer on this project — you can browse, comment and export. Ask a project owner for edit access, or sign out if that is not the account you meant to use.`
4222
- : 'Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access.'
4280
+ ? `${cloud.user} is a viewer on this project — you can browse, comment and export where available. Ask a project owner for edit access, or sign out if that is not the account you meant to use.`
4281
+ : 'Your role in this project is viewer — you can browse, comment when available, and export where available. Ask a project owner for edit access.'
4223
4282
  }
4224
4283
  >
4225
4284
  VIEW ONLY
@@ -9610,13 +9669,50 @@ function App() {
9610
9669
  // UNKNOWN, and anything that would behave differently in the
9611
9670
  // two shells must wait rather than assume the desktop.
9612
9671
  // null — desktop or plain local browser.
9613
- // object — a cloud tab (`{ dashboardUrl, projectName, user, role }`).
9672
+ // object — a cloud tab (`{ dashboardUrl?, projectName, user, role }`).
9614
9673
  //
9615
9674
  // Collapsing unknown into "not cloud" is what made the cloud studio open with
9616
9675
  // two console 404s every time: the sign-in bar and the export centre mounted
9617
9676
  // for one frame, each fired the request its shell exists to refuse, and then
9618
9677
  // unmounted. Nothing was broken and everything looked broken.
9619
9678
  const [cfg, setCfg] = useState({ designRel: '.design', cloud: undefined });
9679
+ // WHO IS SAVING THIS PROJECT — one expression, read by every surface that
9680
+ // would otherwise offer to save it, poll for it, or badge it (DDR-218, widened
9681
+ // by feature-cloud-managed-git-posture).
9682
+ //
9683
+ // These two flags were previously derived at the GitPanel call site alone, so
9684
+ // the panel withdrew correctly while the toolbar menu and the status-bar chip
9685
+ // kept rendering a raw dirty count from `gitStatus.files.length` — the very
9686
+ // "lie about work that is already saved" the withdrawal exists to delete. The
9687
+ // count is the claim, wherever it is drawn; hoisting the rule here is what
9688
+ // keeps a third surface from re-introducing it.
9689
+ //
9690
+ // DECLARED HERE, AT THE TOP, ON PURPOSE. Every local-git POLL below is gated
9691
+ // on it, and a dependency array evaluates during render — a `const` further
9692
+ // down would be in its temporal dead zone at the first `useEffect` that names
9693
+ // it. The same hazard the remote-sync effect's own comment records.
9694
+ //
9695
+ // PRESENTATION, NOT A CONTROL — unchanged from DDR-218. `.git` is untouched:
9696
+ // no hook is installed, no config is written, a terminal `git status` /
9697
+ // `git commit` behaves exactly as before, the local `/_api/git/*` routes keep
9698
+ // exactly their old gates, and `git/watch.ts` still flushes a terminal
9699
+ // `git checkout` into Yjs (DDR-051). What stops is MAUDE's own local git
9700
+ // activity — the polls and the surfaces that describe a repo nobody is
9701
+ // editing through. Disconnect restores every one of them live.
9702
+ const cellManaged = !!cfg.cloud;
9703
+ const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;
9704
+ const savingIsManaged = cellManaged || cloudManaged;
9705
+ // The raw truth stays available for surfaces that legitimately need it (the
9706
+ // panel's own file list); only the COUNT — the thing that reads as a to-do —
9707
+ // is withheld when this project's saving is somebody else's job.
9708
+ const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;
9709
+ // The posture, readable from callbacks that must NOT re-subscribe when it
9710
+ // flips: the WS handler is installed once for the socket's lifetime, and the
9711
+ // two git-status refreshers are `useCallback([])`s a dozen call sites depend
9712
+ // on being stable. A ref is how they read a live value without becoming a
9713
+ // reason to tear the socket down.
9714
+ const savingIsManagedRef = useRef(savingIsManaged);
9715
+ savingIsManagedRef.current = savingIsManaged;
9620
9716
  // Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
9621
9717
  // editing affordance in the shell gates on this (absent, not hidden).
9622
9718
  const viewerMode = !!cfg.readOnly;
@@ -9662,11 +9758,16 @@ function App() {
9662
9758
  // Undefined on a server too old to report it, which is why the chip
9663
9759
  // renders conditionally rather than printing `vundefined`.
9664
9760
  version: data.version,
9665
- // C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
9761
+ // C2/C4 — `{ dashboardUrl?, projectName, user, role }` when this is a
9666
9762
  // cloud tab. Its presence is what tells the shared client it is in a
9667
9763
  // browser tab on somebody else's machine; `user`/`role` are what let
9668
9764
  // it say WHICH account that tab is, and offer the way out.
9669
9765
  cloud: data.cloud ?? null,
9766
+ // feature-cloud-export-render-workers — `local` | `remote` | `none`.
9767
+ // Which export formats this server can actually produce; the export
9768
+ // dialogs gate on it. Absent on older servers → undefined, treated
9769
+ // as `local` (the pre-lane behavior) everywhere it is read.
9770
+ exportLane: data.exportLane,
9670
9771
  }));
9671
9772
  })
9672
9773
  .catch(() => {});
@@ -9693,7 +9794,20 @@ function App() {
9693
9794
  }, []);
9694
9795
  // Phase 27 (E2) — seed the git dirty-state on mount; live updates arrive over
9695
9796
  // the `git-status` WS broadcast (Task 5). Solo/non-git projects → repo:false.
9797
+ //
9798
+ // NOT WHILE SOMEBODY ELSE IS COMMITTING. In cloud-managed posture this asks
9799
+ // about a repo the user is not editing through, and every answer it brings
9800
+ // back is drawn somewhere as a claim about their work. Reactive on
9801
+ // `savingIsManaged`, not mount-only, so Disconnect resumes it live and
9802
+ // Connect stops it live — the requirement `git-cloud-posture.test.ts`
9803
+ // already pins for the panel.
9696
9804
  useEffect(() => {
9805
+ if (savingIsManaged) {
9806
+ // Drop what the local repo last said, too. A stale `gitStatus` left
9807
+ // standing keeps the tree's M/A/D badges lit after the posture changed.
9808
+ setGitStatus(null);
9809
+ return undefined;
9810
+ }
9697
9811
  let cancelled = false;
9698
9812
  fetch('/_api/git/status')
9699
9813
  .then((r) => r.json())
@@ -9704,7 +9818,7 @@ function App() {
9704
9818
  return () => {
9705
9819
  cancelled = true;
9706
9820
  };
9707
- }, []);
9821
+ }, [savingIsManaged]);
9708
9822
  const [commentsByFile, setCommentsByFile] = useState({}); // { file: [Comment] }
9709
9823
  // Phase 6 — the in-iframe composer owns drafting; the shell no longer holds
9710
9824
  // a `draft` state. Mutations route through postMessage → WS instead.
@@ -10034,10 +10148,15 @@ function App() {
10034
10148
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
10035
10149
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
10036
10150
  const whatsNew = useWhatsNew(MDCC_VERSION);
10037
- // Same tri-state rule as the sign-in bar: the export queue is a LOCAL job
10038
- // runner, `/_api/export-jobs` is one of the routes a cell refuses outright
10039
- // (DDR-209 D1), and hydrating before the shell is known 404s every boot.
10040
- const exportCenter = useExportCenter({ enabled: cfg.cloud === null });
10151
+ // Same tri-state rule as the sign-in bar: hydrating before the shell is
10152
+ // known 404s every boot. On desktop (`cloud === null`) the export queue is
10153
+ // the LOCAL job runner, always on. In a cloud tab it hydrates only when the
10154
+ // cell actually holds a jobs lane (`exportLane === 'remote'`) — a cell
10155
+ // without a render service still refuses `/_api/export-jobs` outright
10156
+ // (DDR-209 D1 posture, now lane-scoped by feature-cloud-export-render-workers).
10157
+ const exportCenter = useExportCenter({
10158
+ enabled: cfg.cloud === null || cfg.exportLane === 'remote',
10159
+ });
10041
10160
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
10042
10161
  // "welcome" project on first launch; we ask it whether this is a first run and, if
10043
10162
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -11245,7 +11364,13 @@ function App() {
11245
11364
  } else if (m.type === 'git-status' && m.payload) {
11246
11365
  // Phase 27 (E2) Task 5 — live dirty-state. Updates the Changes-panel
11247
11366
  // count + tree M/A/D badges reactively, no polling.
11248
- setGitStatus(m.payload);
11367
+ //
11368
+ // The server PUSHES this, so gating the fetches alone would not be
11369
+ // enough: the broadcast would quietly re-populate everything the
11370
+ // polls stopped asking for. Read through a ref because this handler
11371
+ // is installed once, by an effect that must not re-subscribe the
11372
+ // socket every time the posture flips.
11373
+ if (!savingIsManagedRef.current) setGitStatus(m.payload);
11249
11374
  } else if (m.type === 'git-lifecycle' && m.payload) {
11250
11375
  // Phase 8 Task 7 — branch switch / pull mid-session. Server has
11251
11376
  // already flushed every dirty Y.Doc to JSON; just prompt the user.
@@ -11283,6 +11408,7 @@ function App() {
11283
11408
  // dual-allowlist gate them main-origin only). After a mutation we refresh
11284
11409
  // status optimistically; the `git-status` WS broadcast also lands shortly.
11285
11410
  const refreshGitStatus = useCallback(async () => {
11411
+ if (savingIsManagedRef.current) return; // cloud-managed — nobody polls a repo they don't commit to
11286
11412
  try {
11287
11413
  const r = await fetch('/_api/git/status');
11288
11414
  if (r.ok) setGitStatus(await r.json());
@@ -11295,6 +11421,11 @@ function App() {
11295
11421
  // without it the status is local-only and the nudge never fires. Network call —
11296
11422
  // call sparingly (mount / interval / post-action), never on the WS hot path.
11297
11423
  const refreshRemoteSync = useCallback(async () => {
11424
+ // The ahead/behind probe is a real network `git fetch` against the LOCAL
11425
+ // repo's remote — the one remote a cloud-managed project has nothing to do
11426
+ // with. Gated here as well as at the interval, so a post-action call site
11427
+ // cannot reintroduce it.
11428
+ if (savingIsManagedRef.current) return;
11298
11429
  try {
11299
11430
  const r = await fetch('/_api/git/status?remote=1');
11300
11431
  if (!r.ok) return;
@@ -11392,35 +11523,49 @@ function App() {
11392
11523
  }
11393
11524
  }, []);
11394
11525
 
11526
+ // THE HISTORY THAT IS ACTUALLY BEING WRITTEN (feature-cloud-managed-git-
11527
+ // posture). Same signature and same row shape as `gitLoadLog` — the two are
11528
+ // interchangeable at the call site precisely because the format, the argv and
11529
+ // the parser behind them are ONE module (`git/log-format.ts`), imported by
11530
+ // both the local service and the cell.
11531
+ //
11532
+ // Failure is REPORTED, never mistaken for emptiness: `null` means "we could
11533
+ // not reach the cloud" (the panel shows a Retry callout), `[]` means "the
11534
+ // cloud has no versions yet" (the panel shows the empty state). Collapsing
11535
+ // the two is what produced the original bug in the first place.
11536
+ const [cloudHistory, setCloudHistory] = useState(null); // { branch, project, hubHost } | null
11537
+ const gitLoadCloudLog = useCallback(async (path) => {
11538
+ try {
11539
+ const qs =
11540
+ '/_api/cloud/history?limit=40' + (path ? `&path=${encodeURIComponent(path)}` : '');
11541
+ const r = await fetch(qs);
11542
+ if (!r.ok) return null;
11543
+ const data = await r.json();
11544
+ if (!data?.ok) return null;
11545
+ setCloudHistory({
11546
+ branch: data.branch ?? null,
11547
+ project: data.project ?? null,
11548
+ hubHost: data.hubHost ?? null,
11549
+ });
11550
+ return data.entries || [];
11551
+ } catch {
11552
+ return null;
11553
+ }
11554
+ }, []);
11555
+
11395
11556
  // Repo-relative path → M/A/D/U badge for the tree (paths match: both the tree
11396
11557
  // and gitStatus use `.design/ui/Foo.tsx`). Keyed off gitStatus so it updates
11397
11558
  // live with the WS broadcast.
11398
11559
  const dirtyByPath = useMemo(() => {
11399
11560
  const KIND = { modified: 'M', added: 'A', deleted: 'D', untracked: 'U' };
11400
11561
  const m = new Map();
11562
+ // A tree badge is the SAME claim as the withdrawn count, drawn one row at a
11563
+ // time: "this file is unsaved". In cloud-managed posture it is not — the
11564
+ // cell committed it seconds ago. Empty, so the tree simply says nothing.
11565
+ if (savingIsManaged) return m;
11401
11566
  for (const f of gitStatus?.files || []) m.set(f.path, KIND[f.status]);
11402
11567
  return m;
11403
- }, [gitStatus]);
11404
- // WHO IS SAVING THIS PROJECT — one expression, read by every surface that
11405
- // would otherwise offer to save it (DDR-218 + the cell's `historyOnly`).
11406
- //
11407
- // These two flags were previously derived at the GitPanel call site alone, so
11408
- // the panel withdrew correctly while the toolbar menu and the status-bar chip
11409
- // kept rendering a raw dirty count from `gitStatus.files.length` — the very
11410
- // "lie about work that is already saved" the withdrawal exists to delete. The
11411
- // count is the claim, wherever it is drawn; hoisting the rule here is what
11412
- // keeps a third surface from re-introducing it.
11413
- //
11414
- // PRESENTATION, NOT A CONTROL — unchanged from DDR-218. `.git` is untouched,
11415
- // a terminal `git` behaves exactly as before, the server routes keep their own
11416
- // gates, and Disconnect restores every surface live.
11417
- const cellManaged = !!cfg.cloud;
11418
- const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;
11419
- const savingIsManaged = cellManaged || cloudManaged;
11420
- // The raw truth stays available for surfaces that legitimately need it (the
11421
- // panel's own file list, `dirtyByPath`); only the COUNT — the thing that reads
11422
- // as a to-do — is withheld when this project's saving is somebody else's job.
11423
- const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;
11568
+ }, [gitStatus, savingIsManaged]);
11424
11569
 
11425
11570
  // Phase 28 (E3) — keep remote ahead/behind fresh so the "Get latest" nudge
11426
11571
  // surfaces on its own: probe once a repo is known, again whenever the Changes
@@ -11428,6 +11573,13 @@ function App() {
11428
11573
  // publish then shows up without the user first attempting their own publish).
11429
11574
  // Declared after `refreshRemoteSync` to avoid a temporal-dead-zone on the dep.
11430
11575
  useEffect(() => {
11576
+ if (savingIsManaged) {
11577
+ // Nothing to nudge about: Publish is withdrawn in this posture, so a
11578
+ // probe could only produce a count nobody may act on. Clear the last
11579
+ // answer as well — a stale ahead/behind would outlive the link.
11580
+ setRemoteSync(null);
11581
+ return undefined;
11582
+ }
11431
11583
  if (gitStatus?.repo === false) return; // solo / non-git project — no remote
11432
11584
  refreshRemoteSync();
11433
11585
  // Poll in the background too — not only while Changes is open — so the dock's
@@ -11436,7 +11588,7 @@ function App() {
11436
11588
  // TTL-cached server-side, so a missed tick is cheap to re-issue).
11437
11589
  const id = setInterval(refreshRemoteSync, changesOpen ? 60000 : 120000);
11438
11590
  return () => clearInterval(id);
11439
- }, [gitStatus?.repo, changesOpen, refreshRemoteSync]);
11591
+ }, [savingIsManaged, gitStatus?.repo, changesOpen, refreshRemoteSync]);
11440
11592
 
11441
11593
  // ----- Tab management (single-canvas) -----
11442
11594
  // Single-canvas model: opening a file REPLACES the active one (no tab strip).
@@ -12854,6 +13006,18 @@ function App() {
12854
13006
  if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
12855
13007
  } catch {}
12856
13008
  };
13009
+ // feature-cloud-export-render-workers — a workspace with no render
13010
+ // service can't produce browser-rendered formats; answer the in-canvas
13011
+ // dialog with the reason instead of relaying a request the proxy 404s.
13012
+ // ZIP (and any browser-free format) still goes through.
13013
+ if (cfg.exportLane === 'none' && payload?.format !== 'zip') {
13014
+ reply({
13015
+ ok: false,
13016
+ error:
13017
+ 'This format needs the render service, which this workspace doesn’t have configured. ZIP still works here; for rendered formats use the desktop app or ask your admin to add maude-render.',
13018
+ });
13019
+ return;
13020
+ }
12857
13021
  try {
12858
13022
  // feature-background-export-notification-center — enqueue and reply
12859
13023
  // with the job id immediately; the notification center (which
@@ -14462,6 +14626,7 @@ function App() {
14462
14626
  canvasKinds={cfg?.canvasKinds}
14463
14627
  syncStatus={syncStatus}
14464
14628
  onLinkedHub={setCloudLinkedHub}
14629
+ savingIsManaged={savingIsManaged}
14465
14630
  />
14466
14631
  );
14467
14632
  if (id === 'changes')
@@ -14498,7 +14663,16 @@ function App() {
14498
14663
  onDiscard={gitDiscard}
14499
14664
  onPublish={gitPublish}
14500
14665
  onGetLatest={gitGetLatest}
14501
- loadLog={gitLoadLog}
14666
+ // WHICH HISTORY, decided at the ONE place the posture is named — not
14667
+ // inside the panel, which would be a second derivation of the rule
14668
+ // `cloud-managed-save-surfaces.test.ts` exists to keep singular.
14669
+ loadLog={cloudManaged ? gitLoadCloudLog : gitLoadLog}
14670
+ historySource={cloudManaged ? 'cloud' : 'local'}
14671
+ // What the cloud half of the header names. The cell reports its own
14672
+ // project and branch with the log; the hub host is the fallback,
14673
+ // because a header that names the LOCAL folder while listing the
14674
+ // CLOUD's commits is the confusion this feature exists to end.
14675
+ cloudHistory={cloudHistory}
14502
14676
  onOpenCanvas={(p) => openTab(p)}
14503
14677
  onOpenDiff={(file) => setDiffTarget({ file, beforeSha: 'HEAD', conflict: false })}
14504
14678
  activeCanvas={activeCanvasFile}
@@ -14606,6 +14780,10 @@ function App() {
14606
14780
  <CloudRoleBanner cloud={cfg.cloud} />
14607
14781
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
14608
14782
  <SyncBanner status={syncStatus} />
14783
+ {/* First-upgrade consent (Task 2) — global on purpose: a consent that
14784
+ only appears if you happen to open the Sync panel is not consent.
14785
+ Skipped during first-run onboarding so two flows don't stack. */}
14786
+ {!firstRun && <SyncConsentDialog status={syncStatus} cloud={cfg.cloud} />}
14609
14787
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
14610
14788
  {!usageNudge && !tourSteps && <ExportToast center={exportCenter} />}
14611
14789
  {gitLifecycle && (
@@ -15652,6 +15830,7 @@ function App() {
15652
15830
  // scope=artboard fell back to `:first-of-type` (always the first).
15653
15831
  activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
15654
15832
  selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
15833
+ exportLane={cfg.exportLane || 'local'}
15655
15834
  onClose={() => setExportDialog(null)}
15656
15835
  />
15657
15836
  )}