feat(modifiers): bundle preset activations on profiles + editor Presets tab

Profiles can now carry a list of PresetActivation entries alongside
their per-type / per-instance modifiers. When such a profile is
picked in the Lobby, its bundled presets are unioned with any
layout suggestedPresets (profile config wins on id-collision).

Changes:

- ModifierProfile.presetActivations added (optional readonly array).
  Zod schema + server wire schema mirror the field; drift guard
  picks up forgotten updates on either side.
- ModifierProfileEditor grows a Presets tab in the right column
  sharing space with the Library tab. Each preset row carries a
  checkbox, scope radio (both/white/black), and a turns counter
  (blank = permanent). Inline diagnostics warn on redundant layout
  overlap and on loose-scope incompatibility; hard errors under
  overlapping scope disable Save.
- Lobby merges profile.presetActivations into its active preset
  set on profile select, on editor close, and on URL deep-link.
- LayoutPicker suggested-preset chips now expose aria-pressed so
  the active state is readable by assistive tech + e2e tests.

Tests: library round-trip preserves presetActivations (unit); two
new e2e scenarios (pre-seeded bundled profile auto-activates; full
editor-author loop persists + reflects in the lobby). 1752 unit +
105 playwright all green.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 14:03:52 -06:00
commit 699c288a98
No known key found for this signature in database
9 changed files with 602 additions and 12 deletions

View file

@ -158,6 +158,101 @@ test.describe('Modifier profile ⇄ layout binding', () => {
).toHaveCount(0);
});
test('profile with bundled presetActivations auto-activates them in the Lobby', async ({
page,
}) => {
await freshLobby(page);
// Seed a profile bound to the pawns-only layout (which suggests
// first-promotion-wins) AND bundling that same preset. This lets
// us verify the chip flips to active purely from the profile
// selection — no user-tap on the suggested-preset chip involved.
await page.evaluate(
({ key }) => {
const entry = {
id: 'lib-with-preset',
name: 'FPW Profile',
profile: {
id: 'lib-with-preset',
name: 'FPW Profile',
description: '',
layoutId: 'pawns-only',
perType: [],
perInstance: [],
presetActivations: [
{
id: 'first-promotion-wins',
scope: 'both',
turnsRemaining: null,
},
],
version: 1,
source: 'custom',
},
starred: false,
updatedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify([entry]));
},
{ key: PROFILE_LIBRARY_KEY },
);
await page.reload();
await page.getByTestId('profile-picker').selectOption('lib-with-preset');
// Layout auto-snaps to pawns-only (layoutId binding, already
// covered elsewhere but confirmed here for precondition).
await expect(page.getByTestId('layout-picker')).toHaveValue('pawns-only');
// The layout's suggested-preset chip for first-promotion-wins
// should now be pressed because the profile's
// presetActivations entry populated the Lobby's `presets` state.
const chip = page.getByTestId(
'layout-suggested-preset-first-promotion-wins',
);
await expect(chip).toBeVisible();
await expect(chip).toHaveAttribute('aria-pressed', 'true');
});
test('editor bundles a preset on save, which auto-activates on reselect', async ({
page,
}) => {
await freshLobby(page);
// Open the editor via the picker's "Custom…" option.
await page.getByTestId('profile-picker').selectOption('custom');
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible({
timeout: 3000,
});
// Set name, bind the pawns-only layout, switch to the Presets tab.
await page.getByTestId('profile-name').fill('Bundled Presets Test');
await page.getByTestId('bound-layout-picker').selectOption('pawns-only');
await page.getByTestId('right-tab-presets').click();
await expect(page.getByTestId('preset-panel')).toBeVisible();
// Toggle the first-promotion-wins preset on.
await page
.getByTestId('preset-panel-toggle-first-promotion-wins')
.check();
// Scope defaults to "both" (the toggle button exposes aria-checked).
await expect(
page.getByTestId('preset-panel-scope-first-promotion-wins-both'),
).toHaveAttribute('aria-checked', 'true');
// Save + close.
await page.getByTestId('save-profile').click();
await page.keyboard.press('Escape');
// Back in the lobby: the suggested-preset chip for the bundled
// rule shows as pressed because the editor-close handler applied
// the profile's bundled presets to the Lobby's active set.
const chip = page.getByTestId(
'layout-suggested-preset-first-promotion-wins',
);
await expect(chip).toBeVisible();
await expect(chip).toHaveAttribute('aria-pressed', 'true');
});
test('editor persists the bound layout on save', async ({ page }) => {
await freshLobby(page);

View file

@ -151,6 +151,28 @@ describe("saveToLibrary()", () => {
expect(reloaded?.profile.layoutId).toBe("classic");
});
it("preserves presetActivations through the save/load round-trip", () => {
const bundledProfile: ModifierProfile = {
...emptyProfile,
id: "bundled-profile",
presetActivations: [
{ id: "king-of-the-hill", scope: "both", turnsRemaining: null },
{ id: "piece-hp", scope: "white", turnsRemaining: 5 },
],
};
saveToLibrary(
seed({
id: "lib-bundled",
profile: bundledProfile,
}),
);
const [reloaded] = loadLibrary();
expect(reloaded?.profile.presetActivations).toEqual([
{ id: "king-of-the-hill", scope: "both", turnsRemaining: null },
{ id: "piece-hp", scope: "white", turnsRemaining: 5 },
]);
});
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {

View file

@ -73,6 +73,20 @@ export const InstanceModifierSchema = z.object({
// ModifierProfile
// ---------------------------------------------------------------------------
/**
* Preset activation bundled on a profile. Matches the runtime
* `PresetActivation` shape in presets/active-set.ts. We don't
* enum-check `id` against the registry here — the Lobby + server
* already tolerate unknown ids (skip + warn), and a strict enum
* would silently drop profiles that reference a preset not yet
* registered on the receiver (e.g. during a rolling schema bump).
*/
const PresetActivationSchema = z.object({
id: z.string().min(1),
scope: z.enum(["both", "white", "black"]),
turnsRemaining: z.number().int().positive().nullable(),
});
export const ModifierProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
@ -80,6 +94,7 @@ export const ModifierProfileSchema = z.object({
layoutId: z.string().optional(),
perType: z.array(TypeModifierSchema),
perInstance: z.array(InstanceModifierSchema),
presetActivations: z.array(PresetActivationSchema).optional(),
version: z.literal(1),
source: z.enum(["premade", "custom"]),
});

View file

@ -14,6 +14,7 @@
*/
import type { EntityId, Session } from "@paratype/rete";
import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js";
import type { PresetActivation } from "../presets/active-set.js";
import type { ZodType } from "zod";
/** The six T1 modifier category identifiers. */
@ -84,6 +85,17 @@ export interface ModifierProfile {
readonly layoutId?: string | undefined;
readonly perType: readonly TypeModifier[];
readonly perInstance: readonly InstanceModifier[];
/**
* Optional preset activations bundled with this profile. When the
* profile is picked in the Lobby, these are unioned with the
* layout's `suggestedPresets` (same-id entries favour the profile's
* full config — scope + turnsRemaining — over the layout's default
* `{scope: 'both', turnsRemaining: null}`). Validated at authoring
* time against ActivePresetSet so incompatible pairs can't be
* saved. Omit or leave empty to author a layout/preset-agnostic
* profile — the former behaviour.
*/
readonly presetActivations?: readonly PresetActivation[];
readonly version: 1;
readonly source: "premade" | "custom";
}

View file

@ -154,6 +154,13 @@ export function LayoutPicker({
key={presetId}
data-testid={`layout-suggested-preset-${presetId}`}
type="button"
// aria-pressed exposes the toggle state to both
// accessibility tooling and e2e tests. Matches the
// WAI-ARIA toggle-button pattern — isActive reads
// from the lobby-level `activations` array so it
// also reflects bundles coming from the selected
// modifier profile, not just user taps here.
aria-pressed={isActive}
onClick={() => togglePreset(presetId)}
disabled={disabled || !setPresets}
className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${

View file

@ -170,6 +170,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
setSelectedLayout(layout);
}
}
// Hydrate bundled presets — same union+dedupe contract as
// the picker's handleProfileChange so URL and in-lobby
// selection behave identically.
const urlBundled = profile.presetActivations;
if (urlBundled !== undefined && urlBundled.length > 0) {
setPresets((prev) => {
const next = prev.filter(
(p) => !urlBundled.some((b) => b.id === p.id),
);
return [...next, ...urlBundled];
});
}
} catch {
/* invalid param — ignore */
}
@ -231,6 +243,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
setSelectedLayout(layout);
}
}
// Union+dedupe the profile's bundled presets into the lobby's
// active preset set. Profile's explicit config (scope +
// turnsRemaining) wins when the same id already exists —
// authored intent > previously-selected defaults. Non-bundled
// entries stay intact so user toggles from a layout's
// suggestedPresets survive switching profiles.
const bundled = entry.profile.presetActivations;
if (bundled !== undefined && bundled.length > 0) {
setPresets((prev) => {
const next = prev.filter((p) => !bundled.some((b) => b.id === p.id));
return [...next, ...bundled];
});
}
}
/**
@ -256,6 +281,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
setSelectedLayout(layout);
}
}
// Same union+dedupe as handleProfileChange for bundled presets.
const bundled = newest.profile.presetActivations;
if (bundled !== undefined && bundled.length > 0) {
setPresets((prev) => {
const next = prev.filter((p) => !bundled.some((b) => b.id === p.id));
return [...next, ...bundled];
});
}
}
/**

View file

@ -25,6 +25,8 @@ import { CustomModifierEditor } from './CustomModifierEditor.js';
import { PerInstancePanel } from './PerInstancePanel';
import { PerTypePanel } from './PerTypePanel.js';
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
import { PresetPanel, lintPresetActivations } from './PresetPanel.js';
import type { PresetActivation } from '@paratype/chess';
export type ModifierClipboard =
| { kind: 'empty' }
@ -106,6 +108,14 @@ export function ModifierProfileEditor({
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
/**
* Which subpanel is visible in the right column. Presets and the
* Library share real estate so the main area keeps enough width
* for the per-instance board. Defaults to Library so the existing
* open → load flow is unchanged; users who want to bundle presets
* switch once and the editor remembers until the modal closes.
*/
const [rightTab, setRightTab] = useState<'library' | 'presets'>('library');
// Fresh profile whenever the modal opens.
useEffect(() => {
@ -168,9 +178,23 @@ export function ModifierProfileEditor({
if (!isOpen) return null;
// Lint the currently-authored preset bundle. Hard errors disable
// Save; soft warnings are surfaced in the PresetPanel itself.
const presetLint = lintPresetActivations(
profile.presetActivations ?? [],
boundLayout,
);
const hasPresetErrors = presetLint.errors.length > 0;
// ── Library actions ─────────────────────────────────────────────────
function handleSaveToLibrary() {
if (hasPresetErrors) {
toast.error(
`Fix preset conflicts before saving: ${presetLint.errors[0]}`,
);
return;
}
const entry: SavedModifierProfile = {
id: profile.id,
name: profile.name.trim() || 'Untitled Profile',
@ -335,7 +359,9 @@ export function ModifierProfileEditor({
<button
data-testid="save-profile"
onClick={handleSaveToLibrary}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
disabled={hasPresetErrors}
title={hasPresetErrors ? presetLint.errors[0] : undefined}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Save
</button>
@ -449,14 +475,83 @@ export function ModifierProfileEditor({
/>
</main>
{/* RIGHT — profile library (T23) */}
<ProfileLibraryPanel
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
/>
{/* RIGHT COLUMN — tabbed between Presets (bundle rule
variants on this profile) and Library (load/save). The
two subpanels share real estate so fitting four full-
width aside columns doesn't squeeze Per-Instance below
its minimum comfortable width (need room for the 8×8
board preview). */}
<aside
data-testid="right-column"
className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden"
>
<div
role="tablist"
aria-label="Right panel tabs"
className="flex border-b border-neutral-200"
>
<button
role="tab"
data-testid="right-tab-library"
aria-selected={rightTab === 'library'}
onClick={() => setRightTab('library')}
className={`flex-1 px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors ${
rightTab === 'library'
? 'bg-white text-neutral-900 border-b-2 border-blue-500 -mb-px'
: 'text-neutral-400 hover:bg-neutral-100'
}`}
>
Library
</button>
<button
role="tab"
data-testid="right-tab-presets"
aria-selected={rightTab === 'presets'}
onClick={() => setRightTab('presets')}
className={`flex-1 px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors ${
rightTab === 'presets'
? 'bg-white text-neutral-900 border-b-2 border-blue-500 -mb-px'
: 'text-neutral-400 hover:bg-neutral-100'
}`}
>
Presets
{(profile.presetActivations?.length ?? 0) > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center text-[10px] font-bold rounded-full bg-blue-100 text-blue-700 px-1.5 py-0.5">
{profile.presetActivations?.length ?? 0}
</span>
)}
</button>
</div>
<div className="flex-1 overflow-hidden">
{rightTab === 'library' ? (
<ProfileLibraryPanel
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
/>
) : (
<PresetPanel
activations={profile.presetActivations ?? []}
boundLayout={boundLayout}
onChange={(next: readonly PresetActivation[]) =>
pushSnapshot((p) =>
next.length === 0
? (() => {
const {
presetActivations: _drop,
...rest
} = p;
return { ...rest };
})()
: { ...p, presetActivations: next },
)
}
/>
)}
</div>
</aside>
</div>
</div>
<CustomModifierEditor
@ -491,8 +586,8 @@ function ProfileLibraryPanel({
});
return (
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
<div className="h-full flex flex-col overflow-hidden">
<div className="px-4 pt-3 pb-2 border-b border-neutral-200">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Profiles
</p>
@ -566,6 +661,6 @@ function ProfileLibraryPanel({
</ul>
)}
</div>
</aside>
</div>
);
}

View file

@ -0,0 +1,297 @@
/**
* PresetPanel — Modifier Profile Editor's "Presets" sub-panel.
*
* Lets the user bundle a list of `PresetActivation` entries on the
* profile so that picking the profile in the Lobby auto-activates
* the bundled presets (unioned with any layout `suggestedPresets`,
* profile config winning on id-collision).
*
* UI structure:
* - One row per registered preset (from PRESET_REGISTRY).
* - Checkbox on the left toggles activation.
* - When activated, scope radio (both/white/black) + turns input
* (number, 0 = permanent rendered as blank) appear inline.
* - Warning banners at the top when:
* - Profile is bound to a layout AND that layout already
* suggests a preset the profile bundles. (Harmless but
* redundant — info tone.)
* - Two activated presets target disjoint colors (white+black)
* yet are declared incompatible. (Useful callout — still
* allowed by ActivePresetSet's loose compatibility rule, but
* usually a sign the author didn't mean it.)
* - Hard-error banner when two bundled presets are incompatible
* under overlapping scope — drives validate() to return false so
* the Save button disables upstream.
*
* The panel treats `PresetActivation` as the canonical unit:
* checking a preset inserts `{id, scope:'both', turnsRemaining:null}`;
* toggling scope / turns mutates that entry in place.
*/
import { useMemo } from 'react';
import { PRESET_REGISTRY } from '@paratype/chess';
import type {
PresetActivation,
PresetScope,
StartingLayout,
} from '@paratype/chess';
export interface PresetPanelProps {
readonly activations: readonly PresetActivation[];
readonly onChange: (next: readonly PresetActivation[]) => void;
/**
* Profile's currently-bound layout (if any). Drives the
* "already suggested by this layout" redundancy notice.
*/
readonly boundLayout: StartingLayout | null;
}
/**
* Compute a list of lint-style warnings + hard errors for the
* current activation set. `hasError: true` is consumed upstream to
* disable the Save button.
*/
export function lintPresetActivations(
activations: readonly PresetActivation[],
boundLayout: StartingLayout | null,
): {
readonly errors: readonly string[];
readonly warnings: readonly string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Hard error: two bundled presets are incompatible AND their
// scopes overlap. Mirrors ActivePresetSet's runtime check —
// catching it at authoring time stops bad profiles from entering
// the library. We check every unordered pair once.
const seenPair = new Set<string>();
for (const a of activations) {
const defA = PRESET_REGISTRY.get(a.id);
if (defA === undefined) continue;
for (const b of activations) {
if (a.id === b.id) continue;
const pairKey = [a.id, b.id].sort().join('|');
if (seenPair.has(pairKey)) continue;
seenPair.add(pairKey);
if (!defA.incompatibleWith.includes(b.id)) continue;
const overlaps = scopesOverlap(a.scope, b.scope);
if (overlaps) {
errors.push(
`"${defA.name}" (${a.scope}) and "${PRESET_REGISTRY.get(b.id)?.name ?? b.id}" (${b.scope}) are incompatible under overlapping scope.`,
);
} else {
// Loose-compatible pair — allowed but almost always a mistake.
warnings.push(
`"${defA.name}" (${a.scope}) and "${PRESET_REGISTRY.get(b.id)?.name ?? b.id}" (${b.scope}) are declared incompatible but their scopes don't overlap. Allowed, but double-check this is intentional.`,
);
}
}
}
// Layout redundancy notice — when the bound layout already
// suggests a preset the profile bundles. Same-id duplicates are
// harmless (Lobby's union+dedupe favours the profile's config),
// but surfacing the overlap helps authors keep profiles lean.
if (boundLayout?.suggestedPresets !== undefined) {
for (const act of activations) {
if (boundLayout.suggestedPresets.includes(act.id)) {
const name = PRESET_REGISTRY.get(act.id)?.name ?? act.id;
warnings.push(
`"${name}" is already suggested by layout "${boundLayout.name}". Keeping it on the profile is fine — just redundant.`,
);
}
}
}
return { errors, warnings };
}
/**
* Internal helper mirroring ActivePresetSet's private scopesOverlap.
* Duplicated rather than imported to keep the UI module from pulling
* in the engine-side active-set state machine.
*/
function scopesOverlap(a: PresetScope, b: PresetScope): boolean {
if (a === 'both' || b === 'both') return true;
return a === b;
}
export function PresetPanel({
activations,
onChange,
boundLayout,
}: PresetPanelProps) {
const allPresets = useMemo(() => PRESET_REGISTRY.getAll(), []);
const activeById = useMemo(() => {
const m = new Map<string, PresetActivation>();
for (const a of activations) m.set(a.id, a);
return m;
}, [activations]);
const { errors, warnings } = useMemo(
() => lintPresetActivations(activations, boundLayout),
[activations, boundLayout],
);
/** Toggle a preset on/off in the activation list. */
const togglePreset = (id: string): void => {
const existing = activeById.get(id);
if (existing !== undefined) {
onChange(activations.filter((a) => a.id !== id));
return;
}
onChange([
...activations,
{ id, scope: 'both', turnsRemaining: null },
]);
};
const updateScope = (id: string, scope: PresetScope): void => {
onChange(
activations.map((a) => (a.id === id ? { ...a, scope } : a)),
);
};
const updateTurns = (id: string, turns: number | null): void => {
onChange(
activations.map((a) =>
a.id === id ? { ...a, turnsRemaining: turns } : a,
),
);
};
return (
<div
data-testid="preset-panel"
className="h-full flex flex-col overflow-hidden"
>
<div className="px-4 pt-3 pb-2 border-b border-neutral-200">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Bundled Presets
</p>
<p className="text-[11px] text-neutral-500 mt-1 leading-snug">
Rule variants to auto-activate when this profile is picked.
Unioned with the selected layout's suggestions; profile
config wins on overlap.
</p>
</div>
{/* Diagnostic banners */}
{(errors.length > 0 || warnings.length > 0) && (
<div className="px-3 pt-2 space-y-1.5">
{errors.map((e, i) => (
<div
key={`err-${String(i)}`}
data-testid={`preset-panel-error-${String(i)}`}
className="px-2.5 py-1.5 text-xs text-red-800 bg-red-50 border border-red-200 rounded"
>
⚠ {e}
</div>
))}
{warnings.map((w, i) => (
<div
key={`warn-${String(i)}`}
data-testid={`preset-panel-warning-${String(i)}`}
className="px-2.5 py-1.5 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded"
>
ℹ {w}
</div>
))}
</div>
)}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{allPresets.map((preset) => {
const active = activeById.get(preset.id);
return (
<div
key={preset.id}
data-testid={`preset-panel-row-${preset.id}`}
className={`rounded-lg border px-2.5 py-2 transition-colors ${
active !== undefined
? 'border-blue-300 bg-blue-50/40'
: 'border-neutral-200 bg-white'
}`}
>
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
data-testid={`preset-panel-toggle-${preset.id}`}
checked={active !== undefined}
onChange={() => togglePreset(preset.id)}
className="mt-0.5 h-4 w-4 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-neutral-800 truncate">
{preset.name}
</div>
<div className="text-[11px] text-neutral-500 leading-snug">
{preset.description}
</div>
</div>
</label>
{active !== undefined && (
<div className="mt-2 pl-6 flex flex-wrap items-center gap-2">
<div
role="radiogroup"
aria-label={`Scope for ${preset.name}`}
className="flex items-center gap-1"
>
{(['both', 'white', 'black'] as const).map((s) => {
const selected = active.scope === s;
return (
<button
key={s}
type="button"
role="radio"
aria-checked={selected}
data-testid={`preset-panel-scope-${preset.id}-${s}`}
onClick={() => updateScope(preset.id, s)}
className={`px-1.5 py-0.5 text-[10px] font-semibold rounded border ${
selected
? 'bg-neutral-900 text-white border-neutral-900'
: 'bg-white text-neutral-700 border-neutral-200 hover:bg-neutral-50'
}`}
>
{s}
</button>
);
})}
</div>
<label className="text-[10px] font-semibold text-neutral-500 uppercase tracking-wide flex items-center gap-1">
Turns
<input
type="number"
min={1}
step={1}
data-testid={`preset-panel-turns-${preset.id}`}
value={
active.turnsRemaining === null
? ''
: String(active.turnsRemaining)
}
placeholder="∞"
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
updateTurns(preset.id, null);
return;
}
const n = Number.parseInt(raw, 10);
if (Number.isFinite(n) && n >= 1) {
updateTurns(preset.id, n);
}
}}
className="w-14 px-1 py-0.5 text-[11px] border border-neutral-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</label>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View file

@ -260,6 +260,19 @@ export type InstanceModifierWire = z.infer<typeof InstanceModifierSchema>;
* drift without requiring us to export the chess-side zod schema across
* a zod major-version boundary.
*/
/**
* Wire schema for a bundled preset activation. Shape-mirrored with
* chess-side `PresetActivation` in presets/active-set.ts. We don't
* enum-check `id` against the registry here — the engine tolerates
* unknown ids and the drift guard below only checks key parity with
* the chess-side type.
*/
export const PresetActivationWireSchema = z.object({
id: z.string().min(1),
scope: z.enum(["both", "white", "black"]),
turnsRemaining: z.number().int().positive().nullable(),
});
export const ModifierProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
@ -267,6 +280,7 @@ export const ModifierProfileSchema = z.object({
layoutId: z.string().optional(),
perType: z.array(TypeModifierSchema),
perInstance: z.array(InstanceModifierSchema),
presetActivations: z.array(PresetActivationWireSchema).optional(),
version: z.literal(1),
source: z.enum(["premade", "custom"]),
});