From e740ea6c0176248252597b2826894a5f6fa59650 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 12 Jul 2026 04:42:59 +0200 Subject: [PATCH] Move live presence into the TopBar, signed-in only (#102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the TopBar registers a presence slot (only rendered for signed-in users) next to the page-actions slot; PageEditor portals the PresenceStrip into it — behavior unchanged (initials avatars, max 5 + overflow, viewer badge, hidden when empty, both view and edit mode) - pinned guarantee: public.spec asserts the anonymous read path opens no /collab websocket and renders no presence data Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- apps/web/e2e/public.spec.ts | 9 +++++++++ apps/web/src/layout/AppLayout.tsx | 14 ++++++++++---- apps/web/src/layout/TopBar.tsx | 7 +++++-- apps/web/src/layout/page-actions.tsx | 15 +++++++++++---- apps/web/src/pages/PageEditorPage.tsx | 8 +++++++- apps/web/src/styles/base.css | 6 ++++++ 6 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/web/e2e/public.spec.ts b/apps/web/e2e/public.spec.ts index 59d20c0..1b9d855 100644 --- a/apps/web/e2e/public.spec.ts +++ b/apps/web/e2e/public.spec.ts @@ -29,12 +29,21 @@ test('an anonymous visitor reads a public page and its image via the SPA', async try { const anon = await browser.newContext({ baseURL: BASE_URL }); // no session const page = await anon.newPage(); + // Pinned guarantee (#102): the anonymous/public read path never opens an + // awareness/presence connection and never renders presence data. + const collabSockets: string[] = []; + page.on('websocket', (ws) => { + if (ws.url().includes('/collab')) collabSockets.push(ws.url()); + }); await page.goto('/public/content-fixtures/fixture-image'); // The read-only public view renders — with no collaborative editor. await expect(page.locator('.public-page__badge')).toBeVisible(); await expect(page.locator('.public-page__title')).toContainText('Fixture Image'); await expect(page.locator('.ProseMirror')).toHaveCount(0); + await expect(page.locator('.presence-strip')).toHaveCount(0); + await expect(page.locator('.presence-avatar')).toHaveCount(0); + expect(collabSockets, `awareness sockets on the public path: ${collabSockets}`).toEqual([]); // The embedded image streams to the anonymous visitor (media honors public): // fetch its resolved /media URL from the same session-less context. diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index b6439b3..c645e9e 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -19,12 +19,18 @@ export function AppLayout(): React.JSX.Element { const collapsed = sidebarCollapsed || forcedHidden; // Clamp on read too — the persisted value may predate a bounds change. const widthRem = clampSidebarWidth(sidebarWidth); - // The TopBar registers its page-actions element here; the active page - // portals its icon actions into it (issue #101). + // The TopBar registers its page-scoped elements here; the active page + // portals its icon actions (#101) and presence strip (#102) into them. const [actionsElement, setActionsElement] = useState(null); + const [presenceElement, setPresenceElement] = useState(null); const actionsSlot = useMemo( - () => ({ element: actionsElement, setElement: setActionsElement }), - [actionsElement], + () => ({ + element: actionsElement, + setElement: setActionsElement, + presenceElement, + setPresenceElement, + }), + [actionsElement, presenceElement], ); // Ctrl/Cmd+\ toggles the sidebar (same shortcut as Notion), regardless of diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index f740352..9b48dd1 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -27,7 +27,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); - const { setElement: setPageActionsElement } = usePageActionsSlot(); + const { setElement: setPageActionsElement, setPresenceElement } = usePageActionsSlot(); async function handleLogout(): Promise { setMenuOpen(false); @@ -64,7 +64,10 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac {user && } - {/* Pages portal their icon actions here while active (issue #101). */} + {/* Page-scoped slots, rendered only for signed-in users: live presence + (#102) and the page's icon actions (#101) portal in while a page + route is active. */} + {user &&
} {user &&
} {user && (