From 8828908a5962f252a860ef6cac2b6162700cf13d Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sat, 18 Apr 2026 20:43:11 -0600 Subject: [PATCH] feat(chess): live-sync FEN textarea with board edits in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the FEN textarea held an independent draft that only synced to the board when the user clicked Reset. Placing a piece left the FEN showing stale content, which was confusing — the field looked like a proxy for the board but wasn't. Now the textarea mirrors the live board FEN by default: - Placing, erasing, or clearing pieces updates the textarea immediately. - Starting to type into the textarea marks it 'dirty' (via a ref so we don't rerender), pausing the auto-sync until the user Loads (accept the draft) or Discards (restore the live FEN). - The 'Unsaved edit' indicator appears while dirty. - Load / Discard buttons disable when draft equals live FEN — nothing to apply. - Library load also resets the dirty flag, as that's a clean state reset. - Removed the redundant 'Current: ' helper line below the textarea; the textarea itself is the live FEN now. E2E: 2 new tests - FEN field updates live as pieces are placed. - Typing into FEN field pauses live sync until Load or Discard. 25/25 Playwright tests passing. 1025 unit tests green. --- packages/chess/e2e/layouts.spec.ts | 48 +++++++++++++++ packages/chess/src/ui/LayoutEditor.tsx | 81 ++++++++++++++++++++------ 2 files changed, 110 insertions(+), 19 deletions(-) diff --git a/packages/chess/e2e/layouts.spec.ts b/packages/chess/e2e/layouts.spec.ts index 557a4a1..76ec9c5 100644 --- a/packages/chess/e2e/layouts.spec.ts +++ b/packages/chess/e2e/layouts.spec.ts @@ -168,6 +168,54 @@ test.describe('LayoutEditor modal', () => { ).toBeVisible(); }); + test('FEN field updates live as pieces are placed on the board', async ({ + page, + }) => { + const fenField = page.getByTestId('editor-fen'); + + // Empty board → all-eights FEN. + await expect(fenField).toHaveValue('8/8/8/8/8/8/8/8'); + + // Place a white king on e1. + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); + + // Textarea now reflects the board without any Load/Reset step. + await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3'); + + // Place a black king on e8. + await page.getByTestId('palette-black-king').click(); + await page.getByTestId('editor-square-60').click(); + await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3'); + }); + + test('typing into FEN field stops live sync until Load or Discard', async ({ + page, + }) => { + // Place a king so the live FEN has something meaningful. + await page.getByTestId('palette-white-king').click(); + await page.getByTestId('editor-square-4').click(); + + const fenField = page.getByTestId('editor-fen'); + await expect(fenField).toHaveValue('8/8/8/8/8/8/8/4K3'); + + // Start editing the textarea — live sync should now pause. + await fenField.fill('garbage in progress'); + + // "Unsaved edit" indicator appears; the Load button is enabled. + await expect(page.getByTestId('editor-fen-dirty')).toBeVisible(); + + // Adding more pieces to the board does NOT clobber the draft. + await page.getByTestId('palette-black-king').click(); + await page.getByTestId('editor-square-60').click(); + await expect(fenField).toHaveValue('garbage in progress'); + + // Discard restores the live FEN and clears the dirty indicator. + await page.locator('[data-action="sync-fen"]').click(); + await expect(fenField).toHaveValue('4k3/8/8/8/8/8/8/4K3'); + await expect(page.getByTestId('editor-fen-dirty')).toHaveCount(0); + }); + test('Use This Layout commits a custom layout to the lobby', async ({ page }) => { // Build minimal valid layout. await page.getByTestId('palette-white-king').click(); diff --git a/packages/chess/src/ui/LayoutEditor.tsx b/packages/chess/src/ui/LayoutEditor.tsx index 960f232..f929905 100644 --- a/packages/chess/src/ui/LayoutEditor.tsx +++ b/packages/chess/src/ui/LayoutEditor.tsx @@ -29,7 +29,7 @@ * incrementally — simpler and avoids ambiguity about which source * of truth wins). */ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { toast } from 'sonner'; import { fromFen, @@ -92,6 +92,17 @@ export function LayoutEditor({ const [libraryOpen, setLibraryOpen] = useState(false); const [libraryVersion, setLibraryVersion] = useState(0); // bump to force reload + /** + * Whether the FEN textarea is currently being edited by the user. + * While true, we DON'T overwrite its contents with the derived + * liveFen — that would clobber typing mid-edit. It flips off when + * the user clicks Load (accept) or Reset (discard) or blurs out. + * + * A ref rather than state because we only need to gate an effect; + * we don't render based on it. + */ + const fenDirtyRef = useRef(false); + // Global Esc listener: close the editor regardless of where focus // currently is. We can't rely on the modal div's onKeyDown because // the user might have focused a textarea / button inside a nested @@ -105,11 +116,18 @@ export function LayoutEditor({ return () => window.removeEventListener('keydown', handler); }, [onClose]); - // 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. + // Derived FEN whenever placements change. The textarea mirrors + // this so placing pieces on the board updates the FEN field live + // — except when the user is actively editing the field (see + // fenDirtyRef), in which case we leave their draft alone until + // they explicitly Load or Reset. const liveFen = useMemo(() => toFen(placements), [placements]); + useEffect(() => { + if (fenDirtyRef.current) return; + setFenDraft(liveFen); + }, [liveFen]); + const validation = useMemo( () => validateLayout({ @@ -149,13 +167,16 @@ export function LayoutEditor({ return; } setPlacements(pieces); + // Successful load re-syncs the two sources of truth — the + // textarea is no longer "dirty" vs. the board. + fenDirtyRef.current = false; 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. + // Discard in-progress edits, restore the textarea to the current + // board state. + fenDirtyRef.current = false; setFenDraft(liveFen); } @@ -200,7 +221,10 @@ export function LayoutEditor({ function handleLoadFromLibrary(entry: SavedLayout) { setPlacements([...entry.pieces]); setName(entry.name); - setFenDraft(toFen(entry.pieces)); + // Library load is a clean state reset — clear any in-progress + // FEN edits. The useEffect that mirrors liveFen → fenDraft will + // sync the textarea on the next render. + fenDirtyRef.current = false; setLibraryOpen(false); toast.success(`Loaded "${entry.name}"`); } @@ -303,7 +327,13 @@ export function LayoutEditor({ { + // User is typing: mark the textarea as dirty so + // the board→FEN auto-sync effect doesn't clobber + // their edit until they Load or Reset. + fenDirtyRef.current = true; + setFenDraft(v); + }} onLoadFen={loadFen} onSyncFen={syncFenDraft} validation={validation} @@ -528,9 +558,19 @@ function ActionsPanel({ return (