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.
|
* Zod schemas for ModifierProfile serialization and validation.
|
||||||
*
|
*
|
||||||
* The `value` field on TypeModifier and InstanceModifier is intentionally
|
* The `value` field on TypeModifier and InstanceModifier uses `z.any()`
|
||||||
* `z.unknown()` — per-kind value validation happens at descriptor
|
* rather than `z.unknown()` — Zod v4 infers `z.unknown()` object fields as
|
||||||
* registration time, not at profile-parse time. This keeps the schema
|
* optional (`value?: unknown`) which conflicts with the required `value:
|
||||||
* forwards-compatible with future modifier kinds.
|
* 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 { z } from "zod";
|
||||||
import type { ModifierProfile } from "./types.js";
|
import type { ModifierProfile } from "./types.js";
|
||||||
|
|
@ -47,7 +51,7 @@ export const TypeModifierSchema = z.object({
|
||||||
kind: ModifierKindIdSchema,
|
kind: ModifierKindIdSchema,
|
||||||
pieceType: PieceTypeSchema,
|
pieceType: PieceTypeSchema,
|
||||||
color: PieceColorExtSchema,
|
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({
|
export const InstanceModifierSchema = z.object({
|
||||||
kind: ModifierKindIdSchema,
|
kind: ModifierKindIdSchema,
|
||||||
square: SquareStringSchema,
|
square: SquareStringSchema,
|
||||||
value: z.unknown(),
|
value: z.any() as z.ZodType<unknown>,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import {
|
||||||
import type { LayoutRequest } from '../net/types';
|
import type { LayoutRequest } from '../net/types';
|
||||||
import { LayoutPicker } from './LayoutPicker';
|
import { LayoutPicker } from './LayoutPicker';
|
||||||
import { LayoutEditor } from './LayoutEditor';
|
import { LayoutEditor } from './LayoutEditor';
|
||||||
|
import { parseModifierProfile } from '../modifiers/schema';
|
||||||
|
import type { ModifierProfile } from '../modifiers/types';
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
/** Optional — when provided, create/join/solo flows reset the local
|
/** Optional — when provided, create/join/solo flows reset the local
|
||||||
|
|
@ -38,6 +40,10 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
const [selectedLayout, setSelectedLayout] =
|
const [selectedLayout, setSelectedLayout] =
|
||||||
useState<StartingLayout>(CLASSIC_LAYOUT);
|
useState<StartingLayout>(CLASSIC_LAYOUT);
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const layoutId = searchParams.get('layoutId');
|
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
|
// Only depends on searchParams: we pre-select from query params
|
||||||
// on first mount or when the URL changes. selectedLayout is
|
// on first mount or when the URL changes. selectedLayout is
|
||||||
// intentionally omitted so user picks after mount don't re-trigger
|
// 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