Restyle HP display as compact rounded pill

Group current HP, temp HP, and max HP into a single bordered
pill container with a subtle slash separator. Removes the
scattered layout with separate elements and gaps. Temp HP +N
only renders when present (no invisible spacer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-23 13:11:28 +01:00
parent 8bf69fd47d
commit 9cdf004c15
3 changed files with 41 additions and 78 deletions

View File

@@ -143,7 +143,6 @@ describe("App integration", () => {
await addCombatant(user, "Ogre", { maxHp: "59" }); await addCombatant(user, "Ogre", { maxHp: "59" });
// Verify HP displays — currentHp and maxHp both show "59" // Verify HP displays — currentHp and maxHp both show "59"
expect(screen.getByText("/")).toBeInTheDocument();
const hpButton = screen.getByRole("button", { const hpButton = screen.getByRole("button", {
name: "Current HP: 59 (healthy)", name: "Current HP: 59 (healthy)",
}); });

View File

@@ -12,9 +12,8 @@ import { PLAYER_COLOR_HEX } from "../player-icon-map.js";
const TEMP_HP_REGEX = /^\+\d/; const TEMP_HP_REGEX = /^\+\d/;
// Mock persistence — no localStorage interaction // Mock persistence — no localStorage interaction
const mockLoadEncounter = vi.fn<() => unknown>(() => null);
vi.mock("../../persistence/encounter-storage.js", () => ({ vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => mockLoadEncounter(), loadEncounter: () => null,
saveEncounter: () => {}, saveEncounter: () => {},
})); }));
@@ -126,14 +125,14 @@ describe("CombatantRow", () => {
expect(nameContainer).not.toBeNull(); expect(nameContainer).not.toBeNull();
}); });
it("shows '--' for current HP when no maxHp is set", () => { it("shows 'Max' placeholder when no maxHp is set", () => {
renderRow({ renderRow({
combatant: { combatant: {
id: combatantId("1"), id: combatantId("1"),
name: "Goblin", name: "Goblin",
}, },
}); });
expect(screen.getByLabelText("No HP set")).toBeInTheDocument(); expect(screen.getByText("Max")).toBeInTheDocument();
}); });
it("shows concentration icon when isConcentrating is true", () => { it("shows concentration icon when isConcentrating is true", () => {
@@ -245,11 +244,6 @@ describe("CombatantRow", () => {
tempHp: 8, tempHp: 8,
isConcentrating: true, isConcentrating: true,
}; };
mockLoadEncounter.mockReturnValueOnce({
combatants: [combatant],
activeIndex: 0,
roundNumber: 1,
});
const { rerender, container } = renderRow({ combatant }); const { rerender, container } = renderRow({ combatant });
// Temp HP absorbs all damage, currentHp unchanged // Temp HP absorbs all damage, currentHp unchanged
rerender( rerender(
@@ -265,20 +259,15 @@ describe("CombatantRow", () => {
describe("temp HP display", () => { describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => { it("shows +N when combatant has temp HP", () => {
const combatant = { renderRow({
id: combatantId("1"), combatant: {
name: "Goblin", id: combatantId("1"),
maxHp: 20, name: "Goblin",
currentHp: 15, maxHp: 20,
tempHp: 5, currentHp: 15,
}; tempHp: 5,
// Provide encounter with tempHp so hasTempHp is true },
mockLoadEncounter.mockReturnValueOnce({
combatants: [combatant],
activeIndex: 0,
roundNumber: 1,
}); });
renderRow({ combatant });
expect(screen.getByText("+5")).toBeInTheDocument(); expect(screen.getByText("+5")).toBeInTheDocument();
}); });
@@ -295,19 +284,15 @@ describe("CombatantRow", () => {
}); });
it("temp HP display uses cyan color", () => { it("temp HP display uses cyan color", () => {
const combatant = { renderRow({
id: combatantId("1"), combatant: {
name: "Goblin", id: combatantId("1"),
maxHp: 20, name: "Goblin",
currentHp: 15, maxHp: 20,
tempHp: 8, currentHp: 15,
}; tempHp: 8,
mockLoadEncounter.mockReturnValueOnce({ },
combatants: [combatant],
activeIndex: 0,
roundNumber: 1,
}); });
renderRow({ combatant });
const tempHpEl = screen.getByText("+8"); const tempHpEl = screen.getByText("+8");
expect(tempHpEl.className).toContain("text-cyan-400"); expect(tempHpEl.className).toContain("text-cyan-400");
}); });

View File

@@ -172,7 +172,12 @@ function MaxHpDisplay({
<button <button
type="button" type="button"
onClick={startEditing} onClick={startEditing}
className="inline-block h-7 min-w-[3ch] text-center text-muted-foreground text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral" className={cn(
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
maxHp === undefined
? "text-muted-foreground text-sm"
: "text-muted-foreground text-xs",
)}
> >
{maxHp ?? "Max"} {maxHp ?? "Max"}
</button> </button>
@@ -183,35 +188,20 @@ function ClickableHp({
currentHp, currentHp,
maxHp, maxHp,
tempHp, tempHp,
hasTempHp,
onAdjust, onAdjust,
onSetTempHp, onSetTempHp,
dimmed,
}: Readonly<{ }: Readonly<{
currentHp: number | undefined; currentHp: number | undefined;
maxHp: number | undefined; maxHp: number | undefined;
tempHp: number | undefined; tempHp: number | undefined;
hasTempHp: boolean;
onAdjust: (delta: number) => void; onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void; onSetTempHp: (value: number) => void;
dimmed?: boolean;
}>) { }>) {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp); const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) { if (maxHp === undefined) {
return ( return null;
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-muted-foreground text-sm tabular-nums leading-7",
dimmed && "opacity-50",
)}
role="status"
aria-label="No HP set"
>
--
</span>
);
} }
return ( return (
@@ -221,24 +211,17 @@ function ClickableHp({
onClick={() => setPopoverOpen(true)} onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`} aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
className={cn( className={cn(
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm tabular-nums leading-7 transition-colors hover:text-hover-neutral", "inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
status === "bloodied" && "text-amber-400", status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400", status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground", status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)} )}
> >
{currentHp} {currentHp}
</button> </button>
{!!hasTempHp && ( {!!tempHp && (
<span <span className="font-medium text-cyan-400 text-sm leading-7">
className={cn( +{tempHp}
"inline-block min-w-[3ch] text-center text-sm tabular-nums leading-7",
tempHp ? "font-medium text-cyan-400" : "invisible",
dimmed && "opacity-50",
)}
>
{tempHp ? `+${tempHp}` : ""}
</span> </span>
)} )}
{!!popoverOpen && ( {!!popoverOpen && (
@@ -463,7 +446,6 @@ export function CombatantRow({
setHp, setHp,
adjustHp, adjustHp,
setTempHp, setTempHp,
hasTempHp,
setAc, setAc,
toggleCondition, toggleCondition,
toggleConcentration, toggleConcentration,
@@ -615,29 +597,26 @@ export function CombatantRow({
</div> </div>
{/* HP */} {/* HP */}
<div className="flex items-center gap-1"> <div
className={cn(
"flex items-center rounded-md tabular-nums",
maxHp === undefined
? ""
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
dimmed && "opacity-50",
)}
>
<ClickableHp <ClickableHp
currentHp={currentHp} currentHp={currentHp}
maxHp={maxHp} maxHp={maxHp}
tempHp={combatant.tempHp} tempHp={combatant.tempHp}
hasTempHp={hasTempHp}
onAdjust={(delta) => adjustHp(id, delta)} onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)} onSetTempHp={(value) => setTempHp(id, value)}
dimmed={dimmed}
/> />
{maxHp !== undefined && ( {maxHp !== undefined && (
<span <span className="text-muted-foreground/50 text-xs">/</span>
className={cn(
"text-muted-foreground text-sm tabular-nums",
dimmed && "opacity-50",
)}
>
/
</span>
)} )}
<div className={cn(dimmed && "opacity-50")}> <MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div>
</div> </div>
{/* Actions */} {/* Actions */}