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:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user