feat(ui): per-instance modifier panel

- Export LayoutBoardView from LayoutEditor.tsx (reusable board display)
- Create PerInstancePanel.tsx: 8x8 board + per-square modifier list/form
- Wire PerInstancePanel into ModifierProfileEditor center panel
- Add bound-layout-picker select in editor header (uses LAYOUT_REGISTRY)
- 2 new e2e scenarios: no-layout prompt, attach modifier to b1 square
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:56:31 -06:00
commit 28e03d06fa
No known key found for this signature in database
4 changed files with 766 additions and 36 deletions

View file

@ -52,4 +52,92 @@ test.describe('Modifier Profiles', () => {
page.locator('[data-testid="modifier-editor-modal"]'),
).not.toBeVisible({ timeout: 500 });
});
test('add per-type HP modifier shows in row list', async ({ page }) => {
// Open the modifier editor.
await page.click('[data-testid="open-modifier-editor"]');
await expect(
page.locator('[data-testid="modifier-editor-modal"]'),
).toBeVisible();
// Open the inline add form.
await page.click('[data-testid="add-type-modifier"]');
// Fill in: piece type = knight, color = white, kind = hp-bonus, value = 2.
await page.selectOption('[data-testid="piece-type-select"]', 'knight');
await page.selectOption('[data-testid="color-select"]', 'white');
await page.selectOption('[data-testid="kind-select"]', 'hp-bonus');
await page.fill('[data-testid="value-input"]', '2');
// Save the modifier.
await page.click('[data-testid="save-type-modifier"]');
// The row must appear and display the described value.
await expect(
page.locator('[data-testid="type-modifier-row"]'),
).toBeVisible();
await expect(
page.locator('[data-testid="type-modifier-row"]'),
).toContainText('HP +2');
});
test('invalid value disables save button', async ({ page }) => {
// Open the modifier editor.
await page.click('[data-testid="open-modifier-editor"]');
await expect(
page.locator('[data-testid="modifier-editor-modal"]'),
).toBeVisible();
// Open the inline add form.
await page.click('[data-testid="add-type-modifier"]');
// Select range-bonus (max = 7) and enter 100 — outside valid range.
await page.selectOption('[data-testid="kind-select"]', 'range-bonus');
await page.fill('[data-testid="value-input"]', '100');
// Save button must be disabled since 100 > 7 fails Zod validation.
await expect(
page.locator('[data-testid="save-type-modifier"]'),
).toBeDisabled();
});
// ── T22: per-instance modifier panel ───────────────────────────────────────
test('no-layout state shows select-a-layout prompt', async ({ page }) => {
await page.click('[data-testid="open-modifier-editor"]');
await expect(
page.locator('[data-testid="modifier-editor-modal"]'),
).toBeVisible();
// With no layout bound, the center panel shows the empty-state prompt.
await expect(page.getByText('Select a layout first')).toBeVisible();
});
test('per-instance modifier attached to specific square', async ({ page }) => {
await page.click('[data-testid="open-modifier-editor"]');
await expect(
page.locator('[data-testid="modifier-editor-modal"]'),
).toBeVisible();
// Bind the Classic layout via the header picker.
await page.selectOption('[data-testid="bound-layout-picker"]', 'classic');
// The per-instance board should now be visible.
await expect(
page.locator('[data-testid="per-instance-board"]'),
).toBeVisible();
// Click the b1 square (white knight in the classic starting position).
await page.click('[data-testid="piece-square-b1"]');
// Add a range-bonus modifier with value 1.
await page.selectOption('[data-testid="instance-modifier-kind"]', 'range-bonus');
await page.fill('[data-testid="instance-modifier-value"]', '1');
await page.click('[data-testid="instance-modifier-add"]');
// The modifier row must appear in the selected-square list.
await expect(
page.locator('[data-testid="instance-modifier-b1-range-bonus"]'),
).toBeVisible();
});
});

View file

@ -678,6 +678,95 @@ function ValidationPanel({
);
}
/**
* Reusable read-only / interactive board view for piece layouts.
*
* Exported so that other panels (e.g. PerInstancePanel in the modifier
* profile editor) can display the same 8×8 grid without duplicating
* the rendering logic.
*
* - `selectedSquare` (algebraic, e.g. "b1") highlights that cell with a
* blue ring so the caller can indicate which square is "active".
* - `onSquareClick` is called with the algebraic notation of the clicked
* cell; if omitted the board is purely decorative (all cells disabled).
* - `testId` sets `data-testid` on the board wrapper div.
*/
export function LayoutBoardView({
pieces,
selectedSquare,
testId = 'layout-board-view',
onSquareClick,
}: {
pieces: readonly PiecePlacement[];
selectedSquare?: string | null;
testId?: string;
onSquareClick?: (algebraic: string) => void;
}) {
const placementBySquare = useMemo(() => {
const map = new Map<number, PiecePlacement>();
for (const p of pieces) map.set(p.square, p);
return map;
}, [pieces]);
const rows: ReactNode[] = [];
for (let rank = 7; rank >= 0; rank--) {
const cells: ReactNode[] = [];
for (let file = 0; file < 8; file++) {
const sq = rank * 8 + file;
const algebraic = squareToAlgebraic(sq);
const piece = placementBySquare.get(sq);
const isDark = (rank + file) % 2 === 0;
const isSelected = selectedSquare === algebraic;
const isOccupied = piece !== undefined;
const hasClick = onSquareClick !== undefined;
cells.push(
<button
key={sq}
data-testid={isOccupied ? `piece-square-${algebraic}` : undefined}
data-square={algebraic}
onClick={() => onSquareClick?.(algebraic)}
disabled={!hasClick}
className={`aspect-square flex items-center justify-center transition-colors ${
isSelected
? 'bg-blue-200 ring-2 ring-inset ring-blue-500'
: isDark
? 'bg-neutral-400'
: 'bg-neutral-100'
} ${
hasClick
? 'hover:brightness-110 cursor-pointer'
: 'cursor-default'
} disabled:cursor-default`}
>
{piece !== undefined && (
<img
src={pieceAssets[piece.color][piece.type]}
alt={`${piece.color} ${piece.type}`}
className="w-full h-full object-contain p-1"
draggable={false}
/>
)}
</button>,
);
}
rows.push(
<div key={rank} className="grid grid-cols-8">
{cells}
</div>,
);
}
return (
<div
data-testid={testId}
className="w-full max-w-lg aspect-square border-2 border-neutral-800 shadow-xl"
>
{rows}
</div>
);
}
function LibraryDrawer({
onLoad,
onDelete,

View file

@ -1,22 +1,62 @@
/**
* Modifier Profile Editor modal shell.
*
* Shell only: three placeholder panels filled by later tasks:
* T21 modifier catalog (left panel)
* T22 board preview (center panel)
* T23 profile list (right panel)
* Three panels:
* LEFT modifier catalog (T21 placeholder)
* CENTER board preview (T22 placeholder)
* RIGHT profile library: save, share, list of saved profiles (T23)
*
* Follows the same overlay/close pattern as LayoutEditor.tsx.
*/
import { useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess';
import {
deleteFromLibrary,
duplicateEntry,
loadLibrary,
makeId,
saveToLibrary,
setStarred,
type SavedModifierProfile,
} from '../modifiers/library';
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
import { PerInstancePanel } from './PerInstancePanel';
import { PerTypePanel } from './PerTypePanel.js';
interface Props {
isOpen: boolean;
onClose: () => void;
}
function makeBlankProfile(): ModifierProfile {
return {
id: makeId(),
name: 'My Profile',
description: '',
perType: [],
perInstance: [],
version: 1,
source: 'custom',
};
}
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
// Register Esc listener only while the modal is visible.
const [profile, setProfile] = useState<ModifierProfile>(makeBlankProfile);
const [libraryVersion, setLibraryVersion] = useState(0);
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
const [perInstance, setPerInstance] = useState<InstanceModifier[]>([]);
const [perType, setPerType] = useState<TypeModifier[]>([]);
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
// Fresh profile whenever the modal opens.
useEffect(() => {
if (isOpen) {
setProfile(makeBlankProfile());
}
}, [isOpen]);
// Esc closes the modal regardless of focus.
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
@ -28,6 +68,70 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
if (!isOpen) return null;
// ── Library actions ─────────────────────────────────────────────────
function handleSaveToLibrary() {
const entry: SavedModifierProfile = {
id: profile.id,
name: profile.name.trim() || 'Untitled Profile',
profile,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(entry);
if (!result.ok) {
toast.error(result.reason);
return;
}
toast.success(`Saved "${entry.name}" to library`);
setLibraryVersion((n) => n + 1);
}
function handleShareProfile() {
try {
const json = JSON.stringify(profile);
const b64 = btoa(json);
if (b64.length > 8 * 1024) {
toast.error('Profile too large for URL — save to library instead.');
return;
}
const url = new URL(window.location.href);
url.searchParams.set('modifierProfile', b64);
void navigator.clipboard
.writeText(url.toString())
.then(() => toast.success('Copied to clipboard'))
.catch(() =>
toast.error(
'Copy failed — share URL could not be written to clipboard',
),
);
} catch {
toast.error('Failed to generate share URL');
}
}
function handleLoadFromLibrary(entry: SavedModifierProfile) {
setProfile({ ...entry.profile });
toast.success(`Loaded "${entry.name}"`);
}
function handleDeleteFromLibrary(id: string) {
deleteFromLibrary(id);
setLibraryVersion((n) => n + 1);
}
function handleStarToggle(id: string, starred: boolean) {
setStarred(id, starred);
setLibraryVersion((n) => n + 1);
}
function handleDuplicate(id: string) {
duplicateEntry(id);
setLibraryVersion((n) => n + 1);
}
// ── Render ───────────────────────────────────────────────────────────
return (
<div
data-testid="modifier-editor-modal"
@ -36,45 +140,215 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
<h2 className="text-lg font-bold text-neutral-900">
Modifier Profiles
</h2>
<button
onClick={onClose}
aria-label="Close modifier editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
<div className="flex items-center gap-3 min-w-0 flex-wrap">
<h2 className="text-lg font-bold text-neutral-900 shrink-0">
Modifier Profiles
</h2>
<input
data-testid="profile-name"
type="text"
value={profile.name}
onChange={(e) =>
setProfile((p) => ({ ...p, name: e.target.value }))
}
placeholder="Profile name"
className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={40}
/>
{/* Bound layout for per-instance modifiers */}
<div className="flex items-center gap-1.5 shrink-0">
<label
htmlFor="bound-layout-picker-input"
className="text-xs font-bold text-neutral-500 uppercase tracking-widest whitespace-nowrap"
>
Layout:
</label>
<select
id="bound-layout-picker-input"
data-testid="bound-layout-picker"
value={boundLayout?.id ?? ''}
onChange={(e) => {
const id = e.target.value;
setBoundLayout(id ? (LAYOUT_REGISTRY.get(id) ?? null) : null);
}}
className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]"
>
<option value=""> none </option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="save-profile"
onClick={handleSaveToLibrary}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Save
</button>
<button
data-testid="share-profile"
onClick={handleShareProfile}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Share
</button>
<button
onClick={onClose}
aria-label="Close modifier editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
</div>
</header>
{/* Body — three placeholder panels, filled by T21 / T22 / T23 */}
{/* Body — three panels */}
<div className="flex-1 overflow-hidden flex">
<aside className="w-64 border-r border-neutral-200 bg-neutral-50 p-4">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest mb-2">
Modifiers
</p>
<p className="text-sm text-neutral-500 italic">
Modifier catalog (T21)
</p>
{/* LEFT — per-type modifier panel (T21) */}
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
<PerTypePanel
modifiers={perType}
onAdd={(m) => setPerType((prev) => [...prev, m])}
onDelete={(i) =>
setPerType((prev) => prev.filter((_, idx) => idx !== i))
}
/>
</aside>
<main className="flex-1 flex items-center justify-center p-6 bg-white">
<p className="text-sm text-neutral-500 italic">
Board preview (T22)
</p>
{/* CENTER — per-instance board preview (T22) */}
<main className="flex-1 flex overflow-hidden">
<PerInstancePanel
modifiers={perInstance}
boundLayout={boundLayout}
onLayoutSelect={() => {
document
.querySelector<HTMLSelectElement>(
'[data-testid="bound-layout-picker"]',
)
?.focus();
}}
onAdd={(m) => setPerInstance((prev) => [...prev, m])}
onDelete={(i) =>
setPerInstance((prev) => prev.filter((_, idx) => idx !== i))
}
/>
</main>
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest mb-2">
Profiles
</p>
<p className="text-sm text-neutral-500 italic">
Profile list (T23)
</p>
</aside>
{/* RIGHT — profile library (T23) */}
<ProfileLibraryPanel
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
/>
</div>
</div>
</div>
);
}
// ── ProfileLibraryPanel ──────────────────────────────────────────────────────
function ProfileLibraryPanel({
onLoad,
onDelete,
onStarToggle,
onDuplicate,
}: {
onLoad: (entry: SavedModifierProfile) => void;
onDelete: (id: string) => void;
onStarToggle: (id: string, starred: boolean) => void;
onDuplicate: (id: string) => void;
}) {
const entries = loadLibrary();
// Starred entries first, then most-recent within each group.
const sorted = [...entries].sort((a, b) => {
if (a.starred !== b.starred) return a.starred ? -1 : 1;
return b.updatedAt - a.updatedAt;
});
return (
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Profiles
</p>
</div>
<div className="flex-1 overflow-y-auto p-4">
{sorted.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No saved profiles yet. Click{' '}
<span className="font-semibold">Save</span> to add the current
profile to your library.
</p>
) : (
<ul className="space-y-2">
{sorted.map((entry) => (
<li
key={entry.id}
data-testid="profile-library-entry"
className="flex items-start gap-2 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300 transition-colors"
>
<button
onClick={() => onStarToggle(entry.id, !entry.starred)}
aria-label={entry.starred ? 'Unstar' : 'Star'}
className={
entry.starred
? 'text-amber-500 mt-0.5 shrink-0'
: 'text-neutral-300 mt-0.5 shrink-0'
}
>
</button>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-neutral-900 truncate">
{entry.name}
</div>
<div className="text-xs text-neutral-500 mt-0.5">
{entry.profile.perType.length +
entry.profile.perInstance.length}{' '}
modifier
{entry.profile.perType.length +
entry.profile.perInstance.length !==
1
? 's'
: ''}{' '}
· {new Date(entry.updatedAt).toLocaleDateString()}
</div>
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={() => onLoad(entry)}
className="px-2 py-0.5 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
>
Load
</button>
<button
onClick={() => onDuplicate(entry.id)}
className="px-2 py-0.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
>
Dupe
</button>
<button
onClick={() => onDelete(entry.id)}
aria-label="Delete"
className="px-2 py-0.5 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,279 @@
/**
* Per-Instance Modifier Panel (T22).
*
* Center panel of the Modifier Profile Editor. Displays an 8×8 board
* preview for the bound layout and lets the user attach per-instance
* modifiers to individual squares.
*
* Usage flow:
* 1. If no layout is bound prompt the user to choose one.
* 2. With a bound layout click a square with a piece to select it.
* 3. For the selected square view existing instance modifiers and
* add/delete them via the right-hand sidebar form.
*/
import { useState } from 'react';
import type { InstanceModifier, ModifierKindId } from '../modifiers/types';
import type { StartingLayout } from '../layouts/types';
import { squareToAlgebraic } from '../coord';
import { LayoutBoardView } from './LayoutEditor';
export interface PerInstancePanelProps {
modifiers: readonly InstanceModifier[];
boundLayout: StartingLayout | null;
/** Called when the user requests to open the layout picker. */
onLayoutSelect: () => void;
onAdd: (modifier: InstanceModifier) => void;
onDelete: (index: number) => void;
}
const MODIFIER_KINDS: ReadonlyArray<{ id: ModifierKindId; label: string }> = [
{ id: 'hp-bonus', label: 'HP Bonus' },
{ id: 'range-bonus', label: 'Range Bonus' },
{ id: 'direction-additions', label: 'Direction Additions' },
{ id: 'capture-flags', label: 'Capture Flags' },
{ id: 'promotion-override', label: 'Promotion Override' },
{ id: 'damage-resistance', label: 'Damage Resistance' },
];
export function PerInstancePanel({
modifiers,
boundLayout,
onLayoutSelect,
onAdd,
onDelete,
}: PerInstancePanelProps) {
const [selectedSquare, setSelectedSquare] = useState<string | null>(null);
// No layout bound — show the empty state prompt.
if (boundLayout === null) {
return (
<div
data-testid="per-instance-panel"
className="flex flex-1 flex-col items-center justify-center gap-4 p-8"
>
<p className="text-sm text-neutral-500 italic">Select a layout first</p>
<button
data-testid="per-instance-choose-layout"
onClick={onLayoutSelect}
className="px-4 py-2 text-sm font-semibold text-white bg-neutral-900 rounded hover:bg-neutral-800 transition-colors"
>
Choose Layout
</button>
</div>
);
}
// Collect modifiers for the currently selected square, preserving
// their original indices so onDelete can target the right entry.
const squareModifiers = modifiers
.map((modifier, index) => ({ modifier, index }))
.filter(({ modifier }) => modifier.square === selectedSquare);
const selectedPiece =
selectedSquare !== null
? boundLayout.pieces.find(
(p) => squareToAlgebraic(p.square) === selectedSquare,
)
: undefined;
function handleSquareClick(algebraic: string) {
const hasPiece = boundLayout!.pieces.some(
(p) => squareToAlgebraic(p.square) === algebraic,
);
if (hasPiece) setSelectedSquare(algebraic);
}
return (
<div data-testid="per-instance-panel" className="flex flex-1 overflow-hidden">
{/* Board preview */}
<div className="flex-1 flex items-center justify-center p-4 bg-white">
<LayoutBoardView
pieces={boundLayout.pieces}
selectedSquare={selectedSquare}
testId="per-instance-board"
onSquareClick={handleSquareClick}
/>
</div>
{/* Square detail sidebar */}
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
{selectedSquare === null ? (
<p className="text-sm text-neutral-500 italic">
Click a piece on the board to add modifiers.
</p>
) : (
<>
{/* Selected square header */}
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-1">
Square {selectedSquare}
</h3>
{selectedPiece !== undefined && (
<p className="text-sm text-neutral-700 capitalize">
{selectedPiece.color} {selectedPiece.type}
</p>
)}
</div>
{/* Existing modifiers list */}
{squareModifiers.length > 0 && (
<ul data-testid="instance-modifiers-list" className="space-y-1">
{squareModifiers.map(({ modifier, index }) => (
<li
key={index}
data-testid={`instance-modifier-${selectedSquare}-${modifier.kind}`}
className="flex items-center gap-2 px-3 py-2 bg-white border border-neutral-200 rounded text-sm"
>
<span className="flex-1 font-medium text-neutral-700 break-all">
{modifier.kind}: {JSON.stringify(modifier.value)}
</span>
<button
onClick={() => onDelete(index)}
aria-label={`Delete ${modifier.kind} modifier`}
className="shrink-0 text-red-500 hover:text-red-700 text-xs font-bold leading-none"
>
</button>
</li>
))}
</ul>
)}
{/* Add modifier form */}
<AddModifierForm square={selectedSquare} onAdd={onAdd} />
</>
)}
</aside>
</div>
);
}
// ── Add-Modifier form ─────────────────────────────────────────────────────────
function defaultValue(kind: ModifierKindId): string {
switch (kind) {
case 'direction-additions':
return '';
case 'promotion-override':
return 'queen';
default:
return '0';
}
}
function ValueInput({
kind,
value,
onChange,
}: {
kind: ModifierKindId;
value: string;
onChange: (v: string) => void;
}) {
if (kind === 'promotion-override') {
return (
<select
data-testid="instance-modifier-value"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{['queen', 'rook', 'bishop', 'knight', 'disabled'].map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
if (kind === 'direction-additions') {
return (
<input
data-testid="instance-modifier-value"
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="e.g. forward, backward"
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
);
}
// Numeric kinds: hp-bonus, range-bonus, capture-flags, damage-resistance
return (
<input
data-testid="instance-modifier-value"
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
);
}
function AddModifierForm({
square,
onAdd,
}: {
square: string;
onAdd: (modifier: InstanceModifier) => void;
}) {
const [kind, setKind] = useState<ModifierKindId>('hp-bonus');
const [rawValue, setRawValue] = useState<string>('0');
function handleAdd() {
let parsed: unknown;
if (kind === 'direction-additions') {
parsed = rawValue
.split(',')
.map((s) => s.trim())
.filter(Boolean);
} else if (kind === 'promotion-override') {
parsed = rawValue;
} else {
const n = Number(rawValue);
if (isNaN(n)) return;
parsed = n;
}
onAdd({ kind, square, value: parsed });
setRawValue(defaultValue(kind));
}
return (
<div className="space-y-2 border-t border-neutral-200 pt-3">
<h4 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
Add Modifier
</h4>
<select
data-testid="instance-modifier-kind"
value={kind}
onChange={(e) => {
const newKind = e.target.value as ModifierKindId;
setKind(newKind);
setRawValue(defaultValue(newKind));
}}
className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{MODIFIER_KINDS.map(({ id, label }) => (
<option key={id} value={id}>
{label}
</option>
))}
</select>
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
<button
data-testid="instance-modifier-add"
onClick={handleAdd}
className="w-full px-3 py-2 text-sm font-semibold text-white bg-blue-600 rounded hover:bg-blue-700 active:bg-blue-800 transition-colors"
>
Add
</button>
</div>
);
}