Tablet controls, participant action states, and Android shortcut fixes #9

Merged
robert merged 3 commits from chore/test-and-cleanup into main 2026-07-15 15:08:22 -04:00
6 changed files with 270 additions and 42 deletions
Showing only changes of commit 119d252d02 - Show all commits
+6
View File
@@ -5,6 +5,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open ## Open
caff doent work on fron tpage or big tablet
max/caff need to float or at least be availabe in combagt popout
save needs bigger save button on char editor
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
+124 -39
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle, UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon, Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText, StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X, Maximize2, Minimize2, PanelTopOpen, PanelTopClose, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair Undo2, Redo2, Crosshair
} from 'lucide-react'; } from 'lucide-react';
@@ -1051,8 +1051,12 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
/> />
NPC NPC
</label> </label>
<button type="submit" className="p-1 text-green-400 hover:text-green-300"> <button
<Save size={18} /> type="submit"
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white font-semibold text-sm min-h-[40px]"
aria-label="Save character"
>
<Save size={18} /> Save
</button> </button>
<button <button
type="button" type="button"
@@ -1763,7 +1767,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isGeneric = (encounter.ruleset || '5e') === 'generic'; const isGeneric = (encounter.ruleset || '5e') === 'generic';
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc'); const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]'; let bgColor = !p.isActive ? 'bg-stone-800' : (p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]');
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600'; if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (isGeneric const participantStatus = p.status || (isGeneric
@@ -1781,7 +1785,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined} onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined} onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)} onDragEnd={() => setDraggedItemId(null)}
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`} className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'ring-2 ring-stone-500' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
> >
{editingId === p.id && ( {editingId === p.id && (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"> <span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
@@ -2062,22 +2066,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)} )}
<button <button
onClick={() => toggleParticipantActive(p.id)} onClick={() => toggleParticipantActive(p.id)}
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`} className={`p-1 rounded transition-all ring-2 ${p.isActive ? 'bg-emerald-700 hover:bg-emerald-600 text-white ring-emerald-300' : 'bg-red-900 hover:bg-red-800 text-red-100 ring-red-500'}`}
title={p.isActive ? "Mark Inactive" : "Mark Active"} title={p.isActive ? "Participant active — click to disable" : "Participant inactive — click to enable"}
aria-label={p.isActive ? "Disable participant" : "Enable participant"}
aria-pressed={p.isActive}
> >
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />} {p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
</button> </button>
<button <button
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)} onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`} className={`p-1 rounded transition-all ${openConditionsId === p.id ? 'bg-purple-700 text-white ring-2 ring-purple-300' : (p.conditions || []).length > 0 ? 'bg-stone-700 text-purple-300 ring-1 ring-purple-500 hover:bg-stone-600' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
title="Conditions" title={openConditionsId === p.id ? 'Conditions open — click to close' : 'Open conditions'}
aria-label={openConditionsId === p.id ? 'Close conditions' : 'Open conditions'}
aria-expanded={openConditionsId === p.id}
> >
<span className="text-base leading-none"></span> <span className="text-base leading-none"></span>
</button> </button>
<button <button
onClick={() => setEditingParticipant(p)} onClick={() => setEditingParticipant(editingParticipant?.id === p.id ? null : p)}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600" className={`p-1 rounded transition-all ${editingParticipant?.id === p.id ? 'bg-amber-500 text-stone-950 ring-2 ring-amber-200' : 'text-yellow-300 bg-stone-700 hover:bg-stone-600'}`}
title="Edit" title={editingParticipant?.id === p.id ? 'Editing — click to close' : 'Edit participant'}
aria-label={editingParticipant?.id === p.id ? 'Close participant editor' : 'Edit participant'}
aria-pressed={editingParticipant?.id === p.id}
> >
<Edit3 size={18} /> <Edit3 size={18} />
</button> </button>
@@ -2402,7 +2412,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ENCOUNTER MANAGER COMPONENT // ENCOUNTER MANAGER COMPONENT
// ============================================================================ // ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) { function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef, wakeLockEnabled, onToggleWakeLock, isBrowserFullscreen, onToggleBrowserFullscreen }) {
const { showToast } = useUIFeedback(); const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection( const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null campaignId ? getPath.encounters(campaignId) : null
@@ -2708,15 +2718,34 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{selectedEncounter && ( {selectedEncounter && (
<div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}> <div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between"> <h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between gap-2">
<span>Managing Encounter: {selectedEncounter.name}</span> <span className="min-w-0 truncate">Managing Encounter: {selectedEncounter.name}</span>
<button <span className="flex items-center gap-1 flex-shrink-0">
onClick={() => setEncounterFullscreen(v => !v)} <button
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0" onClick={onToggleWakeLock}
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'} className={`p-1.5 rounded-md transition-colors ${wakeLockEnabled ? 'bg-amber-600 text-white hover:bg-amber-700' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
> title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />} aria-label={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
</button> >
{wakeLockEnabled ? <Coffee size={18} /> : <Moon size={18} />}
</button>
<button
onClick={onToggleBrowserFullscreen}
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
title={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
aria-label={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
>
{isBrowserFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button
onClick={() => setEncounterFullscreen(v => !v)}
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
title={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
aria-label={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
>
{encounterFullscreen ? <PanelTopClose size={18} /> : <PanelTopOpen size={18} />}
</button>
</span>
</h3> </h3>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
{/* Combat Controls - always left */} {/* Combat Controls - always left */}
@@ -2770,21 +2799,43 @@ function AdminView({ userId }) {
catch { return false; } catch { return false; }
}); });
const wakeLockRef = useRef(null); const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const toggleFullscreen = () => { const toggleFullscreen = async () => {
if (!document.fullscreenElement) { try {
document.documentElement.requestFullscreen(); if (!document.fullscreenElement) {
} else { fullscreenWantedRef.current = true;
document.exitFullscreen(); await document.documentElement.requestFullscreen();
} else {
fullscreenWantedRef.current = false;
await document.exitFullscreen();
}
} catch (e) {
showToast('Fullscreen failed. Tap fullscreen again.');
} }
}; };
useEffect(() => { useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); const onFsChange = () => {
const active = !!document.fullscreenElement;
setIsFullscreen(active);
if (active) fullscreenWantedRef.current = true;
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
};
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []); }, []);
useEffect(() => {
const restoreFullscreen = async () => {
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
}
};
document.addEventListener('visibilitychange', restoreFullscreen);
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
}, []);
useEffect(() => { useEffect(() => {
if (!wakeLockEnabled) { if (!wakeLockEnabled) {
wakeLockRef.current?.release(); wakeLockRef.current?.release();
@@ -2792,8 +2843,13 @@ function AdminView({ userId }) {
return; return;
} }
const acquire = async () => { const acquire = async () => {
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); } if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
catch (e) { try {
if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
const sentinel = await navigator.wakeLock.request('screen');
wakeLockRef.current = sentinel;
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
} catch (e) {
console.error('Wake lock failed:', e); console.error('Wake lock failed:', e);
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure'); showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
} }
@@ -3250,6 +3306,10 @@ function AdminView({ userId }) {
campaignCharacters={selectedCampaign.characters || []} campaignCharacters={selectedCampaign.characters || []}
encounterStartedRef={encounterStartedRef} encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef} encounterActiveRef={encounterActiveRef}
wakeLockEnabled={wakeLockEnabled}
onToggleWakeLock={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
isBrowserFullscreen={isFullscreen}
onToggleBrowserFullscreen={toggleFullscreen}
/> />
</div> </div>
)} )}
@@ -3350,6 +3410,7 @@ function DisplayView() {
}); });
const [displayParticipants, setDisplayParticipants] = useState([]); const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null); const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const currentParticipantRef = useRef(null); const currentParticipantRef = useRef(null);
// Player display transition state. Active participants render normally. // Player display transition state. Active participants render normally.
@@ -3380,19 +3441,40 @@ function DisplayView() {
}, []); }, []);
useEffect(() => { useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); const onFsChange = () => {
const active = !!document.fullscreenElement;
setIsFullscreen(active);
if (active) fullscreenWantedRef.current = true;
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
};
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []); }, []);
const toggleFullscreen = () => { const toggleFullscreen = async () => {
if (!document.fullscreenElement) { try {
document.documentElement.requestFullscreen(); if (!document.fullscreenElement) {
} else { fullscreenWantedRef.current = true;
document.exitFullscreen(); await document.documentElement.requestFullscreen();
} else {
fullscreenWantedRef.current = false;
await document.exitFullscreen();
}
} catch (e) {
console.error('Fullscreen failed:', e);
} }
}; };
useEffect(() => {
const restoreFullscreen = async () => {
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
}
};
document.addEventListener('visibilitychange', restoreFullscreen);
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
}, []);
useEffect(() => { useEffect(() => {
if (!wakeLockEnabled) { if (!wakeLockEnabled) {
wakeLockRef.current?.release(); wakeLockRef.current?.release();
@@ -3401,8 +3483,12 @@ function DisplayView() {
} }
const acquire = async () => { const acquire = async () => {
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
try { try {
wakeLockRef.current = await navigator.wakeLock.request('screen'); if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
const sentinel = await navigator.wakeLock.request('screen');
wakeLockRef.current = sentinel;
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
} catch (e) { } catch (e) {
console.error('Wake lock failed:', e); console.error('Wake lock failed:', e);
} }
@@ -3410,10 +3496,9 @@ function DisplayView() {
acquire(); acquire();
// Re-acquire after tab becomes visible again (browser auto-releases on hide) // Re-acquire after tab becomes visible again (browser auto-releases on hide).
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); }; const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
document.addEventListener('visibilitychange', onVisChange); document.addEventListener('visibilitychange', onVisChange);
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
const onFsChange = () => { if (document.fullscreenElement) acquire(); }; const onFsChange = () => { if (document.fullscreenElement) acquire(); };
document.addEventListener('fullscreenchange', onFsChange); document.addEventListener('fullscreenchange', onFsChange);
return () => { return () => {
@@ -70,7 +70,7 @@ describe('Participant -> Firebase', () => {
test('toggleActive: updateDoc flips isActive', async () => { test('toggleActive: updateDoc flips isActive', async () => {
await setupReady(); await setupReady();
await addMonsterViaUI('Toggle', 10, 0); await addMonsterViaUI('Toggle', 10, 0);
fireEvent.click(screen.getByTitle('Mark Inactive')); fireEvent.click(screen.getByRole('button', { name: 'Disable participant' }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false); await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
expect(lastEncCall().data.participants[0].isActive).toBe(false); expect(lastEncCall().data.participants[0].isActive).toBe(false);
}); });
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
test('toggleCondition: updateDoc adds condition to array', async () => { test('toggleCondition: updateDoc adds condition to array', async () => {
await setupReady(); await setupReady();
await addMonsterViaUI('Cond', 10, 0); await addMonsterViaUI('Cond', 10, 0);
fireEvent.click(screen.getByTitle('Conditions')); fireEvent.click(screen.getByRole('button', { name: 'Open conditions' }));
await waitFor(() => screen.getByRole('button', { name: /Blinded/i })); await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
fireEvent.click(screen.getByRole('button', { name: /Blinded/i })); fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
await waitFor(() => { await waitFor(() => {
+42
View File
@@ -0,0 +1,42 @@
import { screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady, addMonsterViaUI } from './testHelpers';
describe('participant action selected states', () => {
test('active toggle clearly changes to selected inactive state', async () => {
await setupReady('ActionCamp1', 'ActionEnc1');
await addMonsterViaUI('Toggle Orc', 15, 3);
const disable = screen.getByRole('button', { name: 'Disable participant' });
expect(disable).toHaveAttribute('aria-pressed', 'true');
expect(disable).toHaveClass('bg-emerald-700');
fireEvent.click(disable);
const enable = await screen.findByRole('button', { name: 'Enable participant' });
expect(enable).toHaveAttribute('aria-pressed', 'false');
expect(enable).toHaveClass('bg-red-900', 'ring-red-500');
});
test('conditions toggle highlights open state and gives close action', async () => {
await setupReady('ActionCamp2', 'ActionEnc2');
await addMonsterViaUI('Condition Orc', 15, 3);
const open = screen.getByRole('button', { name: 'Open conditions' });
expect(open).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(open);
const close = screen.getByRole('button', { name: 'Close conditions' });
expect(close).toHaveAttribute('aria-expanded', 'true');
expect(close).toHaveClass('bg-purple-700', 'ring-purple-300');
});
test('edit toggle exposes selected state while editor is open', async () => {
await setupReady('ActionCamp3', 'ActionEnc3');
await addMonsterViaUI('Edit Orc', 15, 3);
fireEvent.click(screen.getByRole('button', { name: 'Edit participant' }));
const closeEditor = screen.getByRole('button', { name: 'Close participant editor' });
expect(closeEditor).toHaveAttribute('aria-pressed', 'true');
expect(closeEditor).toHaveClass('bg-amber-500', 'ring-amber-200');
});
});
+1 -1
View File
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']); expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
// open edit modal for Goblin, bump init to 8 // open edit modal for Goblin, bump init to 8
const editBtns = screen.getAllByTitle('Edit'); const editBtns = screen.getAllByRole('button', { name: 'Edit participant' });
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin')); const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
fireEvent.click(goblinEdit); fireEvent.click(goblinEdit);
+95
View File
@@ -0,0 +1,95 @@
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady } from './testHelpers';
describe('screen controls', () => {
let originalRequestFullscreen;
let originalExitFullscreen;
let originalFullscreenElement;
let originalVisibilityState;
let originalWakeLock;
beforeEach(() => {
localStorage.clear();
originalRequestFullscreen = document.documentElement.requestFullscreen;
originalExitFullscreen = document.exitFullscreen;
originalFullscreenElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
originalWakeLock = Object.getOwnPropertyDescriptor(navigator, 'wakeLock');
});
afterEach(() => {
document.documentElement.requestFullscreen = originalRequestFullscreen;
document.exitFullscreen = originalExitFullscreen;
if (originalFullscreenElement) Object.defineProperty(document, 'fullscreenElement', originalFullscreenElement);
else Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
if (originalVisibilityState) Object.defineProperty(document, 'visibilityState', originalVisibilityState);
if (originalWakeLock) Object.defineProperty(navigator, 'wakeLock', originalWakeLock);
else Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: undefined });
localStorage.clear();
});
test('encounter header exposes prevent-sleep, browser-fullscreen, and popout controls', async () => {
await setupReady('ScreenCamp', 'ScreenEnc');
expect(screen.getAllByRole('button', { name: 'Prevent sleep' }).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByRole('button', { name: 'Enter browser fullscreen' })).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Expand encounter to full page' })).toBeInTheDocument();
});
test('re-acquires wake lock when tablet becomes visible after browser release', async () => {
let releaseListener;
const sentinel = {
released: false,
release: jest.fn().mockResolvedValue(undefined),
addEventListener: jest.fn((event, callback) => { if (event === 'release') releaseListener = callback; }),
};
const request = jest.fn().mockResolvedValue(sentinel);
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('WakeCamp', 'WakeEnc');
fireEvent.click(screen.getAllByRole('button', { name: 'Prevent sleep' })[0]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
visibility = 'hidden';
releaseListener();
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
});
test('character editor has labeled tablet-sized save button', async () => {
await setupReady('SaveCamp', 'SaveEnc');
fireEvent.change(screen.getByLabelText('Name', { selector: '#characterName' }), { target: { value: 'Tablet Hero' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Character' }));
await screen.findByText('Tablet Hero');
fireEvent.click(screen.getByRole('button', { name: 'Edit character' }));
const save = screen.getByRole('button', { name: 'Save character' });
expect(save).toHaveTextContent('Save');
expect(save).toHaveClass('min-h-[40px]');
});
test('attempts to restore fullscreen when tablet becomes visible after lock', async () => {
const requestFullscreen = jest.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = requestFullscreen;
document.exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('RestoreCamp', 'RestoreEnc');
fireEvent.click(screen.getByRole('button', { name: 'Enter browser fullscreen' }));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1));
visibility = 'hidden';
document.dispatchEvent(new Event('fullscreenchange'));
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(2));
});
});