Compare commits

...
8 Commits
Author SHA1 Message Date
LukasandClaude Opus 4.6 228a2603e8 Add Sapped and Slowed conditions for 5.5e weapon mastery
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 15s
These D&D 2024 weapon mastery conditions are edition-gated: they only
appear in the condition picker when 5.5e rules are selected. Applied
conditions still render correctly regardless of edition setting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:31:41 +01:00
LukasandClaude Opus 4.6 27ff8ba1ad Collapse hover-only buttons to zero width when hidden
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
Edit and add-condition buttons now take no space when not hovered,
eliminating the gap between name and condition icons. They slide in
smoothly on hover with a 150ms transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:19:47 +01:00
LukasandClaude Opus 4.6 4cfcefe6c3 Hide custom stat fields on mobile, fix action bar gap consistency
CI / check (push) Successful in 1m7s
CI / build-image (push) Successful in 16s
Init/AC/MaxHP inputs are hidden on phones — users set these values
directly in the combatant row after adding. Fixes uneven spacing
between action bar elements by using consistent gap-3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:41:18 +01:00
Lukas 8baccf3cd3 Merge 006-mobile-touch-targets: mobile foundation and bug fixes
CI / check (push) Successful in 1m8s
CI / build-image (push) Successful in 16s
- iOS zoom fix (16px input font)
- Safe area insets for notched phones
- viewport-fit=cover
- Action bar flex-wrap for narrow screens
- Slightly increased row padding on mobile
- Fix stat block panel showing wrong creature on first open
- Skip auto-opening stat block when adding on mobile
2026-03-24 23:26:33 +01:00
LukasandClaude Opus 4.6 a9ca31e9bc Skip auto-opening stat block panel when adding creatures on mobile
On desktop the panel has room alongside the combatant list, but on
mobile it covers the screen and disrupts the add-combatant flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:22:54 +01:00
LukasandClaude Opus 4.6 64a1f0b8db Add mobile foundation: iOS zoom fix, safe area insets, row padding
- Input base font 16px on mobile to prevent iOS Safari auto-zoom
- Safe area insets for notched phones (top/bottom bars)
- viewport-fit=cover to enable safe area env() values
- Action bar flex-wrap for custom stat field overflow
- Slightly increased row padding on mobile (py-3 sm:py-2)
- Removed redundant font-size classes from Input usages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:19:10 +01:00
LukasandClaude Opus 4.6 5e5812bcaa Fix stat block panel showing wrong creature on first open
useAutoStatBlock was overriding the user's creature selection when
the panel transitioned from closed to open. Now only auto-updates
when the active turn index changes (advance/retreat), not when the
panel mode changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:18:49 +01:00
LukasandClaude Opus 4.6 9e09c8ae2a Sync theme-color meta tag with active light/dark theme
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:59:13 +01:00
13 changed files with 123 additions and 21 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0e1a2e" /> <meta name="theme-color" content="#0e1a2e" />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+8 -2
View File
@@ -46,7 +46,10 @@ export function App() {
<div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4"> <div className="relative mx-auto flex min-h-0 w-full flex-1 flex-col gap-3 sm:max-w-2xl sm:px-4">
{!!actionBarAnim.showTopBar && ( {!!actionBarAnim.showTopBar && (
<div <div
className={cn("shrink-0 sm:pt-8", actionBarAnim.topBarClass)} className={cn(
"shrink-0 pt-[env(safe-area-inset-top)] sm:pt-[max(env(safe-area-inset-top),2rem)]",
actionBarAnim.topBarClass,
)}
onAnimationEnd={actionBarAnim.onTopBarExitEnd} onAnimationEnd={actionBarAnim.onTopBarExitEnd}
> >
<TurnNavigation /> <TurnNavigation />
@@ -85,7 +88,10 @@ export function App() {
</div> </div>
<div <div
className={cn("shrink-0 sm:pb-8", actionBarAnim.settlingClass)} className={cn(
"shrink-0 pb-[env(safe-area-inset-bottom)] sm:pb-[max(env(safe-area-inset-bottom),2rem)]",
actionBarAnim.settlingClass,
)}
onAnimationEnd={actionBarAnim.onSettleEnd} onAnimationEnd={actionBarAnim.onSettleEnd}
> >
<ActionBar <ActionBar
+4 -3
View File
@@ -279,7 +279,8 @@ export function ActionBar({
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
const creatureId = addFromBestiary(result); const creatureId = addFromBestiary(result);
if (creatureId && panelView.mode === "closed") { const isDesktop = globalThis.matchMedia("(min-width: 1024px)").matches;
if (creatureId && panelView.mode === "closed" && isDesktop) {
showCreature(creatureId); showCreature(creatureId);
} }
}, },
@@ -521,7 +522,7 @@ export function ActionBar({
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border"> <div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
<form <form
onSubmit={handleAdd} onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2" className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
> >
<div className="flex-1"> <div className="flex-1">
<div className="relative max-w-xs"> <div className="relative max-w-xs">
@@ -605,7 +606,7 @@ export function ActionBar({
</div> </div>
</div> </div>
{!browseMode && nameInput.length >= 2 && !hasSuggestions && ( {!browseMode && nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2"> <div className="hidden items-center gap-2 sm:flex">
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
+5 -5
View File
@@ -112,7 +112,7 @@ function EditableName({
onClick={startEditing} onClick={startEditing}
title="Rename" title="Rename"
aria-label="Rename" aria-label="Rename"
className="inline-flex shrink-0 items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100" className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </button>
@@ -157,7 +157,7 @@ function MaxHpDisplay({
inputMode="numeric" inputMode="numeric"
value={draft} value={draft}
placeholder="Max" placeholder="Max"
className="h-7 w-[7ch] text-center text-sm tabular-nums" className="h-7 w-[7ch] text-center tabular-nums"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -272,7 +272,7 @@ function AcDisplay({
inputMode="numeric" inputMode="numeric"
value={draft} value={draft}
placeholder="AC" placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums" className="h-7 w-[6ch] text-center tabular-nums"
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -348,7 +348,7 @@ function InitiativeDisplay({
value={draft} value={draft}
placeholder="--" placeholder="--"
className={cn( className={cn(
"h-7 w-full text-center text-sm tabular-nums", "h-7 w-full text-center tabular-nums",
dimmed && "opacity-50", dimmed && "opacity-50",
)} )}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
@@ -520,7 +520,7 @@ export function CombatantRow({
isPulsing && "animate-concentration-pulse", isPulsing && "animate-concentration-pulse",
)} )}
> >
<div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-2 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3"> <div className="grid grid-cols-[2rem_3rem_auto_1fr_auto_2rem] items-center gap-1.5 py-3 sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem] sm:gap-3 sm:py-2">
{/* Concentration */} {/* Concentration */}
<button <button
type="button" type="button"
+8 -2
View File
@@ -1,7 +1,7 @@
import { import {
CONDITION_DEFINITIONS,
type ConditionId, type ConditionId,
getConditionDescription, getConditionDescription,
getConditionsForEdition,
} from "@initiative/domain"; } from "@initiative/domain";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
@@ -17,7 +17,9 @@ import {
Heart, Heart,
Link, Link,
Moon, Moon,
ShieldMinus,
Siren, Siren,
Snail,
Sparkles, Sparkles,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
@@ -41,6 +43,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
Droplet, Droplet,
ArrowDown, ArrowDown,
Link, Link,
ShieldMinus,
Snail,
Sparkles, Sparkles,
Moon, Moon,
}; };
@@ -56,6 +60,7 @@ const COLOR_CLASSES: Record<string, string> = {
slate: "text-slate-400", slate: "text-slate-400",
green: "text-green-400", green: "text-green-400",
indigo: "text-indigo-400", indigo: "text-indigo-400",
sky: "text-sky-400",
}; };
interface ConditionPickerProps { interface ConditionPickerProps {
@@ -110,6 +115,7 @@ export function ConditionPicker({
}, [onClose]); }, [onClose]);
const { edition } = useRulesEditionContext(); const { edition } = useRulesEditionContext();
const conditions = getConditionsForEdition(edition);
const active = new Set(activeConditions ?? []); const active = new Set(activeConditions ?? []);
return createPortal( return createPortal(
@@ -122,7 +128,7 @@ export function ConditionPicker({
: { visibility: "hidden" as const } : { visibility: "hidden" as const }
} }
> >
{CONDITION_DEFINITIONS.map((def) => { {conditions.map((def) => {
const Icon = ICON_MAP[def.iconName]; const Icon = ICON_MAP[def.iconName];
if (!Icon) return null; if (!Icon) return null;
const isActive = active.has(def.id); const isActive = active.has(def.id);
+6 -1
View File
@@ -18,7 +18,9 @@ import {
Link, Link,
Moon, Moon,
Plus, Plus,
ShieldMinus,
Siren, Siren,
Snail,
Sparkles, Sparkles,
ZapOff, ZapOff,
} from "lucide-react"; } from "lucide-react";
@@ -40,6 +42,8 @@ const ICON_MAP: Record<string, LucideIcon> = {
Droplet, Droplet,
ArrowDown, ArrowDown,
Link, Link,
ShieldMinus,
Snail,
Sparkles, Sparkles,
Moon, Moon,
}; };
@@ -55,6 +59,7 @@ const COLOR_CLASSES: Record<string, string> = {
slate: "text-slate-400", slate: "text-slate-400",
green: "text-green-400", green: "text-green-400",
indigo: "text-indigo-400", indigo: "text-indigo-400",
sky: "text-sky-400",
}; };
interface ConditionTagsProps { interface ConditionTagsProps {
@@ -103,7 +108,7 @@ export function ConditionTags({
type="button" type="button"
title="Add condition" title="Add condition"
aria-label="Add condition" aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-colors transition-opacity hover:bg-hover-neutral-bg hover:text-hover-neutral focus:opacity-100 group-hover:opacity-100" className="inline-flex pointer-coarse:w-auto w-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenPicker(); onOpenPicker();
@@ -106,7 +106,7 @@ export function HpAdjustPopover({
inputMode="numeric" inputMode="numeric"
value={inputValue} value={inputValue}
placeholder="HP" placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums" className="h-7 w-[7ch] text-center tabular-nums"
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
if (v === "" || DIGITS_ONLY_REGEX.test(v)) { if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
+1 -1
View File
@@ -12,7 +12,7 @@ export const Input = ({
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-foreground text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50", "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 sm:text-sm",
className, className,
)} )}
{...props} {...props}
+13 -3
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { useEncounterContext } from "../contexts/encounter-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js";
import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js";
@@ -8,10 +8,20 @@ export function useAutoStatBlock(): void {
const activeCreatureId = const activeCreatureId =
encounter.combatants[encounter.activeIndex]?.creatureId; encounter.combatants[encounter.activeIndex]?.creatureId;
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => { useEffect(() => {
if (activeCreatureId && panelView.mode === "creature") { const prevIndex = prevActiveIndexRef.current;
prevActiveIndexRef.current = encounter.activeIndex;
// Only auto-update when the active turn changes (advance/retreat),
// not when the panel mode changes (user clicking a different creature).
if (
encounter.activeIndex !== prevIndex &&
activeCreatureId &&
panelView.mode === "creature"
) {
updateCreature(activeCreatureId); updateCreature(activeCreatureId);
} }
}, [activeCreatureId, panelView.mode, updateCreature]); }, [encounter.activeIndex, activeCreatureId, panelView.mode, updateCreature]);
} }
+3
View File
@@ -39,6 +39,9 @@ function resolve(pref: ThemePreference): ResolvedTheme {
function applyTheme(resolved: ResolvedTheme): void { function applyTheme(resolved: ResolvedTheme): void {
document.documentElement.dataset.theme = resolved; document.documentElement.dataset.theme = resolved;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", resolved === "light" ? "#eeecea" : "#0e1a2e");
} }
function notifyAll(): void { function notifyAll(): void {
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
CONDITION_DEFINITIONS, CONDITION_DEFINITIONS,
getConditionDescription, getConditionDescription,
getConditionsForEdition,
} from "../conditions.js"; } from "../conditions.js";
function findCondition(id: string) { function findCondition(id: string) {
@@ -25,13 +26,27 @@ describe("getConditionDescription", () => {
); );
}); });
it("every condition has both descriptions", () => { it("universal conditions have both descriptions", () => {
for (const def of CONDITION_DEFINITIONS) { const universal = CONDITION_DEFINITIONS.filter(
(d) => d.edition === undefined,
);
expect(universal.length).toBeGreaterThan(0);
for (const def of universal) {
expect(def.description).toBeTruthy(); expect(def.description).toBeTruthy();
expect(def.description5e).toBeTruthy(); expect(def.description5e).toBeTruthy();
} }
}); });
it("edition-specific conditions have their edition description", () => {
const sapped = findCondition("sapped");
expect(sapped.description).toBeTruthy();
expect(sapped.edition).toBe("5.5e");
const slowed = findCondition("slowed");
expect(slowed.description).toBeTruthy();
expect(slowed.edition).toBe("5.5e");
});
it("conditions with identical rules share the same text", () => { it("conditions with identical rules share the same text", () => {
const blinded = findCondition("blinded"); const blinded = findCondition("blinded");
expect(blinded.description).toBe(blinded.description5e); expect(blinded.description).toBe(blinded.description5e);
@@ -42,3 +57,26 @@ describe("getConditionDescription", () => {
expect(exhaustion.description).not.toBe(exhaustion.description5e); expect(exhaustion.description).not.toBe(exhaustion.description5e);
}); });
}); });
describe("getConditionsForEdition", () => {
it("includes sapped and slowed for 5.5e", () => {
const conditions = getConditionsForEdition("5.5e");
const ids = conditions.map((d) => d.id);
expect(ids).toContain("sapped");
expect(ids).toContain("slowed");
});
it("excludes sapped and slowed for 5e", () => {
const conditions = getConditionsForEdition("5e");
const ids = conditions.map((d) => d.id);
expect(ids).not.toContain("sapped");
expect(ids).not.toContain("slowed");
});
it("includes universal conditions for both editions", () => {
const ids5e = getConditionsForEdition("5e").map((d) => d.id);
const ids55e = getConditionsForEdition("5.5e").map((d) => d.id);
expect(ids5e).toContain("blinded");
expect(ids55e).toContain("blinded");
});
});
+32
View File
@@ -12,6 +12,8 @@ export type ConditionId =
| "poisoned" | "poisoned"
| "prone" | "prone"
| "restrained" | "restrained"
| "sapped"
| "slowed"
| "stunned" | "stunned"
| "unconscious"; | "unconscious";
@@ -24,6 +26,8 @@ export interface ConditionDefinition {
readonly description5e: string; readonly description5e: string;
readonly iconName: string; readonly iconName: string;
readonly color: string; readonly color: string;
/** When set, the condition only appears in this edition's picker. */
readonly edition?: RulesEdition;
} }
export function getConditionDescription( export function getConditionDescription(
@@ -159,6 +163,26 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
iconName: "Link", iconName: "Link",
color: "neutral", color: "neutral",
}, },
{
id: "sapped",
label: "Sapped",
description:
"Disadvantage on next attack roll before the start of your next turn. (Weapon Mastery: Sap)",
description5e: "",
iconName: "ShieldMinus",
color: "amber",
edition: "5.5e",
},
{
id: "slowed",
label: "Slowed",
description:
"Speed reduced by 10 ft. until the start of your next turn. (Weapon Mastery: Slow)",
description5e: "",
iconName: "Snail",
color: "sky",
edition: "5.5e",
},
{ {
id: "stunned", id: "stunned",
label: "Stunned", label: "Stunned",
@@ -184,3 +208,11 @@ export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set( export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
CONDITION_DEFINITIONS.map((d) => d.id), CONDITION_DEFINITIONS.map((d) => d.id),
); );
export function getConditionsForEdition(
edition: RulesEdition,
): readonly ConditionDefinition[] {
return CONDITION_DEFINITIONS.filter(
(d) => d.edition === undefined || d.edition === edition,
);
}
+1
View File
@@ -11,6 +11,7 @@ export {
type ConditionDefinition, type ConditionDefinition,
type ConditionId, type ConditionId,
getConditionDescription, getConditionDescription,
getConditionsForEdition,
type RulesEdition, type RulesEdition,
VALID_CONDITION_IDS, VALID_CONDITION_IDS,
} from "./conditions.js"; } from "./conditions.js";