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

@@ -93,8 +93,12 @@ export function App() {
}, [encounter.activeIndex]);
// Auto-show stat block for the active combatant when turn changes,
// but only when the viewport is wide enough to show it alongside the tracker
// but only when the viewport is wide enough to show it alongside the tracker.
// Only react to activeIndex changes — not combatant reordering (e.g. Roll All).
const prevActiveIndexRef = useRef(encounter.activeIndex);
useEffect(() => {
if (prevActiveIndexRef.current === encounter.activeIndex) return;
prevActiveIndexRef.current = encounter.activeIndex;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
@@ -104,7 +108,7 @@ export function App() {
return (
<div className="flex h-screen flex-col">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 min-h-0">
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-4 min-h-0">
{/* Turn Navigation — fixed at top */}
<div className="shrink-0 pt-8">
<TurnNavigation
@@ -118,13 +122,7 @@ export function App() {
{/* Scrollable area — combatant list */}
<div className="flex-1 overflow-y-auto min-h-0">
<header className="space-y-1 mb-6">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
</header>
<div className="flex flex-col gap-1 pb-2">
<div className="flex flex-col pb-2">
{encounter.combatants.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No combatants yet add one to get started

View File

@@ -0,0 +1,37 @@
import { cn } from "../lib/utils";
interface AcShieldProps {
readonly value: number | undefined;
readonly onClick?: () => void;
readonly className?: string;
}
export function AcShield({ value, onClick, className }: AcShieldProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative inline-flex items-center justify-center text-sm tabular-nums text-muted-foreground transition-colors hover:text-primary",
className,
)}
style={{ width: 28, height: 32 }}
>
<svg
viewBox="0 0 28 32"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<path d="M14 1.5 L2.5 6.5 L2.5 15 Q2.5 25 14 30.5 Q25.5 25 25.5 15 L25.5 6.5 Z" />
</svg>
<span className="relative text-xs font-medium leading-none">
{value !== undefined ? value : "\u2014"}
</span>
</button>
);
}

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>
);
}

View File

@@ -76,7 +76,10 @@ export function ConditionTags({
title={def.label}
aria-label={`Remove ${def.label}`}
className={`inline-flex items-center rounded p-0.5 hover:bg-card transition-colors ${colorClass}`}
onClick={() => onRemove(condId)}
onClick={(e) => {
e.stopPropagation();
onRemove(condId);
}}
>
<Icon size={14} />
</button>
@@ -86,8 +89,11 @@ export function ConditionTags({
type="button"
title="Add condition"
aria-label="Add condition"
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-card transition-colors"
onClick={onOpenPicker}
className="inline-flex items-center rounded p-0.5 text-muted-foreground hover:text-foreground hover:bg-card transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onOpenPicker();
}}
>
<Plus size={14} />
</button>

View File

@@ -1,5 +1,5 @@
import type { Encounter } from "@initiative/domain";
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import { StepBack, StepForward, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
@@ -26,13 +26,14 @@ export function TurnNavigation({
<div className="flex items-center justify-between rounded-md border border-border bg-card px-4 py-3">
<Button
variant="outline"
size="sm"
className="hover:bg-muted"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-primary hover:border-primary hover:bg-muted"
onClick={onRetreatTurn}
disabled={!hasCombatants || isAtStart}
title="Previous turn"
aria-label="Previous turn"
>
<ChevronLeft className="h-4 w-4" />
Previous
<StepBack className="h-5 w-5" />
</Button>
<div className="text-center text-sm">
@@ -49,35 +50,38 @@ export function TurnNavigation({
)}
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-3">
<div className="flex items-center gap-0">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-6 w-6" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={onClearEncounter}
disabled={!hasCombatants}
>
<Trash2 className="h-5 w-5" />
</Button>
</div>
<Button
variant="outline"
size="sm"
className="hover:bg-muted"
size="icon"
className="h-8 w-8 text-foreground border-foreground hover:text-primary hover:border-primary hover:bg-muted"
onClick={onAdvanceTurn}
disabled={!hasCombatants}
title="Next turn"
aria-label="Next turn"
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={onClearEncounter}
disabled={!hasCombatants}
>
<Trash2 className="h-4 w-4" />
<StepForward className="h-5 w-5" />
</Button>
</div>
</div>

View File

@@ -68,6 +68,11 @@
concentration-glow 1200ms ease-out;
}
* {
scrollbar-color: var(--color-border) transparent;
scrollbar-width: thin;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);