Add min/max hit point variants for D&D creatures

A D&D stat block opened for a combatant now offers a Min/Avg/Max toggle
below the Hit Points line, deriving both ends from the Hit Dice formula
(3d6 + 6 -> 9 / 16 / 24). The switch is applied as a delta, so manual HP
edits and damage already taken survive it. Unlike the PF2e weak/elite
adjustment this is a convenience tool, not a rules mechanic: nothing but
HP changes and the combatant is not renamed. The toggle is hidden when
the HP field carries prose instead of a dice pool.

Also extracts a SegmentedControl primitive. Game system, theme and the
PF2e weak/elite toggle were three hand-rolled copies of the same markup,
which is why the D&D toggle first came out chunkier and in the wrong
accent color. All four now share one definition, and segments expose
aria-pressed instead of signalling the active state by color alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-08-05 17:06:01 +02:00
co-authored by Claude Opus 5
parent 7b599e9ad9
commit be3778a0c9
22 changed files with 942 additions and 88 deletions
@@ -0,0 +1,59 @@
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.js";
export interface SegmentedOption<T> {
readonly value: T;
readonly label: ReactNode;
}
interface SegmentedControlProps<T> {
options: readonly SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
/** "sm" for standalone controls, "xs" for controls inline in a stat block. */
size?: "sm" | "xs";
/** Segments share the full width of the row instead of hugging their label. */
stretch?: boolean;
label?: string;
className?: string;
}
/**
* A row of mutually exclusive options where exactly one is active — game
* system, theme, PF2e adjustment, D&D hit point variant.
*/
export function SegmentedControl<T>({
options,
value,
onChange,
size = "sm",
stretch = false,
label,
className,
}: Readonly<SegmentedControlProps<T>>) {
return (
<fieldset aria-label={label} className={cn("flex gap-1", className)}>
{options.map((option) => {
const isActive = option.value === value;
return (
<button
key={String(option.value)}
type="button"
aria-pressed={isActive}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors",
size === "xs" ? "px-2 py-0.5 text-xs" : "px-3 py-1.5 text-sm",
stretch ? "flex-1" : "",
isActive
? "bg-accent text-primary-foreground"
: "bg-card text-muted-foreground hover:bg-hover-neutral-bg hover:text-foreground",
)}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
);
})}
</fieldset>
);
}