From 37e485537d00707750449d04ca914696145cd2ed Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 08:51:37 -0600 Subject: [PATCH] feat(ui): modifier editor undo/redo + conflict resolution panel + copy/paste modifiers --- .sisyphus/boulder.json | 9 + .sisyphus/plans/modifier-profiles-t2.md | 2 +- packages/chess/src/net/types.ts | 16 ++ .../chess/src/ui/ConflictResolutionPanel.tsx | 104 ++++++++++ .../chess/src/ui/ModifierProfileEditor.tsx | 179 ++++++++++++++++-- packages/chess/src/ui/PerInstancePanel.tsx | 61 +++++- packages/chess/src/ui/PerTypePanel.tsx | 50 +++-- 7 files changed, 382 insertions(+), 39 deletions(-) create mode 100644 .sisyphus/boulder.json create mode 100644 packages/chess/src/ui/ConflictResolutionPanel.tsx diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json new file mode 100644 index 0000000..36b3026 --- /dev/null +++ b/.sisyphus/boulder.json @@ -0,0 +1,9 @@ +{ + "active_plan": "/home/joey/Projects/rules/.sisyphus/plans/modifier-profiles-t2.md", + "started_at": "2026-04-19T14:42:06.329Z", + "session_ids": [ + "ses_267b9d7a2ffeFkGcPFn1iv223J" + ], + "plan_name": "modifier-profiles-t2", + "agent": "atlas" +} \ No newline at end of file diff --git a/.sisyphus/plans/modifier-profiles-t2.md b/.sisyphus/plans/modifier-profiles-t2.md index c6aac00..a3ddbf8 100644 --- a/.sisyphus/plans/modifier-profiles-t2.md +++ b/.sisyphus/plans/modifier-profiles-t2.md @@ -203,7 +203,7 @@ Wave FINAL (4 parallel reviewers): ## TODOs -- [ ] 1. **T2-ADR documentation** +- [x] 1. **T2-ADR documentation** **What to do**: - Append 3 new sections to `docs/adr/modifier-profiles.md`: diff --git a/packages/chess/src/net/types.ts b/packages/chess/src/net/types.ts index f993241..5741c1d 100644 --- a/packages/chess/src/net/types.ts +++ b/packages/chess/src/net/types.ts @@ -202,6 +202,21 @@ export interface ModifierProfileUpdatedPayload { appliedAt: "turn-boundary"; } +/** + * Server → client ack: a submitted `modifier-profile.update` passed + * receipt-time validation and is queued for the next turn boundary + * (T2-ADR-1). Sent only to the submitting client — the opponent + * learns about the pending swap only when it actually lands via + * `modifier-profile.updated`. `pendingVersion` is the version the + * server expects to emit when the queue drains; a subsequent + * `modifier-profile.updated` with a higher version means another + * swap beat this one on the last-write-wins queue. + */ +export interface ModifierProfileQueuedPayload { + roomCode: string; + pendingVersion: number; +} + export interface ErrorPayload { code: string; message: string; @@ -265,6 +280,7 @@ export type ServerMessage = | MessageEnvelope<"room.created", RoomCreatedPayload> | MessageEnvelope<"room.joined", RoomJoinedPayload> | MessageEnvelope<"modifier-profile.updated", ModifierProfileUpdatedPayload> + | MessageEnvelope<"modifier-profile.queued", ModifierProfileQueuedPayload> | MessageEnvelope<"error", ErrorPayload>; export type ClientMessage = diff --git a/packages/chess/src/ui/ConflictResolutionPanel.tsx b/packages/chess/src/ui/ConflictResolutionPanel.tsx new file mode 100644 index 0000000..b36c72f --- /dev/null +++ b/packages/chess/src/ui/ConflictResolutionPanel.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import type { StartingLayout } from '../layouts/types.js'; +import type { ModifierProfile } from '../modifiers/types.js'; +import { + validateProfile, + type ValidationError, + type ValidationWarning, +} from '../modifiers/validate.js'; +import { CaptureFlag } from '../schema.js'; + +interface Props { + profile: ModifierProfile; + layout: StartingLayout | null; + onResolve: (nextProfile: ModifierProfile) => void; +} + +function applyFix( + profile: ModifierProfile, + issue: ValidationError | ValidationWarning, +): ModifierProfile { + switch (issue.code) { + case 'E_PROFILE_INVULN_KING': + return { + ...profile, + perType: profile.perType.map((tm) => + tm.kind === 'capture-flags' && tm.pieceType === 'king' + ? { ...tm, value: (tm.value as number) & ~CaptureFlag.CANNOT_BE_CAPTURED } + : tm, + ), + perInstance: profile.perInstance.filter((im) => { + if (issue.code !== 'E_PROFILE_INVULN_KING') return true; + if ('square' in issue && im.square === issue.square && im.kind === 'capture-flags') { + const v = im.value as number; + return (v & CaptureFlag.CANNOT_BE_CAPTURED) === 0; + } + return true; + }), + }; + case 'E_PROFILE_ORPHAN_INSTANCE': + return { + ...profile, + perInstance: profile.perInstance.filter((im) => im.square !== issue.square), + }; + case 'E_PROFILE_NO_KING': + return profile; + case 'E_PROFILE_ATTR_LIMIT': + return profile; + case 'E_PROFILE_DEADLOCK': + return profile; + default: + return profile; + } +} + +export function ConflictResolutionPanel({ profile, layout, onResolve }: Props) { + const result = useMemo(() => { + if (layout === null) return { errors: [], warnings: [], valid: true }; + return validateProfile(profile, layout); + }, [profile, layout]); + + if (result.valid && result.warnings.length === 0) return null; + + return ( +
+ {result.errors.map((err) => ( +
+
⚠ {err.message}
+ {err.code !== 'E_PROFILE_NO_KING' && err.code !== 'E_PROFILE_ATTR_LIMIT' && err.code !== 'E_PROFILE_DEADLOCK' && ( + + )} +
+ ))} + {result.warnings.map((warn) => ( +
+
⚠ {warn.message}
+ +
+ ))} +
+ ); +} diff --git a/packages/chess/src/ui/ModifierProfileEditor.tsx b/packages/chess/src/ui/ModifierProfileEditor.tsx index 9f129df..6356d44 100644 --- a/packages/chess/src/ui/ModifierProfileEditor.tsx +++ b/packages/chess/src/ui/ModifierProfileEditor.tsx @@ -8,7 +8,7 @@ * * Follows the same overlay/close pattern as LayoutEditor.tsx. */ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { LAYOUT_REGISTRY, type StartingLayout } from '@paratype/chess'; import { @@ -23,6 +23,12 @@ import { import type { InstanceModifier, ModifierProfile, TypeModifier } from '../modifiers/types'; import { PerInstancePanel } from './PerInstancePanel'; import { PerTypePanel } from './PerTypePanel.js'; +import { ConflictResolutionPanel } from './ConflictResolutionPanel.js'; + +export type ModifierClipboard = + | { kind: 'empty' } + | { kind: 'type-modifiers'; entries: readonly TypeModifier[] } + | { kind: 'instance-modifiers'; entries: readonly Omit[] }; interface Props { isOpen: boolean; @@ -42,17 +48,47 @@ function makeBlankProfile(): ModifierProfile { } export function ModifierProfileEditor({ isOpen, onClose }: Props) { - const [profile, setProfile] = useState(makeBlankProfile); + const [history, setHistory] = useState(() => [ + makeBlankProfile(), + ]); + const [historyIndex, setHistoryIndex] = useState(0); + const profile = history[historyIndex]!; + const canUndo = historyIndex > 0; + const canRedo = historyIndex < history.length - 1; + + const pushSnapshot = useCallback( + (next: ModifierProfile | ((prev: ModifierProfile) => ModifierProfile)) => { + setHistory((h) => { + const current = h[historyIndex]!; + const nextProfile = typeof next === 'function' ? next(current) : next; + const truncated = h.slice(0, historyIndex + 1); + truncated.push(nextProfile); + while (truncated.length > 50) truncated.shift(); + return truncated; + }); + setHistoryIndex((i) => Math.min(i + 1, 49)); + }, + [historyIndex], + ); + + const undo = useCallback(() => { + setHistoryIndex((i) => Math.max(0, i - 1)); + }, []); + + const redo = useCallback(() => { + setHistoryIndex((i) => Math.min(history.length - 1, i + 1)); + }, [history.length]); + const [libraryVersion, setLibraryVersion] = useState(0); const [boundLayout, setBoundLayout] = useState(null); - const [perInstance, setPerInstance] = useState([]); - const [perType, setPerType] = useState([]); + const [clipboard, setClipboard] = useState({ kind: 'empty' }); const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []); // Fresh profile whenever the modal opens. useEffect(() => { if (isOpen) { - setProfile(makeBlankProfile()); + setHistory([makeBlankProfile()]); + setHistoryIndex(0); } }, [isOpen]); @@ -69,11 +105,23 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { if (e.key === 'Escape') { e.stopImmediatePropagation(); onClose(); + return; + } + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + if (e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + e.stopImmediatePropagation(); + undo(); + } else if ((e.key === 'z' && e.shiftKey) || e.key === 'Z') { + e.preventDefault(); + e.stopImmediatePropagation(); + redo(); } } window.addEventListener('keydown', handleKeyDown, true); return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [isOpen, onClose]); + }, [isOpen, onClose, undo, redo]); if (!isOpen) return null; @@ -94,6 +142,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { } toast.success(`Saved "${entry.name}" to library`); setLibraryVersion((n) => n + 1); + setHistory([profile]); + setHistoryIndex(0); } function handleShareProfile() { @@ -120,7 +170,8 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { } function handleLoadFromLibrary(entry: SavedModifierProfile) { - setProfile({ ...entry.profile }); + setHistory([{ ...entry.profile }]); + setHistoryIndex(0); toast.success(`Loaded "${entry.name}"`); } @@ -158,7 +209,7 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { type="text" value={profile.name} onChange={(e) => - setProfile((p) => ({ ...p, name: e.target.value })) + pushSnapshot((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" @@ -178,7 +229,11 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) { value={boundLayout?.id ?? ''} onChange={(e) => { const id = e.target.value; - setBoundLayout(id ? (LAYOUT_REGISTRY.get(id) ?? null) : null); + const newLayout = id ? (LAYOUT_REGISTRY.get(id) ?? null) : null; + setBoundLayout(newLayout); + // Any layout change is tracked as an action because per-instance + // rules are inherently bound to the layout context. + pushSnapshot((p) => ({ ...p })); }} className="text-sm border border-neutral-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[160px]" > @@ -192,6 +247,33 @@ export function ModifierProfileEditor({ isOpen, onClose }: Props) {
+ + +
+
+ {clipboard.kind === 'empty' + ? 'Clipboard: empty' + : `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`} +
+
+ )} + {clipboard.kind === 'instance-modifiers' && ( + + )} +
{/* Existing modifiers list */} diff --git a/packages/chess/src/ui/PerTypePanel.tsx b/packages/chess/src/ui/PerTypePanel.tsx index a16c0f7..f7a8c50 100644 --- a/packages/chess/src/ui/PerTypePanel.tsx +++ b/packages/chess/src/ui/PerTypePanel.tsx @@ -15,6 +15,7 @@ import { useState } from 'react'; import { MODIFIER_REGISTRY } from '../modifiers/index.js'; import type { TypeModifier, ModifierDescriptor } from '../modifiers/types.js'; import type { PieceType, PieceColor } from '../schema.js'; +import type { ModifierClipboard } from './ModifierProfileEditor.js'; const PIECE_TYPES: PieceType[] = [ 'pawn', 'knight', 'bishop', 'rook', 'queen', 'king', @@ -23,6 +24,9 @@ const COLORS: (PieceColor | 'both')[] = ['white', 'black', 'both']; interface Props { modifiers: readonly TypeModifier[]; + clipboard: ModifierClipboard; + onClipboardChange: (clipboard: ModifierClipboard) => void; + onPaste: (modifiers: readonly TypeModifier[]) => void; onAdd: (modifier: TypeModifier) => void; onDelete: (index: number) => void; } @@ -53,7 +57,7 @@ function parseFormValue(descriptor: ModifierDescriptor, rawValue: string): unkno } } -export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) { +export function PerTypePanel({ modifiers, clipboard, onClipboardChange, onPaste, onAdd, onDelete }: Props) { const [formOpen, setFormOpen] = useState(false); const [pieceType, setPieceType] = useState('pawn'); const [color, setColor] = useState('both'); @@ -91,10 +95,23 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) { return (
{/* Panel header */} -
+

Per-Type Modifiers

+
{/* Scrollable body */} @@ -111,7 +128,7 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) {
{mod.pieceType} @@ -122,14 +139,25 @@ export function PerTypePanel({ modifiers, onAdd, onDelete }: Props) { {desc ? desc.describe(mod.value) : String(mod.value)} - +
+ + +
); })}