@1agh/maude 0.53.1 → 0.54.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 (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -2429,6 +2429,13 @@ function Sidebar({
2429
2429
  // Cloud Phase 25 C2 — viewer role: create / delete / move / rename
2430
2430
  // affordances are absent (buttons, composer, row menus, drag & drop).
2431
2431
  readOnly = false,
2432
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
2433
+ // Tri-state (see the note where this value is created): `undefined` until
2434
+ // the server config answers, `null` for the desktop, an object for a cloud
2435
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
2436
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
2437
+ // became "not cloud" one layer further in, invisibly.
2438
+ cloud,
2432
2439
  groups,
2433
2440
  activePath,
2434
2441
  activeDsName,
@@ -2707,6 +2714,7 @@ function Sidebar({
2707
2714
  <StIcon name="search" size={13} />
2708
2715
  <input
2709
2716
  type="search"
2717
+ data-testid="canvas-search"
2710
2718
  placeholder="Search canvases…"
2711
2719
  value={search}
2712
2720
  onChange={(e) => setSearch(e.target.value)}
@@ -2825,7 +2833,12 @@ function Sidebar({
2825
2833
  {/* Cloud Phase 23 C3 — Maude Cloud sign-in + remote-project attach, docked
2826
2834
  above the GitHub identity. Dev-server-backed, so it works in the desktop
2827
2835
  shell AND a plain browser. */}
2828
- <CloudBar />
2836
+ {/* Cloud Phase 27 — "Sign in to Maude Cloud" is an offer to do the thing
2837
+ you have already done: you reached this tab THROUGH a Maude account.
2838
+ Absent rather than disabled, because unlike the agent chat there is
2839
+ nothing here to explain — the capability is not missing, it is
2840
+ already satisfied. */}
2841
+ {cloud === null ? <CloudBar /> : null}
2829
2842
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2830
2843
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2831
2844
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3353,11 +3366,21 @@ function ViewDropdown({ panels, onToggle, onClose, onZoom, hasCanvas }) {
3353
3366
  role="menuitem"
3354
3367
  className={'st-dd-item' + (p.checked ? ' is-on' : '')}
3355
3368
  aria-disabled={p.disabled ? 'true' : undefined}
3369
+ title={p.hint || undefined}
3356
3370
  onClick={() => {
3357
- if (!p.disabled) {
3358
- onToggle(p.id);
3359
- onClose();
3371
+ // Cloud Phase 27 C2 — a row that states an absence still has
3372
+ // somewhere to send you. A disabled item with a `href` opens it
3373
+ // instead of doing nothing, which is the difference between "this
3374
+ // is not available here" and a dead button.
3375
+ if (p.disabled) {
3376
+ if (p.href) {
3377
+ window.open(p.href, '_blank', 'noopener,noreferrer');
3378
+ onClose();
3379
+ }
3380
+ return;
3360
3381
  }
3382
+ onToggle(p.id);
3383
+ onClose();
3361
3384
  }}
3362
3385
  >
3363
3386
  <span className="st-dd-lead">
@@ -3604,6 +3627,13 @@ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3604
3627
  function Menubar({
3605
3628
  activePath,
3606
3629
  project,
3630
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
3631
+ // Tri-state (see the note where this value is created): `undefined` until
3632
+ // the server config answers, `null` for the desktop, an object for a cloud
3633
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
3634
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
3635
+ // became "not cloud" one layer further in, invisibly.
3636
+ cloud,
3607
3637
  tabsCount,
3608
3638
  openMenu,
3609
3639
  setOpenMenu,
@@ -3678,8 +3708,19 @@ function Menubar({
3678
3708
  <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
3679
3709
  );
3680
3710
 
3681
- // Cloud Phase 25 C2 — panels a viewer's session doesn't have (edit chrome).
3682
- const viewerHiddenPanels = new Set(['assistant', 'inspector', 'layers', 'autoopen']);
3711
+ // Cloud Phase 27 C1 — ROLE SHAPES WHAT IS OFFERED, NEVER WHAT IS VISIBLE.
3712
+ //
3713
+ // `inspector` and `layers` used to be on this list and should never have been.
3714
+ // Read-only means cannot CHANGE, not cannot SEE: a reviewer needs the
3715
+ // structure and the measured values to say anything useful, and hiding them
3716
+ // is how a viewer's Maude became a worse Maude rather than a narrower one.
3717
+ // What stays hidden is only what genuinely does not exist for this session —
3718
+ // the agent chat (`assistant`, which needs a `claude` on the machine the app
3719
+ // is running on) and auto-open (a preference for a surface you cannot edit).
3720
+ // In the cloud the `assistant` row is the stated-absence row above, so it must
3721
+ // NOT be filtered away here — a viewer would then get silence, which is the
3722
+ // exact thing C2 forbids.
3723
+ const viewerHiddenPanels = new Set(cloud ? ['autoopen'] : ['assistant', 'autoopen']);
3683
3724
  const panels = [
3684
3725
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
3685
3726
  {
@@ -3745,7 +3786,26 @@ function Menubar({
3745
3786
  disabled: false,
3746
3787
  },
3747
3788
  ]
3748
- : []),
3789
+ : // Cloud Phase 27 C2 — THE AGENT'S ABSENCE IS STATED WHERE THE AGENT
3790
+ // WOULD BE. It runs on YOUR `claude` subscription, on YOUR machine
3791
+ // (DDR-123), so a browser tab genuinely cannot have it. That is a
3792
+ // legitimate difference — but a menu that silently lacks an item its
3793
+ // user has seen on the desktop reads as "the cloud one is broken", so
3794
+ // the row stays, disabled, saying where to find it. Never a hidden
3795
+ // item, never a dead button, never silence.
3796
+ cloud
3797
+ ? [
3798
+ {
3799
+ id: 'assistant',
3800
+ label: 'Assistant — in the desktop app',
3801
+ shortcut: '',
3802
+ checked: false,
3803
+ disabled: true,
3804
+ href: 'https://maude.sh/download',
3805
+ hint: 'The agent runs on your own Claude subscription, on your own machine.',
3806
+ },
3807
+ ]
3808
+ : []),
3749
3809
  {
3750
3810
  id: 'annotate',
3751
3811
  label: 'Annotations',
@@ -3841,13 +3901,41 @@ function Menubar({
3841
3901
  }, [openMenu, setOpenMenu]);
3842
3902
 
3843
3903
  return (
3844
- <header className="st-menubar" role="menubar" aria-label="Application menubar">
3904
+ <header
3905
+ className="st-menubar"
3906
+ role="menubar"
3907
+ aria-label="Application menubar"
3908
+ data-testid="menubar"
3909
+ >
3845
3910
  <span className="st-brand" data-tour="brand">
3846
3911
  <span className="st-brand-mark">
3847
3912
  <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>
3848
3913
  </span>
3849
3914
  <span className="st-brand-name">maude</span>
3850
3915
  </span>
3916
+ {/* Cloud Phase 27 C4 — THE WAY BACK OUT.
3917
+ A browser tab has no window title and no app switcher, so a teammate
3918
+ who followed a link has nothing telling them which project they are in
3919
+ or how to leave it. The desktop gets both for free from the OS; the
3920
+ cloud has to say them. This is an ADDITION to the shared client behind
3921
+ the same `cloud` flag as C2's agent notice — never a fork of the
3922
+ component, which is how the two shells started diverging last time. */}
3923
+ {cloud ? (
3924
+ <span className="st-cloudback" data-testid="cloud-back">
3925
+ <a
3926
+ className="st-cloudback-link"
3927
+ href={cloud.dashboardUrl || 'https://cloud.maude.sh'}
3928
+ title="Back to your projects"
3929
+ >
3930
+ ← Dashboard
3931
+ </a>
3932
+ {cloud.projectName ? (
3933
+ <span className="st-cloudback-project" data-testid="cloud-project-name">
3934
+ {cloud.projectName}
3935
+ </span>
3936
+ ) : null}
3937
+ </span>
3938
+ ) : null}
3851
3939
  <nav className="st-menus" aria-label="Application menus" data-tour="menus">
3852
3940
  {MENU_NAMES.map((name) => {
3853
3941
  const key = name.toLowerCase();
@@ -3972,11 +4060,45 @@ function Menubar({
3972
4060
  <span
3973
4061
  className="st-stamp st-stamp--viewonly"
3974
4062
  data-testid="view-only-stamp"
3975
- data-tip="Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access."
4063
+ data-tip={
4064
+ cloud?.user
4065
+ ? `${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.`
4066
+ : 'Your role in this project is viewer — you can browse, comment when available, and export. Ask a project owner for edit access.'
4067
+ }
3976
4068
  >
3977
4069
  VIEW ONLY
3978
4070
  </span>
3979
4071
  )}
4072
+ {/* Cloud Phase 27 C4 — WHO YOU ARE, AND HOW TO STOP BEING THEM.
4073
+ A browser tab carries no account identity of its own: the cookie is
4074
+ invisible, and the only thing on screen that reflected it was a
4075
+ three-word stamp. An owner who read VIEW ONLY had no way to tell
4076
+ whether the role was wrong or the ACCOUNT was, and no way to change
4077
+ either — the session outlived any fix by up to twelve hours because
4078
+ nothing in the studio could end it. This ends it. */}
4079
+ {cloud?.user ? (
4080
+ <span className="st-cloudwho" data-testid="cloud-account">
4081
+ <span
4082
+ className="st-cloudwho-email"
4083
+ title={cloud.role ? `Signed in as ${cloud.user} — ${cloud.role}` : cloud.user}
4084
+ >
4085
+ {cloud.user}
4086
+ </span>
4087
+ {/* A FORM, not a link. Signing out revokes the token server-side,
4088
+ so it is a write — and a write behind a GET is one any page can
4089
+ force on you from across the internet. */}
4090
+ <form method="post" action="/auth/browser/signout" className="st-cloudwho-form">
4091
+ <button
4092
+ type="submit"
4093
+ className="st-cloudwho-out"
4094
+ data-testid="cloud-signout"
4095
+ title="Sign out of this project"
4096
+ >
4097
+ Sign out
4098
+ </button>
4099
+ </form>
4100
+ </span>
4101
+ ) : null}
3980
4102
  {isNativeApp() && !readOnly && (
3981
4103
  <button
3982
4104
  type="button"
@@ -4442,7 +4564,7 @@ function StatusBar({
4442
4564
  })();
4443
4565
 
4444
4566
  return (
4445
- <footer className="st-statusbar" role="contentinfo">
4567
+ <footer className="st-statusbar" role="contentinfo" data-testid="statusbar">
4446
4568
  <span className="st-sb-slot st-sb-active" role="group" aria-label="Active file">
4447
4569
  <span className="lead" aria-hidden="true" />
4448
4570
  <span className="lbl">active</span>
@@ -4720,6 +4842,69 @@ function CommentsPanel({
4720
4842
  // Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
4721
4843
  // staged a newer build in the background. Non-blocking: "Restart now" applies it,
4722
4844
  // "Later" dismisses (the next focus/4h check re-stages and re-surfaces it).
4845
+ /**
4846
+ * What this role can do, said once, where a teammate lands — Cloud Phase 27 C3.
4847
+ *
4848
+ * A browser tab arrives with none of the context a desktop has: no window
4849
+ * title, no app the person chose to install, and — for a viewer — a set of
4850
+ * controls that will refuse them without explaining why. One line, dismissible,
4851
+ * remembered per role. It re-appears if the role CHANGES, because "you can edit
4852
+ * this now" is worth saying exactly as much as the first sentence was.
4853
+ *
4854
+ * Copy comes from the role the cell vouched (`role-matrix.mjs` is the authority
4855
+ * on what each one means; this is its sentence, not a second opinion).
4856
+ */
4857
+ const ROLE_LINE = {
4858
+ owner: 'You own this project — edit, invite and export.',
4859
+ member: 'You can edit this project, comment and export.',
4860
+ viewer: 'You can look at this project, comment and download it, but not change it.',
4861
+ };
4862
+
4863
+ function CloudRoleBanner({ cloud }) {
4864
+ const role = cloud?.role || null;
4865
+ // Dismissal is READ AT RENDER, not seeded into state. `cfg.cloud` is
4866
+ // `undefined` until `/_config` lands, so a `useState` initializer runs while
4867
+ // there is no role yet — and whatever it decided then would stick forever,
4868
+ // which on the first attempt meant the banner never appeared at all. Found by
4869
+ // opening it in a browser; no test in this repo would have said a word.
4870
+ const [dismissedNow, setDismissedNow] = useState(false);
4871
+ if (!role || dismissedNow) return null;
4872
+ const storageKey = `maude-cloud-role-seen:${role}`;
4873
+ let alreadySeen = false;
4874
+ try {
4875
+ alreadySeen = localStorage.getItem(storageKey) === '1';
4876
+ } catch {
4877
+ /* private mode / storage disabled — show it, saying it twice beats never */
4878
+ }
4879
+ if (alreadySeen) return null;
4880
+ const line = ROLE_LINE[role];
4881
+ if (!line) return null;
4882
+ return (
4883
+ <div
4884
+ role="status"
4885
+ aria-live="polite"
4886
+ className="st-banner st-banner--info"
4887
+ data-testid="cloud-role-banner"
4888
+ >
4889
+ <span className="st-banner-dot" aria-hidden="true" />
4890
+ <span>{line}</span>
4891
+ <button
4892
+ type="button"
4893
+ className="btn btn--ghost btn--sm"
4894
+ data-testid="cloud-role-banner-dismiss"
4895
+ onClick={() => {
4896
+ setDismissedNow(true);
4897
+ try {
4898
+ localStorage.setItem(storageKey, '1');
4899
+ } catch {}
4900
+ }}
4901
+ >
4902
+ Got it
4903
+ </button>
4904
+ </div>
4905
+ );
4906
+ }
4907
+
4723
4908
  function UpdateBanner({ update, onDismiss }) {
4724
4909
  const [restarting, setRestarting] = useState(false);
4725
4910
  if (!update) return null;
@@ -8232,6 +8417,9 @@ function photoAssetOfSelection(el) {
8232
8417
  }
8233
8418
 
8234
8419
  function InspectorPanel({
8420
+ /** E4 — `inspector-panel` / `layers-panel`, so one parity spec can assert
8421
+ * both shells show them. Undefined elsewhere; the attribute simply absent. */
8422
+ testId,
8235
8423
  selected,
8236
8424
  onClose,
8237
8425
  layersTree,
@@ -8701,6 +8889,7 @@ function InspectorPanel({
8701
8889
  style={width ? { width, flexBasis: width } : undefined}
8702
8890
  aria-label="Inspector"
8703
8891
  data-tour="inspector"
8892
+ data-testid={testId}
8704
8893
  >
8705
8894
  <div
8706
8895
  className="st-rp-tabs"
@@ -9164,7 +9353,18 @@ function App() {
9164
9353
  // `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
9165
9354
  // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
9166
9355
  // ?designRel + ?tokens query to the canvas mount shell.
9167
- const [cfg, setCfg] = useState({ designRel: '.design' });
9356
+ // `cloud` is deliberately TRI-STATE, and the third state is the useful one:
9357
+ // undefined — `/_config` has not answered yet; which shell this is is
9358
+ // UNKNOWN, and anything that would behave differently in the
9359
+ // two shells must wait rather than assume the desktop.
9360
+ // null — desktop or plain local browser.
9361
+ // object — a cloud tab (`{ dashboardUrl, projectName, user, role }`).
9362
+ //
9363
+ // Collapsing unknown into "not cloud" is what made the cloud studio open with
9364
+ // two console 404s every time: the sign-in bar and the export centre mounted
9365
+ // for one frame, each fired the request its shell exists to refuse, and then
9366
+ // unmounted. Nothing was broken and everything looked broken.
9367
+ const [cfg, setCfg] = useState({ designRel: '.design', cloud: undefined });
9168
9368
  // Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
9169
9369
  // editing affordance in the shell gates on this (absent, not hidden).
9170
9370
  const viewerMode = !!cfg.readOnly;
@@ -9196,6 +9396,21 @@ function App() {
9196
9396
  // decides what the UI offers; the cell (C1) and the dev-server's
9197
9397
  // read-only gate (http.ts) are what actually stop a write.
9198
9398
  readOnly: !!data.readOnly,
9399
+ // Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
9400
+ // canvas origin. `canvasUrl()` appends it to every iframe URL. Absent
9401
+ // on a desktop, where the canvas origin is loopback and needs none.
9402
+ //
9403
+ // NOTE FOR WHOEVER ADDS THE NEXT FIELD: this is an explicit
9404
+ // projection, not a spread of `data`. A field the server starts
9405
+ // returning does NOT arrive here until it is named — which is how
9406
+ // both of these went missing and the cloud studio rendered without
9407
+ // its chrome and with every canvas iframe unauthenticated.
9408
+ canvasToken: data.canvasToken,
9409
+ // C2/C4 — `{ dashboardUrl, projectName, user, role }` when this is a
9410
+ // cloud tab. Its presence is what tells the shared client it is in a
9411
+ // browser tab on somebody else's machine; `user`/`role` are what let
9412
+ // it say WHICH account that tab is, and offer the way out.
9413
+ cloud: data.cloud ?? null,
9199
9414
  }));
9200
9415
  })
9201
9416
  .catch(() => {});
@@ -9544,7 +9759,10 @@ function App() {
9544
9759
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
9545
9760
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
9546
9761
  const whatsNew = useWhatsNew(MDCC_VERSION);
9547
- const exportCenter = useExportCenter();
9762
+ // Same tri-state rule as the sign-in bar: the export queue is a LOCAL job
9763
+ // runner, `/_api/export-jobs` is one of the routes a cell refuses outright
9764
+ // (DDR-209 D1), and hydrating before the shell is known 404s every boot.
9765
+ const exportCenter = useExportCenter({ enabled: cfg.cloud === null });
9548
9766
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
9549
9767
  // "welcome" project on first launch; we ask it whether this is a first run and, if
9550
9768
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -10917,6 +11135,41 @@ function App() {
10917
11135
  if (path !== SYSTEM_TAB) setLoadingPath(path);
10918
11136
  }, []);
10919
11137
 
11138
+ /**
11139
+ * First open lands on a rendered canvas — Cloud Phase 27 C3.
11140
+ *
11141
+ * A desktop user chose this project, installed the app and knows what is in
11142
+ * it; the empty state is a helpful "pick a screen". A teammate following a
11143
+ * link has done none of that, and an empty pane with an arrow pointing at a
11144
+ * list is the browser saying "the thing you came for is somewhere else".
11145
+ *
11146
+ * CLOUD ONLY, and once. The desktop's empty state is deliberately unchanged —
11147
+ * it earns its keep there (the ⌘-hover lesson, quick setup) — and re-opening
11148
+ * a canvas the person deliberately closed would be a UI arguing with them.
11149
+ */
11150
+ const autoOpened = useRef(false);
11151
+ useEffect(() => {
11152
+ if (autoOpened.current) return;
11153
+ if (!cfg.cloud) return; // desktop / unknown-yet
11154
+ if (tabs.length > 0) return;
11155
+ if (!groups.length) return; // tree not loaded
11156
+ // The first canvas that is somebody's WORK — a design-system specimen is a
11157
+ // rendered canvas too, and not what a teammate followed a link to see.
11158
+ let first = null;
11159
+ for (const g of groups) {
11160
+ for (const p of g.paths || []) {
11161
+ if (!CANVAS_EXT_RE.test(p)) continue;
11162
+ if (/(^|\/)system\//.test(p)) continue;
11163
+ first = p;
11164
+ break;
11165
+ }
11166
+ if (first) break;
11167
+ }
11168
+ if (!first) return;
11169
+ autoOpened.current = true;
11170
+ openTab(first);
11171
+ }, [cfg.cloud, groups, tabs.length, openTab]);
11172
+
10920
11173
  const openSystem = useCallback(
10921
11174
  (dsName) => {
10922
11175
  // DsFolderRow passes the clicked DS name → scope the System view to it so
@@ -13386,10 +13639,17 @@ function App() {
13386
13639
  }
13387
13640
  // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
13388
13641
  // canvas highlighter tool (same letter, different action by focus).
13389
- // Cloud Phase 25 C2 — absent for a viewer (the panel is edit chrome).
13642
+ //
13643
+ // Cloud Phase 27 C1 — a VIEWER GETS THIS TOO, and the line above used to
13644
+ // say the opposite ("the panel is edit chrome"). C1 reversed that on
13645
+ // purpose: read-only means cannot CHANGE, not cannot SEE, and a reviewer
13646
+ // needs structure and measured values as much as anyone. The View menu
13647
+ // already offers it (`viewerHiddenPanels` no longer hides it); the
13648
+ // shortcut had been left behind, so the menu said yes and the keyboard
13649
+ // said no. Found by writing the parity spec that runs in both shells.
13390
13650
  if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
13391
13651
  e.preventDefault();
13392
- if (!viewerMode) toggleRightPanel('inspector');
13652
+ toggleRightPanel('inspector');
13393
13653
  return;
13394
13654
  }
13395
13655
  // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
@@ -13718,9 +13978,15 @@ function App() {
13718
13978
  // assigned to it and which one is active (the open one). Layers is only a
13719
13979
  // dockable panel in `separate` mode; Assistant is native-only.
13720
13980
  const panelAvailable = (id) => {
13721
- // Cloud Phase 25 C2 — edit-chrome panels don't exist for a viewer: no
13722
- // dock tab, no shortcut, no empty slot.
13723
- if (viewerMode && (id === 'assistant' || id === 'inspector' || id === 'layers')) return false;
13981
+ // The AGENT is genuinely absent for a viewer (it edits; Phase 25 C2).
13982
+ //
13983
+ // Inspector and Layers are NOT — Cloud Phase 27 C1 reversed that, and this
13984
+ // line had been left behind: `viewerHiddenPanels` stopped hiding them in
13985
+ // the View menu while this still refused to give them a dock slot, so the
13986
+ // menu offered a panel that could never appear. Read-only means cannot
13987
+ // CHANGE, not cannot SEE: a reviewer needs structure and measured values,
13988
+ // which is the whole reason C1 exists.
13989
+ if (viewerMode && id === 'assistant') return false;
13724
13990
  return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
13725
13991
  };
13726
13992
  const idsForSide = (side) =>
@@ -13753,12 +14019,15 @@ function App() {
13753
14019
  // which owns the resizable width). Assistant is handled separately below as an
13754
14020
  // always-mounted ChatPanel so its stream survives a tab switch.
13755
14021
  const renderPanelBody = (id) => {
13756
- // Cloud Phase 25 C2 — the Inspector/Layers panels are edit chrome; a
13757
- // viewer's session never mounts them (absent, not hidden).
13758
- if (viewerMode && (id === 'inspector' || id === 'layers')) return null;
14022
+ // C1 again, the second half of the same gate. The panels MOUNT for a
14023
+ // viewer; what they must not do is offer an edit, and that is already
14024
+ // handled inside them (`readOnly` is threaded through every control).
14025
+ // Refusing to mount was a blunter instrument than the role model asks for.
14026
+
13759
14027
  if (id === 'tree')
13760
14028
  return (
13761
14029
  <Sidebar
14030
+ cloud={cfg.cloud}
13762
14031
  readOnly={viewerMode}
13763
14032
  groups={groups}
13764
14033
  activePath={activePath}
@@ -13813,6 +14082,9 @@ function App() {
13813
14082
  if (id === 'inspector' || id === 'layers')
13814
14083
  return (
13815
14084
  <InspectorPanel
14085
+ // Cloud Phase 27 E4 — the parity spec asserts these two panels exist
14086
+ // in BOTH shells, so each needs a name a spec can ask for.
14087
+ testId={id === 'layers' ? 'layers-panel' : 'inspector-panel'}
13816
14088
  layersOnly={id === 'layers'}
13817
14089
  hideLayersTab={layersMode === 'separate'}
13818
14090
  cpMode={cpMode}
@@ -13893,6 +14165,7 @@ function App() {
13893
14165
  onContextMenu={onShellContextMenu}
13894
14166
  >
13895
14167
  {firstRun && <OnboardingWizard />}
14168
+ <CloudRoleBanner cloud={cfg.cloud} />
13896
14169
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
13897
14170
  <SyncBanner status={syncStatus} />
13898
14171
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
@@ -13920,6 +14193,10 @@ function App() {
13920
14193
  <div className="st-shell">
13921
14194
  <Menubar
13922
14195
  readOnly={viewerMode}
14196
+ // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
14197
+ // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
14198
+ // in a browser tab on somebody else's machine, and nothing else.
14199
+ cloud={cfg.cloud}
13923
14200
  activePath={activePath}
13924
14201
  project={project}
13925
14202
  tabsCount={tabs.length}
@@ -14919,6 +15196,7 @@ function App() {
14919
15196
  )}
14920
15197
  {settingsOpen && (
14921
15198
  <SettingsPanel
15199
+ cloud={cfg.cloud}
14922
15200
  onClose={() => setSettingsOpen(false)}
14923
15201
  initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
14924
15202
  theme={theme}
@@ -14994,7 +15272,16 @@ function App() {
14994
15272
  startTour(USAGE_TOUR);
14995
15273
  }}
14996
15274
  />
14997
- <ReportBugDialog open={reportBugOpen} onClose={() => setReportBugOpen(false)} />
15275
+ {/* `activeCanvas` is THIS window's truth. The server's `_active.json` is
15276
+ global and sticky — it survives closing every tab and is written by any
15277
+ other session (an agent driving a headless browser, a second window),
15278
+ so a report built from it can claim a canvas the user does not have
15279
+ open, and capture it. Pass what this client actually shows. */}
15280
+ <ReportBugDialog
15281
+ open={reportBugOpen}
15282
+ activeCanvas={activePath}
15283
+ onClose={() => setReportBugOpen(false)}
15284
+ />
14998
15285
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
14999
15286
  <ExportPanel center={exportCenter} />
15000
15287
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />
@@ -41,6 +41,13 @@ export function canvasUrl(p, cfg, opts) {
41
41
  // reads this flag). Boot-static on purpose — a role is per-session, and a
42
42
  // query param can't race the way a post-load message could.
43
43
  if (cfg?.readOnly) params.set('ro', '1');
44
+ // Cloud Phase 27 (DDR-209) — the canvas origin's capability. On a desktop the
45
+ // canvas origin is loopback and needs none, so this is absent and the URL is
46
+ // byte-identical to before. In the cloud it is a cookieless, cross-site
47
+ // origin: a cookie scoped widely enough to cover it would be readable by the
48
+ // untrusted canvas content itself, which is the one thing the DDR-054 split
49
+ // exists to prevent — so the capability lives in the URL instead.
50
+ if (cfg?.canvasToken) params.set('t', cfg.canvasToken);
44
51
  const ds0 = cfg?.designSystems?.[0];
45
52
  // Specimen detection: anything under `system/<ds>/preview/` belongs to that
46
53
  // specific DS, so it must render with *that* DS's tokens — not always the
@@ -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;
@@ -11,7 +11,7 @@
11
11
  // (st-scrim / st-dialog). Icons are local Lucide-line paths (IdentityBar
12
12
  // precedent); colors are theme tokens only (no hardcoded hex).
13
13
 
14
- import { useCallback, useEffect, useState } from 'react';
14
+ import { useCallback, useEffect, useMemo, useState } from 'react';
15
15
 
16
16
  function Icon({ name, size = 16 }) {
17
17
  const p = {
@@ -588,9 +588,21 @@ const TABS = [
588
588
  { id: 'appearance', label: 'Appearance' },
589
589
  { id: 'canvas-view', label: 'Canvas & View' },
590
590
  { id: 'layout', label: 'Layout' },
591
- { id: 'ai-generation', label: 'AI generation' },
592
- { id: 'subtitles', label: 'Subtitles' },
593
- { id: 'video', label: 'Video' },
591
+ // Cloud Phase 27 — `local: true` means "this tab configures something that
592
+ // runs on the machine Maude is installed on". A browser tab has no ffmpeg, no
593
+ // mlx-vlm, no HuggingFace cache and no provider key of yours, and the routes
594
+ // behind these panes are refused in a cell by design (DDR-209 D1) — so in the
595
+ // cloud they rendered as a wall of `HTTP 404`.
596
+ //
597
+ // ABSENT, not disabled. C2 says state an absence where the thing would be,
598
+ // and that rule earns its keep for the agent chat: the feature exists, you
599
+ // just need the desktop app. These are settings for machinery that is not
600
+ // missing so much as inapplicable — a preference pane for hardware this shell
601
+ // does not have. There is nothing for the reader to act on, so a whole tab of
602
+ // dead controls is worse than no tab.
603
+ { id: 'ai-generation', label: 'AI generation', local: true },
604
+ { id: 'subtitles', label: 'Subtitles', local: true },
605
+ { id: 'video', label: 'Video', local: true },
594
606
  ];
595
607
  const SETTINGS_TAB_STORE = 'mdcc-settings-tab';
596
608
 
@@ -789,6 +801,13 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
789
801
  }
790
802
 
791
803
  export default function SettingsPanel({
804
+ /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
805
+ // Tri-state (see the note where this value is created): `undefined` until
806
+ // the server config answers, `null` for the desktop, an object for a cloud
807
+ // tab. Defaulting it to `null` here is what re-broke the boot 404 after the
808
+ // call sites were fixed — a default fires on `undefined`, so "not known yet"
809
+ // became "not cloud" one layer further in, invisibly.
810
+ cloud,
792
811
  onClose,
793
812
  initialTab,
794
813
  theme,
@@ -811,11 +830,17 @@ export default function SettingsPanel({
811
830
  }) {
812
831
  const [providers, setProviders] = useState(null); // null = loading
813
832
  const [error, setError] = useState(null);
833
+ // The tabs THIS shell can honour. See TABS for why the local-machine ones are
834
+ // absent in a browser tab rather than disabled.
835
+ const tabs = useMemo(() => TABS.filter((t) => !(cloud && t.local)), [cloud]);
814
836
  const [tab, setTab] = useState(() => {
815
- if (initialTab && TABS.some((t) => t.id === initialTab)) return initialTab;
837
+ // A remembered tab that this shell does not have (you opened Video on the
838
+ // desktop, then opened Settings in the cloud) must not select nothing.
839
+ const has = (id) => TABS.some((t) => t.id === id && !(cloud && t.local));
840
+ if (initialTab && has(initialTab)) return initialTab;
816
841
  try {
817
842
  const v = localStorage.getItem(SETTINGS_TAB_STORE);
818
- if (v && TABS.some((t) => t.id === v)) return v;
843
+ if (v && has(v)) return v;
819
844
  } catch {}
820
845
  return 'appearance';
821
846
  });
@@ -853,19 +878,19 @@ export default function SettingsPanel({
853
878
 
854
879
  // Vertical roving focus over the tab rail (native-menu parity).
855
880
  function onRailKey(e) {
856
- const idx = TABS.findIndex((t) => t.id === tab);
881
+ const idx = tabs.findIndex((t) => t.id === tab);
857
882
  if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
858
883
  e.preventDefault();
859
- setTab(TABS[(idx + 1) % TABS.length].id);
884
+ setTab(tabs[(idx + 1) % tabs.length].id);
860
885
  } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
861
886
  e.preventDefault();
862
- setTab(TABS[(idx - 1 + TABS.length) % TABS.length].id);
887
+ setTab(tabs[(idx - 1 + tabs.length) % tabs.length].id);
863
888
  } else if (e.key === 'Home') {
864
889
  e.preventDefault();
865
- setTab(TABS[0].id);
890
+ setTab(tabs[0].id);
866
891
  } else if (e.key === 'End') {
867
892
  e.preventDefault();
868
- setTab(TABS[TABS.length - 1].id);
893
+ setTab(tabs[tabs.length - 1].id);
869
894
  }
870
895
  }
871
896
 
@@ -896,7 +921,7 @@ export default function SettingsPanel({
896
921
  aria-label="Settings categories"
897
922
  onKeyDown={onRailKey}
898
923
  >
899
- {TABS.map((t) => (
924
+ {tabs.map((t) => (
900
925
  <button
901
926
  key={t.id}
902
927
  type="button"