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 { useUiStore } from '@/stores/uiStore';
import { CommandPalette } from '@/components/CommandPalette';
@@ -63,8 +63,11 @@ function ThemeToggle() {
export function RootLayout() {
const pathname = useRouterState({ select: (s) => s.location.pathname });
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 sessionRole = useSessionStore((s) => s.role);
const joinIntent = useSessionStore((s) => s.joinIntent);
const activeCampaign = useActiveCampaign();
// While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null);
@@ -82,6 +85,15 @@ export function RootLayout() {
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 (
<div className="flex h-full flex-col">
<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>
<div className="ml-auto flex items-center gap-2">
<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" />}
</Button>
{activeCampaign && <HandoutControl />}
@@ -121,14 +133,16 @@ export function RootLayout() {
</div>
</header>
<main className="flex-1 overflow-auto">
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
</main>
<div className="flex min-h-0 flex-1">
<main className="flex-1 overflow-auto">
<ErrorBoundary resetKey={pathname}>
<Outlet />
</ErrorBoundary>
</main>
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
</div>
<RollTray />
<SessionSidebar open={sessionOpen} onClose={() => setSessionOpen(false)} />
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
</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 inSession = role !== 'off' || !!intent;
if (!open) return null;
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
className={cn(
'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',
)}
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"
aria-label="Session panel"
>
<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;
/** a handout currently shown to players (title/body/optional image) */
activeHandout: Handout | null;
/** whether the docked session sidebar is open (persisted) */
sessionDockOpen: boolean;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setActiveCampaign: (id: string | null) => void;
setActiveEncounter: (id: string | null) => void;
setActiveMap: (id: string | null) => void;
setActiveHandout: (h: Handout | null) => void;
setSessionDock: (open: boolean) => void;
}
export const useUiStore = create<UiState>()(
@@ -31,6 +34,7 @@ export const useUiStore = create<UiState>()(
activeEncounterId: null,
activeMapId: null,
activeHandout: null,
sessionDockOpen: false,
setTheme: (theme) => {
applyTheme(theme);
set({ theme });
@@ -40,6 +44,7 @@ export const useUiStore = create<UiState>()(
setActiveEncounter: (id) => set({ activeEncounterId: id }),
setActiveMap: (id) => set({ activeMapId: id }),
setActiveHandout: (activeHandout) => set({ activeHandout }),
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
}),
{
name: 'ttrpg-ui',