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:
@@ -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
@@ -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(() => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user