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 * 3. For the selected square → view existing instance modifiers and
* add/delete them via the right-hand sidebar form. * 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 { InstanceModifier, ModifierKindId } from '../modifiers/types';
import type { StartingLayout } from '../layouts/types'; import type { StartingLayout } from '../layouts/types';
import { squareToAlgebraic } from '../coord'; import { squareToAlgebraic } from '../coord';
import { LayoutBoardView } from './LayoutEditor'; import { LayoutBoardView } from './LayoutEditor';
import {
loadCustomModifierLibrary,
type SavedCustomModifier,
} from '../modifiers/custom/library.js';
import type { ModifierClipboard } from './ModifierProfileEditor.js'; import type { ModifierClipboard } from './ModifierProfileEditor.js';
export interface PerInstancePanelProps { export interface PerInstancePanelProps {
@ -264,10 +268,29 @@ function AddModifierForm({
}) { }) {
const [kind, setKind] = useState<ModifierKindId>('hp-bonus'); const [kind, setKind] = useState<ModifierKindId>('hp-bonus');
const [rawValue, setRawValue] = useState<string>('0'); 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() { 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') { if (kind === 'direction-additions') {
parsed = rawValue parsed = rawValue
.split(',') .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" className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
> >
<optgroup label="Built-in">
{MODIFIER_KINDS.map(({ id, label }) => ( {MODIFIER_KINDS.map(({ id, label }) => (
<option key={id} value={id}> <option key={id} value={id}>
{label} {label}
</option> </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> </select>
{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} /> <ValueInput kind={kind} value={rawValue} onChange={setRawValue} />
)}
<button <button
data-testid="instance-modifier-add" data-testid="instance-modifier-add"

View file

@ -11,10 +11,14 @@
* "direction-set" → placeholder (T21+) * "direction-set" → placeholder (T21+)
* "capture-flags" → placeholder (T21+) * "capture-flags" → placeholder (T21+)
*/ */
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { MODIFIER_REGISTRY } from '../modifiers/index.js'; import { MODIFIER_REGISTRY } from '../modifiers/index.js';
import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js'; import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js';
import type { PieceType, PieceColor } from '../schema.js'; import type { PieceType, PieceColor } from '../schema.js';
import {
loadCustomModifierLibrary,
type SavedCustomModifier,
} from '../modifiers/custom/library.js';
import type { ModifierClipboard } from './ModifierProfileEditor.js'; import type { ModifierClipboard } from './ModifierProfileEditor.js';
const PIECE_TYPES: PieceType[] = [ const PIECE_TYPES: PieceType[] = [
@ -63,13 +67,26 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
const [color, setColor] = useState<PieceColor | 'both'>('both'); const [color, setColor] = useState<PieceColor | 'both'>('both');
const descriptors = MODIFIER_REGISTRY.list(); 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 [kindId, setKindId] = useState<string>(descriptors[0]?.id ?? '');
const [rawValue, setRawValue] = useState(''); const [rawValue, setRawValue] = useState('');
const descriptor = descriptors.find((d) => d.id === kindId); 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 parsedValue = descriptor ? parseFormValue(descriptor, rawValue) : undefined;
const isValid = const isValid = isCustomKind
descriptor !== undefined && ? true // custom kinds carry no per-instance value; descriptor is applied as-is.
: descriptor !== undefined &&
parsedValue !== undefined && parsedValue !== undefined &&
descriptor.valueSchema.safeParse(parsedValue).success; descriptor.valueSchema.safeParse(parsedValue).success;
@ -81,13 +98,27 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
} }
function handleSave() { function handleSave() {
if (!isValid || !descriptor) return; 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({ onAdd({
kind: descriptor.id, kind: descriptor.id,
pieceType, pieceType,
color, color,
value: parsedValue, value: parsedValue,
}); });
} else {
return;
}
setFormOpen(false); setFormOpen(false);
resetForm(); 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" 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"
> >
<optgroup label="Built-in">
{descriptors.map((d) => ( {descriptors.map((d) => (
<option key={d.id} value={d.id}> <option key={d.id} value={d.id}>
{d.label} {d.label}
</option> </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> </select>
</div> </div>
{/* Value input — rendered by uiForm type */} {/* Value input — rendered by uiForm type. Custom kinds skip
{descriptor !== undefined && ( this entirely; the descriptor's primitive list is the payload. */}
{!isCustomKind && descriptor !== undefined && (
<div> <div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1"> <label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Value Value
@ -250,6 +297,17 @@ export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste,
/> />
</div> </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 */} {/* Actions */}
<div className="flex gap-2 pt-1"> <div className="flex gap-2 pt-1">