V1.32.0: battles that keep to the fight

- The scene description shows during a battle only between rounds (while
  the first in the order is on turn), and always before and after it
- Combatant details start closed in a battle: the spotlight has whoever is
  on turn, or on a narrow screen their own card opens
- While a battle is under way, Add to the scene, Experience and Rewards
  fold to their titles
- Button labels wrap inside their buttons instead of spilling out
- Sticky turn order and side column follow the measured top bar height
- Phone: quick stats no longer sits on Log out, incoming rolls clear the
  bottom bar, the damage bar no longer scrolls on its own
- Docs updated; all packages at version 1.32.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 08:19:48 +02:00
co-authored by Claude Opus 5.5
parent cbda10668b
commit 4bd301182b
10 changed files with 125 additions and 53 deletions
+6
View File
@@ -106,6 +106,12 @@ including a character sheet or an encounter) and **Account**.
items) and by level in the class that gives them.
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
in light armor, at most +2 in medium, none in heavy.
- **Battles that keep to the fight.** While a battle is under way the scene
description shows only between rounds, cards' details start closed (the
spotlight, or on a phone the card on turn, has them), and the DM's setup,
Experience and Rewards panels fold to their titles. Button labels wrap
inside their buttons, and on a phone quick stats no longer sits on Log out
nor incoming rolls on the bottom bar.
- **Stricter battles.** A spell with no slot of its level or higher left
cannot be cast (cantrips never need one), and a player changes the weapon
in hand only as the battle begins or on their own turn after attacking.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.31.0",
"version": "1.32.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
+16 -1
View File
@@ -155,6 +155,16 @@ characters, and characters keep their current HP.
Both save when the DM clicks out of the box.
In a battle under way the Scene box is only there between rounds: while the
first in the turn order is on turn. For the rest of the round it is out of
the way, for the DM and the players alike. Before the battle begins and once
it is over it is always there.
**Folded panels.** While a battle is in progress, "Add to the scene",
Experience and Rewards on the DM's side column start folded to their title
(click the title to open one), so the fight is what fills the screen. They
open again when the battle ends, or before it begins.
## 5. Who is in the scene
The DM adds people from the "Add to the scene" panel.
@@ -240,7 +250,12 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
- **NPCs:** role, attitude and description; for the DM also AC, attack,
damage and DM notes.
Details start open for the DM and closed for players. Weapons and spells
In a battle every card's details start closed, so the list stays short: on a
wide screen the spotlight shows whoever is on turn in full, and on a narrower
one the card of whoever is on turn opens its details by itself (and closes
them again when the turn passes). In a quest or free roam the details start
open for the DM and closed for players. "▸ Show details" / "▾ Hide details"
on a card overrides this for that card. Weapons and spells
above a character's level are left out, because the character cannot use
them.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.31.0",
"version": "1.32.0",
"type": "module",
"scripts": {
"dev": "vite",
+15 -2
View File
@@ -1,4 +1,4 @@
import { Suspense } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
@@ -15,6 +15,19 @@ export function Layout() {
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// How tall the top bar really is, as --topbar-h, so that what sticks under
// it (the turn order in a battle) sits right below it on any screen.
const topbar = useRef<HTMLElement>(null);
useEffect(() => {
const bar = topbar.current;
if (!bar) return;
const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`);
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
return () => observer.disconnect();
}, []);
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
@@ -39,7 +52,7 @@ export function Layout() {
return (
<div className="app-shell">
<header className="topbar">
<header className="topbar" ref={topbar}>
<Link to="/campaigns" className="topbar-brand">
<span className="die" aria-hidden>
⚄
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
// Whether a CSS media query matches right now, kept up to date as the window
// is resized: useMediaQuery('(min-width: 960px)').
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const list = window.matchMedia(query);
const onChange = () => setMatches(list.matches);
onChange();
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}
+28 -28
View File
@@ -25,6 +25,8 @@
--radius: 8px;
--font-display: 'Cinzel', Georgia, serif;
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
/* The top bar's height, measured by Layout.tsx; this is only until then. */
--topbar-h: 3.6rem;
}
@media (prefers-color-scheme: light) {
@@ -262,7 +264,12 @@ textarea {
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
/* A label longer than the room there is wraps inside the button rather
than running out of it (or under the button beside it). */
max-width: 100%;
line-height: 1.25;
text-align: center;
overflow-wrap: break-word;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
@@ -883,7 +890,7 @@ input[type='radio'] {
/* Who acts when, as one line of names: this round, a flag, the next round. */
.initiative-track {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
align-items: center;
@@ -2176,16 +2183,26 @@ textarea {
.fab-dice:hover {
transform: none;
}
.dice-incoming {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
/* Log out has a set width on a phone, so quick stats can sit just left of it. */
.topbar-user .btn {
width: 5.2rem;
padding: 0.45rem 0.3rem;
}
.fab-quickstats {
top: 0.3rem;
top: calc((var(--topbar-h) - 40px) / 2);
bottom: auto;
right: 6.4rem;
right: calc(0.85rem + 5.2rem + 0.5rem);
z-index: 31;
width: 40px;
height: 40px;
@@ -2194,7 +2211,7 @@ textarea {
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
}
.drawer {
width: 100vw;
@@ -2237,7 +2254,6 @@ textarea {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
@@ -2289,11 +2305,6 @@ textarea {
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
@@ -2748,7 +2759,7 @@ button.slot-pip:disabled {
/* Everyone in the scene on one line that stays under the top bar. */
.turn-strip {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
gap: 0.4rem;
@@ -2813,20 +2824,17 @@ button.slot-pip:disabled {
.turn-chip.has-acted {
opacity: 0.55;
}
/* The attack bar sits under the strip, not behind it. */
.attack-bar {
top: 7.6rem;
}
/* On a wide screen the side column stays in view, scrolling on its own. */
@media (min-width: 960px) {
.encounter-side {
position: sticky;
top: 7.6rem;
max-height: calc(100vh - 8.4rem);
top: calc(var(--topbar-h) + 4rem);
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
/* Room to scroll the last card up past the quick-stats button. */
padding-bottom: 5rem;
}
}
.spotlight {
@@ -2886,6 +2894,7 @@ button.slot-pip:disabled {
summary.card-title {
cursor: pointer;
list-style: none;
justify-content: flex-start;
}
summary.card-title::-webkit-details-marker {
display: none;
@@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
content: '▸';
}
@media (max-width: 900px) {
.turn-strip {
top: 2.9rem;
}
.attack-bar {
top: 6.6rem;
}
}
/* In one column the card on turn is already at the top of the list. */
@media (max-width: 959px) {
.spotlight,
+38 -15
View File
@@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
import { SheetNumbers } from '../components/SheetNumbers';
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
import { useHotkeys } from '../hooks/useHotkeys';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { Rollable } from '../components/Rollable';
import { GroupCheckModal } from '../components/GroupCheckModal';
import { withBonus } from '../lib/diceRoller';
@@ -496,6 +497,7 @@ function CombatantCard({
onTarget,
isPinned,
onPin,
detailsOpen,
onRemove,
onError,
}: {
@@ -519,17 +521,18 @@ function CombatantCard({
// Whether this combatant's stats are the ones kept in view, and the switch for it.
isPinned: boolean;
onPin: () => void;
// Whether the details start open (until the viewer opens or closes them).
detailsOpen: boolean;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// How many rounds the next condition added lasts; empty for until removed.
const [rounds, setRounds] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
// closed for players; null = "not toggled yet", so it follows isDM once the
// campaign role has loaded.
// null = "not toggled yet", so the details follow detailsOpen (which
// changes as the turn passes, and once the campaign role has loaded).
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
const showDetails = detailsToggled ?? isDM;
const showDetails = detailsToggled ?? detailsOpen;
const info = p.characterCombatInfo;
const monster = p.monster;
@@ -987,12 +990,14 @@ function ExperiencePanel({
difficulty,
xpAwarded,
partySize,
folded,
onError,
}: {
encounterId: string;
difficulty: EncounterDifficulty;
xpAwarded: number | null;
partySize: number;
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const awardXp = useAwardXp(encounterId);
@@ -1001,11 +1006,11 @@ function ExperiencePanel({
const { thresholds, rating } = difficulty;
return (
<div className="card">
<div className="card-title">
<details className="card" open={!folded}>
<summary className="card-title">
<span>Experience</span>
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
</div>
</summary>
{difficulty.monsters === 0 ? (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount.
@@ -1060,7 +1065,7 @@ function ExperiencePanel({
</button>
</div>
)}
</div>
</details>
);
}
@@ -1076,6 +1081,7 @@ function RewardsPanel({
characters,
weapons,
items,
folded,
onError,
}: {
encounter: Encounter;
@@ -1083,6 +1089,7 @@ function RewardsPanel({
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const addReward = useAddReward(encounter.id);
@@ -1130,8 +1137,8 @@ function RewardsPanel({
}
return (
<div className="card">
<div className="card-title">Rewards</div>
<details className="card" open={!folded}>
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up coins, weapons and items here. Players only see a reward once you award it.
@@ -1284,7 +1291,7 @@ function RewardsPanel({
</div>
</div>
)}
</div>
</details>
);
}
@@ -1319,6 +1326,8 @@ export function EncounterPage() {
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
const navigate = useNavigate();
// Wide enough for the side column, and so for the spotlight in it.
const wide = useMediaQuery('(min-width: 960px)');
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
const [pickId, setPickId] = useState('');
@@ -1454,6 +1463,17 @@ export function EncounterPage() {
);
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
// In a battle every card's details start closed, so the list stays short:
// the spotlight beside it shows whoever is on turn in full, and where there
// is no room for the spotlight (one column) their own card opens instead.
// Elsewhere the DM, who runs the scene from this screen, gets them open.
const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM);
// A battle under way folds away what is for before and after it.
const inBattle = isBattle && encounter.status === 'ACTIVE';
// The scene is set before the fight and between rounds (while the first in
// the order is on turn), and stays out of the way during the round.
const showScene = !inBattle || active?.id === standing[0]?.id;
const card = (p: EncounterParticipant) => (
<CombatantCard
key={p.id}
@@ -1473,6 +1493,7 @@ export function EncounterPage() {
}
isPinned={p.id === pinnedId}
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
detailsOpen={detailsOpen(p)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
@@ -1655,7 +1676,7 @@ export function EncounterPage() {
</div>
)}
{(isDM || encounter.description) && (
{(isDM || encounter.description) && showScene && (
<div className="card">
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
{isDM ? (
@@ -1790,8 +1811,8 @@ export function EncounterPage() {
/>
)}
{isDM && (
<div className="card">
<div className="card-title">Add to the scene</div>
<details className="card" open={!inBattle}>
<summary className="card-title">Add to the scene</summary>
<div className="flex-row flex-wrap gap-sm">
<select
aria-label="What to add"
@@ -1855,7 +1876,7 @@ export function EncounterPage() {
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
</p>
)}
</div>
</details>
)}
{isBattle && (encounter.recentActions ?? []).length > 0 && (
@@ -1895,6 +1916,7 @@ export function EncounterPage() {
difficulty={encounter.difficulty}
xpAwarded={encounter.xpAwarded ?? null}
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
folded={inBattle}
onError={onError}
/>
)}
@@ -1905,6 +1927,7 @@ export function EncounterPage() {
characters={builtCharacters}
weapons={weapons ?? []}
items={items ?? []}
folded={inBattle}
onError={onError}
/>
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.31.0",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.31.0",
"version": "1.32.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.31.0",
"version": "1.32.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.31.0",
"version": "1.32.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.31.0",
"version": "1.32.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [