Files
initiative/apps/web/src/components/hp-adjust-popover.tsx
Lukas f4a7b53393 Restyle dark theme with blue-tinted palette, card glow, and rounded surfaces
Shift the dark theme from neutral gray to a richer blue-tinted palette
inspired by CharBuilder-style TTRPG apps. Deeper navy background, steel-blue
card surfaces, and visible blue borders create more depth and visual layering.

- Update design tokens: background, card, border, input, muted colors
- Add card-glow utility (radial gradient + blue box-shadow) for card surfaces
- Add panel-glow utility (top-down gradient) for tall panels like stat blocks
- Apply glow and rounded-lg to all card surfaces, dropdowns, dialogs, toasts
- Give outline buttons a subtle fill instead of transparent background
- Active combatant row now uses full border with glow instead of left accent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 13:39:44 +01:00

137 lines
3.6 KiB
TypeScript

import { Heart, Sword } from "lucide-react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { Input } from "./ui/input";
const DIGITS_ONLY_REGEX = /^\d+$/;
interface HpAdjustPopoverProps {
readonly onAdjust: (delta: number) => void;
readonly onClose: () => void;
}
export function HpAdjustPopover({ onAdjust, onClose }: HpAdjustPopoverProps) {
const [inputValue, setInputValue] = useState("");
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const parent = el.parentElement;
if (!parent) return;
const trigger = parent.getBoundingClientRect();
const popover = el.getBoundingClientRect();
const vw = document.documentElement.clientWidth;
let left = trigger.left;
if (left + popover.width > vw) {
left = vw - popover.width - 8;
}
if (left < 8) {
left = 8;
}
setPos({ top: trigger.bottom + 4, left });
}, []);
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const parsedValue =
inputValue === "" ? null : Number.parseInt(inputValue, 10);
const isValid =
parsedValue !== null && !Number.isNaN(parsedValue) && parsedValue > 0;
const applyDelta = useCallback(
(sign: -1 | 1) => {
if (inputValue === "") return;
const n = Number.parseInt(inputValue, 10);
if (Number.isNaN(n) || n <= 0) return;
onAdjust(sign * n);
onClose();
},
[inputValue, onAdjust, onClose],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
applyDelta(1);
} else {
applyDelta(-1);
}
} else if (e.key === "Escape") {
onClose();
}
},
[applyDelta, onClose],
);
return (
<div
ref={ref}
className="card-glow fixed z-10 rounded-lg border border-border bg-background p-2"
style={
pos
? { top: pos.top, left: pos.left }
: { visibility: "hidden" as const }
}
>
<div className="flex items-center gap-1">
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={inputValue}
placeholder="HP"
className="h-7 w-[7ch] text-center text-sm tabular-nums"
onChange={(e) => {
const v = e.target.value;
if (v === "" || DIGITS_ONLY_REGEX.test(v)) {
setInputValue(v);
}
}}
onKeyDown={handleKeyDown}
/>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-red-400 transition-colors hover:bg-red-950 hover:text-red-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(-1)}
title="Apply damage"
aria-label="Apply damage"
>
<Sword size={14} />
</button>
<button
type="button"
disabled={!isValid}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-emerald-400 transition-colors hover:bg-emerald-950 hover:text-emerald-300 disabled:pointer-events-none disabled:opacity-50"
onClick={() => applyDelta(1)}
title="Apply healing"
aria-label="Apply healing"
>
<Heart size={14} />
</button>
</div>
</div>
);
}