Redesign in Prime Intellect's design language, both themes, three viewports
ci / web (push) Successful in 2m42s
ci / python (push) Successful in 3m8s

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:
karti-ai
2026-08-28 21:41:15 -07:00
parent af15ba584e
commit a21596b3e4
85 changed files with 4717 additions and 2230 deletions
+82 -49
View File
@@ -1,4 +1,4 @@
import { useId, useMemo, useState } from 'react';
import { useId, useMemo, useState, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -24,17 +24,29 @@ type Panel = 'none' | 'close' | 'escalate';
const price = (minutes: number) => `+${hours(minutes)} h`;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
/** A price or a "done" beside a verb: mono, muted, the way a cost is printed. */
function Price({ children }: { children: ReactNode }) {
return <span className="font-mono text-caption font-normal text-muted">{children}</span>;
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<fieldset className="min-w-0 rounded-lg border border-border p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-muted">{title}</legend>
<fieldset className="min-w-0 rounded-control border border-border p-3">
<legend className="px-1 font-mono text-label uppercase text-muted">{title}</legend>
{children}
</fieldset>
);
}
/**
* A native select. The border is the only affordance a select has on this
* ground, so it is `border-strong` (≥3:1 in both themes) rather than the
* decorative hairline; the focus ring is the site's.
*/
const SELECT =
'tap min-w-0 rounded-md border border-border bg-surface px-2 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50';
'min-h-11 min-w-0 rounded-control border border-border-strong bg-surface px-2.5 font-sans text-ui text-fg ' +
'transition-colors duration-1 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
'disabled:cursor-not-allowed disabled:opacity-50';
/** The citable ids, grouped the way the screen groups them. */
function groupShown(shown: readonly string[]) {
@@ -132,10 +144,10 @@ export function Controls({
: `Hours spent so far: ${hours(state.minutesSpent)}. ${left} ${left === 1 ? 'reply' : 'replies'} left.`;
return (
<div className="flex flex-col gap-3" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
<div className="flex flex-col gap-3 pb-[max(1rem,var(--safe-bottom))]">
{/* The board is a group of tables; the outcome of every click is said
here in words, where it announces itself. */}
<p aria-live="polite" className="text-sm text-muted">
<p aria-live="polite" className="font-sans text-ui text-fg-2">
{status}
</p>
@@ -147,7 +159,7 @@ export function Controls({
</label>
<select
id={`${ids}-month`}
className={cn(SELECT, 'min-w-[7rem] flex-1')}
className={cn(SELECT, 'min-w-[7rem] flex-1 font-mono text-mono-data')}
value={month}
disabled={done || monthsLeft.length === 0}
onChange={(event) => setMonth(event.target.value)}
@@ -163,13 +175,14 @@ export function Controls({
type="button"
variant="outline"
size="touch"
className="shrink-0"
disabled={done || month === ''}
onClick={() => {
send({ action: 'lookup', what: 'history', month });
setMonth('');
}}
>
History <span className="nums text-muted">{price(PRICES.history)}</span>
History <Price>{price(PRICES.history)}</Price>
</Button>
</div>
@@ -179,7 +192,7 @@ export function Controls({
</label>
<select
id={`${ids}-cp`}
className={cn(SELECT, 'min-w-[7rem] flex-1')}
className={cn(SELECT, 'min-w-[7rem] flex-1 font-mono text-mono-data')}
value={cp}
disabled={done || cpsLeft.length === 0}
onChange={(event) => setCp(event.target.value)}
@@ -195,45 +208,50 @@ export function Controls({
type="button"
variant="outline"
size="touch"
className="shrink-0"
disabled={done || cp === ''}
onClick={() => {
send({ action: 'lookup', what: 'counterparty', id: cp });
setCp('');
}}
>
Counterparty <span className="nums text-muted">{price(PRICES.counterparty)}</span>
Counterparty <Price>{price(PRICES.counterparty)}</Price>
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="touch"
disabled={done || priorsMade}
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
>
Prior alerts <span className="nums text-muted">{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</span>
</Button>
<Button
type="button"
variant="outline"
size="touch"
disabled={done || docsMade}
onClick={() => send({ action: 'lookup', what: 'documents' })}
>
Documents <span className="nums text-muted">{docsMade ? 'done' : price(DOC_MINUTES)}</span>
</Button>
{/* Two per row on a phone, so the pair reads as the pair it is. */}
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant="outline"
size="touch"
disabled={done || priorsMade}
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
>
Prior alerts <Price>{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</Price>
</Button>
<Button
type="button"
variant="outline"
size="touch"
disabled={done || docsMade}
onClick={() => send({ action: 'lookup', what: 'documents' })}
>
Documents <Price>{docsMade ? 'done' : price(DOC_MINUTES)}</Price>
</Button>
</div>
</div>
</Section>
<Section title="Disposition — ends the episode">
<div className="flex flex-wrap gap-2" role="group" aria-label="Choose a disposition">
{/* A toggle pair: the pressed half is the one green fill in the
controls, with the accent edge that keeps it a control on paper. */}
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Choose a disposition">
<Button
type="button"
size="touch"
variant={panel === 'close' ? 'default' : 'outline'}
className={cn(panel === 'close' && 'border border-accent-edge shadow-none')}
aria-pressed={panel === 'close'}
disabled={done}
onClick={() => setPanel(panel === 'close' ? 'none' : 'close')}
@@ -244,6 +262,7 @@ export function Controls({
type="button"
size="touch"
variant={panel === 'escalate' ? 'default' : 'outline'}
className={cn(panel === 'escalate' && 'border border-accent-edge shadow-none')}
aria-pressed={panel === 'escalate'}
disabled={done}
onClick={() => setPanel(panel === 'escalate' ? 'none' : 'escalate')}
@@ -260,8 +279,8 @@ export function Controls({
submitDisposition();
}}
>
<label className="flex flex-col gap-1 text-xs text-muted">
{panel === 'close' ? 'Reason' : 'Typology'}
<label className="flex flex-col gap-1">
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Reason' : 'Typology'}</span>
{panel === 'close' ? (
<select className={SELECT} value={reason} onChange={(event) => setReason(event.target.value as CloseReason)}>
{CLOSE_REASONS.map((r) => (
@@ -282,13 +301,13 @@ export function Controls({
</label>
<fieldset className="min-w-0">
<legend className="mb-1 text-xs text-muted">
<legend className="mb-1 font-sans text-small text-fg-2">
Cite the facts that decide it only ids you have been shown
</legend>
<div className="flex max-h-56 flex-col gap-2 overflow-y-auto rounded-md border border-border p-2">
<div className="flex max-h-56 flex-col gap-2.5 overflow-y-auto overscroll-contain rounded-control border border-border-strong p-2">
{groups.map((group) => (
<div key={group.label}>
<p className="mb-1 text-[0.65rem] font-semibold uppercase tracking-wide text-muted">{group.label}</p>
<p className="mb-1 font-mono text-label uppercase text-muted">{group.label}</p>
<div className="flex flex-wrap gap-1">
{group.ids.map((id) => {
const on = cites.includes(id);
@@ -296,12 +315,16 @@ export function Controls({
<label
key={id}
className={cn(
'inline-flex cursor-pointer select-none items-center gap-1 rounded-md border px-1.5 py-1 font-mono text-[0.7rem] transition-colors duration-1',
on ? 'border-brand bg-accent-subtle text-fg' : 'border-border text-muted hover:bg-surface-2',
'inline-flex cursor-pointer select-none items-center gap-1 rounded-tile border px-1.5 py-1.5 font-mono text-label tracking-normal transition-colors duration-1',
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-bg',
on
? 'border-accent-edge bg-accent-subtle text-fg'
: 'border-border-strong text-fg-2 hover:bg-surface-2',
)}
title={id.startsWith('kyc.') ? fieldLabel(id) : id}
>
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCite(id)} />
{on ? <span aria-hidden="true"></span> : null}
{id}
</label>
);
@@ -312,26 +335,31 @@ export function Controls({
</div>
</fieldset>
<label className="flex flex-col gap-1 text-xs text-muted">
<span>
{panel === 'close' ? 'Note' : 'Narrative'}{' '}
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
<label className="flex flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Note' : 'Narrative'}</span>
<span className="rounded-tile border border-border-strong px-1.5 py-px font-mono text-label uppercase text-muted">
displayed, never scored
</span>
</span>
<textarea
className="min-h-16 rounded-md border border-border bg-surface px-2 py-1 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
className="min-h-16 rounded-control border border-border-strong bg-surface px-2.5 py-1.5 font-sans text-ui text-fg placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
value={prose}
onChange={(event) => setProse(event.target.value)}
placeholder={panel === 'close' ? 'Why this is consistent with the file…' : 'What the pattern is and why it is reportable…'}
/>
</label>
<div className="flex flex-wrap items-center gap-2">
<Button type="submit" size="touch" disabled={cites.length === 0}>
<div className="flex flex-wrap items-center gap-3">
<Button
type="submit"
size="touch"
className="w-full border border-accent-edge shadow-none sm:w-auto"
disabled={cites.length === 0}
>
{panel === 'close' ? 'Close the alert' : 'Escalate for filing'}
</Button>
<span className="nums text-xs text-muted">
<span className="font-mono text-caption text-muted">
{cites.length} cited · {price(TURN_MINUTES)}
</span>
</div>
@@ -340,9 +368,14 @@ export function Controls({
</Section>
{lastReply ? (
<details className="text-xs text-muted">
<summary className="cursor-pointer">The reply the engine received</summary>
<pre className="mt-1 overflow-x-auto rounded-md bg-surface-2 p-2 font-mono text-[0.7rem] text-fg [contain:inline-size]">
<details className="group font-mono text-label uppercase text-muted">
<summary className="min-h-11 cursor-pointer list-none py-2 [&::-webkit-details-marker]:hidden">
<span aria-hidden="true" className="mr-1.5 inline-block transition-transform duration-1 group-open:rotate-90">
</span>
The reply the engine received
</summary>
<pre className="overflow-x-auto rounded-control border border-border bg-surface-3 p-2 font-mono text-code normal-case tracking-normal text-fg [contain:inline-size]">
{lastReply}
</pre>
</details>
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineMeta({
tagline:
'Work one transaction-monitoring alert against the customers file and twelve months of history, then close it or escalate it for a filing.',
vertical: 'financial-crime',
status: 'spec',
status: 'live',
order: 0,
icon: 'Siren',
persona: 'BSA / AML Officer',
+267 -168
View File
@@ -11,10 +11,15 @@ import type { Lookup, Reveal, Screen, Transaction, TriageState } from './types';
* playing, the recorded replay, and the timeline thumbnail — because three
* near-identical boards is how they drift.
*
* It is an instrument, not a document: dense, every id and number in mono,
* every sentence in sans. It draws no outer chrome — the shell's card supplies
* the border, ground and padding — and nothing inside it is a link or a
* button; the actions live in the controls beside it.
*
* Two things this board must do that a game board does not:
*
* · Say, on the board itself, that the data is synthetic. Not in a footnote.
* The label is the first row of every render.
* The label is the first row of every render, the thumbnail included.
* · Show the answer only once the episode is over. `state.reveal` is null
* until then, so the surface cannot leak the label even by accident.
*
@@ -27,12 +32,17 @@ const MAX_TURNS = 8;
/* ---------------------------------------------------------------- pieces */
/**
* Warning ink on the warning ground: 6.08 light / 9.12 dark (index.css). The
* dashed edge is the one place on the site a border is dashed, so the shape
* itself says "a note about the data".
*/
function SyntheticLabel({ compact }: { compact?: boolean }) {
return (
<p
className={cn(
'rounded-md border border-dashed border-warning/60 bg-warning/5 font-semibold uppercase tracking-wide text-warning',
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-[0.65rem]',
'rounded-tile border border-dashed border-warning bg-warning-subtle font-mono font-medium uppercase tracking-label text-warning',
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-label',
)}
>
Synthetic data no real customer, account or transaction
@@ -40,20 +50,37 @@ function SyntheticLabel({ compact }: { compact?: boolean }) {
);
}
/** An identifier. `fg` for something the analyst can cite, `muted` for a reference to one. */
function Id({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'fg' }) {
return <code className={cn('font-mono', tone === 'muted' ? 'text-muted' : 'text-fg')}>{children}</code>;
}
/** The mono 11 uppercase label, the same style as the shell's chrome. */
function Label({ children, className }: { children: ReactNode; className?: string }) {
return <span className={cn('font-mono text-label uppercase text-muted', className)}>{children}</span>;
}
/** A 4px outline chip for a state word. Never colour alone: the word is the signal. */
function Chip({ children, className, title }: { children: ReactNode; className?: string; title?: string }) {
return (
<code className={cn('nums font-mono text-[0.8em]', tone === 'muted' ? 'text-muted' : 'text-fg')}>
<span
className={cn(
'inline-flex items-center gap-1 whitespace-nowrap rounded-tile border border-border-strong px-1.5 py-px font-mono text-label uppercase',
className,
)}
title={title}
>
{children}
</code>
</span>
);
}
function Block({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
return (
<section className="rounded-lg border border-border bg-surface p-3">
<header className="mb-2 flex items-baseline justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h4>
{aside ? <div className="text-xs text-muted">{aside}</div> : null}
<section className="min-w-0 rounded-control border border-border bg-surface p-3">
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<h4 className="font-mono text-label uppercase text-muted">{title}</h4>
{aside ? <div className="min-w-0 font-mono text-caption text-muted">{aside}</div> : null}
</header>
{children}
</section>
@@ -70,31 +97,43 @@ function Scroll({ children }: { children: ReactNode }) {
return <div className="overflow-x-auto [contain:inline-size]">{children}</div>;
}
/** Table styling shared by every table on the screen: mono 12, heads mono 11 on surface-3. */
const TABLE = 'w-full border-collapse font-mono text-caption';
const TH = 'bg-surface-3 px-2 py-1 text-left font-mono text-label font-medium uppercase text-muted first:rounded-l-tile last:rounded-r-tile';
const TD = 'px-2 py-1 align-baseline';
const NUM = 'whitespace-nowrap text-right';
function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: string }) {
if (rows.length === 0) return <p className="text-xs text-muted">No transactions.</p>;
if (rows.length === 0) return <p className="font-sans text-small text-muted">No transactions.</p>;
return (
<Scroll>
<table className="w-full min-w-[26rem] text-xs">
<thead className="text-left text-muted">
<table className={cn(TABLE, 'min-w-[26rem]')}>
<thead>
<tr>
<th className="pb-1 pr-2 font-medium">id</th>
<th className="pb-1 pr-2 font-medium">date</th>
<th className="pb-1 pr-2 font-medium">channel</th>
<th className="pb-1 pr-2 text-right font-medium">amount</th>
<th className="pb-1 font-medium">where</th>
<th className={TH}>id</th>
<th className={cn(TH, 'text-right')}>date</th>
<th className={TH}>channel</th>
<th className={cn(TH, 'text-right')}>amount</th>
<th className={TH}>where</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id} className="border-t border-border/60">
<td className="py-0.5 pr-2">
<tr key={t.id} className="border-t border-border">
<td className={TD}>
<Id tone="fg">{t.id}</Id>
</td>
<td className="nums py-0.5 pr-2 whitespace-nowrap">{t.date}</td>
<td className="py-0.5 pr-2 whitespace-nowrap">{channelLabel(t.channel)}</td>
<td className="nums py-0.5 pr-2 text-right whitespace-nowrap">{money(t.amount, currency)}</td>
<td className="py-0.5 whitespace-nowrap">
{t.branch ? `branch ${t.branch}` : t.cp ? <Id>{t.cp}</Id> : '—'}
<td className={cn(TD, NUM)}>{t.date}</td>
<td className={cn(TD, 'whitespace-nowrap font-sans text-small')}>{channelLabel(t.channel)}</td>
<td className={cn(TD, NUM, 'text-fg')}>{money(t.amount, currency)}</td>
<td className={cn(TD, 'whitespace-nowrap')}>
{t.branch ? (
<span className="font-sans text-small">branch {t.branch}</span>
) : t.cp ? (
<Id>{t.cp}</Id>
) : (
<span className="text-muted"></span>
)}
</td>
</tr>
))}
@@ -107,15 +146,17 @@ function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: s
function AlertBlock({ screen }: { screen: Screen }) {
const a = screen.alert;
return (
<Block title="Alert" aside={<span className="nums">fired {a.fired}</span>}>
<p className="text-sm">
<Block title="Alert" aside={<span>fired {a.fired}</span>}>
<p className="font-sans text-small text-fg">
<Id tone="fg">{a.id}</Id>
<span className="mx-1.5 text-muted">·</span>
<span className="font-mono text-xs font-semibold">{a.rule}</span>
<span className="font-mono font-semibold">{a.rule}</span>
<span className="mx-1.5 text-muted"></span>
{a.rule_text}
<span className="text-fg-2">{a.rule_text}</span>
</p>
<p className="mb-1 mt-2">
<Label>Triggering transactions</Label>
</p>
<p className="mb-1 mt-2 text-xs text-muted">Triggering transactions</p>
<TransactionTable rows={screen.triggering_transactions} currency={screen.currency} />
</Block>
);
@@ -133,14 +174,12 @@ function KycBlock({ screen }: { screen: Screen }) {
const c = screen.customer;
return (
<Block title="KYC file" aside={<Id>{c.id}</Id>}>
<p className="mb-2 text-sm font-medium">{c.name}</p>
<dl className="grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
<p className="mb-2 font-sans text-ui font-medium text-fg">{c.name}</p>
<dl className="grid grid-cols-1 gap-x-4 gap-y-1.5 sm:grid-cols-2">
{kycEntries(c as unknown as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex min-w-0 flex-col">
<dt className="text-muted">
<Id>kyc.{key}</Id>
</dt>
<dd className="nums break-words">{kycValue(key, value, screen.currency)}</dd>
<div key={key} className="flex min-w-0 flex-col border-t border-border pt-1">
<dt className="font-mono text-caption text-muted">kyc.{key}</dt>
<dd className="break-words font-sans text-small text-fg">{kycValue(key, value, screen.currency)}</dd>
</div>
))}
</dl>
@@ -168,28 +207,28 @@ function SummaryBlock({ screen }: { screen: Screen }) {
}
>
<Scroll>
<table className="w-full min-w-[30rem] text-xs">
<thead className="text-left text-muted">
<table className={cn(TABLE, 'min-w-[30rem]')}>
<thead>
<tr>
<th className="pb-1 pr-2 font-medium">month</th>
<th className={TH}>month</th>
{cols.map(([key, label]) => (
<th key={key} className="pb-1 pr-2 text-right font-medium">
<th key={key} className={cn(TH, 'text-right')}>
{label}
</th>
))}
<th className="pb-1 text-right font-medium">txns</th>
<th className={cn(TH, 'text-right')}>txns</th>
</tr>
</thead>
<tbody>
{screen.summary.map((row) => (
<tr key={row.month} className="border-t border-border/60">
<td className="nums py-0.5 pr-2 whitespace-nowrap">{row.month}</td>
<tr key={row.month} className="border-t border-border">
<td className={cn(TD, 'whitespace-nowrap text-fg')}>{row.month}</td>
{cols.map(([key]) => (
<td key={key} className="nums py-0.5 pr-2 text-right">
<td key={key} className={cn(TD, NUM)}>
{wholeUnits(row[key])}
</td>
))}
<td className="nums py-0.5 text-right">{row.count}</td>
<td className={cn(TD, NUM, 'text-muted')}>{row.count}</td>
</tr>
))}
</tbody>
@@ -202,19 +241,33 @@ function SummaryBlock({ screen }: { screen: Screen }) {
function CounterpartyBlock({ screen }: { screen: Screen }) {
return (
<Block title="Counterparties on screen" aside="top by twelve-month volume">
<ul className="space-y-0.5 text-xs">
{screen.screen_counterparties.map((cp) => (
<li key={cp.id} className="flex flex-wrap items-baseline gap-x-2">
<Id tone="fg">{cp.id}</Id>
<span className="text-muted">{cp.direction === 'in' ? 'inbound' : 'outbound'}</span>
<span className="nums">{cp.count} txns</span>
<span className="nums">{money(cp.total, screen.currency)}</span>
</li>
))}
</ul>
<p className="mt-2 text-xs text-muted">
<Scroll>
<table className={cn(TABLE, 'min-w-[20rem]')}>
<thead>
<tr>
<th className={TH}>id</th>
<th className={TH}>direction</th>
<th className={cn(TH, 'text-right')}>txns</th>
<th className={cn(TH, 'text-right')}>total</th>
</tr>
</thead>
<tbody>
{screen.screen_counterparties.map((cp) => (
<tr key={cp.id} className="border-t border-border">
<td className={TD}>
<Id tone="fg">{cp.id}</Id>
</td>
<td className={cn(TD, 'font-sans text-small text-muted')}>{cp.direction === 'in' ? 'inbound' : 'outbound'}</td>
<td className={cn(TD, NUM)}>{cp.count}</td>
<td className={cn(TD, NUM, 'text-fg')}>{money(cp.total, screen.currency)}</td>
</tr>
))}
</tbody>
</table>
</Scroll>
<p className="mt-2 font-sans text-small text-muted">
Reporting threshold in this jurisdiction:{' '}
<span className="nums text-fg">{money(screen.threshold, screen.currency)}</span>
<span className="font-mono text-fg">{money(screen.threshold, screen.currency)}</span>
</p>
</Block>
);
@@ -226,7 +279,7 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
return (
<Block
title={`History ${lookup.result.month}`}
aside={<span className="nums">{lookup.result.transactions.length} transactions</span>}
aside={<span>{lookup.result.transactions.length} transactions</span>}
>
<TransactionTable rows={lookup.result.transactions} currency={currency} />
</Block>
@@ -234,19 +287,19 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
case 'counterparty': {
const r = lookup.result;
return (
<Block title={`Counterparty ${r.id}`} aside={<span className="nums">first seen {r.first_seen}</span>}>
<p className="text-sm font-medium">{r.name}</p>
<p className="text-xs text-muted">
<Block title={`Counterparty ${r.id}`} aside={<span>first seen {r.first_seen}</span>}>
<p className="font-sans text-ui font-medium text-fg">{r.name}</p>
<p className="font-sans text-small text-muted">
{r.type.replace(/_/g, ' ')} · {r.jurisdiction}
</p>
<p className="mt-1 text-xs">
<p className="mt-1.5 font-sans text-small text-fg-2">
Other alerted accounts sharing this counterparty in twelve months:{' '}
<span className="nums font-semibold">{r.other_alerted_accounts}</span>
<span className="font-mono font-semibold text-fg">{r.other_alerted_accounts}</span>
</p>
<p className="mt-1 text-xs text-muted">
<p className="mt-1.5 font-sans text-small text-muted">
transactions:{' '}
{r.transactions.map((id) => (
<span key={id} className="mr-1">
<span key={id} className="mr-1.5">
<Id>{id}</Id>
</span>
))}
@@ -256,22 +309,23 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
}
case 'prior_alerts':
return (
<Block title="Prior alerts" aside={<span className="nums">{lookup.result.alerts.length} on file</span>}>
<Block title="Prior alerts" aside={<span>{lookup.result.alerts.length} on file</span>}>
{lookup.result.alerts.length === 0 ? (
<p className="text-xs text-muted">None on file.</p>
<p className="font-sans text-small text-muted">None on file.</p>
) : (
<ul className="space-y-1.5 text-xs">
<ul className="divide-y divide-border">
{lookup.result.alerts.map((p) => (
<li key={p.id}>
<div className="flex flex-wrap items-baseline gap-x-2">
<li key={p.id} className="py-1.5 first:pt-0 last:pb-0">
<div className="flex flex-wrap items-baseline gap-x-2 font-mono text-caption">
<Id tone="fg">{p.id}</Id>
<span className="font-mono">{p.rule}</span>
<span className="nums text-muted">fired {p.fired}</span>
<span>
{p.disposition} after {p.elapsed_days} {p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
<span className="font-semibold text-fg">{p.rule}</span>
<span className="text-muted">fired {p.fired}</span>
<span className="font-sans text-small text-fg-2">
{p.disposition} after <span className="font-mono">{p.elapsed_days}</span>{' '}
{p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
</span>
</div>
<p className="text-muted">{p.rationale}</p>
<p className="mt-0.5 font-sans text-small text-fg-2">{p.rationale}</p>
</li>
))}
</ul>
@@ -280,15 +334,15 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
);
case 'documents':
return (
<Block title="Documents on file" aside={<span className="nums">{lookup.result.documents.length}</span>}>
<ul className="space-y-1 text-xs">
<Block title="Documents on file" aside={<span>{lookup.result.documents.length}</span>}>
<ul className="divide-y divide-border">
{lookup.result.documents.map((d) => (
<li key={d.id} className="flex flex-col gap-0.5 sm:flex-row sm:gap-2">
<span className="shrink-0">
<li key={d.id} className="flex flex-col gap-0.5 py-1.5 first:pt-0 last:pb-0 sm:flex-row sm:gap-3">
<span className="shrink-0 font-mono text-caption">
<Id tone="fg">{d.id}</Id>{' '}
<span className="text-muted">[{d.kind.replace(/_/g, ' ')}]</span>
</span>
<span>{d.description}</span>
<span className="font-sans text-small text-fg-2">{d.description}</span>
</li>
))}
</ul>
@@ -297,14 +351,20 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
}
}
/**
* A cited id. Three states, each with a word for screen readers and a mark
* for everyone else: planted (✓ on the green ground), not planted (struck
* through), and unknown (a plain strong-edged chip — the episode is not
* over, or the disposition was not scored).
*/
function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[0.7rem]',
hit === true && 'border-positive/50 bg-positive/10 text-fg',
hit === false && 'border-border text-muted line-through decoration-muted/60',
(hit === null || hit === undefined) && 'border-border text-fg',
'inline-flex items-center gap-1 rounded-tile border px-1.5 py-0.5 font-mono text-label tracking-normal',
hit === true && 'border-positive bg-accent-subtle text-fg',
hit === false && 'border-border text-muted line-through',
(hit === null || hit === undefined) && 'border-border-strong text-fg',
)}
>
{hit === true ? <span aria-hidden="true"></span> : null}
@@ -315,42 +375,45 @@ function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
);
}
function NeverScored() {
return <Chip className="text-muted">displayed, never scored</Chip>;
}
function DispositionCard({ state }: { state: TriageState }) {
const d = state.disposition;
if (!d) return null;
const planted = state.reveal ? bestAlternate(d.cites, state.reveal.planted) : null;
const scored = state.reveal !== null && state.outcome === 'solved';
const prose = (d.action === 'close' ? d.note : d.narrative).trim();
return (
<Block
title={d.action === 'close' ? 'Closed' : 'Escalated'}
aside={
<span className="font-mono">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>
}
aside={<span className="text-fg">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>}
>
<p className="mb-1 text-xs text-muted">Cites</p>
<p className="mb-1">
<Label>Cites</Label>
</p>
<div className="flex flex-wrap gap-1">
{d.cites.map((id) => (
<CiteChip key={id} id={id} hit={planted && scored ? planted.includes(id) : null} />
))}
</div>
{(d.action === 'close' ? d.note : d.narrative).trim() ? (
<div className="mt-2">
<p className="mb-1 flex items-baseline gap-2 text-xs text-muted">
<span>{d.action === 'close' ? 'Note' : 'Narrative'}</span>
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
displayed, never scored
</span>
{prose ? (
<div className="mt-2.5">
<p className="mb-1 flex flex-wrap items-center gap-2">
<Label>{d.action === 'close' ? 'Note' : 'Narrative'}</Label>
<NeverScored />
</p>
<blockquote className="border-l-2 border-border pl-2 text-xs leading-relaxed">
{d.action === 'close' ? d.note : d.narrative}
</blockquote>
<blockquote className="border-l-2 border-border-strong pl-2.5 font-sans text-small text-fg-2">{prose}</blockquote>
</div>
) : null}
</Block>
);
}
function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone: 'positive' | 'danger' | 'warning' } {
type Tone = 'positive' | 'danger' | 'warning';
function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone: Tone } {
if (state.status === 'aborted') {
return {
text: 'No disposition in eight replies. The alert stays in the queue and the episode scores 0.000 on every component.',
@@ -359,7 +422,10 @@ function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone:
}
if (state.outcome === 'solved') {
return {
text: reveal.label === 'benign' ? 'Right call: the alert was benign and it was closed.' : 'Right call: the alert was suspicious and it was escalated.',
text:
reveal.label === 'benign'
? 'Right call: the alert was benign and it was closed.'
: 'Right call: the alert was suspicious and it was escalated.',
tone: 'positive',
};
}
@@ -375,103 +441,129 @@ function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone:
};
}
/**
* Verdict ink sits on surface-2, where all three status colours clear 4.5
* in both themes (danger is 4.70 dark — surface-2 is the darkest ground it is
* allowed on, see index.css). The sentence begins with its word.
*/
const TONE_TEXT: Record<Tone, string> = {
positive: 'text-positive',
danger: 'text-danger',
warning: 'text-warning',
};
function RevealCard({ state }: { state: TriageState }) {
const reveal = state.reveal;
if (!reveal) return null;
const cites = state.disposition?.cites ?? [];
const best = bestAlternate(cites, reveal.planted);
const verdict = verdictLine(state, reveal);
const TONE = { positive: 'text-positive', danger: 'text-danger', warning: 'text-warning' } as const;
return (
<Block
title="What the generator planted"
aside={
<span className="font-mono">
<span>
{reveal.template}
{reveal.overlay ? ` + ${reveal.overlay.replace(/_/g, ' ')}` : ''}
</span>
}
>
<p className="text-sm">
<span className={cn('font-semibold', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-1 font-sans text-small">
<span className={cn('font-mono font-semibold uppercase', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
{reveal.label}
</span>
{reveal.typology ? (
<>
<span className="mx-1.5 text-muted">·</span>
<span className="font-mono text-xs">{reveal.typology}</span>
</>
) : null}
{reveal.typology ? <span className="font-mono text-caption text-fg">{reveal.typology}</span> : null}
{reveal.tier === 'hidden' ? (
<>
<span className="mx-1.5 text-muted">·</span>
<span className="text-xs text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
</>
<span className="text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
) : null}
</p>
<p className={cn('mt-1 text-xs font-medium', TONE[verdict.tone])} role="status">
<p
className={cn('mt-2 rounded-control bg-surface-2 px-2.5 py-2 font-sans text-small font-medium', TONE_TEXT[verdict.tone])}
role="status"
>
{verdict.text}
</p>
<p className="mb-1 mt-2 text-xs text-muted">
The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}
<p className="mb-1 mt-2.5">
<Label>The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}</Label>
</p>
<div className="flex flex-wrap gap-1">
{best.map((id) => (
<CiteChip key={id} id={id} hit={cites.includes(id) ? true : null} />
))}
</div>
<p className="mt-2 text-xs text-muted">
Hours spent <span className="nums text-fg">{hours(state.minutesSpent)}</span>
<p className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 font-mono text-caption text-muted">
<span>
<Label>Hours spent</Label> <span className="text-fg">{hours(state.minutesSpent)}</span>
</span>
{reveal.referenceMinutes !== null ? (
<>
{' '}
· cheapest shipped analyst that got it right{' '}
<span className="nums text-fg">{hours(reveal.referenceMinutes)}</span>
</>
<span>
<Label>Cheapest shipped analyst that got it right</Label>{' '}
<span className="text-fg">{hours(reveal.referenceMinutes)}</span>
</span>
) : null}
</p>
</Block>
);
}
/**
* The meter: what the episode has cost so far, and where it stands. The hour
* count climbs with every lookup and the eight reply pips fill one per turn,
* so the budget is visible without reading a number — and the numbers are
* there for anyone who does.
*/
const STATUS_WORD: Record<TriageState['status'], string> = {
playing: 'in the queue',
closed: 'closed',
escalated: 'escalated',
aborted: 'left in the queue',
};
const STATUS_TONE: Record<TriageState['status'], string> = {
playing: 'text-muted',
closed: 'text-positive',
escalated: 'text-warning',
aborted: 'text-danger',
};
function Meter({ state }: { state: TriageState }) {
const STATUS: Record<TriageState['status'], string> = {
playing: 'in the queue',
closed: 'closed',
escalated: 'escalated',
aborted: 'left in the queue',
};
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span>
Hours <span className="nums font-semibold text-fg">{hours(state.minutesSpent)}</span>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 font-mono text-caption">
<span className="flex items-baseline gap-1.5">
<Label>Hours</Label>
<span className="text-mono-data font-semibold text-fg">{hours(state.minutesSpent)}</span>
</span>
<span>
Replies{' '}
<span className="nums font-semibold text-fg">
{state.turnsSpent} of {MAX_TURNS}
<span className="flex items-center gap-1.5">
<Label>Replies</Label>
<span className="text-mono-data font-semibold text-fg">
{state.turnsSpent}
<span className="font-normal text-muted"> of {MAX_TURNS}</span>
</span>
<span className="inline-flex items-center gap-0.5" aria-hidden="true">
{Array.from({ length: MAX_TURNS }, (_, i) => (
<span
key={i}
className={cn(
'h-2.5 w-1.5 rounded-[1px] border',
i < state.turnsSpent ? 'border-fg bg-fg' : 'border-border-strong bg-transparent',
)}
/>
))}
</span>
</span>
{state.rejected > 0 ? (
<span>
refused <span className="nums font-semibold text-fg">{state.rejected}</span>
<span className="flex items-baseline gap-1.5">
<Label className="text-warning">Refused</Label>
<span className="text-mono-data font-semibold text-warning">{state.rejected}</span>
</span>
) : null}
<span
className={cn(
'rounded-md border px-1.5 py-px font-medium',
state.status === 'playing' && 'border-border',
state.status === 'closed' && 'border-positive/50 text-positive',
state.status === 'escalated' && 'border-warning/60 text-warning',
state.status === 'aborted' && 'border-danger/50 text-danger',
)}
>
{STATUS[state.status]}
</span>
<Chip className={STATUS_TONE[state.status]}>{STATUS_WORD[state.status]}</Chip>
{state.screen.held_out ? (
<span className="rounded-md border border-border px-1.5 py-px" title="fnv1a(seed) % 8 == 7: second currency, renamed rules, KYC before the alert">
<Chip
className="text-fg"
title="fnv1a(seed) % 8 == 7: second currency, renamed rules, KYC before the alert"
>
held-out seed
</span>
</Chip>
) : null}
</div>
);
@@ -481,8 +573,8 @@ function Meter({ state }: { state: TriageState }) {
/**
* The thumbnail: what the timeline chip and the gallery card show. A whole
* screen at 7rem is noise, so this is the shape of the run instead — the rule,
* the twelve months of cash as bars, and what has happened so far.
* screen at 7.5rem is noise, so this is the shape of the run instead — the
* rule, the twelve months of cash as bars, and what has happened so far.
*/
function CompactBoard({ state }: { state: TriageState }) {
const rows = state.screen.summary;
@@ -491,42 +583,44 @@ function CompactBoard({ state }: { state: TriageState }) {
const label =
`${state.screen.alert.rule}, ${hours(state.minutesSpent)} hours, ${state.lookups.length} lookups` +
(d ? `, ${d.action === 'close' ? 'closed' : 'escalated'}` : state.status === 'aborted' ? ', aborted' : '');
const CHIP = 'rounded-[2px] border px-0.5 leading-[1.3]';
return (
<div className="w-[7.5rem] text-[9px] leading-tight" role="img" aria-label={label}>
<div className="w-[7.5rem] font-mono text-[9px] leading-tight" role="img" aria-label={label}>
<SyntheticLabel compact />
<div className="mt-1 flex items-baseline justify-between gap-1">
<span className="truncate font-mono font-semibold">{state.screen.alert.rule}</span>
<span className="nums shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
<span className="truncate font-semibold text-fg">{state.screen.alert.rule}</span>
<span className="shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
</div>
<div className="mt-1 flex h-4 items-end gap-px" aria-hidden="true">
{rows.map((r) => (
<span
key={r.month}
className="flex-1 rounded-sm bg-muted/50"
className="flex-1 rounded-[1px] bg-border-strong"
style={{ height: `${Math.max(6, Math.round((r.cash_in / peak) * 100))}%` }}
/>
))}
</div>
<div className="mt-1 flex flex-wrap gap-0.5">
{state.lookups.map((l, i) => (
<span key={i} className="rounded border border-border px-0.5 text-muted">
<span key={i} className={cn(CHIP, 'border-border text-muted')}>
{l.what === 'history' ? l.key.slice(5) : l.what === 'counterparty' ? l.key : l.what === 'prior_alerts' ? 'priors' : 'docs'}
</span>
))}
{state.rejected > 0 ? (
<span className="rounded border border-warning/60 px-0.5 text-warning">!{state.rejected}</span>
<span className={cn(CHIP, 'border-warning text-warning')}>!{state.rejected}</span>
) : null}
{d ? (
<span
className={cn(
'rounded border px-0.5 font-semibold uppercase',
d.action === 'close' ? 'border-positive/50 text-positive' : 'border-warning/60 text-warning',
CHIP,
'font-semibold uppercase',
d.action === 'close' ? 'border-positive text-positive' : 'border-warning text-warning',
)}
>
{d.action === 'close' ? 'close' : 'escalate'}
</span>
) : state.status === 'aborted' ? (
<span className="rounded border border-danger/50 px-0.5 font-semibold uppercase text-danger">aborted</span>
<span className={cn(CHIP, 'border-danger font-semibold uppercase text-danger')}>aborted</span>
) : null}
</div>
</div>
@@ -550,13 +644,15 @@ export const Board = memo(function Board({ state, compact }: { state: TriageStat
// shrink-to-fit card on every tab, so `w-full` resolves to nothing and
// `max-w-full` resolves against a parent that sized itself to this
// board — measured: 640px on a 390px phone, 304px of page overflow.
// 5rem is the page gutters plus the card padding at the phone breakpoint.
// 4.5rem is the phone gutters (2 × 1.25rem) plus the shell card's p-3
// (2 × 0.75rem) with half a rem for its borders: the shell's padding
// and this constant are coupled and move together.
// `max-w-full` is for the one place the parent IS definite: a blind
// comparison's grid cell, which is narrower than 40rem at 1280px and
// measured 31px of page overflow without it.
className="w-[min(40rem,calc(100vw-5rem))] max-w-full min-w-0 space-y-3 text-sm"
className="w-[min(40rem,calc(100vw-4.5rem))] min-w-0 max-w-full space-y-3 font-sans text-small text-fg"
role="group"
aria-label={`Alert ${screen.alert.id} on customer ${screen.customer.id}. ${state.lookups.length} lookups made, ${hours(state.minutesSpent)} hours spent.`}
aria-label={`Alert ${screen.alert.id} on customer ${screen.customer.id}. ${state.lookups.length} lookups made, ${hours(state.minutesSpent)} hours spent, ${STATUS_WORD[state.status]}.`}
>
<SyntheticLabel />
<Meter state={state} />
@@ -567,8 +663,11 @@ export const Board = memo(function Board({ state, compact }: { state: TriageStat
<LookupCard key={`${lookup.what}-${lookup.key ?? i}`} lookup={lookup} currency={screen.currency} />
))}
{state.lastRejection ? (
<p role="status" className="rounded-md border border-warning/60 bg-warning/5 px-3 py-2 text-xs text-warning">
Reply refused: {state.lastRejection}. It still cost a turn.
<p
role="status"
className="rounded-control border border-warning bg-warning-subtle px-3 py-2 font-sans text-small text-warning"
>
<span className="font-mono text-label uppercase">Reply refused</span> {state.lastRejection}. It still cost a turn.
</p>
) : null}
<DispositionCard state={state} />
+83 -42
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { play, type BoardState } from './engine';
@@ -8,14 +9,16 @@ import { allowedNow, ANSWERS, primeGuessList } from './words';
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
type TileState = 'G' | 'Y' | 'X';
/** Best-known state of each letter, for tinting the keys. */
function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
function letterStates(board: BoardState): Record<string, TileState> {
const rank = { X: 0, Y: 1, G: 2 } as const;
const out: Record<string, 'G' | 'Y' | 'X'> = {};
const out: Record<string, TileState> = {};
for (const { guess, pattern } of board.rows) {
for (let i = 0; i < guess.length; i += 1) {
const letter = guess[i]!;
const tile = pattern[i] as 'G' | 'Y' | 'X';
const tile = pattern[i] as TileState;
const current = out[letter];
if (!current || rank[tile] > rank[current]) out[letter] = tile;
}
@@ -23,12 +26,31 @@ function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
return out;
}
const KEY_TINT: Record<string, string> = {
G: 'bg-tile-exact text-tile-exact-fg',
Y: 'bg-tile-present text-tile-present-fg',
X: 'bg-tile-absent/70 text-tile-absent-fg',
/**
* A used key takes the tile token at FULL opacity. The old `/70` on absent
* made the grey key a fourth colour the board never shows; at full strength
* the key is the tile, and the ink on it is measured there (7.75 dark, 5.74
* light — see index.css).
*/
const KEY_TINT: Record<TileState, string> = {
G: 'bg-tile-exact text-tile-exact-fg hover:bg-tile-exact',
Y: 'bg-tile-present text-tile-present-fg hover:bg-tile-present',
X: 'bg-tile-absent text-tile-absent-fg hover:bg-tile-absent',
};
/** The board's high-contrast glyphs, repeated on the keys so the keyboard has the same second channel. */
const KEY_GLYPH: Record<TileState, string> = { G: '●', Y: '◆', X: '' };
/**
* One key. Letter keys are `flex-1` with no minimum width: ten of them plus
* nine gaps have to fit 326px on a phone, which is roughly 29px each — the
* 44px target is the height. ENTER and DEL are wider and set in mono, the
* way a command is.
*/
const KEY =
'min-h-11 min-w-0 select-none rounded-control transition-[background-color,color,transform] duration-1 ease-enter ' +
'motion-safe:active:scale-[0.94] disabled:pointer-events-none disabled:opacity-40';
export function Keyboard({
state,
onChange,
@@ -69,7 +91,7 @@ export function Keyboard({
const handler = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
if (event.key === 'Enter') press('enter');
else if (event.key === 'Backspace') press('back');
else if (/^[a-zA-Z]$/.test(event.key)) press(event.key.toLowerCase());
@@ -85,36 +107,37 @@ export function Keyboard({
return (
// Own cap, not the shell's. Unconstrained, these flex rows stretch to the
// page and the keys drift a screen away from the board they belong to.
<div className="grid w-full max-w-md gap-1 sm:gap-1.5" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
<div
// 28rem caps a letter key at 39px on a 1024px iPad; a coarse pointer
// gets 32rem, which is the narrowest width at which ten keys and nine
// 6px gaps are all 44px. The phone never reaches either cap.
className="grid w-full max-w-md gap-1 pb-[max(0px,var(--safe-bottom))] sm:gap-1.5 [@media(pointer:coarse)]:max-w-lg"
>
{/* The solver, live, on whatever word you picked — not a recording. This
is the difference between a demo and a video: it answers for boards no
rollout on this site ever covered. */}
{hint.status !== 'unavailable' && !done ? (
<div className="mb-1 flex min-h-9 flex-wrap items-center gap-2 text-xs">
<button
<div className="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Button
type="button"
variant="outline"
size="touch"
onClick={() => solver.ask(state.rows)}
disabled={hint.status === 'thinking'}
className="tap inline-flex items-center rounded-md border border-border px-2.5 py-1 font-medium transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-50"
>
{hint.status === 'thinking' ? 'Thinking…' : 'What would the best player guess?'}
</button>
<span aria-live="polite" className="text-muted">
</Button>
<span aria-live="polite" className="font-mono text-mono-data text-muted">
{hint.status === 'ready' ? (
<>
<span className="font-mono font-semibold uppercase text-fg">
{hint.suggestion.guess}
</span>{' '}
<span className="nums">{hint.suggestion.bits.toFixed(2)}</span> bits from{' '}
<span className="nums">{hint.suggestion.candidatesBefore.toLocaleString('en-US')}</span>{' '}
remaining
<span className="font-semibold uppercase text-fg">{hint.suggestion.guess}</span>
{' — '}
{hint.suggestion.bits.toFixed(2)} bits from{' '}
{hint.suggestion.candidatesBefore.toLocaleString('en-US')} remaining
{hint.suggestion.viable ? null : (
<>
{' '}
·{' '}
<span className="text-warning">
cannot win it is buying information
</span>
{' · '}
<span className="text-warning">cannot win it is buying information</span>
</>
)}
</>
@@ -129,33 +152,51 @@ export function Keyboard({
type="button"
onClick={() => press('enter')}
disabled={done}
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
className={cn(
KEY,
'flex-[1.6] bg-surface-2 px-1 font-mono text-label uppercase text-fg hover:bg-surface-3',
)}
>
Enter
</button>
) : null}
{[...row].map((letter) => (
<button
key={letter}
type="button"
onClick={() => press(letter)}
disabled={done}
aria-label={letter.toUpperCase()}
className={cn(
'tap min-w-0 flex-1 rounded-md text-sm font-semibold uppercase transition-colors duration-1 disabled:opacity-40',
states[letter] ? KEY_TINT[states[letter]!] : 'bg-surface-2 hover:bg-accent-subtle',
)}
>
{letter}
</button>
))}
{[...row].map((letter) => {
const used = states[letter];
return (
<button
key={letter}
type="button"
onClick={() => press(letter)}
disabled={done}
aria-label={letter.toUpperCase()}
className={cn(
KEY,
'relative flex-1 font-sans text-[0.8125rem] font-semibold uppercase',
used ? KEY_TINT[used] : 'bg-surface-2 text-fg hover:bg-surface-3',
)}
>
{letter}
{used && KEY_GLYPH[used] ? (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-0.5 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-100"
>
{KEY_GLYPH[used]}
</span>
) : null}
</button>
);
})}
{index === 2 ? (
<button
type="button"
onClick={() => press('back')}
disabled={done}
aria-label="Backspace"
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
className={cn(
KEY,
'flex-[1.6] bg-surface-2 px-1 font-mono text-label uppercase text-fg hover:bg-surface-3',
)}
>
Del
</button>
+92 -30
View File
@@ -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}
/>
))}