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
@@ -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)}>