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" });
// Verify HP displays — currentHp and maxHp both show "59"
expect(screen.getByText("/")).toBeInTheDocument();
const hpButton = screen.getByRole("button", {
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/;
// Mock persistence — no localStorage interaction
const mockLoadEncounter = vi.fn<() => unknown>(() => null);
vi.mock("../../persistence/encounter-storage.js", () => ({
loadEncounter: () => mockLoadEncounter(),
loadEncounter: () => null,
saveEncounter: () => {},
}));
@@ -126,14 +125,14 @@ describe("CombatantRow", () => {
expect(nameContainer).not.toBeNull();
});
it("shows '--' for current HP when no maxHp is set", () => {
it("shows 'Max' placeholder when no maxHp is set", () => {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
},
});
expect(screen.getByLabelText("No HP set")).toBeInTheDocument();
expect(screen.getByText("Max")).toBeInTheDocument();
});
it("shows concentration icon when isConcentrating is true", () => {
@@ -245,11 +244,6 @@ describe("CombatantRow", () => {
tempHp: 8,
isConcentrating: true,
};
mockLoadEncounter.mockReturnValueOnce({
combatants: [combatant],
activeIndex: 0,
roundNumber: 1,
});
const { rerender, container } = renderRow({ combatant });
// Temp HP absorbs all damage, currentHp unchanged
rerender(
@@ -265,20 +259,15 @@ describe("CombatantRow", () => {
describe("temp HP display", () => {
it("shows +N when combatant has temp HP", () => {
const combatant = {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
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();
});
@@ -295,19 +284,15 @@ describe("CombatantRow", () => {
});
it("temp HP display uses cyan color", () => {
const combatant = {
renderRow({
combatant: {
id: combatantId("1"),
name: "Goblin",
maxHp: 20,
currentHp: 15,
tempHp: 8,
};
mockLoadEncounter.mockReturnValueOnce({
combatants: [combatant],
activeIndex: 0,
roundNumber: 1,
},
});
renderRow({ combatant });
const tempHpEl = screen.getByText("+8");
expect(tempHpEl.className).toContain("text-cyan-400");
});

View File

@@ -172,7 +172,12 @@ function MaxHpDisplay({
<button
type="button"
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"}
</button>
@@ -183,35 +188,20 @@ function ClickableHp({
currentHp,
maxHp,
tempHp,
hasTempHp,
onAdjust,
onSetTempHp,
dimmed,
}: Readonly<{
currentHp: number | undefined;
maxHp: number | undefined;
tempHp: number | undefined;
hasTempHp: boolean;
onAdjust: (delta: number) => void;
onSetTempHp: (value: number) => void;
dimmed?: boolean;
}>) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) {
return (
<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 null;
}
return (
@@ -221,24 +211,17 @@ function ClickableHp({
onClick={() => setPopoverOpen(true)}
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
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 === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)}
>
{currentHp}
</button>
{!!hasTempHp && (
<span
className={cn(
"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}` : ""}
{!!tempHp && (
<span className="font-medium text-cyan-400 text-sm leading-7">
+{tempHp}
</span>
)}
{!!popoverOpen && (
@@ -463,7 +446,6 @@ export function CombatantRow({
setHp,
adjustHp,
setTempHp,
hasTempHp,
setAc,
toggleCondition,
toggleConcentration,
@@ -615,30 +597,27 @@ export function CombatantRow({
</div>
{/* 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
currentHp={currentHp}
maxHp={maxHp}
tempHp={combatant.tempHp}
hasTempHp={hasTempHp}
onAdjust={(delta) => adjustHp(id, delta)}
onSetTempHp={(value) => setTempHp(id, value)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
<span
className={cn(
"text-muted-foreground text-sm tabular-nums",
dimmed && "opacity-50",
<span className="text-muted-foreground/50 text-xs">/</span>
)}
>
/
</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
</div>
</div>
{/* Actions */}
<ConfirmButton