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:
parent
3bdfba38e0
commit
699c288a98
9 changed files with 602 additions and 12 deletions
|
|
@ -158,6 +158,101 @@ test.describe('Modifier profile ⇄ layout binding', () => {
|
||||||
).toHaveCount(0);
|
).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 }) => {
|
test('editor persists the bound layout on save', async ({ page }) => {
|
||||||
await freshLobby(page);
|
await freshLobby(page);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,28 @@ describe("saveToLibrary()", () => {
|
||||||
expect(reloaded?.profile.layoutId).toBe("classic");
|
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", () => {
|
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
|
||||||
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
|
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
|
||||||
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,20 @@ export const InstanceModifierSchema = z.object({
|
||||||
// ModifierProfile
|
// 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({
|
export const ModifierProfileSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -80,6 +94,7 @@ export const ModifierProfileSchema = z.object({
|
||||||
layoutId: z.string().optional(),
|
layoutId: z.string().optional(),
|
||||||
perType: z.array(TypeModifierSchema),
|
perType: z.array(TypeModifierSchema),
|
||||||
perInstance: z.array(InstanceModifierSchema),
|
perInstance: z.array(InstanceModifierSchema),
|
||||||
|
presetActivations: z.array(PresetActivationSchema).optional(),
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
source: z.enum(["premade", "custom"]),
|
source: z.enum(["premade", "custom"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
*/
|
*/
|
||||||
import type { EntityId, Session } from "@paratype/rete";
|
import type { EntityId, Session } from "@paratype/rete";
|
||||||
import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js";
|
import type { PieceType, PieceColor, ChessAttrKey } from "../schema.js";
|
||||||
|
import type { PresetActivation } from "../presets/active-set.js";
|
||||||
import type { ZodType } from "zod";
|
import type { ZodType } from "zod";
|
||||||
|
|
||||||
/** The six T1 modifier category identifiers. */
|
/** The six T1 modifier category identifiers. */
|
||||||
|
|
@ -84,6 +85,17 @@ export interface ModifierProfile {
|
||||||
readonly layoutId?: string | undefined;
|
readonly layoutId?: string | undefined;
|
||||||
readonly perType: readonly TypeModifier[];
|
readonly perType: readonly TypeModifier[];
|
||||||
readonly perInstance: readonly InstanceModifier[];
|
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 version: 1;
|
||||||
readonly source: "premade" | "custom";
|
readonly source: "premade" | "custom";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,13 @@ export function LayoutPicker({
|
||||||
key={presetId}
|
key={presetId}
|
||||||
data-testid={`layout-suggested-preset-${presetId}`}
|
data-testid={`layout-suggested-preset-${presetId}`}
|
||||||
type="button"
|
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)}
|
onClick={() => togglePreset(presetId)}
|
||||||
disabled={disabled || !setPresets}
|
disabled={disabled || !setPresets}
|
||||||
className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${
|
className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
setSelectedLayout(layout);
|
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 {
|
} catch {
|
||||||
/* invalid param — ignore */
|
/* invalid param — ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +243,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
setSelectedLayout(layout);
|
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);
|
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];
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import { CustomModifierEditor } from './CustomModifierEditor.js';
|
||||||
import { PerInstancePanel } from './PerInstancePanel';
|
import { PerInstancePanel } from './PerInstancePanel';
|
||||||
import { PerTypePanel } from './PerTypePanel.js';
|
import { PerTypePanel } from './PerTypePanel.js';
|
||||||
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
|
import { ConflictResolutionPanel } from './ConflictResolutionPanel.js';
|
||||||
|
import { PresetPanel, lintPresetActivations } from './PresetPanel.js';
|
||||||
|
import type { PresetActivation } from '@paratype/chess';
|
||||||
|
|
||||||
export type ModifierClipboard =
|
export type ModifierClipboard =
|
||||||
| { kind: 'empty' }
|
| { kind: 'empty' }
|
||||||
|
|
@ -106,6 +108,14 @@ export function ModifierProfileEditor({
|
||||||
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
const [boundLayout, setBoundLayout] = useState<StartingLayout | null>(null);
|
||||||
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
|
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
|
||||||
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
|
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.
|
// Fresh profile whenever the modal opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -168,9 +178,23 @@ export function ModifierProfileEditor({
|
||||||
|
|
||||||
if (!isOpen) return null;
|
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 ─────────────────────────────────────────────────
|
// ── Library actions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleSaveToLibrary() {
|
function handleSaveToLibrary() {
|
||||||
|
if (hasPresetErrors) {
|
||||||
|
toast.error(
|
||||||
|
`Fix preset conflicts before saving: ${presetLint.errors[0]}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const entry: SavedModifierProfile = {
|
const entry: SavedModifierProfile = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name.trim() || 'Untitled Profile',
|
name: profile.name.trim() || 'Untitled Profile',
|
||||||
|
|
@ -335,7 +359,9 @@ export function ModifierProfileEditor({
|
||||||
<button
|
<button
|
||||||
data-testid="save-profile"
|
data-testid="save-profile"
|
||||||
onClick={handleSaveToLibrary}
|
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
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -449,14 +475,83 @@ export function ModifierProfileEditor({
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* RIGHT — profile library (T23) */}
|
{/* RIGHT COLUMN — tabbed between Presets (bundle rule
|
||||||
<ProfileLibraryPanel
|
variants on this profile) and Library (load/save). The
|
||||||
key={libraryVersion}
|
two subpanels share real estate so fitting four full-
|
||||||
onLoad={handleLoadFromLibrary}
|
width aside columns doesn't squeeze Per-Instance below
|
||||||
onDelete={handleDeleteFromLibrary}
|
its minimum comfortable width (need room for the 8×8
|
||||||
onStarToggle={handleStarToggle}
|
board preview). */}
|
||||||
onDuplicate={handleDuplicate}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<CustomModifierEditor
|
<CustomModifierEditor
|
||||||
|
|
@ -491,8 +586,8 @@ function ProfileLibraryPanel({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
|
<div className="h-full flex flex-col overflow-hidden">
|
||||||
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
|
<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">
|
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||||
Profiles
|
Profiles
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -566,6 +661,6 @@ function ProfileLibraryPanel({
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
297
packages/chess/src/ui/PresetPanel.tsx
Normal file
297
packages/chess/src/ui/PresetPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -260,6 +260,19 @@ export type InstanceModifierWire = z.infer<typeof InstanceModifierSchema>;
|
||||||
* drift without requiring us to export the chess-side zod schema across
|
* drift without requiring us to export the chess-side zod schema across
|
||||||
* a zod major-version boundary.
|
* 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({
|
export const ModifierProfileSchema = z.object({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
|
@ -267,6 +280,7 @@ export const ModifierProfileSchema = z.object({
|
||||||
layoutId: z.string().optional(),
|
layoutId: z.string().optional(),
|
||||||
perType: z.array(TypeModifierSchema),
|
perType: z.array(TypeModifierSchema),
|
||||||
perInstance: z.array(InstanceModifierSchema),
|
perInstance: z.array(InstanceModifierSchema),
|
||||||
|
presetActivations: z.array(PresetActivationWireSchema).optional(),
|
||||||
version: z.literal(1),
|
version: z.literal(1),
|
||||||
source: z.enum(["premade", "custom"]),
|
source: z.enum(["premade", "custom"]),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue