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 { 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>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineMeta({
|
||||
tagline:
|
||||
'Work one transaction-monitoring alert against the customer’s 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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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