@1agh/maude 0.53.2 → 0.55.0

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 (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. package/plugins/flow/dependencies.json +1 -1
@@ -628,6 +628,14 @@ const STICONS = {
628
628
  <path d="M4.3 9.5v2.3a1.2 1.2 0 0 0 2.4 0v-1.7" />
629
629
  </>
630
630
  ),
631
+ bug: (
632
+ <>
633
+ <circle cx="8" cy="9" r="3.6" />
634
+ <circle cx="8" cy="4.4" r="1.3" />
635
+ <path d="M6.2 3.4 5.2 2M9.8 3.4l1-1.4" />
636
+ <path d="M4.6 7.6H2.2M4.6 9H2.2M4.6 10.6H2.5M11.4 7.6h2.4M11.4 9h2.4M11.4 10.6h2.3" />
637
+ </>
638
+ ),
631
639
  'panel-left': (
632
640
  <>
633
641
  <rect x="2.5" y="3" width="11" height="10" rx="1.5" />
@@ -2430,7 +2438,12 @@ function Sidebar({
2430
2438
  // affordances are absent (buttons, composer, row menus, drag & drop).
2431
2439
  readOnly = false,
2432
2440
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
2433
- cloud = null,
2441
+ // Tri-state (see the note where this value is created): `undefined` until
2442
+ // the server config answers, `null` for the desktop, an object for a cloud
2443
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
2444
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
2445
+ // became "not cloud" one layer further in, invisibly.
2446
+ cloud,
2434
2447
  groups,
2435
2448
  activePath,
2436
2449
  activeDsName,
@@ -2709,6 +2722,7 @@ function Sidebar({
2709
2722
  <StIcon name="search" size={13} />
2710
2723
  <input
2711
2724
  type="search"
2725
+ data-testid="canvas-search"
2712
2726
  placeholder="Search canvases…"
2713
2727
  value={search}
2714
2728
  onChange={(e) => setSearch(e.target.value)}
@@ -2832,7 +2846,7 @@ function Sidebar({
2832
2846
  Absent rather than disabled, because unlike the agent chat there is
2833
2847
  nothing here to explain — the capability is not missing, it is
2834
2848
  already satisfied. */}
2835
- {cloud ? null : <CloudBar />}
2849
+ {cloud === null ? <CloudBar /> : null}
2836
2850
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2837
2851
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2838
2852
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3622,7 +3636,12 @@ function Menubar({
3622
3636
  activePath,
3623
3637
  project,
3624
3638
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
3625
- cloud = null,
3639
+ // Tri-state (see the note where this value is created): `undefined` until
3640
+ // the server config answers, `null` for the desktop, an object for a cloud
3641
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
3642
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
3643
+ // became "not cloud" one layer further in, invisibly.
3644
+ cloud,
3626
3645
  tabsCount,
3627
3646
  openMenu,
3628
3647
  setOpenMenu,
@@ -3890,7 +3909,12 @@ function Menubar({
3890
3909
  }, [openMenu, setOpenMenu]);
3891
3910
 
3892
3911
  return (
3893
- <header className="st-menubar" role="menubar" aria-label="Application menubar">
3912
+ <header
3913
+ className="st-menubar"
3914
+ role="menubar"
3915
+ aria-label="Application menubar"
3916
+ data-testid="menubar"
3917
+ >
3894
3918
  <span className="st-brand" data-tour="brand">
3895
3919
  <span className="st-brand-mark">
3896
3920
  <svg viewBox="0 0 32 32" width="100%" height="100%" fill="none" aria-hidden="true"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="currentColor" /></svg>
@@ -4044,11 +4068,45 @@ function Menubar({
4044
4068
  <span
4045
4069
  className="st-stamp st-stamp--viewonly"
4046
4070
  data-testid="view-only-stamp"
4047
- data-tip="Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access."
4071
+ data-tip={
4072
+ cloud?.user
4073
+ ? `${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.`
4074
+ : 'Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access.'
4075
+ }
4048
4076
  >
4049
4077
  VIEW ONLY
4050
4078
  </span>
4051
4079
  )}
4080
+ {/* Cloud Phase 27 C4 — WHO YOU ARE, AND HOW TO STOP BEING THEM.
4081
+ A browser tab carries no account identity of its own: the cookie is
4082
+ invisible, and the only thing on screen that reflected it was a
4083
+ three-word stamp. An owner who read VIEW ONLY had no way to tell
4084
+ whether the role was wrong or the ACCOUNT was, and no way to change
4085
+ either — the session outlived any fix by up to twelve hours because
4086
+ nothing in the studio could end it. This ends it. */}
4087
+ {cloud?.user ? (
4088
+ <span className="st-cloudwho" data-testid="cloud-account">
4089
+ <span
4090
+ className="st-cloudwho-email"
4091
+ title={cloud.role ? `Signed in as ${cloud.user} — ${cloud.role}` : cloud.user}
4092
+ >
4093
+ {cloud.user}
4094
+ </span>
4095
+ {/* A FORM, not a link. Signing out revokes the token server-side,
4096
+ so it is a write — and a write behind a GET is one any page can
4097
+ force on you from across the internet. */}
4098
+ <form method="post" action="/auth/browser/signout" className="st-cloudwho-form">
4099
+ <button
4100
+ type="submit"
4101
+ className="st-cloudwho-out"
4102
+ data-testid="cloud-signout"
4103
+ title="Sign out of this project"
4104
+ >
4105
+ Sign out
4106
+ </button>
4107
+ </form>
4108
+ </span>
4109
+ ) : null}
4052
4110
  {isNativeApp() && !readOnly && (
4053
4111
  <button
4054
4112
  type="button"
@@ -4065,6 +4123,16 @@ function Menubar({
4065
4123
  </button>
4066
4124
  )}
4067
4125
  {exportCenter && <ExportBadge center={exportCenter} />}
4126
+ <button
4127
+ type="button"
4128
+ className="st-reportbug"
4129
+ data-testid="report-bug-toggle"
4130
+ aria-label="Report a bug"
4131
+ data-tip="Report a bug"
4132
+ onClick={onReportBug}
4133
+ >
4134
+ <StIcon name="bug" size={15} />
4135
+ </button>
4068
4136
  <button
4069
4137
  type="button"
4070
4138
  className="st-whatsnew"
@@ -4514,7 +4582,7 @@ function StatusBar({
4514
4582
  })();
4515
4583
 
4516
4584
  return (
4517
- <footer className="st-statusbar" role="contentinfo">
4585
+ <footer className="st-statusbar" role="contentinfo" data-testid="statusbar">
4518
4586
  <span className="st-sb-slot st-sb-active" role="group" aria-label="Active file">
4519
4587
  <span className="lead" aria-hidden="true" />
4520
4588
  <span className="lbl">active</span>
@@ -4792,6 +4860,69 @@ function CommentsPanel({
4792
4860
  // Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
4793
4861
  // staged a newer build in the background. Non-blocking: "Restart now" applies it,
4794
4862
  // "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
4863
+ /**
4864
+ * What this role can do, said once, where a teammate lands — Cloud Phase 27 C3.
4865
+ *
4866
+ * A browser tab arrives with none of the context a desktop has: no window
4867
+ * title, no app the person chose to install, and — for a viewer — a set of
4868
+ * controls that will refuse them without explaining why. One line, dismissible,
4869
+ * remembered per role. It re-appears if the role CHANGES, because "you can edit
4870
+ * this now" is worth saying exactly as much as the first sentence was.
4871
+ *
4872
+ * Copy comes from the role the cell vouched (`role-matrix.mjs` is the authority
4873
+ * on what each one means; this is its sentence, not a second opinion).
4874
+ */
4875
+ const ROLE_LINE = {
4876
+ owner: 'You own this project — edit, invite and export.',
4877
+ member: 'You can edit this project, comment and export.',
4878
+ viewer: 'You can look at this project, comment and download it, but not change it.',
4879
+ };
4880
+
4881
+ function CloudRoleBanner({ cloud }) {
4882
+ const role = cloud?.role || null;
4883
+ // Dismissal is READ AT RENDER, not seeded into state. `cfg.cloud` is
4884
+ // `undefined` until `/_config` lands, so a `useState` initializer runs while
4885
+ // there is no role yet — and whatever it decided then would stick forever,
4886
+ // which on the first attempt meant the banner never appeared at all. Found by
4887
+ // opening it in a browser; no test in this repo would have said a word.
4888
+ const [dismissedNow, setDismissedNow] = useState(false);
4889
+ if (!role || dismissedNow) return null;
4890
+ const storageKey = `maude-cloud-role-seen:${role}`;
4891
+ let alreadySeen = false;
4892
+ try {
4893
+ alreadySeen = localStorage.getItem(storageKey) === '1';
4894
+ } catch {
4895
+ /* private mode / storage disabled — show it, saying it twice beats never */
4896
+ }
4897
+ if (alreadySeen) return null;
4898
+ const line = ROLE_LINE[role];
4899
+ if (!line) return null;
4900
+ return (
4901
+ <div
4902
+ role="status"
4903
+ aria-live="polite"
4904
+ className="st-banner st-banner--info"
4905
+ data-testid="cloud-role-banner"
4906
+ >
4907
+ <span className="st-banner-dot" aria-hidden="true" />
4908
+ <span>{line}</span>
4909
+ <button
4910
+ type="button"
4911
+ className="btn btn--ghost btn--sm"
4912
+ data-testid="cloud-role-banner-dismiss"
4913
+ onClick={() => {
4914
+ setDismissedNow(true);
4915
+ try {
4916
+ localStorage.setItem(storageKey, '1');
4917
+ } catch {}
4918
+ }}
4919
+ >
4920
+ Got it
4921
+ </button>
4922
+ </div>
4923
+ );
4924
+ }
4925
+
4795
4926
  function UpdateBanner({ update, onDismiss }) {
4796
4927
  const [restarting, setRestarting] = useState(false);
4797
4928
  if (!update) return null;
@@ -8304,6 +8435,9 @@ function photoAssetOfSelection(el) {
8304
8435
  }
8305
8436
 
8306
8437
  function InspectorPanel({
8438
+ /** E4 — `inspector-panel` / `layers-panel`, so one parity spec can assert
8439
+ * both shells show them. Undefined elsewhere; the attribute simply absent. */
8440
+ testId,
8307
8441
  selected,
8308
8442
  onClose,
8309
8443
  layersTree,
@@ -8773,6 +8907,7 @@ function InspectorPanel({
8773
8907
  style={width ? { width, flexBasis: width } : undefined}
8774
8908
  aria-label="Inspector"
8775
8909
  data-tour="inspector"
8910
+ data-testid={testId}
8776
8911
  >
8777
8912
  <div
8778
8913
  className="st-rp-tabs"
@@ -9236,7 +9371,18 @@ function App() {
9236
9371
  // `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
9237
9372
  // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
9238
9373
  // ?designRel + ?tokens query to the canvas mount shell.
9239
- const [cfg, setCfg] = useState({ designRel: '.design' });
9374
+ // `cloud` is deliberately TRI-STATE, and the third state is the useful one:
9375
+ // undefined — `/_config` has not answered yet; which shell this is is
9376
+ // UNKNOWN, and anything that would behave differently in the
9377
+ // two shells must wait rather than assume the desktop.
9378
+ // null — desktop or plain local browser.
9379
+ // object — a cloud tab (`{ dashboardUrl, projectName, user, role }`).
9380
+ //
9381
+ // Collapsing unknown into "not cloud" is what made the cloud studio open with
9382
+ // two console 404s every time: the sign-in bar and the export centre mounted
9383
+ // for one frame, each fired the request its shell exists to refuse, and then
9384
+ // unmounted. Nothing was broken and everything looked broken.
9385
+ const [cfg, setCfg] = useState({ designRel: '.design', cloud: undefined });
9240
9386
  // Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
9241
9387
  // editing affordance in the shell gates on this (absent, not hidden).
9242
9388
  const viewerMode = !!cfg.readOnly;
@@ -9278,13 +9424,11 @@ function App() {
9278
9424
  // both of these went missing and the cloud studio rendered without
9279
9425
  // its chrome and with every canvas iframe unauthenticated.
9280
9426
  canvasToken: data.canvasToken,
9281
- // C2/C4 — `{ dashboardUrl, projectName }` when this is a cloud tab.
9282
- // Its presence is what tells the shared client it is in a browser tab
9283
- // on somebody else's machine.
9427
+ // C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
9428
+ // cloud tab. Its presence is what tells the shared client it is in a
9429
+ // browser tab on somebody else's machine; `user`/`role` are what let
9430
+ // it say WHICH account that tab is, and offer the way out.
9284
9431
  cloud: data.cloud ?? null,
9285
- // C2/C4 — `{ dashboardUrl, projectName }` when this is a cloud tab.
9286
- // Its presence is what tells the shared client it is in a browser tab
9287
- // on somebody else's machine.
9288
9432
  }));
9289
9433
  })
9290
9434
  .catch(() => {});
@@ -9633,7 +9777,10 @@ function App() {
9633
9777
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
9634
9778
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
9635
9779
  const whatsNew = useWhatsNew(MDCC_VERSION);
9636
- const exportCenter = useExportCenter();
9780
+ // Same tri-state rule as the sign-in bar: the export queue is a LOCAL job
9781
+ // runner, `/_api/export-jobs` is one of the routes a cell refuses outright
9782
+ // (DDR-209 D1), and hydrating before the shell is known 404s every boot.
9783
+ const exportCenter = useExportCenter({ enabled: cfg.cloud === null });
9637
9784
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
9638
9785
  // "welcome" project on first launch; we ask it whether this is a first run and, if
9639
9786
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -11006,6 +11153,41 @@ function App() {
11006
11153
  if (path !== SYSTEM_TAB) setLoadingPath(path);
11007
11154
  }, []);
11008
11155
 
11156
+ /**
11157
+ * First open lands on a rendered canvas — Cloud Phase 27 C3.
11158
+ *
11159
+ * A desktop user chose this project, installed the app and knows what is in
11160
+ * it; the empty state is a helpful "pick a screen". A teammate following a
11161
+ * link has done none of that, and an empty pane with an arrow pointing at a
11162
+ * list is the browser saying "the thing you came for is somewhere else".
11163
+ *
11164
+ * CLOUD ONLY, and once. The desktop's empty state is deliberately unchanged —
11165
+ * it earns its keep there (the ⌘-hover lesson, quick setup) — and re-opening
11166
+ * a canvas the person deliberately closed would be a UI arguing with them.
11167
+ */
11168
+ const autoOpened = useRef(false);
11169
+ useEffect(() => {
11170
+ if (autoOpened.current) return;
11171
+ if (!cfg.cloud) return; // desktop / unknown-yet
11172
+ if (tabs.length > 0) return;
11173
+ if (!groups.length) return; // tree not loaded
11174
+ // The first canvas that is somebody's WORK — a design-system specimen is a
11175
+ // rendered canvas too, and not what a teammate followed a link to see.
11176
+ let first = null;
11177
+ for (const g of groups) {
11178
+ for (const p of g.paths || []) {
11179
+ if (!CANVAS_EXT_RE.test(p)) continue;
11180
+ if (/(^|\/)system\//.test(p)) continue;
11181
+ first = p;
11182
+ break;
11183
+ }
11184
+ if (first) break;
11185
+ }
11186
+ if (!first) return;
11187
+ autoOpened.current = true;
11188
+ openTab(first);
11189
+ }, [cfg.cloud, groups, tabs.length, openTab]);
11190
+
11009
11191
  const openSystem = useCallback(
11010
11192
  (dsName) => {
11011
11193
  // DsFolderRow passes the clicked DS name → scope the System view to it so
@@ -13475,10 +13657,17 @@ function App() {
13475
13657
  }
13476
13658
  // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
13477
13659
  // canvas highlighter tool (same letter, different action by focus).
13478
- // Cloud Phase 25 C2 — absent for a viewer (the panel is edit chrome).
13660
+ //
13661
+ // Cloud Phase 27 C1 — a VIEWER GETS THIS TOO, and the line above used to
13662
+ // say the opposite ("the panel is edit chrome"). C1 reversed that on
13663
+ // purpose: read-only means cannot CHANGE, not cannot SEE, and a reviewer
13664
+ // needs structure and measured values as much as anyone. The View menu
13665
+ // already offers it (`viewerHiddenPanels` no longer hides it); the
13666
+ // shortcut had been left behind, so the menu said yes and the keyboard
13667
+ // said no. Found by writing the parity spec that runs in both shells.
13479
13668
  if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
13480
13669
  e.preventDefault();
13481
- if (!viewerMode) toggleRightPanel('inspector');
13670
+ toggleRightPanel('inspector');
13482
13671
  return;
13483
13672
  }
13484
13673
  // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
@@ -13807,9 +13996,15 @@ function App() {
13807
13996
  // assigned to it and which one is active (the open one). Layers is only a
13808
13997
  // dockable panel in `separate` mode; Assistant is native-only.
13809
13998
  const panelAvailable = (id) => {
13810
- // Cloud Phase 25 C2 — edit-chrome panels don't exist for a viewer: no
13811
- // dock tab, no shortcut, no empty slot.
13812
- if (viewerMode && (id === 'assistant' || id === 'inspector' || id === 'layers')) return false;
13999
+ // The AGENT is genuinely absent for a viewer (it edits; Phase 25 C2).
14000
+ //
14001
+ // Inspector and Layers are NOT — Cloud Phase 27 C1 reversed that, and this
14002
+ // line had been left behind: `viewerHiddenPanels` stopped hiding them in
14003
+ // the View menu while this still refused to give them a dock slot, so the
14004
+ // menu offered a panel that could never appear. Read-only means cannot
14005
+ // CHANGE, not cannot SEE: a reviewer needs structure and measured values,
14006
+ // which is the whole reason C1 exists.
14007
+ if (viewerMode && id === 'assistant') return false;
13813
14008
  return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
13814
14009
  };
13815
14010
  const idsForSide = (side) =>
@@ -13842,13 +14037,15 @@ function App() {
13842
14037
  // which owns the resizable width). Assistant is handled separately below as an
13843
14038
  // always-mounted ChatPanel so its stream survives a tab switch.
13844
14039
  const renderPanelBody = (id) => {
13845
- // Cloud Phase 25 C2 — the Inspector/Layers panels are edit chrome; a
13846
- // viewer's session never mounts them (absent, not hidden).
13847
- if (viewerMode && (id === 'inspector' || id === 'layers')) return null;
14040
+ // C1 again, the second half of the same gate. The panels MOUNT for a
14041
+ // viewer; what they must not do is offer an edit, and that is already
14042
+ // handled inside them (`readOnly` is threaded through every control).
14043
+ // Refusing to mount was a blunter instrument than the role model asks for.
14044
+
13848
14045
  if (id === 'tree')
13849
14046
  return (
13850
14047
  <Sidebar
13851
- cloud={cfg?.cloud ?? null}
14048
+ cloud={cfg.cloud}
13852
14049
  readOnly={viewerMode}
13853
14050
  groups={groups}
13854
14051
  activePath={activePath}
@@ -13903,6 +14100,9 @@ function App() {
13903
14100
  if (id === 'inspector' || id === 'layers')
13904
14101
  return (
13905
14102
  <InspectorPanel
14103
+ // Cloud Phase 27 E4 — the parity spec asserts these two panels exist
14104
+ // in BOTH shells, so each needs a name a spec can ask for.
14105
+ testId={id === 'layers' ? 'layers-panel' : 'inspector-panel'}
13906
14106
  layersOnly={id === 'layers'}
13907
14107
  hideLayersTab={layersMode === 'separate'}
13908
14108
  cpMode={cpMode}
@@ -13983,6 +14183,7 @@ function App() {
13983
14183
  onContextMenu={onShellContextMenu}
13984
14184
  >
13985
14185
  {firstRun && <OnboardingWizard />}
14186
+ <CloudRoleBanner cloud={cfg.cloud} />
13986
14187
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
13987
14188
  <SyncBanner status={syncStatus} />
13988
14189
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
@@ -14013,7 +14214,7 @@ function App() {
14013
14214
  // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
14014
14215
  // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
14015
14216
  // in a browser tab on somebody else's machine, and nothing else.
14016
- cloud={cfg?.cloud ?? null}
14217
+ cloud={cfg.cloud}
14017
14218
  activePath={activePath}
14018
14219
  project={project}
14019
14220
  tabsCount={tabs.length}
@@ -15013,7 +15214,7 @@ function App() {
15013
15214
  )}
15014
15215
  {settingsOpen && (
15015
15216
  <SettingsPanel
15016
- cloud={cfg?.cloud ?? null}
15217
+ cloud={cfg.cloud}
15017
15218
  onClose={() => setSettingsOpen(false)}
15018
15219
  initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
15019
15220
  theme={theme}
@@ -65,7 +65,14 @@ async function saveNative(id, filename) {
65
65
  return saveExport(filename || 'export', id);
66
66
  }
67
67
 
68
- export function useExportCenter() {
68
+ /**
69
+ * @param {{ enabled?: boolean }} [opts] — `enabled` false suppresses the
70
+ * hydration fetch. The cloud shell passes false: `/_api/export-jobs` is
71
+ * refused there by design, so asking is guaranteed noise. It stays UNDEFINED
72
+ * until `/_config` answers, and undefined is not "false" here — see the
73
+ * tri-state note on `cfg.cloud`.
74
+ */
75
+ export function useExportCenter({ enabled = true } = {}) {
69
76
  const [jobs, setJobs] = useState(() => new Map());
70
77
  const [panelOpen, setPanelOpen] = useState(false);
71
78
  const [toastQueue, setToastQueue] = useState([]);
@@ -77,6 +84,7 @@ export function useExportCenter() {
77
84
  const startToastShownRef = useRef(new Set()); // "Exporting…" toast fires at most once per job
78
85
 
79
86
  useEffect(() => {
87
+ if (!enabled) return undefined;
80
88
  let alive = true;
81
89
  fetch('/_api/export-jobs')
82
90
  .then((r) => r.json())
@@ -99,7 +107,7 @@ export function useExportCenter() {
99
107
  return () => {
100
108
  alive = false;
101
109
  };
102
- }, []);
110
+ }, [enabled]);
103
111
 
104
112
  const upsert = useCallback((job) => {
105
113
  if (!job || typeof job.id !== 'string') return;
@@ -39,6 +39,13 @@ export const openVerification = () =>
39
39
  * Rust side host-locks it to github.com. Throws on an older desktop build without
40
40
  * the command — callers fall back to copy-to-clipboard. */
41
41
  export const openGitHubUrl = (url) => invoke('open_github_url', { url });
42
+ /** Open a Maude Cloud URL (device activation, share view, dashboard) in the OS
43
+ * browser. Native only — `window.open` is a silent no-op in WKWebView, which is
44
+ * what stranded people mid-sign-in. The Rust side zone-locks it against the
45
+ * address it resolves per call, so this argument can't widen where it goes.
46
+ * Throws on an older desktop build without the command — every caller must keep
47
+ * a visible link the person can use by hand. */
48
+ export const openCloudUrl = (url) => invoke('open_cloud_url', { url });
42
49
  /** Show the device code as soon as the shell has it. Returns an unlisten promise. */
43
50
  export const onDeviceCode = (cb) => listen('github://device-code', cb);
44
51
  /** Fire when sign-in completes (any surface) so other surfaces can flip live. cb(login). */