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.
This commit is contained in:
parent
28b11f342d
commit
cbe4a4b5f6
3 changed files with 569 additions and 1 deletions
|
|
@ -23,7 +23,8 @@ export type ModifierKindId =
|
|||
| "direction-additions"
|
||||
| "capture-flags"
|
||||
| "promotion-override"
|
||||
| "damage-resistance";
|
||||
| "damage-resistance"
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* Named movement directions (from the perspective of the moving piece,
|
||||
|
|
|
|||
552
packages/chess/src/ui/CustomModifierEditor.tsx
Normal file
552
packages/chess/src/ui/CustomModifierEditor.tsx
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
type SavedModifierProfile,
|
||||
} from '../modifiers/library';
|
||||
import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types';
|
||||
import { CustomModifierEditor } from './CustomModifierEditor.js';
|
||||
import { PerInstancePanel } from './PerInstancePanel';
|
||||
import { PerTypePanel } from './PerTypePanel.js';
|
||||
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
|
||||
|
|
@ -48,6 +49,8 @@ function makeBlankProfile(): ModifierProfile {
|
|||
}
|
||||
|
||||
export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
||||
const [showCustomModifierEditor, setShowCustomModifierEditor] = useState(false);
|
||||
|
||||
const [history, setHistory] = useState<ModifierProfile[]>(() => [
|
||||
makeBlankProfile(),
|
||||
]);
|
||||
|
|
@ -274,6 +277,14 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
|
||||
</div>
|
||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<button
|
||||
data-testid="open-custom-modifier-editor"
|
||||
onClick={() => setShowCustomModifierEditor(true)}
|
||||
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
|
||||
title="Author a custom modifier from primitives"
|
||||
>
|
||||
+ Custom Modifier
|
||||
</button>
|
||||
<button
|
||||
data-testid="save-profile"
|
||||
onClick={handleSaveToLibrary}
|
||||
|
|
@ -401,6 +412,10 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CustomModifierEditor
|
||||
isOpen={showCustomModifierEditor}
|
||||
onClose={() => setShowCustomModifierEditor(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue