feat(ui): group RulesDrawer presets by category + layout suggested-rules chips

Phase F.3 + F.2-UI of the rule-variants epic.

Preset metadata:
  - Added optional `category: 'king' | 'objective' | 'movement' |
    'multi-move' | 'pieces' | 'misc'` on PresetDef (registry.ts).
    Defaults to 'misc' when omitted.
  - Categorized all 28 registered presets:
    * King Variants: knightmate-rules, coregal, dual-king,
      weak-dual-king.
    * Objectives: capture-to-win, last-piece-standing,
      first-promotion-wins, suicide-chess, capture-all,
      extinction-chess.
    * Movement: pawns-move-backward, double-pawn-sprint,
      pawn-diagonal-no-capture, knights-leap-twice,
      bishops-ignore-color, rook-warp, wrap-board,
      berolina-pawns(+2), bouncing-pieces(+2), queen-splits.
    * Multi-move: double-move, monster-rules.
    * Pieces: piece-hp, king-heals, explosive-rook,
      knight-immunity, poisoned-squares.

UI:
  - RulesDrawer.tsx renders presets in category sections with
    sentence-case headers ('King Variants', 'Objectives',
    'Movement', 'Multi-move', 'Pieces', 'Misc'). Empty sections
    hidden. Each section has data-testid='rules-category-<id>' for
    e2e addressability. Existing search / scope / duration controls
    preserved.
  - LayoutPicker.tsx renders a 'Suggested rules' chip group under
    each layout with suggestedPresets.length > 0. Chips toggle
    presets on/off. Active = filled (neutral-800), inactive =
    ghost (neutral-100). data-testid='layout-suggested-preset-<id>'
    on each chip.
  - Lobby.tsx wires activations + setPresets props to LayoutPicker
    so chip toggles flow into the host-game payload
    (rulesetIds array).

Tests: 1651 passing (unchanged — pure UI metadata).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 09:19:38 -06:00
commit bd20032767
No known key found for this signature in database
33 changed files with 361 additions and 257 deletions

View file

@ -94,6 +94,7 @@ function emitMove(
}
PRESET_REGISTRY.register({
category: "movement",
id: PRESET_ID,
name: "Berolina Pawns (Extended)",
description:

View file

@ -134,6 +134,7 @@ function emitMove(
}
PRESET_REGISTRY.register({
category: "movement",
id: PRESET_ID,
name: "Berolina Pawns",
description:

View file

@ -18,6 +18,7 @@ const ORTHOGONAL_DELTAS: ReadonlyArray<readonly [number, number]> = [
];
PRESET_REGISTRY.register({
category: "movement",
id: "bishops-ignore-color",
name: "Colour-Blind Bishops",
description: "Bishops may additionally step one square orthogonally to an empty or enemy-occupied square.",

View file

@ -192,6 +192,7 @@ function walkBouncingRay(
}
PRESET_REGISTRY.register({
category: "movement",
id: "bouncing-pieces-2",
name: "Bouncing Pieces (Extended)",
description:

View file

@ -203,6 +203,7 @@ function walkBouncingRay(
}
PRESET_REGISTRY.register({
category: "movement",
id: "bouncing-pieces",
name: "Bouncing Pieces",
description:

View file

@ -94,6 +94,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { GameResult } from "../engine.js";
PRESET_REGISTRY.register({
category: "objective",
id: "capture-all",
name: "Capture All",
description: "Capture every enemy piece to win. Kings are not royal.",

View file

@ -34,6 +34,7 @@ interface CaptureToWinState extends Record<string, unknown> {
}
PRESET_REGISTRY.register({
category: "objective",
id: "capture-to-win",
name: "First Blood",
description:

View file

@ -64,6 +64,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
category: "king",
id: "coregal",
name: "Coregal",
description:

View file

@ -75,6 +75,7 @@
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
category: "multi-move",
id: "double-move",
name: "Double Move",
description:

View file

@ -54,6 +54,7 @@ function getDoubleSprintMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
}
PRESET_REGISTRY.register({
category: "movement",
id: "double-pawn-sprint",
name: "Perpetual Sprint",
description:

View file

@ -70,6 +70,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
category: "king",
id: "dual-king",
name: "Dual King",
description:

View file

@ -39,6 +39,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "explosive-rook",
name: "Detonating Rook",
description:

View file

@ -144,6 +144,7 @@ interface ExtinctionChessState extends Record<string, unknown> {
const DEFAULT_TARGET_TYPE: PieceType = "pawn";
PRESET_REGISTRY.register({
category: "objective",
id: "extinction-chess",
name: "Extinction",
description:

View file

@ -118,6 +118,7 @@ interface FirstPromotionWinsState extends Record<string, unknown> {
}
PRESET_REGISTRY.register({
category: "objective",
id: "first-promotion-wins",
name: "First to Promote Wins",
description:

View file

@ -35,6 +35,7 @@ function findKing(session: Session, color: PieceColor): EntityId | null {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "king-heals",
name: "Regenerating King",
description:

View file

@ -1,6 +1,7 @@
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
category: "pieces",
id: "knight-immunity",
name: "Bishop-Proof Knights",
description: "Knights are immune to capture by Bishops. Bishops cannot make moves that would capture a Knight.",

View file

@ -46,6 +46,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { EntityId } from "@paratype/rete";
PRESET_REGISTRY.register({
category: "king",
id: "knightmate-rules",
name: "Knightmate",
description:

View file

@ -12,6 +12,7 @@ import { isAllyAt, isPieceAt } from "../rules/board-queries.js";
import { PRESET_REGISTRY } from "./registry.js";
PRESET_REGISTRY.register({
category: "movement",
id: "knights-leap-twice",
name: "Double-Leap Knights",
description: "Knights move in two consecutive L-shapes in a single turn; the intermediate square must be empty.",

View file

@ -29,6 +29,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { GameResult } from "../engine.js";
PRESET_REGISTRY.register({
category: "objective",
id: "last-piece-standing",
name: "Annihilation",
description:

View file

@ -63,6 +63,7 @@ import { PRESET_REGISTRY } from "./registry.js";
const PRESET_ID = "monster-rules";
PRESET_REGISTRY.register({
category: "multi-move",
id: PRESET_ID,
name: "Monster",
description:

View file

@ -40,6 +40,7 @@ function getDiagonalQuietMoves(engine: ChessEngine, pieceId: EntityId): LegalMov
}
PRESET_REGISTRY.register({
category: "movement",
id: "pawn-diagonal-no-capture",
name: "Slanting Pawns",
description:

View file

@ -40,6 +40,7 @@ function getPawnBackwardMove(engine: ChessEngine, pieceId: EntityId): LegalMove[
}
PRESET_REGISTRY.register({
category: "movement",
id: "pawns-move-backward",
name: "Backward-Marching Pawns",
description:

View file

@ -74,6 +74,7 @@ function iteratePieceIds(session: Session): EntityId[] {
}
PRESET_REGISTRY.register({
category: "pieces",
id: "piece-hp",
name: "Hit Points",
description:

View file

@ -25,6 +25,7 @@ import type { EntityId } from "@paratype/rete";
export const POISONED_SQUARES: ReadonlySet<number> = new Set([27, 28, 35, 36]);
PRESET_REGISTRY.register({
category: "pieces",
id: "poisoned-squares",
name: "Poisoned Centre",
description:

View file

@ -82,6 +82,7 @@ function pieceAtSquare(session: Session, sq: Square): EntityId | null {
// on fission-spawned pieces automatically.
PRESET_REGISTRY.register({
category: "movement",
id: "queen-splits",
name: "Queen Fission",
description:

View file

@ -328,6 +328,11 @@ export interface PresetDef {
readonly id: string;
readonly name: string;
readonly description: string;
/**
* The category this preset belongs to for UI grouping.
* Defaults to "misc" if not specified.
*/
readonly category?: "king" | "objective" | "movement" | "multi-move" | "pieces" | "misc";
/** Preset IDs whose activation must NOT overlap with this one. */
readonly incompatibleWith: readonly string[];
/** Preset IDs that must also be active for this one to be valid. */

View file

@ -36,6 +36,7 @@ function warpDest(
}
PRESET_REGISTRY.register({
category: "movement",
id: "rook-warp",
name: "Rook Warp",
description: "After a rook's normal slide, it may warp around the board edge to the opposite end of its rank or file.",

View file

@ -126,6 +126,7 @@ import { PRESET_REGISTRY } from "./registry.js";
import type { ChessEngine, GameResult } from "../engine.js";
PRESET_REGISTRY.register({
category: "objective",
id: "suicide-chess",
name: "Suicide Chess (Antichess)",
description:

View file

@ -219,6 +219,7 @@ function hasSavingMove(engine: ChessEngine): boolean {
}
PRESET_REGISTRY.register({
category: "king",
id: "weak-dual-king",
name: "Weak Dual King",
description:

View file

@ -202,6 +202,7 @@ function computeCylinderMoves(
}
PRESET_REGISTRY.register({
category: "movement",
id: "wrap-board",
name: "Cylindrical Board",
description:

View file

@ -22,15 +22,21 @@
import { useMemo } from 'react';
import {
LAYOUT_REGISTRY,
PRESET_REGISTRY,
buildChess960Layout,
type StartingLayout,
} from '@paratype/chess';
import type { PresetActivation } from '../net/types.js';
export interface LayoutPickerProps {
/** Currently selected layout. Parents own the state. */
value: StartingLayout;
/** Called when the user picks a new premade. */
onChange: (layout: StartingLayout) => void;
/** Current authoritative preset activations (for toggling suggested rules). */
activations?: readonly PresetActivation[];
/** Replace the active set on the engine / server. */
setPresets?: (activations: PresetActivation[]) => void;
/** Called when the user picks the "Custom…" entry. */
onCustomRequested?: () => void;
/** Disables the control (during network requests, etc). */
@ -40,6 +46,8 @@ export interface LayoutPickerProps {
export function LayoutPicker({
value,
onChange,
activations = [],
setPresets,
onCustomRequested,
disabled = false,
}: LayoutPickerProps) {
@ -75,6 +83,16 @@ export function LayoutPicker({
}
}
const togglePreset = (presetId: string) => {
if (!setPresets) return;
const isActive = activations.some(a => a.id === presetId);
if (isActive) {
setPresets(activations.filter(a => a.id !== presetId));
} else {
setPresets([...activations, { id: presetId, scope: 'both', turnsRemaining: null }]);
}
};
return (
<div className="space-y-2">
<label className="block text-xs font-bold text-neutral-500 uppercase tracking-widest">
@ -124,9 +142,31 @@ export function LayoutPicker({
</p>
{value.suggestedPresets !== undefined &&
value.suggestedPresets.length > 0 && (
<p className="text-xs text-neutral-400 italic">
Suggested rules: {value.suggestedPresets.join(', ')}
</p>
<div className="pt-2 flex flex-wrap items-center gap-1.5">
<span className="text-xs text-neutral-400 italic mr-1">Suggested rules:</span>
{value.suggestedPresets.map((presetId) => {
const presetDef = PRESET_REGISTRY.get(presetId);
const label = presetDef?.name ?? presetId;
const isActive = activations.some(a => a.id === presetId);
return (
<button
key={presetId}
data-testid={`layout-suggested-preset-${presetId}`}
type="button"
onClick={() => togglePreset(presetId)}
disabled={disabled || !setPresets}
className={`inline-flex px-2 py-0.5 rounded-full text-xs transition-colors border ${
isActive
? 'bg-neutral-800 text-white border-neutral-800 hover:bg-neutral-700'
: 'bg-neutral-100 text-neutral-600 border-neutral-200 hover:bg-neutral-200'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{label}
</button>
);
})}
</div>
)}
</div>
);

View file

@ -12,6 +12,7 @@ import {
validateLayout,
type StartingLayout,
} from '@paratype/chess';
import type { PresetActivation } from '../net/types.js';
import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor';
@ -90,6 +91,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
* wire the server protocol carries one ModifierProfile per room
* (T2). Multi-stack on the wire is a future T27+ extension.
*/
// Suggested rules driven by LayoutPicker
const [presets, setPresets] = useState<PresetActivation[]>([]);
const [additionalProfiles, setAdditionalProfiles] = useState<
ModifierProfile[]
>([]);
@ -301,6 +305,9 @@ export function Lobby({ chessState }: LobbyProps = {}) {
if (selectedProfile !== null) {
createPayload.profile = selectedProfile;
}
if (presets.length > 0) {
createPayload.rulesetIds = presets.map(p => p.id);
}
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
await oneShotRoomRequest('room.create', createPayload);
@ -428,6 +435,8 @@ export function Lobby({ chessState }: LobbyProps = {}) {
<LayoutPicker
value={selectedLayout}
onChange={setSelectedLayout}
activations={presets}
setPresets={setPresets}
onCustomRequested={() => setEditorOpen(true)}
disabled={loading}
/>

View file

@ -223,7 +223,7 @@ export function RulesDrawer({
data-testid="rules-drawer"
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden border-l border-neutral-200/50"
>
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white shrink-0">
<div>
<h2 className="text-xl font-bold tracking-tight text-neutral-900">
Live Rules
@ -243,272 +243,293 @@ export function RulesDrawer({
</button>
</header>
<div className="flex-1 overflow-y-auto overscroll-contain p-6 space-y-4">
{presets.map((preset) => {
const active = activeById.get(preset.id);
const isOn = active !== undefined;
// Compute whether this preset CAN be toggled right now
// given the currently-active set. Pre-checking mirrors
// the engine's own validation in
// `ActivePresetSet.replaceAll` so we don't round-trip
// to the server just to get an error back (which
// would produce a misleading "Preset enabled" toast
// followed by an error banner). Scope defaults to
// 'both', so overlap is always true for this quick
// check.
//
// Enable block: a currently-active preset conflicts,
// OR a required preset isn't active.
// Disable block: another active preset REQUIRES this
// one. (Turning off piece-hp while
// king-heals is on would fail.)
//
// Returns null when the toggle action would succeed,
// otherwise a structured reason used to drive the
// disabled toggle + tooltip.
let blockReason:
| null
| { kind: 'conflicts'; withName: string }
| { kind: 'missing'; needsName: string }
| { kind: 'depended-on-by'; dependentName: string } = null;
if (!isOn) {
for (const otherId of preset.incompatibleWith) {
if (activeById.has(otherId)) {
blockReason = {
kind: 'conflicts',
withName: nameById(otherId),
};
break;
}
}
if (blockReason === null) {
for (const depId of preset.requires) {
if (!activeById.has(depId)) {
blockReason = {
kind: 'missing',
needsName: nameById(depId),
};
break;
}
}
}
} else {
// Disable path: check whether any OTHER active
// preset requires this one. If so, disabling would
// leave that preset with an unmet dependency and
// the server / engine would reject the whole set.
for (const other of presets) {
if (!activeById.has(other.id)) continue;
if (other.id === preset.id) continue;
if (other.requires.includes(preset.id)) {
blockReason = {
kind: 'depended-on-by',
dependentName: other.name,
};
break;
}
}
}
const toggleDisabled = blockReason !== null;
const blockMessage =
blockReason === null
? undefined
: blockReason.kind === 'conflicts'
? `Disable "${blockReason.withName}" first — it conflicts with this rule`
: blockReason.kind === 'missing'
? `Enable "${blockReason.needsName}" first — this rule requires it`
: `Disable "${blockReason.dependentName}" first — it requires this rule`;
<div className="flex-1 overflow-y-auto overscroll-contain p-6">
{['king', 'objective', 'movement', 'multi-move', 'pieces', 'misc'].map((categoryId) => {
const categoryPresets = presets.filter(p => (p.category ?? 'misc') === categoryId);
if (categoryPresets.length === 0) return null;
const categoryName = {
'king': 'King Variants',
'objective': 'Objectives',
'movement': 'Movement',
'multi-move': 'Multi-move',
'pieces': 'Pieces',
'misc': 'Misc'
}[categoryId];
return (
<div
key={preset.id}
data-preset={preset.id}
className={`border rounded-xl p-4 transition-all duration-200 ${
isOn
? 'border-neutral-300 bg-neutral-50 shadow-sm'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm">
{preset.name}
</h3>
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
{preset.description}
</p>
{(preset.requires.length > 0 ||
preset.incompatibleWith.length > 0) && (
<div
className="mt-2 flex flex-wrap gap-1.5"
data-role="deps"
>
{preset.requires.map((depId) => {
const depActive = activeById.has(depId);
return (
<span
key={`req-${depId}`}
data-role="dep-requires"
data-dep-id={depId}
data-dep-satisfied={depActive}
title={
depActive
? `Requires ${nameById(depId)} (active)`
: `Requires ${nameById(depId)} — enable this first`
}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
depActive
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-amber-50 border-amber-200 text-amber-700'
}`}
>
<span aria-hidden="true">
{depActive ? '✓' : '!'}
</span>
Requires {nameById(depId)}
</span>
);
})}
{preset.incompatibleWith.map((otherId) => {
const otherActive = activeById.has(otherId);
return (
<span
key={`inc-${otherId}`}
data-role="dep-conflicts"
data-dep-id={otherId}
data-dep-conflicting={otherActive}
title={
otherActive
? `Conflicts with ${nameById(otherId)} (active) — enabling this will clash`
: `Conflicts with ${nameById(otherId)}`
}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
otherActive
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-neutral-100 border-neutral-200 text-neutral-600'
}`}
>
<span aria-hidden="true"></span>
Conflicts with {nameById(otherId)}
</span>
);
})}
</div>
)}
</div>
<button
type="button"
data-role="toggle"
data-disabled-reason={
blockReason === null ? undefined : blockReason.kind
}
onClick={() => toggle(preset.id, preset.name)}
disabled={toggleDisabled}
title={blockMessage}
aria-label={
blockMessage !== undefined
? `${preset.name}${blockMessage}`
: `${preset.name} toggle`
}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
toggleDisabled
? 'cursor-not-allowed opacity-40 bg-neutral-200'
: `cursor-pointer ${
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
}`
}`}
role="switch"
aria-checked={isOn}
aria-disabled={toggleDisabled}
>
<motion.span
layout
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
<div key={categoryId} className="mb-8 last:mb-0" data-testid={`rules-category-${categoryId}`}>
<h3 className="text-xs font-bold text-neutral-400 uppercase tracking-wider mb-4 px-1">{categoryName}</h3>
<div className="space-y-4">
{categoryPresets.map((preset) => {
const active = activeById.get(preset.id);
const isOn = active !== undefined;
{isOn && (
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
{/* Scope radios */}
<div className="flex items-center justify-between gap-4">
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
Apply to
</label>
// Compute whether this preset CAN be toggled right now
// given the currently-active set. Pre-checking mirrors
// the engine's own validation in
// `ActivePresetSet.replaceAll` so we don't round-trip
// to the server just to get an error back (which
// would produce a misleading "Preset enabled" toast
// followed by an error banner). Scope defaults to
// 'both', so overlap is always true for this quick
// check.
//
// Enable block: a currently-active preset conflicts,
// OR a required preset isn't active.
// Disable block: another active preset REQUIRES this
// one. (Turning off piece-hp while
// king-heals is on would fail.)
//
// Returns null when the toggle action would succeed,
// otherwise a structured reason used to drive the
// disabled toggle + tooltip.
let blockReason:
| null
| { kind: 'conflicts'; withName: string }
| { kind: 'missing'; needsName: string }
| { kind: 'depended-on-by'; dependentName: string } = null;
if (!isOn) {
for (const otherId of preset.incompatibleWith) {
if (activeById.has(otherId)) {
blockReason = {
kind: 'conflicts',
withName: nameById(otherId),
};
break;
}
}
if (blockReason === null) {
for (const depId of preset.requires) {
if (!activeById.has(depId)) {
blockReason = {
kind: 'missing',
needsName: nameById(depId),
};
break;
}
}
}
} else {
// Disable path: check whether any OTHER active
// preset requires this one. If so, disabling would
// leave that preset with an unmet dependency and
// the server / engine would reject the whole set.
for (const other of presets) {
if (!activeById.has(other.id)) continue;
if (other.id === preset.id) continue;
if (other.requires.includes(preset.id)) {
blockReason = {
kind: 'depended-on-by',
dependentName: other.name,
};
break;
}
}
}
const toggleDisabled = blockReason !== null;
const blockMessage =
blockReason === null
? undefined
: blockReason.kind === 'conflicts'
? `Disable "${blockReason.withName}" first — it conflicts with this rule`
: blockReason.kind === 'missing'
? `Enable "${blockReason.needsName}" first — this rule requires it`
: `Disable "${blockReason.dependentName}" first — it requires this rule`;
return (
<div
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
role="radiogroup"
aria-label={`${preset.name} scope`}
data-role="scope-group"
key={preset.id}
data-preset={preset.id}
className={`border rounded-xl p-4 transition-all duration-200 ${
isOn
? 'border-neutral-300 bg-neutral-50 shadow-sm'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
{(['both', 'white', 'black'] as const).map(
(scope) => {
const selected = active.scope === scope;
return (
<button
key={scope}
type="button"
role="radio"
aria-checked={selected}
data-role={`scope-${scope}`}
onClick={() => setScope(preset.id, scope)}
className={`px-3 py-1 rounded-md transition-colors capitalize ${
selected
? 'bg-white text-neutral-900 shadow-sm'
: 'text-neutral-500 hover:text-neutral-800'
}`}
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-900 text-sm">
{preset.name}
</h3>
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
{preset.description}
</p>
{(preset.requires.length > 0 ||
preset.incompatibleWith.length > 0) && (
<div
className="mt-2 flex flex-wrap gap-1.5"
data-role="deps"
>
{scope}
</button>
);
},
{preset.requires.map((depId) => {
const depActive = activeById.has(depId);
return (
<span
key={`req-${depId}`}
data-role="dep-requires"
data-dep-id={depId}
data-dep-satisfied={depActive}
title={
depActive
? `Requires ${nameById(depId)} (active)`
: `Requires ${nameById(depId)} — enable this first`
}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
depActive
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-amber-50 border-amber-200 text-amber-700'
}`}
>
<span aria-hidden="true">
{depActive ? '✓' : '!'}
</span>
Requires {nameById(depId)}
</span>
);
})}
{preset.incompatibleWith.map((otherId) => {
const otherActive = activeById.has(otherId);
return (
<span
key={`inc-${otherId}`}
data-role="dep-conflicts"
data-dep-id={otherId}
data-dep-conflicting={otherActive}
title={
otherActive
? `Conflicts with ${nameById(otherId)} (active) — enabling this will clash`
: `Conflicts with ${nameById(otherId)}`
}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium border ${
otherActive
? 'bg-red-50 border-red-200 text-red-700'
: 'bg-neutral-100 border-neutral-200 text-neutral-600'
}`}
>
<span aria-hidden="true"></span>
Conflicts with {nameById(otherId)}
</span>
);
})}
</div>
)}
</div>
<button
type="button"
data-role="toggle"
data-disabled-reason={
blockReason === null ? undefined : blockReason.kind
}
onClick={() => toggle(preset.id, preset.name)}
disabled={toggleDisabled}
title={blockMessage}
aria-label={
blockMessage !== undefined
? `${preset.name}${blockMessage}`
: `${preset.name} toggle`
}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
toggleDisabled
? 'cursor-not-allowed opacity-40 bg-neutral-200'
: `cursor-pointer ${
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
}`
}`}
role="switch"
aria-checked={isOn}
aria-disabled={toggleDisabled}
>
<motion.span
layout
aria-hidden="true"
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
isOn ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{isOn && (
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-3">
{/* Scope radios */}
<div className="flex items-center justify-between gap-4">
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
Apply to
</label>
<div
className="flex rounded-lg bg-neutral-100 p-0.5 text-xs font-medium"
role="radiogroup"
aria-label={`${preset.name} scope`}
data-role="scope-group"
>
{(['both', 'white', 'black'] as const).map(
(scope) => {
const selected = active.scope === scope;
return (
<button
key={scope}
type="button"
role="radio"
aria-checked={selected}
data-role={`scope-${scope}`}
onClick={() => setScope(preset.id, scope)}
className={`px-3 py-1 rounded-md transition-colors capitalize ${
selected
? 'bg-white text-neutral-900 shadow-sm'
: 'text-neutral-500 hover:text-neutral-800'
}`}
>
{scope}
</button>
);
},
)}
</div>
</div>
{/* Duration input */}
<div className="flex items-center justify-between gap-4">
<label
htmlFor={`duration-${preset.id}`}
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
>
Duration
</label>
<div className="flex items-center gap-2">
<input
id={`duration-${preset.id}`}
data-role="duration-input"
type="number"
min="1"
step="1"
placeholder="∞"
value={
active.turnsRemaining === null
? ''
: String(active.turnsRemaining)
}
onChange={(e) => setDuration(preset.id, e)}
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
/>
<span className="text-xs text-neutral-500">
{active.turnsRemaining === null
? 'permanent'
: 'turns'}
</span>
</div>
</div>
</div>
)}
</div>
</div>
{/* Duration input */}
<div className="flex items-center justify-between gap-4">
<label
htmlFor={`duration-${preset.id}`}
className="text-xs font-semibold text-neutral-600 uppercase tracking-wide"
>
Duration
</label>
<div className="flex items-center gap-2">
<input
id={`duration-${preset.id}`}
data-role="duration-input"
type="number"
min="1"
step="1"
placeholder="∞"
value={
active.turnsRemaining === null
? ''
: String(active.turnsRemaining)
}
onChange={(e) => setDuration(preset.id, e)}
className="w-20 px-2 py-1 border border-neutral-300 rounded-md text-sm text-right focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:border-transparent"
/>
<span className="text-xs text-neutral-500">
{active.turnsRemaining === null
? 'permanent'
: 'turns'}
</span>
</div>
</div>
</div>
)}
);
})}
</div>
</div>
);
})}
</div>
<footer className="p-4 border-t border-neutral-100 bg-white space-y-3">
<footer className="p-4 border-t border-neutral-100 bg-white space-y-3 shrink-0">
<button
type="button"
data-testid="open-modifier-editor"