From 119d252d02dadad6d0874778894bed0e2292f2b6 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:24:51 -0400 Subject: [PATCH] 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 --- TODO.md | 6 + src/App.js | 163 +++++++++++++----- .../Participant.characterization.test.js | 4 +- src/tests/ParticipantActionStates.test.js | 42 +++++ src/tests/ReslotAllPaths.test.js | 2 +- src/tests/ScreenControls.test.js | 95 ++++++++++ 6 files changed, 270 insertions(+), 42 deletions(-) create mode 100644 src/tests/ParticipantActionStates.test.js create mode 100644 src/tests/ScreenControls.test.js diff --git a/TODO.md b/TODO.md index 5aa8345..8118807 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/App.js b/src/App.js index 2d682cf..28a87e1 100644 --- a/src/App.js +++ b/src/App.js @@ -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 - @@ -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 && (
-

- Managing Encounter: {selectedEncounter.name} - +

+ Managing Encounter: {selectedEncounter.name} + + + + +

{/* 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} />
)} @@ -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 () => { diff --git a/src/tests/Participant.characterization.test.js b/src/tests/Participant.characterization.test.js index 77b6bd1..5504f6d 100644 --- a/src/tests/Participant.characterization.test.js +++ b/src/tests/Participant.characterization.test.js @@ -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(() => { diff --git a/src/tests/ParticipantActionStates.test.js b/src/tests/ParticipantActionStates.test.js new file mode 100644 index 0000000..7361841 --- /dev/null +++ b/src/tests/ParticipantActionStates.test.js @@ -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'); + }); +}); diff --git a/src/tests/ReslotAllPaths.test.js b/src/tests/ReslotAllPaths.test.js index 771b66f..f7d7159 100644 --- a/src/tests/ReslotAllPaths.test.js +++ b/src/tests/ReslotAllPaths.test.js @@ -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); diff --git a/src/tests/ScreenControls.test.js b/src/tests/ScreenControls.test.js new file mode 100644 index 0000000..4703cb2 --- /dev/null +++ b/src/tests/ScreenControls.test.js @@ -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)); + }); +});