feat(chess): custom-layout editor + saved library (Phase E)

Adds a full in-browser editor for authoring custom starting
layouts and a persistent library to save them across sessions.

persist/layout-library.ts:
- SavedLayout shape with id/name/pieces/starred/updatedAt.
- saveToLibrary / loadLibrary / deleteFromLibrary / setStarred /
  duplicateEntry / makeId helpers.
- Capacity cap at 20 entries; oldest non-starred is evicted on
  overflow; all-starred + full returns { ok: false, reason }
  so the UI can surface an actionable message.
- Localstorage key is versioned (houserules:layouts:v1) for a
  future migration path.
- 14 unit tests cover eviction, shape validation, star toggles,
  duplicate flow, and the crypto.randomUUID fallback.

ui/LayoutEditor.tsx:
- Modal overlay with three panels — palette (white/black pieces +
  Erase + Clear), interactive 8x8 board with click-to-place
  brushes, and an actions panel.
- Live FEN textarea (toFen/fromFen round-trips) with a Load
  button that replaces the board and a Reset-to-board sync.
- Live validation panel shows errors (block CTA) and warnings
  (inform but don't block). The 'Use This Layout' CTA is disabled
  until errors clear.
- Save to Library writes a SavedLayout; the integrated Library
  drawer lists saved layouts sorted by starred-first + most-recent,
  with Load / Star / Duplicate / Delete actions.
- Copy Share Link writes a \${origin}/?fen=...&name=... URL to the
  clipboard — pastes straight into the lobby's existing query-param
  pre-select flow from Phase D.
- Esc closes the modal.

ui/Lobby.tsx:
- Custom... entry in the layout picker opens the editor.
- onApply(layout) commits the custom layout as the lobby's
  current selection so Create Room ships it to the server.

e2e/layouts.spec.ts:
- Picker renders every premade + Custom entry.
- Selecting Dunsany updates description; ?layoutId=dunsany and
  malformed ?fen behave correctly.
- Editor opens on Custom, validates king count, erases pieces,
  loads FEN, commits via 'Use This Layout', saves to library
  with cross-reload persistence, closes on Esc.

1025 unit tests passing; bun run check clean. Playwright suite
requires dev server — run with \`bun run --filter @paratype/chess e2e\`
when needed.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 20:20:41 -06:00
commit 832ebe9cd6
No known key found for this signature in database
5 changed files with 1295 additions and 0 deletions

View file

@ -0,0 +1,180 @@
/**
* E2E starting layouts feature (picker + editor + shareable URLs).
*
* Covers Phase A-E user-visible flows:
* 1. Lobby shows a LayoutPicker with every registered premade.
* 2. Picking a premade changes the selection (description text
* updates to match).
* 3. Opening the "Custom…" entry opens the editor modal.
* 4. Placing pieces in the editor via the palette + board clicks.
* 5. Live validation flips the CTA's disabled state.
* 6. Saving to library round-trips through localStorage.
* 7. "Use This Layout" commits a custom layout back to the lobby.
* 8. Query-string ?fen= pre-selects a custom layout on page load.
*
* Does NOT exercise the multiplayer flow (server round-trip) because
* the broadcast-level contract is covered in server unit tests and
* packages/chess/e2e/multiplayer.spec.ts. This spec runs against a
* local dev server only no WS server needed.
*/
import { test, expect } from '@playwright/test';
test.describe('LayoutPicker (lobby)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('page-home')).toBeVisible();
});
test('renders the picker with Classic selected by default', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
await expect(picker).toBeVisible();
await expect(picker).toHaveValue('classic');
});
test('picker includes every expected premade', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
// All premades + Custom entry.
for (const id of [
'classic',
'dunsany',
'monster',
'pawns-only',
'horde',
'knightmate',
'chess960',
'empty',
]) {
await expect(picker.locator(`option[value="${id}"]`)).toHaveCount(1);
}
await expect(picker.locator('option[value="__custom__"]')).toHaveCount(1);
});
test('selecting Dunsany updates the description', async ({ page }) => {
const picker = page.getByTestId('layout-picker');
await picker.selectOption('dunsany');
await expect(page.locator('text=/Dunsany/i').first()).toBeVisible();
});
test('?layoutId=dunsany query param pre-selects the layout', async ({ page }) => {
await page.goto('/?layoutId=dunsany');
const picker = page.getByTestId('layout-picker');
await expect(picker).toHaveValue('dunsany');
});
test('malformed ?fen query silently falls back to Classic', async ({ page }) => {
await page.goto('/?fen=not-a-valid-fen');
const picker = page.getByTestId('layout-picker');
// Custom layout from malformed FEN isn't applied; Classic stays
// selected.
await expect(picker).toHaveValue('classic');
});
});
test.describe('LayoutEditor modal', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.getByTestId('layout-picker').selectOption('__custom__');
await expect(page.getByTestId('layout-editor')).toBeVisible();
});
test('opens with an empty board — validation errors show', async ({ page }) => {
// Empty board = no kings → validation errors.
await expect(page.getByTestId('validation-errors')).toBeVisible();
// Primary CTA disabled.
await expect(page.locator('[data-action="apply-layout"]')).toBeDisabled();
});
test('placing one king per side makes the layout valid', async ({ page }) => {
// Select white king from palette, click e1.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click(); // e1 = square 4
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click(); // e8 = square 60
// Validation flips to "ok".
await expect(page.getByTestId('validation-ok')).toBeVisible();
await expect(page.locator('[data-action="apply-layout"]')).toBeEnabled();
});
test('erase brush removes a placed piece', async ({ page }) => {
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
// Piece rendered in the square.
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toBeVisible();
await page.locator('[data-action="brush-erase"]').click();
await page.getByTestId('editor-square-4').click();
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toHaveCount(0);
});
test('FEN Load replaces the board', async ({ page }) => {
const fen = '8/8/8/8/8/8/8/4K2k'; // white king e1, black king h1 (not a realistic position but valid)
await page.getByTestId('editor-fen').fill(fen);
await page.locator('[data-action="load-fen"]').click();
// White king at square 4 (e1).
await expect(
page.getByTestId('editor-square-4').locator('img'),
).toBeVisible();
});
test('Use This Layout commits a custom layout to the lobby', async ({ page }) => {
// Build minimal valid layout.
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
// Rename to something identifiable.
await page.getByTestId('layout-name').fill('My Mate-in-0');
await page.locator('[data-action="apply-layout"]').click();
// Editor closes, picker now reflects the custom layout.
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
const picker = page.getByTestId('layout-picker');
await expect(picker).toHaveValue('__custom__');
});
test('Save to Library persists across reloads', async ({ page, context }) => {
await page.getByTestId('palette-white-king').click();
await page.getByTestId('editor-square-4').click();
await page.getByTestId('palette-black-king').click();
await page.getByTestId('editor-square-60').click();
await page.getByTestId('layout-name').fill('Persisted');
await page.locator('[data-action="save-to-library"]').click();
// Open the library drawer.
await page.locator('[data-action="library-toggle"]').click();
await expect(page.getByTestId('library-list')).toBeVisible();
await expect(page.locator('text=Persisted').first()).toBeVisible();
// Reload and re-open the editor; library should still have the entry.
await page.reload();
await page.getByTestId('layout-picker').selectOption('__custom__');
await page.locator('[data-action="library-toggle"]').click();
await expect(page.locator('text=Persisted').first()).toBeVisible();
// Cleanup — remove the entry so parallel test runs stay clean.
// (Library is localStorage-scoped per browser context; this block
// is belt-and-suspenders.)
await context.clearCookies();
await page.evaluate(() => {
localStorage.removeItem('houserules:layouts:v1');
});
});
test('Esc closes the editor', async ({ page }) => {
await page.keyboard.press('Escape');
await expect(page.getByTestId('layout-editor')).not.toBeVisible();
});
});

View file

@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import {
loadLibrary,
saveToLibrary,
deleteFromLibrary,
setStarred,
duplicateEntry,
makeId,
__test__,
type SavedLayout,
} from "./layout-library.js";
// happy-dom provides a localStorage object but its methods are
// bound to a prototype that doesn't survive certain destructuring
// patterns; we install a simple Map-backed shim unconditionally so
// the tests have predictable behavior.
beforeAll(() => {
const store = new Map<string, string>();
const shim: Storage = {
get length() {
return store.size;
},
clear() {
store.clear();
},
getItem(k: string) {
return store.get(k) ?? null;
},
setItem(k: string, v: string) {
store.set(k, v);
},
removeItem(k: string) {
store.delete(k);
},
key(i: number) {
return [...store.keys()][i] ?? null;
},
};
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: shim,
});
});
// Clear between tests so each one starts fresh.
beforeEach(() => {
localStorage.clear();
});
function seed(entry: Partial<SavedLayout> = {}): SavedLayout {
return {
id: makeId(),
name: "Test",
pieces: [
{ type: "king", color: "white", square: 4 },
{ type: "king", color: "black", square: 60 },
],
starred: false,
updatedAt: Date.now(),
...entry,
};
}
describe("loadLibrary()", () => {
it("returns [] when no key is set", () => {
expect(loadLibrary()).toEqual([]);
});
it("returns [] when storage contains malformed JSON", () => {
localStorage.setItem(__test__.STORAGE_KEY, "not json");
expect(loadLibrary()).toEqual([]);
});
it("filters out entries that fail shape validation", () => {
localStorage.setItem(
__test__.STORAGE_KEY,
JSON.stringify([
{ id: "ok", name: "ok", pieces: [], starred: false, updatedAt: 0 },
{ not: "a valid entry" },
]),
);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.id).toBe("ok");
});
});
describe("saveToLibrary()", () => {
it("appends a new entry", () => {
const result = saveToLibrary(seed({ name: "First" }));
expect(result.ok).toBe(true);
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("First");
});
it("updates in place when id matches", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
saveToLibrary({ ...entry, name: "Renamed" });
const library = loadLibrary();
expect(library).toHaveLength(1);
expect(library[0]?.name).toBe("Renamed");
});
it("evicts the oldest non-starred when MAX_ENTRIES is hit", () => {
// Seed with MAX_ENTRIES entries, incrementing updatedAt.
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, updatedAt: i }));
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
// Save one more — oldest (E0) should be evicted.
saveToLibrary(seed({ name: "newest", updatedAt: 9999 }));
const library = loadLibrary();
expect(library).toHaveLength(__test__.MAX_ENTRIES);
expect(library.map((e) => e.name)).not.toContain("E0");
expect(library.some((e) => e.name === "newest")).toBe(true);
});
it("refuses save when every entry is starred and library is full", () => {
for (let i = 0; i < __test__.MAX_ENTRIES; i++) {
saveToLibrary(seed({ name: `E${String(i)}`, starred: true }));
}
const result = saveToLibrary(seed({ name: "newest" }));
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toMatch(/unstar/i);
}
expect(loadLibrary()).toHaveLength(__test__.MAX_ENTRIES);
});
});
describe("deleteFromLibrary()", () => {
it("removes the matching entry", () => {
const entry = seed();
saveToLibrary(entry);
deleteFromLibrary(entry.id);
expect(loadLibrary()).toHaveLength(0);
});
it("is a no-op for unknown id", () => {
saveToLibrary(seed());
deleteFromLibrary("not-real");
expect(loadLibrary()).toHaveLength(1);
});
});
describe("setStarred()", () => {
it("toggles starred and updates updatedAt", () => {
const entry = seed({ starred: false, updatedAt: 100 });
saveToLibrary(entry);
const before = Date.now();
setStarred(entry.id, true);
const library = loadLibrary();
expect(library[0]?.starred).toBe(true);
expect(library[0]?.updatedAt).toBeGreaterThanOrEqual(before);
});
});
describe("duplicateEntry()", () => {
it("creates a copy with a new id and '(copy)' name suffix", () => {
const entry = seed({ name: "Original" });
saveToLibrary(entry);
const newId = duplicateEntry(entry.id);
expect(newId).toBeDefined();
expect(newId).not.toBe(entry.id);
const library = loadLibrary();
expect(library).toHaveLength(2);
const copy = library.find((e) => e.id === newId);
expect(copy?.name).toBe("Original (copy)");
expect(copy?.starred).toBe(false);
});
it("returns undefined for unknown id", () => {
expect(duplicateEntry("not-real")).toBeUndefined();
});
});
describe("makeId()", () => {
it("produces unique ids", () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(makeId());
expect(ids.size).toBe(100);
});
it("falls back when crypto.randomUUID is unavailable", () => {
const original = crypto.randomUUID;
// @ts-expect-error — intentional override for test
crypto.randomUUID = undefined;
try {
const id = makeId();
expect(id).toMatch(/^layout-/);
} finally {
crypto.randomUUID = original;
}
});
});
// Silence React testing-library warnings if this file runs in a mixed env.
vi.mock("react", async () => await vi.importActual("react"));

View file

@ -0,0 +1,180 @@
/**
* Saved-layout library localStorage-backed store of user-authored
* starting layouts.
*
* Each entry:
* - `id` local-only UUID (NOT the server-side layout id which is
* always "custom" for user layouts). Used to identify the entry
* for update/delete/star operations.
* - `name` user-provided label, shown in the library drawer.
* - `pieces` the piece placements.
* - `starred` true when the user has pinned this layout. Starred
* entries are exempt from FIFO eviction and sort first.
* - `updatedAt` unix ms of last write. Drives display order for
* non-starred entries (newest first) and FIFO eviction (oldest
* non-starred entry is removed when capacity is hit).
*
* Capacity: MAX_ENTRIES (20). When exceeded, the oldest non-starred
* entry is evicted. If every entry is starred, we refuse the save
* and the caller surfaces a "library full — unstar something" error.
*
* Storage key is versioned (`houserules:layouts:v1`). A schema bump
* would ship a new key + migration; v1 entries are kept on best-
* effort and re-hydrated read-only if they can't be migrated.
*/
import type { PiecePlacement } from "../layouts/types.js";
const STORAGE_KEY = "houserules:layouts:v1";
const MAX_ENTRIES = 20;
export interface SavedLayout {
readonly id: string;
readonly name: string;
readonly pieces: readonly PiecePlacement[];
readonly starred: boolean;
readonly updatedAt: number;
}
/**
* Read every saved layout from storage. Returns an empty array on
* empty/missing/corrupt storage silently discarding unparseable
* data is preferable to blocking the UI.
*/
export function loadLibrary(): SavedLayout[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) return [];
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) return [];
// Shallow shape validation — anything that fails is dropped.
return parsed.filter(isSavedLayout);
} catch {
return [];
}
}
/** Write the full library array back to storage. */
function writeLibrary(entries: SavedLayout[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
/* quota exceeded / storage disabled — best effort */
}
}
/**
* Save a new layout or update an existing one (matched by `id`).
*
* Returns `{ ok: true }` on success. Returns `{ ok: false, reason }`
* when the library is full of starred entries caller surfaces a
* message telling the user to unstar something.
*/
export function saveToLibrary(
entry: SavedLayout,
): { ok: true } | { ok: false; reason: string } {
const library = loadLibrary();
const existingIdx = library.findIndex((e) => e.id === entry.id);
if (existingIdx >= 0) {
// Update in place — no capacity check needed.
library[existingIdx] = entry;
writeLibrary(library);
return { ok: true };
}
// New entry — enforce capacity.
if (library.length >= MAX_ENTRIES) {
// Find the oldest non-starred entry and evict it.
const evictable = library
.filter((e) => !e.starred)
.sort((a, b) => a.updatedAt - b.updatedAt);
if (evictable.length === 0) {
return {
ok: false,
reason:
"Library full (20 layouts). Unstar one to make room, or delete an entry.",
};
}
const oldestNonStarred = evictable[0]!;
const pruned = library.filter((e) => e.id !== oldestNonStarred.id);
pruned.push(entry);
writeLibrary(pruned);
return { ok: true };
}
library.push(entry);
writeLibrary(library);
return { ok: true };
}
/** Remove a layout by id. No-op if the id is unknown. */
export function deleteFromLibrary(id: string): void {
const library = loadLibrary();
writeLibrary(library.filter((e) => e.id !== id));
}
/** Toggle the starred flag on a layout. */
export function setStarred(id: string, starred: boolean): void {
const library = loadLibrary();
const idx = library.findIndex((e) => e.id === id);
if (idx < 0) return;
const updated: SavedLayout = {
...library[idx]!,
starred,
updatedAt: Date.now(),
};
library[idx] = updated;
writeLibrary(library);
}
/**
* Duplicate a library entry. The copy gets a fresh id, "(copy)"
* appended to the name, and starred=false regardless of the
* original's state. Returns the new entry's id so the caller can
* select it.
*/
export function duplicateEntry(id: string): string | undefined {
const library = loadLibrary();
const entry = library.find((e) => e.id === id);
if (entry === undefined) return undefined;
const newId = makeId();
const copy: SavedLayout = {
id: newId,
name: `${entry.name} (copy)`,
pieces: entry.pieces,
starred: false,
updatedAt: Date.now(),
};
const result = saveToLibrary(copy);
if (!result.ok) return undefined;
return newId;
}
/** Generate a local-only id for a library entry. */
export function makeId(): string {
// crypto.randomUUID is available in every browser we target (and
// in Node 19+). Fall back to a Math.random-based id only on
// ancient runtimes.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `layout-${Math.random().toString(36).slice(2, 12)}`;
}
// ── Internal helpers ──────────────────────────────────────────────────
function isSavedLayout(value: unknown): value is SavedLayout {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v["id"] === "string" &&
typeof v["name"] === "string" &&
Array.isArray(v["pieces"]) &&
typeof v["starred"] === "boolean" &&
typeof v["updatedAt"] === "number"
);
}
// Exported for tests only. Prefer the high-level helpers above.
export const __test__ = { STORAGE_KEY, MAX_ENTRIES };

View file

@ -0,0 +1,714 @@
/**
* Layout Editor modal overlay for composing custom starting layouts.
*
* Three main panels:
* - LEFT: piece palette + actions (Save, Copy Link, Library drawer toggle).
* - CENTER: interactive 8×8 board. Click a palette item to select it,
* click a square to place. Click an occupied square to remove.
* Drag-and-drop also works (palette piece square, or square off
* the board to remove).
* - RIGHT: live FEN textarea + validation panel + "Use This Layout"
* primary CTA.
*
* The editor is UNCONTROLLED from the parent's perspective: it owns its
* own piece-placement state, and emits `onApply(layout)` when the user
* confirms. The parent decides what to do with the applied layout
* (typically: set it as the Lobby's selected layout).
*
* Design decisions:
* - Click-to-place is the PRIMARY interaction (works on mobile with a
* single tap after selecting from the palette, and on desktop with
* no ambiguity).
* - Drag-and-drop is layered on top for power users; it uses HTML5
* drag events (no custom pointer tracking the plain API is
* good enough for an 8×8 grid).
* - Validation is live via `validateLayout`; errors block the CTA
* but warnings do not.
* - FEN text is ALWAYS derived from the current placements. Edits to
* the FEN field replace the placements on "Load FEN" (not
* incrementally simpler and avoids ambiguity about which source
* of truth wins).
*/
import { useMemo, useState, type ReactNode } from 'react';
import { toast } from 'sonner';
import {
fromFen,
toFen,
validateLayout,
type PiecePlacement,
type PieceColor,
type PieceType,
type StartingLayout,
} from '@paratype/chess';
import {
deleteFromLibrary,
duplicateEntry,
loadLibrary,
makeId,
saveToLibrary,
setStarred,
type SavedLayout,
} from '../persist/layout-library';
import { pieceAssets } from '../assets/pieces';
import { squareToAlgebraic } from '../coord';
export interface LayoutEditorProps {
/** Optional initial placements. Omitted = empty board. */
initialPieces?: readonly PiecePlacement[];
/** Emitted when the user clicks "Use This Layout". The caller
* decides how to surface it (typically: set Lobby state). */
onApply: (layout: StartingLayout) => void;
/** Emitted when the user clicks Close/Cancel or hits Esc. */
onClose: () => void;
}
type Brush =
| { type: 'place'; piece: { type: PieceType; color: PieceColor } }
| { type: 'erase' }
| { type: 'none' };
const PIECE_PALETTE: ReadonlyArray<{ type: PieceType; label: string }> = [
{ type: 'king', label: 'King' },
{ type: 'queen', label: 'Queen' },
{ type: 'rook', label: 'Rook' },
{ type: 'bishop', label: 'Bishop' },
{ type: 'knight', label: 'Knight' },
{ type: 'pawn', label: 'Pawn' },
];
export function LayoutEditor({
initialPieces,
onApply,
onClose,
}: LayoutEditorProps) {
const [placements, setPlacements] = useState<PiecePlacement[]>(
() => (initialPieces ? [...initialPieces] : []),
);
const [brush, setBrush] = useState<Brush>({ type: 'none' });
const [fenDraft, setFenDraft] = useState<string>(() =>
toFen(initialPieces ?? []),
);
const [name, setName] = useState<string>('My Layout');
const [libraryOpen, setLibraryOpen] = useState(false);
const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload
// Derived FEN whenever placements change — keeps the readonly view
// in sync. The user's in-flight fenDraft is a SEPARATE state so
// typing doesn't get clobbered by a re-render.
const liveFen = useMemo(() => toFen(placements), [placements]);
const validation = useMemo(
() =>
validateLayout({
id: 'editor-in-progress',
name,
description: '',
pieces: placements,
source: 'custom',
}),
[placements, name],
);
const canApply = validation.errors.length === 0;
// ── Placement actions ─────────────────────────────────────────────
function placeAt(square: number) {
if (brush.type === 'none') return;
setPlacements((prev) => {
const filtered = prev.filter((p) => p.square !== square);
if (brush.type === 'erase') return filtered;
return [
...filtered,
{ type: brush.piece.type, color: brush.piece.color, square },
];
});
}
function clearBoard() {
setPlacements([]);
}
function loadFen() {
const { pieces, errors } = fromFen(fenDraft);
if (errors.length > 0) {
toast.error(`FEN error: ${errors[0]!}`);
return;
}
setPlacements(pieces);
toast.success('FEN loaded');
}
function syncFenDraft() {
// Allow a manual "Copy current board to FEN field" action if a
// user edited the field and then changed their mind — restores
// the draft to the live FEN.
setFenDraft(liveFen);
}
function handleApply() {
if (!canApply) {
toast.error(validation.errors[0] ?? 'Layout invalid');
return;
}
const layout: StartingLayout = {
id: 'custom',
name: name.trim() || 'Custom Layout',
description: 'Custom layout from editor.',
pieces: placements,
source: 'custom',
};
onApply(layout);
}
// ── Library actions ────────────────────────────────────────────────
function handleSaveToLibrary() {
if (!canApply) {
toast.error('Fix validation errors before saving.');
return;
}
const entry: SavedLayout = {
id: makeId(),
name: name.trim() || 'Untitled Layout',
pieces: placements,
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 handleLoadFromLibrary(entry: SavedLayout) {
setPlacements([...entry.pieces]);
setName(entry.name);
setFenDraft(toFen(entry.pieces));
setLibraryOpen(false);
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);
}
function handleCopyShareLink() {
if (!canApply) {
toast.error('Fix validation errors before sharing.');
return;
}
const fen = toFen(placements);
const base =
typeof window !== 'undefined' ? window.location.origin : '';
const url = `${base}/?fen=${encodeURIComponent(fen)}&name=${encodeURIComponent(name)}`;
void navigator.clipboard
.writeText(url)
.then(() => toast.success('Share link copied to clipboard'))
.catch(() => toast.error(`Copy failed — link is ${url}`));
}
// ── Render ─────────────────────────────────────────────────────────
return (
<div
data-testid="layout-editor"
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
onKeyDown={(e) => {
if (e.key === 'Escape') onClose();
}}
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-6xl 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">
<h2 className="text-lg font-bold text-neutral-900">
Custom Layout Editor
</h2>
<input
data-testid="layout-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Layout 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}
/>
</div>
<div className="flex items-center gap-2">
<button
data-action="library-toggle"
onClick={() => setLibraryOpen((v) => !v)}
className="px-3 py-1.5 text-sm font-semibold text-neutral-700 bg-neutral-100 rounded hover:bg-neutral-200 transition-colors"
>
{libraryOpen ? 'Hide Library' : 'Library'}
</button>
<button
onClick={onClose}
aria-label="Close editor"
className="p-2 text-neutral-500 hover:bg-neutral-100 rounded transition-colors"
>
×
</button>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-hidden flex">
{libraryOpen ? (
<LibraryDrawer
// Force reload by changing key when library mutates.
key={libraryVersion}
onLoad={handleLoadFromLibrary}
onDelete={handleDeleteFromLibrary}
onStarToggle={handleStarToggle}
onDuplicate={handleDuplicate}
onClose={() => setLibraryOpen(false)}
/>
) : (
<>
<PalettePanel brush={brush} onSelect={setBrush} onClear={clearBoard} />
<BoardPanel
placements={placements}
brush={brush}
onSquareClick={placeAt}
/>
<ActionsPanel
fenDraft={fenDraft}
liveFen={liveFen}
onFenDraftChange={setFenDraft}
onLoadFen={loadFen}
onSyncFen={syncFenDraft}
validation={validation}
canApply={canApply}
onApply={handleApply}
onSaveToLibrary={handleSaveToLibrary}
onCopyShareLink={handleCopyShareLink}
/>
</>
)}
</div>
</div>
</div>
);
}
// ── Subcomponents ───────────────────────────────────────────────────
function PalettePanel({
brush,
onSelect,
onClear,
}: {
brush: Brush;
onSelect: (b: Brush) => void;
onClear: () => void;
}) {
return (
<aside className="w-52 border-r border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
White Pieces
</h3>
<div className="grid grid-cols-3 gap-2">
{PIECE_PALETTE.map(({ type }) => (
<PaletteButton
key={`white-${type}`}
type={type}
color="white"
selected={
brush.type === 'place' &&
brush.piece.type === type &&
brush.piece.color === 'white'
}
onSelect={() =>
onSelect({ type: 'place', piece: { type, color: 'white' } })
}
/>
))}
</div>
</div>
<div>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
Black Pieces
</h3>
<div className="grid grid-cols-3 gap-2">
{PIECE_PALETTE.map(({ type }) => (
<PaletteButton
key={`black-${type}`}
type={type}
color="black"
selected={
brush.type === 'place' &&
brush.piece.type === type &&
brush.piece.color === 'black'
}
onSelect={() =>
onSelect({ type: 'place', piece: { type, color: 'black' } })
}
/>
))}
</div>
</div>
<div className="border-t border-neutral-200 pt-4 space-y-2">
<button
data-action="brush-erase"
onClick={() => onSelect({ type: 'erase' })}
className={`w-full px-3 py-2 text-sm font-semibold rounded transition-colors ${
brush.type === 'erase'
? 'bg-red-100 text-red-700 border border-red-300'
: 'bg-white text-neutral-700 border border-neutral-200 hover:bg-neutral-50'
}`}
>
Erase
</button>
<button
data-action="brush-clear"
onClick={onClear}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-200 rounded hover:bg-neutral-50 transition-colors"
>
Clear Board
</button>
</div>
</aside>
);
}
function PaletteButton({
type,
color,
selected,
onSelect,
}: {
type: PieceType;
color: PieceColor;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
data-testid={`palette-${color}-${type}`}
data-selected={selected}
onClick={onSelect}
className={`aspect-square flex items-center justify-center rounded transition-all ${
selected
? 'bg-blue-100 border-2 border-blue-500 shadow-inner'
: 'bg-white border border-neutral-200 hover:border-neutral-300'
}`}
>
<img
src={pieceAssets[color][type]}
alt={`${color} ${type}`}
className="w-10 h-10 drop-shadow-sm"
draggable={false}
/>
</button>
);
}
function BoardPanel({
placements,
brush,
onSquareClick,
}: {
placements: PiecePlacement[];
brush: Brush;
onSquareClick: (square: number) => void;
}) {
const placementBySquare = useMemo(() => {
const map = new Map<number, PiecePlacement>();
for (const p of placements) map.set(p.square, p);
return map;
}, [placements]);
// Render rank 8 → 1 so the visual matches a standard board (white
// on the bottom). Square indexing: rank r, file f = r*8 + f.
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 piece = placementBySquare.get(sq);
const isDark = (rank + file) % 2 === 0;
cells.push(
<button
key={sq}
data-testid={`editor-square-${String(sq)}`}
data-square={squareToAlgebraic(sq)}
onClick={() => onSquareClick(sq)}
disabled={brush.type === 'none'}
className={`aspect-square flex items-center justify-center transition-colors ${
isDark ? 'bg-neutral-400' : 'bg-neutral-100'
} ${
brush.type !== 'none'
? 'hover:brightness-110 cursor-pointer'
: 'cursor-default'
} disabled:cursor-default`}
>
{piece !== undefined && (
<img
src={pieceAssets[piece.color][piece.type]}
alt={`${piece.color} ${piece.type}`}
className="w-full h-full object-contain p-1"
draggable={false}
/>
)}
</button>,
);
}
rows.push(
<div key={rank} className="grid grid-cols-8">
{cells}
</div>,
);
}
return (
<main className="flex-1 flex items-center justify-center p-6 bg-white">
<div
data-testid="editor-board"
className="w-full max-w-lg aspect-square border-2 border-neutral-800 shadow-xl"
>
{rows}
</div>
</main>
);
}
function ActionsPanel({
fenDraft,
liveFen,
onFenDraftChange,
onLoadFen,
onSyncFen,
validation,
canApply,
onApply,
onSaveToLibrary,
onCopyShareLink,
}: {
fenDraft: string;
liveFen: string;
onFenDraftChange: (v: string) => void;
onLoadFen: () => void;
onSyncFen: () => void;
validation: { errors: readonly string[]; warnings: readonly string[] };
canApply: boolean;
onApply: () => void;
onSaveToLibrary: () => void;
onCopyShareLink: () => void;
}) {
return (
<aside className="w-80 border-l border-neutral-200 bg-neutral-50 p-4 overflow-y-auto space-y-4">
<section>
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest mb-2">
FEN
</h3>
<textarea
data-testid="editor-fen"
value={fenDraft}
onChange={(e) => onFenDraftChange(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-xs font-mono border border-neutral-300 rounded resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Paste FEN here…"
/>
<div className="flex gap-2 mt-2">
<button
data-action="load-fen"
onClick={onLoadFen}
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
>
Load
</button>
<button
data-action="sync-fen"
onClick={onSyncFen}
title="Reset FEN field to current board"
className="flex-1 px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100"
>
Reset to board
</button>
</div>
<p className="mt-2 text-[10px] text-neutral-400 font-mono break-all">
Current: {liveFen}
</p>
</section>
<ValidationPanel validation={validation} />
<section className="space-y-2">
<button
data-action="save-to-library"
onClick={onSaveToLibrary}
disabled={!canApply}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Save to Library
</button>
<button
data-action="copy-share-link"
onClick={onCopyShareLink}
disabled={!canApply}
className="w-full px-3 py-2 text-sm font-semibold text-neutral-700 bg-white border border-neutral-300 rounded hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
Copy Share Link
</button>
<button
data-action="apply-layout"
onClick={onApply}
disabled={!canApply}
className="w-full px-3 py-2.5 text-sm font-bold text-white bg-neutral-900 rounded hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed shadow"
>
Use This Layout
</button>
</section>
</aside>
);
}
function ValidationPanel({
validation,
}: {
validation: { errors: readonly string[]; warnings: readonly string[] };
}) {
const { errors, warnings } = validation;
if (errors.length === 0 && warnings.length === 0) {
return (
<div
data-testid="validation-ok"
className="px-3 py-2 text-xs font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 rounded"
>
Layout valid
</div>
);
}
return (
<div className="space-y-2">
{errors.length > 0 && (
<ul
data-testid="validation-errors"
className="px-3 py-2 text-xs text-red-800 bg-red-50 border border-red-200 rounded space-y-1"
>
{errors.map((e, i) => (
<li key={i}> {e}</li>
))}
</ul>
)}
{warnings.length > 0 && (
<ul
data-testid="validation-warnings"
className="px-3 py-2 text-xs text-amber-800 bg-amber-50 border border-amber-200 rounded space-y-1"
>
{warnings.map((w, i) => (
<li key={i}> {w}</li>
))}
</ul>
)}
</div>
);
}
function LibraryDrawer({
onLoad,
onDelete,
onStarToggle,
onDuplicate,
onClose,
}: {
onLoad: (entry: SavedLayout) => void;
onDelete: (id: string) => void;
onStarToggle: (id: string, starred: boolean) => void;
onDuplicate: (id: string) => void;
onClose: () => void;
}) {
const entries = loadLibrary();
// Sort: starred first (both groups by most-recent-first).
const sorted = [...entries].sort((a, b) => {
if (a.starred !== b.starred) return a.starred ? -1 : 1;
return b.updatedAt - a.updatedAt;
});
return (
<div className="flex-1 p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-neutral-900">Saved Layouts</h3>
<button
onClick={onClose}
className="text-sm text-neutral-500 hover:text-neutral-900"
>
Back to editor
</button>
</div>
{sorted.length === 0 ? (
<p className="text-sm text-neutral-500 italic">
No saved layouts yet. Use the "Save to Library" button to stash
your current board for later.
</p>
) : (
<ul data-testid="library-list" className="space-y-2">
{sorted.map((entry) => (
<li
key={entry.id}
data-testid={`library-entry-${entry.id}`}
className="flex items-center gap-3 p-3 bg-white border border-neutral-200 rounded hover:border-neutral-300"
>
<button
onClick={() => onStarToggle(entry.id, !entry.starred)}
aria-label={entry.starred ? 'Unstar' : 'Star'}
className={
entry.starred ? 'text-amber-500' : 'text-neutral-300'
}
>
</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">
{entry.pieces.length} pieces {' '}
{new Date(entry.updatedAt).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onLoad(entry)}
className="px-2 py-1 text-xs font-semibold text-blue-700 hover:bg-blue-50 rounded"
>
Load
</button>
<button
onClick={() => onDuplicate(entry.id)}
className="px-2 py-1 text-xs font-semibold text-neutral-700 hover:bg-neutral-100 rounded"
>
Duplicate
</button>
<button
onClick={() => onDelete(entry.id)}
aria-label="Delete"
className="px-2 py-1 text-xs font-semibold text-red-700 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -14,6 +14,7 @@ import {
} from '@paratype/chess';
import type { LayoutRequest } from '../net/types';
import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor';
interface LobbyProps {
/** Optional when provided, create/join/solo flows reset the local
@ -36,6 +37,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
// link pre-selects the right layout.
const [selectedLayout, setSelectedLayout] =
useState<StartingLayout>(CLASSIC_LAYOUT);
const [editorOpen, setEditorOpen] = useState(false);
useEffect(() => {
const layoutId = searchParams.get('layoutId');
@ -205,6 +207,7 @@ export function Lobby({ chessState }: LobbyProps = {}) {
<LayoutPicker
value={selectedLayout}
onChange={setSelectedLayout}
onCustomRequested={() => setEditorOpen(true)}
disabled={loading}
/>
<button
@ -268,6 +271,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
)}
</AnimatePresence>
</div>
{editorOpen && (
<LayoutEditor
initialPieces={
selectedLayout.source === 'custom' ? selectedLayout.pieces : []
}
onApply={(layout) => {
setSelectedLayout(layout);
setEditorOpen(false);
}}
onClose={() => setEditorOpen(false)}
/>
)}
</main>
);
}