From 28e03d06faba9889fbed95600323bbe328d46a93 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 22:56:31 -0600 Subject: [PATCH] feat(ui): per-instance modifier panel - Export LayoutBoardView from LayoutEditor.tsx (reusable board display) - Create PerInstancePanel.tsx: 8x8 board + per-square modifier list/form - Wire PerInstancePanel into ModifierProfileEditor center panel - Add bound-layout-picker select in editor header (uses LAYOUT_REGISTRY) - 2 new e2e scenarios: no-layout prompt, attach modifier to b1 square --- packages/chess/e2e/modifier-profiles.spec.ts | 88 +++++ packages/chess/src/ui/LayoutEditor.tsx | 89 +++++ .../chess/src/ui/ModifierProfileEditor.tsx | 346 ++++++++++++++++-- packages/chess/src/ui/PerInstancePanel.tsx | 279 ++++++++++++++ 4 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 packages/chess/src/ui/PerInstancePanel.tsx diff --git a/packages/chess/e2e/modifier-profiles.spec.ts b/packages/chess/e2e/modifier-profiles.spec.ts index a9c5240..69a1099 100644 --- a/packages/chess/e2e/modifier-profiles.spec.ts +++ b/packages/chess/e2e/modifier-profiles.spec.ts @@ -52,4 +52,92 @@ test.describe('Modifier Profiles', () => { page.locator('[data-testid="modifier-editor-modal"]'), ).not.toBeVisible({ timeout: 500 }); }); + + test('add per-type HP modifier shows in row list', async ({ page }) => { + // Open the modifier editor. + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible(); + + // Open the inline add form. + await page.click('[data-testid="add-type-modifier"]'); + + // Fill in: piece type = knight, color = white, kind = hp-bonus, value = 2. + await page.selectOption('[data-testid="piece-type-select"]', 'knight'); + await page.selectOption('[data-testid="color-select"]', 'white'); + await page.selectOption('[data-testid="kind-select"]', 'hp-bonus'); + await page.fill('[data-testid="value-input"]', '2'); + + // Save the modifier. + await page.click('[data-testid="save-type-modifier"]'); + + // The row must appear and display the described value. + await expect( + page.locator('[data-testid="type-modifier-row"]'), + ).toBeVisible(); + await expect( + page.locator('[data-testid="type-modifier-row"]'), + ).toContainText('HP +2'); + }); + + test('invalid value disables save button', async ({ page }) => { + // Open the modifier editor. + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible(); + + // Open the inline add form. + await page.click('[data-testid="add-type-modifier"]'); + + // Select range-bonus (max = 7) and enter 100 — outside valid range. + await page.selectOption('[data-testid="kind-select"]', 'range-bonus'); + await page.fill('[data-testid="value-input"]', '100'); + + // Save button must be disabled since 100 > 7 fails Zod validation. + await expect( + page.locator('[data-testid="save-type-modifier"]'), + ).toBeDisabled(); + }); + + // ── T22: per-instance modifier panel ─────────────────────────────────────── + + test('no-layout state shows select-a-layout prompt', async ({ page }) => { + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible(); + + // With no layout bound, the center panel shows the empty-state prompt. + await expect(page.getByText('Select a layout first')).toBeVisible(); + }); + + test('per-instance modifier attached to specific square', async ({ page }) => { + await page.click('[data-testid="open-modifier-editor"]'); + await expect( + page.locator('[data-testid="modifier-editor-modal"]'), + ).toBeVisible(); + + // Bind the Classic layout via the header picker. + await page.selectOption('[data-testid="bound-layout-picker"]', 'classic'); + + // The per-instance board should now be visible. + await expect( + page.locator('[data-testid="per-instance-board"]'), + ).toBeVisible(); + + // Click the b1 square (white knight in the classic starting position). + await page.click('[data-testid="piece-square-b1"]'); + + // Add a range-bonus modifier with value 1. + await page.selectOption('[data-testid="instance-modifier-kind"]', 'range-bonus'); + await page.fill('[data-testid="instance-modifier-value"]', '1'); + await page.click('[data-testid="instance-modifier-add"]'); + + // The modifier row must appear in the selected-square list. + await expect( + page.locator('[data-testid="instance-modifier-b1-range-bonus"]'), + ).toBeVisible(); + }); }); diff --git a/packages/chess/src/ui/LayoutEditor.tsx b/packages/chess/src/ui/LayoutEditor.tsx index f929905..666175a 100644 --- a/packages/chess/src/ui/LayoutEditor.tsx +++ b/packages/chess/src/ui/LayoutEditor.tsx @@ -678,6 +678,95 @@ function ValidationPanel({ ); } +/** + * Reusable read-only / interactive board view for piece layouts. + * + * Exported so that other panels (e.g. PerInstancePanel in the modifier + * profile editor) can display the same 8×8 grid without duplicating + * the rendering logic. + * + * - `selectedSquare` (algebraic, e.g. "b1") highlights that cell with a + * blue ring so the caller can indicate which square is "active". + * - `onSquareClick` is called with the algebraic notation of the clicked + * cell; if omitted the board is purely decorative (all cells disabled). + * - `testId` sets `data-testid` on the board wrapper div. + */ +export function LayoutBoardView({ + pieces, + selectedSquare, + testId = 'layout-board-view', + onSquareClick, +}: { + pieces: readonly PiecePlacement[]; + selectedSquare?: string | null; + testId?: string; + onSquareClick?: (algebraic: string) => void; +}) { + const placementBySquare = useMemo(() => { + const map = new Map(); + for (const p of pieces) map.set(p.square, p); + return map; + }, [pieces]); + + const rows: ReactNode[] = []; + for (let rank = 7; rank >= 0; rank--) { + const cells: ReactNode[] = []; + for (let file = 0; file < 8; file++) { + const sq = rank * 8 + file; + const algebraic = squareToAlgebraic(sq); + const piece = placementBySquare.get(sq); + const isDark = (rank + file) % 2 === 0; + const isSelected = selectedSquare === algebraic; + const isOccupied = piece !== undefined; + const hasClick = onSquareClick !== undefined; + + cells.push( + , + ); + } + rows.push( +
+ {cells} +
, + ); + } + + return ( +
+ {rows} +
+ ); +} + function LibraryDrawer({ onLoad, onDelete, diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx index c431811..0124f1c 100644 --- a/packages/chess/src/ui/ModifierProfileEditor.tsx +++ b/packages/chess/src/ui/ModifierProfileEditor.tsx @@ -1,22 +1,62 @@ /** * Modifier Profile Editor — modal shell. * - * Shell only: three placeholder panels filled by later tasks: - * T21 — modifier catalog (left panel) - * T22 — board preview (center panel) - * T23 — profile list (right panel) + * Three panels: + * LEFT — modifier catalog (T21 placeholder) + * CENTER — board preview (T22 placeholder) + * RIGHT — profile library: save, share, list of saved profiles (T23) * * Follows the same overlay/close pattern as LayoutEditor.tsx. */ -import { useEffect } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess'; +import { + deleteFromLibrary, + duplicateEntry, + loadLibrary, + makeId, + saveToLibrary, + setStarred, + type SavedModifierProfile, +} from '../modifiers/library'; +import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types'; +import { PerInstancePanel } from './PerInstancePanel'; +import { PerTypePanel } from './PerTypePanel.js'; interface Props { isOpen: boolean; onClose: () => void; } +function makeBlankProfile(): ModifierProfile { + return { + id: makeId(), + name: 'My Profile', + description: '', + perType: [], + perInstance: [], + version: 1, + source: 'custom', + }; +} + export function ModifierProfileEditor({ isOpen, onClose }: Props) { - // Register Esc listener only while the modal is visible. + const [profile, setProfile] = useState(makeBlankProfile); + const [libraryVersion, setLibraryVersion] = useState(0); + const [boundLayout, setBoundLayout] = useState(null); + const [perInstance, setPerInstance] = useState([]); + const [perType, setPerType] = useState([]); + const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []); + + // Fresh profile whenever the modal opens. + useEffect(() => { + if (isOpen) { + setProfile(makeBlankProfile()); + } + }, [isOpen]); + + // Esc closes the modal regardless of focus. useEffect(() => { if (!isOpen) return; function handleKeyDown(e: KeyboardEvent) { @@ -28,6 +68,70 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { if (!isOpen) return null; + // ── Library actions ───────────────────────────────────────────────── + + function handleSaveToLibrary() { + const entry: SavedModifierProfile = { + id: profile.id, + name: profile.name.trim() || 'Untitled Profile', + profile, + starred: false, + updatedAt: Date.now(), + }; + const result = saveToLibrary(entry); + if (!result.ok) { + toast.error(result.reason); + return; + } + toast.success(`Saved "${entry.name}" to library`); + setLibraryVersion((n) => n + 1); + } + + function handleShareProfile() { + try { + const json = JSON.stringify(profile); + const b64 = btoa(json); + if (b64.length > 8 * 1024) { + toast.error('Profile too large for URL — save to library instead.'); + return; + } + const url = new URL(window.location.href); + url.searchParams.set('modifierProfile', b64); + void navigator.clipboard + .writeText(url.toString()) + .then(() => toast.success('Copied to clipboard')) + .catch(() => + toast.error( + 'Copy failed — share URL could not be written to clipboard', + ), + ); + } catch { + toast.error('Failed to generate share URL'); + } + } + + function handleLoadFromLibrary(entry: SavedModifierProfile) { + setProfile({ ...entry.profile }); + toast.success(`Loaded "${entry.name}"`); + } + + function handleDeleteFromLibrary(id: string) { + deleteFromLibrary(id); + setLibraryVersion((n) => n + 1); + } + + function handleStarToggle(id: string, starred: boolean) { + setStarred(id, starred); + setLibraryVersion((n) => n + 1); + } + + function handleDuplicate(id: string) { + duplicateEntry(id); + setLibraryVersion((n) => n + 1); + } + + // ── Render ─────────────────────────────────────────────────────────── + return (
{/* Header */}
-

- Modifier Profiles -

- +
+

+ Modifier Profiles +

+ + setProfile((p) => ({ ...p, name: e.target.value })) + } + placeholder="Profile name" + className="px-3 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + maxLength={40} + /> + {/* Bound layout for per-instance modifiers */} +
+ + +
+
+
+ + + +
- {/* Body — three placeholder panels, filled by T21 / T22 / T23 */} + {/* Body — three panels */}
-
); } + +// ── ProfileLibraryPanel ────────────────────────────────────────────────────── + +function ProfileLibraryPanel({ + onLoad, + onDelete, + onStarToggle, + onDuplicate, +}: { + onLoad: (entry: SavedModifierProfile) => void; + onDelete: (id: string) => void; + onStarToggle: (id: string, starred: boolean) => void; + onDuplicate: (id: string) => void; +}) { + const entries = loadLibrary(); + // Starred entries first, then most-recent within each group. + const sorted = [...entries].sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1; + return b.updatedAt - a.updatedAt; + }); + + return ( + + ); +} diff --git a/packages/chess/src/ui/PerInstancePanel.tsx b/packages/chess/src/ui/PerInstancePanel.tsx new file mode 100644 index 0000000..ee5b6c7 --- /dev/null +++ b/packages/chess/src/ui/PerInstancePanel.tsx @@ -0,0 +1,279 @@ +/** + * Per-Instance Modifier Panel (T22). + * + * Center panel of the Modifier Profile Editor. Displays an 8×8 board + * preview for the bound layout and lets the user attach per-instance + * modifiers to individual squares. + * + * Usage flow: + * 1. If no layout is bound → prompt the user to choose one. + * 2. With a bound layout → click a square with a piece to select it. + * 3. For the selected square → view existing instance modifiers and + * add/delete them via the right-hand sidebar form. + */ +import { useState } from 'react'; +import type { InstanceModifier, ModifierKindId } from '../modifiers/types'; +import type { StartingLayout } from '../layouts/types'; +import { squareToAlgebraic } from '../coord'; +import { LayoutBoardView } from './LayoutEditor'; + +export interface PerInstancePanelProps { + modifiers: readonly InstanceModifier[]; + boundLayout: StartingLayout | null; + /** Called when the user requests to open the layout picker. */ + onLayoutSelect: () => void; + onAdd: (modifier: InstanceModifier) => void; + onDelete: (index: number) => void; +} + +const MODIFIER_KINDS: ReadonlyArray<{ id: ModifierKindId; label: string }> = [ + { id: 'hp-bonus', label: 'HP Bonus' }, + { id: 'range-bonus', label: 'Range Bonus' }, + { id: 'direction-additions', label: 'Direction Additions' }, + { id: 'capture-flags', label: 'Capture Flags' }, + { id: 'promotion-override', label: 'Promotion Override' }, + { id: 'damage-resistance', label: 'Damage Resistance' }, +]; + +export function PerInstancePanel({ + modifiers, + boundLayout, + onLayoutSelect, + onAdd, + onDelete, +}: PerInstancePanelProps) { + const [selectedSquare, setSelectedSquare] = useState(null); + + // No layout bound — show the empty state prompt. + if (boundLayout === null) { + return ( +
+

Select a layout first

+ +
+ ); + } + + // Collect modifiers for the currently selected square, preserving + // their original indices so onDelete can target the right entry. + const squareModifiers = modifiers + .map((modifier, index) => ({ modifier, index })) + .filter(({ modifier }) => modifier.square === selectedSquare); + + const selectedPiece = + selectedSquare !== null + ? boundLayout.pieces.find( + (p) => squareToAlgebraic(p.square) === selectedSquare, + ) + : undefined; + + function handleSquareClick(algebraic: string) { + const hasPiece = boundLayout!.pieces.some( + (p) => squareToAlgebraic(p.square) === algebraic, + ); + if (hasPiece) setSelectedSquare(algebraic); + } + + return ( +
+ {/* Board preview */} +
+ +
+ + {/* Square detail sidebar */} + +
+ ); +} + +// ── Add-Modifier form ───────────────────────────────────────────────────────── + +function defaultValue(kind: ModifierKindId): string { + switch (kind) { + case 'direction-additions': + return ''; + case 'promotion-override': + return 'queen'; + default: + return '0'; + } +} + +function ValueInput({ + kind, + value, + onChange, +}: { + kind: ModifierKindId; + value: string; + onChange: (v: string) => void; +}) { + if (kind === 'promotion-override') { + return ( + + ); + } + + if (kind === 'direction-additions') { + return ( + onChange(e.target.value)} + placeholder="e.g. forward, backward" + className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ); + } + + // Numeric kinds: hp-bonus, range-bonus, capture-flags, damage-resistance + return ( + onChange(e.target.value)} + className="w-full px-3 py-2 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + ); +} + +function AddModifierForm({ + square, + onAdd, +}: { + square: string; + onAdd: (modifier: InstanceModifier) => void; +}) { + const [kind, setKind] = useState('hp-bonus'); + const [rawValue, setRawValue] = useState('0'); + + function handleAdd() { + let parsed: unknown; + + if (kind === 'direction-additions') { + parsed = rawValue + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } else if (kind === 'promotion-override') { + parsed = rawValue; + } else { + const n = Number(rawValue); + if (isNaN(n)) return; + parsed = n; + } + + onAdd({ kind, square, value: parsed }); + setRawValue(defaultValue(kind)); + } + + return ( +
+

+ Add Modifier +

+ + + + + + +
+ ); +}