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
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -7,9 +7,22 @@ import { MAX_GUESSES, WORD_LENGTH, type BoardState, type Pattern } from './engin
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const TILE_CLASS: Record<string, string> = {
|
||||
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',
|
||||
@@ -18,12 +31,40 @@ const TILE_CLASS: Record<string, string> = {
|
||||
/**
|
||||
* A glyph per state, shown only in high-contrast mode.
|
||||
*
|
||||
* Green/yellow/grey is a colour-only distinction, which is exactly why the
|
||||
* 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.
|
||||
* 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<string, string> = { G: '●', Y: '◆', X: '' };
|
||||
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,
|
||||
@@ -33,35 +74,45 @@ function Tile({
|
||||
compact,
|
||||
}: {
|
||||
letter: string;
|
||||
tile: Pattern[number] | null;
|
||||
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 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
|
||||
'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
|
||||
? '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`
|
||||
? // 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 ? { animationDelay: `${index * 100}ms` } : undefined}
|
||||
style={revealing && tile ? { animationDelay: `${index * STAGGER_MS}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">
|
||||
{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}
|
||||
@@ -83,19 +134,25 @@ function Row({
|
||||
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',
|
||||
shake && 'motion-safe:animate-[tile-shake_600ms_ease-enter]',
|
||||
)}
|
||||
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 Pattern[number]) : null}
|
||||
tile={pattern ? (pattern[i] as TileState) : null}
|
||||
index={i}
|
||||
revealing={revealing}
|
||||
compact={compact}
|
||||
@@ -105,6 +162,14 @@ function Row({
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -119,26 +184,23 @@ export const Board = memo(function Board({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-1 sm:gap-1.5',
|
||||
'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]' : 'w-[min(100%,20rem)] min-w-[15rem]',
|
||||
compact ? 'w-[7rem] gap-0.5' : 'w-[min(100%,20rem)] min-w-[15rem] gap-1 sm:gap-1.5',
|
||||
)}
|
||||
role="img"
|
||||
aria-label={
|
||||
rows.length === 0
|
||||
? 'Empty board, six guesses remaining.'
|
||||
: `${rows.length} of ${MAX_GUESSES} guesses played.`
|
||||
}
|
||||
aria-label={boardLabel(state)}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<Row
|
||||
key={`${row.guess}-${i}`}
|
||||
guess={row.guess}
|
||||
pattern={row.pattern}
|
||||
revealing={i === rows.length - 1}
|
||||
// The thumbnail is the shape of the run, not a replay: no flip there.
|
||||
revealing={!compact && i === rows.length - 1}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user