feat(chess): live-sync FEN textarea with board edits in the editor

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: <fen>' 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.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-18 20:43:11 -06:00
commit 8828908a59
No known key found for this signature in database
2 changed files with 110 additions and 19 deletions

View file

@ -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();

View file

@ -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({
<ActionsPanel
fenDraft={fenDraft}
liveFen={liveFen}
onFenDraftChange={setFenDraft}
onFenDraftChange={(v) => {
// 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 (
<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>
<div className="flex items-baseline justify-between mb-2">
<h3 className="text-xs font-bold text-neutral-500 uppercase tracking-widest">
FEN
</h3>
{fenDraft !== liveFen && (
<span
data-testid="editor-fen-dirty"
className="text-[10px] font-semibold text-amber-600 uppercase tracking-wide"
>
Unsaved edit
</span>
)}
</div>
<textarea
data-testid="editor-fen"
value={fenDraft}
@ -539,26 +579,29 @@ function ActionsPanel({
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…"
/>
<p className="mt-1 text-[10px] text-neutral-400 leading-snug">
Updates live as you place pieces. Paste a FEN and hit Load
to replace the board.
</p>
<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"
disabled={fenDraft === liveFen}
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
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"
title="Discard unsaved FEN edits and re-sync to the current board"
disabled={fenDraft === liveFen}
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
Reset to board
Discard
</button>
</div>
<p className="mt-2 text-[10px] text-neutral-400 font-mono break-all">
Current: {liveFen}
</p>
</section>
<ValidationPanel validation={validation} />