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:
parent
28e03d06fa
commit
100bf5c909
3 changed files with 362 additions and 6 deletions
|
|
@ -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>,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
330
packages/chess/src/ui/PerTypePanel.tsx
Normal file
330
packages/chess/src/ui/PerTypePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue