diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts
index 57e7b22..01aceed 100644
--- a/e2e-realtime/realtime.spec.ts
+++ b/e2e-realtime/realtime.spec.ts
@@ -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();
diff --git a/e2e-realtime/seats.spec.ts b/e2e-realtime/seats.spec.ts
index bdc7f5a..39812b5 100644
--- a/e2e-realtime/seats.spec.ts
+++ b/e2e-realtime/seats.spec.ts
@@ -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);
diff --git a/e2e/assistant-levelup.spec.ts b/e2e/assistant-levelup.spec.ts
index 05eef81..9c2e11d 100644
--- a/e2e/assistant-levelup.spec.ts
+++ b/e2e/assistant-levelup.spec.ts
@@ -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();
diff --git a/e2e/assistant.spec.ts b/e2e/assistant.spec.ts
index 7fce28b..944d24f 100644
--- a/e2e/assistant.spec.ts
+++ b/e2e/assistant.spec.ts
@@ -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();
diff --git a/e2e/homebrew.spec.ts b/e2e/homebrew.spec.ts
index 7b77d32..4ec08f4 100644
--- a/e2e/homebrew.spec.ts
+++ b/e2e/homebrew.spec.ts
@@ -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();
diff --git a/e2e/worldbuilding.spec.ts b/e2e/worldbuilding.spec.ts
index ec4481d..84681db 100644
--- a/e2e/worldbuilding.spec.ts
+++ b/e2e/worldbuilding.spec.ts
@@ -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();
});
diff --git a/server/src/index.ts b/server/src/index.ts
index 94cd161..09dd82b 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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); });
});
});
diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx
index 793d107..4a11c88 100644
--- a/src/app/RootLayout.tsx
+++ b/src/app/RootLayout.tsx
@@ -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() {
{activeCampaign && }
+
diff --git a/src/features/cloud/SyncStatusIndicator.tsx b/src/features/cloud/SyncStatusIndicator.tsx
new file mode 100644
index 0000000..faf3570
--- /dev/null
+++ b/src/features/cloud/SyncStatusIndicator.tsx
@@ -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 (
+
+ Offline
+
+ );
+ }
+
+ 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 (
+
+
+ {s.text}
+
+ );
+}
diff --git a/src/features/cloud/useCloudAutosave.ts b/src/features/cloud/useCloudAutosave.ts
index 4509d0e..8e4d320 100644
--- a/src/features/cloud/useCloudAutosave.ts
+++ b/src/features/cloud/useCloudAutosave.ts
@@ -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.
diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx
index 437ebef..bcd3766 100644
--- a/src/features/combat/EncounterTracker.tsx
+++ b/src/features/combat/EncounterTracker.tsx
@@ -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 (
{/* Control bar */}
@@ -175,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
)}
{isActive && (
<>
-