houserules/packages/chess/src/ui/ModifierProfileEditor.tsx
Joey Yakimowich-Payne babee38702
feat(ui): share custom modifier with multiplayer room — closes T29 fixmes
Threads the multiplayer publisher all the way from useMultiplayerGame
down through GameView → RulesDrawer → ModifierProfileEditor →
CustomModifierEditor, surfacing a Share with Room button in the
custom modifier editor when (and only when) the editor was opened
from a multiplayer game.

Wiring summary (top-down):
- useMultiplayerGame.ts: returns sendRegisterCustomModifier(descriptor),
  a thin wrapper around the GameClient.sendRegisterCustomModifier
  helper added in the previous commit.
- useMultiplayerGame.ts: onError handler surfaces CUSTOM_MODIFIER_INVALID
  and CUSTOM_MODIFIER_LIMIT as toasts on top of the existing in-game
  error banner so the user notices the rejection immediately.
- GameView.tsx: GameEngineState gains an optional
  sendRegisterCustomModifier field; the multiplayer destructure
  pulls it out and passes it to RulesDrawer as
  onShareCustomModifierWithRoom (omitted in solo, where the prop is
  undefined and Share UI doesn't render).
- RulesDrawer.tsx: optional onShareCustomModifierWithRoom prop;
  conditionally forwards to ModifierProfileEditor.
- ModifierProfileEditor.tsx: optional onShareCustomModifierWithRoom
  prop; conditionally forwards to CustomModifierEditor as onShareWithRoom.
- CustomModifierEditor.tsx: when onShareWithRoom is provided, renders
  a green Share with Room button in the header alongside Save. Click
  invokes the publisher with the current descriptor; toast confirms
  the share landed (server broadcast is the actual proof, observed
  by the local PredictionManager subscriber registering the descriptor
  on the engine's customModifiers registry).

E2E coverage (both formerly-fixme tests now PASS):
- multiplayer custom modifier sharing — both clients see the
  registered descriptor: opens two browser contexts via raw WS
  (matches modifier-profiles.spec.ts MP pattern), host registers a
  descriptor after both reconnect-by-token complete, both sides
  observe custom-modifier.registered.
- server rejects custom modifier with > 50 primitives — error event
  observed: host registers a 51-primitive descriptor, asserts an
  INVALID_MESSAGE / CUSTOM_MODIFIER_INVALID error is observed and
  no broadcast fires.

Final state: 79/79 e2e + 1386 unit tests, zero fixmes, zero skipped.
2026-04-19 21:56:34 -06:00

536 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Modifier Profile Editor — modal shell.
*
* 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 { useCallback, 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 { CustomModifierEditor } from './CustomModifierEditor.js';
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<InstanceModifier, 'square'>[] };
interface Props {
isOpen: boolean;
onClose: () => void;
/**
* Optional multiplayer publisher passed through to the nested
* Custom Modifier editor. Present only when this editor was opened
* inside an in-game RulesDrawer with a multiplayer hook in scope.
*/
onShareCustomModifierWithRoom?: (
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
) => void;
}
function makeBlankProfile(): ModifierProfile {
return {
id: makeId(),
name: 'My Profile',
description: '',
perType: [],
perInstance: [],
version: 1,
source: 'custom',
};
}
export function ModifierProfileEditor({
isOpen,
onClose,
onShareCustomModifierWithRoom,
}: Props) {
const [showCustomModifierEditor, setShowCustomModifierEditor] = useState(false);
const [history, setHistory] = useState<ModifierProfile[]>(() => [
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<StartingLayout | null>(null);
const [clipboard, setClipboard] = useState<ModifierClipboard>({ kind: 'empty' });
const layouts = useMemo(() => LAYOUT_REGISTRY.list(), []);
// Fresh profile whenever the modal opens.
useEffect(() => {
if (isOpen) {
setHistory([makeBlankProfile()]);
setHistoryIndex(0);
}
}, [isOpen]);
// Esc closes the modal regardless of focus.
//
// We use capture phase + stopImmediatePropagation so that other
// window-level Esc handlers (e.g. the RulesDrawer behind us) do not
// ALSO fire on the same keystroke. Without this, pressing Esc with
// both editor+drawer open would close both, leaving neither visible
// but the drawer's pointer-events-blocking backdrop briefly lingers.
useEffect(() => {
if (!isOpen) return;
function handleKeyDown(e: KeyboardEvent) {
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, undo, redo]);
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);
setHistory([profile]);
setHistoryIndex(0);
}
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) {
setHistory([{ ...entry.profile }]);
setHistoryIndex(0);
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 (
<div
data-testid="modifier-editor-modal"
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl min-h-[85vh] max-h-[95vh] overflow-hidden flex flex-col">
{/* Header */}
<header className="flex items-center justify-between px-6 py-4 border-b border-neutral-200">
<div className="flex items-center gap-3 min-w-0 flex-wrap">
<h2 className="text-lg font-bold text-neutral-900 shrink-0">
Modifier Profiles
</h2>
<input
data-testid="profile-name"
type="text"
value={profile.name}
onChange={(e) =>
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"
maxLength={40}
/>
{/* Bound layout for per-instance modifiers */}
<div className="flex items-center gap-1.5 shrink-0">
<label
htmlFor="bound-layout-picker-input"
className="text-xs font-bold text-neutral-500 uppercase tracking-widest whitespace-nowrap"
>
Layout:
</label>
<select
id="bound-layout-picker-input"
data-testid="bound-layout-picker"
value={boundLayout?.id ?? ''}
onChange={(e) => {
const id = e.target.value;
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]"
>
<option value=""> none </option>
{layouts.map((l) => (
<option key={l.id} value={l.id}>
{l.name}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
data-testid="undo-button"
onClick={undo}
disabled={!canUndo}
aria-label="Undo"
title="Undo (Cmd/Ctrl+Z)"
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
>
</button>
<button
data-testid="redo-button"
onClick={redo}
disabled={!canRedo}
aria-label="Redo"
title="Redo (Cmd/Ctrl+Shift+Z)"
className="px-2 text-neutral-500 hover:bg-neutral-100 disabled:opacity-30 disabled:hover:bg-transparent rounded transition-colors"
>
</button>
<div className="w-px h-5 bg-neutral-200 mx-1" />
<div data-testid="clipboard-status" className="px-2 text-xs font-medium text-neutral-500">
{clipboard.kind === 'empty'
? 'Clipboard: empty'
: `Clipboard: ${clipboard.entries.length} modifier${clipboard.entries.length !== 1 ? 's' : ''}`}
</div>
<div className="w-px h-5 bg-neutral-200 mx-1" />
<button
data-testid="open-custom-modifier-editor"
onClick={() => setShowCustomModifierEditor(true)}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
title="Author a custom modifier from primitives"
>
+ Custom Modifier
</button>
<button
data-testid="save-profile"
onClick={handleSaveToLibrary}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Save
</button>
<button
data-testid="share-profile"
onClick={handleShareProfile}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
Share
</button>
<button
onClick={onClose}
aria-label="Close modifier editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
</div>
</header>
{/* Conflict Resolution Panel */}
<ConflictResolutionPanel
profile={profile}
layout={boundLayout}
onResolve={(next) => pushSnapshot(next)}
/>
{/* Body — three panels */}
<div className="flex-1 overflow-hidden flex border-t border-neutral-200">
{/* LEFT — per-type modifier panel (T21) */}
<aside className="w-64 border-r border-neutral-200 overflow-hidden">
<PerTypePanel
modifiers={profile.perType}
clipboard={clipboard}
onClipboardChange={setClipboard}
onAdd={(m) =>
pushSnapshot((p) => ({ ...p, perType: [...p.perType, m] }))
}
onDelete={(i) =>
pushSnapshot((p) => ({
...p,
perType: p.perType.filter((_, idx) => idx !== i),
}))
}
onPaste={(entries: readonly TypeModifier[]) => {
pushSnapshot((p) => {
const newPerType = [...p.perType];
for (const entry of entries) {
const existingIdx = newPerType.findIndex(
(m) =>
m.kind === entry.kind &&
m.pieceType === entry.pieceType &&
m.color === entry.color
);
if (existingIdx !== -1) {
newPerType[existingIdx] = entry;
} else {
newPerType.push(entry);
}
}
return { ...p, perType: newPerType };
});
}}
/>
</aside>
{/* CENTER — per-instance board preview (T22) */}
<main className="flex-1 flex overflow-hidden">
<PerInstancePanel
modifiers={profile.perInstance}
boundLayout={boundLayout}
clipboard={clipboard}
onClipboardChange={setClipboard}
onLayoutSelect={() => {
document
.querySelector<HTMLSelectElement>(
'[data-testid="bound-layout-picker"]',
)
?.focus();
}}
onAdd={(m) =>
pushSnapshot((p) => ({
...p,
perInstance: [...p.perInstance, m],
}))
}
onDelete={(i) =>
pushSnapshot((p) => ({
...p,
perInstance: p.perInstance.filter((_, idx) => idx !== i),
}))
}
onPaste={(entries: readonly InstanceModifier[]) => {
pushSnapshot((p) => {
const newPerInstance = [...p.perInstance];
for (const entry of entries) {
const existingIdx = newPerInstance.findIndex(
(m) =>
m.kind === entry.kind &&
m.square === entry.square
);
if (existingIdx !== -1) {
newPerInstance[existingIdx] = entry;
} else {
newPerInstance.push(entry);
}
}
return { ...p, perInstance: newPerInstance };
});
}}
/>
</main>
{/* RIGHT — profile library (T23) */}
<ProfileLibraryPanel
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
/>
</div>
</div>
<CustomModifierEditor
isOpen={showCustomModifierEditor}
onClose={() => setShowCustomModifierEditor(false)}
{...(onShareCustomModifierWithRoom !== undefined
? { onShareWithRoom: onShareCustomModifierWithRoom }
: {})}
/>
</div>
);
}
// ── 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 (
<aside className="w-72 border-l border-neutral-200 bg-neutral-50 flex flex-col overflow-hidden">
<div className="px-4 pt-4 pb-2 border-b border-neutral-200">
<p className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
Profiles
</p>
</div>
<div className="flex-1 overflow-y-auto p-4">
{sorted.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No saved profiles yet. Click{' '}
<span className="font-semibold">Save</span> to add the current
profile to your library.
</p>
) : (
<ul className="space-y-2">
{sorted.map((entry) => (
<li
key={entry.id}
data-testid="profile-library-entry"
className="flex items-start gap-2 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300 transition-colors"
>
<button
onClick={() => onStarToggle(entry.id, !entry.starred)}
aria-label={entry.starred ? 'Unstar' : 'Star'}
className={
entry.starred
? 'text-amber-500 mt-0.5 shrink-0'
: 'text-neutral-300 mt-0.5 shrink-0'
}
>
</button>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm text-neutral-900 truncate">
{entry.name}
</div>
<div className="text-xs text-neutral-500 mt-0.5">
{entry.profile.perType.length +
entry.profile.perInstance.length}{' '}
modifier
{entry.profile.perType.length +
entry.profile.perInstance.length !==
1
? 's'
: ''}{' '}
· {new Date(entry.updatedAt).toLocaleDateString()}
</div>
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={() => onLoad(entry)}
className="px-2 py-0.5 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
>
Load
</button>
<button
onClick={() => onDuplicate(entry.id)}
className="px-2 py-0.5 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
>
Dupe
</button>
<button
onClick={() => onDelete(entry.id)}
aria-label="Delete"
className="px-2 py-0.5 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
</aside>
);
}