3 Commits
0.1.0 ... 0.3.0

Author SHA1 Message Date
Lukas
768e7a390f Improve empty encounter UX with interactive add button
All checks were successful
CI / check (push) Successful in 44s
CI / build-image (push) Successful in 22s
Replace the static "No combatants yet" text with a centered, breathing
"+" icon that focuses the action bar input on click, guiding users to
add their first combatant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:56:56 +01:00
Lukas
7feaf90eab Add "custom creature" option to bestiary suggestions dropdown
When typing a name that partially matches bestiary entries, users
couldn't access the custom creature fields (Init/AC/MaxHP). Now a
prominent option at the top of the dropdown lets users dismiss
suggestions and add a custom creature instead, with an Esc hint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:45:39 +01:00
Lukas
b39e4923e1 Remove demo combatants and allow empty encounters
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 28s
Empty encounters are now valid (INV-1 updated). New sessions start
with zero combatants instead of pre-populated Aria/Brak/Cael.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 10:24:26 +01:00
6 changed files with 70 additions and 31 deletions

View File

@@ -3,6 +3,7 @@ import {
rollInitiativeUseCase, rollInitiativeUseCase,
} from "@initiative/application"; } from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { Plus } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar"; import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row"; import { CombatantRow } from "./components/combatant-row";
@@ -160,6 +161,8 @@ export function App() {
setPinnedCreatureId(null); setPinnedCreatureId(null);
}, []); }, []);
const actionBarInputRef = useRef<HTMLInputElement>(null);
// Auto-scroll to the active combatant when the turn changes // Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@@ -205,11 +208,17 @@ export function App() {
{/* Scrollable area — combatant list */} {/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0"> <div className="flex-1 overflow-y-auto min-h-0">
<div className="flex flex-col px-2 py-2"> <div
className={`flex flex-col px-2 py-2${encounter.combatants.length === 0 ? " h-full items-center justify-center" : ""}`}
>
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground"> <button
No combatants yet add one to get started type="button"
</p> onClick={() => actionBarInputRef.current?.focus()}
className="animate-breathe cursor-pointer text-muted-foreground transition-colors hover:text-primary"
>
<Plus className="size-14" />
</button>
) : ( ) : (
encounter.combatants.map((c, i) => ( encounter.combatants.map((c, i) => (
<CombatantRow <CombatantRow
@@ -249,6 +258,7 @@ export function App() {
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={handleBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef}
/> />
</div> </div>
</div> </div>

View File

@@ -1,5 +1,11 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react"; import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react"; import {
type FormEvent,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import type { SearchResult } from "../hooks/use-bestiary.js"; import type { SearchResult } from "../hooks/use-bestiary.js";
import { Button } from "./ui/button.js"; import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js"; import { Input } from "./ui/input.js";
@@ -20,6 +26,7 @@ interface ActionBarProps {
onViewStatBlock?: (result: SearchResult) => void; onViewStatBlock?: (result: SearchResult) => void;
onBulkImport?: () => void; onBulkImport?: () => void;
bulkImportDisabled?: boolean; bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
} }
function creatureKey(r: SearchResult): string { function creatureKey(r: SearchResult): string {
@@ -34,6 +41,7 @@ export function ActionBar({
onViewStatBlock, onViewStatBlock,
onBulkImport, onBulkImport,
bulkImportDisabled, bulkImportDisabled,
inputRef,
}: ActionBarProps) { }: ActionBarProps) {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [suggestions, setSuggestions] = useState<SearchResult[]>([]); const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
@@ -222,6 +230,7 @@ export function ActionBar({
> >
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
ref={inputRef}
type="text" type="text"
value={nameInput} value={nameInput}
onChange={(e) => handleNameChange(e.target.value)} onChange={(e) => handleNameChange(e.target.value)}
@@ -231,6 +240,22 @@ export function ActionBar({
/> />
{suggestions.length > 0 && ( {suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg"> <div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<button
type="button"
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSuggestions([]);
setQueued(null);
setSuggestionIndex(-1);
}}
>
<Plus className="h-3.5 w-3.5" />
<span className="flex-1">Add "{nameInput}" as custom</span>
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
Esc
</kbd>
</button>
<ul className="max-h-48 overflow-y-auto py-1"> <ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((result, i) => { {suggestions.map((result, i) => {
const key = creatureKey(result); const key = creatureKey(result);

View File

@@ -22,7 +22,6 @@ import type {
} from "@initiative/domain"; } from "@initiative/domain";
import { import {
combatantId, combatantId,
createEncounter,
isDomainError, isDomainError,
creatureId as makeCreatureId, creatureId as makeCreatureId,
resolveCreatureName, resolveCreatureName,
@@ -33,24 +32,16 @@ import {
saveEncounter, saveEncounter,
} from "../persistence/encounter-storage.js"; } from "../persistence/encounter-storage.js";
function createDemoEncounter(): Encounter { const EMPTY_ENCOUNTER: Encounter = {
const result = createEncounter([ combatants: [],
{ id: combatantId("1"), name: "Aria" }, activeIndex: 0,
{ id: combatantId("2"), name: "Brak" }, roundNumber: 1,
{ id: combatantId("3"), name: "Cael" }, };
]);
if (isDomainError(result)) {
throw new Error(`Failed to create demo encounter: ${result.message}`);
}
return result;
}
function initializeEncounter(): Encounter { function initializeEncounter(): Encounter {
const stored = loadEncounter(); const stored = loadEncounter();
if (stored !== null) return stored; if (stored !== null) return stored;
return createDemoEncounter(); return EMPTY_ENCOUNTER;
} }
function deriveNextId(encounter: Encounter): number { function deriveNextId(encounter: Encounter): number {

View File

@@ -80,6 +80,22 @@
} }
} }
@keyframes breathe {
0%,
100% {
opacity: 0.4;
scale: 0.9;
}
50% {
opacity: 1;
scale: 1.1;
}
}
@utility animate-breathe {
animation: breathe 3s ease-in-out infinite;
}
@custom-variant pointer-coarse (@media (pointer: coarse)); @custom-variant pointer-coarse (@media (pointer: coarse));
@utility animate-confirm-pulse { @utility animate-confirm-pulse {

View File

@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
}); });
describe("invariants", () => { describe("invariants", () => {
it("INV-1: createEncounter rejects empty combatant list", () => { it("INV-1: createEncounter accepts empty combatant list", () => {
const result = createEncounter([]); const result = createEncounter([]);
expect(isDomainError(result)).toBe(true); expect(isDomainError(result)).toBe(false);
}); });
it("INV-2: activeIndex always in bounds across all scenarios", () => { it("INV-2: activeIndex always in bounds across all scenarios", () => {

View File

@@ -38,8 +38,8 @@ function domainError(code: string, message: string): DomainError {
/** /**
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3. * Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
* - INV-1: At least one combatant required. * - INV-1: An encounter MAY have zero combatants.
* - INV-2: activeIndex defaults to 0 (always in bounds). * - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
* - INV-3: roundNumber defaults to 1 (positive integer). * - INV-3: roundNumber defaults to 1 (positive integer).
*/ */
export function createEncounter( export function createEncounter(
@@ -47,13 +47,10 @@ export function createEncounter(
activeIndex = 0, activeIndex = 0,
roundNumber = 1, roundNumber = 1,
): Encounter | DomainError { ): Encounter | DomainError {
if (combatants.length === 0) { if (
return domainError( combatants.length > 0 &&
"invalid-encounter", (activeIndex < 0 || activeIndex >= combatants.length)
"An encounter must have at least one combatant", ) {
);
}
if (activeIndex < 0 || activeIndex >= combatants.length) {
return domainError( return domainError(
"invalid-encounter", "invalid-encounter",
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`, `activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,