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