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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user