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
@@ -28,11 +28,11 @@ intersection with the above, to derive envs/wordle_five/words/answers.json.
|
|||||||
See envs/wordle_five/words/PROVENANCE.md for exactly how.
|
See envs/wordle_five/words/PROVENANCE.md for exactly how.
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
Manrope — SIL Open Font License 1.1
|
Geist and Geist Mono — SIL Open Font License 1.1
|
||||||
https://github.com/sharanda/manrope
|
https://github.com/vercel/geist-font
|
||||||
Copyright 2019 The Manrope Project Authors. Licence text: public/fonts/OFL.txt
|
Copyright 2024 The Geist Project Authors. The sans and mono faces are embedded
|
||||||
"Manrope" is a Reserved Font Name under that licence: a modified version of the
|
as variable woff2 files via @fontsource-variable/geist and
|
||||||
font may not be distributed under that name.
|
@fontsource-variable/geist-mono (latin subset). Licence text: public/fonts/OFL.txt
|
||||||
|
|
||||||
Lucide — ISC
|
Lucide — ISC
|
||||||
https://github.com/lucide-icons/lucide
|
https://github.com/lucide-icons/lucide
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"conformance": "node scripts/conformance.mjs"
|
"conformance": "node scripts/conformance.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||||
"@fontsource-variable/manrope": "^5.3.0",
|
"@fontsource-variable/manrope": "^5.3.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-dialog": "^1.1.23",
|
"@radix-ui/react-dialog": "^1.1.23",
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@fontsource-variable/geist':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
|
'@fontsource-variable/geist-mono':
|
||||||
|
specifier: ^5.3.0
|
||||||
|
version: 5.3.0
|
||||||
'@fontsource-variable/manrope':
|
'@fontsource-variable/manrope':
|
||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
@@ -526,6 +532,12 @@ packages:
|
|||||||
'@floating-ui/utils@0.2.12':
|
'@floating-ui/utils@0.2.12':
|
||||||
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist-mono@5.3.0':
|
||||||
|
resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist@5.3.0':
|
||||||
|
resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==}
|
||||||
|
|
||||||
'@fontsource-variable/manrope@5.3.0':
|
'@fontsource-variable/manrope@5.3.0':
|
||||||
resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==}
|
resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==}
|
||||||
|
|
||||||
@@ -2117,6 +2129,10 @@ snapshots:
|
|||||||
|
|
||||||
'@floating-ui/utils@0.2.12': {}
|
'@floating-ui/utils@0.2.12': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist-mono@5.3.0': {}
|
||||||
|
|
||||||
|
'@fontsource-variable/geist@5.3.0': {}
|
||||||
|
|
||||||
'@fontsource-variable/manrope@5.3.0': {}
|
'@fontsource-variable/manrope@5.3.0': {}
|
||||||
|
|
||||||
'@jridgewell/gen-mapping@0.3.13':
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope)
|
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font) Geist-Italic[wght].ttf: Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
|
||||||
|
|
||||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
This license is copied below, and is also available with a FAQ at:
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 121 KiB |
@@ -4,14 +4,14 @@
|
|||||||
*
|
*
|
||||||
* This repo is public and Apache-2.0, and almost none of what makes the demo
|
* This repo is public and Apache-2.0, and almost none of what makes the demo
|
||||||
* work is ours: the primitives are shadcn/ui over Radix, the answer list is
|
* work is ours: the primitives are shadcn/ui over Radix, the answer list is
|
||||||
* Wordnik's filtered through SCOWL, the typeface is Manrope under the SIL Open
|
* Wordnik's filtered through SCOWL, the typefaces are Geist and Geist Mono
|
||||||
* Font License, the icons are lucide, the charts are recharts. Every one of
|
* under the SIL Open Font License, the icons are lucide, the charts are recharts. Every one of
|
||||||
* those licences is permissive, and every one of them requires attribution.
|
* those licences is permissive, and every one of them requires attribution.
|
||||||
*
|
*
|
||||||
* So `NOTICE` is not paperwork, it is a build artifact with a test. This script
|
* So `NOTICE` is not paperwork, it is a build artifact with a test. This script
|
||||||
* is that test: it fails if NOTICE is missing any source we actually ship, and
|
* is that test: it fails if NOTICE is missing any source we actually ship, and
|
||||||
* it fails if a source is named without its licence beside it — because
|
* it fails if a source is named without its licence beside it — because
|
||||||
* "uses Manrope" without "OFL-1.1" is not attribution, it is a mention.
|
* "uses Geist" without "OFL-1.1" is not attribution, it is a mention.
|
||||||
*
|
*
|
||||||
* The OFL additionally requires that the licence text travel WITH the font, so
|
* The OFL additionally requires that the licence text travel WITH the font, so
|
||||||
* `public/fonts/OFL.txt` is checked for separately. Shipping the .woff2 out of
|
* `public/fonts/OFL.txt` is checked for separately. Shipping the .woff2 out of
|
||||||
@@ -64,13 +64,22 @@ const SOURCES = [
|
|||||||
used: () => hasWordFile(/scowl/i),
|
used: () => hasWordFile(/scowl/i),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Manrope',
|
label: 'Geist',
|
||||||
match: /manrope/i,
|
match: /\bgeist\b/i,
|
||||||
// The OFL is version-specific and the version matters: OFL-1.1 is the one
|
// The OFL is version-specific and the version matters: OFL-1.1 is the one
|
||||||
// Manrope ships under, and it is the one whose terms are quoted in OFL.txt.
|
// Geist ships under, and it is the one whose terms are quoted in OFL.txt.
|
||||||
licence: /OFL[-\s]?1\.1|SIL\s+Open\s+Font\s+License/i,
|
licence: /OFL[-\s]?1\.1|SIL\s+Open\s+Font\s+License/i,
|
||||||
why: 'Manrope is the typeface, embedded as a variable woff2.',
|
why: 'Geist is the sans typeface, embedded as a variable woff2.',
|
||||||
used: () => exists(abs('node_modules', '@fontsource-variable', 'manrope')),
|
used: () => exists(abs('node_modules', '@fontsource-variable', 'geist')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Geist Mono',
|
||||||
|
// A separate package with its own LICENSE file, so it is a separate entry:
|
||||||
|
// naming the sans face does not attribute the mono one.
|
||||||
|
match: /geist\s+mono|geist-mono/i,
|
||||||
|
licence: /OFL[-\s]?1\.1|SIL\s+Open\s+Font\s+License/i,
|
||||||
|
why: 'Geist Mono is the mono typeface, embedded as a variable woff2.',
|
||||||
|
used: () => exists(abs('node_modules', '@fontsource-variable', 'geist-mono')),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'lucide',
|
label: 'lucide',
|
||||||
@@ -162,8 +171,8 @@ report.check(
|
|||||||
exists(OFL),
|
exists(OFL),
|
||||||
rel(OFL),
|
rel(OFL),
|
||||||
'OFL text ships with the font',
|
'OFL text ships with the font',
|
||||||
'Manrope is under SIL OFL-1.1, which requires the licence text to travel with the font files. ' +
|
'Geist and Geist Mono are under SIL OFL-1.1, which requires the licence text to travel with the font files. ' +
|
||||||
'Copy node_modules/@fontsource-variable/manrope/LICENSE to public/fonts/OFL.txt. ' +
|
'Copy node_modules/@fontsource-variable/geist/LICENSE to public/fonts/OFL.txt. ' +
|
||||||
'Shipping the woff2 and leaving the licence behind is the one violation on this list with teeth.',
|
'Shipping the woff2 and leaving the licence behind is the one violation on this list with teeth.',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { ComponentType } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { Eye, Trophy } from 'lucide-react';
|
import { Eye } from 'lucide-react';
|
||||||
import type { DemoStep } from '@/lib/demo-kit/types';
|
import type { DemoStep } from '@/lib/demo-kit/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { InterventionChip, Label } from './chrome';
|
||||||
import { formatOrDash } from './format';
|
import { formatOrDash } from './format';
|
||||||
|
|
||||||
export interface BlindCompareRun<T> {
|
export interface BlindCompareRun<T> {
|
||||||
@@ -39,6 +40,9 @@ export interface BlindCompareProps<T> {
|
|||||||
* unknow it, and every "obviously the trained one looks better" is worthless
|
* unknow it, and every "obviously the trained one looks better" is worthless
|
||||||
* after the fact.
|
* after the fact.
|
||||||
*
|
*
|
||||||
|
* The two vote buttons are OUTLINE, not green: two green fills on the Watch
|
||||||
|
* tab would compete with ▶, and a vote is a question, not a call to action.
|
||||||
|
*
|
||||||
* There is a "reveal without voting" escape on purpose: a visitor who does not
|
* There is a "reveal without voting" escape on purpose: a visitor who does not
|
||||||
* want to play should not be held hostage by a modal-shaped page.
|
* want to play should not be held hostage by a modal-shaped page.
|
||||||
*/
|
*/
|
||||||
@@ -77,63 +81,82 @@ export function BlindCompare<T>({
|
|||||||
return (
|
return (
|
||||||
<section aria-label="Blind comparison" className={cn('space-y-3', className)}>
|
<section aria-label="Blind comparison" className={cn('space-y-3', className)}>
|
||||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
<h3 className="text-sm font-semibold">{question}</h3>
|
<h3 className="text-h3 text-fg">{question}</h3>
|
||||||
<p className="nums text-xs text-muted">Same puzzle, same seed ({seed}).</p>
|
<p className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
Same puzzle · seed {seed}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
{sides.map(({ id, run }) => {
|
{sides.map(({ id, run }) => {
|
||||||
const last = run.steps[run.steps.length - 1];
|
const last = run.steps[run.steps.length - 1];
|
||||||
const picked = vote === id;
|
const picked = vote === id;
|
||||||
|
const won = revealed && winner === id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'card flex flex-col gap-3 p-3 transition-colors duration-2 ease-enter',
|
'flex flex-col gap-3 rounded-xl border bg-surface p-3 transition-colors duration-2 ease-enter sm:p-4',
|
||||||
picked && 'border-brand',
|
won ? 'border-accent-fg' : picked ? 'border-border-strong' : 'border-border',
|
||||||
revealed && winner === id && 'border-positive',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h4 className="text-sm font-semibold">Agent {id}</h4>
|
<Label as="h4" className="text-fg">
|
||||||
<span className="nums text-xs text-muted">{run.steps.length} steps</span>
|
Agent {id}
|
||||||
|
</Label>
|
||||||
|
<span className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
{run.steps.length} steps
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg bg-surface-2 p-3">
|
<div className="max-w-full overflow-x-auto rounded-md bg-surface-2 p-3">
|
||||||
{last ? (
|
{last ? (
|
||||||
<Surface state={last.state} />
|
<div className="mx-auto w-fit max-w-full sm:max-w-[20rem]">
|
||||||
|
<Surface state={last.state} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">This run recorded no steps.</p>
|
<p className="text-ui text-muted">This run recorded no steps.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!revealed ? (
|
{!revealed ? (
|
||||||
<Button size="touch" className="w-full" onClick={() => commit(id)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
className="w-full sm:min-h-12"
|
||||||
|
onClick={() => commit(id)}
|
||||||
|
>
|
||||||
Agent {id} is better
|
Agent {id} is better
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<dl className="space-y-1 text-sm">
|
<dl className="space-y-1 text-ui">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<dt className="text-muted">Identity</dt>
|
<dt className="text-muted">Identity</dt>
|
||||||
<dd className="text-right font-medium">{run.label}</dd>
|
<dd className="text-right font-medium text-fg">{run.label}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<dt className="text-muted">Model</dt>
|
<dt className="text-muted">Model</dt>
|
||||||
<dd className="nums text-right font-mono text-xs">{run.model}</dd>
|
<dd className="min-w-0 break-all text-right font-mono text-mono-data text-fg">
|
||||||
|
{run.model}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<dt className="text-muted">Intervention</dt>
|
<dt className="text-muted">Intervention</dt>
|
||||||
<dd className="text-right text-xs">
|
<dd className="text-right">
|
||||||
{run.intervention ?? (
|
{run.intervention ? (
|
||||||
<span className="text-muted">none — plain rollout</span>
|
<InterventionChip>{run.intervention}</InterventionChip>
|
||||||
|
) : (
|
||||||
|
<span className="text-small text-muted">none — plain rollout</span>
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-1">
|
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-1">
|
||||||
<dt className="text-muted">Reward</dt>
|
<dt className="text-muted">Reward</dt>
|
||||||
<dd className="nums flex items-center gap-1 text-right font-mono font-semibold">
|
<dd className="flex items-center gap-1.5 text-right font-mono text-stat text-fg">
|
||||||
{revealed && winner === id ? (
|
{won ? (
|
||||||
<Trophy className="size-3.5 text-positive" aria-hidden="true" />
|
<span className="font-mono text-label uppercase tracking-label text-positive">
|
||||||
|
higher
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{formatOrDash(run.total)}
|
{formatOrDash(run.total)}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -150,24 +173,25 @@ export function BlindCompare<T>({
|
|||||||
<Button variant="outline" size="touch" onClick={() => commit('tie')}>
|
<Button variant="outline" size="touch" onClick={() => commit('tie')}>
|
||||||
Too close to call
|
Too close to call
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="touch" className="text-muted" onClick={() => setRevealed(true)}>
|
<Button variant="ghost" size="touch" className="text-muted hover:text-fg" onClick={() => setRevealed(true)}>
|
||||||
<Eye aria-hidden="true" />
|
<Eye aria-hidden="true" />
|
||||||
Just show me
|
Just show me
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p role="status" className="card bg-surface-2 p-3 text-sm leading-relaxed">
|
<p
|
||||||
|
role="status"
|
||||||
|
className="max-w-measure rounded-xl border border-border bg-surface-2 p-3 text-prose text-fg-2 sm:p-4"
|
||||||
|
>
|
||||||
{vote === null ? (
|
{vote === null ? (
|
||||||
<>Revealed without a vote. </>
|
<>Revealed without a vote. </>
|
||||||
) : vote === winner ? (
|
) : vote === winner ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold text-positive">You picked the higher-scoring run.</span>{' '}
|
<span className="font-medium text-positive">You picked the higher-scoring run.</span>{' '}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold text-warning">
|
<span className="font-medium text-warning">You picked the lower-scoring run.</span>{' '}
|
||||||
You picked the lower-scoring run.
|
|
||||||
</span>{' '}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
The environment scored these two with the same grader, on the same seed. The difference
|
The environment scored these two with the same grader, on the same seed. The difference
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ExternalLink } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import * as st from '@/content/styles';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { External } from './chrome';
|
||||||
|
|
||||||
export interface CodeReceiptProps {
|
export interface CodeReceiptProps {
|
||||||
/** The file's text, imported with `?raw` so it cannot drift from the source. */
|
/** The file's text, imported with `?raw` so it cannot drift from the source. */
|
||||||
@@ -14,7 +13,7 @@ export interface CodeReceiptProps {
|
|||||||
* See `resolveMarkedRange` for the two conventions it supports.
|
* See `resolveMarkedRange` for the two conventions it supports.
|
||||||
*/
|
*/
|
||||||
marker?: string;
|
marker?: string;
|
||||||
/** Link to the whole file — GitHub, usually. */
|
/** Link to the whole file in the repository. */
|
||||||
href?: string;
|
href?: string;
|
||||||
/** Lines of context kept around the marked range while collapsed. */
|
/** Lines of context kept around the marked range while collapsed. */
|
||||||
context?: number;
|
context?: number;
|
||||||
@@ -102,9 +101,9 @@ export function resolveMarkedRange(lines: string[], marker?: string): MarkedRang
|
|||||||
* that says "this, right here, is the grader" — costs nothing and is the whole
|
* that says "this, right here, is the grader" — costs nothing and is the whole
|
||||||
* reason the panel exists.
|
* reason the panel exists.
|
||||||
*
|
*
|
||||||
* The block scrolls horizontally inside itself. Long Python lines must never
|
* The block scrolls on both axes inside its own box. Long Python lines must
|
||||||
* make the PAGE scroll sideways; on a phone that turns every vertical swipe
|
* never make the PAGE scroll sideways; on a phone that turns every vertical
|
||||||
* into a fight.
|
* swipe into a fight.
|
||||||
*/
|
*/
|
||||||
export function CodeReceipt({
|
export function CodeReceipt({
|
||||||
code,
|
code,
|
||||||
@@ -130,42 +129,43 @@ export function CodeReceipt({
|
|||||||
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={`Source: ${path}`} className={cn('card overflow-hidden', className)}>
|
<section
|
||||||
<header className="flex flex-col items-start gap-2 border-b border-border px-3 py-2 sm:flex-row sm:items-center sm:gap-x-3">
|
aria-label={`Source: ${path}`}
|
||||||
|
className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}
|
||||||
|
>
|
||||||
|
<header className="flex flex-col items-start gap-2 border-b border-border px-4 py-2.5 sm:flex-row sm:items-center sm:gap-x-3">
|
||||||
{/* Full width on a phone: sharing a row with two controls truncates a
|
{/* Full width on a phone: sharing a row with two controls truncates a
|
||||||
path to "envs/wo…", which names nothing. */}
|
path to "envs/wo…", which names nothing. */}
|
||||||
<h3
|
<h3 className="w-full min-w-0 truncate font-mono text-caption text-muted sm:flex-1" title={path}>
|
||||||
className="nums w-full min-w-0 truncate font-mono text-xs text-muted sm:flex-1"
|
|
||||||
title={path}
|
|
||||||
>
|
|
||||||
{path}
|
{path}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
{range && !expanded ? (
|
{range ? (
|
||||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(true)}>
|
<Button
|
||||||
Show all {lines.length} lines
|
variant="outline"
|
||||||
</Button>
|
size="sm"
|
||||||
) : null}
|
className="tap lg:min-h-8"
|
||||||
{range && expanded ? (
|
aria-expanded={expanded}
|
||||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(false)}>
|
onClick={() => setExpanded((was) => !was)}
|
||||||
Collapse to the marked part
|
>
|
||||||
|
{expanded ? 'Collapse to the marked part' : `Expand all ${lines.length} lines`}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{href ? (
|
{href ? (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(st.link, 'tap inline-flex items-center gap-1 text-xs')}
|
className="tap inline-flex items-center font-mono text-caption text-accent-fg underline decoration-border-strong underline-offset-4 hover:decoration-accent-fg"
|
||||||
>
|
>
|
||||||
Read the whole file
|
Open in repo
|
||||||
<ExternalLink className="size-3" aria-hidden="true" />
|
<External />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-auto">
|
<div className="max-h-[20rem] overflow-auto bg-surface-3 sm:max-h-[24rem]">
|
||||||
<pre className="w-max min-w-full py-2 font-mono text-xs leading-relaxed">
|
<pre className="w-max min-w-full py-2 font-mono text-code text-fg [font-feature-settings:'liga'_0]">
|
||||||
<code>
|
<code>
|
||||||
{shown.from > 0 ? (
|
{shown.from > 0 ? (
|
||||||
<span className="block px-3 text-muted" aria-hidden="true">
|
<span className="block px-3 text-muted" aria-hidden="true">
|
||||||
@@ -180,17 +180,19 @@ export function CodeReceipt({
|
|||||||
key={number}
|
key={number}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block border-l-2 pr-4',
|
'block border-l-2 pr-4',
|
||||||
marked
|
marked ? 'border-accent-fg bg-accent-subtle text-fg' : 'border-transparent text-fg-2',
|
||||||
? 'border-brand bg-accent-subtle/60 text-fg'
|
|
||||||
: 'border-transparent text-muted',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Sticky so the line numbers survive a horizontal scroll —
|
{/* Sticky so the line numbers survive a horizontal scroll —
|
||||||
without it they slide out of view exactly when a long
|
without it they slide out of view exactly when a long
|
||||||
line makes you want them. */}
|
line makes you want them. The gutter paints its own
|
||||||
|
ground so scrolled code does not show through it. */}
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="sticky left-0 inline-block select-none bg-surface pr-3 text-right text-muted"
|
className={cn(
|
||||||
|
'sticky left-0 inline-block select-none pr-3 text-right text-muted',
|
||||||
|
marked ? 'bg-accent-subtle' : 'bg-surface-3',
|
||||||
|
)}
|
||||||
style={{ width: gutterWidth }}
|
style={{ width: gutterWidth }}
|
||||||
>
|
>
|
||||||
{number + 1}
|
{number + 1}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import type { ErrorInfo, ReactNode } from 'react';
|
import type { ErrorInfo, ReactNode } from 'react';
|
||||||
import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react';
|
import { AlertTriangle, RotateCcw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
const REPO_URL = 'https://git.karti.ai/PIG/PIG-Demo';
|
const REPO_URL = 'https://git.karti.ai/PIG/PIG-Demo';
|
||||||
@@ -53,21 +53,21 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
|
|||||||
|
|
||||||
const { demoTitle, sourceHref } = this.props;
|
const { demoTitle, sourceHref } = this.props;
|
||||||
return (
|
return (
|
||||||
<div role="alert" className="card mx-auto my-10 max-w-xl p-6">
|
<div role="alert" className="mx-auto my-10 max-w-xl rounded-xl border border-border bg-surface p-5 sm:p-6">
|
||||||
<div className="flex items-center gap-2 text-warning">
|
<div className="flex items-center gap-2 text-warning">
|
||||||
<AlertTriangle className="size-5" aria-hidden="true" />
|
<AlertTriangle className="size-5" aria-hidden="true" />
|
||||||
<h2 className="text-base font-semibold">
|
<h2 className="text-h3 text-fg">
|
||||||
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
|
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
<p className="mt-3 max-w-measure text-prose text-fg-2">
|
||||||
Something in this demo threw while drawing. The rest of the site is unaffected — every
|
Something in this demo threw while drawing. The rest of the site is unaffected — every
|
||||||
other demo is a separate module. The environment and the recorded runs behind this page
|
other demo is a separate module. The environment and the recorded runs behind this page
|
||||||
are in the repository either way, and you can run them yourself.
|
are in the repository either way, and you can run them yourself.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-3 break-words rounded-lg bg-surface-2 px-3 py-2 font-mono text-xs text-muted">
|
<pre className="mt-3 whitespace-pre-wrap break-words rounded-md border border-border bg-surface-3 px-3 py-2 font-mono text-code text-fg-2">
|
||||||
{error.message || 'Unknown error'}
|
{error.message || 'Unknown error'}
|
||||||
</p>
|
</pre>
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
<Button size="touch" onClick={this.handleReset}>
|
<Button size="touch" onClick={this.handleReset}>
|
||||||
<RotateCcw aria-hidden="true" />
|
<RotateCcw aria-hidden="true" />
|
||||||
@@ -76,7 +76,7 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
|
|||||||
<Button variant="outline" size="touch" asChild>
|
<Button variant="outline" size="touch" asChild>
|
||||||
<a href={sourceHref ?? REPO_URL} rel="noreferrer">
|
<a href={sourceHref ?? REPO_URL} rel="noreferrer">
|
||||||
Read the source
|
Read the source
|
||||||
<ExternalLink aria-hidden="true" />
|
<span aria-hidden="true">↗</span>
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { ComponentType, ReactNode } from 'react';
|
import type { ComponentProps, ComponentType, ReactNode } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { TermScope } from '@/components/site/Term';
|
||||||
import { listRuns, loadEpisode, rewardTotal } from '@/lib/demo-kit/episode';
|
import { listRuns, loadEpisode, rewardTotal } from '@/lib/demo-kit/episode';
|
||||||
import { usePlayer } from '@/lib/demo-kit/player';
|
import { usePlayer } from '@/lib/demo-kit/player';
|
||||||
import { loadDemoModule } from '@/lib/demo-kit/registry';
|
import { loadDemoModule } from '@/lib/demo-kit/registry';
|
||||||
@@ -12,6 +13,7 @@ import { useRunParam, useSpeedParam, useStepParam, useTabParam } from '@/lib/url
|
|||||||
import * as st from '@/content/styles';
|
import * as st from '@/content/styles';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { BlindCompare } from './BlindCompare';
|
import { BlindCompare } from './BlindCompare';
|
||||||
|
import { Chip, Label, StatusPill } from './chrome';
|
||||||
import { CodeReceipt } from './CodeReceipt';
|
import { CodeReceipt } from './CodeReceipt';
|
||||||
import { DemoErrorBoundary } from './DemoErrorBoundary';
|
import { DemoErrorBoundary } from './DemoErrorBoundary';
|
||||||
import { DemoTabBar, TabClaim, resolveTab, visibleTabs } from './DemoTabs';
|
import { DemoTabBar, TabClaim, resolveTab, visibleTabs } from './DemoTabs';
|
||||||
@@ -33,14 +35,14 @@ import { StatStrip } from './StatStrip';
|
|||||||
import type { Stat } from './StatStrip';
|
import type { Stat } from './StatStrip';
|
||||||
import { RecordedBadge, TracePlayer } from './TracePlayer';
|
import { RecordedBadge, TracePlayer } from './TracePlayer';
|
||||||
import { VerifyBadge } from './VerifyBadge';
|
import { VerifyBadge } from './VerifyBadge';
|
||||||
import { formatOrDash, useIsDesktop } from './format';
|
import { formatDate, formatOrDash, useIsDesktop } from './format';
|
||||||
|
|
||||||
const REPO_BLOB = 'https://git.karti.ai/PIG/PIG-Demo/src/branch/main/';
|
const REPO_BLOB = 'https://git.karti.ai/PIG/PIG-Demo/src/branch/main/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The panel the step-detail strip inside the Watch tab opens on.
|
* The panel the step-detail strip inside the Watch tab opens on.
|
||||||
*
|
*
|
||||||
* This control is deliberately NOT in the URL. `?tab=` now belongs to the page's
|
* This control is deliberately NOT in the URL. `?tab=` belongs to the page's
|
||||||
* four top-level tabs, and one param cannot address two nested controls without
|
* four top-level tabs, and one param cannot address two nested controls without
|
||||||
* one of them silently winning; a permalink to `?tab=call` would land the reader
|
* one of them silently winning; a permalink to `?tab=call` would land the reader
|
||||||
* on a page with no such top-level tab.
|
* on a page with no such top-level tab.
|
||||||
@@ -50,6 +52,18 @@ const DEFAULT_DETAIL_PANEL = 'reasoning';
|
|||||||
/** Reserved slug for the shell's own hand-written demo. Dev builds only. */
|
/** Reserved slug for the shell's own hand-written demo. Dev builds only. */
|
||||||
const MOCK_SLUG = '__mock';
|
const MOCK_SLUG = '__mock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-level tab panel. Each one is its own `TermScope`, so "first occurrence
|
||||||
|
* per panel gets the underline" is literally per panel rather than per page.
|
||||||
|
*/
|
||||||
|
function Panel({ children, ...props }: ComponentProps<typeof TabsContent>) {
|
||||||
|
return (
|
||||||
|
<TabsContent {...props}>
|
||||||
|
<TermScope>{children}</TermScope>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface DemoBundle {
|
export interface DemoBundle {
|
||||||
demo: AnyDemoModule;
|
demo: AnyDemoModule;
|
||||||
runs: RunRef[];
|
runs: RunRef[];
|
||||||
@@ -58,7 +72,7 @@ export interface DemoBundle {
|
|||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: 'loading' }
|
| { status: 'loading' }
|
||||||
| { status: 'ready'; bundle: DemoBundle }
|
| { status: 'ready'; bundle: DemoBundle; dropped: number }
|
||||||
| { status: 'error'; message: string };
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,15 +81,20 @@ type LoadState =
|
|||||||
* `loadDemoModule` and `loadEpisode` both cache their promises, so the route
|
* `loadDemoModule` and `loadEpisode` both cache their promises, so the route
|
||||||
* loader having already fetched the module makes this resolve without a second
|
* loader having already fetched the module makes this resolve without a second
|
||||||
* request. Runs are loaded with `allSettled` on purpose: one unreadable trace
|
* request. Runs are loaded with `allSettled` on purpose: one unreadable trace
|
||||||
* drops that arm rather than blanking the page.
|
* drops that arm rather than blanking the page — and the page SAYS it dropped
|
||||||
|
* one, because a missing arm that goes unmentioned is a missing arm nobody can
|
||||||
|
* ask about.
|
||||||
*/
|
*/
|
||||||
async function loadBundle(slug: string): Promise<DemoBundle> {
|
async function loadBundle(slug: string): Promise<{ bundle: DemoBundle; dropped: number }> {
|
||||||
if (slug === MOCK_SLUG) {
|
if (slug === MOCK_SLUG) {
|
||||||
// Dynamic, so the mock lands in its own chunk and production never fetches
|
// Dynamic, so the mock lands in its own chunk and production never fetches
|
||||||
// it. A static import would ship several hundred lines of fake trace to
|
// it. A static import would ship several hundred lines of fake trace to
|
||||||
// every visitor of every real demo.
|
// every visitor of every real demo.
|
||||||
const mock = await import('./mock');
|
const mock = await import('./mock');
|
||||||
return { demo: mock.mockDemo, runs: mock.mockRuns, episodes: mock.mockEpisodes };
|
return {
|
||||||
|
bundle: { demo: mock.mockDemo, runs: mock.mockRuns, episodes: mock.mockEpisodes },
|
||||||
|
dropped: 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const demo = await loadDemoModule(slug);
|
const demo = await loadDemoModule(slug);
|
||||||
@@ -83,14 +102,21 @@ async function loadBundle(slug: string): Promise<DemoBundle> {
|
|||||||
const settled = await Promise.allSettled(runs.map((run) => loadEpisode(run)));
|
const settled = await Promise.allSettled(runs.map((run) => loadEpisode(run)));
|
||||||
|
|
||||||
const episodes: Record<string, DemoEpisode> = {};
|
const episodes: Record<string, DemoEpisode> = {};
|
||||||
|
let dropped = 0;
|
||||||
settled.forEach((outcome, index) => {
|
settled.forEach((outcome, index) => {
|
||||||
const run = runs[index];
|
const run = runs[index];
|
||||||
if (!run) return;
|
if (!run) return;
|
||||||
if (outcome.status === 'fulfilled') episodes[run.id] = outcome.value;
|
if (outcome.status === 'fulfilled') episodes[run.id] = outcome.value;
|
||||||
else console.error(`[pig-demo] dropped run "${run.id}":`, outcome.reason);
|
else {
|
||||||
|
dropped += 1;
|
||||||
|
console.error(`[pig-demo] dropped run "${run.id}":`, outcome.reason);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { demo, runs: runs.filter((run) => episodes[run.id] !== undefined), episodes };
|
return {
|
||||||
|
bundle: { demo, runs: runs.filter((run) => episodes[run.id] !== undefined), episodes },
|
||||||
|
dropped,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DemoShellProps {
|
export interface DemoShellProps {
|
||||||
@@ -112,19 +138,19 @@ export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const slug = slugProp ?? params['slug'] ?? '';
|
const slug = slugProp ?? params['slug'] ?? '';
|
||||||
const [state, setState] = useState<LoadState>(
|
const [state, setState] = useState<LoadState>(
|
||||||
bundle ? { status: 'ready', bundle } : { status: 'loading' },
|
bundle ? { status: 'ready', bundle, dropped: 0 } : { status: 'loading' },
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bundle) {
|
if (bundle) {
|
||||||
setState({ status: 'ready', bundle });
|
setState({ status: 'ready', bundle, dropped: 0 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let live = true;
|
let live = true;
|
||||||
setState({ status: 'loading' });
|
setState({ status: 'loading' });
|
||||||
loadBundle(slug)
|
loadBundle(slug)
|
||||||
.then((loaded) => {
|
.then((loaded) => {
|
||||||
if (live) setState({ status: 'ready', bundle: loaded });
|
if (live) setState({ status: 'ready', bundle: loaded.bundle, dropped: loaded.dropped });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (!live) return;
|
if (!live) return;
|
||||||
@@ -142,11 +168,11 @@ export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
|||||||
if (state.status === 'error') {
|
if (state.status === 'error') {
|
||||||
return (
|
return (
|
||||||
<main className={cn(st.shell, 'py-16')}>
|
<main className={cn(st.shell, 'py-16')}>
|
||||||
<div role="alert" className="card max-w-xl p-6">
|
<div role="alert" className="max-w-xl rounded-xl border border-border bg-surface p-5 sm:p-6">
|
||||||
<h1 className={st.h2}>That demo is not here</h1>
|
<h1 className="text-h2 text-fg">That environment is not here</h1>
|
||||||
<p className={cn(st.prose, 'mt-3')}>{state.message}</p>
|
<p className="mt-3 max-w-measure text-prose text-fg-2">{state.message}</p>
|
||||||
<a href="/gallery" className={cn(st.btnSecondary, 'mt-6')}>
|
<a href="/gallery" className={cn(st.btnSecondary, 'mt-6')}>
|
||||||
Back to the demos
|
All environments
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -158,12 +184,12 @@ export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
|||||||
// so a crash names it, and resetting re-renders the surfaces rather than
|
// so a crash names it, and resetting re-renders the surfaces rather than
|
||||||
// re-navigating.
|
// re-navigating.
|
||||||
<DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}>
|
<DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}>
|
||||||
<DemoBody bundle={state.bundle} />
|
<DemoBody bundle={state.bundle} dropped={state.dropped} />
|
||||||
</DemoErrorBoundary>
|
</DemoErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
function DemoBody({ bundle, dropped }: { bundle: DemoBundle; dropped: number }) {
|
||||||
const { demo, runs, episodes } = bundle;
|
const { demo, runs, episodes } = bundle;
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
|
||||||
@@ -271,25 +297,39 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
// agent's. With no recording to match, the demo's own first board will do.
|
// agent's. With no recording to match, the demo's own first board will do.
|
||||||
const playSeed = run?.seed ?? 0;
|
const playSeed = run?.seed ?? 0;
|
||||||
|
|
||||||
|
// The newest capture, for the Evidence readout. `runs` is manifest order,
|
||||||
|
// which is not date order.
|
||||||
|
const newestCapture = useMemo(
|
||||||
|
() =>
|
||||||
|
runs.reduce<string | null>(
|
||||||
|
(newest, candidate) =>
|
||||||
|
newest === null || candidate.capturedAt > newest ? candidate.capturedAt : newest,
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
[runs],
|
||||||
|
);
|
||||||
|
|
||||||
const headerStats: Stat[] = episode
|
const headerStats: Stat[] = episode
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Outcome',
|
label: 'Outcome',
|
||||||
value: episode.outcome,
|
value: episode.outcome,
|
||||||
tone: episode.outcome === 'solved' ? 'positive' : 'warning',
|
tone: episode.outcome === 'solved' ? 'positive' : 'warning',
|
||||||
...(episode.truncated ? { title: 'Truncated before a terminal state' } : {}),
|
title: episode.truncated
|
||||||
|
? 'Truncated before a terminal state'
|
||||||
|
: 'How the recorded run ended',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reward',
|
label: 'Reward',
|
||||||
value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
|
value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
|
||||||
tone: 'brand',
|
tone: 'brand',
|
||||||
title: 'Total under the shipped weights',
|
title: 'Reward — total under the shipped weights',
|
||||||
},
|
},
|
||||||
{ label: 'Steps', value: steps.length, title: 'Model calls in this run' },
|
{ label: 'Steps', value: steps.length, title: 'Steps — model calls in this run' },
|
||||||
{
|
{
|
||||||
label: 'Seed',
|
label: 'Seed',
|
||||||
value: episode.seed,
|
value: episode.seed,
|
||||||
title: 'The same seed reproduces this board',
|
title: 'Seed — the same seed reproduces this board',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
@@ -322,19 +362,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
{
|
{
|
||||||
id: 'call',
|
id: 'call',
|
||||||
label: 'Model call',
|
label: 'Model call',
|
||||||
content: (
|
content: <ModelCallPanel call={current?.call ?? null} reply={current?.reply ?? null} />,
|
||||||
<div className="space-y-3">
|
|
||||||
<ModelCallPanel call={current?.call ?? null} />
|
|
||||||
{current?.reply ? (
|
|
||||||
<div className="card p-3">
|
|
||||||
<h3 className="text-sm font-semibold">Reply</h3>
|
|
||||||
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-relaxed">
|
|
||||||
{current.reply}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
// A demo's own extra panels ride alongside the step detail, where they sit
|
// A demo's own extra panels ride alongside the step detail, where they sit
|
||||||
// next to the step they are almost always about. With no recording there is
|
// next to the step they are almost always about. With no recording there is
|
||||||
@@ -347,19 +375,6 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
? detailPanel
|
? detailPanel
|
||||||
: DEFAULT_DETAIL_PANEL;
|
: DEFAULT_DETAIL_PANEL;
|
||||||
|
|
||||||
const timeline = (
|
|
||||||
<StepTimeline
|
|
||||||
steps={steps}
|
|
||||||
current={player.index}
|
|
||||||
onSelect={(next) => {
|
|
||||||
player.pause();
|
|
||||||
player.seek(next);
|
|
||||||
}}
|
|
||||||
Surface={Surface}
|
|
||||||
onTogglePlay={player.toggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={cn(st.shell, 'pb-16')}>
|
<main className={cn(st.shell, 'pb-16')}>
|
||||||
{/*
|
{/*
|
||||||
@@ -371,15 +386,23 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
{current?.announce ?? ''}
|
{current?.announce ?? ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header className="pt-6 sm:pt-8">
|
<header className="pt-6">
|
||||||
<p className={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
|
<Label className="text-accent-fg">For {demo.meta.persona}</Label>
|
||||||
<p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
|
<StatusPill status={demo.meta.status} />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1.5 text-h1 text-fg lg:text-h1-lg">{demo.meta.title}</h1>
|
||||||
|
<p className="mt-2 max-w-measure-wide text-pretty text-lede text-fg-2 lg:text-lede-lg">
|
||||||
|
{demo.meta.tagline}
|
||||||
|
</p>
|
||||||
{/* The buyer's question, kept quiet on purpose: it is the thing they
|
{/* The buyer's question, kept quiet on purpose: it is the thing they
|
||||||
walked in with, not the thing this page is asserting. */}
|
walked in with, not the thing this page is asserting. */}
|
||||||
<p className="mt-2 max-w-prose text-sm italic leading-relaxed text-muted">
|
<div className="mt-4 border-l-2 border-border-strong pl-3">
|
||||||
{demo.narrative.anxiety}
|
<Label>The question you walked in with</Label>
|
||||||
</p>
|
<p className="mt-0.5 max-w-[60ch] text-pretty text-prose text-fg-2">
|
||||||
|
{demo.narrative.anxiety}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{/*
|
{/*
|
||||||
The stats describe the RECORDED RUN, so they only belong on the tabs
|
The stats describe the RECORDED RUN, so they only belong on the tabs
|
||||||
whose subject is that run. On Play they sat above the visitor's own
|
whose subject is that run. On Play they sat above the visitor's own
|
||||||
@@ -387,23 +410,22 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
having already failed before you have touched a key.
|
having already failed before you have touched a key.
|
||||||
*/}
|
*/}
|
||||||
{run && headerStats.length > 0 && activeTab !== 'play' ? (
|
{run && headerStats.length > 0 && activeTab !== 'play' ? (
|
||||||
// One line that scrolls itself rather than a block that wraps: every
|
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
// row this header spends is a row of the interactive board pushed
|
|
||||||
// below the fold on a 390px phone.
|
|
||||||
<div className="mt-3 flex items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-visible sm:pb-0">
|
|
||||||
<StatStrip stats={headerStats} />
|
<StatStrip stats={headerStats} />
|
||||||
<RecordedBadge
|
<RecordedBadge
|
||||||
model={run.model}
|
model={run.model}
|
||||||
capturedAt={run.capturedAt}
|
capturedAt={run.capturedAt}
|
||||||
{...(run.intervention ? { intervention: run.intervention } : {})}
|
{...(run.intervention ? { intervention: run.intervention } : {})}
|
||||||
className="ml-0 shrink-0 flex-nowrap"
|
{...(player.timingIsReal || activeTab !== 'watch'
|
||||||
|
? {}
|
||||||
|
: { timingNote: 'pacing approximate' })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<SlotRegion id="hero-aside" />
|
<SlotRegion id="hero-aside" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setTabParam} className="mt-4 sm:mt-5">
|
<Tabs value={activeTab} onValueChange={setTabParam} className="mt-5 sm:mt-6">
|
||||||
<DemoTabBar tabs={tabs} />
|
<DemoTabBar tabs={tabs} />
|
||||||
|
|
||||||
{tabs.includes('play') ? (
|
{tabs.includes('play') ? (
|
||||||
@@ -412,23 +434,45 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
// Reward and back. Radix leaves the hiding to the author under
|
// Reward and back. Radix leaves the hiding to the author under
|
||||||
// `forceMount`, which is what the `data-[state=inactive]` class does —
|
// `forceMount`, which is what the `data-[state=inactive]` class does —
|
||||||
// it is load-bearing, not belt-and-braces.
|
// it is load-bearing, not belt-and-braces.
|
||||||
<TabsContent
|
<Panel
|
||||||
value="play"
|
value="play"
|
||||||
forceMount
|
forceMount
|
||||||
className="mt-5 space-y-5 data-[state=inactive]:hidden sm:mt-6 sm:space-y-6"
|
className="mt-5 space-y-6 data-[state=inactive]:hidden sm:mt-6"
|
||||||
>
|
>
|
||||||
<TabClaim>{claims.play}</TabClaim>
|
<TabClaim>{claims.play}</TabClaim>
|
||||||
<PlayYourself demo={demo} seed={playSeed} />
|
<PlayYourself
|
||||||
|
demo={demo}
|
||||||
|
seed={playSeed}
|
||||||
|
// The bridge to Watch, rendered only once the visitor has acted
|
||||||
|
// and only when there is a Watch tab to bridge to. No slug, no
|
||||||
|
// narrative literal: the same sentence on every environment.
|
||||||
|
{...(tabs.includes('watch')
|
||||||
|
? {
|
||||||
|
afterMove: (
|
||||||
|
<p className="max-w-measure text-prose text-fg-2">
|
||||||
|
The score on this board comes from the grader, not from us.{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTabParam('watch')}
|
||||||
|
className="font-medium text-accent-fg underline decoration-border-strong underline-offset-4 hover:decoration-accent-fg"
|
||||||
|
>
|
||||||
|
Watch a model take the same board →
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
/>
|
||||||
<SlotRegion id="below-board" />
|
<SlotRegion id="below-board" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<h2 className={st.h3}>The machine you are inside</h2>
|
<Label as="h2">The machine you are inside</Label>
|
||||||
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} compact />
|
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} compact />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tabs.includes('watch') && run && episode ? (
|
{tabs.includes('watch') && run && episode ? (
|
||||||
<TabsContent value="watch" className="mt-6 space-y-4">
|
<Panel value="watch" className="mt-5 space-y-4 sm:mt-6">
|
||||||
<TabClaim>{claims.watch}</TabClaim>
|
<TabClaim>{claims.watch}</TabClaim>
|
||||||
|
|
||||||
{runs.length > 1 ? (
|
{runs.length > 1 ? (
|
||||||
@@ -472,13 +516,15 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
|
||||||
<div className="card w-fit p-4">
|
<div className="w-fit max-w-full rounded-xl border border-border bg-surface p-3 sm:p-4">
|
||||||
{current ? <Surface state={current.state} /> : null}
|
{current ? <Surface state={current.state} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Tabs value={activeDetail} onValueChange={setDetailPanel}>
|
<Tabs value={activeDetail} onValueChange={setDetailPanel}>
|
||||||
<TabsList aria-label="Details for this step" className="w-full overflow-x-auto">
|
{/* The nested list keeps the PILL segment style, so the
|
||||||
|
primary underline bar and this never look alike. */}
|
||||||
|
<TabsList aria-label="Details for this step" className="max-w-full overflow-x-auto">
|
||||||
{detailPanels.map((panel) => (
|
{detailPanels.map((panel) => (
|
||||||
<TabsTrigger key={panel.id} value={panel.id}>
|
<TabsTrigger key={panel.id} value={panel.id}>
|
||||||
{panel.label}
|
{panel.label}
|
||||||
@@ -486,7 +532,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{detailPanels.map((panel) => (
|
{detailPanels.map((panel) => (
|
||||||
<TabsContent key={panel.id} value={panel.id}>
|
<TabsContent key={panel.id} value={panel.id} className="mt-3">
|
||||||
{panel.content}
|
{panel.content}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
@@ -494,13 +540,27 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{timeline}
|
<StepTimeline
|
||||||
|
steps={steps}
|
||||||
|
current={player.index}
|
||||||
|
onSelect={(next) => {
|
||||||
|
player.pause();
|
||||||
|
player.seek(next);
|
||||||
|
}}
|
||||||
|
Surface={Surface}
|
||||||
|
onTogglePlay={player.toggle}
|
||||||
|
/>
|
||||||
{player.timingIsReal ? null : (
|
{player.timingIsReal ? null : (
|
||||||
<p className="text-xs text-muted">
|
<p className="max-w-measure text-small text-muted">
|
||||||
Some steps in this run carried no recorded latency, so their dwell on the timeline
|
Some steps in this run carried no recorded latency, so their dwell on the timeline
|
||||||
is the player's fallback rather than a measurement.
|
is the player's fallback rather than a measurement.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{dropped > 0 ? (
|
||||||
|
<p className="font-mono text-caption text-muted">
|
||||||
|
{dropped} recorded {dropped === 1 ? 'run' : 'runs'} could not be loaded
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<SlotRegion id="below-timeline" />
|
<SlotRegion id="below-timeline" />
|
||||||
|
|
||||||
{blindPair ? (
|
{blindPair ? (
|
||||||
@@ -529,10 +589,10 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<TabsContent value="reward" className="mt-6 space-y-4">
|
<Panel value="reward" className="mt-5 space-y-4 sm:mt-6">
|
||||||
<TabClaim>{claims.reward}</TabClaim>
|
<TabClaim>{claims.reward}</TabClaim>
|
||||||
|
|
||||||
{episode ? (
|
{episode ? (
|
||||||
@@ -543,7 +603,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className={cn(st.prose, 'max-w-prose')}>
|
<p className="max-w-measure text-prose text-fg-2">
|
||||||
No recorded run has been scored for this environment yet, so every term below
|
No recorded run has been scored for this environment yet, so every term below
|
||||||
reads as not scored rather than as zero. The weights are the ones the environment
|
reads as not scored rather than as zero. The weights are the ones the environment
|
||||||
ships.
|
ships.
|
||||||
@@ -553,11 +613,10 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* The editor carries the ranking it re-orders in its own right-hand
|
{/* The editor carries the ranking it re-orders in its own right-hand
|
||||||
column, so the two are never on screen apart. */}
|
column, so the two are never on screen apart. Absent, not greyed,
|
||||||
|
when there is nothing to rank. */}
|
||||||
{arms.length > 1 ? <RewardEditor spec={demo.reward} arms={arms} /> : null}
|
{arms.length > 1 ? <RewardEditor spec={demo.reward} arms={arms} /> : null}
|
||||||
|
|
||||||
{episode ? <VerifyBadge demo={demo} episode={episode} /> : null}
|
|
||||||
|
|
||||||
{run && episode ? (
|
{run && episode ? (
|
||||||
<HeadlineMetric
|
<HeadlineMetric
|
||||||
label={`Total reward — ${run.label}`}
|
label={`Total reward — ${run.label}`}
|
||||||
@@ -568,9 +627,9 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SlotRegion id="beside-reward" />
|
<SlotRegion id="beside-reward" />
|
||||||
</TabsContent>
|
</Panel>
|
||||||
|
|
||||||
<TabsContent value="evidence" className="mt-6 space-y-6">
|
<Panel value="evidence" className="mt-5 space-y-6 sm:mt-6">
|
||||||
<TabClaim>{claims.evidence}</TabClaim>
|
<TabClaim>{claims.evidence}</TabClaim>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
@@ -580,10 +639,45 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
tab a visitor opens to read rather than to do — Play stays a board
|
tab a visitor opens to read rather than to do — Play stays a board
|
||||||
above the fold, which is the one thing a paragraph here would cost.
|
above the fold, which is the one thing a paragraph here would cost.
|
||||||
*/}
|
*/}
|
||||||
<p className={cn(st.prose, 'max-w-prose')}>{demo.narrative.thesis}</p>
|
<div className="space-y-3">
|
||||||
|
<p className="max-w-measure text-pretty text-prose text-fg-2">{demo.narrative.thesis}</p>
|
||||||
|
{/* The readout that left the landing. Every value is data. */}
|
||||||
|
<p className="flex flex-wrap gap-x-2 gap-y-1 font-mono text-caption text-muted">
|
||||||
|
<span>
|
||||||
|
<span className="uppercase tracking-label">verifiers</span>{' '}
|
||||||
|
<span className="text-fg">{demo.provenance.verifiersVersion}</span>
|
||||||
|
</span>
|
||||||
|
{newestCapture ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span>
|
||||||
|
<span className="uppercase tracking-label">captured</span>{' '}
|
||||||
|
<span className="text-fg">{formatDate(newestCapture)}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{run ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span title="The same seed reproduces this board">
|
||||||
|
<span className="uppercase tracking-label">seed</span>{' '}
|
||||||
|
<span className="text-fg">{run.seed}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} />
|
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} />
|
||||||
|
|
||||||
|
{/*
|
||||||
|
The receipt sits on Evidence, not Reward: the Evidence claim both
|
||||||
|
demos make is "here is the grader, and here is proof your browser
|
||||||
|
ran it", and that proof belongs next to the printed grader rather
|
||||||
|
than under a slider. Reward keeps the breakdown and the editor.
|
||||||
|
*/}
|
||||||
|
{episode ? <VerifyBadge demo={demo} episode={episode} /> : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
|
||||||
<ProvenanceCard provenance={demo.provenance} {...(run ? { run } : {})} />
|
<ProvenanceCard provenance={demo.provenance} {...(run ? { run } : {})} />
|
||||||
<CodeReceipt
|
<CodeReceipt
|
||||||
@@ -605,7 +699,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
|||||||
|
|
||||||
<SlotRegion id="before-limits" />
|
<SlotRegion id="before-limits" />
|
||||||
<LimitsCallout limits={demo.narrative.limits} />
|
<LimitsCallout limits={demo.narrative.limits} />
|
||||||
</TabsContent>
|
</Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
@@ -689,33 +783,46 @@ function RunSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<SegmentedControl
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
label="Agent"
|
<Label as="span">Agent</Label>
|
||||||
options={arms.map((arm) => {
|
|
||||||
const intervention = arm.runs.find((r) => r.intervention)?.intervention;
|
|
||||||
return {
|
|
||||||
value: arm.label,
|
|
||||||
label: arm.label,
|
|
||||||
...(intervention ? { title: intervention } : {}),
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
value={activeArm.label}
|
|
||||||
onChange={pickArm}
|
|
||||||
className="border border-border p-1"
|
|
||||||
optionClassName="tap px-3 text-sm"
|
|
||||||
/>
|
|
||||||
{activeArm.runs.length > 1 ? (
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
label="Hidden word"
|
label="Agent"
|
||||||
options={activeArm.runs.map((run) => ({
|
options={arms.map((arm) => {
|
||||||
value: run.id,
|
const intervention = arm.runs.find((r) => r.intervention)?.intervention;
|
||||||
label: `#${run.seed}`,
|
return {
|
||||||
}))}
|
value: arm.label,
|
||||||
value={active.id}
|
label: intervention ? (
|
||||||
onChange={onSelect}
|
<span className="inline-flex items-center gap-1.5">
|
||||||
className="border border-border p-1"
|
{arm.label}
|
||||||
optionClassName="tap px-2.5 text-xs nums"
|
{/* PROMPTED: this arm's runs were intervened on. The chip
|
||||||
|
travels with the arm so a switch never hides it. */}
|
||||||
|
<Chip variant="default">prompted</Chip>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
arm.label
|
||||||
|
),
|
||||||
|
...(intervention ? { title: intervention } : {}),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
value={activeArm.label}
|
||||||
|
onChange={pickArm}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{activeArm.runs.length > 1 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label as="span">Seed</Label>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Seed"
|
||||||
|
options={activeArm.runs.map((run) => ({
|
||||||
|
value: run.id,
|
||||||
|
label: `#${run.seed}`,
|
||||||
|
title: `Seed ${run.seed} — the same seed reproduces this board`,
|
||||||
|
}))}
|
||||||
|
value={active.id}
|
||||||
|
onChange={onSelect}
|
||||||
|
optionClassName="px-2.5 font-mono text-caption"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -730,23 +837,23 @@ function ShellSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<div className={cn(st.shell, 'py-10')} aria-busy="true">
|
<div className={cn(st.shell, 'py-10')} aria-busy="true">
|
||||||
<p className="sr-only">Loading the recorded run.</p>
|
<p className="sr-only">Loading the recorded run.</p>
|
||||||
<Skeleton className="h-8 w-64" />
|
<Skeleton className="h-3 w-40" />
|
||||||
<Skeleton className="mt-3 h-4 w-full max-w-md" />
|
<Skeleton className="mt-3 h-8 w-64" />
|
||||||
<Skeleton className="mt-4 h-10 w-full max-w-sm" />
|
<Skeleton className="mt-3 h-5 w-full max-w-md" />
|
||||||
<Skeleton className="mt-6 h-11 w-full max-w-md" />
|
<Skeleton className="mt-6 h-11 w-full max-w-md" />
|
||||||
<Skeleton className="mt-6 h-80" />
|
<Skeleton className="mt-6 h-80" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The run a visitor sees before choosing one.
|
* The run a visitor sees before choosing one.
|
||||||
*
|
*
|
||||||
* `runs[0]` is the manifest's first entry — the weakest agent on seed 0, which
|
* `runs[0]` is the manifest's first entry — the weakest agent on seed 0, which
|
||||||
* for the first demo shipped was a failed game with thinking off. So the Watch tab opened on a
|
* for the first demo shipped was a failed game with thinking off. So the Watch
|
||||||
* loss with an empty reasoning panel and the Reward tab on a row of zeros:
|
* tab opened on a loss with an empty reasoning panel and the Reward tab on a
|
||||||
* the model's least interesting attempt, chosen by accident of sort order.
|
* row of zeros: the model's least interesting attempt, chosen by accident of
|
||||||
|
* sort order.
|
||||||
*
|
*
|
||||||
* Prefer, in order: a run with recorded reasoning (there is something to
|
* Prefer, in order: a run with recorded reasoning (there is something to
|
||||||
* stream), then a solved one (the board reaches a conclusion), then the
|
* stream), then a solved one (the board reaches a conclusion), then the
|
||||||
|
|||||||
@@ -69,12 +69,25 @@ export function resolveTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tab strip.
|
* The mono index prefix (`01 `–`04 `) is the tab's position in the CONTRACT
|
||||||
|
* order, not in the visible list — a demo without Play still shows Watch as
|
||||||
|
* `02`, so the numbers mean the same thing on every page.
|
||||||
|
*/
|
||||||
|
function indexOf(id: DemoTabId): string {
|
||||||
|
return String(DEMO_TABS.indexOf(id) + 1).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The primary tab strip: underline tabs.
|
||||||
*
|
*
|
||||||
* Full width and equal columns below `sm` so four tabs land inside 390px
|
* Underline rather than the pill segment for one reason — the nested lists
|
||||||
* without the list becoming a horizontal scroller — a tab bar you have to
|
* inside Watch (Reasoning | Model call) keep the pill style, and primary and
|
||||||
* scroll hides the tabs, which is the one thing it exists to advertise. From
|
* secondary navigation must never look alike. The primitive's `underline`
|
||||||
* `sm` up it shrinks back to its natural width and sits left.
|
* variant supplies the rail, the 44px triggers, the equal columns below `sm`
|
||||||
|
* and the 2px bar; this adds the sticky behaviour and the index prefix.
|
||||||
|
* Sticky under the site header from `lg` only: on a phone a sticky strip eats
|
||||||
|
* a fifth of the viewport. `bg-bg/90` is one of the two alpha grounds the
|
||||||
|
* spec allows, because it sits on the page ground itself.
|
||||||
*/
|
*/
|
||||||
export function DemoTabBar({
|
export function DemoTabBar({
|
||||||
tabs,
|
tabs,
|
||||||
@@ -85,16 +98,19 @@ export function DemoTabBar({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TabsList
|
<TabsList
|
||||||
|
variant="underline"
|
||||||
aria-label="How to explore this environment"
|
aria-label="How to explore this environment"
|
||||||
className={cn('flex w-full sm:inline-flex sm:w-auto', className)}
|
className={cn(
|
||||||
|
'sm:gap-2',
|
||||||
|
'lg:sticky lg:top-[var(--app-header-h)] lg:z-30 lg:bg-bg/90 lg:backdrop-blur-md',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((id) => (
|
{tabs.map((id) => (
|
||||||
<TabsTrigger
|
<TabsTrigger key={id} value={id} title={TAB_DESCRIPTIONS[id]}>
|
||||||
key={id}
|
<span aria-hidden="true" className="mr-1.5 hidden font-mono text-label text-muted sm:inline">
|
||||||
value={id}
|
{indexOf(id)}
|
||||||
title={TAB_DESCRIPTIONS[id]}
|
</span>
|
||||||
className="min-w-0 flex-1 px-2 text-[0.8125rem] sm:flex-none sm:px-3 sm:text-sm"
|
|
||||||
>
|
|
||||||
{TAB_LABELS[id]}
|
{TAB_LABELS[id]}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
@@ -111,15 +127,13 @@ export function DemoTabBar({
|
|||||||
* every panel for that reason, and it is deliberately not an `<h2>`: the tab
|
* every panel for that reason, and it is deliberately not an `<h2>`: the tab
|
||||||
* trigger is already this panel's accessible name, and a heading here would
|
* trigger is already this panel's accessible name, and a heading here would
|
||||||
* make the claim navigable furniture instead of a thing someone reads.
|
* make the claim navigable furniture instead of a thing someone reads.
|
||||||
|
*
|
||||||
|
* Never mono, never uppercase, never shortened. This is the one place the
|
||||||
|
* language yields to the lesson outright.
|
||||||
*/
|
*/
|
||||||
export function TabClaim({ children, className }: { children: ReactNode; className?: string }) {
|
export function TabClaim({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p className={cn('max-w-[60ch] text-pretty text-claim text-fg lg:text-claim-lg', className)}>
|
||||||
className={cn(
|
|
||||||
'max-w-2xl text-pretty text-base leading-snug text-fg sm:text-lg lg:text-xl',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { ArrowRight, ListChecks, Scale, Target, TrendingUp } from 'lucide-react';
|
|
||||||
|
import { anatomyTerm } from '@/content/anatomy';
|
||||||
|
import type { AnatomyKey } from '@/content/anatomy';
|
||||||
import type { DemoModule } from '@/lib/demo-kit/types';
|
import type { DemoModule } from '@/lib/demo-kit/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -9,107 +11,80 @@ export interface EnvAnatomyProps {
|
|||||||
anatomy: Anatomy;
|
anatomy: Anatomy;
|
||||||
/** `DemoMeta.rewardLine` — six words on what the reward pays for. */
|
/** `DemoMeta.rewardLine` — six words on what the reward pays for. */
|
||||||
rewardLine?: string;
|
rewardLine?: string;
|
||||||
/** Set on the gallery/overview page, where the four boxes are context rather
|
/**
|
||||||
* than the lesson, to drop the closing line and tighten the type. */
|
* The Play-tab form: four boxes and their bodies, no questions and no closing
|
||||||
|
* line, because the visitor has just acted and the boxes are a caption to
|
||||||
|
* what they did rather than the lesson itself.
|
||||||
|
*/
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Box {
|
|
||||||
key: keyof Anatomy;
|
|
||||||
kicker: string;
|
|
||||||
question: string;
|
|
||||||
Icon: LucideIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The four boxes, in the order an executive builds the mental model: what is
|
* The four boxes, in the order an executive builds the mental model: what is
|
||||||
* being asked, what the agent is allowed to do, who decides whether it was
|
* being asked, what the agent is allowed to do, who decides whether it was
|
||||||
* good, and what number that decision moves.
|
* good, and what number that decision moves.
|
||||||
*
|
*
|
||||||
* The kickers are deliberately generic. This object is the site's one piece of
|
* The kickers come from `@/content/anatomy` — the SAME strings the landing's
|
||||||
|
* definition strip renders. This object is the site's one piece of
|
||||||
* transferable explanation: someone who learns the machine on a word game
|
* transferable explanation: someone who learns the machine on a word game
|
||||||
* should read the fraud demo as "same machine, different grader", and that only
|
* should read the fraud screen as "same machine, different grader", and that
|
||||||
* works if the four labels never change between demos.
|
* only works if the four labels never change between the landing and a demo.
|
||||||
|
* The order is the contract's, whatever order the content file lists them in.
|
||||||
*/
|
*/
|
||||||
const BOXES: Box[] = [
|
const KEYS: readonly AnatomyKey[] = ['task', 'actions', 'grader', 'score'];
|
||||||
{ key: 'task', kicker: 'The task', question: 'What is the agent asked to do?', Icon: Target },
|
const BOXES = KEYS.map(anatomyTerm);
|
||||||
{
|
|
||||||
key: 'actions',
|
|
||||||
kicker: 'Legal actions',
|
|
||||||
question: 'What is it allowed to do?',
|
|
||||||
Icon: ListChecks,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'grader',
|
|
||||||
kicker: 'The grader',
|
|
||||||
question: 'Who decides whether it was good?',
|
|
||||||
Icon: Scale,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'score',
|
|
||||||
kicker: 'The score that moves',
|
|
||||||
question: 'What number does that produce?',
|
|
||||||
Icon: TrendingUp,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function EnvAnatomy({ anatomy, rewardLine, compact = false, className }: EnvAnatomyProps) {
|
export function EnvAnatomy({ anatomy, rewardLine, compact = false, className }: EnvAnatomyProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-4', className)}>
|
<div className={cn('space-y-4', className)}>
|
||||||
<ol className="flex flex-col lg:flex-row lg:items-stretch">
|
<ol className="flex flex-col lg:flex-row lg:items-stretch">
|
||||||
{BOXES.map((box, index) => (
|
{BOXES.map((box, position) => {
|
||||||
<li
|
const grader = box.key === 'grader';
|
||||||
key={box.key}
|
return (
|
||||||
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-center"
|
<li
|
||||||
>
|
key={box.key}
|
||||||
<div
|
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-stretch"
|
||||||
className={cn(
|
|
||||||
'card flex flex-1 flex-col gap-2 p-4',
|
|
||||||
// The grader is the box every later demo differs on. It is the
|
|
||||||
// one the eye should land on second, after the task.
|
|
||||||
box.key === 'grader' && 'border-brand/40 bg-accent-subtle/40',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
|
|
||||||
<box.Icon className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-semibold leading-tight">
|
|
||||||
<span className="nums mr-1.5 text-muted">{index + 1}</span>
|
|
||||||
{box.kicker}
|
|
||||||
</p>
|
|
||||||
{!compact ? (
|
|
||||||
<p className="text-xs leading-tight text-muted">{box.question}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className={cn('text-pretty text-fg', compact ? 'text-xs' : 'text-sm')}>
|
|
||||||
{anatomy[box.key]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{index < BOXES.length - 1 ? (
|
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
className={cn(
|
||||||
className="flex items-center justify-center py-2 text-muted lg:px-2 lg:py-0"
|
'flex flex-1 flex-col gap-2 rounded-xl border p-4',
|
||||||
|
// The grader is the box every later demo differs on. It is
|
||||||
|
// the one the eye should land on second, after the task.
|
||||||
|
grader ? 'border-accent-fg bg-accent-subtle' : 'border-border bg-surface',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4 rotate-90 lg:rotate-0" />
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="font-mono text-label text-muted" aria-hidden="true">
|
||||||
|
{box.index}
|
||||||
|
</span>
|
||||||
|
<p className="text-ui font-semibold leading-tight text-fg">{box.kicker}</p>
|
||||||
|
</div>
|
||||||
|
{!compact ? <p className="text-small text-muted">{box.question}</p> : null}
|
||||||
|
<p className="text-pretty text-small text-fg-2">{anatomy[box.key]}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
{position < BOXES.length - 1 ? (
|
||||||
</li>
|
<div
|
||||||
))}
|
aria-hidden="true"
|
||||||
|
className="flex items-center justify-center py-1.5 text-border-strong lg:px-1.5 lg:py-0"
|
||||||
|
>
|
||||||
|
<ArrowRight className="size-4 rotate-90 lg:rotate-0" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{rewardLine ? (
|
{rewardLine ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-ui text-muted">
|
||||||
<span className="font-medium text-fg">This reward: </span>
|
<span className="font-mono text-label uppercase tracking-label">This reward</span>{' '}
|
||||||
{rewardLine}
|
<span className="text-fg-2">{rewardLine}</span>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!compact ? (
|
{!compact ? (
|
||||||
<p className="max-w-3xl text-pretty text-sm leading-relaxed text-muted">
|
<p className="max-w-measure text-pretty text-prose text-fg-2">
|
||||||
Every demo on this site is that same machine. The task changes, the legal actions
|
Every demo on this site is that same machine. The task changes, the legal actions
|
||||||
change, and the grader changes — but the grader is always code you can read, and the
|
change, and the grader changes — but the grader is always code you can read, and the
|
||||||
score is always a number you can watch move. That is what makes an environment
|
score is always a number you can watch move. That is what makes an environment
|
||||||
|
|||||||
@@ -1,75 +1,89 @@
|
|||||||
import { ArrowRight, ShieldQuestion } from 'lucide-react';
|
|
||||||
import { getDemo } from '@/lib/demo-kit/registry';
|
import { getDemo } from '@/lib/demo-kit/registry';
|
||||||
import type { Limit } from '@/lib/demo-kit/types';
|
import type { DemoStatus, Limit } from '@/lib/demo-kit/types';
|
||||||
import * as st from '@/content/styles';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ResolvedDemo {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
/** `spec` renders as "not built yet" beside the link. */
|
||||||
|
status?: DemoStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LimitsCalloutProps {
|
export interface LimitsCalloutProps {
|
||||||
limits: Limit[];
|
limits: Limit[];
|
||||||
/**
|
/**
|
||||||
* Turns a demo slug into something linkable. The shell has no registry
|
* Turns a demo slug into something linkable. Defaults to the registry, so a
|
||||||
* dependency of its own, so the page that knows the routes supplies this.
|
* limit pointing at a demo that does not exist yet renders as a stated gap
|
||||||
|
* rather than as a link to a 404 — the honest outcome, since the roadmap is
|
||||||
|
* allowed to be ahead of the repository.
|
||||||
*/
|
*/
|
||||||
resolveDemo?: (slug: string) => { title: string; href: string } | undefined;
|
resolveDemo?: (slug: string) => ResolvedDemo | undefined;
|
||||||
title?: string;
|
title?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function defaultResolve(slug: string): ResolvedDemo | undefined {
|
||||||
* A limit names the demo that closes it by SLUG. Resolving it through the
|
|
||||||
* registry means a limit pointing at a demo that does not exist yet renders as
|
|
||||||
* a stated gap rather than as a link to a 404 — which is the honest outcome,
|
|
||||||
* since the roadmap is allowed to be ahead of the repository.
|
|
||||||
*/
|
|
||||||
function defaultResolve(slug: string): { title: string; href: string } | undefined {
|
|
||||||
const meta = getDemo(slug);
|
const meta = getDemo(slug);
|
||||||
return meta ? { title: meta.title, href: `/demos/${meta.slug}` } : undefined;
|
return meta ? { title: meta.title, href: `/demos/${meta.slug}`, status: meta.status } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What this demo does not teach.
|
* What this demo does not teach.
|
||||||
*
|
*
|
||||||
* Styled as confidence, not apology. A vendor who tells you the limits of their
|
* Styled as confidence, not apology: an amber card, because a limit is a
|
||||||
* own demo before you find them is a vendor you believe about everything else
|
* caution about the data and amber is what every other caution on the site
|
||||||
* on the page — and every limit here names the demo that closes it, so the list
|
* wears. A vendor who tells you the limits of their own demo before you find
|
||||||
* reads as a roadmap rather than a disclaimer.
|
* them is a vendor you believe about everything else on the page — and every
|
||||||
|
* limit names the demo that closes it, so the list reads as a roadmap rather
|
||||||
|
* than a disclaimer.
|
||||||
*/
|
*/
|
||||||
export function LimitsCallout({
|
export function LimitsCallout({
|
||||||
limits,
|
limits,
|
||||||
resolveDemo = defaultResolve,
|
resolveDemo = defaultResolve,
|
||||||
title = 'What this demo does not teach',
|
title = 'What this does not teach',
|
||||||
className,
|
className,
|
||||||
}: LimitsCalloutProps) {
|
}: LimitsCalloutProps) {
|
||||||
if (limits.length === 0) return null;
|
if (limits.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<section aria-label={title} className={cn('card p-4', className)}>
|
<section
|
||||||
<div className="flex items-center gap-2">
|
aria-label={title}
|
||||||
<ShieldQuestion className="size-4 text-muted" aria-hidden="true" />
|
className={cn('rounded-xl border border-border bg-warning-subtle p-4 sm:p-5', className)}
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
>
|
||||||
</div>
|
<h3 className="font-mono text-label uppercase tracking-label text-warning">{title}</h3>
|
||||||
<ul className="mt-3 space-y-3">
|
<ol className="mt-3 space-y-3">
|
||||||
{limits.map((limit) => {
|
{limits.map((limit, index) => {
|
||||||
const target = limit.answeredBy ? resolveDemo(limit.answeredBy) : undefined;
|
const target = limit.answeredBy ? resolveDemo(limit.answeredBy) : undefined;
|
||||||
return (
|
return (
|
||||||
<li key={limit.text} className="border-l-2 border-border pl-3">
|
<li key={limit.text} className="flex gap-3">
|
||||||
<p className="text-pretty text-sm leading-relaxed text-fg">{limit.text}</p>
|
<span className="pt-1 font-mono text-label text-muted" aria-hidden="true">
|
||||||
{target ? (
|
{String(index + 1).padStart(2, '0')}
|
||||||
<a
|
</span>
|
||||||
href={target.href}
|
<div className="min-w-0 flex-1">
|
||||||
className={cn(st.link, 'mt-1 inline-flex items-center gap-1 text-xs')}
|
<p className="max-w-measure text-pretty text-prose text-fg-2">{limit.text}</p>
|
||||||
>
|
{target ? (
|
||||||
Answered by {target.title}
|
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-small">
|
||||||
<ArrowRight className="size-3" aria-hidden="true" />
|
<a
|
||||||
</a>
|
href={target.href}
|
||||||
) : (
|
className="inline-flex min-h-11 items-center font-medium text-accent-fg underline decoration-border-strong underline-offset-4 hover:decoration-accent-fg sm:min-h-0"
|
||||||
<p className="mt-1 text-xs text-muted">
|
>
|
||||||
No demo answers this one yet. It is a real gap, not a rhetorical one.
|
answered by {target.title} →
|
||||||
</p>
|
</a>
|
||||||
)}
|
{target.status === 'spec' ? (
|
||||||
|
<span className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
not built yet
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-small text-muted">
|
||||||
|
No demo answers this one yet. It is a real gap, not a rhetorical one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,14 +27,24 @@ export interface MetricChartProps {
|
|||||||
digits?: number;
|
digits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Every colour is an `hsl(var(--…))` string read from the token layer at paint
|
||||||
|
* time, so the chart follows the theme toggle without a re-render and no hex
|
||||||
|
* ever lands in this file. The series takes `--tab-underline`, which is the
|
||||||
|
* one token that is the accent fill in dark and the accent INK in light — a
|
||||||
|
* 2px line on the page ground is a state indicator, and the raw accent is
|
||||||
|
* 1.47:1 against white.
|
||||||
|
*/
|
||||||
|
const SERIES = 'hsl(var(--tab-underline))';
|
||||||
|
const GRID = 'hsl(var(--border))';
|
||||||
|
const BASELINE = 'hsl(var(--border-strong))';
|
||||||
|
const TICK = 'hsl(var(--muted))';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chart, in its own module so `React.lazy` can hold recharts out of the
|
* The chart, in its own module so `React.lazy` can hold recharts out of the
|
||||||
* entry chunk. Nothing else in the shell may import this file directly — an
|
* entry chunk. Nothing else in the shell may import this file directly — an
|
||||||
* ordinary import here defeats the whole arrangement and the entry chunk grows
|
* ordinary import here defeats the whole arrangement and the entry chunk grows
|
||||||
* by ~100 kB without anyone noticing.
|
* by ~100 kB without anyone noticing.
|
||||||
*
|
|
||||||
* Every colour is read from the token layer at paint time rather than passed in
|
|
||||||
* as a literal, so the chart follows the theme toggle without a re-render.
|
|
||||||
*/
|
*/
|
||||||
export default function MetricChart({
|
export default function MetricChart({
|
||||||
data,
|
data,
|
||||||
@@ -44,19 +54,19 @@ export default function MetricChart({
|
|||||||
digits = 3,
|
digits = 3,
|
||||||
}: MetricChartProps) {
|
}: MetricChartProps) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height }} className="w-full">
|
<div style={{ height }} className="w-full font-mono">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 4 }}>
|
<LineChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 4 }}>
|
||||||
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="2 4" vertical={false} />
|
<CartesianGrid stroke={GRID} strokeDasharray="2 4" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="x"
|
dataKey="x"
|
||||||
tick={{ fill: 'hsl(var(--muted))', fontSize: 11 }}
|
tick={{ fill: TICK, fontSize: 11, fontFamily: 'inherit' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={{ stroke: 'hsl(var(--border))' }}
|
axisLine={{ stroke: GRID }}
|
||||||
minTickGap={12}
|
minTickGap={12}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: 'hsl(var(--muted))', fontSize: 11 }}
|
tick={{ fill: TICK, fontSize: 11, fontFamily: 'inherit' }}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
width={40}
|
width={40}
|
||||||
@@ -65,18 +75,21 @@ export default function MetricChart({
|
|||||||
{baseline ? (
|
{baseline ? (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={baseline.value}
|
y={baseline.value}
|
||||||
stroke="hsl(var(--muted))"
|
stroke={BASELINE}
|
||||||
strokeDasharray="5 4"
|
strokeDasharray="5 4"
|
||||||
|
// Right-anchored: a baseline near zero sits on the x axis, and
|
||||||
|
// a left-anchored label lands on the first tick's text.
|
||||||
label={{
|
label={{
|
||||||
value: baseline.label,
|
value: baseline.label,
|
||||||
position: 'insideTopLeft',
|
position: 'insideTopRight',
|
||||||
fill: 'hsl(var(--muted))',
|
fill: TICK,
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
cursor={{ stroke: 'hsl(var(--border))' }}
|
cursor={{ stroke: BASELINE }}
|
||||||
content={(props: TooltipProps<number, string>) => (
|
content={(props: TooltipProps<number, string>) => (
|
||||||
<ChartTooltip {...props} digits={digits} />
|
<ChartTooltip {...props} digits={digits} />
|
||||||
)}
|
)}
|
||||||
@@ -87,10 +100,10 @@ export default function MetricChart({
|
|||||||
// recorded arm) that curve is a claim nobody measured.
|
// recorded arm) that curve is a claim nobody measured.
|
||||||
type="linear"
|
type="linear"
|
||||||
dataKey="y"
|
dataKey="y"
|
||||||
stroke="hsl(var(--accent))"
|
stroke={SERIES}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ r: 2.5, fill: 'hsl(var(--accent))', strokeWidth: 0 }}
|
dot={{ r: 3, fill: SERIES, strokeWidth: 0 }}
|
||||||
activeDot={{ r: 4 }}
|
activeDot={{ r: 4.5, fill: SERIES, strokeWidth: 0 }}
|
||||||
isAnimationActive={animate}
|
isAnimationActive={animate}
|
||||||
animationDuration={400}
|
animationDuration={400}
|
||||||
/>
|
/>
|
||||||
@@ -109,9 +122,9 @@ function ChartTooltip({
|
|||||||
if (!active || !payload || payload.length === 0) return null;
|
if (!active || !payload || payload.length === 0) return null;
|
||||||
const value = payload[0]?.value;
|
const value = payload[0]?.value;
|
||||||
return (
|
return (
|
||||||
<div className="card px-2.5 py-1.5 text-xs shadow-md">
|
<div className="rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-caption shadow-overlay">
|
||||||
<p className="nums text-muted">{String(label)}</p>
|
<p className="text-muted">{String(label)}</p>
|
||||||
<p className="nums font-mono font-semibold">
|
<p className="font-semibold text-fg">
|
||||||
{typeof value === 'number' ? formatNumber(value, digits) : '—'}
|
{typeof value === 'number' ? formatNumber(value, digits) : '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Suspense, lazy, useMemo } from 'react';
|
import { Suspense, lazy, useMemo } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatNumber, formatSigned, usePrefersReducedMotion } from './format';
|
import { EditedChip, Label } from './chrome';
|
||||||
|
import { formatNumber, formatSigned, useMediaQuery, usePrefersReducedMotion } from './format';
|
||||||
import type { MetricPoint } from './MetricChart';
|
import type { MetricPoint } from './MetricChart';
|
||||||
import { EditedChip } from './StatStrip';
|
|
||||||
|
|
||||||
export type { MetricPoint } from './MetricChart';
|
export type { MetricPoint } from './MetricChart';
|
||||||
|
|
||||||
@@ -35,6 +35,10 @@ export interface MetricMoverProps {
|
|||||||
* The reference rule is not decoration: a number on its own is a claim, and a
|
* The reference rule is not decoration: a number on its own is a claim, and a
|
||||||
* number against the line it used to sit on is evidence. If a demo has no
|
* number against the line it used to sit on is evidence. If a demo has no
|
||||||
* baseline to draw, it should not be using this component.
|
* baseline to draw, it should not be using this component.
|
||||||
|
*
|
||||||
|
* Under `sm` the table IS the view and the chart is not fetched: a 168px line
|
||||||
|
* with four categorical ticks in a 326px column is a picture of a table that
|
||||||
|
* would fit better than the picture.
|
||||||
*/
|
*/
|
||||||
export function MetricMover({
|
export function MetricMover({
|
||||||
label,
|
label,
|
||||||
@@ -48,6 +52,7 @@ export function MetricMover({
|
|||||||
className,
|
className,
|
||||||
}: MetricMoverProps) {
|
}: MetricMoverProps) {
|
||||||
const reducedMotion = usePrefersReducedMotion();
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const wide = useMediaQuery('(min-width: 640px)', true);
|
||||||
// The rule is still drawn when the headline IS the baseline — it is the line
|
// The rule is still drawn when the headline IS the baseline — it is the line
|
||||||
// the other arms are read against. The delta text is not: "+0.000 vs itself"
|
// the other arms are read against. The delta text is not: "+0.000 vs itself"
|
||||||
// is noise dressed up as a measurement.
|
// is noise dressed up as a measurement.
|
||||||
@@ -55,76 +60,96 @@ export function MetricMover({
|
|||||||
const delta = rawDelta !== null && Math.abs(rawDelta) > 1e-9 ? rawDelta : null;
|
const delta = rawDelta !== null && Math.abs(rawDelta) > 1e-9 ? rawDelta : null;
|
||||||
const points = useMemo(() => series ?? [], [series]);
|
const points = useMemo(() => series ?? [], [series]);
|
||||||
|
|
||||||
|
const table = (
|
||||||
|
<table className="w-full border-collapse font-mono text-caption">
|
||||||
|
<caption className="sr-only">{label}, one row per recorded arm</caption>
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-surface-3 text-left text-label uppercase tracking-label text-muted">
|
||||||
|
<th scope="col" className="px-2 py-1.5 font-medium">
|
||||||
|
Arm
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{points.map((point) => (
|
||||||
|
<tr key={String(point.x)}>
|
||||||
|
<th scope="row" className="px-2 py-1.5 text-left font-normal text-fg-2">
|
||||||
|
{String(point.x)}
|
||||||
|
</th>
|
||||||
|
<td className="px-2 py-1.5 text-right text-fg">{formatNumber(point.y, digits)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={label} className={cn('card p-4', className)}>
|
<section
|
||||||
|
aria-label={label}
|
||||||
|
className={cn('rounded-xl border border-border bg-surface p-4 sm:p-5', className)}
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
<h3 className="text-sm font-medium text-muted">{label}</h3>
|
<Label as="h3">{label}</Label>
|
||||||
{edited ? <EditedChip /> : null}
|
{edited ? <EditedChip /> : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 flex items-baseline gap-2">
|
<p className="mt-1.5 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
<span className="nums text-4xl font-semibold leading-none tracking-tight">
|
<span className="font-mono text-metric text-fg">{formatNumber(value, digits)}</span>
|
||||||
{formatNumber(value, digits)}
|
{unit ? <span className="text-ui text-muted">{unit}</span> : null}
|
||||||
</span>
|
|
||||||
{unit ? <span className="text-sm text-muted">{unit}</span> : null}
|
|
||||||
{delta !== null ? (
|
{delta !== null ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'nums text-sm font-medium',
|
'font-mono text-mono-data font-medium',
|
||||||
delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
|
delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatSigned(delta, digits)} vs {baseline?.label}
|
{formatSigned(delta, digits)} <span className="text-muted">vs {baseline?.label}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{points.length > 1 ? (
|
{points.length > 1 ? (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Suspense
|
{wide ? (
|
||||||
fallback={
|
<>
|
||||||
// Reserve the exact chart height. A chart that pops in and pushes
|
<Suspense
|
||||||
// the caption down is the layout shift this whole page is trying
|
fallback={
|
||||||
// not to have.
|
// Reserve the exact chart height. A chart that pops in and
|
||||||
<div
|
// pushes the caption down is the layout shift this whole
|
||||||
style={{ height: CHART_HEIGHT }}
|
// page is trying not to have.
|
||||||
className="w-full rounded-lg bg-surface-2"
|
<div
|
||||||
aria-hidden="true"
|
style={{ height: CHART_HEIGHT }}
|
||||||
/>
|
className="w-full rounded-md bg-surface-2"
|
||||||
}
|
aria-hidden="true"
|
||||||
>
|
/>
|
||||||
<MetricChart
|
}
|
||||||
data={points}
|
>
|
||||||
{...(baseline ? { baseline } : {})}
|
<MetricChart
|
||||||
height={CHART_HEIGHT}
|
data={points}
|
||||||
animate={!reducedMotion}
|
{...(baseline ? { baseline } : {})}
|
||||||
digits={digits}
|
height={CHART_HEIGHT}
|
||||||
/>
|
animate={!reducedMotion}
|
||||||
</Suspense>
|
digits={digits}
|
||||||
{/* The chart is a picture of the table; the table is the accessible
|
/>
|
||||||
version of the picture. Both are the same numbers. */}
|
</Suspense>
|
||||||
<details className="mt-1">
|
{/* The chart is a picture of the table; the table is the
|
||||||
<summary className="tap inline-flex cursor-pointer items-center text-xs text-muted hover:text-fg">
|
accessible version of the picture. Both are the same numbers. */}
|
||||||
Show these points as a table
|
<details className="mt-1">
|
||||||
</summary>
|
<summary className="tap inline-flex cursor-pointer items-center text-small text-muted hover:text-fg">
|
||||||
<table className="nums mt-1.5 w-full border-collapse font-mono text-xs">
|
Show these points as a table
|
||||||
<tbody className="divide-y divide-border">
|
</summary>
|
||||||
{points.map((point) => (
|
<div className="mt-1.5 overflow-hidden rounded-md border border-border">{table}</div>
|
||||||
<tr key={String(point.x)}>
|
</details>
|
||||||
<th scope="row" className="py-1 text-left font-normal text-muted">
|
</>
|
||||||
{String(point.x)}
|
) : (
|
||||||
</th>
|
<div className="overflow-hidden rounded-md border border-border">{table}</div>
|
||||||
<td className="py-1 text-right">{formatNumber(point.y, digits)}</td>
|
)}
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{caption ? (
|
{caption ? <p className="mt-3 max-w-measure text-small text-muted">{caption}</p> : null}
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted">{caption}</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { DASH, formatInt, formatMs, humaniseToken } from './format';
|
|||||||
|
|
||||||
export interface ModelCallPanelProps {
|
export interface ModelCallPanelProps {
|
||||||
call: ModelCall | null;
|
call: ModelCall | null;
|
||||||
|
/** The model's visible reply for this step, verbatim. */
|
||||||
|
reply?: string | null;
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
@@ -23,60 +25,74 @@ interface Row {
|
|||||||
* not report reasoning tokens is a fact about the trace, and hiding the row
|
* not report reasoning tokens is a fact about the trace, and hiding the row
|
||||||
* would turn "we do not know" into "there were none".
|
* would turn "we do not know" into "there were none".
|
||||||
*/
|
*/
|
||||||
export function ModelCallPanel({ call, className, title = 'Model call' }: ModelCallPanelProps) {
|
export function ModelCallPanel({
|
||||||
|
call,
|
||||||
|
reply = null,
|
||||||
|
className,
|
||||||
|
title = 'Model call',
|
||||||
|
}: ModelCallPanelProps) {
|
||||||
const rows: Row[] = [
|
const rows: Row[] = [
|
||||||
{
|
{
|
||||||
label: 'finish_reason',
|
label: 'prompt tokens',
|
||||||
value: call?.finishReason ? humaniseToken(call.finishReason) : DASH,
|
|
||||||
hint: 'Why the model stopped generating',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Prompt tokens',
|
|
||||||
value: formatInt(call?.promptTokens ?? null),
|
value: formatInt(call?.promptTokens ?? null),
|
||||||
hint: 'Everything sent in: system, board, history',
|
hint: 'Everything sent in: system, board, history',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Completion tokens',
|
label: 'completion tokens',
|
||||||
value: formatInt(call?.completionTokens ?? null),
|
value: formatInt(call?.completionTokens ?? null),
|
||||||
hint: 'The visible reply',
|
hint: 'The visible reply',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Reasoning tokens',
|
label: 'reasoning tokens',
|
||||||
value: formatInt(call?.reasoningTokens ?? null),
|
value: formatInt(call?.reasoningTokens ?? null),
|
||||||
hint: 'Billed thinking, when the provider reports it',
|
hint: 'Billed thinking, when the provider reports it',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Latency',
|
label: 'duration',
|
||||||
value: formatMs(call?.durationMs ?? null),
|
value: formatMs(call?.durationMs ?? null),
|
||||||
hint: 'Real elapsed time when the run was recorded',
|
hint: 'Real elapsed time when the run was recorded',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'finish reason',
|
||||||
|
value: call?.finishReason ? humaniseToken(call.finishReason) : DASH,
|
||||||
|
hint: 'Why the model stopped generating',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={title} className={cn('card overflow-hidden', className)}>
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
className={cn('overflow-hidden rounded-lg border border-border bg-surface-2', className)}
|
||||||
|
>
|
||||||
<header className="border-b border-border px-3 py-2">
|
<header className="border-b border-border px-3 py-2">
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
<h3 className="font-mono text-label uppercase tracking-label text-muted">{title}</h3>
|
||||||
</header>
|
</header>
|
||||||
{call === null ? (
|
{call === null ? (
|
||||||
<p className="px-3 py-3 text-sm text-muted">
|
<p className="max-w-measure px-3 py-3 text-prose text-muted">
|
||||||
This step did not involve a model call — it is a state change the environment made on
|
This step did not involve a model call — it is a state change the environment made on
|
||||||
its own.
|
its own.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<dl className="divide-y divide-border">
|
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 px-3 py-1.5 font-mono text-mono-data">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<div key={row.label} className="flex items-baseline gap-3 px-3 py-2">
|
<div key={row.label} className="col-span-2 grid grid-cols-subgrid items-baseline py-1">
|
||||||
<dt className="min-w-0 flex-1">
|
<dt className="text-label uppercase tracking-label text-muted" title={row.hint}>
|
||||||
<span className="block text-sm font-medium">{row.label}</span>
|
{row.label}
|
||||||
{row.hint ? (
|
|
||||||
<span className="block text-xs leading-snug text-muted">{row.hint}</span>
|
|
||||||
) : null}
|
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="nums shrink-0 font-mono text-sm text-fg">{row.value}</dd>
|
<dd className="text-right text-fg">{row.value}</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{reply ? (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<h4 className="px-3 pt-2 font-mono text-label uppercase tracking-label text-muted">Reply</h4>
|
||||||
|
<pre className="m-3 mt-1.5 overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-surface-3 p-2.5 font-mono text-caption leading-relaxed text-fg [font-feature-settings:'liga'_0]">
|
||||||
|
{reply}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { RotateCcw } from 'lucide-react';
|
import { RotateCcw } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { LabelledContrastToggle } from '@/components/site/ContrastToggle';
|
||||||
import type { DemoModule } from '@/lib/demo-kit';
|
import type { DemoModule } from '@/lib/demo-kit';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import * as st from '@/content/styles';
|
import { Label } from './chrome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The visitor's own attempt, on the same seed as the run beside it.
|
* The visitor's own attempt, on the same seed as the run beside it.
|
||||||
@@ -20,10 +23,19 @@ import * as st from '@/content/styles';
|
|||||||
export function PlayYourself<TState>({
|
export function PlayYourself<TState>({
|
||||||
demo,
|
demo,
|
||||||
seed,
|
seed,
|
||||||
|
afterMove,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
demo: DemoModule<TState>;
|
demo: DemoModule<TState>;
|
||||||
seed: number;
|
seed: number;
|
||||||
|
/**
|
||||||
|
* Rendered under the board once the visitor has changed the state at all.
|
||||||
|
* The shell has no contract-level way to know a board is FINISHED — the
|
||||||
|
* state is the demo's own type and the shell never inspects it — so "has
|
||||||
|
* acted" is the honest gate for the bridge line, and the copy is written for
|
||||||
|
* that moment rather than for a terminal one.
|
||||||
|
*/
|
||||||
|
afterMove?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const interactive = demo.interactive;
|
const interactive = demo.interactive;
|
||||||
@@ -42,37 +54,50 @@ export function PlayYourself<TState>({
|
|||||||
|
|
||||||
if (!interactive || state === null) return null;
|
if (!interactive || state === null) return null;
|
||||||
const Controls = interactive.Controls;
|
const Controls = interactive.Controls;
|
||||||
|
const moved = state !== initial;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The SHELL does not decide how wide a demo's play surface is. A word grid
|
<div className={cn('space-y-3', className)}>
|
||||||
// wants a narrow column with its keyboard beside it; an analyst screen full
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
// of tables wants most of the page. So this is a generous ceiling and each
|
<Label as="h2">Your attempt</Label>
|
||||||
// demo's Surface and Controls cap themselves — wordle's keyboard carries
|
<div className="flex items-center gap-1">
|
||||||
// its own max width for exactly that reason.
|
{/* The contrast toggle sits here as well as in the header because
|
||||||
<div className={cn('w-full max-w-4xl space-y-3', className)}>
|
this is where a colour-blind visitor is looking when they need it. */}
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<LabelledContrastToggle />
|
||||||
<h3 className={st.h3}>Your attempt</h3>
|
<Button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="touch"
|
||||||
setState(interactive.init(seed));
|
className="text-muted hover:text-fg lg:min-h-9"
|
||||||
setGeneration((g) => g + 1);
|
onClick={() => {
|
||||||
}}
|
setState(interactive.init(seed));
|
||||||
className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted transition-colors duration-1 hover:bg-surface-2 hover:text-fg"
|
setGeneration((g) => g + 1);
|
||||||
>
|
}}
|
||||||
<RotateCcw aria-hidden="true" className="size-3.5" />
|
>
|
||||||
Start over
|
<RotateCcw aria-hidden="true" />
|
||||||
</button>
|
Start over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card w-fit p-4">
|
{/* The shell decides the CARD, the Surface decides its own width (§8):
|
||||||
<Surface state={state} />
|
a word grid is a narrow column with its keyboard beside it, an
|
||||||
|
analyst screen wants most of the page. `flex-wrap` lets either stack
|
||||||
|
under its controls the moment the two do not fit side by side. */}
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
<div className="w-fit max-w-full rounded-xl border border-border bg-surface p-3 sm:p-4">
|
||||||
|
<Surface state={state} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remounting on `generation` clears any state the controls hold of
|
||||||
|
their own — a half-typed word survives a reset otherwise, and the
|
||||||
|
board and the keyboard then disagree about the current row. */}
|
||||||
|
<div className="min-w-[18rem] max-w-md flex-1 pb-[max(1rem,var(--safe-bottom))] sm:pb-0 [@media(pointer:coarse)]:max-w-lg">
|
||||||
|
<Controls key={generation} state={state} onChange={setState} seed={seed} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remounting on `generation` clears any state the controls hold of their
|
{moved && afterMove ? <div>{afterMove}</div> : null}
|
||||||
own — a half-typed word survives a reset otherwise, and the board and
|
|
||||||
the keyboard then disagree about what is on the current row. */}
|
|
||||||
<Controls key={generation} state={state} onChange={setState} seed={seed} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Check, Copy, ExternalLink } from 'lucide-react';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import type { Provenance, RunRef } from '@/lib/demo-kit/types';
|
import type { Provenance, RunRef } from '@/lib/demo-kit/types';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import * as st from '@/content/styles';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { External, Label } from './chrome';
|
||||||
import { formatDate } from './format';
|
import { formatDate } from './format';
|
||||||
|
|
||||||
export interface ProvenanceCardProps {
|
export interface ProvenanceCardProps {
|
||||||
@@ -21,7 +21,8 @@ type CopyState = 'idle' | 'copied' | 'failed';
|
|||||||
* The command is the load-bearing element. Everything else on this page is us
|
* The command is the load-bearing element. Everything else on this page is us
|
||||||
* telling you what happened; this is the line you paste into your own terminal
|
* telling you what happened; this is the line you paste into your own terminal
|
||||||
* to find out for yourself, which is why it is verbatim and copy-pasteable
|
* to find out for yourself, which is why it is verbatim and copy-pasteable
|
||||||
* rather than prettified into something that would not actually run.
|
* rather than prettified into something that would not actually run — and why
|
||||||
|
* it WRAPS on a phone: a copyable command hidden off-screen defeats its purpose.
|
||||||
*/
|
*/
|
||||||
export function ProvenanceCard({ provenance, run, className }: ProvenanceCardProps) {
|
export function ProvenanceCard({ provenance, run, className }: ProvenanceCardProps) {
|
||||||
const [copyState, setCopyState] = useState<CopyState>('idle');
|
const [copyState, setCopyState] = useState<CopyState>('idle');
|
||||||
@@ -38,34 +39,41 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows: { label: string; value: string; mono?: boolean }[] = [
|
const rows: { label: string; value: string; sans?: boolean }[] = [
|
||||||
{ label: 'Environment package', value: provenance.envPackage, mono: true },
|
{ label: 'Environment package', value: provenance.envPackage },
|
||||||
{ label: 'Taskset', value: provenance.tasksetId, mono: true },
|
{ label: 'Taskset', value: provenance.tasksetId },
|
||||||
{ label: 'verifiers version', value: provenance.verifiersVersion, mono: true },
|
{ label: 'verifiers version', value: provenance.verifiersVersion },
|
||||||
];
|
];
|
||||||
if (run) {
|
if (run) {
|
||||||
rows.push(
|
rows.push(
|
||||||
{ label: 'Model', value: run.model, mono: true },
|
{ label: 'Run', value: run.id },
|
||||||
|
{ label: 'Model', value: run.model },
|
||||||
{ label: 'Captured', value: formatDate(run.capturedAt) },
|
{ label: 'Captured', value: formatDate(run.capturedAt) },
|
||||||
{ label: 'Seed', value: String(run.seed), mono: true },
|
{ label: 'Seed', value: String(run.seed) },
|
||||||
);
|
);
|
||||||
if (run.intervention) rows.push({ label: 'Intervention', value: run.intervention });
|
if (run.intervention) rows.push({ label: 'Intervention', value: run.intervention, sans: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="Provenance" className={cn('card overflow-hidden', className)}>
|
<section
|
||||||
<header className="border-b border-border px-3 py-2">
|
aria-label="Provenance"
|
||||||
<h3 className="text-sm font-semibold">Provenance</h3>
|
className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}
|
||||||
|
>
|
||||||
|
<header className="border-b border-border px-4 py-2.5">
|
||||||
|
<Label as="h3">Provenance</Label>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl className="divide-y divide-border">
|
<dl className="divide-y divide-border">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<div key={row.label} className="flex items-baseline gap-3 px-3 py-2">
|
<div
|
||||||
<dt className="flex-1 text-sm text-muted">{row.label}</dt>
|
key={row.label}
|
||||||
|
className="flex flex-col gap-0.5 px-4 py-2 sm:flex-row sm:items-baseline sm:gap-3"
|
||||||
|
>
|
||||||
|
<dt className="text-ui text-muted sm:flex-1">{row.label}</dt>
|
||||||
<dd
|
<dd
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 break-all text-right text-sm',
|
'min-w-0 break-all sm:text-right',
|
||||||
row.mono && 'nums font-mono text-xs',
|
row.sans ? 'text-ui text-fg' : 'font-mono text-mono-data text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.value}
|
{row.value}
|
||||||
@@ -74,12 +82,10 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<Label as="h4">Run it yourself</Label>
|
||||||
Run it yourself
|
<Button variant="outline" size="touch" onClick={copy} className="lg:min-h-9">
|
||||||
</h4>
|
|
||||||
<Button variant="outline" size="sm" className="tap" onClick={copy}>
|
|
||||||
{copyState === 'copied' ? (
|
{copyState === 'copied' ? (
|
||||||
<Check className="text-positive" aria-hidden="true" />
|
<Check className="text-positive" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
@@ -88,10 +94,10 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
|||||||
{copyState === 'copied' ? 'Copied' : 'Copy'}
|
{copyState === 'copied' ? 'Copied' : 'Copy'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<pre className={cn(st.codeBlock, 'mt-2')}>
|
<pre className="mt-2 whitespace-pre-wrap break-all rounded-md border border-border bg-surface-3 p-3 font-mono text-code text-fg [font-feature-settings:'liga'_0] sm:whitespace-pre sm:break-normal sm:overflow-x-auto">
|
||||||
<code>{provenance.command}</code>
|
<code>{provenance.command}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<p role="status" className="mt-1 text-xs text-muted">
|
<p role="status" className="mt-1 text-small text-muted">
|
||||||
{copyState === 'copied'
|
{copyState === 'copied'
|
||||||
? 'Command copied to your clipboard.'
|
? 'Command copied to your clipboard.'
|
||||||
: copyState === 'failed'
|
: copyState === 'failed'
|
||||||
@@ -101,20 +107,18 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{provenance.credits.length > 0 ? (
|
{provenance.credits.length > 0 ? (
|
||||||
<div className="border-t border-border px-3 py-2.5">
|
<div className="border-t border-border px-4 py-3">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
<Label as="h4">Credits</Label>
|
||||||
Built on
|
<ul className="mt-1.5 flex flex-col gap-1">
|
||||||
</h4>
|
|
||||||
<ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1">
|
|
||||||
{provenance.credits.map((credit) => (
|
{provenance.credits.map((credit) => (
|
||||||
<li key={credit.href}>
|
<li key={credit.href}>
|
||||||
<a
|
<a
|
||||||
href={credit.href}
|
href={credit.href}
|
||||||
className={cn(st.link, 'inline-flex items-center gap-1 text-sm')}
|
className="inline-flex min-h-11 items-center font-mono text-mono-data text-accent-fg underline decoration-border-strong underline-offset-4 hover:decoration-accent-fg sm:min-h-0"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{credit.label}
|
{credit.label}
|
||||||
<ExternalLink className="size-3" aria-hidden="true" />
|
<External />
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Brain, ChevronUp } from 'lucide-react';
|
import { ChevronUp } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Drawer,
|
Drawer,
|
||||||
DrawerBody,
|
DrawerBody,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
DrawerTrigger,
|
DrawerTrigger,
|
||||||
} from '@/components/ui/drawer';
|
} from '@/components/ui/drawer';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ThinkingOffChip } from './chrome';
|
||||||
import { ReasoningPanel } from './ReasoningPanel';
|
import { ReasoningPanel } from './ReasoningPanel';
|
||||||
import type { ReasoningPanelProps } from './ReasoningPanel';
|
import type { ReasoningPanelProps } from './ReasoningPanel';
|
||||||
|
|
||||||
@@ -53,30 +54,30 @@ export function ReasoningDrawer({
|
|||||||
>
|
>
|
||||||
<DrawerTrigger
|
<DrawerTrigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'tap flex w-full items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2',
|
'tap flex w-full items-center gap-2 rounded-md border border-border-strong bg-surface px-3 py-2 text-ui font-medium text-fg transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||||
triggerClassName,
|
triggerClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Brain className="size-4 text-muted" aria-hidden="true" />
|
|
||||||
<span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span>
|
<span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span>
|
||||||
<ChevronUp className="ml-auto size-4 text-muted" aria-hidden="true" />
|
{hasReasoning ? null : <ThinkingOffChip />}
|
||||||
|
<ChevronUp className="ml-auto size-4 text-border-strong" aria-hidden="true" />
|
||||||
</DrawerTrigger>
|
</DrawerTrigger>
|
||||||
{/*
|
{/*
|
||||||
`h-full max-h-[97%]` overrides the wrapper's `h-auto max-h-[88svh]`.
|
`h-full max-h-[97%]` overrides the wrapper's `h-auto max-h-[88svh]`.
|
||||||
Snap points size the sheet by translating a FIXED-height panel; on an
|
Snap points size the sheet by translating a FIXED-height panel; on an
|
||||||
auto-height one the 0.4 snap and the 0.9 snap look identical.
|
auto-height one the 0.4 snap and the 0.9 snap look identical.
|
||||||
*/}
|
*/}
|
||||||
<DrawerContent className="mx-auto h-full max-h-[97%] max-w-canvas">
|
<DrawerContent className="mx-auto h-full max-h-[97%] max-w-canvas shadow-overlay">
|
||||||
<DrawerHeader className="pb-2">
|
<DrawerHeader className="pb-2">
|
||||||
<DrawerTitle className="text-sm">{panel.title ?? 'Reasoning'}</DrawerTitle>
|
<DrawerTitle className="text-ui">{panel.title ?? 'Reasoning'}</DrawerTitle>
|
||||||
<DrawerDescription className="text-xs">
|
<DrawerDescription className="text-small">
|
||||||
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||||
</DrawerDescription>
|
</DrawerDescription>
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
<DrawerBody className="overflow-hidden">
|
<DrawerBody className="overflow-hidden">
|
||||||
{/* The panel keeps its own reserved height inside the sheet so the
|
{/* The panel keeps its own reserved height inside the sheet so the
|
||||||
sheet does not resize as the text streams under the drag. */}
|
sheet does not resize as the text streams under the drag. */}
|
||||||
<ReasoningPanel {...panel} className="h-full border-0" reservedLines={14} />
|
<ReasoningPanel {...panel} className="h-full" reservedLines={14} />
|
||||||
</DrawerBody>
|
</DrawerBody>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
// has to pin the viewport to the bottom as characters land, and that needs a
|
// has to pin the viewport to the bottom as characters land, and that needs a
|
||||||
// ref on the viewport element, which the wrapper does not expose.
|
// ref on the viewport element, which the wrapper does not expose.
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import { Brain } from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { usePrefersReducedMotion } from './format';
|
import { ThinkingOffChip } from './chrome';
|
||||||
|
import { formatMs, usePrefersReducedMotion } from './format';
|
||||||
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,7 +28,7 @@ export interface ReasoningPanelProps {
|
|||||||
/**
|
/**
|
||||||
* Lines of height held open whether or not there is text. Reserving the box
|
* Lines of height held open whether or not there is text. Reserving the box
|
||||||
* is not a nicety: this panel sits beside the board, and letting it grow as
|
* is not a nicety: this panel sits beside the board, and letting it grow as
|
||||||
* the text arrives shoves the board down the page mid-run.
|
* the text arrives shoves the board down the page mid-run. Capped at 24rem.
|
||||||
*/
|
*/
|
||||||
reservedLines?: number;
|
reservedLines?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -40,13 +40,20 @@ function charsPerSecond(length: number, durationMs: number | null): number {
|
|||||||
return Math.min(Math.max((length / durationMs) * 1000, MIN_CPS), MAX_CPS);
|
return Math.min(Math.max((length / durationMs) * 1000, MIN_CPS), MAX_CPS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model's reasoning, streamed at the pace it was recorded.
|
||||||
|
*
|
||||||
|
* The text is SANS at the prose size. Reasoning is sentences, and a wall of
|
||||||
|
* 13px mono reads as a log to skim rather than a thought to follow — which is
|
||||||
|
* backwards on the one panel whose job is to show a model thinking.
|
||||||
|
*/
|
||||||
export function ReasoningPanel({
|
export function ReasoningPanel({
|
||||||
reasoning,
|
reasoning,
|
||||||
durationMs,
|
durationMs,
|
||||||
playing,
|
playing,
|
||||||
speed,
|
speed,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
reservedLines = 10,
|
reservedLines = 9,
|
||||||
title = 'Reasoning',
|
title = 'Reasoning',
|
||||||
className,
|
className,
|
||||||
}: ReasoningPanelProps) {
|
}: ReasoningPanelProps) {
|
||||||
@@ -87,17 +94,28 @@ export function ReasoningPanel({
|
|||||||
}, [shouldStream, full, durationMs, speed, stepIndex]);
|
}, [shouldStream, full, durationMs, speed, stepIndex]);
|
||||||
|
|
||||||
const streaming = visible < full.length;
|
const streaming = visible < full.length;
|
||||||
|
// Prose is 16px at 1.6 — 1.6rem per line — capped at the spec's 24rem.
|
||||||
|
const height = `${Math.min(reservedLines * 1.6 + 1.25, 24)}rem`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className={cn('card flex flex-col overflow-hidden', className)}
|
className={cn('flex flex-col overflow-hidden rounded-lg border border-border bg-surface-2', className)}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
<Brain className="h-4 w-4 text-muted" aria-hidden="true" />
|
<h3 className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
<h3 className="text-sm font-semibold">{title}</h3>
|
{title}
|
||||||
|
{durationMs !== null ? (
|
||||||
|
<>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
<span className="sr-only">, recorded over </span>
|
||||||
|
{formatMs(durationMs)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</h3>
|
||||||
|
{full.length === 0 ? <ThinkingOffChip className="ml-auto" /> : null}
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<span className="nums ml-auto text-xs text-muted">
|
<span className="ml-auto font-mono text-caption text-muted">
|
||||||
{visible}/{full.length}
|
{visible}/{full.length}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -108,7 +126,7 @@ export function ReasoningPanel({
|
|||||||
className="min-h-0 flex-1"
|
className="min-h-0 flex-1"
|
||||||
// Height, not min-height: the panel is the same size on every step,
|
// Height, not min-height: the panel is the same size on every step,
|
||||||
// including the ones with no reasoning at all.
|
// including the ones with no reasoning at all.
|
||||||
style={{ height: `${reservedLines * 1.45}rem` }}
|
style={{ height }}
|
||||||
>
|
>
|
||||||
<ScrollArea.Viewport
|
<ScrollArea.Viewport
|
||||||
ref={viewportRef}
|
ref={viewportRef}
|
||||||
@@ -118,18 +136,18 @@ export function ReasoningPanel({
|
|||||||
aria-live="off"
|
aria-live="off"
|
||||||
>
|
>
|
||||||
{full.length === 0 ? (
|
{full.length === 0 ? (
|
||||||
<p className="text-sm leading-relaxed text-muted">
|
<p className="max-w-measure text-prose text-muted">
|
||||||
This step has no recorded reasoning. The run was captured with thinking disabled, so
|
This step has no recorded reasoning. The run was captured with thinking disabled, so
|
||||||
there is nothing to show here — which is different from the model having thought
|
there is nothing to show here — which is different from the model having thought
|
||||||
nothing.
|
nothing.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="whitespace-pre-wrap font-mono text-[13px] leading-relaxed text-fg">
|
<p className="max-w-measure whitespace-pre-wrap text-prose text-fg-2">
|
||||||
{full.slice(0, visible)}
|
{full.slice(0, visible)}
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="ml-px inline-block h-[1em] w-[0.5ch] translate-y-[0.15em] animate-pulse bg-brand align-baseline"
|
className="ml-px inline-block h-[1em] w-[0.5ch] translate-y-[0.15em] animate-pulse bg-tab-underline align-baseline"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
@@ -139,7 +157,7 @@ export function ReasoningPanel({
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="flex w-2 touch-none select-none p-0.5"
|
className="flex w-2 touch-none select-none p-0.5"
|
||||||
>
|
>
|
||||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-border" />
|
<ScrollArea.Thumb className="flex-1 rounded-full bg-border-strong" />
|
||||||
</ScrollArea.Scrollbar>
|
</ScrollArea.Scrollbar>
|
||||||
</ScrollArea.Root>
|
</ScrollArea.Root>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
import { Scale, Target, Weight } from 'lucide-react';
|
|
||||||
import type { LucideIcon } from 'lucide-react';
|
|
||||||
import { decompose } from '@/lib/demo-kit/reward';
|
import { decompose } from '@/lib/demo-kit/reward';
|
||||||
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Term } from '@/components/site/Term';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Chip, EditedChip } from './chrome';
|
||||||
import { DASH, formatNumber, formatOrDash } from './format';
|
import { DASH, formatNumber, formatOrDash } from './format';
|
||||||
import { EditedChip } from './StatStrip';
|
|
||||||
|
|
||||||
const ROLE_META: Record<RewardComponent['role'], { label: string; Icon: LucideIcon }> = {
|
type Role = RewardComponent['role'];
|
||||||
objective: { label: 'Objective', Icon: Target },
|
|
||||||
counterweight: { label: 'Counterweight', Icon: Weight },
|
/**
|
||||||
gate: { label: 'Gate', Icon: Scale },
|
* The three roles a reward term can play. All three are glossary terms and
|
||||||
|
* render through `Term`, so the chip carries its gloss on hover, focus and
|
||||||
|
* tap. The counterweight is introduced in the sentence under the table as
|
||||||
|
* "the term that takes points away" before its label, because that phrase is
|
||||||
|
* the lesson and the label is only its name.
|
||||||
|
*/
|
||||||
|
const ROLE_META: Record<Role, { label: string; variant: 'outline' | 'warning' | 'muted' }> = {
|
||||||
|
objective: { label: 'Objective', variant: 'outline' },
|
||||||
|
counterweight: { label: 'Counterweight', variant: 'warning' },
|
||||||
|
gate: { label: 'Gate', variant: 'muted' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function RoleChip({ role }: { role: Role }) {
|
||||||
|
const meta = ROLE_META[role];
|
||||||
|
return (
|
||||||
|
<Term term={role} className="no-underline">
|
||||||
|
<Chip variant={meta.variant} className="cursor-help">
|
||||||
|
{meta.label}
|
||||||
|
</Chip>
|
||||||
|
</Term>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface RewardBreakdownProps {
|
export interface RewardBreakdownProps {
|
||||||
spec: RewardSpec;
|
spec: RewardSpec;
|
||||||
values: RewardValues;
|
values: RewardValues;
|
||||||
@@ -29,12 +47,17 @@ export interface RewardBreakdownProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `score x weight = value`, per component, plus the total.
|
* `score × weight = value`, per component, plus the total.
|
||||||
*
|
*
|
||||||
* The counterweight row is called out because it is the row that makes the
|
* The counterweight row is called out because it is the row that makes the
|
||||||
* reward an opinion rather than a scoreboard. Everyone understands "pay for
|
* reward an opinion rather than a scoreboard. Everyone understands "pay for
|
||||||
* solving it". The interesting engineering is the term that takes points away,
|
* solving it". The interesting engineering is the term that takes points away,
|
||||||
* and an exec who leaves this page understanding only that has got the point.
|
* and an exec who leaves this page understanding only that has got the point.
|
||||||
|
*
|
||||||
|
* A term the environment did not score prints `—` with a title, never 0.000:
|
||||||
|
* a zero is a measurement and a dash is the absence of one. Below `sm` the
|
||||||
|
* SCORE column folds under the label as `score 0.125` so four columns never
|
||||||
|
* force the table to scroll.
|
||||||
*/
|
*/
|
||||||
export function RewardBreakdown({
|
export function RewardBreakdown({
|
||||||
spec,
|
spec,
|
||||||
@@ -49,119 +72,136 @@ export function RewardBreakdown({
|
|||||||
const counterweights = rows.filter((row) => row.role === 'counterweight');
|
const counterweights = rows.filter((row) => row.role === 'counterweight');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('card overflow-hidden', className)}>
|
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse">
|
||||||
<caption className="sr-only">
|
<caption className="sr-only">
|
||||||
Reward components, their weights and their contribution to the total score
|
Reward components, their weights and their contribution to the total score
|
||||||
</caption>
|
</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
|
<tr className="bg-surface-3 text-left font-mono text-label uppercase tracking-label text-muted">
|
||||||
<th scope="col" className="px-3 py-2 font-medium">
|
<th scope="col" className="px-3 py-2 font-medium">
|
||||||
Component
|
Component
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
<th scope="col" className="hidden px-2 py-2 text-right font-medium sm:table-cell">
|
||||||
Score
|
Score
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||||
Weight
|
Weight
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="px-3 py-2 text-right font-medium">
|
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||||
Value
|
Value
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody className="divide-y divide-border">
|
||||||
{rows.map((row) => {
|
{rows.map((row) => {
|
||||||
const role = ROLE_META[row.role];
|
const isCounterweight = row.role === 'counterweight';
|
||||||
const isCounterweight = row.role === 'counterweight';
|
return (
|
||||||
return (
|
<tr key={row.key} className={cn(isCounterweight && 'bg-accent-subtle')}>
|
||||||
<tr key={row.key} className={cn(isCounterweight && 'bg-accent-subtle/40')}>
|
<th scope="row" className="max-w-[24rem] px-3 py-2.5 text-left font-normal align-top">
|
||||||
<th scope="row" className="max-w-[22rem] px-3 py-2.5 text-left font-normal">
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
<span className="flex flex-wrap items-center gap-1.5">
|
<span className="text-ui font-medium text-fg">{row.label}</span>
|
||||||
<span className="font-medium text-fg">{row.label}</span>
|
<RoleChip role={row.role} />
|
||||||
<Badge variant={isCounterweight ? 'default' : 'muted'}>
|
|
||||||
<role.Icon className="size-3" aria-hidden="true" />
|
|
||||||
{role.label}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block text-xs leading-snug text-muted">
|
|
||||||
{row.description}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block font-mono text-[11px] text-muted">{row.key}</span>
|
|
||||||
</th>
|
|
||||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
|
||||||
{row.score === null ? (
|
|
||||||
<span className="text-muted" title="The environment did not score this run">
|
|
||||||
not scored
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
<span className="mt-0.5 block text-small text-fg-2">{row.description}</span>
|
||||||
formatNumber(row.score)
|
<span className="mt-1 flex flex-wrap items-baseline gap-x-3 font-mono text-label text-muted">
|
||||||
)}
|
<span>{row.key}</span>
|
||||||
</td>
|
<span className="sm:hidden">
|
||||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
<span className="uppercase tracking-label">score</span>{' '}
|
||||||
<span className={cn(edited && 'font-semibold text-accent-fg')}>
|
<span className="text-mono-data text-fg">
|
||||||
{formatNumber(row.weight, 2)}
|
{row.score === null ? (
|
||||||
|
<span className="text-muted" title="not scored">
|
||||||
|
{DASH}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatNumber(row.score)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td className="hidden px-2 py-2.5 text-right align-top font-mono text-mono-data sm:table-cell">
|
||||||
|
{row.score === null ? (
|
||||||
|
<span className="text-muted" title="not scored">
|
||||||
|
{DASH}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatNumber(row.score)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5 text-right align-top font-mono text-mono-data">
|
||||||
|
<span className={cn(edited && 'font-semibold text-accent-fg')}>
|
||||||
|
{formatNumber(row.weight, 2)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-right align-top font-mono text-stat text-fg">
|
||||||
|
{row.value === null ? (
|
||||||
|
<span className="font-normal text-muted" title="not scored">
|
||||||
|
{DASH}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatNumber(row.value)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t-2 border-border-strong bg-surface-2">
|
||||||
|
<th scope="row" className="px-3 py-2.5 text-left" colSpan={2}>
|
||||||
|
<span className="flex items-center gap-2 text-ui font-medium text-fg">
|
||||||
|
Total reward
|
||||||
|
{edited ? <EditedChip /> : null}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td className="hidden sm:table-cell" />
|
||||||
|
<td className="px-3 py-2.5 text-right font-mono text-[15px] font-semibold text-accent-fg">
|
||||||
|
{total === null ? (
|
||||||
|
<span className="font-normal text-muted" title="not scored">
|
||||||
|
{DASH}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
) : (
|
||||||
<td className="nums px-3 py-2.5 text-right align-top font-mono font-semibold">
|
formatOrDash(total)
|
||||||
{row.value === null ? DASH : formatNumber(row.value)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
</tfoot>
|
||||||
})}
|
</table>
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t-2 border-border bg-surface-2">
|
|
||||||
<th scope="row" className="px-3 py-2.5 text-left">
|
|
||||||
<span className="flex items-center gap-2 font-semibold">
|
|
||||||
Total reward
|
|
||||||
{edited ? <EditedChip /> : null}
|
|
||||||
</span>
|
|
||||||
</th>
|
|
||||||
<td colSpan={2} />
|
|
||||||
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
|
||||||
{total === null ? (
|
|
||||||
<span className="text-muted">not scored</span>
|
|
||||||
) : (
|
|
||||||
formatOrDash(total)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{counterweights.length > 0 ? (
|
|
||||||
<p className="border-t border-border bg-accent-subtle/40 px-3 py-2.5 text-xs leading-relaxed text-fg">
|
|
||||||
<span className="font-semibold">
|
|
||||||
{counterweights.map((row) => row.label).join(' and ')}
|
|
||||||
</span>{' '}
|
|
||||||
{counterweights.length > 1 ? 'are counterweights' : 'is the counterweight'}: without a
|
|
||||||
term pulling the other way, the cheapest way to maximise the objective is a behaviour
|
|
||||||
you would never ship, and the model will find it.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{spec.metrics && spec.metrics.length > 0 ? (
|
|
||||||
<div className="border-t border-border px-3 py-2.5">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
|
||||||
Diagnostics — reported, never summed
|
|
||||||
</h4>
|
|
||||||
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
|
|
||||||
{spec.metrics.map((metric) => (
|
|
||||||
<div key={metric.key} className="flex items-baseline justify-between gap-2">
|
|
||||||
<dt className="text-xs text-muted" title={metric.description}>
|
|
||||||
{metric.label}
|
|
||||||
</dt>
|
|
||||||
<dd className="nums font-mono text-xs">
|
|
||||||
{formatOrDash(metrics?.[metric.key] ?? null)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
{counterweights.length > 0 ? (
|
||||||
|
<p className="max-w-measure border-t border-border px-3 py-3 text-prose text-fg-2">
|
||||||
|
<span className="font-medium text-fg">
|
||||||
|
{counterweights.map((row) => row.label).join(' and ')}
|
||||||
|
</span>{' '}
|
||||||
|
{counterweights.length > 1 ? 'are the terms that take points away' : 'is the term that takes points away'}
|
||||||
|
: without one pulling the other way, the cheapest way to maximise the objective is a
|
||||||
|
behaviour you would never ship, and the model will find it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{spec.metrics && spec.metrics.length > 0 ? (
|
||||||
|
<div className="border-t border-border px-3 py-2.5">
|
||||||
|
<h4 className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
Diagnostics — reported, never summed
|
||||||
|
</h4>
|
||||||
|
<dl className="mt-1.5 grid grid-cols-1 gap-x-6 gap-y-1 sm:grid-cols-2">
|
||||||
|
{spec.metrics.map((metric) => (
|
||||||
|
<div key={metric.key} className="flex items-baseline justify-between gap-2">
|
||||||
|
<dt className="text-small text-muted" title={metric.description}>
|
||||||
|
{metric.label}{' '}
|
||||||
|
<span className="font-mono text-label">{metric.key}</span>
|
||||||
|
</dt>
|
||||||
|
<dd className="font-mono text-mono-data text-fg">
|
||||||
|
{formatOrDash(metrics?.[metric.key] ?? null)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import type { RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { formatNumber, formatOrDash } from './format';
|
import { EditedChip, Label } from './chrome';
|
||||||
import { EditedChip } from './StatStrip';
|
import { DASH, formatNumber, formatOrDash } from './format';
|
||||||
|
|
||||||
/** One recorded arm — a run, or a group of runs already reduced to one score. */
|
/** One recorded arm — a run, or a group of runs already reduced to one score. */
|
||||||
export interface RewardArm {
|
export interface RewardArm {
|
||||||
@@ -43,6 +43,19 @@ export interface RewardEditorProps {
|
|||||||
|
|
||||||
const STEP = 0.05;
|
const STEP = 0.05;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows shown before the disclosure. The shell groups runs into one arm per
|
||||||
|
* agent, so this is four today; a demo that ever yields more than eight arms
|
||||||
|
* gets eight and a "show all", because a ranking you have to scroll is not a
|
||||||
|
* ranking you can watch flip.
|
||||||
|
*/
|
||||||
|
const RANK_CAP = 8;
|
||||||
|
|
||||||
|
/** Row pitch in px: a 52px row (label + note) on an 8px gap. */
|
||||||
|
const ROW_H = 52;
|
||||||
|
const ROW_GAP = 8;
|
||||||
|
const PITCH = ROW_H + ROW_GAP;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Presets built from the spec's own labels, for a demo that does not supply its
|
* Presets built from the spec's own labels, for a demo that does not supply its
|
||||||
* own. Each is phrased as a position someone would actually argue for in a
|
* own. Each is phrased as a position someone would actually argue for in a
|
||||||
@@ -104,7 +117,8 @@ function sameWeights(spec: RewardSpec, overrides: WeightOverrides, preset: Rewar
|
|||||||
* is NOT training. It shows the ranking a different reward would have produced
|
* is NOT training. It shows the ranking a different reward would have produced
|
||||||
* over these exact attempts; it cannot show the different attempts a model
|
* over these exact attempts; it cannot show the different attempts a model
|
||||||
* trained on that reward would have made. That distinction is the permanent
|
* trained on that reward would have made. That distinction is the permanent
|
||||||
* caption at the bottom, and it is not collapsible.
|
* caption at the bottom, and it is not collapsible. The EDITED chip is amber
|
||||||
|
* for the same reason: a re-scored ranking is a caution, not a brand moment.
|
||||||
*
|
*
|
||||||
* Weights are normalised to sum to 1 before scoring — `reweight` does it — so
|
* Weights are normalised to sum to 1 before scoring — `reweight` does it — so
|
||||||
* dragging one slider up trades weight away from the others instead of lifting
|
* dragging one slider up trades weight away from the others instead of lifting
|
||||||
@@ -119,6 +133,7 @@ export function RewardEditor({
|
|||||||
className,
|
className,
|
||||||
}: RewardEditorProps) {
|
}: RewardEditorProps) {
|
||||||
const [overrides, setOverrides] = useState<WeightOverrides>({});
|
const [overrides, setOverrides] = useState<WeightOverrides>({});
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
|
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
|
||||||
|
|
||||||
const edited = isEdited(overrides, spec.components);
|
const edited = isEdited(overrides, spec.components);
|
||||||
@@ -156,9 +171,13 @@ export function RewardEditor({
|
|||||||
const shippedOrder = [...rows]
|
const shippedOrder = [...rows]
|
||||||
.sort((a, b) => byTotal({ total: a.shippedTotal }, { total: b.shippedTotal }))
|
.sort((a, b) => byTotal({ total: a.shippedTotal }, { total: b.shippedTotal }))
|
||||||
.map((row) => row.arm.id);
|
.map((row) => row.arm.id);
|
||||||
return [...rows].sort(byTotal).map((row, index) => ({
|
const order = [...rows].sort(byTotal).map((row) => row.arm.id);
|
||||||
|
// Returned in ARM order, not rank order, and positioned by rank in the
|
||||||
|
// DOM: React keeps each row's element, so the change of rank is a
|
||||||
|
// transform the browser can animate rather than a remount it cannot.
|
||||||
|
return rows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
rank: index + 1,
|
rank: order.indexOf(row.arm.id) + 1,
|
||||||
shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
|
shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
|
||||||
}));
|
}));
|
||||||
}, [arms, inForce, shippedNormalised, degenerate]);
|
}, [arms, inForce, shippedNormalised, degenerate]);
|
||||||
@@ -171,13 +190,20 @@ export function RewardEditor({
|
|||||||
return { lo, hi: hi === lo ? lo + 1 : hi };
|
return { lo, hi: hi === lo ? lo + 1 : hi };
|
||||||
}, [ranked]);
|
}, [ranked]);
|
||||||
|
|
||||||
const leader = ranked[0];
|
const leader = ranked.find((row) => row.rank === 1);
|
||||||
|
const shownCount = showAll ? ranked.length : Math.min(ranked.length, RANK_CAP);
|
||||||
|
const shown = ranked.filter((row) => row.rank <= shownCount);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2', className)}>
|
<div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start', className)}>
|
||||||
<section aria-label="Reward weights" className="card p-3">
|
<section
|
||||||
|
aria-label="Reward weights"
|
||||||
|
className="rounded-xl border border-border bg-surface p-4 sm:p-5"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h3 className="text-sm font-semibold">Change what good means</h3>
|
<Label as="h3" className="text-fg">
|
||||||
|
Change what good means
|
||||||
|
</Label>
|
||||||
{edited ? <EditedChip /> : null}
|
{edited ? <EditedChip /> : null}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -191,18 +217,21 @@ export function RewardEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{effectivePresets.map((preset) => {
|
{effectivePresets.map((preset) => {
|
||||||
const active = sameWeights(spec, overrides, preset);
|
const active = sameWeights(spec, overrides, preset);
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
variant={active ? 'subtle' : 'outline'}
|
variant="outline"
|
||||||
size="touch"
|
size="touch"
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
title={preset.description ?? preset.label}
|
title={preset.description ?? preset.label}
|
||||||
onClick={() => apply({ ...preset.weights })}
|
onClick={() => apply({ ...preset.weights })}
|
||||||
className="text-xs"
|
className={cn(
|
||||||
|
'text-small',
|
||||||
|
active && 'border-accent-fg bg-accent-subtle text-accent-fg hover:bg-accent-subtle',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{preset.label}
|
{preset.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -221,22 +250,20 @@ export function RewardEditor({
|
|||||||
{/* A <label htmlFor> would point at Radix's root <span>, which
|
{/* A <label htmlFor> would point at Radix's root <span>, which
|
||||||
is not a labelable element — the association would silently
|
is not a labelable element — the association would silently
|
||||||
do nothing. The thumb takes its name from this text. */}
|
do nothing. The thumb takes its name from this text. */}
|
||||||
<span id={`weight-label-${component.key}`} className="text-sm font-medium text-fg">
|
<span id={`weight-label-${component.key}`} className="text-ui font-medium text-fg">
|
||||||
{component.label}
|
{component.label}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="font-mono text-mono-data">
|
||||||
className={cn(
|
<span className={cn(changed ? 'font-semibold text-accent-fg' : 'text-fg')}>
|
||||||
'nums font-mono text-sm',
|
{formatNumber(raw, 2)}
|
||||||
changed ? 'font-semibold text-accent-fg' : 'text-muted',
|
</span>
|
||||||
)}
|
<span className="ml-1.5 text-caption text-muted" title="Share of the normalised reward">
|
||||||
>
|
{degenerate ? DASH : `${Math.round(share * 100)}%`}
|
||||||
{formatNumber(raw, 2)}
|
|
||||||
<span className="ml-1.5 text-xs font-normal text-muted">
|
|
||||||
{degenerate ? '—' : `${Math.round(share * 100)}%`}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-1 text-xs leading-snug text-muted">{component.description}</p>
|
<p className="text-small text-fg-2">{component.description}</p>
|
||||||
|
{/* The primitive's `py-3` around its 20px thumb is the 44px band. */}
|
||||||
<Slider
|
<Slider
|
||||||
min={0}
|
min={0}
|
||||||
max={max}
|
max={max}
|
||||||
@@ -252,27 +279,36 @@ export function RewardEditor({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-3 text-xs leading-relaxed text-muted">
|
<p className="mt-2 max-w-measure text-small text-muted">
|
||||||
The percentage is the share of the reward each term carries once the weights are
|
The percentage is the share of the reward each term carries once the weights are
|
||||||
normalised. Raising one lowers the others — that is the trade a reward designer
|
normalised. Raising one lowers the others — that is the trade a reward designer
|
||||||
actually makes.
|
actually makes.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Ranking under this reward" className="card flex flex-col p-3">
|
<section
|
||||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
aria-label="Ranking under this reward"
|
||||||
Ranking under this reward
|
className="flex flex-col rounded-xl border border-border bg-surface p-4 sm:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label as="h3" className="text-fg">
|
||||||
|
Ranking under this reward
|
||||||
|
</Label>
|
||||||
{edited ? <EditedChip /> : null}
|
{edited ? <EditedChip /> : null}
|
||||||
</h3>
|
</div>
|
||||||
|
|
||||||
{degenerate ? (
|
{degenerate ? (
|
||||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
<p className="mt-3 max-w-measure text-prose text-fg-2">
|
||||||
Every weight is zero, so there is no reward left to rank by. That is not a score of
|
Every weight is zero, so there is no reward left to rank by. That is not a score of
|
||||||
nought — it is a reward that expresses no preference at all.
|
nought — it is a reward that expresses no preference at all.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="mt-3 space-y-2">
|
<ol
|
||||||
{ranked.map((row) => {
|
className="relative mt-3"
|
||||||
|
style={{ height: shown.length * PITCH - ROW_GAP }}
|
||||||
|
aria-label="Arms, best first"
|
||||||
|
>
|
||||||
|
{shown.map((row) => {
|
||||||
const moved = row.rank - row.shippedRank;
|
const moved = row.rank - row.shippedRank;
|
||||||
const width =
|
const width =
|
||||||
row.total === null
|
row.total === null
|
||||||
@@ -281,37 +317,66 @@ export function RewardEditor({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={row.arm.id}
|
key={row.arm.id}
|
||||||
|
// The transform is what animates over --dur-2. DOM order is
|
||||||
|
// arm order, so the rank is also spoken (`aria-label`) —
|
||||||
|
// a screen reader is not reading the transform.
|
||||||
|
aria-label={`Rank ${row.rank}: ${row.arm.label}, ${formatOrDash(row.total)}`}
|
||||||
|
style={{
|
||||||
|
transform: `translateY(${(row.rank - 1) * PITCH}px)`,
|
||||||
|
height: ROW_H,
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
'absolute inset-x-0 top-0 flex flex-col justify-center rounded-md border px-3 py-1.5 motion-safe:transition-transform motion-safe:duration-2 motion-safe:ease-enter',
|
||||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
row.rank === 1 ? 'border-accent-fg bg-accent-subtle' : 'border-border bg-surface',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="nums text-sm font-semibold text-muted">{row.rank}</span>
|
<span className="font-mono text-label text-muted">
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
{String(row.rank).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ui font-medium text-fg">
|
||||||
{row.arm.label}
|
{row.arm.label}
|
||||||
</span>
|
</span>
|
||||||
<RankMove moved={moved} />
|
<RankMove moved={moved} />
|
||||||
<span className="nums font-mono text-sm font-semibold">
|
<span className="font-mono text-stat text-fg">{formatOrDash(row.total)}</span>
|
||||||
{formatOrDash(row.total)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="mt-1 flex items-center gap-2">
|
||||||
aria-hidden="true"
|
|
||||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
aria-hidden="true"
|
||||||
style={{ width: `${width}%` }}
|
className="h-1.5 flex-1 overflow-hidden rounded-sm bg-surface-3"
|
||||||
/>
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-tab-underline motion-safe:transition-[width] motion-safe:duration-2 motion-safe:ease-enter"
|
||||||
|
style={{ width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{row.arm.note ? (
|
||||||
|
<span
|
||||||
|
className="max-w-[45%] truncate font-mono text-[10px] text-muted"
|
||||||
|
title={row.arm.note}
|
||||||
|
>
|
||||||
|
{row.arm.note}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{row.arm.note ? <p className="mt-1 text-xs text-muted">{row.arm.note}</p> : null}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ranked.length > RANK_CAP ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="touch"
|
||||||
|
className="mt-2 self-start text-muted hover:text-fg"
|
||||||
|
aria-expanded={showAll}
|
||||||
|
onClick={() => setShowAll((was) => !was)}
|
||||||
|
>
|
||||||
|
{showAll ? `Show the top ${RANK_CAP}` : `Show all ${ranked.length} arms`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Safe to announce here without fighting the shell's step-change
|
{/* Safe to announce here without fighting the shell's step-change
|
||||||
region: a slider drag and a step advance never fire from one action. */}
|
region: a slider drag and a step advance never fire from one action. */}
|
||||||
<p role="status" className="sr-only">
|
<p role="status" className="sr-only">
|
||||||
@@ -320,7 +385,7 @@ export function RewardEditor({
|
|||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="mt-auto pt-3 text-xs leading-relaxed text-muted">
|
<p className="mt-auto max-w-measure pt-3 text-small text-muted">
|
||||||
We re-scored the same recorded attempts under your reward. Training on it would change
|
We re-scored the same recorded attempts under your reward. Training on it would change
|
||||||
the behaviour, not just the ranking.
|
the behaviour, not just the ranking.
|
||||||
</p>
|
</p>
|
||||||
@@ -329,6 +394,7 @@ export function RewardEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ↑ / ↓ / — against the shipped ranking: a glyph AND a colour, never colour alone. */
|
||||||
function RankMove({ moved }: { moved: number }) {
|
function RankMove({ moved }: { moved: number }) {
|
||||||
if (moved === 0) {
|
if (moved === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -342,7 +408,12 @@ function RankMove({ moved }: { moved: number }) {
|
|||||||
const places = Math.abs(moved);
|
const places = Math.abs(moved);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn('nums inline-flex items-center text-xs', up ? 'text-positive' : 'text-danger')}
|
className={cn(
|
||||||
|
'inline-flex items-center font-mono text-caption',
|
||||||
|
// ↓ in danger only ever lands on the plain surface row: rank 1 (the
|
||||||
|
// accent-subtle row) cannot have moved down.
|
||||||
|
up ? 'text-positive' : 'text-danger',
|
||||||
|
)}
|
||||||
title={`${places} place${places === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`}
|
title={`${places} place${places === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`}
|
||||||
>
|
>
|
||||||
{up ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
|
{up ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export interface SegmentedControlProps<T extends string> {
|
|||||||
* control rather than through every option, and the arrow keys move within it.
|
* control rather than through every option, and the arrow keys move within it.
|
||||||
* Getting that wrong is the most common a11y bug in a segmented control, which
|
* Getting that wrong is the most common a11y bug in a segmented control, which
|
||||||
* is why there is one implementation here instead of three inline copies.
|
* is why there is one implementation here instead of three inline copies.
|
||||||
|
*
|
||||||
|
* Shape (§9): a 6px rail on surface-2 with a 1px border; options 6px, 44px on
|
||||||
|
* touch and 36px from lg, the active one surface-3 + fg. No shadow — the
|
||||||
|
* ground step is the state.
|
||||||
*/
|
*/
|
||||||
export function SegmentedControl<T extends string>({
|
export function SegmentedControl<T extends string>({
|
||||||
label,
|
label,
|
||||||
@@ -78,7 +82,10 @@ export function SegmentedControl<T extends string>({
|
|||||||
role="radiogroup"
|
role="radiogroup"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
className={cn('flex flex-wrap items-center gap-0.5 rounded-lg bg-surface-2 p-0.5', className)}
|
className={cn(
|
||||||
|
'inline-flex flex-wrap items-center gap-0.5 rounded-md border border-border bg-surface-2 p-0.5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{options.map((option, position) => {
|
{options.map((option, position) => {
|
||||||
const selected = option.value === value;
|
const selected = option.value === value;
|
||||||
@@ -95,8 +102,11 @@ export function SegmentedControl<T extends string>({
|
|||||||
}}
|
}}
|
||||||
onClick={() => onChange(option.value)}
|
onClick={() => onChange(option.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
|
// `min-w-11` as well as `min-h-11`: a two-glyph option ("#1",
|
||||||
selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
|
// "1×") is 34px wide on padding alone, which is a thumb-sized
|
||||||
|
// row with a fingertip-sized hit area. Measured at 390px.
|
||||||
|
'min-h-11 min-w-11 rounded-md px-3 text-ui font-medium transition-colors duration-1 ease-enter lg:min-h-9 lg:min-w-9',
|
||||||
|
selected ? 'bg-surface-3 text-fg' : 'text-muted hover:text-fg',
|
||||||
optionClassName,
|
optionClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
||||||
|
|
||||||
export interface Stat {
|
export interface Stat {
|
||||||
/** Short. Two or three words; it sits beside the number. */
|
/** Short. One or two words; it sits beside the number as a mono key. */
|
||||||
label: string;
|
label: string;
|
||||||
value: ReactNode;
|
value: ReactNode;
|
||||||
tone?: StatTone;
|
tone?: StatTone;
|
||||||
/** One clarifying line. A tooltip here, because the strip is one line high. */
|
/** The unit, as a sentence. A tooltip, because the strip is one line high. */
|
||||||
title?: string;
|
title?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ const TONE: Record<StatTone, string> = {
|
|||||||
default: 'text-fg',
|
default: 'text-fg',
|
||||||
positive: 'text-positive',
|
positive: 'text-positive',
|
||||||
warning: 'text-warning',
|
warning: 'text-warning',
|
||||||
|
// `danger` on surface-2 is 4.70 in dark — the last ground it is allowed on.
|
||||||
danger: 'text-danger',
|
danger: 'text-danger',
|
||||||
info: 'text-info',
|
info: 'text-info',
|
||||||
brand: 'text-accent-fg',
|
brand: 'text-accent-fg',
|
||||||
@@ -25,23 +25,17 @@ const TONE: Record<StatTone, string> = {
|
|||||||
/**
|
/**
|
||||||
* The run's headline numbers, one line high.
|
* The run's headline numbers, one line high.
|
||||||
*
|
*
|
||||||
* This used to be a row of cards, which is the right thing in the middle of a
|
* One surface-2 chip, keys mono 11 and values mono 13/600. It scrolls itself
|
||||||
* page and the wrong thing in a header: four stat cards push the interactive
|
* rather than wrapping: every row this header spends is a row of the board
|
||||||
* board below the fold on a 390px phone, and the board arriving above the fold
|
* pushed below the fold on a 390px phone, and the strip is one of the five
|
||||||
* is what the whole page is now organised around. So the cards are gone and
|
* horizontal scrollers §11 permits.
|
||||||
* this is the only stat strip — a second, compact variant living beside the
|
|
||||||
* card version is how the two drift into disagreeing about what a stat looks
|
|
||||||
* like.
|
|
||||||
*
|
|
||||||
* It scrolls itself rather than wrapping: every row this header spends is a row
|
|
||||||
* of the board pushed down.
|
|
||||||
*/
|
*/
|
||||||
export function StatStrip({ stats, className }: { stats: Stat[]; className?: string }) {
|
export function StatStrip({ stats, className }: { stats: Stat[]; className?: string }) {
|
||||||
if (stats.length === 0) return null;
|
if (stats.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<dl
|
<dl
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shrink-0 items-baseline gap-x-4 rounded-lg border border-border bg-surface-2 px-3 py-2',
|
'flex max-w-full shrink-0 items-baseline gap-x-4 overflow-x-auto whitespace-nowrap rounded-md border border-border bg-surface-2 px-3 py-2 [scrollbar-width:thin]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -51,25 +45,14 @@ export function StatStrip({ stats, className }: { stats: Stat[]; className?: str
|
|||||||
className="flex shrink-0 items-baseline gap-1.5"
|
className="flex shrink-0 items-baseline gap-1.5"
|
||||||
{...(stat.title ? { title: stat.title } : {})}
|
{...(stat.title ? { title: stat.title } : {})}
|
||||||
>
|
>
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted">{stat.label}</dt>
|
<dt className="font-mono text-label uppercase tracking-label text-muted">{stat.label}</dt>
|
||||||
<dd className={cn('nums text-sm font-semibold', TONE[stat.tone ?? 'default'])}>
|
<dd className={cn('font-mono text-stat', TONE[stat.tone ?? 'default'])}>{stat.value}</dd>
|
||||||
{stat.value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Kept as a re-export so nothing that imported the chip from here breaks; the
|
||||||
* Marks a number the visitor caused rather than one we recorded. It appears on
|
// chip itself lives with the other chips in chrome.tsx.
|
||||||
* every derived value in the reward editor; without it, an edited ranking
|
export { EditedChip } from './chrome';
|
||||||
* screenshots identically to a measured one.
|
|
||||||
*/
|
|
||||||
export function EditedChip({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<Badge className={cn('px-1.5 py-0 text-[10px] uppercase tracking-wide', className)}>
|
|
||||||
edited
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,7 +97,9 @@ export function StepTimeline<T>({
|
|||||||
aria-label="Steps in the recorded run"
|
aria-label="Steps in the recorded run"
|
||||||
aria-keyshortcuts="ArrowLeft ArrowRight Home End Space"
|
aria-keyshortcuts="ArrowLeft ArrowRight Home End Space"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={cn('flex snap-x gap-2 overflow-x-auto pb-2', className)}
|
// One of the five horizontal scrollers §11 permits. The visible rule
|
||||||
|
// underneath says "this strip ends here" where a scrollbar may not show.
|
||||||
|
className={cn('flex snap-x gap-2 overflow-x-auto border-b border-border pb-3', className)}
|
||||||
>
|
>
|
||||||
{steps.map((step, index) => {
|
{steps.map((step, index) => {
|
||||||
const selected = index === current;
|
const selected = index === current;
|
||||||
@@ -118,17 +120,22 @@ export function StepTimeline<T>({
|
|||||||
}}
|
}}
|
||||||
onClick={() => onSelect(index)}
|
onClick={() => onSelect(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'tap w-[9.5rem] shrink-0 snap-center rounded-lg border p-2 text-left transition-colors duration-2 ease-enter',
|
'tap w-[8rem] shrink-0 snap-center rounded-lg border p-2 text-left transition-colors duration-2 ease-enter sm:w-[9rem]',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||||
|
// `accent-edge` is the green that reads as a border in both
|
||||||
|
// themes; the raw accent is 1.47:1 against white.
|
||||||
selected
|
selected
|
||||||
? 'border-brand bg-accent-subtle/60'
|
? 'border-accent-edge bg-accent-subtle'
|
||||||
: 'border-border bg-surface hover:bg-surface-2',
|
: 'border-border bg-surface hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline justify-between gap-1">
|
<span className="flex items-baseline justify-between gap-1">
|
||||||
<span className="nums text-xs font-semibold">Step {index + 1}</span>
|
<span className="font-mono text-caption font-semibold uppercase tracking-label text-fg">
|
||||||
|
Step {index + 1}
|
||||||
|
</span>
|
||||||
{call?.finishReason ? (
|
{call?.finishReason ? (
|
||||||
<span
|
<span
|
||||||
className="max-w-[4.5rem] truncate text-[10px] uppercase tracking-wide text-muted"
|
className="max-w-[4.5rem] truncate font-mono text-[10px] uppercase tracking-label text-muted"
|
||||||
title={call.finishReason}
|
title={call.finishReason}
|
||||||
>
|
>
|
||||||
{humaniseToken(call.finishReason)}
|
{humaniseToken(call.finishReason)}
|
||||||
@@ -143,12 +150,14 @@ export function StepTimeline<T>({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step.caption ? (
|
{step.caption ? (
|
||||||
<span className="mt-1.5 block truncate text-xs text-muted" title={step.caption}>
|
// fg-2, not muted: on the selected chip's accent-subtle ground
|
||||||
|
// muted is 4.93 in dark, and a caption is a sentence.
|
||||||
|
<span className="mt-1.5 block truncate text-caption text-fg-2" title={step.caption}>
|
||||||
{step.caption}
|
{step.caption}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<span className="nums mt-1 flex flex-wrap gap-x-2 text-[10px] text-muted">
|
<span className="mt-1 flex flex-wrap gap-x-2 font-mono text-[10px] text-muted">
|
||||||
<span title="Completion tokens">
|
<span title="Completion tokens">
|
||||||
{call?.completionTokens === null || call?.completionTokens === undefined
|
{call?.completionTokens === null || call?.completionTokens === undefined
|
||||||
? DASH
|
? DASH
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Pause, Play, RotateCcw } from 'lucide-react';
|
||||||
import { PLAYBACK_SPEEDS } from '@/lib/demo-kit/player';
|
import { PLAYBACK_SPEEDS } from '@/lib/demo-kit/player';
|
||||||
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Chip, InterventionChip } from './chrome';
|
||||||
import { formatDate } from './format';
|
import { formatDate } from './format';
|
||||||
import { SegmentedControl } from './SegmentedControl';
|
import { SegmentedControl } from './SegmentedControl';
|
||||||
|
|
||||||
/** `PlaybackSpeed` is `1 | 2 | 4 | 'instant'`; the control speaks strings. */
|
/** `PlaybackSpeed` is `1 | 2 | 4 | 'instant'`; the control speaks strings. */
|
||||||
const SPEED_OPTIONS = PLAYBACK_SPEEDS.map((speed) => ({
|
const SPEED_OPTIONS = PLAYBACK_SPEEDS.map((speed) => ({
|
||||||
value: String(speed),
|
value: String(speed),
|
||||||
label: speed === 'instant' ? 'Instant' : `${speed}x`,
|
label: speed === 'instant' ? 'INSTANT' : `${speed}×`,
|
||||||
title: speed === 'instant' ? 'Jump straight to the end of the run' : `${speed} times real pace`,
|
title: speed === 'instant' ? 'Jump straight to the end of the run' : `${speed} times real pace`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,6 +51,9 @@ export interface TracePlayerProps {
|
|||||||
* believes they are watching a model think in real time has been misled by the
|
* believes they are watching a model think in real time has been misled by the
|
||||||
* UI rather than the copy. Hence the permanent badge — not a disclosure tucked
|
* UI rather than the copy. Hence the permanent badge — not a disclosure tucked
|
||||||
* into a footnote, but a fixture of the transport bar for the whole session.
|
* into a footnote, but a fixture of the transport bar for the whole session.
|
||||||
|
*
|
||||||
|
* ▶ is the only green fill on the Watch tab. Everything else in the bar is a
|
||||||
|
* ghost, so the one thing that starts the run is the one thing that is lit.
|
||||||
*/
|
*/
|
||||||
export function TracePlayer({
|
export function TracePlayer({
|
||||||
playing,
|
playing,
|
||||||
@@ -70,7 +74,7 @@ export function TracePlayer({
|
|||||||
const canPlay = stepCount > 1;
|
const canPlay = stepCount > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('card overflow-hidden', className)}>
|
<div className={cn('overflow-hidden rounded-xl border border-border bg-surface', className)}>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -83,7 +87,7 @@ export function TracePlayer({
|
|||||||
<ChevronLeft aria-hidden="true" />
|
<ChevronLeft aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="touch"
|
size="icon-touch"
|
||||||
onClick={() => onPlayingChange(!playing)}
|
onClick={() => onPlayingChange(!playing)}
|
||||||
disabled={!canPlay}
|
disabled={!canPlay}
|
||||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||||
@@ -110,8 +114,11 @@ export function TracePlayer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="nums text-sm text-muted">
|
<p className="font-mono text-mono-data text-muted">
|
||||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
<span className="text-label uppercase tracking-label">Step</span>{' '}
|
||||||
|
<span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span>
|
||||||
|
<span aria-hidden="true"> / </span>
|
||||||
|
<span className="sr-only">of </span>
|
||||||
{stepCount}
|
{stepCount}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -120,6 +127,7 @@ export function TracePlayer({
|
|||||||
options={SPEED_OPTIONS}
|
options={SPEED_OPTIONS}
|
||||||
value={String(speed)}
|
value={String(speed)}
|
||||||
onChange={(next) => onSpeedChange(toSpeed(next))}
|
onChange={(next) => onSpeedChange(toSpeed(next))}
|
||||||
|
optionClassName="px-2.5 font-mono text-caption tracking-label"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RecordedBadge
|
<RecordedBadge
|
||||||
@@ -127,6 +135,7 @@ export function TracePlayer({
|
|||||||
capturedAt={capturedAt}
|
capturedAt={capturedAt}
|
||||||
{...(intervention ? { intervention } : {})}
|
{...(intervention ? { intervention } : {})}
|
||||||
{...(timingIsReal ? {} : { timingNote: 'pacing approximate' })}
|
{...(timingIsReal ? {} : { timingNote: 'pacing approximate' })}
|
||||||
|
className="ml-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,10 +144,12 @@ export function TracePlayer({
|
|||||||
exists so the pause between steps reads as time passing rather than as
|
exists so the pause between steps reads as time passing rather than as
|
||||||
the page having stopped. `transition-[width]` covers the ~15 Hz at which
|
the page having stopped. `transition-[width]` covers the ~15 Hz at which
|
||||||
the player pushes progress — without it the bar visibly ratchets.
|
the player pushes progress — without it the bar visibly ratchets.
|
||||||
|
`tab-underline` is the token that is accent in dark and accent-fg in
|
||||||
|
light; a state indicator on a surface-3 track needs exactly that split.
|
||||||
*/}
|
*/}
|
||||||
<div className="h-0.5 w-full bg-surface-2" aria-hidden="true">
|
<div className="h-0.5 w-full bg-surface-3" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-brand transition-[width] duration-1 ease-enter"
|
className="h-full bg-tab-underline transition-[width] duration-1 ease-enter"
|
||||||
style={{
|
style={{
|
||||||
width: `${
|
width: `${
|
||||||
stepCount <= 1 ? 0 : ((step + Math.min(Math.max(progress, 0), 1)) / (stepCount - 1)) * 100
|
stepCount <= 1 ? 0 : ((step + Math.min(Math.max(progress, 0), 1)) / (stepCount - 1)) * 100
|
||||||
@@ -159,6 +170,12 @@ export interface RecordedBadgeProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `● RECORDED RUN · {model} · {date}`. The model string is printed verbatim
|
||||||
|
* from the manifest: the design adds no "model" word the data does not carry,
|
||||||
|
* because on some environments the recorded agent is a scripted reference
|
||||||
|
* policy and calling it a model would be the lie this badge exists to prevent.
|
||||||
|
*/
|
||||||
export function RecordedBadge({
|
export function RecordedBadge({
|
||||||
model,
|
model,
|
||||||
capturedAt,
|
capturedAt,
|
||||||
@@ -169,24 +186,18 @@ export function RecordedBadge({
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs text-muted',
|
'flex flex-wrap items-center gap-x-1.5 gap-y-1 font-mono text-caption text-muted',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Circle className="size-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
<span aria-hidden="true" className="size-2 shrink-0 rounded-full bg-border-strong" />
|
||||||
<span className="font-medium text-fg">Recorded run</span>
|
<span className="font-medium uppercase tracking-label text-fg">Recorded run</span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span className="nums font-mono">{model}</span>
|
<span className="break-all">{model}</span>
|
||||||
<span aria-hidden="true">·</span>
|
<span aria-hidden="true">·</span>
|
||||||
<span className="nums">{formatDate(capturedAt)}</span>
|
<span>{formatDate(capturedAt)}</span>
|
||||||
{intervention ? (
|
{intervention ? <InterventionChip>{intervention}</InterventionChip> : null}
|
||||||
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
|
{timingNote ? <Chip variant="muted">{timingNote}</Chip> : null}
|
||||||
{intervention}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{timingNote ? (
|
|
||||||
<span className="rounded-md border border-border px-1.5 py-0.5">{timingNote}</span>
|
|
||||||
) : null}
|
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, ChevronDown, HelpCircle, XCircle } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { VERIFY_TOLERANCE, verifyEpisode } from '@/lib/demo-kit/verify';
|
import { VERIFY_TOLERANCE, verifyEpisode } from '@/lib/demo-kit/verify';
|
||||||
import type { AnyDemoModule } from '@/lib/demo-kit/registry';
|
import type { AnyDemoModule } from '@/lib/demo-kit/registry';
|
||||||
import type { DemoEpisode } from '@/lib/demo-kit/types';
|
import type { DemoEpisode } from '@/lib/demo-kit/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Chip } from './chrome';
|
||||||
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
||||||
|
|
||||||
export interface VerifyBadgeProps {
|
export interface VerifyBadgeProps {
|
||||||
@@ -27,7 +28,7 @@ export interface VerifyBadgeProps {
|
|||||||
* it could not check anything is worse than no badge: it teaches the sceptic
|
* it could not check anything is worse than no badge: it teaches the sceptic
|
||||||
* that the whole thing is decoration. The three states come straight from
|
* that the whole thing is decoration. The three states come straight from
|
||||||
* `verifyEpisode`, and `unverifiable` is never dressed up as either of the
|
* `verifyEpisode`, and `unverifiable` is never dressed up as either of the
|
||||||
* other two.
|
* other two — it is a `?` on a plain ground, never a green tick.
|
||||||
*/
|
*/
|
||||||
export function VerifyBadge({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps) {
|
export function VerifyBadge({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
@@ -37,22 +38,23 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Verification"
|
aria-label="Verification"
|
||||||
className={cn('card bg-surface-2 p-3', className)}
|
className={cn('rounded-xl border border-border bg-surface-2 p-3 sm:p-4', className)}
|
||||||
>
|
>
|
||||||
<p className="flex items-start gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<HelpCircle className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden="true" />
|
<span
|
||||||
<span>
|
aria-hidden="true"
|
||||||
<span className="font-semibold">Unverifiable in your browser.</span>{' '}
|
className="grid size-5 shrink-0 place-items-center rounded-sm border border-border-strong font-mono text-caption text-muted"
|
||||||
<span className="text-muted">
|
>
|
||||||
{result.reason ?? 'This run cannot be re-computed here.'}
|
?
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<Chip variant="muted">Unverifiable in your browser</Chip>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 max-w-measure text-prose text-fg-2">
|
||||||
|
{result.reason ?? 'This run cannot be re-computed here.'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-caption text-muted">
|
||||||
|
recomputed {DASH} · recorded {formatOrDash(result.recorded)} · Δ {DASH}
|
||||||
</p>
|
</p>
|
||||||
{result.recorded !== null ? (
|
|
||||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
|
||||||
Recorded {formatOrDash(result.recorded)} · recomputed {DASH} · Δ {DASH}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -62,40 +64,37 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-label="Verification"
|
aria-label="Verification"
|
||||||
className={cn('card overflow-hidden', matched ? 'border-positive/40' : 'border-danger', className)}
|
className={cn(
|
||||||
|
'overflow-hidden rounded-xl border',
|
||||||
|
// Match: accent-subtle ground, accent-fg edge. Mismatch: danger-subtle
|
||||||
|
// ground, danger edge — danger text on danger-subtle is 4.72 dark /
|
||||||
|
// 5.66 light, the last ground the red is allowed on.
|
||||||
|
matched ? 'border-accent-fg bg-accent-subtle' : 'border-danger bg-danger-subtle',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('p-3', matched ? 'bg-positive/10' : 'bg-danger/10')}>
|
<div className="p-3 sm:p-4">
|
||||||
<p className="flex items-start gap-2 text-sm leading-relaxed">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{matched ? (
|
{matched ? (
|
||||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-positive" aria-hidden="true" />
|
<Chip variant="positive" className="border-accent-fg">
|
||||||
|
✓ Verified in your browser
|
||||||
|
</Chip>
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="mt-0.5 size-4 shrink-0 text-danger" aria-hidden="true" />
|
<Chip variant="danger" className="border-danger">
|
||||||
|
✗{' '}
|
||||||
|
{result.culprit
|
||||||
|
? `Mismatch on "${result.culprit.label}"`
|
||||||
|
: 'Mismatch against the recorded score'}
|
||||||
|
</Chip>
|
||||||
)}
|
)}
|
||||||
<span>
|
</div>
|
||||||
{matched ? (
|
<p className="mt-2 max-w-measure text-prose text-fg-2">
|
||||||
<>
|
{matched
|
||||||
<span className="font-semibold text-positive">Verified in your browser</span>{' '}
|
? 'Every move in this run was re-played through the TypeScript engine on this page and re-scored. The recorded number and the recomputed one agree.'
|
||||||
<span className="text-fg">
|
: 'The browser re-run disagrees with the published number. Trust the source in the repository, not this page.'}
|
||||||
— re-ran every move through the TypeScript engine and rescored.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="font-semibold text-danger">
|
|
||||||
{result.culprit
|
|
||||||
? `Mismatch on "${result.culprit.label}"`
|
|
||||||
: 'Mismatch against the recorded score'}
|
|
||||||
</span>{' '}
|
|
||||||
<span className="text-fg">
|
|
||||||
— the browser re-run disagrees with the published number. Trust the source in
|
|
||||||
the repository, not this page.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
<p className={cn('mt-2 font-mono text-caption', matched ? 'text-fg-2' : 'text-danger')}>
|
||||||
Recomputed {formatOrDash(result.recomputed)} · recorded {formatOrDash(result.recorded)} ·
|
recomputed {formatOrDash(result.recomputed)} · recorded {formatOrDash(result.recorded)} ·
|
||||||
Δ {result.delta === null ? DASH : formatDelta(result.delta)}
|
Δ {result.delta === null ? DASH : formatDelta(result.delta)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,21 +103,21 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen((was) => !was)}
|
onClick={() => setOpen((was) => !was)}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="tap flex w-full items-center gap-1.5 border-t border-border px-3 text-left text-xs font-medium text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
className="tap flex w-full items-center gap-1.5 border-t border-border bg-surface px-3 text-left text-small font-medium text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn('size-4 transition-transform duration-2 ease-enter', open && 'rotate-180')}
|
className={cn('size-4 text-border-strong transition-transform duration-2 ease-enter', open && 'rotate-180')}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{open ? 'Hide the component-by-component receipt' : 'Show the component-by-component receipt'}
|
{open ? 'Hide the component-by-component receipt' : 'Show the component-by-component receipt'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-border bg-surface">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="nums w-full border-collapse font-mono text-xs">
|
<table className="w-full border-collapse font-mono text-caption">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-muted">
|
<tr className="bg-surface-3 text-left text-label uppercase tracking-label text-muted">
|
||||||
<th scope="col" className="px-3 py-1.5 font-medium">
|
<th scope="col" className="px-3 py-1.5 font-medium">
|
||||||
Component
|
Component
|
||||||
</th>
|
</th>
|
||||||
@@ -137,14 +136,14 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
|||||||
{result.components.map((row) => {
|
{result.components.map((row) => {
|
||||||
const bad = row.delta !== null && Math.abs(row.delta) > VERIFY_TOLERANCE;
|
const bad = row.delta !== null && Math.abs(row.delta) > VERIFY_TOLERANCE;
|
||||||
return (
|
return (
|
||||||
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
<tr key={row.key} className={cn(bad && 'bg-danger-subtle')}>
|
||||||
<th scope="row" className="whitespace-nowrap px-3 py-1.5 text-left font-normal">
|
<th scope="row" className="whitespace-nowrap px-3 py-1.5 text-left font-normal text-fg">
|
||||||
{row.label}
|
{row.label}
|
||||||
</th>
|
</th>
|
||||||
<td className="px-2 py-1.5 text-right">
|
<td className="px-2 py-1.5 text-right text-fg">
|
||||||
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5 text-right">
|
<td className="px-2 py-1.5 text-right text-fg">
|
||||||
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
||||||
</td>
|
</td>
|
||||||
<td className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}>
|
<td className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}>
|
||||||
@@ -158,7 +157,7 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className="px-3 py-2 text-[11px] leading-relaxed text-muted">
|
<p className="max-w-measure-wide px-3 py-2 text-small text-muted">
|
||||||
Tolerance {VERIFY_TOLERANCE.toExponential()}. The recorded numbers came out of Python
|
Tolerance {VERIFY_TOLERANCE.toExponential()}. The recorded numbers came out of Python
|
||||||
and these came out of JavaScript; both are IEEE-754 doubles summing the same terms in
|
and these came out of JavaScript; both are IEEE-754 doubles summing the same terms in
|
||||||
a different order, so the comparison is made within a tolerance rather than demanding
|
a different order, so the comparison is made within a tolerance rather than demanding
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* The shell's small typographic parts, in one place.
|
||||||
|
*
|
||||||
|
* Spec §1 has two rules that every panel in this directory has to obey: data
|
||||||
|
* is mono, sentences are sans; and every "thing we are telling you about the
|
||||||
|
* data" (RECORDED RUN, EDITED, THINKING OFF, VERIFIED IN YOUR BROWSER…) is the
|
||||||
|
* same chip shape so a visitor learns after the second one what the shape
|
||||||
|
* means. Fourteen panels each writing `font-mono text-label uppercase` by hand
|
||||||
|
* is how those two rules drift, so they are written here once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/** Mono 11 / 500 / uppercase / +0.04em. The section-label style of §3. */
|
||||||
|
export const LABEL = 'font-mono text-label uppercase tracking-label text-muted';
|
||||||
|
|
||||||
|
/** Mono 13 / 400, tabular (body sets tabular-nums). Every compared number. */
|
||||||
|
export const MONO_DATA = 'font-mono text-mono-data';
|
||||||
|
|
||||||
|
/** A mono section label. `as` lets it be a heading when the panel needs one. */
|
||||||
|
export function Label({
|
||||||
|
as: Tag = 'p',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: {
|
||||||
|
as?: 'p' | 'h2' | 'h3' | 'h4' | 'span' | 'dt';
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
} & Omit<ComponentProps<'p'>, 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Tag className={cn(LABEL, className)} {...rest}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChipProps = ComponentProps<typeof Badge>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The honesty chip: the Badge under the name the spec uses for it (§9). The
|
||||||
|
* variant names are the spec's — `muted` for RECORDED RUN / PACING
|
||||||
|
* APPROXIMATE / THINKING OFF / GATE, `warning` for EDITED / COUNTERWEIGHT,
|
||||||
|
* `positive` for VERIFIED, `danger` for a mismatch, `default` for the
|
||||||
|
* intervention, `outline` for OBJECTIVE, `solid` for LIVE.
|
||||||
|
*/
|
||||||
|
export function Chip(props: ChipProps) {
|
||||||
|
return <Badge {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks a number the visitor caused rather than one we recorded. Amber, not
|
||||||
|
* green: an edited ranking is a caution, not a brand moment, and without this
|
||||||
|
* chip it screenshots identically to a measured one.
|
||||||
|
*/
|
||||||
|
export function EditedChip({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Chip variant="warning" className={className}>
|
||||||
|
edited
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intervention, on an intervened run. The contract requires the text and
|
||||||
|
* it is a SENTENCE ("Told the model the rules before the first guess"), so it
|
||||||
|
* keeps the chip's ground and edge but sets in sans, mixed case, and wraps —
|
||||||
|
* an 80-character sentence in uppercase mono is unreadable and at 390px is
|
||||||
|
* the one thing on the page that makes the body scroll sideways.
|
||||||
|
*/
|
||||||
|
export function InterventionChip({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
variant="default"
|
||||||
|
className={cn('max-w-full whitespace-normal text-left font-sans text-caption normal-case tracking-normal', className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `THINKING OFF`: the run was captured without reasoning. A chip, never a blank. */
|
||||||
|
export function ThinkingOffChip({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Chip variant="muted" className={className} title="This run was recorded with thinking disabled">
|
||||||
|
thinking off
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A trailing ↗ for an external link. Text, so it inherits the link's colour. */
|
||||||
|
export function External() {
|
||||||
|
return (
|
||||||
|
<span aria-hidden="true" className="ml-0.5">
|
||||||
|
↗
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status pill beside a title: LIVE (green fill) or SPEC (outline). */
|
||||||
|
export function StatusPill({ status, className }: { status: 'live' | 'spec'; className?: string }) {
|
||||||
|
return status === 'live' ? (
|
||||||
|
<Chip variant="solid" className={cn('rounded-full border-accent-edge', className)}>
|
||||||
|
live
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip variant="outline" className={cn('rounded-full', className)}>
|
||||||
|
spec
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,11 +31,11 @@ function writeStored(high: boolean): void {
|
|||||||
/**
|
/**
|
||||||
* One value shared by every mounted toggle, not one `useState` each.
|
* One value shared by every mounted toggle, not one `useState` each.
|
||||||
*
|
*
|
||||||
* The header renders this control twice — once in the desktop bar, once inside
|
* This control renders in three places — the desktop bar, the mobile sheet and
|
||||||
* the mobile sheet — and only one of them is ever visible. With local state the
|
* the Play tab — and at most two are ever visible at once. With local state the
|
||||||
* hidden one keeps a stale `aria-pressed`, so a visitor who toggles on a phone
|
* hidden one keeps a stale `aria-pressed`, so a visitor who toggles on the
|
||||||
* and then rotates into the desktop layout is told the setting is off while the
|
* board and then opens the menu is told the setting is off while the page is
|
||||||
* page is plainly showing it on.
|
* plainly showing it on.
|
||||||
*/
|
*/
|
||||||
let high = false;
|
let high = false;
|
||||||
let initialised = false;
|
let initialised = false;
|
||||||
@@ -69,14 +69,57 @@ function subscribe(listener: () => void): () => void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContrastToggle({ className }: { className?: string }) {
|
function useContrast(): boolean {
|
||||||
// The server snapshot is `false` so a prerendered page never claims a
|
// The server snapshot is `false` so a prerendered page never claims a
|
||||||
// preference it cannot know; the real value lands on the first subscribe.
|
// preference it cannot know; the real value lands on the first subscribe.
|
||||||
const isHigh = React.useSyncExternalStore(
|
return React.useSyncExternalStore(
|
||||||
subscribe,
|
subscribe,
|
||||||
() => high,
|
() => high,
|
||||||
() => false,
|
() => false,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContrastToggleProps {
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Print "High contrast" beside the icon. This is the form the Play tab and
|
||||||
|
* the mobile sheet use: a colour-blind visitor looking at the board finds the
|
||||||
|
* mode by its name, not by guessing what a half-filled circle means.
|
||||||
|
*/
|
||||||
|
labelled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContrastToggle({ className, labelled = false }: ContrastToggleProps) {
|
||||||
|
const isHigh = useContrast();
|
||||||
|
const ariaLabel = isHigh ? 'High contrast tiles on. Turn off.' : 'High contrast tiles off. Turn on.';
|
||||||
|
|
||||||
|
if (labelled) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
// 44px below lg, 36px at lg — the same step as the icon form, so the
|
||||||
|
// Play tab's row lines up with the ghost button beside it.
|
||||||
|
className={cn(
|
||||||
|
'justify-start lg:min-h-9',
|
||||||
|
isHigh && 'border-accent-edge bg-accent-subtle text-accent-fg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-pressed={isHigh}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={() => setHigh(!isHigh)}
|
||||||
|
>
|
||||||
|
<Contrast aria-hidden="true" />
|
||||||
|
<span className="text-ui">High contrast</span>
|
||||||
|
{/* The state as a word as well as a fill, on the control that exists
|
||||||
|
for people who cannot rely on the fill. */}
|
||||||
|
<span className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
{isHigh ? 'on' : 'off'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -85,7 +128,7 @@ export function ContrastToggle({ className }: { className?: string }) {
|
|||||||
size="icon-touch"
|
size="icon-touch"
|
||||||
className={cn('lg:size-9', isHigh && 'bg-accent-subtle text-accent-fg', className)}
|
className={cn('lg:size-9', isHigh && 'bg-accent-subtle text-accent-fg', className)}
|
||||||
aria-pressed={isHigh}
|
aria-pressed={isHigh}
|
||||||
aria-label={isHigh ? 'High contrast tiles on. Turn off.' : 'High contrast tiles off. Turn on.'}
|
aria-label={ariaLabel}
|
||||||
title="High contrast tiles"
|
title="High contrast tiles"
|
||||||
onClick={() => setHigh(!isHigh)}
|
onClick={() => setHigh(!isHigh)}
|
||||||
>
|
>
|
||||||
@@ -93,3 +136,8 @@ export function ContrastToggle({ className }: { className?: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Play tab's form of the toggle. Same store, same state, with its name on it. */
|
||||||
|
export function LabelledContrastToggle({ className }: { className?: string }) {
|
||||||
|
return <ContrastToggle labelled className={className} />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ANATOMY } from '@/content/anatomy';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The definition, before the catalogue.
|
||||||
|
*
|
||||||
|
* Four cells, one per part of an environment, read from `@/content/anatomy` —
|
||||||
|
* the same source `EnvAnatomy` renders on every Play and Evidence tab. That is
|
||||||
|
* the whole point: a visitor learns the four labels here, on the landing, and
|
||||||
|
* then meets them again on the word game and the fraud screen and reads both
|
||||||
|
* as "same machine, different grader". If this strip and the anatomy boxes
|
||||||
|
* ever disagreed on the words, the lesson would break at the first click.
|
||||||
|
*
|
||||||
|
* Hairline above and below; 2×2 at a phone width, four across from `lg`.
|
||||||
|
*/
|
||||||
|
export function DefinitionStrip({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
aria-label="The four parts of an environment"
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-2 gap-x-6 gap-y-5 border-y border-border py-5 lg:grid-cols-4 lg:gap-x-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ANATOMY.map((part) => (
|
||||||
|
<li key={part.key} className="flex min-w-0 flex-col gap-1">
|
||||||
|
<p className="flex items-baseline gap-2">
|
||||||
|
<span className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
{String(part.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="text-ui font-semibold text-fg">{part.kicker}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-small text-fg-2">{part.gloss}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* The environment picker: one card shape, two pages.
|
* The environment picker: one catalogue, three shapes.
|
||||||
*
|
*
|
||||||
* Home leads with it and Gallery filters it, so the card lives here rather than
|
* The list is built at module scope from the registry and the vertical lineup —
|
||||||
* twice. The list is built at module scope from the registry and the vertical
|
* creating `src/demos/<slug>/` adds an entry with no edit to this file, which is
|
||||||
* lineup — creating `src/demos/<slug>/` adds a card with no edit to this file,
|
* the same discovery-by-existence property the registry itself has. The header
|
||||||
* which is the same discovery-by-existence property the registry itself has.
|
* reads the same `environments` array, so the menu, the landing and the gallery
|
||||||
|
* can never disagree about what exists.
|
||||||
*
|
*
|
||||||
* The asymmetry is the honest part. Almost everything in the lineup is written
|
* Three shapes:
|
||||||
* rather than built, and a wall of identical tiles would imply a wall of
|
* - `FeaturedEnvironment`: a built environment, with its own board and the
|
||||||
* working demos on a site whose entire argument is that its claims are
|
* mini tab strip. The only filled call to action on the landing.
|
||||||
* checkable. So a built environment gets its own board, its own block and the
|
* - `EnvironmentRow`: one line of the list. Built and written environments
|
||||||
* only filled call to action, and the written ones read as a published roadmap
|
* share it; the pill and the absent board are the whole difference. There is
|
||||||
* underneath it: dimmed, badged, and each linking to a real specification
|
* NO opacity dimming — a spec row links to a real specification and is
|
||||||
* rather than to nothing. Both pages count them from the data, never from a
|
* told apart by token, not by fading.
|
||||||
* number typed into the copy.
|
* - `StatusPill`: LIVE / SPEC, exported so the header prints the same pill.
|
||||||
|
*
|
||||||
|
* Both pages count from the data, never from a number typed into the copy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowRight, FileText, Play } from 'lucide-react';
|
import { ChevronRight, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
import { TAB_LABELS, visibleTabs } from '@/components/demo/DemoTabs';
|
||||||
import { DemoIcon } from '@/components/site/DemoIcon';
|
import { DemoIcon } from '@/components/site/DemoIcon';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { lineup, routes } from '@/content/lineup';
|
import { lineup, routes } from '@/content/lineup';
|
||||||
import * as s from '@/content/styles';
|
import { listRuns } from '@/lib/demo-kit/episode';
|
||||||
import {
|
import {
|
||||||
listDemos,
|
listDemos,
|
||||||
listVerticals,
|
listVerticals,
|
||||||
@@ -32,10 +36,10 @@ import {
|
|||||||
VERTICAL_LABELS,
|
VERTICAL_LABELS,
|
||||||
VERTICAL_ORDER,
|
VERTICAL_ORDER,
|
||||||
} from '@/lib/demo-kit/registry';
|
} from '@/lib/demo-kit/registry';
|
||||||
import type { DemoStatus, Vertical } from '@/lib/demo-kit/types';
|
import type { DemoStatus, DemoTabId, Vertical } from '@/lib/demo-kit/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/** One card's worth of environment, whether it is built or only written. */
|
/** One environment, whether it is built or only written. */
|
||||||
export interface EnvironmentEntry {
|
export interface EnvironmentEntry {
|
||||||
/** React key. Namespaced, because a demo and a vertical can share a slug. */
|
/** React key. Namespaced, because a demo and a vertical can share a slug. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,12 +60,17 @@ export interface EnvironmentEntry {
|
|||||||
verticalKey: Vertical | null;
|
verticalKey: Vertical | null;
|
||||||
/** Set when the entry is a built demo; drives the board thumbnail. */
|
/** Set when the entry is a built demo; drives the board thumbnail. */
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
/** A standing qualifier printed on the card, e.g. "Not in the first set". */
|
/** A standing qualifier printed on the row, e.g. "Not in the first set". */
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
/**
|
||||||
|
* 1-based position among the written environments, printed as `01`–`12`.
|
||||||
|
* `null` for a built one: the built ones are not ranked, they are done.
|
||||||
|
*/
|
||||||
|
rank: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The card wants one sentence. `verticals.ts` writes two or three, because the
|
* The row wants one sentence. `verticals.ts` writes two or three, because the
|
||||||
* vertical page needs the whole argument. Trimming here rather than adding a
|
* vertical page needs the whole argument. Trimming here rather than adding a
|
||||||
* `tagline` field to the data is what stops a specification and its summary
|
* `tagline` field to the data is what stops a specification and its summary
|
||||||
* drifting apart — there is only ever one copy of the sentence.
|
* drifting apart — there is only ever one copy of the sentence.
|
||||||
@@ -71,14 +80,14 @@ function firstSentence(text: string): string {
|
|||||||
const match = /^[\s\S]*?[.!?](?=\s|$)/.exec(trimmed);
|
const match = /^[\s\S]*?[.!?](?=\s|$)/.exec(trimmed);
|
||||||
const first = match?.[0]?.trim();
|
const first = match?.[0]?.trim();
|
||||||
// A very short first fragment means the split found an abbreviation rather
|
// A very short first fragment means the split found an abbreviation rather
|
||||||
// than a sentence end, and half a clause on a card is worse than three lines.
|
// than a sentence end, and half a clause on a row is worse than three lines.
|
||||||
return first !== undefined && first.length >= 24 ? first : trimmed;
|
return first !== undefined && first.length >= 24 ? first : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verticals a demo already exists for. Those are shown as the demo, not as a proposal. */
|
/** Verticals a demo already exists for. Those are shown as the demo, not as a proposal. */
|
||||||
const builtVerticals = new Set<Vertical>(listVerticals().map((group) => group.vertical));
|
const builtVerticals = new Set<Vertical>(listVerticals().map((group) => group.vertical));
|
||||||
|
|
||||||
const demoEntries: readonly EnvironmentEntry[] = listDemos().map((meta) => ({
|
const demoEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = listDemos().map((meta) => ({
|
||||||
id: `demo:${meta.slug}`,
|
id: `demo:${meta.slug}`,
|
||||||
title: meta.title,
|
title: meta.title,
|
||||||
tagline: meta.tagline,
|
tagline: meta.tagline,
|
||||||
@@ -100,7 +109,7 @@ const demoEntries: readonly EnvironmentEntry[] = listDemos().map((meta) => ({
|
|||||||
note: null,
|
note: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const proposalEntries: readonly EnvironmentEntry[] = lineup
|
const proposalEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = lineup
|
||||||
.filter((vertical) => vertical.key === null || !builtVerticals.has(vertical.key))
|
.filter((vertical) => vertical.key === null || !builtVerticals.has(vertical.key))
|
||||||
.map((vertical) => ({
|
.map((vertical) => ({
|
||||||
id: `vertical:${vertical.slug}`,
|
id: `vertical:${vertical.slug}`,
|
||||||
@@ -111,7 +120,7 @@ const proposalEntries: readonly EnvironmentEntry[] = lineup
|
|||||||
icon: vertical.icon,
|
icon: vertical.icon,
|
||||||
status: 'spec' as const,
|
status: 'spec' as const,
|
||||||
// Its own page, with the task, the reward, the counterweight and the
|
// Its own page, with the task, the reward, the counterweight and the
|
||||||
// caveat written out. A dimmed card is only honest if it goes somewhere.
|
// caveat written out. A spec row is only honest if it goes somewhere.
|
||||||
href: routes.vertical(vertical.slug),
|
href: routes.vertical(vertical.slug),
|
||||||
verticalLabel: vertical.key === null ? null : VERTICAL_LABELS[vertical.key],
|
verticalLabel: vertical.key === null ? null : VERTICAL_LABELS[vertical.key],
|
||||||
verticalKey: vertical.key,
|
verticalKey: vertical.key,
|
||||||
@@ -119,13 +128,21 @@ const proposalEntries: readonly EnvironmentEntry[] = lineup
|
|||||||
note: vertical.plannedForV1 ? null : 'Not in the first set',
|
note: vertical.plannedForV1 ? null : 'Not in the first set',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/** Everything, built first. `lineup` is already ranked, so proposals keep that order. */
|
const unranked: readonly Omit<EnvironmentEntry, 'rank'>[] = [
|
||||||
export const environments: readonly EnvironmentEntry[] = [
|
|
||||||
...demoEntries.filter((entry) => entry.status === 'live'),
|
...demoEntries.filter((entry) => entry.status === 'live'),
|
||||||
...demoEntries.filter((entry) => entry.status === 'spec'),
|
...demoEntries.filter((entry) => entry.status === 'spec'),
|
||||||
...proposalEntries,
|
...proposalEntries,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Everything, built first. `lineup` is already ranked, so proposals keep that order. */
|
||||||
|
export const environments: readonly EnvironmentEntry[] = (() => {
|
||||||
|
let next = 0;
|
||||||
|
return unranked.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
rank: entry.status === 'spec' ? ++next : null,
|
||||||
|
}));
|
||||||
|
})();
|
||||||
|
|
||||||
/** Playable in this tab. */
|
/** Playable in this tab. */
|
||||||
export const builtEnvironments: readonly EnvironmentEntry[] = environments.filter(
|
export const builtEnvironments: readonly EnvironmentEntry[] = environments.filter(
|
||||||
(entry) => entry.status === 'live',
|
(entry) => entry.status === 'live',
|
||||||
@@ -148,122 +165,66 @@ export const filterKeys: readonly Vertical[] = VERTICAL_ORDER.filter((key) =>
|
|||||||
environments.some((entry) => entry.verticalKey === key),
|
environments.some((entry) => entry.verticalKey === key),
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ cards */
|
/* ------------------------------------------------------------------ atoms */
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: DemoStatus }) {
|
/**
|
||||||
if (status === 'live') {
|
* LIVE / SPEC. The one status pill on the site, shared with the header so a
|
||||||
return (
|
* visitor learns the shape once. Filled green means playable now; an outline
|
||||||
<Badge variant="positive" className="uppercase tracking-wide">
|
* means a specification you can read. Nothing is dimmed to say "not yet".
|
||||||
Live
|
*/
|
||||||
</Badge>
|
export function StatusPill({ status, className }: { status: DemoStatus; className?: string }) {
|
||||||
);
|
return status === 'live' ? (
|
||||||
}
|
<Badge variant="solid" className={cn('rounded-full', className)}>
|
||||||
return (
|
Live
|
||||||
<Badge variant="outline" className="uppercase tracking-wide">
|
</Badge>
|
||||||
<FileText aria-hidden="true" className="size-3.5" />
|
) : (
|
||||||
|
<Badge variant="outline" className={cn('rounded-full', className)}>
|
||||||
Spec
|
Spec
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Mono key / sans value, the two lines an executive scans for. */
|
||||||
* The two lines an executive actually scans for: whose budget this is, and
|
function MetaRows({ entry, className }: { entry: EnvironmentEntry; className?: string }) {
|
||||||
* what the number pays for. The vertical is not repeated here — both card
|
|
||||||
* layouts already print it beside the icon.
|
|
||||||
*/
|
|
||||||
function MetaRows({ entry }: { entry: EnvironmentEntry }) {
|
|
||||||
return (
|
return (
|
||||||
<dl className="mt-4 space-y-1.5 text-sm">
|
<dl className={cn('space-y-1.5', className)}>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-16 shrink-0 text-muted">For</dt>
|
<dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
|
||||||
<dd className="min-w-0 text-fg">{entry.persona}</dd>
|
For
|
||||||
|
</dt>
|
||||||
|
<dd className="min-w-0 text-ui text-fg">{entry.persona}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<dt className="w-16 shrink-0 text-muted">Reward</dt>
|
<dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
|
||||||
<dd className="min-w-0 text-fg">{entry.rewardLine}</dd>
|
Reward
|
||||||
|
</dt>
|
||||||
|
<dd className="min-w-0 text-ui text-fg">{entry.rewardLine}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- the featured */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One environment, as a card.
|
* What a built demo ships, loaded from its own lazy chunk.
|
||||||
*
|
*
|
||||||
* The whole rectangle is the link — one target, one focus ring, no nested
|
* `tabs` is derived through the shell's own `visibleTabs`, never declared:
|
||||||
* interactive elements to trap a keyboard in.
|
* `play` is whether the module has an interactive mode, `watch` is whether the
|
||||||
|
* manifest lists a recorded run for it. (The demo page additionally requires
|
||||||
|
* the run to adapt to at least one step; a card does not fetch an episode to
|
||||||
|
* find that out, so a run listed but unplayable would show a WATCH chip the
|
||||||
|
* page then drops. The manifest gate in check-demos is what keeps those equal.)
|
||||||
*/
|
*/
|
||||||
export function EnvironmentCard({
|
interface DemoShape {
|
||||||
entry,
|
board: ReactNode;
|
||||||
className,
|
tabs: readonly DemoTabId[];
|
||||||
}: {
|
|
||||||
entry: EnvironmentEntry;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const isSpec = entry.status === 'spec';
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
|
|
||||||
className={cn(
|
|
||||||
s.cardLink,
|
|
||||||
'h-full',
|
|
||||||
// Dimmed, never disabled. It goes to a real specification, which is the
|
|
||||||
// only thing that makes dimming it honest rather than teasing.
|
|
||||||
isSpec && 'opacity-75 hover:opacity-100 focus-visible:opacity-100',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
to={entry.href}
|
|
||||||
>
|
|
||||||
<span className="flex items-start justify-between gap-3">
|
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'grid size-10 shrink-0 place-items-center rounded-lg',
|
|
||||||
isSpec ? 'bg-surface-2 text-muted' : 'bg-accent-subtle text-accent-fg',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DemoIcon className="size-5" name={entry.icon} />
|
|
||||||
</span>
|
|
||||||
{entry.verticalLabel === null ? null : (
|
|
||||||
<span className="truncate text-xs text-muted">{entry.verticalLabel}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={entry.status} />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<h3 className="mt-4 text-base font-semibold leading-snug tracking-tight text-fg">
|
|
||||||
{entry.title}
|
|
||||||
</h3>
|
|
||||||
<p className={`${s.prose} mt-1.5 text-sm`}>{entry.tagline}</p>
|
|
||||||
|
|
||||||
<MetaRows entry={entry} />
|
|
||||||
|
|
||||||
{/* `mt-auto` so the call to action sits on the same line in every card of
|
|
||||||
a row, however long the tagline above it ran. */}
|
|
||||||
<span className="mt-auto flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-4">
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent-fg">
|
|
||||||
{isSpec ? 'Read the specification' : 'Play it'}
|
|
||||||
<ArrowRight
|
|
||||||
aria-hidden="true"
|
|
||||||
className="size-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{entry.note === null ? null : <span className="text-xs text-muted">{entry.note}</span>}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The board, drawn by the demo's own `Surface`.
|
|
||||||
*
|
|
||||||
* A screenshot would go stale the first time the board changed and nobody would
|
|
||||||
* notice. This cannot: it is the same component the demo page renders, in the
|
|
||||||
* state `interactive.init` hands a new player.
|
|
||||||
*/
|
|
||||||
const THUMBNAIL_SEED = 0;
|
const THUMBNAIL_SEED = 0;
|
||||||
|
|
||||||
function BoardThumbnail({ slug }: { slug: string }) {
|
function useDemoShape(slug: string): DemoShape | null {
|
||||||
const [board, setBoard] = useState<ReactNode>(null);
|
const [shape, setShape] = useState<DemoShape | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -273,11 +234,17 @@ function BoardThumbnail({ slug }: { slug: string }) {
|
|||||||
* away from — so the cost is a prefetch of the destination, and the board
|
* away from — so the cost is a prefetch of the destination, and the board
|
||||||
* arriving is proof the destination is already loaded.
|
* arriving is proof the destination is already loaded.
|
||||||
*/
|
*/
|
||||||
void loadDemoModule(slug)
|
void Promise.all([loadDemoModule(slug), listRuns(slug).catch(() => [])])
|
||||||
.then((module) => {
|
.then(([module, runs]) => {
|
||||||
if (!live || !module.interactive) return;
|
if (!live) return;
|
||||||
const { Surface } = module;
|
const { Surface } = module;
|
||||||
setBoard(<Surface state={module.interactive.init(THUMBNAIL_SEED)} />);
|
const board = module.interactive ? (
|
||||||
|
<Surface state={module.interactive.init(THUMBNAIL_SEED)} />
|
||||||
|
) : null;
|
||||||
|
setShape({
|
||||||
|
board,
|
||||||
|
tabs: visibleTabs({ play: Boolean(module.interactive), watch: runs.length > 0 }),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// A card without a board is still a complete card. A demo whose chunk
|
// A card without a board is still a complete card. A demo whose chunk
|
||||||
@@ -288,16 +255,53 @@ function BoardThumbnail({ slug }: { slug: string }) {
|
|||||||
};
|
};
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four tabs the page will open with, as mono chips: `01 PLAY · 02 WATCH`.
|
||||||
|
* Same-height placeholder until the shape lands, so the card does not jump.
|
||||||
|
*/
|
||||||
|
function MiniTabStrip({ tabs }: { tabs: readonly DemoTabId[] | null }) {
|
||||||
|
return (
|
||||||
|
<ul aria-label="Tabs on this environment's page" className="flex min-h-5 flex-wrap gap-1.5">
|
||||||
|
{tabs === null
|
||||||
|
? Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-5 w-16 rounded-sm" />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: tabs.map((id, index) => (
|
||||||
|
<li key={id}>
|
||||||
|
<Badge variant="muted">
|
||||||
|
{String(index + 1).padStart(2, '0')} {TAB_LABELS[id]}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The board, drawn by the demo's own `Surface`, in a surface-2 inset.
|
||||||
|
*
|
||||||
|
* A screenshot would go stale the first time the board changed and nobody would
|
||||||
|
* notice. This cannot: it is the same component the demo page renders, in the
|
||||||
|
* state `interactive.init` hands a new player.
|
||||||
|
*/
|
||||||
|
function BoardThumbnail({ board }: { board: ReactNode | null }) {
|
||||||
return (
|
return (
|
||||||
// Decorative: the title, the tagline and the reward already say everything
|
// Decorative: the title, the tagline and the reward already say everything
|
||||||
// this board says, and an unplayed board is a shape, not information.
|
// this board says, and an unplayed board is a shape, not information.
|
||||||
<div aria-hidden="true">
|
<div aria-hidden="true" className="flex flex-col gap-2">
|
||||||
<div className="rounded-lg border border-border bg-surface-2 p-3">
|
<div className="rounded-lg border border-border bg-surface-2 p-3">
|
||||||
{board ?? <Skeleton className="aspect-[5/6] w-full" />}
|
{board ?? <Skeleton className="aspect-[5/6] w-full" />}
|
||||||
</div>
|
</div>
|
||||||
{/* An unplayed board is easy to read as a broken one. One line stops
|
{/* An unplayed board is easy to read as a broken one. One line stops
|
||||||
that, and it is true of any demo's Surface, not just this one. */}
|
that, and it is true of any demo's Surface, not just this one. */}
|
||||||
<p className="mt-2 text-xs text-muted">The environment’s own board, before a move.</p>
|
<p className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
The environment’s own board, before a move.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -305,9 +309,10 @@ function BoardThumbnail({ slug }: { slug: string }) {
|
|||||||
/**
|
/**
|
||||||
* The built environment, given the weight it has earned.
|
* The built environment, given the weight it has earned.
|
||||||
*
|
*
|
||||||
* Wider than a card, with the real board in it and the only filled call to
|
* Whole card a link — one target, one focus ring, no nested controls. The copy
|
||||||
* action on the page. If a second demo ever ships, this renders for that one
|
* is FIRST in the source; the board sits to the right from `lg` and follows
|
||||||
* too and the picker stays honest without an edit.
|
* the copy, centred, below that, so on a phone the name of the environment and
|
||||||
|
* the call to action are never pushed under the fold by a board.
|
||||||
*/
|
*/
|
||||||
export function FeaturedEnvironment({
|
export function FeaturedEnvironment({
|
||||||
entry,
|
entry,
|
||||||
@@ -316,64 +321,195 @@ export function FeaturedEnvironment({
|
|||||||
entry: EnvironmentEntry;
|
entry: EnvironmentEntry;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
return entry.slug === null ? (
|
||||||
|
<FeaturedCard entry={entry} className={className} shape={null} withBoard={false} />
|
||||||
|
) : (
|
||||||
|
<FeaturedWithShape entry={entry} slug={entry.slug} className={className} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedWithShape({
|
||||||
|
entry,
|
||||||
|
slug,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
entry: EnvironmentEntry;
|
||||||
|
slug: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const shape = useDemoShape(slug);
|
||||||
|
return <FeaturedCard entry={entry} className={className} shape={shape} withBoard />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturedCard({
|
||||||
|
entry,
|
||||||
|
shape,
|
||||||
|
withBoard,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
entry: EnvironmentEntry;
|
||||||
|
shape: DemoShape | null;
|
||||||
|
withBoard: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isSpec = entry.status === 'spec';
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
aria-label={`${entry.title} — play it`}
|
aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
|
||||||
className={cn(s.cardLink, 'p-5 sm:p-7', className)}
|
className={cn(
|
||||||
|
'card group flex flex-col p-4 transition-colors duration-1 ease-enter hover:border-border-strong sm:p-5',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
to={entry.href}
|
to={entry.href}
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] lg:gap-10">
|
<div
|
||||||
{/*
|
className={cn(
|
||||||
The copy is FIRST in the source and the board is pulled left only from
|
'grid gap-5',
|
||||||
`lg`. On a phone a full-width board above the title pushes the name of
|
withBoard && 'lg:grid-cols-[minmax(0,1fr)_minmax(0,17rem)] lg:gap-8',
|
||||||
the environment and its call to action below the fold, which is the
|
)}
|
||||||
one thing this card exists to avoid.
|
>
|
||||||
*/}
|
<div className="flex min-w-0 flex-col">
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="flex flex-wrap items-center gap-2">
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge status={entry.status} />
|
<StatusPill status={entry.status} />
|
||||||
{entry.verticalLabel === null ? null : (
|
{entry.verticalLabel === null ? null : (
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted">
|
<span className="inline-flex items-center gap-1.5 font-mono text-label uppercase tracking-label text-muted">
|
||||||
<DemoIcon className="size-3.5" name={entry.icon} />
|
<DemoIcon className="size-3.5 text-border-strong" name={entry.icon} />
|
||||||
{entry.verticalLabel}
|
{entry.verticalLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<h3 className="mt-3 text-2xl font-bold tracking-tight text-fg sm:text-3xl">
|
<h3 className="mt-3 text-h2 text-fg">{entry.title}</h3>
|
||||||
{entry.title}
|
<p className="mt-2 max-w-measure text-prose text-fg-2">{entry.tagline}</p>
|
||||||
</h3>
|
|
||||||
<p className={`${s.lede} mt-2 text-base sm:text-lg`}>{entry.tagline}</p>
|
|
||||||
|
|
||||||
<MetaRows entry={entry} />
|
<MetaRows entry={entry} className="mt-4" />
|
||||||
|
|
||||||
<p className={`${s.prose} mt-4 text-sm`}>
|
<div className="mt-4">
|
||||||
Four tabs. Play the board, watch a recorded model play the same board, move the reward
|
<MiniTabStrip tabs={shape?.tabs ?? (withBoard ? null : [])} />
|
||||||
weights and see the ranking change, then read the Python that scored it.
|
</div>
|
||||||
</p>
|
|
||||||
|
|
||||||
<span className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-2">
|
<span className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
{/* Styled as the button it behaves as. It is not a <button>: the
|
{/* Styled as the button it behaves as. It is not a <button>: the
|
||||||
whole card is already the one link, and a control inside a link
|
whole card is already the one link, and a control inside a link
|
||||||
is a keyboard trap dressed up as a call to action. */}
|
is a keyboard trap dressed up as a call to action. */}
|
||||||
<span aria-hidden="true" className={s.btnPrimary}>
|
<span
|
||||||
<Play className="size-4" />
|
aria-hidden="true"
|
||||||
Play it
|
className="inline-flex min-h-11 items-center gap-2 rounded-md border border-accent-edge bg-brand px-4 text-ui font-medium text-accent-on"
|
||||||
|
>
|
||||||
|
{isSpec ? null : <Play className="size-4" />}
|
||||||
|
{isSpec ? 'Read the specification' : 'Play it'}
|
||||||
|
</span>
|
||||||
|
<span className="text-small text-muted">
|
||||||
|
{isSpec
|
||||||
|
? 'The task, the reward, the counterweight and where it stops being honest.'
|
||||||
|
: 'Opens on the board. No sign-up, no sales call.'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted">Opens on the board. No sign-up, no sales call.</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{entry.slug === null ? null : (
|
{withBoard ? (
|
||||||
<div className="max-w-[17rem] lg:order-first">
|
<div className="mx-auto w-full max-w-[17rem] lg:mx-0">
|
||||||
<BoardThumbnail slug={entry.slug} />
|
<BoardThumbnail board={shape?.board ?? null} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------- the list */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One environment, as a row. Whole row a link; 56px from `lg`, 64px below.
|
||||||
|
*
|
||||||
|
* At a phone width it is two lines: title + pill, then the persona. The reward
|
||||||
|
* line, the tagline and the icon square come in from `lg`, where there is room
|
||||||
|
* for a row to carry the whole argument in one glance.
|
||||||
|
*/
|
||||||
|
export function EnvironmentRow({
|
||||||
|
entry,
|
||||||
|
rank = entry.rank,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
entry: EnvironmentEntry;
|
||||||
|
/** The printed numeral. Defaults to the entry's own rank; a filtered list passes its own. */
|
||||||
|
rank?: number | null;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isSpec = entry.status === 'spec';
|
||||||
|
return (
|
||||||
|
// An `<li>`, because the pages render rows straight into their own `<ol>`
|
||||||
|
// and the hairline between rows belongs to the row, not to the list.
|
||||||
|
<li className={cn('border-b border-border last:border-b-0', className)}>
|
||||||
|
<Link
|
||||||
|
aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
|
||||||
|
to={entry.href}
|
||||||
|
className="group flex min-h-16 items-center gap-3 px-2 py-2 transition-colors duration-1 ease-enter hover:bg-surface-2 lg:min-h-14 lg:gap-4"
|
||||||
|
>
|
||||||
|
<span className="hidden w-6 shrink-0 font-mono text-mono-data text-muted lg:block">
|
||||||
|
{rank === null || rank === undefined ? '' : String(rank).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="hidden size-9 shrink-0 place-items-center rounded-md bg-surface-3 lg:grid">
|
||||||
|
<DemoIcon className="size-4 text-border-strong" name={entry.icon} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5 lg:flex-row lg:items-center lg:gap-4">
|
||||||
|
<span className="flex min-w-0 flex-col lg:w-[19rem] lg:shrink-0">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-[0.9375rem] font-medium leading-tight text-fg">
|
||||||
|
{entry.title}
|
||||||
|
</span>
|
||||||
|
<StatusPill status={entry.status} className="lg:hidden" />
|
||||||
|
</span>
|
||||||
|
<span className="hidden truncate text-small text-muted lg:block">{entry.tagline}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="truncate text-small text-muted lg:w-[11rem] lg:shrink-0 lg:text-fg-2">
|
||||||
|
{entry.persona}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden min-w-0 flex-1 truncate text-small text-fg-2 lg:block">
|
||||||
|
{entry.rewardLine}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden shrink-0 items-center gap-2 lg:flex">
|
||||||
|
{entry.note === null ? null : <Badge variant="muted">{entry.note}</Badge>}
|
||||||
|
<StatusPill status={entry.status} />
|
||||||
|
</span>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 shrink-0 text-border-strong transition-transform duration-1 ease-enter group-hover:translate-x-0.5"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The rows in one ordered list, so a screen reader gets the count. */
|
||||||
|
export function EnvironmentList({
|
||||||
|
entries,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
entries: readonly EnvironmentEntry[];
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ol className={cn('border-y border-border', className)}>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<EnvironmentRow entry={entry} key={entry.id} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated The catalogue is a list, not a grid of cards. Kept as an alias
|
||||||
|
* so a page that still imports the old name renders the rows; use
|
||||||
|
* `EnvironmentList`.
|
||||||
|
*/
|
||||||
|
export const EnvironmentGrid = EnvironmentList;
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- filter */
|
/* ----------------------------------------------------------------- filter */
|
||||||
|
|
||||||
/** The "no vertical chosen" value. Not a `Vertical`, so it cannot collide with one. */
|
/** The "no vertical chosen" value. Not a `Vertical`, so it cannot collide with one. */
|
||||||
@@ -382,8 +518,9 @@ export const ALL_VERTICALS = 'all';
|
|||||||
export type VerticalFilterValue = Vertical | typeof ALL_VERTICALS;
|
export type VerticalFilterValue = Vertical | typeof ALL_VERTICALS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vertical chips. Wraps on a phone rather than scrolling sideways — a chip
|
* Vertical chips for `/gallery?vertical=`. Wraps on a phone rather than
|
||||||
* hidden off the right edge is a filter nobody knows exists.
|
* scrolling sideways — a chip hidden off the right edge is a filter nobody
|
||||||
|
* knows exists. 44px tall; the border is the affordance, so it is border-strong.
|
||||||
*/
|
*/
|
||||||
export function VerticalFilter({
|
export function VerticalFilter({
|
||||||
active,
|
active,
|
||||||
@@ -404,10 +541,12 @@ export function VerticalFilter({
|
|||||||
<button
|
<button
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={cn(
|
className={cn(
|
||||||
'tap inline-flex items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors duration-2 ease-enter',
|
// `text-sm`, not `text-ui`: composed through `cn`, where
|
||||||
|
// tailwind-merge v2 reads an unknown `text-*` as a colour.
|
||||||
|
'tap inline-flex items-center rounded-md border px-3.5 text-sm font-medium transition-colors duration-1 ease-enter',
|
||||||
selected
|
selected
|
||||||
? 'border-brand bg-accent-subtle text-accent-fg'
|
? 'border-accent-edge bg-accent-subtle text-accent-fg'
|
||||||
: 'border-border bg-surface text-muted hover:bg-surface-2 hover:text-fg',
|
: 'border-border-strong bg-surface text-fg hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
onClick={() => onSelect(key)}
|
onClick={() => onSelect(key)}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -420,22 +559,3 @@ export function VerticalFilter({
|
|||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cards in a responsive grid. One list, so a screen reader gets the count. */
|
|
||||||
export function EnvironmentGrid({
|
|
||||||
entries,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
entries: readonly EnvironmentEntry[];
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ul className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3', className)}>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<li className="flex" key={entry.id}>
|
|
||||||
<EnvironmentCard entry={entry} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +1,65 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { GitBranch } from 'lucide-react';
|
import { ArrowUpRight, GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { PIG_URL, REPO_URL, repoFile } from '@/components/site/links';
|
import { PIG_URL, REPO_URL, repoFile } from '@/components/site/links';
|
||||||
import { Wordmark } from '@/components/site/Wordmark';
|
import { Wordmark } from '@/components/site/Wordmark';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
|
||||||
|
const navLink =
|
||||||
|
'tap inline-flex items-center gap-2 py-1 text-ui text-fg underline-offset-4 transition-colors duration-1 ease-enter hover:text-accent-fg hover:underline';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The footer. The wordmark and the thesis on the left, the three destinations
|
||||||
|
* every page must reach on the right, then the three legal lines verbatim.
|
||||||
|
*
|
||||||
|
* The reproducibility and licence lines are set in the mono label style: they
|
||||||
|
* are statements about the repository, the kind of thing a machine reads. The
|
||||||
|
* NYT line is a sentence, so it stays in the sans. No line implying Prime
|
||||||
|
* Intellect endorsement is added here; that is a business-owner copy decision.
|
||||||
|
*/
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="mt-16 border-t border-border bg-surface">
|
<footer className="mt-12 border-t border-border bg-surface sm:mt-16">
|
||||||
<div className="mx-auto max-w-canvas px-4 pb-[max(2rem,var(--safe-bottom))] pl-[max(1rem,var(--safe-left))] pr-[max(1rem,var(--safe-right))] pt-8">
|
<div className={`${s.shell} pb-[max(2rem,var(--safe-bottom))] pt-8`}>
|
||||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Wordmark className="text-base" />
|
<Link to="/" className="tap inline-flex w-fit items-center rounded-md" aria-label="PIG demo, home">
|
||||||
<p className="max-w-sm text-sm text-muted">
|
<Wordmark />
|
||||||
|
</Link>
|
||||||
|
<p className="max-w-[38ch] text-small text-muted">
|
||||||
An environment is an eval you can take the gradient of.
|
An environment is an eval you can take the gradient of.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav aria-label="Footer" className="flex flex-col gap-1 text-sm">
|
<nav aria-label="Footer" className="flex flex-col gap-1">
|
||||||
<a
|
<a className={navLink} href={REPO_URL} target="_blank" rel="noreferrer noopener">
|
||||||
className="tap inline-flex items-center gap-2 py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
<GitBranch aria-hidden="true" className="size-4 text-border-strong" />
|
||||||
href={REPO_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
<GitBranch aria-hidden="true" className="size-4" />
|
|
||||||
Source on Gitea
|
Source on Gitea
|
||||||
</a>
|
</a>
|
||||||
<Link
|
<Link className={navLink} to="/honesty">
|
||||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
|
||||||
to="/honesty"
|
|
||||||
>
|
|
||||||
What we are not claiming
|
What we are not claiming
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a className={navLink} href={PIG_URL} target="_blank" rel="noreferrer noopener">
|
||||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
|
||||||
href={PIG_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
>
|
|
||||||
primeintellectgrowth.com
|
primeintellectgrowth.com
|
||||||
|
<ArrowUpRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="my-6" />
|
<Separator className="my-6" />
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 text-xs leading-relaxed text-muted">
|
<div className="flex flex-col gap-2">
|
||||||
<p>
|
{/* Mono 11, but NOT uppercase: these are sentences about the
|
||||||
|
repository, and a forty-word sentence in small caps is unreadable. */}
|
||||||
|
<p className="font-mono text-label normal-case tracking-normal text-muted">
|
||||||
Every number on this site is reproducible from the repository: the environments, the
|
Every number on this site is reproducible from the repository: the environments, the
|
||||||
recorded rollouts and the command that produced them all ship with the source.
|
recorded rollouts and the command that produced them all ship with the source.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p className="font-mono text-label normal-case tracking-normal text-muted">
|
||||||
Licensed{' '}
|
Licensed{' '}
|
||||||
<a
|
<a
|
||||||
className="underline underline-offset-2 hover:text-accent-fg"
|
className="underline underline-offset-2 transition-colors duration-1 ease-enter hover:text-accent-fg"
|
||||||
href={repoFile('LICENSE')}
|
href={repoFile('LICENSE')}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
@@ -64,12 +69,12 @@ export function SiteFooter() {
|
|||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
{/*
|
{/*
|
||||||
Stated plainly and kept in the footer of every page. The word game
|
Stated plainly and kept in the footer of every page, verbatim. The
|
||||||
demo is an independent implementation of a public game mechanic; the
|
word game demo is an independent implementation of a public game
|
||||||
trademark belongs to someone else and this site must never read as
|
mechanic; the trademark belongs to someone else and this site must
|
||||||
if it were theirs.
|
never read as if it were theirs.
|
||||||
*/}
|
*/}
|
||||||
<p>
|
<p className="text-caption text-muted">
|
||||||
Not affiliated with, endorsed by, or connected to The New York Times Company. Wordle is
|
Not affiliated with, endorsed by, or connected to The New York Times Company. Wordle is
|
||||||
their trademark.
|
their trademark.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import * as React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowRight, ArrowUpRight, GitBranch, Menu } from 'lucide-react';
|
import { ArrowRight, ArrowUpRight, GitBranch, Menu } from 'lucide-react';
|
||||||
|
|
||||||
import { listDemos } from '@/lib/demo-kit/registry';
|
import { VERTICAL_LABELS, VERTICAL_ORDER } from '@/lib/demo-kit/registry';
|
||||||
import type { DemoMeta } from '@/lib/demo-kit/types';
|
import type { Vertical } from '@/lib/demo-kit/types';
|
||||||
import { lineup, routes } from '@/content/lineup';
|
import { routes } from '@/content/lineup';
|
||||||
import type { VerticalEntry } from '@/content/verticals';
|
import { CORE_TERMS } from '@/content/glossary';
|
||||||
|
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +38,8 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
||||||
import { DemoIcon } from '@/components/site/DemoIcon';
|
import { DemoIcon } from '@/components/site/DemoIcon';
|
||||||
|
import { environments, StatusPill } from '@/components/site/EnvironmentPicker';
|
||||||
|
import type { EnvironmentEntry } from '@/components/site/EnvironmentPicker';
|
||||||
import { PIG_URL, REPO_URL, VERIFIERS_WORDLE_URL } from '@/components/site/links';
|
import { PIG_URL, REPO_URL, VERIFIERS_WORDLE_URL } from '@/components/site/links';
|
||||||
import { ThemeToggle } from '@/components/site/ThemeToggle';
|
import { ThemeToggle } from '@/components/site/ThemeToggle';
|
||||||
import { Wordmark } from '@/components/site/Wordmark';
|
import { Wordmark } from '@/components/site/Wordmark';
|
||||||
@@ -44,10 +48,18 @@ import { cn } from '@/lib/utils';
|
|||||||
/*
|
/*
|
||||||
* This header is GENERATED. Nothing in it names a demo.
|
* This header is GENERATED. Nothing in it names a demo.
|
||||||
*
|
*
|
||||||
* Adding `src/demos/<slug>/` puts that demo in the Demos panel, in its
|
* The Environments panel is the whole catalogue — the same `environments`
|
||||||
* vertical, and (if it is the first live one) behind the CTA, with zero edits
|
* array the landing and the gallery render, so a demo that exists is in the
|
||||||
* to this file. The only hand-written lists here are the five concepts under
|
* menu, in its vertical, and (if it is the first live one) behind the CTA,
|
||||||
* "How it works", which are properties of the idea rather than of the lineup.
|
* with zero edits to this file. The only hand-written text here is chrome:
|
||||||
|
* the labels of the four destinations and the quoted taskset.
|
||||||
|
*
|
||||||
|
* Built for fifty entries: rows are 40px, groups carry sticky mono headers
|
||||||
|
* inside the panel's own scroll, and the count in each column header is
|
||||||
|
* derived. A type-to-filter input is DEFERRED until the catalogue passes
|
||||||
|
* about twenty entries — it is console plumbing the content does not yet
|
||||||
|
* justify, and a search box over fourteen rows says "we could not organise
|
||||||
|
* fourteen rows".
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,212 +75,241 @@ const TASKSET_SOURCE = `class WordleConfig(TextArenaConfig):
|
|||||||
class WordleTaskset(TextArenaTaskset, vf.Taskset[TextArenaTask, WordleConfig]):
|
class WordleTaskset(TextArenaTaskset, vf.Taskset[TextArenaTask, WordleConfig]):
|
||||||
pass`;
|
pass`;
|
||||||
|
|
||||||
const CONCEPTS: readonly { term: string; gloss: string }[] = [
|
/* -------------------------------------------------------------- catalogue */
|
||||||
{
|
|
||||||
term: 'Environment',
|
interface Group {
|
||||||
gloss: 'The task, the legal moves and the grader, packaged so anyone can install and run it.',
|
key: Vertical | null;
|
||||||
},
|
label: string;
|
||||||
{
|
entries: EnvironmentEntry[];
|
||||||
term: 'Rollout',
|
}
|
||||||
gloss: 'One episode. The model acts, the environment answers, and every turn is recorded.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
term: 'Reward',
|
|
||||||
gloss: 'The number the run is scored on. You write it, so you decide what "good" means.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
term: 'Harness',
|
|
||||||
gloss: 'The runner that plays a taskset against a model and keeps the receipts.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
term: 'Held-out grading',
|
|
||||||
gloss: 'Scored on problems the model has never seen, which is the only way the score means anything.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The header's Verticals panel shows the LINEUP, not the registry's groups.
|
* Entries grouped under their vertical, in `VERTICAL_ORDER`, keeping each
|
||||||
*
|
* group's internal order (registry order for demos, lineup rank for
|
||||||
* `listVerticals()` only ever contains verticals that already have a demo —
|
* proposals). A proposal the contract has no key for lands in a final group so
|
||||||
* today that is one group, `reference`, which `src/content/verticals.ts` is
|
* it is reachable from the bar like everything else.
|
||||||
* explicit is not an industry and must never be presented as one. The twelve
|
|
||||||
* proposals each have a real page at `/verticals/<slug>`, so the panel links
|
|
||||||
* there. `VerticalEntry.slug` is the route's slug; the registry's `Vertical`
|
|
||||||
* key is a different, shorter string and is deliberately not used for URLs.
|
|
||||||
*/
|
*/
|
||||||
type HeaderVertical = VerticalEntry;
|
function groupByVertical(entries: readonly EnvironmentEntry[]): Group[] {
|
||||||
|
const buckets = new Map<Vertical | null, EnvironmentEntry[]>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const bucket = buckets.get(entry.verticalKey);
|
||||||
|
if (bucket) bucket.push(entry);
|
||||||
|
else buckets.set(entry.verticalKey, [entry]);
|
||||||
|
}
|
||||||
|
const groups: Group[] = VERTICAL_ORDER.flatMap((key) => {
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
return bucket ? [{ key, label: VERTICAL_LABELS[key], entries: bucket }] : [];
|
||||||
|
});
|
||||||
|
const rest = buckets.get(null);
|
||||||
|
if (rest) groups.push({ key: null, label: 'Beyond the lineup', entries: rest });
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
function useLineup() {
|
interface Catalogue {
|
||||||
|
live: EnvironmentEntry[];
|
||||||
|
spec: EnvironmentEntry[];
|
||||||
|
liveGroups: Group[];
|
||||||
|
specGroups: Group[];
|
||||||
|
/** The primary CTA: `Play {title}` for the first live environment. */
|
||||||
|
cta: { href: string; label: string; short: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCatalogue(): Catalogue {
|
||||||
return React.useMemo(() => {
|
return React.useMemo(() => {
|
||||||
// `listDemos` already returns a fresh array sorted by order then slug, and
|
// `environments` is already ordered live-first, then registry specs, then
|
||||||
// `listVerticals` already drops the empty verticals. Neither needs redoing
|
// the ranked lineup. Not re-sorted here: if this file starts re-sorting,
|
||||||
// here — if this file starts re-sorting the lineup, the header and the
|
// the header and the gallery will eventually disagree about "first".
|
||||||
// gallery will eventually disagree about what "first" means.
|
const live = environments.filter((entry) => entry.status === 'live');
|
||||||
const demos = listDemos();
|
const spec = environments.filter((entry) => entry.status === 'spec');
|
||||||
const live = demos.filter((demo) => demo.status === 'live');
|
const primary = live[0];
|
||||||
const spec = demos.filter((demo) => demo.status === 'spec');
|
// When nothing is live, the bar points at the catalogue rather than
|
||||||
|
// promising a board that does not exist.
|
||||||
// Already sorted by rank in `@/content/lineup`. Copied rather than passed
|
const cta = primary
|
||||||
// through, because the panel props are mutable arrays and `lineup` is the
|
? { href: primary.href, label: `Play ${primary.title}`, short: 'Play' }
|
||||||
// one every page reads.
|
: { href: routes.gallery, label: 'All environments', short: 'Browse' };
|
||||||
const verticals: HeaderVertical[] = [...lineup];
|
return {
|
||||||
|
live,
|
||||||
// The CTA follows the lineup rather than naming a slug. Today the first
|
spec,
|
||||||
// live demo IS the word game, so this resolves to the wordle route; when a
|
liveGroups: groupByVertical(live),
|
||||||
// second one ships ahead of it, the button moves with it and this file does
|
specGroups: groupByVertical(spec),
|
||||||
// not change.
|
cta,
|
||||||
const primary = live[0] ?? demos[0];
|
};
|
||||||
const ctaHref = primary ? `/demos/${primary.slug}` : '/demos';
|
|
||||||
|
|
||||||
return { live, spec, verticals, ctaHref };
|
|
||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ panels */
|
/* ------------------------------------------------------------------- atoms */
|
||||||
|
|
||||||
|
/** The mono column / group label. */
|
||||||
|
function PanelLabel({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
sticky = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
sticky?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
// Arbitrary size rather than `text-label`: this string goes through
|
||||||
|
// `cn`, and tailwind-merge v2 reads an unknown `text-*` as a COLOUR, so
|
||||||
|
// `text-label text-muted` would silently drop the size.
|
||||||
|
'px-2 pb-1.5 pt-2 font-mono text-[0.6875rem] font-medium uppercase leading-[1.2] tracking-label text-muted',
|
||||||
|
// Sticky inside the panel's own scroll, on the panel's own surface, so
|
||||||
|
// the vertical a row belongs to is never off-screen at fifty entries.
|
||||||
|
sticky && 'sticky top-0 z-10 bg-surface',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spreads `...rest` onto the Link, and that is not tidiness.
|
* Spreads `...rest` onto the Link, and that is not tidiness.
|
||||||
*
|
*
|
||||||
* Both `NavigationMenuLink asChild` and `SheetClose asChild` work by cloning
|
* Both `NavigationMenuLink asChild` and `SheetClose asChild` work by cloning
|
||||||
* this element and merging their own `onClick` into it. A component that takes
|
* this element and merging their own `onClick` into it. A component that takes
|
||||||
* `demo` and ignores everything else silently drops those handlers: the row
|
* `entry` and ignores everything else silently drops those handlers: the row
|
||||||
* still navigates, and the mega panel or the mobile sheet stays open on top of
|
* still navigates, and the mega panel or the mobile sheet stays open on top of
|
||||||
* the page you just arrived at.
|
* the page you just arrived at.
|
||||||
*/
|
*/
|
||||||
function DemoRow({
|
function DemoRow({
|
||||||
demo,
|
entry,
|
||||||
muted = false,
|
|
||||||
className,
|
className,
|
||||||
...rest
|
...rest
|
||||||
}: { demo: DemoMeta; muted?: boolean } & Omit<React.ComponentProps<typeof Link>, 'to'>) {
|
}: { entry: EnvironmentEntry } & Omit<React.ComponentProps<typeof Link>, 'to'>) {
|
||||||
|
const isSpec = entry.status === 'spec';
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/demos/${demo.slug}`}
|
to={entry.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2',
|
// 44px below lg (the sheet), 40px from lg (the panel). Never both
|
||||||
muted && 'opacity-70 hover:opacity-100',
|
// visible at once, so one row serves both.
|
||||||
|
'flex min-h-11 items-start gap-2.5 rounded-md px-2 py-1.5 transition-colors duration-1 ease-enter hover:bg-surface-2 lg:min-h-10',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
<DemoIcon name={demo.icon} className="mt-0.5 size-4 text-accent-fg" />
|
{entry.rank === null ? null : (
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
<span className="w-5 shrink-0 pt-0.5 font-mono text-mono-data text-muted">
|
||||||
<span className="flex items-center gap-2 text-sm font-medium text-fg">
|
{String(entry.rank).padStart(2, '0')}
|
||||||
{demo.title}
|
|
||||||
{demo.status === 'spec' ? (
|
|
||||||
<Badge variant="outline" className="font-normal">
|
|
||||||
Spec
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs leading-relaxed text-muted">
|
)}
|
||||||
|
<DemoIcon name={entry.icon} className="mt-0.5 size-4 text-border-strong" />
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span className="text-ui font-medium leading-tight text-fg">{entry.title}</span>
|
||||||
|
<StatusPill status={entry.status} />
|
||||||
|
{entry.note === null ? null : <Badge variant="muted">{entry.note}</Badge>}
|
||||||
|
</span>
|
||||||
|
<span className="text-small text-muted">
|
||||||
{/*
|
{/*
|
||||||
A spec demo shows the BUYER, not the technology: "Head of Claims"
|
A spec row shows the BUYER, not the technology: "For the Head of
|
||||||
says who is meant to care, where a taskset name says nothing to the
|
Claims" says who is meant to care, where a taskset name says
|
||||||
person reading this in a boardroom.
|
nothing to the person reading this in a boardroom.
|
||||||
*/}
|
*/}
|
||||||
{demo.status === 'spec' ? `For the ${demo.persona}` : demo.tagline}
|
{isSpec ? `For the ${entry.persona}` : entry.tagline}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PanelHeading({ children }: { children: React.ReactNode }) {
|
/**
|
||||||
|
* Groups of rows under sticky vertical labels. `wrap` is what turns a row into
|
||||||
|
* a panel link or a sheet-closing link — the two contexts close differently.
|
||||||
|
*/
|
||||||
|
function GroupedRows({
|
||||||
|
groups,
|
||||||
|
wrap,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
wrap: (row: React.ReactElement, key: string) => React.ReactNode;
|
||||||
|
empty: string;
|
||||||
|
}) {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return <p className="px-2 py-2 text-small text-muted">{empty}</p>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<p className="px-2.5 pb-1 text-xs font-semibold uppercase tracking-wider text-muted">
|
<>
|
||||||
{children}
|
{groups.map((group) => (
|
||||||
</p>
|
<section key={group.key ?? 'none'} aria-label={group.label}>
|
||||||
|
<PanelLabel sticky>{group.label}</PanelLabel>
|
||||||
|
{group.entries.map((entry) => wrap(<DemoRow entry={entry} />, entry.id))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DemosPanel({ live, spec }: { live: DemoMeta[]; spec: DemoMeta[] }) {
|
// `text-sm`, not `text-ui`: this string is composed through `cn`, where
|
||||||
|
// tailwind-merge v2 treats an unknown `text-*` as a colour and drops it.
|
||||||
|
const panelLink =
|
||||||
|
'inline-flex min-h-10 items-center gap-1 rounded-md px-2 text-sm font-medium text-accent-fg underline-offset-4 hover:underline';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ panels */
|
||||||
|
|
||||||
|
function EnvironmentsPanel({ live, spec, liveGroups, specGroups }: Catalogue) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[min(92vw,720px)]">
|
<div className="flex w-[min(92vw,880px)] flex-col">
|
||||||
<div className="grid grid-cols-2 gap-4 p-4">
|
<div className="grid max-h-[70vh] grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] gap-4 overflow-y-auto overscroll-contain p-3">
|
||||||
<section aria-label="Live now" className="flex flex-col gap-0.5">
|
<section aria-label={`Live, ${live.length}`} className="flex flex-col">
|
||||||
<PanelHeading>Live now</PanelHeading>
|
<PanelLabel className="text-fg">
|
||||||
{live.length > 0 ? (
|
Live <span className="text-muted">({String(live.length).padStart(2, '0')})</span>
|
||||||
live.map((demo) => (
|
</PanelLabel>
|
||||||
<NavigationMenuLink asChild key={demo.slug}>
|
<GroupedRows
|
||||||
<DemoRow demo={demo} />
|
groups={liveGroups}
|
||||||
|
empty="No playable environment yet."
|
||||||
|
wrap={(row, key) => (
|
||||||
|
<NavigationMenuLink asChild key={key}>
|
||||||
|
{row}
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
))
|
)}
|
||||||
) : (
|
/>
|
||||||
<p className="p-2.5 text-xs text-muted">No interactive demos published yet.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section aria-label={`Specifications, ${spec.length}`} className="flex flex-col border-l border-border pl-4">
|
||||||
aria-label="Shipping next"
|
<PanelLabel className="text-fg">
|
||||||
className="flex flex-col gap-0.5 border-l border-border pl-4"
|
Spec <span className="text-muted">({String(spec.length).padStart(2, '0')})</span>
|
||||||
>
|
</PanelLabel>
|
||||||
<PanelHeading>Shipping next</PanelHeading>
|
<GroupedRows
|
||||||
{spec.length > 0 ? (
|
groups={specGroups}
|
||||||
spec.map((demo) => (
|
empty="Nothing written that is not built."
|
||||||
<NavigationMenuLink asChild key={demo.slug}>
|
wrap={(row, key) => (
|
||||||
<DemoRow demo={demo} muted />
|
<NavigationMenuLink asChild key={key}>
|
||||||
|
{row}
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
))
|
)}
|
||||||
) : (
|
/>
|
||||||
<p className="p-2.5 text-xs text-muted">Nothing queued.</p>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 border-t border-border bg-surface-2 px-5 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-t border-border bg-surface-2 px-4 py-2.5">
|
||||||
<p className="text-xs text-muted">
|
<p className="text-small text-muted">
|
||||||
Every demo replays a real rollout from a real environment.
|
Every playable environment replays a real recorded rollout.
|
||||||
</p>
|
</p>
|
||||||
<NavigationMenuLink asChild>
|
<span className="flex items-center gap-3">
|
||||||
<Link
|
<Badge variant="outline">{PROPOSAL_NOTICE}</Badge>
|
||||||
to="/demos"
|
<NavigationMenuLink asChild>
|
||||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
<Link to={routes.gallery} className={panelLink}>
|
||||||
>
|
All environments
|
||||||
Browse all demos
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
</Link>
|
||||||
</Link>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuLink>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerticalsPanel({ verticals }: { verticals: HeaderVertical[] }) {
|
|
||||||
return (
|
|
||||||
<div className="w-[min(92vw,720px)] p-4">
|
|
||||||
{verticals.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
|
||||||
{verticals.map((vertical) => (
|
|
||||||
<NavigationMenuLink asChild key={vertical.slug}>
|
|
||||||
<Link
|
|
||||||
to={routes.vertical(vertical.slug)}
|
|
||||||
className="flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<DemoIcon name={vertical.icon} className="mt-0.5 size-4 text-accent-fg" />
|
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-medium text-fg">{vertical.title}</span>
|
|
||||||
{/* The budget holder, not the reward sentence: a nav row has
|
|
||||||
one line, and "who buys this" is the more useful half. */}
|
|
||||||
<span className="text-xs leading-relaxed text-muted">{vertical.persona}</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="p-2.5 text-xs text-muted">No verticals in the lineup yet.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TasksetSource({ className }: { className?: string }) {
|
function TasksetSource({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border bg-bg p-3', className)}>
|
<div className={cn('flex flex-col gap-2 rounded-md border border-border bg-surface-3 p-3', className)}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
<p className="font-mono text-label uppercase tracking-label text-muted">The actual taskset</p>
|
||||||
The actual taskset
|
|
||||||
</p>
|
|
||||||
{/*
|
{/*
|
||||||
Wrapped, not scrolled. The source is quoted verbatim — reformatting it
|
Wrapped, not scrolled. The source is quoted verbatim — reformatting it
|
||||||
to fit would make it stop being a quote — and the widest line is a third
|
to fit would make it stop being a quote — and the widest line is a third
|
||||||
@@ -276,14 +317,14 @@ function TasksetSource({ className }: { className?: string }) {
|
|||||||
entire job is "this is real code", a visible wrap beats a third of the
|
entire job is "this is real code", a visible wrap beats a third of the
|
||||||
line hidden behind a scrollbar nobody in a boardroom will drag.
|
line hidden behind a scrollbar nobody in a boardroom will drag.
|
||||||
*/}
|
*/}
|
||||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-fg">
|
<pre className="whitespace-pre-wrap break-words font-mono text-[0.6875rem] leading-relaxed text-fg [font-feature-settings:'liga'_0]">
|
||||||
<code className="font-mono">{TASKSET_SOURCE}</code>
|
<code>{TASKSET_SOURCE}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<a
|
<a
|
||||||
href={VERIFIERS_WORDLE_URL}
|
href={VERIFIERS_WORDLE_URL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
className="inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
className="inline-flex min-h-10 w-fit items-center gap-1 font-mono text-mono-data text-accent-fg underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
verifiers/environments/wordle
|
verifiers/environments/wordle
|
||||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||||
@@ -292,6 +333,19 @@ function TasksetSource({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Glossary() {
|
||||||
|
return (
|
||||||
|
<dl className="flex flex-col gap-3">
|
||||||
|
{CORE_TERMS.map((entry) => (
|
||||||
|
<div key={entry.key} className="flex flex-col gap-0.5">
|
||||||
|
<dt className="text-ui font-medium text-fg">{entry.term}</dt>
|
||||||
|
<dd className="text-small text-fg-2">{entry.gloss}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -300,25 +354,17 @@ function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
|||||||
// a 1024px laptop and gives the whole page a horizontal scrollbar.
|
// a 1024px laptop and gives the whole page a horizontal scrollbar.
|
||||||
className="w-[min(90vw,820px)] p-4"
|
className="w-[min(90vw,820px)] p-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)] gap-4">
|
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)] gap-5">
|
||||||
<dl className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{CONCEPTS.map((concept) => (
|
<Glossary />
|
||||||
<div key={concept.term} className="flex flex-col gap-0.5">
|
|
||||||
<dt className="text-sm font-medium text-fg">{concept.term}</dt>
|
|
||||||
<dd className="text-xs leading-relaxed text-muted">{concept.gloss}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<NavigationMenuLink asChild>
|
<NavigationMenuLink asChild>
|
||||||
<Link
|
<Link to={ctaHref} className={cn(panelLink, '-ml-2 mt-1 w-fit')}>
|
||||||
to={ctaHref}
|
|
||||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
See all five on one recorded run
|
See all five on one recorded run
|
||||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</dl>
|
</div>
|
||||||
<TasksetSource />
|
<TasksetSource className="self-start" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -326,22 +372,17 @@ function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
|||||||
|
|
||||||
/* ------------------------------------------------------------------ mobile */
|
/* ------------------------------------------------------------------ mobile */
|
||||||
|
|
||||||
function MobileNav({
|
const sheetRow =
|
||||||
live,
|
'tap flex items-center justify-between gap-3 border-b border-border py-3 text-ui font-medium text-fg';
|
||||||
spec,
|
|
||||||
verticals,
|
function MobileNav(catalogue: Catalogue) {
|
||||||
ctaHref,
|
const { liveGroups, specGroups, cta } = catalogue;
|
||||||
}: {
|
|
||||||
live: DemoMeta[];
|
|
||||||
spec: DemoMeta[];
|
|
||||||
verticals: HeaderVertical[];
|
|
||||||
ctaHref: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon-touch" aria-label="Open menu">
|
<Button variant="ghost" size="touch" className="px-3" aria-label="Open menu">
|
||||||
<Menu aria-hidden="true" />
|
<Menu aria-hidden="true" />
|
||||||
|
<span aria-hidden="true">Menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
{/*
|
{/*
|
||||||
@@ -350,110 +391,95 @@ function MobileNav({
|
|||||||
long lineup reachable on a short phone, and `overscroll-contain` on it
|
long lineup reachable on a short phone, and `overscroll-contain` on it
|
||||||
stops the flick chaining into the page underneath.
|
stops the flick chaining into the page underneath.
|
||||||
*/}
|
*/}
|
||||||
<SheetContent side="right" className="w-[min(92vw,24rem)]">
|
<SheetContent side="right" className="w-[min(92vw,24rem)] rounded-l-xl shadow-overlay">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Menu</SheetTitle>
|
<SheetTitle className="font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
Menu
|
||||||
|
</SheetTitle>
|
||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
Demos, verticals, and how these environments work.
|
Every environment, how they work, and the rest of the site.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<SheetBody>
|
<SheetBody className="px-4 py-2">
|
||||||
<Accordion type="multiple" defaultValue={['demos']}>
|
{/* Environments open by default: the menu's first job is the catalogue. */}
|
||||||
<AccordionItem value="demos">
|
<Accordion type="multiple" defaultValue={['environments']}>
|
||||||
<AccordionTrigger>Demos</AccordionTrigger>
|
<AccordionItem value="environments">
|
||||||
<AccordionContent className="flex flex-col gap-0.5">
|
<AccordionTrigger>Environments</AccordionTrigger>
|
||||||
<PanelHeading>Live now</PanelHeading>
|
<AccordionContent className="flex flex-col">
|
||||||
{live.map((demo) => (
|
<PanelLabel className="text-fg">Live</PanelLabel>
|
||||||
<SheetClose asChild key={demo.slug}>
|
<GroupedRows
|
||||||
<DemoRow demo={demo} />
|
groups={liveGroups}
|
||||||
</SheetClose>
|
empty="No playable environment yet."
|
||||||
))}
|
wrap={(row, key) => (
|
||||||
<PanelHeading>Shipping next</PanelHeading>
|
<SheetClose asChild key={key}>
|
||||||
{spec.map((demo) => (
|
{row}
|
||||||
<SheetClose asChild key={demo.slug}>
|
</SheetClose>
|
||||||
<DemoRow demo={demo} muted />
|
)}
|
||||||
</SheetClose>
|
/>
|
||||||
))}
|
<PanelLabel className="mt-2 text-fg">Spec</PanelLabel>
|
||||||
|
<GroupedRows
|
||||||
|
groups={specGroups}
|
||||||
|
empty="Nothing written that is not built."
|
||||||
|
wrap={(row, key) => (
|
||||||
|
<SheetClose asChild key={key}>
|
||||||
|
{row}
|
||||||
|
</SheetClose>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Link
|
{/* Explicit `min-h-11`, not `.tap`: `.tap` lives in the
|
||||||
to="/demos"
|
components layer and the utility `min-h-10` in `panelLink`
|
||||||
className="tap inline-flex items-center gap-1 p-2.5 text-xs font-medium text-accent-fg"
|
would win over it, leaving a 40px target on a phone. */}
|
||||||
>
|
<Link to={routes.gallery} className={cn(panelLink, 'mt-1 min-h-11 w-fit')}>
|
||||||
Browse all demos
|
All environments
|
||||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
|
||||||
<AccordionItem value="verticals">
|
|
||||||
<AccordionTrigger>Verticals</AccordionTrigger>
|
|
||||||
<AccordionContent className="flex flex-col gap-0.5">
|
|
||||||
{verticals.map((vertical) => (
|
|
||||||
<SheetClose asChild key={vertical.slug}>
|
|
||||||
<Link
|
|
||||||
to={routes.vertical(vertical.slug)}
|
|
||||||
className="flex gap-3 rounded-lg p-2.5 hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<DemoIcon name={vertical.icon} className="mt-0.5 text-accent-fg" />
|
|
||||||
<span className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-medium text-fg">{vertical.title}</span>
|
|
||||||
<span className="text-xs leading-relaxed text-muted">
|
|
||||||
{vertical.persona}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</SheetClose>
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
|
|
||||||
<AccordionItem value="how">
|
<AccordionItem value="how">
|
||||||
<AccordionTrigger>How it works</AccordionTrigger>
|
<AccordionTrigger>How it works</AccordionTrigger>
|
||||||
<AccordionContent className="flex flex-col gap-3">
|
<AccordionContent className="flex flex-col gap-3">
|
||||||
<dl className="flex flex-col gap-3">
|
<Glossary />
|
||||||
{CONCEPTS.map((concept) => (
|
|
||||||
<div key={concept.term} className="flex flex-col gap-0.5">
|
|
||||||
<dt className="text-sm font-medium text-fg">{concept.term}</dt>
|
|
||||||
<dd className="text-xs leading-relaxed text-muted">{concept.gloss}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<TasksetSource />
|
<TasksetSource />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<a
|
<SheetClose asChild>
|
||||||
href={PIG_URL}
|
<Link to={routes.honesty} className={sheetRow}>
|
||||||
target="_blank"
|
Honesty
|
||||||
rel="noreferrer noopener"
|
<ArrowRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||||
className="tap mt-2 flex items-center justify-between border-b border-border py-3 text-sm font-medium text-fg"
|
</Link>
|
||||||
>
|
</SheetClose>
|
||||||
primeintellectgrowth.com
|
<a href={REPO_URL} target="_blank" rel="noreferrer noopener" className={sheetRow}>
|
||||||
<ArrowUpRight aria-hidden="true" className="size-4 text-muted" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={REPO_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
className="tap flex items-center justify-between border-b border-border py-3 text-sm font-medium text-fg"
|
|
||||||
>
|
|
||||||
Source on Gitea
|
Source on Gitea
|
||||||
<GitBranch aria-hidden="true" className="size-4 text-muted" />
|
<ArrowUpRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||||
|
</a>
|
||||||
|
<a href={PIG_URL} target="_blank" rel="noreferrer noopener" className={sheetRow}>
|
||||||
|
primeintellectgrowth.com
|
||||||
|
<ArrowUpRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className="flex items-center gap-1 pt-3">
|
<div className="grid grid-cols-2 gap-2 py-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle labelled />
|
||||||
<ContrastToggle />
|
<ContrastToggle labelled />
|
||||||
</div>
|
</div>
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
|
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button asChild size="lg" className="w-full">
|
<Button
|
||||||
<Link to={ctaHref}>Play the demo</Link>
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="min-h-12 w-full border border-accent-edge text-[0.9375rem]"
|
||||||
|
>
|
||||||
|
<Link to={cta.href}>
|
||||||
|
{cta.label}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
@@ -465,19 +491,20 @@ function MobileNav({
|
|||||||
/* ------------------------------------------------------------------ header */
|
/* ------------------------------------------------------------------ header */
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const { live, spec, verticals, ctaHref } = useLineup();
|
const catalogue = useCatalogue();
|
||||||
|
const { cta } = catalogue;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
// The height token already folds in --safe-top, so the padding and the
|
// The height token already folds in --safe-top (56px + safe-top at
|
||||||
// height come from the same source and a notch cannot push the row off
|
// EVERY width), so the padding and the height come from the same source
|
||||||
// the bottom edge of the bar.
|
// and a notch cannot push the row off the bottom edge of the bar.
|
||||||
className="sticky top-0 z-50 h-[var(--app-header-h)] border-b border-border bg-surface/80 pt-[var(--safe-top)] backdrop-blur-md supports-[backdrop-filter]:bg-surface/70"
|
className="sticky top-0 z-50 h-[var(--app-header-h)] border-b border-border bg-surface/85 pt-[var(--safe-top)] backdrop-blur-md"
|
||||||
>
|
>
|
||||||
<div className="mx-auto flex h-full max-w-canvas items-center gap-2 px-4 pl-[max(1rem,var(--safe-left))] pr-[max(1rem,var(--safe-right))]">
|
<div className={cn(s.shell, 'flex h-full items-center gap-2')}>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="tap flex shrink-0 items-center rounded-md pr-2 text-base"
|
className="tap -ml-2 flex shrink-0 items-center rounded-md px-2"
|
||||||
aria-label="PIG demo, home"
|
aria-label="PIG demo, home"
|
||||||
>
|
>
|
||||||
<Wordmark />
|
<Wordmark />
|
||||||
@@ -491,35 +518,24 @@ export function SiteHeader() {
|
|||||||
<NavigationMenu className="hidden h-full lg:flex" delayDuration={120}>
|
<NavigationMenu className="hidden h-full lg:flex" delayDuration={120}>
|
||||||
<NavigationMenuList>
|
<NavigationMenuList>
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuTrigger>Demos</NavigationMenuTrigger>
|
<NavigationMenuTrigger>Environments</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
<DemosPanel live={live} spec={spec} />
|
<EnvironmentsPanel {...catalogue} />
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger>Verticals</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent>
|
|
||||||
<VerticalsPanel verticals={verticals} />
|
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuTrigger>How it works</NavigationMenuTrigger>
|
<NavigationMenuTrigger>How it works</NavigationMenuTrigger>
|
||||||
<NavigationMenuContent>
|
<NavigationMenuContent>
|
||||||
<HowItWorksPanel ctaHref={ctaHref} />
|
<HowItWorksPanel ctaHref={cta.href} />
|
||||||
</NavigationMenuContent>
|
</NavigationMenuContent>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
<NavigationMenuLink
|
<NavigationMenuLink asChild>
|
||||||
href={PIG_URL}
|
<Link to={routes.honesty} className={navigationMenuTriggerStyle()}>
|
||||||
target="_blank"
|
Honesty
|
||||||
rel="noreferrer noopener"
|
</Link>
|
||||||
className={cn(navigationMenuTriggerStyle(), 'text-muted hover:text-fg')}
|
|
||||||
>
|
|
||||||
primeintellectgrowth.com
|
|
||||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
@@ -530,23 +546,28 @@ export function SiteHeader() {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<ContrastToggle />
|
<ContrastToggle />
|
||||||
<Button asChild variant="ghost" size="icon">
|
<Button asChild variant="ghost" size="icon">
|
||||||
<a
|
<a href={REPO_URL} target="_blank" rel="noreferrer noopener" aria-label="Source on Gitea">
|
||||||
href={REPO_URL}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
aria-label="Source on Gitea"
|
|
||||||
>
|
|
||||||
<GitBranch aria-hidden="true" />
|
<GitBranch aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button asChild size="touch" className="hidden lg:inline-flex">
|
{/* Desktop: the full verb + title, 40px. Phone: the verb only, so it
|
||||||
<Link to={ctaHref}>Play the demo</Link>
|
fits at 320px beside the menu button and is still 44px. */}
|
||||||
|
<Button asChild className="ml-1 hidden h-10 border border-accent-edge lg:inline-flex">
|
||||||
|
<Link to={cta.href}>
|
||||||
|
{cta.label}
|
||||||
|
<ArrowRight aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="touch" className="border border-accent-edge lg:hidden">
|
||||||
|
<Link to={cta.href} aria-label={cta.label}>
|
||||||
|
{cta.short}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="lg:hidden">
|
<div className="lg:hidden">
|
||||||
<MobileNav live={live} spec={spec} verticals={verticals} ctaHref={ctaHref} />
|
<MobileNav {...catalogue} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function SkipLink() {
|
|||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href="#main"
|
href="#main"
|
||||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:left-3 focus-visible:top-[max(0.75rem,var(--safe-top))] focus-visible:z-[60] focus-visible:inline-flex focus-visible:min-h-11 focus-visible:items-center focus-visible:rounded-md focus-visible:border focus-visible:border-border focus-visible:bg-surface focus-visible:px-4 focus-visible:text-sm focus-visible:font-medium focus-visible:text-fg focus-visible:shadow-lg"
|
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:left-3 focus-visible:top-[max(0.75rem,var(--safe-top))] focus-visible:z-[60] focus-visible:inline-flex focus-visible:min-h-11 focus-visible:items-center focus-visible:rounded-md focus-visible:border focus-visible:border-border focus-visible:bg-surface focus-visible:px-4 focus-visible:text-sm focus-visible:font-medium focus-visible:text-fg focus-visible:shadow-overlay"
|
||||||
>
|
>
|
||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { GLOSSARY } from '@/content/glossary';
|
||||||
|
import type { GlossaryEntry, GlossaryKey } from '@/content/glossary';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A glossed word inside running text.
|
||||||
|
*
|
||||||
|
* The glossary lives in `@/content/glossary`; this is the one component that
|
||||||
|
* renders it inline. A term is a dotted-underlined word of the same size as
|
||||||
|
* the words around it — never a 44px target inflating a sentence — with the
|
||||||
|
* gloss in a panel that opens on hover and on keyboard focus, and, because
|
||||||
|
* hover does not exist on a phone, pins open on tap. A pinned panel carries
|
||||||
|
* the 44px close target; the term itself keeps its inline size. Outside tap
|
||||||
|
* or Escape dismisses it.
|
||||||
|
*
|
||||||
|
* One panel, not a Radix Tooltip plus a popover. Radix's uncontrolled state
|
||||||
|
* (react-use-controllable-state 1.2.x under React 19) fires `onChange` in a
|
||||||
|
* post-commit effect, AFTER the freshly mounted content has registered its
|
||||||
|
* `tooltip.open` listener — so a tooltip opened by keyboard focus closes
|
||||||
|
* itself on the same commit. Measured, not guessed: the trigger went
|
||||||
|
* `closed → instant-open → closed` inside one tick. Hover survived only by
|
||||||
|
* accident of timing. A term the keyboard cannot open fails the whole point of
|
||||||
|
* glossing, so the panel is ours: three inputs, one boolean, no portal.
|
||||||
|
*
|
||||||
|
* Screen readers get the gloss through `aria-describedby` on a hidden span
|
||||||
|
* that is always in the DOM, so the description does not depend on which
|
||||||
|
* visual affordance happens to be open.
|
||||||
|
*
|
||||||
|
* Demos cannot import this (rule 9), so glossing happens in shell chrome and
|
||||||
|
* pages only; surfaces stay literal. A Term is never placed inside a link —
|
||||||
|
* it is a button, and a button inside an anchor is a keyboard trap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const byKey: ReadonlyMap<string, GlossaryEntry> = new Map(GLOSSARY.map((entry) => [entry.key, entry]));
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ scope */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "First occurrence per panel gets the underline; later ones do not."
|
||||||
|
*
|
||||||
|
* A scope is a set of keys already glossed within one panel. Terms claim their
|
||||||
|
* key in a layout effect and release it on unmount, so React StrictMode's
|
||||||
|
* mount/unmount/mount rehearsal cannot leave a key claimed by nobody. Without
|
||||||
|
* a provider every Term shares one page-wide scope, which is the right default
|
||||||
|
* for a page with a single panel.
|
||||||
|
*/
|
||||||
|
interface Scope {
|
||||||
|
claim: (key: string) => boolean;
|
||||||
|
release: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScope(): Scope {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return {
|
||||||
|
claim: (key) => {
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
release: (key) => {
|
||||||
|
seen.delete(key);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScopeContext = React.createContext<Scope>(createScope());
|
||||||
|
|
||||||
|
/** Wrap a tab panel in one of these so each panel gets its own "first use". */
|
||||||
|
export function TermScope({ children }: { children: React.ReactNode }) {
|
||||||
|
const scope = React.useMemo(createScope, []);
|
||||||
|
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- term */
|
||||||
|
|
||||||
|
export interface TermProps {
|
||||||
|
/** A glossary key, e.g. `'rollout'`. Typed, so a typo fails the build, not the reader. */
|
||||||
|
term: GlossaryKey;
|
||||||
|
/** The word as it appears in the sentence. Defaults to the glossary's own term. */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Term({ term, children, className }: TermProps) {
|
||||||
|
const entry = byKey.get(term);
|
||||||
|
const scope = React.useContext(ScopeContext);
|
||||||
|
const id = React.useId();
|
||||||
|
const [first, setFirst] = React.useState(false);
|
||||||
|
const [hovered, setHovered] = React.useState(false);
|
||||||
|
const [focused, setFocused] = React.useState(false);
|
||||||
|
const [pinned, setPinned] = React.useState(false);
|
||||||
|
const [align, setAlign] = React.useState<'left' | 'right'>('left');
|
||||||
|
const rootRef = React.useRef<HTMLSpanElement>(null);
|
||||||
|
const panelRef = React.useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const open = pinned || hovered || focused;
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!entry) return undefined;
|
||||||
|
const got = scope.claim(entry.key);
|
||||||
|
setFirst(got);
|
||||||
|
return () => {
|
||||||
|
if (got) scope.release(entry.key);
|
||||||
|
};
|
||||||
|
}, [scope, entry]);
|
||||||
|
|
||||||
|
// Outside tap or Escape dismisses. Listeners exist only while the panel is
|
||||||
|
// open, so a page with forty terms is not forty document listeners.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onPointer = (event: PointerEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setPinned(false);
|
||||||
|
setHovered(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setPinned(false);
|
||||||
|
setHovered(false);
|
||||||
|
// Stays dismissed until the next focus event, so Escape means "go
|
||||||
|
// away" and not "flicker".
|
||||||
|
setFocused(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', onPointer);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', onPointer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Anchor the panel to whichever edge keeps it on screen. Measured once per
|
||||||
|
// open, after paint, so the first frame is already in the right place.
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const panel = panelRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const rect = panel.getBoundingClientRect();
|
||||||
|
setAlign(rect.right > window.innerWidth - 16 ? 'right' : 'left');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// An unknown key renders the plain word. A sentence must never fail to
|
||||||
|
// render because the glossary is missing an entry.
|
||||||
|
if (!entry) return <>{children ?? term}</>;
|
||||||
|
|
||||||
|
const glossId = `${id}-gloss`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={rootRef} className="relative inline">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-describedby={glossId}
|
||||||
|
aria-expanded={pinned}
|
||||||
|
onClick={() => setPinned((value) => !value)}
|
||||||
|
// Touch pointers never hover; a tap is a click, handled above.
|
||||||
|
onPointerEnter={(event) => {
|
||||||
|
if (event.pointerType !== 'touch') setHovered(true);
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => setHovered(false)}
|
||||||
|
// Keyboard focus only. A mouse click also focuses the button, and
|
||||||
|
// opening on that would double up with the click toggle.
|
||||||
|
onFocus={(event) => {
|
||||||
|
if (event.currentTarget.matches(':focus-visible')) setFocused(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
className={cn(
|
||||||
|
// Inherit everything: the term is a word in a sentence, not a
|
||||||
|
// control that happens to hold a word.
|
||||||
|
'inline cursor-help appearance-none rounded-sm bg-transparent p-0 text-inherit [font:inherit] [letter-spacing:inherit]',
|
||||||
|
first
|
||||||
|
? 'underline decoration-border-strong decoration-dotted decoration-[1.5px] underline-offset-[0.2em]'
|
||||||
|
: 'no-underline',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children ?? entry.term}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span id={glossId} className="sr-only">
|
||||||
|
{entry.term}: {entry.gloss}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<span
|
||||||
|
ref={panelRef}
|
||||||
|
data-term-panel={pinned ? 'pinned' : 'hover'}
|
||||||
|
// Pinned, it is a small dialog with its own close control. Otherwise
|
||||||
|
// it is a purely visual echo of the sr-only description above, so it
|
||||||
|
// is hidden from the accessibility tree rather than read twice.
|
||||||
|
role={pinned ? 'dialog' : undefined}
|
||||||
|
aria-label={pinned ? entry.term : undefined}
|
||||||
|
aria-hidden={pinned ? undefined : true}
|
||||||
|
className={cn(
|
||||||
|
'absolute top-full z-50 mt-1.5 flex w-max max-w-[min(36ch,calc(100vw-2.5rem))] items-start gap-2 rounded-md border border-border bg-surface p-3 text-left font-sans text-[0.8125rem] font-normal normal-case leading-[1.45] tracking-normal text-fg-2 shadow-overlay',
|
||||||
|
pinned && 'pr-1',
|
||||||
|
align === 'left' ? 'left-0' : 'right-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('min-w-0', pinned && 'py-2')}>
|
||||||
|
<span className="mr-1.5 font-mono text-label uppercase tracking-label text-muted">
|
||||||
|
{entry.term}
|
||||||
|
</span>
|
||||||
|
{entry.gloss}
|
||||||
|
</span>
|
||||||
|
{pinned ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => setPinned(false)}
|
||||||
|
className="tap -mr-0.5 -mt-1 inline-flex shrink-0 items-center justify-center rounded-md text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,19 +23,52 @@ const LABEL: Record<Mode, string> = {
|
|||||||
* the wrong one and then swaps it — the flash this component exists to avoid.
|
* the wrong one and then swaps it — the flash this component exists to avoid.
|
||||||
* The placeholder is the same size as the button so the header does not reflow
|
* The placeholder is the same size as the button so the header does not reflow
|
||||||
* when it resolves.
|
* when it resolves.
|
||||||
|
*
|
||||||
|
* `labelled` is the mobile-sheet form: the same control with its current state
|
||||||
|
* printed beside the icon, because a row of two icon-only buttons in a menu
|
||||||
|
* reads as decoration. Both forms are 44px below `lg`; the icon form drops to
|
||||||
|
* 36px in the desktop bar.
|
||||||
*/
|
*/
|
||||||
export function ThemeToggle({ className }: { className?: string }) {
|
export function ThemeToggle({ className, labelled = false }: { className?: string; labelled?: boolean }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
React.useEffect(() => setMounted(true), []);
|
React.useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return <div className={cn('size-11 lg:size-9', className)} aria-hidden="true" />;
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(labelled ? 'h-11 w-32' : 'size-11 lg:size-9', className)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const current: Mode = theme === 'dark' || theme === 'light' ? theme : 'system';
|
const current: Mode = theme === 'dark' || theme === 'light' ? theme : 'system';
|
||||||
const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length] ?? 'system';
|
const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length] ?? 'system';
|
||||||
const Icon = current === 'light' ? Sun : current === 'dark' ? Moon : Laptop;
|
const Icon = current === 'light' ? Sun : current === 'dark' ? Moon : Laptop;
|
||||||
|
// The label states where you ARE and where the press takes you, because an
|
||||||
|
// icon-only toggle announced as just "Theme" tells a screen-reader user
|
||||||
|
// nothing about what pressing it will do.
|
||||||
|
const ariaLabel = `Theme: ${LABEL[current]}. Switch to ${LABEL[next].toLowerCase()}.`;
|
||||||
|
|
||||||
|
if (labelled) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
|
className={cn('justify-start', className)}
|
||||||
|
onClick={() => setTheme(next)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
<span className="flex flex-col items-start leading-tight">
|
||||||
|
<span className="font-mono text-label uppercase tracking-label text-muted">Theme</span>
|
||||||
|
<span className="text-ui text-fg">{LABEL[current]}</span>
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -44,10 +77,7 @@ export function ThemeToggle({ className }: { className?: string }) {
|
|||||||
size="icon-touch"
|
size="icon-touch"
|
||||||
className={cn('lg:size-9', className)}
|
className={cn('lg:size-9', className)}
|
||||||
onClick={() => setTheme(next)}
|
onClick={() => setTheme(next)}
|
||||||
// The label states where you ARE and where the press takes you, because
|
aria-label={ariaLabel}
|
||||||
// an icon-only toggle announced as just "Theme" tells a screen-reader
|
|
||||||
// user nothing about what pressing it will do.
|
|
||||||
aria-label={`Theme: ${LABEL[current]}. Switch to ${LABEL[next].toLowerCase()}.`}
|
|
||||||
title={`Theme: ${LABEL[current]}`}
|
title={`Theme: ${LABEL[current]}`}
|
||||||
>
|
>
|
||||||
<Icon aria-hidden="true" />
|
<Icon aria-hidden="true" />
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A static row of scored tiles, drawn from the tile tokens.
|
||||||
|
*
|
||||||
|
* The landing's "the grader computes" passage needs a board fragment beside
|
||||||
|
* it. It cannot import the word game's Surface — the site lane may never reach
|
||||||
|
* into `src/demos/*` — and a screenshot would go stale. So this is the shape of
|
||||||
|
* a scored row, painted with the same `--tile-*` tokens the real board uses,
|
||||||
|
* which means it follows the theme AND the high-contrast toggle without knowing
|
||||||
|
* either exists. It is a figure, not a game: nothing here is interactive.
|
||||||
|
*
|
||||||
|
* Each tile carries a second signal in high-contrast mode (● exact, ◆ present,
|
||||||
|
* nothing for absent), exactly as the real board does, so the figure reads for
|
||||||
|
* the same visitor the toggle exists for.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TileState = 'exact' | 'present' | 'absent';
|
||||||
|
|
||||||
|
/** The pattern the landing caption spells out: `G Y X X G`. */
|
||||||
|
const DEFAULT_PATTERN: readonly TileState[] = ['exact', 'present', 'absent', 'absent', 'exact'];
|
||||||
|
const DEFAULT_LETTERS = 'CRANE';
|
||||||
|
|
||||||
|
const STATE_WORD: Record<TileState, string> = {
|
||||||
|
exact: 'exact',
|
||||||
|
present: 'present',
|
||||||
|
absent: 'absent',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TILE_CLASS: Record<TileState, string> = {
|
||||||
|
// Glyph contrast on each fill is measured in index.css: 13.08 / 11.16 / 7.75
|
||||||
|
// (light 5.74) in the standard palette, and ≥4.74 in high contrast.
|
||||||
|
exact: 'border-tile-exact bg-tile-exact text-tile-exact-fg',
|
||||||
|
present: 'border-tile-present bg-tile-present text-tile-present-fg',
|
||||||
|
absent: 'border-tile-absent bg-tile-absent text-tile-absent-fg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLYPH: Record<TileState, string | null> = { exact: '●', present: '◆', absent: null };
|
||||||
|
|
||||||
|
export interface TileRowProps {
|
||||||
|
/** Five letters, one per tile. Uppercased for display. */
|
||||||
|
letters?: string;
|
||||||
|
/** One state per letter. Defaults to the `G Y X X G` pattern. */
|
||||||
|
pattern?: readonly TileState[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TileRow({
|
||||||
|
letters = DEFAULT_LETTERS,
|
||||||
|
pattern = DEFAULT_PATTERN,
|
||||||
|
className,
|
||||||
|
}: TileRowProps) {
|
||||||
|
const cells = Array.from(letters.toUpperCase()).map((letter, index) => ({
|
||||||
|
letter,
|
||||||
|
state: pattern[index] ?? 'absent',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// One sentence for a screen reader, instead of five unlabelled squares.
|
||||||
|
const label = cells.map((cell) => `${cell.letter} ${STATE_WORD[cell.state]}`).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={`Scored row: ${label}.`}
|
||||||
|
className={cn('flex w-fit gap-1 sm:gap-1.5', className)}
|
||||||
|
>
|
||||||
|
{cells.map((cell, index) => {
|
||||||
|
const glyph = GLYPH[cell.state];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
// Board letters are content, not a terminal: sans 600 uppercase,
|
||||||
|
// 22px rising to 24px from sm, the same as the real tiles.
|
||||||
|
'relative grid size-10 place-items-center rounded-tile border-2 font-sans text-[1.375rem] font-semibold uppercase leading-none sm:size-12 sm:text-2xl',
|
||||||
|
TILE_CLASS[cell.state],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cell.letter}
|
||||||
|
{glyph === null ? null : (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute bottom-0.5 right-0.5 text-[0.5em] leading-none opacity-0 [[data-contrast=high]_&]:opacity-100"
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two words, one tone shift: the brand carries the accent, the qualifier does
|
* Two words, two faces: "PIG" is a name and is set in the sans; "demo" is a
|
||||||
* not. Rendered as a single string for a screen reader so it is announced
|
* qualifier that reads like a tag, so it takes the mono the site uses for
|
||||||
* "PIG demo" rather than as two unrelated fragments.
|
* every label. One tone shift — the brand carries the accent, the qualifier
|
||||||
|
* does not — and a single sr-only string so a screen reader announces
|
||||||
|
* "PIG demo" rather than two unrelated fragments.
|
||||||
|
*
|
||||||
|
* This is the site's only mark. Prime Intellect's logo is never rendered here.
|
||||||
*/
|
*/
|
||||||
export function Wordmark({ className }: { className?: string }) {
|
export function Wordmark({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={cn('inline-flex items-baseline gap-1 font-semibold tracking-tight', className)}>
|
<span className={cn('inline-flex items-baseline gap-1.5 text-base', className)}>
|
||||||
<span aria-hidden="true" className="text-accent-fg">
|
<span aria-hidden="true" className="font-sans font-semibold tracking-tight text-accent-fg">
|
||||||
PIG
|
PIG
|
||||||
</span>
|
</span>
|
||||||
<span aria-hidden="true" className="font-normal lowercase text-muted">
|
<span aria-hidden="true" className="font-mono text-[0.8125rem] font-normal lowercase text-muted">
|
||||||
demo
|
demo
|
||||||
</span>
|
</span>
|
||||||
<span className="sr-only">PIG demo</span>
|
<span className="sr-only">PIG demo</span>
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function AccordionItem({
|
|||||||
return <AccordionPrimitive.Item className={cn('border-b border-border', className)} {...props} />;
|
return <AccordionPrimitive.Item className={cn('border-b border-border', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 44px rows: the accordion lives in the mobile sheet, where every row is a
|
||||||
|
* thumb target. The chevron is `border-strong` — the ≥3:1 non-text token — so
|
||||||
|
* it reads at rest in dark, where `muted` on a surface is text-weight ink and
|
||||||
|
* `border` is invisible.
|
||||||
|
*/
|
||||||
function AccordionTrigger({
|
function AccordionTrigger({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -22,7 +28,7 @@ function AccordionTrigger({
|
|||||||
<AccordionPrimitive.Header className="flex">
|
<AccordionPrimitive.Header className="flex">
|
||||||
<AccordionPrimitive.Trigger
|
<AccordionPrimitive.Trigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'tap flex flex-1 items-center justify-between gap-3 py-3 text-left text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg [&[data-state=open]>svg]:rotate-180',
|
'flex min-h-11 flex-1 items-center justify-between gap-3 py-2.5 text-left font-sans text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg [&[data-state=open]>svg]:rotate-180',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -30,7 +36,7 @@ function AccordionTrigger({
|
|||||||
{children}
|
{children}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="size-4 shrink-0 text-muted transition-transform duration-2 ease-enter"
|
className="size-4 shrink-0 text-border-strong transition-transform duration-2 ease-enter"
|
||||||
/>
|
/>
|
||||||
</AccordionPrimitive.Trigger>
|
</AccordionPrimitive.Trigger>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionPrimitive.Header>
|
||||||
|
|||||||
@@ -3,19 +3,31 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Chip (spec §9). One shape for every machine-made label on the site —
|
||||||
|
* LIVE, SPEC, RECORDED RUN, SYNTHETIC DATA, EDITED, VERIFIED IN YOUR BROWSER —
|
||||||
|
* so a visitor learns after the second one that this shape means "a thing we
|
||||||
|
* are telling you about the data". Mono, uppercase, 11px, 4px radius. Status
|
||||||
|
* pills (LIVE, SPEC) pass `rounded-full`; SYNTHETIC DATA passes
|
||||||
|
* `border-dashed border-warning` and the merge keeps it.
|
||||||
|
*
|
||||||
|
* Grounds are the `*-subtle` tokens, never an alpha tint: `bg-warning/10`
|
||||||
|
* vanishes on #0F0F0F. Every text/ground pair here is measured ≥ 4.5 in both
|
||||||
|
* themes (index.css carries the numbers).
|
||||||
|
*/
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium leading-5 transition-colors duration-1 ease-enter',
|
'inline-flex items-center gap-1 whitespace-nowrap rounded-sm border px-1.5 py-0.5 font-mono text-[0.6875rem] font-medium uppercase leading-[1.2] tracking-label transition-colors duration-1 ease-enter',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
||||||
outline: 'border-border text-muted',
|
outline: 'border-border-strong bg-transparent text-muted',
|
||||||
solid: 'border-transparent bg-brand text-accent-on',
|
solid: 'border-accent-edge bg-brand text-accent-on',
|
||||||
positive: 'border-transparent bg-positive/10 text-positive',
|
positive: 'border-transparent bg-accent-subtle text-positive',
|
||||||
warning: 'border-transparent bg-warning/10 text-warning',
|
warning: 'border-transparent bg-warning-subtle text-warning',
|
||||||
danger: 'border-transparent bg-danger/10 text-danger',
|
danger: 'border-transparent bg-danger-subtle text-danger',
|
||||||
info: 'border-transparent bg-info/10 text-info',
|
info: 'border-transparent bg-surface-3 text-info',
|
||||||
muted: 'border-border bg-surface-2 text-muted',
|
muted: 'border-transparent bg-surface-3 text-muted',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: 'default' },
|
defaultVariants: { variant: 'default' },
|
||||||
|
|||||||
@@ -5,32 +5,42 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* shadcn "new-york" button, retuned to PIG's tokens.
|
* shadcn "new-york" button, retuned to PIG's tokens (spec §9).
|
||||||
*
|
*
|
||||||
* Note `accent` in tailwind.config.js is the SUBTLE hover surface, not the
|
* Note `accent` in tailwind.config.js is the SUBTLE hover surface, not the
|
||||||
* brand — that mapping is deliberate and documented there. So the solid CTA
|
* brand — that mapping is deliberate and documented there. So the solid CTA
|
||||||
* uses `bg-brand text-accent-on`, never `bg-accent`.
|
* uses `bg-brand text-accent-on`, never `bg-accent`.
|
||||||
|
*
|
||||||
|
* Every variant carries a 1px border (transparent where it has no colour) so
|
||||||
|
* the variants line up to the pixel when they sit in one row; a borderless
|
||||||
|
* ghost next to a bordered outline is otherwise 2px shorter.
|
||||||
|
*
|
||||||
|
* The solid CTA's hover differs by theme on purpose. In dark the fill has a
|
||||||
|
* darker sibling (`brand/90`). In light the neon green has none — darkening it
|
||||||
|
* reads brown — so the hover flips to the subtle ground with the text green,
|
||||||
|
* and the accent-edge border is what keeps it reading as the same control.
|
||||||
*/
|
*/
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md border border-transparent font-sans text-sm font-medium transition-colors duration-1 ease-enter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-brand text-accent-on shadow-sm hover:bg-brand/90',
|
default:
|
||||||
secondary: 'bg-surface-2 text-fg hover:bg-surface-2/70',
|
'border-accent-edge bg-brand text-accent-on hover:bg-accent-subtle hover:text-accent-fg dark:hover:bg-brand/90 dark:hover:text-accent-on',
|
||||||
outline: 'border border-border bg-surface text-fg hover:bg-surface-2',
|
secondary: 'bg-surface-2 text-fg hover:bg-surface-3',
|
||||||
|
outline: 'border-border-strong bg-surface text-fg hover:bg-surface-2',
|
||||||
ghost: 'text-fg hover:bg-surface-2',
|
ghost: 'text-fg hover:bg-surface-2',
|
||||||
subtle: 'bg-accent-subtle text-accent-fg hover:bg-accent-subtle/70',
|
subtle: 'bg-accent-subtle text-accent-fg hover:border-accent-edge',
|
||||||
destructive: 'bg-danger text-white shadow-sm hover:bg-danger/90',
|
destructive: 'bg-danger text-danger-on hover:bg-danger/90',
|
||||||
link: 'text-accent-fg underline-offset-4 hover:underline',
|
link: 'text-accent-fg underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
// `h-9` is 36px, which is fine for a mouse and too small for a thumb.
|
// `h-9` is 36px, which is fine for a mouse and too small for a thumb.
|
||||||
// Anything a phone visitor taps gets `size="touch"` or the `.tap`
|
// Anything a phone visitor taps gets `size="touch"` or the `.tap`
|
||||||
// helper on top — see SiteHeader.
|
// helper on top — see SiteHeader.
|
||||||
sm: 'h-8 rounded-md px-3 text-xs [&_svg]:size-3.5',
|
sm: 'h-8 px-3 text-[0.8125rem] [&_svg]:size-3.5',
|
||||||
default: 'h-9 px-4 py-2 [&_svg]:size-4',
|
default: 'h-9 px-4 py-2 [&_svg]:size-4',
|
||||||
lg: 'h-11 rounded-lg px-6 [&_svg]:size-4',
|
lg: 'h-11 px-6 [&_svg]:size-4',
|
||||||
touch: 'min-h-11 px-4 py-2 [&_svg]:size-4',
|
touch: 'min-h-11 px-4 py-2 [&_svg]:size-4',
|
||||||
icon: 'size-9 [&_svg]:size-4',
|
icon: 'size-9 [&_svg]:size-4',
|
||||||
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
||||||
|
|||||||
@@ -2,30 +2,34 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/** `.card` is defined in src/index.css so the shell and the demos agree on
|
/**
|
||||||
* one surface treatment; this component is the React face of it. */
|
* `.card` is defined in src/index.css (12px, surface, 1px border, no shadow)
|
||||||
|
* so the shell and the demos agree on one surface treatment; this component
|
||||||
|
* is the React face of it. Where a card is a link the caller adds
|
||||||
|
* `hover:border-border-strong` — the border is the hover, never a shadow.
|
||||||
|
*/
|
||||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return <div className={cn('card text-fg', className)} {...props} />;
|
return <div className={cn('card text-fg', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return <div className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />;
|
return <div className={cn('flex flex-col gap-1.5 p-4 sm:p-5', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
|
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
|
||||||
return <h3 className={cn('font-semibold leading-tight tracking-tight', className)} {...props} />;
|
return <h3 className={cn('text-h3 text-fg', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||||
return <p className={cn('text-sm text-muted', className)} {...props} />;
|
return <p className={cn('text-small text-muted', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return <div className={cn('p-5 pt-0', className)} {...props} />;
|
return <div className={cn('p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return <div className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
return <div className={cn('flex items-center p-4 pt-0 sm:p-5 sm:pt-0', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ function DrawerOverlay({
|
|||||||
return <DrawerPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-fg/40', className)} {...props} />;
|
return <DrawerPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-fg/40', className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An overlay (spec §9): surface, 1px border, 12px on the free (top) edge,
|
||||||
|
* `shadow-overlay`. The handle is `border-strong`, not `border`: in dark the
|
||||||
|
* hairline is 1.14:1 against the surface and a handle nobody can see is not a
|
||||||
|
* drag affordance.
|
||||||
|
*/
|
||||||
function DrawerContent({
|
function DrawerContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
@@ -37,14 +43,17 @@ function DrawerContent({
|
|||||||
<DrawerOverlay />
|
<DrawerOverlay />
|
||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto max-h-[88svh] flex-col rounded-t-xl border border-border bg-surface pb-[var(--safe-bottom)]',
|
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto max-h-[88svh] flex-col rounded-t-xl border border-border bg-surface pb-[var(--safe-bottom)] shadow-overlay',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{/* The grab handle is decorative; the drawer is also closable with
|
{/* The grab handle is decorative; the drawer is also closable with
|
||||||
Escape and by the close control the caller renders. */}
|
Escape and by the close control the caller renders. */}
|
||||||
<div className="mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border" aria-hidden="true" />
|
<div
|
||||||
|
className="mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border-strong"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</DrawerPrimitive.Content>
|
</DrawerPrimitive.Content>
|
||||||
</DrawerPortal>
|
</DrawerPortal>
|
||||||
@@ -70,7 +79,10 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
|
|
||||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<DrawerPrimitive.Title className={cn('text-base font-semibold text-fg', className)} {...props} />
|
<DrawerPrimitive.Title
|
||||||
|
className={cn('font-sans text-base font-semibold text-fg', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +91,7 @@ function DrawerDescription({
|
|||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
return (
|
return (
|
||||||
<DrawerPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />
|
<DrawerPrimitive.Description className={cn('text-small text-muted', className)} {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,13 @@ function NavigationMenuList({
|
|||||||
|
|
||||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 40px rows at the desktop bar (spec §5: nav rows 40px desktop / 44px touch —
|
||||||
|
* this menu only renders from lg). Plain link items in the bar reuse this
|
||||||
|
* style, so the hover and open grounds are the same surface-2 for both.
|
||||||
|
*/
|
||||||
const navigationMenuTriggerStyle = cva(
|
const navigationMenuTriggerStyle = cva(
|
||||||
'group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:bg-surface-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-surface-2',
|
'group inline-flex h-10 w-max items-center justify-center gap-1 rounded-md px-3 py-2 font-sans text-sm font-medium text-fg transition-colors duration-1 ease-enter 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:pointer-events-none disabled:opacity-50 data-[state=open]:bg-surface-2 data-[state=open]:text-fg',
|
||||||
);
|
);
|
||||||
|
|
||||||
function NavigationMenuTrigger({
|
function NavigationMenuTrigger({
|
||||||
@@ -74,7 +79,7 @@ function NavigationMenuTrigger({
|
|||||||
{children}
|
{children}
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="relative top-px size-3.5 text-muted transition-transform duration-3 ease-enter group-data-[state=open]:rotate-180"
|
className="relative top-px size-3.5 text-border-strong transition-transform duration-3 ease-enter group-data-[state=open]:rotate-180 group-hover:text-fg group-data-[state=open]:text-fg"
|
||||||
/>
|
/>
|
||||||
</NavigationMenuPrimitive.Trigger>
|
</NavigationMenuPrimitive.Trigger>
|
||||||
);
|
);
|
||||||
@@ -112,7 +117,7 @@ function NavigationMenuViewport({
|
|||||||
<div className="absolute left-0 top-full z-50 flex justify-center">
|
<div className="absolute left-0 top-full z-50 flex justify-center">
|
||||||
<NavigationMenuPrimitive.Viewport
|
<NavigationMenuPrimitive.Viewport
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top overflow-hidden rounded-xl border border-border bg-surface text-fg shadow-xl',
|
'relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top overflow-hidden rounded-xl border border-border bg-surface text-fg shadow-overlay',
|
||||||
'transition-[width,height] duration-3 ease-enter',
|
'transition-[width,height] duration-3 ease-enter',
|
||||||
'data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
'md:w-[var(--radix-navigation-menu-viewport-width)]',
|
'md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
|||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thumb is `border-strong`, not `border`: a scrollbar is the only signal
|
||||||
|
* that a box scrolls, and `border` is 1.14:1 against a dark surface.
|
||||||
|
*/
|
||||||
function ScrollBar({
|
function ScrollBar({
|
||||||
className,
|
className,
|
||||||
orientation = 'vertical',
|
orientation = 'vertical',
|
||||||
@@ -19,7 +23,7 @@ function ScrollBar({
|
|||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border-strong" />
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,17 +22,24 @@ function SheetOverlay({ className, ...props }: React.ComponentProps<typeof Sheet
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An overlay (spec §9): surface, 1px border on the attached edge, 12px radius
|
||||||
|
* on the free edge, `shadow-overlay`. The side sheets default to
|
||||||
|
* `min(92vw, 24rem)` — the mobile menu's width from §5 — so the header needs
|
||||||
|
* no override. The free-edge radius is per side: a right sheet rounds its
|
||||||
|
* left corners, and so on.
|
||||||
|
*/
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
'fixed z-50 flex flex-col gap-0 bg-surface shadow-xl duration-3 ease-enter data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'fixed z-50 flex flex-col gap-0 bg-surface shadow-overlay duration-3 ease-enter data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
top: 'inset-x-0 top-0 border-b border-border pt-[var(--safe-top)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
top: 'inset-x-0 top-0 rounded-b-xl border-b border-border pt-[var(--safe-top)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
bottom:
|
bottom:
|
||||||
'inset-x-0 bottom-0 border-t border-border pb-[var(--safe-bottom)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
'inset-x-0 bottom-0 rounded-t-xl border-t border-border pb-[var(--safe-bottom)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
left: 'inset-y-0 left-0 h-full w-[min(88vw,22rem)] border-r border-border pl-[var(--safe-left)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
left: 'inset-y-0 left-0 h-full w-[min(92vw,24rem)] rounded-r-xl border-r border-border pl-[var(--safe-left)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||||
right:
|
right:
|
||||||
'inset-y-0 right-0 h-full w-[min(88vw,22rem)] border-l border-border pr-[var(--safe-right)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
'inset-y-0 right-0 h-full w-[min(92vw,24rem)] rounded-l-xl border-l border-border pr-[var(--safe-right)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { side: 'right' },
|
defaultVariants: { side: 'right' },
|
||||||
@@ -60,7 +67,7 @@ function SheetContent({
|
|||||||
{children}
|
{children}
|
||||||
{showClose ? (
|
{showClose ? (
|
||||||
<SheetPrimitive.Close
|
<SheetPrimitive.Close
|
||||||
className="tap absolute right-3 top-3 inline-flex items-center justify-center rounded-md p-2 text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
className="absolute right-2 top-[max(0.5rem,var(--safe-top))] inline-flex size-11 items-center justify-center rounded-md text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X aria-hidden="true" className="size-5" />
|
<X aria-hidden="true" className="size-5" />
|
||||||
@@ -113,7 +120,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
|||||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
return (
|
return (
|
||||||
<SheetPrimitive.Title
|
<SheetPrimitive.Title
|
||||||
className={cn('text-base font-semibold text-fg', className)}
|
className={cn('font-sans text-base font-semibold text-fg', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -123,7 +130,9 @@ function SheetDescription({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
return <SheetPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />;
|
return (
|
||||||
|
<SheetPrimitive.Description className={cn('text-small text-muted', className)} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { cn } from '@/lib/utils';
|
|||||||
/**
|
/**
|
||||||
* `bg-muted` would be wrong here: in this palette `muted` is the muted TEXT
|
* `bg-muted` would be wrong here: in this palette `muted` is the muted TEXT
|
||||||
* colour, a mid grey that reads as a filled block rather than a placeholder.
|
* colour, a mid grey that reads as a filled block rather than a placeholder.
|
||||||
* The placeholder surface is `surface-2`.
|
* The placeholder ground is `surface-3` — one step above the card it sits in,
|
||||||
|
* so it reads on a surface in both themes (spec §9).
|
||||||
*/
|
*/
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
className={cn('animate-pulse rounded-md bg-surface-3', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ import * as SliderPrimitive from '@radix-ui/react-slider';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One thumb per value, not one hard-coded thumb: the reward editor drives this
|
* The reward editor's control (spec §9). Track 6px on surface-3, range in the
|
||||||
* with a single weight today and a range tomorrow, and a single-thumb slider
|
* text green (`accent-fg` equals `accent` in dark and is the only green that
|
||||||
* fed a two-value array silently drops the second value.
|
* reads on paper), a 20px square-ish thumb with a 2px green edge and no
|
||||||
|
* shadow. The thumb element itself is a 44px square around the 20px knob, so
|
||||||
|
* the slider handle a phone measures is fingertip-sized and not the knob; the
|
||||||
|
* root's `py-3` keeps the row the same 44px band either side of it.
|
||||||
|
*
|
||||||
|
* "Physical" is the hover/active pair: the thumb tints on hover, grows a
|
||||||
|
* little under the pointer, and swaps to a grabbing cursor. Nothing here
|
||||||
|
* animates layout — `transform` only, clamped by the reduced-motion block.
|
||||||
|
*
|
||||||
|
* One thumb per value, not one hard-coded thumb: the editor drives this with
|
||||||
|
* a single weight today and a range tomorrow, and a single-thumb slider fed a
|
||||||
|
* two-value array silently drops the second value.
|
||||||
*/
|
*/
|
||||||
function Slider({
|
function Slider({
|
||||||
className,
|
className,
|
||||||
@@ -17,19 +28,32 @@ function Slider({
|
|||||||
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
||||||
return (
|
return (
|
||||||
<SliderPrimitive.Root
|
<SliderPrimitive.Root
|
||||||
className={cn('relative flex w-full touch-none select-none items-center py-2', className)}
|
className={cn(
|
||||||
|
'group relative flex w-full cursor-pointer touch-none select-none items-center py-3 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...(value === undefined ? {} : { value })}
|
{...(value === undefined ? {} : { value })}
|
||||||
{...(defaultValue === undefined ? {} : { defaultValue })}
|
{...(defaultValue === undefined ? {} : { defaultValue })}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-2">
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-3">
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-brand" />
|
<SliderPrimitive.Range className="absolute h-full bg-accent-fg" />
|
||||||
</SliderPrimitive.Track>
|
</SliderPrimitive.Track>
|
||||||
{Array.from({ length: thumbCount }, (_, i) => (
|
{Array.from({ length: thumbCount }, (_, i) => (
|
||||||
|
// The focusable thumb is a 44px square (the `role="slider"` element a
|
||||||
|
// finger actually lands on); the visible 20px knob is a child. Radix
|
||||||
|
// centres the thumb with a -50% translate, so the larger box changes
|
||||||
|
// nothing about where the knob sits. The hover/active growth and the
|
||||||
|
// focus ring are drawn on the knob, not on the invisible square.
|
||||||
<SliderPrimitive.Thumb
|
<SliderPrimitive.Thumb
|
||||||
key={i}
|
key={i}
|
||||||
className="block size-5 rounded-full border-2 border-brand bg-surface shadow-sm transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50"
|
className="group/thumb flex size-11 cursor-grab items-center justify-center focus-visible:outline-none active:cursor-grabbing disabled:pointer-events-none"
|
||||||
/>
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="block size-5 rounded-md border-2 border-accent-fg bg-surface transition-[transform,background-color] duration-1 ease-enter group-hover/thumb:scale-110 group-hover/thumb:bg-accent-subtle group-focus-visible/thumb:ring-2 group-focus-visible/thumb:ring-ring group-focus-visible/thumb:ring-offset-2 group-focus-visible/thumb:ring-offset-bg group-active/thumb:scale-110 group-active/thumb:bg-accent-subtle"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Thumb>
|
||||||
))}
|
))}
|
||||||
</SliderPrimitive.Root>
|
</SliderPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,23 +5,67 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
const Tabs = TabsPrimitive.Root;
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
/**
|
||||||
|
* Two looks, one primitive (spec §9).
|
||||||
|
*
|
||||||
|
* `segment` — a bordered surface-2 rail with a surface-3 pill for the active
|
||||||
|
* trigger. The default, so every nested caller (Reasoning | Model call) keeps
|
||||||
|
* compiling unchanged. 36px triggers at lg, 44px below.
|
||||||
|
*
|
||||||
|
* `underline` — the site's primary navigation: a transparent full-width rail
|
||||||
|
* with a hairline underneath and a 2px `tab-underline` bar on the active
|
||||||
|
* trigger, flush with the rule. Triggers are 44px at every width; equal
|
||||||
|
* columns below sm so four tabs fit 390px without a scroller, natural width
|
||||||
|
* from sm. The two must never look alike — primary and secondary navigation
|
||||||
|
* are told apart by shape.
|
||||||
|
*
|
||||||
|
* The variant reaches the triggers by context, not by prop: Radix gives a
|
||||||
|
* trigger no handle on its list, and a `variant` prop on every trigger would
|
||||||
|
* be an API change that also lets one trigger disagree with its rail.
|
||||||
|
*/
|
||||||
|
type TabsVariant = 'segment' | 'underline';
|
||||||
|
|
||||||
|
const TabsVariantContext = React.createContext<TabsVariant>('segment');
|
||||||
|
|
||||||
|
const listStyles: Record<TabsVariant, string> = {
|
||||||
|
segment:
|
||||||
|
'inline-flex items-center justify-start gap-1 rounded-md border border-border bg-surface-2 p-1 text-muted',
|
||||||
|
underline: 'flex w-full items-stretch justify-start gap-0 border-b border-border text-muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerStyles: Record<TabsVariant, string> = {
|
||||||
|
segment:
|
||||||
|
'min-h-11 rounded-md px-3 py-1.5 lg:min-h-9 hover:text-fg data-[state=active]:bg-surface-3 data-[state=active]:text-fg',
|
||||||
|
// `after:-bottom-px` drops the bar onto the rail's 1px rule so the two read
|
||||||
|
// as one line; the bar is always present (transparent) so activation only
|
||||||
|
// changes colour and never shifts layout.
|
||||||
|
underline:
|
||||||
|
'relative min-h-11 min-w-0 flex-1 px-2 text-[0.8125rem] sm:flex-none sm:px-3 sm:text-sm hover:text-fg data-[state=active]:text-fg after:pointer-events-none after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:bg-transparent after:transition-colors after:duration-2 after:ease-enter after:content-[""] data-[state=active]:after:bg-tab-underline',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {
|
||||||
|
variant?: TabsVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, variant = 'segment', ...props }: TabsListProps) {
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.List
|
<TabsVariantContext.Provider value={variant}>
|
||||||
className={cn(
|
<TabsPrimitive.List
|
||||||
'inline-flex items-center justify-start gap-1 rounded-lg border border-border bg-surface-2 p-1 text-muted',
|
data-variant={variant}
|
||||||
className,
|
className={cn(listStyles[variant], className)}
|
||||||
)}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
</TabsVariantContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
const variant = React.useContext(TabsVariantContext);
|
||||||
return (
|
return (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
className={cn(
|
className={cn(
|
||||||
'tap inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm',
|
'inline-flex items-center justify-center whitespace-nowrap font-sans text-sm font-medium transition-colors duration-1 ease-enter focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
triggerStyles[variant],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -30,7 +74,15 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
'mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const TooltipProvider = TooltipPrimitive.Provider;
|
|||||||
const Tooltip = TooltipPrimitive.Root;
|
const Tooltip = TooltipPrimitive.Root;
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tooltip is a sentence (a gloss, a unit), so it is sans 13 in prose ink and
|
||||||
|
* capped at 36ch — never mono, never uppercase. It is an overlay, so it is the
|
||||||
|
* one flat-looking thing allowed a shadow.
|
||||||
|
*/
|
||||||
function TooltipContent({
|
function TooltipContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 6,
|
sideOffset = 6,
|
||||||
@@ -19,7 +24,7 @@ function TooltipContent({
|
|||||||
className={cn(
|
className={cn(
|
||||||
// z-50 and not z-10: the tooltip has to clear the sticky header,
|
// z-50 and not z-10: the tooltip has to clear the sticky header,
|
||||||
// which is itself z-50 and creates a stacking context of its own.
|
// which is itself z-50 and creates a stacking context of its own.
|
||||||
'z-50 overflow-hidden rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-fg shadow-md',
|
'z-50 max-w-[36ch] overflow-hidden rounded-md border border-border bg-surface px-2.5 py-1.5 font-sans text-small text-fg-2 shadow-overlay',
|
||||||
'duration-1 ease-enter animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
'duration-1 ease-enter animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
'data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
'data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* The four parts of an environment, as data.
|
||||||
|
*
|
||||||
|
* This is the site's one transferable piece of explanation. The landing strip
|
||||||
|
* (`DefinitionStrip`) and every demo page's four boxes (`EnvAnatomy`) both read
|
||||||
|
* these strings, so a visitor who learns the labels on the word game reads the
|
||||||
|
* fraud screen as "same machine, different grader". That only works if the
|
||||||
|
* four labels never change between surfaces — which is why they live here and
|
||||||
|
* nowhere else. Do not paraphrase them in a page.
|
||||||
|
*
|
||||||
|
* `key` is the field name on `DemoModule.anatomy`, so a demo's own text for
|
||||||
|
* each part is looked up with it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DemoModule } from '@/lib/demo-kit/types';
|
||||||
|
|
||||||
|
export type AnatomyKey = keyof DemoModule['anatomy'];
|
||||||
|
|
||||||
|
export interface AnatomyTerm {
|
||||||
|
key: AnatomyKey;
|
||||||
|
/** Zero-padded ordinal, rendered in mono: `01`–`04`. */
|
||||||
|
index: string;
|
||||||
|
/** The label. Sans, 600. The same four words on every surface. */
|
||||||
|
kicker: string;
|
||||||
|
/** One line, for the landing strip. A sentence, not a fragment. */
|
||||||
|
gloss: string;
|
||||||
|
/** The question the box answers, for the full `EnvAnatomy`. */
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANATOMY: readonly AnatomyTerm[] = [
|
||||||
|
{
|
||||||
|
key: 'task',
|
||||||
|
index: '01',
|
||||||
|
kicker: 'The task',
|
||||||
|
gloss: 'What the agent is asked to do.',
|
||||||
|
question: 'What is the agent asked to do?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
index: '02',
|
||||||
|
kicker: 'Legal actions',
|
||||||
|
gloss: 'What it is allowed to do.',
|
||||||
|
question: 'What is it allowed to do?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'grader',
|
||||||
|
index: '03',
|
||||||
|
kicker: 'The grader',
|
||||||
|
gloss: 'Code that computes, never a judge.',
|
||||||
|
question: 'Who decides whether it was good?',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'score',
|
||||||
|
index: '04',
|
||||||
|
kicker: 'The score that moves',
|
||||||
|
gloss: 'The number you train against.',
|
||||||
|
question: 'What number does that produce?',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One term by its key. Throws at build time rather than rendering a blank box. */
|
||||||
|
export function anatomyTerm(key: AnatomyKey): AnatomyTerm {
|
||||||
|
const term = ANATOMY.find((t) => t.key === key);
|
||||||
|
if (!term) throw new Error(`No anatomy term "${key}".`);
|
||||||
|
return term;
|
||||||
|
}
|
||||||
@@ -59,6 +59,13 @@ export const trainingResult = {
|
|||||||
method: 'SFT warm-up, then multi-turn RL with group-relative advantages (GRPO)',
|
method: 'SFT warm-up, then multi-turn RL with group-relative advantages (GRPO)',
|
||||||
/** From the same README: 20 held-out words, 3 rollouts each. */
|
/** From the same README: 20 held-out words, 3 rollouts each. */
|
||||||
evalDescription: '20 held-out words the model never trained on, played 3 times each',
|
evalDescription: '20 held-out words the model never trained on, played 3 times each',
|
||||||
|
/**
|
||||||
|
* Rendered wherever the number is, in these words. Home and Honesty both
|
||||||
|
* print it, and a caveat that appears on one page and not the other is the
|
||||||
|
* kind of thing a careful reader notices first.
|
||||||
|
*/
|
||||||
|
caveat:
|
||||||
|
'We quote the win rate only. The reward numbers in that write-up span versions of the environment and were never re-measured together.',
|
||||||
source: {
|
source: {
|
||||||
label: 'prime-rl / examples / basic / wordle',
|
label: 'prime-rl / examples / basic / wordle',
|
||||||
href: 'https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/basic/wordle',
|
href: 'https://github.com/PrimeIntellect-ai/prime-rl/tree/main/examples/basic/wordle',
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* Every piece of jargon the site uses, with the one-sentence gloss a visitor
|
||||||
|
* gets when they hover or tap it.
|
||||||
|
*
|
||||||
|
* One list, two audiences. The five `core` terms are the vocabulary of the
|
||||||
|
* idea itself and are rendered as a set: in the header's "How it works" panel
|
||||||
|
* and on the landing page. The extended terms are glossed inline, one at a
|
||||||
|
* time, by `Term` wherever a page or the shell first uses the word.
|
||||||
|
*
|
||||||
|
* Glosses are sentences a board member can repeat. They say what the thing IS
|
||||||
|
* before they say what it is for, and they never lean on another term from
|
||||||
|
* this list to do it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GlossaryKey =
|
||||||
|
| 'environment'
|
||||||
|
| 'rollout'
|
||||||
|
| 'reward'
|
||||||
|
| 'harness'
|
||||||
|
| 'held-out'
|
||||||
|
| 'seed'
|
||||||
|
| 'arm'
|
||||||
|
| 'objective'
|
||||||
|
| 'counterweight'
|
||||||
|
| 'gate'
|
||||||
|
| 'intervention'
|
||||||
|
| 'verify'
|
||||||
|
| 'taskset'
|
||||||
|
| 'verifiers'
|
||||||
|
| 'tokens'
|
||||||
|
| 'latency';
|
||||||
|
|
||||||
|
export interface GlossaryEntry {
|
||||||
|
key: GlossaryKey;
|
||||||
|
/** As it is written in running text. Sentence case. */
|
||||||
|
term: string;
|
||||||
|
gloss: string;
|
||||||
|
/** One of the five rendered as a set. */
|
||||||
|
core: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GLOSSARY: readonly GlossaryEntry[] = [
|
||||||
|
{
|
||||||
|
key: 'environment',
|
||||||
|
term: 'Environment',
|
||||||
|
gloss:
|
||||||
|
'The task, the legal moves and the grader, packaged so anyone can install and run it.',
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rollout',
|
||||||
|
term: 'Rollout',
|
||||||
|
gloss: 'One episode. The model acts, the environment answers, and every turn is recorded.',
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'reward',
|
||||||
|
term: 'Reward',
|
||||||
|
gloss: 'The number the run is scored on. You write it, so you decide what "good" means.',
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'harness',
|
||||||
|
term: 'Harness',
|
||||||
|
gloss: 'The runner that plays a taskset against a model and keeps the receipts.',
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'held-out',
|
||||||
|
term: 'Held-out grading',
|
||||||
|
gloss:
|
||||||
|
'Scored on problems the model has never seen, which is the only way the score means anything.',
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'seed',
|
||||||
|
term: 'Seed',
|
||||||
|
gloss:
|
||||||
|
'The number that fixes the board. The same seed reproduces the same hidden word, so two agents can be compared on the same puzzle.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'arm',
|
||||||
|
term: 'Arm',
|
||||||
|
gloss:
|
||||||
|
'One model under one setting, with its recorded runs. The ranking compares arms, not companies.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'objective',
|
||||||
|
term: 'Objective',
|
||||||
|
gloss:
|
||||||
|
'What the task is for: the term the agent is paid to maximise. Every other term in the reward exists to keep this one honest.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'counterweight',
|
||||||
|
term: 'Counterweight',
|
||||||
|
gloss:
|
||||||
|
'The term that takes points away. It pulls against the objective so the crude way of winning does not pay.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gate',
|
||||||
|
term: 'Gate',
|
||||||
|
gloss:
|
||||||
|
'A term every good attempt also scores full marks on. It rules out cheating; it does not rank.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'intervention',
|
||||||
|
term: 'Intervention',
|
||||||
|
gloss:
|
||||||
|
'Something done to a run that was not training: a changed prompt, a hint. Always labelled, never presented as a training result.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'verify',
|
||||||
|
term: 'Verify',
|
||||||
|
gloss:
|
||||||
|
'Re-run the grader in your browser over the recorded moves and compare the answer to the number shipped in the file.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'taskset',
|
||||||
|
term: 'Taskset',
|
||||||
|
gloss: 'The list of problems an environment draws from. On the word game, the hidden words.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'verifiers',
|
||||||
|
term: 'verifiers',
|
||||||
|
gloss:
|
||||||
|
'Prime Intellect’s open-source library for writing environments. Every environment on this site is built with it.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
term: 'Tokens',
|
||||||
|
gloss:
|
||||||
|
'The units a model reads and writes. The counts here are what the recording measured, not a price.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'latency',
|
||||||
|
term: 'Latency',
|
||||||
|
gloss:
|
||||||
|
'Wall-clock time per model call, as recorded on that day against that endpoint. Not a benchmark.',
|
||||||
|
core: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The five rendered as a set, in the order they are taught. */
|
||||||
|
export const CORE_TERMS: readonly GlossaryEntry[] = GLOSSARY.filter((entry) => entry.core);
|
||||||
|
|
||||||
|
/** One entry by key. Throws at build time rather than rendering an empty tooltip. */
|
||||||
|
export function glossaryEntry(key: GlossaryKey): GlossaryEntry {
|
||||||
|
const entry = GLOSSARY.find((e) => e.key === key);
|
||||||
|
if (!entry) throw new Error(`No glossary entry "${key}".`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
* This is the ONLY place the marketing pages touch the registry. Home, Gallery
|
* This is the ONLY place the marketing pages touch the registry. Home, Gallery
|
||||||
* and Vertical all read demos through here, so when the registry's export name
|
* and Vertical all read demos through here, so when the registry's export name
|
||||||
* or path moves, it moves in one line instead of in four pages.
|
* or path moves, it moves in one line instead of in four pages.
|
||||||
|
*
|
||||||
|
* Every count a page prints comes from a list in this file. A typed "twelve"
|
||||||
|
* on a site whose whole argument is that its claims are checkable goes stale
|
||||||
|
* the first time a demo ships.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { demos } from '@/lib/demo-kit/registry';
|
import { demos } from '@/lib/demo-kit/registry';
|
||||||
@@ -38,6 +42,8 @@ export const allDemos: readonly DemoMeta[] = demos;
|
|||||||
|
|
||||||
export const liveDemos: readonly DemoMeta[] = allDemos.filter((d) => d.status === 'live');
|
export const liveDemos: readonly DemoMeta[] = allDemos.filter((d) => d.status === 'live');
|
||||||
|
|
||||||
|
export const specDemos: readonly DemoMeta[] = allDemos.filter((d) => d.status === 'spec');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The demo the site leads with. `reference` is the hello-world vertical, and
|
* The demo the site leads with. `reference` is the hello-world vertical, and
|
||||||
* the first live one in it is the front door; if there is none yet, any live
|
* the first live one in it is the front door; if there is none yet, any live
|
||||||
@@ -74,3 +80,58 @@ export function verticalForDemo(demo: DemoMeta): VerticalEntry | undefined {
|
|||||||
|
|
||||||
/** The lineup, in the order we would build it. */
|
/** The lineup, in the order we would build it. */
|
||||||
export const lineup: readonly VerticalEntry[] = [...VERTICALS].sort((a, b) => a.rank - b.rank);
|
export const lineup: readonly VerticalEntry[] = [...VERTICALS].sort((a, b) => a.rank - b.rank);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lineup entries with nothing behind them: no live demo and no published spec
|
||||||
|
* demo. This is the honest count for "environments that do not exist", and it
|
||||||
|
* shrinks by one every time a proposal is built or specified.
|
||||||
|
*/
|
||||||
|
export const unbuiltLineup: readonly VerticalEntry[] = lineup.filter(
|
||||||
|
(v) => v.key === null || demosForVertical(v.key).length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- format */
|
||||||
|
|
||||||
|
/** `4` → `04`. Rank numerals and counts in mono labels. */
|
||||||
|
export function pad2(n: number): string {
|
||||||
|
return String(Math.max(0, Math.trunc(n))).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORDS = [
|
||||||
|
'zero',
|
||||||
|
'one',
|
||||||
|
'two',
|
||||||
|
'three',
|
||||||
|
'four',
|
||||||
|
'five',
|
||||||
|
'six',
|
||||||
|
'seven',
|
||||||
|
'eight',
|
||||||
|
'nine',
|
||||||
|
'ten',
|
||||||
|
'eleven',
|
||||||
|
'twelve',
|
||||||
|
'thirteen',
|
||||||
|
'fourteen',
|
||||||
|
'fifteen',
|
||||||
|
'sixteen',
|
||||||
|
'seventeen',
|
||||||
|
'eighteen',
|
||||||
|
'nineteen',
|
||||||
|
'twenty',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A count as a word inside a sentence: "Two of these are built". Digits past
|
||||||
|
* twenty, because "twenty-three of these" is where a word stops reading.
|
||||||
|
*/
|
||||||
|
export function countWord(n: number): string {
|
||||||
|
const whole = Math.max(0, Math.trunc(n));
|
||||||
|
return WORDS[whole] ?? String(whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capitalised, for a sentence that starts with the number. */
|
||||||
|
export function CountWord(n: number): string {
|
||||||
|
const word = countWord(n);
|
||||||
|
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
* characters of Tailwind copied into five pages and drifting apart. Strings,
|
* characters of Tailwind copied into five pages and drifting apart. Strings,
|
||||||
* not components, so nothing here reaches for React.
|
* not components, so nothing here reaches for React.
|
||||||
*
|
*
|
||||||
|
* Two rules from the design spec are enforced by what is and is not in here:
|
||||||
|
*
|
||||||
|
* 1. Data is mono, sentences are sans. The `label`, `chip*`, `monoData` and
|
||||||
|
* `codeBlock` styles carry `font-mono`; nothing with a verb in it does.
|
||||||
|
* 2. Nothing that explains is smaller than 16px. `prose` is the floor, and
|
||||||
|
* `small` / `caption` exist for metadata, never for a paragraph.
|
||||||
|
*
|
||||||
|
* Weight ceiling is 600 — the type scale in tailwind.config.js bakes weight
|
||||||
|
* into each size, so `font-bold` never appears on a page.
|
||||||
|
*
|
||||||
* Horizontal padding is written against the safe-area tokens rather than a
|
* Horizontal padding is written against the safe-area tokens rather than a
|
||||||
* flat value: on a notched phone in landscape, a flat `px-5` puts the first
|
* flat value: on a notched phone in landscape, a flat `px-5` puts the first
|
||||||
* character of every heading under the rounded corner.
|
* character of every heading under the rounded corner.
|
||||||
@@ -15,47 +25,109 @@
|
|||||||
export const shell =
|
export const shell =
|
||||||
'mx-auto w-full max-w-canvas pl-[max(1.25rem,var(--safe-left))] pr-[max(1.25rem,var(--safe-right))] sm:pl-[max(2rem,var(--safe-left))] sm:pr-[max(2rem,var(--safe-right))]';
|
'mx-auto w-full max-w-canvas pl-[max(1.25rem,var(--safe-left))] pr-[max(1.25rem,var(--safe-right))] sm:pl-[max(2rem,var(--safe-left))] sm:pr-[max(2rem,var(--safe-right))]';
|
||||||
|
|
||||||
/** Vertical rhythm between top-level sections. */
|
/** Vertical rhythm between top-level sections. The language's density, not an essay's. */
|
||||||
export const section = 'py-12 sm:py-16';
|
export const section = 'py-12 sm:py-16';
|
||||||
|
|
||||||
/** Small uppercase label above a section heading. */
|
/* ------------------------------------------------------------------- type */
|
||||||
export const eyebrow = 'text-xs font-semibold uppercase tracking-[0.14em] text-muted';
|
|
||||||
|
|
||||||
export const h1 =
|
/** Mono small-caps section label. The same style as every chip and table head. */
|
||||||
'text-[2rem] leading-[1.08] font-extrabold tracking-tight text-fg sm:text-5xl lg:text-6xl';
|
export const label = 'font-mono text-label uppercase text-muted';
|
||||||
|
|
||||||
export const h2 = 'text-2xl font-bold tracking-tight text-fg sm:text-3xl';
|
/** Kept under its old name: the shell and the router read `eyebrow`. */
|
||||||
|
export const eyebrow = label;
|
||||||
|
|
||||||
export const h3 = 'text-base font-semibold text-fg';
|
/** The landing h1 only. 36 → 48 → 56. */
|
||||||
|
export const display = 'text-display text-fg lg:text-display-lg xl:text-display-xl';
|
||||||
|
|
||||||
export const lede = 'text-lg leading-relaxed text-muted sm:text-xl';
|
/** Every other page's h1. 28 → 32. */
|
||||||
|
export const h1 = 'text-h1 text-fg lg:text-h1-lg';
|
||||||
|
|
||||||
export const prose = 'text-[0.9375rem] leading-relaxed text-muted';
|
export const h2 = 'text-h2 text-fg lg:text-h2-lg';
|
||||||
|
|
||||||
/** Filled call to action. 44px tall via `.tap`. */
|
export const h3 = 'text-h3 text-fg';
|
||||||
export const btnPrimary =
|
|
||||||
'tap inline-flex items-center justify-center gap-2 rounded-lg bg-brand px-5 py-2.5 text-sm font-semibold text-accent-on transition-colors duration-2 ease-enter hover:bg-accent-fg';
|
/** Under an h1. Prose ink, 72ch. */
|
||||||
|
export const lede = 'max-w-measure-wide text-lede text-fg-2 lg:text-lede-lg';
|
||||||
|
|
||||||
|
/** The education floor: 16/1.6, prose ink, 62ch. Never smaller. */
|
||||||
|
export const prose = 'max-w-measure text-prose text-fg-2';
|
||||||
|
|
||||||
|
/** Metadata beside a thing, never a paragraph. */
|
||||||
|
export const small = 'text-small text-muted';
|
||||||
|
|
||||||
|
export const caption = 'text-caption text-muted';
|
||||||
|
|
||||||
|
/** A number or an identifier in running chrome. */
|
||||||
|
export const monoData = 'font-mono text-mono-data';
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- buttons */
|
||||||
|
|
||||||
|
const btnBase =
|
||||||
|
'tap inline-flex items-center justify-center gap-2 rounded-md px-5 py-2.5 text-ui font-medium transition-colors duration-1 ease-enter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled call to action. The 1px `accent-edge` border is what makes it a
|
||||||
|
* control on a light projector, where the fill is 1.47:1 against white. The
|
||||||
|
* neon fill has no darker sibling on paper, so light hovers to the subtle
|
||||||
|
* ground with accent ink; dark dims the fill.
|
||||||
|
*/
|
||||||
|
export const btnPrimary = `${btnBase} border border-accent-edge bg-brand text-accent-on hover:bg-accent-subtle hover:text-accent-fg dark:hover:bg-brand/90 dark:hover:text-accent-on`;
|
||||||
|
|
||||||
/** Outlined call to action, for the second-choice action beside a primary. */
|
/** Outlined call to action, for the second-choice action beside a primary. */
|
||||||
export const btnSecondary =
|
export const btnSecondary = `${btnBase} border border-border-strong bg-surface text-fg hover:bg-surface-2`;
|
||||||
'tap inline-flex items-center justify-center gap-2 rounded-lg border border-border bg-surface px-5 py-2.5 text-sm font-semibold text-fg transition-colors duration-2 ease-enter hover:bg-surface-2';
|
|
||||||
|
|
||||||
/** Inline link inside running text. Underlined, because colour alone is not a link. */
|
/** Inline link inside running text. Underlined, because colour alone is not a link. */
|
||||||
export const link =
|
export const link =
|
||||||
'font-medium text-accent-fg underline decoration-accent-fg/40 underline-offset-4 transition-colors duration-1 ease-enter hover:decoration-accent-fg';
|
'font-medium text-accent-fg underline decoration-border-strong underline-offset-4 transition-colors duration-1 ease-enter hover:decoration-accent-fg';
|
||||||
|
|
||||||
/** Small pill. Neutral by default; pass a colour class after it. */
|
/** A quiet navigation link — back, next, "all twelve". 44px tall. */
|
||||||
export const pill =
|
export const navLink =
|
||||||
'inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-2 px-2 py-1 text-xs font-medium text-muted';
|
'tap inline-flex items-center gap-1.5 text-ui font-medium text-muted transition-colors duration-1 ease-enter hover:text-fg';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ chips */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip family. One shape, mono 11 uppercase, 4px radius. A visitor learns
|
||||||
|
* after the second one that this shape means "a thing we are telling you about
|
||||||
|
* the data", so no chip on a page is ever sans or rounder than this.
|
||||||
|
*/
|
||||||
|
const chipBase =
|
||||||
|
'inline-flex items-center gap-1 rounded-sm border px-1.5 py-0.5 font-mono text-label uppercase';
|
||||||
|
|
||||||
|
/** Outline: SPEC, PROPOSED BY PIG-DEMO, OBJECTIVE, RANK. */
|
||||||
|
export const chipOutline = `${chipBase} border-border-strong text-muted`;
|
||||||
|
|
||||||
|
/** Muted: RECORDED RUN, NOT IN THE FIRST SET, GATE. */
|
||||||
|
export const chipMuted = `${chipBase} border-transparent bg-surface-3 text-muted`;
|
||||||
|
|
||||||
|
/** Solid: LIVE. Pass `rounded-full` for a status pill. */
|
||||||
|
export const chipSolid = `${chipBase} border-accent-edge bg-brand text-accent-on`;
|
||||||
|
|
||||||
|
/** Warning: EDITED, COUNTERWEIGHT, WHERE THIS STOPS. */
|
||||||
|
export const chipWarning = `${chipBase} border-transparent bg-warning-subtle text-warning`;
|
||||||
|
|
||||||
|
/** Kept under the old names for the two callers outside this lane. */
|
||||||
|
export const pill = chipOutline;
|
||||||
|
|
||||||
/** The standing "these are our proposals" badge. */
|
/** The standing "these are our proposals" badge. */
|
||||||
export const proposalPill =
|
export const proposalPill = chipOutline;
|
||||||
'inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-2 px-2 py-1 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted';
|
|
||||||
|
/* ------------------------------------------------------------- surfaces */
|
||||||
|
|
||||||
/** A card that is also a link: the whole rectangle is the target. */
|
/** A card that is also a link: the whole rectangle is the target. */
|
||||||
export const cardLink =
|
export const cardLink =
|
||||||
'card group flex flex-col p-5 transition-colors duration-2 ease-enter hover:border-brand/40 hover:bg-surface-2';
|
'card group flex flex-col p-4 transition-colors duration-1 ease-enter hover:border-border-strong sm:p-5';
|
||||||
|
|
||||||
|
/** A list row that is a link. 64px on a phone, 56px from lg. */
|
||||||
|
export const rowLink =
|
||||||
|
'flex min-h-16 items-center gap-3 py-3 transition-colors duration-1 ease-enter hover:bg-surface-2 lg:min-h-14';
|
||||||
|
|
||||||
/** Monospaced command block. Scrolls itself rather than the page. */
|
/** Monospaced command block. Scrolls itself rather than the page. */
|
||||||
export const codeBlock =
|
export const codeBlock =
|
||||||
'nums overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-[0.8125rem] leading-relaxed text-fg';
|
'overflow-x-auto rounded-md border border-border bg-surface-3 p-3 font-mono text-code text-fg [font-feature-settings:"liga"_0]';
|
||||||
|
|
||||||
|
/** The buyer's question, as a quiet quote. 2px rule, no italics. */
|
||||||
|
export const quote = 'max-w-[60ch] border-l-2 border-border-strong pl-4 text-prose text-fg-2';
|
||||||
|
|
||||||
|
/** A hairline between blocks. */
|
||||||
|
export const rule = 'border-t border-border';
|
||||||
|
|||||||
@@ -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 { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -24,17 +24,29 @@ type Panel = 'none' | 'close' | 'escalate';
|
|||||||
|
|
||||||
const price = (minutes: number) => `+${hours(minutes)} h`;
|
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 (
|
return (
|
||||||
<fieldset className="min-w-0 rounded-lg border border-border p-3">
|
<fieldset className="min-w-0 rounded-control border border-border p-3">
|
||||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-muted">{title}</legend>
|
<legend className="px-1 font-mono text-label uppercase text-muted">{title}</legend>
|
||||||
{children}
|
{children}
|
||||||
</fieldset>
|
</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 =
|
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. */
|
/** The citable ids, grouped the way the screen groups them. */
|
||||||
function groupShown(shown: readonly string[]) {
|
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.`;
|
: `Hours spent so far: ${hours(state.minutesSpent)}. ${left} ${left === 1 ? 'reply' : 'replies'} left.`;
|
||||||
|
|
||||||
return (
|
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
|
{/* The board is a group of tables; the outcome of every click is said
|
||||||
here in words, where it announces itself. */}
|
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}
|
{status}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -147,7 +159,7 @@ export function Controls({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={`${ids}-month`}
|
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}
|
value={month}
|
||||||
disabled={done || monthsLeft.length === 0}
|
disabled={done || monthsLeft.length === 0}
|
||||||
onChange={(event) => setMonth(event.target.value)}
|
onChange={(event) => setMonth(event.target.value)}
|
||||||
@@ -163,13 +175,14 @@ export function Controls({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="touch"
|
size="touch"
|
||||||
|
className="shrink-0"
|
||||||
disabled={done || month === ''}
|
disabled={done || month === ''}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
send({ action: 'lookup', what: 'history', month });
|
send({ action: 'lookup', what: 'history', month });
|
||||||
setMonth('');
|
setMonth('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
History <span className="nums text-muted">{price(PRICES.history)}</span>
|
History <Price>{price(PRICES.history)}</Price>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,7 +192,7 @@ export function Controls({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={`${ids}-cp`}
|
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}
|
value={cp}
|
||||||
disabled={done || cpsLeft.length === 0}
|
disabled={done || cpsLeft.length === 0}
|
||||||
onChange={(event) => setCp(event.target.value)}
|
onChange={(event) => setCp(event.target.value)}
|
||||||
@@ -195,45 +208,50 @@ export function Controls({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="touch"
|
size="touch"
|
||||||
|
className="shrink-0"
|
||||||
disabled={done || cp === ''}
|
disabled={done || cp === ''}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
send({ action: 'lookup', what: 'counterparty', id: cp });
|
send({ action: 'lookup', what: 'counterparty', id: cp });
|
||||||
setCp('');
|
setCp('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Counterparty <span className="nums text-muted">{price(PRICES.counterparty)}</span>
|
Counterparty <Price>{price(PRICES.counterparty)}</Price>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
{/* Two per row on a phone, so the pair reads as the pair it is. */}
|
||||||
<Button
|
<div className="grid grid-cols-2 gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="touch"
|
variant="outline"
|
||||||
disabled={done || priorsMade}
|
size="touch"
|
||||||
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
|
disabled={done || priorsMade}
|
||||||
>
|
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
|
||||||
Prior alerts <span className="nums text-muted">{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</span>
|
>
|
||||||
</Button>
|
Prior alerts <Price>{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</Price>
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="touch"
|
variant="outline"
|
||||||
disabled={done || docsMade}
|
size="touch"
|
||||||
onClick={() => send({ action: 'lookup', what: 'documents' })}
|
disabled={done || docsMade}
|
||||||
>
|
onClick={() => send({ action: 'lookup', what: 'documents' })}
|
||||||
Documents <span className="nums text-muted">{docsMade ? 'done' : price(DOC_MINUTES)}</span>
|
>
|
||||||
</Button>
|
Documents <Price>{docsMade ? 'done' : price(DOC_MINUTES)}</Price>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Disposition — ends the episode">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="touch"
|
size="touch"
|
||||||
variant={panel === 'close' ? 'default' : 'outline'}
|
variant={panel === 'close' ? 'default' : 'outline'}
|
||||||
|
className={cn(panel === 'close' && 'border border-accent-edge shadow-none')}
|
||||||
aria-pressed={panel === 'close'}
|
aria-pressed={panel === 'close'}
|
||||||
disabled={done}
|
disabled={done}
|
||||||
onClick={() => setPanel(panel === 'close' ? 'none' : 'close')}
|
onClick={() => setPanel(panel === 'close' ? 'none' : 'close')}
|
||||||
@@ -244,6 +262,7 @@ export function Controls({
|
|||||||
type="button"
|
type="button"
|
||||||
size="touch"
|
size="touch"
|
||||||
variant={panel === 'escalate' ? 'default' : 'outline'}
|
variant={panel === 'escalate' ? 'default' : 'outline'}
|
||||||
|
className={cn(panel === 'escalate' && 'border border-accent-edge shadow-none')}
|
||||||
aria-pressed={panel === 'escalate'}
|
aria-pressed={panel === 'escalate'}
|
||||||
disabled={done}
|
disabled={done}
|
||||||
onClick={() => setPanel(panel === 'escalate' ? 'none' : 'escalate')}
|
onClick={() => setPanel(panel === 'escalate' ? 'none' : 'escalate')}
|
||||||
@@ -260,8 +279,8 @@ export function Controls({
|
|||||||
submitDisposition();
|
submitDisposition();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
<label className="flex flex-col gap-1">
|
||||||
{panel === 'close' ? 'Reason' : 'Typology'}
|
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Reason' : 'Typology'}</span>
|
||||||
{panel === 'close' ? (
|
{panel === 'close' ? (
|
||||||
<select className={SELECT} value={reason} onChange={(event) => setReason(event.target.value as CloseReason)}>
|
<select className={SELECT} value={reason} onChange={(event) => setReason(event.target.value as CloseReason)}>
|
||||||
{CLOSE_REASONS.map((r) => (
|
{CLOSE_REASONS.map((r) => (
|
||||||
@@ -282,13 +301,13 @@ export function Controls({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset className="min-w-0">
|
<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
|
Cite the facts that decide it — only ids you have been shown
|
||||||
</legend>
|
</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) => (
|
{groups.map((group) => (
|
||||||
<div key={group.label}>
|
<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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{group.ids.map((id) => {
|
{group.ids.map((id) => {
|
||||||
const on = cites.includes(id);
|
const on = cites.includes(id);
|
||||||
@@ -296,12 +315,16 @@ export function Controls({
|
|||||||
<label
|
<label
|
||||||
key={id}
|
key={id}
|
||||||
className={cn(
|
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',
|
'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',
|
||||||
on ? 'border-brand bg-accent-subtle text-fg' : 'border-border text-muted hover:bg-surface-2',
|
'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}
|
title={id.startsWith('kyc.') ? fieldLabel(id) : id}
|
||||||
>
|
>
|
||||||
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCite(id)} />
|
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCite(id)} />
|
||||||
|
{on ? <span aria-hidden="true">✓</span> : null}
|
||||||
{id}
|
{id}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -312,26 +335,31 @@ export function Controls({
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
<label className="flex flex-col gap-1">
|
||||||
<span>
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
{panel === 'close' ? 'Note' : 'Narrative'}{' '}
|
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Note' : 'Narrative'}</span>
|
||||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
<span className="rounded-tile border border-border-strong px-1.5 py-px font-mono text-label uppercase text-muted">
|
||||||
displayed, never scored
|
displayed, never scored
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<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}
|
value={prose}
|
||||||
onChange={(event) => setProse(event.target.value)}
|
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…'}
|
placeholder={panel === 'close' ? 'Why this is consistent with the file…' : 'What the pattern is and why it is reportable…'}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Button type="submit" size="touch" disabled={cites.length === 0}>
|
<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'}
|
{panel === 'close' ? 'Close the alert' : 'Escalate for filing'}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="nums text-xs text-muted">
|
<span className="font-mono text-caption text-muted">
|
||||||
{cites.length} cited · {price(TURN_MINUTES)}
|
{cites.length} cited · {price(TURN_MINUTES)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,9 +368,14 @@ export function Controls({
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{lastReply ? (
|
{lastReply ? (
|
||||||
<details className="text-xs text-muted">
|
<details className="group font-mono text-label uppercase text-muted">
|
||||||
<summary className="cursor-pointer">The reply the engine received</summary>
|
<summary className="min-h-11 cursor-pointer list-none py-2 [&::-webkit-details-marker]:hidden">
|
||||||
<pre className="mt-1 overflow-x-auto rounded-md bg-surface-2 p-2 font-mono text-[0.7rem] text-fg [contain:inline-size]">
|
<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}
|
{lastReply}
|
||||||
</pre>
|
</pre>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default defineMeta({
|
|||||||
tagline:
|
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.',
|
'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',
|
vertical: 'financial-crime',
|
||||||
status: 'spec',
|
status: 'live',
|
||||||
order: 0,
|
order: 0,
|
||||||
icon: 'Siren',
|
icon: 'Siren',
|
||||||
persona: 'BSA / AML Officer',
|
persona: 'BSA / AML Officer',
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ import type { Lookup, Reveal, Screen, Transaction, TriageState } from './types';
|
|||||||
* playing, the recorded replay, and the timeline thumbnail — because three
|
* playing, the recorded replay, and the timeline thumbnail — because three
|
||||||
* near-identical boards is how they drift.
|
* 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:
|
* 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.
|
* · 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
|
* · 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.
|
* until then, so the surface cannot leak the label even by accident.
|
||||||
*
|
*
|
||||||
@@ -27,12 +32,17 @@ const MAX_TURNS = 8;
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- pieces */
|
/* ---------------------------------------------------------------- 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 }) {
|
function SyntheticLabel({ compact }: { compact?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md border border-dashed border-warning/60 bg-warning/5 font-semibold uppercase tracking-wide text-warning',
|
'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-[0.65rem]',
|
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-label',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Synthetic data — no real customer, account or transaction
|
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' }) {
|
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 (
|
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}
|
{children}
|
||||||
</code>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Block({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
function Block({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border border-border bg-surface p-3">
|
<section className="min-w-0 rounded-control border border-border bg-surface p-3">
|
||||||
<header className="mb-2 flex items-baseline justify-between gap-2">
|
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h4>
|
<h4 className="font-mono text-label uppercase text-muted">{title}</h4>
|
||||||
{aside ? <div className="text-xs text-muted">{aside}</div> : null}
|
{aside ? <div className="min-w-0 font-mono text-caption text-muted">{aside}</div> : null}
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@@ -70,31 +97,43 @@ function Scroll({ children }: { children: ReactNode }) {
|
|||||||
return <div className="overflow-x-auto [contain:inline-size]">{children}</div>;
|
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 }) {
|
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 (
|
return (
|
||||||
<Scroll>
|
<Scroll>
|
||||||
<table className="w-full min-w-[26rem] text-xs">
|
<table className={cn(TABLE, 'min-w-[26rem]')}>
|
||||||
<thead className="text-left text-muted">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="pb-1 pr-2 font-medium">id</th>
|
<th className={TH}>id</th>
|
||||||
<th className="pb-1 pr-2 font-medium">date</th>
|
<th className={cn(TH, 'text-right')}>date</th>
|
||||||
<th className="pb-1 pr-2 font-medium">channel</th>
|
<th className={TH}>channel</th>
|
||||||
<th className="pb-1 pr-2 text-right font-medium">amount</th>
|
<th className={cn(TH, 'text-right')}>amount</th>
|
||||||
<th className="pb-1 font-medium">where</th>
|
<th className={TH}>where</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((t) => (
|
{rows.map((t) => (
|
||||||
<tr key={t.id} className="border-t border-border/60">
|
<tr key={t.id} className="border-t border-border">
|
||||||
<td className="py-0.5 pr-2">
|
<td className={TD}>
|
||||||
<Id tone="fg">{t.id}</Id>
|
<Id tone="fg">{t.id}</Id>
|
||||||
</td>
|
</td>
|
||||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{t.date}</td>
|
<td className={cn(TD, NUM)}>{t.date}</td>
|
||||||
<td className="py-0.5 pr-2 whitespace-nowrap">{channelLabel(t.channel)}</td>
|
<td className={cn(TD, 'whitespace-nowrap font-sans text-small')}>{channelLabel(t.channel)}</td>
|
||||||
<td className="nums py-0.5 pr-2 text-right whitespace-nowrap">{money(t.amount, currency)}</td>
|
<td className={cn(TD, NUM, 'text-fg')}>{money(t.amount, currency)}</td>
|
||||||
<td className="py-0.5 whitespace-nowrap">
|
<td className={cn(TD, 'whitespace-nowrap')}>
|
||||||
{t.branch ? `branch ${t.branch}` : t.cp ? <Id>{t.cp}</Id> : '—'}
|
{t.branch ? (
|
||||||
|
<span className="font-sans text-small">branch {t.branch}</span>
|
||||||
|
) : t.cp ? (
|
||||||
|
<Id>{t.cp}</Id>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted">—</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -107,15 +146,17 @@ function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: s
|
|||||||
function AlertBlock({ screen }: { screen: Screen }) {
|
function AlertBlock({ screen }: { screen: Screen }) {
|
||||||
const a = screen.alert;
|
const a = screen.alert;
|
||||||
return (
|
return (
|
||||||
<Block title="Alert" aside={<span className="nums">fired {a.fired}</span>}>
|
<Block title="Alert" aside={<span>fired {a.fired}</span>}>
|
||||||
<p className="text-sm">
|
<p className="font-sans text-small text-fg">
|
||||||
<Id tone="fg">{a.id}</Id>
|
<Id tone="fg">{a.id}</Id>
|
||||||
<span className="mx-1.5 text-muted">·</span>
|
<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>
|
<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>
|
||||||
<p className="mb-1 mt-2 text-xs text-muted">Triggering transactions</p>
|
|
||||||
<TransactionTable rows={screen.triggering_transactions} currency={screen.currency} />
|
<TransactionTable rows={screen.triggering_transactions} currency={screen.currency} />
|
||||||
</Block>
|
</Block>
|
||||||
);
|
);
|
||||||
@@ -133,14 +174,12 @@ function KycBlock({ screen }: { screen: Screen }) {
|
|||||||
const c = screen.customer;
|
const c = screen.customer;
|
||||||
return (
|
return (
|
||||||
<Block title="KYC file" aside={<Id>{c.id}</Id>}>
|
<Block title="KYC file" aside={<Id>{c.id}</Id>}>
|
||||||
<p className="mb-2 text-sm font-medium">{c.name}</p>
|
<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 text-xs sm:grid-cols-2">
|
<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]) => (
|
{kycEntries(c as unknown as Record<string, unknown>).map(([key, value]) => (
|
||||||
<div key={key} className="flex min-w-0 flex-col">
|
<div key={key} className="flex min-w-0 flex-col border-t border-border pt-1">
|
||||||
<dt className="text-muted">
|
<dt className="font-mono text-caption text-muted">kyc.{key}</dt>
|
||||||
<Id>kyc.{key}</Id>
|
<dd className="break-words font-sans text-small text-fg">{kycValue(key, value, screen.currency)}</dd>
|
||||||
</dt>
|
|
||||||
<dd className="nums break-words">{kycValue(key, value, screen.currency)}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -168,28 +207,28 @@ function SummaryBlock({ screen }: { screen: Screen }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Scroll>
|
<Scroll>
|
||||||
<table className="w-full min-w-[30rem] text-xs">
|
<table className={cn(TABLE, 'min-w-[30rem]')}>
|
||||||
<thead className="text-left text-muted">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="pb-1 pr-2 font-medium">month</th>
|
<th className={TH}>month</th>
|
||||||
{cols.map(([key, label]) => (
|
{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}
|
{label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="pb-1 text-right font-medium">txns</th>
|
<th className={cn(TH, 'text-right')}>txns</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{screen.summary.map((row) => (
|
{screen.summary.map((row) => (
|
||||||
<tr key={row.month} className="border-t border-border/60">
|
<tr key={row.month} className="border-t border-border">
|
||||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{row.month}</td>
|
<td className={cn(TD, 'whitespace-nowrap text-fg')}>{row.month}</td>
|
||||||
{cols.map(([key]) => (
|
{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])}
|
{wholeUnits(row[key])}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td className="nums py-0.5 text-right">{row.count}</td>
|
<td className={cn(TD, NUM, 'text-muted')}>{row.count}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -202,19 +241,33 @@ function SummaryBlock({ screen }: { screen: Screen }) {
|
|||||||
function CounterpartyBlock({ screen }: { screen: Screen }) {
|
function CounterpartyBlock({ screen }: { screen: Screen }) {
|
||||||
return (
|
return (
|
||||||
<Block title="Counterparties on screen" aside="top by twelve-month volume">
|
<Block title="Counterparties on screen" aside="top by twelve-month volume">
|
||||||
<ul className="space-y-0.5 text-xs">
|
<Scroll>
|
||||||
{screen.screen_counterparties.map((cp) => (
|
<table className={cn(TABLE, 'min-w-[20rem]')}>
|
||||||
<li key={cp.id} className="flex flex-wrap items-baseline gap-x-2">
|
<thead>
|
||||||
<Id tone="fg">{cp.id}</Id>
|
<tr>
|
||||||
<span className="text-muted">{cp.direction === 'in' ? 'inbound' : 'outbound'}</span>
|
<th className={TH}>id</th>
|
||||||
<span className="nums">{cp.count} txns</span>
|
<th className={TH}>direction</th>
|
||||||
<span className="nums">{money(cp.total, screen.currency)}</span>
|
<th className={cn(TH, 'text-right')}>txns</th>
|
||||||
</li>
|
<th className={cn(TH, 'text-right')}>total</th>
|
||||||
))}
|
</tr>
|
||||||
</ul>
|
</thead>
|
||||||
<p className="mt-2 text-xs text-muted">
|
<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:{' '}
|
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>
|
</p>
|
||||||
</Block>
|
</Block>
|
||||||
);
|
);
|
||||||
@@ -226,7 +279,7 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
|||||||
return (
|
return (
|
||||||
<Block
|
<Block
|
||||||
title={`History ${lookup.result.month}`}
|
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} />
|
<TransactionTable rows={lookup.result.transactions} currency={currency} />
|
||||||
</Block>
|
</Block>
|
||||||
@@ -234,19 +287,19 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
|||||||
case 'counterparty': {
|
case 'counterparty': {
|
||||||
const r = lookup.result;
|
const r = lookup.result;
|
||||||
return (
|
return (
|
||||||
<Block title={`Counterparty ${r.id}`} aside={<span className="nums">first seen {r.first_seen}</span>}>
|
<Block title={`Counterparty ${r.id}`} aside={<span>first seen {r.first_seen}</span>}>
|
||||||
<p className="text-sm font-medium">{r.name}</p>
|
<p className="font-sans text-ui font-medium text-fg">{r.name}</p>
|
||||||
<p className="text-xs text-muted">
|
<p className="font-sans text-small text-muted">
|
||||||
{r.type.replace(/_/g, ' ')} · {r.jurisdiction}
|
{r.type.replace(/_/g, ' ')} · {r.jurisdiction}
|
||||||
</p>
|
</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:{' '}
|
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>
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1.5 font-sans text-small text-muted">
|
||||||
transactions:{' '}
|
transactions:{' '}
|
||||||
{r.transactions.map((id) => (
|
{r.transactions.map((id) => (
|
||||||
<span key={id} className="mr-1">
|
<span key={id} className="mr-1.5">
|
||||||
<Id>{id}</Id>
|
<Id>{id}</Id>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -256,22 +309,23 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
|||||||
}
|
}
|
||||||
case 'prior_alerts':
|
case 'prior_alerts':
|
||||||
return (
|
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 ? (
|
{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) => (
|
{lookup.result.alerts.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id} className="py-1.5 first:pt-0 last:pb-0">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
<div className="flex flex-wrap items-baseline gap-x-2 font-mono text-caption">
|
||||||
<Id tone="fg">{p.id}</Id>
|
<Id tone="fg">{p.id}</Id>
|
||||||
<span className="font-mono">{p.rule}</span>
|
<span className="font-semibold text-fg">{p.rule}</span>
|
||||||
<span className="nums text-muted">fired {p.fired}</span>
|
<span className="text-muted">fired {p.fired}</span>
|
||||||
<span>
|
<span className="font-sans text-small text-fg-2">
|
||||||
{p.disposition} after {p.elapsed_days} {p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
|
{p.disposition} after <span className="font-mono">{p.elapsed_days}</span>{' '}
|
||||||
|
{p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted">{p.rationale}</p>
|
<p className="mt-0.5 font-sans text-small text-fg-2">{p.rationale}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -280,15 +334,15 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
|||||||
);
|
);
|
||||||
case 'documents':
|
case 'documents':
|
||||||
return (
|
return (
|
||||||
<Block title="Documents on file" aside={<span className="nums">{lookup.result.documents.length}</span>}>
|
<Block title="Documents on file" aside={<span>{lookup.result.documents.length}</span>}>
|
||||||
<ul className="space-y-1 text-xs">
|
<ul className="divide-y divide-border">
|
||||||
{lookup.result.documents.map((d) => (
|
{lookup.result.documents.map((d) => (
|
||||||
<li key={d.id} className="flex flex-col gap-0.5 sm:flex-row sm:gap-2">
|
<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">
|
<span className="shrink-0 font-mono text-caption">
|
||||||
<Id tone="fg">{d.id}</Id>{' '}
|
<Id tone="fg">{d.id}</Id>{' '}
|
||||||
<span className="text-muted">[{d.kind.replace(/_/g, ' ')}]</span>
|
<span className="text-muted">[{d.kind.replace(/_/g, ' ')}]</span>
|
||||||
</span>
|
</span>
|
||||||
<span>{d.description}</span>
|
<span className="font-sans text-small text-fg-2">{d.description}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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 }) {
|
function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[0.7rem]',
|
'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/50 bg-positive/10 text-fg',
|
hit === true && 'border-positive bg-accent-subtle text-fg',
|
||||||
hit === false && 'border-border text-muted line-through decoration-muted/60',
|
hit === false && 'border-border text-muted line-through',
|
||||||
(hit === null || hit === undefined) && 'border-border text-fg',
|
(hit === null || hit === undefined) && 'border-border-strong text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hit === true ? <span aria-hidden="true">✓</span> : null}
|
{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 }) {
|
function DispositionCard({ state }: { state: TriageState }) {
|
||||||
const d = state.disposition;
|
const d = state.disposition;
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
const planted = state.reveal ? bestAlternate(d.cites, state.reveal.planted) : null;
|
const planted = state.reveal ? bestAlternate(d.cites, state.reveal.planted) : null;
|
||||||
const scored = state.reveal !== null && state.outcome === 'solved';
|
const scored = state.reveal !== null && state.outcome === 'solved';
|
||||||
|
const prose = (d.action === 'close' ? d.note : d.narrative).trim();
|
||||||
return (
|
return (
|
||||||
<Block
|
<Block
|
||||||
title={d.action === 'close' ? 'Closed' : 'Escalated'}
|
title={d.action === 'close' ? 'Closed' : 'Escalated'}
|
||||||
aside={
|
aside={<span className="text-fg">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>}
|
||||||
<span className="font-mono">{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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{d.cites.map((id) => (
|
{d.cites.map((id) => (
|
||||||
<CiteChip key={id} id={id} hit={planted && scored ? planted.includes(id) : null} />
|
<CiteChip key={id} id={id} hit={planted && scored ? planted.includes(id) : null} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(d.action === 'close' ? d.note : d.narrative).trim() ? (
|
{prose ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2.5">
|
||||||
<p className="mb-1 flex items-baseline gap-2 text-xs text-muted">
|
<p className="mb-1 flex flex-wrap items-center gap-2">
|
||||||
<span>{d.action === 'close' ? 'Note' : 'Narrative'}</span>
|
<Label>{d.action === 'close' ? 'Note' : 'Narrative'}</Label>
|
||||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
<NeverScored />
|
||||||
displayed, never scored
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<blockquote className="border-l-2 border-border pl-2 text-xs leading-relaxed">
|
<blockquote className="border-l-2 border-border-strong pl-2.5 font-sans text-small text-fg-2">{prose}</blockquote>
|
||||||
{d.action === 'close' ? d.note : d.narrative}
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Block>
|
</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') {
|
if (state.status === 'aborted') {
|
||||||
return {
|
return {
|
||||||
text: 'No disposition in eight replies. The alert stays in the queue and the episode scores 0.000 on every component.',
|
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') {
|
if (state.outcome === 'solved') {
|
||||||
return {
|
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',
|
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 }) {
|
function RevealCard({ state }: { state: TriageState }) {
|
||||||
const reveal = state.reveal;
|
const reveal = state.reveal;
|
||||||
if (!reveal) return null;
|
if (!reveal) return null;
|
||||||
const cites = state.disposition?.cites ?? [];
|
const cites = state.disposition?.cites ?? [];
|
||||||
const best = bestAlternate(cites, reveal.planted);
|
const best = bestAlternate(cites, reveal.planted);
|
||||||
const verdict = verdictLine(state, reveal);
|
const verdict = verdictLine(state, reveal);
|
||||||
const TONE = { positive: 'text-positive', danger: 'text-danger', warning: 'text-warning' } as const;
|
|
||||||
return (
|
return (
|
||||||
<Block
|
<Block
|
||||||
title="What the generator planted"
|
title="What the generator planted"
|
||||||
aside={
|
aside={
|
||||||
<span className="font-mono">
|
<span>
|
||||||
{reveal.template}
|
{reveal.template}
|
||||||
{reveal.overlay ? ` + ${reveal.overlay.replace(/_/g, ' ')}` : ''}
|
{reveal.overlay ? ` + ${reveal.overlay.replace(/_/g, ' ')}` : ''}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-sm">
|
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-1 font-sans text-small">
|
||||||
<span className={cn('font-semibold', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
|
<span className={cn('font-mono font-semibold uppercase', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
|
||||||
{reveal.label}
|
{reveal.label}
|
||||||
</span>
|
</span>
|
||||||
{reveal.typology ? (
|
{reveal.typology ? <span className="font-mono text-caption text-fg">{reveal.typology}</span> : null}
|
||||||
<>
|
|
||||||
<span className="mx-1.5 text-muted">·</span>
|
|
||||||
<span className="font-mono text-xs">{reveal.typology}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{reveal.tier === 'hidden' ? (
|
{reveal.tier === 'hidden' ? (
|
||||||
<>
|
<span className="text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</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}
|
{verdict.text}
|
||||||
</p>
|
</p>
|
||||||
<p className="mb-1 mt-2 text-xs text-muted">
|
<p className="mb-1 mt-2.5">
|
||||||
The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}
|
<Label>The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}</Label>
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{best.map((id) => (
|
{best.map((id) => (
|
||||||
<CiteChip key={id} id={id} hit={cites.includes(id) ? true : null} />
|
<CiteChip key={id} id={id} hit={cites.includes(id) ? true : null} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted">
|
<p className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 font-mono text-caption text-muted">
|
||||||
Hours spent <span className="nums text-fg">{hours(state.minutesSpent)}</span>
|
<span>
|
||||||
|
<Label>Hours spent</Label> <span className="text-fg">{hours(state.minutesSpent)}</span>
|
||||||
|
</span>
|
||||||
{reveal.referenceMinutes !== null ? (
|
{reveal.referenceMinutes !== null ? (
|
||||||
<>
|
<span>
|
||||||
{' '}
|
<Label>Cheapest shipped analyst that got it right</Label>{' '}
|
||||||
· cheapest shipped analyst that got it right{' '}
|
<span className="text-fg">{hours(reveal.referenceMinutes)}</span>
|
||||||
<span className="nums text-fg">{hours(reveal.referenceMinutes)}</span>
|
</span>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</Block>
|
</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 }) {
|
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 (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 font-mono text-caption">
|
||||||
<span>
|
<span className="flex items-baseline gap-1.5">
|
||||||
Hours <span className="nums font-semibold text-fg">{hours(state.minutesSpent)}</span>
|
<Label>Hours</Label>
|
||||||
|
<span className="text-mono-data font-semibold text-fg">{hours(state.minutesSpent)}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span className="flex items-center gap-1.5">
|
||||||
Replies{' '}
|
<Label>Replies</Label>
|
||||||
<span className="nums font-semibold text-fg">
|
<span className="text-mono-data font-semibold text-fg">
|
||||||
{state.turnsSpent} of {MAX_TURNS}
|
{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>
|
||||||
</span>
|
</span>
|
||||||
{state.rejected > 0 ? (
|
{state.rejected > 0 ? (
|
||||||
<span>
|
<span className="flex items-baseline gap-1.5">
|
||||||
refused <span className="nums font-semibold text-fg">{state.rejected}</span>
|
<Label className="text-warning">Refused</Label>
|
||||||
|
<span className="text-mono-data font-semibold text-warning">{state.rejected}</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span
|
<Chip className={STATUS_TONE[state.status]}>{STATUS_WORD[state.status]}</Chip>
|
||||||
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>
|
|
||||||
{state.screen.held_out ? (
|
{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
|
held-out seed
|
||||||
</span>
|
</Chip>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -481,8 +573,8 @@ function Meter({ state }: { state: TriageState }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The thumbnail: what the timeline chip and the gallery card show. A whole
|
* 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,
|
* screen at 7.5rem is noise, so this is the shape of the run instead — the
|
||||||
* the twelve months of cash as bars, and what has happened so far.
|
* rule, the twelve months of cash as bars, and what has happened so far.
|
||||||
*/
|
*/
|
||||||
function CompactBoard({ state }: { state: TriageState }) {
|
function CompactBoard({ state }: { state: TriageState }) {
|
||||||
const rows = state.screen.summary;
|
const rows = state.screen.summary;
|
||||||
@@ -491,42 +583,44 @@ function CompactBoard({ state }: { state: TriageState }) {
|
|||||||
const label =
|
const label =
|
||||||
`${state.screen.alert.rule}, ${hours(state.minutesSpent)} hours, ${state.lookups.length} lookups` +
|
`${state.screen.alert.rule}, ${hours(state.minutesSpent)} hours, ${state.lookups.length} lookups` +
|
||||||
(d ? `, ${d.action === 'close' ? 'closed' : 'escalated'}` : state.status === 'aborted' ? ', aborted' : '');
|
(d ? `, ${d.action === 'close' ? 'closed' : 'escalated'}` : state.status === 'aborted' ? ', aborted' : '');
|
||||||
|
const CHIP = 'rounded-[2px] border px-0.5 leading-[1.3]';
|
||||||
return (
|
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 />
|
<SyntheticLabel compact />
|
||||||
<div className="mt-1 flex items-baseline justify-between gap-1">
|
<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="truncate font-semibold text-fg">{state.screen.alert.rule}</span>
|
||||||
<span className="nums shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
|
<span className="shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex h-4 items-end gap-px" aria-hidden="true">
|
<div className="mt-1 flex h-4 items-end gap-px" aria-hidden="true">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<span
|
<span
|
||||||
key={r.month}
|
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))}%` }}
|
style={{ height: `${Math.max(6, Math.round((r.cash_in / peak) * 100))}%` }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex flex-wrap gap-0.5">
|
<div className="mt-1 flex flex-wrap gap-0.5">
|
||||||
{state.lookups.map((l, i) => (
|
{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'}
|
{l.what === 'history' ? l.key.slice(5) : l.what === 'counterparty' ? l.key : l.what === 'prior_alerts' ? 'priors' : 'docs'}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{state.rejected > 0 ? (
|
{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}
|
) : null}
|
||||||
{d ? (
|
{d ? (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded border px-0.5 font-semibold uppercase',
|
CHIP,
|
||||||
d.action === 'close' ? 'border-positive/50 text-positive' : 'border-warning/60 text-warning',
|
'font-semibold uppercase',
|
||||||
|
d.action === 'close' ? 'border-positive text-positive' : 'border-warning text-warning',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{d.action === 'close' ? 'close' : 'escalate'}
|
{d.action === 'close' ? 'close' : 'escalate'}
|
||||||
</span>
|
</span>
|
||||||
) : state.status === 'aborted' ? (
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</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
|
// 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
|
// `max-w-full` resolves against a parent that sized itself to this
|
||||||
// board — measured: 640px on a 390px phone, 304px of page overflow.
|
// 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
|
// `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
|
// comparison's grid cell, which is narrower than 40rem at 1280px and
|
||||||
// measured 31px of page overflow without it.
|
// 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"
|
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 />
|
<SyntheticLabel />
|
||||||
<Meter state={state} />
|
<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} />
|
<LookupCard key={`${lookup.what}-${lookup.key ?? i}`} lookup={lookup} currency={screen.currency} />
|
||||||
))}
|
))}
|
||||||
{state.lastRejection ? (
|
{state.lastRejection ? (
|
||||||
<p role="status" className="rounded-md border border-warning/60 bg-warning/5 px-3 py-2 text-xs text-warning">
|
<p
|
||||||
Reply refused: {state.lastRejection}. It still cost a turn.
|
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>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<DispositionCard state={state} />
|
<DispositionCard state={state} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
import { play, type BoardState } from './engine';
|
import { play, type BoardState } from './engine';
|
||||||
@@ -8,14 +9,16 @@ import { allowedNow, ANSWERS, primeGuessList } from './words';
|
|||||||
|
|
||||||
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
||||||
|
|
||||||
|
type TileState = 'G' | 'Y' | 'X';
|
||||||
|
|
||||||
/** Best-known state of each letter, for tinting the keys. */
|
/** 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 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 (const { guess, pattern } of board.rows) {
|
||||||
for (let i = 0; i < guess.length; i += 1) {
|
for (let i = 0; i < guess.length; i += 1) {
|
||||||
const letter = guess[i]!;
|
const letter = guess[i]!;
|
||||||
const tile = pattern[i] as 'G' | 'Y' | 'X';
|
const tile = pattern[i] as TileState;
|
||||||
const current = out[letter];
|
const current = out[letter];
|
||||||
if (!current || rank[tile] > rank[current]) out[letter] = tile;
|
if (!current || rank[tile] > rank[current]) out[letter] = tile;
|
||||||
}
|
}
|
||||||
@@ -23,12 +26,31 @@ function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY_TINT: Record<string, string> = {
|
/**
|
||||||
G: 'bg-tile-exact text-tile-exact-fg',
|
* A used key takes the tile token at FULL opacity. The old `/70` on absent
|
||||||
Y: 'bg-tile-present text-tile-present-fg',
|
* made the grey key a fourth colour the board never shows; at full strength
|
||||||
X: 'bg-tile-absent/70 text-tile-absent-fg',
|
* 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({
|
export function Keyboard({
|
||||||
state,
|
state,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -69,7 +91,7 @@ export function Keyboard({
|
|||||||
const handler = (event: KeyboardEvent) => {
|
const handler = (event: KeyboardEvent) => {
|
||||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
const target = event.target as HTMLElement | null;
|
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');
|
if (event.key === 'Enter') press('enter');
|
||||||
else if (event.key === 'Backspace') press('back');
|
else if (event.key === 'Backspace') press('back');
|
||||||
else if (/^[a-zA-Z]$/.test(event.key)) press(event.key.toLowerCase());
|
else if (/^[a-zA-Z]$/.test(event.key)) press(event.key.toLowerCase());
|
||||||
@@ -85,36 +107,37 @@ export function Keyboard({
|
|||||||
return (
|
return (
|
||||||
// Own cap, not the shell's. Unconstrained, these flex rows stretch to the
|
// 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.
|
// 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
|
{/* 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
|
is the difference between a demo and a video: it answers for boards no
|
||||||
rollout on this site ever covered. */}
|
rollout on this site ever covered. */}
|
||||||
{hint.status !== 'unavailable' && !done ? (
|
{hint.status !== 'unavailable' && !done ? (
|
||||||
<div className="mb-1 flex min-h-9 flex-wrap items-center gap-2 text-xs">
|
<div className="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="touch"
|
||||||
onClick={() => solver.ask(state.rows)}
|
onClick={() => solver.ask(state.rows)}
|
||||||
disabled={hint.status === 'thinking'}
|
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?'}
|
{hint.status === 'thinking' ? 'Thinking…' : 'What would the best player guess?'}
|
||||||
</button>
|
</Button>
|
||||||
<span aria-live="polite" className="text-muted">
|
<span aria-live="polite" className="font-mono text-mono-data text-muted">
|
||||||
{hint.status === 'ready' ? (
|
{hint.status === 'ready' ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-mono font-semibold uppercase text-fg">
|
<span className="font-semibold uppercase text-fg">{hint.suggestion.guess}</span>
|
||||||
{hint.suggestion.guess}
|
{' — '}
|
||||||
</span>{' '}
|
{hint.suggestion.bits.toFixed(2)} bits from{' '}
|
||||||
— <span className="nums">{hint.suggestion.bits.toFixed(2)}</span> bits from{' '}
|
{hint.suggestion.candidatesBefore.toLocaleString('en-US')} remaining
|
||||||
<span className="nums">{hint.suggestion.candidatesBefore.toLocaleString('en-US')}</span>{' '}
|
|
||||||
remaining
|
|
||||||
{hint.suggestion.viable ? null : (
|
{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"
|
type="button"
|
||||||
onClick={() => press('enter')}
|
onClick={() => press('enter')}
|
||||||
disabled={done}
|
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
|
Enter
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{[...row].map((letter) => (
|
{[...row].map((letter) => {
|
||||||
<button
|
const used = states[letter];
|
||||||
key={letter}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => press(letter)}
|
key={letter}
|
||||||
disabled={done}
|
type="button"
|
||||||
aria-label={letter.toUpperCase()}
|
onClick={() => press(letter)}
|
||||||
className={cn(
|
disabled={done}
|
||||||
'tap min-w-0 flex-1 rounded-md text-sm font-semibold uppercase transition-colors duration-1 disabled:opacity-40',
|
aria-label={letter.toUpperCase()}
|
||||||
states[letter] ? KEY_TINT[states[letter]!] : 'bg-surface-2 hover:bg-accent-subtle',
|
className={cn(
|
||||||
)}
|
KEY,
|
||||||
>
|
'relative flex-1 font-sans text-[0.8125rem] font-semibold uppercase',
|
||||||
{letter}
|
used ? KEY_TINT[used] : 'bg-surface-2 text-fg hover:bg-surface-3',
|
||||||
</button>
|
)}
|
||||||
))}
|
>
|
||||||
|
{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 ? (
|
{index === 2 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => press('back')}
|
onClick={() => press('back')}
|
||||||
disabled={done}
|
disabled={done}
|
||||||
aria-label="Backspace"
|
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
|
Del
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo } from 'react';
|
import { memo, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
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 board. One component for all three jobs — you playing, the replay, and
|
||||||
* the gallery thumbnail — because three near-identical boards is how they drift.
|
* 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',
|
G: 'bg-tile-exact text-tile-exact-fg border-tile-exact',
|
||||||
Y: 'bg-tile-present text-tile-present-fg border-tile-present',
|
Y: 'bg-tile-present text-tile-present-fg border-tile-present',
|
||||||
X: 'bg-tile-absent text-tile-absent-fg border-tile-absent',
|
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.
|
* 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
|
* original game ships a high-contrast mode. A second channel means the result
|
||||||
* survives deuteranopia, a projector with the colour balance wrong, and a
|
* 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({
|
function Tile({
|
||||||
letter,
|
letter,
|
||||||
@@ -33,35 +74,45 @@ function Tile({
|
|||||||
compact,
|
compact,
|
||||||
}: {
|
}: {
|
||||||
letter: string;
|
letter: string;
|
||||||
tile: Pattern[number] | null;
|
tile: TileState | null;
|
||||||
index: number;
|
index: number;
|
||||||
revealing: boolean;
|
revealing: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const filled = letter !== '';
|
const filled = letter !== '';
|
||||||
|
const landed = useLanded(revealing && tile !== null, index);
|
||||||
|
const scored = tile !== null && landed;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative grid place-items-center select-none font-semibold uppercase',
|
'relative grid select-none place-items-center font-sans font-semibold uppercase',
|
||||||
'aspect-square rounded-md border-2 transition-colors',
|
'aspect-square rounded-tile transition-colors duration-1',
|
||||||
compact ? 'text-[0.55rem] border' : 'text-xl sm:text-2xl',
|
compact ? 'border text-[0.55rem]' : 'border-2 text-[1.375rem] sm:text-2xl',
|
||||||
tile
|
scored
|
||||||
? TILE_CLASS[tile]
|
? TILE_CLASS[tile]
|
||||||
: filled
|
: filled
|
||||||
? 'border-muted/60 bg-surface text-fg'
|
? // Typed, unscored: a strong edge says "this is yours", the letter is
|
||||||
: 'border-border bg-surface-2/40 text-fg',
|
// plain ink. No fill, so the scored fill is an unmistakable change.
|
||||||
// The flip is a rotation about X with the colour landing at the
|
'border-border-strong bg-transparent text-fg'
|
||||||
// half-way point, staggered along the row. `prefers-reduced-motion`
|
: '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.
|
// clamps it to nothing globally, which is why announceRow() exists.
|
||||||
revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
|
revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
|
||||||
filled && !tile && 'motion-safe:animate-[tile-pop_120ms_ease-enter]',
|
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"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{letter}
|
{letter}
|
||||||
{tile ? (
|
{scored ? (
|
||||||
<span className="pointer-events-none absolute bottom-0 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-90">
|
<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]}
|
{TILE_GLYPH[tile]}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -83,19 +134,25 @@ function Row({
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const letters = guess.padEnd(WORD_LENGTH, ' ').slice(0, WORD_LENGTH);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('grid gap-1 sm:gap-1.5', shaking && 'motion-safe:animate-[tile-shake_400ms_ease-enter]')}
|
||||||
'grid gap-1 sm:gap-1.5',
|
|
||||||
shake && 'motion-safe:animate-[tile-shake_600ms_ease-enter]',
|
|
||||||
)}
|
|
||||||
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
|
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
|
||||||
|
onAnimationEnd={(event) => {
|
||||||
|
if (event.animationName === 'tile-shake') setShaking(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{[...letters].map((letter, i) => (
|
{[...letters].map((letter, i) => (
|
||||||
<Tile
|
<Tile
|
||||||
key={i}
|
key={i}
|
||||||
letter={letter.trim()}
|
letter={letter.trim()}
|
||||||
tile={pattern ? (pattern[i] as Pattern[number]) : null}
|
tile={pattern ? (pattern[i] as TileState) : null}
|
||||||
index={i}
|
index={i}
|
||||||
revealing={revealing}
|
revealing={revealing}
|
||||||
compact={compact}
|
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({
|
export const Board = memo(function Board({
|
||||||
state,
|
state,
|
||||||
compact,
|
compact,
|
||||||
@@ -119,26 +184,23 @@ export const Board = memo(function Board({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'grid gap-1 sm:gap-1.5',
|
'grid',
|
||||||
// An explicit width, not `w-full max-w-sm`. An empty board has no
|
// 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
|
// 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
|
// to nothing and the board collapses to a stub — which is exactly the
|
||||||
// state the interactive board starts in.
|
// 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"
|
role="img"
|
||||||
aria-label={
|
aria-label={boardLabel(state)}
|
||||||
rows.length === 0
|
|
||||||
? 'Empty board, six guesses remaining.'
|
|
||||||
: `${rows.length} of ${MAX_GUESSES} guesses played.`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{rows.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
<Row
|
<Row
|
||||||
key={`${row.guess}-${i}`}
|
key={`${row.guess}-${i}`}
|
||||||
guess={row.guess}
|
guess={row.guess}
|
||||||
pattern={row.pattern}
|
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}
|
compact={compact}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,41 +2,107 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Faces. Geist (sans) and Geist Mono, both SIL OFL 1.1, self-hosted from the
|
||||||
|
* @fontsource-variable packages. Production CSP is `font-src 'self'`, so a
|
||||||
|
* hosted font would silently fall through to the system stack; Vite resolves
|
||||||
|
* these bare-package URLs and emits the woff2 under /assets, which is 'self'.
|
||||||
|
* Latin subset only — every string on the site is English and the ids are
|
||||||
|
* ASCII; the other subsets would be 400 kB of bytes nobody renders.
|
||||||
|
*
|
||||||
|
* ABC Favorit (Prime Intellect's display mono) is a licensed trial font and is
|
||||||
|
* NOT used. Geist Mono is the honest substitute.
|
||||||
|
*/
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Manrope Variable';
|
font-family: 'Geist Variable';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
font-weight: 200 800;
|
font-weight: 100 900;
|
||||||
src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations');
|
src:
|
||||||
|
url('@fontsource-variable/geist/files/geist-latin-wght-normal.woff2') format('woff2-variations'),
|
||||||
|
url('@fontsource-variable/geist/files/geist-latin-wght-normal.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Geist Mono Variable';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src:
|
||||||
|
url('@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2') format('woff2-variations'),
|
||||||
|
url('@fontsource-variable/geist-mono/files/geist-mono-latin-wght-normal.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* PIG's token layer, with the accent inlined as literals rather than written
|
* PIG's token layer, inlined as literals rather than written onto the root at
|
||||||
* onto the root at runtime. This site has no user-chosen accent, so a runtime
|
* runtime. This site has no user-chosen accent, so a runtime theme module
|
||||||
* theme module would be a moving part that buys nothing here.
|
* would be a moving part that buys nothing here.
|
||||||
*
|
*
|
||||||
* Every colour is an HSL channel triple — `H S% L%` — so Tailwind can compose
|
* Every colour is an HSL channel triple — `H S% L%` — so Tailwind can compose
|
||||||
* it with an opacity modifier (`bg-brand/10`). Writing `hsl(...)` into the
|
* it with an opacity modifier (`bg-fg/40`). Writing `hsl(...)` into the
|
||||||
* variable breaks that silently: the class compiles and the opacity is ignored.
|
* variable breaks that silently: the class compiles and the opacity is ignored.
|
||||||
|
*
|
||||||
|
* The palette adopts Prime Intellect's materials (pure-neutral greys, one green,
|
||||||
|
* amber and red for status) — measured from primeintellect.ai, not guessed. It
|
||||||
|
* does not impersonate: no PI logo, no PI wordmark, and the greys that fail
|
||||||
|
* WCAG on their own ground are lifted until they pass (see --muted).
|
||||||
|
*
|
||||||
|
* Every ratio in this file is WCAG 2.x relative-luminance contrast computed by
|
||||||
|
* scratchpad/contrast.mjs from these exact triples. Change a triple, re-run
|
||||||
|
* the script, and change the comment: a stale number here is worse than none.
|
||||||
|
*
|
||||||
|
* Rules that have no lint and rely on review:
|
||||||
|
* - `--danger` as text goes only on bg, surface, surface-2 and danger-subtle.
|
||||||
|
* Never on surface-3 or accent-subtle (4.28 / 4.13 in dark — both fail).
|
||||||
|
* - `--accent` is a FILL token. As text it is only valid in dark, where it
|
||||||
|
* equals `--accent-fg`. Use `--accent-fg` for any green text or icon.
|
||||||
|
* - Every green-filled control carries a 1px `--accent-edge` border. In light
|
||||||
|
* the fill is 1.47:1 against white; the edge is what makes it a control.
|
||||||
|
* - No alpha tints on grounds (`bg-warning/5`, `border-border/60`) — they
|
||||||
|
* vanish on #0F0F0F. Use the `*-subtle` tokens. Alpha survives only on the
|
||||||
|
* overlay scrim (`bg-fg/40`) and the light-mode CTA hover.
|
||||||
|
* - There is no `--muted-2`. Icons at rest and hairlines that must read use
|
||||||
|
* `--border-strong`, which clears 3:1 (non-text) in both themes.
|
||||||
|
*
|
||||||
|
* Bare :root is the LIGHT theme; next-themes stamps data-theme="dark" (including
|
||||||
|
* for a system-dark visitor), so no prefers-color-scheme block is needed.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--bg: 240 5% 98%;
|
/* ---------------------------------------------------------- light grounds */
|
||||||
--surface: 0 0% 100%;
|
--bg: 0 0% 98%; /* #FAFAFA */
|
||||||
--surface-2: 240 5% 97%;
|
--surface: 0 0% 100%; /* #FFFFFF cards, header, panels */
|
||||||
--border: 240 6% 90%;
|
--surface-2: 0 0% 96%; /* #F5F5F5 insets, idle keys, chips, row hover */
|
||||||
--fg: 240 10% 4%;
|
--surface-3: 0 0% 93%; /* #EDEDED code grounds, table heads, active tab, surface-2 hover */
|
||||||
--muted: 240 4% 46%;
|
--border: 0 0% 89%; /* #E3E3E3 decorative hairline: 1.28 vs surface, never the sole signal */
|
||||||
|
--border-strong: 0 0% 55%; /* #8C8C8C any boundary that IS the signal: 3.36 vs surface, 3.08 vs surface-2, 3.22 vs bg */
|
||||||
|
|
||||||
--accent: 262 83% 55%;
|
/* -------------------------------------------------------------- light ink */
|
||||||
--accent-fg: 263 70% 46%;
|
--fg: 0 0% 6%; /* #0F0F0F headings, claims, values: 19.17 on surface */
|
||||||
--accent-on: 0 0% 100%;
|
--fg-2: 0 0% 22%; /* #383838 prose ink: 11.73 on surface, 9.92 on danger-subtle */
|
||||||
--accent-subtle: 270 100% 97%;
|
--muted: 0 0% 40%; /* #666666 captions, labels: 5.74 on surface, 4.90 on surface-3, 4.86 on danger-subtle */
|
||||||
|
|
||||||
--positive: 160 84% 24%;
|
/* ------------------------------------------------------------ light accent */
|
||||||
--warning: 32 95% 31%;
|
--accent: 112 77% 69%; /* #83ED73 FILL ONLY — 1.47 on white as text, forbidden as text in light */
|
||||||
--danger: 0 72% 45%;
|
--accent-fg: 112 60% 27%; /* #276E1C accent as text/icon/link: 6.30 on surface, 6.04 on bg, 5.33 on danger-subtle */
|
||||||
--info: 201 90% 32%;
|
--accent-on: 0 0% 6%; /* #0F0F0F text on an accent fill: 13.08 */
|
||||||
--shadow: 240 10% 4%;
|
--accent-edge: 112 60% 27%;/* #276E1C the 1px edge of every green-filled control: 6.30 vs white */
|
||||||
|
--accent-subtle: 112 60% 93%; /* #E5F8E2 selected rows, grader box, verified badge */
|
||||||
|
--ring: 112 60% 27%; /* #276E1C focus ring — the neon ring would be 1.47 on white */
|
||||||
|
--positive: 112 60% 27%; /* = accent-fg. One green: live, pass, solved, exact */
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ light status */
|
||||||
|
--warning: 32 95% 30%; /* #955104 text only: 6.08 on surface. Reads brown as a fill, so tiles never use it */
|
||||||
|
--warning-on: 0 0% 100%; /* text on a warning fill: 6.08 */
|
||||||
|
--warning-subtle: 39 90% 94%; /* #FDF4E2 synthetic banner, EDITED chip, counterweight chip, limits card */
|
||||||
|
--danger: 357 80% 40%; /* #B8141D 6.65 on surface, 5.63 on danger-subtle */
|
||||||
|
--danger-on: 0 0% 100%; /* text on a danger fill: 6.65 */
|
||||||
|
--danger-subtle: 357 85% 95%; /* #FDE7E8 verify mismatch ground, out-of-tolerance rows */
|
||||||
|
--info: 210 22% 36%; /* #485C70 kept for StatTone 'info' only: 6.91 on surface */
|
||||||
|
--shadow: 0 0% 6%;
|
||||||
|
|
||||||
|
/* The tab-bar underline. `--accent` is a fill token and the underline is a
|
||||||
|
2px state indicator on the page ground, so in light it takes the text
|
||||||
|
green (6.04 vs bg); in dark the two are the same colour. */
|
||||||
|
--tab-underline: 112 60% 27%;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Board tiles. Foregrounds are stated explicitly and measured, because the
|
* Board tiles. Foregrounds are stated explicitly and measured, because the
|
||||||
@@ -44,30 +110,44 @@
|
|||||||
* tile and a grey tile differ only by fill, and a letter that fails contrast
|
* tile and a grey tile differ only by fill, and a letter that fails contrast
|
||||||
* against its own tile makes the result unreadable exactly when it matters.
|
* against its own tile makes the result unreadable exactly when it matters.
|
||||||
*
|
*
|
||||||
* The present tile does NOT reuse --warning. That token is 32 95% 31%, darkened
|
* The exact and present fills are the SAME in both themes: the board is the
|
||||||
* until white text clears 4.5:1 on it, and at that lightness it reads BROWN.
|
* one theme-invariant object on the site. A green-and-amber board on a white
|
||||||
* On a board where people arrive with a hard prior that this square is yellow,
|
* page is deliberate and will be called "loud" on the first screenshot; the
|
||||||
* a brown square reads as a bug in the scorer rather than as a design choice —
|
* answer is the glyph contrast below and the high-contrast mode. Do not
|
||||||
* which on a page whose whole argument is "the grader is correct" is the
|
* soften the fills.
|
||||||
* single worst thing it could look like. So the fill stays a real yellow and
|
|
||||||
* the GLYPH goes dark instead, which is both more expected and higher contrast
|
|
||||||
* than the version that was trying to be careful.
|
|
||||||
*
|
*
|
||||||
* Measured against each tile's own fill:
|
* The present tile does NOT reuse --warning. That token is darkened until
|
||||||
* exact white on 160 84% 24% -> 6.03:1
|
* white text clears 4.5:1 on it, and at that lightness it reads BROWN. On a
|
||||||
* present near-black on 45 93% 47% -> 10.02:1
|
* board where people arrive with a hard prior that this square is yellow, a
|
||||||
* absent white on 240 4% 46% -> 4.61:1
|
* brown square reads as a bug in the scorer — which on a page whose whole
|
||||||
|
* argument is "the grader is correct" is the worst thing it could look like.
|
||||||
|
* So the fill stays amber and the GLYPH goes dark.
|
||||||
|
*
|
||||||
|
* Measured against each tile's own fill (light):
|
||||||
|
* exact near-black on 112 77% 69% -> 13.08
|
||||||
|
* present near-black on 39 87% 65% -> 11.16
|
||||||
|
* absent white on 0 0% 40% -> 5.74 (tile vs surface 5.74)
|
||||||
|
* exact vs present 1.17 — hue-only, same as the original game; HC mode is the path
|
||||||
*/
|
*/
|
||||||
--tile-exact: 160 84% 24%;
|
--tile-exact: 112 77% 69%;
|
||||||
--tile-exact-fg: 0 0% 100%;
|
--tile-exact-fg: 0 0% 6%;
|
||||||
--tile-present: 45 93% 47%;
|
--tile-present: 39 87% 65%;
|
||||||
--tile-present-fg: 240 10% 4%;
|
--tile-present-fg: 0 0% 6%;
|
||||||
--tile-absent: 240 4% 46%;
|
--tile-absent: 0 0% 40%;
|
||||||
--tile-absent-fg: 0 0% 100%;
|
--tile-absent-fg: 0 0% 100%;
|
||||||
|
--tile-empty-border: 0 0% 78%; /* #C7C7C7 unplayed tile edge: 1.69 vs surface */
|
||||||
|
|
||||||
--dur-1: 120ms;
|
/* ------------------------------------------------------------- non-colour */
|
||||||
--dur-2: 180ms;
|
--radius-control: 0.375rem; /* 6px: buttons, inputs, tabs, selects, chips */
|
||||||
--dur-3: 240ms;
|
--radius-surface: 0.75rem; /* 12px: cards, panels, sheets */
|
||||||
|
--radius-tile: 0.25rem; /* 4px: board tiles, table cells, cite chips */
|
||||||
|
|
||||||
|
--measure: 62ch; /* explaining prose */
|
||||||
|
--measure-wide: 72ch; /* ledes */
|
||||||
|
|
||||||
|
--dur-1: 120ms; /* hover, colour */
|
||||||
|
--dur-2: 180ms; /* ranking reorder, tab underline */
|
||||||
|
--dur-3: 240ms; /* sheet, drawer */
|
||||||
--ease-out: cubic-bezier(0.2, 0, 0, 1);
|
--ease-out: cubic-bezier(0.2, 0, 0, 1);
|
||||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
|
||||||
@@ -76,62 +156,98 @@
|
|||||||
--safe-left: env(safe-area-inset-left, 0px);
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
--safe-right: env(safe-area-inset-right, 0px);
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
|
||||||
|
/* One bar, 56px + safe-top at EVERY width. The old lg step to 4rem is gone:
|
||||||
|
the demo tab bar sticks under this and the language's bar is 56px. */
|
||||||
--app-header-h: calc(3.5rem + var(--safe-top));
|
--app-header-h: calc(3.5rem + var(--safe-top));
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
/* Overlays only — sheet, drawer, tooltip, navigation panel. Flat components
|
||||||
:root { --app-header-h: calc(4rem + var(--safe-top)); }
|
(buttons, tabs, cards, chips) carry no shadow; their borders do the work. */
|
||||||
}
|
--shadow-overlay: 0 8px 24px hsl(var(--shadow) / 0.12);
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
|
||||||
--bg: 240 10% 4%;
|
|
||||||
--surface: 240 8% 7%;
|
|
||||||
--surface-2: 240 6% 11%;
|
|
||||||
--border: 240 5% 18%;
|
|
||||||
--fg: 0 0% 98%;
|
|
||||||
--muted: 240 5% 60%;
|
|
||||||
|
|
||||||
--accent: 258 90% 70%;
|
|
||||||
--accent-fg: 255 92% 78%;
|
|
||||||
--accent-on: 264 80% 12%;
|
|
||||||
--accent-subtle: 260 35% 20%;
|
|
||||||
|
|
||||||
--positive: 158 64% 48%;
|
|
||||||
--warning: 38 92% 58%;
|
|
||||||
--danger: 0 84% 65%;
|
|
||||||
--info: 199 89% 58%;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Dark tiles lighten and take a dark glyph. Keeping the light theme's dark
|
|
||||||
* fills would have made the board the only near-black object on a near-black
|
|
||||||
* page, so a solved row read as an empty one.
|
|
||||||
* exact 240 10% 4% on 158 64% 48% -> 7.31:1
|
|
||||||
* present 240 10% 4% on 38 92% 58% -> 9.24:1
|
|
||||||
* absent 0 0% 98% on 240 5% 28% -> 8.94:1
|
|
||||||
*/
|
|
||||||
--tile-exact: 158 64% 48%;
|
|
||||||
--tile-exact-fg: 240 10% 4%;
|
|
||||||
--tile-present: 38 92% 58%;
|
|
||||||
--tile-present-fg: 240 10% 4%;
|
|
||||||
--tile-absent: 240 5% 28%;
|
|
||||||
--tile-absent-fg: 0 0% 98%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* High contrast. Green/yellow/grey is a colour-only distinction, which is
|
* Dark — the home theme. Grounds and greys are Prime Intellect's measured
|
||||||
* exactly why the original game ships a high-contrast mode. Toggled from the
|
* values (#0F0F0F, #141414, #1C1C1C, #242424, #F5F5F5). Two of their greys
|
||||||
* header and persisted per-viewer; the tiles also carry a glyph in this mode,
|
* FAIL on their own ground: #737373 is 4.04:1 and #6E6E6E is 3.76:1 on
|
||||||
|
* #0F0F0F. `--muted` is deliberately lifted to 58% (#949494, 6.32 on bg,
|
||||||
|
* 4.94 on accent-subtle) so captions pass everywhere they land. Do not
|
||||||
|
* "correct" it back to PI's number.
|
||||||
|
*/
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--bg: 0 0% 6%; /* #0F0F0F */
|
||||||
|
--surface: 0 0% 8%; /* #141414 */
|
||||||
|
--surface-2: 0 0% 11%; /* #1C1C1C */
|
||||||
|
--surface-3: 0 0% 14%; /* #242424 */
|
||||||
|
--border: 0 0% 13%; /* #212121 1.14 vs surface, by design — never the sole signal */
|
||||||
|
--border-strong: 0 0% 42%; /* #6B6B6B 3.46 vs surface, 3.20 vs surface-2, 3.60 vs bg */
|
||||||
|
|
||||||
|
--fg: 0 0% 96%; /* #F5F5F5 17.58 on bg, 13.74 on accent-subtle */
|
||||||
|
--fg-2: 0 0% 80%; /* #CCCCCC 11.94 on bg, 9.33 on accent-subtle */
|
||||||
|
--muted: 0 0% 58%; /* #949494 6.32 on bg, 5.12 on surface-3, 4.94 on accent-subtle (lifted, see above) */
|
||||||
|
|
||||||
|
--accent: 112 77% 69%; /* #83ED73 fills: CTA, progress hairline, tile-exact, bars */
|
||||||
|
--accent-fg: 112 77% 69%; /* = accent in dark: 13.08 on bg, 10.22 on accent-subtle */
|
||||||
|
--accent-on: 0 0% 6%; /* #0F0F0F on accent: 13.08 */
|
||||||
|
--accent-edge: 112 77% 69%;/* = accent: the 1px edge is a no-op in dark and exists so the class is theme-blind */
|
||||||
|
--accent-subtle: 112 30% 13%; /* #1A2B17 */
|
||||||
|
--ring: 112 77% 69%;
|
||||||
|
--positive: 112 77% 69%; /* = accent */
|
||||||
|
|
||||||
|
--warning: 39 87% 65%; /* #F3BD58 PI amber: 11.16 on bg, 8.72 on accent-subtle */
|
||||||
|
--warning-on: 0 0% 6%; /* on a warning fill: 11.16 */
|
||||||
|
--warning-subtle: 39 40% 12%; /* #2B2212 */
|
||||||
|
--danger: 357 100% 60%; /* #FF333D PI red: 5.29 on bg, 5.08 on surface, 4.70 on surface-2, 4.72 on danger-subtle.
|
||||||
|
FAILS on surface-3 (4.28) and accent-subtle (4.13) — never put it there */
|
||||||
|
--danger-on: 0 0% 6%; /* on a danger fill: 5.29. `text-white` on this red is 3.63 and fails */
|
||||||
|
--danger-subtle: 357 40% 13%; /* #2E1415 */
|
||||||
|
--info: 210 16% 72%; /* #ACB8C3 near-neutral steel, StatTone 'info' only: 9.49 on bg */
|
||||||
|
--shadow: 0 0% 0%;
|
||||||
|
|
||||||
|
--tab-underline: 112 77% 69%; /* = accent */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Measured against each tile's own fill (dark):
|
||||||
|
* exact near-black on 112 77% 69% -> 13.08
|
||||||
|
* present near-black on 39 87% 65% -> 11.16
|
||||||
|
* absent 0 0% 96% on 0 0% 30% -> 7.75 (tile vs surface 2.18 — raised from 24% so an absent tile reads FILLED, not empty)
|
||||||
|
* empty border 0 0% 24% vs surface 1.70 — a visible step above --border (1.14), quieter than border-strong across 30 tiles
|
||||||
|
*/
|
||||||
|
--tile-exact: 112 77% 69%;
|
||||||
|
--tile-exact-fg: 0 0% 6%;
|
||||||
|
--tile-present: 39 87% 65%;
|
||||||
|
--tile-present-fg: 0 0% 6%;
|
||||||
|
--tile-absent: 0 0% 30%;
|
||||||
|
--tile-absent-fg: 0 0% 96%;
|
||||||
|
--tile-empty-border: 0 0% 24%;
|
||||||
|
|
||||||
|
--shadow-overlay: 0 8px 24px hsl(var(--shadow) / 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* High contrast — tiles only. Green/amber/grey is a colour-only distinction,
|
||||||
|
* which is exactly why the original game ships a high-contrast mode. Toggled
|
||||||
|
* from the header and the Play tab, persisted per-viewer; the tiles also carry
|
||||||
|
* a glyph (● exact, ◆ present, none for absent) at full opacity in this mode,
|
||||||
* so the result survives being printed, screenshotted or seen by someone with
|
* so the result survives being printed, screenshotted or seen by someone with
|
||||||
* deuteranopia.
|
* deuteranopia.
|
||||||
|
*
|
||||||
|
* White glyph on each fill:
|
||||||
|
* exact 209 100% 40% (#0069CC) -> 5.39 (3.41 vs the dark surface, 5.39 vs the light one)
|
||||||
|
* present 27 100% 36% (#B85300) -> 4.91 THE PREVIOUSLY SHIPPED 27 100% 42% MEASURES 3.80 AND FAILS. One number, committed here.
|
||||||
|
* absent 0 0% 38% (#616161) -> 6.19 light theme; 2.97 vs the dark surface, hence the override below
|
||||||
|
* exact vs present 1.10 — the glyphs are the distinction, not the hues
|
||||||
*/
|
*/
|
||||||
:root[data-contrast='high'] {
|
:root[data-contrast='high'] {
|
||||||
--tile-exact: 209 100% 40%;
|
--tile-exact: 209 100% 40%;
|
||||||
--tile-present: 27 100% 42%;
|
--tile-present: 27 100% 36%;
|
||||||
--tile-absent: 240 4% 38%;
|
--tile-absent: 0 0% 38%;
|
||||||
--tile-exact-fg: 0 0% 100%;
|
--tile-exact-fg: 0 0% 100%;
|
||||||
--tile-present-fg: 0 0% 100%;
|
--tile-present-fg: 0 0% 100%;
|
||||||
--tile-absent-fg: 0 0% 100%;
|
--tile-absent-fg: 0 0% 100%;
|
||||||
}
|
}
|
||||||
|
:root[data-theme='dark'][data-contrast='high'] {
|
||||||
|
--tile-absent: 0 0% 45%; /* #737373: white glyph 4.74; 3.89 vs the dark surface so it still reads filled */
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
@@ -140,8 +256,12 @@
|
|||||||
/* Painted explicitly: the viewer's chrome shows through a transparent body
|
/* Painted explicitly: the viewer's chrome shows through a transparent body
|
||||||
and the page would borrow the host's theme instead of its own. */
|
and the page would borrow the host's theme instead of its own. */
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
|
/* Tabular figures site-wide: every number on this site is compared to
|
||||||
|
another number, and a proportional "1" in a reward column makes a
|
||||||
|
ranking look re-ordered when it is not. */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
/* Tabular figures everywhere a number is compared to another number. */
|
/* Kept as an explicit no-op for compat: body already sets tabular-nums. */
|
||||||
.nums { font-variant-numeric: tabular-nums; }
|
.nums { font-variant-numeric: tabular-nums; }
|
||||||
:focus-visible { @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-bg; }
|
:focus-visible { @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-bg; }
|
||||||
}
|
}
|
||||||
@@ -153,6 +273,32 @@
|
|||||||
.tap { @apply min-h-11 min-w-11; }
|
.tap { @apply min-h-11 min-w-11; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Touch targets follow the POINTER, not the width. Components step their
|
||||||
|
* controls down from 44px to 36-40px at `lg` (1024px) on the assumption that
|
||||||
|
* a 1024px viewport has a mouse. An iPad in landscape is exactly 1024px and
|
||||||
|
* has a finger; measured there, the header icons were 36px, the nav triggers
|
||||||
|
* 40px and the nested tab strips 36px. On a coarse pointer every control keeps
|
||||||
|
* the 44px floor at every width, and icon-only controls (an aria-label and a
|
||||||
|
* lone svg) keep it on both axes. `:root :is(...)` outranks the single-class
|
||||||
|
* `lg:min-h-9` utilities without `!important`. `a[href]` rather than a class:
|
||||||
|
* the header CTA is `hidden lg:inline-flex`, so no display class is reliable,
|
||||||
|
* and inline links in prose are unaffected because min-height does not apply
|
||||||
|
* to inline boxes.
|
||||||
|
*/
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
:root :is(button, a[href], [role='button'], [role='tab'], [role='radio'], [role='switch'], [role='slider'], summary, select) {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
/* Icon-only controls, and the segmented options ("#1", "1×") whose text is
|
||||||
|
two glyphs wide, keep the floor on both axes. Segments wrap, so this
|
||||||
|
cannot widen a row past the viewport. */
|
||||||
|
:root :is(button, a)[aria-label]:has(> svg:only-child),
|
||||||
|
:root [role='radio'] {
|
||||||
|
min-width: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Reduced motion clamps everything to effectively zero. That is correct, and
|
* Reduced motion clamps everything to effectively zero. That is correct, and
|
||||||
* it is also why every state change in this app must ALSO be announced: with
|
* it is also why every state change in this app must ALSO be announced: with
|
||||||
@@ -172,7 +318,7 @@
|
|||||||
* Board motion. Declared here rather than in the demo because Tailwind's
|
* Board motion. Declared here rather than in the demo because Tailwind's
|
||||||
* arbitrary `animate-[...]` needs the keyframes to exist in the stylesheet, and
|
* arbitrary `animate-[...]` needs the keyframes to exist in the stylesheet, and
|
||||||
* a demo-local <style> block would be a second place for the token values to
|
* a demo-local <style> block would be a second place for the token values to
|
||||||
* drift from.
|
* drift from. Pop 120ms, flip 520ms staggered 100ms/tile, shake 400ms.
|
||||||
*/
|
*/
|
||||||
@keyframes tile-flip {
|
@keyframes tile-flip {
|
||||||
0% { transform: rotateX(0deg); }
|
0% { transform: rotateX(0deg); }
|
||||||
|
|||||||
@@ -1,11 +1,62 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx';
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { extendTailwindMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shadcn `cn`. `twMerge` is v2 here — importing from a v3 path
|
* The shadcn `cn`, on a `twMerge` that knows this project's theme.
|
||||||
* (`tailwind-merge/v3` or the `createTailwindMerge` split entry) resolves at
|
*
|
||||||
* type level and then fails at bundle time, so keep this import bare.
|
* `tailwind-merge` is v2 here — importing from a v3 path (`tailwind-merge/v3`
|
||||||
|
* or the `createTailwindMerge` split entry) resolves at type level and then
|
||||||
|
* fails at bundle time, so keep this import bare.
|
||||||
|
*
|
||||||
|
* The extension is load-bearing, not cosmetic. Stock `twMerge` only knows the
|
||||||
|
* t-shirt font sizes, so it files every custom `text-<size>` from
|
||||||
|
* tailwind.config.js (`text-label`, `text-ui`, `text-h3`, …) under text
|
||||||
|
* COLOUR — and `cn('text-label text-muted')` silently drops the size. Every
|
||||||
|
* `cva` variant and every `className` override runs through here, so without
|
||||||
|
* this list the whole type scale loses to the first colour class it meets.
|
||||||
|
* The other groups exist so an override can actually override
|
||||||
|
* (`shadow-overlay` beats `shadow-sm`, `rounded-control` beats `rounded-md`).
|
||||||
|
* Keep these lists in step with `theme.extend` in tailwind.config.js.
|
||||||
*/
|
*/
|
||||||
|
const FONT_SIZES = [
|
||||||
|
'display',
|
||||||
|
'display-lg',
|
||||||
|
'display-xl',
|
||||||
|
'h1',
|
||||||
|
'h1-lg',
|
||||||
|
'h2',
|
||||||
|
'h2-lg',
|
||||||
|
'h3',
|
||||||
|
'lede',
|
||||||
|
'lede-lg',
|
||||||
|
'claim',
|
||||||
|
'claim-lg',
|
||||||
|
'prose',
|
||||||
|
'ui',
|
||||||
|
'small',
|
||||||
|
'caption',
|
||||||
|
'label',
|
||||||
|
'mono-data',
|
||||||
|
'stat',
|
||||||
|
'metric',
|
||||||
|
'figure',
|
||||||
|
'figure-lg',
|
||||||
|
'code',
|
||||||
|
];
|
||||||
|
|
||||||
|
const twMerge = extendTailwindMerge({
|
||||||
|
extend: {
|
||||||
|
classGroups: {
|
||||||
|
'font-size': [{ text: FONT_SIZES }],
|
||||||
|
shadow: [{ shadow: ['overlay'] }],
|
||||||
|
rounded: [{ rounded: ['tile', 'control', 'surface'] }],
|
||||||
|
tracking: [{ tracking: ['label'] }],
|
||||||
|
'max-w': [{ 'max-w': ['canvas', 'measure', 'measure-wide'] }],
|
||||||
|
ease: [{ ease: ['enter', 'exit'] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]): string {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,32 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DefinitionStrip } from '@/components/site/DefinitionStrip';
|
||||||
import {
|
import {
|
||||||
ALL_VERTICALS,
|
ALL_VERTICALS,
|
||||||
EnvironmentGrid,
|
EnvironmentList,
|
||||||
FeaturedEnvironment,
|
FeaturedEnvironment,
|
||||||
environments,
|
environments,
|
||||||
filterKeys,
|
filterKeys,
|
||||||
VerticalFilter,
|
VerticalFilter,
|
||||||
type VerticalFilterValue,
|
type VerticalFilterValue,
|
||||||
} from '@/components/site/EnvironmentPicker';
|
} from '@/components/site/EnvironmentPicker';
|
||||||
import { routes } from '@/content/lineup';
|
import { pad2, routes } from '@/content/lineup';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||||
import { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
|
import { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
|
||||||
import type { Vertical } from '@/lib/demo-kit/types';
|
import type { Vertical } from '@/lib/demo-kit/types';
|
||||||
import * as s from '@/content/styles';
|
|
||||||
import { pageTitle, useSeo } from '@/lib/seo';
|
import { pageTitle, useSeo } from '@/lib/seo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The fuller list.
|
* The fuller list.
|
||||||
*
|
*
|
||||||
* Home leads with the picker; this is where you go to see everything and cut it
|
* Home leads with the picker; this is where you go to see everything and cut it
|
||||||
* by industry. The cards are the same component, so the two pages cannot drift
|
* by industry. The built environments are the same `FeaturedEnvironment` cards
|
||||||
* into describing the same environment two different ways — which is how an
|
* and the written ones the same `EnvironmentRow` rows, so the two pages cannot
|
||||||
* honest site quietly becomes a dishonest one.
|
* drift into describing the same environment two different ways — which is how
|
||||||
|
* an honest site quietly becomes a dishonest one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** What a `spec` has to contain before it is allowed on this page. Contract rule 12. */
|
|
||||||
const SPEC_PARTS: readonly { label: string; body: string }[] = [
|
|
||||||
{ label: 'A task', body: 'One unit of work with a beginning and an end.' },
|
|
||||||
{ label: 'Legal moves', body: 'What the agent is allowed to do, and what is refused.' },
|
|
||||||
{ label: 'A grader', body: 'Deterministic code that marks the attempt. No judge, no rubric.' },
|
|
||||||
{
|
|
||||||
label: 'A counterweight',
|
|
||||||
body: 'The term that stops the objective being maximised the crude way.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Gallery() {
|
export default function Gallery() {
|
||||||
useSeo({
|
useSeo({
|
||||||
title: pageTitle('Every environment'),
|
title: pageTitle('Every environment'),
|
||||||
@@ -74,14 +64,15 @@ export default function Gallery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${s.shell} py-10 sm:py-16`}>
|
<main className={`${s.shell} py-10 sm:py-14`}>
|
||||||
<p className={s.eyebrow}>Gallery</p>
|
<p className={s.label}>
|
||||||
<h1 className={`${s.h1} mt-3 max-w-3xl`}>Every environment we have built or specified.</h1>
|
Gallery · {pad2(environments.length)} environments
|
||||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
</p>
|
||||||
A built environment is playable in this tab, on the same code the repository ships. A spec is
|
<h1 className={`${s.h1} mt-3 max-w-[24ch]`}>Every environment we have built or specified.</h1>
|
||||||
a written environment — task, action set, grader, counterweight and the command that
|
<p className={`${s.lede} mt-4`}>
|
||||||
evaluates it — published in full, with no interactive surface yet. There are no coming-soon
|
A built environment is playable in this tab, on the code the repository ships. A spec is a
|
||||||
cards here.
|
written environment — task, legal actions, grader, counterweight and the command that
|
||||||
|
evaluates it — published in full, with no board yet. There are no coming-soon cards here.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{filterKeys.length > 1 ? (
|
{filterKeys.length > 1 ? (
|
||||||
@@ -95,14 +86,21 @@ export default function Gallery() {
|
|||||||
The count changes without a page load and without moving focus, so it
|
The count changes without a page load and without moving focus, so it
|
||||||
is announced. Without this, a filter tap is silent to a screen reader.
|
is announced. Without this, a filter tap is silent to a screen reader.
|
||||||
*/}
|
*/}
|
||||||
<p aria-live="polite" className="mt-6 text-sm text-muted">
|
<p aria-live="polite" className={`${s.label} mt-6`}>
|
||||||
{shown.length === 1 ? '1 environment' : `${shown.length} environments`}
|
{[
|
||||||
{active === ALL_VERTICALS ? '' : ` in ${VERTICAL_LABELS[active]}`}
|
`${pad2(shown.length)} ${shown.length === 1 ? 'environment' : 'environments'}`,
|
||||||
{shown.length === 0 ? '' : ` · ${built.length} built · ${written.length} written`}
|
active === ALL_VERTICALS ? null : VERTICAL_LABELS[active],
|
||||||
|
// Zero segments are dropped, not printed as `00`: a filter with no
|
||||||
|
// live entry says so by omission, the same way the landing does.
|
||||||
|
built.length > 0 ? `${pad2(built.length)} live` : null,
|
||||||
|
written.length > 0 ? `${pad2(written.length)} spec` : null,
|
||||||
|
]
|
||||||
|
.filter((part): part is string => part !== null)
|
||||||
|
.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{shown.length === 0 ? (
|
{shown.length === 0 ? (
|
||||||
<div className="card mt-4 p-6">
|
<div className="card mt-4 p-4 sm:p-5">
|
||||||
{/* Two different nothings. Telling a visitor "no environment is filed
|
{/* Two different nothings. Telling a visitor "no environment is filed
|
||||||
under this vertical" when they have not filtered anything reads as
|
under this vertical" when they have not filtered anything reads as
|
||||||
a broken page rather than an empty one. */}
|
a broken page rather than an empty one. */}
|
||||||
@@ -129,15 +127,17 @@ export default function Gallery() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{built.length > 0 ? (
|
{built.length > 0 ? (
|
||||||
<section aria-labelledby="built" className="mt-4">
|
<section aria-labelledby="built" className="mt-6">
|
||||||
<h2 className={s.h2} id="built">
|
<h2 className={s.h2} id="built">
|
||||||
{built.length === 1 ? 'The one you can play' : 'The ones you can play'}
|
{built.length === 1 ? 'The one you can play' : 'The ones you can play'}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-4 space-y-4">
|
<ul className={`mt-4 grid gap-4 ${built.length >= 2 ? 'lg:grid-cols-2' : ''}`}>
|
||||||
{built.map((entry) => (
|
{built.map((entry) => (
|
||||||
<FeaturedEnvironment entry={entry} key={entry.id} />
|
<li className="flex" key={entry.id}>
|
||||||
|
<FeaturedEnvironment className="w-full" entry={entry} />
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -147,32 +147,31 @@ export default function Gallery() {
|
|||||||
<h2 className={s.h2} id="written">
|
<h2 className={s.h2} id="written">
|
||||||
{written.length === 1 ? 'The one that is written' : `The ${written.length} that are written`}
|
{written.length === 1 ? 'The one that is written' : `The ${written.length} that are written`}
|
||||||
</h2>
|
</h2>
|
||||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||||
</div>
|
</div>
|
||||||
<EnvironmentGrid className="mt-4" entries={written} />
|
{/* The same rows as the landing, with the same numerals: a filtered
|
||||||
|
list keeps each row's place in the whole lineup rather than
|
||||||
|
renumbering from 01, so `04` means the same thing on both pages. */}
|
||||||
|
<EnvironmentList className="mt-4" entries={written} />
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="card mt-12 p-5 sm:p-7">
|
{/* What a spec has to contain — the same four parts as everywhere else,
|
||||||
<h2 className={s.h2}>What a spec has to contain</h2>
|
read from the same source, so this page cannot teach a fifth word. */}
|
||||||
<p className={`${s.prose} mt-3 max-w-2xl`}>
|
<section aria-labelledby="contains" className="mt-12 sm:mt-16">
|
||||||
|
<h2 className={s.h2} id="contains">
|
||||||
|
What a spec has to contain
|
||||||
|
</h2>
|
||||||
|
<p className={`${s.prose} mt-3`}>
|
||||||
A card that says a demo is coming is not a specification, and the build refuses one. Every
|
A card that says a demo is coming is not a specification, and the build refuses one. Every
|
||||||
written environment above states all four of these, and the reward weights that go with
|
written environment above states all four of these, and the counterweight that stops the
|
||||||
them, before it is allowed on this page.
|
score being farmed, before it is allowed on this page.
|
||||||
</p>
|
</p>
|
||||||
<ol className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<DefinitionStrip className="mt-6" />
|
||||||
{SPEC_PARTS.map((part, i) => (
|
|
||||||
<li className="card bg-surface-2 p-4" key={part.label}>
|
|
||||||
<span className="nums text-xs font-semibold text-brand">0{i + 1}</span>
|
|
||||||
<h3 className={`${s.h3} mt-1.5`}>{part.label}</h3>
|
|
||||||
<p className={`${s.prose} mt-1.5 text-sm`}>{part.body}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
<Link className={`${s.btnSecondary} mt-6`} to={routes.honesty}>
|
<Link className={`${s.btnSecondary} mt-6`} to={routes.honesty}>
|
||||||
What we measured, and what we didn’t
|
What we measured, and what we didn’t
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,208 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowRight, ArrowUpRight } from 'lucide-react';
|
import { ArrowDown, ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { EnvAnatomy } from '@/components/demo/EnvAnatomy';
|
||||||
|
import { DefinitionStrip } from '@/components/site/DefinitionStrip';
|
||||||
import {
|
import {
|
||||||
EnvironmentGrid,
|
EnvironmentList,
|
||||||
FeaturedEnvironment,
|
FeaturedEnvironment,
|
||||||
builtEnvironments,
|
builtEnvironments,
|
||||||
|
environments,
|
||||||
writtenEnvironments,
|
writtenEnvironments,
|
||||||
} from '@/components/site/EnvironmentPicker';
|
} from '@/components/site/EnvironmentPicker';
|
||||||
|
import { Term } from '@/components/site/Term';
|
||||||
|
import { TileRow } from '@/components/site/TileRow';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { helloWorldCitations, trainingResult } from '@/content/evidence';
|
import { helloWorldCitations, trainingResult } from '@/content/evidence';
|
||||||
import { routes } from '@/content/lineup';
|
import { CountWord, countWord, pad2, routes } from '@/content/lineup';
|
||||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
|
||||||
import * as s from '@/content/styles';
|
import * as s from '@/content/styles';
|
||||||
|
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||||
|
import { listRuns, loadDemoModule, loadEpisode, verifyEpisode, verifySummary } from '@/lib/demo-kit';
|
||||||
|
import type { AnyDemoModule, RunRef, VerifyResult } from '@/lib/demo-kit';
|
||||||
import { pageTitle, useSeo } from '@/lib/seo';
|
import { pageTitle, useSeo } from '@/lib/seo';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The landing page: a line of thesis, then the picker.
|
* The landing page. Definition before catalogue, catalogue before reading list.
|
||||||
*
|
*
|
||||||
* The page used to open with the argument and bury the demos below it. That is
|
* The h1 is the site's thesis and it is jargon on its own, so the lede opens
|
||||||
* an essay, and an executive who has read the first sentence has already
|
* with the plain definition and the four-part strip under it names the parts
|
||||||
* decided whether to click something. So the picker is the primary object here
|
* a visitor will meet again on every Play and Evidence tab. Then the picker,
|
||||||
* and everything that used to lead — the credential, the measured number — sits
|
* then the machine explained once with the live demo's own numbers, then the
|
||||||
* underneath it as support for a visitor who wants it before they click.
|
* twelve written environments as a list, then the evidence.
|
||||||
*
|
*
|
||||||
* Counts in the copy are derived from the registry and the lineup, never typed.
|
* Counts in the copy are derived from the registry and the lineup, never
|
||||||
* A hard-coded "eleven" on a site whose whole argument is that its claims are
|
* typed. The three figures in "How it works" are read off the featured demo's
|
||||||
* checkable goes stale the first time a demo ships.
|
* own module and its newest recorded run, loaded lazily when the section
|
||||||
|
* scrolls near — never typed, and never on the first paint's critical path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ lazy figures */
|
||||||
|
|
||||||
|
/** True once the element has come within a screen of the viewport. */
|
||||||
|
function useNearViewport<T extends Element>(): [RefObject<T | null>, boolean] {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
const [near, setNear] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = ref.current;
|
||||||
|
if (!node || near) return undefined;
|
||||||
|
// No observer (a prerender capture, an old engine) means "load now": a
|
||||||
|
// missing figure is worse than an early fetch.
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
setNear(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) setNear(true);
|
||||||
|
},
|
||||||
|
{ rootMargin: '100% 0px' },
|
||||||
|
);
|
||||||
|
observer.observe(node);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [near]);
|
||||||
|
|
||||||
|
return [ref, near];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The most recently captured run. Ties keep manifest order, last wins. */
|
||||||
|
function newestRun(runs: readonly RunRef[]): RunRef | undefined {
|
||||||
|
let best: RunRef | undefined;
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!best || run.capturedAt >= best.capturedAt) best = run;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Figures {
|
||||||
|
module: AnyDemoModule | null;
|
||||||
|
verify: VerifyResult | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The featured demo's module and the verification of its newest run.
|
||||||
|
*
|
||||||
|
* Both start `null` and stay `null` on any failure: the placeholders print
|
||||||
|
* dashes, which is the honest rendering of "not loaded" — a zero here would be
|
||||||
|
* a claim about the grader.
|
||||||
|
*/
|
||||||
|
function useFigures(slug: string | null, near: boolean): Figures {
|
||||||
|
const [figures, setFigures] = useState<Figures>({ module: null, verify: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slug || !near) return undefined;
|
||||||
|
let live = true;
|
||||||
|
|
||||||
|
void loadDemoModule(slug)
|
||||||
|
.then(async (module) => {
|
||||||
|
if (!live) return;
|
||||||
|
setFigures({ module, verify: null });
|
||||||
|
const run = newestRun(await listRuns(slug));
|
||||||
|
if (!run) return;
|
||||||
|
const episode = await loadEpisode(run);
|
||||||
|
if (!live) return;
|
||||||
|
setFigures({ module, verify: verifyEpisode(module, episode) });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The passages still read without their figures. The demo page has
|
||||||
|
// its own error card; the landing does not repeat it.
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [slug, near]);
|
||||||
|
|
||||||
|
return figures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A reward total, three places; `—` for not scored. */
|
||||||
|
function fixed(value: number | null, digits: number): string {
|
||||||
|
if (value === null || !Number.isFinite(value)) return '—';
|
||||||
|
// Below the last printed digit a negative rounding residue would print as
|
||||||
|
// "-0.0000000", which reads as a disagreement that is not there.
|
||||||
|
const clamped = Math.abs(value) < 10 ** -digits / 2 ? 0 : value;
|
||||||
|
return clamped.toFixed(digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ pieces */
|
||||||
|
|
||||||
|
function SectionLabel({ children, id }: { children: ReactNode; id?: string }) {
|
||||||
|
return (
|
||||||
|
<p className={s.label} id={id}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One numbered passage in "How it works": a claim, a paragraph and a mono
|
||||||
|
* figure. Figure right from `lg`, under the paragraph on a phone.
|
||||||
|
*/
|
||||||
|
function Passage({
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
figure,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
index: string;
|
||||||
|
title: string;
|
||||||
|
figure: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li className="grid gap-4 border-t border-border py-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,22rem)] lg:gap-10">
|
||||||
|
<div>
|
||||||
|
<h3 className={`${s.h3} flex items-baseline gap-3`}>
|
||||||
|
<span className="font-mono text-label uppercase text-muted">{index}</span>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className={`${s.prose} mt-2`}>{children}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 lg:pt-1">{figure}</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mono figure with a caption. A caption that is data (a call, a run id) is
|
||||||
|
* mono; one that is a sentence (the verifier's verdict) is sans, because it
|
||||||
|
* has a verb in it.
|
||||||
|
*/
|
||||||
|
function Figure({
|
||||||
|
caption,
|
||||||
|
sentence = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
caption: ReactNode;
|
||||||
|
sentence?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<figure className="rounded-lg border border-border bg-surface-2 p-3">
|
||||||
|
<div className="min-w-0">{children}</div>
|
||||||
|
<figcaption
|
||||||
|
className={
|
||||||
|
sentence ? 'mt-2 text-small text-fg-2' : 'mt-2 break-words font-mono text-caption text-muted'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The figure's own line: mono, wrapping at the operators rather than clipping. */
|
||||||
|
const FIGURE_LINE = 'break-words font-mono text-mono-data text-fg';
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- page */
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
// The head is set here as well as baked by `scripts/prerender.mjs`, and the
|
// The head is set here as well as baked by `scripts/prerender.mjs`, and the
|
||||||
// two are not redundant: prerender covers the crawler that fetches the
|
// two are not redundant: prerender covers the crawler that fetches the
|
||||||
// document, this covers the visitor who arrives on another route and clicks
|
// document, this covers the visitor who arrives on another route and clicks
|
||||||
// through without ever fetching a new one. Prerender prefers whatever the
|
// through without ever fetching a new one.
|
||||||
// app set, so this is also the single source for the baked tags.
|
|
||||||
useSeo({
|
useSeo({
|
||||||
title: pageTitle(),
|
title: pageTitle(),
|
||||||
description:
|
description:
|
||||||
@@ -40,112 +211,248 @@ export default function Home() {
|
|||||||
ogImage: '/og/home.png',
|
ogImage: '/og/home.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [featured, ...otherBuilt] = builtEnvironments;
|
const featured = builtEnvironments[0];
|
||||||
const builtCount = builtEnvironments.length;
|
const builtCount = builtEnvironments.length;
|
||||||
const writtenCount = writtenEnvironments.length;
|
const writtenCount = writtenEnvironments.length;
|
||||||
|
const totalCount = environments.length;
|
||||||
|
|
||||||
// Written out rather than interpolated inline so the zero and the singular
|
const [howRef, howNear] = useNearViewport<HTMLElement>();
|
||||||
// both read as English. Both numbers come from the data — `VERTICALS` has
|
const { module, verify } = useFigures(featured?.slug ?? null, howNear);
|
||||||
// twelve entries and none of them is keyed `reference`, so the one live demo
|
|
||||||
// suppresses no proposal and the lineup reads one and twelve today. It will
|
// The mono eyebrow. Zero segments are dropped rather than printed as `00`.
|
||||||
// not stay that way, and the sentence has to survive the day it changes.
|
const eyebrow = [
|
||||||
const builtSentence =
|
'Environments',
|
||||||
builtCount === 1
|
builtCount > 0 ? `${pad2(builtCount)} live` : null,
|
||||||
? 'One environment is built and playable in this tab.'
|
writtenCount > 0 ? `${pad2(writtenCount)} spec` : null,
|
||||||
: `${builtCount} environments are built and playable in this tab.`;
|
]
|
||||||
const writtenSentence =
|
.filter((part): part is string => part !== null)
|
||||||
writtenCount === 1
|
.join(' · ');
|
||||||
? 'One more is a written specification:'
|
|
||||||
: `The other ${writtenCount} are written specifications:`;
|
// The second sentence of the lede, in English at every count.
|
||||||
|
const builtClause =
|
||||||
|
builtCount === 0
|
||||||
|
? 'None of these is built yet'
|
||||||
|
: `${CountWord(builtCount)} of these ${builtCount === 1 ? 'is' : 'are'} built and playable`;
|
||||||
|
const writtenClause =
|
||||||
|
writtenCount === 0
|
||||||
|
? ''
|
||||||
|
: `; ${countWord(writtenCount)} more ${writtenCount === 1 ? 'is' : 'are'} written`;
|
||||||
|
|
||||||
|
const pickSentence =
|
||||||
|
builtCount === 0
|
||||||
|
? 'Nothing is playable yet; every environment below is a written specification.'
|
||||||
|
: `${CountWord(builtCount)} ${builtCount === 1 ? 'environment is' : 'environments are'} built and playable in this tab.${
|
||||||
|
writtenCount === 0
|
||||||
|
? ''
|
||||||
|
: ` The other ${countWord(writtenCount)} ${writtenCount === 1 ? 'is' : 'are'} written: the task, the grader, the counterweight and the command that evaluates it.`
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const equation = module
|
||||||
|
? `${module.reward.components
|
||||||
|
.map((component) => `${component.weight.toFixed(2)} · ${component.key}`)
|
||||||
|
.join(' + ')} = reward`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
{/* ── The thesis, in two sentences ───────────────────────────────── */}
|
{/* ── 1. Masthead ─────────────────────────────────────────────────── */}
|
||||||
<section className={`${s.shell} pt-10 sm:pt-16`}>
|
<section className={`${s.shell} pt-10 sm:pt-14`}>
|
||||||
<p className={s.eyebrow}>Environments, demonstrated</p>
|
<SectionLabel>{eyebrow}</SectionLabel>
|
||||||
<h1 className={`${s.h1} mt-3 max-w-4xl`}>
|
<h1 className={`${s.display} mt-3 max-w-[20ch]`}>
|
||||||
An environment is an eval you can take the gradient of.
|
An environment is an eval you can take the gradient of.
|
||||||
</h1>
|
</h1>
|
||||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
<p className={`${s.lede} mt-5`}>
|
||||||
You write down what good means, in code, and then you train against that number and watch
|
A task, the moves that are allowed, a grader that computes instead of judging, and a score
|
||||||
it move — or watch it sit still, which you found out in an afternoon instead of a quarter.
|
that moves when the model gets better. {builtClause}
|
||||||
|
{writtenClause}.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<DefinitionStrip className="mt-8" />
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
{featured ? (
|
||||||
|
<Link className={`${s.btnPrimary} min-h-12 sm:min-h-11`} to={featured.href}>
|
||||||
|
Play {featured.title}
|
||||||
|
<ArrowRight aria-hidden="true" className="size-4" />
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<a className={`${s.btnSecondary} min-h-12 sm:min-h-11`} href="#how">
|
||||||
|
What an environment is
|
||||||
|
<ArrowDown aria-hidden="true" className="size-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── The picker: the built one ──────────────────────────────────── */}
|
{/* ── 2. Pick one ─────────────────────────────────────────────────── */}
|
||||||
<section aria-labelledby="pick" className={`${s.shell} mt-10 sm:mt-14`}>
|
<section aria-labelledby="pick" className={`${s.shell} ${s.section}`} id="play">
|
||||||
<h2 className={s.h2} id="pick">
|
<SectionLabel>Playable</SectionLabel>
|
||||||
|
<h2 className={`${s.h2} mt-2`} id="pick">
|
||||||
Pick one. You are playing it in a click.
|
Pick one. You are playing it in a click.
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`${s.prose} mt-3 max-w-3xl`}>
|
<p className={`${s.prose} mt-3`}>{pickSentence}</p>
|
||||||
{builtSentence}{' '}
|
|
||||||
{writtenCount === 0 ? null : (
|
{builtCount > 0 ? (
|
||||||
<>
|
<ul
|
||||||
{writtenSentence} the task, the grader, the counterweight that stops the grader being
|
className={`mt-6 grid gap-4 ${
|
||||||
farmed, and the command that evaluates it. Nothing here is a coming-soon card.
|
builtCount >= 3 ? 'lg:grid-cols-3' : builtCount === 2 ? 'lg:grid-cols-2' : ''
|
||||||
</>
|
}`}
|
||||||
|
>
|
||||||
|
{builtEnvironments.map((entry) => (
|
||||||
|
<li className="flex" key={entry.id}>
|
||||||
|
<FeaturedEnvironment className="w-full" entry={entry} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 3. How it works ─────────────────────────────────────────────── */}
|
||||||
|
<section
|
||||||
|
aria-labelledby="how-heading"
|
||||||
|
className={`${s.shell} ${s.section} scroll-mt-[var(--app-header-h)]`}
|
||||||
|
id="how"
|
||||||
|
ref={howRef}
|
||||||
|
>
|
||||||
|
<SectionLabel>
|
||||||
|
The same machine, {countWord(totalCount)} times
|
||||||
|
</SectionLabel>
|
||||||
|
<h2 className={`${s.h2} mt-2`} id="how-heading">
|
||||||
|
The same machine, four times over.
|
||||||
|
</h2>
|
||||||
|
<p className={`${s.prose} mt-3`}>
|
||||||
|
Every environment on this site is the four parts above. The task changes and the grader
|
||||||
|
changes; the shape never does. Here it is on the {featured ? featured.title : 'first'}{' '}
|
||||||
|
board, with that environment’s own text in each box.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{module ? (
|
||||||
|
<EnvAnatomy anatomy={module.anatomy} rewardLine={module.meta.rewardLine} />
|
||||||
|
) : (
|
||||||
|
// The same footprint as the four boxes, so the page does not jump
|
||||||
|
// when the module lands.
|
||||||
|
<div aria-hidden="true" className="grid gap-3 lg:grid-cols-4">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<Skeleton className="h-32 rounded-xl" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</p>
|
|
||||||
|
|
||||||
{featured ? <FeaturedEnvironment className="mt-6" entry={featured} /> : null}
|
|
||||||
{otherBuilt.length > 0 ? <EnvironmentGrid className="mt-4" entries={otherBuilt} /> : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ── The picker: the written ones ───────────────────────────────── */}
|
|
||||||
{/* Dropped entirely rather than rendered as "The 0 we have written": the
|
|
||||||
day every proposal becomes a demo, this section should disappear. */}
|
|
||||||
{writtenCount === 0 ? null : (
|
|
||||||
<section aria-labelledby="written" className={`${s.shell} mt-10 sm:mt-14`}>
|
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
||||||
<h2 className={s.h2} id="written">
|
|
||||||
The {writtenCount} we have written but not built.
|
|
||||||
</h2>
|
|
||||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className={`${s.prose} mt-3 max-w-3xl`}>
|
|
||||||
Each one is a task an environment could run, a reward stated in a number your board already
|
|
||||||
reads, and the counterweight that stops that reward being maximised the crude way. Every
|
|
||||||
card opens the whole argument, including where it stops being honest.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<EnvironmentGrid className="mt-6" entries={writtenEnvironments} />
|
<ol className="mt-8 border-b border-border">
|
||||||
|
<Passage
|
||||||
|
figure={
|
||||||
|
<Figure caption="compare(guess, answer) → G Y X X G">
|
||||||
|
<TileRow />
|
||||||
|
</Figure>
|
||||||
|
}
|
||||||
|
index="01"
|
||||||
|
title="The grader computes."
|
||||||
|
>
|
||||||
|
Five letters go in, five verdicts come out, and the same guess scores the same way every
|
||||||
|
time. No judge reads the reply and no rubric is consulted. A recorded{' '}
|
||||||
|
<Term term="rollout">rollout</Term> is a file of those comparisons, one per turn.
|
||||||
|
</Passage>
|
||||||
|
|
||||||
<Link className={`${s.btnSecondary} mt-6`} to={routes.gallery}>
|
<Passage
|
||||||
Filter the lineup by industry
|
figure={
|
||||||
<ArrowRight aria-hidden="true" className="size-4" />
|
<Figure caption={module ? `${module.meta.title} · shipped weights` : 'shipped weights'}>
|
||||||
</Link>
|
<p className={`min-h-[1.4em] ${FIGURE_LINE}`}>
|
||||||
|
{equation ?? <span className="text-muted">— · — + — · — + — · — = reward</span>}
|
||||||
|
</p>
|
||||||
|
</Figure>
|
||||||
|
}
|
||||||
|
index="02"
|
||||||
|
title="The reward is the product."
|
||||||
|
>
|
||||||
|
Pay for winning, take away for wasted guesses, and the model will find whichever
|
||||||
|
behaviour you forgot to price. The term that takes points away is the{' '}
|
||||||
|
<Term term="counterweight">counterweight</Term>; it is the half of the reward worth an
|
||||||
|
argument.
|
||||||
|
</Passage>
|
||||||
|
|
||||||
|
<Passage
|
||||||
|
figure={
|
||||||
|
<Figure
|
||||||
|
caption={
|
||||||
|
verify
|
||||||
|
? verifySummary(verify)
|
||||||
|
: 'Recomputed in your browser from the recorded moves, once they load.'
|
||||||
|
}
|
||||||
|
sentence
|
||||||
|
>
|
||||||
|
<p className={FIGURE_LINE}>
|
||||||
|
recomputed {fixed(verify?.recomputed ?? null, 3)} · recorded{' '}
|
||||||
|
{fixed(verify?.recorded ?? null, 3)} · Δ {fixed(verify?.delta ?? null, 7)}
|
||||||
|
</p>
|
||||||
|
</Figure>
|
||||||
|
}
|
||||||
|
index="03"
|
||||||
|
title="The numbers are receipts."
|
||||||
|
>
|
||||||
|
Every score on this site is recomputed in your browser from the recorded moves and set
|
||||||
|
beside the number in the file. The one published training result is measured on{' '}
|
||||||
|
<Term term="held-out">held-out</Term> words the model never trained on.
|
||||||
|
</Passage>
|
||||||
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── 4. Written but not built ────────────────────────────────────── */}
|
||||||
|
{writtenCount === 0 ? null : (
|
||||||
|
<section aria-labelledby="written" className={`${s.shell} ${s.section}`}>
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<h2 className={s.h2} id="written">
|
||||||
|
The {writtenCount} we have written but not built.
|
||||||
|
</h2>
|
||||||
|
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`${s.prose} mt-3`}>
|
||||||
|
Each one is a task, a reward in a number your board already reads, and the counterweight
|
||||||
|
that stops that reward being farmed. Every row opens the whole argument, including where
|
||||||
|
it stops being honest.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* A list, not a wall of cards: twelve rows the eye can scan in one
|
||||||
|
pass, each ranked in the order we would build it. */}
|
||||||
|
<EnvironmentList className="mt-6" entries={writtenEnvironments} />
|
||||||
|
|
||||||
|
<Link className={`${s.btnSecondary} mt-6`} to={routes.gallery}>
|
||||||
|
Filter the lineup by industry
|
||||||
|
<ArrowRight aria-hidden="true" className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── The support: why this game, and the one measured number ────── */}
|
{/* ── 5. Evidence ─────────────────────────────────────────────────── */}
|
||||||
<section aria-labelledby="evidence" className={`${s.shell} ${s.section}`}>
|
<section aria-labelledby="evidence" className={`${s.shell} ${s.section}`}>
|
||||||
<h2 className="sr-only" id="evidence">
|
<h2 className="sr-only" id="evidence">
|
||||||
Why this environment, and what has been measured on it
|
Why this environment, and what has been measured on it
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<div className="card p-5 sm:p-7">
|
<div className="card p-4 sm:p-5">
|
||||||
<p className={s.eyebrow}>Why a word game</p>
|
<SectionLabel>Why a word game</SectionLabel>
|
||||||
<h3 className="mt-2 text-xl font-bold tracking-tight text-fg sm:text-2xl">
|
<h3 className={`${s.h3} mt-2`}>We didn’t pick a game. We picked theirs.</h3>
|
||||||
We didn’t pick a game. We picked theirs.
|
|
||||||
</h3>
|
|
||||||
<p className={`${s.prose} mt-3`}>
|
<p className={`${s.prose} mt-3`}>
|
||||||
Wordle is Prime Intellect’s own hello-world: a basic example in their trainer, a
|
The word game is Prime Intellect’s own hello-world: a basic example in their trainer, a
|
||||||
shipped environment in their library, and the environment their official tutorial
|
shipped environment in their library, and the environment their official tutorial
|
||||||
optimises prompts against.
|
optimises prompts against.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-4 space-y-2">
|
<ul className="mt-4 divide-y divide-border">
|
||||||
{helloWorldCitations.map((c) => (
|
{helloWorldCitations.map((c) => (
|
||||||
<li key={c.href}>
|
<li key={c.href}>
|
||||||
<a
|
<a
|
||||||
className="tap flex items-start gap-2 rounded-md py-1 text-sm text-muted transition-colors duration-1 ease-enter hover:text-fg"
|
className="tap flex items-start gap-2 py-2.5 transition-colors duration-1 ease-enter hover:text-fg"
|
||||||
href={c.href}
|
href={c.href}
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ArrowUpRight aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-brand" />
|
<ArrowUpRight
|
||||||
<span>
|
aria-hidden="true"
|
||||||
<span className="font-mono text-[0.8125rem] text-fg">{c.label}</span>
|
className="mt-0.5 size-4 shrink-0 text-border-strong"
|
||||||
<span className="block">{c.claim}</span>
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-mono text-mono-data text-fg">{c.label}</span>
|
||||||
|
<span className="block text-small text-fg-2">{c.claim}</span>
|
||||||
<span className="sr-only">(opens in a new tab)</span>
|
<span className="sr-only">(opens in a new tab)</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -154,26 +461,25 @@ export default function Home() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-5 sm:p-7">
|
{/* A citation of a published result, worded as one. No logo, no
|
||||||
<p className={s.eyebrow}>Published by Prime Intellect</p>
|
"partner", nothing that reads as endorsement. */}
|
||||||
<p className="nums mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
<div className="card p-4 sm:p-5">
|
||||||
<span className="text-4xl font-extrabold tracking-tight text-muted sm:text-5xl">
|
<SectionLabel>Published by Prime Intellect</SectionLabel>
|
||||||
{trainingResult.before}
|
<p className="mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono">
|
||||||
</span>
|
<span className="text-figure text-muted lg:text-figure-lg">{trainingResult.before}</span>
|
||||||
<ArrowRight aria-hidden="true" className="size-6 shrink-0 text-muted" />
|
<ArrowRight aria-hidden="true" className="size-6 shrink-0 self-center text-border-strong" />
|
||||||
<span className="text-4xl font-extrabold tracking-tight text-positive sm:text-5xl">
|
<span className="text-figure text-positive lg:text-figure-lg">{trainingResult.after}</span>
|
||||||
{trainingResult.after}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-3`}>
|
<p className={`${s.prose} mt-3`}>
|
||||||
{trainingResult.model} {trainingResult.metric} on this task, before and after training.
|
<span className="font-mono text-mono-data text-fg">{trainingResult.model}</span>{' '}
|
||||||
Out of the box it never once guesses the word; after an {trainingResult.method}, it
|
{trainingResult.metric} on this task, before and after training. Out of the box it
|
||||||
wins about six games in ten. Measured on {trainingResult.evalDescription}, and both
|
never once guesses the word; after an {trainingResult.method}, it wins about six games
|
||||||
checkpoints are public, so the claim is checkable rather than quotable.
|
in ten. Measured on {trainingResult.evalDescription}. Both checkpoints are public, so
|
||||||
|
the claim is checkable rather than quotable.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-4 flex flex-wrap gap-2">
|
<p className="mt-4 flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
className={s.pill}
|
className="tap inline-flex items-center gap-1 rounded-md border border-border-strong px-3 font-mono text-mono-data text-fg transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||||||
href={trainingResult.source.href}
|
href={trainingResult.source.href}
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -183,33 +489,29 @@ export default function Home() {
|
|||||||
</a>
|
</a>
|
||||||
{trainingResult.checkpoints.map((c) => (
|
{trainingResult.checkpoints.map((c) => (
|
||||||
<a
|
<a
|
||||||
className={s.pill}
|
className="tap inline-flex items-center gap-1 rounded-md border border-border-strong px-3 font-mono text-mono-data text-fg transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||||||
href={c.href}
|
href={c.href}
|
||||||
key={c.href}
|
key={c.href}
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<span className="font-mono">{c.label.replace('PrimeIntellect/', '')}</span>
|
{c.label.replace('PrimeIntellect/', '')}
|
||||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</p>
|
</p>
|
||||||
{/*
|
<p className={`${s.caption} mt-3`}>{trainingResult.caveat}</p>
|
||||||
The same write-up publishes average-reward figures for these runs.
|
|
||||||
They are deliberately not on this page — see /honesty.
|
|
||||||
*/}
|
|
||||||
<p className="mt-3 text-xs text-muted">
|
|
||||||
We quote the win rate only. The reward numbers in that write-up span versions of the
|
|
||||||
environment and were never re-measured together.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center">
|
{/* ── 6. Close ────────────────────────────────────────────────────── */}
|
||||||
|
<section className={`${s.shell} pb-12 sm:pb-16`}>
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border pt-8 sm:flex-row sm:items-center">
|
||||||
<Link className={s.btnSecondary} to={routes.honesty}>
|
<Link className={s.btnSecondary} to={routes.honesty}>
|
||||||
What we measured, and what we didn’t
|
What we measured, and what we didn’t
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-sm text-muted">
|
<p className={s.small}>
|
||||||
Every number on this site, with the one that is ours and the ones that are not.
|
Every number on this site, with the one that is ours and the ones that are not.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowUpRight } from 'lucide-react';
|
import { ArrowUpRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Term } from '@/components/site/Term';
|
||||||
import {
|
import {
|
||||||
helloWorldCitations,
|
helloWorldCitations,
|
||||||
optimalPlay,
|
optimalPlay,
|
||||||
@@ -10,10 +11,19 @@ import {
|
|||||||
trainingResult,
|
trainingResult,
|
||||||
wordList,
|
wordList,
|
||||||
} from '@/content/evidence';
|
} from '@/content/evidence';
|
||||||
import { REPO_URL, routes } from '@/content/lineup';
|
import { countWord, CountWord, lineup, REPO_URL, routes, unbuiltLineup } from '@/content/lineup';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||||
import { pageTitle, useSeo } from '@/lib/seo';
|
import { pageTitle, useSeo } from '@/lib/seo';
|
||||||
import * as s from '@/content/styles';
|
|
||||||
|
/**
|
||||||
|
* Every claim on the site, sorted by how much weight it can carry.
|
||||||
|
*
|
||||||
|
* The numbers here are the site's receipts, so they follow the same rules as
|
||||||
|
* the demo pages: anything measured is mono, anything with a verb in it is
|
||||||
|
* sans, and no count is typed — the proposals are counted from the lineup, so
|
||||||
|
* the day one of them is built this page corrects itself.
|
||||||
|
*/
|
||||||
|
|
||||||
/** External link with the new-tab affordance and the announcement to match. */
|
/** External link with the new-tab affordance and the announcement to match. */
|
||||||
function Out({ children, href }: { children: ReactNode; href: string }) {
|
function Out({ children, href }: { children: ReactNode; href: string }) {
|
||||||
@@ -26,6 +36,11 @@ function Out({ children, href }: { children: ReactNode; href: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A measured number inside a sentence. */
|
||||||
|
function Num({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="font-mono text-mono-data font-semibold text-fg">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function Heading({ children, id }: { children: ReactNode; id: string }) {
|
function Heading({ children, id }: { children: ReactNode; id: string }) {
|
||||||
return (
|
return (
|
||||||
<h2 className={`${s.h2} scroll-mt-[calc(var(--app-header-h)+1rem)]`} id={id}>
|
<h2 className={`${s.h2} scroll-mt-[calc(var(--app-header-h)+1rem)]`} id={id}>
|
||||||
@@ -54,43 +69,53 @@ export default function Honesty() {
|
|||||||
ogImage: '/og/honesty.png',
|
ogImage: '/og/honesty.png',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const withCaveat = lineup.filter((v) => v.caveat !== undefined);
|
||||||
|
const answers = wordList.answers.toLocaleString('en-US');
|
||||||
|
const guesses = wordList.guesses.toLocaleString('en-US');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${s.shell} py-10 sm:py-16`}>
|
<main className={`${s.shell} py-10 sm:py-14`}>
|
||||||
<p className={s.eyebrow}>Honesty</p>
|
<p className={s.label}>Honesty</p>
|
||||||
<h1 className={`${s.h1} mt-3 max-w-3xl`}>What is measured, what is cited, and what is not.</h1>
|
<h1 className={`${s.h1} mt-3 max-w-[24ch]`}>What is measured, what is cited, and what is not.</h1>
|
||||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
<p className={`${s.lede} mt-4`}>
|
||||||
This site argues that a number you can check beats a claim you have to trust. That argument
|
This site argues that a number you can check beats a claim you have to trust. That argument
|
||||||
only works if we hold our own numbers to it. So here is every claim on the site, sorted by
|
only works if we hold our own numbers to it. So here is every claim on the site, sorted by
|
||||||
how much weight it can carry.
|
how much weight it can carry.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<nav aria-label="On this page" className="card mt-8 p-5">
|
<nav aria-label="On this page" className="card mt-8 p-4 sm:p-5">
|
||||||
<p className={s.eyebrow}>On this page</p>
|
<p className={s.label}>On this page</p>
|
||||||
<ul className="mt-3 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
<ol className="mt-3 grid gap-x-6 sm:grid-cols-2">
|
||||||
{CONTENTS.map((item) => (
|
{CONTENTS.map((item, index) => (
|
||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
<a className={`${s.link} text-sm`} href={`#${item.id}`}>
|
<a
|
||||||
|
className="tap inline-flex items-center gap-3 py-1.5 text-ui text-fg transition-colors duration-1 ease-enter hover:text-accent-fg"
|
||||||
|
href={`#${item.id}`}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-label uppercase text-muted">
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
{item.label}
|
{item.label}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ── Measured ───────────────────────────────────────────────────── */}
|
{/* ── Measured ───────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="measured">What we measured ourselves</Heading>
|
<Heading id="measured">What we measured ourselves</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
Everything in this section is reproducible from a clone of this repository, offline, with
|
Everything in this section is reproducible from a clone of this repository, offline, with
|
||||||
no account and no key. If any of it does not reproduce, that is a bug and we want the
|
no account and no key. If any of it does not reproduce, that is a bug and we want the
|
||||||
issue.
|
issue.
|
||||||
</p>
|
</p>
|
||||||
<ul className={`${s.prose} mt-4 space-y-4`}>
|
<ol className="mt-4 max-w-measure space-y-5">
|
||||||
<li>
|
<li className={s.prose}>
|
||||||
<strong className="text-fg">The environment runs.</strong> The word game is a real
|
<strong className="font-semibold text-fg">The environment runs.</strong> The word game
|
||||||
environment in this repository, not a mock behind the page. Three commands score a model
|
is a real environment in this repository, not a mock behind the page. Three commands
|
||||||
with it:
|
score a model with it:
|
||||||
<pre className={`${s.codeBlock} mt-2`}>
|
<pre className={`${s.codeBlock} mt-2 whitespace-pre-wrap sm:whitespace-pre`}>
|
||||||
<code>
|
<code>
|
||||||
{reproduce.clone}
|
{reproduce.clone}
|
||||||
{'\n'}
|
{'\n'}
|
||||||
@@ -100,43 +125,45 @@ export default function Honesty() {
|
|||||||
</code>
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className={s.prose}>
|
||||||
<strong className="text-fg">The word lists rebuild byte for byte.</strong> Both source
|
<strong className="font-semibold text-fg">The word lists rebuild byte for byte.</strong>{' '}
|
||||||
dictionaries are committed beside the script that filters them, so{' '}
|
Both source dictionaries are committed beside the script that filters them, so{' '}
|
||||||
<code className="font-mono text-fg">{wordList.rebuild}</code> produces the same{' '}
|
<code className="font-mono text-mono-data text-fg">{wordList.rebuild}</code> produces the
|
||||||
<span className="nums">{wordList.answers.toLocaleString('en-US')}</span> answers and{' '}
|
same <Num>{answers}</Num> answers and <Num>{guesses}</Num> legal guesses on any machine.
|
||||||
<span className="nums">{wordList.guesses.toLocaleString('en-US')}</span> legal guesses on
|
|
||||||
any machine.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className={s.prose}>
|
||||||
<strong className="text-fg">The two implementations of the rules agree.</strong> The
|
<strong className="font-semibold text-fg">
|
||||||
grader exists twice — once in Python for the environment, once in TypeScript for the
|
The two implementations of the rules agree.
|
||||||
|
</strong>{' '}
|
||||||
|
The grader exists twice — once in Python for the environment, once in TypeScript for the
|
||||||
board in your browser. Continuous integration scores every ordered pair of words in the
|
board in your browser. Continuous integration scores every ordered pair of words in the
|
||||||
answer list through both, about{' '}
|
answer list through both, about <Num>21.2 million</Num> feedback patterns, and compares
|
||||||
<span className="nums">21.2 million</span> feedback patterns, and compares a hash. A
|
a hash. A divergence fails the build rather than showing you one result and scoring
|
||||||
divergence fails the build rather than showing you one result and scoring another.
|
another.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li className={s.prose}>
|
||||||
<strong className="text-fg">The scores on a recorded run are re-derived, not
|
<strong className="font-semibold text-fg">
|
||||||
recited.</strong> Where a demo ships a verifier, the page recomputes the reward in your
|
The scores on a recorded run are re-derived, not recited.
|
||||||
browser from the recorded turns instead of printing a number stored in the file. If a
|
</strong>{' '}
|
||||||
trace is truncated, it renders as unverifiable — never as zero.
|
Where a demo ships a verifier, the page recomputes the reward in your browser from the
|
||||||
|
recorded turns instead of printing a number stored in the file. If a trace is truncated,
|
||||||
|
it renders as unverifiable — never as zero.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Cited ──────────────────────────────────────────────────────── */}
|
{/* ── Cited ──────────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="cited">What we cite from elsewhere</Heading>
|
<Heading id="cited">What we cite from elsewhere</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
Three claims on this site are not ours. They are linked so you can go and check them
|
Three claims on this site are not ours. They are linked so you can go and check them
|
||||||
instead of taking them from us.
|
instead of taking them from us.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="card mt-5 p-5">
|
<div className="card mt-5 max-w-measure-wide p-4 sm:p-5">
|
||||||
<h3 className={s.h3}>
|
<h3 className={`${s.h3} font-mono`}>
|
||||||
{trainingResult.model}: {trainingResult.before} → {trainingResult.after}{' '}
|
{trainingResult.model}: {trainingResult.before} → {trainingResult.after}{' '}
|
||||||
{trainingResult.metric}
|
<span className="font-sans font-normal text-fg-2">{trainingResult.metric}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`${s.prose} mt-2`}>
|
<p className={`${s.prose} mt-2`}>
|
||||||
Published by Prime Intellect, measured on {trainingResult.evalDescription}, after{' '}
|
Published by Prime Intellect, measured on {trainingResult.evalDescription}, after{' '}
|
||||||
@@ -146,32 +173,46 @@ export default function Honesty() {
|
|||||||
<Out href={trainingResult.source.href}>the write-up</Out>.
|
<Out href={trainingResult.source.href}>the write-up</Out>.
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-3`}>
|
<p className={`${s.prose} mt-3`}>
|
||||||
We quote the win rate and nothing else. The same write-up gives average-reward figures
|
{trainingResult.caveat} A win rate survives a reward change. An average reward does not.
|
||||||
for those runs; we leave them out because the reward function has changed across
|
We have also not reproduced this result: their environment and word list are not ours,
|
||||||
versions of that environment and the two figures were never re-measured together. A win
|
and a number measured on a different task list is not a number about this one.
|
||||||
rate survives a reward change. An average reward does not. We have also not reproduced
|
|
||||||
this result: their environment and word list are not ours, and a number measured on a
|
|
||||||
different task list is not a number about this one.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card mt-4 p-5">
|
<div className="card mt-4 max-w-measure-wide p-4 sm:p-5">
|
||||||
<h3 className={s.h3}>Wordle is Prime Intellect’s own hello-world</h3>
|
<h3 className={s.h3}>The word game is Prime Intellect’s own hello-world</h3>
|
||||||
<p className={`${s.prose} mt-2`}>
|
<p className={`${s.prose} mt-2`}>
|
||||||
We say this because a demo picked to flatter itself is worth nothing. It appears in three
|
We say this because a demo picked to flatter itself is worth nothing. It appears in
|
||||||
places in their stack:
|
three places in their stack:
|
||||||
</p>
|
</p>
|
||||||
<ul className={`${s.prose} mt-3 space-y-2`}>
|
<ul className="mt-3 divide-y divide-border">
|
||||||
{helloWorldCitations.map((c) => (
|
{helloWorldCitations.map((c) => (
|
||||||
<li key={c.href}>
|
<li className="py-2.5" key={c.href}>
|
||||||
<Out href={c.href}>{c.label}</Out> — {c.claim}
|
<a
|
||||||
|
className="tap flex items-start gap-2 transition-colors duration-1 ease-enter hover:text-fg"
|
||||||
|
href={c.href}
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<ArrowUpRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-0.5 size-4 shrink-0 text-border-strong"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-mono text-mono-data text-fg">{c.label}</span>
|
||||||
|
<span className="block text-small text-fg-2">{c.claim}</span>
|
||||||
|
<span className="sr-only">(opens in a new tab)</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card mt-4 p-5">
|
<div className="card mt-4 max-w-measure-wide p-4 sm:p-5">
|
||||||
<h3 className={s.h3}>{optimalPlay.average} is not our number</h3>
|
<h3 className={s.h3}>
|
||||||
|
<span className="font-mono">{optimalPlay.average}</span> is not our number
|
||||||
|
</h3>
|
||||||
<p className={`${s.prose} mt-2`}>
|
<p className={`${s.prose} mt-2`}>
|
||||||
The famous optimum is <Out href={optimalPlay.href}>{optimalPlay.label}</Out>. It belongs
|
The famous optimum is <Out href={optimalPlay.href}>{optimalPlay.label}</Out>. It belongs
|
||||||
to that list and that solver. It is not the optimum for our list, we have not computed
|
to that list and that solver. It is not the optimum for our list, we have not computed
|
||||||
@@ -181,27 +222,28 @@ export default function Honesty() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Recorded ───────────────────────────────────────────────────── */}
|
{/* ── Recorded ───────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="recorded">Every rollout on this site is recorded, not live</Heading>
|
<Heading id="recorded">Every rollout on this site is recorded, not live</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
When you watch a model play here, you are watching a file. No model is called while you
|
When you watch a model play here, you are watching a file. Every{' '}
|
||||||
|
<Term term="rollout">rollout</Term> was captured once, and no model is called while you
|
||||||
read this page. That is a choice, and these are the reasons for it:
|
read this page. That is a choice, and these are the reasons for it:
|
||||||
</p>
|
</p>
|
||||||
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">The argument is about determinism.</strong> A page whose
|
<strong className="font-semibold text-fg">The argument is about determinism.</strong> A
|
||||||
model says something different on every reload cannot be used to make the case that the
|
page whose model says something different on every reload cannot be used to make the
|
||||||
grader stays put. The demo has to behave like the thing it is describing.
|
case that the grader stays put. The demo has to behave like the thing it is describing.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">There is nowhere to hide a cherry-pick.</strong> Every run is
|
<strong className="font-semibold text-fg">There is nowhere to hide a cherry-pick.</strong>{' '}
|
||||||
a file in the repository carrying its model, its seed and the date it was captured. A bad
|
Every run is a file in the repository carrying its model, its{' '}
|
||||||
run is in there being bad. A live demo lets you re-roll until it looks good and tell
|
<Term term="seed">seed</Term> and the date it was captured. A bad run is in there being
|
||||||
nobody.
|
bad. A live demo lets you re-roll until it looks good and tell nobody.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">It works.</strong> No key, no rate limit, no bill, no
|
<strong className="font-semibold text-fg">It works.</strong> No key, no rate limit, no
|
||||||
dependence on a provider being up during the ten minutes you are looking at it.
|
bill, no dependence on a provider being up during the ten minutes you are looking at it.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
@@ -212,32 +254,34 @@ export default function Honesty() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Reward editor ──────────────────────────────────────────────── */}
|
{/* ── Reward editor ──────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="editor">The reward editor re-scores. It does not train anything.</Heading>
|
<Heading id="editor">The reward editor re-scores. It does not train anything.</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
Moving a weight changes how the recorded attempts are scored, and the page recomputes and
|
Moving a weight changes how the recorded attempts are scored, and the page recomputes and
|
||||||
re-ranks them. That is the entire mechanism. Nothing is fine-tuned, no model is called, and
|
re-ranks them. That is the entire mechanism. Nothing is fine-tuned, no model is called,
|
||||||
nothing learns while you drag a slider.
|
and nothing learns while you drag a slider.
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
What it is there to show is narrow and worth the space: two runs can swap places when you
|
What it is there to show is narrow and worth the space: two runs can swap places when you
|
||||||
change what you are paying for. That is a fact about your definition of good, not about the
|
change what you are paying for. That is a fact about your definition of good, not about
|
||||||
models — and it is the reason the definition is worth an hour of your team’s attention
|
the models — and it is the reason the definition is worth an hour of your team’s
|
||||||
before the training run is worth a GPU.
|
attention before the training run is worth a GPU.
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
The other half — actually training against the reward you just wrote — takes GPUs and
|
The other half — actually training against the reward you just wrote — takes GPUs and
|
||||||
hours, and it does not belong in a browser tab. That is what a trainer like prime-rl is
|
hours, and it does not belong in a browser tab. That is what a trainer like{' '}
|
||||||
for, and the number at the top of the home page is what it produced.
|
<span className="font-mono text-mono-data text-fg">prime-rl</span> is for, and the number
|
||||||
|
on the home page is what it produced.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Proposals ──────────────────────────────────────────────────── */}
|
{/* ── Proposals ──────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="proposals">The verticals are our proposals</Heading>
|
<Heading id="proposals">The verticals are our proposals</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
Eleven of the twelve entries in the lineup describe environments that do not exist. They
|
{CountWord(unbuiltLineup.length)} of the {countWord(lineup.length)} entries in the lineup
|
||||||
are labelled <span className="font-semibold text-fg">“{PROPOSAL_NOTICE}”</span> on every
|
describe environments that do not exist. They are labelled{' '}
|
||||||
|
<span className={`${s.chipOutline} align-[0.1em]`}>{PROPOSAL_NOTICE}</span> on every
|
||||||
surface that shows them, and that label is the literal truth: we wrote them.
|
surface that shows them, and that label is the literal truth: we wrote them.
|
||||||
</p>
|
</p>
|
||||||
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
||||||
@@ -247,50 +291,50 @@ export default function Honesty() {
|
|||||||
customer, a reference or a logo, because none is one.
|
customer, a reference or a logo, because none is one.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Where a page names a KPI, we are describing how that function usually measures itself. We
|
Where a page names a KPI, we are describing how that function usually measures itself.
|
||||||
are not reporting anyone’s numbers, and there are no numbers on those pages to report.
|
We are not reporting anyone’s numbers, and there are no numbers on those pages to
|
||||||
|
report.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className={`${s.prose} mt-4`}>
|
{withCaveat.length > 0 ? (
|
||||||
Two of them carry a standing caveat on their own page — the{' '}
|
<p className={`${s.prose} mt-4`}>
|
||||||
<Link className={s.link} to={routes.vertical('legal-playbook-redline')}>
|
{CountWord(withCaveat.length)} of them {withCaveat.length === 1 ? 'carries' : 'carry'} a
|
||||||
legal redline
|
standing caveat on {withCaveat.length === 1 ? 'its' : 'their'} own page:{' '}
|
||||||
</Link>
|
{withCaveat.map((v, index) => (
|
||||||
, where grading the drafting half collapses into an LLM judge, and{' '}
|
<span key={v.slug}>
|
||||||
<Link className={s.link} to={routes.vertical('semiconductor-ppa-closure')}>
|
{index > 0 ? (index === withCaveat.length - 1 ? ' and ' : ', ') : ''}
|
||||||
semiconductor timing closure
|
<Link className={s.link} to={routes.vertical(v.slug)}>
|
||||||
</Link>
|
{v.title}
|
||||||
, which has the best reward on the page and is the one thing here we are telling you we are
|
</Link>
|
||||||
not going to build.
|
</span>
|
||||||
</p>
|
))}
|
||||||
|
. Each says, in its own words, where the argument stops being checkable.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Word list ──────────────────────────────────────────────────── */}
|
{/* ── Word list ──────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="words">The word list is our own construction</Heading>
|
<Heading id="words">The word list is our own construction</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
We did not copy the original game’s word lists. Our answers are the intersection of two
|
We did not copy the original game’s word lists. Our answers are the intersection of two
|
||||||
permissively-licensed sources — every five-letter headword in{' '}
|
permissively-licensed sources — every five-letter headword in{' '}
|
||||||
<Out href={wordList.sources[0].href}>{wordList.sources[0].label}</Out> that also appears in{' '}
|
<Out href={wordList.sources[0].href}>{wordList.sources[0].label}</Out> that also appears
|
||||||
<Out href={wordList.sources[1].href}>{wordList.sources[1].label}</Out>, minus a short
|
in <Out href={wordList.sources[1].href}>{wordList.sources[1].label}</Out>, minus a short
|
||||||
hand-written blocklist. Legal guesses are the whole five-letter Wordnik set.
|
hand-written blocklist. Legal guesses are the whole five-letter Wordnik set.
|
||||||
</p>
|
</p>
|
||||||
<dl className="card mt-5 grid gap-4 p-5 sm:grid-cols-3">
|
<dl className="card mt-5 grid max-w-measure-wide gap-4 p-4 sm:grid-cols-3 sm:p-5">
|
||||||
<div>
|
<div>
|
||||||
<dt className={s.eyebrow}>Answers</dt>
|
<dt className={s.label}>Answers</dt>
|
||||||
<dd className="nums mt-1 text-2xl font-bold text-fg">
|
<dd className="mt-1 font-mono text-metric text-fg">{answers}</dd>
|
||||||
{wordList.answers.toLocaleString('en-US')}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className={s.eyebrow}>Legal guesses</dt>
|
<dt className={s.label}>Legal guesses</dt>
|
||||||
<dd className="nums mt-1 text-2xl font-bold text-fg">
|
<dd className="mt-1 font-mono text-metric text-fg">{guesses}</dd>
|
||||||
{wordList.guesses.toLocaleString('en-US')}
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className={s.eyebrow}>Original game’s answers</dt>
|
<dt className={s.label}>Original game’s answers</dt>
|
||||||
<dd className="nums mt-1 text-2xl font-bold text-muted">
|
<dd className="mt-1 font-mono text-metric text-muted">
|
||||||
{wordList.originalAnswers.toLocaleString('en-US')}
|
{wordList.originalAnswers.toLocaleString('en-US')}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,116 +347,127 @@ export default function Honesty() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Harder ────────────────────────────────────────────────────── */}
|
{/* ── Harder ────────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="harder">This environment is harder than the original game</Heading>
|
<Heading id="harder">This environment is harder than the original game</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
Two things pull in opposite directions here, so we measured rather than argued. Our answer
|
Two things pull in opposite directions here, so we measured rather than argued. Our
|
||||||
pool comes from a dictionary rule, so it keeps the regular plurals and past tenses the
|
answer pool comes from a dictionary rule, so it keeps the regular plurals and past tenses
|
||||||
original game’s editor removed by hand:{' '}
|
the original game’s editor removed by hand: <Num>{wordList.endsInS}</Num> of our answers
|
||||||
<span className="nums font-semibold text-fg">{wordList.endsInS}</span> of our answers end
|
end in a plain <span className="font-mono text-fg">S</span> and{' '}
|
||||||
in a plain <span className="font-mono">S</span> and{' '}
|
<Num>{wordList.endsInEd}</Num> in <span className="font-mono text-fg">-ED</span>. That is
|
||||||
<span className="nums font-semibold text-fg">{wordList.endsInEd}</span> in{' '}
|
an exploitable regularity, and it makes our list easier. Our pool is also roughly twice
|
||||||
<span className="font-mono">-ED</span>. That is an exploitable regularity, and it makes our
|
the size, which makes it harder.
|
||||||
list easier. Our pool is also roughly twice the size, which makes it harder.
|
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
The net effect is the second one. We ran the same greedy solver over both pools, on{' '}
|
The net effect is the second one. We ran the same greedy solver over both pools, on{' '}
|
||||||
<span className="nums font-semibold text-fg">{poolComparison.sampled}</span> sampled words
|
<Num>{poolComparison.sampled}</Num> sampled words each:
|
||||||
each:
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 overflow-x-auto">
|
<div className="mt-4 max-w-measure-wide overflow-x-auto rounded-md border border-border">
|
||||||
<table className="w-full min-w-[26rem] border-collapse text-sm">
|
<table className="w-full min-w-[26rem] border-collapse font-mono text-mono-data">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border text-left text-muted">
|
<tr className="bg-surface-3 text-left">
|
||||||
<th className="py-2 pr-4 font-medium">Answer pool</th>
|
<th className={`${s.label} px-3 py-2`} scope="col">
|
||||||
<th className="py-2 pr-4 font-medium">Best opener</th>
|
Answer pool
|
||||||
<th className="py-2 pr-4 font-medium">Mean guesses</th>
|
</th>
|
||||||
<th className="py-2 font-medium">Worst case</th>
|
<th className={`${s.label} px-3 py-2`} scope="col">
|
||||||
|
Best opener
|
||||||
|
</th>
|
||||||
|
<th className={`${s.label} px-3 py-2 text-right`} scope="col">
|
||||||
|
Mean guesses
|
||||||
|
</th>
|
||||||
|
<th className={`${s.label} px-3 py-2 text-right`} scope="col">
|
||||||
|
Worst case
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="nums">
|
<tbody>
|
||||||
<tr className="border-b border-border">
|
<tr className="border-t border-border text-fg-2">
|
||||||
<td className="py-2 pr-4">Original, {poolComparison.original.size}</td>
|
<td className="px-3 py-2">
|
||||||
<td className="py-2 pr-4 font-mono">{poolComparison.original.opener}</td>
|
<span className="font-sans">Original,</span> {poolComparison.original.size}
|
||||||
<td className="py-2 pr-4">{poolComparison.original.mean}</td>
|
</td>
|
||||||
<td className="py-2">{poolComparison.original.worst}</td>
|
<td className="px-3 py-2">{poolComparison.original.opener}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{poolComparison.original.mean}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{poolComparison.original.worst}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr className="border-t border-border font-semibold text-fg">
|
||||||
<td className="py-2 pr-4 font-semibold text-fg">Ours, {poolComparison.ours.size}</td>
|
<td className="px-3 py-2">
|
||||||
<td className="py-2 pr-4 font-mono">{poolComparison.ours.opener}</td>
|
<span className="font-sans">Ours,</span> {poolComparison.ours.size}
|
||||||
<td className="py-2 pr-4 font-semibold text-fg">{poolComparison.ours.mean}</td>
|
</td>
|
||||||
<td className="py-2">{poolComparison.ours.worst}</td>
|
<td className="px-3 py-2">{poolComparison.ours.opener}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{poolComparison.ours.mean}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{poolComparison.ours.worst}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
About a seventh of a guess harder, and one worse in the worst case. Both rows are the same
|
About a seventh of a guess harder, and one worse in the worst case. Both rows are the same
|
||||||
algorithm, which is the only fair comparison available — setting our greedy number against
|
algorithm, which is the only fair comparison available — setting our greedy number
|
||||||
the published{' '}
|
against the published <Num>3.4212</Num> optimum for the original list would be comparing
|
||||||
<span className="nums">3.4212</span> optimum for the original list would be comparing two
|
two different algorithms and calling the difference a property of the words.
|
||||||
different algorithms and calling the difference a property of the words.
|
|
||||||
</p>
|
</p>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
The practical instruction is unchanged: do not compare a guess count from this site to your
|
The practical instruction is unchanged: do not compare a guess count from this site to
|
||||||
own statistics from the original game. It is a different game with a different shape.
|
your own statistics from the original game. It is a different game with a different shape.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Unmeasured ─────────────────────────────────────────────────── */}
|
{/* ── Unmeasured ─────────────────────────────────────────────────── */}
|
||||||
<section className="mt-12 max-w-3xl">
|
<section className="mt-12">
|
||||||
<Heading id="unmeasured">What we have not measured</Heading>
|
<Heading id="unmeasured">What we have not measured</Heading>
|
||||||
<p className={`${s.prose} mt-4`}>
|
<p className={`${s.prose} mt-4`}>
|
||||||
This is the list we would want if we were the ones being sold to.
|
This is the list we would want if we were the ones being sold to.
|
||||||
</p>
|
</p>
|
||||||
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">Any training result of our own.</strong> We have not trained
|
<strong className="font-semibold text-fg">Any training result of our own.</strong> We
|
||||||
a model against this environment. The before-and-after number on the home page is Prime
|
have not trained a model against this environment. The before-and-after number on the
|
||||||
Intellect’s, measured on their word list, and we did not rerun it on ours.
|
home page is Prime Intellect’s, measured on their word list, and we did not rerun it on
|
||||||
|
ours.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">Variance.</strong> The recorded runs are individual seeds,
|
<strong className="font-semibold text-fg">Variance.</strong> The recorded runs are
|
||||||
not samples. There is no confidence interval anywhere on this site and no claim that a
|
individual seeds, not samples. There is no confidence interval anywhere on this site and
|
||||||
given run is typical. A demonstration with one seed is a demonstration, not evidence.
|
no claim that a given run is typical. A demonstration with one seed is a demonstration,
|
||||||
|
not evidence.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">An optimum, or a human baseline, for our list.</strong> We do
|
<strong className="font-semibold text-fg">
|
||||||
not know how few guesses perfect play needs on our answers, and we have not asked people
|
An optimum, or a human baseline, for our list.
|
||||||
to play it.
|
</strong>{' '}
|
||||||
|
We do not know how few guesses perfect play needs on our answers, and we have not asked
|
||||||
|
people to play it.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">Cost and latency.</strong> No number here is a price, a
|
<strong className="font-semibold text-fg">Cost and latency.</strong> No number here is a
|
||||||
token count you should budget from, or a wall-clock comparison between models. The
|
price, a <Term term="tokens">token</Term> count you should budget from, or a wall-clock
|
||||||
durations shown in a trace are what that recording measured, on that day, on that
|
comparison between models. The durations shown in a trace are what that recording
|
||||||
endpoint.
|
measured, on that day, on that endpoint.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">Anything at all about the eleven proposals.</strong> Nothing
|
<strong className="font-semibold text-fg">
|
||||||
in those pages has been run. Every reward, counterweight and KPI there is a design.
|
Anything at all about the {countWord(unbuiltLineup.length)} proposals.
|
||||||
|
</strong>{' '}
|
||||||
|
Nothing in those pages has been run. Every reward, counterweight and KPI there is a
|
||||||
|
design.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-fg">A full review of the answer list.</strong> We block a short
|
<strong className="font-semibold text-fg">A full review of the answer list.</strong> We
|
||||||
hand-written set of words from ever being the hidden answer. We have not audited the
|
block a short hand-written set of words from ever being the hidden answer. We have not
|
||||||
remaining {wordList.answers.toLocaleString('en-US')} exhaustively, and a five-letter
|
audited the remaining <Num>{answers}</Num> exhaustively, and a five-letter English
|
||||||
English dictionary contains words somebody will not want on a boardroom screen.
|
dictionary contains words somebody will not want on a boardroom screen.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="card mt-14 p-5 sm:p-7">
|
<div className="card mt-14 max-w-measure-wide p-4 sm:p-5">
|
||||||
<h2 className={s.h3}>Found something on this site that is wrong?</h2>
|
<h2 className={s.h3}>Found something on this site that is wrong?</h2>
|
||||||
<p className={`${s.prose} mt-2 max-w-2xl`}>
|
<p className={`${s.prose} mt-2`}>
|
||||||
Every claim above is traceable to a file in the repository. If one of them does not hold,
|
Every claim above is traceable to a file in the repository. If one of them does not hold,
|
||||||
open an issue with the page and the sentence and we will fix the sentence or the code.
|
open an issue with the page and the sentence and we will fix the sentence or the code.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||||
<a
|
<a className={s.btnPrimary} href={REPO_URL} rel="noreferrer noopener" target="_blank">
|
||||||
className={s.btnPrimary}
|
|
||||||
href={REPO_URL}
|
|
||||||
rel="noreferrer noopener"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Read the source
|
Read the source
|
||||||
</a>
|
</a>
|
||||||
<Link className={s.btnSecondary} to={routes.home}>
|
<Link className={s.btnSecondary} to={routes.home}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
import { allDemos, lineup, REPO_URL, routes } from '@/content/lineup';
|
import { StatusPill } from '@/components/site/EnvironmentPicker';
|
||||||
|
import { allDemos, lineup, pad2, REPO_URL, routes } from '@/content/lineup';
|
||||||
import * as s from '@/content/styles';
|
import * as s from '@/content/styles';
|
||||||
import { pageTitle, useSeo } from '@/lib/seo';
|
import { pageTitle, useSeo } from '@/lib/seo';
|
||||||
|
|
||||||
@@ -27,12 +28,12 @@ export default function NotFound() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${s.shell} py-16 sm:py-24`}>
|
<main className={`${s.shell} py-16 sm:py-24`}>
|
||||||
<p className={`${s.eyebrow} nums`}>404</p>
|
<p className={s.label}>404</p>
|
||||||
<h1 className={`${s.h1} mt-3 max-w-2xl`}>That page isn’t here.</h1>
|
<h1 className={`${s.h1} mt-3`}>That page isn’t here.</h1>
|
||||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
<p className={`${s.lede} mt-4`}>
|
||||||
Nothing is served at{' '}
|
Nothing is served at{' '}
|
||||||
<code className="break-all font-mono text-fg">{pathname}</code>. Either the link is old or
|
<code className="break-all font-mono text-mono-data text-fg">{pathname}</code>. Either the
|
||||||
we moved something. Here is everything this site has.
|
link is old or we moved something. Here is everything this site has.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
@@ -46,43 +47,45 @@ export default function NotFound() {
|
|||||||
|
|
||||||
<div className="mt-12 grid gap-8 sm:grid-cols-2">
|
<div className="mt-12 grid gap-8 sm:grid-cols-2">
|
||||||
<nav aria-labelledby="nf-demos">
|
<nav aria-labelledby="nf-demos">
|
||||||
<h2 className={s.h3} id="nf-demos">
|
<h2 className={s.label} id="nf-demos">
|
||||||
Demos
|
Environments · {pad2(allDemos.length)}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 divide-y divide-border border-y border-border">
|
||||||
{allDemos.map((demo) => (
|
{allDemos.map((demo) => (
|
||||||
<li key={demo.slug}>
|
<li key={demo.slug}>
|
||||||
<Link className={`${s.link} text-sm`} to={routes.demo(demo.slug)}>
|
<Link
|
||||||
|
className="tap flex items-center justify-between gap-3 py-3 text-ui font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg"
|
||||||
|
to={routes.demo(demo.slug)}
|
||||||
|
>
|
||||||
{demo.title}
|
{demo.title}
|
||||||
|
<StatusPill status={demo.status} />
|
||||||
</Link>
|
</Link>
|
||||||
<span className="ml-2 text-xs text-muted">
|
|
||||||
{demo.status === 'live' ? 'Live' : 'Specification'}
|
|
||||||
</span>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{allDemos.length === 0 ? (
|
{allDemos.length === 0 ? (
|
||||||
<li className="text-sm text-muted">No demos are registered yet.</li>
|
<li className={`${s.small} py-3`}>No environments are registered yet.</li>
|
||||||
) : null}
|
) : null}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav aria-labelledby="nf-verticals">
|
<nav aria-labelledby="nf-verticals">
|
||||||
<h2 className={s.h3} id="nf-verticals">
|
<h2 className={s.label} id="nf-verticals">
|
||||||
The lineup
|
The lineup · {pad2(lineup.length)}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="mt-3 space-y-2">
|
<ul className="mt-3 divide-y divide-border border-y border-border">
|
||||||
{topVerticals.map((v) => (
|
{topVerticals.map((v) => (
|
||||||
<li key={v.slug}>
|
<li key={v.slug}>
|
||||||
<Link className={`${s.link} text-sm`} to={routes.vertical(v.slug)}>
|
<Link
|
||||||
|
className="tap flex items-center gap-3 py-3 text-ui font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg"
|
||||||
|
to={routes.vertical(v.slug)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-label uppercase text-muted">{pad2(v.rank)}</span>
|
||||||
{v.title}
|
{v.title}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link className={`${s.navLink} py-3`} to={routes.home}>
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-muted transition-colors duration-1 ease-enter hover:text-fg"
|
|
||||||
to={routes.home}
|
|
||||||
>
|
|
||||||
All {lineup.length}
|
All {lineup.length}
|
||||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -91,9 +94,9 @@ export default function NotFound() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card mt-12 p-5">
|
<div className="card mt-12 p-4 sm:p-5">
|
||||||
<h2 className={s.h3}>Looking for the numbers?</h2>
|
<h2 className={s.h3}>Looking for the numbers?</h2>
|
||||||
<p className={`${s.prose} mt-2 max-w-2xl`}>
|
<p className={`${s.prose} mt-2`}>
|
||||||
What is measured, what is cited from elsewhere, and what we have deliberately not measured
|
What is measured, what is cited from elsewhere, and what we have deliberately not measured
|
||||||
all live on one page.
|
all live on one page.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import { ArrowLeft, ArrowRight, FileText, Play, Quote, TriangleAlert } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
import { StatusPill } from '@/components/site/EnvironmentPicker';
|
||||||
|
import { Term } from '@/components/site/Term';
|
||||||
import { iconFor } from '@/content/icons';
|
import { iconFor } from '@/content/icons';
|
||||||
import { demosForVertical, featuredDemo, lineup, routes } from '@/content/lineup';
|
import { demosForVertical, featuredDemo, lineup, pad2, routes } from '@/content/lineup';
|
||||||
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
|
||||||
import * as s from '@/content/styles';
|
import * as s from '@/content/styles';
|
||||||
|
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
||||||
import { pageTitle, useSeo } from '@/lib/seo';
|
import { pageTitle, useSeo } from '@/lib/seo';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One proposal, in full.
|
||||||
|
*
|
||||||
|
* The page is the same shape as a demo page's header — persona, the buyer's
|
||||||
|
* question as a quiet quote, then the environment in its parts — so a visitor
|
||||||
|
* who has seen the word game reads this as the same machine with the board
|
||||||
|
* missing. Every count and every neighbour is derived from the lineup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function Part({
|
||||||
|
label,
|
||||||
|
tone = 'default',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: ReactNode;
|
||||||
|
tone?: 'default' | 'warning';
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article className={`card p-4 sm:p-5 ${tone === 'warning' ? 'bg-warning-subtle' : ''}`}>
|
||||||
|
<h3 className={`${s.label} ${tone === 'warning' ? 'text-warning' : ''}`}>{label}</h3>
|
||||||
|
<p className={`${s.prose} mt-2 text-fg`}>{children}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function VerticalPage() {
|
export default function VerticalPage() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const vertical = verticalBySlug(slug);
|
const vertical = verticalBySlug(slug);
|
||||||
@@ -18,7 +47,7 @@ export default function VerticalPage() {
|
|||||||
title: pageTitle(vertical?.title ?? 'No such vertical'),
|
title: pageTitle(vertical?.title ?? 'No such vertical'),
|
||||||
description: vertical
|
description: vertical
|
||||||
? `${vertical.task} ${vertical.reward}`
|
? `${vertical.task} ${vertical.reward}`
|
||||||
: 'There is no proposal at that address. The twelve we have written are all on the home page.',
|
: `There is no proposal at that address. The ${lineup.length} we have written are all on the home page.`,
|
||||||
...(slug ? { canonical: routes.vertical(slug) } : {}),
|
...(slug ? { canonical: routes.vertical(slug) } : {}),
|
||||||
...(vertical ? { ogImage: `/og/vertical-${vertical.slug}.png` } : {}),
|
...(vertical ? { ogImage: `/og/vertical-${vertical.slug}.png` } : {}),
|
||||||
});
|
});
|
||||||
@@ -26,10 +55,12 @@ export default function VerticalPage() {
|
|||||||
if (!vertical) {
|
if (!vertical) {
|
||||||
return (
|
return (
|
||||||
<main className={`${s.shell} py-16`}>
|
<main className={`${s.shell} py-16`}>
|
||||||
<h1 className={s.h1}>No such vertical.</h1>
|
<p className={s.label}>No such vertical</p>
|
||||||
<p className={`${s.lede} mt-4 max-w-xl`}>
|
<h1 className={`${s.h1} mt-3`}>There is no proposal at that address.</h1>
|
||||||
There is no proposal at <code className="font-mono text-fg">/verticals/{slug}</code>. The
|
<p className={`${s.lede} mt-4`}>
|
||||||
twelve we have written are all on the home page.
|
Nothing is written at{' '}
|
||||||
|
<code className="break-all font-mono text-mono-data text-fg">/verticals/{slug}</code>. The{' '}
|
||||||
|
{lineup.length} we have written are all on the home page.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
<Link className={s.btnPrimary} to={routes.home}>
|
<Link className={s.btnPrimary} to={routes.home}>
|
||||||
@@ -53,90 +84,82 @@ export default function VerticalPage() {
|
|||||||
const next = index >= 0 ? lineup[index + 1] : undefined;
|
const next = index >= 0 ? lineup[index + 1] : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={`${s.shell} py-10 sm:py-14`}>
|
<main className={`${s.shell} py-8 sm:py-12`}>
|
||||||
<Link
|
<Link className={s.navLink} to={routes.home}>
|
||||||
className="tap inline-flex items-center gap-1.5 text-sm font-medium text-muted transition-colors duration-1 ease-enter hover:text-fg"
|
|
||||||
to={routes.home}
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
All twelve
|
All {lineup.length}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<header className="mt-4">
|
<header className="mt-4">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||||
<span className={s.pill}>Rank {vertical.rank} of {lineup.length}</span>
|
<span className={s.chipOutline}>
|
||||||
<span className={s.pill}>
|
Rank {pad2(vertical.rank)} / {pad2(lineup.length)}
|
||||||
|
</span>
|
||||||
|
<span className={s.chipMuted}>
|
||||||
{vertical.plannedForV1 ? 'In the first set' : 'Not in the first set'}
|
{vertical.plannedForV1 ? 'In the first set' : 'Not in the first set'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex items-start gap-4">
|
<div className="mt-5 flex items-start gap-4">
|
||||||
<span className="card grid size-12 shrink-0 place-items-center bg-surface-2">
|
<span className="grid size-11 shrink-0 place-items-center rounded-md bg-surface-3">
|
||||||
<Icon aria-hidden="true" className="size-6 text-brand" />
|
<Icon aria-hidden="true" className="size-5 text-border-strong" />
|
||||||
</span>
|
</span>
|
||||||
<h1 className={`${s.h1} min-w-0`}>{vertical.title}</h1>
|
<h1 className={`${s.h1} min-w-0`}>{vertical.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<p className={`${s.label} mt-5 text-accent-fg`}>For the {vertical.persona}</p>
|
||||||
|
<p className={`${s.label} mt-4`}>The question you walked in with</p>
|
||||||
|
<blockquote className={`${s.quote} mt-2`}>
|
||||||
|
<p>{vertical.anxiety}</p>
|
||||||
|
</blockquote>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Who this is for, and what is already worrying them ─────────── */}
|
{/* ── The environment, in the same parts every time ───────────────── */}
|
||||||
<section className="mt-8">
|
<section aria-labelledby="parts" className="mt-10">
|
||||||
<p className={s.eyebrow}>Who it is for</p>
|
<h2 className={s.h2} id="parts">
|
||||||
<p className="mt-2 text-lg font-semibold text-fg">{vertical.persona}</p>
|
The environment
|
||||||
<blockquote className="card mt-4 max-w-3xl p-5">
|
</h2>
|
||||||
<Quote aria-hidden="true" className="size-5 text-muted" />
|
<p className={`${s.prose} mt-3`}>
|
||||||
<p className="mt-2 text-lg leading-relaxed text-fg">{vertical.anxiety}</p>
|
The same machine as the word game: a task, a score in the number this desk already
|
||||||
<footer className="mt-3 text-sm text-muted">
|
reports, and the term that takes points away — the{' '}
|
||||||
The question in the room before anyone opens a laptop.
|
<Term term="counterweight">counterweight</Term>. A <Term term="reward">reward</Term> with
|
||||||
</footer>
|
nothing pulling against it is a target, and a target is what a model learns to hit
|
||||||
</blockquote>
|
instead of doing the job.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-4 lg:grid-cols-3">
|
||||||
|
<Part label={<>01 The task</>}>{vertical.task}</Part>
|
||||||
|
<Part label={<>04 The score that moves</>}>{vertical.reward}</Part>
|
||||||
|
<Part label="The counterweight" tone="warning">
|
||||||
|
{vertical.counterweight}
|
||||||
|
</Part>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── The environment, in the same three parts every time ────────── */}
|
|
||||||
<section className="mt-10 grid gap-4 lg:grid-cols-3">
|
|
||||||
<article className="card p-5">
|
|
||||||
<h2 className={s.eyebrow}>The task</h2>
|
|
||||||
<p className={`${s.prose} mt-2 text-[0.9375rem] text-fg`}>{vertical.task}</p>
|
|
||||||
</article>
|
|
||||||
<article className="card p-5">
|
|
||||||
<h2 className={s.eyebrow}>The reward</h2>
|
|
||||||
<p className={`${s.prose} mt-2 text-[0.9375rem] text-fg`}>{vertical.reward}</p>
|
|
||||||
</article>
|
|
||||||
<article className="card border-warning/40 p-5">
|
|
||||||
<h2 className={`${s.eyebrow} text-warning`}>The counterweight</h2>
|
|
||||||
<p className={`${s.prose} mt-2 text-[0.9375rem] text-fg`}>{vertical.counterweight}</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<p className={`${s.prose} mt-4 max-w-3xl text-sm`}>
|
|
||||||
The counterweight is the part worth arguing about. A reward with nothing pulling against it
|
|
||||||
is a target, and a target is what a model learns to hit instead of doing the job.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{vertical.caveat ? (
|
{vertical.caveat ? (
|
||||||
<section className="card mt-8 border-warning/40 bg-warning/5 p-5 sm:p-6">
|
<section aria-labelledby="stops" className="card mt-6 bg-warning-subtle p-4 sm:p-5">
|
||||||
<h2 className={`${s.h3} flex items-center gap-2 text-warning`}>
|
<h2 className={`${s.label} text-warning`} id="stops">
|
||||||
<TriangleAlert aria-hidden="true" className="size-4 shrink-0" />
|
|
||||||
Where this stops
|
Where this stops
|
||||||
</h2>
|
</h2>
|
||||||
<p className={`${s.prose} mt-2 max-w-3xl text-fg`}>{vertical.caveat}</p>
|
<p className={`${s.prose} mt-2 text-fg`}>{vertical.caveat}</p>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ── What actually exists for this vertical, stated plainly ─────── */}
|
{/* ── What actually exists for this vertical, stated plainly ─────── */}
|
||||||
<section className="mt-10">
|
<section aria-labelledby="exists" className="mt-10">
|
||||||
<h2 className={s.h2}>What exists today</h2>
|
<h2 className={s.h2} id="exists">
|
||||||
|
What exists today
|
||||||
|
</h2>
|
||||||
{live ? (
|
{live ? (
|
||||||
<Link
|
<Link
|
||||||
aria-label={`${live.title} — play it`}
|
aria-label={`${live.title} — play it`}
|
||||||
className={`${s.cardLink} mt-4`}
|
className={`${s.cardLink} mt-4`}
|
||||||
to={routes.demo(live.slug)}
|
to={`${routes.demo(live.slug)}?tab=play`}
|
||||||
>
|
>
|
||||||
<span className={`${s.pill} self-start border-positive/30 bg-positive/10 text-positive`}>
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
Live demo
|
<StatusPill status="live" />
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-3 text-xl font-bold tracking-tight text-fg">{live.title}</span>
|
<span className={`${s.h3} mt-3`}>{live.title}</span>
|
||||||
<span className={`${s.prose} mt-1.5`}>{live.tagline}</span>
|
<span className={`${s.prose} mt-1.5`}>{live.tagline}</span>
|
||||||
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-fg">
|
<span className="mt-4 inline-flex items-center gap-1.5 text-ui font-medium text-accent-fg">
|
||||||
<Play aria-hidden="true" className="size-4" />
|
<Play aria-hidden="true" className="size-4" />
|
||||||
Play it
|
Play it
|
||||||
</span>
|
</span>
|
||||||
@@ -147,34 +170,23 @@ export default function VerticalPage() {
|
|||||||
className={`${s.cardLink} mt-4`}
|
className={`${s.cardLink} mt-4`}
|
||||||
to={routes.demo(spec.slug)}
|
to={routes.demo(spec.slug)}
|
||||||
>
|
>
|
||||||
<span className={`${s.pill} self-start gap-1`}>
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
<FileText aria-hidden="true" className="size-3.5" />
|
<StatusPill status="spec" />
|
||||||
Published specification
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-3 text-xl font-bold tracking-tight text-fg">{spec.title}</span>
|
<span className={`${s.h3} mt-3`}>{spec.title}</span>
|
||||||
<span className={`${s.prose} mt-1.5`}>{spec.tagline}</span>
|
<span className={`${s.prose} mt-1.5`}>{spec.tagline}</span>
|
||||||
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-accent-fg">
|
<span className="mt-4 inline-flex items-center gap-1.5 text-ui font-medium text-accent-fg">
|
||||||
Read the specification
|
Read the specification
|
||||||
<ArrowRight aria-hidden="true" className="size-4" />
|
<ArrowRight aria-hidden="true" className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div className="card mt-4 p-5 sm:p-6">
|
<div className="card mt-4 p-4 sm:p-5">
|
||||||
<p className={`${s.prose} max-w-3xl text-fg`}>
|
<p className={`${s.prose} text-fg`}>
|
||||||
Nothing. There is no environment for this vertical, no recorded run, and no number. It
|
Nothing. There is no environment for this vertical, no recorded run, and no number. It
|
||||||
is a proposal, written to the same shape as the one that is built so you can judge it
|
is a proposal, written to the same shape as the ones that are built so you can judge
|
||||||
on the same terms.
|
it on the same terms.
|
||||||
</p>
|
</p>
|
||||||
{featuredDemo ? (
|
|
||||||
<p className={`${s.prose} mt-3 max-w-3xl`}>
|
|
||||||
The environment that does exist is{' '}
|
|
||||||
<Link className={s.link} to={routes.demo(featuredDemo.slug)}>
|
|
||||||
{featuredDemo.title}
|
|
||||||
</Link>
|
|
||||||
. It is a word game, and it is deliberately not one of these twelve: it is there to
|
|
||||||
show the machinery, not to imply we have built yours.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||||
<Link className={s.btnSecondary} to={routes.gallery}>
|
<Link className={s.btnSecondary} to={routes.gallery}>
|
||||||
See what is built
|
See what is built
|
||||||
@@ -187,6 +199,25 @@ export default function VerticalPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── First visit ─────────────────────────────────────────────────── */}
|
||||||
|
{!live && featuredDemo ? (
|
||||||
|
<section aria-labelledby="first" className="mt-8 border-t border-border pt-6">
|
||||||
|
<h2 className={s.label} id="first">
|
||||||
|
If this is your first visit
|
||||||
|
</h2>
|
||||||
|
<p className={`${s.prose} mt-2`}>
|
||||||
|
Play {featuredDemo.title} first. It is the same four parts on a board you already know,
|
||||||
|
and every word on this page — task, grader, counterweight, score — is met there before
|
||||||
|
it is used here. It is deliberately not one of these {lineup.length}: it shows the
|
||||||
|
machinery without implying we have built yours.
|
||||||
|
</p>
|
||||||
|
<Link className={`${s.btnPrimary} mt-4`} to={`${routes.demo(featuredDemo.slug)}?tab=play`}>
|
||||||
|
Play {featuredDemo.title}
|
||||||
|
<ArrowRight aria-hidden="true" className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* ── Move along the ranking ─────────────────────────────────────── */}
|
{/* ── Move along the ranking ─────────────────────────────────────── */}
|
||||||
<nav aria-label="Other verticals" className="mt-12 grid gap-3 sm:grid-cols-2">
|
<nav aria-label="Other verticals" className="mt-12 grid gap-3 sm:grid-cols-2">
|
||||||
{previous ? (
|
{previous ? (
|
||||||
@@ -195,9 +226,9 @@ export default function VerticalPage() {
|
|||||||
className={`${s.cardLink} sm:items-start`}
|
className={`${s.cardLink} sm:items-start`}
|
||||||
to={routes.vertical(previous.slug)}
|
to={routes.vertical(previous.slug)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted">
|
<span className={`${s.label} inline-flex items-center gap-1.5`}>
|
||||||
<ArrowLeft aria-hidden="true" className="size-3.5" />
|
<ArrowLeft aria-hidden="true" className="size-3.5" />
|
||||||
Rank {previous.rank}
|
Rank {pad2(previous.rank)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`${s.h3} mt-1.5`}>{previous.title}</span>
|
<span className={`${s.h3} mt-1.5`}>{previous.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -210,8 +241,8 @@ export default function VerticalPage() {
|
|||||||
className={`${s.cardLink} sm:col-start-2 sm:items-end sm:text-right`}
|
className={`${s.cardLink} sm:col-start-2 sm:items-end sm:text-right`}
|
||||||
to={routes.vertical(next.slug)}
|
to={routes.vertical(next.slug)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted">
|
<span className={`${s.label} inline-flex items-center gap-1.5`}>
|
||||||
Rank {next.rank}
|
Rank {pad2(next.rank)}
|
||||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className={`${s.h3} mt-1.5`}>{next.title}</span>
|
<span className={`${s.h3} mt-1.5`}>{next.title}</span>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import animate from 'tailwindcss-animate';
|
|||||||
*
|
*
|
||||||
* The palette is PIG's, inlined as literals rather than injected at runtime:
|
* The palette is PIG's, inlined as literals rather than injected at runtime:
|
||||||
* this site has no user-chosen accent, so a runtime theme layer would be a
|
* this site has no user-chosen accent, so a runtime theme layer would be a
|
||||||
* moving part that buys nothing.
|
* moving part that buys nothing. Every value here is `hsl(var(--token))` —
|
||||||
|
* the triples themselves, with their measured contrast ratios, live in
|
||||||
|
* src/index.css. Nothing in this file is a raw colour.
|
||||||
*/
|
*/
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
@@ -20,11 +22,16 @@ export default {
|
|||||||
background: 'hsl(var(--bg))',
|
background: 'hsl(var(--bg))',
|
||||||
surface: 'hsl(var(--surface))',
|
surface: 'hsl(var(--surface))',
|
||||||
'surface-2': 'hsl(var(--surface-2))',
|
'surface-2': 'hsl(var(--surface-2))',
|
||||||
|
'surface-3': 'hsl(var(--surface-3))',
|
||||||
border: 'hsl(var(--border))',
|
border: 'hsl(var(--border))',
|
||||||
|
// The only boundary token that clears 3:1 as a non-text signal. Inputs,
|
||||||
|
// selects, outline chips and icons at rest use it; `border` is decorative.
|
||||||
|
'border-strong': 'hsl(var(--border-strong))',
|
||||||
input: 'hsl(var(--border))',
|
input: 'hsl(var(--border))',
|
||||||
ring: 'hsl(var(--accent))',
|
ring: 'hsl(var(--ring))',
|
||||||
fg: 'hsl(var(--fg))',
|
fg: 'hsl(var(--fg))',
|
||||||
foreground: 'hsl(var(--fg))',
|
foreground: 'hsl(var(--fg))',
|
||||||
|
'fg-2': 'hsl(var(--fg-2))',
|
||||||
muted: 'hsl(var(--muted))',
|
muted: 'hsl(var(--muted))',
|
||||||
'muted-foreground': 'hsl(var(--muted))',
|
'muted-foreground': 'hsl(var(--muted))',
|
||||||
// shadcn's `accent` is its SUBTLE hover surface, not the brand. Mapping
|
// shadcn's `accent` is its SUBTLE hover surface, not the brand. Mapping
|
||||||
@@ -34,15 +41,24 @@ export default {
|
|||||||
brand: 'hsl(var(--accent))',
|
brand: 'hsl(var(--accent))',
|
||||||
'accent-fg': 'hsl(var(--accent-fg))',
|
'accent-fg': 'hsl(var(--accent-fg))',
|
||||||
'accent-on': 'hsl(var(--accent-on))',
|
'accent-on': 'hsl(var(--accent-on))',
|
||||||
|
'accent-edge': 'hsl(var(--accent-edge))',
|
||||||
'accent-subtle': 'hsl(var(--accent-subtle))',
|
'accent-subtle': 'hsl(var(--accent-subtle))',
|
||||||
|
// The tab bar's 2px state bar: accent in dark, accent-fg in light, so
|
||||||
|
// the trigger needs one class and no theme variant.
|
||||||
|
'tab-underline': 'hsl(var(--tab-underline))',
|
||||||
primary: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-on))' },
|
primary: { DEFAULT: 'hsl(var(--accent))', foreground: 'hsl(var(--accent-on))' },
|
||||||
secondary: { DEFAULT: 'hsl(var(--surface-2))', foreground: 'hsl(var(--fg))' },
|
secondary: { DEFAULT: 'hsl(var(--surface-2))', foreground: 'hsl(var(--fg))' },
|
||||||
card: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
|
card: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
|
||||||
popover: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
|
popover: { DEFAULT: 'hsl(var(--surface))', foreground: 'hsl(var(--fg))' },
|
||||||
destructive: { DEFAULT: 'hsl(var(--danger))', foreground: 'hsl(0 0% 100%)' },
|
// `danger-on`, not white: white on the dark theme's red is 3.63:1.
|
||||||
|
destructive: { DEFAULT: 'hsl(var(--danger))', foreground: 'hsl(var(--danger-on))' },
|
||||||
positive: 'hsl(var(--positive))',
|
positive: 'hsl(var(--positive))',
|
||||||
warning: 'hsl(var(--warning))',
|
warning: 'hsl(var(--warning))',
|
||||||
|
'warning-on': 'hsl(var(--warning-on))',
|
||||||
|
'warning-subtle': 'hsl(var(--warning-subtle))',
|
||||||
danger: 'hsl(var(--danger))',
|
danger: 'hsl(var(--danger))',
|
||||||
|
'danger-on': 'hsl(var(--danger-on))',
|
||||||
|
'danger-subtle': 'hsl(var(--danger-subtle))',
|
||||||
info: 'hsl(var(--info))',
|
info: 'hsl(var(--info))',
|
||||||
// The board's three tile states. Named by MEANING, not by colour, so
|
// The board's three tile states. Named by MEANING, not by colour, so
|
||||||
// the high-contrast palette can swap the values without renaming.
|
// the high-contrast palette can swap the values without renaming.
|
||||||
@@ -53,16 +69,68 @@ export default {
|
|||||||
'present-fg': 'hsl(var(--tile-present-fg))',
|
'present-fg': 'hsl(var(--tile-present-fg))',
|
||||||
absent: 'hsl(var(--tile-absent))',
|
absent: 'hsl(var(--tile-absent))',
|
||||||
'absent-fg': 'hsl(var(--tile-absent-fg))',
|
'absent-fg': 'hsl(var(--tile-absent-fg))',
|
||||||
|
// An unplayed tile's edge (`border-tile-empty`).
|
||||||
|
empty: 'hsl(var(--tile-empty-border))',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Manrope Variable', 'Avenir Next', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
sans: ['Geist Variable', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
mono: ['Geist Mono Variable', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||||
},
|
},
|
||||||
borderRadius: { md: '0.5rem', lg: '0.75rem', xl: '1rem' },
|
/*
|
||||||
|
* The type scale (spec §3). Sans unless the name says mono; the face is
|
||||||
|
* still applied by `font-mono` on the element, these only fix size,
|
||||||
|
* leading, weight and tracking. Responsive steps are the `-lg` / `-xl`
|
||||||
|
* siblings (`text-display lg:text-display-lg xl:text-display-xl`).
|
||||||
|
* Weight ceiling is 600 everywhere.
|
||||||
|
*/
|
||||||
|
fontSize: {
|
||||||
|
display: ['2.25rem', { lineHeight: '1.05', fontWeight: '600', letterSpacing: '-0.02em' }],
|
||||||
|
'display-lg': ['3rem', { lineHeight: '1.05', fontWeight: '600', letterSpacing: '-0.02em' }],
|
||||||
|
'display-xl': ['3.5rem', { lineHeight: '1.05', fontWeight: '600', letterSpacing: '-0.02em' }],
|
||||||
|
h1: ['1.75rem', { lineHeight: '1.15', fontWeight: '600', letterSpacing: '-0.015em' }],
|
||||||
|
'h1-lg': ['2rem', { lineHeight: '1.15', fontWeight: '600', letterSpacing: '-0.015em' }],
|
||||||
|
h2: ['1.5rem', { lineHeight: '1.15', fontWeight: '600', letterSpacing: '-0.015em' }],
|
||||||
|
'h2-lg': ['1.875rem', { lineHeight: '1.15', fontWeight: '600', letterSpacing: '-0.015em' }],
|
||||||
|
h3: ['1.125rem', { lineHeight: '1.3', fontWeight: '600', letterSpacing: '-0.01em' }],
|
||||||
|
lede: ['1.125rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||||
|
'lede-lg': ['1.25rem', { lineHeight: '1.5', fontWeight: '400' }],
|
||||||
|
// The tab claim: never mono, never uppercase, never shortened.
|
||||||
|
claim: ['1.125rem', { lineHeight: '1.35', fontWeight: '500' }],
|
||||||
|
'claim-lg': ['1.25rem', { lineHeight: '1.35', fontWeight: '500' }],
|
||||||
|
// The education floor. Nothing that explains is ever smaller.
|
||||||
|
prose: ['1rem', { lineHeight: '1.6', fontWeight: '400' }],
|
||||||
|
ui: ['0.875rem', { lineHeight: '1.4' }],
|
||||||
|
small: ['0.8125rem', { lineHeight: '1.45', fontWeight: '400' }],
|
||||||
|
caption: ['0.75rem', { lineHeight: '1.4' }],
|
||||||
|
// mono, uppercase — pair with `font-mono uppercase`.
|
||||||
|
label: ['0.6875rem', { lineHeight: '1.2', fontWeight: '500', letterSpacing: '0.04em' }],
|
||||||
|
'mono-data': ['0.8125rem', { lineHeight: '1.4', fontWeight: '400' }],
|
||||||
|
stat: ['0.8125rem', { lineHeight: '1.4', fontWeight: '600' }],
|
||||||
|
metric: ['2rem', { lineHeight: '1.1', fontWeight: '600', letterSpacing: '-0.01em' }],
|
||||||
|
// The landing's 0% → ~60% figure.
|
||||||
|
figure: ['2.5rem', { lineHeight: '1', fontWeight: '500', letterSpacing: '-0.02em' }],
|
||||||
|
'figure-lg': ['3.5rem', { lineHeight: '1', fontWeight: '500', letterSpacing: '-0.02em' }],
|
||||||
|
code: ['0.8125rem', { lineHeight: '1.6' }],
|
||||||
|
},
|
||||||
|
letterSpacing: { label: '0.04em' },
|
||||||
|
// A real four-step scale. 4px tiles/cells, 6px controls, 8px inner blocks,
|
||||||
|
// 12px surfaces. Named aliases resolve to the same CSS variables index.css
|
||||||
|
// declares, so a component can say what it means (`rounded-control`).
|
||||||
|
borderRadius: {
|
||||||
|
sm: '0.25rem',
|
||||||
|
md: '0.375rem',
|
||||||
|
lg: '0.5rem',
|
||||||
|
xl: '0.75rem',
|
||||||
|
tile: 'var(--radius-tile)',
|
||||||
|
control: 'var(--radius-control)',
|
||||||
|
surface: 'var(--radius-surface)',
|
||||||
|
},
|
||||||
|
// Overlays only. Flat components carry no shadow; borders do the work.
|
||||||
|
boxShadow: { overlay: 'var(--shadow-overlay)' },
|
||||||
|
maxWidth: { canvas: '80rem', measure: 'var(--measure)', 'measure-wide': 'var(--measure-wide)' },
|
||||||
transitionDuration: { 1: 'var(--dur-1)', 2: 'var(--dur-2)', 3: 'var(--dur-3)' },
|
transitionDuration: { 1: 'var(--dur-1)', 2: 'var(--dur-2)', 3: 'var(--dur-3)' },
|
||||||
transitionTimingFunction: { enter: 'var(--ease-out)', exit: 'var(--ease-in)' },
|
transitionTimingFunction: { enter: 'var(--ease-out)', exit: 'var(--ease-in)' },
|
||||||
maxWidth: { canvas: '80rem' },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [animate],
|
plugins: [animate],
|
||||||
|
|||||||