Files
PIG-Demo/src/demos/wordle/surface.tsx
T
karti-ai a21596b3e4
ci / web (push) Successful in 2m42s
ci / python (push) Successful in 3m8s
Redesign in Prime Intellect's design language, both themes, three viewports
Eighteen agents: three design directions judged on whether a COO on a phone
actually learns what an environment is, on craft, and on landing without a
rewrite; one spec; a foundation of measured tokens; six build lanes; three
browser verifiers; a final gate pass.

The materials are Prime Intellect's, measured from their site: near-black
grounds, one green, sharp radii, mono small-caps labels, Geist and Geist Mono
self-hosted because production CSP is font-src 'self'. Two of their own greys
fail contrast on their own ground (#737373 is 4.02:1, #6E6E6E is 3.73:1 on
#0F0F0F), so --muted is lifted and the CSS comment carries the number — or
someone will 'correct' it back. Every text-on-ground pair in both themes is
tabulated in src/index.css with its measured ratio.

The two rules that resolved every conflict: data is mono, sentences are sans;
the language wins on materials, the lesson wins on legibility. Light mode is a
finished paper theme, not an inversion.

What did not change: the derived-tabs contract, the honesty markers, the
isolation lint, every gate. 419 contract checks, entry chunk at 74% of budget,
zero horizontal overflow on any route at 390/1024/1440 in either theme.

Also flips Alert Triage to status 'live' — the pipeline built it but never
promoted it, so it was badged SPEC on its own playable page and the home page
counted one environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
2026-08-28 21:41:15 -07:00

224 lines
7.6 KiB
TypeScript

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<TileState, 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/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<TileState, string> = { 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 (
<div
className={cn(
'relative grid select-none place-items-center font-sans font-semibold uppercase',
'aspect-square rounded-tile transition-colors duration-1',
compact ? 'border text-[0.55rem]' : 'border-2 text-[1.375rem] sm:text-2xl',
scored
? TILE_CLASS[tile]
: filled
? // Typed, unscored: a strong edge says "this is yours", the letter is
// plain ink. No fill, so the scored fill is an unmistakable change.
'border-border-strong bg-transparent text-fg'
: 'border-tile-empty bg-transparent text-fg',
// The flip is a rotation about X, staggered along the row; the colour
// lands at the half-way point (see useLanded). `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 && tile ? { animationDelay: `${index * STAGGER_MS}ms` } : undefined}
aria-hidden="true"
>
{letter}
{scored ? (
<span
className={cn(
'pointer-events-none absolute bottom-0 right-0.5 leading-none opacity-0',
"[:root[data-contrast='high']_&]:opacity-100",
compact ? 'text-[0.6em]' : 'text-[0.5em]',
)}
>
{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);
// 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 (
<div
className={cn('grid gap-1 sm:gap-1.5', shaking && 'motion-safe:animate-[tile-shake_400ms_ease-enter]')}
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
onAnimationEnd={(event) => {
if (event.animationName === 'tile-shake') setShaking(false);
}}
>
{[...letters].map((letter, i) => (
<Tile
key={i}
letter={letter.trim()}
tile={pattern ? (pattern[i] as TileState) : null}
index={i}
revealing={revealing}
compact={compact}
/>
))}
</div>
);
}
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 (
<div
className={cn(
'grid',
// An explicit width, not `w-full max-w-sm`. An empty board has no
// intrinsic width, so inside a shrink-to-fit parent `w-full` resolves
// to nothing and the board collapses to a stub — which is exactly the
// state the interactive board starts in.
compact ? 'w-[7rem] gap-0.5' : 'w-[min(100%,20rem)] min-w-[15rem] gap-1 sm:gap-1.5',
)}
role="img"
aria-label={boardLabel(state)}
>
{rows.map((row, i) => (
<Row
key={`${row.guess}-${i}`}
guess={row.guess}
pattern={row.pattern}
// The thumbnail is the shape of the run, not a replay: no flip there.
revealing={!compact && 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;