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:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
39
apps/web/src/components/action-bar.tsx
Normal file
39
apps/web/src/components/action-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
apps/web/src/components/combatant-row.tsx
Normal file
246
apps/web/src/components/combatant-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
apps/web/src/components/ui/button.tsx
Normal file
38
apps/web/src/components/ui/button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
apps/web/src/components/ui/input.tsx
Normal file
19
apps/web/src/components/ui/input.tsx
Normal 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
26
apps/web/src/index.css
Normal 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);
|
||||||
|
}
|
||||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
382
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
36
specs/010-ui-baseline/checklists/requirements.md
Normal file
36
specs/010-ui-baseline/checklists/requirements.md
Normal 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).
|
||||||
78
specs/010-ui-baseline/contracts/ui-components.md
Normal file
78
specs/010-ui-baseline/contracts/ui-components.md
Normal 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
|
||||||
75
specs/010-ui-baseline/data-model.md
Normal file
75
specs/010-ui-baseline/data-model.md
Normal 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 (1–2 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 |
|
||||||
75
specs/010-ui-baseline/plan.md
Normal file
75
specs/010-ui-baseline/plan.md
Normal 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.
|
||||||
62
specs/010-ui-baseline/quickstart.md
Normal file
62
specs/010-ui-baseline/quickstart.md
Normal 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)
|
||||||
72
specs/010-ui-baseline/research.md
Normal file
72
specs/010-ui-baseline/research.md
Normal 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).
|
||||||
140
specs/010-ui-baseline/spec.md
Normal file
140
specs/010-ui-baseline/spec.md
Normal 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).
|
||||||
207
specs/010-ui-baseline/tasks.md
Normal file
207
specs/010-ui-baseline/tasks.md
Normal 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
|
||||||
Reference in New Issue
Block a user