feat(ui): custom modifiers in modifier profile panels

T3 Wave 4 (T26). PerTypePanel and PerInstancePanel kind dropdowns now
include the user's custom modifier library alongside built-ins.

- Kind dropdown uses <optgroup> separation: 'Built-in' (MODIFIER_REGISTRY)
  + 'Custom (from library)' (loadCustomModifierLibrary).
- Selecting a custom kind hides the per-instance value input and shows
  a small summary card ('Custom modifier — N primitives. Edit it in
  the Custom Modifier editor.') — the descriptor's primitive list IS
  the payload; per-instance value is null.
- Custom kinds skip Zod-schema-based validation (they have no
  per-instance value to validate); isValid is true once a custom
  kind is selected.
- handleSave / handleAdd branches on isCustomKind: built-in path
  preserves T1/T2 behaviour exactly; custom path emits {kind: id,
  pieceType, color, value: null}.
- PerInstancePanel mirrors the same pattern in AddModifierForm.

E2E tests for the new UI defer to T29; existing 1378 unit tests pass.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 20:20:03 -06:00
commit 9b586b83b5
No known key found for this signature in database
2 changed files with 135 additions and 27 deletions

View file

@ -11,11 +11,15 @@
* 3. For the selected square → view existing instance modifiers and
* add/delete them via the right-hand sidebar form.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { InstanceModifier, ModifierKindId } from '../modifiers/types';
import type { StartingLayout } from '../layouts/types';
import { squareToAlgebraic } from '../coord';
import { LayoutBoardView } from './LayoutEditor';
import {
loadCustomModifierLibrary,
type SavedCustomModifier,
} from '../modifiers/custom/library.js';
import type { ModifierClipboard } from './ModifierProfileEditor.js';
export interface PerInstancePanelProps {
@ -264,10 +268,29 @@ function AddModifierForm({
}) {
const [kind, setKind] = useState<ModifierKindId>('hp-bonus');
const [rawValue, setRawValue] = useState<string>('0');
// Custom modifiers from the user's library appear in the kind
// dropdown alongside built-ins (T26). Reload on mount; new entries
// saved via CustomModifierEditor become visible the next time the
// user opens this form.
const [customs, setCustoms] = useState<SavedCustomModifier[]>(() =>
loadCustomModifierLibrary(),
);
useEffect(() => {
setCustoms(loadCustomModifierLibrary());
}, []);
const customDescriptor = customs.find((c) => c.id === kind);
const isCustomKind = customDescriptor !== undefined;
function handleAdd() {
let parsed: unknown;
if (isCustomKind && customDescriptor) {
// Custom kinds carry no per-instance value; the descriptor's
// primitive list is the entire payload.
onAdd({ kind: customDescriptor.id, square, value: null });
return;
}
let parsed: unknown;
if (kind === 'direction-additions') {
parsed = rawValue
.split(',')
@ -301,14 +324,41 @@ function AddModifierForm({
}}
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>
))}
<optgroup label="Built-in">
{MODIFIER_KINDS.map(({ id, label }) => (
<option key={id} value={id}>
{label}
</option>
))}
</optgroup>
{customs.length > 0 && (
<optgroup label="Custom (from library)">
{customs.map((c) => (
<option
key={c.id}
value={c.id}
data-testid={`instance-kind-custom-${c.id}`}
>
{c.descriptor.name}
</option>
))}
</optgroup>
)}
</select>
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
{isCustomKind && customDescriptor !== undefined ? (
<div
data-testid="instance-custom-modifier-summary"
className="px-3 py-2 text-xs text-neutral-600 bg-neutral-50 border border-neutral-200 rounded"
>
Custom modifier — {customDescriptor.descriptor.primitives.length}{' '}
primitive
{customDescriptor.descriptor.primitives.length !== 1 ? 's' : ''}.
Edit it in the Custom Modifier editor.
</div>
) : (
<ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
)}
<button
data-testid="instance-modifier-add"

View file

@ -11,10 +11,14 @@
* "direction-set" → placeholder (T21+)
* "capture-flags" → placeholder (T21+)
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { MODIFIER_REGISTRY } from '../modifiers/index.js';
import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js';
import type { PieceType, PieceColor } from '../schema.js';
import {
loadCustomModifierLibrary,
type SavedCustomModifier,
} from '../modifiers/custom/library.js';
import type { ModifierClipboard } from './ModifierProfileEditor.js';
const PIECE_TYPES: PieceType[] = [
@ -63,15 +67,28 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
const [color, setColor] = useState<PieceColor | 'both'>('both');
const descriptors = MODIFIER_REGISTRY.list();
// Custom modifiers from the user's library appear in the kind
// dropdown alongside built-ins (T26). Re-loaded when the form
// opens so newly-saved custom modifiers show up immediately.
const [customs, setCustoms] = useState<SavedCustomModifier[]>(() =>
loadCustomModifierLibrary(),
);
useEffect(() => {
if (formOpen) setCustoms(loadCustomModifierLibrary());
}, [formOpen]);
const [kindId, setKindId] = useState<string>(descriptors[0]?.id ?? '');
const [rawValue, setRawValue] = useState('');
const descriptor = descriptors.find((d) => d.id === kindId);
const customDescriptor = customs.find((c) => c.id === kindId);
const isCustomKind = customDescriptor !== undefined;
const parsedValue = descriptor ? parseFormValue(descriptor, rawValue) : undefined;
const isValid =
descriptor !== undefined &&
parsedValue !== undefined &&
descriptor.valueSchema.safeParse(parsedValue).success;
const isValid = isCustomKind
? true // custom kinds carry no per-instance value; descriptor is applied as-is.
: descriptor !== undefined &&
parsedValue !== undefined &&
descriptor.valueSchema.safeParse(parsedValue).success;
function resetForm() {
setPieceType('pawn');
@ -81,13 +98,27 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
}
function handleSave() {
if (!isValid || !descriptor) return;
onAdd({
kind: descriptor.id,
pieceType,
color,
value: parsedValue,
});
if (!isValid) return;
if (isCustomKind && customDescriptor) {
// Custom modifiers don't take a per-instance value; the descriptor's
// primitive list is the entire payload. Pass `null` so the apply-time
// dispatcher (T22) sees a present-but-meaningless value.
onAdd({
kind: customDescriptor.id,
pieceType,
color,
value: null,
});
} else if (descriptor) {
onAdd({
kind: descriptor.id,
pieceType,
color,
value: parsedValue,
});
} else {
return;
}
setFormOpen(false);
resetForm();
}
@ -229,16 +260,32 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
}}
className="w-full px-2 py-1.5 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-neutral-900"
>
{descriptors.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
<optgroup label="Built-in">
{descriptors.map((d) => (
<option key={d.id} value={d.id}>
{d.label}
</option>
))}
</optgroup>
{customs.length > 0 && (
<optgroup label="Custom (from library)">
{customs.map((c) => (
<option
key={c.id}
value={c.id}
data-testid={`kind-custom-${c.id}`}
>
{c.descriptor.name}
</option>
))}
</optgroup>
)}
</select>
</div>
{/* Value input — rendered by uiForm type */}
{descriptor !== undefined && (
{/* Value input — rendered by uiForm type. Custom kinds skip
this entirely; the descriptor's primitive list is the payload. */}
{!isCustomKind && descriptor !== undefined && (
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Value
@ -250,6 +297,17 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
/>
</div>
)}
{isCustomKind && customDescriptor !== undefined && (
<div
data-testid="custom-modifier-summary"
className="px-3 py-2 text-xs text-neutral-600 bg-neutral-50 border border-neutral-200 rounded"
>
Custom modifier — {customDescriptor.descriptor.primitives.length}{' '}
primitive
{customDescriptor.descriptor.primitives.length !== 1 ? 's' : ''}.
Edit it in the Custom Modifier editor.
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">