Implement the 019-combatant-row-declutter feature that replaces always-visible HP controls and AC/MaxHP inputs with compact click-to-edit and click-to-adjust patterns in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 15:07:04 +01:00
parent e59fd83292
commit 0c0da9b90e
11 changed files with 723 additions and 207 deletions

View File

@@ -69,6 +69,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- N/A (no storage changes) (015-add-jscpd-gate)
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Vite 6, Tailwind CSS v4, Lucide React (icons) (017-combat-conditions)
- N/A (no storage changes — existing localStorage persistence unchanged) (019-combatant-row-declutter)
## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite

View File

@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { QuickHpInput } from "./quick-hp-input";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -91,36 +91,39 @@ function EditableName({
);
}
function MaxHpInput({
function MaxHpDisplay({
maxHp,
onCommit,
}: {
maxHp: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
const prev = useRef(maxHp);
if (maxHp !== prev.current) {
prev.current = maxHp;
setDraft(maxHp?.toString() ?? "");
}
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 1) {
onCommit(n);
} else {
setDraft(maxHp?.toString() ?? "");
}
}, [draft, maxHp, onCommit]);
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(maxHp?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [maxHp]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
@@ -130,93 +133,102 @@ function MaxHpInput({
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
{maxHp ?? "Max"}
</button>
);
}
function CurrentHpInput({
function ClickableHp({
currentHp,
maxHp,
onCommit,
className,
onAdjust,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onCommit: (value: number) => void;
className?: string;
onAdjust: (delta: number) => void;
}) {
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
const prev = useRef(currentHp);
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (currentHp !== prev.current) {
prev.current = currentHp;
setDraft(currentHp?.toString() ?? "");
if (maxHp === undefined) {
return (
<span className="inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground">
--
</span>
);
}
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={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
<div className="relative">
<button
type="button"
onClick={() => setPopoverOpen(true)}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-primary",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
)}
>
{currentHp}
</button>
{popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
/>
)}
</div>
);
}
function AcInput({
function AcDisplay({
ac,
onCommit,
}: {
ac: number | undefined;
onCommit: (value: number | undefined) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(ac?.toString() ?? "");
const prev = useRef(ac);
if (ac !== prev.current) {
prev.current = ac;
setDraft(ac?.toString() ?? "");
}
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onCommit(undefined);
return;
}
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n) && n >= 0) {
onCommit(n);
} else {
setDraft(ac?.toString() ?? "");
}
}, [draft, ac, onCommit]);
}
setEditing(false);
}, [draft, onCommit]);
const startEditing = useCallback(() => {
setDraft(ac?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [ac]);
if (editing) {
return (
<div className="flex items-center gap-1">
<Shield size={14} className="text-muted-foreground" />
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
@@ -226,10 +238,23 @@ function AcInput({
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
</div>
);
}
return (
<button
type="button"
onClick={startEditing}
className="flex items-center gap-1 text-sm tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
<Shield size={14} />
{ac !== undefined ? <span>{ac}</span> : null}
</button>
);
}
export function CombatantRow({
@@ -329,32 +354,21 @@ export function CombatantRow({
<EditableName name={name} combatantId={id} onRename={onRename} />
{/* AC */}
<AcInput ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
{/* HP */}
<div className="flex items-center gap-1">
<CurrentHpInput
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
className={cn(
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
)}
onCommit={(value) => {
if (currentHp === undefined) return;
const delta = value - currentHp;
if (delta !== 0) onAdjustHp(id, delta);
}}
onAdjust={(delta) => onAdjustHp(id, delta)}
/>
{maxHp !== undefined && (
<span className="text-sm tabular-nums text-muted-foreground">
/
</span>
)}
<MaxHpInput maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
{maxHp !== undefined && (
<QuickHpInput combatantId={id} onAdjustHp={onAdjustHp} />
)}
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
{/* Actions */}

View File

@@ -0,0 +1,109 @@
import { Heart, Sword } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
}
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState("");
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
const isValid =
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
const applyDelta = useCallback(
(sign: -1 | 1) => {
if (inputValue === "") return;
const n = Number.parseInt(inputValue, 10);
if (Number.isNaN(n) || n <= 0) return;
onAdjust(sign * n);
onClose();
},
[inputValue, onAdjust, onClose],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
applyDelta(1);
} else {
applyDelta(-1);
}
} else if (e.key === "Escape") {
onClose();
}
},
[applyDelta, onClose],
);
return (
<div
ref={ref}
className="absolute z-10 mt-1 rounded-md border border-border bg-background p-2 shadow-lg"
>
<div className="flex items-center gap-1">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={inputValue}
placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-red-400 hover:bg-red-950 hover:text-red-300"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={!isValid}
className="h-7 w-7 shrink-0 text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</div>
</div>
);
}

View File

@@ -1,99 +0,0 @@
import type { CombatantId } from "@initiative/domain";
import { Heart, Sword } 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 QuickHpInputProps {
readonly combatantId: CombatantId;
readonly disabled?: boolean;
readonly onAdjustHp: (id: CombatantId, delta: number) => void;
}
export function QuickHpInput({
combatantId,
disabled,
onAdjustHp,
}: QuickHpInputProps) {
const [inputValue, setInputValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
const isValid =
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
const applyDelta = useCallback(
(sign: -1 | 1) => {
if (inputValue === "") return;
const n = Number.parseInt(inputValue, 10);
if (Number.isNaN(n) || n <= 0) return;
onAdjustHp(combatantId, sign * n);
setInputValue("");
},
[inputValue, combatantId, onAdjustHp],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
applyDelta(-1);
} else if (e.key === "Escape") {
setInputValue("");
}
},
[applyDelta],
);
return (
<div className="flex items-center gap-0.5">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
disabled={disabled}
value={inputValue}
placeholder="±HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || /^\d+$/.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !isValid}
className={cn(
"h-7 w-7 shrink-0",
"text-red-400 hover:bg-red-950 hover:text-red-300",
)}
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
disabled={disabled || !isValid}
className={cn(
"h-7 w-7 shrink-0",
"text-emerald-400 hover:bg-emerald-950 hover:text-emerald-300",
)}
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</Button>
</div>
);
}

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Combatant Row Declutter
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-06
**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
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.

View File

@@ -0,0 +1,29 @@
# Data Model: Combatant Row Declutter
**Feature**: 019-combatant-row-declutter
**Date**: 2026-03-06
## No Data Model Changes
This feature is a purely visual/interaction refactor within the web adapter layer. No domain entities, application state, or persistence formats are affected.
### Existing entities (unchanged)
- **Combatant**: `id`, `name`, `initiative`, `maxHp`, `currentHp`, `ac`, `conditions`, `isConcentrating` — no fields added, removed, or modified.
### Existing callbacks (unchanged)
- `onAdjustHp(id: CombatantId, delta: number)` — same signature, now triggered from popover instead of inline QuickHpInput.
- `onSetAc(id: CombatantId, value: number | undefined)` — same signature, now triggered from click-to-edit instead of always-visible input.
### UI State (component-local only)
New component-local state introduced in the HP popover (not persisted):
- **popoverOpen**: boolean — whether the HP adjustment popover is visible.
- **inputValue**: string — the draft delta value in the popover input.
New component-local state in the AC click-to-edit (not persisted):
- **editing**: boolean — whether the inline AC edit input is visible.
- **draft**: string — the draft AC value being edited.
These follow the same patterns as existing component-local state (`EditableName.editing`, `ConditionPicker` open state).

View File

@@ -0,0 +1,75 @@
# Implementation Plan: Combatant Row Declutter
**Branch**: `019-combatant-row-declutter` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/019-combatant-row-declutter/spec.md`
## Summary
Replace always-visible HP adjustment controls (QuickHpInput with delta input + Sword/Heart buttons) and AC input field with compact, on-demand interaction patterns. HP becomes a clickable value that opens a popover for damage/healing. AC becomes a static shield+number display with click-to-edit inline editing. This is purely a web adapter (UI) change — domain and application layers are untouched.
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, Lucide React (icons)
**Storage**: N/A (no storage changes — existing localStorage persistence unchanged)
**Testing**: Vitest (unit tests for pure functions; UI behavior verified manually)
**Target Platform**: Desktop and tablet-width browsers
**Project Type**: Web application (monorepo with domain/application/web layers)
**Performance Goals**: N/A (no performance-sensitive changes)
**Constraints**: Keyboard-accessible, no domain/application layer modifications
**Scale/Scope**: 1 component modified (CombatantRow), 1 component removed (QuickHpInput), 1 new component (HpAdjustPopover). AC and Max HP click-to-edit are implemented inline within CombatantRow (no separate component files)
## 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 | Feature description was fully specified, no ambiguities |
| V. Escalation Gates | PASS | All changes within spec scope |
| VI. MVP Baseline Language | PASS | No permanent bans introduced |
| VII. No Gameplay Rules | PASS | No gameplay mechanics in plan |
**Pre-design gate: PASS** — no violations.
## Project Structure
### Documentation (this feature)
```text
specs/019-combatant-row-declutter/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
apps/web/src/components/
├── combatant-row.tsx # MODIFY: replace CurrentHpInput with clickable display + popover,
│ # replace AcInput with click-to-edit static display
├── hp-adjust-popover.tsx # NEW: popover with numeric input + Damage/Heal buttons
├── quick-hp-input.tsx # REMOVE: replaced by hp-adjust-popover
└── ui/
├── button.tsx # existing (used by popover)
└── input.tsx # existing (used by popover and AC inline edit)
```
**Structure Decision**: All changes are confined to `apps/web/src/components/`. No new directories needed. The existing monorepo structure (domain → application → web) is preserved. No contracts directory needed since this is an internal UI refactor with no external interfaces.
## Post-Design Constitution Re-Check
| Principle | Status | Notes |
|-----------|--------|-------|
| I. Deterministic Domain Core | PASS | No domain changes |
| II. Layered Architecture | PASS | Only adapter-layer files touched |
| IV. Clarification-First | PASS | No new ambiguities introduced |
| V. Escalation Gates | PASS | All within spec scope |
**Post-design gate: PASS** — no violations.

View File

@@ -0,0 +1,44 @@
# Quickstart: Combatant Row Declutter
**Feature**: 019-combatant-row-declutter
**Date**: 2026-03-06
## What This Feature Does
Replaces always-visible HP adjustment controls and AC input with compact, on-demand interactions:
1. **HP**: Click the current HP number to open a small popover for damage/healing.
2. **AC**: Click the shield+number display to edit AC inline.
## Key Files
| File | Role |
|------|------|
| `apps/web/src/components/combatant-row.tsx` | Main combatant row — modified to use new patterns |
| `apps/web/src/components/hp-adjust-popover.tsx` | New popover component for HP adjustment |
| `apps/web/src/components/quick-hp-input.tsx` | Removed — replaced by HP popover |
## How It Works
### HP Adjustment Flow
1. Current HP displays as a clickable, color-coded number (amber=bloodied, red=unconscious)
2. Click opens a popover with auto-focused numeric input + Damage/Heal buttons
3. Enter = apply damage (negative delta), Shift+Enter = apply healing (positive delta)
4. Popover dismisses on action, Escape, or click-outside
### AC Edit Flow
1. AC displays as static text: shield icon + number (or just shield if unset)
2. Click opens inline input (same pattern as editable name)
3. Enter/blur commits, Escape cancels
## Development
```bash
pnpm --filter web dev # Start dev server
pnpm check # Run full quality gate before committing
```
## Constraints
- Domain and application layers are unchanged
- `onAdjustHp` and `onSetAc` callback signatures unchanged
- Max HP input remains always-visible

View File

@@ -0,0 +1,42 @@
# Research: Combatant Row Declutter
**Feature**: 019-combatant-row-declutter
**Date**: 2026-03-06
## R1: Popover Dismissal Pattern
**Decision**: Use a click-outside listener with ref-based boundary detection, consistent with the existing ConditionPicker component pattern.
**Rationale**: The codebase already uses this pattern in `condition-picker.tsx` / `condition-tags.tsx` where clicking outside the picker closes it. Reusing the same approach maintains consistency and avoids introducing new dependencies (e.g., a popover library). The popover will use `useEffect` with a document-level click handler that checks if the click target is outside the popover ref.
**Alternatives considered**:
- Headless UI library (Radix Popover, Floating UI): Adds a dependency for a simple use case. Rejected — the project has no headless UI library and introducing one for a single popover is over-engineering.
- HTML `<dialog>` / `popover` attribute: Native browser support is good, but the `popover` attribute doesn't provide the positioning control needed (anchored to the HP value). Rejected for insufficient positioning semantics.
## R2: Click-to-Edit Pattern for AC
**Decision**: Reuse the exact same pattern as the existing `EditableName` component — toggle between static display and input on click, commit on Enter/blur, cancel on Escape.
**Rationale**: `EditableName` in `combatant-row.tsx` already implements this exact interaction pattern. The AC click-to-edit can follow the same state machine (editing boolean, draft state, commit/cancel callbacks). This ensures behavioral consistency across the row.
**Alternatives considered**:
- Popover (same as HP): AC editing is simpler (just set a value, no damage/heal distinction), so a popover adds unnecessary complexity. Rejected.
- Double-click to edit: Less discoverable than single-click. Rejected — the existing name edit uses single-click.
## R3: HP Popover Positioning
**Decision**: Position the popover directly below (or above if near viewport bottom) the current HP value using simple CSS absolute/relative positioning.
**Rationale**: The popover only needs to appear near the trigger element. The combatant row is a simple list layout — no complex scrolling containers or overflow clipping that would require a positioning library. Simple CSS positioning (relative parent + absolute child) is sufficient.
**Alternatives considered**:
- Floating UI / Popper.js: Provides advanced positioning with flip/shift. Overkill for this use case since the encounter list is a straightforward vertical layout. Rejected.
## R4: Keyboard Shortcuts in Popover
**Decision**: Handle Enter (damage) and Shift+Enter (heal) via `onKeyDown` on the popover's input element. Escape handled at the same level to close the popover.
**Rationale**: This matches the existing QuickHpInput pattern where Enter applies damage. Adding Shift+Enter for healing is a natural modifier key extension. The input captures all keyboard events, so no global listeners are needed.
**Alternatives considered**:
- Separate keyboard shortcut system: Unnecessary complexity for two shortcuts scoped to a single input. Rejected.

View File

@@ -0,0 +1,113 @@
# Feature Specification: Combatant Row Declutter
**Feature Branch**: `019-combatant-row-declutter`
**Created**: 2026-03-06
**Status**: Draft
**Input**: User description: "Declutter combatant row with click-to-edit patterns — replace always-visible HP adjustment and AC inputs with compact, on-demand interactions"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - HP Click-to-Adjust Popover (Priority: P1)
As a DM running combat, I want to click on a combatant's current HP value to open a small adjustment popover, so the combatant row is visually clean and I can still quickly apply damage or healing when needed.
**Why this priority**: HP adjustment is the most frequent mid-combat interaction and the QuickHpInput with its Sword/Heart buttons is the biggest source of visual clutter. Replacing it with an on-demand popover delivers the largest declutter impact.
**Independent Test**: Can be fully tested by clicking a combatant's current HP, entering a number, and pressing Enter (damage) or Shift+Enter (heal), then verifying the HP updates correctly and the popover dismisses.
**Acceptance Scenarios**:
1. **Given** a combatant with max HP set (e.g., 30/30), **When** I look at the row, **Then** I see only the current HP number and max HP — no delta input or action buttons are visible.
2. **Given** a combatant with max HP set, **When** I click the current HP number, **Then** a small popover opens containing an auto-focused numeric input and Damage/Heal buttons.
3. **Given** the HP popover is open with a valid number entered, **When** I press Enter, **Then** damage is applied (negative delta), the popover closes, and the HP value updates.
4. **Given** the HP popover is open with a valid number entered, **When** I press Shift+Enter, **Then** healing is applied (positive delta), the popover closes, and the HP value updates.
5. **Given** the HP popover is open with a valid number entered, **When** I click the Damage button, **Then** damage is applied and the popover closes.
6. **Given** the HP popover is open with a valid number entered, **When** I click the Heal button, **Then** healing is applied and the popover closes.
7. **Given** the HP popover is open, **When** I press Escape, **Then** the popover closes without applying any change.
8. **Given** the HP popover is open, **When** I click outside the popover, **Then** the popover closes without applying any change.
9. **Given** a combatant whose HP is at or below half (bloodied), **When** I view the row, **Then** the current HP number displays in the bloodied color (amber).
10. **Given** a combatant whose HP is at 0 (unconscious), **When** I view the row, **Then** the current HP number displays in the unconscious color (red).
11. **Given** a combatant with no max HP set, **When** I view the row, **Then** the HP area shows only the max HP input — no clickable current HP value.
---
### User Story 2 - AC Click-to-Edit (Priority: P2)
As a DM, I want the AC field to appear as a compact static display (shield icon + number) that I can click to edit inline, so the row is less cluttered while AC remains easy to set or change.
**Why this priority**: AC is set less frequently than HP is adjusted, so converting it to a click-to-edit pattern provides good declutter value with lower interaction frequency impact.
**Independent Test**: Can be fully tested by clicking the AC display, editing the value, and confirming via Enter or blur, then verifying the AC updates correctly.
**Acceptance Scenarios**:
1. **Given** a combatant with AC set (e.g., 15), **When** I view the row, **Then** I see a shield icon followed by the number "15" as static text — not an input field.
2. **Given** a combatant with no AC set, **When** I view the row, **Then** I see just the shield icon (no number) as a clickable element.
3. **Given** a combatant's AC display, **When** I click it, **Then** an inline input appears with the current AC value pre-filled and selected.
4. **Given** the AC inline edit is active, **When** I type a new value and press Enter, **Then** the AC updates and the display reverts to static mode.
5. **Given** the AC inline edit is active, **When** I click away (blur), **Then** the AC updates and the display reverts to static mode.
6. **Given** the AC inline edit is active, **When** I press Escape, **Then** the edit is cancelled, the original value is preserved, and the display reverts to static mode.
7. **Given** the AC inline edit is active, **When** I clear the field and press Enter, **Then** the AC is unset and the display shows just the shield icon.
---
### User Story 3 - Max HP Click-to-Edit (Priority: P2)
As a DM, I want the max HP field to appear as a compact static display that I can click to edit inline, so the row is consistent with the AC click-to-edit pattern and further reduces visual clutter.
**Why this priority**: Max HP is set once during encounter setup and rarely changed. Converting it to click-to-edit applies the same declutter pattern as AC for consistency.
**Independent Test**: Can be fully tested by clicking the max HP display, editing the value, and confirming via Enter or blur, then verifying the max HP updates correctly.
**Acceptance Scenarios**:
1. **Given** a combatant with max HP set (e.g., 30), **When** I view the row, **Then** I see the max HP as static text — not an input field.
2. **Given** a combatant with no max HP set, **When** I view the row, **Then** I see a clickable placeholder to set max HP.
3. **Given** a combatant's max HP display, **When** I click it, **Then** an inline input appears with the current max HP value pre-filled and selected.
4. **Given** the max HP inline edit is active, **When** I type a new value and press Enter, **Then** the max HP updates and the display reverts to static mode.
5. **Given** the max HP inline edit is active, **When** I click away (blur), **Then** the max HP updates and the display reverts to static mode.
6. **Given** the max HP inline edit is active, **When** I press Escape, **Then** the edit is cancelled and the display reverts to static mode.
7. **Given** the max HP inline edit is active, **When** I clear the field and press Enter, **Then** the max HP is unset and HP tracking is removed.
---
### Edge Cases
- What happens when the user enters non-numeric or negative values in the HP popover? The input only accepts positive integers; non-numeric input is ignored.
- What happens when the user enters 0 in the HP popover? Zero is not a valid delta and the action buttons remain disabled.
- What happens when the HP popover is open and the user tabs away? The popover closes without applying changes (same as blur/click-outside).
- What happens on tablet-width screens? The popover and inline edit must remain accessible and not overflow or clip at viewports ≥ 768px wide (Tailwind `md` breakpoint).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST replace the always-visible QuickHpInput (delta input + Sword/Heart buttons) with a clickable current HP display that opens a popover on interaction.
- **FR-002**: The HP popover MUST contain a single auto-focused numeric input and two action buttons (Damage and Heal).
- **FR-003**: The HP popover MUST support keyboard shortcuts: Enter for damage, Shift+Enter for heal, Escape to dismiss.
- **FR-004**: The HP popover MUST dismiss automatically after an action is applied, on Escape, or when clicking outside.
- **FR-005**: The current HP display MUST retain color-coded status indicators (amber for bloodied, red for unconscious) when the popover is closed.
- **FR-006**: System MUST replace the always-visible AC input field with a static display (shield icon + number).
- **FR-007**: Clicking the AC static display MUST open an inline edit input that commits on Enter or blur and cancels on Escape.
- **FR-008**: The max HP MUST display as compact static text with click-to-edit, consistent with the AC pattern.
- **FR-009**: The existing callback signatures (`onAdjustHp`, `onSetAc`) MUST remain unchanged — this is a UI-only change.
- **FR-010**: All interactive elements MUST remain keyboard-accessible (focusable, operable via keyboard).
- **FR-011**: The HP popover input MUST only accept positive integers as valid delta values.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: The combatant row displays fewer always-visible interactive controls — specifically, 3 fewer visible elements (delta input, Damage button, Heal button) per combatant when HP is set.
- **SC-002**: The AC field takes up less horizontal space in its default state compared to an always-visible input field.
- **SC-003**: Users can apply damage or healing in 3 or fewer interactions (click HP, type number, press Enter/Shift+Enter).
- **SC-004**: Users can edit AC in 3 or fewer interactions (click AC, type number, press Enter).
- **SC-005**: All HP and AC interactions remain fully operable using only a keyboard.
## Assumptions
- The popover is a lightweight component positioned near the current HP value — not a full modal dialog.
- The HP popover uses a simple overlay/click-outside pattern, consistent with how the condition picker already works in the app.
- The AC click-to-edit follows the exact same pattern as the existing editable name component (click to edit, Enter/blur to commit, Escape to cancel).
- Domain and application layers require zero changes — all modifications are confined to the web adapter (React components).
- The CurrentHpInput component is either removed or repurposed as a static display + popover trigger, rather than being an always-visible input.

View File

@@ -0,0 +1,154 @@
# Tasks: Combatant Row Declutter
**Input**: Design documents from `/specs/019-combatant-row-declutter/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, quickstart.md
**Tests**: Not explicitly requested in the feature specification. Test tasks are omitted.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## 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)
- Include exact file paths in descriptions
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: No new project setup needed — this feature modifies existing components only. Phase skipped.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: No foundational/blocking work required. Both user stories modify independent components within the same file and can proceed directly.
---
## Phase 3: User Story 1 - HP Click-to-Adjust Popover (Priority: P1) MVP
**Goal**: Replace the always-visible QuickHpInput (delta input + Sword/Heart buttons) with a clickable current HP display that opens a popover for damage/healing.
**Independent Test**: Click a combatant's current HP number, enter a value, press Enter (damage) or Shift+Enter (heal), verify HP updates and popover dismisses. Verify bloodied/unconscious color coding on the static HP display.
### Implementation for User Story 1
- [x] T001 [US1] Create HpAdjustPopover component in `apps/web/src/components/hp-adjust-popover.tsx` — popover with auto-focused numeric input, Damage button (red/Sword icon), and Heal button (green/Heart icon). Input accepts positive integers only. Enter applies damage (negative delta), Shift+Enter applies healing (positive delta). Popover dismisses on action, Escape, or click-outside (use ref-based click-outside listener per research.md R1).
- [x] T002 [US1] Replace CurrentHpInput in `apps/web/src/components/combatant-row.tsx` with a clickable static HP display — render current HP as a `<button>` element with color-coded text (amber for bloodied, red for unconscious via `deriveHpStatus`). On click, open the HpAdjustPopover. Wire popover's onAdjustHp to existing `onAdjustHp` callback with unchanged signature.
- [x] T003 [US1] Remove QuickHpInput usage from `apps/web/src/components/combatant-row.tsx` — delete the `<QuickHpInput>` render and its import. The HP section should now show: clickable current HP / max HP input (always-visible).
- [x] T004 [US1] Delete `apps/web/src/components/quick-hp-input.tsx` — component is fully replaced by HpAdjustPopover.
- [x] T005 [US1] Run `pnpm check` to verify no lint, type, format, test, or unused-code errors after HP popover changes.
**Checkpoint**: HP adjustment works via click-to-adjust popover. No always-visible delta input or Sword/Heart buttons. Color-coded HP status preserved.
---
## Phase 4: User Story 2 - AC Click-to-Edit (Priority: P2)
**Goal**: Replace the always-visible AC input field with a static display (shield icon + number) that becomes an inline edit on click.
**Independent Test**: View a combatant row and verify AC shows as shield+number text (not an input). Click it, edit the value, press Enter — verify AC updates. Press Escape — verify edit cancels. Clear and commit — verify AC unsets.
### Implementation for User Story 2
- [x] T006 [US2] Refactor AcInput in `apps/web/src/components/combatant-row.tsx` to a click-to-edit pattern — default state renders shield icon + AC number as a `<button>` (or just shield icon if AC is unset). On click, switch to an inline `<Input>` with the current value pre-filled and selected. Commit on Enter/blur, cancel on Escape. Follow the same state pattern as the existing `EditableName` component (editing boolean, draft state, commit/cancel callbacks).
- [x] T007 [US2] Run `pnpm check` to verify no lint, type, format, test, or unused-code errors after AC click-to-edit changes.
**Checkpoint**: AC displays as compact static text. Click-to-edit works with Enter/blur commit and Escape cancel.
---
## Phase 4b: User Story 3 - Max HP Click-to-Edit (Priority: P2)
**Goal**: Replace the always-visible Max HP input field with a static display that becomes an inline edit on click, consistent with the AC click-to-edit pattern.
**Independent Test**: View a combatant row and verify Max HP shows as static text (not an input). Click it, edit the value, press Enter — verify Max HP updates. Press Escape — verify edit cancels. Clear and commit — verify Max HP unsets.
### Implementation for User Story 3
- [x] T010 [US3] Refactor MaxHpInput in `apps/web/src/components/combatant-row.tsx` to a click-to-edit pattern — default state renders max HP number as a `<button>` (or placeholder text "Max" if unset). On click, switch to an inline `<Input>` with the current value pre-filled and selected. Commit on Enter/blur, cancel on Escape. Follow the same state pattern as `AcDisplay` and `EditableName`.
- [x] T011 [US3] Run `pnpm check` to verify no lint, type, format, test, or unused-code errors after Max HP click-to-edit changes.
**Checkpoint**: Max HP displays as compact static text. Click-to-edit works with Enter/blur commit and Escape cancel.
---
## Phase 5: Polish & Cross-Cutting Concerns
**Purpose**: Final cleanup and grid layout adjustment after both stories are complete.
- [x] T008 Adjust combatant row grid layout in `apps/web/src/components/combatant-row.tsx` — update the `grid-cols-[...]` template to account for reduced HP section width (no more QuickHpInput) and compact AC display. Verify alignment on desktop and tablet-width viewports. Verify keyboard accessibility: HP display and AC display must be focusable via Tab, operable via Enter, and dismissable via Escape (FR-010).
- [x] T009 Run final `pnpm check` to verify the complete feature passes all quality gates.
---
## Dependencies & Execution Order
### Phase Dependencies
- **User Story 1 (Phase 3)**: No prerequisites — can start immediately
- **User Story 2 (Phase 4)**: No dependency on US1 — can start in parallel
- **Polish (Phase 5)**: Depends on both US1 and US2 being complete
### User Story Dependencies
- **User Story 1 (P1)**: Independent — modifies HP section of combatant row
- **User Story 2 (P2)**: Independent — modifies AC section of combatant row
### Within Each User Story
- HpAdjustPopover component (T001) must be created before integrating into combatant row (T002)
- QuickHpInput removal (T003, T004) must follow popover integration (T002)
- AC refactor (T006) is a single self-contained change
### Parallel Opportunities
- US1 (T001-T005) and US2 (T006-T007) can run in parallel since they modify different sections of the combatant row
- Within US1: T001 (new component) can be written before T002-T004 (integration + cleanup)
---
## Parallel Example: User Stories 1 & 2
```bash
# These two stories can run in parallel (different component sections):
# Developer A: User Story 1 — HP popover
Task: T001 "Create HpAdjustPopover component"
Task: T002 "Replace CurrentHpInput with clickable display + popover"
Task: T003 "Remove QuickHpInput usage from combatant row"
Task: T004 "Delete quick-hp-input.tsx"
Task: T005 "Run pnpm check"
# Developer B: User Story 2 — AC click-to-edit
Task: T006 "Refactor AcInput to click-to-edit pattern"
Task: T007 "Run pnpm check"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 3: User Story 1 (HP popover) — T001 through T005
2. **STOP and VALIDATE**: Test HP popover independently
3. This alone delivers the biggest declutter win (removes 3 always-visible controls per row)
### Incremental Delivery
1. Add User Story 1 → Test independently → Commit (MVP!)
2. Add User Story 2 → Test independently → Commit
3. Polish phase → Final grid adjustments → Commit
4. Each story adds declutter value without breaking previous work
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Domain and application layers are untouched — all changes in `apps/web/src/components/`
- Callback signatures (`onAdjustHp`, `onSetAc`) remain unchanged
- Commit after each completed user story