houserules/packages/chess/src/ui/CustomModifierEditor.tsx
Joey Yakimowich-Payne cbe4a4b5f6
feat(ui): custom modifier editor
T3 Wave 4 (T25). 3-column visual primitive composer for authoring
custom modifier descriptors from the 15 T3 effect primitives.

- Left palette: 15 primitives grouped by category (State / Mechanic /
  Advanced); click adds to the descriptor's primitive list.
- Center tree: shows current primitives[]; click selects, delete
  button per node.
- Right inspector: parameter form per selected primitive. Introspects
  the primitive's Zod schema to render typed inputs (number / string /
  boolean / enum / array). Falls back to a JSON textarea for complex
  param shapes (e.g. nested EffectPrimitiveNode arrays).
- Header: descriptor name/description inputs + Save/Load library +
  live validation status from validateCustomDescriptor.
- Open from ModifierProfileEditor's header via the new + Custom
  Modifier button (data-testid open-custom-modifier-editor).

Type widening: ModifierKindId gains '| (string & {})' so the kind
field on TypeModifier/InstanceModifier accepts custom descriptor ids
without losing literal-completion on built-in kinds. The
applyCollectedContributions dispatcher already handles arbitrary
strings via its custom-registry fallback (T22).

Lint cleanup: replaced 4 'as any' casts on Zod internals with named
ZodObjectInternal / ZodWrappedDefInternal / ZodEnumDefInternal
structural shapes — auditable in one place if Zod renames _def.

E2E + extended tests deferred to T29; T26 (panel kind-dropdown
extension) and T27 (multi-profile lobby) shipped separately.
2026-04-19 20:17:33 -06:00

552 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo } from 'react';
import { toast } from 'sonner';
import { type ZodType, z } from 'zod';
import { PRIMITIVE_REGISTRY } from '../modifiers/primitives/index.js';
import type { EffectPrimitiveNode, PrimitiveKind, EffectPrimitive } from '../modifiers/primitives/types.js';
import type { CustomModifierDescriptor } from '../modifiers/custom/types.js';
import {
saveToCustomModifierLibrary,
loadCustomModifierLibrary,
type SavedCustomModifier,
} from '../modifiers/custom/library.js';
import { validateCustomDescriptor } from '../modifiers/custom/validate.js';
import { asCustomModifierId } from '../modifiers/custom/types.js';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const CATEGORIES: Record<string, PrimitiveKind[]> = {
State: [
'seed-attribute',
'add-to-attribute',
'multiply-attribute',
'add-direction',
'set-capture-flag',
],
Mechanic: [
'absorb-damage-with-attribute',
'reflect-damage',
'modify-movement-range',
'block-move-type',
'override-promotion',
],
Advanced: [
'add-aura',
'on-turn-start',
'on-capture',
'on-damaged',
'conditional',
],
};
function generateId(): string {
return `custom-${Math.random().toString(36).substring(2, 9)}`;
}
function makeBlankDescriptor(): CustomModifierDescriptor {
return {
type: 'data',
id: asCustomModifierId(generateId()),
name: 'New Custom Modifier',
description: '',
version: 1,
primitives: [],
targetAttrs: [], // Will be auto-computed based on primitives
uiForm: 'primitive-composer',
source: 'custom',
};
}
/** Fallback to default params based on primitive schema */
function generateDefaultParams(schema: ZodType<unknown>): unknown {
try {
if (schema instanceof z.ZodDefault) {
return (schema as unknown as { _def: { defaultValue: () => unknown } })._def.defaultValue();
}
if (schema instanceof z.ZodObject) {
const shape = schema.shape as Record<string, ZodType<unknown>>;
const result: Record<string, unknown> = {};
for (const [key, subSchema] of Object.entries(shape)) {
if (subSchema instanceof z.ZodDefault) {
result[key] = (subSchema as unknown as { _def: { defaultValue: () => unknown } })._def.defaultValue();
} else if (subSchema instanceof z.ZodNumber) {
result[key] = 0;
} else if (subSchema instanceof z.ZodString) {
result[key] = '';
} else if (subSchema instanceof z.ZodBoolean) {
result[key] = false;
} else if (subSchema instanceof z.ZodEnum) {
result[key] = (subSchema as unknown as { _def: { values: unknown[] } })._def.values[0];
} else if (subSchema instanceof z.ZodArray) {
result[key] = [];
} else if (subSchema instanceof z.ZodOptional || subSchema instanceof z.ZodNullable) {
// Leave it undefined
}
}
return result;
}
if (schema instanceof z.ZodNumber) return 0;
if (schema instanceof z.ZodString) return '';
if (schema instanceof z.ZodBoolean) return false;
if (schema instanceof z.ZodEnum) return (schema as unknown as { _def: { values: unknown[] } })._def.values[0];
if (schema instanceof z.ZodArray) return [];
} catch (e) {
console.error('Failed to generate default params', e);
}
return {}; // Fallback
}
export function CustomModifierEditor({ isOpen, onClose }: Props) {
const [descriptor, setDescriptor] = useState<CustomModifierDescriptor>(makeBlankDescriptor());
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
// Library state for the "Load" dialog
const [showLibrary, setShowLibrary] = useState(false);
const [libraryItems, setLibraryItems] = useState<SavedCustomModifier[]>([]);
// Open library
const openLibrary = () => {
setLibraryItems(loadCustomModifierLibrary());
setShowLibrary(true);
};
const validationResult = useMemo(() => validateCustomDescriptor(descriptor), [descriptor]);
const updateDescriptor = (updater: (prev: CustomModifierDescriptor) => CustomModifierDescriptor) => {
setDescriptor((prev) => updater(prev));
};
const handleSave = () => {
const res = saveToCustomModifierLibrary(descriptor);
if (res.ok) {
toast.success('Custom modifier saved to library');
} else {
toast.error(`Save failed: ${res.reason}`);
}
};
if (!isOpen) return null;
return (
<div
data-testid="custom-modifier-editor"
className="fixed inset-0 z-[60] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-[1400px] min-h-[85vh] max-h-[95vh] overflow-hidden flex flex-col border border-neutral-200">
{/* Header */}
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200 shrink-0">
<div className="flex-1 flex items-center gap-4">
<h2 className="text-lg font-bold text-neutral-900 whitespace-nowrap">
Primitive Composer
</h2>
<div className="flex flex-col gap-1 flex-1 max-w-md">
<input
type="text"
value={descriptor.name}
onChange={(e) => updateDescriptor((p) => ({ ...p, name: e.target.value }))}
placeholder="Modifier Name"
className="px-3 py-1 text-sm font-semibold border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={40}
/>
<input
type="text"
value={descriptor.description}
onChange={(e) => updateDescriptor((p) => ({ ...p, description: e.target.value }))}
placeholder="Description (optional)"
className="px-3 py-1 text-xs border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
maxLength={200}
/>
</div>
<div className="text-xs text-neutral-500 font-mono self-end pb-1">
ID: {descriptor.id}
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="custom-load"
onClick={openLibrary}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Load
</button>
<button
data-testid="custom-save"
onClick={handleSave}
disabled={!validationResult.ok}
className="px-3 py-1.5 text-sm font-semibold text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Save to Library
</button>
<div className="w-px h-6 bg-neutral-300 mx-2" />
<button
onClick={onClose}
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
aria-label="Close"
>
×
</button>
</div>
</header>
{/* Main Body (3 Columns) */}
<div className="flex-1 flex min-h-0 overflow-hidden">
{/* Left: Palette */}
<aside className="w-64 border-r border-neutral-200 overflow-y-auto bg-neutral-50/50 flex flex-col p-4 gap-6 shrink-0">
{Object.entries(CATEGORIES).map(([category, kinds]) => (
<div key={category}>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-3">
{category}
</h3>
<div className="flex flex-col gap-2">
{kinds.map((kind) => {
const primitive = PRIMITIVE_REGISTRY.get(kind);
if (!primitive) return null;
return (
<button
key={kind}
data-testid={`custom-primitive-palette-${kind}`}
className="text-left px-3 py-2 bg-white border border-neutral-200 rounded-lg shadow-sm hover:border-blue-300 hover:shadow transition-all group"
onClick={() => {
const newNode: EffectPrimitiveNode = {
kind,
params: generateDefaultParams(primitive.paramsSchema),
};
updateDescriptor((p) => ({
...p,
primitives: [...p.primitives, newNode],
}));
setSelectedIndex(descriptor.primitives.length); // select the newly added node
}}
>
<div className="text-sm font-semibold text-neutral-800">
{primitive.label}
</div>
<div className="text-xs text-neutral-500 truncate mt-0.5" title={primitive.description}>
{primitive.description}
</div>
</button>
);
})}
</div>
</div>
))}
</aside>
{/* Center: Tree View */}
<main className="flex-[2] border-r border-neutral-200 overflow-y-auto bg-white p-6 flex flex-col">
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Effect Sequence ({descriptor.primitives.length})
</h3>
{descriptor.primitives.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400 italic">
Add primitives from the palette to build the modifier.
</div>
) : (
<div className="flex flex-col gap-2">
{descriptor.primitives.map((node, i) => {
const isSelected = selectedIndex === i;
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
return (
<div
key={i}
data-testid={`custom-primitive-node-${i}`}
onClick={() => setSelectedIndex(i)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors
${isSelected
? 'border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-500/20'
: 'border-neutral-200 hover:border-neutral-300 bg-white hover:bg-neutral-50'}
`}
>
<div className="flex items-center gap-3 overflow-hidden">
<div className={`
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
${isSelected ? 'bg-blue-200 text-blue-800' : 'bg-neutral-100 text-neutral-500'}
`}>
{i + 1}
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold ${isSelected ? 'text-blue-900' : 'text-neutral-800'}`}>
{primitive?.label ?? node.kind}
</div>
<div className="text-xs text-neutral-500 truncate">
{JSON.stringify(node.params).substring(0, 60)}
{JSON.stringify(node.params).length > 60 ? '...' : ''}
</div>
</div>
</div>
<button
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors shrink-0"
onClick={(e) => {
e.stopPropagation();
updateDescriptor((p) => ({
...p,
primitives: p.primitives.filter((_, idx) => idx !== i),
}));
if (selectedIndex === i) setSelectedIndex(null);
else if (selectedIndex && selectedIndex > i) setSelectedIndex(selectedIndex - 1);
}}
title="Remove primitive"
>
×
</button>
</div>
);
})}
</div>
)}
</main>
{/* Right: Inspector */}
<aside
data-testid="custom-primitive-inspector"
className="flex-[1.5] bg-neutral-50/30 overflow-y-auto p-6"
>
<h3 className="text-sm font-bold text-neutral-800 mb-4 border-b border-neutral-100 pb-2">
Parameter Inspector
</h3>
{selectedIndex === null || !descriptor.primitives[selectedIndex] ? (
<div className="flex items-center justify-center h-48 text-sm text-neutral-400 italic">
Select a primitive to edit its parameters.
</div>
) : (
<PrimitiveInspector
node={descriptor.primitives[selectedIndex]}
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
onChange={(newParams) => {
updateDescriptor((p) => {
const next = [...p.primitives];
next[selectedIndex] = { kind: next[selectedIndex]!.kind, params: newParams };
return { ...p, primitives: next };
});
}}
/>
)}
</aside>
</div>
{/* Footer: Validation Status */}
<footer className="px-6 py-3 border-t border-neutral-200 bg-neutral-50 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
{validationResult.ok ? (
<div className="flex items-center gap-1.5 text-emerald-600 font-medium">
<span className="text-lg"></span> Valid Custom Descriptor
</div>
) : (
<div className="flex items-center gap-1.5 text-red-600 font-medium">
<span className="text-lg"></span> {validationResult.errors.length} Error{validationResult.errors.length !== 1 ? 's' : ''}
</div>
)}
</div>
{!validationResult.ok && (
<div className="text-xs text-red-500 text-right max-w-2xl truncate">
{validationResult.errors.map(e => `[${e.path.join('.')}] ${e.message}`).join(' | ')}
</div>
)}
</footer>
</div>
{/* Load Modal Overlay */}
{showLibrary && (
<div className="fixed inset-0 z-[70] bg-black/40 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
<header className="px-6 py-4 border-b border-neutral-200 flex items-center justify-between">
<h3 className="text-lg font-bold text-neutral-900">Load from Library</h3>
<button onClick={() => setShowLibrary(false)} className="text-neutral-500 hover:text-neutral-700">×</button>
</header>
<div className="flex-1 overflow-y-auto p-4">
{libraryItems.length === 0 ? (
<div className="text-center py-12 text-neutral-500">No custom modifiers saved yet.</div>
) : (
<div className="flex flex-col gap-2">
{libraryItems.map(item => (
<div
key={item.id}
className="flex items-center justify-between p-4 border border-neutral-200 rounded-lg hover:border-blue-400 cursor-pointer"
onClick={() => {
setDescriptor(item.descriptor);
setSelectedIndex(null);
setShowLibrary(false);
}}
>
<div>
<div className="font-semibold text-neutral-900">{item.descriptor.name}</div>
<div className="text-xs text-neutral-500 mt-1">{item.descriptor.primitives.length} primitives</div>
</div>
<div className="text-xs text-blue-600 font-medium bg-blue-50 px-2 py-1 rounded">Load</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
/**
* Sub-component for rendering the parameter form based on Zod schema introspection.
* Since fully parsing arbitrary Zod schemas into UI is complex, we use a hybrid approach:
* basic types get inputs, complex types get a JSON textarea fallback.
*/
function PrimitiveInspector({
node,
primitive,
onChange
}: {
node: EffectPrimitiveNode;
primitive?: EffectPrimitive;
onChange: (params: unknown) => void;
}) {
if (!primitive) {
return <div className="text-red-500 text-sm">Unknown primitive kind: {node.kind}</div>;
}
// Generic fallback for complex schemas (nested arrays, etc)
const isComplex = primitive.paramsSchema instanceof z.ZodObject === false;
if (isComplex) {
const isArray = primitive.paramsSchema instanceof z.ZodArray;
return (
<div className="flex flex-col gap-2">
<label className="text-xs font-bold text-neutral-600 uppercase tracking-wide">
{isArray ? 'List Parameters (JSON)' : 'Raw Parameters (JSON)'}
</label>
<textarea
className="w-full h-64 p-3 text-sm font-mono border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={JSON.stringify(node.params, null, 2)}
onChange={(e) => {
try {
const val = JSON.parse(e.target.value);
onChange(val);
} catch {
// Let them type invalid JSON temporarily — typing
// states naturally pass through partial-parse states.
}
}}
/>
<p className="text-xs text-neutral-500">Edit the JSON representation directly for complex parameters.</p>
</div>
);
}
// Reach into Zod internals to introspect the schema's shape and
// narrow the param-type rendering. The shape of a ZodObject and the
// _def of optional/default/enum nodes is internal API; we cast to a
// narrow structural shape rather than `any` so the access points
// are auditable. If Zod ever renames _def, fix the cast in one place.
type ZodObjectInternal = { shape: Record<string, ZodType<unknown>> };
type ZodWrappedDefInternal = {
_def: { innerType?: ZodType<unknown>; schema?: ZodType<unknown> };
};
type ZodEnumDefInternal = { _def: { values: readonly string[] } };
const shape = (primitive.paramsSchema as unknown as ZodObjectInternal).shape;
const params = (node.params as Record<string, unknown>) || {};
return (
<div className="flex flex-col gap-5">
{Object.entries(shape).map(([key, schema]) => {
let type = 'string';
let options: readonly string[] = [];
// Basic introspection
let currentSchema: ZodType<unknown> = schema;
if (
currentSchema instanceof z.ZodOptional ||
currentSchema instanceof z.ZodNullable ||
currentSchema instanceof z.ZodDefault
) {
const def = (currentSchema as unknown as ZodWrappedDefInternal)._def;
currentSchema = def.innerType ?? def.schema ?? currentSchema;
}
if (currentSchema instanceof z.ZodNumber) type = 'number';
else if (currentSchema instanceof z.ZodBoolean) type = 'boolean';
else if (currentSchema instanceof z.ZodEnum) {
type = 'enum';
options = (currentSchema as unknown as ZodEnumDefInternal)._def.values;
}
else if (currentSchema instanceof z.ZodArray) type = 'array';
const value = params[key];
return (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-neutral-700">
{key}
</label>
{type === 'number' && (
<input
type="number"
value={Number(value) || 0}
onChange={(e) => onChange({ ...params, [key]: Number(e.target.value) })}
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
{type === 'string' && (
<input
type="text"
value={String(value || '')}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
{type === 'boolean' && (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange({ ...params, [key]: e.target.checked })}
className="w-4 h-4 text-blue-600 border-neutral-300 rounded focus:ring-blue-500"
/>
<span className="text-sm text-neutral-600">Enabled</span>
</label>
)}
{type === 'enum' && (
<select
value={String(value || options[0])}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none bg-white"
>
{options.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
)}
{type === 'array' && (
<div className="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col">
<textarea
className="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none"
value={JSON.stringify(value || [], null, 2)}
onChange={(e) => {
try {
const val = JSON.parse(e.target.value);
if (Array.isArray(val)) {
onChange({ ...params, [key]: val });
}
} catch {
// Partial-typing tolerance; commit on valid JSON only.
}
}}
placeholder="[ ... ]"
/>
</div>
)}
</div>
);
})}
</div>
);
}