@1agh/maude 0.53.2 → 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 (46) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +43 -3
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/client/app.jsx +208 -25
  5. package/apps/studio/client/export-center.jsx +10 -2
  6. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  7. package/apps/studio/client/styles/4-components.css +40 -0
  8. package/apps/studio/cloud-build.ts +208 -0
  9. package/apps/studio/collab/room.ts +43 -7
  10. package/apps/studio/context.ts +12 -5
  11. package/apps/studio/dist/client.bundle.js +1007 -1007
  12. package/apps/studio/dist/comment-mount.js +2 -2
  13. package/apps/studio/dist/styles.css +1 -1
  14. package/apps/studio/git/repo-lock.ts +305 -0
  15. package/apps/studio/git/service.ts +171 -21
  16. package/apps/studio/http.ts +145 -12
  17. package/apps/studio/inspect.ts +66 -6
  18. package/apps/studio/server.ts +114 -17
  19. package/apps/studio/session-scope.ts +101 -0
  20. package/apps/studio/sync/autocommit.ts +84 -46
  21. package/apps/studio/sync/index.ts +20 -0
  22. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  23. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  24. package/apps/studio/test/cloud-build.test.ts +95 -0
  25. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  26. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  27. package/apps/studio/test/git-system-engine.test.ts +135 -0
  28. package/apps/studio/test/read-only-gate.test.ts +8 -0
  29. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  30. package/apps/studio/test/repo-lock.test.ts +350 -0
  31. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  32. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  33. package/apps/studio/test/session-scope.test.ts +100 -0
  34. package/apps/studio/test/shell-importmap.test.ts +8 -1
  35. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  36. package/apps/studio/test/sync-hardening.test.ts +75 -0
  37. package/apps/studio/use-collab.tsx +21 -1
  38. package/apps/studio/whats-new.json +36 -0
  39. package/apps/studio/ws.ts +44 -8
  40. package/cli/commands/kg.mjs +49 -19
  41. package/cli/lib/gitignore-block.mjs +3 -0
  42. package/package.json +11 -9
  43. package/plugins/design/dependencies.json +1 -1
  44. package/plugins/design/templates/_shell.html +66 -32
  45. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  46. package/plugins/flow/dependencies.json +1 -1
@@ -98,6 +98,7 @@ import { createHistory } from './history.ts';
98
98
  import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
99
99
  import { STICKERS_DIR } from './paths.ts';
100
100
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
101
+ import { sessionDir } from './session-scope.ts';
101
102
 
102
103
  // Directories that never hold user-facing canvases. Exported so the
103
104
  // external-canvas watcher (`canvas-list-watch.ts`) shares one source instead of
@@ -1324,8 +1325,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1324
1325
 
1325
1326
  // ---------- Canvas state ----------
1326
1327
 
1328
+ // Cloud Phase 27 D3 — one member's place in the project is not another's.
1329
+ // `sessionDir` is a no-op without an ambient session, so a desktop resolves
1330
+ // the identical path it always did.
1327
1331
  function canvasStatePath(file: string): string {
1328
- return path.join(paths.canvasStateDir, `${fileSlug(file)}.json`);
1332
+ return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.json`);
1329
1333
  }
1330
1334
 
1331
1335
  async function loadCanvasState(file: string) {
@@ -1468,7 +1472,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1468
1472
  * canvas-ext). Returns null when `file` is not a valid canvas path. */
1469
1473
  function canvasViewPath(file: string): string | null {
1470
1474
  if (!canvasMetaPath(file)) return null; // reuse the containment + ext gate
1471
- return path.join(paths.canvasStateDir, `${fileSlug(file)}.view.json`);
1475
+ // D3 again — the camera is the other per-machine singleton two members in
1476
+ // one cell were silently sharing.
1477
+ return path.join(sessionDir(paths.canvasStateDir), `${fileSlug(file)}.view.json`);
1472
1478
  }
1473
1479
 
1474
1480
  const MAX_OVERLAY_KEYS = 32;
@@ -1572,7 +1578,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1572
1578
  const vp = normalizeViewport(viewport);
1573
1579
  if (!vp) return null;
1574
1580
  try {
1575
- await mkdir(paths.canvasStateDir, { recursive: true });
1581
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1576
1582
  const current = await readCanvasViewRaw(file);
1577
1583
  await Bun.write(viewAbs, `${JSON.stringify({ ...current, viewport: vp }, null, 2)}\n`);
1578
1584
  return vp;
@@ -1597,7 +1603,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1597
1603
  const ov = normalizeOverlays(overlays);
1598
1604
  if (ov === null) return null;
1599
1605
  try {
1600
- await mkdir(paths.canvasStateDir, { recursive: true });
1606
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1601
1607
  const current = await readCanvasViewRaw(file);
1602
1608
  const prevOverlays =
1603
1609
  current.overlays && typeof current.overlays === 'object' && !Array.isArray(current.overlays)
@@ -1710,7 +1716,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1710
1716
  try {
1711
1717
  const current = await readCanvasViewRaw(file);
1712
1718
  const { overlays: _drop, ...rest } = current;
1713
- await mkdir(paths.canvasStateDir, { recursive: true });
1719
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1714
1720
  await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1715
1721
  } catch {
1716
1722
  /* best-effort clear */
@@ -1732,12 +1738,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1732
1738
  const current = await readCanvasViewRaw(file);
1733
1739
  if (patch.locked === null) {
1734
1740
  const { locked: _drop, ...rest } = current;
1735
- await mkdir(paths.canvasStateDir, { recursive: true });
1741
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1736
1742
  await Bun.write(viewAbs, `${JSON.stringify(rest, null, 2)}\n`);
1737
1743
  } else {
1738
1744
  const lk = normalizeLocked(patch.locked);
1739
1745
  if (lk !== null) {
1740
- await mkdir(paths.canvasStateDir, { recursive: true });
1746
+ await mkdir(sessionDir(paths.canvasStateDir), { recursive: true });
1741
1747
  await Bun.write(viewAbs, `${JSON.stringify({ ...current, locked: lk }, null, 2)}\n`);
1742
1748
  }
1743
1749
  }
@@ -292,6 +292,27 @@ pw_screenshot() {
292
292
  # iframe to appear. Every leg is best-effort — chrome is the point of this mode,
293
293
  # so a missing row or a slow mount degrades to "chrome, nothing open" rather
294
294
  # than failing the capture.
295
+ # A reachable port is not a mounted app: the server can answer while the client
296
+ # bundle is still parsing, or React can fail to mount and leave an empty #root
297
+ # (the exact shape of the v0.51.1 blank-window bug). Wait for the studio's own
298
+ # file list before capturing, so a broken shell fails loud instead of shipping a
299
+ # blank PNG that looks like a real screenshot.
300
+ await_studio_shell() {
301
+ [ "$ENGINE" = "agent-browser" ] || return 0
302
+ local poll=0
303
+ while [ $poll -lt "$TIMEOUT" ]; do
304
+ local n
305
+ n=$("$AB" eval "document.querySelectorAll('[data-testid^=\"canvas-row-\"]').length" 2>/dev/null | tr -d '[:space:]')
306
+ case "$n" in
307
+ ''|*[!0-9]*|0) ;;
308
+ *) return 0 ;;
309
+ esac
310
+ sleep 1
311
+ poll=$((poll + 1))
312
+ done
313
+ return 1
314
+ }
315
+
295
316
  open_active_in_shell() {
296
317
  [ "$ENGINE" = "agent-browser" ] || return 0
297
318
  [ -n "$ACTIVE" ] || { echo "→ shell: no active canvas — capturing chrome as-is" >&2; return 0; }
@@ -332,11 +353,22 @@ open_active_in_shell() {
332
353
 
333
354
  navigate_once() {
334
355
  if [ "$ENGINE" = "agent-browser" ]; then
335
- "$AB" open "$URL" >&2
356
+ # A failed navigation MUST propagate. agent-browser leaves the session on a
357
+ # blank page and `screenshot` then happily writes a ~4 kB white PNG, so
358
+ # ignoring this exit code turns "the server is down" into "here is your
359
+ # screenshot" — which for the Report-a-Bug shell shot means a blank image
360
+ # labelled "Maude window" lands in someone's bug report.
361
+ if ! "$AB" open "$URL" >&2; then
362
+ echo "✗ navigation failed: $URL" >&2
363
+ return 1
364
+ fi
336
365
  # Shell mode lands on the studio root, which has no DC mount to poll for —
337
366
  # the canvas arrives only after we click a row.
338
367
  if [ "$MODE" = "shell" ]; then
339
- sleep 1
368
+ if ! await_studio_shell; then
369
+ echo "✗ the studio shell never mounted at $URL" >&2
370
+ return 1
371
+ fi
340
372
  open_active_in_shell
341
373
  apply_theme_override
342
374
  return 0
@@ -503,7 +535,15 @@ if [ $ALL_SCREENS -eq 1 ]; then
503
535
  fi
504
536
 
505
537
  # ---------- single-shot ----------
506
- navigate_once
538
+ # A failed navigation is exactly the port-bounce case (DDR-094), so give
539
+ # relive_url its one shot before giving up rather than capturing a blank page.
540
+ if ! navigate_once; then
541
+ if ! relive_url || ! navigate_once; then
542
+ echo "✗ could not load $URL — no screenshot written" >&2
543
+ rm -f "$OUT"
544
+ exit 3
545
+ fi
546
+ fi
507
547
  if capture_resilient "$CSS_SEL" "$OUT"; then
508
548
  echo "$OUT"
509
549
  exit 0
@@ -21,6 +21,8 @@ import { fileURLToPath } from 'node:url';
21
21
 
22
22
  import { browserslistToTargets, bundle as lcssBundle } from 'lightningcss';
23
23
 
24
+ import { cloudStubPlugin } from './cloud-build.ts';
25
+
24
26
  const ROOT = dirname(fileURLToPath(import.meta.url));
25
27
  const DIST = join(ROOT, 'dist');
26
28
 
@@ -32,6 +34,9 @@ const MODE: 'dev' | 'release' | 'dry' = ARGS.has('--dry-run')
32
34
  ? 'release'
33
35
  : 'dev';
34
36
  const WATCH = ARGS.has('--watch');
37
+ /** Cloud Phase 27 D1 — build the CELL's variant (secret-bearing surfaces
38
+ * removed from the binary, not merely un-routed). `--cloud` with `--release`. */
39
+ const CLOUD = ARGS.has('--cloud');
35
40
 
36
41
  const PLATFORM_MATRIX = [
37
42
  'bun-darwin-arm64',
@@ -336,11 +341,20 @@ import ${JSON.stringify(realServerRel)};
336
341
  return entryPath;
337
342
  }
338
343
 
339
- async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: string }> {
344
+ async function buildServerBinary(
345
+ target: PlatformTarget,
346
+ /**
347
+ * Cloud Phase 27 D1 — the variant a CELL runs, with the secret-bearing
348
+ * surfaces removed from the binary rather than merely un-routed. Named
349
+ * separately (`maude-cloud-<slug>`) so a desktop release can never pick one
350
+ * up by accident, and so both can be built from one checkout.
351
+ */
352
+ { cloud = false }: { cloud?: boolean } = {}
353
+ ): Promise<{ outPath: string }> {
340
354
  ensureDist();
341
355
  const slug = platformSlug(target);
342
356
  const ext = slug.startsWith('win32') ? '.exe' : '';
343
- const outPath = join(DIST, `maude-${slug}${ext}`);
357
+ const outPath = join(DIST, `maude-${cloud ? 'cloud-' : ''}${slug}${ext}`);
344
358
  const realEntry = join(ROOT, 'server.ts');
345
359
  if (!existsSync(realEntry)) {
346
360
  // Legacy fallback — pre-DDR-009 .mjs path. Doesn't use oxc-parser, so the
@@ -369,6 +383,26 @@ async function buildServerBinary(target: PlatformTarget): Promise<{ outPath: str
369
383
  // filter skipped it (cross-compile case — see ensureBindingForTarget docs).
370
384
  await ensureBindingForTarget(oxcBindingSlug(slug));
371
385
  const entry = writeCompileEntry(target);
386
+
387
+ // The CLOUD variant goes through the Bun.build API rather than the CLI,
388
+ // because that is the only path that takes a plugin — and a plugin is what
389
+ // makes D1 elimination rather than un-routing. `--define` + dead-code
390
+ // elimination was tried first and does not do it: Bun inlines the
391
+ // dynamically-imported module and keeps the branch, so the bytes stay.
392
+ if (cloud) {
393
+ const result = await Bun.build({
394
+ entrypoints: [entry],
395
+ target: 'bun',
396
+ minify: true,
397
+ plugins: [cloudStubPlugin(ROOT)],
398
+ compile: { target, outfile: outPath },
399
+ } as Parameters<typeof Bun.build>[0]);
400
+ if (!result.success) {
401
+ throw new Error(`cloud compile failed for ${target}:\n${result.logs.map(String).join('\n')}`);
402
+ }
403
+ return { outPath };
404
+ }
405
+
372
406
  const proc = Bun.spawn(
373
407
  [
374
408
  'bun',
@@ -538,7 +572,7 @@ async function main() {
538
572
  : [currentTarget()];
539
573
  for (const target of targets) {
540
574
  const t = performance.now();
541
- const bin = await buildServerBinary(target);
575
+ const bin = await buildServerBinary(target, { cloud: CLOUD });
542
576
  console.log(`[build] ${bin.outPath} (${(performance.now() - t).toFixed(0)} ms)`);
543
577
  }
544
578
  }
@@ -2430,7 +2430,12 @@ function Sidebar({
2430
2430
  // affordances are absent (buttons, composer, row menus, drag & drop).
2431
2431
  readOnly = false,
2432
2432
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
2433
- cloud = 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,
2434
2439
  groups,
2435
2440
  activePath,
2436
2441
  activeDsName,
@@ -2709,6 +2714,7 @@ function Sidebar({
2709
2714
  <StIcon name="search" size={13} />
2710
2715
  <input
2711
2716
  type="search"
2717
+ data-testid="canvas-search"
2712
2718
  placeholder="Search canvases…"
2713
2719
  value={search}
2714
2720
  onChange={(e) => setSearch(e.target.value)}
@@ -2832,7 +2838,7 @@ function Sidebar({
2832
2838
  Absent rather than disabled, because unlike the agent chat there is
2833
2839
  nothing here to explain — the capability is not missing, it is
2834
2840
  already satisfied. */}
2835
- {cloud ? null : <CloudBar />}
2841
+ {cloud === null ? <CloudBar /> : null}
2836
2842
  {/* Phase 28 (E3) — GitHub identity as a compact avatar docked at the BOTTOM:
2837
2843
  sign in, connected account + New/Pull/Share, sign out. Self-contained
2838
2844
  (owns its device-code + CreateProject dialogs). Renders nothing in browser. */}
@@ -3622,7 +3628,12 @@ function Menubar({
3622
3628
  activePath,
3623
3629
  project,
3624
3630
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
3625
- cloud = 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,
3626
3637
  tabsCount,
3627
3638
  openMenu,
3628
3639
  setOpenMenu,
@@ -3890,7 +3901,12 @@ function Menubar({
3890
3901
  }, [openMenu, setOpenMenu]);
3891
3902
 
3892
3903
  return (
3893
- <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
+ >
3894
3910
  <span className="st-brand" data-tour="brand">
3895
3911
  <span className="st-brand-mark">
3896
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>
@@ -4044,11 +4060,45 @@ function Menubar({
4044
4060
  <span
4045
4061
  className="st-stamp st-stamp--viewonly"
4046
4062
  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."
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
+ }
4048
4068
  >
4049
4069
  VIEW ONLY
4050
4070
  </span>
4051
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}
4052
4102
  {isNativeApp() && !readOnly && (
4053
4103
  <button
4054
4104
  type="button"
@@ -4514,7 +4564,7 @@ function StatusBar({
4514
4564
  })();
4515
4565
 
4516
4566
  return (
4517
- <footer className="st-statusbar" role="contentinfo">
4567
+ <footer className="st-statusbar" role="contentinfo" data-testid="statusbar">
4518
4568
  <span className="st-sb-slot st-sb-active" role="group" aria-label="Active file">
4519
4569
  <span className="lead" aria-hidden="true" />
4520
4570
  <span className="lbl">active</span>
@@ -4792,6 +4842,69 @@ function CommentsPanel({
4792
4842
  // Phase 32 (Task 1) — auto-update notice. Shown after the shell has downloaded +
4793
4843
  // staged a newer build in the background. Non-blocking: "Restart now" applies it,
4794
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
+
4795
4908
  function UpdateBanner({ update, onDismiss }) {
4796
4909
  const [restarting, setRestarting] = useState(false);
4797
4910
  if (!update) return null;
@@ -8304,6 +8417,9 @@ function photoAssetOfSelection(el) {
8304
8417
  }
8305
8418
 
8306
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,
8307
8423
  selected,
8308
8424
  onClose,
8309
8425
  layersTree,
@@ -8773,6 +8889,7 @@ function InspectorPanel({
8773
8889
  style={width ? { width, flexBasis: width } : undefined}
8774
8890
  aria-label="Inspector"
8775
8891
  data-tour="inspector"
8892
+ data-testid={testId}
8776
8893
  >
8777
8894
  <div
8778
8895
  className="st-rp-tabs"
@@ -9236,7 +9353,18 @@ function App() {
9236
9353
  // `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
9237
9354
  // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
9238
9355
  // ?designRel + ?tokens query to the canvas mount shell.
9239
- 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 });
9240
9368
  // Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
9241
9369
  // editing affordance in the shell gates on this (absent, not hidden).
9242
9370
  const viewerMode = !!cfg.readOnly;
@@ -9278,13 +9406,11 @@ function App() {
9278
9406
  // both of these went missing and the cloud studio rendered without
9279
9407
  // its chrome and with every canvas iframe unauthenticated.
9280
9408
  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.
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.
9284
9413
  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
9414
  }));
9289
9415
  })
9290
9416
  .catch(() => {});
@@ -9633,7 +9759,10 @@ function App() {
9633
9759
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
9634
9760
  const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
9635
9761
  const whatsNew = useWhatsNew(MDCC_VERSION);
9636
- 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 });
9637
9766
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
9638
9767
  // "welcome" project on first launch; we ask it whether this is a first run and, if
9639
9768
  // so, show the wizard OVER the (empty) canvas browser. Completing any door switches
@@ -11006,6 +11135,41 @@ function App() {
11006
11135
  if (path !== SYSTEM_TAB) setLoadingPath(path);
11007
11136
  }, []);
11008
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
+
11009
11173
  const openSystem = useCallback(
11010
11174
  (dsName) => {
11011
11175
  // DsFolderRow passes the clicked DS name → scope the System view to it so
@@ -13475,10 +13639,17 @@ function App() {
13475
13639
  }
13476
13640
  // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
13477
13641
  // canvas highlighter tool (same letter, different action by focus).
13478
- // 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.
13479
13650
  if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
13480
13651
  e.preventDefault();
13481
- if (!viewerMode) toggleRightPanel('inspector');
13652
+ toggleRightPanel('inspector');
13482
13653
  return;
13483
13654
  }
13484
13655
  // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
@@ -13807,9 +13978,15 @@ function App() {
13807
13978
  // assigned to it and which one is active (the open one). Layers is only a
13808
13979
  // dockable panel in `separate` mode; Assistant is native-only.
13809
13980
  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;
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;
13813
13990
  return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
13814
13991
  };
13815
13992
  const idsForSide = (side) =>
@@ -13842,13 +14019,15 @@ function App() {
13842
14019
  // which owns the resizable width). Assistant is handled separately below as an
13843
14020
  // always-mounted ChatPanel so its stream survives a tab switch.
13844
14021
  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;
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
+
13848
14027
  if (id === 'tree')
13849
14028
  return (
13850
14029
  <Sidebar
13851
- cloud={cfg?.cloud ?? null}
14030
+ cloud={cfg.cloud}
13852
14031
  readOnly={viewerMode}
13853
14032
  groups={groups}
13854
14033
  activePath={activePath}
@@ -13903,6 +14082,9 @@ function App() {
13903
14082
  if (id === 'inspector' || id === 'layers')
13904
14083
  return (
13905
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'}
13906
14088
  layersOnly={id === 'layers'}
13907
14089
  hideLayersTab={layersMode === 'separate'}
13908
14090
  cpMode={cpMode}
@@ -13983,6 +14165,7 @@ function App() {
13983
14165
  onContextMenu={onShellContextMenu}
13984
14166
  >
13985
14167
  {firstRun && <OnboardingWizard />}
14168
+ <CloudRoleBanner cloud={cfg.cloud} />
13986
14169
  <UpdateBanner update={updateReady} onDismiss={() => setUpdateReady(null)} />
13987
14170
  <SyncBanner status={syncStatus} />
13988
14171
  {!usageNudge && !tourSteps && <WhatsNewToast wn={whatsNew} />}
@@ -14013,7 +14196,7 @@ function App() {
14013
14196
  // Cloud Phase 27 C2/C4 — the ONE cloud-only input the shared chrome
14014
14197
  // takes. A prop, not the whole `cfg`: the Menubar needs to know it is
14015
14198
  // in a browser tab on somebody else's machine, and nothing else.
14016
- cloud={cfg?.cloud ?? null}
14199
+ cloud={cfg.cloud}
14017
14200
  activePath={activePath}
14018
14201
  project={project}
14019
14202
  tabsCount={tabs.length}
@@ -15013,7 +15196,7 @@ function App() {
15013
15196
  )}
15014
15197
  {settingsOpen && (
15015
15198
  <SettingsPanel
15016
- cloud={cfg?.cloud ?? null}
15199
+ cloud={cfg.cloud}
15017
15200
  onClose={() => setSettingsOpen(false)}
15018
15201
  initialTab={typeof settingsOpen === 'string' ? settingsOpen : undefined}
15019
15202
  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;
@@ -802,7 +802,12 @@ function AppearanceTab({ theme, onSetTheme, cpMode, onSetCpMode }) {
802
802
 
803
803
  export default function SettingsPanel({
804
804
  /** `{ dashboardUrl, projectName }` when this is a cloud tab, else null. */
805
- cloud = 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,
806
811
  onClose,
807
812
  initialTab,
808
813
  theme,