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.getByRole('button', { name: 'Load sample campaign' }).click();
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, '') ?? '';
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.getByLabel('Share title').fill('Secret passage');
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 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.getByRole('button', { name: 'Load sample campaign' }).click();
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, '') ?? '';
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 }) => {
await page.getByLabel('Settings').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();
// Sample has a single encounter → not enough for a trend yet.
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
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.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.getByRole('button', { name: 'Create' }).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
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
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 expect(page.getByLabel('NPC name')).toBeVisible();
// Quest with an objective
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.getByPlaceholder('Add objective…').fill('Find the Tome');
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
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 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
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() {
const app = Fastify({ bodyLimit: BODY_LIMIT });
@@ -155,6 +159,15 @@ export function buildServer() {
let tokens = RATE.capacity;
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) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed;
@@ -177,7 +190,7 @@ export function buildServer() {
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 {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
type LucideIcon,
} from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -20,10 +21,11 @@ import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar';
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[] = [
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
{ 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: '/maps', label: 'Battle Map', icon: MapIcon, 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: '/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' },
];
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'top', label: null },
{ id: 'play', label: 'At the Table' },
{ id: 'world', label: 'World' },
{ id: 'reference', label: 'Reference' },
];
@@ -206,6 +215,7 @@ export function RootLayout() {
</Button>
{activeCampaign && <HandoutControl />}
<SessionControl />
<SyncStatusIndicator />
<SignalsBell />
<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">
@@ -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 { db } from '@/lib/db/db';
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
/**
@@ -30,7 +31,12 @@ export function useCloudAutosave(): void {
}, [], undefined);
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);
// 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 {
ArrowDown,
ArrowUp,
@@ -121,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
const current = currentCombatant(encounter);
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 (
<div>
{/* Control bar */}
@@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
)}
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
<ChevronLeft size={15} aria-hidden /> Prev
</Button>
<Button variant="primary" onClick={advanceTurn}>
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
Next turn <ChevronRight size={15} aria-hidden />
</Button>
<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