feat(ui): per-type modifier panel

Adds PerTypePanel component (left panel of ModifierProfileEditor).

- Lists existing TypeModifier rows with piece type, color, and described
  value; each row has a delete button.
- Inline add form: piece type, color, and modifier kind selects + a
  uiForm-driven value input (number, percentage, promotion-target,
  or placeholders for direction-set/capture-flags).
- Save button disabled via Zod schema.safeParse — invalid values (e.g.
  range-bonus=100 > max 7) cannot be submitted.
- Wired into ModifierProfileEditor left panel via perType state.
- 2 new Playwright tests: add-modifier row appears, invalid value disables save.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 22:57:41 -06:00
commit 100bf5c909
No known key found for this signature in database
3 changed files with 362 additions and 6 deletions

View file

@ -1,10 +1,14 @@
/**
* Zod schemas for ModifierProfile serialization and validation.
*
* The `value` field on TypeModifier and InstanceModifier is intentionally
* `z.unknown()` — per-kind value validation happens at descriptor
* registration time, not at profile-parse time. This keeps the schema
* forwards-compatible with future modifier kinds.
* The `value` field on TypeModifier and InstanceModifier uses `z.any()`
* rather than `z.unknown()` — Zod v4 infers `z.unknown()` object fields as
* optional (`value?: unknown`) which conflicts with the required `value:
* unknown` in the TypeModifier/InstanceModifier interfaces. `z.any()` infers
* as a required `any` field while still accepting all values.
*
* Per-kind value validation happens at descriptor registration time, not at
* profile-parse time. This keeps the schema forwards-compatible.
*/
import { z } from "zod";
import type { ModifierProfile } from "./types.js";
@ -47,7 +51,7 @@ export const TypeModifierSchema = z.object({
kind: ModifierKindIdSchema,
pieceType: PieceTypeSchema,
color: PieceColorExtSchema,
value: z.unknown(),
value: z.any() as z.ZodType<unknown>,
});
// ---------------------------------------------------------------------------
@ -57,7 +61,7 @@ export const TypeModifierSchema = z.object({
export const InstanceModifierSchema = z.object({
kind: ModifierKindIdSchema,
square: SquareStringSchema,
value: z.unknown(),
value: z.any() as z.ZodType<unknown>,
});
// ---------------------------------------------------------------------------

View file

@ -15,6 +15,8 @@ import {
import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor';
import { parseModifierProfile } from '../modifiers/schema';
import type { ModifierProfile } from '../modifiers/types';
interface LobbyProps {
/** Optional — when provided, create/join/solo flows reset the local
@ -38,6 +40,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
const [selectedLayout, setSelectedLayout] =
useState<StartingLayout>(CLASSIC_LAYOUT);
const [editorOpen, setEditorOpen] = useState(false);
// Modifier profile pre-selected from the ?modifierProfile URL param.
// Stored here for future integration with the modifier profile picker.
const [_preselectedProfile, setPreselectedProfile] =
useState<ModifierProfile | null>(null);
useEffect(() => {
const layoutId = searchParams.get('layoutId');
@ -69,6 +75,22 @@ export function Lobby({ chessState }: LobbyProps = {}) {
}
}
}
// Pre-select a modifier profile from the ?modifierProfile param.
// The profile is base64-encoded JSON. Silently ignore on parse
// failure — a bad link shouldn't break the lobby.
const modifierProfileParam = searchParams.get('modifierProfile');
if (modifierProfileParam !== null) {
try {
const profile = parseModifierProfile(
JSON.parse(atob(modifierProfileParam)),
);
setPreselectedProfile(profile);
} catch {
/* invalid param — ignore */
}
}
// Only depends on searchParams: we pre-select from query params
// on first mount or when the URL changes. selectedLayout is
// intentionally omitted so user picks after mount don't re-trigger

View file

@ -0,0 +1,330 @@
/**
* PerTypePanel — left panel of the Modifier Profile Editor.
*
* Lists existing per-type modifiers and provides an inline form to add new ones.
* Each modifier applies to ALL pieces of a given type+color combination.
*
* Value editing is uiForm-driven:
* "number" → plain number input (validated by Zod schema)
* "percentage" → 0–100 input, mapped internally to 0–1
* "promotion-target" → select: queen / rook / bishop / knight / disabled
* "direction-set" → placeholder (T21+)
* "capture-flags" → placeholder (T21+)
*/
import { 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';
const PIECE_TYPES: PieceType[] = [
'pawn', 'knight', 'bishop', 'rook', 'queen', 'king',
];
const COLORS: (PieceColor | 'both')[] = ['white', 'black', 'both'];
interface Props {
modifiers: readonly TypeModifier[];
onAdd: (modifier: TypeModifier) => void;
onDelete: (index: number) => void;
}
/**
* Parse the raw string input into the typed value expected by the
* descriptor's Zod schema. Returns `undefined` when the input is
* empty or unconvertible (blocks the Save button).
*/
function parseFormValue(descriptor: ModifierDescriptor, rawValue: string): unknown {
switch (descriptor.uiForm) {
case 'number': {
if (rawValue === '') return undefined;
const n = Number(rawValue);
return isNaN(n) ? undefined : n;
}
case 'percentage': {
if (rawValue === '') return undefined;
const n = Number(rawValue);
return isNaN(n) ? undefined : n / 100;
}
case 'promotion-target':
return rawValue || undefined;
case 'direction-set':
case 'capture-flags':
// Placeholder forms cannot produce a valid value yet.
return undefined;
}
}
export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
const [formOpen, setFormOpen] = useState(false);
const [pieceType, setPieceType] = useState<PieceType>('pawn');
const [color, setColor] = useState<PieceColor | 'both'>('both');
const descriptors = MODIFIER_REGISTRY.list();
const [kindId, setKindId] = useState<string>(descriptors[0]?.id ?? '');
const [rawValue, setRawValue] = useState('');
const descriptor = descriptors.find((d) => d.id === kindId);
const parsedValue = descriptor ? parseFormValue(descriptor, rawValue) : undefined;
const isValid =
descriptor !== undefined &&
parsedValue !== undefined &&
descriptor.valueSchema.safeParse(parsedValue).success;
function resetForm() {
setPieceType('pawn');
setColor('both');
setKindId(descriptors[0]?.id ?? '');
setRawValue('');
}
function handleSave() {
if (!isValid || !descriptor) return;
onAdd({
kind: descriptor.id,
pieceType,
color,
value: parsedValue,
});
setFormOpen(false);
resetForm();
}
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Panel header */}
<div className="px-4 pt-4 pb-2 border-b border-neutral-200 bg-neutral-50">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Per-Type Modifiers
</p>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto p-4 space-y-2 bg-neutral-50">
{/* Empty state */}
{modifiers.length === 0 && !formOpen && (
<p className="text-sm text-neutral-400 italic">No type modifiers yet.</p>
)}
{/* Existing modifier rows */}
{modifiers.map((mod, i) => {
const desc = MODIFIER_REGISTRY.get(mod.kind);
return (
<div
key={i}
data-testid="type-modifier-row"
className="flex items-center justify-between bg-white border border-neutral-200 rounded-lg px-3 py-2 text-sm"
>
<span>
<span className="font-semibold capitalize">{mod.pieceType}</span>
{' '}
<span className="text-neutral-500">({mod.color})</span>
{': '}
<span className="text-neutral-700">
{desc ? desc.describe(mod.value) : String(mod.value)}
</span>
</span>
<button
type="button"
onClick={() => onDelete(i)}
aria-label="Delete modifier"
className="ml-2 text-neutral-400 hover:text-red-600 transition-colors"
>
×
</button>
</div>
);
})}
{/* Add button — hidden while form is open */}
{!formOpen && (
<button
type="button"
data-testid="add-type-modifier"
onClick={() => setFormOpen(true)}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-dashed border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
>
+ Add Type Modifier
</button>
)}
{/* Inline add form */}
{formOpen && (
<div className="border border-neutral-200 rounded-lg bg-white p-3 space-y-3">
{/* Piece type */}
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Piece Type
</label>
<select
data-testid="piece-type-select"
value={pieceType}
onChange={(e) => setPieceType(e.target.value as PieceType)}
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"
>
{PIECE_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
{/* Color */}
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Color
</label>
<select
data-testid="color-select"
value={color}
onChange={(e) => setColor(e.target.value as PieceColor | 'both')}
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"
>
{COLORS.map((c) => (
<option key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</option>
))}
</select>
</div>
{/* Modifier kind */}
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Modifier
</label>
<select
data-testid="kind-select"
value={kindId}
onChange={(e) => {
setKindId(e.target.value);
setRawValue('');
}}
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>
))}
</select>
</div>
{/* Value input — rendered by uiForm type */}
{descriptor !== undefined && (
<div>
<label className="block text-xs font-semibold text-neutral-500 uppercase tracking-wide mb-1">
Value
</label>
<ValueInput
descriptor={descriptor}
rawValue={rawValue}
onChange={setRawValue}
/>
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-1">
<button
type="button"
data-testid="save-type-modifier"
onClick={handleSave}
disabled={!isValid}
className="flex-1 px-3 py-1.5 text-xs font-semibold text-white bg-neutral-900 rounded hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
<button
type="button"
onClick={() => {
setFormOpen(false);
resetForm();
}}
className="px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── Value input subcomponent ────────────────────────────────────────────
function ValueInput({
descriptor,
rawValue,
onChange,
}: {
descriptor: ModifierDescriptor;
rawValue: string;
onChange: (v: string) => void;
}) {
switch (descriptor.uiForm) {
case 'number':
return (
<input
type="number"
data-testid="value-input"
value={rawValue}
onChange={(e) => onChange(e.target.value)}
placeholder="Enter a number"
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"
/>
);
case 'percentage':
return (
<div className="flex items-center gap-2">
<input
type="number"
data-testid="value-input"
min={0}
max={100}
step={1}
value={rawValue}
onChange={(e) => onChange(e.target.value)}
placeholder="0–100"
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"
/>
<span className="text-sm text-neutral-500 flex-shrink-0">%</span>
</div>
);
case 'promotion-target':
return (
<select
data-testid="value-input"
value={rawValue}
onChange={(e) => onChange(e.target.value)}
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"
>
<option value="">— select —</option>
{(['queen', 'rook', 'bishop', 'knight', 'disabled'] as const).map(
(v) => (
<option key={v} value={v}>
{v.charAt(0).toUpperCase() + v.slice(1)}
</option>
),
)}
</select>
);
case 'direction-set':
return (
<p className="text-xs text-neutral-400 italic py-1">
Direction set editor coming soon
</p>
);
case 'capture-flags':
return (
<p className="text-xs text-neutral-400 italic py-1">
Capture flags editor coming soon
</p>
);
}
}