Author SHA1 Message Date
robert 23aa99ba0a Merge pull request 'Tablet controls, participant action states, and Android shortcut fixes' (#9) from chore/test-and-cleanup into main
Reviewed-on: #9
2026-07-15 15:08:22 -04:00
david raistrick dc0a1f1633 fix: preserve user-renamable Android shortcuts
Chrome Android forces the root URL into PWA installation when required
manifest icon files exist. PWA names come from the manifest and cannot be
edited, which breaks workflows using multiple tracker copies.

Keep required 192/512 icon assets intentionally unavailable by committing
generated files with .png-not suffixes. Removing the manifest link does not
restore root shortcut naming; Chrome still installs the root as a PWA.

Chrome handles /display differently: even with a working manifest it offers
Install app or Create shortcut, and Create shortcut permits renaming. Keep
both manifests intentionally non-installable for consistent user-controlled
shortcut names.

Also add separate player-display favicon/logo artwork and swap /display browser
favicon to player-favicon.ico. Keep generated standard/player logo sources
under renamed inert filenames.
2026-07-15 13:50:53 -04:00
david raistrick 119d252d02 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
2026-07-15 12:30:07 -04:00
david raistrick 30712f0e1e chore: strengthen lint coverage and update project housekeeping
- Recursively lint all production JS/JSX under src/ and shared/
- Exclude tests, mocks, and node_modules from static guard
- Update TODO status
- Exclude TODO.md from Docker build context
2026-07-14 22:38:44 -04:00
robertandClaude Opus 4.8 7d0ea883b0 fix: drop useless string concat in devTools KEY to unblock CI build
react-scripts build with CI=true treats the no-useless-concat ESLint
warning as an error. DefinePlugin inlining is already prevented by the
dynamic process.env[KEY] lookup, so the literal split was unnecessary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:07:04 -04:00
robert 5c062bf944 Merge pull request 'Generic (non-5e) ruleset mode + UI polish + combat switch fix' (#7) from feat/generic-ruleset into main
Reviewed-on: #7
2026-07-08 22:54:03 -04:00
19 changed files with 338 additions and 85 deletions
+1
View File
@@ -1,3 +1,4 @@
TODO.md
node_modules node_modules
**/node_modules **/node_modules
.git .git
+18 -11
View File
@@ -2,44 +2,51 @@
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md. Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open ## Open
x caff doent work on fron tpage or big tablet
x max/caff need to float or at least be availabe in combagt popout
x 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
also better vert tab layout - labelt friendly x also better vert tab layout - labelt friendly
x needs AC for players dude x needs AC for players dude
x and quick entry hp x and quick entry hp
hp do not carry from encounter to ecnounter!!! x hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter? ^feature to add back to campaign character after encounter?
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront) maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
hp wont go over max and no temp hp support
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes? x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
x encounters cards need clear "in progress"...
I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs..... npcs.....
x hp wont go over max and no temp hp support
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### dm list - keep active particpant in view (scroll)
not sure good way to do this
### npm install warnings cleanup pass ### npm install warnings cleanup pass
lots of updates lots of updates
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
+9 -11
View File
@@ -1,27 +1,25 @@
{ {
"id": "/display",
"short_name": "TTRPG Display", "short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker Player Display", "name": "TTRPG Initiative Tracker - Player Display",
"icons": [ "icons": [
{ {
"src": "favicon.ico", "src": "player-favicon.ico",
"sizes": "64x64 32x32 24x24 16x16", "sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon" "type": "image/x-icon"
}, },
{ {
"src": "logo192.png", "src": "player-logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"start_url": "/display", "start_url": "/display",
"scope": "/", "scope": "/display",
"display": "standalone", "note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"orientation": "landscape", "display": "fullscreen",
"orientation": "portrait",
"theme_color": "#1A202C", "theme_color": "#1A202C",
"background_color": "#1A202C" "background_color": "#1A202C"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+7 -7
View File
@@ -1,4 +1,5 @@
{ {
"id": "/",
"short_name": "TTRPG Tracker", "short_name": "TTRPG Tracker",
"name": "TTRPG Initiative Tracker", "name": "TTRPG Initiative Tracker",
"icons": [ "icons": [
@@ -9,17 +10,16 @@
}, },
{ {
"src": "logo192.png", "src": "logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png", "type": "image/png",
"sizes": "192x192" "sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
} }
], ],
"start_url": ".", "start_url": "/",
"display": "standalone", "scope": "/",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#2D3748", "theme_color": "#2D3748",
"background_color": "#1A202C" "background_color": "#1A202C"
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+18 -9
View File
@@ -1,21 +1,30 @@
// STATIC GUARD: prod source must pass eslint with zero errors/warnings. // STATIC GUARD: prod source must pass eslint with zero errors/warnings.
// Scans App.js + storage adapters + shared modules. Catches unused imports, // Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). Catches unused imports,
// dead code, undefined vars before they hit the browser console. // dead code, undefined vars before they hit the browser console.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem. // Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process'); const { execSync } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..', '..'); const ROOT = path.resolve(__dirname, '..', '..');
function walkJs(dir) {
let out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
out = out.concat(walkJs(full));
} else if (/\.(js|jsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
const TARGETS = [ const TARGETS = [
'src/App.js', ...walkJs(path.join(ROOT, 'src')),
'src/storage/contract.js', ...walkJs(path.join(ROOT, 'shared')),
'src/storage/firebase.js',
'src/storage/index.js',
'src/storage/server.js',
'shared/index.js',
'shared/logEvent.js',
'shared/turn.js',
]; ];
function runEslint(files) { function runEslint(files) {
+126 -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 () => {
@@ -4057,6 +4142,8 @@ function App() {
// swap manifest so Android home-screen installs launch /display // swap manifest so Android home-screen installs launch /display
const link = document.querySelector('link[rel="manifest"]'); const link = document.querySelector('link[rel="manifest"]');
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`; if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = `${process.env.PUBLIC_URL || ''}/player-favicon.ico`;
} }
if (window.location.pathname === '/logs') { if (window.location.pathname === '/logs') {
setIsLogsMode(true); setIsLogsMode(true);
+4 -3
View File
@@ -1,6 +1,7 @@
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1. // Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
// Safe default: any value other than exactly '1' = off. // Safe default: any value other than exactly '1' = off.
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value // Dynamic key access (process.env[KEY]) so react-scripts DefinePlugin does NOT
// at build time — allows tests + runtime env changes to take effect. // inline the value at build time — allows tests + runtime env changes to take
const KEY = 'REACT_APP_' + 'DEV_TOOLS'; // effect. DefinePlugin only replaces static member access, never computed lookups.
const KEY = 'REACT_APP_DEV_TOOLS';
export const isDevToolsEnabled = () => process.env[KEY] === '1'; export const isDevToolsEnabled = () => process.env[KEY] === '1';
@@ -39,6 +39,10 @@ function participant(status) {
describe('DisplayView characterization', () => { describe('DisplayView characterization', () => {
beforeEach(() => { beforeEach(() => {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.href = '/favicon.ico';
document.head.appendChild(favicon);
window.history.replaceState({}, '', '/display'); window.history.replaceState({}, '', '/display');
global.alert = jest.fn(); global.alert = jest.fn();
window.open = jest.fn(); window.open = jest.fn();
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
Element.prototype.scrollIntoView = jest.fn(); Element.prototype.scrollIntoView = jest.fn();
}); });
afterEach(() => { afterEach(() => {
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
}); });
test('display route swaps browser favicon to player icon', async () => {
seedActiveDisplay();
render(<App />);
await waitFor(() => {
expect(document.querySelector('link[rel="icon"]')).toHaveAttribute('href', '/player-favicon.ico');
});
});
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => { test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
seedActiveDisplay(); seedActiveDisplay();
render(<App />); render(<App />);
@@ -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));
});
});