P14: dock the session sidebar (persistent column, not a temp drawer)

- The session panel now docks as a persistent right-hand column on desktop and
  only floats as an overlay drawer on phones (md breakpoint). Open state persists
  (uiStore.sessionDockOpen). The header button opens it (idempotent); the ✕ closes.
- Players get it auto-opened on join — they live in the panel during play.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 17:08:13 +02:00
parent b2cf62f642
commit 4cb834ad6c
3 changed files with 33 additions and 14 deletions
+23 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { CommandPalette } from '@/components/CommandPalette'; import { CommandPalette } from '@/components/CommandPalette';
@@ -63,8 +63,11 @@ function ThemeToggle() {
export function RootLayout() { export function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname }); const pathname = useRouterState({ select: (s) => s.location.pathname });
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [sessionOpen, setSessionOpen] = useState(false); const sessionDockOpen = useUiStore((s) => s.sessionDockOpen);
const setSessionDock = useUiStore((s) => s.setSessionDock);
const sessionConnected = useSessionStore((s) => s.status === 'connected'); const sessionConnected = useSessionStore((s) => s.status === 'connected');
const sessionRole = useSessionStore((s) => s.role);
const joinIntent = useSessionStore((s) => s.joinIntent);
const activeCampaign = useActiveCampaign(); const activeCampaign = useActiveCampaign();
// While the GM is hosting, mirror state to players (inert unless hosting). // While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null); useSessionBroadcaster(activeCampaign ?? null);
@@ -82,6 +85,15 @@ export function RootLayout() {
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, []); }, []);
// Players get the session panel docked open by default (once) — they live in it.
const autoOpenedRef = useRef(false);
useEffect(() => {
if (!autoOpenedRef.current && joinIntent && sessionRole === 'player') {
autoOpenedRef.current = true;
setSessionDock(true);
}
}, [joinIntent, sessionRole, setSessionDock]);
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2"> <header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
@@ -107,7 +119,7 @@ export function RootLayout() {
</nav> </nav>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<PlayerSessionBadge /> <PlayerSessionBadge />
<Button size="sm" variant="ghost" onClick={() => setSessionOpen(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel"> <Button size="sm" variant="ghost" onClick={() => setSessionDock(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />} Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
</Button> </Button>
{activeCampaign && <HandoutControl />} {activeCampaign && <HandoutControl />}
@@ -121,14 +133,16 @@ export function RootLayout() {
</div> </div>
</header> </header>
<main className="flex-1 overflow-auto"> <div className="flex min-h-0 flex-1">
<ErrorBoundary resetKey={pathname}> <main className="flex-1 overflow-auto">
<Outlet /> <ErrorBoundary resetKey={pathname}>
</ErrorBoundary> <Outlet />
</main> </ErrorBoundary>
</main>
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
</div>
<RollTray /> <RollTray />
<SessionSidebar open={sessionOpen} onClose={() => setSessionOpen(false)} />
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />} {paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
</div> </div>
); );
+5 -5
View File
@@ -18,14 +18,14 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
const code = role === 'gm' ? joinCode : intent?.joinCode; const code = role === 'gm' ? joinCode : intent?.joinCode;
const inSession = role !== 'off' || !!intent; const inSession = role !== 'off' || !!intent;
if (!open) return null;
return ( return (
<> <>
{open && <div className="fixed inset-0 z-40 bg-black/40 print:hidden" onClick={onClose} aria-hidden />} {/* On phones the panel floats over content with a backdrop; on desktop it docks as a column. */}
<div className="fixed inset-0 z-40 bg-black/40 md:hidden print:hidden" onClick={onClose} aria-hidden />
<aside <aside
className={cn( className="fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] flex-col border-l border-line bg-panel shadow-2xl print:hidden md:static md:z-auto md:h-auto md:w-80 md:max-w-none md:shrink-0 md:self-stretch md:shadow-none"
'fixed right-0 top-0 z-50 flex h-full w-80 max-w-[88vw] transform flex-col border-l border-line bg-panel shadow-2xl transition-transform print:hidden',
open ? 'translate-x-0' : 'translate-x-full',
)}
aria-label="Session panel" aria-label="Session panel"
> >
<div className="flex items-center justify-between border-b border-line p-3"> <div className="flex items-center justify-between border-b border-line p-3">
+5
View File
@@ -15,12 +15,15 @@ interface UiState {
activeMapId: string | null; activeMapId: string | null;
/** a handout currently shown to players (title/body/optional image) */ /** a handout currently shown to players (title/body/optional image) */
activeHandout: Handout | null; activeHandout: Handout | null;
/** whether the docked session sidebar is open (persisted) */
sessionDockOpen: boolean;
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void; setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void; setActiveEncounter: (id: string | null) => void;
setActiveMap: (id: string | null) => void; setActiveMap: (id: string | null) => void;
setActiveHandout: (h: Handout | null) => void; setActiveHandout: (h: Handout | null) => void;
setSessionDock: (open: boolean) => void;
} }
export const useUiStore = create<UiState>()( export const useUiStore = create<UiState>()(
@@ -31,6 +34,7 @@ export const useUiStore = create<UiState>()(
activeEncounterId: null, activeEncounterId: null,
activeMapId: null, activeMapId: null,
activeHandout: null, activeHandout: null,
sessionDockOpen: false,
setTheme: (theme) => { setTheme: (theme) => {
applyTheme(theme); applyTheme(theme);
set({ theme }); set({ theme });
@@ -40,6 +44,7 @@ export const useUiStore = create<UiState>()(
setActiveEncounter: (id) => set({ activeEncounterId: id }), setActiveEncounter: (id) => set({ activeEncounterId: id }),
setActiveMap: (id) => set({ activeMapId: id }), setActiveMap: (id) => set({ activeMapId: id }),
setActiveHandout: (activeHandout) => set({ activeHandout }), setActiveHandout: (activeHandout) => set({ activeHandout }),
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
}), }),
{ {
name: 'ttrpg-ui', name: 'ttrpg-ui',