Pin seed->word across both languages with a shared hash
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -72,6 +72,21 @@ def test_seeded_answer_is_deterministic() -> None:
|
|||||||
assert answer_for_seed(1) != answer_for_seed(2)
|
assert answer_for_seed(1) != answer_for_seed(2)
|
||||||
|
|
||||||
|
|
||||||
|
# Pinned so the browser cannot drift. `src/demos/wordle/__tests__/engine.test.ts`
|
||||||
|
# asserts the same twelve words for the same twelve seeds. If a language's
|
||||||
|
# built-in RNG were used instead of the shared hash, these two lists would
|
||||||
|
# differ and every ?seed= permalink would show a different word than the
|
||||||
|
# recorded run it claims to replay.
|
||||||
|
SEED_VECTORS = [
|
||||||
|
"wants", "amber", "spume", "toady", "divot", "filly",
|
||||||
|
"bobby", "clews", "hikes", "lawns", "wreak", "twist",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_vectors_match_the_typescript_port() -> None:
|
||||||
|
assert [answer_for_seed(s) for s in range(12)] == SEED_VECTORS
|
||||||
|
|
||||||
|
|
||||||
def test_rejections_do_not_consume_a_row() -> None:
|
def test_rejections_do_not_consume_a_row() -> None:
|
||||||
game = Game(seed=3, answer="tares")
|
game = Game(seed=3, answer="tares")
|
||||||
assert game.play("zzzzz")[1] is not None # not a word
|
assert game.play("zzzzz")[1] is not None # not a word
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import random
|
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -105,15 +104,27 @@ def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str |
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def answer_for_seed(seed: int) -> str:
|
def fnv1a32(text: str) -> int:
|
||||||
"""The hidden word for a seed.
|
"""FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
|
||||||
|
|
||||||
Seeded from a dedicated Random rather than the module-global one, because
|
A language's built-in RNG is not portable: `random.Random(7)` is a
|
||||||
TextArena seeds the process-global RNG and anything sharing it becomes
|
Mersenne Twister and there is no honest one-line JavaScript equivalent, so
|
||||||
order-dependent under concurrency.
|
seed 7 would pick one word here and a different one in the browser. Every
|
||||||
|
permalink on the site would then disagree with the recorded run it claims
|
||||||
|
to show. A hash sidesteps the whole problem — both sides compute the same
|
||||||
|
integer from the same string, and there is nothing to keep in step.
|
||||||
"""
|
"""
|
||||||
|
h = 0x811C9DC5
|
||||||
|
for byte in text.encode():
|
||||||
|
h ^= byte
|
||||||
|
h = (h * 0x01000193) & 0xFFFFFFFF
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def answer_for_seed(seed: int) -> str:
|
||||||
|
"""The hidden word for a seed. Identical in engine.ts — see fnv1a32."""
|
||||||
pool = answers()
|
pool = answers()
|
||||||
return pool[random.Random(seed).randrange(len(pool))]
|
return pool[fnv1a32(str(seed)) % len(pool)]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
+71
-3
@@ -316,17 +316,43 @@ export function literalAfter(src, anchor, open = '{') {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marker key on the stand-in an unresolvable identifier evaluates to. */
|
||||||
|
export const UNRESOLVED = '__pigUnresolvedIdentifier__';
|
||||||
|
|
||||||
|
export const isUnresolved = (v) => Boolean(v) && typeof v === 'object' && UNRESOLVED in v;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sandbox in which every free identifier resolves to a labelled stand-in.
|
||||||
|
*
|
||||||
|
* `RewardSpec.source.code` is legitimately an identifier — the Python is
|
||||||
|
* imported with `?raw` and cannot exist in plain Node — so the reward literal
|
||||||
|
* must be readable WITHOUT its `code`. Strict mode is still the default: a
|
||||||
|
* weight that turns out to be a stand-in is a contract failure, not a skip.
|
||||||
|
*/
|
||||||
|
function lenientSandbox() {
|
||||||
|
return new Proxy(Object.create(null), {
|
||||||
|
has: () => true,
|
||||||
|
get: (_target, key) => {
|
||||||
|
if (key === Symbol.unscopables) return undefined;
|
||||||
|
if (typeof key !== 'string') return undefined;
|
||||||
|
return { [UNRESOLVED]: key };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates a TypeScript object/array literal as plain data.
|
* Evaluates a TypeScript object/array literal as plain data.
|
||||||
*
|
*
|
||||||
* `as const` and `satisfies T` are stripped from code spans only. Anything else
|
* `as const` and `satisfies T` are stripped from code spans only. Anything else
|
||||||
* a literal might carry — an identifier, a call, a spread of an import — throws,
|
* a literal might carry — an identifier, a call, a spread of an import — throws,
|
||||||
* and callers turn that into a contract failure with the file named.
|
* and callers turn that into a contract failure with the file named. Pass
|
||||||
|
* `{lenient: true}` to get stand-ins for free identifiers instead.
|
||||||
*
|
*
|
||||||
* @param {string} text
|
* @param {string} text
|
||||||
* @param {string} label
|
* @param {string} label
|
||||||
|
* @param {{lenient?: boolean}} [options]
|
||||||
*/
|
*/
|
||||||
export function evalLiteral(text, label) {
|
export function evalLiteral(text, label, options = {}) {
|
||||||
const js = segment(text)
|
const js = segment(text)
|
||||||
.map((s) =>
|
.map((s) =>
|
||||||
s.code
|
s.code
|
||||||
@@ -338,7 +364,8 @@ export function evalLiteral(text, label) {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
try {
|
try {
|
||||||
const value = vm.runInNewContext(`(${js})`, Object.create(null), { timeout: 2000 });
|
const sandbox = options.lenient ? lenientSandbox() : Object.create(null);
|
||||||
|
const value = vm.runInNewContext(`(${js})`, sandbox, { timeout: 2000 });
|
||||||
return { ok: true, value, error: null };
|
return { ok: true, value, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -421,6 +448,47 @@ export function loadAllMetas() {
|
|||||||
return { metas, errors };
|
return { metas, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every .ts/.tsx file that belongs to one demo. */
|
||||||
|
export function demoFiles(slug) {
|
||||||
|
return walk(path.join(DEMOS_DIR, slug), (f) => /\.tsx?$/.test(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds one named literal anywhere inside a demo's own source.
|
||||||
|
*
|
||||||
|
* A demo is free to put `reward` in `reward.ts` or inline it in `demo.tsx`;
|
||||||
|
* the contract is about the values, not the file layout. First match in
|
||||||
|
* filename order wins, and the file it came from is returned so failures can
|
||||||
|
* name it.
|
||||||
|
*
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {RegExp[]} anchors
|
||||||
|
* @param {'{' | '['} open
|
||||||
|
* @param {string} label
|
||||||
|
* @param {{lenient?: boolean}} [options]
|
||||||
|
*/
|
||||||
|
export function findInDemo(slug, anchors, open, label, options = {}) {
|
||||||
|
for (const file of demoFiles(slug)) {
|
||||||
|
const src = read(file);
|
||||||
|
for (const anchor of anchors) {
|
||||||
|
let text = null;
|
||||||
|
try {
|
||||||
|
text = literalAfter(src, anchor, open);
|
||||||
|
} catch (error) {
|
||||||
|
return { file: rel(file), error: `could not brace-match the ${label} literal: ${error.message}` };
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
const result = evalLiteral(text, label, options);
|
||||||
|
if (!result.ok) return { file: rel(file), error: result.error, text };
|
||||||
|
return { file: rel(file), value: result.value, text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
file: null,
|
||||||
|
error: `no ${label} literal found in any .ts/.tsx file under ${rel(path.join(DEMOS_DIR, slug))}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ verticals */
|
/* ------------------------------------------------------------------ verticals */
|
||||||
|
|
||||||
export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts');
|
export const VERTICALS_FILE = abs('src', 'content', 'verticals.ts');
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { ModelCall } from '@/lib/demo-kit/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DASH, formatInt, formatMs, humaniseToken } from './format';
|
||||||
|
|
||||||
|
export interface ModelCallPanelProps {
|
||||||
|
call: ModelCall | null;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The model call, straight off the trace.
|
||||||
|
*
|
||||||
|
* Nothing here is computed, averaged or estimated. Every field is a value the
|
||||||
|
* recorder wrote down, and a field the recorder did not write down renders as
|
||||||
|
* an em dash — never as zero, and never quietly omitted. A provider that does
|
||||||
|
* 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) {
|
||||||
|
const rows: Row[] = [
|
||||||
|
{
|
||||||
|
label: 'finish_reason',
|
||||||
|
value: call?.finishReason ? humaniseToken(call.finishReason) : DASH,
|
||||||
|
hint: 'Why the model stopped generating',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Prompt tokens',
|
||||||
|
value: formatInt(call?.promptTokens ?? null),
|
||||||
|
hint: 'Everything sent in: system, board, history',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Completion tokens',
|
||||||
|
value: formatInt(call?.completionTokens ?? null),
|
||||||
|
hint: 'The visible reply',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reasoning tokens',
|
||||||
|
value: formatInt(call?.reasoningTokens ?? null),
|
||||||
|
hint: 'Billed thinking, when the provider reports it',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Latency',
|
||||||
|
value: formatMs(call?.durationMs ?? null),
|
||||||
|
hint: 'Real elapsed time when the run was recorded',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label={title} className={cn('card overflow-hidden', className)}>
|
||||||
|
<header className="border-b border-border px-3 py-2">
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
</header>
|
||||||
|
{call === null ? (
|
||||||
|
<p className="px-3 py-3 text-sm 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">
|
||||||
|
{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}
|
||||||
|
</dt>
|
||||||
|
<dd className="nums shrink-0 font-mono text-sm text-fg">{row.value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Drawer } from 'vaul';
|
||||||
|
import { Brain, ChevronUp } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ReasoningPanel } from './ReasoningPanel';
|
||||||
|
import type { ReasoningPanelProps } from './ReasoningPanel';
|
||||||
|
|
||||||
|
const SNAP_POINTS = [0.4, 0.9];
|
||||||
|
|
||||||
|
export interface ReasoningDrawerProps extends ReasoningPanelProps {
|
||||||
|
/** Controlled from the shell when it wants the drawer open on a step change. */
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
triggerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reasoning panel, for a phone.
|
||||||
|
*
|
||||||
|
* Below `lg` there is no room for a column beside the board, and putting the
|
||||||
|
* reasoning under the board means the visitor watches the run with the thinking
|
||||||
|
* off-screen. A drawer at 40% shows the first few lines without covering the
|
||||||
|
* board; dragging to 90% is the "let me actually read this" gesture.
|
||||||
|
*
|
||||||
|
* Rendering is caller-gated rather than CSS-gated: mounting a vaul drawer on
|
||||||
|
* desktop and hiding it with `lg:hidden` still locks body scroll when it opens,
|
||||||
|
* so the shell mounts this only under `lg`.
|
||||||
|
*/
|
||||||
|
export function ReasoningDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
triggerClassName,
|
||||||
|
...panel
|
||||||
|
}: ReasoningDrawerProps) {
|
||||||
|
const [snap, setSnap] = useState<number | string | null>(SNAP_POINTS[0] ?? 0.4);
|
||||||
|
const hasReasoning = (panel.reasoning ?? '').length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer.Root
|
||||||
|
snapPoints={SNAP_POINTS}
|
||||||
|
activeSnapPoint={snap}
|
||||||
|
setActiveSnapPoint={setSnap}
|
||||||
|
{...(open === undefined ? {} : { open })}
|
||||||
|
{...(onOpenChange ? { onOpenChange } : {})}
|
||||||
|
>
|
||||||
|
<Drawer.Trigger
|
||||||
|
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',
|
||||||
|
triggerClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Brain className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||||
|
<span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span>
|
||||||
|
<ChevronUp className="ml-auto h-4 w-4 text-muted" aria-hidden="true" />
|
||||||
|
</Drawer.Trigger>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 z-40 bg-fg/40" />
|
||||||
|
<Drawer.Content
|
||||||
|
className="fixed inset-x-0 bottom-0 z-50 mx-auto flex h-full max-h-[97%] max-w-canvas flex-col rounded-t-xl border border-border bg-surface outline-none"
|
||||||
|
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
<div className="px-4 pb-2 pt-3">
|
||||||
|
<Drawer.Title className="text-sm font-semibold">
|
||||||
|
{panel.title ?? 'Reasoning'}
|
||||||
|
</Drawer.Title>
|
||||||
|
<Drawer.Description className="text-xs text-muted">
|
||||||
|
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||||
|
</Drawer.Description>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4">
|
||||||
|
{/* 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} />
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
|
import { Brain } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { usePrefersReducedMotion } from './format';
|
||||||
|
import type { PlaybackSpeed } from './TracePlayer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characters per second, clamped. A 40-character reasoning trace recorded over
|
||||||
|
* 30 seconds would otherwise crawl at 1.3 chars/s and read as a hung page,
|
||||||
|
* and a 6,000-character one recorded in 800 ms would flash past unread.
|
||||||
|
*/
|
||||||
|
const MIN_CPS = 24;
|
||||||
|
const MAX_CPS = 900;
|
||||||
|
const FALLBACK_CPS = 90;
|
||||||
|
|
||||||
|
export interface ReasoningPanelProps {
|
||||||
|
reasoning: string | null;
|
||||||
|
/** The recorded latency of the call this reasoning came from. */
|
||||||
|
durationMs: number | null;
|
||||||
|
playing: boolean;
|
||||||
|
speed: PlaybackSpeed;
|
||||||
|
/** Changing this restarts the stream. Pass the step index. */
|
||||||
|
stepIndex: number;
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
reservedLines?: number;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function charsPerSecond(length: number, durationMs: number | null): number {
|
||||||
|
if (!durationMs || durationMs <= 0 || length === 0) return FALLBACK_CPS;
|
||||||
|
return Math.min(Math.max((length / durationMs) * 1000, MIN_CPS), MAX_CPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReasoningPanel({
|
||||||
|
reasoning,
|
||||||
|
durationMs,
|
||||||
|
playing,
|
||||||
|
speed,
|
||||||
|
stepIndex,
|
||||||
|
reservedLines = 10,
|
||||||
|
title = 'Reasoning',
|
||||||
|
className,
|
||||||
|
}: ReasoningPanelProps) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const full = reasoning ?? '';
|
||||||
|
const [visible, setVisible] = useState(full.length);
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Whether this step's text should stream at all. Scrubbing to a step while
|
||||||
|
// paused shows it whole — someone reading at their own pace is not asking to
|
||||||
|
// be typed at.
|
||||||
|
const shouldStream = playing && speed !== 'instant' && !reducedMotion && full.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shouldStream) {
|
||||||
|
setVisible(full.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVisible(0);
|
||||||
|
const cps = charsPerSecond(full.length, durationMs) * (typeof speed === 'number' ? speed : 1);
|
||||||
|
const started = performance.now();
|
||||||
|
let frame = 0;
|
||||||
|
let last = -1;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const next = Math.min(Math.floor(((now - started) / 1000) * cps), full.length);
|
||||||
|
// Only re-render when a character actually lands; at 120 Hz the naive
|
||||||
|
// version re-renders the whole panel twice per revealed character.
|
||||||
|
if (next !== last) {
|
||||||
|
last = next;
|
||||||
|
setVisible(next);
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||||
|
}
|
||||||
|
if (next < full.length) frame = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [shouldStream, full, durationMs, speed, stepIndex]);
|
||||||
|
|
||||||
|
const streaming = visible < full.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label={title}
|
||||||
|
className={cn('card flex flex-col overflow-hidden', 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>
|
||||||
|
{streaming ? (
|
||||||
|
<span className="nums ml-auto text-xs text-muted">
|
||||||
|
{visible}/{full.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ScrollArea.Root
|
||||||
|
type="auto"
|
||||||
|
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` }}
|
||||||
|
>
|
||||||
|
<ScrollArea.Viewport
|
||||||
|
ref={viewportRef}
|
||||||
|
className="h-full w-full px-3 py-2.5"
|
||||||
|
// The shell owns the page's single polite live region. A streaming
|
||||||
|
// region here would read every partial word over the top of it.
|
||||||
|
aria-live="off"
|
||||||
|
>
|
||||||
|
{full.length === 0 ? (
|
||||||
|
<p className="text-sm leading-relaxed 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">
|
||||||
|
{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"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ScrollArea.Viewport>
|
||||||
|
<ScrollArea.Scrollbar
|
||||||
|
orientation="vertical"
|
||||||
|
className="flex w-2 touch-none select-none p-0.5"
|
||||||
|
>
|
||||||
|
<ScrollArea.Thumb className="flex-1 rounded-full bg-border" />
|
||||||
|
</ScrollArea.Scrollbar>
|
||||||
|
</ScrollArea.Root>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Scale, Target, Weight } from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DASH, formatNumber, formatOrDash } from './format';
|
||||||
|
import { scoreReward } from './reward-math';
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RewardBreakdownProps {
|
||||||
|
spec: RewardSpec;
|
||||||
|
values: RewardValues;
|
||||||
|
/** Unweighted diagnostics. Rendered, never summed — the contract is explicit. */
|
||||||
|
metrics?: Record<string, number | null>;
|
||||||
|
/** Overridden weights from the editor. Absent means the shipped weights. */
|
||||||
|
weights?: Record<string, number>;
|
||||||
|
/** Set when `weights` came from the visitor rather than the environment. */
|
||||||
|
edited?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `score x 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.
|
||||||
|
*/
|
||||||
|
export function RewardBreakdown({
|
||||||
|
spec,
|
||||||
|
values,
|
||||||
|
metrics,
|
||||||
|
weights,
|
||||||
|
edited = false,
|
||||||
|
className,
|
||||||
|
}: RewardBreakdownProps) {
|
||||||
|
const { rows, total } = scoreReward(spec, values, weights);
|
||||||
|
const counterweights = rows.filter((row) => row.component.role === 'counterweight');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('card overflow-hidden', className)}>
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
<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">
|
||||||
|
<th scope="col" className="px-3 py-2 font-medium">
|
||||||
|
Component
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||||
|
Score
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||||
|
Weight
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const role = ROLE_META[row.component.role];
|
||||||
|
const isCounterweight = row.component.role === 'counterweight';
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.component.key}
|
||||||
|
className={cn(isCounterweight && 'bg-accent-subtle/40')}
|
||||||
|
>
|
||||||
|
<th scope="row" className="max-w-0 px-3 py-2.5 text-left font-normal">
|
||||||
|
<span className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="font-medium text-fg">{row.component.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
isCounterweight
|
||||||
|
? 'bg-brand/15 text-accent-fg'
|
||||||
|
: 'bg-surface-2 text-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<role.Icon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
{role.label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block text-xs leading-snug text-muted">
|
||||||
|
{row.component.description}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block font-mono text-[11px] text-muted">
|
||||||
|
{row.component.key}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||||
|
{row.score === null ? (
|
||||||
|
<span className="text-muted" title="The environment did not score this run">
|
||||||
|
not scored
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatNumber(row.score)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||||
|
<span className={cn(edited && '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>
|
||||||
|
</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">
|
||||||
|
Total reward
|
||||||
|
{edited ? <EditedChip /> : null}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<td colSpan={2} />
|
||||||
|
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
||||||
|
{total === null ? (
|
||||||
|
<span className="text-muted">not scored</span>
|
||||||
|
) : (
|
||||||
|
formatOrDash(total)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{counterweights.length > 0 ? (
|
||||||
|
<p className="border-t border-border bg-accent-subtle/40 px-3 py-2.5 text-xs leading-relaxed text-fg">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{counterweights.map((row) => row.component.label).join(' and ')}
|
||||||
|
</span>{' '}
|
||||||
|
{counterweights.length > 1 ? 'are counterweights' : 'is the counterweight'}: without a
|
||||||
|
term pulling the other way, the cheapest way to maximise the objective is a behaviour
|
||||||
|
you would never ship, and the model will find it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{spec.metrics && spec.metrics.length > 0 ? (
|
||||||
|
<div className="border-t border-border px-3 py-2.5">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Diagnostics — reported, never summed
|
||||||
|
</h4>
|
||||||
|
<dl className="mt-1.5 grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||||
|
{spec.metrics.map((metric) => (
|
||||||
|
<div key={metric.key} className="flex items-baseline justify-between gap-2">
|
||||||
|
<dt className="text-xs text-muted" title={metric.description}>
|
||||||
|
{metric.label}
|
||||||
|
</dt>
|
||||||
|
<dd className="nums font-mono text-xs">
|
||||||
|
{formatOrDash(metrics?.[metric.key] ?? null)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { ComponentType, KeyboardEvent } from 'react';
|
||||||
|
import type { DemoStep } from '@/lib/demo-kit/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DASH, formatInt, formatMs, humaniseToken, usePrefersReducedMotion } from './format';
|
||||||
|
|
||||||
|
export interface StepTimelineProps<T> {
|
||||||
|
steps: DemoStep<T>[];
|
||||||
|
current: number;
|
||||||
|
onSelect: (index: number) => void;
|
||||||
|
/** The demo's own board, drawn small. Omit and the chips are text only. */
|
||||||
|
Surface?: ComponentType<{ state: T; compact?: boolean }>;
|
||||||
|
/** Bound to Space, per the transport convention on the rest of the page. */
|
||||||
|
onTogglePlay?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scrubber: one chip per recorded step, each carrying its own board
|
||||||
|
* preview and its own real numbers.
|
||||||
|
*
|
||||||
|
* Why numbers on a chip at all — the chips are the only place the cost of the
|
||||||
|
* run is visible without opening a panel, and "this took 6 calls and 4,200
|
||||||
|
* tokens" is the sentence a buyer repeats to their CFO.
|
||||||
|
*
|
||||||
|
* Keyboard: this is a radiogroup, so arrows move AND select, which is the
|
||||||
|
* standard pattern. Space is bound to play/pause rather than to select — a
|
||||||
|
* deliberate break from the radio pattern, because the timeline sits under a
|
||||||
|
* transport bar where Space means play everywhere else, and being internally
|
||||||
|
* consistent beats being technically canonical. It is advertised on the group
|
||||||
|
* with `aria-keyshortcuts`.
|
||||||
|
*/
|
||||||
|
export function StepTimeline<T>({
|
||||||
|
steps,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
Surface,
|
||||||
|
onTogglePlay,
|
||||||
|
className,
|
||||||
|
}: StepTimelineProps<T>) {
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const chip = chipRefs.current[current];
|
||||||
|
const scroller = scrollerRef.current;
|
||||||
|
if (!chip || !scroller) return;
|
||||||
|
// `scrollIntoView` on the element would also scroll the PAGE to the
|
||||||
|
// timeline on every step, which is intolerable while the run plays. Scroll
|
||||||
|
// the strip only.
|
||||||
|
const chipBox = chip.getBoundingClientRect();
|
||||||
|
const viewBox = scroller.getBoundingClientRect();
|
||||||
|
const delta = chipBox.left - viewBox.left - (viewBox.width - chipBox.width) / 2;
|
||||||
|
scroller.scrollBy({ left: delta, behavior: reducedMotion ? 'auto' : 'smooth' });
|
||||||
|
}, [current, reducedMotion]);
|
||||||
|
|
||||||
|
const move = (next: number, event: KeyboardEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const clamped = Math.min(Math.max(next, 0), steps.length - 1);
|
||||||
|
onSelect(clamped);
|
||||||
|
chipRefs.current[clamped]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
move(current + 1, event);
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
move(current - 1, event);
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
move(0, event);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
move(steps.length - 1, event);
|
||||||
|
break;
|
||||||
|
case ' ':
|
||||||
|
case 'Spacebar':
|
||||||
|
if (onTogglePlay) {
|
||||||
|
event.preventDefault();
|
||||||
|
onTogglePlay();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
role="radiogroup"
|
||||||
|
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)}
|
||||||
|
>
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const selected = index === current;
|
||||||
|
const call = step.call;
|
||||||
|
return (
|
||||||
|
// A div rather than a button: the chip embeds the demo's own Surface,
|
||||||
|
// and a Surface is a grid of divs. Nesting flow content inside a
|
||||||
|
// <button> is invalid HTML and browsers reflow it unpredictably. The
|
||||||
|
// radio role plus the group's key handling gives the same semantics.
|
||||||
|
<div
|
||||||
|
key={step.index}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
// Roving tabindex: one stop for the whole strip, arrows inside it.
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
ref={(node) => {
|
||||||
|
chipRefs.current[index] = node;
|
||||||
|
}}
|
||||||
|
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',
|
||||||
|
selected
|
||||||
|
? 'border-brand bg-accent-subtle/60'
|
||||||
|
: '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>
|
||||||
|
{call?.finishReason ? (
|
||||||
|
<span
|
||||||
|
className="max-w-[4.5rem] truncate text-[10px] uppercase tracking-wide text-muted"
|
||||||
|
title={call.finishReason}
|
||||||
|
>
|
||||||
|
{humaniseToken(call.finishReason)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{Surface ? (
|
||||||
|
<span className="mt-1.5 block overflow-hidden rounded-md bg-surface-2 p-1.5">
|
||||||
|
<Surface state={step.state} compact />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.caption ? (
|
||||||
|
<span className="mt-1.5 block truncate text-xs text-muted" title={step.caption}>
|
||||||
|
{step.caption}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="nums mt-1 flex flex-wrap gap-x-2 text-[10px] text-muted">
|
||||||
|
<span title="Completion tokens">
|
||||||
|
{call?.completionTokens === null || call?.completionTokens === undefined
|
||||||
|
? DASH
|
||||||
|
: `${formatInt(call.completionTokens)} tok`}
|
||||||
|
</span>
|
||||||
|
<span title="Recorded latency">{formatMs(call?.durationMs ?? null)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { CheckCircle2, ChevronDown, HelpCircle, XCircle } from 'lucide-react';
|
||||||
|
import { verifyEpisode } from '@/lib/demo-kit';
|
||||||
|
import type { DemoEpisode, DemoModule, RewardValues } from '@/lib/demo-kit/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
||||||
|
import { rewardDelta, scoreReward } from './reward-math';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Float tolerance for "the browser agrees with Python".
|
||||||
|
*
|
||||||
|
* 1e-9 would be theatre: the two runtimes accumulate a sum in a different
|
||||||
|
* order, and IEEE-754 does not promise associativity. 1e-6 is well below any
|
||||||
|
* difference a reward change would produce and well above the noise.
|
||||||
|
*/
|
||||||
|
const EPSILON = 1e-6;
|
||||||
|
|
||||||
|
type Verdict =
|
||||||
|
| { kind: 'match'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[] }
|
||||||
|
| { kind: 'mismatch'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[]; guilty: string[] }
|
||||||
|
| { kind: 'unverifiable'; reason: string };
|
||||||
|
|
||||||
|
interface VerifyRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
recorded: number | null;
|
||||||
|
recomputed: number | null;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerifyBadgeProps<T> {
|
||||||
|
demo: DemoModule<T>;
|
||||||
|
episode: DemoEpisode;
|
||||||
|
className?: string;
|
||||||
|
/** Open the receipt on load. The sceptic we are writing for opens it anyway. */
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The receipt.
|
||||||
|
*
|
||||||
|
* This object exists for one person: the engineer sitting next to the CEO who
|
||||||
|
* assumes the numbers on a vendor's demo page are hard-coded. It re-runs every
|
||||||
|
* recorded move through the TypeScript engine in the visitor's own browser,
|
||||||
|
* rescores it, and prints the comparison — including the delta, to seven
|
||||||
|
* decimals, because a comparison without a delta is an assertion.
|
||||||
|
*
|
||||||
|
* It must therefore be allowed to FAIL loudly. A verifier that silently
|
||||||
|
* degrades to "verified" when it cannot check anything is worse than no
|
||||||
|
* verifier: it teaches the sceptic that the badge is decoration.
|
||||||
|
*/
|
||||||
|
export function VerifyBadge<T>({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps<T>) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
const verdict = useMemo<Verdict>(() => {
|
||||||
|
if (!demo.verify) {
|
||||||
|
return {
|
||||||
|
kind: 'unverifiable',
|
||||||
|
reason:
|
||||||
|
'This demo does not ship a browser-side engine, so the recorded scores cannot be re-derived here. The Python that produced them is in the repository and the eval command is below.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let recomputed: RewardValues | null;
|
||||||
|
try {
|
||||||
|
recomputed = verifyEpisode(demo, episode);
|
||||||
|
} catch (error) {
|
||||||
|
// A verifier that throws is a bug on our side, not a failed run. Say so
|
||||||
|
// rather than showing a red mismatch that blames the recorded numbers.
|
||||||
|
return {
|
||||||
|
kind: 'unverifiable',
|
||||||
|
reason: `The in-browser verifier threw while re-running this episode: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recomputed === null) {
|
||||||
|
return {
|
||||||
|
kind: 'unverifiable',
|
||||||
|
reason: episode.truncated
|
||||||
|
? 'This run was truncated before the environment reached a terminal state, so there is nothing complete to re-score. The recorded partial numbers are shown as they were captured.'
|
||||||
|
: 'The environment could not re-derive this episode from the recorded transcript. Nothing here is being asserted as verified.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltas = rewardDelta(episode.rewards, recomputed);
|
||||||
|
const labels = new Map(demo.reward.components.map((c) => [c.key, c.label]));
|
||||||
|
const rows: VerifyRow[] = deltas
|
||||||
|
.map(({ key, delta }) => ({
|
||||||
|
key,
|
||||||
|
label: labels.get(key) ?? key,
|
||||||
|
recorded: episode.rewards[key] ?? null,
|
||||||
|
recomputed: recomputed[key] ?? null,
|
||||||
|
delta,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.delta - a.delta || a.key.localeCompare(b.key));
|
||||||
|
|
||||||
|
const recordedTotal = scoreReward(demo.reward, episode.rewards).total;
|
||||||
|
const recomputedTotal = scoreReward(demo.reward, recomputed).total;
|
||||||
|
const totalDelta =
|
||||||
|
recordedTotal === null || recomputedTotal === null
|
||||||
|
? recordedTotal === recomputedTotal
|
||||||
|
? 0
|
||||||
|
: Number.POSITIVE_INFINITY
|
||||||
|
: Math.abs(recordedTotal - recomputedTotal);
|
||||||
|
const maxDelta = rows.reduce((worst, row) => Math.max(worst, row.delta), totalDelta);
|
||||||
|
const guilty = rows.filter((row) => row.delta > EPSILON).map((row) => row.label);
|
||||||
|
|
||||||
|
if (guilty.length === 0 && maxDelta <= EPSILON) {
|
||||||
|
return { kind: 'match', recomputed, recordedTotal, recomputedTotal, maxDelta, rows };
|
||||||
|
}
|
||||||
|
return { kind: 'mismatch', recomputed, recordedTotal, recomputedTotal, maxDelta, rows, guilty };
|
||||||
|
}, [demo, episode]);
|
||||||
|
|
||||||
|
if (verdict.kind === 'unverifiable') {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-label="Verification"
|
||||||
|
className={cn('card border-border bg-surface-2 p-3', className)}
|
||||||
|
>
|
||||||
|
<p className="flex items-start gap-2 text-sm">
|
||||||
|
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">Unverifiable in your browser.</span>{' '}
|
||||||
|
<span className="text-muted">{verdict.reason}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = verdict.kind === 'match';
|
||||||
|
|
||||||
|
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 h-4 w-4 shrink-0 text-positive" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-danger" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold text-danger">
|
||||||
|
Mismatch on {verdict.guilty.join(', ')}
|
||||||
|
</span>{' '}
|
||||||
|
<span className="text-fg">
|
||||||
|
— the browser re-run disagrees with the recorded score. Trust the source, not
|
||||||
|
this page.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
||||||
|
Recomputed {formatOrDash(verdict.recomputedTotal)} · recorded{' '}
|
||||||
|
{formatOrDash(verdict.recordedTotal)} · Δ {formatDelta(verdict.maxDelta)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn('h-4 w-4 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="overflow-x-auto border-t border-border">
|
||||||
|
<table className="nums w-full border-collapse font-mono text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-muted">
|
||||||
|
<th scope="col" className="px-3 py-1.5 font-medium">
|
||||||
|
Component
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||||
|
Recorded
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||||
|
Recomputed
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-3 py-1.5 text-right font-medium">
|
||||||
|
Δ
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{verdict.rows.map((row) => {
|
||||||
|
const bad = row.delta > EPSILON;
|
||||||
|
return (
|
||||||
|
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
||||||
|
<th scope="row" className="px-3 py-1.5 text-left font-normal">
|
||||||
|
{row.label}
|
||||||
|
</th>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}
|
||||||
|
>
|
||||||
|
{Number.isFinite(row.delta) ? formatDelta(row.delta) : 'not comparable'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="px-3 py-2 text-[11px] leading-relaxed text-muted">
|
||||||
|
Tolerance {EPSILON.toExponential()}. The browser engine and the Python environment
|
||||||
|
can sum the same terms in a different order, and IEEE-754 addition is not
|
||||||
|
associative, so the comparison is made within a tolerance rather than demanding
|
||||||
|
bit-identical floats.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||||
|
|
||||||
|
export interface ScoredRow {
|
||||||
|
component: RewardComponent;
|
||||||
|
/** The environment's raw per-component score. `null` means NOT SCORED. */
|
||||||
|
score: number | null;
|
||||||
|
/** The weight in force — shipped, or the visitor's edit. */
|
||||||
|
weight: number;
|
||||||
|
/** `score x weight`, or null when the component was not scored. */
|
||||||
|
value: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoredReward {
|
||||||
|
rows: ScoredRow[];
|
||||||
|
/**
|
||||||
|
* The weighted sum over components that were actually scored. `null` when
|
||||||
|
* none of them were: a total of 0 would claim the run scored nothing, which
|
||||||
|
* is a different and much stronger statement than "we could not score it".
|
||||||
|
*/
|
||||||
|
total: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The weights the environment ships, as a plain map the editor can copy. */
|
||||||
|
export function shippedWeights(spec: RewardSpec): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const component of spec.components) out[component.key] = component.weight;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreReward(
|
||||||
|
spec: RewardSpec,
|
||||||
|
values: RewardValues,
|
||||||
|
weights?: Record<string, number>,
|
||||||
|
): ScoredReward {
|
||||||
|
let total = 0;
|
||||||
|
let anyScored = false;
|
||||||
|
const rows = spec.components.map((component) => {
|
||||||
|
const raw = values[component.key];
|
||||||
|
const score = raw === undefined ? null : raw;
|
||||||
|
const weight = weights?.[component.key] ?? component.weight;
|
||||||
|
const value = score === null ? null : score * weight;
|
||||||
|
if (value !== null) {
|
||||||
|
total += value;
|
||||||
|
anyScored = true;
|
||||||
|
}
|
||||||
|
return { component, score, weight, value };
|
||||||
|
});
|
||||||
|
return { rows, total: anyScored ? total : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two reward maps agree to within float noise on every key. */
|
||||||
|
export function rewardDelta(a: RewardValues, b: RewardValues): { key: string; delta: number }[] {
|
||||||
|
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||||
|
const out: { key: string; delta: number }[] = [];
|
||||||
|
for (const key of keys) {
|
||||||
|
const left = a[key];
|
||||||
|
const right = b[key];
|
||||||
|
// Both absent or both explicitly not-scored is agreement, not a zero
|
||||||
|
// delta on a number nobody produced.
|
||||||
|
if ((left === null || left === undefined) && (right === null || right === undefined)) {
|
||||||
|
out.push({ key, delta: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (left === null || left === undefined || right === null || right === undefined) {
|
||||||
|
// One side scored and the other did not. That is a real disagreement and
|
||||||
|
// it has no numeric magnitude, so flag it as infinite rather than as 0.
|
||||||
|
out.push({ key, delta: Number.POSITIVE_INFINITY });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ key, delta: Math.abs(left - right) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,90 +1,38 @@
|
|||||||
import {
|
import type { Vertical } from '@/lib/demo-kit/types';
|
||||||
Boxes,
|
import { iconFor } from '@/content/icons';
|
||||||
BookOpen,
|
|
||||||
Code2,
|
|
||||||
Database,
|
|
||||||
FlaskConical,
|
|
||||||
Gauge,
|
|
||||||
Grid3x3,
|
|
||||||
Headset,
|
|
||||||
Landmark,
|
|
||||||
LifeBuoy,
|
|
||||||
Network,
|
|
||||||
Package,
|
|
||||||
PhoneCall,
|
|
||||||
Plane,
|
|
||||||
RadioTower,
|
|
||||||
Receipt,
|
|
||||||
Scale,
|
|
||||||
ShieldCheck,
|
|
||||||
ShoppingCart,
|
|
||||||
Stethoscope,
|
|
||||||
Target,
|
|
||||||
Truck,
|
|
||||||
Wallet,
|
|
||||||
Zap,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `DemoMeta.icon` is a lucide NAME, not a component — that is deliberate, and
|
* `DemoMeta.icon` is a lucide NAME, not a component — types.ts explains why:
|
||||||
* types.ts says why: importing the component in the meta would drag lucide into
|
* a component in the meta would drag lucide into the entry chunk for every
|
||||||
* the entry chunk for every demo at once.
|
* demo at once. `@/content/icons` is the one place that turns a name back into
|
||||||
*
|
* a component, by name, so it stays tree-shaken. Resolve through it rather than
|
||||||
* Resolving the name therefore has to happen against a static map. A dynamic
|
* growing a second map here, or the same icon name renders as two different
|
||||||
* `import * as lucide` here would work and would also pull all 1,500 icons into
|
* glyphs depending on which surface you are looking at.
|
||||||
* this chunk, which is the exact cost the contract was avoiding. So: named
|
|
||||||
* imports, tree-shaken to what is listed, and an unknown name falls back to a
|
|
||||||
* neutral glyph rather than rendering nothing. If you add a demo whose icon
|
|
||||||
* lands on the fallback, add the name here — that is the one line the header
|
|
||||||
* ever needs.
|
|
||||||
*/
|
*/
|
||||||
const ICONS: Record<string, LucideIcon> = {
|
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
||||||
BookOpen,
|
const Icon = iconFor(name);
|
||||||
Boxes,
|
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
||||||
Code2,
|
}
|
||||||
Database,
|
|
||||||
FlaskConical,
|
|
||||||
Gauge,
|
|
||||||
Grid3x3,
|
|
||||||
Headset,
|
|
||||||
Landmark,
|
|
||||||
LifeBuoy,
|
|
||||||
Network,
|
|
||||||
Package,
|
|
||||||
PhoneCall,
|
|
||||||
Plane,
|
|
||||||
RadioTower,
|
|
||||||
Receipt,
|
|
||||||
Scale,
|
|
||||||
ShieldCheck,
|
|
||||||
ShoppingCart,
|
|
||||||
Stethoscope,
|
|
||||||
Target,
|
|
||||||
Truck,
|
|
||||||
Wallet,
|
|
||||||
Zap,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Used when a vertical has no icon of its own to offer. */
|
/**
|
||||||
export const VERTICAL_ICONS: Record<string, string> = {
|
* The registry groups demos by vertical and gives each group a label, but no
|
||||||
|
* icon — an icon belongs to a demo, not to a taxonomy key. The header wants one
|
||||||
|
* anyway, so this is the mapping, and it is exhaustive over the union: adding a
|
||||||
|
* thirteenth vertical to the contract fails the build here rather than shipping
|
||||||
|
* a blank square in the menu.
|
||||||
|
*/
|
||||||
|
export const VERTICAL_ICONS: Readonly<Record<Vertical, string>> = {
|
||||||
reference: 'Grid3x3',
|
reference: 'Grid3x3',
|
||||||
support: 'Headset',
|
support: 'Headset',
|
||||||
healthcare: 'Stethoscope',
|
healthcare: 'Stethoscope',
|
||||||
insurance: 'ShieldCheck',
|
insurance: 'ShieldCheck',
|
||||||
'financial-crime': 'Landmark',
|
'financial-crime': 'Siren',
|
||||||
energy: 'Zap',
|
energy: 'Zap',
|
||||||
logistics: 'Truck',
|
logistics: 'Truck',
|
||||||
code: 'Code2',
|
code: 'Braces',
|
||||||
retail: 'ShoppingCart',
|
retail: 'Tag',
|
||||||
telecom: 'RadioTower',
|
telecom: 'RadioTower',
|
||||||
data: 'Database',
|
data: 'Table2',
|
||||||
legal: 'Scale',
|
legal: 'Scale',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
|
||||||
const Icon = ICONS[name] ?? Boxes;
|
|
||||||
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowRight, ArrowUpRight, Github, Menu } from 'lucide-react';
|
||||||
|
|
||||||
|
import { listDemos, listVerticals, type VerticalGroup } from '@/lib/demo-kit/registry';
|
||||||
|
import type { DemoMeta } from '@/lib/demo-kit/types';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
NavigationMenu,
|
||||||
|
NavigationMenuContent,
|
||||||
|
NavigationMenuItem,
|
||||||
|
NavigationMenuLink,
|
||||||
|
NavigationMenuList,
|
||||||
|
NavigationMenuTrigger,
|
||||||
|
navigationMenuTriggerStyle,
|
||||||
|
} from '@/components/ui/navigation-menu';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '@/components/ui/accordion';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetBody,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
||||||
|
import { DemoIcon, VERTICAL_ICONS } from '@/components/site/DemoIcon';
|
||||||
|
import { PIG_URL, REPO_URL, VERIFIERS_WORDLE_URL } from '@/components/site/links';
|
||||||
|
import { ThemeToggle } from '@/components/site/ThemeToggle';
|
||||||
|
import { Wordmark } from '@/components/site/Wordmark';
|
||||||
|
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 real upstream taskset, quoted rather than paraphrased.
|
||||||
|
*
|
||||||
|
* A mega-menu that contains source code instead of more links is the cheapest
|
||||||
|
* signal on the whole site that this is not a brochure — it is the first thing
|
||||||
|
* a technical buyer sees, and it is true before they have clicked anything.
|
||||||
|
*/
|
||||||
|
const TASKSET_SOURCE = `class WordleConfig(TextArenaConfig):
|
||||||
|
game: Literal["Wordle-v0"] = "Wordle-v0"
|
||||||
|
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A vertical group as the header renders it: the registry's group plus a glyph. */
|
||||||
|
interface HeaderVertical extends VerticalGroup {
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useLineup() {
|
||||||
|
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');
|
||||||
|
|
||||||
|
const verticals: HeaderVertical[] = listVerticals().map((group) => ({
|
||||||
|
...group,
|
||||||
|
icon: VERTICAL_ICONS[group.vertical],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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 { demos, live, spec, verticals, ctaHref };
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ panels */
|
||||||
|
|
||||||
|
function DemoRow({ demo, muted = false }: { demo: DemoMeta; muted?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/demos/${demo.slug}`}
|
||||||
|
className={cn(
|
||||||
|
'group/row flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||||
|
muted && 'opacity-70 hover:opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs leading-relaxed 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.
|
||||||
|
*/}
|
||||||
|
{demo.status === 'spec' ? `For the ${demo.persona}` : demo.tagline}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelHeading({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="px-2.5 pb-1 text-xs font-semibold uppercase tracking-wider text-muted">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemosPanel({ live, spec }: { live: DemoMeta[]; spec: DemoMeta[] }) {
|
||||||
|
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} />
|
||||||
|
</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 />
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
<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
|
||||||
|
<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) => {
|
||||||
|
// The lead demo is the lowest-order one in the vertical; its reward
|
||||||
|
// line is what the vertical is actually promising.
|
||||||
|
const lead = vertical.demos[0]!;
|
||||||
|
return (
|
||||||
|
<NavigationMenuLink asChild key={vertical.vertical}>
|
||||||
|
<Link
|
||||||
|
to={`/demos/${lead.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.label}</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted">{lead.rewardLine}</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>
|
||||||
|
<pre className="overflow-x-auto text-[11px] leading-relaxed text-fg">
|
||||||
|
<code className="font-mono">{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"
|
||||||
|
>
|
||||||
|
verifiers/environments/wordle
|
||||||
|
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
||||||
|
return (
|
||||||
|
<div className="w-[min(92vw,760px)] p-4">
|
||||||
|
<div className="grid grid-cols-2 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>
|
||||||
|
))}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
See all five on one recorded run
|
||||||
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</dl>
|
||||||
|
<TasksetSource />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ mobile */
|
||||||
|
|
||||||
|
function MobileNav({
|
||||||
|
live,
|
||||||
|
spec,
|
||||||
|
verticals,
|
||||||
|
ctaHref,
|
||||||
|
}: {
|
||||||
|
live: DemoMeta[];
|
||||||
|
spec: DemoMeta[];
|
||||||
|
verticals: HeaderVertical[];
|
||||||
|
ctaHref: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-touch" aria-label="Open menu">
|
||||||
|
<Menu aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
{/*
|
||||||
|
The sheet is a flex column: header, a scrolling body, then a pinned
|
||||||
|
footer. Scrolling the BODY rather than the content root is what keeps a
|
||||||
|
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)]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Menu</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
Demos, verticals, and how these environments work.
|
||||||
|
</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} />
|
||||||
|
</SheetClose>
|
||||||
|
))}
|
||||||
|
<PanelHeading>Shipping next</PanelHeading>
|
||||||
|
{spec.map((demo) => (
|
||||||
|
<SheetClose asChild key={demo.slug}>
|
||||||
|
<DemoRow demo={demo} muted />
|
||||||
|
</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
|
||||||
|
<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) => {
|
||||||
|
const lead = vertical.demos[0]!;
|
||||||
|
return (
|
||||||
|
<SheetClose asChild key={vertical.vertical}>
|
||||||
|
<Link
|
||||||
|
to={`/demos/${lead.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.label}</span>
|
||||||
|
<span className="text-xs leading-relaxed text-muted">
|
||||||
|
{lead.rewardLine}
|
||||||
|
</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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Source on GitHub
|
||||||
|
<Github aria-hidden="true" className="size-4 text-muted" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 pt-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
<ContrastToggle />
|
||||||
|
</div>
|
||||||
|
</SheetBody>
|
||||||
|
|
||||||
|
<SheetFooter>
|
||||||
|
<SheetClose asChild>
|
||||||
|
<Button asChild size="lg" className="w-full">
|
||||||
|
<Link to={ctaHref}>Play the demo</Link>
|
||||||
|
</Button>
|
||||||
|
</SheetClose>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ header */
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
const { live, spec, verticals, ctaHref } = useLineup();
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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))]">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="tap flex shrink-0 items-center rounded-md pr-2 text-base"
|
||||||
|
aria-label="PIG demo, home"
|
||||||
|
>
|
||||||
|
<Wordmark />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
`h-full` is not cosmetic. The viewport hangs off the Root's
|
||||||
|
`top-full`, so a Root only as tall as its 36px triggers drops the
|
||||||
|
panel INSIDE the header, over its own bottom border.
|
||||||
|
*/}
|
||||||
|
<NavigationMenu className="hidden h-full lg:flex" delayDuration={120}>
|
||||||
|
<NavigationMenuList>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuTrigger>Demos</NavigationMenuTrigger>
|
||||||
|
<NavigationMenuContent>
|
||||||
|
<DemosPanel live={live} spec={spec} />
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuTrigger>Verticals</NavigationMenuTrigger>
|
||||||
|
<NavigationMenuContent>
|
||||||
|
<VerticalsPanel verticals={verticals} />
|
||||||
|
</NavigationMenuContent>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<NavigationMenuTrigger>How it works</NavigationMenuTrigger>
|
||||||
|
<NavigationMenuContent>
|
||||||
|
<HowItWorksPanel ctaHref={ctaHref} />
|
||||||
|
</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>
|
||||||
|
</NavigationMenuItem>
|
||||||
|
</NavigationMenuList>
|
||||||
|
</NavigationMenu>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
<div className="hidden items-center gap-1 lg:flex">
|
||||||
|
<ThemeToggle />
|
||||||
|
<ContrastToggle />
|
||||||
|
<Button asChild variant="ghost" size="icon">
|
||||||
|
<a
|
||||||
|
href={REPO_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
aria-label="Source on GitHub"
|
||||||
|
>
|
||||||
|
<Github aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild size="touch" className="hidden lg:inline-flex">
|
||||||
|
<Link to={ctaHref}>Play the demo</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="lg:hidden">
|
||||||
|
<MobileNav live={live} spec={spec} verticals={verticals} ctaHref={ctaHref} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* The demo-kit public barrel.
|
||||||
|
*
|
||||||
|
* This is the ONLY module a demo under `src/demos/` is allowed to import from
|
||||||
|
* the shared shell, and it deliberately exposes a small surface: the contract
|
||||||
|
* types, the two `define*` wrappers, and the two pure helpers a demo's own
|
||||||
|
* surface might need to render a score honestly.
|
||||||
|
*
|
||||||
|
* The player, the registry, the verifier and the reward editor's arithmetic are
|
||||||
|
* NOT here. They are shell machinery — a demo that reaches for `usePlayer` is a
|
||||||
|
* demo that has started rendering its own chrome, and the whole point of the
|
||||||
|
* contract is that the shell owns chrome so every demo gets the same one. The
|
||||||
|
* shell imports those from their own modules:
|
||||||
|
*
|
||||||
|
* import { listDemos, loadDemoModule } from '@/lib/demo-kit/registry';
|
||||||
|
* import { usePlayer } from '@/lib/demo-kit/player';
|
||||||
|
* import { loadEpisode, listRuns } from '@/lib/demo-kit/episode';
|
||||||
|
* import { decompose, reweight } from '@/lib/demo-kit/reward';
|
||||||
|
* import { verifyEpisode } from '@/lib/demo-kit/verify';
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DemoEpisode,
|
||||||
|
DemoMeta,
|
||||||
|
DemoModule,
|
||||||
|
DemoStatus,
|
||||||
|
DemoStep,
|
||||||
|
Limit,
|
||||||
|
ModelCall,
|
||||||
|
Narrative,
|
||||||
|
Provenance,
|
||||||
|
RewardComponent,
|
||||||
|
RewardSpec,
|
||||||
|
RewardValues,
|
||||||
|
RunRef,
|
||||||
|
StoryBeat,
|
||||||
|
Vertical,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { defineDemo, defineMeta } from './define';
|
||||||
|
|
||||||
|
/** `null` is "not scored", never 0.0. Demos render absences with these two. */
|
||||||
|
export { isNotScored, rewardTotal } from './episode';
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* The replay transport.
|
||||||
|
*
|
||||||
|
* A recorded rollout is played back on the timings the model actually took.
|
||||||
|
* That is not decoration: "the second guess took four seconds and 900 tokens"
|
||||||
|
* is one of the few things on this page an executive can feel rather than read.
|
||||||
|
*
|
||||||
|
* So the hard rule here is that **no code path invents a duration silently**.
|
||||||
|
* When a step's `call.durationMs` is null the player falls back to
|
||||||
|
* `FALLBACK_STEP_MS` and says so — `timingIsReal` goes false and `invented[i]`
|
||||||
|
* marks the step — so the UI can label the timeline as approximate instead of
|
||||||
|
* quietly presenting a made-up number as a measurement.
|
||||||
|
*
|
||||||
|
* Playback always starts PAUSED. A board that animates itself the moment the
|
||||||
|
* page loads has already played its best moment to a visitor who was still
|
||||||
|
* reading the headline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
|
import type { DemoStep } from './types';
|
||||||
|
|
||||||
|
/** 1x, 2x, 4x, or straight to the end. */
|
||||||
|
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
||||||
|
|
||||||
|
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
||||||
|
|
||||||
|
export function isPlaybackSpeed(value: unknown): value is PlaybackSpeed {
|
||||||
|
return value === 1 || value === 2 || value === 4 || value === 'instant';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dwell for a step whose real duration was not recorded. Exported and named so
|
||||||
|
* that when it appears on screen it can be labelled as the estimate it is.
|
||||||
|
*/
|
||||||
|
export const FALLBACK_STEP_MS = 900;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often the fractional progress within a step is pushed into React state.
|
||||||
|
*
|
||||||
|
* Not every frame, on purpose: `progress` re-renders the whole demo page, and
|
||||||
|
* at 60 Hz on a phone that is the difference between a smooth board and a warm
|
||||||
|
* one. ~15 Hz is plenty as long as whatever consumes it has a CSS transition on
|
||||||
|
* the property it drives — give your progress bar `transition-[width]
|
||||||
|
* duration-1` and the gaps disappear.
|
||||||
|
*/
|
||||||
|
const PROGRESS_TICK_MS = 66;
|
||||||
|
|
||||||
|
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
function subscribeReducedMotion(onChange: () => void): () => void {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return () => undefined;
|
||||||
|
}
|
||||||
|
const query = window.matchMedia(REDUCED_MOTION_QUERY);
|
||||||
|
query.addEventListener('change', onChange);
|
||||||
|
return () => query.removeEventListener('change', onChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readReducedMotion(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||||
|
return window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live `prefers-reduced-motion`. Re-renders when the OS setting changes. */
|
||||||
|
export function usePrefersReducedMotion(): boolean {
|
||||||
|
return useSyncExternalStore(subscribeReducedMotion, readReducedMotion, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recorded duration, or `null` when the trace did not carry a usable one. */
|
||||||
|
function recordedDurationMs(step: DemoStep<unknown> | undefined): number | null {
|
||||||
|
const recorded = step?.call?.durationMs;
|
||||||
|
if (recorded === null || recorded === undefined) return null;
|
||||||
|
// A zero or negative duration is a recording artefact, not a measurement.
|
||||||
|
if (!Number.isFinite(recorded) || recorded <= 0) return null;
|
||||||
|
return recorded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerOptions {
|
||||||
|
/** Where to start — typically the `step` URL param. Clamped. */
|
||||||
|
initialIndex?: number;
|
||||||
|
initialSpeed?: PlaybackSpeed;
|
||||||
|
/** Fired on every index change, including seeks. Used to sync the permalink. */
|
||||||
|
onIndexChange?: (index: number) => void;
|
||||||
|
/** Override the invented dwell. Still reported as invented. */
|
||||||
|
fallbackStepMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player<TState> {
|
||||||
|
index: number;
|
||||||
|
/** `undefined` only when there are no steps at all. */
|
||||||
|
step: DemoStep<TState> | undefined;
|
||||||
|
stepCount: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
speed: PlaybackSpeed;
|
||||||
|
/** 0..1 through the current step. Pinned to 0 under reduced motion. */
|
||||||
|
progress: number;
|
||||||
|
elapsedMs: number;
|
||||||
|
totalMs: number;
|
||||||
|
atStart: boolean;
|
||||||
|
atEnd: boolean;
|
||||||
|
/** The OS setting, surfaced so surfaces can skip their own animations too. */
|
||||||
|
reducedMotion: boolean;
|
||||||
|
/** False when ANY step's dwell was invented. Label the timeline when false. */
|
||||||
|
timingIsReal: boolean;
|
||||||
|
/** Per step: was this dwell invented? */
|
||||||
|
invented: readonly boolean[];
|
||||||
|
/** The dwell actually used per step, real or invented. */
|
||||||
|
durationsMs: readonly number[];
|
||||||
|
fallbackStepMs: number;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
/** Jump to a step. Does NOT change play/pause — a scrubber drag keeps playing. */
|
||||||
|
seek: (index: number) => void;
|
||||||
|
/** Manual stepping pauses: you asked to look at this one. */
|
||||||
|
next: () => void;
|
||||||
|
prev: () => void;
|
||||||
|
restart: () => void;
|
||||||
|
setSpeed: (speed: PlaybackSpeed) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlayer<TState>(
|
||||||
|
steps: readonly DemoStep<TState>[],
|
||||||
|
options: PlayerOptions = {},
|
||||||
|
): Player<TState> {
|
||||||
|
const fallbackStepMs = options.fallbackStepMs ?? FALLBACK_STEP_MS;
|
||||||
|
const stepCount = steps.length;
|
||||||
|
const lastIndex = Math.max(0, stepCount - 1);
|
||||||
|
const reducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
const [index, setIndexState] = useState(() =>
|
||||||
|
clamp(options.initialIndex ?? 0, 0, Math.max(0, steps.length - 1)),
|
||||||
|
);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [speed, setSpeedState] = useState<PlaybackSpeed>(options.initialSpeed ?? 1);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
const indexRef = useRef(index);
|
||||||
|
const elapsedRef = useRef(0);
|
||||||
|
const lastProgressPushRef = useRef(0);
|
||||||
|
|
||||||
|
// Held in a ref so changing the callback never restarts the animation loop.
|
||||||
|
const onIndexChangeRef = useRef(options.onIndexChange);
|
||||||
|
onIndexChangeRef.current = options.onIndexChange;
|
||||||
|
|
||||||
|
const durationsMs = useMemo(
|
||||||
|
() => steps.map((step) => recordedDurationMs(step) ?? fallbackStepMs),
|
||||||
|
[steps, fallbackStepMs],
|
||||||
|
);
|
||||||
|
const invented = useMemo(() => steps.map((step) => recordedDurationMs(step) === null), [steps]);
|
||||||
|
const timingIsReal = useMemo(() => !invented.includes(true), [invented]);
|
||||||
|
|
||||||
|
const cumulativeMs = useMemo(() => {
|
||||||
|
let running = 0;
|
||||||
|
return durationsMs.map((duration) => {
|
||||||
|
const start = running;
|
||||||
|
running += duration;
|
||||||
|
return start;
|
||||||
|
});
|
||||||
|
}, [durationsMs]);
|
||||||
|
const totalMs = useMemo(() => durationsMs.reduce((sum, d) => sum + d, 0), [durationsMs]);
|
||||||
|
|
||||||
|
const dwellAt = useCallback(
|
||||||
|
(at: number) => durationsMs[at] ?? fallbackStepMs,
|
||||||
|
[durationsMs, fallbackStepMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commitIndex = useCallback((next: number) => {
|
||||||
|
if (indexRef.current === next) return;
|
||||||
|
indexRef.current = next;
|
||||||
|
setIndexState(next);
|
||||||
|
onIndexChangeRef.current?.(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A new steps array means a new run. Rewind rather than leaving the transport
|
||||||
|
// pointing at step 7 of a rollout that only has four turns.
|
||||||
|
const stepsRef = useRef(steps);
|
||||||
|
useEffect(() => {
|
||||||
|
if (stepsRef.current === steps) return;
|
||||||
|
stepsRef.current = steps;
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
setIsPlaying(false);
|
||||||
|
setProgress(0);
|
||||||
|
commitIndex(0);
|
||||||
|
}, [steps, commitIndex]);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(to: number) => {
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
setProgress(0);
|
||||||
|
commitIndex(clamp(to, 0, Math.max(0, stepsRef.current.length - 1)));
|
||||||
|
},
|
||||||
|
[commitIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
const play = useCallback(() => {
|
||||||
|
if (stepsRef.current.length === 0) return;
|
||||||
|
// Pressing play on the final step replays from the top; the alternative is
|
||||||
|
// a button that visibly does nothing.
|
||||||
|
if (indexRef.current >= stepsRef.current.length - 1) {
|
||||||
|
elapsedRef.current = 0;
|
||||||
|
setProgress(0);
|
||||||
|
commitIndex(0);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
}, [commitIndex]);
|
||||||
|
|
||||||
|
const pause = useCallback(() => setIsPlaying(false), []);
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
if (isPlaying) pause();
|
||||||
|
else play();
|
||||||
|
}, [isPlaying, pause, play]);
|
||||||
|
|
||||||
|
const next = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
seek(indexRef.current + 1);
|
||||||
|
}, [seek]);
|
||||||
|
|
||||||
|
const prev = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
seek(indexRef.current - 1);
|
||||||
|
}, [seek]);
|
||||||
|
|
||||||
|
const restart = useCallback(() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
seek(0);
|
||||||
|
}, [seek]);
|
||||||
|
|
||||||
|
const setSpeed = useCallback((nextSpeed: PlaybackSpeed) => setSpeedState(nextSpeed), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || stepCount === 0) return;
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let previous = performance.now();
|
||||||
|
|
||||||
|
const frame = (now: number): void => {
|
||||||
|
// `instant` is expressed as infinite elapsed time rather than an infinite
|
||||||
|
// rate: `(now - previous) * Infinity` is NaN on the very first frame,
|
||||||
|
// where `now === previous`, and NaN would freeze the transport forever.
|
||||||
|
const gained = speed === 'instant' ? Number.POSITIVE_INFINITY : (now - previous) * speed;
|
||||||
|
previous = now;
|
||||||
|
|
||||||
|
const startIndex = indexRef.current;
|
||||||
|
let elapsed = elapsedRef.current + gained;
|
||||||
|
let at = startIndex;
|
||||||
|
|
||||||
|
while (at < lastIndex && elapsed >= dwellAt(at)) {
|
||||||
|
elapsed -= dwellAt(at);
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (at >= lastIndex && elapsed >= dwellAt(lastIndex)) {
|
||||||
|
elapsedRef.current = dwellAt(lastIndex);
|
||||||
|
commitIndex(lastIndex);
|
||||||
|
setProgress(1);
|
||||||
|
setIsPlaying(false);
|
||||||
|
return; // Run over. Deliberately not scheduling another frame.
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsedRef.current = elapsed;
|
||||||
|
|
||||||
|
if (at !== startIndex) {
|
||||||
|
commitIndex(at);
|
||||||
|
lastProgressPushRef.current = now;
|
||||||
|
// Reduced motion still advances on real time — the content is not the
|
||||||
|
// animation — but no fractional progress is emitted, so nothing on the
|
||||||
|
// page is being driven frame by frame.
|
||||||
|
setProgress(reducedMotion ? 0 : Math.min(1, elapsed / dwellAt(at)));
|
||||||
|
} else if (!reducedMotion && now - lastProgressPushRef.current >= PROGRESS_TICK_MS) {
|
||||||
|
lastProgressPushRef.current = now;
|
||||||
|
setProgress(Math.min(1, elapsed / dwellAt(at)));
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [isPlaying, speed, stepCount, lastIndex, dwellAt, reducedMotion, commitIndex]);
|
||||||
|
|
||||||
|
const elapsedMs = (cumulativeMs[index] ?? 0) + progress * dwellAt(index);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
step: steps[index],
|
||||||
|
stepCount,
|
||||||
|
isPlaying,
|
||||||
|
speed,
|
||||||
|
progress,
|
||||||
|
elapsedMs,
|
||||||
|
totalMs,
|
||||||
|
atStart: index === 0,
|
||||||
|
atEnd: stepCount === 0 || index >= lastIndex,
|
||||||
|
reducedMotion,
|
||||||
|
timingIsReal,
|
||||||
|
invented,
|
||||||
|
durationsMs,
|
||||||
|
fallbackStepMs,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggle,
|
||||||
|
seek,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
restart,
|
||||||
|
setSpeed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) return min;
|
||||||
|
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Per-route document head.
|
||||||
|
*
|
||||||
|
* `scripts/prerender.mjs` bakes these tags into the static HTML at build time,
|
||||||
|
* which is what crawlers and link unfurlers actually read. This hook exists for
|
||||||
|
* the other half: a visitor who lands on `/` and clicks through to a demo never
|
||||||
|
* fetches a new document, so without it the tab title and the canonical link
|
||||||
|
* would still say "home" three pages later.
|
||||||
|
*
|
||||||
|
* Deliberately no cleanup. Restoring the previous head on unmount would mean
|
||||||
|
* every navigation flickers back to the old title before the next route sets
|
||||||
|
* its own; the next route always sets one, so the last writer simply wins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export const SITE_ORIGIN = 'https://demo.primeintellectgrowth.com';
|
||||||
|
export const SITE_NAME = 'PIG Demo';
|
||||||
|
|
||||||
|
/** `Wordle-five — PIG Demo`. One place, so every tab reads the same shape. */
|
||||||
|
export function pageTitle(name?: string): string {
|
||||||
|
return name && name.trim() !== '' ? `${name} — ${SITE_NAME}` : SITE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolutise a site-root path. Open Graph consumers do not resolve relative
|
||||||
|
* URLs — a relative `og:image` is simply no image, silently, and you only find
|
||||||
|
* out when someone pastes the link into Slack.
|
||||||
|
*/
|
||||||
|
export function absoluteUrl(pathOrUrl: string): string {
|
||||||
|
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
||||||
|
return `${SITE_ORIGIN}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeoInput {
|
||||||
|
/** Used verbatim as `document.title`. Wrap with `pageTitle()` if you want the suffix. */
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
/** Site-root path or absolute URL. */
|
||||||
|
canonical?: string;
|
||||||
|
/** Site-root path or absolute URL. */
|
||||||
|
ogImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MetaKey = { name: string } | { property: string };
|
||||||
|
|
||||||
|
function upsertMeta(key: MetaKey, content: string): void {
|
||||||
|
const selector =
|
||||||
|
'name' in key ? `meta[name="${key.name}"]` : `meta[property="${key.property}"]`;
|
||||||
|
let tag = document.head.querySelector<HTMLMetaElement>(selector);
|
||||||
|
if (!tag) {
|
||||||
|
tag = document.createElement('meta');
|
||||||
|
if ('name' in key) tag.setAttribute('name', key.name);
|
||||||
|
else tag.setAttribute('property', key.property);
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
}
|
||||||
|
tag.setAttribute('content', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertCanonical(href: string): void {
|
||||||
|
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.setAttribute('rel', 'canonical');
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
link.setAttribute('href', href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySeo(input: SeoInput): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
|
||||||
|
document.title = input.title;
|
||||||
|
upsertMeta({ property: 'og:title' }, input.title);
|
||||||
|
upsertMeta({ name: 'twitter:title' }, input.title);
|
||||||
|
|
||||||
|
if (input.description) {
|
||||||
|
upsertMeta({ name: 'description' }, input.description);
|
||||||
|
upsertMeta({ property: 'og:description' }, input.description);
|
||||||
|
upsertMeta({ name: 'twitter:description' }, input.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.canonical) {
|
||||||
|
const href = absoluteUrl(input.canonical);
|
||||||
|
upsertCanonical(href);
|
||||||
|
upsertMeta({ property: 'og:url' }, href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.ogImage) {
|
||||||
|
const href = absoluteUrl(input.ogImage);
|
||||||
|
upsertMeta({ property: 'og:image' }, href);
|
||||||
|
upsertMeta({ name: 'twitter:image' }, href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the head for this route.
|
||||||
|
*
|
||||||
|
* Deps are the individual strings rather than the object, so a caller can pass
|
||||||
|
* an inline literal without re-running this on every render.
|
||||||
|
*/
|
||||||
|
export function useSeo(input: SeoInput): void {
|
||||||
|
const { title, description, canonical, ogImage } = input;
|
||||||
|
useEffect(() => {
|
||||||
|
applySeo({
|
||||||
|
title,
|
||||||
|
...(description === undefined ? {} : { description }),
|
||||||
|
...(canonical === undefined ? {} : { canonical }),
|
||||||
|
...(ogImage === undefined ? {} : { ogImage }),
|
||||||
|
});
|
||||||
|
}, [title, description, canonical, ogImage]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* Every permalink parameter on this site, in one module.
|
||||||
|
*
|
||||||
|
* The demo pages are meant to be sent to someone: "look at step 4 of the
|
||||||
|
* fine-tuned run". That only works if the URL is the state, and it only stays
|
||||||
|
* readable if a clean state produces a clean URL. So the two rules here are:
|
||||||
|
*
|
||||||
|
* 1. Defaults are OMITTED. `/demos/wordle-five` and
|
||||||
|
* `/demos/wordle-five?step=0&speed=1&tab=play` are the same page, and only
|
||||||
|
* the first one is worth pasting into an email.
|
||||||
|
* 2. Unknown params SURVIVE. Every write is built from the params that are
|
||||||
|
* already there, so a campaign tag or a future param added by another page
|
||||||
|
* is not silently eaten by a scrub of the timeline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
import { isPlaybackSpeed, type PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||||
|
|
||||||
|
export const PARAM = {
|
||||||
|
run: 'run',
|
||||||
|
step: 'step',
|
||||||
|
tab: 'tab',
|
||||||
|
seed: 'seed',
|
||||||
|
speed: 'speed',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The tab a demo page opens on when the URL says nothing. */
|
||||||
|
export const DEFAULT_TAB = 'play';
|
||||||
|
|
||||||
|
export interface DemoUrlState {
|
||||||
|
/** Run id from the manifest. `null` means "the demo's first run". */
|
||||||
|
run: string | null;
|
||||||
|
/** Zero-based step index. */
|
||||||
|
step: number;
|
||||||
|
tab: string;
|
||||||
|
/** `null` means "the demo's own default seed". */
|
||||||
|
seed: number | null;
|
||||||
|
speed: PlaybackSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DemoUrlPatch = Partial<DemoUrlState>;
|
||||||
|
|
||||||
|
export interface PatchOptions {
|
||||||
|
/**
|
||||||
|
* Replace the history entry instead of pushing one. Left unset, params that
|
||||||
|
* change during playback (`step`, `speed`) replace and everything else
|
||||||
|
* pushes — so Back leaves the tab you were on rather than rewinding the
|
||||||
|
* scrubber one frame at a time.
|
||||||
|
*/
|
||||||
|
replace?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Params that move while the visitor is just watching, not navigating. */
|
||||||
|
const TRANSIENT_PARAMS = new Set<keyof DemoUrlState>(['step', 'speed']);
|
||||||
|
|
||||||
|
function parseIndex(raw: string | null): number {
|
||||||
|
if (raw === null) return 0;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) return 0;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeed(raw: string | null): number | null {
|
||||||
|
if (raw === null || raw.trim() === '') return null;
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpeed(raw: string | null): PlaybackSpeed {
|
||||||
|
if (raw === null) return 1;
|
||||||
|
if (raw === 'instant') return 'instant';
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
return isPlaybackSpeed(parsed) ? parsed : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUrlState(params: URLSearchParams, defaultTab = DEFAULT_TAB): DemoUrlState {
|
||||||
|
const run = params.get(PARAM.run);
|
||||||
|
const tab = params.get(PARAM.tab);
|
||||||
|
return {
|
||||||
|
run: run === null || run.trim() === '' ? null : run,
|
||||||
|
step: parseIndex(params.get(PARAM.step)),
|
||||||
|
tab: tab === null || tab.trim() === '' ? defaultTab : tab,
|
||||||
|
seed: parseSeed(params.get(PARAM.seed)),
|
||||||
|
speed: parseSpeed(params.get(PARAM.speed)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a patch to a set of params, dropping anything that is at its default.
|
||||||
|
* Returns a NEW URLSearchParams; the input is never mutated.
|
||||||
|
*/
|
||||||
|
export function writeUrlState(
|
||||||
|
params: URLSearchParams,
|
||||||
|
patch: DemoUrlPatch,
|
||||||
|
defaultTab = DEFAULT_TAB,
|
||||||
|
): URLSearchParams {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
|
||||||
|
const put = (key: string, value: string | null): void => {
|
||||||
|
if (value === null) next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('run' in patch) put(PARAM.run, patch.run ?? null);
|
||||||
|
if ('step' in patch) {
|
||||||
|
const step = patch.step ?? 0;
|
||||||
|
put(PARAM.step, step > 0 ? String(Math.trunc(step)) : null);
|
||||||
|
}
|
||||||
|
if ('tab' in patch) {
|
||||||
|
const tab = patch.tab ?? defaultTab;
|
||||||
|
put(PARAM.tab, tab === defaultTab ? null : tab);
|
||||||
|
}
|
||||||
|
if ('seed' in patch) {
|
||||||
|
const seed = patch.seed;
|
||||||
|
put(PARAM.seed, seed === null || seed === undefined ? null : String(Math.trunc(seed)));
|
||||||
|
}
|
||||||
|
if ('speed' in patch) {
|
||||||
|
const speed = patch.speed ?? 1;
|
||||||
|
put(PARAM.speed, speed === 1 ? null : String(speed));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UrlStateApi {
|
||||||
|
state: DemoUrlState;
|
||||||
|
patch: (changes: DemoUrlPatch, options?: PatchOptions) => void;
|
||||||
|
/** Clear every param this module owns; anything else in the URL survives. */
|
||||||
|
reset: (options?: PatchOptions) => void;
|
||||||
|
/** `/demos/x?step=3` — path plus search, for a router `<Link to>`. */
|
||||||
|
hrefFor: (changes?: DemoUrlPatch) => string;
|
||||||
|
/** Absolute URL, for a copy-link button. */
|
||||||
|
permalinkFor: (changes?: DemoUrlPatch) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUrlState(options?: { defaultTab?: string }): UrlStateApi {
|
||||||
|
const defaultTab = options?.defaultTab ?? DEFAULT_TAB;
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
const state = useMemo(() => readUrlState(params, defaultTab), [params, defaultTab]);
|
||||||
|
|
||||||
|
const patch = useCallback(
|
||||||
|
(changes: DemoUrlPatch, patchOptions?: PatchOptions) => {
|
||||||
|
const keys = Object.keys(changes) as (keyof DemoUrlState)[];
|
||||||
|
const replace = patchOptions?.replace ?? keys.every((key) => TRANSIENT_PARAMS.has(key));
|
||||||
|
// The updater form matters: two patches in the same tick (the player
|
||||||
|
// advancing a step while the visitor clicks a tab) would otherwise both
|
||||||
|
// read the pre-render params and the second would undo the first.
|
||||||
|
setParams((prev) => writeUrlState(prev, changes, defaultTab), {
|
||||||
|
replace,
|
||||||
|
preventScrollReset: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setParams, defaultTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = useCallback(
|
||||||
|
(patchOptions?: PatchOptions) => {
|
||||||
|
setParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
for (const key of Object.values(PARAM)) next.delete(key);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: patchOptions?.replace ?? false, preventScrollReset: true },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setParams],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hrefFor = useCallback(
|
||||||
|
(changes: DemoUrlPatch = {}) => {
|
||||||
|
const search = writeUrlState(params, changes, defaultTab).toString();
|
||||||
|
return search ? `${pathname}?${search}` : pathname;
|
||||||
|
},
|
||||||
|
[params, pathname, defaultTab],
|
||||||
|
);
|
||||||
|
|
||||||
|
const permalinkFor = useCallback(
|
||||||
|
(changes: DemoUrlPatch = {}) => {
|
||||||
|
const href = hrefFor(changes);
|
||||||
|
// Prerendering runs this file in a browser too, but guard anyway: a
|
||||||
|
// permalink is not worth throwing a page away for.
|
||||||
|
const origin = typeof window === 'undefined' ? '' : window.location.origin;
|
||||||
|
return `${origin}${href}`;
|
||||||
|
},
|
||||||
|
[hrefFor],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { state, patch, reset, hrefFor, permalinkFor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* Single-param conveniences. Each is `[value, setValue]` and each writes
|
||||||
|
* through the same omit-the-default path, so mixing them cannot produce a URL
|
||||||
|
* that `useUrlState` reads back differently.
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function useRunParam(): [string | null, (run: string | null, options?: PatchOptions) => void] {
|
||||||
|
const { state, patch } = useUrlState();
|
||||||
|
const set = useCallback(
|
||||||
|
(run: string | null, options?: PatchOptions) => {
|
||||||
|
// A new run invalidates the step index: step 6 of a nine-turn rollout is
|
||||||
|
// not step 6 of a three-turn one.
|
||||||
|
patch({ run, step: 0 }, options);
|
||||||
|
},
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
return [state.run, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStepParam(): [number, (step: number, options?: PatchOptions) => void] {
|
||||||
|
const { state, patch } = useUrlState();
|
||||||
|
const set = useCallback(
|
||||||
|
(step: number, options?: PatchOptions) => patch({ step }, options),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
return [state.step, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabParam(
|
||||||
|
defaultTab = DEFAULT_TAB,
|
||||||
|
): [string, (tab: string, options?: PatchOptions) => void] {
|
||||||
|
const { state, patch } = useUrlState({ defaultTab });
|
||||||
|
const set = useCallback(
|
||||||
|
(tab: string, options?: PatchOptions) => patch({ tab }, options),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
return [state.tab, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSeedParam(): [number | null, (seed: number | null, options?: PatchOptions) => void] {
|
||||||
|
const { state, patch } = useUrlState();
|
||||||
|
const set = useCallback(
|
||||||
|
(seed: number | null, options?: PatchOptions) => patch({ seed }, options),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
return [state.seed, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpeedParam(): [PlaybackSpeed, (speed: PlaybackSpeed, options?: PatchOptions) => void] {
|
||||||
|
const { state, patch } = useUrlState();
|
||||||
|
const set = useCallback(
|
||||||
|
(speed: PlaybackSpeed, options?: PatchOptions) => patch({ speed }, options),
|
||||||
|
[patch],
|
||||||
|
);
|
||||||
|
return [state.speed, set];
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* App entry.
|
||||||
|
*
|
||||||
|
* `next-themes` is configured with `attribute="data-theme"` because that is
|
||||||
|
* what `tailwind.config.js` darkMode and every `:root[data-theme='dark']` block
|
||||||
|
* in `index.css` key off. Switch it to the default `class` and the site
|
||||||
|
* compiles, runs, and is stuck in light mode forever with no error anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { ThemeProvider } from 'next-themes';
|
||||||
|
import { RouterProvider } from 'react-router-dom';
|
||||||
|
import { router } from '@/router';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const container = document.getElementById('root');
|
||||||
|
if (!container) {
|
||||||
|
throw new Error('No #root element — index.html and main.tsx disagree.');
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(container).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="data-theme"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
/* Without this, switching theme animates every colour on the page at once
|
||||||
|
because half the site has a `transition-colors` on it. */
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { ArrowRight, FileText } from 'lucide-react';
|
||||||
|
|
||||||
|
import { iconFor } from '@/content/icons';
|
||||||
|
import { allDemos, routes, verticalForDemo, verticalKeysInUse } from '@/content/lineup';
|
||||||
|
import { PROPOSAL_NOTICE, verticalByKey } from '@/content/verticals';
|
||||||
|
import type { Vertical } from '@/lib/demo-kit/types';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
|
||||||
|
const ALL = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A label for a `Vertical` key. Everything except `reference` has a vertical
|
||||||
|
* entry to borrow the title from; `reference` is the hello-world demo and
|
||||||
|
* belongs to no industry, so it is named for what it is.
|
||||||
|
*/
|
||||||
|
function verticalLabel(key: Vertical): string {
|
||||||
|
if (key === 'reference') return 'Reference';
|
||||||
|
return verticalByKey(key)?.title ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Gallery() {
|
||||||
|
/*
|
||||||
|
* The filter lives in the URL, not in component state. A filtered gallery is
|
||||||
|
* the thing somebody pastes into a message, and it also means the back button
|
||||||
|
* undoes a filter change, which is what people expect it to do.
|
||||||
|
*/
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const raw = params.get('vertical');
|
||||||
|
const active: Vertical | typeof ALL =
|
||||||
|
raw && verticalKeysInUse.includes(raw as Vertical) ? (raw as Vertical) : ALL;
|
||||||
|
|
||||||
|
const shown = useMemo(
|
||||||
|
() => (active === ALL ? allDemos : allDemos.filter((d) => d.vertical === active)),
|
||||||
|
[active],
|
||||||
|
);
|
||||||
|
|
||||||
|
function select(next: Vertical | typeof ALL) {
|
||||||
|
// `replace` so a run of filter taps leaves one entry in history, not eight.
|
||||||
|
if (next === ALL) setParams({}, { replace: true });
|
||||||
|
else setParams({ vertical: next }, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: readonly (Vertical | typeof ALL)[] = [ALL, ...verticalKeysInUse];
|
||||||
|
|
||||||
|
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 live demo is playable in this tab. 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{filters.length > 2 ? (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="sr-only">Filter by vertical</h2>
|
||||||
|
<ul aria-label="Filter demos by vertical" className="flex flex-wrap gap-2">
|
||||||
|
{filters.map((key) => {
|
||||||
|
const selected = key === active;
|
||||||
|
return (
|
||||||
|
<li key={key}>
|
||||||
|
<button
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`tap inline-flex items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors duration-2 ease-enter ${
|
||||||
|
selected
|
||||||
|
? 'border-brand bg-accent-subtle text-accent-fg'
|
||||||
|
: 'border-border bg-surface text-muted hover:bg-surface-2 hover:text-fg'
|
||||||
|
}`}
|
||||||
|
onClick={() => select(key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{key === ALL ? 'All' : verticalLabel(key)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
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 ? '' : ` in ${verticalLabel(active)}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="card mt-4 p-6">
|
||||||
|
<p className={s.h3}>Nothing here yet.</p>
|
||||||
|
<p className={`${s.prose} mt-2`}>
|
||||||
|
No environment is filed under this vertical. The proposal for it is still on the
|
||||||
|
verticals page, written out in full.
|
||||||
|
</p>
|
||||||
|
<button className={`${s.btnSecondary} mt-4`} onClick={() => select(ALL)} type="button">
|
||||||
|
Show every environment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{shown.map((demo) => {
|
||||||
|
const Icon = iconFor(demo.icon);
|
||||||
|
const isSpec = demo.status === 'spec';
|
||||||
|
const vertical = verticalForDemo(demo);
|
||||||
|
return (
|
||||||
|
<li className="flex" key={demo.slug}>
|
||||||
|
<article className="flex w-full flex-col">
|
||||||
|
<Link
|
||||||
|
// A spec is dimmed but never disabled: it goes to a real
|
||||||
|
// page with a real specification on it, which is the only
|
||||||
|
// thing that makes dimming it honest rather than teasing.
|
||||||
|
className={`${s.cardLink} h-full ${isSpec ? 'opacity-70 hover:opacity-100' : ''}`}
|
||||||
|
to={routes.demo(demo.slug)}
|
||||||
|
>
|
||||||
|
<span className="flex items-start justify-between gap-3">
|
||||||
|
<Icon aria-hidden="true" className="size-5 shrink-0 text-brand" />
|
||||||
|
{isSpec ? (
|
||||||
|
<span className={`${s.pill} gap-1`}>
|
||||||
|
<FileText aria-hidden="true" className="size-3.5" />
|
||||||
|
Spec
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`${s.pill} border-positive/30 bg-positive/10 text-positive`}>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h3 className="mt-3 text-lg font-bold tracking-tight text-fg">{demo.title}</h3>
|
||||||
|
<p className={`${s.prose} mt-1.5 text-sm`}>{demo.tagline}</p>
|
||||||
|
|
||||||
|
<dl className="mt-4 space-y-1.5 text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="shrink-0 text-muted">For</dt>
|
||||||
|
<dd className="text-fg">{demo.persona}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<dt className="shrink-0 text-muted">Reward</dt>
|
||||||
|
<dd className="text-fg">{demo.rewardLine}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<span className="mt-4 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>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{vertical ? (
|
||||||
|
<p className="mt-2 px-1 text-xs text-muted">
|
||||||
|
<Link className={s.link} to={routes.vertical(vertical.slug)}>
|
||||||
|
{vertical.title}
|
||||||
|
</Link>{' '}
|
||||||
|
· {PROPOSAL_NOTICE}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-12 card p-5 sm:p-7">
|
||||||
|
<h2 className={s.h2}>The eleven we have not built</h2>
|
||||||
|
<p className={`${s.prose} mt-3 max-w-2xl`}>
|
||||||
|
The lineup is a set of proposals, written to the same four-part shape as the live one.
|
||||||
|
Reading one takes a minute and tells you whether the idea survives contact with your own
|
||||||
|
numbers.
|
||||||
|
</p>
|
||||||
|
<Link className={`${s.btnPrimary} mt-5`} to={routes.home}>
|
||||||
|
See the lineup
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ArrowUpRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { helloWorldCitations, optimalPlay, reproduce, trainingResult, wordList } from '@/content/evidence';
|
||||||
|
import { REPO_URL, routes } from '@/content/lineup';
|
||||||
|
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
|
||||||
|
/** External link with the new-tab affordance and the announcement to match. */
|
||||||
|
function Out({ children, href }: { children: ReactNode; href: string }) {
|
||||||
|
return (
|
||||||
|
<a className={s.link} href={href} rel="noreferrer noopener" target="_blank">
|
||||||
|
{children}
|
||||||
|
<ArrowUpRight aria-hidden="true" className="ml-0.5 inline size-3.5 align-[-0.1em]" />
|
||||||
|
<span className="sr-only"> (opens in a new tab)</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Heading({ children, id }: { children: ReactNode; id: string }) {
|
||||||
|
return (
|
||||||
|
<h2 className={`${s.h2} scroll-mt-[calc(var(--app-header-h)+1rem)]`} id={id}>
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENTS: readonly { id: string; label: string }[] = [
|
||||||
|
{ id: 'measured', label: 'What we measured ourselves' },
|
||||||
|
{ id: 'cited', label: 'What we cite from elsewhere' },
|
||||||
|
{ id: 'recorded', label: 'The rollouts are recorded' },
|
||||||
|
{ id: 'editor', label: 'The reward editor does not train' },
|
||||||
|
{ id: 'proposals', label: 'The verticals are proposals' },
|
||||||
|
{ id: 'words', label: 'The word list is ours' },
|
||||||
|
{ id: 'easier', label: 'This game is easier than the original' },
|
||||||
|
{ id: 'unmeasured', label: 'What we have not measured' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Honesty() {
|
||||||
|
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`}>
|
||||||
|
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) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<a className={`${s.link} text-sm`} href={`#${item.id}`}>
|
||||||
|
{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ── Measured ───────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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`}>
|
||||||
|
<code>
|
||||||
|
{reproduce.clone}
|
||||||
|
{'\n'}
|
||||||
|
{reproduce.install}
|
||||||
|
{'\n'}
|
||||||
|
{reproduce.evaluate}
|
||||||
|
</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>
|
||||||
|
<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
|
||||||
|
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.
|
||||||
|
</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>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Cited ──────────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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}>
|
||||||
|
{trainingResult.model}: {trainingResult.before} → {trainingResult.after}{' '}
|
||||||
|
{trainingResult.metric}
|
||||||
|
</h3>
|
||||||
|
<p className={`${s.prose} mt-2`}>
|
||||||
|
Published by Prime Intellect, measured on {trainingResult.evalDescription}, after{' '}
|
||||||
|
{trainingResult.method}. Both checkpoints are on Hugging Face, so you can run the eval
|
||||||
|
yourself: <Out href={trainingResult.checkpoints[0].href}>the SFT checkpoint</Out> and{' '}
|
||||||
|
<Out href={trainingResult.checkpoints[1].href}>the RL checkpoint</Out>, with{' '}
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-4 p-5">
|
||||||
|
<h3 className={s.h3}>Wordle 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:
|
||||||
|
</p>
|
||||||
|
<ul className={`${s.prose} mt-3 space-y-2`}>
|
||||||
|
{helloWorldCitations.map((c) => (
|
||||||
|
<li key={c.href}>
|
||||||
|
<Out href={c.href}>{c.label}</Out> — {c.claim}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-4 p-5">
|
||||||
|
<h3 className={s.h3}>{optimalPlay.average} 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
|
||||||
|
ours, and you should not read it as a bar this environment is being measured against.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Recorded ───────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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
|
||||||
|
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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className={`${s.prose} mt-4`}>
|
||||||
|
The cost is real: you cannot make a model react to a word you chose. If that is what you
|
||||||
|
want, run the environment on your own machine against your own endpoint. It is three
|
||||||
|
commands and the code is above.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Reward editor ──────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Proposals ──────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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
|
||||||
|
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`}>
|
||||||
|
<li>They are not on Prime Intellect’s roadmap and we do not speak for Prime Intellect.</li>
|
||||||
|
<li>
|
||||||
|
No customer asked for any of them. No company appears anywhere on this site as a
|
||||||
|
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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<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
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Word list ──────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<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
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<dt className={s.eyebrow}>Answers</dt>
|
||||||
|
<dd className="nums mt-1 text-2xl font-bold text-fg">
|
||||||
|
{wordList.answers.toLocaleString('en-US')}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className={s.eyebrow}>Original game’s answers</dt>
|
||||||
|
<dd className="nums mt-1 text-2xl font-bold text-muted">
|
||||||
|
{wordList.originalAnswers.toLocaleString('en-US')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p className={`${s.prose} mt-4`}>
|
||||||
|
The list is built by a stated rule from sources anyone can fetch, rather than lifted from
|
||||||
|
somebody’s editorial selection. That is the point of it. It also means every comparison to
|
||||||
|
the original game is a comparison between two different games.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Easier ─────────────────────────────────────────────────────── */}
|
||||||
|
<section className="mt-12 max-w-3xl">
|
||||||
|
<Heading id="easier">This environment is materially easier than the original game</Heading>
|
||||||
|
<p className={`${s.prose} mt-4`}>
|
||||||
|
Our answer pool comes from a dictionary rule, so it keeps the regular plurals and past
|
||||||
|
tenses that 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> end in{' '}
|
||||||
|
<span className="font-mono">-ED</span> —{' '}
|
||||||
|
<span className="nums font-semibold text-fg">{wordList.endsInSorEd}</span> in one or the
|
||||||
|
other.
|
||||||
|
</p>
|
||||||
|
<p className={`${s.prose} mt-4`}>
|
||||||
|
That is a large, exploitable regularity. A guess that tests a trailing{' '}
|
||||||
|
<span className="font-mono">S</span> splits our pool roughly one-third to two-thirds every
|
||||||
|
single game, and buys information that the same guess simply does not buy in a list where
|
||||||
|
plurals were deliberately stripped out. Nobody has to be clever to use it; a fixed opening
|
||||||
|
pair picks it up for free.
|
||||||
|
</p>
|
||||||
|
<p className={`${s.prose} mt-4`}>
|
||||||
|
One thing cuts the other way, and we would rather say it than be caught leaving it out: our
|
||||||
|
pool is roughly twice the size of the original’s, which is harder. We have not computed an
|
||||||
|
optimal average for our list, so we cannot give you a single number for the net effect. The
|
||||||
|
practical instruction is the same either way — 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">
|
||||||
|
<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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</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.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="card mt-14 p-5 sm:p-7">
|
||||||
|
<h2 className={s.h3}>Found something on this site that is wrong?</h2>
|
||||||
|
<p className={`${s.prose} mt-2 max-w-2xl`}>
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Read the source
|
||||||
|
</a>
|
||||||
|
<Link className={s.btnSecondary} to={routes.home}>
|
||||||
|
Back to the demo
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { allDemos, lineup, REPO_URL, routes } from '@/content/lineup';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 404 with somewhere to go.
|
||||||
|
*
|
||||||
|
* The destinations are read from the registry and the lineup rather than
|
||||||
|
* hard-coded, so this page cannot become the one place on the site that still
|
||||||
|
* links to a demo we removed.
|
||||||
|
*/
|
||||||
|
export default function NotFound() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const topVerticals = lineup.slice(0, 4);
|
||||||
|
|
||||||
|
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`}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link className={s.btnPrimary} to={routes.home}>
|
||||||
|
Start at the beginning
|
||||||
|
</Link>
|
||||||
|
<Link className={s.btnSecondary} to={routes.gallery}>
|
||||||
|
Every environment
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{allDemos.map((demo) => (
|
||||||
|
<li key={demo.slug}>
|
||||||
|
<Link className={`${s.link} text-sm`} to={routes.demo(demo.slug)}>
|
||||||
|
{demo.title}
|
||||||
|
</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>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav aria-labelledby="nf-verticals">
|
||||||
|
<h2 className={s.h3} id="nf-verticals">
|
||||||
|
The lineup
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{topVerticals.map((v) => (
|
||||||
|
<li key={v.slug}>
|
||||||
|
<Link className={`${s.link} text-sm`} to={routes.vertical(v.slug)}>
|
||||||
|
{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}
|
||||||
|
>
|
||||||
|
All {lineup.length}
|
||||||
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mt-12 p-5">
|
||||||
|
<h2 className={s.h3}>Looking for the numbers?</h2>
|
||||||
|
<p className={`${s.prose} mt-2 max-w-2xl`}>
|
||||||
|
What is measured, what is cited from elsewhere, and what we have deliberately not measured
|
||||||
|
all live on one page.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link className={s.btnSecondary} to={routes.honesty}>
|
||||||
|
Honesty
|
||||||
|
</Link>
|
||||||
|
<a className={s.btnSecondary} href={REPO_URL} rel="noreferrer noopener" target="_blank">
|
||||||
|
The source on GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, ArrowRight, FileText, Play, Quote, TriangleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
import { iconFor } from '@/content/icons';
|
||||||
|
import { demosForVertical, featuredDemo, lineup, routes } from '@/content/lineup';
|
||||||
|
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
|
||||||
|
export default function VerticalPage() {
|
||||||
|
const { slug } = useParams<{ slug: string }>();
|
||||||
|
const vertical = verticalBySlug(slug);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<Link className={s.btnPrimary} to={routes.home}>
|
||||||
|
See the lineup
|
||||||
|
</Link>
|
||||||
|
<Link className={s.btnSecondary} to={routes.gallery}>
|
||||||
|
See what is built
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = iconFor(vertical.icon);
|
||||||
|
const demos = demosForVertical(vertical.key);
|
||||||
|
const live = demos.find((d) => d.status === 'live');
|
||||||
|
const spec = demos.find((d) => d.status === 'spec');
|
||||||
|
|
||||||
|
const index = lineup.findIndex((v) => v.slug === vertical.slug);
|
||||||
|
const previous = index > 0 ? lineup[index - 1] : undefined;
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
||||||
|
All twelve
|
||||||
|
</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}>
|
||||||
|
{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>
|
||||||
|
<h1 className={`${s.h1} min-w-0`}>{vertical.title}</h1>
|
||||||
|
</div>
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{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" />
|
||||||
|
Where this stops
|
||||||
|
</h2>
|
||||||
|
<p className={`${s.prose} mt-2 max-w-3xl 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>
|
||||||
|
{live ? (
|
||||||
|
<Link className={`${s.cardLink} mt-4`} to={routes.demo(live.slug)}>
|
||||||
|
<span className={`${s.pill} self-start border-positive/30 bg-positive/10 text-positive`}>
|
||||||
|
Live demo
|
||||||
|
</span>
|
||||||
|
<span className="mt-3 text-xl font-bold tracking-tight text-fg">{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">
|
||||||
|
<Play aria-hidden="true" className="size-4" />
|
||||||
|
Play it
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
) : spec ? (
|
||||||
|
<Link 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>
|
||||||
|
<span className="mt-3 text-xl font-bold tracking-tight text-fg">{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">
|
||||||
|
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`}>
|
||||||
|
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.
|
||||||
|
</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
|
||||||
|
</Link>
|
||||||
|
<Link className={s.btnSecondary} to={routes.honesty}>
|
||||||
|
What we have not measured
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Move along the ranking ─────────────────────────────────────── */}
|
||||||
|
<nav aria-label="Other verticals" className="mt-12 grid gap-3 sm:grid-cols-2">
|
||||||
|
{previous ? (
|
||||||
|
<Link 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">
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-3.5" />
|
||||||
|
Rank {previous.rank}
|
||||||
|
</span>
|
||||||
|
<span className={`${s.h3} mt-1.5`}>{previous.title}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
{next ? (
|
||||||
|
<Link
|
||||||
|
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}
|
||||||
|
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className={`${s.h3} mt-1.5`}>{next.title}</span>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</nav>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+275
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* Routes, built from the registry.
|
||||||
|
*
|
||||||
|
* There is exactly one demo route — `/demos/:slug` — and it resolves through
|
||||||
|
* `@/lib/demo-kit/registry`. Adding a demo to this site is creating a directory
|
||||||
|
* under `src/demos/`; it is not, and must never become, an edit to this file.
|
||||||
|
* The same goes for `/verticals/:slug`, which groups whatever the registry
|
||||||
|
* found rather than a list written down anywhere.
|
||||||
|
*
|
||||||
|
* Every route below `/` sits under one layout with a root error boundary, and
|
||||||
|
* the demo route adds its own on top. That nesting is the point: a demo whose
|
||||||
|
* chunk fails to load, or whose module is malformed, renders a card inside the
|
||||||
|
* normal page chrome. A single broken demo taking the whole site to a white
|
||||||
|
* screen would be the most expensive bug this repo could ship, given what the
|
||||||
|
* site is arguing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
isRouteErrorResponse,
|
||||||
|
Link,
|
||||||
|
Outlet,
|
||||||
|
ScrollRestoration,
|
||||||
|
useRouteError,
|
||||||
|
type LoaderFunctionArgs,
|
||||||
|
type RouteObject,
|
||||||
|
} from 'react-router-dom';
|
||||||
|
import { getDemo, loadDemoModule } from '@/lib/demo-kit/registry';
|
||||||
|
import { SiteFooter } from '@/components/site/SiteFooter';
|
||||||
|
import { SiteHeader } from '@/components/site/SiteHeader';
|
||||||
|
import { SkipLink } from '@/components/site/SkipLink';
|
||||||
|
import * as s from '@/content/styles';
|
||||||
|
import Home from '@/pages/Home';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one element that renders on every route.
|
||||||
|
*
|
||||||
|
* The header, the footer and the skip link live here rather than in each page,
|
||||||
|
* so a new page cannot ship without them. `#main` is this wrapper, not the
|
||||||
|
* page's own `<main>`: pages own their landmark, and a second `<main>` around
|
||||||
|
* theirs would be invalid HTML and a duplicate landmark in a screen reader's
|
||||||
|
* rotor. The wrapper is only what the skip link focuses.
|
||||||
|
*/
|
||||||
|
export function RootLayout(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-bg text-fg">
|
||||||
|
<SkipLink />
|
||||||
|
<SiteHeader />
|
||||||
|
{/* Restores scroll on Back, and puts a fresh route at the top rather
|
||||||
|
than halfway down the previous page. */}
|
||||||
|
<ScrollRestoration />
|
||||||
|
<div id="main" tabIndex={-1} className="flex-1 outline-none">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({
|
||||||
|
heading,
|
||||||
|
body,
|
||||||
|
detail,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
body: string;
|
||||||
|
detail?: string;
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<main className={`${s.shell} py-16 sm:py-24`}>
|
||||||
|
<div className="card max-w-2xl p-6 sm:p-8" role="alert">
|
||||||
|
<p className={`${s.eyebrow} text-danger`}>Something broke</p>
|
||||||
|
<h1 className={`${s.h2} mt-2`}>{heading}</h1>
|
||||||
|
<p className={`${s.prose} mt-3`}>{body}</p>
|
||||||
|
{detail ? (
|
||||||
|
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-surface-2 p-3 text-xs text-muted">
|
||||||
|
<code>{detail}</code>
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<Link to="/gallery" className={s.btnPrimary}>
|
||||||
|
Back to the demos
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className={s.btnSecondary}>
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns whatever react-router threw into something a person can read. */
|
||||||
|
function describeError(error: unknown): { heading: string; body: string; detail?: string } {
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
if (error.status === 404) {
|
||||||
|
return {
|
||||||
|
heading: 'That page does not exist',
|
||||||
|
body: 'The link may be from an older version of the site, or the demo it pointed at has been renamed.',
|
||||||
|
detail: typeof error.data === 'string' ? error.data : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
heading: `${error.status} ${error.statusText}`,
|
||||||
|
body: 'The page could not be loaded.',
|
||||||
|
detail: typeof error.data === 'string' ? error.data : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return {
|
||||||
|
heading: 'This page failed to load',
|
||||||
|
body: 'The rest of the site still works. If this keeps happening, the source is on GitHub and the issue is reproducible from it.',
|
||||||
|
detail: error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
heading: 'This page failed to load',
|
||||||
|
body: 'The rest of the site still works.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootErrorBoundary(): ReactNode {
|
||||||
|
const error = useRouteError();
|
||||||
|
const described = describeError(error);
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
heading={described.heading}
|
||||||
|
body={described.body}
|
||||||
|
{...(described.detail === undefined ? {} : { detail: described.detail })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DemoErrorBoundary(): ReactNode {
|
||||||
|
const error = useRouteError();
|
||||||
|
if (isRouteErrorResponse(error) && error.status === 404) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
heading="No demo by that name"
|
||||||
|
body="Every demo on this site is a directory in the repository, so a missing one is usually a renamed slug rather than a deleted page."
|
||||||
|
{...(typeof error.data === 'string' ? { detail: error.data } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const described = describeError(error);
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
heading="This demo failed to load"
|
||||||
|
body="Only this demo is affected — the others are separate bundles and still work."
|
||||||
|
{...(described.detail === undefined ? {} : { detail: described.detail })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches errors thrown while a demo's own components RENDER.
|
||||||
|
*
|
||||||
|
* The route error boundary above only sees loader and lazy-import failures; a
|
||||||
|
* demo whose `Surface` throws on a malformed board state would still white-page
|
||||||
|
* the app without this.
|
||||||
|
*/
|
||||||
|
export class DemoRenderBoundary extends Component<
|
||||||
|
{ children: ReactNode },
|
||||||
|
{ error: Error | null }
|
||||||
|
> {
|
||||||
|
override state: { error: Error | null } = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: unknown): { error: Error } {
|
||||||
|
return { error: error instanceof Error ? error : new Error(String(error)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: unknown): void {
|
||||||
|
console.error('[demo] render failed', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render(): ReactNode {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorCard
|
||||||
|
heading="This demo failed to render"
|
||||||
|
body="Only this demo is affected. The recorded runs and the environment source in the repository are unaffected by a bug in the viewer."
|
||||||
|
detail={error.message}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shown while a lazy route's chunk is in flight. */
|
||||||
|
function RouteFallback(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-canvas px-4 py-24 sm:px-6"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-muted">Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the slug and starts the demo's chunk during the navigation rather
|
||||||
|
* than after the page mounts. `loadDemoModule` caches the promise, so the page
|
||||||
|
* calling it again resolves from cache instead of fetching twice.
|
||||||
|
*/
|
||||||
|
async function demoLoader({ params }: LoaderFunctionArgs) {
|
||||||
|
const slug = params.slug;
|
||||||
|
if (typeof slug !== 'string' || slug === '') {
|
||||||
|
throw new Response('No demo slug in the URL.', { status: 404 });
|
||||||
|
}
|
||||||
|
const meta = getDemo(slug);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Response(`No demo named "${slug}".`, { status: 404 });
|
||||||
|
}
|
||||||
|
return { meta, module: await loadDemoModule(slug) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: RouteObject[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: <RootLayout />,
|
||||||
|
errorElement: <RootErrorBoundary />,
|
||||||
|
hydrateFallbackElement: <RouteFallback />,
|
||||||
|
children: [
|
||||||
|
// Home is the landing page and is imported eagerly on purpose: making the
|
||||||
|
// first paint wait on a second network round trip to save bytes on a page
|
||||||
|
// almost every visitor sees is the wrong trade.
|
||||||
|
{ index: true, element: <Home /> },
|
||||||
|
// Two paths, one page. `/gallery` is what the site's own links use;
|
||||||
|
// `/demos` is the shape people guess and the one older links used, and a
|
||||||
|
// redirect would cost a round trip to say the same thing.
|
||||||
|
{
|
||||||
|
path: 'gallery',
|
||||||
|
lazy: async () => ({ Component: (await import('@/pages/Gallery')).default }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'demos',
|
||||||
|
lazy: async () => ({ Component: (await import('@/pages/Gallery')).default }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'demos/:slug',
|
||||||
|
loader: demoLoader,
|
||||||
|
errorElement: <DemoErrorBoundary />,
|
||||||
|
lazy: async () => {
|
||||||
|
const { default: DemoPage } = await import('@/pages/DemoPage');
|
||||||
|
return {
|
||||||
|
Component: () => (
|
||||||
|
<DemoRenderBoundary>
|
||||||
|
<DemoPage />
|
||||||
|
</DemoRenderBoundary>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'verticals/:slug',
|
||||||
|
lazy: async () => ({ Component: (await import('@/pages/Vertical')).default }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'honesty',
|
||||||
|
lazy: async () => ({ Component: (await import('@/pages/Honesty')).default }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
lazy: async () => ({ Component: (await import('@/pages/NotFound')).default }),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const router = createBrowserRouter(routes);
|
||||||
Reference in New Issue
Block a user