refactor(chess/ui): extract ParamField from CustomModifierEditor (no behavior change)

Moves the 247-line PrimitiveInspector function out of the 872-line
CustomModifierEditor (now 533 lines) into its own ParamField module so
the upcoming visual builder can reuse the same Zod-introspecting form
renderer without coupling to the form-mode editor's three-pane layout.

Discipline:
- Snapshot test seeded under the original PrimitiveInspector first;
  component then extracted; snapshots re-verified byte-identical.
- Renamed export from PrimitiveInspector to ParamField; props,
  data-testids, classNames, and Zod-internal access patterns preserved
  verbatim — including the existing block-move-type and override-promotion
  Zod-v4 enum bug (out of scope to fix here).
- Internal helpers (isAttrFieldName, attrFieldPreferredType,
  attrFieldMode, collectSeededAttrs, ATTR_FIELD_NAMES) move with the
  component; generateDefaultParams stays in CustomModifierEditor since
  the palette button still uses it.

Snapshot harness uses react-dom/server#renderToStaticMarkup for
deterministic SSR — no @testing-library dependency added. vitest.config
include pattern widened to *.test.tsx alongside *.test.ts.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 16:57:42 -06:00
commit 776c192874
No known key found for this signature in database
5 changed files with 687 additions and 343 deletions

View file

@ -2,7 +2,7 @@ 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 { EffectPrimitiveNode, PrimitiveKind } from '../modifiers/primitives/types.js';
import type { CustomModifierDescriptor } from '../modifiers/custom/types.js';
import {
saveToCustomModifierLibrary,
@ -15,8 +15,7 @@ import {
CUSTOM_MODIFIER_RECIPES,
type CustomModifierRecipe,
} from '../modifiers/custom/recipes.js';
import { AttrCombobox } from './AttrCombobox.js';
import type { AttrTypeHint } from './attr-suggestions.js';
import { ParamField } from './ParamField.js';
interface Props {
isOpen: boolean;
@ -403,7 +402,7 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
Select a primitive to edit its parameters.
</div>
) : (
<PrimitiveInspector
<ParamField
node={descriptor.primitives[selectedIndex]}
primitive={PRIMITIVE_REGISTRY.get(descriptor.primitives[selectedIndex].kind)!}
allPrimitives={descriptor.primitives}
@ -532,341 +531,3 @@ export function CustomModifierEditor({ isOpen, onClose, onShareWithRoom }: Props
);
}
/**
* Field names we render with the AttrCombobox instead of a plain
* text input. All the attr-string params in the T3 primitive set
* seed-attribute.attr, add-to-attribute.attr, multiply-attribute.attr,
* absorb-damage-with-attribute.attr, add-aura.targetAttr use one
* of these two keys. Conditional.condition.attr lives inside a
* nested JSON struct (rendered via the complex-schema fallback);
* the combobox doesn't reach there.
*/
const ATTR_FIELD_NAMES = new Set<string>(['attr', 'targetAttr']);
function isAttrFieldName(key: string): boolean {
return ATTR_FIELD_NAMES.has(key);
}
/**
* Rank numeric suggestions first for primitives whose attr value is
* arithmetically consumed. seed-attribute accepts any runtime type
* so we leave it unopinionated (undefined = no preferred-type lift).
*/
function attrFieldPreferredType(
primitiveKind: string,
_fieldKey: string,
): AttrTypeHint | undefined {
switch (primitiveKind) {
case 'add-to-attribute':
case 'multiply-attribute':
case 'add-aura':
case 'absorb-damage-with-attribute':
return 'numeric';
default:
return undefined;
}
}
/**
* Does this primitive+field DECLARE a new attr (seed-attribute.attr)
* or CONSUME an existing one (add/multiply/absorb/aura)? Drives the
* AttrCombobox's mode so consume-mode can hide the illustrative
* user-defined-examples group and warn on unseeded names.
*/
function attrFieldMode(
primitiveKind: string,
_fieldKey: string,
): 'declare' | 'consume' {
if (primitiveKind === 'seed-attribute') return 'declare';
return 'consume';
}
/**
* Walk the current descriptor's primitive tree and collect every
* user-defined attr name a seed-attribute primitive declares. Used
* by the combobox to surface a "Seeded in this descriptor" group
* when the active field is a consume-site (add-to-attribute.attr,
* add-aura.targetAttr, etc.). Built-in attrs (ChessAttrMap keys)
* are omitted they already appear in the core groups.
*/
function collectSeededAttrs(
nodes: readonly EffectPrimitiveNode[],
): string[] {
const seen = new Set<string>();
const walk = (ns: readonly EffectPrimitiveNode[]): void => {
for (const node of ns) {
if (node.kind === 'seed-attribute') {
const params = node.params as { attr?: unknown } | undefined;
if (typeof params?.attr === 'string' && params.attr.length > 0) {
seen.add(params.attr);
}
}
// Recurse through trigger primitives' nested children so a
// seed-attribute inside on-turn-start / on-capture / conditional
// still contributes to the "seeded" set the inspector offers.
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive?.childPrimitives !== undefined) {
try {
walk(primitive.childPrimitives(node.params));
} catch {
/* malformed params → skip its children */
}
}
}
};
walk(nodes);
return [...seen];
}
/**
* 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,
allPrimitives,
onChange
}: {
node: EffectPrimitiveNode;
primitive?: EffectPrimitive;
/** Full descriptor tree — used to compute seededAttrs for consume-mode fields. */
allPrimitives: readonly EffectPrimitiveNode[];
onChange: (params: unknown) => void;
}) {
// Precompute the set of attr names declared by seed-attribute
// primitives anywhere in the current descriptor tree. The result
// feeds the AttrCombobox's "Seeded in this descriptor" group when
// the field is a consume-site.
const seededAttrs = useMemo(
() => collectSeededAttrs(allPrimitives),
[allPrimitives],
);
// Local expansion state for the docs panel. Default collapsed so the
// form fields stay the focal point; the user toggles "Show examples"
// when they want reference material.
const [docsExpanded, setDocsExpanded] = useState(true);
if (!primitive) {
return <div className="text-red-500 text-sm">Unknown primitive kind: {node.kind}</div>;
}
// Docs panel rendered once for every primitive, above whatever
// param-form we fall into below (object form vs JSON fallback).
const docsPanel = (
<div
data-testid="custom-primitive-docs"
className="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"
>
<button
type="button"
onClick={() => setDocsExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors"
aria-expanded={docsExpanded}
>
<div className="flex items-center gap-2">
<span className="text-blue-700 text-sm font-bold">{primitive.label}</span>
<span className="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">
{primitive.kind}
</span>
</div>
<span className="text-xs text-blue-600 font-medium">
{docsExpanded ? 'Hide' : 'Show'} docs & examples
</span>
</button>
{docsExpanded && (
<div className="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3">
<p className="leading-relaxed">
{primitive.longDescription ?? primitive.description}
</p>
{primitive.examples && primitive.examples.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-bold text-neutral-600 uppercase tracking-wide">
Examples
</div>
{primitive.examples.map((ex, i) => (
<div
key={i}
data-testid={`custom-primitive-example-${i}`}
className="bg-white border border-blue-200 rounded p-2.5"
>
<div className="text-xs font-semibold text-blue-800 mb-1">
{ex.title}
</div>
<pre className="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">
{JSON.stringify(ex.params, null, 2)}
</pre>
<p className="text-xs text-neutral-600 mt-1.5 italic leading-snug">
{ex.effect}
</p>
</div>
))}
</div>
)}
</div>
)}
</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">
{docsPanel}
<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">
{docsPanel}
{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' && isAttrFieldName(key) ? (
(() => {
const preferred = attrFieldPreferredType(primitive.kind, key);
const mode = attrFieldMode(primitive.kind, key);
// exactOptionalPropertyTypes: spread preferredType only
// when defined so we don't pass `undefined` explicitly.
return (
<AttrCombobox
value={String(value || '')}
onChange={(next) => onChange({ ...params, [key]: next })}
testId={`primitive-${primitive.kind}-${key}`}
placeholder="Attribute name…"
mode={mode}
seededAttrs={seededAttrs}
{...(preferred !== undefined ? { preferredType: preferred } : {})}
/>
);
})()
) : 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>
);
}

View file

@ -0,0 +1,142 @@
/**
* T14 regression baseline snapshots the current parameter-inspector
* rendering for every T3 primitive kind. Drives the pure refactor that
* extracts `PrimitiveInspector` into `./ParamField.tsx`; the extracted
* component must produce byte-identical HTML for each of these fixtures
* or the test fails and the extraction is rejected.
*
* Rendering approach: React SSR (`renderToStaticMarkup`) no DOM
* mount, no event handlers fire, purely the declarative tree. That
* keeps the snapshot deterministic (no ids, no focus, no portals) and
* avoids pulling in @testing-library just for HTML capture.
*/
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import "../modifiers/primitives/index.js";
import { PRIMITIVE_REGISTRY } from "../modifiers/primitives/index.js";
import type {
EffectPrimitiveNode,
PrimitiveKind,
} from "../modifiers/primitives/types.js";
import { ParamField } from "./ParamField.js";
/**
* Sample params per primitive kind. Chosen to exercise each field
* rendering branch:
* - attr-string fields (rendered via AttrCombobox)
* - number/boolean/enum/array/string fields (rendered via plain inputs)
* - complex-schema fallback (conditional, triggers with nested array)
* Values are literal no randomness so the captured HTML is stable.
*/
const SAMPLE_PARAMS: Record<PrimitiveKind, unknown> = {
"seed-attribute": { attr: "ShieldCharges", value: 3 },
"add-to-attribute": { attr: "Hp", delta: 2 },
"multiply-attribute": { attr: "Hp", factor: 2 },
"add-direction": { directions: ["forward", "backward"] },
"set-capture-flag": { flag: 2 },
"absorb-damage-with-attribute": { attr: "ShieldCharges", rate: 1 },
"reflect-damage": { percentage: 25 },
"block-move-type": { moveType: "capture" },
"modify-movement-range": { delta: 1 },
"override-promotion": { target: "knight" },
"add-aura": { radius: 2, targetAttr: "HpBonus", delta: 1 },
"on-turn-start": {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
"on-capture": {
primitives: [
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
],
},
"on-damaged": {
primitives: [{ kind: "reflect-damage", params: { percentage: 25 } }],
},
conditional: {
condition: { type: "attr-lt", attr: "Hp", value: 2 },
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
},
};
/**
* Deterministic wrapper renders ParamField with a no-op onChange
* (snapshots cover initial render only; event wiring is e2e territory)
* and a one-node descriptor tree so the seededAttrs computation stays
* predictable.
*/
function render(kind: PrimitiveKind): string {
const node: EffectPrimitiveNode = {
kind,
params: SAMPLE_PARAMS[kind],
};
const primitive = PRIMITIVE_REGISTRY.get(kind);
if (!primitive) throw new Error(`missing primitive: ${kind}`);
return renderToStaticMarkup(
<ParamField
node={node}
primitive={primitive}
allPrimitives={[node]}
onChange={() => {
/* snapshot test — event wiring covered elsewhere */
}}
/>,
);
}
describe("ParamField rendering (T14 regression baseline)", () => {
it("seed-attribute renders attr + value fields", () => {
expect(render("seed-attribute")).toMatchSnapshot();
});
it("add-to-attribute renders attr + delta fields", () => {
expect(render("add-to-attribute")).toMatchSnapshot();
});
it("multiply-attribute renders attr + factor fields", () => {
expect(render("multiply-attribute")).toMatchSnapshot();
});
it("add-direction renders directions array fallback", () => {
expect(render("add-direction")).toMatchSnapshot();
});
it("set-capture-flag renders flag enum", () => {
expect(render("set-capture-flag")).toMatchSnapshot();
});
it("absorb-damage-with-attribute renders attr + rate", () => {
expect(render("absorb-damage-with-attribute")).toMatchSnapshot();
});
it("reflect-damage renders percentage", () => {
expect(render("reflect-damage")).toMatchSnapshot();
});
/**
* `block-move-type` and `override-promotion` use `z.enum([...])`.
* The inspector's current introspection reads `_def.values`, which
* is `undefined` on Zod v4's ZodEnum (shape renamed to `entries`).
* That means the inline component throws before rendering for these
* kinds a pre-existing behaviour the pure refactor must preserve.
* The assertion below pins the throw so the extracted `ParamField`
* continues to reproduce it exactly.
*/
it("block-move-type throws today (pre-existing z.enum bug)", () => {
expect(() => render("block-move-type")).toThrow();
});
it("modify-movement-range renders delta", () => {
expect(render("modify-movement-range")).toMatchSnapshot();
});
it("override-promotion throws today (pre-existing z.enum bug)", () => {
expect(() => render("override-promotion")).toThrow();
});
it("add-aura renders radius + targetAttr + delta", () => {
expect(render("add-aura")).toMatchSnapshot();
});
it("on-turn-start renders primitives-array fallback", () => {
expect(render("on-turn-start")).toMatchSnapshot();
});
it("on-capture renders primitives-array fallback", () => {
expect(render("on-capture")).toMatchSnapshot();
});
it("on-damaged renders primitives-array fallback", () => {
expect(render("on-damaged")).toMatchSnapshot();
});
it("conditional renders complex-schema JSON fallback", () => {
expect(render("conditional")).toMatchSnapshot();
});
});

View file

@ -0,0 +1,345 @@
import { useState, useMemo } from 'react';
import { type ZodType, z } from 'zod';
import { PRIMITIVE_REGISTRY } from '../modifiers/primitives/index.js';
import type { EffectPrimitiveNode, EffectPrimitive } from '../modifiers/primitives/types.js';
import { AttrCombobox } from './AttrCombobox.js';
import type { AttrTypeHint } from './attr-suggestions.js';
/**
* Field names we render with the AttrCombobox instead of a plain
* text input. All the attr-string params in the T3 primitive set
* seed-attribute.attr, add-to-attribute.attr, multiply-attribute.attr,
* absorb-damage-with-attribute.attr, add-aura.targetAttr use one
* of these two keys. Conditional.condition.attr lives inside a
* nested JSON struct (rendered via the complex-schema fallback);
* the combobox doesn't reach there.
*/
const ATTR_FIELD_NAMES = new Set<string>(['attr', 'targetAttr']);
function isAttrFieldName(key: string): boolean {
return ATTR_FIELD_NAMES.has(key);
}
/**
* Rank numeric suggestions first for primitives whose attr value is
* arithmetically consumed. seed-attribute accepts any runtime type
* so we leave it unopinionated (undefined = no preferred-type lift).
*/
function attrFieldPreferredType(
primitiveKind: string,
_fieldKey: string,
): AttrTypeHint | undefined {
switch (primitiveKind) {
case 'add-to-attribute':
case 'multiply-attribute':
case 'add-aura':
case 'absorb-damage-with-attribute':
return 'numeric';
default:
return undefined;
}
}
/**
* Does this primitive+field DECLARE a new attr (seed-attribute.attr)
* or CONSUME an existing one (add/multiply/absorb/aura)? Drives the
* AttrCombobox's mode so consume-mode can hide the illustrative
* user-defined-examples group and warn on unseeded names.
*/
function attrFieldMode(
primitiveKind: string,
_fieldKey: string,
): 'declare' | 'consume' {
if (primitiveKind === 'seed-attribute') return 'declare';
return 'consume';
}
/**
* Walk the current descriptor's primitive tree and collect every
* user-defined attr name a seed-attribute primitive declares. Used
* by the combobox to surface a "Seeded in this descriptor" group
* when the active field is a consume-site (add-to-attribute.attr,
* add-aura.targetAttr, etc.). Built-in attrs (ChessAttrMap keys)
* are omitted they already appear in the core groups.
*/
function collectSeededAttrs(
nodes: readonly EffectPrimitiveNode[],
): string[] {
const seen = new Set<string>();
const walk = (ns: readonly EffectPrimitiveNode[]): void => {
for (const node of ns) {
if (node.kind === 'seed-attribute') {
const params = node.params as { attr?: unknown } | undefined;
if (typeof params?.attr === 'string' && params.attr.length > 0) {
seen.add(params.attr);
}
}
// Recurse through trigger primitives' nested children so a
// seed-attribute inside on-turn-start / on-capture / conditional
// still contributes to the "seeded" set the inspector offers.
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
if (primitive?.childPrimitives !== undefined) {
try {
walk(primitive.childPrimitives(node.params));
} catch {
/* malformed params → skip its children */
}
}
}
};
walk(nodes);
return [...seen];
}
/**
* 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.
*/
export function ParamField({
node,
primitive,
allPrimitives,
onChange
}: {
node: EffectPrimitiveNode;
primitive?: EffectPrimitive;
/** Full descriptor tree — used to compute seededAttrs for consume-mode fields. */
allPrimitives: readonly EffectPrimitiveNode[];
onChange: (params: unknown) => void;
}) {
// Precompute the set of attr names declared by seed-attribute
// primitives anywhere in the current descriptor tree. The result
// feeds the AttrCombobox's "Seeded in this descriptor" group when
// the field is a consume-site.
const seededAttrs = useMemo(
() => collectSeededAttrs(allPrimitives),
[allPrimitives],
);
// Local expansion state for the docs panel. Default collapsed so the
// form fields stay the focal point; the user toggles "Show examples"
// when they want reference material.
const [docsExpanded, setDocsExpanded] = useState(true);
if (!primitive) {
return <div className="text-red-500 text-sm">Unknown primitive kind: {node.kind}</div>;
}
// Docs panel rendered once for every primitive, above whatever
// param-form we fall into below (object form vs JSON fallback).
const docsPanel = (
<div
data-testid="custom-primitive-docs"
className="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"
>
<button
type="button"
onClick={() => setDocsExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors"
aria-expanded={docsExpanded}
>
<div className="flex items-center gap-2">
<span className="text-blue-700 text-sm font-bold">{primitive.label}</span>
<span className="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">
{primitive.kind}
</span>
</div>
<span className="text-xs text-blue-600 font-medium">
{docsExpanded ? 'Hide' : 'Show'} docs & examples
</span>
</button>
{docsExpanded && (
<div className="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3">
<p className="leading-relaxed">
{primitive.longDescription ?? primitive.description}
</p>
{primitive.examples && primitive.examples.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-bold text-neutral-600 uppercase tracking-wide">
Examples
</div>
{primitive.examples.map((ex, i) => (
<div
key={i}
data-testid={`custom-primitive-example-${i}`}
className="bg-white border border-blue-200 rounded p-2.5"
>
<div className="text-xs font-semibold text-blue-800 mb-1">
{ex.title}
</div>
<pre className="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">
{JSON.stringify(ex.params, null, 2)}
</pre>
<p className="text-xs text-neutral-600 mt-1.5 italic leading-snug">
{ex.effect}
</p>
</div>
))}
</div>
)}
</div>
)}
</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">
{docsPanel}
<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">
{docsPanel}
{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' && isAttrFieldName(key) ? (
(() => {
const preferred = attrFieldPreferredType(primitive.kind, key);
const mode = attrFieldMode(primitive.kind, key);
// exactOptionalPropertyTypes: spread preferredType only
// when defined so we don't pass `undefined` explicitly.
return (
<AttrCombobox
value={String(value || '')}
onChange={(next) => onChange({ ...params, [key]: next })}
testId={`primitive-${primitive.kind}-${key}`}
placeholder="Attribute name…"
mode={mode}
seededAttrs={seededAttrs}
{...(preferred !== undefined ? { preferredType: preferred } : {})}
/>
);
})()
) : 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>
);
}

View file

@ -0,0 +1,196 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ParamField rendering (T14 regression baseline) > absorb-damage-with-attribute renders attr + rate 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Absorb Damage with Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">absorb-damage-with-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Declares that incoming damage should first deplete a user-chosen counter (rate points per damage) before touching HP. You must seed the counter itself with seed-attribute — this primitive only wires the absorb mechanic, not the charge supply.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">3-charge shield (pair with seed-attribute)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;rate&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Pair with seed-attribute {attr: &#x27;ShieldCharges&#x27;, value: 3}. Each damage point consumes one charge; after 3 damage, HP starts taking hits.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Hardened armor (rate=2)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ArmorPlates&quot;,
&quot;rate&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Each damage point consumes 2 ArmorPlates instead of HP — makes plates deplete twice as fast but with the same absorption curve.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-absorb-damage-with-attribute-attr" data-recognized="false" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-absorb-damage-with-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/><span class="text-[10px] font-semibold px-1.5 py-0.5 rounded text-red-800 bg-red-50 border border-red-200" title="This attribute isn&#x27;t a built-in and isn&#x27;t seeded by any primitive in this descriptor. Reading it will be a no-op unless seeded elsewhere." data-testid="primitive-absorb-damage-with-attribute-attr-badge">not seeded</span></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">rate</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > add-aura renders radius + targetAttr + delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Aura</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-aura</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Radiates a numeric contribution to targetAttr onto every piece within \`radius\` (Chebyshev / king-move distance — radius 1 = 8 neighbours). Recomputes after every move; pieces moving out of range lose the contribution on the next pass. Self-application is skipped. Multiple auras to the same targetAttr from different sources accumulate additively.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">King aura: +1 HP within 2 squares</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 2,
&quot;targetAttr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every friendly or enemy piece within 2 squares of this piece gains +1 HpBonus while in range.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Adjacent range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;radius&quot;: 1,
&quot;targetAttr&quot;: &quot;RangeBonus&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Anyone standing next to this piece (8 neighbouring squares) gets +1 to range.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">radius</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">targetAttr</label><div class="relative" data-testid="primitive-add-aura-targetAttr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-aura-targetAttr-input" aria-autocomplete="list" aria-expanded="false" value="HpBonus"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > add-direction renders directions array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add Direction</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-direction</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Appends one or more color-relative named directions into the piece&#x27;s DirectionAdditions array, deduplicated by name. Composes with the built-in Direction Additions modifier — both write to the same fact. Valid directions: forward, backward, left, right, diagonal-fl, diagonal-fr, diagonal-bl, diagonal-br.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Backward-capable pawn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;backward&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Lets a pawn step backward as well as forward.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Full omnidirectional king-lite</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;directions&quot;: [
&quot;forward&quot;,
&quot;backward&quot;,
&quot;left&quot;,
&quot;right&quot;
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds all 4 orthogonal directions in one primitive. Diagonal names are listed separately if you need them.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">directions</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
&quot;forward&quot;,
&quot;backward&quot;
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > add-to-attribute renders attr + delta fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Add To Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">add-to-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the current numeric value of attr (0 if unset) and writes existing + delta. Delta may be negative. Composes additively with other primitives and built-in modifiers — multiple add-to-attribute primitives for the same attr simply accumulate.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+2 HP bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;HpBonus&quot;,
&quot;delta&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Adds 2 to whatever HpBonus is already there.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Heal 1/turn (inside on-turn-start)</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Wrapped in on-turn-start, restores 1 HP to this piece at the start of its color&#x27;s turn.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-add-to-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-add-to-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > conditional renders complex-schema JSON fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Conditional</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">conditional</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Branches on a condition. If true → runs every primitive in \`then\`; if false and \`else\` is set → runs \`else\`. Condition types: attr-lt (numeric less-than), attr-gt (numeric greater-than), attr-eq (exact match against string/number/boolean/null), always (unconditional then), never (forces else path only).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Low-HP fortress</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;attr-lt&quot;,
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 2
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When Hp drops below 2, the piece gains CANNOT_BE_CAPTURED — a last-stand invulnerability.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Unconditional thorns example</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;condition&quot;: {
&quot;type&quot;: &quot;always&quot;
},
&quot;then&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 10
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Equivalent to applying reflect-damage unconditionally; useful as a template you can later tighten.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">condition</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="[object Object]"/></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">then</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;set-capture-flag&quot;,
&quot;params&quot;: {
&quot;flag&quot;: 2
}
}
]</textarea></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">else</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > modify-movement-range renders delta 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Modify Movement Range</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">modify-movement-range</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Adds delta to the piece&#x27;s RangeBonus. Composes additively with the built-in Range Bonus modifier and with other modify-movement-range primitives. Delta is clamped to integer range [-7, 7]. Rook/bishop/queen sliding is extended/reduced by this amount; knight/king ranges are treated by their own pipeline.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">+1 range buff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">A rook&#x27;s horizontal slide reaches one square further than its baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">-2 range debuff</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;delta&quot;: -2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Cuts 2 squares from the piece&#x27;s reach (useful for &#x27;slowed&#x27; tokens).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">delta</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="1"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > multiply-attribute renders attr + factor fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Multiply Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">multiply-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reads the existing numeric value of attr and writes existing * factor. No-op if the attribute is unset — it does NOT treat absent as 1. Use after seed-attribute or add-to-attribute when you need a baseline to scale.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Double HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;factor&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If the piece already has 4 HP, becomes 8 HP.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Halve range bonus</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;RangeBonus&quot;,
&quot;factor&quot;: 0.5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">If RangeBonus is already 4, becomes 2 (rounded per attr consumer). Silently skipped if RangeBonus is unset.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-multiply-attribute-attr" data-recognized="true" data-mode="consume"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-multiply-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="Hp"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">factor</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > on-capture renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Capture</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-capture</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire when this piece captures another. Typical uses: &#x27;vampire&#x27; lifesteal (heal on capture), stacking buffs, or power-up triggers. Fires only on actual captures, not on quiet moves.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Vampire lifesteal</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Every time this piece captures an enemy, it gains 1 HP. Stacks over a long game.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > on-damaged renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Damaged</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-damaged</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps nested primitives that fire whenever this piece takes damage. Useful for reactive behaviours: auto-thorns, emergency buffs, or conditional transformations when HP crosses a threshold (combine with \`conditional\`).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Thorns on hit</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">When this piece takes damage, reflects 25% back to the attacker for that hit.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;reflect-damage&quot;,
&quot;params&quot;: {
&quot;percentage&quot;: 25
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > on-turn-start renders primitives-array fallback 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">On Turn Start</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">on-turn-start</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Wraps a list of nested primitives that fire at the start of this piece&#x27;s color&#x27;s turn. Use for recurring buffs/healing/debuffs tied to turn cadence. The editor&#x27;s Parameter Inspector accepts the nested \`primitives\` array as JSON; copy snippets from the simpler primitives into that array.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Regenerate 1 HP/turn</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;primitives&quot;: [
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">At the start of every turn, this piece regains 1 HP (until capped by its damage pipeline).</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">primitives</label><div class="border border-neutral-200 rounded bg-white overflow-hidden flex flex-col"><textarea class="w-full h-32 p-2 text-xs font-mono border-0 focus:ring-0 resize-none" placeholder="[ ... ]">[
{
&quot;kind&quot;: &quot;add-to-attribute&quot;,
&quot;params&quot;: {
&quot;attr&quot;: &quot;Hp&quot;,
&quot;delta&quot;: 1
}
}
]</textarea></div></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > reflect-damage renders percentage 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Reflect Damage</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">reflect-damage</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Reflects a percentage of incoming damage back to the attacker. Integer percent, 0-100. Multiple reflect primitives on the same piece do NOT stack — the most recent value wins. Great inside on-damaged if you want a one-time thorns reaction instead of a permanent aura.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Half-reflective armour</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 50
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">50% of incoming damage is dealt back to the attacker.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Total thorns</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;percentage&quot;: 100
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Full reflection — the attacker takes whatever they dealt.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">percentage</label><input type="number" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="25"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > seed-attribute renders attr + value fields 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Seed Attribute</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">seed-attribute</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Writes { attr, value } directly onto the piece, overwriting any existing value. Use to introduce new attributes (like a custom ShieldCharges counter) or to force a baseline (e.g. set HP to an exact number regardless of inheritance). Pair with add-to-attribute / multiply-attribute to build up a final value.</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Force exact HP</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;Hp&quot;,
&quot;value&quot;: 5
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Piece always starts with 5 HP regardless of baseline.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Declare shield charges</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;attr&quot;: &quot;ShieldCharges&quot;,
&quot;value&quot;: 3
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Creates a 3-charge counter. Combine with absorb-damage-with-attribute to make each charge soak one damage point.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">attr</label><div class="relative" data-testid="primitive-seed-attribute-attr" data-recognized="true" data-mode="declare"><div class="flex items-center gap-2"><input type="text" placeholder="Attribute name…" class="flex-1 px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" data-testid="primitive-seed-attribute-attr-input" aria-autocomplete="list" aria-expanded="false" value="ShieldCharges"/></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">value</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="3"/></div></div>"
`;
exports[`ParamField rendering (T14 regression baseline) > set-capture-flag renders flag enum 1`] = `
"<div class="flex flex-col gap-5"><div data-testid="custom-primitive-docs" class="mb-5 rounded-lg border border-blue-200 bg-blue-50/60 overflow-hidden"><button type="button" class="w-full flex items-center justify-between px-4 py-2.5 text-left hover:bg-blue-100/60 transition-colors" aria-expanded="true"><div class="flex items-center gap-2"><span class="text-blue-700 text-sm font-bold">Set Capture Flag</span><span class="text-xs font-mono text-blue-600/80 bg-blue-100 px-1.5 py-0.5 rounded">set-capture-flag</span></div><span class="text-xs text-blue-600 font-medium">Hide docs &amp; examples</span></button><div class="px-4 py-3 border-t border-blue-200 text-sm text-neutral-700 space-y-3"><p class="leading-relaxed">Turns on one capture-flag bit. Flags combine (OR) so stacking multiple primitives is fine. Supported: 1 = CAN_CAPTURE_OWN (piece may capture its own color), 2 = CANNOT_BE_CAPTURED (untargetable by enemies), 4 = EN_PASSANT (piece participates in en-passant capture resolution).</p><div class="space-y-2"><div class="text-xs font-bold text-neutral-600 uppercase tracking-wide">Examples</div><div data-testid="custom-primitive-example-0" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Untouchable piece</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 2
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CANNOT_BE_CAPTURED — no enemy move can target this piece.</p></div><div data-testid="custom-primitive-example-1" class="bg-white border border-blue-200 rounded p-2.5"><div class="text-xs font-semibold text-blue-800 mb-1">Friendly-fire rook</div><pre class="text-xs font-mono text-neutral-700 bg-neutral-50 px-2 py-1.5 rounded overflow-x-auto">{
&quot;flag&quot;: 1
}</pre><p class="text-xs text-neutral-600 mt-1.5 italic leading-snug">Sets CAN_CAPTURE_OWN — the piece may capture its own color&#x27;s pieces.</p></div></div></div></div><div class="flex flex-col gap-1.5"><label class="text-xs font-bold text-neutral-700">flag</label><input type="text" class="px-3 py-2 text-sm border border-neutral-300 rounded focus:ring-2 focus:ring-blue-500 focus:outline-none" value="2"/></div></div>"
`;

View file

@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
name: "chess",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
include: ["src/**/*.test.ts", "src/**/*.test.tsx", "tests/**/*.test.ts"],
environment: "happy-dom",
},
});