From 1c40bf7889d941171d3cbbf32a286945512e2b13 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 5 Mar 2026 18:36:39 +0100 Subject: [PATCH] Implement the 010-ui-baseline feature that establishes a modern UI using Tailwind CSS v4 and shadcn/ui-style components for the encounter screen Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 + apps/web/package.json | 8 +- apps/web/src/App.tsx | 291 ++----------- apps/web/src/components/action-bar.tsx | 39 ++ apps/web/src/components/combatant-row.tsx | 246 +++++++++++ apps/web/src/components/ui/button.tsx | 38 ++ apps/web/src/components/ui/input.tsx | 19 + apps/web/src/index.css | 26 ++ apps/web/src/lib/utils.ts | 6 + apps/web/src/main.tsx | 1 + apps/web/vite.config.ts | 3 +- pnpm-lock.yaml | 382 +++++++++++++++++- .../checklists/requirements.md | 36 ++ .../contracts/ui-components.md | 78 ++++ specs/010-ui-baseline/data-model.md | 75 ++++ specs/010-ui-baseline/plan.md | 75 ++++ specs/010-ui-baseline/quickstart.md | 62 +++ specs/010-ui-baseline/research.md | 72 ++++ specs/010-ui-baseline/spec.md | 140 +++++++ specs/010-ui-baseline/tasks.md | 207 ++++++++++ 20 files changed, 1533 insertions(+), 273 deletions(-) create mode 100644 apps/web/src/components/action-bar.tsx create mode 100644 apps/web/src/components/combatant-row.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/utils.ts create mode 100644 specs/010-ui-baseline/checklists/requirements.md create mode 100644 specs/010-ui-baseline/contracts/ui-components.md create mode 100644 specs/010-ui-baseline/data-model.md create mode 100644 specs/010-ui-baseline/plan.md create mode 100644 specs/010-ui-baseline/quickstart.md create mode 100644 specs/010-ui-baseline/research.md create mode 100644 specs/010-ui-baseline/spec.md create mode 100644 specs/010-ui-baseline/tasks.md diff --git a/CLAUDE.md b/CLAUDE.md index fb23172..c4d2463 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,8 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work: - TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, existing domain/application packages (008-persist-encounter) - Browser localStorage (adapter layer only) (008-persist-encounter) - TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Biome 2.0, Vites (009-combatant-hp) +- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons) (010-ui-baseline) +- N/A (no storage changes — localStorage persistence unchanged) (010-ui-baseline) ## Recent Changes - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite diff --git a/apps/web/package.json b/apps/web/package.json index a1bd7d9..4b217a1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,13 +11,19 @@ "dependencies": { "@initiative/application": "workspace:*", "@initiative/domain": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^3.5.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.2.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "tailwindcss": "^4.2.1", "vite": "^6.2.0" } } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cb571e4..b96786b 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,175 +1,10 @@ -import type { CombatantId } from "@initiative/domain"; -import { type FormEvent, useCallback, useRef, useState } from "react"; +import { ActionBar } from "./components/action-bar"; +import { CombatantRow } from "./components/combatant-row"; import { useEncounter } from "./hooks/use-encounter"; -function formatEvent(e: ReturnType["events"][number]) { - switch (e.type) { - case "TurnAdvanced": - return `Turn: ${e.previousCombatantId} → ${e.newCombatantId} (round ${e.roundNumber})`; - case "RoundAdvanced": - return `Round advanced to ${e.newRoundNumber}`; - case "CombatantAdded": - return `Added combatant: ${e.name}`; - case "CombatantRemoved": - return `Removed combatant: ${e.name}`; - case "CombatantUpdated": - return `Renamed combatant: ${e.oldName} → ${e.newName}`; - case "InitiativeSet": - return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"} → ${e.newValue ?? "unset"}`; - case "MaxHpSet": - return `Max HP: ${e.combatantId} ${e.previousMaxHp ?? "unset"} → ${e.newMaxHp ?? "unset"}`; - case "CurrentHpAdjusted": - return `HP: ${e.combatantId} ${e.previousHp} → ${e.newHp} (${e.delta > 0 ? "+" : ""}${e.delta})`; - } -} - -function EditableName({ - name, - combatantId, - isActive, - onRename, -}: { - name: string; - combatantId: CombatantId; - isActive: boolean; - onRename: (id: CombatantId, newName: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(name); - const inputRef = useRef(null); - - const commit = useCallback(() => { - const trimmed = draft.trim(); - if (trimmed !== "" && trimmed !== name) { - onRename(combatantId, trimmed); - } - setEditing(false); - }, [draft, name, combatantId, onRename]); - - const startEditing = useCallback(() => { - setDraft(name); - setEditing(true); - requestAnimationFrame(() => inputRef.current?.select()); - }, [name]); - - if (editing) { - return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - if (e.key === "Escape") setEditing(false); - }} - /> - ); - } - - return ( - - ); -} - -function MaxHpInput({ - maxHp, - onCommit, -}: { - maxHp: number | undefined; - onCommit: (value: number | undefined) => void; -}) { - const [draft, setDraft] = useState(maxHp?.toString() ?? ""); - const prev = useRef(maxHp); - - // Sync draft when domain value changes externally (e.g. from another source) - if (maxHp !== prev.current) { - prev.current = maxHp; - setDraft(maxHp?.toString() ?? ""); - } - - const commit = useCallback(() => { - if (draft === "") { - onCommit(undefined); - return; - } - const n = Number.parseInt(draft, 10); - if (!Number.isNaN(n) && n >= 1) { - onCommit(n); - } else { - // Revert invalid input - setDraft(maxHp?.toString() ?? ""); - } - }, [draft, maxHp, onCommit]); - - return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - }} - /> - ); -} - -function CurrentHpInput({ - currentHp, - maxHp, - onCommit, -}: { - currentHp: number | undefined; - maxHp: number | undefined; - onCommit: (value: number) => void; -}) { - const [draft, setDraft] = useState(currentHp?.toString() ?? ""); - const prev = useRef(currentHp); - - if (currentHp !== prev.current) { - prev.current = currentHp; - setDraft(currentHp?.toString() ?? ""); - } - - const commit = useCallback(() => { - if (draft === "" || currentHp === undefined) return; - const n = Number.parseInt(draft, 10); - if (!Number.isNaN(n)) { - onCommit(n); - } else { - setDraft(currentHp.toString()); - } - }, [draft, currentHp, onCommit]); - - return ( - setDraft(e.target.value)} - onBlur={commit} - onKeyDown={(e) => { - if (e.key === "Enter") commit(); - }} - /> - ); -} - export function App() { const { encounter, - events, advanceTurn, addCombatant, removeCombatant, @@ -179,103 +14,45 @@ export function App() { adjustHp, } = useEncounter(); const activeCombatant = encounter.combatants[encounter.activeIndex]; - const [nameInput, setNameInput] = useState(""); - - const handleAdd = (e: FormEvent) => { - e.preventDefault(); - if (nameInput.trim() === "") return; - addCombatant(nameInput); - setNameInput(""); - }; return ( -
-

Initiative Tracker

+
+ {/* Header */} +
+

+ Initiative Tracker +

+ {activeCombatant && ( +

+ Round {encounter.roundNumber} — Current: {activeCombatant.name} +

+ )} +
- {activeCombatant && ( -

- Round {encounter.roundNumber} — Current: {activeCombatant.name} -

- )} - -
    - {encounter.combatants.map((c, i) => ( -
  • - + {encounter.combatants.length === 0 ? ( +

    + No combatants yet — add one to get started +

    + ) : ( + encounter.combatants.map((c, i) => ( + {" "} - { - const raw = e.target.value; - if (raw === "") { - setInitiative(c.id, undefined); - } else { - const n = Number.parseInt(raw, 10); - if (!Number.isNaN(n)) { - setInitiative(c.id, n); - } - } - }} - />{" "} - {c.maxHp !== undefined && c.currentHp !== undefined && ( - - )} - { - if (c.currentHp === undefined) return; - const delta = value - c.currentHp; - if (delta !== 0) adjustHp(c.id, delta); - }} + onSetInitiative={setInitiative} + onRemove={removeCombatant} + onSetHp={setHp} + onAdjustHp={adjustHp} /> - {c.maxHp !== undefined && /} - {c.maxHp !== undefined && c.currentHp !== undefined && ( - - )}{" "} - setHp(c.id, v)} />{" "} - -
  • - ))} -
+ )) + )} +
-
- setNameInput(e.target.value)} - placeholder="Combatant name" - /> - -
- - - - {events.length > 0 && ( -
-

Events

-
    - {events.map((e, i) => ( -
  • {formatEvent(e)}
  • - ))} -
-
- )} + {/* Action Bar */} +
); } diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx new file mode 100644 index 0000000..312d534 --- /dev/null +++ b/apps/web/src/components/action-bar.tsx @@ -0,0 +1,39 @@ +import { type FormEvent, useState } from "react"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +interface ActionBarProps { + onAddCombatant: (name: string) => void; + onAdvanceTurn: () => void; +} + +export function ActionBar({ onAddCombatant, onAdvanceTurn }: ActionBarProps) { + const [nameInput, setNameInput] = useState(""); + + const handleAdd = (e: FormEvent) => { + e.preventDefault(); + if (nameInput.trim() === "") return; + onAddCombatant(nameInput); + setNameInput(""); + }; + + return ( +
+
+ setNameInput(e.target.value)} + placeholder="Combatant name" + className="max-w-xs" + /> + +
+ +
+ ); +} diff --git a/apps/web/src/components/combatant-row.tsx b/apps/web/src/components/combatant-row.tsx new file mode 100644 index 0000000..042cdc3 --- /dev/null +++ b/apps/web/src/components/combatant-row.tsx @@ -0,0 +1,246 @@ +import type { CombatantId } from "@initiative/domain"; +import { X } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; + +interface Combatant { + readonly id: CombatantId; + readonly name: string; + readonly initiative?: number; + readonly maxHp?: number; + readonly currentHp?: number; +} + +interface CombatantRowProps { + combatant: Combatant; + isActive: boolean; + onRename: (id: CombatantId, newName: string) => void; + onSetInitiative: (id: CombatantId, value: number | undefined) => void; + onRemove: (id: CombatantId) => void; + onSetHp: (id: CombatantId, maxHp: number | undefined) => void; + onAdjustHp: (id: CombatantId, delta: number) => void; +} + +function EditableName({ + name, + combatantId, + onRename, +}: { + name: string; + combatantId: CombatantId; + onRename: (id: CombatantId, newName: string) => void; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(name); + const inputRef = useRef(null); + + const commit = useCallback(() => { + const trimmed = draft.trim(); + if (trimmed !== "" && trimmed !== name) { + onRename(combatantId, trimmed); + } + setEditing(false); + }, [draft, name, combatantId, onRename]); + + const startEditing = useCallback(() => { + setDraft(name); + setEditing(true); + requestAnimationFrame(() => inputRef.current?.select()); + }, [name]); + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + if (e.key === "Escape") setEditing(false); + }} + /> + ); + } + + return ( + + ); +} + +function MaxHpInput({ + maxHp, + onCommit, +}: { + maxHp: number | undefined; + onCommit: (value: number | undefined) => void; +}) { + const [draft, setDraft] = useState(maxHp?.toString() ?? ""); + const prev = useRef(maxHp); + + if (maxHp !== prev.current) { + prev.current = maxHp; + setDraft(maxHp?.toString() ?? ""); + } + + const commit = useCallback(() => { + if (draft === "") { + onCommit(undefined); + return; + } + const n = Number.parseInt(draft, 10); + if (!Number.isNaN(n) && n >= 1) { + onCommit(n); + } else { + setDraft(maxHp?.toString() ?? ""); + } + }, [draft, maxHp, onCommit]); + + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + }} + /> + ); +} + +function CurrentHpInput({ + currentHp, + maxHp, + onCommit, +}: { + currentHp: number | undefined; + maxHp: number | undefined; + onCommit: (value: number) => void; +}) { + const [draft, setDraft] = useState(currentHp?.toString() ?? ""); + const prev = useRef(currentHp); + + if (currentHp !== prev.current) { + prev.current = currentHp; + setDraft(currentHp?.toString() ?? ""); + } + + const commit = useCallback(() => { + if (currentHp === undefined) return; + if (draft === "") { + setDraft(currentHp.toString()); + return; + } + const n = Number.parseInt(draft, 10); + if (!Number.isNaN(n)) { + onCommit(n); + } else { + setDraft(currentHp.toString()); + } + }, [draft, currentHp, onCommit]); + + return ( + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") commit(); + }} + /> + ); +} + +export function CombatantRow({ + combatant, + isActive, + onRename, + onSetInitiative, + onRemove, + onSetHp, + onAdjustHp, +}: CombatantRowProps) { + const { id, name, initiative, maxHp, currentHp } = combatant; + + return ( +
+ {/* Initiative */} + { + const raw = e.target.value; + if (raw === "") { + onSetInitiative(id, undefined); + } else { + const n = Number.parseInt(raw, 10); + if (!Number.isNaN(n)) { + onSetInitiative(id, n); + } + } + }} + /> + + {/* Name */} + + + {/* HP */} +
+ { + if (currentHp === undefined) return; + const delta = value - currentHp; + if (delta !== 0) onAdjustHp(id, delta); + }} + /> + {maxHp !== undefined && ( + / + )} + onSetHp(id, v)} /> +
+ + {/* Actions */} + +
+ ); +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..794199c --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,38 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import type { ButtonHTMLAttributes } from "react"; +import { cn } from "../../lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + outline: + "border border-border bg-transparent hover:bg-card hover:text-foreground", + ghost: "hover:bg-card hover:text-foreground", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 px-3 text-xs", + icon: "h-8 w-8", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +type ButtonProps = ButtonHTMLAttributes & + VariantProps; + +export function Button({ className, variant, size, ...props }: ButtonProps) { + return ( +