Implement the 027-ui-polish feature that adds six combatant row improvements (inline conditions, row-click stat block, hover-only remove button, AC shield shape, expanded concentration click target, larger d20 icon) plus top bar redesign with icon-only StepBack/StepForward navigation buttons, dark-themed scrollbars, and multiple UX fixes including stat block panel stability during initiative rolls and mobile touch safety for hidden buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 19:00:49 +01:00
parent d5f7b6ee36
commit f029c1a85b
14 changed files with 811 additions and 120 deletions

View File

@@ -3,9 +3,10 @@ import {
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { BookOpen, Brain, Shield, X } from "lucide-react";
import { Brain, X } from "lucide-react";
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { AcShield } from "./ac-shield";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
@@ -86,7 +87,10 @@ function EditableName({
return (
<button
type="button"
onClick={startEditing}
onClick={(e) => {
e.stopPropagation();
startEditing();
}}
className="truncate text-left text-sm text-foreground hover:text-primary transition-colors"
>
{name}
@@ -236,36 +240,24 @@ function AcDisplay({
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}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
</div>
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
return (
<button
type="button"
onClick={startEditing}
className="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>
);
return <AcShield value={ac} onClick={startEditing} />;
}
function InitiativeDisplay({
@@ -338,7 +330,7 @@ function InitiativeDisplay({
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-5 w-5" />
<D20Icon className="h-7 w-7" />
</button>
);
}
@@ -410,27 +402,34 @@ export function CombatantRow({
}, [combatant.isConcentrating]);
return (
/* biome-ignore lint/a11y/useKeyWithClickEvents: row click opens stat block */
/* biome-ignore lint/a11y/noStaticElementInteractions: row click opens stat block */
<div
ref={ref}
className={cn(
"group rounded-md px-3 py-2 transition-colors",
"group rounded-md pr-3 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
isPulsing && "animate-concentration-pulse",
onShowStatBlock && "cursor-pointer",
)}
onClick={onShowStatBlock}
>
<div className="grid grid-cols-[1.25rem_3rem_1fr_auto_auto_2rem] items-center gap-3">
<div className="grid grid-cols-[2rem_3rem_1fr_auto_auto_2rem] items-center gap-3 py-2">
{/* Concentration */}
<button
type="button"
onClick={() => onToggleConcentration(id)}
onClick={(e) => {
e.stopPropagation();
onToggleConcentration(id);
}}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex items-center justify-center transition-opacity",
"flex w-full items-center justify-center self-stretch -my-2 -ml-[2px] pl-[14px] transition-opacity",
combatant.isConcentrating
? dimmed
? "opacity-50 text-purple-400"
@@ -442,37 +441,59 @@ export function CombatantRow({
</button>
{/* Initiative */}
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div onClick={(e) => e.stopPropagation()}>
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
</div>
{/* Name */}
<div className={cn("flex items-center gap-1", dimmed && "opacity-50")}>
<EditableName name={name} combatantId={id} onRename={onRename} />
{onShowStatBlock && (
<button
type="button"
onClick={onShowStatBlock}
className="text-muted-foreground hover:text-amber-400 transition-colors"
title="View stat block"
aria-label="View stat block"
>
<BookOpen size={14} />
</button>
{/* Name + Conditions */}
<div
className={cn(
"relative flex flex-wrap items-center gap-1 min-w-0",
dimmed && "opacity-50",
)}
>
<span className="min-w-0 truncate">
<EditableName name={name} combatantId={id} onRename={onRename} />
</span>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
{pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div
className={cn(dimmed && "opacity-50")}
onClick={(e) => e.stopPropagation()}
>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
<div className="flex items-center gap-1">
{/* biome-ignore lint/a11y/useKeyWithClickEvents: stopPropagation wrapper */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: stopPropagation wrapper */}
<div
className="flex items-center gap-1"
onClick={(e) => e.stopPropagation()}
>
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
@@ -498,35 +519,17 @@ export function CombatantRow({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 text-muted-foreground hover:text-destructive",
dimmed && "opacity-50",
)}
onClick={() => onRemove(id)}
className="h-7 w-7 text-muted-foreground hover:text-destructive opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus:opacity-100 focus:pointer-events-auto transition-opacity"
onClick={(e) => {
e.stopPropagation();
onRemove(id);
}}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
</div>
{/* Conditions */}
<div className="relative ml-[calc(3rem+0.75rem)]">
<div className={cn(dimmed && "opacity-50")}>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
{pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
</div>
);
}