feat: improve tablet screen controls and participant action states

- Add wake lock and browser fullscreen controls to encounter header/popout
- Re-acquire wake lock after browser release (still doesnt work on tablet)
- Retry fullscreen after Android unlock; keep manual control visible
- Add larger labeled character Save button
- Use distinct panel icon for encounter full-page expansion
- Add strong active/inactive, conditions-open, and editing button states
- Keep inactive participant controls visible
- Add regression tests and update affected characterization tests
- Update project TODO notes
This commit is contained in:
david raistrick
2026-07-15 12:30:07 -04:00
parent 30712f0e1e
commit 119d252d02
6 changed files with 270 additions and 42 deletions
+6
View File
@@ -5,6 +5,12 @@ Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## 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
+124 -39
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
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
} from 'lucide-react';
@@ -1051,8 +1051,12 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
/>
NPC
</label>
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
<Save size={18} />
<button
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
type="button"
@@ -1763,7 +1767,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isGeneric = (encounter.ruleset || '5e') === 'generic';
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';
const participantStatus = p.status || (isGeneric
@@ -1781,7 +1785,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
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 && (
<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
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`}
title={p.isActive ? "Mark Inactive" : "Mark Active"}
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 ? "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} />}
</button>
<button
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'}`}
title="Conditions"
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={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>
</button>
<button
onClick={() => setEditingParticipant(p)}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
title="Edit"
onClick={() => setEditingParticipant(editingParticipant?.id === p.id ? null : p)}
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={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} />
</button>
@@ -2402,7 +2412,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// 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 { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
@@ -2708,15 +2718,34 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{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' : ''}`}>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between">
<span>Managing Encounter: {selectedEncounter.name}</span>
<button
onClick={() => setEncounterFullscreen(v => !v)}
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0"
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'}
>
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between gap-2">
<span className="min-w-0 truncate">Managing Encounter: {selectedEncounter.name}</span>
<span className="flex items-center gap-1 flex-shrink-0">
<button
onClick={onToggleWakeLock}
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'}
aria-label={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
>
{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>
<div className="flex flex-row gap-4">
{/* Combat Controls - always left */}
@@ -2770,21 +2799,43 @@ function AdminView({ userId }) {
catch { return false; }
});
const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
fullscreenWantedRef.current = true;
await document.documentElement.requestFullscreen();
} else {
fullscreenWantedRef.current = false;
await document.exitFullscreen();
}
} catch (e) {
showToast('Fullscreen failed. Tap fullscreen again.');
}
};
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);
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(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
@@ -2792,8 +2843,13 @@ function AdminView({ userId }) {
return;
}
const acquire = async () => {
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); }
catch (e) {
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
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);
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 || []}
encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef}
wakeLockEnabled={wakeLockEnabled}
onToggleWakeLock={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
isBrowserFullscreen={isFullscreen}
onToggleBrowserFullscreen={toggleFullscreen}
/>
</div>
)}
@@ -3350,6 +3410,7 @@ function DisplayView() {
});
const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const currentParticipantRef = useRef(null);
// Player display transition state. Active participants render normally.
@@ -3380,19 +3441,40 @@ function DisplayView() {
}, []);
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);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
fullscreenWantedRef.current = true;
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(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
@@ -3401,8 +3483,12 @@ function DisplayView() {
}
const acquire = async () => {
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
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) {
console.error('Wake lock failed:', e);
}
@@ -3410,10 +3496,9 @@ function DisplayView() {
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(); };
document.addEventListener('visibilitychange', onVisChange);
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
document.addEventListener('fullscreenchange', onFsChange);
return () => {
@@ -70,7 +70,7 @@ describe('Participant -> Firebase', () => {
test('toggleActive: updateDoc flips isActive', async () => {
await setupReady();
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);
expect(lastEncCall().data.participants[0].isActive).toBe(false);
});
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
test('toggleCondition: updateDoc adds condition to array', async () => {
await setupReady();
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 }));
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
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']);
// 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'));
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));
});
});