7 Commits
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
david raistrick b5b339a5dd feat: temp HP, character isNpc, initiative box
Temp HP:
- setTempHp() in shared/turn.js (replaces, no stacking)
- Damage absorbs temp HP first (both 5e + generic rulesets)
- Inline temp HP input on participant card (cyan when active)
- Temp HP field in edit modal
- makeParticipant tempHp field (default 0)
- 8 shared tests

Character isNpc:
- isNpc field on character roster model
- buildCharacterParticipant: type 'npc' when isNpc
- Add form checkbox + edit form checkbox
- NPC badge on character list row
- Writeback preserves isNpc

Initiative box:
- Amber-bordered box around initiative input
- Gold text (amber-300), disabled stays gold
- Stone-950 bg matches page
- Bumped maxHp (stone-200) + tempHp (stone-300/cyan) visibility
2026-07-08 21:36:26 -04:00
21 changed files with 592 additions and 102 deletions
+1
View File
@@ -1,3 +1,4 @@
TODO.md
node_modules
**/node_modules
.git
+18 -11
View File
@@ -2,44 +2,51 @@
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## 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
also better vert tab layout - labelt friendly
x also better vert tab layout - labelt friendly
x needs AC for players dude
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?
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 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.....
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
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",
"name": "TTRPG Initiative Tracker Player Display",
"name": "TTRPG Initiative Tracker - Player Display",
"icons": [
{
"src": "favicon.ico",
"src": "player-favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"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",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/display",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"scope": "/display",
"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": "#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",
"name": "TTRPG Initiative Tracker",
"icons": [
@@ -9,17 +10,16 @@
},
{
"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",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"start_url": "/",
"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",
"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.
// 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.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
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 = [
'src/App.js',
'src/storage/contract.js',
'src/storage/firebase.js',
'src/storage/index.js',
'src/storage/server.js',
'shared/index.js',
'shared/logEvent.js',
'shared/turn.js',
...walkJs(path.join(ROOT, 'src')),
...walkJs(path.join(ROOT, 'shared')),
];
function runEslint(files) {
+102
View File
@@ -0,0 +1,102 @@
// Temp HP: damage hits temp first, then regular.
// setTempHp replaces (no stacking). Healing regular HP ignores temp.
const shared = require('@ttrpg/shared');
const { makeParticipant, applyHpChange, setTempHp, addParticipant } = shared;
const { mockCtx } = require('./_helpers');
function setupEnc() {
const { ctx } = mockCtx();
return {
enc: { id: 'enc1', name: 'TempEnc', participants: [], isStarted: false, isPaused: false, round: 0 },
ctx,
};
}
describe('temp HP', () => {
test('setTempHp sets tempHp on participant', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
expect(e.participants[0].tempHp).toBe(5);
});
test('setTempHp replaces existing temp (no stacking)', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 3, ctx);
expect(e.participants[0].tempHp).toBe(3);
});
test('setTempHp to 0 clears temp', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await setTempHp(e, p.id, 0, ctx);
expect(e.participants[0].tempHp).toBe(0);
});
test('damage: temp absorbs before regular HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 8, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(17);
});
test('damage less than temp: only temp reduced', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 10, ctx);
e = await applyHpChange(e, p.id, 'damage', 4, ctx);
expect(e.participants[0].tempHp).toBe(6);
expect(e.participants[0].currentHp).toBe(20);
});
test('heal: does not affect temp HP', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 10,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'heal', 8, ctx);
expect(e.participants[0].tempHp).toBe(5);
expect(e.participants[0].currentHp).toBe(18);
});
test('damage exactly equals temp: temp gone, regular intact', async () => {
const { enc, ctx } = setupEnc();
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
let e = await addParticipant(enc, p, ctx);
e = await setTempHp(e, p.id, 5, ctx);
e = await applyHpChange(e, p.id, 'damage', 5, ctx);
expect(e.participants[0].tempHp).toBe(0);
expect(e.participants[0].currentHp).toBe(20);
});
test('makeParticipant: tempHp defaults to 0', async () => {
const p = makeParticipant({
name: 'Hero', type: 'character', initiative: 10, maxHp: 20, currentHp: 20,
});
expect(p.tempHp).toBe(0);
});
});
+82 -10
View File
@@ -362,6 +362,7 @@ function makeParticipant(opts) {
deathSaveFailures: opts.deathSaveFailures || 0,
hpFormula: opts.hpFormula || null,
ac: opts.ac !== undefined ? opts.ac : null,
tempHp: opts.tempHp !== undefined ? opts.tempHp : 0,
};
}
@@ -374,7 +375,7 @@ function buildCharacterParticipant(character) {
return {
participant: makeParticipant({
name: character.name,
type: 'character',
type: character.isNpc ? 'npc' : 'character',
originalCharacterId: character.id,
initiative: finalInitiative,
maxHp,
@@ -623,6 +624,25 @@ async function toggleParticipantActive(encounter, participantId, ctx) {
return commit(encounter, patch, log, ctx);
}
async function setTempHp(encounter, participantId, tempHp, ctx) {
const participant = (encounter.participants || []).find(p => p.id === participantId);
if (!participant) throw new Error('Participant not found.');
const value = Math.max(0, tempHp || 0);
const oldValues = { tempHp: participant.tempHp || 0 };
const updatedParticipants = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: value } : p
);
const log = {
type: 'set_temp_hp',
participantId,
participantName: participant.name,
message: `${participant.name} temp HP set to ${value}`,
delta: { tempHp: value, oldTempHp: oldValues.tempHp },
undo: { oldValues, newValues: { tempHp: value } },
};
return commit(encounter, { participants: updatedParticipants }, log, ctx);
}
async function applyHpChange(encounter, participantId, changeType, amount, optionsOrCtx, maybeCtx) {
const ctx = maybeCtx || optionsOrCtx;
const options = maybeCtx ? (optionsOrCtx || {}) : {};
@@ -637,6 +657,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const oldValues = {
currentHp: participant.currentHp,
tempHp: participant.tempHp || 0,
status: participant.status || (participant.currentHp === 0 ? 'dying' : 'conscious'),
deathSaveSuccesses: participant.deathSaveSuccesses || 0,
deathSaveFailures: participant.deathSaveFailures || 0,
@@ -648,6 +669,7 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
let message = '';
let logDelta = { amount, from: participant.currentHp };
const status = oldValues.status;
const currentTemp = participant.tempHp || 0;
// GENERIC ruleset: no death saves, negative HP allowed, down status at <=0.
// Monster death = dead + inactive. No unconscious auto-condition.
@@ -656,6 +678,33 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
}
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
updates.tempHp = tempAfter;
}
// If fully absorbed by temp, no regular HP change.
if (remainingDmg === 0 && participant.currentHp > 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change',
participantId,
participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempBefore = currentTemp;
const tempFinal = updates.tempHp !== undefined ? updates.tempHp : tempBefore;
if (participant.currentHp === 0) {
if (status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
@@ -687,23 +736,23 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
const add = options.isCriticalHit ? 2 : 1;
const failures = (status === 'stable' ? 0 : (participant.deathSaveFailures || 0)) + add;
if (failures >= 3) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
} else {
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures };
updates = { currentHp: 0, status: 'dying', deathSaveSuccesses: 0, deathSaveFailures: failures, tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage while ${status === 'stable' ? 'stable' : 'dying'}`;
logDelta = { ...logDelta, to: 0, status: updates.status, deathSaveFailures: updates.deathSaveFailures };
} else if (amount >= participant.currentHp + participant.maxHp) {
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, ...(participant.type === 'monster' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: 'dead', deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(participant.type === 'monster' ? { isActive: false } : {}) };
message = `Massive damage! ${participant.name} instantly killed`;
logDelta = { ...logDelta, to: 0, status: 'dead', massive: true };
} else {
const newHp = Math.max(0, participant.currentHp - amount);
if (newHp === 0) {
const zeroStatus = participant.type === 'monster' ? 'dead' : 'dying';
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
updates = { currentHp: 0, status: zeroStatus, deathSaveSuccesses: 0, deathSaveFailures: 0, tempHp: tempFinal, ...(zeroStatus === 'dead' ? { isActive: false } : {}) };
} else {
updates = { currentHp: newHp, status: 'conscious' };
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
@@ -747,8 +796,31 @@ async function applyHpChange(encounter, participantId, changeType, amount, optio
async function applyHpChangeGeneric(encounter, participant, changeType, amount, oldValues, ctx) {
const participantId = participant.id;
let updates, message, logDelta = { amount, from: participant.currentHp };
const currentTemp = participant.tempHp || 0;
if (changeType === 'damage') {
// Temp HP absorbs damage first.
let remainingDmg = amount;
let tempAfter = currentTemp;
if (tempAfter > 0) {
const absorbed = Math.min(tempAfter, remainingDmg);
tempAfter -= absorbed;
remainingDmg -= absorbed;
}
if (remainingDmg === 0) {
const updatedParticipants0 = (encounter.participants || []).map(p =>
p.id === participantId ? { ...p, tempHp: tempAfter } : p
);
const log0 = {
type: 'hp_change', participantId, participantName: participant.name,
message: `${participant.name} took ${amount} damage (absorbed by temp HP, ${tempAfter} temp remaining)`,
delta: { amount, from: participant.currentHp, to: participant.currentHp, tempHp: tempAfter },
undo: { oldValues, newValues: { ...oldValues, tempHp: tempAfter } },
};
return commit(encounter, { participants: updatedParticipants0 }, log0, ctx);
}
amount = remainingDmg;
const tempFinal = tempAfter;
if (oldValues.status === 'dead') {
if (participant.type === 'monster' && participant.isActive !== false) {
const updatedParticipants = (encounter.participants || []).map(p =>
@@ -772,11 +844,11 @@ async function applyHpChangeGeneric(encounter, participant, changeType, amount,
const newHp = participant.currentHp - amount;
const isMonster = participant.type === 'monster';
if (newHp <= 0 && isMonster) {
updates = { currentHp: newHp, status: 'dead', isActive: false };
updates = { currentHp: newHp, status: 'dead', isActive: false, tempHp: tempFinal };
} else if (newHp <= 0) {
updates = { currentHp: newHp, status: 'down' };
updates = { currentHp: newHp, status: 'down', tempHp: tempFinal };
} else {
updates = { currentHp: newHp, status: 'conscious' };
updates = { currentHp: newHp, status: 'conscious', tempHp: tempFinal };
}
message = `${participant.name} took ${amount} damage (${participant.currentHp}${newHp} HP)`;
logDelta = { ...logDelta, to: newHp, status: updates.status };
@@ -1115,7 +1187,7 @@ module.exports = {
makeParticipant, buildCharacterParticipant, buildMonsterParticipant,
startEncounter, nextTurn, togglePause,
addParticipant, addParticipants, updateParticipant, removeParticipant,
toggleParticipantActive, applyHpChange, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
toggleParticipantActive, applyHpChange, setTempHp, deathSave, stabilizeParticipant, reviveParticipant, markDead, toggleCondition,
reorderParticipants, endEncounter,
activateDisplay, clearDisplay, toggleHidePlayerHp,
};
+188 -38
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';
@@ -589,6 +589,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
const [hpFormula, setHpFormula] = useState(participant.hpFormula || '');
const [ac, setAc] = useState(participant.ac != null ? participant.ac : '');
const [asNpc, setAsNpc] = useState(participant.type === 'npc');
const [tempHp, setTempHp] = useState(participant.tempHp || 0);
const handleSubmit = (e) => {
e.preventDefault();
@@ -608,6 +609,7 @@ function EditParticipantModal({ participant, onClose, onSave }) {
maxHp: finalMaxHp,
hpFormula: (participant.type === 'monster' || participant.type === 'npc') ? (hpFormula.trim() || null) : null,
ac: ac !== '' ? parseInt(ac, 10) : null,
tempHp: Math.max(0, parseInt(tempHp, 10) || 0),
type: participant.type === 'monster' || participant.type === 'npc' ? (asNpc ? 'npc' : 'monster') : participant.type,
});
};
@@ -666,6 +668,20 @@ function EditParticipantModal({ participant, onClose, onSave }) {
/>
</div>
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-stone-300">Temp HP</label>
<input
type="number"
value={tempHp}
onChange={(e) => setTempHp(e.target.value)}
min="0"
placeholder="0"
className="mt-1 block w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-cyan-600 focus:border-cyan-600 sm:text-sm text-white"
/>
</div>
<div className="flex-1" />
</div>
{(participant.type === 'monster' || participant.type === 'npc') && (
<div>
<label className="block text-sm font-medium text-stone-300 mb-1">HP Formula</label>
@@ -736,6 +752,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
const [defaultMaxHp, setDefaultMaxHp] = useState(DEFAULT_MAX_HP);
const [defaultInitMod, setDefaultInitMod] = useState(DEFAULT_INIT_MOD);
const [defaultAc, setDefaultAc] = useState('');
const [isCharacterNpc, setIsCharacterNpc] = useState(false);
const [editingCharacter, setEditingCharacter] = useState(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
@@ -782,7 +799,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
name: characterName.trim(),
defaultMaxHp: hp,
defaultInitMod: initMod,
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null
defaultAc: defaultAc !== '' ? parseInt(defaultAc, 10) : null,
isNpc: isCharacterNpc || false,
};
try {
@@ -793,12 +811,13 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
setDefaultMaxHp(DEFAULT_MAX_HP);
setDefaultInitMod(DEFAULT_INIT_MOD);
setDefaultAc('');
setIsCharacterNpc(false);
} catch (err) {
console.error("Error adding character:", err);
showToast("Failed to add character. Please try again."); }
};
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp) => {
const handleUpdateCharacter = async (characterId, newName, newDefaultMaxHp, newDefaultInitMod, newDefaultAc, newDefaultCurrentHp, newIsNpc) => {
if (!db || !newName.trim() || !campaignId) return;
const hp = parseInt(newDefaultMaxHp, 10);
@@ -815,7 +834,7 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
const updatedCharacters = campaignCharacters.map(c =>
c.id === characterId
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null }
? { ...c, name: newName.trim(), defaultMaxHp: hp, defaultInitMod: initMod, defaultAc: newDefaultAc !== undefined && newDefaultAc !== '' ? parseInt(newDefaultAc, 10) : null, defaultCurrentHp: newDefaultCurrentHp !== undefined && newDefaultCurrentHp !== '' ? parseInt(newDefaultCurrentHp, 10) : null, isNpc: newIsNpc != null ? newIsNpc : (c.isNpc || false) }
: c
);
@@ -924,6 +943,15 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
className="w-full px-3 py-2 bg-stone-800 border border-stone-700 rounded-md shadow-sm focus:outline-none focus:ring-amber-600 focus:border-amber-600 sm:text-sm text-white"
/>
</div>
<label className="col-span-2 sm:col-span-12 flex items-center gap-2 text-xs font-medium text-stone-400 cursor-pointer">
<input
type="checkbox"
checked={isCharacterNpc}
onChange={(e) => setIsCharacterNpc(e.target.checked)}
className="w-4 h-4 accent-amber-600"
/>
Long-lived NPC (death saves like character, red name in combat)
</label>
<button
type="submit"
className="col-span-2 sm:col-span-12 sm:w-auto sm:justify-self-end px-4 py-2 text-sm font-medium text-white bg-amber-700 hover:bg-amber-800 rounded-md transition-colors flex items-center justify-center"
@@ -959,7 +987,8 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
editingCharacter.defaultMaxHp,
editingCharacter.defaultInitMod,
editingCharacter.defaultAc,
editingCharacter.defaultCurrentHp
editingCharacter.defaultCurrentHp,
editingCharacter.isNpc
);
}}
className="flex-grow flex flex-wrap gap-2 items-center"
@@ -1013,8 +1042,21 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
title="AC"
/>
</div>
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
<Save size={18} />
<label className="flex items-center gap-1 text-[10px] text-stone-400" title="Long-lived NPC">
<input
type="checkbox"
checked={editingCharacter.isNpc || false}
onChange={(e) => setEditingCharacter({ ...editingCharacter, isNpc: e.target.checked })}
className="w-3 h-3 accent-red-600"
/>
NPC
</label>
<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"
@@ -1028,6 +1070,9 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
<>
<div className="flex items-center mr-auto">
<span className="text-stone-100 mr-2">{character.name}</span>
{character.isNpc && (
<span className="px-1.5 py-0.5 rounded bg-red-900 text-red-200 text-xs font-medium border border-red-600" title="Long-lived NPC">NPC</span>
)}
<div className="flex gap-1">
<span className="px-1.5 py-0.5 rounded bg-red-900/60 text-red-200 text-xs font-medium">HP {character.defaultMaxHp || '?'}</span>
{character.defaultCurrentHp != null && character.defaultCurrentHp !== character.defaultMaxHp && (
@@ -1376,6 +1421,15 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
}
};
const handleInlineTempHp = async (participantId, value) => {
const n = Math.max(0, parseInt(value, 10) || 0);
try {
await updateParticipant(encounter, participantId, { tempHp: n }, buildCtx(encounterPath));
} catch (err) {
showToast('Failed to update Temp HP.');
}
};
const handleDeathSaveChange = async (participantId, outcome) => {
if (!db) return;
try {
@@ -1713,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
@@ -1731,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">
@@ -1778,7 +1832,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
{isDead && <span className="ml-2 px-2 py-0.5 rounded bg-red-950 border border-red-500 text-red-200 text-xs font-bold">DEAD</span>}
</p>
<div className={`text-sm ${isCurrentTurn && !encounter.isPaused ? 'text-green-100' : 'text-stone-200'} flex items-center gap-2`}>
<span className="inline-flex items-center gap-1">
<span className="inline-flex items-center px-0.5 border border-amber-500 bg-stone-950 text-amber-300 rounded">
<label htmlFor={`init-${p.id}`} className="sr-only">Initiative</label>
<input
type="number"
@@ -1792,7 +1846,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Initiative'); }}
onBlur={(e) => { if (e.target.value !== String(p.initiative)) handleInlineInitiative(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
className="w-7 bg-transparent text-white text-base text-center border-b border-transparent hover:border-stone-500 focus:border-amber-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="w-7 bg-transparent text-amber-300 text-lg font-bold text-center focus:outline-none disabled:text-amber-300 disabled:opacity-100 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Initiative for ${p.name}`}
/>
</span>
@@ -1816,10 +1870,23 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Max HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.maxHp)) handleInlineMaxHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.maxHp; }}
className="w-10 bg-transparent text-stone-400 text-base text-center border-b border-transparent hover:border-stone-500 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
className="w-10 bg-transparent text-stone-200 text-base text-center border-b border-transparent hover:border-stone-400 focus:border-red-500 focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
aria-label={`Max HP for ${p.name}`}
/>
</span>
<span className="inline-flex items-baseline gap-0.5 ml-1" title="Temporary HP">
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide">+</span>
<input
type="number"
defaultValue={p.tempHp || 0}
key={`temphp-${p.tempHp || 0}`}
onFocus={(e) => { e.target.select(); handleFieldFocus(p.id, 'Temp HP'); }}
onBlur={(e) => { if (e.target.value !== String(p.tempHp || 0)) handleInlineTempHp(p.id, e.target.value); handleFieldBlur(); }}
onKeyDown={(e) => { if (e.key === 'Enter') e.target.blur(); if (e.key === 'Escape') e.target.value = p.tempHp || 0; }}
className={`w-7 bg-transparent text-base text-center border-b focus:outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none ${(p.tempHp || 0) > 0 ? 'text-cyan-200 border-cyan-500' : 'text-stone-300 border-transparent hover:border-stone-400'}`}
aria-label={`Temp HP for ${p.name}`}
/>
</span>
<span className="ml-auto inline-flex items-center gap-1">
{participantStatus === 'dead' && (
<button onClick={() => handleRevive(p.id)} className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-emerald-800 hover:bg-emerald-700 text-white" title="Revive (clear dead status)">
@@ -1999,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>
@@ -2339,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
@@ -2645,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>
<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="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'}
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 ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
{encounterFullscreen ? <PanelTopClose size={18} /> : <PanelTopOpen size={18} />}
</button>
</span>
</h3>
<div className="flex flex-row gap-4">
{/* Combat Controls - always left */}
@@ -2707,21 +2799,43 @@ function AdminView({ userId }) {
catch { return false; }
});
const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const toggleFullscreen = () => {
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
fullscreenWantedRef.current = true;
await document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
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();
@@ -2729,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');
}
@@ -3187,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>
)}
@@ -3287,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.
@@ -3317,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 = () => {
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
fullscreenWantedRef.current = true;
await document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
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();
@@ -3338,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);
}
@@ -3347,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 () => {
@@ -3994,6 +4142,8 @@ function App() {
// swap manifest so Android home-screen installs launch /display
const link = document.querySelector('link[rel="manifest"]');
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') {
setIsLogsMode(true);
+4 -3
View File
@@ -1,6 +1,7 @@
// Dev-tools gate. Explicit opt-in via REACT_APP_DEV_TOOLS=1.
// Safe default: any value other than exactly '1' = off.
// Dynamic key access so react-scripts DefinePlugin does NOT inline the value
// at build time — allows tests + runtime env changes to take effect.
const KEY = 'REACT_APP_' + 'DEV_TOOLS';
// Dynamic key access (process.env[KEY]) so react-scripts DefinePlugin does NOT
// inline the value at build time — allows tests + runtime env changes to take
// effect. DefinePlugin only replaces static member access, never computed lookups.
const KEY = 'REACT_APP_DEV_TOOLS';
export const isDevToolsEnabled = () => process.env[KEY] === '1';
@@ -39,6 +39,10 @@ function participant(status) {
describe('DisplayView characterization', () => {
beforeEach(() => {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.href = '/favicon.ico';
document.head.appendChild(favicon);
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
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 () => {
seedActiveDisplay();
render(<App />);
@@ -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));
});
});