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)
|
||||
|
||||
|
||||
# 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:
|
||||
game = Game(seed=3, answer="tares")
|
||||
assert game.play("zzzzz")[1] is not None # not a word
|
||||
|
||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
@@ -105,15 +104,27 @@ def hard_mode_violation(guess: str, prev_guess: str, prev_pattern: str) -> str |
|
||||
return None
|
||||
|
||||
|
||||
def answer_for_seed(seed: int) -> str:
|
||||
"""The hidden word for a seed.
|
||||
def fnv1a32(text: str) -> int:
|
||||
"""FNV-1a, 32-bit. Chosen because it is trivial to reproduce exactly.
|
||||
|
||||
Seeded from a dedicated Random rather than the module-global one, because
|
||||
TextArena seeds the process-global RNG and anything sharing it becomes
|
||||
order-dependent under concurrency.
|
||||
A language's built-in RNG is not portable: `random.Random(7)` is a
|
||||
Mersenne Twister and there is no honest one-line JavaScript equivalent, so
|
||||
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()
|
||||
return pool[random.Random(seed).randrange(len(pool))]
|
||||
return pool[fnv1a32(str(seed)) % len(pool)]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+71
-3
@@ -316,17 +316,43 @@ export function literalAfter(src, anchor, open = '{') {
|
||||
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.
|
||||
*
|
||||
* `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,
|
||||
* 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} label
|
||||
* @param {{lenient?: boolean}} [options]
|
||||
*/
|
||||
export function evalLiteral(text, label) {
|
||||
export function evalLiteral(text, label, options = {}) {
|
||||
const js = segment(text)
|
||||
.map((s) =>
|
||||
s.code
|
||||
@@ -338,7 +364,8 @@ export function evalLiteral(text, label) {
|
||||
)
|
||||
.join('');
|
||||
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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -421,6 +448,47 @@ export function loadAllMetas() {
|
||||
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 */
|
||||
|
||||
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 {
|
||||
Boxes,
|
||||
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 type { Vertical } from '@/lib/demo-kit/types';
|
||||
import { iconFor } from '@/content/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `DemoMeta.icon` is a lucide NAME, not a component — that is deliberate, and
|
||||
* types.ts says why: importing the component in the meta would drag lucide into
|
||||
* the entry chunk for every demo at once.
|
||||
*
|
||||
* Resolving the name therefore has to happen against a static map. A dynamic
|
||||
* `import * as lucide` here would work and would also pull all 1,500 icons into
|
||||
* 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.
|
||||
* `DemoMeta.icon` is a lucide NAME, not a component — types.ts explains why:
|
||||
* a component in the meta would drag lucide into the entry chunk for every
|
||||
* 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
|
||||
* growing a second map here, or the same icon name renders as two different
|
||||
* glyphs depending on which surface you are looking at.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Code2,
|
||||
Database,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plane,
|
||||
RadioTower,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Target,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
};
|
||||
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
||||
const Icon = iconFor(name);
|
||||
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
support: 'Headset',
|
||||
healthcare: 'Stethoscope',
|
||||
insurance: 'ShieldCheck',
|
||||
'financial-crime': 'Landmark',
|
||||
'financial-crime': 'Siren',
|
||||
energy: 'Zap',
|
||||
logistics: 'Truck',
|
||||
code: 'Code2',
|
||||
retail: 'ShoppingCart',
|
||||
code: 'Braces',
|
||||
retail: 'Tag',
|
||||
telecom: 'RadioTower',
|
||||
data: 'Database',
|
||||
data: 'Table2',
|
||||
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