Frontend: site chrome, demo shell, pages, and the contract gates

Five parallel lanes plus an integration pass. The header, gallery, router and
sitemap are all generated from the demo registry, so adding src/demos/<slug>/
puts a demo everywhere with zero edits to shared files — which is the whole
reason demo nine cannot break demo one.

check-demos enforces the twelve contract rules: 142 checks over one live demo.
Two worth naming. The shell may not mention a specific slug, because an
'if (slug === wordle)' in src/components/demo/ is a contract bug wearing a
patch. And a spec-status demo must ship a real specification — task, actions,
grader, counterweight, eval command — since a coming-soon card reads worse than
an honest empty gallery.

Bundle budget holds: entry 108.79 kB gzipped against a 160 kB ceiling, the demo
chunk 21.15 kB against 90 kB. recharts is 108 kB gzipped and lives behind a lazy
import so it never touches the entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 16:09:59 -07:00
parent b601511e7f
commit eb88138d15
31 changed files with 1341 additions and 680 deletions
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#7c3aed"/>
<g fill="#fff">
<rect x="6" y="7" width="7" height="7" rx="1.6" opacity=".45"/>
<rect x="14.5" y="7" width="7" height="7" rx="1.6" opacity=".45"/>
<rect x="6" y="15.5" width="7" height="7" rx="1.6"/>
<rect x="14.5" y="15.5" width="7" height="7" rx="1.6" opacity=".45"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 422 B

+10
View File
@@ -0,0 +1,10 @@
{
"name": "PIG Demo",
"short_name": "PIG Demo",
"description": "Interactive demos of reinforcement-learning environments.",
"start_url": "/",
"display": "standalone",
"background_color": "#fafafa",
"theme_color": "#7c3aed",
"icons": [{ "src": "/icons/favicon.svg", "sizes": "any", "type": "image/svg+xml" }]
}
+6
View File
@@ -0,0 +1,6 @@
# The inverse of primeintellectgrowth.com, deliberately: being found is the
# entire point of this site.
User-agent: *
Allow: /
Sitemap: https://demo.primeintellectgrowth.com/sitemap.xml
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -329,7 +329,7 @@ for (const slug of slugs) {
const rate = rateSources const rate = rateSources
.map((s) => s?.solveRate ?? s?.solve_rate) .map((s) => s?.solveRate ?? s?.solve_rate)
.find((v) => typeof v === 'number' && Number.isFinite(v)); .find((v) => typeof v === 'number' && Number.isFinite(v));
const rendered = walk(abs('src'), (f) => /\.tsx?$/.test(f)).some((f) => /\bsolve[_R]?ate|solveRate|solve_rate/.test(read(f))); const rendered = walk(abs('src'), (f) => /\.tsx?$/.test(f)).some((f) => /solveRate|solve_rate/.test(read(f)));
report.check( report.check(
typeof rate === 'number' && rendered, typeof rate === 'number' && rendered,
rel(abs('public', 'traces', 'manifest.json')), rel(abs('public', 'traces', 'manifest.json')),
+2 -2
View File
@@ -187,8 +187,8 @@ for (const slug of slugs) {
region.some((line) => line.trim() !== ''), region.some((line) => line.trim() !== ''),
rel(onDisk), rel(onDisk),
RULE_MARKER, RULE_MARKER,
`marker "${marker}" delimits an empty region (lines ${open + 2}-${close}). The markers are exclusive, ` + `marker "${marker}" delimits an empty region between lines ${open + 1} and ${close + 1}. The markers are ` +
'so a pair on adjacent lines quotes nothing.', 'exclusive, so a pair on adjacent lines quotes nothing.',
); );
} }
} }
+17 -1
View File
@@ -96,6 +96,12 @@ for (const job of jobs) {
'Refusing to overwrite. Pick another slug, or delete the directory yourself if you meant to start over.', 'Refusing to overwrite. Pick another slug, or delete the directory yourself if you meant to start over.',
); );
} }
// An empty template directory would copy cleanly, report "Created 0 files"
// and leave behind a demo that the registry quarantines for a reason nobody
// connects back to this command.
if (countFiles(job.from) === 0) {
fail(`${rel(job.from)} is empty.`, 'There is nothing to scaffold from. Fill in the template first.');
}
} }
/* -------------------------------------------------------------------- copy */ /* -------------------------------------------------------------------- copy */
@@ -132,11 +138,21 @@ console.log(dim(`The demo will 404 until meta.ts, demo.tsx and public/og/${slug}
/* ----------------------------------------------------------------- helpers */ /* ----------------------------------------------------------------- helpers */
/** Files in a tree, recursively. Used only to reject an empty template. */
function countFiles(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
if (entry.name === '__pycache__' || entry.name === 'node_modules') return total;
if (entry.isDirectory()) return total + countFiles(path.join(dir, entry.name));
return total + (entry.isFile() ? 1 : 0);
}, 0);
}
function capitalise(word) { function capitalise(word) {
return word.charAt(0).toUpperCase() + word.slice(1); return word.charAt(0).toUpperCase() + word.slice(1);
} }
/** Applies the substitution table, longest token first so prefixes cannot win. */ /** Applies the substitution table. No token is a prefix of another, so order
* does not matter here — but keep it that way if you add one. */
function substitute(text, bareName) { function substitute(text, bareName) {
let out = text; let out = text;
for (const [token, value] of substitutions) out = out.split(token).join(value); for (const [token, value] of substitutions) out = out.split(token).join(value);
+5 -5
View File
@@ -34,12 +34,12 @@ if (errors.length) die(`route enumeration failed:\n - ${errors.join('\n - ')}`
/* ------------------------------------------------------------- robots.txt */ /* ------------------------------------------------------------- robots.txt */
// Kept byte-identical to the committed public/robots.txt so re-running this
// script is a no-op in the diff. If you change the wording, change it here —
// this is the generator, and the committed file is its output.
const robots = [ const robots = [
'# demo.primeintellectgrowth.com', '# The inverse of primeintellectgrowth.com, deliberately: being found is the',
'#', '# entire point of this site.',
'# This site is meant to be found. Every page is public, static and safe to',
'# crawl; the source it documents is public too.',
'',
'User-agent: *', 'User-agent: *',
'Allow: /', 'Allow: /',
'', '',
+10 -21
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { Eye, Trophy } from 'lucide-react'; import { Eye, Trophy } from 'lucide-react';
import type { DemoStep } from '@/lib/demo-kit/types'; import type { DemoStep } from '@/lib/demo-kit/types';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatOrDash } from './format'; import { formatOrDash } from './format';
@@ -80,7 +81,7 @@ export function BlindCompare<T>({
<p className="nums text-xs text-muted">Same puzzle, same seed ({seed}).</p> <p className="nums text-xs text-muted">Same puzzle, same seed ({seed}).</p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{sides.map(({ id, run }) => { {sides.map(({ id, run }) => {
const last = run.steps[run.steps.length - 1]; const last = run.steps[run.steps.length - 1];
const picked = vote === id; const picked = vote === id;
@@ -107,13 +108,9 @@ export function BlindCompare<T>({
</div> </div>
{!revealed ? ( {!revealed ? (
<button <Button size="touch" className="w-full" onClick={() => commit(id)}>
type="button"
onClick={() => commit(id)}
className="tap w-full rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90"
>
Agent {id} is better Agent {id} is better
</button> </Button>
) : ( ) : (
<dl className="space-y-1 text-sm"> <dl className="space-y-1 text-sm">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
@@ -136,7 +133,7 @@ export function BlindCompare<T>({
<dt className="text-muted">Reward</dt> <dt className="text-muted">Reward</dt>
<dd className="nums flex items-center gap-1 text-right font-mono font-semibold"> <dd className="nums flex items-center gap-1 text-right font-mono font-semibold">
{revealed && winner === id ? ( {revealed && winner === id ? (
<Trophy className="h-3.5 w-3.5 text-positive" aria-hidden="true" /> <Trophy className="size-3.5 text-positive" aria-hidden="true" />
) : null} ) : null}
{formatOrDash(run.total)} {formatOrDash(run.total)}
</dd> </dd>
@@ -150,21 +147,13 @@ export function BlindCompare<T>({
{!revealed ? ( {!revealed ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<button <Button variant="outline" size="touch" onClick={() => commit('tie')}>
type="button"
onClick={() => commit('tie')}
className="tap rounded-lg border border-border px-3 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2"
>
Too close to call Too close to call
</button> </Button>
<button <Button variant="ghost" size="touch" className="text-muted" onClick={() => setRevealed(true)}>
type="button" <Eye aria-hidden="true" />
onClick={() => setRevealed(true)}
className="tap inline-flex items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-muted transition-colors duration-2 ease-enter hover:text-fg"
>
<Eye className="h-4 w-4" aria-hidden="true" />
Just show me Just show me
</button> </Button>
</div> </div>
) : ( ) : (
<p role="status" className="card bg-surface-2 p-3 text-sm leading-relaxed"> <p role="status" className="card bg-surface-2 p-3 text-sm leading-relaxed">
+63 -39
View File
@@ -1,5 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import * as st from '@/content/styles';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface CodeReceiptProps { export interface CodeReceiptProps {
@@ -26,17 +28,33 @@ export interface MarkedRange {
end: number; end: number;
} }
/**
* True when a line is nothing but the marker inside a comment — `# --8<-- x`,
* `// region: grader`. Such a line is a POINTER to the code below it, not the
* code itself, and marking only the comment is the most common way this panel
* ends up highlighting nothing worth reading.
*/
function isPointerLine(line: string, marker: string): boolean {
const withoutMarker = line.replace(marker, '');
return withoutMarker.replace(/[#/*\-<!>\s]/g, '') === '';
}
/** /**
* Two marker conventions, because both exist in real repos: * Two marker conventions, because both exist in real repos:
* *
* TWICE — the marker brackets a region (`# --8<-- reward` … `# --8<--`). * TWICE — the marker brackets a region (`# --8<-- reward` … `# --8<-- reward`).
* The marked range is the lines BETWEEN them; the fences themselves * The marked range is the lines BETWEEN them; the fences themselves
* are not interesting code. * are not interesting code.
* *
* ONCE — the marker sits on a definition line (`def compute_reward`). The * ONCE — the marker names a construct. If it sits ON the construct
* marked range is that line plus its indented body, which is what * (`def compute_reward`), the range is that line plus its indented
* you actually meant. Blank lines inside the body are kept; trailing * body. If it sits in a comment ABOVE it, the range starts at the
* blank lines are not, or the highlight runs on past the function. * next real line instead — a fence comment is a pointer, and
* highlighting the pointer rather than the function is a silent,
* plausible-looking failure.
*
* Blank lines inside a body are kept; trailing blank ones are not, or the
* highlight runs on past the end of the function.
* *
* A marker that matches nothing returns null and the whole file renders. That * A marker that matches nothing returns null and the whole file renders. That
* is the right failure: a stale marker must not hide the source. * is the right failure: a stale marker must not hide the source.
@@ -56,17 +74,24 @@ export function resolveMarkedRange(lines: string[], marker?: string): MarkedRang
return last - first > 1 ? { start: first + 1, end: last - 1 } : { start: first, end: last }; return last - first > 1 ? { start: first + 1, end: last - 1 } : { start: first, end: last };
} }
const anchor = lines[first] ?? ''; let start = first;
if (isPointerLine(lines[first] ?? '', marker)) {
const next = lines.findIndex((line, index) => index > first && line.trim() !== '');
if (next === -1) return { start: first, end: first };
start = next;
}
const anchor = lines[start] ?? '';
const indent = anchor.length - anchor.trimStart().length; const indent = anchor.length - anchor.trimStart().length;
let end = first; let end = start;
for (let i = first + 1; i < lines.length; i += 1) { for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i] ?? ''; const line = lines[i] ?? '';
if (line.trim() === '') continue; if (line.trim() === '') continue;
const lineIndent = line.length - line.trimStart().length; const lineIndent = line.length - line.trimStart().length;
if (lineIndent <= indent) break; if (lineIndent <= indent) break;
end = i; end = i;
} }
return { start: first, end }; return { start, end };
} }
/** /**
@@ -106,38 +131,37 @@ export function CodeReceipt({
return ( return (
<section aria-label={`Source: ${path}`} className={cn('card overflow-hidden', className)}> <section aria-label={`Source: ${path}`} className={cn('card overflow-hidden', className)}>
<header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-3 py-2"> <header className="flex flex-col items-start gap-2 border-b border-border px-3 py-2 sm:flex-row sm:items-center sm:gap-x-3">
<h3 className="nums min-w-0 flex-1 truncate font-mono text-xs text-muted" title={path}> {/* Full width on a phone: sharing a row with two controls truncates a
path to "envs/wo…", which names nothing. */}
<h3
className="nums w-full min-w-0 truncate font-mono text-xs text-muted sm:flex-1"
title={path}
>
{path} {path}
</h3> </h3>
{range && !expanded ? ( <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<button {range && !expanded ? (
type="button" <Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(true)}>
onClick={() => setExpanded(true)} Show all {lines.length} lines
className="tap rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2" </Button>
> ) : null}
Show all {lines.length} lines {range && expanded ? (
</button> <Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(false)}>
) : null} Collapse to the marked part
{range && expanded ? ( </Button>
<button ) : null}
type="button" {href ? (
onClick={() => setExpanded(false)} <a
className="tap rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2" href={href}
> rel="noreferrer"
Collapse to the marked part className={cn(st.link, 'tap inline-flex items-center gap-1 text-xs')}
</button> >
) : null} Read the whole file
{href ? ( <ExternalLink className="size-3" aria-hidden="true" />
<a </a>
href={href} ) : null}
rel="noreferrer" </div>
className="tap inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-2 hover:underline"
>
Read the whole file
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
) : null}
</header> </header>
<div className="max-h-96 overflow-auto"> <div className="max-h-96 overflow-auto">
+16 -23
View File
@@ -2,8 +2,10 @@ import type { ComponentType } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import type { DemoMeta } from '@/lib/demo-kit/types'; import type { DemoMeta } from '@/lib/demo-kit/types';
import { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
import { Badge } from '@/components/ui/badge';
import { DemoIcon } from '@/components/site/DemoIcon';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { DemoIcon } from './icons';
export interface DemoCardProps<T> { export interface DemoCardProps<T> {
meta: DemoMeta; meta: DemoMeta;
@@ -11,7 +13,7 @@ export interface DemoCardProps<T> {
href?: string; href?: string;
/** /**
* The demo's OWN board, drawn compact, as the thumbnail. A screenshot would * The demo's OWN board, drawn compact, as the thumbnail. A screenshot would
* go stale the first time the board changes and nobody would notice; this * go stale the first time the board changed and nobody would notice; this
* cannot, because it is the same component the demo page renders. * cannot, because it is the same component the demo page renders.
*/ */
Surface?: ComponentType<{ state: T; compact?: boolean }>; Surface?: ComponentType<{ state: T; compact?: boolean }>;
@@ -20,52 +22,43 @@ export interface DemoCardProps<T> {
className?: string; className?: string;
} }
export function DemoCard<T>({ export function DemoCard<T>({ meta, href, Surface, thumbnailState, className }: DemoCardProps<T>) {
meta, const to = href ?? `/demos/${meta.slug}`;
href,
Surface,
thumbnailState,
className,
}: DemoCardProps<T>) {
const to = href ?? `/demo/${meta.slug}`;
const isSpec = meta.status === 'spec'; const isSpec = meta.status === 'spec';
const showSurface = Surface !== undefined && thumbnailState !== undefined; const showSurface = Surface !== undefined && thumbnailState !== undefined;
return ( return (
<article <article
className={cn( className={cn(
'card group relative flex flex-col overflow-hidden transition-colors duration-2 ease-enter hover:border-brand/50', 'card group relative flex flex-col overflow-hidden transition-colors duration-2 ease-enter hover:border-brand/40',
className, className,
)} )}
> >
<div className="flex items-start gap-3 p-4 pb-3"> <div className="flex items-start gap-3 p-4 pb-3">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg"> <span className="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
<DemoIcon name={meta.icon} className="h-5 w-5" /> <DemoIcon name={meta.icon} className="size-5" />
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h3 className="text-base font-semibold leading-tight"> <h3 className="text-base font-semibold leading-tight">
{/* Stretched link: the whole card is the hit target, but there is {/* Stretched link: the whole card is the hit target, but there is
still exactly ONE link in the accessibility tree for it. */} still exactly ONE link in the accessibility tree for it. */}
<Link <Link to={to} className="after:absolute after:inset-0 after:content-['']">
to={to}
className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
>
{meta.title} {meta.title}
</Link> </Link>
</h3> </h3>
<p className="mt-0.5 text-sm leading-snug text-muted">{meta.tagline}</p> <p className="mt-0.5 text-sm leading-snug text-muted">{meta.tagline}</p>
</div> </div>
{isSpec ? ( {isSpec ? (
<span className="shrink-0 rounded-md border border-border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted"> <Badge variant="outline" className="shrink-0 uppercase tracking-wide">
Spec Spec
</span> </Badge>
) : null} ) : null}
</div> </div>
{showSurface ? ( {showSurface ? (
<div className="mx-4 overflow-hidden rounded-lg bg-surface-2 p-3"> <div className="mx-4 overflow-hidden rounded-lg bg-surface-2 p-3">
{/* Decorative here: the title and tagline already name the demo, and {/* Decorative: the title and tagline already name the demo, and a
a screen reader has no use for a board with no run behind it. */} board with no run behind it is not information. */}
<div aria-hidden="true"> <div aria-hidden="true">
<Surface state={thumbnailState as T} compact /> <Surface state={thumbnailState as T} compact />
</div> </div>
@@ -79,7 +72,7 @@ export function DemoCard<T>({
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<dt className="text-muted">Vertical</dt> <dt className="text-muted">Vertical</dt>
<dd className="font-medium capitalize">{meta.vertical.replace(/-/g, ' ')}</dd> <dd className="font-medium">{VERTICAL_LABELS[meta.vertical]}</dd>
</div> </div>
</dl> </dl>
@@ -91,7 +84,7 @@ export function DemoCard<T>({
<p className="mt-auto flex items-center gap-1 border-t border-border px-4 py-2.5 text-sm font-medium text-accent-fg"> <p className="mt-auto flex items-center gap-1 border-t border-border px-4 py-2.5 text-sm font-medium text-accent-fg">
{isSpec ? 'Read the specification' : 'Open the demo'} {isSpec ? 'Read the specification' : 'Open the demo'}
<ArrowRight <ArrowRight
className="h-4 w-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5" className="size-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5"
aria-hidden="true" aria-hidden="true"
/> />
</p> </p>
+11 -15
View File
@@ -1,6 +1,7 @@
import { Component } from 'react'; import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react'; import type { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react'; import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
const REPO_URL = 'https://github.com/karti-ai/PIG-Demo'; const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
@@ -54,7 +55,7 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
return ( return (
<div role="alert" className="card mx-auto my-10 max-w-xl p-6"> <div role="alert" className="card mx-auto my-10 max-w-xl p-6">
<div className="flex items-center gap-2 text-warning"> <div className="flex items-center gap-2 text-warning">
<AlertTriangle className="h-5 w-5" aria-hidden="true" /> <AlertTriangle className="size-5" aria-hidden="true" />
<h2 className="text-base font-semibold"> <h2 className="text-base font-semibold">
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'} {demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
</h2> </h2>
@@ -68,21 +69,16 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
{error.message || 'Unknown error'} {error.message || 'Unknown error'}
</p> </p>
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2">
<button <Button size="touch" onClick={this.handleReset}>
type="button" <RotateCcw aria-hidden="true" />
onClick={this.handleReset}
className="tap inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
Try again Try again
</button> </Button>
<a <Button variant="outline" size="touch" asChild>
href={sourceHref ?? REPO_URL} <a href={sourceHref ?? REPO_URL} rel="noreferrer">
className="tap inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2" Read the source
> <ExternalLink aria-hidden="true" />
Read the source </a>
<ExternalLink className="h-4 w-4" aria-hidden="true" /> </Button>
</a>
</div> </div>
</div> </div>
); );
+242 -335
View File
@@ -1,14 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import * as Tabs from '@radix-ui/react-tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useUrlState } from '@/lib/url-state'; import { Skeleton } from '@/components/ui/skeleton';
import type { import { listRuns, loadEpisode, rewardTotal } from '@/lib/demo-kit/episode';
DemoEpisode, import { usePlayer } from '@/lib/demo-kit/player';
DemoModule, import { loadDemoModule } from '@/lib/demo-kit/registry';
DemoStep, import type { AnyDemoModule } from '@/lib/demo-kit/registry';
RunRef, import type { DemoEpisode, DemoStep, RunRef, StoryBeat } from '@/lib/demo-kit/types';
StoryBeat, import { useRunParam, useSpeedParam, useStepParam, useTabParam } from '@/lib/url-state';
} from '@/lib/demo-kit/types'; import * as st from '@/content/styles';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BeatSection } from './BeatSection'; import { BeatSection } from './BeatSection';
import { BlindCompare } from './BlindCompare'; import { BlindCompare } from './BlindCompare';
@@ -24,142 +25,71 @@ import { ReasoningPanel } from './ReasoningPanel';
import { RewardBreakdown } from './RewardBreakdown'; import { RewardBreakdown } from './RewardBreakdown';
import { RewardEditor } from './RewardEditor'; import { RewardEditor } from './RewardEditor';
import type { RewardArm } from './RewardEditor'; import type { RewardArm } from './RewardEditor';
import { SegmentedControl } from './SegmentedControl';
import { SlotRegion } from './SlotRegion'; import { SlotRegion } from './SlotRegion';
import { StatStrip } from './StatStrip'; import { StatStrip } from './StatStrip';
import type { Stat } from './StatStrip'; import type { Stat } from './StatStrip';
import { StepTimeline } from './StepTimeline'; import { StepTimeline } from './StepTimeline';
import { RecordedBadge, TracePlayer, useTracePlayback } from './TracePlayer'; import { RecordedBadge, TracePlayer } from './TracePlayer';
import { VerifyBadge } from './VerifyBadge'; import { VerifyBadge } from './VerifyBadge';
import { clampIndex, formatOrDash, useIsDesktop } from './format'; import { formatOrDash, useIsDesktop } from './format';
import { scoreReward } from './reward-math';
import { mockDemo, mockEpisodes, mockRuns } from './mock';
const REPO_BLOB = 'https://github.com/karti-ai/PIG-Demo/blob/main/'; const REPO_BLOB = 'https://github.com/karti-ai/PIG-Demo/blob/main/';
const MANIFEST_URL = '/traces/manifest.json';
/** /** The tab the step-detail strip opens on. Kept out of the URL when it is this. */
* Every demo module in the repo, as an unresolved import each. const DEFAULT_DETAIL_TAB = 'reasoning';
*
* `import.meta.glob` rather than a generated registry import on purpose: this
* file must compile and render before any demo directory exists, and a glob
* that matches nothing is an empty object rather than a build error.
*/
const DEMO_MODULES = import.meta.glob<Record<string, unknown>>('/src/demos/*/index.{ts,tsx}');
export interface DemoBundle<T = unknown> { /** Reserved slug for the shell's own hand-written demo. Dev builds only. */
demo: DemoModule<T>; const MOCK_SLUG = '__mock';
export interface DemoBundle {
demo: AnyDemoModule;
runs: RunRef[]; runs: RunRef[];
episodes: Record<string, DemoEpisode>; episodes: Record<string, DemoEpisode>;
} }
type LoadState<T> = type LoadState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'ready'; bundle: DemoBundle<T> } | { status: 'ready'; bundle: DemoBundle }
| { status: 'error'; message: string }; | { status: 'error'; message: string };
function isRunRef(value: unknown): value is RunRef {
if (typeof value !== 'object' || value === null) return false;
const run = value as Record<string, unknown>;
return (
typeof run['id'] === 'string' &&
typeof run['label'] === 'string' &&
typeof run['path'] === 'string' &&
typeof run['model'] === 'string'
);
}
function isEpisode(value: unknown): value is DemoEpisode {
if (typeof value !== 'object' || value === null) return false;
const episode = value as Record<string, unknown>;
return (
typeof episode['runId'] === 'string' &&
Array.isArray(episode['turns']) &&
typeof episode['rewards'] === 'object' &&
episode['rewards'] !== null
);
}
/** /**
* The manifest is data on disk, not a typed import, so it is validated rather * A demo's module plus every recorded run it has.
* than trusted — and three plausible shapes are accepted because the file is *
* written by a script in another lane and a keyed map, a nested map and a flat * `loadDemoModule` and `loadEpisode` both cache their promises, so the route
* list are all reasonable things for that script to have produced. * loader having already fetched the module makes this resolve without a second
* request. Runs are loaded with `allSettled` on purpose: one unreadable trace
* drops that arm rather than blanking the page.
*/ */
export function extractRuns(json: unknown, slug: string): RunRef[] {
if (typeof json !== 'object' || json === null) return [];
const root = json as Record<string, unknown>;
const nested = root['demos'];
const keyed =
(Array.isArray(root[slug]) ? root[slug] : undefined) ??
(typeof nested === 'object' && nested !== null
? (nested as Record<string, unknown>)[slug]
: undefined);
if (Array.isArray(keyed)) return keyed.filter(isRunRef);
const flat = Array.isArray(root['runs']) ? root['runs'] : Array.isArray(json) ? json : null;
if (flat) {
return flat.filter(isRunRef).filter((run) => {
const owner = (run as unknown as Record<string, unknown>)['demo'];
return owner === undefined || owner === slug;
});
}
return [];
}
function pickModule(mod: Record<string, unknown>): DemoModule | null {
const candidate = mod['default'] ?? mod['demo'];
if (typeof candidate !== 'object' || candidate === null) return null;
const shape = candidate as Record<string, unknown>;
return typeof shape['adapt'] === 'function' && typeof shape['Surface'] === 'function'
? (candidate as DemoModule)
: null;
}
async function loadBundle(slug: string): Promise<DemoBundle> { async function loadBundle(slug: string): Promise<DemoBundle> {
if (slug === '__mock') { if (slug === MOCK_SLUG) {
return { demo: mockDemo as unknown as DemoModule, runs: mockRuns, episodes: mockEpisodes }; // Dynamic, so the mock lands in its own chunk and production never fetches
// it. A static import would ship several hundred lines of fake trace to
// every visitor of every real demo.
const mock = await import('./mock');
return { demo: mock.mockDemo, runs: mock.mockRuns, episodes: mock.mockEpisodes };
} }
const entry = Object.entries(DEMO_MODULES).find(([path]) => const demo = await loadDemoModule(slug);
path.startsWith(`/src/demos/${slug}/index.`), const runs = await listRuns(slug).catch(() => [] as RunRef[]);
); const settled = await Promise.allSettled(runs.map((run) => loadEpisode(run)));
if (!entry) throw new Error(`No demo is registered under the slug "${slug}".`);
const demo = pickModule(await entry[1]());
if (!demo) {
throw new Error(`The module for "${slug}" does not export a demo that satisfies the contract.`);
}
const manifest = await fetch(MANIFEST_URL, { cache: 'no-cache' })
.then((response) => (response.ok ? response.json() : null))
.catch(() => null);
const runs = extractRuns(manifest, slug);
// One unreadable trace must not blank the page: fetch them all, keep the
// ones that parse, and let the shell report the shortfall.
const loaded = await Promise.all(
runs.map(async (run) => {
try {
const response = await fetch(run.path, { cache: 'no-cache' });
if (!response.ok) return null;
const json: unknown = await response.json();
return isEpisode(json) ? ([run.id, json] as const) : null;
} catch {
return null;
}
}),
);
const episodes: Record<string, DemoEpisode> = {}; const episodes: Record<string, DemoEpisode> = {};
for (const item of loaded) { settled.forEach((outcome, index) => {
if (item) episodes[item[0]] = item[1]; const run = runs[index];
} if (!run) return;
if (outcome.status === 'fulfilled') episodes[run.id] = outcome.value;
else console.error(`[pig-demo] dropped run "${run.id}":`, outcome.reason);
});
return { demo, runs: runs.filter((run) => episodes[run.id] !== undefined), episodes }; return { demo, runs: runs.filter((run) => episodes[run.id] !== undefined), episodes };
} }
export interface DemoShellProps<T = unknown> { export interface DemoShellProps {
/** Overrides the route param. Useful for previews and tests. */ /** Overrides the route param. Useful for previews and tests. */
slug?: string; slug?: string;
/** Skips loading entirely when the caller already has the bundle. */ /** Skips loading entirely when the caller already has the bundle. */
bundle?: DemoBundle<T>; bundle?: DemoBundle;
} }
/** /**
@@ -168,13 +98,12 @@ export interface DemoShellProps<T = unknown> {
* It owns four things and no more: loading, the narrative beats, the URL state, * It owns four things and no more: loading, the narrative beats, the URL state,
* and the page's single polite live region. Everything visual is delegated to * and the page's single polite live region. Everything visual is delegated to
* the surfaces in this directory, and the demo module is never reached into — * the surfaces in this directory, and the demo module is never reached into —
* the shell is generic over the demo's board type and only ever calls `adapt` * the shell only ever calls `adapt` and renders `Surface`.
* and renders `Surface`.
*/ */
export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProps<T>) { export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
const params = useParams(); const params = useParams();
const slug = slugProp ?? params['slug'] ?? ''; const slug = slugProp ?? params['slug'] ?? '';
const [state, setState] = useState<LoadState<T>>( const [state, setState] = useState<LoadState>(
bundle ? { status: 'ready', bundle } : { status: 'loading' }, bundle ? { status: 'ready', bundle } : { status: 'loading' },
); );
@@ -187,7 +116,7 @@ export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProp
setState({ status: 'loading' }); setState({ status: 'loading' });
loadBundle(slug) loadBundle(slug)
.then((loaded) => { .then((loaded) => {
if (live) setState({ status: 'ready', bundle: loaded as DemoBundle<T> }); if (live) setState({ status: 'ready', bundle: loaded });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (!live) return; if (!live) return;
@@ -204,36 +133,36 @@ export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProp
if (state.status === 'loading') return <ShellSkeleton />; if (state.status === 'loading') return <ShellSkeleton />;
if (state.status === 'error') { if (state.status === 'error') {
return ( return (
<div role="alert" className="card mx-auto my-16 max-w-xl p-6"> <main className={cn(st.shell, 'py-16')}>
<h1 className="text-lg font-semibold">That demo is not here</h1> <div role="alert" className="card max-w-xl p-6">
<p className="mt-2 text-sm leading-relaxed text-muted">{state.message}</p> <h1 className={st.h2}>That demo is not here</h1>
<a <p className={cn(st.prose, 'mt-3')}>{state.message}</p>
href="/" <a href="/gallery" className={cn(st.btnSecondary, 'mt-6')}>
className="tap mt-4 inline-flex items-center rounded-lg border border-border px-4 text-sm font-medium hover:bg-surface-2" Back to the demos
> </a>
Back to the gallery </div>
</a> </main>
</div>
); );
} }
return ( return (
// A second boundary inside the route's own: this one is keyed to the demo
// so a crash names it, and resetting re-renders the surfaces rather than
// re-navigating.
<DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}> <DemoErrorBoundary demoTitle={state.bundle.demo.meta.title}>
<DemoBody bundle={state.bundle} /> <DemoBody bundle={state.bundle} />
</DemoErrorBoundary> </DemoErrorBoundary>
); );
} }
function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) { function DemoBody({ bundle }: { bundle: DemoBundle }) {
const { demo, runs, episodes } = bundle; const { demo, runs, episodes } = bundle;
const isDesktop = useIsDesktop(); const isDesktop = useIsDesktop();
// The shell writes `?step=` on every advance, including during playback. It const [runParam, setRunParam] = useRunParam();
// is `@/lib/url-state`'s job to REPLACE rather than push for these — pushing const [stepParam, setStepParam] = useStepParam();
// would turn a six-step run into six back-button presses. const [tabParam, setTabParam] = useTabParam(DEFAULT_DETAIL_TAB);
const [runParam, setRunParam] = useUrlState('run', ''); const [speedParam, setSpeedParam] = useSpeedParam();
const [stepParam, setStepParam] = useUrlState('step', '0');
const [tabParam, setTabParam] = useUrlState('tab', '');
const run = useMemo( const run = useMemo(
() => runs.find((candidate) => candidate.id === runParam) ?? runs[0], () => runs.find((candidate) => candidate.id === runParam) ?? runs[0],
@@ -241,34 +170,34 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
); );
const episode = run ? episodes[run.id] : undefined; const episode = run ? episodes[run.id] : undefined;
const steps: DemoStep<T>[] = useMemo( const steps = useMemo<DemoStep<unknown>[]>(
() => (episode ? (demo.adapt(episode) as DemoStep<T>[]) : []), () => (episode ? demo.adapt(episode) : []),
[demo, episode], [demo, episode],
); );
const step = clampIndex(Number(stepParam), steps.length); const player = usePlayer(steps, {
const setStep = useCallback( initialIndex: stepParam,
(next: number) => setStepParam(String(clampIndex(next, steps.length))), initialSpeed: speedParam,
[setStepParam, steps.length], onIndexChange: setStepParam,
); });
const playback = useTracePlayback({ stepCount: steps.length, step, onStepChange: setStep }); // The URL is the other writer of this state — Back, a pasted permalink, the
const current = steps[step]; // run switcher. The player is the source of truth while it is running, so it
// only follows the URL when the two have actually diverged.
const { seek } = player;
useEffect(() => {
if (stepParam !== player.index) seek(stepParam);
// Intentionally keyed on the URL only: including `player.index` here would
// re-run the effect on the player's own advance and fight it.
}, [stepParam, seek]);
const hasBeat = (surface: StoryBeat['surface']) => const hasBeat = (surface: StoryBeat['surface']) =>
demo.narrative.beats.some((beat) => beat.surface === surface); demo.narrative.beats.some((beat) => beat.surface === surface);
const timelineInSplit = !hasBeat('scrubber'); const timelineInSplit = !hasBeat('scrubber');
const extras = demo.tabs ?? []; const extras = demo.tabs ?? [];
const extrasInCustom = hasBeat('custom'); const extrasInCustomBeat = hasBeat('custom');
const tabIds = useMemo(() => { const arms = useMemo<RewardArm[]>(
const ids = isDesktop ? ['reasoning', 'call'] : ['call'];
if (!extrasInCustom) ids.push(...extras.map((tab) => tab.id));
return ids;
}, [isDesktop, extras, extrasInCustom]);
const activeTab = tabIds.includes(tabParam) ? tabParam : (tabIds[0] ?? 'call');
const arms: RewardArm[] = useMemo(
() => () =>
runs.map((candidate) => { runs.map((candidate) => {
const armEpisode = episodes[candidate.id]; const armEpisode = episodes[candidate.id];
@@ -283,15 +212,6 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
[runs, episodes], [runs, episodes],
); );
const totals = useMemo(
() =>
arms.map((arm) => ({
label: arm.label,
total: scoreReward(demo.reward, arm.values).total,
})),
[arms, demo.reward],
);
const blindPair = useMemo(() => { const blindPair = useMemo(() => {
for (let i = 0; i < runs.length; i += 1) { for (let i = 0; i < runs.length; i += 1) {
for (let j = i + 1; j < runs.length; j += 1) { for (let j = i + 1; j < runs.length; j += 1) {
@@ -304,75 +224,113 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
return { left, right, leftEpisode, rightEpisode }; return { left, right, leftEpisode, rightEpisode };
} }
} }
// Two runs on different seeds are two different puzzles; showing them side
// by side would be a comparison of luck.
return null; return null;
}, [runs, episodes]); }, [runs, episodes]);
if (!run || !episode || steps.length === 0) { if (!run || !episode || steps.length === 0) {
return ( return (
<div className="mx-auto max-w-canvas px-4 py-16"> <main className={cn(st.shell, 'py-16')}>
<h1 className="text-lg font-semibold">{demo.meta.title}</h1> <h1 className={st.h2}>{demo.meta.title}</h1>
<p className="mt-2 max-w-prose text-sm leading-relaxed text-muted"> <p className={cn(st.prose, 'mt-3 max-w-prose')}>
No recorded run is available for this demo yet. The environment and its grader are in No recorded run is available for this demo yet. The environment and its grader are in
the repository; the traces are produced by the eval command on the demo's provenance the repository; the traces come from the eval command on the provenance card.
card.
</p> </p>
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} className="mt-8" /> <EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} className="mt-8" />
</div> </main>
); );
} }
const Surface = demo.Surface as unknown as React.ComponentType<{ const Surface = demo.Surface as ComponentType<{ state: unknown; compact?: boolean }>;
state: T; const current = steps[player.index];
compact?: boolean; const lastStep = steps[steps.length - 1];
}>;
const heroStats: Stat[] = [ const heroStats: Stat[] = [
{ {
label: 'Outcome', label: 'Outcome',
value: episode.outcome, value: episode.outcome,
tone: episode.outcome === 'solved' ? 'positive' : 'warning', tone: episode.outcome === 'solved' ? 'positive' : 'warning',
hint: episode.truncated ? 'Truncated before a terminal state' : undefined, ...(episode.truncated ? { hint: 'Truncated before a terminal state' } : {}),
}, },
{ {
label: 'Total reward', label: 'Total reward',
value: formatOrDash(scoreReward(demo.reward, episode.rewards).total), value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
tone: 'brand', tone: 'brand',
hint: 'Shipped weights', hint: 'Shipped weights',
}, },
{ label: 'Steps', value: steps.length, hint: 'Model calls in this run' }, { label: 'Steps', value: steps.length, hint: 'Model calls in this run' },
{ label: 'Seed', value: episode.seed, hint: 'Same seed reproduces this board' }, { label: 'Seed', value: episode.seed, hint: 'The same seed reproduces this board' },
]; ];
const reasoningPanel = (
<ReasoningPanel
reasoning={current?.reasoning ?? null}
durationMs={current?.call?.durationMs ?? null}
playing={playback.playing}
speed={playback.speed}
stepIndex={step}
/>
);
const timeline = ( const timeline = (
<StepTimeline <StepTimeline
steps={steps} steps={steps}
current={step} current={player.index}
onSelect={(next) => { onSelect={(next) => {
playback.setPlaying(false); player.pause();
setStep(next); player.seek(next);
}} }}
Surface={Surface} Surface={Surface}
onTogglePlay={playback.toggle} onTogglePlay={player.toggle}
/> />
); );
const renderSurface = (beat: StoryBeat) => { const detailTabs: { id: string; label: string; content: ReactNode }[] = [
{
id: 'reasoning',
label: 'Reasoning',
content: isDesktop ? (
<ReasoningPanel
reasoning={current?.reasoning ?? null}
durationMs={current?.call?.durationMs ?? null}
playing={player.isPlaying}
speed={player.speed}
stepIndex={player.index}
/>
) : (
// Under `lg` there is no column for this, and putting it below the
// board means watching the run with the thinking off-screen. The sheet
// is mounted only here, so vaul never locks body scroll on desktop.
<ReasoningDrawer
reasoning={current?.reasoning ?? null}
durationMs={current?.call?.durationMs ?? null}
playing={player.isPlaying}
speed={player.speed}
stepIndex={player.index}
/>
),
},
{
id: 'call',
label: 'Model call',
content: (
<div className="space-y-3">
<ModelCallPanel call={current?.call ?? null} />
{current?.reply ? (
<div className="card p-3">
<h3 className="text-sm font-semibold">Reply</h3>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-relaxed">
{current.reply}
</p>
</div>
) : null}
</div>
),
},
...(extrasInCustomBeat
? []
: extras.map((tab) => ({ id: tab.id, label: tab.label, content: <tab.Component /> }))),
];
const activeTab = detailTabs.some((tab) => tab.id === tabParam) ? tabParam : DEFAULT_DETAIL_TAB;
const renderSurface = (beat: StoryBeat): ReactNode => {
switch (beat.surface) { switch (beat.surface) {
case 'hero': case 'hero':
return ( return (
<div className="grid gap-4 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
<div className="card w-fit p-4"> <div className="card w-fit p-4">
<Surface state={(steps[steps.length - 1] as DemoStep<T>).state} /> {lastStep ? <Surface state={lastStep.state} /> : null}
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
<StatStrip stats={heroStats} /> <StatStrip stats={heroStats} />
@@ -382,9 +340,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
{...(run.intervention ? { intervention: run.intervention } : {})} {...(run.intervention ? { intervention: run.intervention } : {})}
className="ml-0 w-fit" className="ml-0 w-fit"
/> />
<p className="max-w-prose text-sm leading-relaxed text-muted"> <p className={cn(st.prose, 'max-w-prose')}>{demo.narrative.thesis}</p>
{demo.narrative.thesis}
</p>
</div> </div>
</div> </div>
); );
@@ -400,90 +356,61 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
runs={runs} runs={runs}
activeId={run.id} activeId={run.id}
onSelect={(id) => { onSelect={(id) => {
playback.setPlaying(false); player.pause();
// The run param setter also zeroes `step`: step 6 of a
// nine-turn rollout is not step 6 of a three-turn one.
setRunParam(id); setRunParam(id);
setStepParam('0');
}} }}
/> />
) : null} ) : null}
<TracePlayer <TracePlayer
playing={playback.playing} playing={player.isPlaying}
onPlayingChange={playback.setPlaying} onPlayingChange={(next) => (next ? player.play() : player.pause())}
speed={playback.speed} speed={player.speed}
onSpeedChange={playback.setSpeed} onSpeedChange={(next) => {
onRestart={playback.restart} player.setSpeed(next);
step={step} setSpeedParam(next);
// `instant` is a destination, not a rate. The player only
// consumes it while running, so choosing it from a paused
// transport has to start the run — otherwise the button
// visibly does nothing, which reads as broken.
if (next === 'instant') player.play();
}}
onRestart={player.restart}
step={player.index}
stepCount={steps.length} stepCount={steps.length}
onStepChange={(next) => { onStepChange={(next) => {
playback.setPlaying(false); player.pause();
setStep(next); player.seek(next);
}} }}
progress={player.progress}
timingIsReal={player.timingIsReal}
model={run.model} model={run.model}
capturedAt={run.capturedAt} capturedAt={run.capturedAt}
{...(run.intervention ? { intervention: run.intervention } : {})} {...(run.intervention ? { intervention: run.intervention } : {})}
/> />
<div className="grid gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-[auto_minmax(0,1fr)] lg:items-start">
<div className="card w-fit p-4"> <div className="card w-fit p-4">
{current ? <Surface state={current.state} /> : null} {current ? <Surface state={current.state} /> : null}
</div> </div>
<div className="min-w-0 space-y-3"> <div className="min-w-0">
{!isDesktop ? ( <Tabs value={activeTab} onValueChange={setTabParam}>
<ReasoningDrawer <TabsList aria-label="Details for this step" className="w-full overflow-x-auto">
reasoning={current?.reasoning ?? null} {detailTabs.map((tab) => (
durationMs={current?.call?.durationMs ?? null} <TabsTrigger key={tab.id} value={tab.id}>
playing={playback.playing} {tab.label}
speed={playback.speed} </TabsTrigger>
stepIndex={step} ))}
/> </TabsList>
) : null} {detailTabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
<Tabs.Root value={activeTab} onValueChange={setTabParam}> {tab.content}
<Tabs.List </TabsContent>
aria-label="Details for this step" ))}
className="flex gap-1 overflow-x-auto rounded-lg bg-surface-2 p-1" </Tabs>
>
{isDesktop ? <TabTrigger value="reasoning">Reasoning</TabTrigger> : null}
<TabTrigger value="call">Model call</TabTrigger>
{!extrasInCustom
? extras.map((tab) => (
<TabTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabTrigger>
))
: null}
</Tabs.List>
{isDesktop ? (
<Tabs.Content value="reasoning" className="mt-3 focus-visible:outline-none">
{reasoningPanel}
</Tabs.Content>
) : null}
<Tabs.Content value="call" className="mt-3 focus-visible:outline-none">
<ModelCallPanel call={current?.call ?? null} />
{current?.reply ? (
<div className="card mt-3 p-3">
<h3 className="text-sm font-semibold">Reply</h3>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-relaxed">
{current.reply}
</p>
</div>
) : null}
</Tabs.Content>
{!extrasInCustom
? extras.map((tab) => (
<Tabs.Content
key={tab.id}
value={tab.id}
className="mt-3 focus-visible:outline-none"
>
<tab.Component />
</Tabs.Content>
))
: null}
</Tabs.Root>
</div> </div>
</div> </div>
@@ -496,6 +423,12 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{timeline} {timeline}
{player.timingIsReal ? null : (
<p className="text-xs text-muted">
Some steps in this run carried no recorded latency, so their dwell on the timeline
is the player's fallback rather than a measurement.
</p>
)}
<SlotRegion id="below-timeline" /> <SlotRegion id="below-timeline" />
</div> </div>
); );
@@ -514,23 +447,24 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
); );
case 'metric': { case 'metric': {
const currentTotal = scoreReward(demo.reward, episode.rewards).total; const currentTotal = rewardTotal(episode.rewards, demo.reward.components);
const first = totals[0]; const points = arms
const series = totals .map((arm) => ({ x: arm.label, y: rewardTotal(arm.values, demo.reward.components) }))
.filter((entry): entry is { label: string; total: number } => entry.total !== null) .filter((point): point is { x: string; y: number } => point.y !== null);
.map((entry) => ({ x: entry.label, y: entry.total })); const baselineArm = arms[0];
const baselineValue = baselineArm
? rewardTotal(baselineArm.values, demo.reward.components)
: null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<MetricMover <MetricMover
label={`Total reward — ${run.label}`} label={`Total reward — ${run.label}`}
value={currentTotal ?? 0} value={currentTotal ?? 0}
{...(first && first.total !== null && first.label !== run.label {...(baselineArm && baselineValue !== null && arms.length > 1
? { baseline: { value: first.total, label: first.label } } ? { baseline: { value: baselineValue, label: baselineArm.label } }
: {})} : {})}
series={series} series={points}
caption={ caption="Every point is a recorded run scored by the same grader. Nothing here is a projection."
'Every point is a recorded run scored by the same grader. Nothing here is a projection.'
}
/> />
{blindPair ? ( {blindPair ? (
<BlindCompare <BlindCompare
@@ -543,8 +477,8 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
...(blindPair.left.intervention ...(blindPair.left.intervention
? { intervention: blindPair.left.intervention } ? { intervention: blindPair.left.intervention }
: {}), : {}),
steps: demo.adapt(blindPair.leftEpisode) as DemoStep<T>[], steps: demo.adapt(blindPair.leftEpisode),
total: scoreReward(demo.reward, blindPair.leftEpisode.rewards).total, total: rewardTotal(blindPair.leftEpisode.rewards, demo.reward.components),
}} }}
b={{ b={{
runId: blindPair.right.id, runId: blindPair.right.id,
@@ -553,8 +487,8 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
...(blindPair.right.intervention ...(blindPair.right.intervention
? { intervention: blindPair.right.intervention } ? { intervention: blindPair.right.intervention }
: {}), : {}),
steps: demo.adapt(blindPair.rightEpisode) as DemoStep<T>[], steps: demo.adapt(blindPair.rightEpisode),
total: scoreReward(demo.reward, blindPair.rightEpisode.rewards).total, total: rewardTotal(blindPair.rightEpisode.rewards, demo.reward.components),
}} }}
/> />
) : null} ) : null}
@@ -564,7 +498,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
case 'receipt': case 'receipt':
return ( return (
<div className="grid gap-4 lg:grid-cols-2 lg:items-start"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<ProvenanceCard provenance={demo.provenance} run={run} /> <ProvenanceCard provenance={demo.provenance} run={run} />
<CodeReceipt <CodeReceipt
code={demo.reward.source.code} code={demo.reward.source.code}
@@ -596,10 +530,10 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
}; };
return ( return (
<div className="mx-auto max-w-canvas px-4 pb-16" style={{ paddingBottom: 'var(--safe-bottom)' }}> <main className={cn(st.shell, 'pb-16')}>
{/* {/*
The page's ONE live region. Every step change lands here and nowhere The page's ONE live region. Every step change lands here and nowhere
else: with reduced motion the tile animation is gone, so this sentence else: with reduced motion the board animation is gone, so this sentence
is the only thing that tells a screen-reader user what just happened. is the only thing that tells a screen-reader user what just happened.
*/} */}
<div aria-live="polite" aria-atomic="true" className="sr-only"> <div aria-live="polite" aria-atomic="true" className="sr-only">
@@ -607,13 +541,9 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
</div> </div>
<header className="pt-8"> <header className="pt-8">
<p className="text-xs font-semibold uppercase tracking-wide text-accent-fg"> <p className={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
{demo.meta.vertical.replace(/-/g, ' ')} · for {demo.meta.persona} <h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
</p> <p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight lg:text-3xl">
{demo.meta.title}
</h1>
<p className="mt-2 max-w-prose text-base text-muted">{demo.meta.tagline}</p>
<p className="mt-4 max-w-prose border-l-2 border-brand pl-3 text-sm italic leading-relaxed text-fg"> <p className="mt-4 max-w-prose border-l-2 border-brand pl-3 text-sm italic leading-relaxed text-fg">
{demo.narrative.anxiety} {demo.narrative.anxiety}
</p> </p>
@@ -626,18 +556,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
</BeatSection> </BeatSection>
))} ))}
</div> </div>
</div> </main>
);
}
function TabTrigger({ value, children }: { value: string; children: React.ReactNode }) {
return (
<Tabs.Trigger
value={value}
className="tap flex-1 whitespace-nowrap rounded-md px-3 text-sm font-medium text-muted transition-colors duration-2 ease-enter data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm"
>
{children}
</Tabs.Trigger>
); );
} }
@@ -651,50 +570,38 @@ function RunSwitcher({
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
return ( return (
<div <SegmentedControl
role="radiogroup" label="Recorded run"
aria-label="Recorded run" options={runs.map((run) => ({
className="flex flex-wrap gap-1 rounded-lg bg-surface-2 p-1" value: run.id,
> label: run.label,
{runs.map((run) => { ...(run.intervention ? { title: run.intervention } : {}),
const active = run.id === activeId; }))}
return ( value={activeId}
<button onChange={onSelect}
key={run.id} className="border border-border p-1"
type="button" optionClassName="tap px-3 text-sm"
role="radio" />
aria-checked={active}
onClick={() => onSelect(run.id)}
className={cn(
'tap rounded-md px-3 text-sm font-medium transition-colors duration-2 ease-enter',
active ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
)}
>
{run.label}
</button>
);
})}
</div>
); );
} }
/** /**
* The loading state. Deliberately shaped like the page it becomes, and with no * The loading state. Shaped like the page it becomes, and with no spinner: a
* spinner: a spinner on this site would imply a live model call, which is the * spinner here would imply a live model call, which is the one thing the whole
* one thing the whole page is at pains to say is not happening. * page is at pains to say is not happening.
*/ */
function ShellSkeleton() { function ShellSkeleton() {
return ( return (
<div className="mx-auto max-w-canvas px-4 py-10" aria-busy="true"> <div className={cn(st.shell, 'py-10')} aria-busy="true">
<p className="sr-only">Loading the recorded run.</p> <p className="sr-only">Loading the recorded run.</p>
<div className="h-8 w-64 rounded-lg bg-surface-2" /> <Skeleton className="h-8 w-64" />
<div className="mt-3 h-4 w-96 max-w-full rounded-lg bg-surface-2" /> <Skeleton className="mt-3 h-4 w-full max-w-md" />
<div className="mt-10 grid gap-3 lg:grid-cols-4"> <div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((index) => ( {[0, 1, 2, 3].map((index) => (
<div key={index} className="h-28 rounded-xl bg-surface-2" /> <Skeleton key={index} className="h-28" />
))} ))}
</div> </div>
<div className="mt-6 h-64 rounded-xl bg-surface-2" /> <Skeleton className="mt-6 h-64" />
</div> </div>
); );
} }
+14 -5
View File
@@ -1,5 +1,7 @@
import { ArrowRight, ShieldQuestion } from 'lucide-react'; import { ArrowRight, ShieldQuestion } from 'lucide-react';
import { getDemo } from '@/lib/demo-kit/registry';
import type { Limit } from '@/lib/demo-kit/types'; import type { Limit } from '@/lib/demo-kit/types';
import * as st from '@/content/styles';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface LimitsCalloutProps { export interface LimitsCalloutProps {
@@ -13,8 +15,15 @@ export interface LimitsCalloutProps {
className?: string; className?: string;
} }
function defaultResolve(slug: string) { /**
return { title: slug, href: `/demo/${slug}` }; * A limit names the demo that closes it by SLUG. Resolving it through the
* registry means a limit pointing at a demo that does not exist yet renders as
* a stated gap rather than as a link to a 404 — which is the honest outcome,
* since the roadmap is allowed to be ahead of the repository.
*/
function defaultResolve(slug: string): { title: string; href: string } | undefined {
const meta = getDemo(slug);
return meta ? { title: meta.title, href: `/demos/${meta.slug}` } : undefined;
} }
/** /**
@@ -35,7 +44,7 @@ export function LimitsCallout({
return ( return (
<section aria-label={title} className={cn('card p-4', className)}> <section aria-label={title} className={cn('card p-4', className)}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShieldQuestion className="h-4 w-4 text-muted" aria-hidden="true" /> <ShieldQuestion className="size-4 text-muted" aria-hidden="true" />
<h3 className="text-sm font-semibold">{title}</h3> <h3 className="text-sm font-semibold">{title}</h3>
</div> </div>
<ul className="mt-3 space-y-3"> <ul className="mt-3 space-y-3">
@@ -47,10 +56,10 @@ export function LimitsCallout({
{target ? ( {target ? (
<a <a
href={target.href} href={target.href}
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-2 hover:underline" className={cn(st.link, 'mt-1 inline-flex items-center gap-1 text-xs')}
> >
Answered by {target.title} Answered by {target.title}
<ArrowRight className="h-3 w-3" aria-hidden="true" /> <ArrowRight className="size-3" aria-hidden="true" />
</a> </a>
) : ( ) : (
<p className="mt-1 text-xs text-muted"> <p className="mt-1 text-xs text-muted">
+4 -1
View File
@@ -82,7 +82,10 @@ export default function MetricChart({
)} )}
/> />
<Line <Line
type="monotone" // `linear`, not `monotone`: a spline invents curvature between two
// measured points, and on a categorical axis (one point per
// recorded arm) that curve is a claim nobody measured.
type="linear"
dataKey="y" dataKey="y"
stroke="hsl(var(--accent))" stroke="hsl(var(--accent))"
strokeWidth={2} strokeWidth={2}
+5 -1
View File
@@ -48,7 +48,11 @@ export function MetricMover({
className, className,
}: MetricMoverProps) { }: MetricMoverProps) {
const reducedMotion = usePrefersReducedMotion(); const reducedMotion = usePrefersReducedMotion();
const delta = baseline ? value - baseline.value : null; // The rule is still drawn when the headline IS the baseline — it is the line
// the other arms are read against. The delta text is not: "+0.000 vs itself"
// is noise dressed up as a measurement.
const rawDelta = baseline ? value - baseline.value : null;
const delta = rawDelta !== null && Math.abs(rawDelta) > 1e-9 ? rawDelta : null;
const points = useMemo(() => series ?? [], [series]); const points = useMemo(() => series ?? [], [series]);
return ( return (
+10 -12
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Check, Copy, ExternalLink } from 'lucide-react'; import { Check, Copy, ExternalLink } from 'lucide-react';
import type { Provenance, RunRef } from '@/lib/demo-kit/types'; import type { Provenance, RunRef } from '@/lib/demo-kit/types';
import { Button } from '@/components/ui/button';
import * as st from '@/content/styles';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatDate } from './format'; import { formatDate } from './format';
@@ -77,21 +79,17 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
Run it yourself Run it yourself
</h4> </h4>
<button <Button variant="outline" size="sm" className="tap" onClick={copy}>
type="button"
onClick={copy}
className="tap inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2"
>
{copyState === 'copied' ? ( {copyState === 'copied' ? (
<Check className="h-3.5 w-3.5 text-positive" aria-hidden="true" /> <Check className="text-positive" aria-hidden="true" />
) : ( ) : (
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> <Copy aria-hidden="true" />
)} )}
{copyState === 'copied' ? 'Copied' : 'Copy'} {copyState === 'copied' ? 'Copied' : 'Copy'}
</button> </Button>
</div> </div>
<pre className="mt-2 overflow-x-auto rounded-lg bg-surface-2 p-3"> <pre className={cn(st.codeBlock, 'mt-2')}>
<code className="font-mono text-xs leading-relaxed">{provenance.command}</code> <code>{provenance.command}</code>
</pre> </pre>
<p role="status" className="mt-1 text-xs text-muted"> <p role="status" className="mt-1 text-xs text-muted">
{copyState === 'copied' {copyState === 'copied'
@@ -112,11 +110,11 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
<li key={credit.href}> <li key={credit.href}>
<a <a
href={credit.href} href={credit.href}
className="inline-flex items-center gap-1 text-sm text-accent-fg underline-offset-2 hover:underline" className={cn(st.link, 'inline-flex items-center gap-1 text-sm')}
rel="noreferrer" rel="noreferrer"
> >
{credit.label} {credit.label}
<ExternalLink className="h-3 w-3" aria-hidden="true" /> <ExternalLink className="size-3" aria-hidden="true" />
</a> </a>
</li> </li>
))} ))}
+33 -32
View File
@@ -1,6 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { Drawer } from 'vaul';
import { Brain, ChevronUp } from 'lucide-react'; import { Brain, ChevronUp } from 'lucide-react';
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ReasoningPanel } from './ReasoningPanel'; import { ReasoningPanel } from './ReasoningPanel';
import type { ReasoningPanelProps } from './ReasoningPanel'; import type { ReasoningPanelProps } from './ReasoningPanel';
@@ -36,48 +44,41 @@ export function ReasoningDrawer({
const hasReasoning = (panel.reasoning ?? '').length > 0; const hasReasoning = (panel.reasoning ?? '').length > 0;
return ( return (
<Drawer.Root <Drawer
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
activeSnapPoint={snap} activeSnapPoint={snap}
setActiveSnapPoint={setSnap} setActiveSnapPoint={setSnap}
{...(open === undefined ? {} : { open })} {...(open === undefined ? {} : { open })}
{...(onOpenChange ? { onOpenChange } : {})} {...(onOpenChange ? { onOpenChange } : {})}
> >
<Drawer.Trigger <DrawerTrigger
className={cn( className={cn(
'tap flex w-full items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2', 'tap flex w-full items-center gap-2 rounded-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, triggerClassName,
)} )}
> >
<Brain className="h-4 w-4 text-muted" aria-hidden="true" /> <Brain className="size-4 text-muted" aria-hidden="true" />
<span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span> <span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span>
<ChevronUp className="ml-auto h-4 w-4 text-muted" aria-hidden="true" /> <ChevronUp className="ml-auto size-4 text-muted" aria-hidden="true" />
</Drawer.Trigger> </DrawerTrigger>
<Drawer.Portal> {/*
<Drawer.Overlay className="fixed inset-0 z-40 bg-fg/40" /> `h-full max-h-[97%]` overrides the wrapper's `h-auto max-h-[88svh]`.
<Drawer.Content Snap points size the sheet by translating a FIXED-height panel; on an
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" auto-height one the 0.4 snap and the 0.9 snap look identical.
style={{ paddingBottom: 'var(--safe-bottom)' }} */}
> <DrawerContent className="mx-auto h-full max-h-[97%] max-w-canvas">
<div <DrawerHeader className="pb-2">
aria-hidden="true" <DrawerTitle className="text-sm">{panel.title ?? 'Reasoning'}</DrawerTitle>
className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-border" <DrawerDescription className="text-xs">
/> Recorded verbatim from step {panel.stepIndex + 1} of this run.
<div className="px-4 pb-2 pt-3"> </DrawerDescription>
<Drawer.Title className="text-sm font-semibold"> </DrawerHeader>
{panel.title ?? 'Reasoning'} <DrawerBody className="overflow-hidden">
</Drawer.Title> {/* The panel keeps its own reserved height inside the sheet so the
<Drawer.Description className="text-xs text-muted"> sheet does not resize as the text streams under the drag. */}
Recorded verbatim from step {panel.stepIndex + 1} of this run. <ReasoningPanel {...panel} className="h-full border-0" reservedLines={14} />
</Drawer.Description> </DrawerBody>
</div> </DrawerContent>
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4"> </Drawer>
{/* 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>
); );
} }
+4 -1
View File
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
// The Radix primitive rather than `@/components/ui/scroll-area`: the stream
// has to pin the viewport to the bottom as characters land, and that needs a
// ref on the viewport element, which the wrapper does not expose.
import * as ScrollArea from '@radix-ui/react-scroll-area'; import * as ScrollArea from '@radix-ui/react-scroll-area';
import { Brain } from 'lucide-react'; import { Brain } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePrefersReducedMotion } from './format'; import { usePrefersReducedMotion } from './format';
import type { PlaybackSpeed } from './TracePlayer'; import type { PlaybackSpeed } from '@/lib/demo-kit/player';
/** /**
* Characters per second, clamped. A 40-character reasoning trace recorded over * Characters per second, clamped. A 40-character reasoning trace recorded over
+1 -1
View File
@@ -148,7 +148,7 @@ export function RewardBreakdown({
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
Diagnostics reported, never summed Diagnostics reported, never summed
</h4> </h4>
<dl className="mt-1.5 grid gap-x-4 gap-y-1 sm:grid-cols-2"> <dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
{spec.metrics.map((metric) => ( {spec.metrics.map((metric) => (
<div key={metric.key} className="flex items-baseline justify-between gap-2"> <div key={metric.key} className="flex items-baseline justify-between gap-2">
<dt className="text-xs text-muted" title={metric.description}> <dt className="text-xs text-muted" title={metric.description}>
+160 -147
View File
@@ -1,10 +1,18 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import * as Slider from '@radix-ui/react-slider';
import { ArrowDown, ArrowUp, Minus, RotateCcw } from 'lucide-react'; import { ArrowDown, ArrowUp, Minus, RotateCcw } from 'lucide-react';
import {
isEdited,
pruneOverrides,
reweight,
weightsAreDegenerate,
} from '@/lib/demo-kit/reward';
import type { WeightOverrides } from '@/lib/demo-kit/reward';
import { rewardTotal } from '@/lib/demo-kit/episode';
import type { RewardSpec, RewardValues } from '@/lib/demo-kit/types'; import type { RewardSpec, RewardValues } from '@/lib/demo-kit/types';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatNumber, formatOrDash } from './format'; import { formatNumber, formatOrDash } from './format';
import { scoreReward, shippedWeights } from './reward-math';
import { EditedChip } from './StatStrip'; import { EditedChip } from './StatStrip';
/** One recorded arm — a run, or a group of runs already reduced to one score. */ /** One recorded arm — a run, or a group of runs already reduced to one score. */
@@ -20,6 +28,7 @@ export interface RewardPreset {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
/** Raw weights, before normalisation. Keyed by component. */
weights: Record<string, number>; weights: Record<string, number>;
} }
@@ -28,19 +37,21 @@ export interface RewardEditorProps {
arms: RewardArm[]; arms: RewardArm[];
/** Two is the right number. More and the visitor reads instead of playing. */ /** Two is the right number. More and the visitor reads instead of playing. */
presets?: RewardPreset[]; presets?: RewardPreset[];
onWeightsChange?: (weights: Record<string, number>, edited: boolean) => void; onWeightsChange?: (overrides: WeightOverrides, edited: boolean) => void;
className?: string; className?: string;
} }
const STEP = 0.05; const STEP = 0.05;
/** /**
* Presets built from the spec's own labels, for a demo that does not supply * Presets built from the spec's own labels, for a demo that does not supply its
* its own. Both are stated as the choice a buyer would actually argue for in a * own. Each is phrased as a position someone would actually argue for in a
* meeting, not as "preset A" and "preset B". * meeting, not as "preset A" and "preset B".
*/ */
function derivePresets(spec: RewardSpec): RewardPreset[] { function derivePresets(spec: RewardSpec): RewardPreset[] {
const shipped = shippedWeights(spec); const shipped: Record<string, number> = {};
for (const component of spec.components) shipped[component.key] = component.weight;
const counterweights = spec.components.filter((c) => c.role === 'counterweight'); const counterweights = spec.components.filter((c) => c.role === 'counterweight');
const objectives = spec.components.filter((c) => c.role === 'objective'); const objectives = spec.components.filter((c) => c.role === 'objective');
const presets: RewardPreset[] = [ const presets: RewardPreset[] = [
@@ -53,23 +64,24 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
]; ];
const firstObjective = objectives[0]; const firstObjective = objectives[0];
if (counterweights.length > 0 && firstObjective) { const firstCounterweight = counterweights[0];
const onlyObjective = { ...shipped }; if (firstCounterweight && firstObjective) {
for (const component of counterweights) onlyObjective[component.key] = 0; const objectiveOnly = { ...shipped };
for (const component of counterweights) objectiveOnly[component.key] = 0;
presets.push({ presets.push({
id: 'objective-only', id: 'objective-only',
label: `${firstObjective.label} at any cost`, label: `${firstObjective.label} at any cost`,
description: `Drops ${counterweights description: `Drops ${counterweights.map((c) => c.label.toLowerCase()).join(' and ')} to zero.`,
.map((c) => c.label.toLowerCase()) weights: objectiveOnly,
.join(' and ')} to zero.`,
weights: onlyObjective,
}); });
const doubled = { ...shipped }; const doubled = { ...shipped };
for (const component of counterweights) doubled[component.key] = component.weight * 2; for (const component of counterweights) doubled[component.key] = component.weight * 2;
presets.push({ presets.push({
id: 'counterweight-heavy', id: 'counterweight-heavy',
label: `Double ${counterweights[0]?.label.toLowerCase() ?? 'the counterweight'}`, // Quoted, because a component label is a phrase written for a table cell
// ("Found it early") and reads as gibberish spliced into a sentence.
label: `Twice as much "${firstCounterweight.label}"`,
description: 'What a risk-averse buyer would ask for.', description: 'What a risk-averse buyer would ask for.',
weights: doubled, weights: doubled,
}); });
@@ -77,22 +89,27 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
return presets; return presets;
} }
function weightsEqual(a: Record<string, number>, b: Record<string, number>): boolean { function sameWeights(spec: RewardSpec, overrides: WeightOverrides, preset: RewardPreset): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]); return spec.components.every((component) => {
for (const key of keys) { const current = overrides[component.key] ?? component.weight;
if (Math.abs((a[key] ?? 0) - (b[key] ?? 0)) > 1e-9) return false; const target = preset.weights[component.key] ?? component.weight;
} return Math.abs(current - target) < 1e-9;
return true; });
} }
/** /**
* Change what "good" means and watch the ranking move. * Change what "good" means and watch the ranking move.
* *
* The honesty problem this component has to solve: re-weighting recorded scores * The honesty problem this component has to solve: re-weighting recorded scores
* is NOT training. It shows you the ranking a different reward would have * is NOT training. It shows the ranking a different reward would have produced
* produced over these exact attempts; it cannot show you the different attempts * over these exact attempts; it cannot show the different attempts a model
* a model trained on that reward would have made. That distinction is the * trained on that reward would have made. That distinction is the permanent
* permanent caption at the bottom, and it is not collapsible. * caption at the bottom, and it is not collapsible.
*
* Weights are normalised to sum to 1 before scoring — `reweight` does it — so
* dragging one slider up trades weight away from the others instead of lifting
* every arm at once. Without that, the totals all rise together and the ranking
* appears to move when only the scale did.
*/ */
export function RewardEditor({ export function RewardEditor({
spec, spec,
@@ -101,32 +118,33 @@ export function RewardEditor({
onWeightsChange, onWeightsChange,
className, className,
}: RewardEditorProps) { }: RewardEditorProps) {
const shipped = useMemo(() => shippedWeights(spec), [spec]); const [overrides, setOverrides] = useState<WeightOverrides>({});
const [weights, setWeights] = useState<Record<string, number>>(shipped);
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]); const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
const bounds = useMemo(() => { const edited = isEdited(overrides, spec.components);
const values = spec.components.map((c) => c.weight); const inForce = useMemo(() => reweight(spec.components, overrides), [spec, overrides]);
const max = Math.max(2, ...values.map((v) => Math.ceil(Math.abs(v) * 2))); const shippedNormalised = useMemo(() => reweight(spec.components, {}), [spec]);
const min = Math.min(0, ...values.map((v) => Math.floor(v))); const degenerate = weightsAreDegenerate(inForce);
return { min, max };
}, [spec]);
const edited = !weightsEqual(weights, shipped); const max = useMemo(
() => Math.max(2, ...spec.components.map((c) => Math.ceil(Math.abs(c.weight) * 2))),
[spec],
);
const apply = (next: Record<string, number>) => { const apply = (next: WeightOverrides) => {
setWeights(next); // Pruned before it goes into state: an override that equals the shipped
onWeightsChange?.(next, !weightsEqual(next, shipped)); // weight is not an edit, and leaving it in makes the "edited" chip stick
// after the visitor drags a slider back where it started.
const pruned = pruneOverrides(next, spec.components);
setOverrides(pruned);
onWeightsChange?.(pruned, isEdited(pruned, spec.components));
}; };
const ranked = useMemo(() => { const ranked = useMemo(() => {
const shippedTotals = new Map(
arms.map((arm) => [arm.id, scoreReward(spec, arm.values).total]),
);
const rows = arms.map((arm) => ({ const rows = arms.map((arm) => ({
arm, arm,
total: scoreReward(spec, arm.values, weights).total, total: degenerate ? null : rewardTotal(arm.values, inForce),
shippedTotal: shippedTotals.get(arm.id) ?? null, shippedTotal: rewardTotal(arm.values, shippedNormalised),
})); }));
// Nulls sort last: an unscored arm is not a zero-scoring arm. // Nulls sort last: an unscored arm is not a zero-scoring arm.
const byTotal = (a: { total: number | null }, b: { total: number | null }) => { const byTotal = (a: { total: number | null }, b: { total: number | null }) => {
@@ -143,10 +161,10 @@ export function RewardEditor({
rank: index + 1, rank: index + 1,
shippedRank: shippedOrder.indexOf(row.arm.id) + 1, shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
})); }));
}, [arms, spec, weights]); }, [arms, inForce, shippedNormalised, degenerate]);
const span = useMemo(() => { const span = useMemo(() => {
const totals = ranked.map((row) => row.total).filter((t): t is number => t !== null); const totals = ranked.map((row) => row.total).filter((total): total is number => total !== null);
if (totals.length === 0) return { lo: 0, hi: 1 }; if (totals.length === 0) return { lo: 0, hi: 1 };
const lo = Math.min(0, ...totals); const lo = Math.min(0, ...totals);
const hi = Math.max(...totals); const hi = Math.max(...totals);
@@ -156,60 +174,54 @@ export function RewardEditor({
const leader = ranked[0]; const leader = ranked[0];
return ( return (
<div className={cn('grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]', className)}> <div className={cn('grid grid-cols-1 gap-4 lg:grid-cols-2', className)}>
<section aria-label="Reward weights" className="card p-3"> <section aria-label="Reward weights" className="card p-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Change what good means</h3> <h3 className="text-sm font-semibold">Change what good means</h3>
{edited ? <EditedChip /> : null} {edited ? <EditedChip /> : null}
<button <Button
type="button" variant="outline"
onClick={() => apply(shipped)} size="sm"
className="tap ml-auto"
onClick={() => apply({})}
disabled={!edited} disabled={!edited}
className="tap ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2 disabled:opacity-40"
> >
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> <RotateCcw aria-hidden="true" />
Reset to shipped Reset to shipped
</button> </Button>
</div> </div>
<div className="mt-2 flex flex-wrap gap-2"> <div className="mt-2 flex flex-wrap gap-2">
{effectivePresets.map((preset) => { {effectivePresets.map((preset) => {
const active = weightsEqual(weights, preset.weights); const active = sameWeights(spec, overrides, preset);
return ( return (
<button <Button
key={preset.id} key={preset.id}
type="button" variant={active ? 'subtle' : 'outline'}
onClick={() => apply({ ...preset.weights })} size="touch"
aria-pressed={active} aria-pressed={active}
title={preset.description ?? preset.label} title={preset.description ?? preset.label}
className={cn( onClick={() => apply({ ...preset.weights })}
'tap rounded-lg border px-3 text-left text-xs font-medium transition-colors duration-2 ease-enter', className="text-xs"
active
? 'border-brand bg-accent-subtle text-accent-fg'
: 'border-border hover:bg-surface-2',
)}
> >
{preset.label} {preset.label}
</button> </Button>
); );
})} })}
</div> </div>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
{spec.components.map((component) => { {spec.components.map((component, index) => {
const value = weights[component.key] ?? component.weight; const raw = overrides[component.key] ?? component.weight;
const changed = Math.abs(value - component.weight) > 1e-9; const changed = Math.abs(raw - component.weight) > 1e-9;
const share = inForce[index]?.weight ?? 0;
return ( return (
<div key={component.key}> <div key={component.key}>
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
{/* A <label htmlFor> would point at Radix's root <span>, {/* A <label htmlFor> would point at Radix's root <span>, which
which is not a labelable element — the association would is not a labelable element — the association would silently
silently do nothing. The thumb takes its name from this do nothing. The thumb takes its name from this text. */}
text via aria-labelledby instead. */} <span id={`weight-label-${component.key}`} className="text-sm font-medium text-fg">
<span
id={`weight-label-${component.key}`}
className="text-sm font-medium text-fg"
>
{component.label} {component.label}
</span> </span>
<span <span
@@ -218,90 +230,94 @@ export function RewardEditor({
changed ? 'font-semibold text-accent-fg' : 'text-muted', changed ? 'font-semibold text-accent-fg' : 'text-muted',
)} )}
> >
{formatNumber(value, 2)} {formatNumber(raw, 2)}
<span className="ml-1.5 text-xs font-normal text-muted">
{degenerate ? '—' : `${Math.round(share * 100)}%`}
</span>
</span> </span>
</div> </div>
<p className="mb-1.5 text-xs leading-snug text-muted">{component.description}</p> <p className="mb-1 text-xs leading-snug text-muted">{component.description}</p>
<Slider.Root <Slider
className="relative flex h-6 w-full touch-none select-none items-center" min={0}
min={bounds.min} max={max}
max={bounds.max}
step={STEP} step={STEP}
value={[value]} value={[raw]}
onValueChange={(next) => onValueChange={(next) =>
apply({ ...weights, [component.key]: next[0] ?? component.weight }) apply({ ...overrides, [component.key]: next[0] ?? component.weight })
} }
> aria-labelledby={`weight-label-${component.key}`}
<Slider.Track className="relative h-1.5 w-full grow rounded-full bg-surface-2"> />
<Slider.Range className="absolute h-full rounded-full bg-brand" />
</Slider.Track>
{/* 44px of hit area around a 16px dot: the visible thumb is
small enough to read the track under it, and still catches
a thumb on a phone. */}
<Slider.Thumb
aria-labelledby={`weight-label-${component.key}`}
className="block h-6 w-6 rounded-full border-4 border-brand bg-surface shadow-sm"
/>
</Slider.Root>
</div> </div>
); );
})} })}
</div> </div>
<p className="mt-3 text-xs leading-relaxed text-muted">
The percentage is the share of the reward each term carries once the weights are
normalised. Raising one lowers the others that is the trade a reward designer
actually makes.
</p>
</section> </section>
<section aria-label="Ranking under this reward" className="card flex flex-col p-3"> <section aria-label="Ranking under this reward" className="card flex flex-col p-3">
<h3 className="text-sm font-semibold"> <h3 className="flex items-center gap-2 text-sm font-semibold">
Ranking under this reward {edited ? <EditedChip className="ml-1 align-middle" /> : null} Ranking under this reward
{edited ? <EditedChip /> : null}
</h3> </h3>
<ol className="mt-3 space-y-2"> {degenerate ? (
{ranked.map((row) => { <p className="mt-3 text-sm leading-relaxed text-muted">
const moved = row.rank - row.shippedRank; Every weight is zero, so there is no reward left to rank by. That is not a score of
const width = nought it is a reward that expresses no preference at all.
row.total === null </p>
? 0 ) : (
: Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100); <ol className="mt-3 space-y-2">
return ( {ranked.map((row) => {
<li const moved = row.rank - row.shippedRank;
key={row.arm.id} const width =
className={cn( row.total === null
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter', ? 0
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border', : Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100);
)} return (
> <li
<div className="flex items-baseline gap-2"> key={row.arm.id}
<span className="nums text-sm font-semibold text-muted">{row.rank}</span> className={cn(
<span className="min-w-0 flex-1 truncate text-sm font-medium"> 'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
{row.arm.label} row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
</span> )}
<RankMove moved={moved} />
<span className="nums font-mono text-sm font-semibold">
{formatOrDash(row.total)}
</span>
</div>
<div
aria-hidden="true"
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
> >
<div className="flex items-baseline gap-2">
<span className="nums text-sm font-semibold text-muted">{row.rank}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{row.arm.label}
</span>
<RankMove moved={moved} />
<span className="nums font-mono text-sm font-semibold">
{formatOrDash(row.total)}
</span>
</div>
<div <div
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter" aria-hidden="true"
style={{ width: `${width}%` }} className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
/> >
</div> <div
{row.arm.note ? ( className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
<p className="mt-1 text-xs text-muted">{row.arm.note}</p> style={{ width: `${width}%` }}
) : null} />
</li> </div>
); {row.arm.note ? <p className="mt-1 text-xs text-muted">{row.arm.note}</p> : null}
})} </li>
</ol> );
})}
</ol>
)}
{/* User-initiated, so it is safe to announce here without fighting the {/* Safe to announce here without fighting the shell's step-change
shell's step-change region: the two never fire from one action. */} region: a slider drag and a step advance never fire from one action. */}
<p role="status" className="sr-only"> <p role="status" className="sr-only">
{leader {leader && leader.total !== null
? `Leading under this reward: ${leader.arm.label}, ${formatOrDash(leader.total)}.` ? `Leading under this reward: ${leader.arm.label}, ${formatOrDash(leader.total)}.`
: 'No arms to rank.'} : ''}
</p> </p>
<p className="mt-auto pt-3 text-xs leading-relaxed text-muted"> <p className="mt-auto pt-3 text-xs leading-relaxed text-muted">
@@ -316,24 +332,21 @@ export function RewardEditor({
function RankMove({ moved }: { moved: number }) { function RankMove({ moved }: { moved: number }) {
if (moved === 0) { if (moved === 0) {
return ( return (
<span className="inline-flex items-center text-muted" title="Same rank as shipped"> <span className="inline-flex items-center text-muted" title="Same rank as the shipped reward">
<Minus className="h-3.5 w-3.5" aria-hidden="true" /> <Minus className="size-3.5" aria-hidden="true" />
<span className="sr-only">unchanged</span> <span className="sr-only">rank unchanged</span>
</span> </span>
); );
} }
const up = moved < 0; const up = moved < 0;
const places = Math.abs(moved);
return ( return (
<span <span
className={cn('nums inline-flex items-center text-xs', up ? 'text-positive' : 'text-danger')} className={cn('nums inline-flex items-center text-xs', up ? 'text-positive' : 'text-danger')}
title={`${Math.abs(moved)} place${Math.abs(moved) === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`} title={`${places} place${places === 1 ? '' : 's'} ${up ? 'up' : 'down'} from the shipped reward`}
> >
{up ? ( {up ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" /> {places}
) : (
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
{Math.abs(moved)}
<span className="sr-only">{up ? ' places up' : ' places down'}</span> <span className="sr-only">{up ? ' places up' : ' places down'}</span>
</span> </span>
); );
+109
View File
@@ -0,0 +1,109 @@
import { useRef } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface SegmentedOption<T extends string> {
value: T;
label: ReactNode;
title?: string;
}
export interface SegmentedControlProps<T extends string> {
/** Names the group for assistive tech. Required — a bare radiogroup is noise. */
label: string;
options: SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
optionClassName?: string;
}
/**
* The one-of-N control the shell uses for speed and for the run switcher.
*
* It exists because a row of buttons with `aria-checked` is not a radiogroup:
* the pattern also requires a ROVING tabindex, so Tab moves past the whole
* control rather than through every option, and the arrow keys move within it.
* Getting that wrong is the most common a11y bug in a segmented control, which
* is why there is one implementation here instead of three inline copies.
*/
export function SegmentedControl<T extends string>({
label,
options,
value,
onChange,
className,
optionClassName,
}: SegmentedControlProps<T>) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const index = Math.max(
options.findIndex((option) => option.value === value),
0,
);
const move = (to: number, event: KeyboardEvent) => {
if (options.length === 0) return;
event.preventDefault();
// Wraps: a radiogroup's arrows cycle rather than dead-ending.
const next = (to + options.length) % options.length;
const option = options[next];
if (!option) return;
onChange(option.value);
refs.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
move(index + 1, event);
break;
case 'ArrowLeft':
case 'ArrowUp':
move(index - 1, event);
break;
case 'Home':
move(0, event);
break;
case 'End':
move(options.length - 1, event);
break;
default:
break;
}
};
return (
<div
role="radiogroup"
aria-label={label}
onKeyDown={onKeyDown}
className={cn('flex flex-wrap items-center gap-0.5 rounded-lg bg-surface-2 p-0.5', className)}
>
{options.map((option, position) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={selected}
tabIndex={position === index ? 0 : -1}
{...(option.title ? { title: option.title } : {})}
ref={(node) => {
refs.current[position] = node;
}}
onClick={() => onChange(option.value)}
className={cn(
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
optionClassName,
)}
>
{option.label}
</button>
);
})}
</div>
);
}
+18 -24
View File
@@ -4,6 +4,18 @@ import type { PlaybackSpeed } from '@/lib/demo-kit/player';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatDate } from './format'; import { formatDate } from './format';
import { SegmentedControl } from './SegmentedControl';
/** `PlaybackSpeed` is `1 | 2 | 4 | 'instant'`; the control speaks strings. */
const SPEED_OPTIONS = PLAYBACK_SPEEDS.map((speed) => ({
value: String(speed),
label: speed === 'instant' ? 'Instant' : `${speed}x`,
title: speed === 'instant' ? 'Jump straight to the end of the run' : `${speed} times real pace`,
}));
function toSpeed(value: string): PlaybackSpeed {
return value === 'instant' ? 'instant' : (Number(value) as 1 | 2 | 4);
}
export interface TracePlayerProps { export interface TracePlayerProps {
playing: boolean; playing: boolean;
@@ -103,30 +115,12 @@ export function TracePlayer({
{stepCount} {stepCount}
</p> </p>
<div <SegmentedControl
role="radiogroup" label="Playback speed"
aria-label="Playback speed" options={SPEED_OPTIONS}
className="flex items-center gap-0.5 rounded-lg bg-surface-2 p-0.5" value={String(speed)}
> onChange={(next) => onSpeedChange(toSpeed(next))}
{PLAYBACK_SPEEDS.map((option) => { />
const selected = option === speed;
return (
<button
key={String(option)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onSpeedChange(option)}
className={cn(
'min-h-9 rounded-md px-2.5 text-xs font-medium transition-colors duration-2 ease-enter',
selected ? 'bg-surface text-fg shadow-sm' : 'text-muted hover:text-fg',
)}
>
{option === 'instant' ? 'Instant' : `${option}x`}
</button>
);
})}
</div>
<RecordedBadge <RecordedBadge
model={model} model={model}
+9 -10
View File
@@ -75,22 +75,21 @@ export function humaniseToken(token: string): string {
return token.replace(/[_-]+/g, ' '); return token.replace(/[_-]+/g, ' ');
} }
function subscribeToQuery(query: string) {
return (onChange: () => void) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
};
}
/** /**
* Media queries as React state. `useSyncExternalStore` rather than an effect, * Media queries as React state. `useSyncExternalStore` rather than an effect,
* because the server snapshot is explicit: the prerendered HTML is built at the * because the server snapshot is explicit: the prerendered HTML is built at the
* desktop, motion-allowed default and corrects itself on the client. * desktop, motion-allowed default and corrects itself on the client.
*/ */
export function useMediaQuery(query: string, serverValue = false): boolean { export function useMediaQuery(query: string, serverValue = false): boolean {
const subscribe = useCallback(subscribeToQuery(query), [query]); const subscribe = useCallback(
(onChange: () => void) => {
if (typeof window === 'undefined' || !window.matchMedia) return () => undefined;
const list = window.matchMedia(query);
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
},
[query],
);
const getSnapshot = useCallback(() => { const getSnapshot = useCallback(() => {
if (typeof window === 'undefined' || !window.matchMedia) return serverValue; if (typeof window === 'undefined' || !window.matchMedia) return serverValue;
return window.matchMedia(query).matches; return window.matchMedia(query).matches;
+44
View File
@@ -0,0 +1,44 @@
/**
* ── THE TEMPLATE ───────────────────────────────────────────────────────────
*
* `node scripts/new-demo.mjs <slug>` copies this directory to
* `src/demos/<slug>` and substitutes the `__token__` names. Everything here is
* a WORKED EXAMPLE, not filler: it is written the way a real demo is written
* so that a scaffold passes `pnpm check` on the first run and you edit prose
* rather than discover the contract one failing rule at a time.
*
* The directory itself is invisible to the site. The registry's glob excludes
* `_`-prefixed directories, and `scripts/_lib.mjs` does the same, so nothing
* in here renders, ships or is graded until it has been copied under a real
* slug.
*
* `meta.ts` is loaded EAGERLY for every demo on every page, so it stays plain
* serialisable data: no React, no lucide component, no imports beyond the kit.
*/
import { defineMeta } from '@/lib/demo-kit';
export default defineMeta({
/** Must equal the directory name. The registry, the route and the OG card key on it. */
slug: '__slug__',
title: '__Title__',
/** One line, exec-facing. What the agent DOES, not how it works. */
tagline: 'Pick the one item in a queue that actually needs a person, and leave the rest alone.',
vertical: 'reference',
/**
* `spec` publishes the specification — task, actions, grader, counterweight
* and eval command — with no interactive surface. Promote to `live` only
* once recorded runs for this slug exist in `public/traces/manifest.json`;
* `check-demos` rule 7 enforces that and will fail the build otherwise.
*/
status: 'spec',
/** Sort order within the vertical. Ties break on slug. */
order: 100,
/** A lucide-react icon NAME, resolved by the shell. Not a component. */
icon: 'ListChecks',
persona: 'The manager who owns the queue',
/** Six words on what the reward pays for, and what it takes away. */
rewardLine: 'Escalate what matters, minus false alarms',
/** `node scripts/og.mjs` writes this file. Rule 4 fails until it exists. */
ogImage: '/og/__slug__.png',
});
+53
View File
@@ -0,0 +1,53 @@
"""The reward this demo quotes, as a runnable placeholder.
A real demo points `reward.source.path` at its environment package — e.g.
`envs/__package__/__package__/reward.py` — and quotes the function the grader
actually runs. This file exists so a freshly scaffolded demo has a receipt that
RESOLVES on day one: an empty receipt panel reads to a visitor as the code not
existing, which is the exact impression this site is built to avoid.
Move the region markers into the environment and repoint `source.path` as soon
as the environment lands. `scripts/check-receipts.mjs` will tell you the moment
the two disagree.
"""
from dataclasses import dataclass
@dataclass
class Episode:
"""One rollout, as the environment records it."""
escalated: frozenset[str]
needed: frozenset[str]
malformed_actions: int
# region: pig-demo/score
def score(episode: Episode) -> dict[str, float]:
"""Three terms, weighted 0.60 / 0.25 / 0.15 in the demo's RewardSpec.
`caught` is the objective. `restraint` is the counterweight: it is what
stops the objective being maximised the crude way, by escalating the whole
queue. `well_formed` is a gate — every competent policy scores 1.0 on it,
so it is declared a gate rather than dressed up as a second counterweight.
"""
needed = episode.needed
escalated = episode.escalated
caught = len(escalated & needed) / len(needed) if needed else 1.0
noise = escalated - needed
quiet = len(escalated) - len(noise)
restraint = 1.0 - (len(noise) / len(escalated)) if escalated else 1.0
well_formed = 0.0 if episode.malformed_actions else 1.0
return {
"caught": caught,
"restraint": restraint,
"well_formed": well_formed,
# Unweighted diagnostic. Rendered, never summed into the reward.
"escalations_that_landed": float(quiet),
}
# endregion: pig-demo/score
+81
View File
@@ -0,0 +1,81 @@
/**
* The board, and the state it renders.
*
* One component does all three jobs — the visitor playing, the recorded
* replay, and the gallery thumbnail (`compact`) — because three near-identical
* boards is how they drift apart. The shell never inspects `__Pascal__State`;
* it only ever hands one back.
*
* Nothing here may import `@/components/demo/*` or reach inside
* `@/lib/demo-kit`. `scripts/check-demos.mjs` rule 9 enforces it.
*/
import { memo } from 'react';
import { cn } from '@/lib/utils';
/** One row of the queue the agent is triaging. */
export interface QueueItem {
id: string;
label: string;
/** 01, as the environment scored it. Rendered, never used to decide. */
risk: number;
/** True when this row genuinely needed a person. The grader's ground truth. */
needsPerson: boolean;
}
/** The board after a step. Steps are snapshots, not deltas. */
export interface __Pascal__State {
seed: number;
items: QueueItem[];
/** Item ids the agent has escalated so far, in the order it escalated them. */
escalated: string[];
/** Set once the episode ends; `pending` while it is still running. */
outcome: 'pending' | 'solved' | 'failed';
}
/** A fresh board for a seed. Deterministic in the seed — the shell relies on it. */
export function empty__Pascal__(seed: number): __Pascal__State {
return { seed, items: [], escalated: [], outcome: 'pending' };
}
function Row({ item, escalated, compact }: { item: QueueItem; escalated: boolean; compact?: boolean }) {
return (
<li
className={cn(
'flex items-center justify-between gap-3 rounded-md border px-3 py-2',
compact ? 'text-[11px]' : 'text-sm',
escalated ? 'border-accent bg-surface-2 text-fg' : 'border-border text-muted',
)}
>
<span className="truncate">{item.label}</span>
{/* A colour-only distinction fails a projector, deuteranopia and a
black-and-white printout, so the state is also a word. */}
<span className="shrink-0 font-mono tabular-nums">
{escalated ? 'escalated' : 'left alone'}
</span>
</li>
);
}
function Board({ state, compact }: { state: __Pascal__State; compact?: boolean }) {
const escalated = new Set(state.escalated);
if (state.items.length === 0) {
return (
<p className={cn('text-muted', compact ? 'text-[11px]' : 'text-sm')}>
Nothing in the queue yet.
</p>
);
}
return (
<ul className={cn('flex flex-col', compact ? 'gap-1' : 'gap-2')}>
{state.items.map((item) => (
<Row key={item.id} item={item} escalated={escalated.has(item.id)} compact={compact} />
))}
</ul>
);
}
export default memo(Board);
+16 -4
View File
@@ -89,11 +89,23 @@ interface DemoModuleShape {
readonly default?: unknown; readonly default?: unknown;
} }
const metaModules = import.meta.glob<MetaModuleShape>('../../demos/*/meta.ts', { /**
eager: true, * A leading underscore marks scaffolding, not a demo — the same convention
}); * `scripts/_lib.mjs` uses for `demoSlugs()` and `scripts/new-demo.mjs` copies
* from. It is excluded in the GLOB rather than filtered after, so the template
* is never even imported: it holds unsubstituted `__slug__` tokens, which the
* validator below would quarantine with a console error on every page load,
* and an eager glob would ship it to every visitor to say so.
*/
const metaModules = import.meta.glob<MetaModuleShape>(
['../../demos/*/meta.ts', '!../../demos/_*/meta.ts'],
{ eager: true },
);
const demoLoaders = import.meta.glob<DemoModuleShape>('../../demos/*/demo.tsx'); const demoLoaders = import.meta.glob<DemoModuleShape>([
'../../demos/*/demo.tsx',
'!../../demos/_*/demo.tsx',
]);
/** `../../demos/wordle-five/meta.ts` -> `wordle-five` */ /** `../../demos/wordle-five/meta.ts` -> `wordle-five` */
function slugFromPath(path: string): string | null { function slugFromPath(path: string): string | null {
+34
View File
@@ -0,0 +1,34 @@
/**
* The route component behind `/demos/:slug`.
*
* It owns almost nothing on purpose. The router's loader has already validated
* the slug against the registry and started the demo's chunk, and `DemoShell`
* owns the loading, the beats and the URL state, so all that is left here is
* the document head — the half of SEO that `scripts/prerender.mjs` cannot do,
* because a visitor who lands on `/` and clicks through never fetches a new
* document and would otherwise keep the home page's title and canonical link.
*/
import { useParams } from 'react-router-dom';
import { DemoShell } from '@/components/demo/DemoShell';
import { getDemo } from '@/lib/demo-kit/registry';
import { pageTitle, useSeo } from '@/lib/seo';
import { routes } from '@/content/lineup';
export default function DemoPage() {
const { slug } = useParams<{ slug: string }>();
// The loader 404s an unknown slug before this ever renders, so `meta` is
// present in practice; the fallbacks are here so a direct render in a test
// or a preview cannot crash on the head.
const meta = slug ? getDemo(slug) : undefined;
useSeo({
title: pageTitle(meta?.title ?? 'Demo'),
...(meta ? { description: meta.tagline } : {}),
...(slug ? { canonical: routes.demo(slug) } : {}),
...(meta ? { ogImage: meta.ogImage } : {}),
});
return <DemoShell />;
}