From 426824fd78084ca23cd13fad1b35bab69439478f Mon Sep 17 00:00:00 2001
From: Nils Briggen
Date: Mon, 8 Jun 2026 21:48:31 +0200
Subject: [PATCH] Fixes batch 1: modal focus, campaign system, combat
difficulty, origin desc
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Modal: focus into the dialog ONCE on open (not on every parent re-render), and
prefer the first field over the X button — fixes focus jumping to the close
button on each keystroke in the handout composer (and every other composer).
- Campaign edit: hide the System selector entirely (it's fixed after creation).
- Combat difficulty: rate against the PCs actually in the encounter (falls back to
the roster) so adding a player updates the reading.
- Character builder: ancestry/background description is now a scrollable panel
instead of clamped to 3 lines (no longer cut off on small screens).
223 unit + key e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/components/ui/Modal.tsx | 27 ++++++++++++++-----
src/features/campaigns/CampaignsPage.tsx | 25 +++++++++--------
.../characters/builder/CreationWizard.tsx | 2 +-
src/features/combat/EncounterTracker.tsx | 5 +++-
4 files changed, 37 insertions(+), 22 deletions(-)
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index ee5281d..221b512 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -15,17 +15,33 @@ interface ModalProps {
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const panelRef = useRef(null);
const previouslyFocused = useRef(null);
+ const onCloseRef = useRef(onClose);
+ onCloseRef.current = onClose;
+ // Focus into the dialog ONCE when it opens (not on every parent re-render —
+ // that was stealing focus to the close button on each keystroke). Prefer the
+ // first real field over the X button so composers land in their input.
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
- panel?.querySelector('[data-autofocus], input, button, textarea, select')?.focus();
+ const target =
+ panel?.querySelector('[data-autofocus]') ??
+ panel?.querySelector('input:not([type="hidden"]), textarea, select') ??
+ panel?.querySelector('a[href], button:not([disabled])');
+ target?.focus();
+ return () => { previouslyFocused.current?.focus(); };
+ }, [open]);
+ // Escape to close + focus trap. Reads onClose via a ref so a changing
+ // onClose identity never re-binds (and never re-triggers the focus effect).
+ useEffect(() => {
+ if (!open) return;
+ const panel = panelRef.current;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
- onClose();
+ onCloseRef.current();
return;
}
if (e.key === 'Tab' && panel) {
@@ -45,11 +61,8 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
}
}
document.addEventListener('keydown', onKey);
- return () => {
- document.removeEventListener('keydown', onKey);
- previouslyFocused.current?.focus();
- };
- }, [open, onClose]);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [open]);
if (!open) return null;
diff --git a/src/features/campaigns/CampaignsPage.tsx b/src/features/campaigns/CampaignsPage.tsx
index 7611321..299227d 100644
--- a/src/features/campaigns/CampaignsPage.tsx
+++ b/src/features/campaigns/CampaignsPage.tsx
@@ -258,19 +258,18 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
placeholder="Curse of Strahd"
/>
-
-
-
+ {/* System is fixed once a campaign exists (content + characters depend on it). */}
+ {!campaign && (
+
+
+
+ )}
}
- {sel?.desc &&
{sel.desc}
}
+ {sel?.desc &&
{sel.desc}
}
);
}
diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx
index 3558e66..c965091 100644
--- a/src/features/combat/EncounterTracker.tsx
+++ b/src/features/combat/EncounterTracker.tsx
@@ -71,7 +71,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
// has started, rate against the levels captured at that time (snapshot) so the
// reading — and the assistant's history — reflects the party as it was.
- const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
+ // Rate against the PCs actually IN the fight (so adding a player updates the
+ // difficulty); fall back to the campaign roster before any PC is added.
+ const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
+ const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
const monsters = encounter.combatants
.filter((c) => c.kind === 'monster')