import { memo, useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; import { MAX_GUESSES, WORD_LENGTH, type BoardState, type Pattern } from './engine'; /** * The board. One component for all three jobs — you playing, the replay, and * the gallery thumbnail — because three near-identical boards is how they drift. * * It draws no outer chrome: the shell's card supplies the border, ground and * padding. Every colour is a `tile-*` token, which is what lets the * high-contrast palette swap the fills without this file knowing. */ type TileState = 'G' | 'Y' | 'X'; /** * Fill, border and glyph ink per state. The fills are the same in both themes * (the board is the one theme-invariant object on the site) and the `-fg` * tokens are measured against their own fill: exact 13.08, present 11.16, * absent 7.75 dark / 5.74 light; in high contrast white on each fill clears * 4.9 or better. The numbers live beside the tokens in index.css. */ const TILE_CLASS: Record = { G: 'bg-tile-exact text-tile-exact-fg border-tile-exact', Y: 'bg-tile-present text-tile-present-fg border-tile-present', X: 'bg-tile-absent text-tile-absent-fg border-tile-absent', }; /** * A glyph per state, shown only in high-contrast mode. * * Green/amber/grey is a colour-only distinction, which is exactly why the * original game ships a high-contrast mode. A second channel means the result * survives deuteranopia, a projector with the colour balance wrong, and a * screenshot printed in black and white. Absent has no glyph: a filled grey * tile with nothing in the corner is the third state. */ const TILE_GLYPH: Record = { G: '●', Y: '◆', X: '' }; /** Flip length and per-tile stagger, matching the keyframes in index.css. */ const FLIP_MS = 520; const STAGGER_MS = 100; /** * Whether a revealing tile has reached the half-way point of its flip. * * The real flip lands the colour at 90°, when the face is edge-on: before that * the tile still looks typed, after it the tile is scored. Applying the colour * on mount and rotating a coloured tile reads as a wobble, not a reveal. Under * reduced motion the colour lands immediately — there is no flip to hide it * behind, and the row's announcement carries the result regardless. */ function useLanded(revealing: boolean, index: number): boolean { const [landed, setLanded] = useState(!revealing); useEffect(() => { if (!revealing) return; if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { setLanded(true); return; } const id = window.setTimeout(() => setLanded(true), index * STAGGER_MS + FLIP_MS / 2); return () => window.clearTimeout(id); }, [revealing, index]); return landed; } function Tile({ letter, tile, index, revealing, compact, }: { letter: string; tile: TileState | null; index: number; revealing: boolean; compact?: boolean; }) { const filled = letter !== ''; const landed = useLanded(revealing && tile !== null, index); const scored = tile !== null && landed; return ( ); } function Row({ guess, pattern, revealing, shake, compact, }: { guess: string; pattern: Pattern | null; revealing: boolean; shake?: boolean; compact?: boolean; }) { const letters = guess.padEnd(WORD_LENGTH, ' ').slice(0, WORD_LENGTH); // The shake is armed by the prop and disarmed by its own end event, so a // second refusal shakes the row again instead of leaving the class in place. const [shaking, setShaking] = useState(false); useEffect(() => { if (shake) setShaking(true); }, [shake]); return (
{ if (event.animationName === 'tile-shake') setShaking(false); }} > {[...letters].map((letter, i) => ( ))}
); } function boardLabel(state: BoardState): string { const played = state.rows.length; if (state.status === 'won') return `Solved in ${played} of ${MAX_GUESSES} guesses.`; if (state.status === 'lost') return `All ${MAX_GUESSES} guesses played, not solved.`; if (played === 0) return `Empty board, ${MAX_GUESSES} guesses remaining.`; return `${played} of ${MAX_GUESSES} guesses played.`; } export const Board = memo(function Board({ state, compact, }: { state: BoardState; compact?: boolean; }) { const rows = state.rows; const draftRow = rows.length < MAX_GUESSES && state.status === 'playing' ? state.draft : null; const blanks = MAX_GUESSES - rows.length - (draftRow === null ? 0 : 1); return (
{rows.map((row, i) => ( ))} {draftRow !== null ? ( ) : null} {Array.from({ length: Math.max(0, blanks) }, (_, i) => ( ))}
); }); export default Board;