Compare commits
3 Commits
0.1.0
...
768e7a390f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
768e7a390f | ||
|
|
7feaf90eab | ||
|
|
b39e4923e1 |
@@ -3,6 +3,7 @@ import {
|
||||
rollInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ActionBar } from "./components/action-bar";
|
||||
import { CombatantRow } from "./components/combatant-row";
|
||||
@@ -160,6 +161,8 @@ export function App() {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-scroll to the active combatant when the turn changes
|
||||
const activeRowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -205,11 +208,17 @@ export function App() {
|
||||
|
||||
{/* Scrollable area — combatant list */}
|
||||
<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 ? (
|
||||
<p className="py-12 text-center text-sm text-muted-foreground">
|
||||
No combatants yet — add one to get started
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
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) => (
|
||||
<CombatantRow
|
||||
@@ -249,6 +258,7 @@ export function App() {
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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 { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
@@ -20,6 +26,7 @@ interface ActionBarProps {
|
||||
onViewStatBlock?: (result: SearchResult) => void;
|
||||
onBulkImport?: () => void;
|
||||
bulkImportDisabled?: boolean;
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
function creatureKey(r: SearchResult): string {
|
||||
@@ -34,6 +41,7 @@ export function ActionBar({
|
||||
onViewStatBlock,
|
||||
onBulkImport,
|
||||
bulkImportDisabled,
|
||||
inputRef,
|
||||
}: ActionBarProps) {
|
||||
const [nameInput, setNameInput] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
|
||||
@@ -222,6 +230,7 @@ export function ActionBar({
|
||||
>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={nameInput}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
@@ -231,6 +240,22 @@ export function ActionBar({
|
||||
/>
|
||||
{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">
|
||||
<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">
|
||||
{suggestions.map((result, i) => {
|
||||
const key = creatureKey(result);
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
combatantId,
|
||||
createEncounter,
|
||||
isDomainError,
|
||||
creatureId as makeCreatureId,
|
||||
resolveCreatureName,
|
||||
@@ -33,24 +32,16 @@ import {
|
||||
saveEncounter,
|
||||
} from "../persistence/encounter-storage.js";
|
||||
|
||||
function createDemoEncounter(): Encounter {
|
||||
const result = createEncounter([
|
||||
{ id: combatantId("1"), name: "Aria" },
|
||||
{ id: combatantId("2"), name: "Brak" },
|
||||
{ id: combatantId("3"), name: "Cael" },
|
||||
]);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
const EMPTY_ENCOUNTER: Encounter = {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
|
||||
function initializeEncounter(): Encounter {
|
||||
const stored = loadEncounter();
|
||||
if (stored !== null) return stored;
|
||||
return createDemoEncounter();
|
||||
return EMPTY_ENCOUNTER;
|
||||
}
|
||||
|
||||
function deriveNextId(encounter: Encounter): number {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@utility animate-confirm-pulse {
|
||||
|
||||
@@ -169,9 +169,9 @@ describe("advanceTurn", () => {
|
||||
});
|
||||
|
||||
describe("invariants", () => {
|
||||
it("INV-1: createEncounter rejects empty combatant list", () => {
|
||||
it("INV-1: createEncounter accepts empty combatant list", () => {
|
||||
const result = createEncounter([]);
|
||||
expect(isDomainError(result)).toBe(true);
|
||||
expect(isDomainError(result)).toBe(false);
|
||||
});
|
||||
|
||||
it("INV-2: activeIndex always in bounds across all scenarios", () => {
|
||||
|
||||
@@ -38,8 +38,8 @@ function domainError(code: string, message: string): DomainError {
|
||||
|
||||
/**
|
||||
* Creates a valid Encounter, enforcing INV-1, INV-2, INV-3.
|
||||
* - INV-1: At least one combatant required.
|
||||
* - INV-2: activeIndex defaults to 0 (always in bounds).
|
||||
* - INV-1: An encounter MAY have zero combatants.
|
||||
* - INV-2: activeIndex defaults to 0 (always in bounds when combatants exist).
|
||||
* - INV-3: roundNumber defaults to 1 (positive integer).
|
||||
*/
|
||||
export function createEncounter(
|
||||
@@ -47,13 +47,10 @@ export function createEncounter(
|
||||
activeIndex = 0,
|
||||
roundNumber = 1,
|
||||
): Encounter | DomainError {
|
||||
if (combatants.length === 0) {
|
||||
return domainError(
|
||||
"invalid-encounter",
|
||||
"An encounter must have at least one combatant",
|
||||
);
|
||||
}
|
||||
if (activeIndex < 0 || activeIndex >= combatants.length) {
|
||||
if (
|
||||
combatants.length > 0 &&
|
||||
(activeIndex < 0 || activeIndex >= combatants.length)
|
||||
) {
|
||||
return domainError(
|
||||
"invalid-encounter",
|
||||
`activeIndex ${activeIndex} out of bounds for ${combatants.length} combatants`,
|
||||
|
||||
Reference in New Issue
Block a user