V2 Phase 5+6: connectivity, presence, navigation & friction

Phase 5 — live-session & multi-device hardening:
- connectivityStore + SyncStatusIndicator in the shell: shows "Offline" when the
  browser is offline and the cloud autosave state (saving / saved / failed) when
  signed in; useCloudAutosave now reports its status. Owns online/offline events.
- server presence heartbeat: protocol-level ping/pong per socket terminates a
  vanished connection (~30s), so a player who closes their laptop drops out of
  the GM's roster instead of lingering. No client changes needed.

Phase 6 — onboarding & friction:
- navigation: surfaced the previously orphaned routes in the rail — a new
  "World" group (Notes, NPCs, Quests, Calendar) and Homebrew + Assistant under
  Reference. (Empty-state hints and map rename already existed.)
- combat keyboard control: n/→ next turn, p/← previous turn, guarded so it never
  fires while typing; button tooltips document it.

Test hygiene: scoped now-ambiguous nav links in e2e to the Primary landmark, and
fixed pre-existing stale emoji selectors in the realtime suite (📡 Host → Host,
📨 Just for you → Just for you) left over from the Living Codex emoji purge.

Gate green: tsc + 277 unit + 34 e2e + 2 realtime. App + server build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 08:51:03 +02:00
parent 3d2c68a1a1
commit dc0e8f701c
13 changed files with 141 additions and 16 deletions
+2 -2
View File
@@ -19,7 +19,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await gm.getByLabel('Settings').click(); await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await gm.getByRole('button', { name: '📡 Host' }).click(); await gm.getByRole('button', { name: 'Host' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? ''; const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6); expect(code).toHaveLength(6);
@@ -76,7 +76,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await gm.getByRole('button', { name: /Share with/ }).click(); await gm.getByRole('button', { name: /Share with/ }).click();
await gm.getByLabel('Share title').fill('Secret passage'); await gm.getByLabel('Share title').fill('Secret passage');
await gm.getByRole('button', { name: /Send to/ }).click(); await gm.getByRole('button', { name: /Send to/ }).click();
await expect(player.getByText('📨 Just for you')).toBeVisible(); await expect(player.getByText('Just for you')).toBeVisible();
await expect(player.getByText('Secret passage')).toBeVisible(); await expect(player.getByText('Secret passage')).toBeVisible();
await gmCtx.close(); await gmCtx.close();
+1 -1
View File
@@ -19,7 +19,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
await gm.getByLabel('Settings').click(); await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await gm.getByRole('button', { name: '📡 Host' }).click(); await gm.getByRole('button', { name: 'Host' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? ''; const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6); expect(code).toHaveLength(6);
+2 -1
View File
@@ -33,7 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)',
test('assistant page renders the campaign insights section', async ({ page }) => { test('assistant page renders the campaign insights section', async ({ page }) => {
await page.getByLabel('Settings').click(); await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click(); await page.getByRole('button', { name: 'Load sample campaign' }).click();
await page.getByRole('link', { name: 'Assistant' }).click(); await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
// Sample has a single encounter → not enough for a trend yet. // Sample has a single encounter → not enough for a trend yet.
await expect(page.getByText(/No trends detected yet/)).toBeVisible(); await expect(page.getByText(/No trends detected yet/)).toBeVisible();
+1 -1
View File
@@ -23,7 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) =
// Assistant shows a resource suggestion // Assistant shows a resource suggestion
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Assistant' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible(); await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
+1 -1
View File
@@ -14,7 +14,7 @@ test('homebrew: create a custom monster and find it in the compendium', async ({
await page.locator('input[data-autofocus]').fill('Brew'); await page.locator('input[data-autofocus]').fill('Brew');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Homebrew' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'Homebrew' }).click();
// Create a homebrew monster and name it // Create a homebrew monster and name it
await page.getByRole('button', { name: /New monster/ }).click(); await page.getByRole('button', { name: /New monster/ }).click();
+3 -3
View File
@@ -30,13 +30,13 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
// NPC // NPC
await page.getByRole('link', { name: 'Dashboard' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'NPCs' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'NPCs' }).click();
await page.getByRole('button', { name: '+ New NPC' }).first().click(); await page.getByRole('button', { name: '+ New NPC' }).first().click();
await expect(page.getByLabel('NPC name')).toBeVisible(); await expect(page.getByLabel('NPC name')).toBeVisible();
// Quest with an objective // Quest with an objective
await page.getByRole('link', { name: 'Dashboard' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Quests' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
await page.getByRole('button', { name: '+ New quest' }).first().click(); await page.getByRole('button', { name: '+ New quest' }).first().click();
await page.getByPlaceholder('Add objective…').fill('Find the Tome'); await page.getByPlaceholder('Add objective…').fill('Find the Tome');
await page.getByRole('button', { name: 'Add', exact: true }).click(); await page.getByRole('button', { name: 'Add', exact: true }).click();
@@ -44,7 +44,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
// Calendar advances days // Calendar advances days
await page.getByRole('link', { name: 'Dashboard' }).click(); await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Calendar' }).click(); await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click();
await page.getByRole('button', { name: '+1 week' }).click(); await page.getByRole('button', { name: '+1 week' }).click();
await expect(page.getByText('7', { exact: true })).toBeVisible(); await expect(page.getByText('7', { exact: true })).toBeVisible();
}); });
+14 -1
View File
@@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => {
// per-socket token bucket: 80 messages / 10s // per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 }; const RATE = { capacity: 80, refillMs: 10_000 };
// Protocol-level keepalive: ping each socket on an interval and terminate one
// that misses a pong, so a vanished player (closed laptop, dead network) is
// detected and drops out of the GM's roster within ~one interval.
const HEARTBEAT_MS = 30_000;
export function buildServer() { export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT }); const app = Fastify({ bodyLimit: BODY_LIMIT });
@@ -155,6 +159,15 @@ export function buildServer() {
let tokens = RATE.capacity; let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('message', (raw: Buffer) => { socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; } if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed; let parsed;
@@ -177,7 +190,7 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break; case 'setName': hub.setName(sender, m.name); break;
} }
}); });
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
}); });
}); });
+11 -1
View File
@@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -20,10 +21,11 @@ import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave'; import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
import { SignalsBell } from '@/features/assistant/SignalsBell'; import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar'; import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' }; type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
const NAV: NavItem[] = [ const NAV: NavItem[] = [
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' }, { to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' }, { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
@@ -31,12 +33,19 @@ const NAV: NavItem[] = [
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' }, { to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' }, { to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' }, { to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
{ to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' },
{ to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' },
{ to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' },
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' }, { to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' },
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' },
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' }, { to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
]; ];
const GROUPS: { id: NavItem['group']; label: string | null }[] = [ const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'top', label: null }, { id: 'top', label: null },
{ id: 'play', label: 'At the Table' }, { id: 'play', label: 'At the Table' },
{ id: 'world', label: 'World' },
{ id: 'reference', label: 'Reference' }, { id: 'reference', label: 'Reference' },
]; ];
@@ -206,6 +215,7 @@ export function RootLayout() {
</Button> </Button>
{activeCampaign && <HandoutControl />} {activeCampaign && <HandoutControl />}
<SessionControl /> <SessionControl />
<SyncStatusIndicator />
<SignalsBell /> <SignalsBell />
<ThemeToggle /> <ThemeToggle />
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings"> <Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Cloud, CloudOff, RefreshCw, WifiOff } from 'lucide-react';
import { cloudUsername } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore';
import { cn } from '@/lib/cn';
/**
* Compact shell indicator for connectivity + cloud sync. Shows "Offline" when
* the browser is offline, otherwise the autosave state (saving / saved / failed)
* while signed into the cloud. Renders nothing when online and not using cloud,
* to keep the bar quiet. Also owns the online/offline event wiring.
*/
export function SyncStatusIndicator() {
const online = useConnectivityStore((s) => s.online);
const cloudSync = useConnectivityStore((s) => s.cloudSync);
const setOnline = useConnectivityStore((s) => s.setOnline);
const signedIn = !!cloudUsername();
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
setOnline(navigator.onLine);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
}, [setOnline]);
if (!online) {
return (
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
</span>
);
}
if (!signedIn) return null;
const map = {
idle: null,
saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' },
saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' },
error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' },
} as const;
const s = map[cloudSync];
if (!s) return null;
return (
<span className={cn('flex items-center gap-1 rounded-md px-2 py-1 text-xs', s.cls)} title={s.title} role="status" aria-live="polite">
<s.Icon size={14} aria-hidden className={s.spin ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{s.text}</span>
</span>
);
}
+7 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db'; import { db } from '@/lib/db/db';
import { cloudUsername, pushBackup } from '@/lib/cloud/client'; import { cloudUsername, pushBackup } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
/** /**
@@ -30,7 +31,12 @@ export function useCloudAutosave(): void {
}, [], undefined); }, [], undefined);
const save = useDebouncedCallback(() => { const save = useDebouncedCallback(() => {
if (cloudUsername()) void pushBackup().catch(() => { /* offline / transient — next change retries */ }); if (!cloudUsername()) return;
const conn = useConnectivityStore.getState();
conn.setCloudSync('saving');
void pushBackup()
.then(() => useConnectivityStore.getState().setCloudSync('saved'))
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
}, 8000); }, 8000);
// Skip the initial load; only push once data actually changes after mount. // Skip the initial load; only push once data actually changes after mount.
+22 -3
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@@ -121,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
const current = currentCombatant(encounter); const current = currentCombatant(encounter);
const isActive = encounter.status === 'active'; const isActive = encounter.status === 'active';
// Keyboard turn control for a GM running the table hands-free. Guarded so it
// never fires while typing in a field, and only during an active fight.
const advanceRef = useRef(advanceTurn);
advanceRef.current = advanceTurn;
const prevRef = useRef(() => mutate(previousTurn));
prevRef.current = () => mutate(previousTurn);
useEffect(() => {
if (!isActive) return;
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); }
else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isActive]);
return ( return (
<div> <div>
{/* Control bar */} {/* Control bar */}
@@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
)} )}
{isActive && ( {isActive && (
<> <>
<Button variant="secondary" onClick={() => mutate(previousTurn)}> <Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
<ChevronLeft size={15} aria-hidden /> Prev <ChevronLeft size={15} aria-hidden /> Prev
</Button> </Button>
<Button variant="primary" onClick={advanceTurn}> <Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
Next turn <ChevronRight size={15} aria-hidden /> Next turn <ChevronRight size={15} aria-hidden />
</Button> </Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}> <Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
export type CloudSync = 'idle' | 'saving' | 'saved' | 'error';
interface ConnectivityState {
/** browser online/offline (navigator.onLine + events) */
online: boolean;
/** cloud autosave state, surfaced in the shell */
cloudSync: CloudSync;
lastSavedAt: number | null;
setOnline: (online: boolean) => void;
setCloudSync: (cloudSync: CloudSync) => void;
}
/** Ephemeral connectivity + sync status (intentionally not persisted). */
export const useConnectivityStore = create<ConnectivityState>((set) => ({
online: typeof navigator === 'undefined' ? true : navigator.onLine,
cloudSync: 'idle',
lastSavedAt: null,
setOnline: (online) => set({ online }),
setCloudSync: (cloudSync) =>
set((s) => ({ cloudSync, lastSavedAt: cloudSync === 'saved' ? Date.now() : s.lastSavedAt })),
}));
File diff suppressed because one or more lines are too long