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:
@@ -69,6 +69,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
|||||||
- N/A (no storage changes) (015-add-jscpd-gate)
|
- N/A (no storage changes) (015-add-jscpd-gate)
|
||||||
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
|
- 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)
|
- 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
|
## 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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { ConditionPicker } from "./condition-picker";
|
import { ConditionPicker } from "./condition-picker";
|
||||||
import { ConditionTags } from "./condition-tags";
|
import { ConditionTags } from "./condition-tags";
|
||||||
import { QuickHpInput } from "./quick-hp-input";
|
import { HpAdjustPopover } from "./hp-adjust-popover";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
@@ -91,36 +91,39 @@ function EditableName({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MaxHpInput({
|
function MaxHpDisplay({
|
||||||
maxHp,
|
maxHp,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: {
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
||||||
const prev = useRef(maxHp);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
if (maxHp !== prev.current) {
|
|
||||||
prev.current = maxHp;
|
|
||||||
setDraft(maxHp?.toString() ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
if (draft === "") {
|
if (draft === "") {
|
||||||
onCommit(undefined);
|
onCommit(undefined);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
const n = Number.parseInt(draft, 10);
|
const n = Number.parseInt(draft, 10);
|
||||||
if (!Number.isNaN(n) && n >= 1) {
|
if (!Number.isNaN(n) && n >= 1) {
|
||||||
onCommit(n);
|
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 (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -130,93 +133,102 @@ function MaxHpInput({
|
|||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") commit();
|
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,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
onCommit,
|
onAdjust,
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
currentHp: number | undefined;
|
currentHp: number | undefined;
|
||||||
maxHp: number | undefined;
|
maxHp: number | undefined;
|
||||||
onCommit: (value: number) => void;
|
onAdjust: (delta: number) => void;
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const [draft, setDraft] = useState(currentHp?.toString() ?? "");
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const prev = useRef(currentHp);
|
const status = deriveHpStatus(currentHp, maxHp);
|
||||||
|
|
||||||
if (currentHp !== prev.current) {
|
if (maxHp === undefined) {
|
||||||
prev.current = currentHp;
|
return (
|
||||||
setDraft(currentHp?.toString() ?? "");
|
<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 (
|
return (
|
||||||
<Input
|
<div className="relative">
|
||||||
type="text"
|
<button
|
||||||
inputMode="numeric"
|
type="button"
|
||||||
value={draft}
|
onClick={() => setPopoverOpen(true)}
|
||||||
placeholder="HP"
|
className={cn(
|
||||||
disabled={maxHp === undefined}
|
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-primary",
|
||||||
className={cn("h-7 w-[7ch] text-center text-sm tabular-nums", className)}
|
status === "bloodied" && "text-amber-400",
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
status === "unconscious" && "text-red-400",
|
||||||
onBlur={commit}
|
status === "healthy" && "text-foreground",
|
||||||
onKeyDown={(e) => {
|
)}
|
||||||
if (e.key === "Enter") commit();
|
>
|
||||||
}}
|
{currentHp}
|
||||||
|
</button>
|
||||||
|
{popoverOpen && (
|
||||||
|
<HpAdjustPopover
|
||||||
|
onAdjust={onAdjust}
|
||||||
|
onClose={() => setPopoverOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AcInput({
|
function AcDisplay({
|
||||||
ac,
|
ac,
|
||||||
onCommit,
|
onCommit,
|
||||||
}: {
|
}: {
|
||||||
ac: number | undefined;
|
ac: number | undefined;
|
||||||
onCommit: (value: number | undefined) => void;
|
onCommit: (value: number | undefined) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||||
const prev = useRef(ac);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
if (ac !== prev.current) {
|
|
||||||
prev.current = ac;
|
|
||||||
setDraft(ac?.toString() ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const commit = useCallback(() => {
|
const commit = useCallback(() => {
|
||||||
if (draft === "") {
|
if (draft === "") {
|
||||||
onCommit(undefined);
|
onCommit(undefined);
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
const n = Number.parseInt(draft, 10);
|
const n = Number.parseInt(draft, 10);
|
||||||
if (!Number.isNaN(n) && n >= 0) {
|
if (!Number.isNaN(n) && n >= 0) {
|
||||||
onCommit(n);
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Shield size={14} className="text-muted-foreground" />
|
<Shield size={14} className="text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={draft}
|
value={draft}
|
||||||
@@ -226,10 +238,23 @@ function AcInput({
|
|||||||
onBlur={commit}
|
onBlur={commit}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") commit();
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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({
|
export function CombatantRow({
|
||||||
@@ -329,32 +354,21 @@ export function CombatantRow({
|
|||||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||||
|
|
||||||
{/* AC */}
|
{/* AC */}
|
||||||
<AcInput ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||||
|
|
||||||
{/* HP */}
|
{/* HP */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<CurrentHpInput
|
<ClickableHp
|
||||||
currentHp={currentHp}
|
currentHp={currentHp}
|
||||||
maxHp={maxHp}
|
maxHp={maxHp}
|
||||||
className={cn(
|
onAdjust={(delta) => onAdjustHp(id, delta)}
|
||||||
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);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{maxHp !== undefined && (
|
{maxHp !== undefined && (
|
||||||
<span className="text-sm tabular-nums text-muted-foreground">
|
<span className="text-sm tabular-nums text-muted-foreground">
|
||||||
/
|
/
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<MaxHpInput maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
|
||||||
{maxHp !== undefined && (
|
|
||||||
<QuickHpInput combatantId={id} onAdjustHp={onAdjustHp} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
109
apps/web/src/components/hp-adjust-popover.tsx
Normal file
109
apps/web/src/components/hp-adjust-popover.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
34
specs/019-combatant-row-declutter/checklists/requirements.md
Normal file
34
specs/019-combatant-row-declutter/checklists/requirements.md
Normal 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`.
|
||||||
29
specs/019-combatant-row-declutter/data-model.md
Normal file
29
specs/019-combatant-row-declutter/data-model.md
Normal 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).
|
||||||
75
specs/019-combatant-row-declutter/plan.md
Normal file
75
specs/019-combatant-row-declutter/plan.md
Normal 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.
|
||||||
44
specs/019-combatant-row-declutter/quickstart.md
Normal file
44
specs/019-combatant-row-declutter/quickstart.md
Normal 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
|
||||||
42
specs/019-combatant-row-declutter/research.md
Normal file
42
specs/019-combatant-row-declutter/research.md
Normal 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.
|
||||||
113
specs/019-combatant-row-declutter/spec.md
Normal file
113
specs/019-combatant-row-declutter/spec.md
Normal 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.
|
||||||
154
specs/019-combatant-row-declutter/tasks.md
Normal file
154
specs/019-combatant-row-declutter/tasks.md
Normal 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
|
||||||
Reference in New Issue
Block a user