Wordle module, cross-language tests, and the deploy path
The browser engine is a port of the Python one and CI proves it: all 21.2M (guess, answer) pairs hashed on both sides to the same SHA-256. Six TS tests, including the duplicate-letter table and the twelve pinned seed vectors that keep ?seed= permalinks pointing at the same word the recording used. Word lists are split by how they are used. answers.json is inlined because the board needs it before first paint to turn a seed into a word, and a fetch there means a visibly empty board on a cold cache. guesses.json is fetched, because it is three times larger and only needed the first time somebody presses Enter; until it lands, validation falls back to the answer list, which accepts strictly fewer words. The failure mode is 'your real word was briefly rejected', not 'a non-word was accepted' — the right way round. The solver runs in a worker constructed from a same-origin module URL, never Vite's ?worker&inline: that yields a blob:, and production CSP has no worker-src, so it falls back to default-src 'self' and the worker is blocked with no console error. It would fail in production only. deploy.sh smoke-tests the real public hostname from the deploying machine and fails on a body under 1 kB, because the bind bug's signature is a valid certificate over an empty 200 and a local --resolve check passes anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { memo } 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.
|
||||
*/
|
||||
|
||||
const TILE_CLASS: Record<string, string> = {
|
||||
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/yellow/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.
|
||||
*/
|
||||
const TILE_GLYPH: Record<string, string> = { G: '●', Y: '◆', X: '' };
|
||||
|
||||
function Tile({
|
||||
letter,
|
||||
tile,
|
||||
index,
|
||||
revealing,
|
||||
compact,
|
||||
}: {
|
||||
letter: string;
|
||||
tile: Pattern[number] | null;
|
||||
index: number;
|
||||
revealing: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const filled = letter !== '';
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative grid place-items-center select-none font-semibold uppercase',
|
||||
'aspect-square rounded-md border-2 transition-colors',
|
||||
compact ? 'text-[0.55rem] border' : 'text-xl sm:text-2xl',
|
||||
tile
|
||||
? TILE_CLASS[tile]
|
||||
: filled
|
||||
? 'border-muted/60 bg-surface text-fg'
|
||||
: 'border-border bg-surface-2/40 text-fg',
|
||||
// The flip is a rotation about X with the colour landing at the
|
||||
// half-way point, staggered along the row. `prefers-reduced-motion`
|
||||
// clamps it to nothing globally, which is why announceRow() exists.
|
||||
revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
|
||||
filled && !tile && 'motion-safe:animate-[tile-pop_120ms_ease-enter]',
|
||||
)}
|
||||
style={revealing ? { animationDelay: `${index * 100}ms` } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
{tile ? (
|
||||
<span className="pointer-events-none absolute bottom-0 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-90">
|
||||
{TILE_GLYPH[tile]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-1 sm:gap-1.5',
|
||||
shake && 'motion-safe:animate-[tile-shake_600ms_ease-enter]',
|
||||
)}
|
||||
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{[...letters].map((letter, i) => (
|
||||
<Tile
|
||||
key={i}
|
||||
letter={letter.trim()}
|
||||
tile={pattern ? (pattern[i] as Pattern[number]) : null}
|
||||
index={i}
|
||||
revealing={revealing}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn('grid gap-1 sm:gap-1.5', compact ? 'w-full max-w-[7rem]' : 'w-full max-w-sm')}
|
||||
role="img"
|
||||
aria-label={
|
||||
rows.length === 0
|
||||
? 'Empty board, six guesses remaining.'
|
||||
: `${rows.length} of ${MAX_GUESSES} guesses played.`
|
||||
}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<Row
|
||||
key={`${row.guess}-${i}`}
|
||||
guess={row.guess}
|
||||
pattern={row.pattern}
|
||||
revealing={i === rows.length - 1}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
{draftRow !== null ? (
|
||||
<Row
|
||||
guess={draftRow}
|
||||
pattern={null}
|
||||
revealing={false}
|
||||
shake={state.invalid !== null}
|
||||
compact={compact}
|
||||
/>
|
||||
) : null}
|
||||
{Array.from({ length: Math.max(0, blanks) }, (_, i) => (
|
||||
<Row key={`blank-${i}`} guess="" pattern={null} revealing={false} compact={compact} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Board;
|
||||
Reference in New Issue
Block a user