collab-presence 0010: unified draggable overlay menu

One circular FAB (peer-count badge) replaces the top-right overlay row:
draggable anywhere (pointer capture, 4px click-vs-drag threshold — the
comment-pin pattern), position persisted in localStorage, panel opens
toward screen center and stacks composable sections — roster, source chip,
view-only pill, follow row, comments, chrome toggle. Renders at z-50 above
everything (decided: covers wx dialogs; trivially dismissed via click-away/
Esc/drag). CommentLayer portals its bar + list panel into the menu's
comments slot; pins/popovers/composer/click-catcher stay canvas-anchored.
The FAB is the chrome-hidden survivor. e2e specs updated with an
openOverlayMenu helper (panel is closed by default, click-away closes it).

NOTE: Playwright suites updated but not executed here (no local WASM
artifacts) — verify on CI / next session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
This commit is contained in:
Gergő Törcsvári 2026-07-16 14:56:31 +02:00
commit f4d3c3f146
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
11 changed files with 410 additions and 141 deletions

View file

@ -1,4 +1,5 @@
import { test, expect, type Browser, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Figma-like "hide UI" toggle e2e (desktop): Cmd/Ctrl+\ and the floating
@ -85,7 +86,9 @@ test('Ctrl+\\ hides every non-canvas UI element; the canvas reclaims the viewpor
);
// shell overlays follow the toggle…
await expect(page.getByText(/console \(/)).toHaveCount(0);
// …but the toggle button itself stays reachable
// …but the toggle stays reachable inside the overlay menu (0010) — the
// FAB is the canvas-only survivor.
await openOverlayMenu(page);
await expect(page.locator('[data-testid="chrome-toggle"]')).toBeVisible();
await page.screenshot({ path: 'test-results/web-chrome-hidden.png', scale: 'css' });
@ -94,7 +97,8 @@ test('Ctrl+\\ hides every non-canvas UI element; the canvas reclaims the viewpor
test('the floating button restores EXACTLY the pre-hide chrome (no over-shown panes)', async () => {
test.setTimeout(120_000);
// still hidden from the previous test — restore via the button
// still hidden from the previous test — restore via the button (in the menu)
await openOverlayMenu(page);
await page.locator('[data-testid="chrome-toggle"]').click();
await expect.poll(() => visibleMenuTitles(page), { timeout: 15000 }).toBeGreaterThan(0);

View file

@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Comment pins vs. canvas resize (collab-presence 0005 regression): the DOM
@ -28,6 +29,7 @@ async function bootAs(page: Page, user: string): Promise<void> {
intervals: [1000],
})
.toMatch(TITLE);
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
await page.getByTestId('comment-bar-toggle').click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();

View file

@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Figma-like comments e2e (collab-presence 0005): two tabs of the real React
@ -28,6 +29,7 @@ async function bootAs(page: Page, user: string): Promise<void> {
.toMatch(TITLE);
// The comment controls mount once the collab session + bridge are up; the
// action buttons live inside the expandable bar — open it for the test.
await openOverlayMenu(page); // the comment bar lives in the overlay menu (0010)
await expect(page.getByTestId('comment-bar-toggle')).toBeVisible({ timeout: 30000 });
await page.getByTestId('comment-bar-toggle').click();
await expect(page.getByTestId('comment-mode-toggle')).toBeVisible();
@ -96,7 +98,9 @@ test('comment lifecycle across two tabs: create → reply → resolve → delete
// Resolved pins hide from the default (unresolved) pin set in both tabs…
await expect(page.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
// …but stay reachable via the panel's "resolved" filter.
// …but stay reachable via the panel's "resolved" filter. (The popover
// click above closed the overlay menu — click-away — so re-open it.)
await openOverlayMenu(page);
await page.getByTestId('comment-panel-toggle').click();
await page.getByTestId('comment-show-resolved').check();
await expect(page.getByTestId('comment-panel-item')).toHaveCount(1);
@ -106,6 +110,7 @@ test('comment lifecycle across two tabs: create → reply → resolve → delete
await expect(page.getByTestId('comment-popover')).toBeVisible();
await page.getByTestId('comment-delete-thread').click();
await openOverlayMenu(page); // the delete click closed the menu (click-away)
await expect(page.getByTestId('comment-panel-item')).toHaveCount(0, { timeout: 20000 });
await expect(pageB.getByTestId('comment-pin')).toHaveCount(0, { timeout: 20000 });
await pageB.close();

View file

@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Follow-user e2e (collab-presence 0008): two tabs on the demo BOARD share
@ -81,7 +82,9 @@ test('B follows A: viewport mirrors, then local input breaks the follow', async
})
.toBe(true);
// B sees alice in the roster and clicks her avatar → follow.
// B sees alice in the roster (inside the overlay menu, 0010) and clicks
// her avatar → follow.
await openOverlayMenu(b);
const avatar = b.locator('[data-presence-user="alice"]');
await expect(avatar).toBeVisible({ timeout: 30000 });
await avatar.click();

View file

@ -1,4 +1,5 @@
import { test, expect, devices, type Page, type Browser } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Mobile canvas-only mode e2e (features/mobile) runs under the
@ -310,7 +311,9 @@ test('editor is canvas-only: no menubar, GL canvas fills the viewport', async ()
});
test('the floating toggle brings the full UI up and back off (Figma-like hide-UI)', async () => {
// Canvas-only is the mobile DEFAULT, but the toggle must stay reachable.
// Canvas-only is the mobile DEFAULT, but the toggle must stay reachable —
// inside the overlay menu (0010); the FAB is the persistent control.
await openOverlayMenu(page);
const toggle = page.locator('[data-testid="chrome-toggle"]');
await expect(toggle, 'toggle button present in canvas-only mode').toBeVisible();

16
tests/web/overlay-menu.ts Normal file
View file

@ -0,0 +1,16 @@
import { expect, type Page } from '@playwright/test';
/**
* Open the overlay menu (collab-presence 0010) if its panel isn't already
* open. The roster, source chip, view-only pill, comment bar and chrome
* toggle live inside the panel; the FAB (with the peer-count badge) is the
* only overlay control always on screen. Any canvas click closes the panel
* (click-away), so specs re-open it before each in-panel interaction.
*/
export async function openOverlayMenu(page: Page): Promise<void> {
const fab = page.getByTestId('overlay-menu-fab');
await expect(fab).toBeVisible({ timeout: 30000 });
if (await page.getByTestId('overlay-menu-panel').count()) return;
await fab.click();
await expect(page.getByTestId('overlay-menu-panel')).toBeVisible();
}

View file

@ -1,4 +1,5 @@
import { test, expect, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Presence roster e2e (collab-presence 0001): two tabs of the real React app on
@ -42,11 +43,14 @@ test('two tabs on one file see each other in the roster; leaving removes promptl
await bootAs(page, 'alice');
// Before anyone else joins, alice has no roster chip at all.
// Before anyone else joins, alice has no roster section in the overlay
// menu (0010) at all.
await openOverlayMenu(page);
await expect(page.getByTestId('presence-roster')).toHaveCount(0);
const pageB = await context.newPage();
await bootAs(pageB, 'bob');
await openOverlayMenu(pageB);
// Each side shows the other user (avatar keyed by slug), not itself.
await expect(page.locator('[data-presence-user="bob"]')).toBeVisible({ timeout: 30000 });

View file

@ -1,4 +1,5 @@
import { test, expect, type Browser, type Page } from '@playwright/test';
import { openOverlayMenu } from './overlay-menu';
/**
* Read-only viewer e2e (read-only-viewer): `?readonly=1` boots the pcbnew
@ -118,6 +119,8 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert,
// "View only" pill is the one read-only affordance; Ctrl+\ stays inert.
await expect.poll(() => visibleMenuTitles(viewer), { timeout: 15000 }).toBe(0);
await expect(viewer.getByText(/console \(/)).toHaveCount(0);
// The pill + (absent) chrome toggle live inside the overlay menu (0010).
await openOverlayMenu(viewer);
await expect(viewer.locator('[data-testid="chrome-toggle"]')).toHaveCount(0);
await expect(viewer.getByTestId('view-only-pill')).toBeVisible();
await viewer.keyboard.press('Control+\\');
@ -136,6 +139,7 @@ test('viewer boots locked: chrome-less, nothing selectable, hotkey edits inert,
expect(await visibleMenuTitles(viewer)).toBe(0);
// The writer beside it keeps the full UI (positive control for the above).
await openOverlayMenu(writer);
await expect(writer.locator('[data-testid="chrome-toggle"]')).toBeVisible();
await expect(writer.getByTestId('view-only-pill')).toHaveCount(0);

View file

@ -1,4 +1,5 @@
import * as React from "react";
import { createPortal } from "react-dom";
import type { CommentAnchor } from "@pcbjam/shared";
import { Eye, EyeOff, List, MessageSquarePlus, MessageSquareText, X } from "lucide-react";
import {
@ -54,10 +55,15 @@ export function CommentLayer({
controller,
viewport,
currentUser,
menuSlot,
}: {
controller: CommentsController;
viewport: ViewportState | null;
currentUser: string;
/** The overlay menu's comments section (0010): the bar + list panel portal
* into it while the menu is open; null (menu closed) renders neither.
* Pins, popovers, composer and the click catcher stay canvas-anchored. */
menuSlot: HTMLElement | null;
}) {
const [threads, setThreads] = React.useState<ResolvedThread[]>(controller.threads());
const [barOpen, setBarOpen] = React.useState(false);
@ -200,58 +206,116 @@ export function CommentLayer({
const visibleThreads = threads.filter((t) => showResolved || !t.resolved);
const pinThreads = hidden ? [] : visibleThreads;
// Comment toolbar + list panel: portaled into the overlay menu's comments
// slot (0010) while the menu is open — in-flow there, not absolute.
const menuUi = menuSlot
? createPortal(
<>
<div className="flex items-center gap-2">
<button
data-testid="comment-bar-toggle"
title="Comments"
onClick={() => setBarOpen((o) => !o)}
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
}`}
>
<MessageSquareText size={15} />
{threads.length > 0 && <span>{threads.length}</span>}
</button>
{barOpen && (
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
<button
data-testid="comment-mode-toggle"
title={mode ? "Cancel comment (Esc)" : "New comment"}
onClick={() => {
if (hidden) toggleHidden();
setMode((m) => !m);
setDraft(null);
}}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
}`}
>
<MessageSquarePlus size={14} />
</button>
<button
data-testid="comment-panel-toggle"
title="Comment list"
onClick={() => setPanel((p) => !p)}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
}`}
>
<List size={14} />
</button>
<button
data-testid="comment-visibility-toggle"
title={hidden ? "Show comments" : "Hide comments"}
onClick={toggleHidden}
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
>
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
</div>
{/* Threads panel (filter + jump-to). */}
{panel && (
<div className="flex max-h-[50vh] w-full flex-col overflow-hidden rounded-lg bg-black/60 text-white ring-1 ring-inset ring-white/15">
<div className="flex items-center justify-between px-3 py-2 text-xs font-semibold">
<span>Comments ({visibleThreads.length})</span>
<label className="flex items-center gap-1 font-normal text-white/70">
<input
data-testid="comment-show-resolved"
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
/>
resolved
</label>
</div>
<div className="overflow-y-auto">
{visibleThreads.length === 0 && (
<p className="px-3 pb-3 text-xs text-white/50">No comments yet.</p>
)}
{visibleThreads.map((t) => (
<button
key={t.id}
data-testid="comment-panel-item"
onClick={() => {
if (hidden) toggleHidden();
controller.jumpTo(t.id);
setOpenId(t.id);
}}
className="block w-full border-t border-white/10 px-3 py-2 text-left text-xs hover:bg-white/10"
>
<span
className="font-semibold"
style={{ color: controller.colorFor(t.createdBy) }}
>
{t.createdBy}
</span>{" "}
<span className="text-white/50">
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
</span>
<span className="mt-0.5 block truncate text-white/90">
{t.messages[0]?.body ?? ""}
</span>
</button>
))}
</div>
</div>
)}
</>,
menuSlot,
)
: null;
return (
<>
{/* Comment toolbar: one icon expanding into a small horizontal bar. */}
<div className="absolute right-3 top-12 z-30 flex flex-row-reverse items-center gap-2">
<button
data-testid="comment-bar-toggle"
title="Comments"
onClick={() => setBarOpen((o) => !o)}
className={`flex h-8 min-w-8 items-center justify-center gap-1 rounded-full px-2 text-xs shadow-sm ring-1 ring-inset ring-white/20 ${
barOpen ? "bg-sky-600 text-white" : "bg-black/70 text-white hover:bg-black/85"
}`}
>
<MessageSquareText size={15} />
{threads.length > 0 && <span>{threads.length}</span>}
</button>
{barOpen && (
<div className="flex items-center gap-1 rounded-full bg-black/70 p-1 shadow-sm ring-1 ring-inset ring-white/20">
<button
data-testid="comment-mode-toggle"
title={mode ? "Cancel comment (Esc)" : "New comment"}
onClick={() => {
if (hidden) toggleHidden();
setMode((m) => !m);
setDraft(null);
}}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
mode ? "bg-amber-500 text-black" : "text-white hover:bg-white/15"
}`}
>
<MessageSquarePlus size={14} />
</button>
<button
data-testid="comment-panel-toggle"
title="Comment list"
onClick={() => setPanel((p) => !p)}
className={`flex h-6 w-6 items-center justify-center rounded-full ${
panel ? "bg-white/25 text-white" : "text-white hover:bg-white/15"
}`}
>
<List size={14} />
</button>
<button
data-testid="comment-visibility-toggle"
title={hidden ? "Show comments" : "Hide comments"}
onClick={toggleHidden}
className="flex h-6 w-6 items-center justify-center rounded-full text-white hover:bg-white/15"
>
{hidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
)}
</div>
{menuUi}
{/* Comment-mode click catcher over the drawing area only. */}
{mode && glRect && (
@ -308,50 +372,6 @@ export function CommentLayer({
/>
)}
{/* Threads panel (filter + jump-to). */}
{panel && (
<div className="absolute right-3 top-24 z-30 flex max-h-[60vh] w-72 flex-col overflow-hidden rounded-lg bg-black/85 text-white shadow-lg ring-1 ring-inset ring-white/20">
<div className="flex items-center justify-between px-3 py-2 text-xs font-semibold">
<span>Comments ({visibleThreads.length})</span>
<label className="flex items-center gap-1 font-normal text-white/70">
<input
data-testid="comment-show-resolved"
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
/>
resolved
</label>
</div>
<div className="overflow-y-auto">
{visibleThreads.length === 0 && (
<p className="px-3 pb-3 text-xs text-white/50">No comments yet.</p>
)}
{visibleThreads.map((t) => (
<button
key={t.id}
data-testid="comment-panel-item"
onClick={() => {
if (hidden) toggleHidden();
controller.jumpTo(t.id);
setOpenId(t.id);
}}
className="block w-full border-t border-white/10 px-3 py-2 text-left text-xs hover:bg-white/10"
>
<span className="font-semibold" style={{ color: controller.colorFor(t.createdBy) }}>
{t.createdBy}
</span>{" "}
<span className="text-white/50">
{timeAgo(t.createdAt)} ago{t.resolved ? " · resolved" : ""}
</span>
<span className="mt-0.5 block truncate text-white/90">
{t.messages[0]?.body ?? ""}
</span>
</button>
))}
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,187 @@
import * as React from "react";
import { Users } from "lucide-react";
/**
* Unified overlay menu (collab-presence 0010): the single circular icon that
* replaces the old top-right overlay row. The FAB is draggable anywhere over
* the canvas (pointer capture, 4px click-vs-drag threshold the comment-pin
* pattern), its position persists per browser, and its badge shows how many
* OTHER users are in the session. Clicking opens a panel that stacks the
* sections WasmTool composes as children (roster, source chip, view-only
* pill, follow row, comments, chrome toggle, ) adding a future section is
* one more child. Renders above everything (z-50, including wx dialogs and
* toasts) by decision: it is trivially dismissed (click-away, Esc, the FAB)
* and can be dragged out of the way. Stays up in chrome-hidden mode it is
* the canvas-only survivor the chrome toggle used to be.
*/
const POS_KEY = "pcbjam:overlay-menu-pos";
const FAB_SIZE = 36;
const DRAG_THRESHOLD_PX = 4;
type Pos = { x: number; y: number };
function loadPos(): Pos | null {
try {
const raw = localStorage.getItem(POS_KEY);
if (!raw) return null;
const p = JSON.parse(raw) as Pos;
return typeof p.x === "number" && typeof p.y === "number" ? p : null;
} catch {
return null;
}
}
function clamp(p: Pos): Pos {
return {
x: Math.min(Math.max(p.x, 4), window.innerWidth - FAB_SIZE - 4),
y: Math.min(Math.max(p.y, 4), window.innerHeight - FAB_SIZE - 4),
};
}
export function OverlayMenu({
badge,
children,
}: {
/** Peer count shown on the FAB (0 hides the badge). */
badge: number;
/** Panel sections, rendered top-to-bottom. Falsy children collapse. */
children: React.ReactNode;
}) {
const [open, setOpen] = React.useState(false);
const [pos, setPos] = React.useState<Pos | null>(() =>
typeof window === "undefined" ? null : loadPos(),
);
const rootRef = React.useRef<HTMLDivElement | null>(null);
const dragRef = React.useRef<{
pointerId: number;
startX: number;
startY: number;
fabX: number;
fabY: number;
moved: boolean;
} | null>(null);
// Esc closes (bubble phase, same etiquette as the comment layer — wx also
// sees the key, matching how every other overlay treats Escape).
React.useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
// Click-away: any pointerdown outside the menu closes it (canvas included —
// wx pointer handlers bind to #canvas, so this listener still fires).
React.useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onDown);
return () => window.removeEventListener("pointerdown", onDown);
}, [open]);
const onFabPointerDown = (e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
const rect = rootRef.current!.getBoundingClientRect();
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
fabX: rect.x,
fabY: rect.y,
moved: false,
};
};
const onFabPointerMove = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
if (!d.moved) {
if (
Math.hypot(e.clientX - d.startX, e.clientY - d.startY) <
DRAG_THRESHOLD_PX
) {
return;
}
d.moved = true;
setOpen(false); // dragging repositions; the click that follows reopens
}
setPos(
clamp({
x: d.fabX + (e.clientX - d.startX),
y: d.fabY + (e.clientY - d.startY),
}),
);
};
const onFabPointerUp = () => {
const d = dragRef.current;
dragRef.current = null;
if (!d) return;
if (d.moved) {
setPos((p) => {
if (p) {
try {
localStorage.setItem(POS_KEY, JSON.stringify(p));
} catch {
/* private mode — position just doesn't persist */
}
}
return p;
});
} else {
setOpen((o) => !o);
}
};
// Default anchor: top-right (the old row's home). After a drag, explicit px.
const style: React.CSSProperties = pos
? { left: pos.x, top: pos.y }
: { right: 12, top: 12 };
// The panel opens toward the screen's center from wherever the FAB sits.
const onLeftHalf = pos ? pos.x < window.innerWidth / 2 : false;
const onTopHalf = pos ? pos.y < window.innerHeight / 2 : true;
return (
<div ref={rootRef} className="absolute z-50" style={style}>
<button
type="button"
data-testid="overlay-menu-fab"
aria-expanded={open}
title="Session menu — drag to move"
onPointerDown={onFabPointerDown}
onPointerMove={onFabPointerMove}
onPointerUp={onFabPointerUp}
className={`relative flex h-9 w-9 items-center justify-center rounded-full shadow-md ring-1 ring-inset ring-white/25 ${
open ? "bg-sky-600 text-white" : "bg-black/75 text-white hover:bg-black/90"
}`}
style={{ touchAction: "none" }}
>
<Users size={16} />
{badge > 0 && (
<span
data-testid="overlay-menu-badge"
className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-sky-500 px-1 text-[10px] font-semibold text-white"
>
{badge}
</span>
)}
</button>
{open && (
<div
data-testid="overlay-menu-panel"
className={`absolute flex w-72 flex-col items-start gap-2 rounded-lg bg-black/85 p-2 shadow-xl ring-1 ring-inset ring-white/20 ${
onLeftHalf ? "left-0" : "right-0"
} ${onTopHalf ? "top-11" : "bottom-11"}`}
>
{children}
</div>
)}
</div>
);
}

View file

@ -80,6 +80,7 @@ import {
} from "@/wasm/collab/comments";
import { PresenceRoster } from "@/components/PresenceRoster";
import { CommentLayer } from "@/components/CommentLayer";
import { OverlayMenu } from "@/components/OverlayMenu";
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
import {
createSheetCollabManager,
@ -895,6 +896,9 @@ export function WasmTool({
// the DOM layer maps world→CSS with. Both rebind with the collab session
// (per sheet in eeschema).
const [commentsCtl, setCommentsCtl] = React.useState<CommentsController | null>(null);
// The overlay menu's comments section (0010): a ref-callback slot the
// CommentLayer portals its bar/panel into; null while the menu is closed.
const [commentsSlot, setCommentsSlot] = React.useState<HTMLDivElement | null>(null);
const [viewportState, setViewportState] = React.useState<ViewportState | null>(null);
const commentsRef = React.useRef<CommentsController | null>(null);
// Dev-time presence style tuner (VITE_PRESENCE_TUNER=1) — set once the wasm
@ -1653,61 +1657,78 @@ export function WasmTool({
</div>
)}
{/* Top-right overlay row: who else is in this file (awareness roster),
where this project lives / whether Save persists (chip hidden while
the UI is hidden), and the Figma-like hide/show-UI toggle the one
control that stays up in canvas-only mode. Read-only sessions swap
the toggle for a "View only" pill (chrome stays force-hidden). */}
{ready &&
(readOnly ||
setChromeFn !== null ||
peers.length > 0 ||
(sourceDescriptor && !effectiveChromeHidden)) && (
<div className="absolute right-3 top-3 z-20 flex items-center gap-2">
{peers.length > 0 && (
<PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
)}
{sourceDescriptor && !effectiveChromeHidden && (
<SourceChip descriptor={sourceDescriptor} />
)}
{readOnly && (
<span
data-testid="view-only-pill"
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
>
View only
{/* Overlay menu (0010): the single draggable circular icon replacing the
old top-right row. Its badge is the peer count; the panel stacks the
session sections roster, source chip, view-only pill, follow row,
comments (portal slot filled by CommentLayer), chrome toggle. It is
the one control that stays up in canvas-only (chrome-hidden) mode. */}
{ready && (
<OverlayMenu badge={peers.length}>
{peers.length > 0 && (
<PresenceRoster
peers={peers}
activeSheetPath={activeSheetPath}
following={followingTarget}
onFollow={(t) => {
if (t) followRef.current?.follow(t);
else followRef.current?.unfollow();
}}
/>
)}
{sourceDescriptor && <SourceChip descriptor={sourceDescriptor} />}
{readOnly && (
<span
data-testid="view-only-pill"
className="flex h-8 items-center rounded-full bg-black/70 px-3 text-xs font-medium text-white shadow-sm ring-1 ring-inset ring-white/20"
>
View only
</span>
)}
{followingTarget && (
<div className="flex items-center gap-2 text-xs text-white">
<span>
Following <span className="font-semibold">{followingTarget.name}</span>
</span>
)}
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
// same pill design as the comment-bar toggle below it
className="flex h-8 min-w-8 items-center justify-center rounded-full bg-black/70 text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
onClick={() => toggleChromeHidden()}
type="button"
className="rounded-full bg-white/15 px-2 py-0.5 font-medium hover:bg-white/25"
onClick={() => followRef.current?.unfollow()}
>
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
Stop
</button>
)}
</div>
)}
</div>
)}
{commentsCtl && (
<div
data-testid="overlay-menu-comments"
ref={setCommentsSlot}
className="flex w-full flex-col items-start gap-2"
/>
)}
{setChromeFn !== null && !readOnly && (
<button
data-testid="chrome-toggle"
aria-pressed={chromeHidden}
className="flex h-8 items-center gap-2 rounded-full bg-black/70 px-3 text-xs text-white shadow-sm ring-1 ring-inset ring-white/20 hover:bg-black/85"
title={`${chromeHidden ? "Show" : "Hide"} UI (${CHROME_HOTKEY_LABEL})`}
onClick={() => toggleChromeHidden()}
>
{chromeHidden ? <PanelsTopLeft size={15} /> : <EyeOff size={15} />}
{chromeHidden ? "Show UI" : "Hide UI"}
</button>
)}
</OverlayMenu>
)}
{/* Figma-like comments (0005): GAL pin dots + this DOM layer (hit targets,
thread popovers, comment mode, panel). */}
thread popovers, comment mode, panel). The bar + list panel render
into the overlay menu's comments slot (0010). */}
{ready && commentsCtl && (
<CommentLayer
controller={commentsCtl}
viewport={viewportState}
currentUser={presenceUser().id}
menuSlot={commentsSlot}
/>
)}