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.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
Manrope — SIL Open Font License 1.1
|
||||
https://github.com/sharanda/manrope
|
||||
Copyright 2019 The Manrope Project Authors. Licence text: public/fonts/OFL.txt
|
||||
"Manrope" is a Reserved Font Name under that licence: a modified version of the
|
||||
font may not be distributed under that name.
|
||||
Geist and Geist Mono — SIL Open Font License 1.1
|
||||
https://github.com/vercel/geist-font
|
||||
Copyright 2024 The Geist Project Authors. The sans and mono faces are embedded
|
||||
as variable woff2 files via @fontsource-variable/geist and
|
||||
@fontsource-variable/geist-mono (latin subset). Licence text: public/fonts/OFL.txt
|
||||
|
||||
Lucide — ISC
|
||||
https://github.com/lucide-icons/lucide
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"conformance": "node scripts/conformance.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@fontsource-variable/manrope": "^5.3.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
|
||||
@@ -8,6 +8,12 @@ importers:
|
||||
|
||||
.:
|
||||
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':
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
@@ -526,6 +532,12 @@ packages:
|
||||
'@floating-ui/utils@0.2.12':
|
||||
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':
|
||||
resolution: {integrity: sha512-6D5dgokHsWDDMtmXHznKa0hK229NN+1a4BLPmUCLqcO1Pw5EEhWY5RFt0AcXnVRAljFFPfRtLkJePQj6LSsV6g==}
|
||||
|
||||
@@ -2117,6 +2129,10 @@ snapshots:
|
||||
|
||||
'@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': {}
|
||||
|
||||
'@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 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
|
||||
* 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
|
||||
* Font License, the icons are lucide, the charts are recharts. Every one of
|
||||
* Wordnik's filtered through SCOWL, the typefaces are Geist and Geist Mono
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* `public/fonts/OFL.txt` is checked for separately. Shipping the .woff2 out of
|
||||
@@ -64,13 +64,22 @@ const SOURCES = [
|
||||
used: () => hasWordFile(/scowl/i),
|
||||
},
|
||||
{
|
||||
label: 'Manrope',
|
||||
match: /manrope/i,
|
||||
label: 'Geist',
|
||||
match: /\bgeist\b/i,
|
||||
// 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,
|
||||
why: 'Manrope is the typeface, embedded as a variable woff2.',
|
||||
used: () => exists(abs('node_modules', '@fontsource-variable', 'manrope')),
|
||||
why: 'Geist is the sans typeface, embedded as a variable woff2.',
|
||||
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',
|
||||
@@ -162,8 +171,8 @@ report.check(
|
||||
exists(OFL),
|
||||
rel(OFL),
|
||||
'OFL text ships with the font',
|
||||
'Manrope is 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. ' +
|
||||
'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/geist/LICENSE to public/fonts/OFL.txt. ' +
|
||||
'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 type { ComponentType } from 'react';
|
||||
import { Eye, Trophy } from 'lucide-react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import type { DemoStep } from '@/lib/demo-kit/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { InterventionChip, Label } from './chrome';
|
||||
import { formatOrDash } from './format';
|
||||
|
||||
export interface BlindCompareRun<T> {
|
||||
@@ -39,6 +40,9 @@ export interface BlindCompareProps<T> {
|
||||
* unknow it, and every "obviously the trained one looks better" is worthless
|
||||
* 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
|
||||
* want to play should not be held hostage by a modal-shaped page.
|
||||
*/
|
||||
@@ -77,63 +81,82 @@ export function BlindCompare<T>({
|
||||
return (
|
||||
<section aria-label="Blind comparison" className={cn('space-y-3', className)}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h3 className="text-sm font-semibold">{question}</h3>
|
||||
<p className="nums text-xs text-muted">Same puzzle, same seed ({seed}).</p>
|
||||
<h3 className="text-h3 text-fg">{question}</h3>
|
||||
<p className="font-mono text-label uppercase tracking-label text-muted">
|
||||
Same puzzle · seed {seed}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{sides.map(({ id, run }) => {
|
||||
const last = run.steps[run.steps.length - 1];
|
||||
const picked = vote === id;
|
||||
const won = revealed && winner === id;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={cn(
|
||||
'card flex flex-col gap-3 p-3 transition-colors duration-2 ease-enter',
|
||||
picked && 'border-brand',
|
||||
revealed && winner === id && 'border-positive',
|
||||
'flex flex-col gap-3 rounded-xl border bg-surface p-3 transition-colors duration-2 ease-enter sm:p-4',
|
||||
won ? 'border-accent-fg' : picked ? 'border-border-strong' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold">Agent {id}</h4>
|
||||
<span className="nums text-xs text-muted">{run.steps.length} steps</span>
|
||||
<Label as="h4" className="text-fg">
|
||||
Agent {id}
|
||||
</Label>
|
||||
<span className="font-mono text-label uppercase tracking-label text-muted">
|
||||
{run.steps.length} steps
|
||||
</span>
|
||||
</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 ? (
|
||||
<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>
|
||||
|
||||
{!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
|
||||
</Button>
|
||||
) : (
|
||||
<dl className="space-y-1 text-sm">
|
||||
<dl className="space-y-1 text-ui">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<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 className="flex items-baseline justify-between gap-2">
|
||||
<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 className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-muted">Intervention</dt>
|
||||
<dd className="text-right text-xs">
|
||||
{run.intervention ?? (
|
||||
<span className="text-muted">none — plain rollout</span>
|
||||
<dd className="text-right">
|
||||
{run.intervention ? (
|
||||
<InterventionChip>{run.intervention}</InterventionChip>
|
||||
) : (
|
||||
<span className="text-small text-muted">none — plain rollout</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 border-t border-border pt-1">
|
||||
<dt className="text-muted">Reward</dt>
|
||||
<dd className="nums flex items-center gap-1 text-right font-mono font-semibold">
|
||||
{revealed && winner === id ? (
|
||||
<Trophy className="size-3.5 text-positive" aria-hidden="true" />
|
||||
<dd className="flex items-center gap-1.5 text-right font-mono text-stat text-fg">
|
||||
{won ? (
|
||||
<span className="font-mono text-label uppercase tracking-label text-positive">
|
||||
higher
|
||||
</span>
|
||||
) : null}
|
||||
{formatOrDash(run.total)}
|
||||
</dd>
|
||||
@@ -150,24 +173,25 @@ export function BlindCompare<T>({
|
||||
<Button variant="outline" size="touch" onClick={() => commit('tie')}>
|
||||
Too close to call
|
||||
</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" />
|
||||
Just show me
|
||||
</Button>
|
||||
</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 ? (
|
||||
<>Revealed without a vote. </>
|
||||
) : 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">
|
||||
You picked the lower-scoring run.
|
||||
</span>{' '}
|
||||
<span className="font-medium text-warning">You picked the lower-scoring run.</span>{' '}
|
||||
</>
|
||||
)}
|
||||
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 { ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { External } from './chrome';
|
||||
|
||||
export interface CodeReceiptProps {
|
||||
/** 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.
|
||||
*/
|
||||
marker?: string;
|
||||
/** Link to the whole file — GitHub, usually. */
|
||||
/** Link to the whole file in the repository. */
|
||||
href?: string;
|
||||
/** Lines of context kept around the marked range while collapsed. */
|
||||
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
|
||||
* reason the panel exists.
|
||||
*
|
||||
* The block scrolls horizontally inside itself. Long Python lines must never
|
||||
* make the PAGE scroll sideways; on a phone that turns every vertical swipe
|
||||
* into a fight.
|
||||
* The block scrolls on both axes inside its own box. Long Python lines must
|
||||
* never make the PAGE scroll sideways; on a phone that turns every vertical
|
||||
* swipe into a fight.
|
||||
*/
|
||||
export function CodeReceipt({
|
||||
code,
|
||||
@@ -130,42 +129,43 @@ export function CodeReceipt({
|
||||
const gutterWidth = `${String(lines.length).length + 1}ch`;
|
||||
|
||||
return (
|
||||
<section aria-label={`Source: ${path}`} className={cn('card overflow-hidden', className)}>
|
||||
<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">
|
||||
<section
|
||||
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
|
||||
path to "envs/wo…", which names nothing. */}
|
||||
<h3
|
||||
className="nums w-full min-w-0 truncate font-mono text-xs text-muted sm:flex-1"
|
||||
title={path}
|
||||
>
|
||||
<h3 className="w-full min-w-0 truncate font-mono text-caption text-muted sm:flex-1" title={path}>
|
||||
{path}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{range && !expanded ? (
|
||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(true)}>
|
||||
Show all {lines.length} lines
|
||||
</Button>
|
||||
) : null}
|
||||
{range && expanded ? (
|
||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(false)}>
|
||||
Collapse to the marked part
|
||||
{range ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="tap lg:min-h-8"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((was) => !was)}
|
||||
>
|
||||
{expanded ? 'Collapse to the marked part' : `Expand all ${lines.length} lines`}
|
||||
</Button>
|
||||
) : null}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
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
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
Open in repo
|
||||
<External />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-h-96 overflow-auto">
|
||||
<pre className="w-max min-w-full py-2 font-mono text-xs leading-relaxed">
|
||||
<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-code text-fg [font-feature-settings:'liga'_0]">
|
||||
<code>
|
||||
{shown.from > 0 ? (
|
||||
<span className="block px-3 text-muted" aria-hidden="true">
|
||||
@@ -180,17 +180,19 @@ export function CodeReceipt({
|
||||
key={number}
|
||||
className={cn(
|
||||
'block border-l-2 pr-4',
|
||||
marked
|
||||
? 'border-brand bg-accent-subtle/60 text-fg'
|
||||
: 'border-transparent text-muted',
|
||||
marked ? 'border-accent-fg bg-accent-subtle text-fg' : 'border-transparent text-fg-2',
|
||||
)}
|
||||
>
|
||||
{/* Sticky so the line numbers survive a horizontal scroll —
|
||||
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
|
||||
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 }}
|
||||
>
|
||||
{number + 1}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component } 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';
|
||||
|
||||
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;
|
||||
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">
|
||||
<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'}
|
||||
</h2>
|
||||
</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
|
||||
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.
|
||||
</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'}
|
||||
</p>
|
||||
</pre>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Button size="touch" onClick={this.handleReset}>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
@@ -76,7 +76,7 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
|
||||
<Button variant="outline" size="touch" asChild>
|
||||
<a href={sourceHref ?? REPO_URL} rel="noreferrer">
|
||||
Read the source
|
||||
<ExternalLink aria-hidden="true" />
|
||||
<span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TermScope } from '@/components/site/Term';
|
||||
import { listRuns, loadEpisode, rewardTotal } from '@/lib/demo-kit/episode';
|
||||
import { usePlayer } from '@/lib/demo-kit/player';
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { BlindCompare } from './BlindCompare';
|
||||
import { Chip, Label, StatusPill } from './chrome';
|
||||
import { CodeReceipt } from './CodeReceipt';
|
||||
import { DemoErrorBoundary } from './DemoErrorBoundary';
|
||||
import { DemoTabBar, TabClaim, resolveTab, visibleTabs } from './DemoTabs';
|
||||
@@ -33,14 +35,14 @@ import { StatStrip } from './StatStrip';
|
||||
import type { Stat } from './StatStrip';
|
||||
import { RecordedBadge, TracePlayer } from './TracePlayer';
|
||||
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/';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* one of them silently winning; a permalink to `?tab=call` would land the reader
|
||||
* 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. */
|
||||
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 {
|
||||
demo: AnyDemoModule;
|
||||
runs: RunRef[];
|
||||
@@ -58,7 +72,7 @@ export interface DemoBundle {
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; bundle: DemoBundle }
|
||||
| { status: 'ready'; bundle: DemoBundle; dropped: number }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
/**
|
||||
@@ -67,15 +81,20 @@ type LoadState =
|
||||
* `loadDemoModule` and `loadEpisode` both cache their promises, so the route
|
||||
* loader having already fetched the module makes this resolve without a second
|
||||
* 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) {
|
||||
// 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
|
||||
// every visitor of every real demo.
|
||||
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);
|
||||
@@ -83,14 +102,21 @@ async function loadBundle(slug: string): Promise<DemoBundle> {
|
||||
const settled = await Promise.allSettled(runs.map((run) => loadEpisode(run)));
|
||||
|
||||
const episodes: Record<string, DemoEpisode> = {};
|
||||
let dropped = 0;
|
||||
settled.forEach((outcome, index) => {
|
||||
const run = runs[index];
|
||||
if (!run) return;
|
||||
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 {
|
||||
@@ -112,19 +138,19 @@ export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
||||
const params = useParams();
|
||||
const slug = slugProp ?? params['slug'] ?? '';
|
||||
const [state, setState] = useState<LoadState>(
|
||||
bundle ? { status: 'ready', bundle } : { status: 'loading' },
|
||||
bundle ? { status: 'ready', bundle, dropped: 0 } : { status: 'loading' },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (bundle) {
|
||||
setState({ status: 'ready', bundle });
|
||||
setState({ status: 'ready', bundle, dropped: 0 });
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
setState({ status: 'loading' });
|
||||
loadBundle(slug)
|
||||
.then((loaded) => {
|
||||
if (live) setState({ status: 'ready', bundle: loaded });
|
||||
if (live) setState({ status: 'ready', bundle: loaded.bundle, dropped: loaded.dropped });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!live) return;
|
||||
@@ -142,11 +168,11 @@ export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<main className={cn(st.shell, 'py-16')}>
|
||||
<div role="alert" className="card max-w-xl p-6">
|
||||
<h1 className={st.h2}>That demo is not here</h1>
|
||||
<p className={cn(st.prose, 'mt-3')}>{state.message}</p>
|
||||
<div role="alert" className="max-w-xl rounded-xl border border-border bg-surface p-5 sm:p-6">
|
||||
<h1 className="text-h2 text-fg">That environment is not here</h1>
|
||||
<p className="mt-3 max-w-measure text-prose text-fg-2">{state.message}</p>
|
||||
<a href="/gallery" className={cn(st.btnSecondary, 'mt-6')}>
|
||||
Back to the demos
|
||||
All environments
|
||||
</a>
|
||||
</div>
|
||||
</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
|
||||
// re-navigating.
|
||||
<DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}>
|
||||
<DemoBody bundle={state.bundle} />
|
||||
<DemoBody bundle={state.bundle} dropped={state.dropped} />
|
||||
</DemoErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
function DemoBody({ bundle, dropped }: { bundle: DemoBundle; dropped: number }) {
|
||||
const { demo, runs, episodes } = bundle;
|
||||
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.
|
||||
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
|
||||
? [
|
||||
{
|
||||
label: 'Outcome',
|
||||
value: episode.outcome,
|
||||
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',
|
||||
value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
|
||||
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',
|
||||
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',
|
||||
label: 'Model call',
|
||||
content: (
|
||||
<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>
|
||||
),
|
||||
content: <ModelCallPanel call={current?.call ?? null} reply={current?.reply ?? null} />,
|
||||
},
|
||||
// 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
|
||||
@@ -347,19 +375,6 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
? detailPanel
|
||||
: DEFAULT_DETAIL_PANEL;
|
||||
|
||||
const timeline = (
|
||||
<StepTimeline
|
||||
steps={steps}
|
||||
current={player.index}
|
||||
onSelect={(next) => {
|
||||
player.pause();
|
||||
player.seek(next);
|
||||
}}
|
||||
Surface={Surface}
|
||||
onTogglePlay={player.toggle}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className={cn(st.shell, 'pb-16')}>
|
||||
{/*
|
||||
@@ -371,15 +386,23 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
{current?.announce ?? ''}
|
||||
</div>
|
||||
|
||||
<header className="pt-6 sm:pt-8">
|
||||
<p className={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
|
||||
<h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
|
||||
<p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
|
||||
<header className="pt-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label className="text-accent-fg">For {demo.meta.persona}</Label>
|
||||
<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
|
||||
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">
|
||||
<Label>The question you walked in with</Label>
|
||||
<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
|
||||
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.
|
||||
*/}
|
||||
{run && headerStats.length > 0 && activeTab !== 'play' ? (
|
||||
// One line that scrolls itself rather than a block that wraps: every
|
||||
// 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">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<StatStrip stats={headerStats} />
|
||||
<RecordedBadge
|
||||
model={run.model}
|
||||
capturedAt={run.capturedAt}
|
||||
{...(run.intervention ? { intervention: run.intervention } : {})}
|
||||
className="ml-0 shrink-0 flex-nowrap"
|
||||
{...(player.timingIsReal || activeTab !== 'watch'
|
||||
? {}
|
||||
: { timingNote: 'pacing approximate' })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<SlotRegion id="hero-aside" />
|
||||
</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} />
|
||||
|
||||
{tabs.includes('play') ? (
|
||||
@@ -412,23 +434,45 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
// Reward and back. Radix leaves the hiding to the author under
|
||||
// `forceMount`, which is what the `data-[state=inactive]` class does —
|
||||
// it is load-bearing, not belt-and-braces.
|
||||
<TabsContent
|
||||
<Panel
|
||||
value="play"
|
||||
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>
|
||||
<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" />
|
||||
<div className="space-y-2">
|
||||
<h2 className={st.h3}>The machine you are inside</h2>
|
||||
<div className="space-y-3">
|
||||
<Label as="h2">The machine you are inside</Label>
|
||||
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} compact />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
|
||||
{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="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}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<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) => (
|
||||
<TabsTrigger key={panel.id} value={panel.id}>
|
||||
{panel.label}
|
||||
@@ -486,7 +532,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
))}
|
||||
</TabsList>
|
||||
{detailPanels.map((panel) => (
|
||||
<TabsContent key={panel.id} value={panel.id}>
|
||||
<TabsContent key={panel.id} value={panel.id} className="mt-3">
|
||||
{panel.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
@@ -494,13 +540,27 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeline}
|
||||
<StepTimeline
|
||||
steps={steps}
|
||||
current={player.index}
|
||||
onSelect={(next) => {
|
||||
player.pause();
|
||||
player.seek(next);
|
||||
}}
|
||||
Surface={Surface}
|
||||
onTogglePlay={player.toggle}
|
||||
/>
|
||||
{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
|
||||
is the player's fallback rather than a measurement.
|
||||
</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" />
|
||||
|
||||
{blindPair ? (
|
||||
@@ -529,10 +589,10 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
</Panel>
|
||||
) : 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>
|
||||
|
||||
{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
|
||||
reads as not scored rather than as zero. The weights are the ones the environment
|
||||
ships.
|
||||
@@ -553,11 +613,10 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
|
||||
{episode ? <VerifyBadge demo={demo} episode={episode} /> : null}
|
||||
|
||||
{run && episode ? (
|
||||
<HeadlineMetric
|
||||
label={`Total reward — ${run.label}`}
|
||||
@@ -568,9 +627,9 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
|
||||
{/*
|
||||
@@ -580,10 +639,45 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
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.
|
||||
*/}
|
||||
<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} />
|
||||
|
||||
{/*
|
||||
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">
|
||||
<ProvenanceCard provenance={demo.provenance} {...(run ? { run } : {})} />
|
||||
<CodeReceipt
|
||||
@@ -605,7 +699,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
|
||||
<SlotRegion id="before-limits" />
|
||||
<LimitsCallout limits={demo.narrative.limits} />
|
||||
</TabsContent>
|
||||
</Panel>
|
||||
</Tabs>
|
||||
</main>
|
||||
);
|
||||
@@ -689,33 +783,46 @@ function RunSwitcher({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label as="span">Agent</Label>
|
||||
<SegmentedControl
|
||||
label="Agent"
|
||||
options={arms.map((arm) => {
|
||||
const intervention = arm.runs.find((r) => r.intervention)?.intervention;
|
||||
return {
|
||||
value: arm.label,
|
||||
label: arm.label,
|
||||
label: intervention ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{arm.label}
|
||||
{/* 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}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{activeArm.runs.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label as="span">Seed</Label>
|
||||
<SegmentedControl
|
||||
label="Hidden word"
|
||||
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}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-2.5 text-xs nums"
|
||||
optionClassName="px-2.5 font-mono text-caption"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -730,23 +837,23 @@ function ShellSkeleton() {
|
||||
return (
|
||||
<div className={cn(st.shell, 'py-10')} aria-busy="true">
|
||||
<p className="sr-only">Loading the recorded run.</p>
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-3 h-4 w-full max-w-md" />
|
||||
<Skeleton className="mt-4 h-10 w-full max-w-sm" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="mt-3 h-8 w-64" />
|
||||
<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-80" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The run a visitor sees before choosing one.
|
||||
*
|
||||
* `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
|
||||
* loss with an empty reasoning panel and the Reward tab on a row of zeros:
|
||||
* the model's least interesting attempt, chosen by accident of sort order.
|
||||
* for the first demo shipped was a failed game with thinking off. So the Watch
|
||||
* tab opened on a loss with an empty reasoning panel and the Reward tab on a
|
||||
* 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
|
||||
* 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
|
||||
* without the list becoming a horizontal scroller — a tab bar you have to
|
||||
* scroll hides the tabs, which is the one thing it exists to advertise. From
|
||||
* `sm` up it shrinks back to its natural width and sits left.
|
||||
* Underline rather than the pill segment for one reason — the nested lists
|
||||
* inside Watch (Reasoning | Model call) keep the pill style, and primary and
|
||||
* secondary navigation must never look alike. The primitive's `underline`
|
||||
* 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({
|
||||
tabs,
|
||||
@@ -85,16 +98,19 @@ export function DemoTabBar({
|
||||
}) {
|
||||
return (
|
||||
<TabsList
|
||||
variant="underline"
|
||||
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) => (
|
||||
<TabsTrigger
|
||||
key={id}
|
||||
value={id}
|
||||
title={TAB_DESCRIPTIONS[id]}
|
||||
className="min-w-0 flex-1 px-2 text-[0.8125rem] sm:flex-none sm:px-3 sm:text-sm"
|
||||
>
|
||||
<TabsTrigger key={id} value={id} title={TAB_DESCRIPTIONS[id]}>
|
||||
<span aria-hidden="true" className="mr-1.5 hidden font-mono text-label text-muted sm:inline">
|
||||
{indexOf(id)}
|
||||
</span>
|
||||
{TAB_LABELS[id]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -111,15 +127,13 @@ export function DemoTabBar({
|
||||
* 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
|
||||
* 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 }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'max-w-2xl text-pretty text-base leading-snug text-fg sm:text-lg lg:text-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={cn('max-w-[60ch] text-pretty text-claim text-fg lg:text-claim-lg', className)}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowRight, ListChecks, Scale, Target, TrendingUp } from 'lucide-react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { anatomyTerm } from '@/content/anatomy';
|
||||
import type { AnatomyKey } from '@/content/anatomy';
|
||||
import type { DemoModule } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -9,107 +11,80 @@ export interface EnvAnatomyProps {
|
||||
anatomy: Anatomy;
|
||||
/** `DemoMeta.rewardLine` — six words on what the reward pays for. */
|
||||
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;
|
||||
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
|
||||
* being asked, what the agent is allowed to do, who decides whether it was
|
||||
* 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
|
||||
* should read the fraud demo as "same machine, different grader", and that only
|
||||
* works if the four labels never change between demos.
|
||||
* should read the fraud screen as "same machine, different grader", and that
|
||||
* 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[] = [
|
||||
{ key: 'task', kicker: 'The task', question: 'What is the agent asked to do?', Icon: Target },
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
const KEYS: readonly AnatomyKey[] = ['task', 'actions', 'grader', 'score'];
|
||||
const BOXES = KEYS.map(anatomyTerm);
|
||||
|
||||
export function EnvAnatomy({ anatomy, rewardLine, compact = false, className }: EnvAnatomyProps) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<ol className="flex flex-col lg:flex-row lg:items-stretch">
|
||||
{BOXES.map((box, index) => (
|
||||
{BOXES.map((box, position) => {
|
||||
const grader = box.key === 'grader';
|
||||
return (
|
||||
<li
|
||||
key={box.key}
|
||||
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-center"
|
||||
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-stretch"
|
||||
>
|
||||
<div
|
||||
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',
|
||||
'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',
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-label text-muted" aria-hidden="true">
|
||||
{box.index}
|
||||
</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}
|
||||
<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>
|
||||
<p className={cn('text-pretty text-fg', compact ? 'text-xs' : 'text-sm')}>
|
||||
{anatomy[box.key]}
|
||||
</p>
|
||||
</div>
|
||||
{index < BOXES.length - 1 ? (
|
||||
{position < BOXES.length - 1 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center justify-center py-2 text-muted lg:px-2 lg:py-0"
|
||||
className="flex items-center justify-center py-1.5 text-border-strong lg:px-1.5 lg:py-0"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 rotate-90 lg:rotate-0" />
|
||||
<ArrowRight className="size-4 rotate-90 lg:rotate-0" />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{rewardLine ? (
|
||||
<p className="text-sm text-muted">
|
||||
<span className="font-medium text-fg">This reward: </span>
|
||||
{rewardLine}
|
||||
<p className="text-ui text-muted">
|
||||
<span className="font-mono text-label uppercase tracking-label">This reward</span>{' '}
|
||||
<span className="text-fg-2">{rewardLine}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!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
|
||||
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
|
||||
|
||||
@@ -1,75 +1,89 @@
|
||||
import { ArrowRight, ShieldQuestion } from 'lucide-react';
|
||||
import { getDemo } from '@/lib/demo-kit/registry';
|
||||
import type { Limit } from '@/lib/demo-kit/types';
|
||||
import * as st from '@/content/styles';
|
||||
import type { DemoStatus, Limit } from '@/lib/demo-kit/types';
|
||||
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 {
|
||||
limits: Limit[];
|
||||
/**
|
||||
* Turns a demo slug into something linkable. The shell has no registry
|
||||
* dependency of its own, so the page that knows the routes supplies this.
|
||||
* Turns a demo slug into something linkable. Defaults to the registry, so a
|
||||
* 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;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
function defaultResolve(slug: string): ResolvedDemo | undefined {
|
||||
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.
|
||||
*
|
||||
* Styled as confidence, not apology. A vendor who tells you the limits of their
|
||||
* own demo before you find them is a vendor you believe about everything else
|
||||
* on the page — and every limit here names the demo that closes it, so the list
|
||||
* reads as a roadmap rather than a disclaimer.
|
||||
* Styled as confidence, not apology: an amber card, because a limit is a
|
||||
* caution about the data and amber is what every other caution on the site
|
||||
* wears. A vendor who tells you the limits of their own demo before you find
|
||||
* 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({
|
||||
limits,
|
||||
resolveDemo = defaultResolve,
|
||||
title = 'What this demo does not teach',
|
||||
title = 'What this does not teach',
|
||||
className,
|
||||
}: LimitsCalloutProps) {
|
||||
if (limits.length === 0) return null;
|
||||
return (
|
||||
<section aria-label={title} className={cn('card p-4', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldQuestion className="size-4 text-muted" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{limits.map((limit) => {
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn('rounded-xl border border-border bg-warning-subtle p-4 sm:p-5', className)}
|
||||
>
|
||||
<h3 className="font-mono text-label uppercase tracking-label text-warning">{title}</h3>
|
||||
<ol className="mt-3 space-y-3">
|
||||
{limits.map((limit, index) => {
|
||||
const target = limit.answeredBy ? resolveDemo(limit.answeredBy) : undefined;
|
||||
return (
|
||||
<li key={limit.text} className="border-l-2 border-border pl-3">
|
||||
<p className="text-pretty text-sm leading-relaxed text-fg">{limit.text}</p>
|
||||
<li key={limit.text} className="flex gap-3">
|
||||
<span className="pt-1 font-mono text-label text-muted" aria-hidden="true">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="max-w-measure text-pretty text-prose text-fg-2">{limit.text}</p>
|
||||
{target ? (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-small">
|
||||
<a
|
||||
href={target.href}
|
||||
className={cn(st.link, 'mt-1 inline-flex items-center gap-1 text-xs')}
|
||||
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"
|
||||
>
|
||||
Answered by {target.title}
|
||||
<ArrowRight className="size-3" aria-hidden="true" />
|
||||
answered by {target.title} →
|
||||
</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-xs text-muted">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,14 +27,24 @@ export interface MetricChartProps {
|
||||
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
|
||||
* 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
|
||||
* 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({
|
||||
data,
|
||||
@@ -44,19 +54,19 @@ export default function MetricChart({
|
||||
digits = 3,
|
||||
}: MetricChartProps) {
|
||||
return (
|
||||
<div style={{ height }} className="w-full">
|
||||
<div style={{ height }} className="w-full font-mono">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<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
|
||||
dataKey="x"
|
||||
tick={{ fill: 'hsl(var(--muted))', fontSize: 11 }}
|
||||
tick={{ fill: TICK, fontSize: 11, fontFamily: 'inherit' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||
axisLine={{ stroke: GRID }}
|
||||
minTickGap={12}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: 'hsl(var(--muted))', fontSize: 11 }}
|
||||
tick={{ fill: TICK, fontSize: 11, fontFamily: 'inherit' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={40}
|
||||
@@ -65,18 +75,21 @@ export default function MetricChart({
|
||||
{baseline ? (
|
||||
<ReferenceLine
|
||||
y={baseline.value}
|
||||
stroke="hsl(var(--muted))"
|
||||
stroke={BASELINE}
|
||||
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={{
|
||||
value: baseline.label,
|
||||
position: 'insideTopLeft',
|
||||
fill: 'hsl(var(--muted))',
|
||||
position: 'insideTopRight',
|
||||
fill: TICK,
|
||||
fontSize: 11,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
cursor={{ stroke: 'hsl(var(--border))' }}
|
||||
cursor={{ stroke: BASELINE }}
|
||||
content={(props: TooltipProps<number, string>) => (
|
||||
<ChartTooltip {...props} digits={digits} />
|
||||
)}
|
||||
@@ -87,10 +100,10 @@ export default function MetricChart({
|
||||
// recorded arm) that curve is a claim nobody measured.
|
||||
type="linear"
|
||||
dataKey="y"
|
||||
stroke="hsl(var(--accent))"
|
||||
stroke={SERIES}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2.5, fill: 'hsl(var(--accent))', strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
dot={{ r: 3, fill: SERIES, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4.5, fill: SERIES, strokeWidth: 0 }}
|
||||
isAnimationActive={animate}
|
||||
animationDuration={400}
|
||||
/>
|
||||
@@ -109,9 +122,9 @@ function ChartTooltip({
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const value = payload[0]?.value;
|
||||
return (
|
||||
<div className="card px-2.5 py-1.5 text-xs shadow-md">
|
||||
<p className="nums text-muted">{String(label)}</p>
|
||||
<p className="nums font-mono font-semibold">
|
||||
<div className="rounded-md border border-border bg-surface px-2.5 py-1.5 font-mono text-caption shadow-overlay">
|
||||
<p className="text-muted">{String(label)}</p>
|
||||
<p className="font-semibold text-fg">
|
||||
{typeof value === 'number' ? formatNumber(value, digits) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Suspense, lazy, useMemo } from 'react';
|
||||
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 { EditedChip } from './StatStrip';
|
||||
|
||||
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
|
||||
* 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.
|
||||
*
|
||||
* 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({
|
||||
label,
|
||||
@@ -48,6 +52,7 @@ export function MetricMover({
|
||||
className,
|
||||
}: MetricMoverProps) {
|
||||
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 other arms are read against. The delta text is not: "+0.000 vs itself"
|
||||
// is noise dressed up as a measurement.
|
||||
@@ -55,39 +60,68 @@ export function MetricMover({
|
||||
const delta = rawDelta !== null && Math.abs(rawDelta) > 1e-9 ? rawDelta : null;
|
||||
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 (
|
||||
<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">
|
||||
<h3 className="text-sm font-medium text-muted">{label}</h3>
|
||||
<Label as="h3">{label}</Label>
|
||||
{edited ? <EditedChip /> : null}
|
||||
</div>
|
||||
<p className="mt-1 flex items-baseline gap-2">
|
||||
<span className="nums text-4xl font-semibold leading-none tracking-tight">
|
||||
{formatNumber(value, digits)}
|
||||
</span>
|
||||
{unit ? <span className="text-sm text-muted">{unit}</span> : null}
|
||||
<p className="mt-1.5 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-metric text-fg">{formatNumber(value, digits)}</span>
|
||||
{unit ? <span className="text-ui text-muted">{unit}</span> : null}
|
||||
{delta !== null ? (
|
||||
<span
|
||||
className={cn(
|
||||
'nums text-sm font-medium',
|
||||
'font-mono text-mono-data font-medium',
|
||||
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>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{points.length > 1 ? (
|
||||
<div className="mt-3">
|
||||
{wide ? (
|
||||
<>
|
||||
<Suspense
|
||||
fallback={
|
||||
// Reserve the exact chart height. A chart that pops in and pushes
|
||||
// the caption down is the layout shift this whole page is trying
|
||||
// not to have.
|
||||
// Reserve the exact chart height. A chart that pops in and
|
||||
// pushes the caption down is the layout shift this whole
|
||||
// page is trying not to have.
|
||||
<div
|
||||
style={{ height: CHART_HEIGHT }}
|
||||
className="w-full rounded-lg bg-surface-2"
|
||||
className="w-full rounded-md bg-surface-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
@@ -100,31 +134,22 @@ export function MetricMover({
|
||||
digits={digits}
|
||||
/>
|
||||
</Suspense>
|
||||
{/* The chart is a picture of the table; the table is the accessible
|
||||
version of the picture. Both are the same numbers. */}
|
||||
{/* The chart is a picture of the table; the table is the
|
||||
accessible version of the picture. Both are the same numbers. */}
|
||||
<details className="mt-1">
|
||||
<summary className="tap inline-flex cursor-pointer items-center text-xs text-muted hover:text-fg">
|
||||
<summary className="tap inline-flex cursor-pointer items-center text-small text-muted hover:text-fg">
|
||||
Show these points as a table
|
||||
</summary>
|
||||
<table className="nums mt-1.5 w-full border-collapse font-mono text-xs">
|
||||
<tbody className="divide-y divide-border">
|
||||
{points.map((point) => (
|
||||
<tr key={String(point.x)}>
|
||||
<th scope="row" className="py-1 text-left font-normal text-muted">
|
||||
{String(point.x)}
|
||||
</th>
|
||||
<td className="py-1 text-right">{formatNumber(point.y, digits)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-1.5 overflow-hidden rounded-md border border-border">{table}</div>
|
||||
</details>
|
||||
</>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border">{table}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{caption ? (
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">{caption}</p>
|
||||
) : null}
|
||||
{caption ? <p className="mt-3 max-w-measure text-small text-muted">{caption}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { DASH, formatInt, formatMs, humaniseToken } from './format';
|
||||
|
||||
export interface ModelCallPanelProps {
|
||||
call: ModelCall | null;
|
||||
/** The model's visible reply for this step, verbatim. */
|
||||
reply?: string | null;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
@@ -23,60 +25,74 @@ interface 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".
|
||||
*/
|
||||
export function ModelCallPanel({ call, className, title = 'Model call' }: ModelCallPanelProps) {
|
||||
export function ModelCallPanel({
|
||||
call,
|
||||
reply = null,
|
||||
className,
|
||||
title = 'Model call',
|
||||
}: ModelCallPanelProps) {
|
||||
const rows: Row[] = [
|
||||
{
|
||||
label: 'finish_reason',
|
||||
value: call?.finishReason ? humaniseToken(call.finishReason) : DASH,
|
||||
hint: 'Why the model stopped generating',
|
||||
},
|
||||
{
|
||||
label: 'Prompt tokens',
|
||||
label: 'prompt tokens',
|
||||
value: formatInt(call?.promptTokens ?? null),
|
||||
hint: 'Everything sent in: system, board, history',
|
||||
},
|
||||
{
|
||||
label: 'Completion tokens',
|
||||
label: 'completion tokens',
|
||||
value: formatInt(call?.completionTokens ?? null),
|
||||
hint: 'The visible reply',
|
||||
},
|
||||
{
|
||||
label: 'Reasoning tokens',
|
||||
label: 'reasoning tokens',
|
||||
value: formatInt(call?.reasoningTokens ?? null),
|
||||
hint: 'Billed thinking, when the provider reports it',
|
||||
},
|
||||
{
|
||||
label: 'Latency',
|
||||
label: 'duration',
|
||||
value: formatMs(call?.durationMs ?? null),
|
||||
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 (
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<h3 className="font-mono text-label uppercase tracking-label text-muted">{title}</h3>
|
||||
</header>
|
||||
{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
|
||||
its own.
|
||||
</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) => (
|
||||
<div key={row.label} className="flex items-baseline gap-3 px-3 py-2">
|
||||
<dt className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{row.label}</span>
|
||||
{row.hint ? (
|
||||
<span className="block text-xs leading-snug text-muted">{row.hint}</span>
|
||||
) : null}
|
||||
<div key={row.label} className="col-span-2 grid grid-cols-subgrid items-baseline py-1">
|
||||
<dt className="text-label uppercase tracking-label text-muted" title={row.hint}>
|
||||
{row.label}
|
||||
</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>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from '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 { 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.
|
||||
@@ -20,10 +23,19 @@ import * as st from '@/content/styles';
|
||||
export function PlayYourself<TState>({
|
||||
demo,
|
||||
seed,
|
||||
afterMove,
|
||||
className,
|
||||
}: {
|
||||
demo: DemoModule<TState>;
|
||||
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;
|
||||
}) {
|
||||
const interactive = demo.interactive;
|
||||
@@ -42,38 +54,51 @@ export function PlayYourself<TState>({
|
||||
|
||||
if (!interactive || state === null) return null;
|
||||
const Controls = interactive.Controls;
|
||||
const moved = state !== initial;
|
||||
|
||||
return (
|
||||
// The SHELL does not decide how wide a demo's play surface is. A word grid
|
||||
// wants a narrow column with its keyboard beside it; an analyst screen full
|
||||
// of tables wants most of the page. So this is a generous ceiling and each
|
||||
// demo's Surface and Controls cap themselves — wordle's keyboard carries
|
||||
// its own max width for exactly that reason.
|
||||
<div className={cn('w-full max-w-4xl space-y-3', className)}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className={st.h3}>Your attempt</h3>
|
||||
<button
|
||||
<div className={cn('space-y-3', className)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label as="h2">Your attempt</Label>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* The contrast toggle sits here as well as in the header because
|
||||
this is where a colour-blind visitor is looking when they need it. */}
|
||||
<LabelledContrastToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="touch"
|
||||
className="text-muted hover:text-fg lg:min-h-9"
|
||||
onClick={() => {
|
||||
setState(interactive.init(seed));
|
||||
setGeneration((g) => g + 1);
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<RotateCcw aria-hidden="true" className="size-3.5" />
|
||||
<RotateCcw aria-hidden="true" />
|
||||
Start over
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card w-fit p-4">
|
||||
{/* The shell decides the CARD, the Surface decides its own width (§8):
|
||||
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 what is on the current row. */}
|
||||
{/* 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>
|
||||
|
||||
{moved && afterMove ? <div>{afterMove}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { External, Label } from './chrome';
|
||||
import { formatDate } from './format';
|
||||
|
||||
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
|
||||
* 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
|
||||
* 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) {
|
||||
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 }[] = [
|
||||
{ label: 'Environment package', value: provenance.envPackage, mono: true },
|
||||
{ label: 'Taskset', value: provenance.tasksetId, mono: true },
|
||||
{ label: 'verifiers version', value: provenance.verifiersVersion, mono: true },
|
||||
const rows: { label: string; value: string; sans?: boolean }[] = [
|
||||
{ label: 'Environment package', value: provenance.envPackage },
|
||||
{ label: 'Taskset', value: provenance.tasksetId },
|
||||
{ label: 'verifiers version', value: provenance.verifiersVersion },
|
||||
];
|
||||
if (run) {
|
||||
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: '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 (
|
||||
<section aria-label="Provenance" className={cn('card overflow-hidden', className)}>
|
||||
<header className="border-b border-border px-3 py-2">
|
||||
<h3 className="text-sm font-semibold">Provenance</h3>
|
||||
<section
|
||||
aria-label="Provenance"
|
||||
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>
|
||||
|
||||
<dl className="divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-baseline gap-3 px-3 py-2">
|
||||
<dt className="flex-1 text-sm text-muted">{row.label}</dt>
|
||||
<div
|
||||
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
|
||||
className={cn(
|
||||
'min-w-0 break-all text-right text-sm',
|
||||
row.mono && 'nums font-mono text-xs',
|
||||
'min-w-0 break-all sm:text-right',
|
||||
row.sans ? 'text-ui text-fg' : 'font-mono text-mono-data text-fg',
|
||||
)}
|
||||
>
|
||||
{row.value}
|
||||
@@ -74,12 +82,10 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
||||
))}
|
||||
</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">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Run it yourself
|
||||
</h4>
|
||||
<Button variant="outline" size="sm" className="tap" onClick={copy}>
|
||||
<Label as="h4">Run it yourself</Label>
|
||||
<Button variant="outline" size="touch" onClick={copy} className="lg:min-h-9">
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="text-positive" aria-hidden="true" />
|
||||
) : (
|
||||
@@ -88,10 +94,10 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
||||
{copyState === 'copied' ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</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>
|
||||
</pre>
|
||||
<p role="status" className="mt-1 text-xs text-muted">
|
||||
<p role="status" className="mt-1 text-small text-muted">
|
||||
{copyState === 'copied'
|
||||
? 'Command copied to your clipboard.'
|
||||
: copyState === 'failed'
|
||||
@@ -101,20 +107,18 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
||||
</div>
|
||||
|
||||
{provenance.credits.length > 0 ? (
|
||||
<div className="border-t border-border px-3 py-2.5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Built on
|
||||
</h4>
|
||||
<ul className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1">
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<Label as="h4">Credits</Label>
|
||||
<ul className="mt-1.5 flex flex-col gap-1">
|
||||
{provenance.credits.map((credit) => (
|
||||
<li key={credit.href}>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
{credit.label}
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
<External />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Brain, ChevronUp } from 'lucide-react';
|
||||
import { ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThinkingOffChip } from './chrome';
|
||||
import { ReasoningPanel } from './ReasoningPanel';
|
||||
import type { ReasoningPanelProps } from './ReasoningPanel';
|
||||
|
||||
@@ -53,30 +54,30 @@ export function ReasoningDrawer({
|
||||
>
|
||||
<DrawerTrigger
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<Brain className="size-4 text-muted" aria-hidden="true" />
|
||||
<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>
|
||||
{/*
|
||||
`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
|
||||
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">
|
||||
<DrawerTitle className="text-sm">{panel.title ?? 'Reasoning'}</DrawerTitle>
|
||||
<DrawerDescription className="text-xs">
|
||||
<DrawerTitle className="text-ui">{panel.title ?? 'Reasoning'}</DrawerTitle>
|
||||
<DrawerDescription className="text-small">
|
||||
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerBody className="overflow-hidden">
|
||||
{/* The panel keeps its own reserved height inside the sheet so the
|
||||
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>
|
||||
</DrawerContent>
|
||||
</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
|
||||
// ref on the viewport element, which the wrapper does not expose.
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import { Brain } from 'lucide-react';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -28,7 +28,7 @@ export interface ReasoningPanelProps {
|
||||
/**
|
||||
* 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
|
||||
* 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;
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
reasoning,
|
||||
durationMs,
|
||||
playing,
|
||||
speed,
|
||||
stepIndex,
|
||||
reservedLines = 10,
|
||||
reservedLines = 9,
|
||||
title = 'Reasoning',
|
||||
className,
|
||||
}: ReasoningPanelProps) {
|
||||
@@ -87,17 +94,28 @@ export function ReasoningPanel({
|
||||
}, [shouldStream, full, durationMs, speed, stepIndex]);
|
||||
|
||||
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 (
|
||||
<section
|
||||
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">
|
||||
<Brain className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<h3 className="font-mono text-label uppercase tracking-label text-muted">
|
||||
{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 ? (
|
||||
<span className="nums ml-auto text-xs text-muted">
|
||||
<span className="ml-auto font-mono text-caption text-muted">
|
||||
{visible}/{full.length}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -108,7 +126,7 @@ export function ReasoningPanel({
|
||||
className="min-h-0 flex-1"
|
||||
// Height, not min-height: the panel is the same size on every step,
|
||||
// including the ones with no reasoning at all.
|
||||
style={{ height: `${reservedLines * 1.45}rem` }}
|
||||
style={{ height }}
|
||||
>
|
||||
<ScrollArea.Viewport
|
||||
ref={viewportRef}
|
||||
@@ -118,18 +136,18 @@ export function ReasoningPanel({
|
||||
aria-live="off"
|
||||
>
|
||||
{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
|
||||
there is nothing to show here — which is different from the model having thought
|
||||
nothing.
|
||||
</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)}
|
||||
{streaming ? (
|
||||
<span
|
||||
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}
|
||||
</p>
|
||||
@@ -139,7 +157,7 @@ export function ReasoningPanel({
|
||||
orientation="vertical"
|
||||
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.Root>
|
||||
</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 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 { Chip, EditedChip } from './chrome';
|
||||
import { DASH, formatNumber, formatOrDash } from './format';
|
||||
import { EditedChip } from './StatStrip';
|
||||
|
||||
const ROLE_META: Record<RewardComponent['role'], { label: string; Icon: LucideIcon }> = {
|
||||
objective: { label: 'Objective', Icon: Target },
|
||||
counterweight: { label: 'Counterweight', Icon: Weight },
|
||||
gate: { label: 'Gate', Icon: Scale },
|
||||
type Role = RewardComponent['role'];
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
spec: RewardSpec;
|
||||
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
|
||||
* reward an opinion rather than a scoreboard. Everyone understands "pay for
|
||||
* 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.
|
||||
*
|
||||
* 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({
|
||||
spec,
|
||||
@@ -49,18 +72,18 @@ export function RewardBreakdown({
|
||||
const counterweights = rows.filter((row) => row.role === 'counterweight');
|
||||
|
||||
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">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<table className="w-full border-collapse">
|
||||
<caption className="sr-only">
|
||||
Reward components, their weights and their contribution to the total score
|
||||
</caption>
|
||||
<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">
|
||||
Component
|
||||
</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
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
@@ -73,56 +96,72 @@ export function RewardBreakdown({
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const role = ROLE_META[row.role];
|
||||
const isCounterweight = row.role === 'counterweight';
|
||||
return (
|
||||
<tr key={row.key} className={cn(isCounterweight && 'bg-accent-subtle/40')}>
|
||||
<th scope="row" className="max-w-[22rem] px-3 py-2.5 text-left font-normal">
|
||||
<tr key={row.key} className={cn(isCounterweight && 'bg-accent-subtle')}>
|
||||
<th scope="row" className="max-w-[24rem] px-3 py-2.5 text-left font-normal align-top">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-fg">{row.label}</span>
|
||||
<Badge variant={isCounterweight ? 'default' : 'muted'}>
|
||||
<role.Icon className="size-3" aria-hidden="true" />
|
||||
{role.label}
|
||||
</Badge>
|
||||
<span className="text-ui font-medium text-fg">{row.label}</span>
|
||||
<RoleChip role={row.role} />
|
||||
</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">
|
||||
<span className="mt-0.5 block text-small text-fg-2">{row.description}</span>
|
||||
<span className="mt-1 flex flex-wrap items-baseline gap-x-3 font-mono text-label text-muted">
|
||||
<span>{row.key}</span>
|
||||
<span className="sm:hidden">
|
||||
<span className="uppercase tracking-label">score</span>{' '}
|
||||
<span className="text-mono-data text-fg">
|
||||
{row.score === null ? (
|
||||
<span className="text-muted" title="The environment did not score this run">
|
||||
not scored
|
||||
<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="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
<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="nums px-3 py-2.5 text-right align-top font-mono font-semibold">
|
||||
{row.value === null ? DASH : formatNumber(row.value)}
|
||||
<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 bg-surface-2">
|
||||
<th scope="row" className="px-3 py-2.5 text-left">
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
<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 colSpan={2} />
|
||||
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
||||
<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="text-muted">not scored</span>
|
||||
<span className="font-normal text-muted" title="not scored">
|
||||
{DASH}
|
||||
</span>
|
||||
) : (
|
||||
formatOrDash(total)
|
||||
)}
|
||||
@@ -133,28 +172,29 @@ export function RewardBreakdown({
|
||||
</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">
|
||||
<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 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.
|
||||
{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="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
<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-4 gap-y-1 sm:grid-cols-2">
|
||||
<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-xs text-muted" title={metric.description}>
|
||||
{metric.label}
|
||||
<dt className="text-small text-muted" title={metric.description}>
|
||||
{metric.label}{' '}
|
||||
<span className="font-mono text-label">{metric.key}</span>
|
||||
</dt>
|
||||
<dd className="nums font-mono text-xs">
|
||||
<dd className="font-mono text-mono-data text-fg">
|
||||
{formatOrDash(metrics?.[metric.key] ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatNumber, formatOrDash } from './format';
|
||||
import { EditedChip } from './StatStrip';
|
||||
import { EditedChip, Label } from './chrome';
|
||||
import { DASH, formatNumber, formatOrDash } from './format';
|
||||
|
||||
/** One recorded arm — a run, or a group of runs already reduced to one score. */
|
||||
export interface RewardArm {
|
||||
@@ -43,6 +43,19 @@ export interface RewardEditorProps {
|
||||
|
||||
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
|
||||
* 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
|
||||
* over these exact attempts; it cannot show the different attempts a model
|
||||
* 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
|
||||
* dragging one slider up trades weight away from the others instead of lifting
|
||||
@@ -119,6 +133,7 @@ export function RewardEditor({
|
||||
className,
|
||||
}: RewardEditorProps) {
|
||||
const [overrides, setOverrides] = useState<WeightOverrides>({});
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
|
||||
|
||||
const edited = isEdited(overrides, spec.components);
|
||||
@@ -156,9 +171,13 @@ export function RewardEditor({
|
||||
const shippedOrder = [...rows]
|
||||
.sort((a, b) => byTotal({ total: a.shippedTotal }, { total: b.shippedTotal }))
|
||||
.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,
|
||||
rank: index + 1,
|
||||
rank: order.indexOf(row.arm.id) + 1,
|
||||
shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
|
||||
}));
|
||||
}, [arms, inForce, shippedNormalised, degenerate]);
|
||||
@@ -171,13 +190,20 @@ export function RewardEditor({
|
||||
return { lo, hi: hi === lo ? lo + 1 : hi };
|
||||
}, [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 (
|
||||
<div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2', className)}>
|
||||
<section aria-label="Reward weights" className="card p-3">
|
||||
<div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start', className)}>
|
||||
<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">
|
||||
<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}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -191,18 +217,21 @@ export function RewardEditor({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{effectivePresets.map((preset) => {
|
||||
const active = sameWeights(spec, overrides, preset);
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
variant={active ? 'subtle' : 'outline'}
|
||||
variant="outline"
|
||||
size="touch"
|
||||
aria-pressed={active}
|
||||
title={preset.description ?? preset.label}
|
||||
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}
|
||||
</Button>
|
||||
@@ -221,22 +250,20 @@ export function RewardEditor({
|
||||
{/* A <label htmlFor> would point at Radix's root <span>, which
|
||||
is not a labelable element — the association would silently
|
||||
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}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'nums font-mono text-sm',
|
||||
changed ? 'font-semibold text-accent-fg' : 'text-muted',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-mono-data">
|
||||
<span className={cn(changed ? 'font-semibold text-accent-fg' : 'text-fg')}>
|
||||
{formatNumber(raw, 2)}
|
||||
<span className="ml-1.5 text-xs font-normal text-muted">
|
||||
{degenerate ? '—' : `${Math.round(share * 100)}%`}
|
||||
</span>
|
||||
<span className="ml-1.5 text-caption text-muted" title="Share of the normalised reward">
|
||||
{degenerate ? DASH : `${Math.round(share * 100)}%`}
|
||||
</span>
|
||||
</span>
|
||||
</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
|
||||
min={0}
|
||||
max={max}
|
||||
@@ -252,27 +279,36 @@ export function RewardEditor({
|
||||
})}
|
||||
</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
|
||||
normalised. Raising one lowers the others — that is the trade a reward designer
|
||||
actually makes.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section aria-label="Ranking under this reward" className="card flex flex-col p-3">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
<section
|
||||
aria-label="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}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{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
|
||||
nought — it is a reward that expresses no preference at all.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-3 space-y-2">
|
||||
{ranked.map((row) => {
|
||||
<ol
|
||||
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 width =
|
||||
row.total === null
|
||||
@@ -281,37 +317,66 @@ export function RewardEditor({
|
||||
return (
|
||||
<li
|
||||
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(
|
||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
||||
'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-accent-fg bg-accent-subtle' : 'border-border bg-surface',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="nums text-sm font-semibold text-muted">{row.rank}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
<span className="font-mono text-label text-muted">
|
||||
{String(row.rank).padStart(2, '0')}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-ui font-medium text-fg">
|
||||
{row.arm.label}
|
||||
</span>
|
||||
<RankMove moved={moved} />
|
||||
<span className="nums font-mono text-sm font-semibold">
|
||||
{formatOrDash(row.total)}
|
||||
</span>
|
||||
<span className="font-mono text-stat text-fg">{formatOrDash(row.total)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
||||
className="h-1.5 flex-1 overflow-hidden rounded-sm bg-surface-3"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
||||
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 ? <p className="mt-1 text-xs text-muted">{row.arm.note}</p> : null}
|
||||
{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>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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
|
||||
region: a slider drag and a step advance never fire from one action. */}
|
||||
<p role="status" className="sr-only">
|
||||
@@ -320,7 +385,7 @@ export function RewardEditor({
|
||||
: ''}
|
||||
</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
|
||||
the behaviour, not just the ranking.
|
||||
</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 }) {
|
||||
if (moved === 0) {
|
||||
return (
|
||||
@@ -342,7 +408,12 @@ function RankMove({ moved }: { moved: number }) {
|
||||
const places = Math.abs(moved);
|
||||
return (
|
||||
<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`}
|
||||
>
|
||||
{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.
|
||||
* 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.
|
||||
*
|
||||
* 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>({
|
||||
label,
|
||||
@@ -78,7 +82,10 @@ export function SegmentedControl<T extends string>({
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
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) => {
|
||||
const selected = option.value === value;
|
||||
@@ -95,8 +102,11 @@ export function SegmentedControl<T extends string>({
|
||||
}}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
|
||||
selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
|
||||
// `min-w-11` as well as `min-h-11`: a two-glyph option ("#1",
|
||||
// "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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
||||
|
||||
export interface Stat {
|
||||
/** Short. Two or three words; it sits beside the number. */
|
||||
/** Short. One or two words; it sits beside the number as a mono key. */
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,6 +16,7 @@ const TONE: Record<StatTone, string> = {
|
||||
default: 'text-fg',
|
||||
positive: 'text-positive',
|
||||
warning: 'text-warning',
|
||||
// `danger` on surface-2 is 4.70 in dark — the last ground it is allowed on.
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
brand: 'text-accent-fg',
|
||||
@@ -25,23 +25,17 @@ const TONE: Record<StatTone, string> = {
|
||||
/**
|
||||
* 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
|
||||
* page and the wrong thing in a header: four stat cards push the interactive
|
||||
* board below the fold on a 390px phone, and the board arriving above the fold
|
||||
* is what the whole page is now organised around. So the cards are gone and
|
||||
* 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.
|
||||
* One surface-2 chip, keys mono 11 and values mono 13/600. It scrolls itself
|
||||
* rather than wrapping: every row this header spends is a row of the board
|
||||
* pushed below the fold on a 390px phone, and the strip is one of the five
|
||||
* horizontal scrollers §11 permits.
|
||||
*/
|
||||
export function StatStrip({ stats, className }: { stats: Stat[]; className?: string }) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<dl
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -51,25 +45,14 @@ export function StatStrip({ stats, className }: { stats: Stat[]; className?: str
|
||||
className="flex shrink-0 items-baseline gap-1.5"
|
||||
{...(stat.title ? { title: stat.title } : {})}
|
||||
>
|
||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted">{stat.label}</dt>
|
||||
<dd className={cn('nums text-sm font-semibold', TONE[stat.tone ?? 'default'])}>
|
||||
{stat.value}
|
||||
</dd>
|
||||
<dt className="font-mono text-label uppercase tracking-label text-muted">{stat.label}</dt>
|
||||
<dd className={cn('font-mono text-stat', TONE[stat.tone ?? 'default'])}>{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a number the visitor caused rather than one we recorded. It appears on
|
||||
* every derived value in the reward editor; without it, an edited ranking
|
||||
* screenshots identically to a measured one.
|
||||
*/
|
||||
export function EditedChip({ className }: { className?: string }) {
|
||||
return (
|
||||
<Badge className={cn('px-1.5 py-0 text-[10px] uppercase tracking-wide', className)}>
|
||||
edited
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
// Kept as a re-export so nothing that imported the chip from here breaks; the
|
||||
// chip itself lives with the other chips in chrome.tsx.
|
||||
export { EditedChip } from './chrome';
|
||||
|
||||
@@ -97,7 +97,9 @@ export function StepTimeline<T>({
|
||||
aria-label="Steps in the recorded run"
|
||||
aria-keyshortcuts="ArrowLeft ArrowRight Home End Space"
|
||||
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) => {
|
||||
const selected = index === current;
|
||||
@@ -118,17 +120,22 @@ export function StepTimeline<T>({
|
||||
}}
|
||||
onClick={() => onSelect(index)}
|
||||
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
|
||||
? 'border-brand bg-accent-subtle/60'
|
||||
? 'border-accent-edge bg-accent-subtle'
|
||||
: 'border-border bg-surface hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<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 ? (
|
||||
<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}
|
||||
>
|
||||
{humaniseToken(call.finishReason)}
|
||||
@@ -143,12 +150,14 @@ export function StepTimeline<T>({
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</span>
|
||||
) : 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">
|
||||
{call?.completionTokens === null || call?.completionTokens === undefined
|
||||
? 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 type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Chip, InterventionChip } from './chrome';
|
||||
import { formatDate } from './format';
|
||||
import { SegmentedControl } from './SegmentedControl';
|
||||
|
||||
/** `PlaybackSpeed` is `1 | 2 | 4 | 'instant'`; the control speaks strings. */
|
||||
const SPEED_OPTIONS = PLAYBACK_SPEEDS.map((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`,
|
||||
}));
|
||||
|
||||
@@ -50,6 +51,9 @@ export interface TracePlayerProps {
|
||||
* 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
|
||||
* 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({
|
||||
playing,
|
||||
@@ -70,7 +74,7 @@ export function TracePlayer({
|
||||
const canPlay = stepCount > 1;
|
||||
|
||||
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 items-center gap-1">
|
||||
<Button
|
||||
@@ -83,7 +87,7 @@ export function TracePlayer({
|
||||
<ChevronLeft aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
size="touch"
|
||||
size="icon-touch"
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
disabled={!canPlay}
|
||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||
@@ -110,8 +114,11 @@ export function TracePlayer({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="nums text-sm text-muted">
|
||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
||||
<p className="font-mono text-mono-data text-muted">
|
||||
<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}
|
||||
</p>
|
||||
|
||||
@@ -120,6 +127,7 @@ export function TracePlayer({
|
||||
options={SPEED_OPTIONS}
|
||||
value={String(speed)}
|
||||
onChange={(next) => onSpeedChange(toSpeed(next))}
|
||||
optionClassName="px-2.5 font-mono text-caption tracking-label"
|
||||
/>
|
||||
|
||||
<RecordedBadge
|
||||
@@ -127,6 +135,7 @@ export function TracePlayer({
|
||||
capturedAt={capturedAt}
|
||||
{...(intervention ? { intervention } : {})}
|
||||
{...(timingIsReal ? {} : { timingNote: 'pacing approximate' })}
|
||||
className="ml-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,10 +144,12 @@ export function TracePlayer({
|
||||
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 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
|
||||
className="h-full bg-brand transition-[width] duration-1 ease-enter"
|
||||
className="h-full bg-tab-underline transition-[width] duration-1 ease-enter"
|
||||
style={{
|
||||
width: `${
|
||||
stepCount <= 1 ? 0 : ((step + Math.min(Math.max(progress, 0), 1)) / (stepCount - 1)) * 100
|
||||
@@ -159,6 +170,12 @@ export interface RecordedBadgeProps {
|
||||
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({
|
||||
model,
|
||||
capturedAt,
|
||||
@@ -169,24 +186,18 @@ export function RecordedBadge({
|
||||
return (
|
||||
<p
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<Circle className="size-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
||||
<span className="font-medium text-fg">Recorded run</span>
|
||||
<span aria-hidden="true" className="size-2 shrink-0 rounded-full bg-border-strong" />
|
||||
<span className="font-medium uppercase tracking-label text-fg">Recorded run</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 className="nums">{formatDate(capturedAt)}</span>
|
||||
{intervention ? (
|
||||
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
|
||||
{intervention}
|
||||
</span>
|
||||
) : null}
|
||||
{timingNote ? (
|
||||
<span className="rounded-md border border-border px-1.5 py-0.5">{timingNote}</span>
|
||||
) : null}
|
||||
<span>{formatDate(capturedAt)}</span>
|
||||
{intervention ? <InterventionChip>{intervention}</InterventionChip> : null}
|
||||
{timingNote ? <Chip variant="muted">{timingNote}</Chip> : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 type { AnyDemoModule } from '@/lib/demo-kit/registry';
|
||||
import type { DemoEpisode } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Chip } from './chrome';
|
||||
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
||||
|
||||
export interface VerifyBadgeProps {
|
||||
@@ -27,7 +28,7 @@ export interface VerifyBadgeProps {
|
||||
* 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
|
||||
* `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) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -37,22 +38,23 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
||||
return (
|
||||
<section
|
||||
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">
|
||||
<HelpCircle className="mt-0.5 size-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="font-semibold">Unverifiable in your browser.</span>{' '}
|
||||
<span className="text-muted">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-5 shrink-0 place-items-center rounded-sm border border-border-strong font-mono text-caption text-muted"
|
||||
>
|
||||
?
|
||||
</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.'}
|
||||
</span>
|
||||
</span>
|
||||
</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 className="mt-2 font-mono text-caption text-muted">
|
||||
recomputed {DASH} · recorded {formatOrDash(result.recorded)} · Δ {DASH}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -62,40 +64,37 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
||||
return (
|
||||
<section
|
||||
aria-label="Verification"
|
||||
className={cn('card overflow-hidden', matched ? 'border-positive/40' : 'border-danger', className)}
|
||||
>
|
||||
<div className={cn('p-3', matched ? 'bg-positive/10' : 'bg-danger/10')}>
|
||||
<p className="flex items-start gap-2 text-sm leading-relaxed">
|
||||
{matched ? (
|
||||
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-positive" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 size-4 shrink-0 text-danger" aria-hidden="true" />
|
||||
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,
|
||||
)}
|
||||
<span>
|
||||
>
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{matched ? (
|
||||
<>
|
||||
<span className="font-semibold text-positive">Verified in your browser</span>{' '}
|
||||
<span className="text-fg">
|
||||
— re-ran every move through the TypeScript engine and rescored.
|
||||
</span>
|
||||
</>
|
||||
<Chip variant="positive" className="border-accent-fg">
|
||||
✓ Verified in your browser
|
||||
</Chip>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-danger">
|
||||
<Chip variant="danger" className="border-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>
|
||||
</>
|
||||
</Chip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 max-w-measure text-prose text-fg-2">
|
||||
{matched
|
||||
? '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.'
|
||||
: 'The browser re-run disagrees with the published number. Trust the source in the repository, not this page.'}
|
||||
</p>
|
||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
||||
Recomputed {formatOrDash(result.recomputed)} · recorded {formatOrDash(result.recorded)} ·
|
||||
<p className={cn('mt-2 font-mono text-caption', matched ? 'text-fg-2' : 'text-danger')}>
|
||||
recomputed {formatOrDash(result.recomputed)} · recorded {formatOrDash(result.recorded)} ·
|
||||
Δ {result.delta === null ? DASH : formatDelta(result.delta)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -104,21 +103,21 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
||||
type="button"
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
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
|
||||
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"
|
||||
/>
|
||||
{open ? 'Hide the component-by-component receipt' : 'Show the component-by-component receipt'}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-border">
|
||||
<div className="border-t border-border bg-surface">
|
||||
<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>
|
||||
<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">
|
||||
Component
|
||||
</th>
|
||||
@@ -137,14 +136,14 @@ export function VerifyBadge({ demo, episode, className, defaultOpen = false }: V
|
||||
{result.components.map((row) => {
|
||||
const bad = row.delta !== null && Math.abs(row.delta) > VERIFY_TOLERANCE;
|
||||
return (
|
||||
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
||||
<th scope="row" className="whitespace-nowrap px-3 py-1.5 text-left font-normal">
|
||||
<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 text-fg">
|
||||
{row.label}
|
||||
</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)}
|
||||
</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)}
|
||||
</td>
|
||||
<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>
|
||||
</table>
|
||||
</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
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* The header renders this control twice — once in the desktop bar, once inside
|
||||
* the mobile sheet — and only one of them is ever visible. With local state the
|
||||
* hidden one keeps a stale `aria-pressed`, so a visitor who toggles on a phone
|
||||
* and then rotates into the desktop layout is told the setting is off while the
|
||||
* page is plainly showing it on.
|
||||
* This control renders in three places — the desktop bar, the mobile sheet and
|
||||
* 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 the
|
||||
* board and then opens the menu is told the setting is off while the page is
|
||||
* plainly showing it on.
|
||||
*/
|
||||
let high = 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
|
||||
// preference it cannot know; the real value lands on the first subscribe.
|
||||
const isHigh = React.useSyncExternalStore(
|
||||
return React.useSyncExternalStore(
|
||||
subscribe,
|
||||
() => high,
|
||||
() => 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 (
|
||||
<Button
|
||||
@@ -85,7 +128,7 @@ export function ContrastToggle({ className }: { className?: string }) {
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', isHigh && 'bg-accent-subtle text-accent-fg', className)}
|
||||
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"
|
||||
onClick={() => setHigh(!isHigh)}
|
||||
>
|
||||
@@ -93,3 +136,8 @@ export function ContrastToggle({ className }: { className?: string }) {
|
||||
</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
|
||||
* twice. The list is built at module scope from the registry and the vertical
|
||||
* lineup — creating `src/demos/<slug>/` adds a card with no edit to this file,
|
||||
* which is the same discovery-by-existence property the registry itself has.
|
||||
* The list is built at module scope from the registry and the vertical lineup —
|
||||
* creating `src/demos/<slug>/` adds an entry with no edit to this file, which is
|
||||
* the same discovery-by-existence property the registry itself has. The header
|
||||
* 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
|
||||
* rather than built, and a wall of identical tiles would imply a wall of
|
||||
* working demos on a site whose entire argument is that its claims are
|
||||
* checkable. So a built environment gets its own board, its own block and the
|
||||
* only filled call to action, and the written ones read as a published roadmap
|
||||
* underneath it: dimmed, badged, and each linking to a real specification
|
||||
* rather than to nothing. Both pages count them from the data, never from a
|
||||
* number typed into the copy.
|
||||
* Three shapes:
|
||||
* - `FeaturedEnvironment`: a built environment, with its own board and the
|
||||
* mini tab strip. The only filled call to action on the landing.
|
||||
* - `EnvironmentRow`: one line of the list. Built and written environments
|
||||
* share it; the pill and the absent board are the whole difference. There is
|
||||
* NO opacity dimming — a spec row links to a real specification and is
|
||||
* told apart by token, not by fading.
|
||||
* - `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 { 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 { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { lineup, routes } from '@/content/lineup';
|
||||
import * as s from '@/content/styles';
|
||||
import { listRuns } from '@/lib/demo-kit/episode';
|
||||
import {
|
||||
listDemos,
|
||||
listVerticals,
|
||||
@@ -32,10 +36,10 @@ import {
|
||||
VERTICAL_LABELS,
|
||||
VERTICAL_ORDER,
|
||||
} 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';
|
||||
|
||||
/** 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 {
|
||||
/** React key. Namespaced, because a demo and a vertical can share a slug. */
|
||||
id: string;
|
||||
@@ -56,12 +60,17 @@ export interface EnvironmentEntry {
|
||||
verticalKey: Vertical | null;
|
||||
/** Set when the entry is a built demo; drives the board thumbnail. */
|
||||
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;
|
||||
/**
|
||||
* 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
|
||||
* `tagline` field to the data is what stops a specification and its summary
|
||||
* 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 first = match?.[0]?.trim();
|
||||
// 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;
|
||||
}
|
||||
|
||||
/** 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 demoEntries: readonly EnvironmentEntry[] = listDemos().map((meta) => ({
|
||||
const demoEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = listDemos().map((meta) => ({
|
||||
id: `demo:${meta.slug}`,
|
||||
title: meta.title,
|
||||
tagline: meta.tagline,
|
||||
@@ -100,7 +109,7 @@ const demoEntries: readonly EnvironmentEntry[] = listDemos().map((meta) => ({
|
||||
note: null,
|
||||
}));
|
||||
|
||||
const proposalEntries: readonly EnvironmentEntry[] = lineup
|
||||
const proposalEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = lineup
|
||||
.filter((vertical) => vertical.key === null || !builtVerticals.has(vertical.key))
|
||||
.map((vertical) => ({
|
||||
id: `vertical:${vertical.slug}`,
|
||||
@@ -111,7 +120,7 @@ const proposalEntries: readonly EnvironmentEntry[] = lineup
|
||||
icon: vertical.icon,
|
||||
status: 'spec' as const,
|
||||
// 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),
|
||||
verticalLabel: vertical.key === null ? null : VERTICAL_LABELS[vertical.key],
|
||||
verticalKey: vertical.key,
|
||||
@@ -119,13 +128,21 @@ const proposalEntries: readonly EnvironmentEntry[] = lineup
|
||||
note: vertical.plannedForV1 ? null : 'Not in the first set',
|
||||
}));
|
||||
|
||||
/** Everything, built first. `lineup` is already ranked, so proposals keep that order. */
|
||||
export const environments: readonly EnvironmentEntry[] = [
|
||||
const unranked: readonly Omit<EnvironmentEntry, 'rank'>[] = [
|
||||
...demoEntries.filter((entry) => entry.status === 'live'),
|
||||
...demoEntries.filter((entry) => entry.status === 'spec'),
|
||||
...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. */
|
||||
export const builtEnvironments: readonly EnvironmentEntry[] = environments.filter(
|
||||
(entry) => entry.status === 'live',
|
||||
@@ -148,122 +165,66 @@ export const filterKeys: readonly Vertical[] = VERTICAL_ORDER.filter((key) =>
|
||||
environments.some((entry) => entry.verticalKey === key),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ cards */
|
||||
/* ------------------------------------------------------------------ atoms */
|
||||
|
||||
function StatusBadge({ status }: { status: DemoStatus }) {
|
||||
if (status === 'live') {
|
||||
return (
|
||||
<Badge variant="positive" className="uppercase tracking-wide">
|
||||
/**
|
||||
* LIVE / SPEC. The one status pill on the site, shared with the header so a
|
||||
* visitor learns the shape once. Filled green means playable now; an outline
|
||||
* means a specification you can read. Nothing is dimmed to say "not yet".
|
||||
*/
|
||||
export function StatusPill({ status, className }: { status: DemoStatus; className?: string }) {
|
||||
return status === 'live' ? (
|
||||
<Badge variant="solid" className={cn('rounded-full', className)}>
|
||||
Live
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="uppercase tracking-wide">
|
||||
<FileText aria-hidden="true" className="size-3.5" />
|
||||
) : (
|
||||
<Badge variant="outline" className={cn('rounded-full', className)}>
|
||||
Spec
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The two lines an executive actually scans for: whose budget this is, and
|
||||
* 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 }) {
|
||||
/** Mono key / sans value, the two lines an executive scans for. */
|
||||
function MetaRows({ entry, className }: { entry: EnvironmentEntry; className?: string }) {
|
||||
return (
|
||||
<dl className="mt-4 space-y-1.5 text-sm">
|
||||
<dl className={cn('space-y-1.5', className)}>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0 text-muted">For</dt>
|
||||
<dd className="min-w-0 text-fg">{entry.persona}</dd>
|
||||
<dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
|
||||
For
|
||||
</dt>
|
||||
<dd className="min-w-0 text-ui text-fg">{entry.persona}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="w-16 shrink-0 text-muted">Reward</dt>
|
||||
<dd className="min-w-0 text-fg">{entry.rewardLine}</dd>
|
||||
<dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
|
||||
Reward
|
||||
</dt>
|
||||
<dd className="min-w-0 text-ui text-fg">{entry.rewardLine}</dd>
|
||||
</div>
|
||||
</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
|
||||
* interactive elements to trap a keyboard in.
|
||||
* `tabs` is derived through the shell's own `visibleTabs`, never declared:
|
||||
* `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({
|
||||
entry,
|
||||
className,
|
||||
}: {
|
||||
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>
|
||||
);
|
||||
interface DemoShape {
|
||||
board: ReactNode;
|
||||
tabs: readonly DemoTabId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
function BoardThumbnail({ slug }: { slug: string }) {
|
||||
const [board, setBoard] = useState<ReactNode>(null);
|
||||
function useDemoShape(slug: string): DemoShape | null {
|
||||
const [shape, setShape] = useState<DemoShape | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
* arriving is proof the destination is already loaded.
|
||||
*/
|
||||
void loadDemoModule(slug)
|
||||
.then((module) => {
|
||||
if (!live || !module.interactive) return;
|
||||
void Promise.all([loadDemoModule(slug), listRuns(slug).catch(() => [])])
|
||||
.then(([module, runs]) => {
|
||||
if (!live) return;
|
||||
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(() => {
|
||||
// A card without a board is still a complete card. A demo whose chunk
|
||||
@@ -288,16 +255,53 @@ function BoardThumbnail({ slug }: { slug: string }) {
|
||||
};
|
||||
}, [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 (
|
||||
// Decorative: the title, the tagline and the reward already say everything
|
||||
// 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">
|
||||
{board ?? <Skeleton className="aspect-[5/6] w-full" />}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -305,9 +309,10 @@ function BoardThumbnail({ slug }: { slug: string }) {
|
||||
/**
|
||||
* 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
|
||||
* action on the page. If a second demo ever ships, this renders for that one
|
||||
* too and the picker stays honest without an edit.
|
||||
* Whole card a link — one target, one focus ring, no nested controls. The copy
|
||||
* is FIRST in the source; the board sits to the right from `lg` and follows
|
||||
* 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({
|
||||
entry,
|
||||
@@ -316,64 +321,195 @@ export function FeaturedEnvironment({
|
||||
entry: EnvironmentEntry;
|
||||
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 (
|
||||
<Link
|
||||
aria-label={`${entry.title} — play it`}
|
||||
className={cn(s.cardLink, 'p-5 sm:p-7', className)}
|
||||
aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
|
||||
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}
|
||||
>
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] lg:gap-10">
|
||||
{/*
|
||||
The copy is FIRST in the source and the board is pulled left only from
|
||||
`lg`. On a phone a full-width board above the title pushes the name of
|
||||
the environment and its call to action below the fold, which is the
|
||||
one thing this card exists to avoid.
|
||||
*/}
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-5',
|
||||
withBoard && 'lg:grid-cols-[minmax(0,1fr)_minmax(0,17rem)] lg:gap-8',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={entry.status} />
|
||||
<StatusPill status={entry.status} />
|
||||
{entry.verticalLabel === null ? null : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted">
|
||||
<DemoIcon className="size-3.5" name={entry.icon} />
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-label uppercase tracking-label text-muted">
|
||||
<DemoIcon className="size-3.5 text-border-strong" name={entry.icon} />
|
||||
{entry.verticalLabel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<h3 className="mt-3 text-2xl font-bold tracking-tight text-fg sm:text-3xl">
|
||||
{entry.title}
|
||||
</h3>
|
||||
<p className={`${s.lede} mt-2 text-base sm:text-lg`}>{entry.tagline}</p>
|
||||
<h3 className="mt-3 text-h2 text-fg">{entry.title}</h3>
|
||||
<p className="mt-2 max-w-measure text-prose text-fg-2">{entry.tagline}</p>
|
||||
|
||||
<MetaRows entry={entry} />
|
||||
<MetaRows entry={entry} className="mt-4" />
|
||||
|
||||
<p className={`${s.prose} mt-4 text-sm`}>
|
||||
Four tabs. Play the board, watch a recorded model play the same board, move the reward
|
||||
weights and see the ranking change, then read the Python that scored it.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<MiniTabStrip tabs={shape?.tabs ?? (withBoard ? null : [])} />
|
||||
</div>
|
||||
|
||||
<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
|
||||
whole card is already the one link, and a control inside a link
|
||||
is a keyboard trap dressed up as a call to action. */}
|
||||
<span aria-hidden="true" className={s.btnPrimary}>
|
||||
<Play className="size-4" />
|
||||
Play it
|
||||
<span
|
||||
aria-hidden="true"
|
||||
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 className="text-xs text-muted">Opens on the board. No sign-up, no sales call.</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{entry.slug === null ? null : (
|
||||
<div className="max-w-[17rem] lg:order-first">
|
||||
<BoardThumbnail slug={entry.slug} />
|
||||
{withBoard ? (
|
||||
<div className="mx-auto w-full max-w-[17rem] lg:mx-0">
|
||||
<BoardThumbnail board={shape?.board ?? null} />
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</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 */
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* Vertical chips. Wraps on a phone rather than scrolling sideways — a chip
|
||||
* hidden off the right edge is a filter nobody knows exists.
|
||||
* Vertical chips for `/gallery?vertical=`. Wraps on a phone rather than
|
||||
* 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({
|
||||
active,
|
||||
@@ -404,10 +541,12 @@ export function VerticalFilter({
|
||||
<button
|
||||
aria-pressed={selected}
|
||||
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
|
||||
? 'border-brand bg-accent-subtle text-accent-fg'
|
||||
: 'border-border bg-surface text-muted hover:bg-surface-2 hover:text-fg',
|
||||
? 'border-accent-edge bg-accent-subtle text-accent-fg'
|
||||
: 'border-border-strong bg-surface text-fg hover:bg-surface-2',
|
||||
)}
|
||||
onClick={() => onSelect(key)}
|
||||
type="button"
|
||||
@@ -420,22 +559,3 @@ export function VerticalFilter({
|
||||
</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 { GitBranch } from 'lucide-react';
|
||||
import { ArrowUpRight, GitBranch } from 'lucide-react';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PIG_URL, REPO_URL, repoFile } from '@/components/site/links';
|
||||
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() {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-border bg-surface">
|
||||
<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">
|
||||
<footer className="mt-12 border-t border-border bg-surface sm:mt-16">
|
||||
<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-2">
|
||||
<Wordmark className="text-base" />
|
||||
<p className="max-w-sm text-sm text-muted">
|
||||
<Link to="/" className="tap inline-flex w-fit items-center rounded-md" aria-label="PIG demo, home">
|
||||
<Wordmark />
|
||||
</Link>
|
||||
<p className="max-w-[38ch] text-small text-muted">
|
||||
An environment is an eval you can take the gradient of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer" className="flex flex-col gap-1 text-sm">
|
||||
<a
|
||||
className="tap inline-flex items-center gap-2 py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<GitBranch aria-hidden="true" className="size-4" />
|
||||
<nav aria-label="Footer" className="flex flex-col gap-1">
|
||||
<a className={navLink} href={REPO_URL} target="_blank" rel="noreferrer noopener">
|
||||
<GitBranch aria-hidden="true" className="size-4 text-border-strong" />
|
||||
Source on Gitea
|
||||
</a>
|
||||
<Link
|
||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
to="/honesty"
|
||||
>
|
||||
<Link className={navLink} to="/honesty">
|
||||
What we are not claiming
|
||||
</Link>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<a className={navLink} href={PIG_URL} target="_blank" rel="noreferrer noopener">
|
||||
primeintellectgrowth.com
|
||||
<ArrowUpRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex flex-col gap-2 text-xs leading-relaxed text-muted">
|
||||
<p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 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
|
||||
recorded rollouts and the command that produced them all ship with the source.
|
||||
</p>
|
||||
<p>
|
||||
<p className="font-mono text-label normal-case tracking-normal text-muted">
|
||||
Licensed{' '}
|
||||
<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')}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
@@ -64,12 +69,12 @@ export function SiteFooter() {
|
||||
.
|
||||
</p>
|
||||
{/*
|
||||
Stated plainly and kept in the footer of every page. The word game
|
||||
demo is an independent implementation of a public game mechanic; the
|
||||
trademark belongs to someone else and this site must never read as
|
||||
if it were theirs.
|
||||
Stated plainly and kept in the footer of every page, verbatim. The
|
||||
word game demo is an independent implementation of a public game
|
||||
mechanic; the trademark belongs to someone else and this site must
|
||||
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
|
||||
their trademark.
|
||||
</p>
|
||||
|
||||
@@ -2,10 +2,12 @@ import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ArrowUpRight, GitBranch, Menu } from 'lucide-react';
|
||||
|
||||
import { listDemos } from '@/lib/demo-kit/registry';
|
||||
import type { DemoMeta } from '@/lib/demo-kit/types';
|
||||
import { lineup, routes } from '@/content/lineup';
|
||||
import type { VerticalEntry } from '@/content/verticals';
|
||||
import { VERTICAL_LABELS, VERTICAL_ORDER } from '@/lib/demo-kit/registry';
|
||||
import type { Vertical } from '@/lib/demo-kit/types';
|
||||
import { routes } from '@/content/lineup';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -36,6 +38,8 @@ import {
|
||||
} from '@/components/ui/sheet';
|
||||
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
||||
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 { ThemeToggle } from '@/components/site/ThemeToggle';
|
||||
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.
|
||||
*
|
||||
* Adding `src/demos/<slug>/` puts that demo in the Demos panel, in its
|
||||
* vertical, and (if it is the first live one) behind the CTA, with zero edits
|
||||
* to this file. The only hand-written lists here are the five concepts under
|
||||
* "How it works", which are properties of the idea rather than of the lineup.
|
||||
* The Environments panel is the whole catalogue — the same `environments`
|
||||
* array the landing and the gallery render, so a demo that exists is in the
|
||||
* menu, in its vertical, and (if it is the first live one) behind the CTA,
|
||||
* 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]):
|
||||
pass`;
|
||||
|
||||
const CONCEPTS: readonly { term: string; gloss: string }[] = [
|
||||
{
|
||||
term: 'Environment',
|
||||
gloss: 'The task, the legal moves and the grader, packaged so anyone can install and run it.',
|
||||
},
|
||||
{
|
||||
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.',
|
||||
},
|
||||
];
|
||||
/* -------------------------------------------------------------- catalogue */
|
||||
|
||||
interface Group {
|
||||
key: Vertical | null;
|
||||
label: string;
|
||||
entries: EnvironmentEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's Verticals panel shows the LINEUP, not the registry's groups.
|
||||
*
|
||||
* `listVerticals()` only ever contains verticals that already have a demo —
|
||||
* today that is one group, `reference`, which `src/content/verticals.ts` is
|
||||
* 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.
|
||||
* Entries grouped under their vertical, in `VERTICAL_ORDER`, keeping each
|
||||
* group's internal order (registry order for demos, lineup rank for
|
||||
* proposals). A proposal the contract has no key for lands in a final group so
|
||||
* it is reachable from the bar like everything else.
|
||||
*/
|
||||
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(() => {
|
||||
// `listDemos` already returns a fresh array sorted by order then slug, and
|
||||
// `listVerticals` already drops the empty verticals. Neither needs redoing
|
||||
// here — if this file starts re-sorting the lineup, the header and the
|
||||
// gallery will eventually disagree about what "first" means.
|
||||
const demos = listDemos();
|
||||
const live = demos.filter((demo) => demo.status === 'live');
|
||||
const spec = demos.filter((demo) => demo.status === 'spec');
|
||||
|
||||
// Already sorted by rank in `@/content/lineup`. Copied rather than passed
|
||||
// through, because the panel props are mutable arrays and `lineup` is the
|
||||
// one every page reads.
|
||||
const verticals: HeaderVertical[] = [...lineup];
|
||||
|
||||
// The CTA follows the lineup rather than naming a slug. Today the first
|
||||
// live demo IS the word game, so this resolves to the wordle route; when a
|
||||
// second one ships ahead of it, the button moves with it and this file does
|
||||
// not change.
|
||||
const primary = live[0] ?? demos[0];
|
||||
const ctaHref = primary ? `/demos/${primary.slug}` : '/demos';
|
||||
|
||||
return { live, spec, verticals, ctaHref };
|
||||
// `environments` is already ordered live-first, then registry specs, then
|
||||
// the ranked lineup. Not re-sorted here: if this file starts re-sorting,
|
||||
// the header and the gallery will eventually disagree about "first".
|
||||
const live = environments.filter((entry) => entry.status === 'live');
|
||||
const spec = environments.filter((entry) => entry.status === 'spec');
|
||||
const primary = live[0];
|
||||
// When nothing is live, the bar points at the catalogue rather than
|
||||
// promising a board that does not exist.
|
||||
const cta = primary
|
||||
? { href: primary.href, label: `Play ${primary.title}`, short: 'Play' }
|
||||
: { href: routes.gallery, label: 'All environments', short: 'Browse' };
|
||||
return {
|
||||
live,
|
||||
spec,
|
||||
liveGroups: groupByVertical(live),
|
||||
specGroups: groupByVertical(spec),
|
||||
cta,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 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.
|
||||
*
|
||||
* Both `NavigationMenuLink asChild` and `SheetClose asChild` work by cloning
|
||||
* 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
|
||||
* the page you just arrived at.
|
||||
*/
|
||||
function DemoRow({
|
||||
demo,
|
||||
muted = false,
|
||||
entry,
|
||||
className,
|
||||
...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 (
|
||||
<Link
|
||||
to={`/demos/${demo.slug}`}
|
||||
to={entry.href}
|
||||
className={cn(
|
||||
'flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||
muted && 'opacity-70 hover:opacity-100',
|
||||
// 44px below lg (the sheet), 40px from lg (the panel). Never both
|
||||
// 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,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<DemoIcon name={demo.icon} className="mt-0.5 size-4 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||
{demo.title}
|
||||
{demo.status === 'spec' ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
Spec
|
||||
</Badge>
|
||||
) : null}
|
||||
{entry.rank === null ? null : (
|
||||
<span className="w-5 shrink-0 pt-0.5 font-mono text-mono-data text-muted">
|
||||
{String(entry.rank).padStart(2, '0')}
|
||||
</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"
|
||||
says who is meant to care, where a taskset name says nothing to the
|
||||
person reading this in a boardroom.
|
||||
A spec row shows the BUYER, not the technology: "For the Head of
|
||||
Claims" says who is meant to care, where a taskset name says
|
||||
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>
|
||||
</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 (
|
||||
<p className="px-2.5 pb-1 text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
{children}
|
||||
</p>
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<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 (
|
||||
<div className="w-[min(92vw,720px)]">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
<section aria-label="Live now" className="flex flex-col gap-0.5">
|
||||
<PanelHeading>Live now</PanelHeading>
|
||||
{live.length > 0 ? (
|
||||
live.map((demo) => (
|
||||
<NavigationMenuLink asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} />
|
||||
<div className="flex w-[min(92vw,880px)] flex-col">
|
||||
<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, ${live.length}`} className="flex flex-col">
|
||||
<PanelLabel className="text-fg">
|
||||
Live <span className="text-muted">({String(live.length).padStart(2, '0')})</span>
|
||||
</PanelLabel>
|
||||
<GroupedRows
|
||||
groups={liveGroups}
|
||||
empty="No playable environment yet."
|
||||
wrap={(row, key) => (
|
||||
<NavigationMenuLink asChild key={key}>
|
||||
{row}
|
||||
</NavigationMenuLink>
|
||||
))
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">No interactive demos published yet.</p>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Shipping next"
|
||||
className="flex flex-col gap-0.5 border-l border-border pl-4"
|
||||
>
|
||||
<PanelHeading>Shipping next</PanelHeading>
|
||||
{spec.length > 0 ? (
|
||||
spec.map((demo) => (
|
||||
<NavigationMenuLink asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} muted />
|
||||
<section aria-label={`Specifications, ${spec.length}`} className="flex flex-col border-l border-border pl-4">
|
||||
<PanelLabel className="text-fg">
|
||||
Spec <span className="text-muted">({String(spec.length).padStart(2, '0')})</span>
|
||||
</PanelLabel>
|
||||
<GroupedRows
|
||||
groups={specGroups}
|
||||
empty="Nothing written that is not built."
|
||||
wrap={(row, key) => (
|
||||
<NavigationMenuLink asChild key={key}>
|
||||
{row}
|
||||
</NavigationMenuLink>
|
||||
))
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">Nothing queued.</p>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border bg-surface-2 px-5 py-3">
|
||||
<p className="text-xs text-muted">
|
||||
Every demo replays a real rollout from a real environment.
|
||||
<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-small text-muted">
|
||||
Every playable environment replays a real recorded rollout.
|
||||
</p>
|
||||
<span className="flex items-center gap-3">
|
||||
<Badge variant="outline">{PROPOSAL_NOTICE}</Badge>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
to="/demos"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
Browse all demos
|
||||
<Link to={routes.gallery} className={panelLink}>
|
||||
All environments
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border bg-bg p-3', className)}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
The actual taskset
|
||||
</p>
|
||||
<div className={cn('flex flex-col gap-2 rounded-md border border-border bg-surface-3 p-3', className)}>
|
||||
<p className="font-mono text-label uppercase tracking-label text-muted">The actual taskset</p>
|
||||
{/*
|
||||
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
|
||||
@@ -276,14 +317,14 @@ function TasksetSource({ className }: { className?: string }) {
|
||||
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.
|
||||
*/}
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words text-[11px] leading-relaxed text-fg">
|
||||
<code className="font-mono">{TASKSET_SOURCE}</code>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-[0.6875rem] leading-relaxed text-fg [font-feature-settings:'liga'_0]">
|
||||
<code>{TASKSET_SOURCE}</code>
|
||||
</pre>
|
||||
<a
|
||||
href={VERIFIERS_WORDLE_URL}
|
||||
target="_blank"
|
||||
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
|
||||
<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 }) {
|
||||
return (
|
||||
<div
|
||||
@@ -300,25 +354,17 @@ function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
||||
// a 1024px laptop and gives the whole page a horizontal scrollbar.
|
||||
className="w-[min(90vw,820px)] p-4"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)] gap-4">
|
||||
<dl className="flex flex-col gap-3">
|
||||
{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>
|
||||
))}
|
||||
<div className="grid grid-cols-[minmax(0,0.85fr)_minmax(0,1fr)] gap-5">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Glossary />
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
to={ctaHref}
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
<Link to={ctaHref} className={cn(panelLink, '-ml-2 mt-1 w-fit')}>
|
||||
See all five on one recorded run
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</dl>
|
||||
<TasksetSource />
|
||||
</div>
|
||||
<TasksetSource className="self-start" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -326,22 +372,17 @@ function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
||||
|
||||
/* ------------------------------------------------------------------ mobile */
|
||||
|
||||
function MobileNav({
|
||||
live,
|
||||
spec,
|
||||
verticals,
|
||||
ctaHref,
|
||||
}: {
|
||||
live: DemoMeta[];
|
||||
spec: DemoMeta[];
|
||||
verticals: HeaderVertical[];
|
||||
ctaHref: string;
|
||||
}) {
|
||||
const sheetRow =
|
||||
'tap flex items-center justify-between gap-3 border-b border-border py-3 text-ui font-medium text-fg';
|
||||
|
||||
function MobileNav(catalogue: Catalogue) {
|
||||
const { liveGroups, specGroups, cta } = catalogue;
|
||||
return (
|
||||
<Sheet>
|
||||
<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" />
|
||||
<span aria-hidden="true">Menu</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/*
|
||||
@@ -350,110 +391,95 @@ function MobileNav({
|
||||
long lineup reachable on a short phone, and `overscroll-contain` on it
|
||||
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>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetTitle className="font-mono text-label uppercase tracking-label text-muted">
|
||||
Menu
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Demos, verticals, and how these environments work.
|
||||
Every environment, how they work, and the rest of the site.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody>
|
||||
<Accordion type="multiple" defaultValue={['demos']}>
|
||||
<AccordionItem value="demos">
|
||||
<AccordionTrigger>Demos</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-0.5">
|
||||
<PanelHeading>Live now</PanelHeading>
|
||||
{live.map((demo) => (
|
||||
<SheetClose asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} />
|
||||
<SheetBody className="px-4 py-2">
|
||||
{/* Environments open by default: the menu's first job is the catalogue. */}
|
||||
<Accordion type="multiple" defaultValue={['environments']}>
|
||||
<AccordionItem value="environments">
|
||||
<AccordionTrigger>Environments</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col">
|
||||
<PanelLabel className="text-fg">Live</PanelLabel>
|
||||
<GroupedRows
|
||||
groups={liveGroups}
|
||||
empty="No playable environment yet."
|
||||
wrap={(row, key) => (
|
||||
<SheetClose asChild key={key}>
|
||||
{row}
|
||||
</SheetClose>
|
||||
))}
|
||||
<PanelHeading>Shipping next</PanelHeading>
|
||||
{spec.map((demo) => (
|
||||
<SheetClose asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} muted />
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
<Link
|
||||
to="/demos"
|
||||
className="tap inline-flex items-center gap-1 p-2.5 text-xs font-medium text-accent-fg"
|
||||
>
|
||||
Browse all demos
|
||||
{/* Explicit `min-h-11`, not `.tap`: `.tap` lives in the
|
||||
components layer and the utility `min-h-10` in `panelLink`
|
||||
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')}>
|
||||
All environments
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</AccordionContent>
|
||||
</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">
|
||||
<AccordionTrigger>How it works</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-3">
|
||||
<dl className="flex flex-col gap-3">
|
||||
{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>
|
||||
<Glossary />
|
||||
<TasksetSource />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<a
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="tap mt-2 flex items-center justify-between border-b border-border py-3 text-sm font-medium text-fg"
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
<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"
|
||||
>
|
||||
<SheetClose asChild>
|
||||
<Link to={routes.honesty} className={sheetRow}>
|
||||
Honesty
|
||||
<ArrowRight aria-hidden="true" className="size-4 text-border-strong" />
|
||||
</Link>
|
||||
</SheetClose>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer noopener" className={sheetRow}>
|
||||
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>
|
||||
|
||||
<div className="flex items-center gap-1 pt-3">
|
||||
<ThemeToggle />
|
||||
<ContrastToggle />
|
||||
<div className="grid grid-cols-2 gap-2 py-3">
|
||||
<ThemeToggle labelled />
|
||||
<ContrastToggle labelled />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<Link to={ctaHref}>Play the demo</Link>
|
||||
<Button
|
||||
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>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
@@ -465,19 +491,20 @@ function MobileNav({
|
||||
/* ------------------------------------------------------------------ header */
|
||||
|
||||
export function SiteHeader() {
|
||||
const { live, spec, verticals, ctaHref } = useLineup();
|
||||
const catalogue = useCatalogue();
|
||||
const { cta } = catalogue;
|
||||
|
||||
return (
|
||||
<header
|
||||
// The height token already folds in --safe-top, so the padding and the
|
||||
// height come from the same source 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"
|
||||
// The height token already folds in --safe-top (56px + safe-top at
|
||||
// EVERY width), so the padding and the height come from the same source
|
||||
// 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/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
|
||||
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"
|
||||
>
|
||||
<Wordmark />
|
||||
@@ -491,35 +518,24 @@ export function SiteHeader() {
|
||||
<NavigationMenu className="hidden h-full lg:flex" delayDuration={120}>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Demos</NavigationMenuTrigger>
|
||||
<NavigationMenuTrigger>Environments</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<DemosPanel live={live} spec={spec} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Verticals</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<VerticalsPanel verticals={verticals} />
|
||||
<EnvironmentsPanel {...catalogue} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>How it works</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<HowItWorksPanel ctaHref={ctaHref} />
|
||||
<HowItWorksPanel ctaHref={cta.href} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={cn(navigationMenuTriggerStyle(), 'text-muted hover:text-fg')}
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
<NavigationMenuLink asChild>
|
||||
<Link to={routes.honesty} className={navigationMenuTriggerStyle()}>
|
||||
Honesty
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
@@ -530,23 +546,28 @@ export function SiteHeader() {
|
||||
<ThemeToggle />
|
||||
<ContrastToggle />
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Source on Gitea"
|
||||
>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer noopener" aria-label="Source on Gitea">
|
||||
<GitBranch aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button asChild size="touch" className="hidden lg:inline-flex">
|
||||
<Link to={ctaHref}>Play the demo</Link>
|
||||
{/* Desktop: the full verb + title, 40px. Phone: the verb only, so it
|
||||
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>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<MobileNav live={live} spec={spec} verticals={verticals} ctaHref={ctaHref} />
|
||||
<MobileNav {...catalogue} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ export function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
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
|
||||
</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 placeholder is the same size as the button so the header does not reflow
|
||||
* 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 [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
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 next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length] ?? 'system';
|
||||
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 (
|
||||
<Button
|
||||
@@ -44,10 +77,7 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', className)}
|
||||
onClick={() => setTheme(next)}
|
||||
// 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.
|
||||
aria-label={`Theme: ${LABEL[current]}. Switch to ${LABEL[next].toLowerCase()}.`}
|
||||
aria-label={ariaLabel}
|
||||
title={`Theme: ${LABEL[current]}`}
|
||||
>
|
||||
<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';
|
||||
|
||||
/**
|
||||
* Two words, one tone shift: the brand carries the accent, the qualifier does
|
||||
* not. Rendered as a single string for a screen reader so it is announced
|
||||
* "PIG demo" rather than as two unrelated fragments.
|
||||
* Two words, two faces: "PIG" is a name and is set in the sans; "demo" is a
|
||||
* qualifier that reads like a tag, so it takes the mono the site uses for
|
||||
* 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 }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-baseline gap-1 font-semibold tracking-tight', className)}>
|
||||
<span aria-hidden="true" className="text-accent-fg">
|
||||
<span className={cn('inline-flex items-baseline gap-1.5 text-base', className)}>
|
||||
<span aria-hidden="true" className="font-sans font-semibold tracking-tight text-accent-fg">
|
||||
PIG
|
||||
</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
|
||||
</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} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
className,
|
||||
children,
|
||||
@@ -22,7 +28,7 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,7 +36,7 @@ function AccordionTrigger({
|
||||
{children}
|
||||
<ChevronDown
|
||||
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.Header>
|
||||
|
||||
@@ -3,19 +3,31 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
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(
|
||||
'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: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
||||
outline: 'border-border text-muted',
|
||||
solid: 'border-transparent bg-brand text-accent-on',
|
||||
positive: 'border-transparent bg-positive/10 text-positive',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
danger: 'border-transparent bg-danger/10 text-danger',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
muted: 'border-border bg-surface-2 text-muted',
|
||||
outline: 'border-border-strong bg-transparent text-muted',
|
||||
solid: 'border-accent-edge bg-brand text-accent-on',
|
||||
positive: 'border-transparent bg-accent-subtle text-positive',
|
||||
warning: 'border-transparent bg-warning-subtle text-warning',
|
||||
danger: 'border-transparent bg-danger-subtle text-danger',
|
||||
info: 'border-transparent bg-surface-3 text-info',
|
||||
muted: 'border-transparent bg-surface-3 text-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
|
||||
@@ -5,32 +5,42 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
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
|
||||
* brand — that mapping is deliberate and documented there. So the solid CTA
|
||||
* 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(
|
||||
'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: {
|
||||
variant: {
|
||||
default: 'bg-brand text-accent-on shadow-sm hover:bg-brand/90',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-surface-2/70',
|
||||
outline: 'border border-border bg-surface text-fg hover:bg-surface-2',
|
||||
default:
|
||||
'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',
|
||||
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',
|
||||
subtle: 'bg-accent-subtle text-accent-fg hover:bg-accent-subtle/70',
|
||||
destructive: 'bg-danger text-white shadow-sm hover:bg-danger/90',
|
||||
subtle: 'bg-accent-subtle text-accent-fg hover:border-accent-edge',
|
||||
destructive: 'bg-danger text-danger-on hover:bg-danger/90',
|
||||
link: 'text-accent-fg underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
// `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`
|
||||
// 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',
|
||||
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',
|
||||
icon: 'size-9 [&_svg]:size-4',
|
||||
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
||||
|
||||
@@ -2,30 +2,34 @@ import * as React from 'react';
|
||||
|
||||
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'>) {
|
||||
return <div className={cn('card text-fg', className)} {...props} />;
|
||||
}
|
||||
|
||||
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'>) {
|
||||
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'>) {
|
||||
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'>) {
|
||||
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'>) {
|
||||
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 };
|
||||
|
||||
@@ -27,6 +27,12 @@ function DrawerOverlay({
|
||||
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({
|
||||
className,
|
||||
children,
|
||||
@@ -37,14 +43,17 @@ function DrawerContent({
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* The grab handle is decorative; the drawer is also closable with
|
||||
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}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
@@ -70,7 +79,10 @@ function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
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
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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(
|
||||
'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({
|
||||
@@ -74,7 +79,7 @@ function NavigationMenuTrigger({
|
||||
{children}
|
||||
<ChevronDown
|
||||
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>
|
||||
);
|
||||
@@ -112,7 +117,7 @@ function NavigationMenuViewport({
|
||||
<div className="absolute left-0 top-full z-50 flex justify-center">
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
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',
|
||||
'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)]',
|
||||
|
||||
@@ -3,6 +3,10 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
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({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
@@ -19,7 +23,7 @@ function ScrollBar({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border-strong" />
|
||||
</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(
|
||||
'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: {
|
||||
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:
|
||||
'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',
|
||||
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',
|
||||
'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(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:
|
||||
'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' },
|
||||
@@ -60,7 +67,7 @@ function SheetContent({
|
||||
{children}
|
||||
{showClose ? (
|
||||
<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"
|
||||
>
|
||||
<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>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
className={cn('font-sans text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -123,7 +130,9 @@ function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: 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 {
|
||||
|
||||
@@ -5,13 +5,14 @@ import { cn } from '@/lib/utils';
|
||||
/**
|
||||
* `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.
|
||||
* 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'>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
||||
className={cn('animate-pulse rounded-md bg-surface-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,20 @@ import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One thumb per value, not one hard-coded thumb: the reward 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.
|
||||
* The reward editor's control (spec §9). Track 6px on surface-3, range in the
|
||||
* text green (`accent-fg` equals `accent` in dark and is the only green that
|
||||
* 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({
|
||||
className,
|
||||
@@ -17,19 +28,32 @@ function Slider({
|
||||
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
||||
return (
|
||||
<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 })}
|
||||
{...(defaultValue === undefined ? {} : { defaultValue })}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-2">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-brand" />
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-3">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-accent-fg" />
|
||||
</SliderPrimitive.Track>
|
||||
{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
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -5,23 +5,67 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
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 (
|
||||
<TabsVariantContext.Provider value={variant}>
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
'inline-flex items-center justify-start gap-1 rounded-lg border border-border bg-surface-2 p-1 text-muted',
|
||||
className,
|
||||
)}
|
||||
data-variant={variant}
|
||||
className={cn(listStyles[variant], className)}
|
||||
{...props}
|
||||
/>
|
||||
</TabsVariantContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
const variant = React.useContext(TabsVariantContext);
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -30,7 +74,15 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -7,6 +7,11 @@ const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
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({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
@@ -19,7 +24,7 @@ function TooltipContent({
|
||||
className={cn(
|
||||
// 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.
|
||||
'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',
|
||||
'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,
|
||||
|
||||
@@ -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)',
|
||||
/** 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',
|
||||
/**
|
||||
* 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: {
|
||||
label: 'prime-rl / 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
|
||||
* 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.
|
||||
*
|
||||
* 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';
|
||||
@@ -38,6 +42,8 @@ export const allDemos: readonly DemoMeta[] = demos;
|
||||
|
||||
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 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. */
|
||||
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,
|
||||
* 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
|
||||
* flat value: on a notched phone in landscape, a flat `px-5` puts the first
|
||||
* character of every heading under the rounded corner.
|
||||
@@ -15,47 +25,109 @@
|
||||
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))]';
|
||||
|
||||
/** 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';
|
||||
|
||||
/** Small uppercase label above a section heading. */
|
||||
export const eyebrow = 'text-xs font-semibold uppercase tracking-[0.14em] text-muted';
|
||||
/* ------------------------------------------------------------------- type */
|
||||
|
||||
export const h1 =
|
||||
'text-[2rem] leading-[1.08] font-extrabold tracking-tight text-fg sm:text-5xl lg:text-6xl';
|
||||
/** Mono small-caps section label. The same style as every chip and table head. */
|
||||
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 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';
|
||||
export const h3 = 'text-h3 text-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. */
|
||||
export const btnSecondary =
|
||||
'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';
|
||||
export const btnSecondary = `${btnBase} border border-border-strong bg-surface text-fg hover:bg-surface-2`;
|
||||
|
||||
/** Inline link inside running text. Underlined, because colour alone is not a 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. */
|
||||
export const pill =
|
||||
'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';
|
||||
/** A quiet navigation link — back, next, "all twelve". 44px tall. */
|
||||
export const navLink =
|
||||
'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. */
|
||||
export const proposalPill =
|
||||
'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';
|
||||
export const proposalPill = chipOutline;
|
||||
|
||||
/* ------------------------------------------------------------- surfaces */
|
||||
|
||||
/** A card that is also a link: the whole rectangle is the target. */
|
||||
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. */
|
||||
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 { cn } from '@/lib/utils';
|
||||
@@ -24,17 +24,29 @@ type Panel = 'none' | 'close' | 'escalate';
|
||||
|
||||
const price = (minutes: number) => `+${hours(minutes)} h`;
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
/** A price or a "done" beside a verb: mono, muted, the way a cost is printed. */
|
||||
function Price({ children }: { children: ReactNode }) {
|
||||
return <span className="font-mono text-caption font-normal text-muted">{children}</span>;
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<fieldset className="min-w-0 rounded-lg border border-border p-3">
|
||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wide text-muted">{title}</legend>
|
||||
<fieldset className="min-w-0 rounded-control border border-border p-3">
|
||||
<legend className="px-1 font-mono text-label uppercase text-muted">{title}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A native select. The border is the only affordance a select has on this
|
||||
* ground, so it is `border-strong` (≥3:1 in both themes) rather than the
|
||||
* decorative hairline; the focus ring is the site's.
|
||||
*/
|
||||
const SELECT =
|
||||
'tap min-w-0 rounded-md border border-border bg-surface px-2 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-50';
|
||||
'min-h-11 min-w-0 rounded-control border border-border-strong bg-surface px-2.5 font-sans text-ui text-fg ' +
|
||||
'transition-colors duration-1 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg ' +
|
||||
'disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
/** The citable ids, grouped the way the screen groups them. */
|
||||
function groupShown(shown: readonly string[]) {
|
||||
@@ -132,10 +144,10 @@ export function Controls({
|
||||
: `Hours spent so far: ${hours(state.minutesSpent)}. ${left} ${left === 1 ? 'reply' : 'replies'} left.`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
|
||||
<div className="flex flex-col gap-3 pb-[max(1rem,var(--safe-bottom))]">
|
||||
{/* The board is a group of tables; the outcome of every click is said
|
||||
here in words, where it announces itself. */}
|
||||
<p aria-live="polite" className="text-sm text-muted">
|
||||
<p aria-live="polite" className="font-sans text-ui text-fg-2">
|
||||
{status}
|
||||
</p>
|
||||
|
||||
@@ -147,7 +159,7 @@ export function Controls({
|
||||
</label>
|
||||
<select
|
||||
id={`${ids}-month`}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1')}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1 font-mono text-mono-data')}
|
||||
value={month}
|
||||
disabled={done || monthsLeft.length === 0}
|
||||
onChange={(event) => setMonth(event.target.value)}
|
||||
@@ -163,13 +175,14 @@ export function Controls({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="shrink-0"
|
||||
disabled={done || month === ''}
|
||||
onClick={() => {
|
||||
send({ action: 'lookup', what: 'history', month });
|
||||
setMonth('');
|
||||
}}
|
||||
>
|
||||
History <span className="nums text-muted">{price(PRICES.history)}</span>
|
||||
History <Price>{price(PRICES.history)}</Price>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +192,7 @@ export function Controls({
|
||||
</label>
|
||||
<select
|
||||
id={`${ids}-cp`}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1')}
|
||||
className={cn(SELECT, 'min-w-[7rem] flex-1 font-mono text-mono-data')}
|
||||
value={cp}
|
||||
disabled={done || cpsLeft.length === 0}
|
||||
onChange={(event) => setCp(event.target.value)}
|
||||
@@ -195,17 +208,19 @@ export function Controls({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
className="shrink-0"
|
||||
disabled={done || cp === ''}
|
||||
onClick={() => {
|
||||
send({ action: 'lookup', what: 'counterparty', id: cp });
|
||||
setCp('');
|
||||
}}
|
||||
>
|
||||
Counterparty <span className="nums text-muted">{price(PRICES.counterparty)}</span>
|
||||
Counterparty <Price>{price(PRICES.counterparty)}</Price>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Two per row on a phone, so the pair reads as the pair it is. */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -213,7 +228,7 @@ export function Controls({
|
||||
disabled={done || priorsMade}
|
||||
onClick={() => send({ action: 'lookup', what: 'prior_alerts' })}
|
||||
>
|
||||
Prior alerts <span className="nums text-muted">{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</span>
|
||||
Prior alerts <Price>{priorsMade ? 'done' : price(LOOKUP_MINUTES)}</Price>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -222,18 +237,21 @@ export function Controls({
|
||||
disabled={done || docsMade}
|
||||
onClick={() => send({ action: 'lookup', what: 'documents' })}
|
||||
>
|
||||
Documents <span className="nums text-muted">{docsMade ? 'done' : price(DOC_MINUTES)}</span>
|
||||
Documents <Price>{docsMade ? 'done' : price(DOC_MINUTES)}</Price>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Disposition — ends the episode">
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Choose a disposition">
|
||||
{/* A toggle pair: the pressed half is the one green fill in the
|
||||
controls, with the accent edge that keeps it a control on paper. */}
|
||||
<div className="grid grid-cols-2 gap-2" role="group" aria-label="Choose a disposition">
|
||||
<Button
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={panel === 'close' ? 'default' : 'outline'}
|
||||
className={cn(panel === 'close' && 'border border-accent-edge shadow-none')}
|
||||
aria-pressed={panel === 'close'}
|
||||
disabled={done}
|
||||
onClick={() => setPanel(panel === 'close' ? 'none' : 'close')}
|
||||
@@ -244,6 +262,7 @@ export function Controls({
|
||||
type="button"
|
||||
size="touch"
|
||||
variant={panel === 'escalate' ? 'default' : 'outline'}
|
||||
className={cn(panel === 'escalate' && 'border border-accent-edge shadow-none')}
|
||||
aria-pressed={panel === 'escalate'}
|
||||
disabled={done}
|
||||
onClick={() => setPanel(panel === 'escalate' ? 'none' : 'escalate')}
|
||||
@@ -260,8 +279,8 @@ export function Controls({
|
||||
submitDisposition();
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
||||
{panel === 'close' ? 'Reason' : 'Typology'}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Reason' : 'Typology'}</span>
|
||||
{panel === 'close' ? (
|
||||
<select className={SELECT} value={reason} onChange={(event) => setReason(event.target.value as CloseReason)}>
|
||||
{CLOSE_REASONS.map((r) => (
|
||||
@@ -282,13 +301,13 @@ export function Controls({
|
||||
</label>
|
||||
|
||||
<fieldset className="min-w-0">
|
||||
<legend className="mb-1 text-xs text-muted">
|
||||
<legend className="mb-1 font-sans text-small text-fg-2">
|
||||
Cite the facts that decide it — only ids you have been shown
|
||||
</legend>
|
||||
<div className="flex max-h-56 flex-col gap-2 overflow-y-auto rounded-md border border-border p-2">
|
||||
<div className="flex max-h-56 flex-col gap-2.5 overflow-y-auto overscroll-contain rounded-control border border-border-strong p-2">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<p className="mb-1 text-[0.65rem] font-semibold uppercase tracking-wide text-muted">{group.label}</p>
|
||||
<p className="mb-1 font-mono text-label uppercase text-muted">{group.label}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{group.ids.map((id) => {
|
||||
const on = cites.includes(id);
|
||||
@@ -296,12 +315,16 @@ export function Controls({
|
||||
<label
|
||||
key={id}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer select-none items-center gap-1 rounded-md border px-1.5 py-1 font-mono text-[0.7rem] transition-colors duration-1',
|
||||
on ? 'border-brand bg-accent-subtle text-fg' : 'border-border text-muted hover:bg-surface-2',
|
||||
'inline-flex cursor-pointer select-none items-center gap-1 rounded-tile border px-1.5 py-1.5 font-mono text-label tracking-normal transition-colors duration-1',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-bg',
|
||||
on
|
||||
? 'border-accent-edge bg-accent-subtle text-fg'
|
||||
: 'border-border-strong text-fg-2 hover:bg-surface-2',
|
||||
)}
|
||||
title={id.startsWith('kyc.') ? fieldLabel(id) : id}
|
||||
>
|
||||
<input type="checkbox" className="sr-only" checked={on} onChange={() => toggleCite(id)} />
|
||||
{on ? <span aria-hidden="true">✓</span> : null}
|
||||
{id}
|
||||
</label>
|
||||
);
|
||||
@@ -312,26 +335,31 @@ export function Controls({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<label className="flex flex-col gap-1 text-xs text-muted">
|
||||
<span>
|
||||
{panel === 'close' ? 'Note' : 'Narrative'}{' '}
|
||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-label uppercase text-muted">{panel === 'close' ? 'Note' : 'Narrative'}</span>
|
||||
<span className="rounded-tile border border-border-strong px-1.5 py-px font-mono text-label uppercase text-muted">
|
||||
displayed, never scored
|
||||
</span>
|
||||
</span>
|
||||
<textarea
|
||||
className="min-h-16 rounded-md border border-border bg-surface px-2 py-1 text-sm text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||
className="min-h-16 rounded-control border border-border-strong bg-surface px-2.5 py-1.5 font-sans text-ui text-fg placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg"
|
||||
value={prose}
|
||||
onChange={(event) => setProse(event.target.value)}
|
||||
placeholder={panel === 'close' ? 'Why this is consistent with the file…' : 'What the pattern is and why it is reportable…'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="submit" size="touch" disabled={cites.length === 0}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
size="touch"
|
||||
className="w-full border border-accent-edge shadow-none sm:w-auto"
|
||||
disabled={cites.length === 0}
|
||||
>
|
||||
{panel === 'close' ? 'Close the alert' : 'Escalate for filing'}
|
||||
</Button>
|
||||
<span className="nums text-xs text-muted">
|
||||
<span className="font-mono text-caption text-muted">
|
||||
{cites.length} cited · {price(TURN_MINUTES)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -340,9 +368,14 @@ export function Controls({
|
||||
</Section>
|
||||
|
||||
{lastReply ? (
|
||||
<details className="text-xs text-muted">
|
||||
<summary className="cursor-pointer">The reply the engine received</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded-md bg-surface-2 p-2 font-mono text-[0.7rem] text-fg [contain:inline-size]">
|
||||
<details className="group font-mono text-label uppercase text-muted">
|
||||
<summary className="min-h-11 cursor-pointer list-none py-2 [&::-webkit-details-marker]:hidden">
|
||||
<span aria-hidden="true" className="mr-1.5 inline-block transition-transform duration-1 group-open:rotate-90">
|
||||
›
|
||||
</span>
|
||||
The reply the engine received
|
||||
</summary>
|
||||
<pre className="overflow-x-auto rounded-control border border-border bg-surface-3 p-2 font-mono text-code normal-case tracking-normal text-fg [contain:inline-size]">
|
||||
{lastReply}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default defineMeta({
|
||||
tagline:
|
||||
'Work one transaction-monitoring alert against the customer’s file and twelve months of history, then close it or escalate it for a filing.',
|
||||
vertical: 'financial-crime',
|
||||
status: 'spec',
|
||||
status: 'live',
|
||||
order: 0,
|
||||
icon: 'Siren',
|
||||
persona: 'BSA / AML Officer',
|
||||
|
||||
@@ -11,10 +11,15 @@ import type { Lookup, Reveal, Screen, Transaction, TriageState } from './types';
|
||||
* playing, the recorded replay, and the timeline thumbnail — because three
|
||||
* near-identical boards is how they drift.
|
||||
*
|
||||
* It is an instrument, not a document: dense, every id and number in mono,
|
||||
* every sentence in sans. It draws no outer chrome — the shell's card supplies
|
||||
* the border, ground and padding — and nothing inside it is a link or a
|
||||
* button; the actions live in the controls beside it.
|
||||
*
|
||||
* Two things this board must do that a game board does not:
|
||||
*
|
||||
* · Say, on the board itself, that the data is synthetic. Not in a footnote.
|
||||
* The label is the first row of every render.
|
||||
* The label is the first row of every render, the thumbnail included.
|
||||
* · Show the answer only once the episode is over. `state.reveal` is null
|
||||
* until then, so the surface cannot leak the label even by accident.
|
||||
*
|
||||
@@ -27,12 +32,17 @@ const MAX_TURNS = 8;
|
||||
|
||||
/* ---------------------------------------------------------------- pieces */
|
||||
|
||||
/**
|
||||
* Warning ink on the warning ground: 6.08 light / 9.12 dark (index.css). The
|
||||
* dashed edge is the one place on the site a border is dashed, so the shape
|
||||
* itself says "a note about the data".
|
||||
*/
|
||||
function SyntheticLabel({ compact }: { compact?: boolean }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'rounded-md border border-dashed border-warning/60 bg-warning/5 font-semibold uppercase tracking-wide text-warning',
|
||||
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-[0.65rem]',
|
||||
'rounded-tile border border-dashed border-warning bg-warning-subtle font-mono font-medium uppercase tracking-label text-warning',
|
||||
compact ? 'px-1 py-0.5 text-[7px] leading-tight' : 'px-2 py-1 text-label',
|
||||
)}
|
||||
>
|
||||
Synthetic data — no real customer, account or transaction
|
||||
@@ -40,20 +50,37 @@ function SyntheticLabel({ compact }: { compact?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** An identifier. `fg` for something the analyst can cite, `muted` for a reference to one. */
|
||||
function Id({ children, tone = 'muted' }: { children: ReactNode; tone?: 'muted' | 'fg' }) {
|
||||
return <code className={cn('font-mono', tone === 'muted' ? 'text-muted' : 'text-fg')}>{children}</code>;
|
||||
}
|
||||
|
||||
/** The mono 11 uppercase label, the same style as the shell's chrome. */
|
||||
function Label({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <span className={cn('font-mono text-label uppercase text-muted', className)}>{children}</span>;
|
||||
}
|
||||
|
||||
/** A 4px outline chip for a state word. Never colour alone: the word is the signal. */
|
||||
function Chip({ children, className, title }: { children: ReactNode; className?: string; title?: string }) {
|
||||
return (
|
||||
<code className={cn('nums font-mono text-[0.8em]', tone === 'muted' ? 'text-muted' : 'text-fg')}>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 whitespace-nowrap rounded-tile border border-border-strong px-1.5 py-px font-mono text-label uppercase',
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<header className="mb-2 flex items-baseline justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</h4>
|
||||
{aside ? <div className="text-xs text-muted">{aside}</div> : null}
|
||||
<section className="min-w-0 rounded-control border border-border bg-surface p-3">
|
||||
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
|
||||
<h4 className="font-mono text-label uppercase text-muted">{title}</h4>
|
||||
{aside ? <div className="min-w-0 font-mono text-caption text-muted">{aside}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
@@ -70,31 +97,43 @@ function Scroll({ children }: { children: ReactNode }) {
|
||||
return <div className="overflow-x-auto [contain:inline-size]">{children}</div>;
|
||||
}
|
||||
|
||||
/** Table styling shared by every table on the screen: mono 12, heads mono 11 on surface-3. */
|
||||
const TABLE = 'w-full border-collapse font-mono text-caption';
|
||||
const TH = 'bg-surface-3 px-2 py-1 text-left font-mono text-label font-medium uppercase text-muted first:rounded-l-tile last:rounded-r-tile';
|
||||
const TD = 'px-2 py-1 align-baseline';
|
||||
const NUM = 'whitespace-nowrap text-right';
|
||||
|
||||
function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: string }) {
|
||||
if (rows.length === 0) return <p className="text-xs text-muted">No transactions.</p>;
|
||||
if (rows.length === 0) return <p className="font-sans text-small text-muted">No transactions.</p>;
|
||||
return (
|
||||
<Scroll>
|
||||
<table className="w-full min-w-[26rem] text-xs">
|
||||
<thead className="text-left text-muted">
|
||||
<table className={cn(TABLE, 'min-w-[26rem]')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="pb-1 pr-2 font-medium">id</th>
|
||||
<th className="pb-1 pr-2 font-medium">date</th>
|
||||
<th className="pb-1 pr-2 font-medium">channel</th>
|
||||
<th className="pb-1 pr-2 text-right font-medium">amount</th>
|
||||
<th className="pb-1 font-medium">where</th>
|
||||
<th className={TH}>id</th>
|
||||
<th className={cn(TH, 'text-right')}>date</th>
|
||||
<th className={TH}>channel</th>
|
||||
<th className={cn(TH, 'text-right')}>amount</th>
|
||||
<th className={TH}>where</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((t) => (
|
||||
<tr key={t.id} className="border-t border-border/60">
|
||||
<td className="py-0.5 pr-2">
|
||||
<tr key={t.id} className="border-t border-border">
|
||||
<td className={TD}>
|
||||
<Id tone="fg">{t.id}</Id>
|
||||
</td>
|
||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{t.date}</td>
|
||||
<td className="py-0.5 pr-2 whitespace-nowrap">{channelLabel(t.channel)}</td>
|
||||
<td className="nums py-0.5 pr-2 text-right whitespace-nowrap">{money(t.amount, currency)}</td>
|
||||
<td className="py-0.5 whitespace-nowrap">
|
||||
{t.branch ? `branch ${t.branch}` : t.cp ? <Id>{t.cp}</Id> : '—'}
|
||||
<td className={cn(TD, NUM)}>{t.date}</td>
|
||||
<td className={cn(TD, 'whitespace-nowrap font-sans text-small')}>{channelLabel(t.channel)}</td>
|
||||
<td className={cn(TD, NUM, 'text-fg')}>{money(t.amount, currency)}</td>
|
||||
<td className={cn(TD, 'whitespace-nowrap')}>
|
||||
{t.branch ? (
|
||||
<span className="font-sans text-small">branch {t.branch}</span>
|
||||
) : t.cp ? (
|
||||
<Id>{t.cp}</Id>
|
||||
) : (
|
||||
<span className="text-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -107,15 +146,17 @@ function TransactionTable({ rows, currency }: { rows: Transaction[]; currency: s
|
||||
function AlertBlock({ screen }: { screen: Screen }) {
|
||||
const a = screen.alert;
|
||||
return (
|
||||
<Block title="Alert" aside={<span className="nums">fired {a.fired}</span>}>
|
||||
<p className="text-sm">
|
||||
<Block title="Alert" aside={<span>fired {a.fired}</span>}>
|
||||
<p className="font-sans text-small text-fg">
|
||||
<Id tone="fg">{a.id}</Id>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="font-mono text-xs font-semibold">{a.rule}</span>
|
||||
<span className="font-mono font-semibold">{a.rule}</span>
|
||||
<span className="mx-1.5 text-muted">—</span>
|
||||
{a.rule_text}
|
||||
<span className="text-fg-2">{a.rule_text}</span>
|
||||
</p>
|
||||
<p className="mb-1 mt-2">
|
||||
<Label>Triggering transactions</Label>
|
||||
</p>
|
||||
<p className="mb-1 mt-2 text-xs text-muted">Triggering transactions</p>
|
||||
<TransactionTable rows={screen.triggering_transactions} currency={screen.currency} />
|
||||
</Block>
|
||||
);
|
||||
@@ -133,14 +174,12 @@ function KycBlock({ screen }: { screen: Screen }) {
|
||||
const c = screen.customer;
|
||||
return (
|
||||
<Block title="KYC file" aside={<Id>{c.id}</Id>}>
|
||||
<p className="mb-2 text-sm font-medium">{c.name}</p>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-1 text-xs sm:grid-cols-2">
|
||||
<p className="mb-2 font-sans text-ui font-medium text-fg">{c.name}</p>
|
||||
<dl className="grid grid-cols-1 gap-x-4 gap-y-1.5 sm:grid-cols-2">
|
||||
{kycEntries(c as unknown as Record<string, unknown>).map(([key, value]) => (
|
||||
<div key={key} className="flex min-w-0 flex-col">
|
||||
<dt className="text-muted">
|
||||
<Id>kyc.{key}</Id>
|
||||
</dt>
|
||||
<dd className="nums break-words">{kycValue(key, value, screen.currency)}</dd>
|
||||
<div key={key} className="flex min-w-0 flex-col border-t border-border pt-1">
|
||||
<dt className="font-mono text-caption text-muted">kyc.{key}</dt>
|
||||
<dd className="break-words font-sans text-small text-fg">{kycValue(key, value, screen.currency)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
@@ -168,28 +207,28 @@ function SummaryBlock({ screen }: { screen: Screen }) {
|
||||
}
|
||||
>
|
||||
<Scroll>
|
||||
<table className="w-full min-w-[30rem] text-xs">
|
||||
<thead className="text-left text-muted">
|
||||
<table className={cn(TABLE, 'min-w-[30rem]')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="pb-1 pr-2 font-medium">month</th>
|
||||
<th className={TH}>month</th>
|
||||
{cols.map(([key, label]) => (
|
||||
<th key={key} className="pb-1 pr-2 text-right font-medium">
|
||||
<th key={key} className={cn(TH, 'text-right')}>
|
||||
{label}
|
||||
</th>
|
||||
))}
|
||||
<th className="pb-1 text-right font-medium">txns</th>
|
||||
<th className={cn(TH, 'text-right')}>txns</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{screen.summary.map((row) => (
|
||||
<tr key={row.month} className="border-t border-border/60">
|
||||
<td className="nums py-0.5 pr-2 whitespace-nowrap">{row.month}</td>
|
||||
<tr key={row.month} className="border-t border-border">
|
||||
<td className={cn(TD, 'whitespace-nowrap text-fg')}>{row.month}</td>
|
||||
{cols.map(([key]) => (
|
||||
<td key={key} className="nums py-0.5 pr-2 text-right">
|
||||
<td key={key} className={cn(TD, NUM)}>
|
||||
{wholeUnits(row[key])}
|
||||
</td>
|
||||
))}
|
||||
<td className="nums py-0.5 text-right">{row.count}</td>
|
||||
<td className={cn(TD, NUM, 'text-muted')}>{row.count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -202,19 +241,33 @@ function SummaryBlock({ screen }: { screen: Screen }) {
|
||||
function CounterpartyBlock({ screen }: { screen: Screen }) {
|
||||
return (
|
||||
<Block title="Counterparties on screen" aside="top by twelve-month volume">
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
<Scroll>
|
||||
<table className={cn(TABLE, 'min-w-[20rem]')}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={TH}>id</th>
|
||||
<th className={TH}>direction</th>
|
||||
<th className={cn(TH, 'text-right')}>txns</th>
|
||||
<th className={cn(TH, 'text-right')}>total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{screen.screen_counterparties.map((cp) => (
|
||||
<li key={cp.id} className="flex flex-wrap items-baseline gap-x-2">
|
||||
<tr key={cp.id} className="border-t border-border">
|
||||
<td className={TD}>
|
||||
<Id tone="fg">{cp.id}</Id>
|
||||
<span className="text-muted">{cp.direction === 'in' ? 'inbound' : 'outbound'}</span>
|
||||
<span className="nums">{cp.count} txns</span>
|
||||
<span className="nums">{money(cp.total, screen.currency)}</span>
|
||||
</li>
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroll>
|
||||
<p className="mt-2 font-sans text-small text-muted">
|
||||
Reporting threshold in this jurisdiction:{' '}
|
||||
<span className="nums text-fg">{money(screen.threshold, screen.currency)}</span>
|
||||
<span className="font-mono text-fg">{money(screen.threshold, screen.currency)}</span>
|
||||
</p>
|
||||
</Block>
|
||||
);
|
||||
@@ -226,7 +279,7 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
||||
return (
|
||||
<Block
|
||||
title={`History ${lookup.result.month}`}
|
||||
aside={<span className="nums">{lookup.result.transactions.length} transactions</span>}
|
||||
aside={<span>{lookup.result.transactions.length} transactions</span>}
|
||||
>
|
||||
<TransactionTable rows={lookup.result.transactions} currency={currency} />
|
||||
</Block>
|
||||
@@ -234,19 +287,19 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
||||
case 'counterparty': {
|
||||
const r = lookup.result;
|
||||
return (
|
||||
<Block title={`Counterparty ${r.id}`} aside={<span className="nums">first seen {r.first_seen}</span>}>
|
||||
<p className="text-sm font-medium">{r.name}</p>
|
||||
<p className="text-xs text-muted">
|
||||
<Block title={`Counterparty ${r.id}`} aside={<span>first seen {r.first_seen}</span>}>
|
||||
<p className="font-sans text-ui font-medium text-fg">{r.name}</p>
|
||||
<p className="font-sans text-small text-muted">
|
||||
{r.type.replace(/_/g, ' ')} · {r.jurisdiction}
|
||||
</p>
|
||||
<p className="mt-1 text-xs">
|
||||
<p className="mt-1.5 font-sans text-small text-fg-2">
|
||||
Other alerted accounts sharing this counterparty in twelve months:{' '}
|
||||
<span className="nums font-semibold">{r.other_alerted_accounts}</span>
|
||||
<span className="font-mono font-semibold text-fg">{r.other_alerted_accounts}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
<p className="mt-1.5 font-sans text-small text-muted">
|
||||
transactions:{' '}
|
||||
{r.transactions.map((id) => (
|
||||
<span key={id} className="mr-1">
|
||||
<span key={id} className="mr-1.5">
|
||||
<Id>{id}</Id>
|
||||
</span>
|
||||
))}
|
||||
@@ -256,22 +309,23 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
||||
}
|
||||
case 'prior_alerts':
|
||||
return (
|
||||
<Block title="Prior alerts" aside={<span className="nums">{lookup.result.alerts.length} on file</span>}>
|
||||
<Block title="Prior alerts" aside={<span>{lookup.result.alerts.length} on file</span>}>
|
||||
{lookup.result.alerts.length === 0 ? (
|
||||
<p className="text-xs text-muted">None on file.</p>
|
||||
<p className="font-sans text-small text-muted">None on file.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-xs">
|
||||
<ul className="divide-y divide-border">
|
||||
{lookup.result.alerts.map((p) => (
|
||||
<li key={p.id}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<li key={p.id} className="py-1.5 first:pt-0 last:pb-0">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 font-mono text-caption">
|
||||
<Id tone="fg">{p.id}</Id>
|
||||
<span className="font-mono">{p.rule}</span>
|
||||
<span className="nums text-muted">fired {p.fired}</span>
|
||||
<span>
|
||||
{p.disposition} after {p.elapsed_days} {p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
|
||||
<span className="font-semibold text-fg">{p.rule}</span>
|
||||
<span className="text-muted">fired {p.fired}</span>
|
||||
<span className="font-sans text-small text-fg-2">
|
||||
{p.disposition} after <span className="font-mono">{p.elapsed_days}</span>{' '}
|
||||
{p.elapsed_days === 1 ? 'day' : 'days'} by {p.analyst}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted">{p.rationale}</p>
|
||||
<p className="mt-0.5 font-sans text-small text-fg-2">{p.rationale}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -280,15 +334,15 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
||||
);
|
||||
case 'documents':
|
||||
return (
|
||||
<Block title="Documents on file" aside={<span className="nums">{lookup.result.documents.length}</span>}>
|
||||
<ul className="space-y-1 text-xs">
|
||||
<Block title="Documents on file" aside={<span>{lookup.result.documents.length}</span>}>
|
||||
<ul className="divide-y divide-border">
|
||||
{lookup.result.documents.map((d) => (
|
||||
<li key={d.id} className="flex flex-col gap-0.5 sm:flex-row sm:gap-2">
|
||||
<span className="shrink-0">
|
||||
<li key={d.id} className="flex flex-col gap-0.5 py-1.5 first:pt-0 last:pb-0 sm:flex-row sm:gap-3">
|
||||
<span className="shrink-0 font-mono text-caption">
|
||||
<Id tone="fg">{d.id}</Id>{' '}
|
||||
<span className="text-muted">[{d.kind.replace(/_/g, ' ')}]</span>
|
||||
</span>
|
||||
<span>{d.description}</span>
|
||||
<span className="font-sans text-small text-fg-2">{d.description}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -297,14 +351,20 @@ function LookupCard({ lookup, currency }: { lookup: Lookup; currency: string })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cited id. Three states, each with a word for screen readers and a mark
|
||||
* for everyone else: planted (✓ on the green ground), not planted (struck
|
||||
* through), and unknown (a plain strong-edged chip — the episode is not
|
||||
* over, or the disposition was not scored).
|
||||
*/
|
||||
function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[0.7rem]',
|
||||
hit === true && 'border-positive/50 bg-positive/10 text-fg',
|
||||
hit === false && 'border-border text-muted line-through decoration-muted/60',
|
||||
(hit === null || hit === undefined) && 'border-border text-fg',
|
||||
'inline-flex items-center gap-1 rounded-tile border px-1.5 py-0.5 font-mono text-label tracking-normal',
|
||||
hit === true && 'border-positive bg-accent-subtle text-fg',
|
||||
hit === false && 'border-border text-muted line-through',
|
||||
(hit === null || hit === undefined) && 'border-border-strong text-fg',
|
||||
)}
|
||||
>
|
||||
{hit === true ? <span aria-hidden="true">✓</span> : null}
|
||||
@@ -315,42 +375,45 @@ function CiteChip({ id, hit }: { id: string; hit?: boolean | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NeverScored() {
|
||||
return <Chip className="text-muted">displayed, never scored</Chip>;
|
||||
}
|
||||
|
||||
function DispositionCard({ state }: { state: TriageState }) {
|
||||
const d = state.disposition;
|
||||
if (!d) return null;
|
||||
const planted = state.reveal ? bestAlternate(d.cites, state.reveal.planted) : null;
|
||||
const scored = state.reveal !== null && state.outcome === 'solved';
|
||||
const prose = (d.action === 'close' ? d.note : d.narrative).trim();
|
||||
return (
|
||||
<Block
|
||||
title={d.action === 'close' ? 'Closed' : 'Escalated'}
|
||||
aside={
|
||||
<span className="font-mono">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>
|
||||
}
|
||||
aside={<span className="text-fg">{d.action === 'close' ? enumLabel(d.reason) : d.typology}</span>}
|
||||
>
|
||||
<p className="mb-1 text-xs text-muted">Cites</p>
|
||||
<p className="mb-1">
|
||||
<Label>Cites</Label>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{d.cites.map((id) => (
|
||||
<CiteChip key={id} id={id} hit={planted && scored ? planted.includes(id) : null} />
|
||||
))}
|
||||
</div>
|
||||
{(d.action === 'close' ? d.note : d.narrative).trim() ? (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 flex items-baseline gap-2 text-xs text-muted">
|
||||
<span>{d.action === 'close' ? 'Note' : 'Narrative'}</span>
|
||||
<span className="rounded border border-border px-1 py-px text-[0.65rem] uppercase tracking-wide">
|
||||
displayed, never scored
|
||||
</span>
|
||||
{prose ? (
|
||||
<div className="mt-2.5">
|
||||
<p className="mb-1 flex flex-wrap items-center gap-2">
|
||||
<Label>{d.action === 'close' ? 'Note' : 'Narrative'}</Label>
|
||||
<NeverScored />
|
||||
</p>
|
||||
<blockquote className="border-l-2 border-border pl-2 text-xs leading-relaxed">
|
||||
{d.action === 'close' ? d.note : d.narrative}
|
||||
</blockquote>
|
||||
<blockquote className="border-l-2 border-border-strong pl-2.5 font-sans text-small text-fg-2">{prose}</blockquote>
|
||||
</div>
|
||||
) : null}
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone: 'positive' | 'danger' | 'warning' } {
|
||||
type Tone = 'positive' | 'danger' | 'warning';
|
||||
|
||||
function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone: Tone } {
|
||||
if (state.status === 'aborted') {
|
||||
return {
|
||||
text: 'No disposition in eight replies. The alert stays in the queue and the episode scores 0.000 on every component.',
|
||||
@@ -359,7 +422,10 @@ function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone:
|
||||
}
|
||||
if (state.outcome === 'solved') {
|
||||
return {
|
||||
text: reveal.label === 'benign' ? 'Right call: the alert was benign and it was closed.' : 'Right call: the alert was suspicious and it was escalated.',
|
||||
text:
|
||||
reveal.label === 'benign'
|
||||
? 'Right call: the alert was benign and it was closed.'
|
||||
: 'Right call: the alert was suspicious and it was escalated.',
|
||||
tone: 'positive',
|
||||
};
|
||||
}
|
||||
@@ -375,103 +441,129 @@ function verdictLine(state: TriageState, reveal: Reveal): { text: string; tone:
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verdict ink sits on surface-2, where all three status colours clear 4.5
|
||||
* in both themes (danger is 4.70 dark — surface-2 is the darkest ground it is
|
||||
* allowed on, see index.css). The sentence begins with its word.
|
||||
*/
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
positive: 'text-positive',
|
||||
danger: 'text-danger',
|
||||
warning: 'text-warning',
|
||||
};
|
||||
|
||||
function RevealCard({ state }: { state: TriageState }) {
|
||||
const reveal = state.reveal;
|
||||
if (!reveal) return null;
|
||||
const cites = state.disposition?.cites ?? [];
|
||||
const best = bestAlternate(cites, reveal.planted);
|
||||
const verdict = verdictLine(state, reveal);
|
||||
const TONE = { positive: 'text-positive', danger: 'text-danger', warning: 'text-warning' } as const;
|
||||
return (
|
||||
<Block
|
||||
title="What the generator planted"
|
||||
aside={
|
||||
<span className="font-mono">
|
||||
<span>
|
||||
{reveal.template}
|
||||
{reveal.overlay ? ` + ${reveal.overlay.replace(/_/g, ' ')}` : ''}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<p className="text-sm">
|
||||
<span className={cn('font-semibold', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
|
||||
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-1 font-sans text-small">
|
||||
<span className={cn('font-mono font-semibold uppercase', reveal.label === 'benign' ? 'text-positive' : 'text-danger')}>
|
||||
{reveal.label}
|
||||
</span>
|
||||
{reveal.typology ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="font-mono text-xs">{reveal.typology}</span>
|
||||
</>
|
||||
) : null}
|
||||
{reveal.typology ? <span className="font-mono text-caption text-fg">{reveal.typology}</span> : null}
|
||||
{reveal.tier === 'hidden' ? (
|
||||
<>
|
||||
<span className="mx-1.5 text-muted">·</span>
|
||||
<span className="text-xs text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
|
||||
</>
|
||||
<span className="text-muted">hidden tier: the screen is a benign case; the signal was only in a lookup</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p className={cn('mt-1 text-xs font-medium', TONE[verdict.tone])} role="status">
|
||||
<p
|
||||
className={cn('mt-2 rounded-control bg-surface-2 px-2.5 py-2 font-sans text-small font-medium', TONE_TEXT[verdict.tone])}
|
||||
role="status"
|
||||
>
|
||||
{verdict.text}
|
||||
</p>
|
||||
<p className="mb-1 mt-2 text-xs text-muted">
|
||||
The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}
|
||||
<p className="mb-1 mt-2.5">
|
||||
<Label>The facts that decide it{reveal.planted.length > 1 ? ' (best of the alternates)' : ''}</Label>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{best.map((id) => (
|
||||
<CiteChip key={id} id={id} hit={cites.includes(id) ? true : null} />
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted">
|
||||
Hours spent <span className="nums text-fg">{hours(state.minutesSpent)}</span>
|
||||
<p className="mt-2.5 flex flex-wrap gap-x-3 gap-y-1 font-mono text-caption text-muted">
|
||||
<span>
|
||||
<Label>Hours spent</Label> <span className="text-fg">{hours(state.minutesSpent)}</span>
|
||||
</span>
|
||||
{reveal.referenceMinutes !== null ? (
|
||||
<>
|
||||
{' '}
|
||||
· cheapest shipped analyst that got it right{' '}
|
||||
<span className="nums text-fg">{hours(reveal.referenceMinutes)}</span>
|
||||
</>
|
||||
<span>
|
||||
<Label>Cheapest shipped analyst that got it right</Label>{' '}
|
||||
<span className="text-fg">{hours(reveal.referenceMinutes)}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
function Meter({ state }: { state: TriageState }) {
|
||||
const STATUS: Record<TriageState['status'], string> = {
|
||||
/**
|
||||
* 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 }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
|
||||
<span>
|
||||
Hours <span className="nums font-semibold text-fg">{hours(state.minutesSpent)}</span>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 font-mono text-caption">
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<Label>Hours</Label>
|
||||
<span className="text-mono-data font-semibold text-fg">{hours(state.minutesSpent)}</span>
|
||||
</span>
|
||||
<span>
|
||||
Replies{' '}
|
||||
<span className="nums font-semibold text-fg">
|
||||
{state.turnsSpent} of {MAX_TURNS}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Label>Replies</Label>
|
||||
<span className="text-mono-data font-semibold text-fg">
|
||||
{state.turnsSpent}
|
||||
<span className="font-normal text-muted"> of {MAX_TURNS}</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5" aria-hidden="true">
|
||||
{Array.from({ length: MAX_TURNS }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-2.5 w-1.5 rounded-[1px] border',
|
||||
i < state.turnsSpent ? 'border-fg bg-fg' : 'border-border-strong bg-transparent',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
{state.rejected > 0 ? (
|
||||
<span>
|
||||
refused <span className="nums font-semibold text-fg">{state.rejected}</span>
|
||||
<span className="flex items-baseline gap-1.5">
|
||||
<Label className="text-warning">Refused</Label>
|
||||
<span className="text-mono-data font-semibold text-warning">{state.rejected}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md border px-1.5 py-px font-medium',
|
||||
state.status === 'playing' && 'border-border',
|
||||
state.status === 'closed' && 'border-positive/50 text-positive',
|
||||
state.status === 'escalated' && 'border-warning/60 text-warning',
|
||||
state.status === 'aborted' && 'border-danger/50 text-danger',
|
||||
)}
|
||||
>
|
||||
{STATUS[state.status]}
|
||||
</span>
|
||||
<Chip className={STATUS_TONE[state.status]}>{STATUS_WORD[state.status]}</Chip>
|
||||
{state.screen.held_out ? (
|
||||
<span className="rounded-md border border-border px-1.5 py-px" title="fnv1a(seed) % 8 == 7: second currency, renamed rules, KYC before the alert">
|
||||
<Chip
|
||||
className="text-fg"
|
||||
title="fnv1a(seed) % 8 == 7: second currency, renamed rules, KYC before the alert"
|
||||
>
|
||||
held-out seed
|
||||
</span>
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -481,8 +573,8 @@ function Meter({ state }: { state: TriageState }) {
|
||||
|
||||
/**
|
||||
* The thumbnail: what the timeline chip and the gallery card show. A whole
|
||||
* screen at 7rem is noise, so this is the shape of the run instead — the rule,
|
||||
* the twelve months of cash as bars, and what has happened so far.
|
||||
* screen at 7.5rem is noise, so this is the shape of the run instead — the
|
||||
* rule, the twelve months of cash as bars, and what has happened so far.
|
||||
*/
|
||||
function CompactBoard({ state }: { state: TriageState }) {
|
||||
const rows = state.screen.summary;
|
||||
@@ -491,42 +583,44 @@ function CompactBoard({ state }: { state: TriageState }) {
|
||||
const label =
|
||||
`${state.screen.alert.rule}, ${hours(state.minutesSpent)} hours, ${state.lookups.length} lookups` +
|
||||
(d ? `, ${d.action === 'close' ? 'closed' : 'escalated'}` : state.status === 'aborted' ? ', aborted' : '');
|
||||
const CHIP = 'rounded-[2px] border px-0.5 leading-[1.3]';
|
||||
return (
|
||||
<div className="w-[7.5rem] text-[9px] leading-tight" role="img" aria-label={label}>
|
||||
<div className="w-[7.5rem] font-mono text-[9px] leading-tight" role="img" aria-label={label}>
|
||||
<SyntheticLabel compact />
|
||||
<div className="mt-1 flex items-baseline justify-between gap-1">
|
||||
<span className="truncate font-mono font-semibold">{state.screen.alert.rule}</span>
|
||||
<span className="nums shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
|
||||
<span className="truncate font-semibold text-fg">{state.screen.alert.rule}</span>
|
||||
<span className="shrink-0 text-muted">{hours(state.minutesSpent)} h</span>
|
||||
</div>
|
||||
<div className="mt-1 flex h-4 items-end gap-px" aria-hidden="true">
|
||||
{rows.map((r) => (
|
||||
<span
|
||||
key={r.month}
|
||||
className="flex-1 rounded-sm bg-muted/50"
|
||||
className="flex-1 rounded-[1px] bg-border-strong"
|
||||
style={{ height: `${Math.max(6, Math.round((r.cash_in / peak) * 100))}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-0.5">
|
||||
{state.lookups.map((l, i) => (
|
||||
<span key={i} className="rounded border border-border px-0.5 text-muted">
|
||||
<span key={i} className={cn(CHIP, 'border-border text-muted')}>
|
||||
{l.what === 'history' ? l.key.slice(5) : l.what === 'counterparty' ? l.key : l.what === 'prior_alerts' ? 'priors' : 'docs'}
|
||||
</span>
|
||||
))}
|
||||
{state.rejected > 0 ? (
|
||||
<span className="rounded border border-warning/60 px-0.5 text-warning">!{state.rejected}</span>
|
||||
<span className={cn(CHIP, 'border-warning text-warning')}>!{state.rejected}</span>
|
||||
) : null}
|
||||
{d ? (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded border px-0.5 font-semibold uppercase',
|
||||
d.action === 'close' ? 'border-positive/50 text-positive' : 'border-warning/60 text-warning',
|
||||
CHIP,
|
||||
'font-semibold uppercase',
|
||||
d.action === 'close' ? 'border-positive text-positive' : 'border-warning text-warning',
|
||||
)}
|
||||
>
|
||||
{d.action === 'close' ? 'close' : 'escalate'}
|
||||
</span>
|
||||
) : state.status === 'aborted' ? (
|
||||
<span className="rounded border border-danger/50 px-0.5 font-semibold uppercase text-danger">aborted</span>
|
||||
<span className={cn(CHIP, 'border-danger font-semibold uppercase text-danger')}>aborted</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -550,13 +644,15 @@ export const Board = memo(function Board({ state, compact }: { state: TriageStat
|
||||
// shrink-to-fit card on every tab, so `w-full` resolves to nothing and
|
||||
// `max-w-full` resolves against a parent that sized itself to this
|
||||
// board — measured: 640px on a 390px phone, 304px of page overflow.
|
||||
// 5rem is the page gutters plus the card padding at the phone breakpoint.
|
||||
// 4.5rem is the phone gutters (2 × 1.25rem) plus the shell card's p-3
|
||||
// (2 × 0.75rem) with half a rem for its borders: the shell's padding
|
||||
// and this constant are coupled and move together.
|
||||
// `max-w-full` is for the one place the parent IS definite: a blind
|
||||
// comparison's grid cell, which is narrower than 40rem at 1280px and
|
||||
// measured 31px of page overflow without it.
|
||||
className="w-[min(40rem,calc(100vw-5rem))] max-w-full min-w-0 space-y-3 text-sm"
|
||||
className="w-[min(40rem,calc(100vw-4.5rem))] min-w-0 max-w-full space-y-3 font-sans text-small text-fg"
|
||||
role="group"
|
||||
aria-label={`Alert ${screen.alert.id} on customer ${screen.customer.id}. ${state.lookups.length} lookups made, ${hours(state.minutesSpent)} hours spent.`}
|
||||
aria-label={`Alert ${screen.alert.id} on customer ${screen.customer.id}. ${state.lookups.length} lookups made, ${hours(state.minutesSpent)} hours spent, ${STATUS_WORD[state.status]}.`}
|
||||
>
|
||||
<SyntheticLabel />
|
||||
<Meter state={state} />
|
||||
@@ -567,8 +663,11 @@ export const Board = memo(function Board({ state, compact }: { state: TriageStat
|
||||
<LookupCard key={`${lookup.what}-${lookup.key ?? i}`} lookup={lookup} currency={screen.currency} />
|
||||
))}
|
||||
{state.lastRejection ? (
|
||||
<p role="status" className="rounded-md border border-warning/60 bg-warning/5 px-3 py-2 text-xs text-warning">
|
||||
Reply refused: {state.lastRejection}. It still cost a turn.
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-control border border-warning bg-warning-subtle px-3 py-2 font-sans text-small text-warning"
|
||||
>
|
||||
<span className="font-mono text-label uppercase">Reply refused</span> — {state.lastRejection}. It still cost a turn.
|
||||
</p>
|
||||
) : null}
|
||||
<DispositionCard state={state} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { play, type BoardState } from './engine';
|
||||
@@ -8,14 +9,16 @@ import { allowedNow, ANSWERS, primeGuessList } from './words';
|
||||
|
||||
const ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
|
||||
|
||||
type TileState = 'G' | 'Y' | 'X';
|
||||
|
||||
/** Best-known state of each letter, for tinting the keys. */
|
||||
function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
|
||||
function letterStates(board: BoardState): Record<string, TileState> {
|
||||
const rank = { X: 0, Y: 1, G: 2 } as const;
|
||||
const out: Record<string, 'G' | 'Y' | 'X'> = {};
|
||||
const out: Record<string, TileState> = {};
|
||||
for (const { guess, pattern } of board.rows) {
|
||||
for (let i = 0; i < guess.length; i += 1) {
|
||||
const letter = guess[i]!;
|
||||
const tile = pattern[i] as 'G' | 'Y' | 'X';
|
||||
const tile = pattern[i] as TileState;
|
||||
const current = out[letter];
|
||||
if (!current || rank[tile] > rank[current]) out[letter] = tile;
|
||||
}
|
||||
@@ -23,12 +26,31 @@ function letterStates(board: BoardState): Record<string, 'G' | 'Y' | 'X'> {
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEY_TINT: Record<string, string> = {
|
||||
G: 'bg-tile-exact text-tile-exact-fg',
|
||||
Y: 'bg-tile-present text-tile-present-fg',
|
||||
X: 'bg-tile-absent/70 text-tile-absent-fg',
|
||||
/**
|
||||
* A used key takes the tile token at FULL opacity. The old `/70` on absent
|
||||
* made the grey key a fourth colour the board never shows; at full strength
|
||||
* the key is the tile, and the ink on it is measured there (7.75 dark, 5.74
|
||||
* light — see index.css).
|
||||
*/
|
||||
const KEY_TINT: Record<TileState, string> = {
|
||||
G: 'bg-tile-exact text-tile-exact-fg hover:bg-tile-exact',
|
||||
Y: 'bg-tile-present text-tile-present-fg hover:bg-tile-present',
|
||||
X: 'bg-tile-absent text-tile-absent-fg hover:bg-tile-absent',
|
||||
};
|
||||
|
||||
/** The board's high-contrast glyphs, repeated on the keys so the keyboard has the same second channel. */
|
||||
const KEY_GLYPH: Record<TileState, string> = { G: '●', Y: '◆', X: '' };
|
||||
|
||||
/**
|
||||
* One key. Letter keys are `flex-1` with no minimum width: ten of them plus
|
||||
* nine gaps have to fit 326px on a phone, which is roughly 29px each — the
|
||||
* 44px target is the height. ENTER and DEL are wider and set in mono, the
|
||||
* way a command is.
|
||||
*/
|
||||
const KEY =
|
||||
'min-h-11 min-w-0 select-none rounded-control transition-[background-color,color,transform] duration-1 ease-enter ' +
|
||||
'motion-safe:active:scale-[0.94] disabled:pointer-events-none disabled:opacity-40';
|
||||
|
||||
export function Keyboard({
|
||||
state,
|
||||
onChange,
|
||||
@@ -69,7 +91,7 @@ export function Keyboard({
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && /^(INPUT|TEXTAREA)$/.test(target.tagName)) return;
|
||||
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) return;
|
||||
if (event.key === 'Enter') press('enter');
|
||||
else if (event.key === 'Backspace') press('back');
|
||||
else if (/^[a-zA-Z]$/.test(event.key)) press(event.key.toLowerCase());
|
||||
@@ -85,36 +107,37 @@ export function Keyboard({
|
||||
return (
|
||||
// Own cap, not the shell's. Unconstrained, these flex rows stretch to the
|
||||
// page and the keys drift a screen away from the board they belong to.
|
||||
<div className="grid w-full max-w-md gap-1 sm:gap-1.5" style={{ paddingBottom: 'max(0px, var(--safe-bottom))' }}>
|
||||
<div
|
||||
// 28rem caps a letter key at 39px on a 1024px iPad; a coarse pointer
|
||||
// gets 32rem, which is the narrowest width at which ten keys and nine
|
||||
// 6px gaps are all 44px. The phone never reaches either cap.
|
||||
className="grid w-full max-w-md gap-1 pb-[max(0px,var(--safe-bottom))] sm:gap-1.5 [@media(pointer:coarse)]:max-w-lg"
|
||||
>
|
||||
{/* The solver, live, on whatever word you picked — not a recording. This
|
||||
is the difference between a demo and a video: it answers for boards no
|
||||
rollout on this site ever covered. */}
|
||||
{hint.status !== 'unavailable' && !done ? (
|
||||
<div className="mb-1 flex min-h-9 flex-wrap items-center gap-2 text-xs">
|
||||
<button
|
||||
<div className="mb-1 flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="touch"
|
||||
onClick={() => solver.ask(state.rows)}
|
||||
disabled={hint.status === 'thinking'}
|
||||
className="tap inline-flex items-center rounded-md border border-border px-2.5 py-1 font-medium transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-50"
|
||||
>
|
||||
{hint.status === 'thinking' ? 'Thinking…' : 'What would the best player guess?'}
|
||||
</button>
|
||||
<span aria-live="polite" className="text-muted">
|
||||
</Button>
|
||||
<span aria-live="polite" className="font-mono text-mono-data text-muted">
|
||||
{hint.status === 'ready' ? (
|
||||
<>
|
||||
<span className="font-mono font-semibold uppercase text-fg">
|
||||
{hint.suggestion.guess}
|
||||
</span>{' '}
|
||||
— <span className="nums">{hint.suggestion.bits.toFixed(2)}</span> bits from{' '}
|
||||
<span className="nums">{hint.suggestion.candidatesBefore.toLocaleString('en-US')}</span>{' '}
|
||||
remaining
|
||||
<span className="font-semibold uppercase text-fg">{hint.suggestion.guess}</span>
|
||||
{' — '}
|
||||
{hint.suggestion.bits.toFixed(2)} bits from{' '}
|
||||
{hint.suggestion.candidatesBefore.toLocaleString('en-US')} remaining
|
||||
{hint.suggestion.viable ? null : (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span className="text-warning">
|
||||
cannot win — it is buying information
|
||||
</span>
|
||||
{' · '}
|
||||
<span className="text-warning">cannot win — it is buying information</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -129,12 +152,17 @@ export function Keyboard({
|
||||
type="button"
|
||||
onClick={() => press('enter')}
|
||||
disabled={done}
|
||||
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
|
||||
className={cn(
|
||||
KEY,
|
||||
'flex-[1.6] bg-surface-2 px-1 font-mono text-label uppercase text-fg hover:bg-surface-3',
|
||||
)}
|
||||
>
|
||||
Enter
|
||||
</button>
|
||||
) : null}
|
||||
{[...row].map((letter) => (
|
||||
{[...row].map((letter) => {
|
||||
const used = states[letter];
|
||||
return (
|
||||
<button
|
||||
key={letter}
|
||||
type="button"
|
||||
@@ -142,20 +170,33 @@ export function Keyboard({
|
||||
disabled={done}
|
||||
aria-label={letter.toUpperCase()}
|
||||
className={cn(
|
||||
'tap min-w-0 flex-1 rounded-md text-sm font-semibold uppercase transition-colors duration-1 disabled:opacity-40',
|
||||
states[letter] ? KEY_TINT[states[letter]!] : 'bg-surface-2 hover:bg-accent-subtle',
|
||||
KEY,
|
||||
'relative flex-1 font-sans text-[0.8125rem] font-semibold uppercase',
|
||||
used ? KEY_TINT[used] : 'bg-surface-2 text-fg hover:bg-surface-3',
|
||||
)}
|
||||
>
|
||||
{letter}
|
||||
{used && KEY_GLYPH[used] ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute bottom-0.5 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-100"
|
||||
>
|
||||
{KEY_GLYPH[used]}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{index === 2 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => press('back')}
|
||||
disabled={done}
|
||||
aria-label="Backspace"
|
||||
className="tap flex-[1.6] rounded-md bg-surface-2 px-1 text-[0.65rem] font-semibold uppercase tracking-wide transition-colors duration-1 hover:bg-accent-subtle disabled:opacity-40"
|
||||
className={cn(
|
||||
KEY,
|
||||
'flex-[1.6] bg-surface-2 px-1 font-mono text-label uppercase text-fg hover:bg-surface-3',
|
||||
)}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -7,9 +7,22 @@ import { MAX_GUESSES, WORD_LENGTH, type BoardState, type Pattern } from './engin
|
||||
/**
|
||||
* The board. One component for all three jobs — you playing, the replay, and
|
||||
* the gallery thumbnail — because three near-identical boards is how they drift.
|
||||
*
|
||||
* It draws no outer chrome: the shell's card supplies the border, ground and
|
||||
* padding. Every colour is a `tile-*` token, which is what lets the
|
||||
* high-contrast palette swap the fills without this file knowing.
|
||||
*/
|
||||
|
||||
const TILE_CLASS: Record<string, string> = {
|
||||
type TileState = 'G' | 'Y' | 'X';
|
||||
|
||||
/**
|
||||
* Fill, border and glyph ink per state. The fills are the same in both themes
|
||||
* (the board is the one theme-invariant object on the site) and the `-fg`
|
||||
* tokens are measured against their own fill: exact 13.08, present 11.16,
|
||||
* absent 7.75 dark / 5.74 light; in high contrast white on each fill clears
|
||||
* 4.9 or better. The numbers live beside the tokens in index.css.
|
||||
*/
|
||||
const TILE_CLASS: Record<TileState, string> = {
|
||||
G: 'bg-tile-exact text-tile-exact-fg border-tile-exact',
|
||||
Y: 'bg-tile-present text-tile-present-fg border-tile-present',
|
||||
X: 'bg-tile-absent text-tile-absent-fg border-tile-absent',
|
||||
@@ -18,12 +31,40 @@ const TILE_CLASS: Record<string, string> = {
|
||||
/**
|
||||
* A glyph per state, shown only in high-contrast mode.
|
||||
*
|
||||
* Green/yellow/grey is a colour-only distinction, which is exactly why the
|
||||
* Green/amber/grey is a colour-only distinction, which is exactly why the
|
||||
* original game ships a high-contrast mode. A second channel means the result
|
||||
* survives deuteranopia, a projector with the colour balance wrong, and a
|
||||
* screenshot printed in black and white.
|
||||
* screenshot printed in black and white. Absent has no glyph: a filled grey
|
||||
* tile with nothing in the corner is the third state.
|
||||
*/
|
||||
const TILE_GLYPH: Record<string, string> = { G: '●', Y: '◆', X: '' };
|
||||
const TILE_GLYPH: Record<TileState, string> = { G: '●', Y: '◆', X: '' };
|
||||
|
||||
/** Flip length and per-tile stagger, matching the keyframes in index.css. */
|
||||
const FLIP_MS = 520;
|
||||
const STAGGER_MS = 100;
|
||||
|
||||
/**
|
||||
* Whether a revealing tile has reached the half-way point of its flip.
|
||||
*
|
||||
* The real flip lands the colour at 90°, when the face is edge-on: before that
|
||||
* the tile still looks typed, after it the tile is scored. Applying the colour
|
||||
* on mount and rotating a coloured tile reads as a wobble, not a reveal. Under
|
||||
* reduced motion the colour lands immediately — there is no flip to hide it
|
||||
* behind, and the row's announcement carries the result regardless.
|
||||
*/
|
||||
function useLanded(revealing: boolean, index: number): boolean {
|
||||
const [landed, setLanded] = useState(!revealing);
|
||||
useEffect(() => {
|
||||
if (!revealing) return;
|
||||
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setLanded(true);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setLanded(true), index * STAGGER_MS + FLIP_MS / 2);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [revealing, index]);
|
||||
return landed;
|
||||
}
|
||||
|
||||
function Tile({
|
||||
letter,
|
||||
@@ -33,35 +74,45 @@ function Tile({
|
||||
compact,
|
||||
}: {
|
||||
letter: string;
|
||||
tile: Pattern[number] | null;
|
||||
tile: TileState | null;
|
||||
index: number;
|
||||
revealing: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const filled = letter !== '';
|
||||
const landed = useLanded(revealing && tile !== null, index);
|
||||
const scored = tile !== null && landed;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative grid place-items-center select-none font-semibold uppercase',
|
||||
'aspect-square rounded-md border-2 transition-colors',
|
||||
compact ? 'text-[0.55rem] border' : 'text-xl sm:text-2xl',
|
||||
tile
|
||||
'relative grid select-none place-items-center font-sans font-semibold uppercase',
|
||||
'aspect-square rounded-tile transition-colors duration-1',
|
||||
compact ? 'border text-[0.55rem]' : 'border-2 text-[1.375rem] sm:text-2xl',
|
||||
scored
|
||||
? TILE_CLASS[tile]
|
||||
: filled
|
||||
? 'border-muted/60 bg-surface text-fg'
|
||||
: 'border-border bg-surface-2/40 text-fg',
|
||||
// The flip is a rotation about X with the colour landing at the
|
||||
// half-way point, staggered along the row. `prefers-reduced-motion`
|
||||
? // Typed, unscored: a strong edge says "this is yours", the letter is
|
||||
// plain ink. No fill, so the scored fill is an unmistakable change.
|
||||
'border-border-strong bg-transparent text-fg'
|
||||
: 'border-tile-empty bg-transparent text-fg',
|
||||
// The flip is a rotation about X, staggered along the row; the colour
|
||||
// lands at the half-way point (see useLanded). `prefers-reduced-motion`
|
||||
// clamps it to nothing globally, which is why announceRow() exists.
|
||||
revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
|
||||
filled && !tile && 'motion-safe:animate-[tile-pop_120ms_ease-enter]',
|
||||
)}
|
||||
style={revealing ? { animationDelay: `${index * 100}ms` } : undefined}
|
||||
style={revealing && tile ? { animationDelay: `${index * STAGGER_MS}ms` } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{letter}
|
||||
{tile ? (
|
||||
<span className="pointer-events-none absolute bottom-0 right-0.5 text-[0.5em] leading-none opacity-0 [:root[data-contrast='high']_&]:opacity-90">
|
||||
{scored ? (
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-0 right-0.5 leading-none opacity-0',
|
||||
"[:root[data-contrast='high']_&]:opacity-100",
|
||||
compact ? 'text-[0.6em]' : 'text-[0.5em]',
|
||||
)}
|
||||
>
|
||||
{TILE_GLYPH[tile]}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -83,19 +134,25 @@ function Row({
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const letters = guess.padEnd(WORD_LENGTH, ' ').slice(0, WORD_LENGTH);
|
||||
// The shake is armed by the prop and disarmed by its own end event, so a
|
||||
// second refusal shakes the row again instead of leaving the class in place.
|
||||
const [shaking, setShaking] = useState(false);
|
||||
useEffect(() => {
|
||||
if (shake) setShaking(true);
|
||||
}, [shake]);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-1 sm:gap-1.5',
|
||||
shake && 'motion-safe:animate-[tile-shake_600ms_ease-enter]',
|
||||
)}
|
||||
className={cn('grid gap-1 sm:gap-1.5', shaking && 'motion-safe:animate-[tile-shake_400ms_ease-enter]')}
|
||||
style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.animationName === 'tile-shake') setShaking(false);
|
||||
}}
|
||||
>
|
||||
{[...letters].map((letter, i) => (
|
||||
<Tile
|
||||
key={i}
|
||||
letter={letter.trim()}
|
||||
tile={pattern ? (pattern[i] as Pattern[number]) : null}
|
||||
tile={pattern ? (pattern[i] as TileState) : null}
|
||||
index={i}
|
||||
revealing={revealing}
|
||||
compact={compact}
|
||||
@@ -105,6 +162,14 @@ function Row({
|
||||
);
|
||||
}
|
||||
|
||||
function boardLabel(state: BoardState): string {
|
||||
const played = state.rows.length;
|
||||
if (state.status === 'won') return `Solved in ${played} of ${MAX_GUESSES} guesses.`;
|
||||
if (state.status === 'lost') return `All ${MAX_GUESSES} guesses played, not solved.`;
|
||||
if (played === 0) return `Empty board, ${MAX_GUESSES} guesses remaining.`;
|
||||
return `${played} of ${MAX_GUESSES} guesses played.`;
|
||||
}
|
||||
|
||||
export const Board = memo(function Board({
|
||||
state,
|
||||
compact,
|
||||
@@ -119,26 +184,23 @@ export const Board = memo(function Board({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-1 sm:gap-1.5',
|
||||
'grid',
|
||||
// An explicit width, not `w-full max-w-sm`. An empty board has no
|
||||
// intrinsic width, so inside a shrink-to-fit parent `w-full` resolves
|
||||
// to nothing and the board collapses to a stub — which is exactly the
|
||||
// state the interactive board starts in.
|
||||
compact ? 'w-[7rem]' : 'w-[min(100%,20rem)] min-w-[15rem]',
|
||||
compact ? 'w-[7rem] gap-0.5' : 'w-[min(100%,20rem)] min-w-[15rem] gap-1 sm:gap-1.5',
|
||||
)}
|
||||
role="img"
|
||||
aria-label={
|
||||
rows.length === 0
|
||||
? 'Empty board, six guesses remaining.'
|
||||
: `${rows.length} of ${MAX_GUESSES} guesses played.`
|
||||
}
|
||||
aria-label={boardLabel(state)}
|
||||
>
|
||||
{rows.map((row, i) => (
|
||||
<Row
|
||||
key={`${row.guess}-${i}`}
|
||||
guess={row.guess}
|
||||
pattern={row.pattern}
|
||||
revealing={i === rows.length - 1}
|
||||
// The thumbnail is the shape of the run, not a replay: no flip there.
|
||||
revealing={!compact && i === rows.length - 1}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -2,41 +2,107 @@
|
||||
@tailwind components;
|
||||
@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-family: 'Manrope Variable';
|
||||
font-family: 'Geist Variable';
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 200 800;
|
||||
src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations');
|
||||
font-weight: 100 900;
|
||||
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
|
||||
* onto the root at runtime. This site has no user-chosen accent, so a runtime
|
||||
* theme module would be a moving part that buys nothing here.
|
||||
* PIG's token layer, inlined as literals rather than written onto the root at
|
||||
* runtime. This site has no user-chosen accent, so a runtime theme module
|
||||
* would be a moving part that buys nothing here.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
--bg: 240 5% 98%;
|
||||
--surface: 0 0% 100%;
|
||||
--surface-2: 240 5% 97%;
|
||||
--border: 240 6% 90%;
|
||||
--fg: 240 10% 4%;
|
||||
--muted: 240 4% 46%;
|
||||
/* ---------------------------------------------------------- light grounds */
|
||||
--bg: 0 0% 98%; /* #FAFAFA */
|
||||
--surface: 0 0% 100%; /* #FFFFFF cards, header, panels */
|
||||
--surface-2: 0 0% 96%; /* #F5F5F5 insets, idle keys, chips, row hover */
|
||||
--surface-3: 0 0% 93%; /* #EDEDED code grounds, table heads, active tab, surface-2 hover */
|
||||
--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%;
|
||||
--accent-fg: 263 70% 46%;
|
||||
--accent-on: 0 0% 100%;
|
||||
--accent-subtle: 270 100% 97%;
|
||||
/* -------------------------------------------------------------- light ink */
|
||||
--fg: 0 0% 6%; /* #0F0F0F headings, claims, values: 19.17 on surface */
|
||||
--fg-2: 0 0% 22%; /* #383838 prose ink: 11.73 on surface, 9.92 on danger-subtle */
|
||||
--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%;
|
||||
--warning: 32 95% 31%;
|
||||
--danger: 0 72% 45%;
|
||||
--info: 201 90% 32%;
|
||||
--shadow: 240 10% 4%;
|
||||
/* ------------------------------------------------------------ light accent */
|
||||
--accent: 112 77% 69%; /* #83ED73 FILL ONLY — 1.47 on white as text, forbidden as text in light */
|
||||
--accent-fg: 112 60% 27%; /* #276E1C accent as text/icon/link: 6.30 on surface, 6.04 on bg, 5.33 on danger-subtle */
|
||||
--accent-on: 0 0% 6%; /* #0F0F0F text on an accent fill: 13.08 */
|
||||
--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
|
||||
@@ -44,30 +110,44 @@
|
||||
* 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.
|
||||
*
|
||||
* The present tile does NOT reuse --warning. That token is 32 95% 31%, darkened
|
||||
* until white text clears 4.5:1 on it, and at that lightness it reads BROWN.
|
||||
* On a board where people arrive with a hard prior that this square is yellow,
|
||||
* a brown square reads as a bug in the scorer rather than as a design choice —
|
||||
* which on a page whose whole argument is "the grader is correct" is the
|
||||
* 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.
|
||||
* The exact and present fills are the SAME in both themes: the board is the
|
||||
* one theme-invariant object on the site. A green-and-amber board on a white
|
||||
* page is deliberate and will be called "loud" on the first screenshot; the
|
||||
* answer is the glyph contrast below and the high-contrast mode. Do not
|
||||
* soften the fills.
|
||||
*
|
||||
* Measured against each tile's own fill:
|
||||
* exact white on 160 84% 24% -> 6.03:1
|
||||
* present near-black on 45 93% 47% -> 10.02:1
|
||||
* absent white on 240 4% 46% -> 4.61:1
|
||||
* The present tile does NOT reuse --warning. That token is darkened until
|
||||
* white text clears 4.5:1 on it, and at that lightness it reads BROWN. On a
|
||||
* board where people arrive with a hard prior that this square is yellow, a
|
||||
* 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-fg: 0 0% 100%;
|
||||
--tile-present: 45 93% 47%;
|
||||
--tile-present-fg: 240 10% 4%;
|
||||
--tile-absent: 240 4% 46%;
|
||||
--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% 40%;
|
||||
--tile-absent-fg: 0 0% 100%;
|
||||
--tile-empty-border: 0 0% 78%; /* #C7C7C7 unplayed tile edge: 1.69 vs surface */
|
||||
|
||||
--dur-1: 120ms;
|
||||
--dur-2: 180ms;
|
||||
--dur-3: 240ms;
|
||||
/* ------------------------------------------------------------- non-colour */
|
||||
--radius-control: 0.375rem; /* 6px: buttons, inputs, tabs, selects, chips */
|
||||
--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-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
|
||||
@@ -76,62 +156,98 @@
|
||||
--safe-left: env(safe-area-inset-left, 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));
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root { --app-header-h: calc(4rem + var(--safe-top)); }
|
||||
}
|
||||
|
||||
: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%;
|
||||
/* Overlays only — sheet, drawer, tooltip, navigation panel. Flat components
|
||||
(buttons, tabs, cards, chips) carry no shadow; their borders do the work. */
|
||||
--shadow-overlay: 0 8px 24px hsl(var(--shadow) / 0.12);
|
||||
}
|
||||
|
||||
/*
|
||||
* High contrast. Green/yellow/grey is a colour-only distinction, which is
|
||||
* exactly why the original game ships a high-contrast mode. Toggled from the
|
||||
* header and persisted per-viewer; the tiles also carry a glyph in this mode,
|
||||
* Dark — the home theme. Grounds and greys are Prime Intellect's measured
|
||||
* values (#0F0F0F, #141414, #1C1C1C, #242424, #F5F5F5). Two of their greys
|
||||
* 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
|
||||
* 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'] {
|
||||
--tile-exact: 209 100% 40%;
|
||||
--tile-present: 27 100% 42%;
|
||||
--tile-absent: 240 4% 38%;
|
||||
--tile-present: 27 100% 36%;
|
||||
--tile-absent: 0 0% 38%;
|
||||
--tile-exact-fg: 0 0% 100%;
|
||||
--tile-present-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 {
|
||||
* { @apply border-border; }
|
||||
@@ -140,8 +256,12 @@
|
||||
/* Painted explicitly: the viewer's chrome shows through a transparent body
|
||||
and the page would borrow the host's theme instead of its own. */
|
||||
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; }
|
||||
: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; }
|
||||
}
|
||||
|
||||
/*
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* drift from.
|
||||
* drift from. Pop 120ms, flip 520ms staggered 100ms/tile, shake 400ms.
|
||||
*/
|
||||
@keyframes tile-flip {
|
||||
0% { transform: rotateX(0deg); }
|
||||
|
||||
@@ -1,11 +1,62 @@
|
||||
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
|
||||
* (`tailwind-merge/v3` or the `createTailwindMerge` split entry) resolves at
|
||||
* type level and then fails at bundle time, so keep this import bare.
|
||||
* The shadcn `cn`, on a `twMerge` that knows this project's theme.
|
||||
*
|
||||
* `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 {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { DefinitionStrip } from '@/components/site/DefinitionStrip';
|
||||
import {
|
||||
ALL_VERTICALS,
|
||||
EnvironmentGrid,
|
||||
EnvironmentList,
|
||||
FeaturedEnvironment,
|
||||
environments,
|
||||
filterKeys,
|
||||
VerticalFilter,
|
||||
type VerticalFilterValue,
|
||||
} 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 { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
|
||||
import type { Vertical } from '@/lib/demo-kit/types';
|
||||
import * as s from '@/content/styles';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
/**
|
||||
* The fuller list.
|
||||
*
|
||||
* 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
|
||||
* into describing the same environment two different ways — which is how an
|
||||
* honest site quietly becomes a dishonest one.
|
||||
* by industry. The built environments are the same `FeaturedEnvironment` cards
|
||||
* and the written ones the same `EnvironmentRow` rows, so the two pages cannot
|
||||
* 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() {
|
||||
useSeo({
|
||||
title: pageTitle('Every environment'),
|
||||
@@ -74,14 +64,15 @@ export default function Gallery() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`${s.shell} py-10 sm:py-16`}>
|
||||
<p className={s.eyebrow}>Gallery</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-3xl`}>Every environment we have built or specified.</h1>
|
||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
||||
A built environment is playable in this tab, on the same code the repository ships. A spec is
|
||||
a written environment — task, action set, grader, counterweight and the command that
|
||||
evaluates it — published in full, with no interactive surface yet. There are no coming-soon
|
||||
cards here.
|
||||
<main className={`${s.shell} py-10 sm:py-14`}>
|
||||
<p className={s.label}>
|
||||
Gallery · {pad2(environments.length)} environments
|
||||
</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-[24ch]`}>Every environment we have built or specified.</h1>
|
||||
<p className={`${s.lede} mt-4`}>
|
||||
A built environment is playable in this tab, on the code the repository ships. A spec is a
|
||||
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>
|
||||
|
||||
{filterKeys.length > 1 ? (
|
||||
@@ -95,14 +86,21 @@ export default function Gallery() {
|
||||
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.
|
||||
*/}
|
||||
<p aria-live="polite" className="mt-6 text-sm text-muted">
|
||||
{shown.length === 1 ? '1 environment' : `${shown.length} environments`}
|
||||
{active === ALL_VERTICALS ? '' : ` in ${VERTICAL_LABELS[active]}`}
|
||||
{shown.length === 0 ? '' : ` · ${built.length} built · ${written.length} written`}
|
||||
<p aria-live="polite" className={`${s.label} mt-6`}>
|
||||
{[
|
||||
`${pad2(shown.length)} ${shown.length === 1 ? 'environment' : 'environments'}`,
|
||||
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>
|
||||
|
||||
{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
|
||||
under this vertical" when they have not filtered anything reads as
|
||||
a broken page rather than an empty one. */}
|
||||
@@ -129,15 +127,17 @@ export default function Gallery() {
|
||||
) : null}
|
||||
|
||||
{built.length > 0 ? (
|
||||
<section aria-labelledby="built" className="mt-4">
|
||||
<section aria-labelledby="built" className="mt-6">
|
||||
<h2 className={s.h2} id="built">
|
||||
{built.length === 1 ? 'The one you can play' : 'The ones you can play'}
|
||||
</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) => (
|
||||
<FeaturedEnvironment entry={entry} key={entry.id} />
|
||||
<li className="flex" key={entry.id}>
|
||||
<FeaturedEnvironment className="w-full" entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -147,32 +147,31 @@ export default function Gallery() {
|
||||
<h2 className={s.h2} id="written">
|
||||
{written.length === 1 ? 'The one that is written' : `The ${written.length} that are written`}
|
||||
</h2>
|
||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
||||
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
<div className="card mt-12 p-5 sm:p-7">
|
||||
<h2 className={s.h2}>What a spec has to contain</h2>
|
||||
<p className={`${s.prose} mt-3 max-w-2xl`}>
|
||||
{/* What a spec has to contain — the same four parts as everywhere else,
|
||||
read from the same source, so this page cannot teach a fifth word. */}
|
||||
<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
|
||||
written environment above states all four of these, and the reward weights that go with
|
||||
them, before it is allowed on this page.
|
||||
written environment above states all four of these, and the counterweight that stops the
|
||||
score being farmed, before it is allowed on this page.
|
||||
</p>
|
||||
<ol className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{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>
|
||||
<DefinitionStrip className="mt-6" />
|
||||
<Link className={`${s.btnSecondary} mt-6`} to={routes.honesty}>
|
||||
What we measured, and what we didn’t
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,208 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
||||
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 {
|
||||
EnvironmentGrid,
|
||||
EnvironmentList,
|
||||
FeaturedEnvironment,
|
||||
builtEnvironments,
|
||||
environments,
|
||||
writtenEnvironments,
|
||||
} 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 { routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||
import { CountWord, countWord, pad2, routes } from '@/content/lineup';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* an essay, and an executive who has read the first sentence has already
|
||||
* decided whether to click something. So the picker is the primary object here
|
||||
* and everything that used to lead — the credential, the measured number — sits
|
||||
* underneath it as support for a visitor who wants it before they click.
|
||||
* The h1 is the site's thesis and it is jargon on its own, so the lede opens
|
||||
* with the plain definition and the four-part strip under it names the parts
|
||||
* a visitor will meet again on every Play and Evidence tab. Then the picker,
|
||||
* then the machine explained once with the live demo's own numbers, then the
|
||||
* twelve written environments as a list, then the evidence.
|
||||
*
|
||||
* Counts in the copy are derived from the registry and the lineup, never typed.
|
||||
* A hard-coded "eleven" on a site whose whole argument is that its claims are
|
||||
* checkable goes stale the first time a demo ships.
|
||||
* Counts in the copy are derived from the registry and the lineup, never
|
||||
* typed. The three figures in "How it works" are read off the featured demo's
|
||||
* 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() {
|
||||
// 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
|
||||
// document, this covers the visitor who arrives on another route and clicks
|
||||
// through without ever fetching a new one. Prerender prefers whatever the
|
||||
// app set, so this is also the single source for the baked tags.
|
||||
// through without ever fetching a new one.
|
||||
useSeo({
|
||||
title: pageTitle(),
|
||||
description:
|
||||
@@ -40,75 +211,210 @@ export default function Home() {
|
||||
ogImage: '/og/home.png',
|
||||
});
|
||||
|
||||
const [featured, ...otherBuilt] = builtEnvironments;
|
||||
const featured = builtEnvironments[0];
|
||||
const builtCount = builtEnvironments.length;
|
||||
const writtenCount = writtenEnvironments.length;
|
||||
const totalCount = environments.length;
|
||||
|
||||
// Written out rather than interpolated inline so the zero and the singular
|
||||
// both read as English. Both numbers come from the data — `VERTICALS` has
|
||||
// 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
|
||||
// not stay that way, and the sentence has to survive the day it changes.
|
||||
const builtSentence =
|
||||
builtCount === 1
|
||||
? 'One environment is built and playable in this tab.'
|
||||
: `${builtCount} environments are built and playable in this tab.`;
|
||||
const writtenSentence =
|
||||
writtenCount === 1
|
||||
? 'One more is a written specification:'
|
||||
: `The other ${writtenCount} are written specifications:`;
|
||||
const [howRef, howNear] = useNearViewport<HTMLElement>();
|
||||
const { module, verify } = useFigures(featured?.slug ?? null, howNear);
|
||||
|
||||
// The mono eyebrow. Zero segments are dropped rather than printed as `00`.
|
||||
const eyebrow = [
|
||||
'Environments',
|
||||
builtCount > 0 ? `${pad2(builtCount)} live` : null,
|
||||
writtenCount > 0 ? `${pad2(writtenCount)} spec` : null,
|
||||
]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(' · ');
|
||||
|
||||
// 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 (
|
||||
<main>
|
||||
{/* ── The thesis, in two sentences ───────────────────────────────── */}
|
||||
<section className={`${s.shell} pt-10 sm:pt-16`}>
|
||||
<p className={s.eyebrow}>Environments, demonstrated</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-4xl`}>
|
||||
{/* ── 1. Masthead ─────────────────────────────────────────────────── */}
|
||||
<section className={`${s.shell} pt-10 sm:pt-14`}>
|
||||
<SectionLabel>{eyebrow}</SectionLabel>
|
||||
<h1 className={`${s.display} mt-3 max-w-[20ch]`}>
|
||||
An environment is an eval you can take the gradient of.
|
||||
</h1>
|
||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
||||
You write down what good means, in code, and then you train against that number and watch
|
||||
it move — or watch it sit still, which you found out in an afternoon instead of a quarter.
|
||||
<p className={`${s.lede} mt-5`}>
|
||||
A task, the moves that are allowed, a grader that computes instead of judging, and a score
|
||||
that moves when the model gets better. {builtClause}
|
||||
{writtenClause}.
|
||||
</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>
|
||||
|
||||
{/* ── The picker: the built one ──────────────────────────────────── */}
|
||||
<section aria-labelledby="pick" className={`${s.shell} mt-10 sm:mt-14`}>
|
||||
<h2 className={s.h2} id="pick">
|
||||
{/* ── 2. Pick one ─────────────────────────────────────────────────── */}
|
||||
<section aria-labelledby="pick" className={`${s.shell} ${s.section}`} id="play">
|
||||
<SectionLabel>Playable</SectionLabel>
|
||||
<h2 className={`${s.h2} mt-2`} id="pick">
|
||||
Pick one. You are playing it in a click.
|
||||
</h2>
|
||||
<p className={`${s.prose} mt-3 max-w-3xl`}>
|
||||
{builtSentence}{' '}
|
||||
{writtenCount === 0 ? null : (
|
||||
<>
|
||||
{writtenSentence} the task, the grader, the counterweight that stops the grader being
|
||||
farmed, and the command that evaluates it. Nothing here is a coming-soon card.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className={`${s.prose} mt-3`}>{pickSentence}</p>
|
||||
|
||||
{featured ? <FeaturedEnvironment className="mt-6" entry={featured} /> : null}
|
||||
{otherBuilt.length > 0 ? <EnvironmentGrid className="mt-4" entries={otherBuilt} /> : null}
|
||||
{builtCount > 0 ? (
|
||||
<ul
|
||||
className={`mt-6 grid gap-4 ${
|
||||
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>
|
||||
|
||||
{/* ── 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. */}
|
||||
{/* ── 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<Passage
|
||||
figure={
|
||||
<Figure caption={module ? `${module.meta.title} · shipped weights` : 'shipped weights'}>
|
||||
<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>
|
||||
|
||||
{/* ── 4. Written but not built ────────────────────────────────────── */}
|
||||
{writtenCount === 0 ? null : (
|
||||
<section aria-labelledby="written" className={`${s.shell} mt-10 sm:mt-14`}>
|
||||
<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.proposalPill}>{PROPOSAL_NOTICE}</span>
|
||||
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||
</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 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>
|
||||
|
||||
<EnvironmentGrid className="mt-6" entries={writtenEnvironments} />
|
||||
{/* 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
|
||||
@@ -117,35 +423,36 @@ export default function Home() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── The support: why this game, and the one measured number ────── */}
|
||||
{/* ── 5. Evidence ─────────────────────────────────────────────────── */}
|
||||
<section aria-labelledby="evidence" className={`${s.shell} ${s.section}`}>
|
||||
<h2 className="sr-only" id="evidence">
|
||||
Why this environment, and what has been measured on it
|
||||
</h2>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="card p-5 sm:p-7">
|
||||
<p className={s.eyebrow}>Why a word game</p>
|
||||
<h3 className="mt-2 text-xl font-bold tracking-tight text-fg sm:text-2xl">
|
||||
We didn’t pick a game. We picked theirs.
|
||||
</h3>
|
||||
<div className="card p-4 sm:p-5">
|
||||
<SectionLabel>Why a word game</SectionLabel>
|
||||
<h3 className={`${s.h3} mt-2`}>We didn’t pick a game. We picked theirs.</h3>
|
||||
<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
|
||||
optimises prompts against.
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
<ul className="mt-4 divide-y divide-border">
|
||||
{helloWorldCitations.map((c) => (
|
||||
<li key={c.href}>
|
||||
<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}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<ArrowUpRight aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-brand" />
|
||||
<span>
|
||||
<span className="font-mono text-[0.8125rem] text-fg">{c.label}</span>
|
||||
<span className="block">{c.claim}</span>
|
||||
<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>
|
||||
@@ -154,26 +461,25 @@ export default function Home() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card p-5 sm:p-7">
|
||||
<p className={s.eyebrow}>Published by Prime Intellect</p>
|
||||
<p className="nums mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="text-4xl font-extrabold tracking-tight text-muted sm:text-5xl">
|
||||
{trainingResult.before}
|
||||
</span>
|
||||
<ArrowRight aria-hidden="true" className="size-6 shrink-0 text-muted" />
|
||||
<span className="text-4xl font-extrabold tracking-tight text-positive sm:text-5xl">
|
||||
{trainingResult.after}
|
||||
</span>
|
||||
{/* A citation of a published result, worded as one. No logo, no
|
||||
"partner", nothing that reads as endorsement. */}
|
||||
<div className="card p-4 sm:p-5">
|
||||
<SectionLabel>Published by Prime Intellect</SectionLabel>
|
||||
<p className="mt-3 flex flex-wrap items-baseline gap-x-3 gap-y-1 font-mono">
|
||||
<span className="text-figure text-muted lg:text-figure-lg">{trainingResult.before}</span>
|
||||
<ArrowRight aria-hidden="true" className="size-6 shrink-0 self-center text-border-strong" />
|
||||
<span className="text-figure text-positive lg:text-figure-lg">{trainingResult.after}</span>
|
||||
</p>
|
||||
<p className={`${s.prose} mt-3`}>
|
||||
{trainingResult.model} {trainingResult.metric} on this task, before and after training.
|
||||
Out of the box it never once guesses the word; after an {trainingResult.method}, it
|
||||
wins about six games in ten. Measured on {trainingResult.evalDescription}, and both
|
||||
checkpoints are public, so the claim is checkable rather than quotable.
|
||||
<span className="font-mono text-mono-data text-fg">{trainingResult.model}</span>{' '}
|
||||
{trainingResult.metric} on this task, before and after training. Out of the box it
|
||||
never once guesses the word; after an {trainingResult.method}, it wins about six games
|
||||
in ten. Measured on {trainingResult.evalDescription}. Both checkpoints are public, so
|
||||
the claim is checkable rather than quotable.
|
||||
</p>
|
||||
<p className="mt-4 flex flex-wrap gap-2">
|
||||
<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}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
@@ -183,33 +489,29 @@ export default function Home() {
|
||||
</a>
|
||||
{trainingResult.checkpoints.map((c) => (
|
||||
<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}
|
||||
key={c.href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="font-mono">{c.label.replace('PrimeIntellect/', '')}</span>
|
||||
{c.label.replace('PrimeIntellect/', '')}
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
))}
|
||||
</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>
|
||||
<p className={`${s.caption} mt-3`}>{trainingResult.caveat}</p>
|
||||
</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}>
|
||||
What we measured, and what we didn’t
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
|
||||
import { Term } from '@/components/site/Term';
|
||||
import {
|
||||
helloWorldCitations,
|
||||
optimalPlay,
|
||||
@@ -10,10 +11,19 @@ import {
|
||||
trainingResult,
|
||||
wordList,
|
||||
} 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 { 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. */
|
||||
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 }) {
|
||||
return (
|
||||
<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',
|
||||
});
|
||||
|
||||
const withCaveat = lineup.filter((v) => v.caveat !== undefined);
|
||||
const answers = wordList.answers.toLocaleString('en-US');
|
||||
const guesses = wordList.guesses.toLocaleString('en-US');
|
||||
|
||||
return (
|
||||
<main className={`${s.shell} py-10 sm:py-16`}>
|
||||
<p className={s.eyebrow}>Honesty</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-3xl`}>What is measured, what is cited, and what is not.</h1>
|
||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
||||
<main className={`${s.shell} py-10 sm:py-14`}>
|
||||
<p className={s.label}>Honesty</p>
|
||||
<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-4`}>
|
||||
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
|
||||
how much weight it can carry.
|
||||
</p>
|
||||
|
||||
<nav aria-label="On this page" className="card mt-8 p-5">
|
||||
<p className={s.eyebrow}>On this page</p>
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||
{CONTENTS.map((item) => (
|
||||
<nav aria-label="On this page" className="card mt-8 p-4 sm:p-5">
|
||||
<p className={s.label}>On this page</p>
|
||||
<ol className="mt-3 grid gap-x-6 sm:grid-cols-2">
|
||||
{CONTENTS.map((item, index) => (
|
||||
<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}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* ── Measured ───────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="measured">What we measured ourselves</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
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
|
||||
issue.
|
||||
</p>
|
||||
<ul className={`${s.prose} mt-4 space-y-4`}>
|
||||
<li>
|
||||
<strong className="text-fg">The environment runs.</strong> The word game is a real
|
||||
environment in this repository, not a mock behind the page. Three commands score a model
|
||||
with it:
|
||||
<pre className={`${s.codeBlock} mt-2`}>
|
||||
<ol className="mt-4 max-w-measure space-y-5">
|
||||
<li className={s.prose}>
|
||||
<strong className="font-semibold text-fg">The environment runs.</strong> The word game
|
||||
is a real environment in this repository, not a mock behind the page. Three commands
|
||||
score a model with it:
|
||||
<pre className={`${s.codeBlock} mt-2 whitespace-pre-wrap sm:whitespace-pre`}>
|
||||
<code>
|
||||
{reproduce.clone}
|
||||
{'\n'}
|
||||
@@ -100,43 +125,45 @@ export default function Honesty() {
|
||||
</code>
|
||||
</pre>
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">The word lists rebuild byte for byte.</strong> Both source
|
||||
dictionaries are committed beside the script that filters them, so{' '}
|
||||
<code className="font-mono text-fg">{wordList.rebuild}</code> produces the same{' '}
|
||||
<span className="nums">{wordList.answers.toLocaleString('en-US')}</span> answers and{' '}
|
||||
<span className="nums">{wordList.guesses.toLocaleString('en-US')}</span> legal guesses on
|
||||
any machine.
|
||||
<li className={s.prose}>
|
||||
<strong className="font-semibold text-fg">The word lists rebuild byte for byte.</strong>{' '}
|
||||
Both source dictionaries are committed beside the script that filters them, so{' '}
|
||||
<code className="font-mono text-mono-data text-fg">{wordList.rebuild}</code> produces the
|
||||
same <Num>{answers}</Num> answers and <Num>{guesses}</Num> legal guesses on any machine.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">The two implementations of the rules agree.</strong> The
|
||||
grader exists twice — once in Python for the environment, once in TypeScript for the
|
||||
<li className={s.prose}>
|
||||
<strong className="font-semibold text-fg">
|
||||
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
|
||||
answer list through both, about{' '}
|
||||
<span className="nums">21.2 million</span> feedback patterns, and compares a hash. A
|
||||
divergence fails the build rather than showing you one result and scoring another.
|
||||
answer list through both, about <Num>21.2 million</Num> feedback patterns, and compares
|
||||
a hash. A divergence fails the build rather than showing you one result and scoring
|
||||
another.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">The scores on a recorded run are re-derived, not
|
||||
recited.</strong> 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 className={s.prose}>
|
||||
<strong className="font-semibold text-fg">
|
||||
The scores on a recorded run are re-derived, not recited.
|
||||
</strong>{' '}
|
||||
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>
|
||||
</ul>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* ── Cited ──────────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="cited">What we cite from elsewhere</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
Three claims on this site are not ours. They are linked so you can go and check them
|
||||
instead of taking them from us.
|
||||
</p>
|
||||
|
||||
<div className="card mt-5 p-5">
|
||||
<h3 className={s.h3}>
|
||||
<div className="card mt-5 max-w-measure-wide p-4 sm:p-5">
|
||||
<h3 className={`${s.h3} font-mono`}>
|
||||
{trainingResult.model}: {trainingResult.before} → {trainingResult.after}{' '}
|
||||
{trainingResult.metric}
|
||||
<span className="font-sans font-normal text-fg-2">{trainingResult.metric}</span>
|
||||
</h3>
|
||||
<p className={`${s.prose} mt-2`}>
|
||||
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>.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-3`}>
|
||||
We quote the win rate and nothing else. The same write-up gives average-reward figures
|
||||
for those runs; we leave them out because the reward function has changed across
|
||||
versions of that environment and the two figures were never re-measured together. A win
|
||||
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.
|
||||
{trainingResult.caveat} A win 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>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4 p-5">
|
||||
<h3 className={s.h3}>Wordle is Prime Intellect’s own hello-world</h3>
|
||||
<div className="card mt-4 max-w-measure-wide p-4 sm:p-5">
|
||||
<h3 className={s.h3}>The word game is Prime Intellect’s own hello-world</h3>
|
||||
<p className={`${s.prose} mt-2`}>
|
||||
We say this because a demo picked to flatter itself is worth nothing. It appears in three
|
||||
places in their stack:
|
||||
We say this because a demo picked to flatter itself is worth nothing. It appears in
|
||||
three places in their stack:
|
||||
</p>
|
||||
<ul className={`${s.prose} mt-3 space-y-2`}>
|
||||
<ul className="mt-3 divide-y divide-border">
|
||||
{helloWorldCitations.map((c) => (
|
||||
<li key={c.href}>
|
||||
<Out href={c.href}>{c.label}</Out> — {c.claim}
|
||||
<li className="py-2.5" key={c.href}>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="card mt-4 p-5">
|
||||
<h3 className={s.h3}>{optimalPlay.average} is not our number</h3>
|
||||
<div className="card mt-4 max-w-measure-wide p-4 sm:p-5">
|
||||
<h3 className={s.h3}>
|
||||
<span className="font-mono">{optimalPlay.average}</span> is not our number
|
||||
</h3>
|
||||
<p className={`${s.prose} mt-2`}>
|
||||
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
|
||||
@@ -181,27 +222,28 @@ export default function Honesty() {
|
||||
</section>
|
||||
|
||||
{/* ── 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>
|
||||
<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:
|
||||
</p>
|
||||
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
||||
<li>
|
||||
<strong className="text-fg">The argument is about determinism.</strong> A page whose
|
||||
model says something different on every reload cannot be used to make the case that the
|
||||
grader stays put. The demo has to behave like the thing it is describing.
|
||||
<strong className="font-semibold text-fg">The argument is about determinism.</strong> A
|
||||
page whose model says something different on every reload cannot be used to make the
|
||||
case that the grader stays put. The demo has to behave like the thing it is describing.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">There is nowhere to hide a cherry-pick.</strong> Every run is
|
||||
a file in the repository carrying its model, its seed and the date it was captured. A bad
|
||||
run is in there being bad. A live demo lets you re-roll until it looks good and tell
|
||||
nobody.
|
||||
<strong className="font-semibold text-fg">There is nowhere to hide a cherry-pick.</strong>{' '}
|
||||
Every run is a file in the repository carrying its model, its{' '}
|
||||
<Term term="seed">seed</Term> and the date it was captured. A bad run is in there being
|
||||
bad. A live demo lets you re-roll until it looks good and tell nobody.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">It works.</strong> No key, no rate limit, no bill, no
|
||||
dependence on a provider being up during the ten minutes you are looking at it.
|
||||
<strong className="font-semibold text-fg">It works.</strong> No key, no rate limit, no
|
||||
bill, no dependence on a provider being up during the ten minutes you are looking at it.
|
||||
</li>
|
||||
</ul>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
@@ -212,32 +254,34 @@ export default function Honesty() {
|
||||
</section>
|
||||
|
||||
{/* ── 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>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
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
|
||||
nothing learns while you drag a slider.
|
||||
re-ranks them. That is the entire mechanism. Nothing is fine-tuned, no model is called,
|
||||
and nothing learns while you drag a slider.
|
||||
</p>
|
||||
<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
|
||||
change what you are paying for. That is a fact about your definition of good, not about the
|
||||
models — and it is the reason the definition is worth an hour of your team’s attention
|
||||
before the training run is worth a GPU.
|
||||
change what you are paying for. That is a fact about your definition of good, not about
|
||||
the models — and it is the reason the definition is worth an hour of your team’s
|
||||
attention before the training run is worth a GPU.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
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
|
||||
for, and the number at the top of the home page is what it produced.
|
||||
hours, and it does not belong in a browser tab. That is what a trainer like{' '}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* ── Proposals ──────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="proposals">The verticals are our proposals</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
Eleven of the twelve entries in the lineup describe environments that do not exist. They
|
||||
are labelled <span className="font-semibold text-fg">“{PROPOSAL_NOTICE}”</span> on every
|
||||
{CountWord(unbuiltLineup.length)} of the {countWord(lineup.length)} entries in the lineup
|
||||
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.
|
||||
</p>
|
||||
<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.
|
||||
</li>
|
||||
<li>
|
||||
Where a page names a KPI, we are describing how that function usually measures itself. We
|
||||
are not reporting anyone’s numbers, and there are no numbers on those pages to report.
|
||||
Where a page names a KPI, we are describing how that function usually measures itself.
|
||||
We are not reporting anyone’s numbers, and there are no numbers on those pages to
|
||||
report.
|
||||
</li>
|
||||
</ul>
|
||||
{withCaveat.length > 0 ? (
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
Two of them carry a standing caveat on their own page — the{' '}
|
||||
<Link className={s.link} to={routes.vertical('legal-playbook-redline')}>
|
||||
legal redline
|
||||
{CountWord(withCaveat.length)} of them {withCaveat.length === 1 ? 'carries' : 'carry'} a
|
||||
standing caveat on {withCaveat.length === 1 ? 'its' : 'their'} own page:{' '}
|
||||
{withCaveat.map((v, index) => (
|
||||
<span key={v.slug}>
|
||||
{index > 0 ? (index === withCaveat.length - 1 ? ' and ' : ', ') : ''}
|
||||
<Link className={s.link} to={routes.vertical(v.slug)}>
|
||||
{v.title}
|
||||
</Link>
|
||||
, where grading the drafting half collapses into an LLM judge, and{' '}
|
||||
<Link className={s.link} to={routes.vertical('semiconductor-ppa-closure')}>
|
||||
semiconductor timing closure
|
||||
</Link>
|
||||
, which has the best reward on the page and is the one thing here we are telling you we are
|
||||
not going to build.
|
||||
</span>
|
||||
))}
|
||||
. Each says, in its own words, where the argument stops being checkable.
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* ── Word list ──────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="words">The word list is our own construction</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
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{' '}
|
||||
<Out href={wordList.sources[0].href}>{wordList.sources[0].label}</Out> that also appears in{' '}
|
||||
<Out href={wordList.sources[1].href}>{wordList.sources[1].label}</Out>, minus a short
|
||||
<Out href={wordList.sources[0].href}>{wordList.sources[0].label}</Out> that also appears
|
||||
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.
|
||||
</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>
|
||||
<dt className={s.eyebrow}>Answers</dt>
|
||||
<dd className="nums mt-1 text-2xl font-bold text-fg">
|
||||
{wordList.answers.toLocaleString('en-US')}
|
||||
</dd>
|
||||
<dt className={s.label}>Answers</dt>
|
||||
<dd className="mt-1 font-mono text-metric text-fg">{answers}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={s.eyebrow}>Legal guesses</dt>
|
||||
<dd className="nums mt-1 text-2xl font-bold text-fg">
|
||||
{wordList.guesses.toLocaleString('en-US')}
|
||||
</dd>
|
||||
<dt className={s.label}>Legal guesses</dt>
|
||||
<dd className="mt-1 font-mono text-metric text-fg">{guesses}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className={s.eyebrow}>Original game’s answers</dt>
|
||||
<dd className="nums mt-1 text-2xl font-bold text-muted">
|
||||
<dt className={s.label}>Original game’s answers</dt>
|
||||
<dd className="mt-1 font-mono text-metric text-muted">
|
||||
{wordList.originalAnswers.toLocaleString('en-US')}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -303,116 +347,127 @@ export default function Honesty() {
|
||||
</section>
|
||||
|
||||
{/* ── Harder ────────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="harder">This environment is harder than the original game</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
Two things pull in opposite directions here, so we measured rather than argued. Our answer
|
||||
pool comes from a dictionary rule, so it keeps the regular plurals and past tenses the
|
||||
original game’s editor removed by hand:{' '}
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInS}</span> of our answers end
|
||||
in a plain <span className="font-mono">S</span> and{' '}
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInEd}</span> in{' '}
|
||||
<span className="font-mono">-ED</span>. That is an exploitable regularity, and it makes our
|
||||
list easier. Our pool is also roughly twice the size, which makes it harder.
|
||||
Two things pull in opposite directions here, so we measured rather than argued. Our
|
||||
answer pool comes from a dictionary rule, so it keeps the regular plurals and past tenses
|
||||
the original game’s editor removed by hand: <Num>{wordList.endsInS}</Num> of our answers
|
||||
end in a plain <span className="font-mono text-fg">S</span> and{' '}
|
||||
<Num>{wordList.endsInEd}</Num> in <span className="font-mono text-fg">-ED</span>. That is
|
||||
an exploitable regularity, and it makes our list easier. Our pool is also roughly twice
|
||||
the size, which makes it harder.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
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
|
||||
each:
|
||||
<Num>{poolComparison.sampled}</Num> sampled words each:
|
||||
</p>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[26rem] border-collapse text-sm">
|
||||
<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 font-mono text-mono-data">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-muted">
|
||||
<th className="py-2 pr-4 font-medium">Answer pool</th>
|
||||
<th className="py-2 pr-4 font-medium">Best opener</th>
|
||||
<th className="py-2 pr-4 font-medium">Mean guesses</th>
|
||||
<th className="py-2 font-medium">Worst case</th>
|
||||
<tr className="bg-surface-3 text-left">
|
||||
<th className={`${s.label} px-3 py-2`} scope="col">
|
||||
Answer pool
|
||||
</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>
|
||||
</thead>
|
||||
<tbody className="nums">
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-2 pr-4">Original, {poolComparison.original.size}</td>
|
||||
<td className="py-2 pr-4 font-mono">{poolComparison.original.opener}</td>
|
||||
<td className="py-2 pr-4">{poolComparison.original.mean}</td>
|
||||
<td className="py-2">{poolComparison.original.worst}</td>
|
||||
<tbody>
|
||||
<tr className="border-t border-border text-fg-2">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-sans">Original,</span> {poolComparison.original.size}
|
||||
</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>
|
||||
<td className="py-2 pr-4 font-semibold text-fg">Ours, {poolComparison.ours.size}</td>
|
||||
<td className="py-2 pr-4 font-mono">{poolComparison.ours.opener}</td>
|
||||
<td className="py-2 pr-4 font-semibold text-fg">{poolComparison.ours.mean}</td>
|
||||
<td className="py-2">{poolComparison.ours.worst}</td>
|
||||
<tr className="border-t border-border font-semibold text-fg">
|
||||
<td className="px-3 py-2">
|
||||
<span className="font-sans">Ours,</span> {poolComparison.ours.size}
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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
|
||||
algorithm, which is the only fair comparison available — setting our greedy number against
|
||||
the published{' '}
|
||||
<span className="nums">3.4212</span> optimum for the original list would be comparing two
|
||||
different algorithms and calling the difference a property of the words.
|
||||
algorithm, which is the only fair comparison available — setting our greedy number
|
||||
against the published <Num>3.4212</Num> optimum for the original list would be comparing
|
||||
two different algorithms and calling the difference a property of the words.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
The practical instruction is unchanged: do not compare a guess count from this site to your
|
||||
own statistics from the original game. It is a different game with a different shape.
|
||||
The practical instruction is unchanged: do not compare a guess count from this site to
|
||||
your own statistics from the original game. It is a different game with a different shape.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Unmeasured ─────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<section className="mt-12">
|
||||
<Heading id="unmeasured">What we have not measured</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
This is the list we would want if we were the ones being sold to.
|
||||
</p>
|
||||
<ul className={`${s.prose} mt-4 list-disc space-y-3 pl-5`}>
|
||||
<li>
|
||||
<strong className="text-fg">Any training result of our own.</strong> We have not trained
|
||||
a model against this environment. The before-and-after number on the home page is Prime
|
||||
Intellect’s, measured on their word list, and we did not rerun it on ours.
|
||||
<strong className="font-semibold text-fg">Any training result of our own.</strong> We
|
||||
have not trained a model against this environment. The before-and-after number on the
|
||||
home page is Prime Intellect’s, measured on their word list, and we did not rerun it on
|
||||
ours.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">Variance.</strong> The recorded runs are individual seeds,
|
||||
not samples. There is no confidence interval anywhere on this site and no claim that a
|
||||
given run is typical. A demonstration with one seed is a demonstration, not evidence.
|
||||
<strong className="font-semibold text-fg">Variance.</strong> The recorded runs are
|
||||
individual seeds, not samples. There is no confidence interval anywhere on this site and
|
||||
no claim that a given run is typical. A demonstration with one seed is a demonstration,
|
||||
not evidence.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">An optimum, or a human baseline, for our list.</strong> We do
|
||||
not know how few guesses perfect play needs on our answers, and we have not asked people
|
||||
to play it.
|
||||
<strong className="font-semibold text-fg">
|
||||
An optimum, or a human baseline, for our list.
|
||||
</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>
|
||||
<strong className="text-fg">Cost and latency.</strong> No number here is a price, a
|
||||
token count you should budget from, or a wall-clock comparison between models. The
|
||||
durations shown in a trace are what that recording measured, on that day, on that
|
||||
endpoint.
|
||||
<strong className="font-semibold text-fg">Cost and latency.</strong> No number here is a
|
||||
price, a <Term term="tokens">token</Term> count you should budget from, or a wall-clock
|
||||
comparison between models. The durations shown in a trace are what that recording
|
||||
measured, on that day, on that endpoint.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fg">Anything at all about the eleven proposals.</strong> Nothing
|
||||
in those pages has been run. Every reward, counterweight and KPI there is a design.
|
||||
<strong className="font-semibold text-fg">
|
||||
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>
|
||||
<strong className="text-fg">A full review of the answer list.</strong> We block a short
|
||||
hand-written set of words from ever being the hidden answer. We have not audited the
|
||||
remaining {wordList.answers.toLocaleString('en-US')} exhaustively, and a five-letter
|
||||
English dictionary contains words somebody will not want on a boardroom screen.
|
||||
<strong className="font-semibold text-fg">A full review of the answer list.</strong> We
|
||||
block a short hand-written set of words from ever being the hidden answer. We have not
|
||||
audited the remaining <Num>{answers}</Num> exhaustively, and a five-letter English
|
||||
dictionary contains words somebody will not want on a boardroom screen.
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
<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,
|
||||
open an issue with the page and the sentence and we will fix the sentence or the code.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
className={s.btnPrimary}
|
||||
href={REPO_URL}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<a className={s.btnPrimary} href={REPO_URL} rel="noreferrer noopener" target="_blank">
|
||||
Read the source
|
||||
</a>
|
||||
<Link className={s.btnSecondary} to={routes.home}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
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 { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
@@ -27,12 +28,12 @@ export default function NotFound() {
|
||||
|
||||
return (
|
||||
<main className={`${s.shell} py-16 sm:py-24`}>
|
||||
<p className={`${s.eyebrow} nums`}>404</p>
|
||||
<h1 className={`${s.h1} mt-3 max-w-2xl`}>That page isn’t here.</h1>
|
||||
<p className={`${s.lede} mt-5 max-w-2xl`}>
|
||||
<p className={s.label}>404</p>
|
||||
<h1 className={`${s.h1} mt-3`}>That page isn’t here.</h1>
|
||||
<p className={`${s.lede} mt-4`}>
|
||||
Nothing is served at{' '}
|
||||
<code className="break-all font-mono text-fg">{pathname}</code>. Either the link is old or
|
||||
we moved something. Here is everything this site has.
|
||||
<code className="break-all font-mono text-mono-data text-fg">{pathname}</code>. Either the
|
||||
link is old or we moved something. Here is everything this site has.
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<nav aria-labelledby="nf-demos">
|
||||
<h2 className={s.h3} id="nf-demos">
|
||||
Demos
|
||||
<h2 className={s.label} id="nf-demos">
|
||||
Environments · {pad2(allDemos.length)}
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<ul className="mt-3 divide-y divide-border border-y border-border">
|
||||
{allDemos.map((demo) => (
|
||||
<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}
|
||||
<StatusPill status={demo.status} />
|
||||
</Link>
|
||||
<span className="ml-2 text-xs text-muted">
|
||||
{demo.status === 'live' ? 'Live' : 'Specification'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{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}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-labelledby="nf-verticals">
|
||||
<h2 className={s.h3} id="nf-verticals">
|
||||
The lineup
|
||||
<h2 className={s.label} id="nf-verticals">
|
||||
The lineup · {pad2(lineup.length)}
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-2">
|
||||
<ul className="mt-3 divide-y divide-border border-y border-border">
|
||||
{topVerticals.map((v) => (
|
||||
<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}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
<Link className={`${s.navLink} py-3`} to={routes.home}>
|
||||
All {lineup.length}
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
@@ -91,9 +94,9 @@ export default function NotFound() {
|
||||
</nav>
|
||||
</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>
|
||||
<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
|
||||
all live on one page.
|
||||
</p>
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import type { ReactNode } from 'react';
|
||||
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 { demosForVertical, featuredDemo, lineup, routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
||||
import { demosForVertical, featuredDemo, lineup, pad2, routes } from '@/content/lineup';
|
||||
import * as s from '@/content/styles';
|
||||
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
||||
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() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const vertical = verticalBySlug(slug);
|
||||
@@ -18,7 +47,7 @@ export default function VerticalPage() {
|
||||
title: pageTitle(vertical?.title ?? 'No such vertical'),
|
||||
description: vertical
|
||||
? `${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) } : {}),
|
||||
...(vertical ? { ogImage: `/og/vertical-${vertical.slug}.png` } : {}),
|
||||
});
|
||||
@@ -26,10 +55,12 @@ export default function VerticalPage() {
|
||||
if (!vertical) {
|
||||
return (
|
||||
<main className={`${s.shell} py-16`}>
|
||||
<h1 className={s.h1}>No such vertical.</h1>
|
||||
<p className={`${s.lede} mt-4 max-w-xl`}>
|
||||
There is no proposal at <code className="font-mono text-fg">/verticals/{slug}</code>. The
|
||||
twelve we have written are all on the home page.
|
||||
<p className={s.label}>No such vertical</p>
|
||||
<h1 className={`${s.h1} mt-3`}>There is no proposal at that address.</h1>
|
||||
<p className={`${s.lede} mt-4`}>
|
||||
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>
|
||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||
<Link className={s.btnPrimary} to={routes.home}>
|
||||
@@ -53,90 +84,82 @@ export default function VerticalPage() {
|
||||
const next = index >= 0 ? lineup[index + 1] : undefined;
|
||||
|
||||
return (
|
||||
<main className={`${s.shell} py-10 sm:py-14`}>
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
<main className={`${s.shell} py-8 sm:py-12`}>
|
||||
<Link className={s.navLink} to={routes.home}>
|
||||
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||
All twelve
|
||||
All {lineup.length}
|
||||
</Link>
|
||||
|
||||
<header className="mt-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={s.proposalPill}>{PROPOSAL_NOTICE}</span>
|
||||
<span className={s.pill}>Rank {vertical.rank} of {lineup.length}</span>
|
||||
<span className={s.pill}>
|
||||
<span className={s.chipOutline}>{PROPOSAL_NOTICE}</span>
|
||||
<span className={s.chipOutline}>
|
||||
Rank {pad2(vertical.rank)} / {pad2(lineup.length)}
|
||||
</span>
|
||||
<span className={s.chipMuted}>
|
||||
{vertical.plannedForV1 ? 'In the first set' : 'Not in the first set'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 flex items-start gap-4">
|
||||
<span className="card grid size-12 shrink-0 place-items-center bg-surface-2">
|
||||
<Icon aria-hidden="true" className="size-6 text-brand" />
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-md bg-surface-3">
|
||||
<Icon aria-hidden="true" className="size-5 text-border-strong" />
|
||||
</span>
|
||||
<h1 className={`${s.h1} min-w-0`}>{vertical.title}</h1>
|
||||
</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>
|
||||
|
||||
{/* ── Who this is for, and what is already worrying them ─────────── */}
|
||||
<section className="mt-8">
|
||||
<p className={s.eyebrow}>Who it is for</p>
|
||||
<p className="mt-2 text-lg font-semibold text-fg">{vertical.persona}</p>
|
||||
<blockquote className="card mt-4 max-w-3xl p-5">
|
||||
<Quote aria-hidden="true" className="size-5 text-muted" />
|
||||
<p className="mt-2 text-lg leading-relaxed text-fg">{vertical.anxiety}</p>
|
||||
<footer className="mt-3 text-sm text-muted">
|
||||
The question in the room before anyone opens a laptop.
|
||||
</footer>
|
||||
</blockquote>
|
||||
</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.
|
||||
{/* ── The environment, in the same parts every time ───────────────── */}
|
||||
<section aria-labelledby="parts" className="mt-10">
|
||||
<h2 className={s.h2} id="parts">
|
||||
The environment
|
||||
</h2>
|
||||
<p className={`${s.prose} mt-3`}>
|
||||
The same machine as the word game: a task, a score in the number this desk already
|
||||
reports, and the term that takes points away — the{' '}
|
||||
<Term term="counterweight">counterweight</Term>. A <Term term="reward">reward</Term> with
|
||||
nothing pulling against it is a target, and a target is what a model learns to hit
|
||||
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>
|
||||
|
||||
{vertical.caveat ? (
|
||||
<section className="card mt-8 border-warning/40 bg-warning/5 p-5 sm:p-6">
|
||||
<h2 className={`${s.h3} flex items-center gap-2 text-warning`}>
|
||||
<TriangleAlert aria-hidden="true" className="size-4 shrink-0" />
|
||||
<section aria-labelledby="stops" className="card mt-6 bg-warning-subtle p-4 sm:p-5">
|
||||
<h2 className={`${s.label} text-warning`} id="stops">
|
||||
Where this stops
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
{/* ── What actually exists for this vertical, stated plainly ─────── */}
|
||||
<section className="mt-10">
|
||||
<h2 className={s.h2}>What exists today</h2>
|
||||
<section aria-labelledby="exists" className="mt-10">
|
||||
<h2 className={s.h2} id="exists">
|
||||
What exists today
|
||||
</h2>
|
||||
{live ? (
|
||||
<Link
|
||||
aria-label={`${live.title} — play it`}
|
||||
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`}>
|
||||
Live demo
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusPill status="live" />
|
||||
</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="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 it
|
||||
</span>
|
||||
@@ -147,34 +170,23 @@ export default function VerticalPage() {
|
||||
className={`${s.cardLink} mt-4`}
|
||||
to={routes.demo(spec.slug)}
|
||||
>
|
||||
<span className={`${s.pill} self-start gap-1`}>
|
||||
<FileText aria-hidden="true" className="size-3.5" />
|
||||
Published specification
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusPill status="spec" />
|
||||
</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="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
|
||||
<ArrowRight aria-hidden="true" className="size-4" />
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="card mt-4 p-5 sm:p-6">
|
||||
<p className={`${s.prose} max-w-3xl text-fg`}>
|
||||
<div className="card mt-4 p-4 sm:p-5">
|
||||
<p className={`${s.prose} text-fg`}>
|
||||
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
|
||||
on the same terms.
|
||||
is a proposal, written to the same shape as the ones that are built so you can judge
|
||||
it on the same terms.
|
||||
</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">
|
||||
<Link className={s.btnSecondary} to={routes.gallery}>
|
||||
See what is built
|
||||
@@ -187,6 +199,25 @@ export default function VerticalPage() {
|
||||
)}
|
||||
</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 ─────────────────────────────────────── */}
|
||||
<nav aria-label="Other verticals" className="mt-12 grid gap-3 sm:grid-cols-2">
|
||||
{previous ? (
|
||||
@@ -195,9 +226,9 @@ export default function VerticalPage() {
|
||||
className={`${s.cardLink} sm:items-start`}
|
||||
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" />
|
||||
Rank {previous.rank}
|
||||
Rank {pad2(previous.rank)}
|
||||
</span>
|
||||
<span className={`${s.h3} mt-1.5`}>{previous.title}</span>
|
||||
</Link>
|
||||
@@ -210,8 +241,8 @@ export default function VerticalPage() {
|
||||
className={`${s.cardLink} sm:col-start-2 sm:items-end sm:text-right`}
|
||||
to={routes.vertical(next.slug)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
Rank {next.rank}
|
||||
<span className={`${s.label} inline-flex items-center gap-1.5`}>
|
||||
Rank {pad2(next.rank)}
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</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:
|
||||
* 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} */
|
||||
export default {
|
||||
@@ -20,11 +22,16 @@ export default {
|
||||
background: 'hsl(var(--bg))',
|
||||
surface: 'hsl(var(--surface))',
|
||||
'surface-2': 'hsl(var(--surface-2))',
|
||||
'surface-3': 'hsl(var(--surface-3))',
|
||||
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))',
|
||||
ring: 'hsl(var(--accent))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
fg: 'hsl(var(--fg))',
|
||||
foreground: 'hsl(var(--fg))',
|
||||
'fg-2': 'hsl(var(--fg-2))',
|
||||
muted: 'hsl(var(--muted))',
|
||||
'muted-foreground': 'hsl(var(--muted))',
|
||||
// shadcn's `accent` is its SUBTLE hover surface, not the brand. Mapping
|
||||
@@ -34,15 +41,24 @@ export default {
|
||||
brand: 'hsl(var(--accent))',
|
||||
'accent-fg': 'hsl(var(--accent-fg))',
|
||||
'accent-on': 'hsl(var(--accent-on))',
|
||||
'accent-edge': 'hsl(var(--accent-edge))',
|
||||
'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))' },
|
||||
secondary: { DEFAULT: 'hsl(var(--surface-2))', foreground: 'hsl(var(--fg))' },
|
||||
card: { 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))',
|
||||
warning: 'hsl(var(--warning))',
|
||||
'warning-on': 'hsl(var(--warning-on))',
|
||||
'warning-subtle': 'hsl(var(--warning-subtle))',
|
||||
danger: 'hsl(var(--danger))',
|
||||
'danger-on': 'hsl(var(--danger-on))',
|
||||
'danger-subtle': 'hsl(var(--danger-subtle))',
|
||||
info: 'hsl(var(--info))',
|
||||
// The board's three tile states. Named by MEANING, not by colour, so
|
||||
// the high-contrast palette can swap the values without renaming.
|
||||
@@ -53,16 +69,68 @@ export default {
|
||||
'present-fg': 'hsl(var(--tile-present-fg))',
|
||||
absent: 'hsl(var(--tile-absent))',
|
||||
'absent-fg': 'hsl(var(--tile-absent-fg))',
|
||||
// An unplayed tile's edge (`border-tile-empty`).
|
||||
empty: 'hsl(var(--tile-empty-border))',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Manrope Variable', 'Avenir Next', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||
sans: ['Geist Variable', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||
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)' },
|
||||
transitionTimingFunction: { enter: 'var(--ease-out)', exit: 'var(--ease-in)' },
|
||||
maxWidth: { canvas: '80rem' },
|
||||
},
|
||||
},
|
||||
plugins: [animate],
|
||||
|
||||