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 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-05 18:36:39 +01:00
parent 8185fde0e8
commit 1c40bf7889
20 changed files with 1533 additions and 273 deletions

View File

@@ -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) - 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) - 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.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 ## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite - 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite

View File

@@ -11,13 +11,19 @@
"dependencies": { "dependencies": {
"@initiative/application": "workspace:*", "@initiative/application": "workspace:*",
"@initiative/domain": "workspace:*", "@initiative/domain": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-dom": "^19.0.0",
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"tailwindcss": "^4.2.1",
"vite": "^6.2.0" "vite": "^6.2.0"
} }
} }

View File

@@ -1,175 +1,10 @@
import type { CombatantId } from "@initiative/domain"; import { ActionBar } from "./components/action-bar";
import { type FormEvent, useCallback, useRef, useState } from "react"; import { CombatantRow } from "./components/combatant-row";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
function formatEvent(e: ReturnType<typeof useEncounter>["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<HTMLInputElement>(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 (
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button type="button" onClick={startEditing}>
{isActive ? `${name}` : name}
</button>
);
}
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 (
<input
type="number"
min={1}
value={draft}
placeholder="Max HP"
style={{ width: "5em" }}
onChange={(e) => 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 (
<input
type="number"
min={0}
max={maxHp}
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
style={{ width: "4em" }}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
);
}
export function App() { export function App() {
const { const {
encounter, encounter,
events,
advanceTurn, advanceTurn,
addCombatant, addCombatant,
removeCombatant, removeCombatant,
@@ -179,103 +14,45 @@ export function App() {
adjustHp, adjustHp,
} = useEncounter(); } = useEncounter();
const activeCombatant = encounter.combatants[encounter.activeIndex]; const activeCombatant = encounter.combatants[encounter.activeIndex];
const [nameInput, setNameInput] = useState("");
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (nameInput.trim() === "") return;
addCombatant(nameInput);
setNameInput("");
};
return ( return (
<div> <div className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8">
<h1>Initiative Tracker</h1> {/* Header */}
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
{activeCombatant && ( {activeCombatant && (
<p> <p className="text-sm text-muted-foreground">
Round {encounter.roundNumber} Current: {activeCombatant.name} Round {encounter.roundNumber} Current: {activeCombatant.name}
</p> </p>
)} )}
</header>
<ul> {/* Combatant List */}
{encounter.combatants.map((c, i) => ( <div className="flex flex-1 flex-col gap-1 overflow-y-auto">
<li key={c.id}> {encounter.combatants.length === 0 ? (
<EditableName <p className="py-12 text-center text-sm text-muted-foreground">
name={c.name} No combatants yet add one to get started
combatantId={c.id} </p>
) : (
encounter.combatants.map((c, i) => (
<CombatantRow
key={c.id}
combatant={c}
isActive={i === encounter.activeIndex} isActive={i === encounter.activeIndex}
onRename={editCombatant} onRename={editCombatant}
/>{" "} onSetInitiative={setInitiative}
<input onRemove={removeCombatant}
type="number" onSetHp={setHp}
value={c.initiative ?? ""} onAdjustHp={adjustHp}
placeholder="Init" />
style={{ width: "4em" }} ))
onChange={(e) => {
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 && (
<button type="button" onClick={() => adjustHp(c.id, -1)}>
-
</button>
)} )}
<CurrentHpInput
currentHp={c.currentHp}
maxHp={c.maxHp}
onCommit={(value) => {
if (c.currentHp === undefined) return;
const delta = value - c.currentHp;
if (delta !== 0) adjustHp(c.id, delta);
}}
/>
{c.maxHp !== undefined && <span>/</span>}
{c.maxHp !== undefined && c.currentHp !== undefined && (
<button type="button" onClick={() => adjustHp(c.id, 1)}>
+
</button>
)}{" "}
<MaxHpInput maxHp={c.maxHp} onCommit={(v) => setHp(c.id, v)} />{" "}
<button type="button" onClick={() => removeCombatant(c.id)}>
Remove
</button>
</li>
))}
</ul>
<form onSubmit={handleAdd}>
<input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Combatant name"
/>
<button type="submit">Add Combatant</button>
</form>
<button type="button" onClick={advanceTurn}>
Next Turn
</button>
{events.length > 0 && (
<div>
<h2>Events</h2>
<ul>
{events.map((e, i) => (
<li key={`${e.type}-${i}`}>{formatEvent(e)}</li>
))}
</ul>
</div> </div>
)}
{/* Action Bar */}
<ActionBar onAddCombatant={addCombatant} onAdvanceTurn={advanceTurn} />
</div> </div>
); );
} }

View File

@@ -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 (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<form onSubmit={handleAdd} className="flex flex-1 items-center gap-2">
<Input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Combatant name"
className="max-w-xs"
/>
<Button type="submit" size="sm">
Add
</Button>
</form>
<Button variant="outline" size="sm" onClick={onAdvanceTurn}>
Next Turn
</Button>
</div>
);
}

View File

@@ -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<HTMLInputElement>(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 (
<Input
ref={inputRef}
type="text"
value={draft}
className="h-7 text-sm"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
className="truncate text-left text-sm text-foreground hover:text-primary transition-colors"
>
{name}
</button>
);
}
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 (
<Input
type="text"
inputMode="numeric"
value={draft}
placeholder="Max"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => 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 (
<Input
type="text"
inputMode="numeric"
value={draft}
placeholder="HP"
disabled={maxHp === undefined}
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => 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 (
<div
className={cn(
"grid grid-cols-[3rem_1fr_auto_2rem] items-center gap-3 rounded-md px-3 py-2 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: "border-l-2 border-l-transparent",
)}
>
{/* Initiative */}
<Input
type="text"
inputMode="numeric"
value={initiative ?? ""}
placeholder="--"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => {
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 */}
<EditableName name={name} combatantId={id} onRename={onRename} />
{/* HP */}
<div className="flex items-center gap-1">
<CurrentHpInput
currentHp={currentHp}
maxHp={maxHp}
onCommit={(value) => {
if (currentHp === undefined) return;
const delta = value - currentHp;
if (delta !== 0) onAdjustHp(id, delta);
}}
/>
{maxHp !== undefined && (
<span className="text-sm tabular-nums text-muted-foreground">/</span>
)}
<MaxHpInput maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => onRemove(id)}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
</div>
);
}

View File

@@ -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<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}

View File

@@ -0,0 +1,19 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm 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",
className,
)}
{...props}
/>
);
},
);

26
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
@theme {
--color-background: #0f172a;
--color-foreground: #e2e8f0;
--color-muted: #64748b;
--color-muted-foreground: #94a3b8;
--color-card: #1e293b;
--color-card-foreground: #e2e8f0;
--color-border: #334155;
--color-input: #334155;
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-accent: #3b82f6;
--color-destructive: #ef4444;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,6 +1,7 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import "./index.css";
const root = document.getElementById("root"); const root = document.getElementById("root");
if (root) { if (root) {

View File

@@ -1,6 +1,7 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [tailwindcss(), react()],
}); });

382
pnpm-lock.yaml generated
View File

@@ -22,7 +22,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1) version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
apps/web: apps/web:
dependencies: dependencies:
@@ -32,13 +32,28 @@ importers:
'@initiative/domain': '@initiative/domain':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/domain version: link:../../packages/domain
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
react: react:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4 version: 19.2.4
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
devDependencies: devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))
'@types/react': '@types/react':
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.14 version: 19.2.14
@@ -47,10 +62,13 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.0 specifier: ^4.3.0
version: 4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)) version: 4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
vite: vite:
specifier: ^6.2.0 specifier: ^6.2.0
version: 6.4.1(@types/node@25.3.3)(jiti@2.6.1) version: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
packages/application: packages/application:
dependencies: dependencies:
@@ -622,6 +640,96 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
'@tailwindcss/oxide-android-arm64@4.2.1':
resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.2.1':
resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.2.1':
resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
engines: {node: '>= 20'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.2.1':
resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
engines: {node: '>= 20'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
engines: {node: '>= 20'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.2.1':
resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
engines: {node: '>= 20'}
'@tailwindcss/vite@4.2.1':
resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -728,6 +836,13 @@ packages:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -747,9 +862,17 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'} engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
electron-to-chromium@1.5.307: electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
enhanced-resolve@5.20.0:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
es-module-lexer@1.7.0: es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -810,6 +933,9 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -908,12 +1034,87 @@ packages:
resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==} resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==}
hasBin: true hasBin: true
lightningcss-android-arm64@1.31.1:
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.31.1:
resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.31.1:
resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.31.1:
resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.31.1:
resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.31.1:
resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.31.1:
resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.31.1:
resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
engines: {node: '>= 12.0.0'}
loupe@3.2.1: loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.577.0:
resolution: {integrity: sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1023,6 +1224,16 @@ packages:
strip-literal@3.1.0: strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
tailwindcss@4.2.1:
resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1574,6 +1785,74 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0': '@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true optional: true
'@tailwindcss/node@4.2.1':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.20.0
jiti: 2.6.1
lightningcss: 1.31.1
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.2.1
'@tailwindcss/oxide-android-arm64@4.2.1':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.2.1':
optional: true
'@tailwindcss/oxide-darwin-x64@4.2.1':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.2.1':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.2.1':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.2.1':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.2.1':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.2.1':
optional: true
'@tailwindcss/oxide@4.2.1':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.2.1
'@tailwindcss/oxide-darwin-arm64': 4.2.1
'@tailwindcss/oxide-darwin-x64': 4.2.1
'@tailwindcss/oxide-freebsd-x64': 4.2.1
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
'@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
'@tailwindcss/oxide-linux-arm64-musl': 4.2.1
'@tailwindcss/oxide-linux-x64-gnu': 4.2.1
'@tailwindcss/oxide-linux-x64-musl': 4.2.1
'@tailwindcss/oxide-wasm32-wasi': 4.2.1
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
'@tailwindcss/oxide-win32-x64-msvc': 4.2.1
'@tailwindcss/vite@4.2.1(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies:
'@tailwindcss/node': 4.2.1
'@tailwindcss/oxide': 4.2.1
tailwindcss: 4.2.1
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -1621,7 +1900,7 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))': '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -1629,7 +1908,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27 '@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1) vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -1641,13 +1920,13 @@ snapshots:
chai: 5.3.3 chai: 5.3.3
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))': '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))':
dependencies: dependencies:
'@vitest/spy': 3.2.4 '@vitest/spy': 3.2.4
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1) vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
'@vitest/pretty-format@3.2.4': '@vitest/pretty-format@3.2.4':
dependencies: dependencies:
@@ -1707,6 +1986,12 @@ snapshots:
check-error@2.1.3: {} check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
clsx@2.1.1: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -1717,8 +2002,15 @@ snapshots:
deep-eql@5.0.2: {} deep-eql@5.0.2: {}
detect-libc@2.1.2: {}
electron-to-chromium@1.5.307: {} electron-to-chromium@1.5.307: {}
enhanced-resolve@5.20.0:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
es-module-lexer@1.7.0: {} es-module-lexer@1.7.0: {}
esbuild@0.25.12: esbuild@0.25.12:
@@ -1795,6 +2087,8 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
graceful-fs@4.2.11: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
is-glob@4.0.3: is-glob@4.0.3:
@@ -1877,12 +2171,65 @@ snapshots:
lefthook-windows-arm64: 1.13.6 lefthook-windows-arm64: 1.13.6
lefthook-windows-x64: 1.13.6 lefthook-windows-x64: 1.13.6
lightningcss-android-arm64@1.31.1:
optional: true
lightningcss-darwin-arm64@1.31.1:
optional: true
lightningcss-darwin-x64@1.31.1:
optional: true
lightningcss-freebsd-x64@1.31.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.31.1:
optional: true
lightningcss-linux-arm64-gnu@1.31.1:
optional: true
lightningcss-linux-arm64-musl@1.31.1:
optional: true
lightningcss-linux-x64-gnu@1.31.1:
optional: true
lightningcss-linux-x64-musl@1.31.1:
optional: true
lightningcss-win32-arm64-msvc@1.31.1:
optional: true
lightningcss-win32-x64-msvc@1.31.1:
optional: true
lightningcss@1.31.1:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.31.1
lightningcss-darwin-arm64: 1.31.1
lightningcss-darwin-x64: 1.31.1
lightningcss-freebsd-x64: 1.31.1
lightningcss-linux-arm-gnueabihf: 1.31.1
lightningcss-linux-arm64-gnu: 1.31.1
lightningcss-linux-arm64-musl: 1.31.1
lightningcss-linux-x64-gnu: 1.31.1
lightningcss-linux-x64-musl: 1.31.1
lightningcss-win32-arm64-msvc: 1.31.1
lightningcss-win32-x64-msvc: 1.31.1
loupe@3.2.1: {} loupe@3.2.1: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@0.577.0(react@19.2.4):
dependencies:
react: 19.2.4
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -2009,6 +2356,12 @@ snapshots:
dependencies: dependencies:
js-tokens: 9.0.1 js-tokens: 9.0.1
tailwind-merge@3.5.0: {}
tailwindcss@4.2.1: {}
tapable@2.3.0: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -2041,13 +2394,13 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1): vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3 debug: 4.4.3
es-module-lexer: 1.7.0 es-module-lexer: 1.7.0
pathe: 2.0.3 pathe: 2.0.3
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1) vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
transitivePeerDependencies: transitivePeerDependencies:
- '@types/node' - '@types/node'
- jiti - jiti
@@ -2062,7 +2415,7 @@ snapshots:
- tsx - tsx
- yaml - yaml
vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1): vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -2074,12 +2427,13 @@ snapshots:
'@types/node': 25.3.3 '@types/node': 25.3.3
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.31.1
vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1): vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.4 '@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)) '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1))
'@vitest/pretty-format': 3.2.4 '@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4 '@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4 '@vitest/snapshot': 3.2.4
@@ -2097,8 +2451,8 @@ snapshots:
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1) vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1) vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 25.3.3 '@types/node': 25.3.3

View File

@@ -0,0 +1,36 @@
# Specification Quality Checklist: UI Baseline
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-05
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Technology choices (Tailwind, shadcn/ui) are mentioned only in the Assumptions section as adapter-layer decisions, not in requirements or success criteria.
- All 11 functional requirements are testable through visual inspection of the rendered UI.
- No clarification markers needed — the feature description was detailed and scope is well-bounded (UI-only, no domain changes).

View File

@@ -0,0 +1,78 @@
# UI Component Contracts: UI Baseline
**Feature**: 010-ui-baseline | **Date**: 2026-03-05
## Layout Contract
The encounter screen follows a single-column layout with three zones:
```
┌─────────────────────────────────┐
│ EncounterHeader │
│ Title + Round/Turn status │
├─────────────────────────────────┤
│ CombatantList │
│ ┌─ CombatantRow (active) ───┐ │
│ │ Init │ Name │ HP │ Actions│ │
│ └───────────────────────────┘ │
│ ┌─ CombatantRow ────────────┐ │
│ │ Init │ Name │ HP │ Actions│ │
│ └───────────────────────────┘ │
│ ... (scrollable if overflow) │
│ │
│ [EmptyState if no combatants] │
├─────────────────────────────────┤
│ ActionBar │
│ [Name input] [Add] [Next Turn] │
└─────────────────────────────────┘
```
## CombatantRow Contract
**Props**:
- `combatant: Combatant` — domain entity
- `isActive: boolean` — whether this is the active turn
- `onRename: (id, newName) => void`
- `onSetInitiative: (id, value) => void`
- `onRemove: (id) => void`
- `onSetHp: (id, maxHp) => void`
- `onAdjustHp: (id, delta) => void`
**Visual contract**:
- Row uses consistent column widths across all combatant rows
- Active row has visually distinct highlight (accent background or left border)
- Name column truncates with ellipsis at max width
- Remove action is an icon button (no text label)
- All inputs use design system styling (no browser defaults)
**Interaction contract**:
- Click name → enter inline edit mode
- Enter/blur in edit mode → commit change
- Escape in edit mode → cancel
- Initiative input is always visible (not click-to-edit), direct typing only
- HP inputs are direct-entry text fields with numeric keyboard (`inputmode="numeric"`)
- All numeric inputs: no browser spinners, ch-based widths (`6ch`), tabular numerals, centered text
## ActionBar Contract
**Props**:
- `onAddCombatant: (name: string) => void`
- `onAdvanceTurn: () => void`
**Visual contract**:
- Visually separated from combatant list (spacing, background, or border)
- Add form: text input + submit button in a row
- Next Turn: distinct button, visually secondary to Add
**Interaction contract**:
- Enter in name input → add combatant + clear input
- Empty name → no action (button may be disabled or form simply ignores)
## EmptyState Contract
**Displayed when**: `encounter.combatants.length === 0`
**Visual contract**:
- Centered message in the combatant list area
- Muted/secondary text color
- Suggests adding a combatant

View File

@@ -0,0 +1,75 @@
# Data Model: UI Baseline
**Feature**: 010-ui-baseline | **Date**: 2026-03-05
## Domain Entities (UNCHANGED)
This feature makes no domain changes. The existing domain types are consumed as-is by the UI layer.
### Combatant (read-only from UI perspective)
| Field | Type | Notes |
|-------|------|-------|
| id | CombatantId (branded string) | Unique identifier |
| name | string | Display name, editable inline |
| initiative | number \| undefined | Sort order, editable inline |
| maxHp | number \| undefined | Maximum hit points |
| currentHp | number \| undefined | Current hit points (present when maxHp is set) |
### Encounter (read-only from UI perspective)
| Field | Type | Notes |
|-------|------|-------|
| combatants | readonly Combatant[] | Sorted by initiative descending |
| activeIndex | number | Index of current turn's combatant |
| roundNumber | number | Current round counter |
## UI Component Model (NEW)
These are adapter-layer visual components — not domain entities.
### CombatantRow
Renders a single combatant as a structured row with consistent column alignment.
| Column | Content | Behavior |
|--------|---------|----------|
| Initiative | Number or placeholder | Text input with `inputmode="numeric"`, no spinners, direct typing only |
| Name | Combatant name | Inline editable, click-to-edit. Truncates with ellipsis |
| HP | currentHp / maxHp | Text inputs with `inputmode="numeric"`, no spinners, direct entry for both values |
| Actions | Remove icon button | Compact icon (X or trash), tooltip on hover |
**Numeric field styling**: All numeric inputs use `type="text"` with `inputmode="numeric"` to suppress browser spinners while showing the numeric keyboard on mobile. Initiative uses `6ch` width (12 digit values typical). HP fields use `7ch` width (supports up to 4-digit values like 1000). All use tabular numerals (`font-variant-numeric: tabular-nums`) for column alignment and centered text.
**Visual states**:
- Default row
- Active row (highlighted background/border for current turn)
- Editing state (inline input replaces display text)
### ActionBar
Groups primary encounter controls.
| Element | Type | Behavior |
|---------|------|----------|
| Name input | Text input | Enter combatant name |
| Add button | Primary button | Adds combatant to encounter |
| Next Turn button | Secondary/outline button | Advances to next combatant |
### EncounterHeader
Displays encounter status.
| Element | Content |
|---------|---------|
| Title | "Initiative Tracker" |
| Status | Round number and active combatant name |
### EmptyState
Displayed when combatants list is empty.
| Element | Content |
|---------|---------|
| Message | "No combatants yet" or similar |
| Hint | Prompt to add first combatant |

View File

@@ -0,0 +1,75 @@
# Implementation Plan: UI Baseline
**Branch**: `010-ui-baseline` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/010-ui-baseline/spec.md`
## Summary
Establish a modern UI baseline for the encounter screen by integrating Tailwind CSS v4 and shadcn/ui into the existing Vite + React 19 web app. Replace all unstyled HTML with a consistent design system: structured combatant rows with aligned columns, active turn highlight, grouped action bar, icon remove buttons, and consistent typography. No domain or application layer changes — all work is in the `apps/web` adapter layer.
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui, Lucide React (icons)
**Storage**: N/A (no storage changes — localStorage persistence unchanged)
**Testing**: Vitest (existing layer boundary tests must pass; no new visual tests in MVP baseline)
**Target Platform**: Modern browsers (desktop-first, not broken on mobile)
**Project Type**: Web application (monorepo: apps/web + packages/domain + packages/application)
**Performance Goals**: No perceptible rendering delay; encounter screen usable during live tabletop play
**Constraints**: UI-only changes; domain and application layers untouched
**Scale/Scope**: Single screen (encounter tracker), ~6 component areas to restyle
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | No domain changes |
| II. Layered Architecture | PASS | All changes in adapter layer (apps/web) only |
| III. Agent Boundary | N/A | No agent features involved |
| IV. Clarification-First | PASS | Spec is fully specified, no ambiguities |
| V. Escalation Gates | PASS | Feature stays within spec scope |
| VI. MVP Baseline Language | PASS | Dark theme, full responsive noted as "not in MVP baseline" |
| VII. No Gameplay Rules | PASS | No gameplay logic involved |
## Project Structure
### Documentation (this feature)
```text
specs/010-ui-baseline/
├── plan.md # This file
├── research.md # Phase 0: Tailwind v4 + shadcn/ui setup research
├── data-model.md # Phase 1: UI component model (no domain changes)
├── quickstart.md # Phase 1: Developer quickstart
├── contracts/ # Phase 1: UI component contracts
│ └── ui-components.md
└── tasks.md # Phase 2 output (created by /speckit.tasks)
```
### Source Code (repository root)
```text
apps/web/
├── src/
│ ├── main.tsx # Add global CSS import
│ ├── index.css # NEW: Tailwind directives + CSS variables
│ ├── lib/
│ │ └── utils.ts # NEW: cn() helper (clsx + twMerge)
│ ├── components/
│ │ └── ui/ # NEW: shadcn/ui primitives (Button, Input, Card, etc.)
│ ├── App.tsx # Refactored: use new components + Tailwind classes
│ └── hooks/
│ └── use-encounter.ts # UNCHANGED
└── package.json # Updated: new dependencies
packages/domain/ # UNCHANGED
packages/application/ # UNCHANGED
```
**Structure Decision**: Follows existing monorepo layout. New UI components live in `apps/web/src/components/ui/` following shadcn/ui convention. No new packages or layers introduced.
## Complexity Tracking
No constitution violations. No complexity justifications needed.

View File

@@ -0,0 +1,62 @@
# Quickstart: UI Baseline
**Feature**: 010-ui-baseline | **Date**: 2026-03-05
## Prerequisites
- Node.js 18+
- pnpm 10.6+
## Setup
```bash
# Install dependencies (from repo root)
pnpm install
# Start dev server
pnpm --filter web dev
# → http://localhost:5173
```
## New Dependencies (to be added)
```bash
# Tailwind CSS v4 with Vite plugin
pnpm --filter web add tailwindcss @tailwindcss/vite
# shadcn/ui utilities
pnpm --filter web add clsx tailwind-merge class-variance-authority
# Radix UI primitives (used by shadcn/ui components)
pnpm --filter web add @radix-ui/react-slot @radix-ui/react-tooltip
# Icons
pnpm --filter web add lucide-react
```
## Key Files
| File | Purpose |
|------|---------|
| `apps/web/src/index.css` | Tailwind directives + CSS custom properties |
| `apps/web/src/lib/utils.ts` | `cn()` class merge utility |
| `apps/web/src/components/ui/` | shadcn/ui primitives (Button, Input, etc.) |
| `apps/web/src/components/combatant-row.tsx` | Combatant row component |
| `apps/web/src/App.tsx` | Main layout (header, list, action bar) |
| `apps/web/vite.config.ts` | Updated with Tailwind plugin |
## Quality Gate
```bash
# Must pass before commit
pnpm check
```
This runs: knip → format → lint → typecheck → test
## Design System
- **Styling**: Tailwind CSS v4 utility classes
- **Components**: shadcn/ui (copied into project, not a package dependency)
- **Icons**: Lucide React
- **Class merging**: `cn()` from `lib/utils.ts` (clsx + tailwind-merge)

View File

@@ -0,0 +1,72 @@
# Research: UI Baseline
**Feature**: 010-ui-baseline | **Date**: 2026-03-05
## R1: Tailwind CSS Version Choice
**Decision**: Use Tailwind CSS v4 (latest stable)
**Rationale**: Tailwind v4 uses a CSS-first configuration approach with `@import "tailwindcss"` and CSS-based theme customization via `@theme`. This simplifies setup — no `tailwind.config.ts` is strictly required for basic usage. Vite has first-class support via `@tailwindcss/vite` plugin.
**Alternatives considered**:
- Tailwind v3: Mature but requires JS config file and PostCSS plugin. v4 is stable and recommended for new projects.
## R2: shadcn/ui Integration Approach
**Decision**: Use shadcn/ui CLI to scaffold components into `apps/web/src/components/ui/`
**Rationale**: shadcn/ui is not a package dependency — it copies component source code into the project. This gives full control over styling and avoids version lock-in. Components use Tailwind classes + Radix UI primitives.
**Alternatives considered**:
- Radix UI directly: More low-level, requires writing all styles manually. shadcn/ui provides pre-styled Tailwind components.
- Material UI / Chakra UI: Heavier runtime, opinionated styling that conflicts with Tailwind approach.
## R3: Tailwind v4 + Vite Setup
**Decision**: Use `@tailwindcss/vite` plugin instead of PostCSS
**Rationale**: Tailwind v4 provides a dedicated Vite plugin that is faster than the PostCSS approach. Setup is:
1. Install `tailwindcss @tailwindcss/vite`
2. Add plugin to `vite.config.ts`
3. Create `index.css` with `@import "tailwindcss"`
4. Import `index.css` in `main.tsx`
**Alternatives considered**:
- PostCSS plugin: Works but slower; Vite plugin is recommended for Vite projects.
## R4: Icon Library
**Decision**: Use Lucide React for icons (remove button, etc.)
**Rationale**: Lucide is the default icon set for shadcn/ui. Tree-shakeable, consistent style, and already expected by shadcn/ui component templates.
**Alternatives considered**:
- Heroicons: Good quality but not the shadcn/ui default.
- Inline SVG: Too manual for maintenance.
## R5: CSS Utility Helper (cn function)
**Decision**: Use `clsx` + `tailwind-merge` via a `cn()` utility function
**Rationale**: Standard shadcn/ui pattern. `clsx` handles conditional classes, `tailwind-merge` deduplicates conflicting Tailwind classes. The `cn()` helper is placed in `lib/utils.ts`.
**Alternatives considered**:
- `clsx` alone: Doesn't deduplicate conflicting Tailwind classes (e.g., `p-2 p-4`).
## R6: Component Decomposition
**Decision**: Extract App.tsx into focused components while keeping them in a single file or minimal files
**Rationale**: The current App.tsx (~280 lines) has inline components (EditableName, MaxHpInput, CurrentHpInput). For the UI baseline, we'll restructure into:
- `App.tsx` — layout shell (header, combatant list, action bar)
- `components/combatant-row.tsx` — single combatant row with all controls
- `components/ui/` — shadcn/ui primitives (Button, Input, Card)
This keeps the change focused while establishing a scalable component structure.
**Alternatives considered**:
- Keep everything in App.tsx: Gets unwieldy with Tailwind classes added.
- Full atomic decomposition: Over-engineered for current scope.
## R7: verbatimModuleSyntax Compatibility
**Decision**: shadcn/ui components work with `verbatimModuleSyntax` since they use standard ESM imports
**Rationale**: shadcn/ui generates standard TypeScript files with explicit type imports. The `cn` utility and Radix imports use value imports. No special handling needed.
## R8: Biome 2.0 Compatibility
**Decision**: No conflicts expected; Tailwind class strings are just strings
**Rationale**: Biome formats/lints TypeScript and JSX. Tailwind classes in `className` props are plain strings, which Biome ignores content-wise. The shadcn/ui generated code follows standard formatting conventions. May need to run `pnpm format` after generating components.
## R9: Knip Compatibility
**Decision**: May need to configure Knip to recognize shadcn/ui component exports
**Rationale**: shadcn/ui components are copied into the project. If not all are immediately used, Knip may flag them as unused. Solution: only install the shadcn/ui components we actually need (Button, Input, Card/container).

View File

@@ -0,0 +1,140 @@
# Feature Specification: UI Baseline
**Feature Branch**: `010-ui-baseline`
**Created**: 2026-03-05
**Status**: Draft
**Input**: User description: "Establish a modern UI baseline for the encounter screen using Tailwind + shadcn/ui."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Structured Combatant Layout (Priority: P1)
As a game master viewing the encounter screen, I see each combatant displayed as a structured row with initiative, name, HP, and actions aligned in consistent columns, so I can quickly scan the battlefield state.
**Why this priority**: The core value of a UI baseline is replacing the unstyled list with a consistent, scannable layout. Every other visual improvement builds on this.
**Independent Test**: Can be fully tested by adding 3+ combatants and verifying that all data fields (initiative, name, HP, actions) are visually aligned in a grid/table-like layout.
**Acceptance Scenarios**:
1. **Given** an encounter with multiple combatants, **When** the screen loads, **Then** each combatant is displayed as a row with initiative, name, HP, and actions in consistent columns.
2. **Given** combatants with varying name lengths, **When** displayed, **Then** columns remain aligned and do not shift or overlap.
3. **Given** a combatant with no initiative or HP set, **When** displayed, **Then** placeholder or empty state is shown in the appropriate column without breaking alignment.
---
### User Story 2 - Active Combatant Highlight (Priority: P1)
As a game master during combat, I see the active combatant clearly highlighted so I can instantly identify whose turn it is without reading text markers.
**Why this priority**: Identifying the active turn is the primary interaction during live play. A clear visual highlight is essential for usability.
**Independent Test**: Can be tested by advancing turns and verifying the active combatant has a distinct visual treatment (background color, border, or similar).
**Acceptance Scenarios**:
1. **Given** an encounter in progress, **When** viewing the combatant list, **Then** the active combatant's row has a visually distinct highlight (e.g., accent background, left border indicator).
2. **Given** the turn advances, **When** a new combatant becomes active, **Then** the highlight moves to the new active combatant and is removed from the previous one.
---
### User Story 3 - Grouped Action Bar (Priority: P2)
As a game master, I see primary encounter controls (add combatant, next turn) grouped in a clearly defined action bar, so controls are easy to find and visually separated from the combatant list.
**Why this priority**: Grouping controls improves discoverability and reduces visual clutter, but is less critical than the combatant layout itself.
**Independent Test**: Can be tested by verifying that the "Add Combatant" form and "Next Turn" button are visually grouped in a distinct bar area, separated from the combatant list.
**Acceptance Scenarios**:
1. **Given** the encounter screen, **When** viewing controls, **Then** the "Add Combatant" input and "Next Turn" button are grouped in a visually distinct action bar.
2. **Given** the action bar, **When** inspecting layout, **Then** it is clearly separated from the combatant list by spacing, background, or border.
---
### User Story 4 - Inline Editing with Consistent Styling (Priority: P2)
As a game master, I can click on a combatant's name to edit it inline, and all editable controls (including initiative and HP inputs) match the overall visual style of the application.
**Why this priority**: Inline editing already exists functionally. This story ensures the edit states are visually consistent with the new design system rather than appearing as unstyled browser defaults.
**Independent Test**: Can be tested by clicking a combatant name to enter edit mode and verifying the input field matches the application's visual style.
**Acceptance Scenarios**:
1. **Given** a combatant row, **When** I click the name, **Then** it transitions to an inline text input styled consistently with the design system.
2. **Given** a combatant row, **When** I edit initiative or HP values, **Then** the number inputs are styled consistently with the design system.
---
### User Story 5 - Remove Action as Icon Button (Priority: P3)
As a game master, I see the remove action as a small icon button rather than a full text button, so it takes up less space and reduces visual noise.
**Why this priority**: This is a refinement that improves information density. The remove action is infrequently used, so a compact icon button is appropriate.
**Independent Test**: Can be tested by verifying each combatant row has a small icon-sized remove button (e.g., trash or X icon) instead of a text "Remove" button.
**Acceptance Scenarios**:
1. **Given** a combatant row, **When** viewing the actions column, **Then** the remove action is displayed as a small icon button (not a text label).
2. **Given** the icon button, **When** hovering, **Then** a tooltip or visual feedback indicates the action is "Remove".
---
### User Story 6 - Consistent Typography and Spacing (Priority: P2)
As a game master, the encounter screen uses consistent font sizes, weights, and spacing throughout, creating a cohesive and professional appearance.
**Why this priority**: Typography and spacing consistency is foundational to a "modern UI baseline" and affects the perceived quality of every other element.
**Independent Test**: Can be tested by verifying that heading sizes, body text, input text, and spacing follow a consistent scale across the entire screen.
**Acceptance Scenarios**:
1. **Given** the encounter screen, **When** inspecting typography, **Then** headings, labels, and body text use a consistent type scale.
2. **Given** the encounter screen, **When** inspecting spacing, **Then** padding and margins follow a consistent spacing scale.
---
### Edge Cases
- What happens when no combatants exist? The screen should display an empty state message (e.g., "No combatants yet") rather than a blank area.
- What happens with very long combatant names? Names should truncate with ellipsis rather than breaking the layout.
- What happens on narrow viewports? The layout should remain usable, though a fully responsive mobile design is not in the MVP baseline for this feature.
- What happens with 20+ combatants? The list should scroll without the action bar or header disappearing.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display combatants in a structured row layout with columns for initiative, name, HP (current/max), and actions. All numeric fields use text inputs with `inputmode="numeric"` (no browser spinners), ch-based widths, tabular numerals, and centered text.
- **FR-002**: System MUST visually highlight the active combatant's row with a distinct background, border, or accent treatment.
- **FR-003**: System MUST group the "Add Combatant" form and "Next Turn" button in a visually distinct action bar area.
- **FR-004**: System MUST display the remove action as a compact icon button (not a text label) on each combatant row.
- **FR-005**: System MUST style all form inputs (text fields, number inputs) consistently using the design system's components.
- **FR-006**: System MUST apply consistent typography (font family, sizes, weights) and spacing (margins, padding) from a defined scale.
- **FR-007**: System MUST show a round/turn status indicator (current round number and active combatant name) in a header or status area.
- **FR-008**: System MUST display an empty state message when no combatants are present.
- **FR-009**: System MUST truncate long combatant names with ellipsis rather than breaking the layout.
- **FR-010**: System MUST NOT introduce any domain logic changes; all changes are confined to the adapter/UI layer.
- **FR-011**: System MUST NOT display domain events in the main UI layout.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All combatant data fields (initiative, name, HP, actions) are visually aligned in consistent columns across all combatant rows.
- **SC-002**: The active combatant is identifiable within 1 second of glancing at the screen, without reading text labels.
- **SC-003**: A new user can locate the "Add Combatant" and "Next Turn" controls within 3 seconds of viewing the screen.
- **SC-004**: All interactive elements (buttons, inputs) are styled consistently with no browser-default styled controls visible.
- **SC-005**: The encounter screen presents a cohesive visual appearance with no mismatched fonts, inconsistent spacing, or unstyled elements.
## Assumptions
- Tailwind CSS and shadcn/ui will be used as the design system and component library. These are adapter-layer technology choices.
- The existing functional behavior (inline editing, HP controls, add/remove/advance) is preserved exactly; only visual presentation changes.
- A dark theme or theme toggle is not included in the MVP baseline for this feature.
- Full mobile/responsive optimization is not included in the MVP baseline for this feature, though the layout should not be broken on smaller screens.
- Domain events will be removed from the main UI display (they were a development aid, not a user-facing feature).

View File

@@ -0,0 +1,207 @@
# Tasks: UI Baseline
**Input**: Design documents from `/specs/010-ui-baseline/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/ui-components.md
**Tests**: No new tests requested in spec. Existing tests (layer boundary checks) must continue to pass.
**Organization**: Tasks grouped by user story. US1+US2 are combined (both P1, same component).
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Install Tailwind CSS v4, shadcn/ui utilities, and configure the build pipeline
- [x] T001 Install `tailwindcss` and `@tailwindcss/vite` as devDependencies and add the Tailwind Vite plugin to `apps/web/vite.config.ts`
- [x] T002 [P] Create `apps/web/src/index.css` with `@import "tailwindcss"` directive and `@theme` block defining CSS custom properties for the design system (colors, radii, fonts)
- [x] T003 [P] Install `clsx` and `tailwind-merge` as dependencies in `apps/web` and create the `cn()` utility in `apps/web/src/lib/utils.ts`
- [x] T004 Import `./index.css` in `apps/web/src/main.tsx`
- [x] T005 [P] Install `lucide-react` as a dependency in `apps/web`
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Create shadcn/ui-style primitive components that all user stories depend on
**Warning**: No user story work can begin until this phase is complete
- [x] T006 [P] Install `class-variance-authority` as a dependency in `apps/web` and create Button component in `apps/web/src/components/ui/button.tsx` following shadcn/ui pattern (variant props: default, outline, ghost, icon; size props: default, sm, icon) using `cn()` and `class-variance-authority`
- [x] T007 [P] Create Input component in `apps/web/src/components/ui/input.tsx` following shadcn/ui pattern (styled text/number input replacing browser defaults) using `cn()`
**Note**: T008 merged into T006 (install CVA + create Button in one task).
**Checkpoint**: Foundation ready — shadcn/ui primitives available, Tailwind active
---
## Phase 3: User Story 1 + 2 — Structured Layout + Active Highlight (Priority: P1) MVP
**Goal**: Replace the unstyled `<ul>/<li>` combatant list with a structured row layout (initiative | name | HP | actions columns) and visually highlight the active combatant's row
**Independent Test**: Add 3+ combatants, verify columns are aligned. Set initiative and HP on some. Advance turns and confirm the active row has a distinct highlight that moves correctly.
### Implementation
- [x] T009 [US1] Extract `CombatantRow` component to `apps/web/src/components/combatant-row.tsx` — accepts `combatant`, `isActive`, and action callbacks per the UI contract. Render a grid/flex row with four columns: initiative (number input), name (click-to-edit text), HP (current/max with +/- buttons), actions (remove button placeholder). Apply active row highlight (accent left border + subtle background) when `isActive` is true. Move `EditableName`, `MaxHpInput`, and `CurrentHpInput` inline components into this file.
- [x] T010 [US1] Refactor `apps/web/src/App.tsx` to use `CombatantRow` — replace the `<ul>` list with a styled container. Add encounter header section showing title ("Initiative Tracker") and round/turn status. Remove domain events display section entirely (FR-011). Keep `useEncounter` hook usage and all callbacks wired through.
**Checkpoint**: Combatants display in aligned columns with active highlight. All existing functionality preserved.
---
## Phase 4: User Story 3 — Grouped Action Bar (Priority: P2)
**Goal**: Group the "Add Combatant" form and "Next Turn" button into a visually distinct action bar separated from the combatant list
**Independent Test**: Verify controls are grouped in a distinct bar area with visual separation (background, border, or spacing) from the combatant list.
### Implementation
- [x] T011 [US3] Extract `ActionBar` component to `apps/web/src/components/action-bar.tsx` — accepts `onAddCombatant` and `onAdvanceTurn` callbacks. Render a styled bar with the add-combatant form (Input + Button) and Next Turn button (outline/secondary variant). Apply visual separation from the combatant list.
- [x] T012 [US3] Update `apps/web/src/App.tsx` to use `ActionBar` component — replace inline form and button with the extracted component.
**Checkpoint**: Action bar is visually grouped and separated from combatant list.
---
## Phase 5: User Story 4 — Inline Editing with Consistent Styling (Priority: P2)
**Goal**: Ensure all inline edit states (name, initiative, HP inputs) use the design system Input component instead of unstyled browser defaults
**Independent Test**: Click a combatant name to edit — the input should match the design system style. Verify initiative and HP number inputs are also styled consistently.
### Implementation
- [x] T013 [US4] Update `EditableName`, `MaxHpInput`, `CurrentHpInput`, and initiative input in `apps/web/src/components/combatant-row.tsx` to use the shadcn/ui-style `Input` component from `components/ui/input.tsx`. Ensure edit-mode inputs match display-mode styling for seamless transitions.
**Checkpoint**: All form inputs across the encounter screen use consistent design system styling.
---
## Phase 6: User Story 5 — Remove Action as Icon Button (Priority: P3)
**Goal**: Replace the text "Remove" button with a compact icon button using a Lucide icon
**Independent Test**: Each combatant row shows a small icon button (X or Trash2) instead of a text "Remove" button. Hovering shows tooltip feedback.
### Implementation
- [x] T014 [US5] Replace the remove `<button>` in `apps/web/src/components/combatant-row.tsx` with a `Button` (ghost/icon variant) wrapping a Lucide `X` or `Trash2` icon. Add `title` attribute for hover tooltip ("Remove combatant").
**Checkpoint**: Remove action is a compact icon button with hover feedback.
---
## Phase 7: User Story 6 — Consistent Typography and Spacing (Priority: P2)
**Goal**: Apply a consistent type scale and spacing scale across the entire encounter screen
**Independent Test**: Inspect the screen — headings, body text, labels, and inputs use a consistent font family, size scale, and spacing rhythm with no arbitrary values.
### Implementation
- [x] T015 [US6] Review and refine typography and spacing across all components — ensure `apps/web/src/index.css` theme defines a consistent type scale (heading, body, label sizes) and spacing tokens. Update `apps/web/src/components/combatant-row.tsx`, `apps/web/src/components/action-bar.tsx`, and `apps/web/src/App.tsx` to use consistent Tailwind spacing/typography utilities (no arbitrary pixel values).
**Checkpoint**: The entire encounter screen has cohesive typography and spacing.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Edge cases, cleanup, and quality gate validation
- [x] T016 Add empty state message in `apps/web/src/App.tsx` — when `encounter.combatants.length === 0`, display a centered muted message ("No combatants yet — add one to get started") instead of an empty list area
- [x] T017 Add long name truncation with CSS `text-overflow: ellipsis` on the name column in `apps/web/src/components/combatant-row.tsx` and make the combatant list area scrollable when it overflows (sticky header + action bar)
- [x] T018 Run `pnpm check` (knip + format + lint + typecheck + test) and fix all issues — ensure no unused imports from shadcn/ui, Biome formatting passes, TypeScript compiles, and layer boundary tests pass
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on T001 (Tailwind installed) and T003 (cn() available)
- **US1+US2 (Phase 3)**: Depends on Phase 2 (Button, Input primitives available)
- **US3 (Phase 4)**: Depends on Phase 3 (App.tsx refactored with layout shell)
- **US4 (Phase 5)**: Depends on Phase 3 (CombatantRow exists with inline edit components)
- **US5 (Phase 6)**: Depends on Phase 3 (CombatantRow exists) + T005 (lucide-react installed)
- **US6 (Phase 7)**: Depends on Phases 3-6 (all components exist to audit typography)
- **Polish (Phase 8)**: Depends on all user stories complete
### User Story Dependencies
- **US1+US2 (P1)**: Can start after Phase 2 — no other story dependencies
- **US3 (P2)**: Depends on US1+US2 (App.tsx layout shell must exist)
- **US4 (P2)**: Depends on US1+US2 (CombatantRow must exist)
- **US5 (P3)**: Depends on US1+US2 (CombatantRow must exist)
- **US6 (P2)**: Depends on US3, US4, US5 (all components must exist to audit)
### Within Each User Story
- Component extraction before integration with App.tsx
- Structural changes before styling refinements
### Parallel Opportunities
- T002, T003, T005 can run in parallel (different files, no dependencies)
- T006, T007 can run in parallel (different files)
- US3 (T011-T012) and US4 (T013) and US5 (T014) can run in parallel after Phase 3 (different files)
---
## Parallel Example: Phase 1 Setup
```bash
# These three tasks touch different files and can run simultaneously:
Task T002: "Create index.css with Tailwind directives in apps/web/src/index.css"
Task T003: "Create cn() utility in apps/web/src/lib/utils.ts"
Task T005: "Install lucide-react in apps/web"
```
## Parallel Example: Phase 2 Foundational
```bash
# These tasks create independent component files:
Task T006: "Install CVA + create Button in apps/web/src/components/ui/button.tsx"
Task T007: "Create Input in apps/web/src/components/ui/input.tsx"
```
---
## Implementation Strategy
### MVP First (US1+US2 Only)
1. Complete Phase 1: Setup (Tailwind + utilities)
2. Complete Phase 2: Foundational (Button, Input primitives)
3. Complete Phase 3: US1+US2 (structured layout + active highlight)
4. **STOP and VALIDATE**: Combatants display in aligned columns, active turn highlighted
5. This alone delivers the core visual upgrade
### Incremental Delivery
1. Setup + Foundational -> Build pipeline ready
2. US1+US2 -> Structured layout with active highlight (MVP!)
3. US3 -> Grouped action bar
4. US4 -> Styled inline editing
5. US5 -> Icon remove button
6. US6 -> Typography/spacing audit
7. Polish -> Edge cases, quality gate
8. Each phase adds visual polish without breaking previous work
---
## Notes
- All changes are in `apps/web/` only — domain and application packages are untouched (FR-010)
- Domain events display is removed in T010 (FR-011)
- No new test files created — existing Vitest layer boundary tests must pass (T018)
- shadcn/ui components are hand-written following the pattern (not CLI-generated) to ensure Biome/Knip compatibility
- Run `pnpm format` after each phase to keep Biome happy