b601511e7f
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
88 lines
2.8 KiB
TypeScript
88 lines
2.8 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
|
|
|
export interface Stat {
|
|
/** Short. Two or three words; it sits above the number. */
|
|
label: string;
|
|
value: ReactNode;
|
|
/** One clarifying line, shown under the number at a smaller size. */
|
|
hint?: string;
|
|
tone?: StatTone;
|
|
/** Set when this number was derived under an edited reward, not recorded. */
|
|
edited?: boolean;
|
|
}
|
|
|
|
export interface StatStripProps {
|
|
stats: Stat[];
|
|
className?: string;
|
|
/** Announce changes as they happen. Off by default — the shell owns the
|
|
* page's single live region and two competing ones talk over each other. */
|
|
live?: boolean;
|
|
}
|
|
|
|
const TONE: Record<StatTone, string> = {
|
|
default: 'text-fg',
|
|
positive: 'text-positive',
|
|
warning: 'text-warning',
|
|
danger: 'text-danger',
|
|
info: 'text-info',
|
|
brand: 'text-accent-fg',
|
|
};
|
|
|
|
/**
|
|
* A row of headline numbers. Scrolls horizontally on a phone rather than
|
|
* wrapping into a ragged grid: four stats reflowing to 2x2 at 390px puts the
|
|
* least important number in the most prominent corner.
|
|
*/
|
|
export function StatStrip({ stats, className, live = false }: StatStripProps) {
|
|
if (stats.length === 0) return null;
|
|
return (
|
|
<dl
|
|
className={cn(
|
|
'flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1',
|
|
'sm:grid sm:snap-none sm:overflow-visible sm:pb-0',
|
|
stats.length <= 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3 lg:grid-cols-4',
|
|
className,
|
|
)}
|
|
{...(live ? { 'aria-live': 'polite' as const } : {})}
|
|
>
|
|
{stats.map((stat) => (
|
|
<div
|
|
key={stat.label}
|
|
className="card min-w-[9.5rem] flex-1 shrink-0 snap-start px-4 py-3"
|
|
>
|
|
<dt className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted">
|
|
<span className="truncate">{stat.label}</span>
|
|
{stat.edited ? <EditedChip /> : null}
|
|
</dt>
|
|
<dd
|
|
className={cn(
|
|
'nums mt-1 text-2xl font-semibold leading-tight',
|
|
TONE[stat.tone ?? 'default'],
|
|
)}
|
|
>
|
|
{stat.value}
|
|
</dd>
|
|
{stat.hint ? <dd className="mt-0.5 text-xs text-muted">{stat.hint}</dd> : null}
|
|
</div>
|
|
))}
|
|
</dl>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Marks a number the visitor caused rather than one we recorded. It appears on
|
|
* every derived value in the reward editor; without it, an edited ranking
|
|
* screenshots identically to a measured one.
|
|
*/
|
|
export function EditedChip({ className }: { className?: string }) {
|
|
return (
|
|
<Badge className={cn('px-1.5 py-0 text-[10px] uppercase tracking-wide', className)}>
|
|
edited
|
|
</Badge>
|
|
);
|
|
}
|