@1agh/maude 0.52.2 → 0.53.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.
@@ -325,7 +325,7 @@ function shellToast(message, ok = false, action) {
325
325
  // pressed. Surface that ONCE, gated by a localStorage marker (same family as
326
326
  // `mdcc-whatsnew-seen`). Auto-dismisses; a V press (learned) clears it early.
327
327
  const BROWSE_HINT_SEEN = 'maude-browse-hint-seen';
328
- function browseFirstRunHint() {
328
+ function browseFirstRunHint(readOnly = false) {
329
329
  if (typeof document === 'undefined' || typeof localStorage === 'undefined') return;
330
330
  try {
331
331
  if (localStorage.getItem(BROWSE_HINT_SEEN) === '1') return;
@@ -346,7 +346,9 @@ function browseFirstRunHint() {
346
346
  '<span>Your mock is <strong>live</strong> — click things to try it. ' +
347
347
  'Press <kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
348
348
  'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd> ' +
349
- 'to select &amp; edit like Figma.</span>' +
349
+ // Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
350
+ // hint must not promise an editor the role doesn't have.
351
+ (readOnly ? 'to select &amp; inspect.</span>' : 'to select &amp; edit like Figma.</span>') +
350
352
  '<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
351
353
  'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
352
354
  const dismiss = () => {
@@ -2424,6 +2426,9 @@ function sectionMetaFor(g) {
2424
2426
  }
2425
2427
 
2426
2428
  function Sidebar({
2429
+ // Cloud Phase 25 C2 — viewer role: create / delete / move / rename
2430
+ // affordances are absent (buttons, composer, row menus, drag & drop).
2431
+ readOnly = false,
2427
2432
  groups,
2428
2433
  activePath,
2429
2434
  activeDsName,
@@ -2588,21 +2593,23 @@ function Sidebar({
2588
2593
  <div className="st-sb-hd">
2589
2594
  <span className="st-sb-title">Files</span>
2590
2595
  <div className="st-sb-hd-actions">
2591
- <button
2592
- type="button"
2593
- className="st-iconbtn"
2594
- data-tip="New blank brief board"
2595
- aria-label="New blank brief board"
2596
- aria-expanded={creating && composerMode === 'board'}
2597
- onClick={() => {
2598
- setNewErr('');
2599
- setComposerMode('board');
2600
- setCreating((v) => (composerMode === 'board' ? !v : true));
2601
- }}
2602
- >
2603
- <StIcon name="plus" size={15} />
2604
- </button>
2605
- {defaultFolderParent && (
2596
+ {!readOnly && (
2597
+ <button
2598
+ type="button"
2599
+ className="st-iconbtn"
2600
+ data-tip="New blank brief board"
2601
+ aria-label="New blank brief board"
2602
+ aria-expanded={creating && composerMode === 'board'}
2603
+ onClick={() => {
2604
+ setNewErr('');
2605
+ setComposerMode('board');
2606
+ setCreating((v) => (composerMode === 'board' ? !v : true));
2607
+ }}
2608
+ >
2609
+ <StIcon name="plus" size={15} />
2610
+ </button>
2611
+ )}
2612
+ {!readOnly && defaultFolderParent && (
2606
2613
  <button
2607
2614
  type="button"
2608
2615
  className="st-iconbtn"
@@ -2758,7 +2765,7 @@ function Sidebar({
2758
2765
  // root". Without this a canvas inside a folder could never return
2759
2766
  // to the top level via drag & drop — DirRow only covers actual
2760
2767
  // subfolders, never the group root itself.
2761
- const canDropOnRoot = !isDs && g.kind === 'canvas';
2768
+ const canDropOnRoot = !isDs && g.kind === 'canvas' && !readOnly;
2762
2769
  const rootDropHandlers = canDropOnRoot ? treeDrag.dropProps(g.fullPath, true) : {};
2763
2770
  const isRootOver = treeDrag.overDir === g.fullPath;
2764
2771
  return (
@@ -2790,12 +2797,12 @@ function Sidebar({
2790
2797
  dsFolders={g.dsFolders}
2791
2798
  activeDsName={activeDsName}
2792
2799
  onOpenSystem={isDs ? onOpenSystem : undefined}
2793
- onDelete={isDs ? undefined : onDeleteBoard}
2800
+ onDelete={isDs || readOnly ? undefined : onDeleteBoard}
2794
2801
  dirtyByPath={dirtyByPath}
2795
2802
  canvasKinds={canvasKinds}
2796
2803
  dirPath={g.fullPath}
2797
- drag={!isDs && g.kind === 'canvas' ? treeDrag : undefined}
2798
- menu={!isDs && g.kind === 'canvas' ? rowMenu : undefined}
2804
+ drag={!isDs && g.kind === 'canvas' && !readOnly ? treeDrag : undefined}
2805
+ menu={!isDs && g.kind === 'canvas' && !readOnly ? rowMenu : undefined}
2799
2806
  />
2800
2807
  ) : (
2801
2808
  <div className="st-tree-empty">{search ? 'No matches.' : 'Empty.'}</div>
@@ -3460,7 +3467,7 @@ function HelpDropdown({ onAction, onClose }) {
3460
3467
  );
3461
3468
  }
3462
3469
 
3463
- function SelectionDropdown({ onAction, onClose }) {
3470
+ function SelectionDropdown({ onAction, onClose, readOnly = false }) {
3464
3471
  return (
3465
3472
  <DropdownMenu
3466
3473
  label="Selection"
@@ -3469,16 +3476,40 @@ function SelectionDropdown({ onAction, onClose }) {
3469
3476
  onClose={onClose}
3470
3477
  items={[
3471
3478
  { id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' },
3472
- { id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' },
3479
+ // Cloud Phase 25 C2 — annotation selection exists to edit/delete
3480
+ // annotations; absent for a viewer.
3481
+ ...(readOnly
3482
+ ? []
3483
+ : [{ id: 'select-all-annotations', label: 'Select all annotations', shortcut: '⌘ ⇧ A' }]),
3473
3484
  ]}
3474
3485
  />
3475
3486
  );
3476
3487
  }
3477
3488
 
3478
- function ToolsDropdown({ onAction, onClose }) {
3489
+ function ToolsDropdown({ onAction, onClose, readOnly = false }) {
3479
3490
  // Mirrors DEFAULT_TOOLS in apps/studio/use-tool-mode.tsx — kept in sync by
3480
3491
  // hand because the menubar lives in the dev-server shell (no shared bundle
3481
- // with the canvas iframes).
3492
+ // with the canvas iframes). Cloud Phase 25 C2 — a viewer keeps only the
3493
+ // navigate/inspect tools (same READ_ONLY_TOOL_IDS the canvas enforces).
3494
+ const items = [
3495
+ // feature-4 (browse/move split) — Browse is the boot default (mock is
3496
+ // alive); Move (V) is the select tool.
3497
+ { id: 'browse', label: 'Browse (interact)', shortcut: '' },
3498
+ { id: 'move', label: 'Select', shortcut: 'V' },
3499
+ { id: 'hand', label: 'Hand', shortcut: 'H' },
3500
+ ...(readOnly
3501
+ ? []
3502
+ : [
3503
+ { id: 'comment', label: 'Comment', shortcut: 'C' },
3504
+ { id: 'pen', label: 'Pen', shortcut: 'B' },
3505
+ { id: 'rect', label: 'Rect', shortcut: 'R' },
3506
+ { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
3507
+ { id: 'sticky', label: 'Sticky', shortcut: 'N' },
3508
+ { id: 'arrow', label: 'Arrow', shortcut: 'A' },
3509
+ { id: 'text', label: 'Text', shortcut: 'T' },
3510
+ { id: 'eraser', label: 'Eraser', shortcut: 'E' },
3511
+ ]),
3512
+ ];
3482
3513
  return (
3483
3514
  <DropdownMenu
3484
3515
  label="Tools"
@@ -3486,35 +3517,25 @@ function ToolsDropdown({ onAction, onClose }) {
3486
3517
  header="Tool palette"
3487
3518
  onAction={onAction}
3488
3519
  onClose={onClose}
3489
- items={[
3490
- // feature-4 (browse/move split) — Browse is the boot default (mock is
3491
- // alive); Move (V) is the select tool.
3492
- { id: 'browse', label: 'Browse (interact)', shortcut: '' },
3493
- { id: 'move', label: 'Select', shortcut: 'V' },
3494
- { id: 'hand', label: 'Hand', shortcut: 'H' },
3495
- { id: 'comment', label: 'Comment', shortcut: 'C' },
3496
- { id: 'pen', label: 'Pen', shortcut: 'B' },
3497
- { id: 'rect', label: 'Rect', shortcut: 'R' },
3498
- { id: 'ellipse', label: 'Ellipse', shortcut: 'O' },
3499
- { id: 'sticky', label: 'Sticky', shortcut: 'N' },
3500
- { id: 'arrow', label: 'Arrow', shortcut: 'A' },
3501
- { id: 'text', label: 'Text', shortcut: 'T' },
3502
- { id: 'eraser', label: 'Eraser', shortcut: 'E' },
3503
- ]}
3520
+ items={items}
3504
3521
  />
3505
3522
  );
3506
3523
  }
3507
3524
 
3508
3525
  // Plan C follow-up — File + Edit menus, previously inert. Both dispatch to real
3509
3526
  // shell flows (File) or the in-canvas undo stack / selection bridges (Edit).
3510
- function FileDropdown({ onAction, onClose, hasCanvas }) {
3511
- return (
3512
- <DropdownMenu
3513
- label="File"
3514
- left={40}
3515
- onAction={onAction}
3516
- onClose={onClose}
3517
- items={[
3527
+ function FileDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3528
+ // Cloud Phase 25 C2 — a viewer keeps the reads (export, handoff, reload,
3529
+ // close); create / assemble / generate / settings are absent.
3530
+ const items = readOnly
3531
+ ? [
3532
+ { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
3533
+ { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
3534
+ { sep: true },
3535
+ { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
3536
+ { id: 'close', label: 'Close canvas', disabled: !hasCanvas },
3537
+ ]
3538
+ : [
3518
3539
  // Bare N — the browser reserves ⌘N (New Window) and never delivers it.
3519
3540
  { id: 'new', label: 'New canvas…', shortcut: 'N' },
3520
3541
  // DDR-150 P4 Task 12 — one-click "udělej z toho video" from the clips
@@ -3529,12 +3550,24 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
3529
3550
  { sep: true },
3530
3551
  { id: 'reload', label: 'Reload canvas', shortcut: '⌘R', disabled: !hasCanvas },
3531
3552
  { id: 'close', label: 'Close canvas', disabled: !hasCanvas },
3532
- ]}
3533
- />
3534
- );
3553
+ ];
3554
+ return <DropdownMenu label="File" left={40} onAction={onAction} onClose={onClose} items={items} />;
3535
3555
  }
3536
3556
 
3537
- function EditDropdown({ onAction, onClose, hasCanvas }) {
3557
+ function EditDropdown({ onAction, onClose, hasCanvas, readOnly = false }) {
3558
+ // Cloud Phase 25 C2 — a viewer's Edit menu is selection only; undo/redo,
3559
+ // artboard insert and annotation ops are writes.
3560
+ if (readOnly) {
3561
+ return (
3562
+ <DropdownMenu
3563
+ label="Edit"
3564
+ left={90}
3565
+ onAction={onAction}
3566
+ onClose={onClose}
3567
+ items={[{ id: 'deselect-all', label: 'Deselect all', shortcut: 'Esc' }]}
3568
+ />
3569
+ );
3570
+ }
3538
3571
  return (
3539
3572
  <DropdownMenu
3540
3573
  label="Edit"
@@ -3629,6 +3662,9 @@ function Menubar({
3629
3662
  onReload,
3630
3663
  onCloseCanvas,
3631
3664
  onInsertArtboard,
3665
+ // Cloud Phase 25 C2 — viewer role: editing entries are absent from every
3666
+ // menu, and the stamp says VIEW ONLY so the absence is legible.
3667
+ readOnly = false,
3632
3668
  }) {
3633
3669
  const isSystem = activePath === SYSTEM_TAB;
3634
3670
  const stamp = isSystem ? 'SYSTEM' : activePath ? 'CANVAS' : 'IDLE';
@@ -3642,6 +3678,8 @@ function Menubar({
3642
3678
  <span style={{ color: 'var(--u-fg-3)' }}>no canvas open</span>
3643
3679
  );
3644
3680
 
3681
+ // Cloud Phase 25 C2 — panels a viewer's session doesn't have (edit chrome).
3682
+ const viewerHiddenPanels = new Set(['assistant', 'inspector', 'layers', 'autoopen']);
3645
3683
  const panels = [
3646
3684
  { id: 'tree', label: 'Project Tree', shortcut: 'T', checked: sidebarOpen, disabled: false },
3647
3685
  {
@@ -3748,7 +3786,7 @@ function Menubar({
3748
3786
  checked: printGuidesVisible,
3749
3787
  disabled: !activePath || isSystem,
3750
3788
  },
3751
- ];
3789
+ ].filter((p) => !readOnly || !viewerHiddenPanels.has(p.id));
3752
3790
 
3753
3791
  const DROPDOWN_MENUS = ['file', 'edit', 'view', 'selection', 'tools', 'help'];
3754
3792
  function onMenuClick(key) {
@@ -3839,6 +3877,7 @@ function Menubar({
3839
3877
  </nav>
3840
3878
  {openMenu === 'file' && (
3841
3879
  <FileDropdown
3880
+ readOnly={readOnly}
3842
3881
  hasCanvas={!!activePath}
3843
3882
  onAction={(id) => {
3844
3883
  if (id === 'new') onNewCanvas?.();
@@ -3855,6 +3894,7 @@ function Menubar({
3855
3894
  )}
3856
3895
  {openMenu === 'edit' && (
3857
3896
  <EditDropdown
3897
+ readOnly={readOnly}
3858
3898
  hasCanvas={!!activePath && !isSystem}
3859
3899
  onAction={(id) => {
3860
3900
  if (id === 'undo') postToActiveCanvas({ dgn: 'undo' });
@@ -3894,6 +3934,7 @@ function Menubar({
3894
3934
  )}
3895
3935
  {openMenu === 'selection' && (
3896
3936
  <SelectionDropdown
3937
+ readOnly={readOnly}
3897
3938
  onAction={(id) => {
3898
3939
  if (id === 'deselect-all') postToActiveCanvas({ dgn: 'selection-clear' });
3899
3940
  else if (id === 'select-all-annotations')
@@ -3904,6 +3945,7 @@ function Menubar({
3904
3945
  )}
3905
3946
  {openMenu === 'tools' && (
3906
3947
  <ToolsDropdown
3948
+ readOnly={readOnly}
3907
3949
  onAction={(tool) => postToActiveCanvas({ dgn: 'tool-set', tool })}
3908
3950
  onClose={() => setOpenMenu(null)}
3909
3951
  />
@@ -3926,7 +3968,16 @@ function Menubar({
3926
3968
  )}
3927
3969
  <div className="st-mb-right" data-tour="status">
3928
3970
  {presence ? <div className="st-presence">{presence}</div> : null}
3929
- {isNativeApp() && (
3971
+ {readOnly && (
3972
+ <span
3973
+ className="st-stamp st-stamp--viewonly"
3974
+ 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."
3976
+ >
3977
+ VIEW ONLY
3978
+ </span>
3979
+ )}
3980
+ {isNativeApp() && !readOnly && (
3930
3981
  <button
3931
3982
  type="button"
3932
3983
  className="st-assistant"
@@ -4607,44 +4658,48 @@ function CommentsPanel({
4607
4658
  <span className="st-comment-sel" title={(c.dom_path || []).join(' > ')}>
4608
4659
  {c.selector || '—'}
4609
4660
  </span>
4610
- <span className="st-mini-act">
4611
- {c.status === 'resolved' ? (
4661
+ {/* Cloud Phase 25 C2 — absent handlers (viewer) ⇒ no
4662
+ mutating actions; the thread stays readable. */}
4663
+ {onResolve && onReopen && onDelete ? (
4664
+ <span className="st-mini-act">
4665
+ {c.status === 'resolved' ? (
4666
+ <button
4667
+ type="button"
4668
+ className="st-iconbtn"
4669
+ aria-label="Reopen"
4670
+ onClick={(e) => {
4671
+ e.stopPropagation();
4672
+ onReopen(c.id);
4673
+ }}
4674
+ >
4675
+ <StIcon name="reopen" size={14} />
4676
+ </button>
4677
+ ) : (
4678
+ <button
4679
+ type="button"
4680
+ className="st-iconbtn"
4681
+ aria-label="Resolve"
4682
+ onClick={(e) => {
4683
+ e.stopPropagation();
4684
+ onResolve(c.id);
4685
+ }}
4686
+ >
4687
+ <StIcon name="resolve" size={14} />
4688
+ </button>
4689
+ )}
4612
4690
  <button
4613
4691
  type="button"
4614
4692
  className="st-iconbtn"
4615
- aria-label="Reopen"
4693
+ aria-label="Delete"
4616
4694
  onClick={(e) => {
4617
4695
  e.stopPropagation();
4618
- onReopen(c.id);
4696
+ onDelete(c.id);
4619
4697
  }}
4620
4698
  >
4621
- <StIcon name="reopen" size={14} />
4699
+ <StIcon name="x" size={14} />
4622
4700
  </button>
4623
- ) : (
4624
- <button
4625
- type="button"
4626
- className="st-iconbtn"
4627
- aria-label="Resolve"
4628
- onClick={(e) => {
4629
- e.stopPropagation();
4630
- onResolve(c.id);
4631
- }}
4632
- >
4633
- <StIcon name="resolve" size={14} />
4634
- </button>
4635
- )}
4636
- <button
4637
- type="button"
4638
- className="st-iconbtn"
4639
- aria-label="Delete"
4640
- onClick={(e) => {
4641
- e.stopPropagation();
4642
- onDelete(c.id);
4643
- }}
4644
- >
4645
- <StIcon name="x" size={14} />
4646
- </button>
4647
- </span>
4701
+ </span>
4702
+ ) : null}
4648
4703
  </div>
4649
4704
  </div>
4650
4705
  ))}
@@ -9110,6 +9165,9 @@ function App() {
9110
9165
  // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
9111
9166
  // ?designRel + ?tokens query to the canvas mount shell.
9112
9167
  const [cfg, setCfg] = useState({ designRel: '.design' });
9168
+ // Cloud Phase 25 C2 — viewer role, known at boot from /_config. Every
9169
+ // editing affordance in the shell gates on this (absent, not hidden).
9170
+ const viewerMode = !!cfg.readOnly;
9113
9171
  const loadServerConfig = useCallback(() => {
9114
9172
  fetch('/_config')
9115
9173
  .then((r) => r.json())
@@ -9132,6 +9190,12 @@ function App() {
9132
9190
  // from the main origin's /_api). Absent on older servers → relative
9133
9191
  // URL fallback keeps same-origin behavior.
9134
9192
  canvasOrigin: data.canvasOrigin,
9193
+ // Cloud Phase 25 C2 — the linked hub vouched a `viewer` role at
9194
+ // sign-in. Known at BOOT (before anything draws), so editing
9195
+ // affordances are ABSENT rather than shown-then-refused. This flag
9196
+ // decides what the UI offers; the cell (C1) and the dev-server's
9197
+ // read-only gate (http.ts) are what actually stop a write.
9198
+ readOnly: !!data.readOnly,
9135
9199
  }));
9136
9200
  })
9137
9201
  .catch(() => {});
@@ -9468,12 +9532,13 @@ function App() {
9468
9532
  const maybeAutoOpenInspectorOnSelect = useCallback(
9469
9533
  (sel) => {
9470
9534
  if (!autoOpenInspectorRef.current) return;
9535
+ if (viewerMode) return; // Cloud Phase 25 C2 — inspector is edit chrome
9471
9536
  if (!sel || !sel.id) return; // need a stable element id to inspect
9472
9537
  if (anyRightPanelOpenRef.current) return; // don't steal from an open panel
9473
9538
  openRightPanel('inspector');
9474
9539
  setInspectorTab('css');
9475
9540
  },
9476
- [openRightPanel]
9541
+ [openRightPanel, viewerMode]
9477
9542
  );
9478
9543
  // DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
9479
9544
  // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
@@ -11334,7 +11399,7 @@ function App() {
11334
11399
  document.body.style.cursor = '';
11335
11400
  const el = document.getElementById('dc-app-cursor');
11336
11401
  if (el) el.textContent = '';
11337
- browseFirstRunHint();
11402
+ browseFirstRunHint(viewerMode);
11338
11403
  return;
11339
11404
  }
11340
11405
  const cursor = resolveToolCursor(m.tool);
@@ -13321,15 +13386,17 @@ function App() {
13321
13386
  }
13322
13387
  // Cmd+Shift+I — toggle Inspector. Was bare "I", which collided with the
13323
13388
  // canvas highlighter tool (same letter, different action by focus).
13389
+ // Cloud Phase 25 C2 — absent for a viewer (the panel is edit chrome).
13324
13390
  if (meta && e.shiftKey && (e.key === 'i' || e.key === 'I')) {
13325
13391
  e.preventDefault();
13326
- toggleRightPanel('inspector');
13392
+ if (!viewerMode) toggleRightPanel('inspector');
13327
13393
  return;
13328
13394
  }
13329
13395
  // Phase 31 (DDR-123) — Cmd+Shift+A opens the native ACP chat sidepanel.
13396
+ // Cloud Phase 25 C2 — the agent edits; absent for a viewer.
13330
13397
  if (meta && e.shiftKey && (e.key === 'a' || e.key === 'A') && isNativeApp()) {
13331
13398
  e.preventDefault();
13332
- toggleRightPanel('assistant');
13399
+ if (!viewerMode) toggleRightPanel('assistant');
13333
13400
  return;
13334
13401
  }
13335
13402
  // Cmd+Shift+E / Cmd+Shift+H — the File-menu chords, previously
@@ -13346,9 +13413,10 @@ function App() {
13346
13413
  }
13347
13414
  // Cmd+, — open Settings (AI generation keys). The platform convention for
13348
13415
  // a settings/preferences surface (feature-ai-media-generation, DDR-16x).
13416
+ // Cloud Phase 25 C2 — settings mutate project config; absent for a viewer.
13349
13417
  if (meta && !e.shiftKey && !e.altKey && e.key === ',') {
13350
13418
  e.preventDefault();
13351
- setSettingsOpen(true);
13419
+ if (!viewerMode) setSettingsOpen(true);
13352
13420
  return;
13353
13421
  }
13354
13422
  // Cmd+Shift+T — toggle the Timeline (video-comp scrub) dock.
@@ -13475,6 +13543,7 @@ function App() {
13475
13543
  toggleTimeline,
13476
13544
  inspectorTab,
13477
13545
  performPhotoUndo,
13546
+ viewerMode,
13478
13547
  ]);
13479
13548
 
13480
13549
  const registerIframe = useCallback((path, el) => {
@@ -13648,8 +13717,12 @@ function App() {
13648
13717
  // feature-configurable-panel-docking — resolve, for each slot, the panels
13649
13718
  // assigned to it and which one is active (the open one). Layers is only a
13650
13719
  // dockable panel in `separate` mode; Assistant is native-only.
13651
- const panelAvailable = (id) =>
13652
- id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
13720
+ 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;
13724
+ return id === 'assistant' ? isNativeApp() : id === 'layers' ? layersMode === 'separate' : true;
13725
+ };
13653
13726
  const idsForSide = (side) =>
13654
13727
  DOCK_PANELS.filter(
13655
13728
  (p) => panelAvailable(p.id) && (panelSide[p.id] || PANEL_SIDES_DEFAULTS[p.id]) === side
@@ -13666,8 +13739,11 @@ function App() {
13666
13739
  const rightIds = idsForSide('right');
13667
13740
  const leftActive = leftIds.find((id) => panelIsOpen[id]) || null;
13668
13741
  const rightActive = rightIds.find((id) => panelIsOpen[id]) || null;
13669
- const leftHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'left';
13670
- const rightHostsAssistant = isNativeApp() && (panelSide.assistant || 'right') === 'right';
13742
+ // Cloud Phase 25 C2 — the Assistant edits; a viewer's session never mounts it.
13743
+ const leftHostsAssistant =
13744
+ isNativeApp() && !viewerMode && (panelSide.assistant || 'right') === 'left';
13745
+ const rightHostsAssistant =
13746
+ isNativeApp() && !viewerMode && (panelSide.assistant || 'right') === 'right';
13671
13747
  const resizingFor = (id) =>
13672
13748
  (panelSide[id] || PANEL_SIDES_DEFAULTS[id]) === 'left' ? dragSide === 'sb' : dragSide === 'rp';
13673
13749
  const activeCanvasFile =
@@ -13677,9 +13753,13 @@ function App() {
13677
13753
  // which owns the resizable width). Assistant is handled separately below as an
13678
13754
  // always-mounted ChatPanel so its stream survives a tab switch.
13679
13755
  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;
13680
13759
  if (id === 'tree')
13681
13760
  return (
13682
13761
  <Sidebar
13762
+ readOnly={viewerMode}
13683
13763
  groups={groups}
13684
13764
  activePath={activePath}
13685
13765
  activeDsName={activePath === SYSTEM_TAB ? (systemData?.ds?.name ?? null) : null}
@@ -13715,7 +13795,7 @@ function App() {
13715
13795
  <GitPanel
13716
13796
  status={gitStatus && remoteSync ? { ...gitStatus, ...remoteSync } : gitStatus}
13717
13797
  project={project}
13718
- readOnly={!isNativeApp()}
13798
+ readOnly={!isNativeApp() || viewerMode}
13719
13799
  resizing={resizingFor('changes')}
13720
13800
  onClose={() => setChangesOpen(false)}
13721
13801
  onCommit={gitCommit}
@@ -13795,9 +13875,11 @@ function App() {
13795
13875
  activePath={activePath}
13796
13876
  focusedId={focusedCommentId}
13797
13877
  onJump={jumpToComment}
13798
- onResolve={resolveComment}
13799
- onReopen={reopenComment}
13800
- onDelete={deleteComment}
13878
+ // Cloud Phase 25 C2 — a viewer READS threads; the mutating actions
13879
+ // are absent until C3 lands comments on the cell's allowlist.
13880
+ onResolve={viewerMode ? undefined : resolveComment}
13881
+ onReopen={viewerMode ? undefined : reopenComment}
13882
+ onDelete={viewerMode ? undefined : deleteComment}
13801
13883
  resizing={resizingFor('comments')}
13802
13884
  />
13803
13885
  );
@@ -13837,6 +13919,7 @@ function App() {
13837
13919
  )}
13838
13920
  <div className="st-shell">
13839
13921
  <Menubar
13922
+ readOnly={viewerMode}
13840
13923
  activePath={activePath}
13841
13924
  project={project}
13842
13925
  tabsCount={tabs.length}
@@ -13994,7 +14077,9 @@ function App() {
13994
14077
  cfg={cfg}
13995
14078
  loadingPath={loadingPath}
13996
14079
  onIframeLoad={onIframeLoad}
13997
- showQuickSetup={isNativeApp() && !!setupReadiness && !setupReadiness.ready}
14080
+ showQuickSetup={
14081
+ isNativeApp() && !viewerMode && !!setupReadiness && !setupReadiness.ready
14082
+ }
13998
14083
  onStartQuickSetup={() => setQuickSetupOpen(true)}
13999
14084
  />
14000
14085
  </div>
@@ -35,6 +35,12 @@ export function canvasUrl(p, cfg, opts) {
35
35
  // Phase 27 (E2) — DiffView renders a CLEAN canvas (no editor chrome: toolbar,
36
36
  // world minimap, devtools) so the before/after is just the design.
37
37
  if (opts?.hideChrome) params.set('hide-chrome', '1');
38
+ // Cloud Phase 25 C2 — the linked hub vouched a viewer role at sign-in
39
+ // (`/_config.readOnly`), so the canvas boots read-only: edit tools and
40
+ // mutating interactions are ABSENT from the iframe chrome (read-only-mode.ts
41
+ // reads this flag). Boot-static on purpose — a role is per-session, and a
42
+ // query param can't race the way a post-load message could.
43
+ if (cfg?.readOnly) params.set('ro', '1');
38
44
  const ds0 = cfg?.designSystems?.[0];
39
45
  // Specimen detection: anything under `system/<ds>/preview/` belongs to that
40
46
  // specific DS, so it must render with *that* DS's tokens — not always the
@@ -69,6 +69,13 @@
69
69
  padding: var(--space-1) var(--space-3); border: 1px solid var(--accent-muted);
70
70
  border-radius: var(--radius-pill); background: var(--accent-tint);
71
71
  }
72
+ /* Cloud Phase 25 C2 — the viewer-role stamp. Warning hue so "why is editing
73
+ missing" has a visible, hoverable answer in the chrome. */
74
+ .st-stamp--viewonly {
75
+ color: var(--status-warning, var(--accent));
76
+ border-color: color-mix(in oklab, var(--status-warning, var(--accent)) 40%, transparent);
77
+ background: color-mix(in oklab, var(--status-warning, var(--accent)) 12%, transparent);
78
+ }
72
79
  .st-mb-file { font-family: var(--font-mono); font-size: var(--type-xs); color: var(--fg-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 260px; }
73
80
  .st-mb-file b { color: var(--fg-0); font-weight: 600; }
74
81
  .st-mb-sep { width: 1px; height: 16px; background: var(--border-default); }