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:
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Eye, Trophy } from 'lucide-react';
|
||||
import type { DemoStep } from '@/lib/demo-kit/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
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>
|
||||
</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 }) => {
|
||||
const last = run.steps[run.steps.length - 1];
|
||||
const picked = vote === id;
|
||||
@@ -107,13 +108,9 @@ export function BlindCompare<T>({
|
||||
</div>
|
||||
|
||||
{!revealed ? (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button size="touch" className="w-full" onClick={() => commit(id)}>
|
||||
Agent {id} is better
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<dl className="space-y-1 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
@@ -136,7 +133,7 @@ export function BlindCompare<T>({
|
||||
<dt className="text-muted">Reward</dt>
|
||||
<dd className="nums flex items-center gap-1 text-right font-mono font-semibold">
|
||||
{revealed && winner === id ? (
|
||||
<Trophy className="h-3.5 w-3.5 text-positive" aria-hidden="true" />
|
||||
<Trophy className="size-3.5 text-positive" aria-hidden="true" />
|
||||
) : null}
|
||||
{formatOrDash(run.total)}
|
||||
</dd>
|
||||
@@ -150,21 +147,13 @@ export function BlindCompare<T>({
|
||||
|
||||
{!revealed ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="outline" size="touch" onClick={() => commit('tie')}>
|
||||
Too close to call
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="touch" className="text-muted" onClick={() => setRevealed(true)}>
|
||||
<Eye aria-hidden="true" />
|
||||
Just show me
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p role="status" className="card bg-surface-2 p-3 text-sm leading-relaxed">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CodeReceiptProps {
|
||||
@@ -26,17 +28,33 @@ export interface MarkedRange {
|
||||
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:
|
||||
*
|
||||
* 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
|
||||
* are not interesting code.
|
||||
*
|
||||
* ONCE — the marker sits on a definition line (`def compute_reward`). The
|
||||
* marked range is that line plus its indented body, which is what
|
||||
* you actually meant. Blank lines inside the body are kept; trailing
|
||||
* blank lines are not, or the highlight runs on past the function.
|
||||
* ONCE — the marker names a construct. If it sits ON the construct
|
||||
* (`def compute_reward`), the range is that line plus its indented
|
||||
* body. If it sits in a comment ABOVE it, the range starts at the
|
||||
* 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
|
||||
* 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 };
|
||||
}
|
||||
|
||||
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;
|
||||
let end = first;
|
||||
for (let i = first + 1; i < lines.length; i += 1) {
|
||||
let end = start;
|
||||
for (let i = start + 1; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? '';
|
||||
if (line.trim() === '') continue;
|
||||
const lineIndent = line.length - line.trimStart().length;
|
||||
if (lineIndent <= indent) break;
|
||||
end = i;
|
||||
}
|
||||
return { start: first, end };
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,38 +131,37 @@ export function CodeReceipt({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h3 className="nums min-w-0 flex-1 truncate font-mono text-xs text-muted" title={path}>
|
||||
<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">
|
||||
{/* 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}
|
||||
</h3>
|
||||
{range && !expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="tap rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
Show all {lines.length} lines
|
||||
</button>
|
||||
) : null}
|
||||
{range && expanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="tap rounded-lg border border-border px-2.5 text-xs font-medium transition-colors duration-2 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
Collapse to the marked part
|
||||
</button>
|
||||
) : null}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
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}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{range && !expanded ? (
|
||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(true)}>
|
||||
Show all {lines.length} lines
|
||||
</Button>
|
||||
) : null}
|
||||
{range && expanded ? (
|
||||
<Button variant="outline" size="sm" className="tap" onClick={() => setExpanded(false)}>
|
||||
Collapse to the marked part
|
||||
</Button>
|
||||
) : null}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
className={cn(st.link, 'tap inline-flex items-center gap-1 text-xs')}
|
||||
>
|
||||
Read the whole file
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-h-96 overflow-auto">
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { ComponentType } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
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 { DemoIcon } from './icons';
|
||||
|
||||
export interface DemoCardProps<T> {
|
||||
meta: DemoMeta;
|
||||
@@ -11,7 +13,7 @@ export interface DemoCardProps<T> {
|
||||
href?: string;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
Surface?: ComponentType<{ state: T; compact?: boolean }>;
|
||||
@@ -20,52 +22,43 @@ export interface DemoCardProps<T> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DemoCard<T>({
|
||||
meta,
|
||||
href,
|
||||
Surface,
|
||||
thumbnailState,
|
||||
className,
|
||||
}: DemoCardProps<T>) {
|
||||
const to = href ?? `/demo/${meta.slug}`;
|
||||
export function DemoCard<T>({ meta, href, Surface, thumbnailState, className }: DemoCardProps<T>) {
|
||||
const to = href ?? `/demos/${meta.slug}`;
|
||||
const isSpec = meta.status === 'spec';
|
||||
const showSurface = Surface !== undefined && thumbnailState !== undefined;
|
||||
|
||||
return (
|
||||
<article
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<DemoIcon name={meta.icon} className="h-5 w-5" />
|
||||
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||
<DemoIcon name={meta.icon} className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold leading-tight">
|
||||
{/* Stretched link: the whole card is the hit target, but there is
|
||||
still exactly ONE link in the accessibility tree for it. */}
|
||||
<Link
|
||||
to={to}
|
||||
className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-none"
|
||||
>
|
||||
<Link to={to} className="after:absolute after:inset-0 after:content-['']">
|
||||
{meta.title}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm leading-snug text-muted">{meta.tagline}</p>
|
||||
</div>
|
||||
{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
|
||||
</span>
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showSurface ? (
|
||||
<div className="mx-4 overflow-hidden rounded-lg bg-surface-2 p-3">
|
||||
{/* Decorative here: the title and tagline already name the demo, and
|
||||
a screen reader has no use for a board with no run behind it. */}
|
||||
{/* Decorative: the title and tagline already name the demo, and a
|
||||
board with no run behind it is not information. */}
|
||||
<div aria-hidden="true">
|
||||
<Surface state={thumbnailState as T} compact />
|
||||
</div>
|
||||
@@ -79,7 +72,7 @@ export function DemoCard<T>({
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<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>
|
||||
</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">
|
||||
{isSpec ? 'Read the specification' : 'Open the demo'}
|
||||
<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"
|
||||
/>
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
|
||||
@@ -54,7 +55,7 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
|
||||
return (
|
||||
<div role="alert" className="card mx-auto my-10 max-w-xl p-6">
|
||||
<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">
|
||||
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
|
||||
</h2>
|
||||
@@ -68,21 +69,16 @@ export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErr
|
||||
{error.message || 'Unknown error'}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
<Button size="touch" onClick={this.handleReset}>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
Try again
|
||||
</button>
|
||||
<a
|
||||
href={sourceHref ?? REPO_URL}
|
||||
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 className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="outline" size="touch" asChild>
|
||||
<a href={sourceHref ?? REPO_URL} rel="noreferrer">
|
||||
Read the source
|
||||
<ExternalLink aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+242
-335
@@ -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 * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useUrlState } from '@/lib/url-state';
|
||||
import type {
|
||||
DemoEpisode,
|
||||
DemoModule,
|
||||
DemoStep,
|
||||
RunRef,
|
||||
StoryBeat,
|
||||
} from '@/lib/demo-kit/types';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { listRuns, loadEpisode, rewardTotal } from '@/lib/demo-kit/episode';
|
||||
import { usePlayer } from '@/lib/demo-kit/player';
|
||||
import { loadDemoModule } from '@/lib/demo-kit/registry';
|
||||
import type { AnyDemoModule } from '@/lib/demo-kit/registry';
|
||||
import type { DemoEpisode, DemoStep, RunRef, StoryBeat } from '@/lib/demo-kit/types';
|
||||
import { useRunParam, useSpeedParam, useStepParam, useTabParam } from '@/lib/url-state';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BeatSection } from './BeatSection';
|
||||
import { BlindCompare } from './BlindCompare';
|
||||
@@ -24,142 +25,71 @@ import { ReasoningPanel } from './ReasoningPanel';
|
||||
import { RewardBreakdown } from './RewardBreakdown';
|
||||
import { RewardEditor } from './RewardEditor';
|
||||
import type { RewardArm } from './RewardEditor';
|
||||
import { SegmentedControl } from './SegmentedControl';
|
||||
import { SlotRegion } from './SlotRegion';
|
||||
import { StatStrip } from './StatStrip';
|
||||
import type { Stat } from './StatStrip';
|
||||
import { StepTimeline } from './StepTimeline';
|
||||
import { RecordedBadge, TracePlayer, useTracePlayback } from './TracePlayer';
|
||||
import { RecordedBadge, TracePlayer } from './TracePlayer';
|
||||
import { VerifyBadge } from './VerifyBadge';
|
||||
import { clampIndex, formatOrDash, useIsDesktop } from './format';
|
||||
import { scoreReward } from './reward-math';
|
||||
import { mockDemo, mockEpisodes, mockRuns } from './mock';
|
||||
import { formatOrDash, useIsDesktop } from './format';
|
||||
|
||||
const REPO_BLOB = 'https://github.com/karti-ai/PIG-Demo/blob/main/';
|
||||
const MANIFEST_URL = '/traces/manifest.json';
|
||||
|
||||
/**
|
||||
* Every demo module in the repo, as an unresolved import each.
|
||||
*
|
||||
* `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}');
|
||||
/** The tab the step-detail strip opens on. Kept out of the URL when it is this. */
|
||||
const DEFAULT_DETAIL_TAB = 'reasoning';
|
||||
|
||||
export interface DemoBundle<T = unknown> {
|
||||
demo: DemoModule<T>;
|
||||
/** Reserved slug for the shell's own hand-written demo. Dev builds only. */
|
||||
const MOCK_SLUG = '__mock';
|
||||
|
||||
export interface DemoBundle {
|
||||
demo: AnyDemoModule;
|
||||
runs: RunRef[];
|
||||
episodes: Record<string, DemoEpisode>;
|
||||
}
|
||||
|
||||
type LoadState<T> =
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; bundle: DemoBundle<T> }
|
||||
| { status: 'ready'; bundle: DemoBundle }
|
||||
| { 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
|
||||
* 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
|
||||
* list are all reasonable things for that script to have produced.
|
||||
* A demo's module plus every recorded run it has.
|
||||
*
|
||||
* `loadDemoModule` and `loadEpisode` both cache their promises, so the route
|
||||
* loader having already fetched the module makes this resolve without a second
|
||||
* request. Runs are loaded with `allSettled` on purpose: one unreadable trace
|
||||
* drops that arm rather than blanking the page.
|
||||
*/
|
||||
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> {
|
||||
if (slug === '__mock') {
|
||||
return { demo: mockDemo as unknown as DemoModule, runs: mockRuns, episodes: mockEpisodes };
|
||||
if (slug === MOCK_SLUG) {
|
||||
// Dynamic, so the mock lands in its own chunk and production never fetches
|
||||
// it. A static import would ship several hundred lines of fake trace to
|
||||
// every visitor of every real demo.
|
||||
const mock = await import('./mock');
|
||||
return { demo: mock.mockDemo, runs: mock.mockRuns, episodes: mock.mockEpisodes };
|
||||
}
|
||||
|
||||
const entry = Object.entries(DEMO_MODULES).find(([path]) =>
|
||||
path.startsWith(`/src/demos/${slug}/index.`),
|
||||
);
|
||||
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 demo = await loadDemoModule(slug);
|
||||
const runs = await listRuns(slug).catch(() => [] as RunRef[]);
|
||||
const settled = await Promise.allSettled(runs.map((run) => loadEpisode(run)));
|
||||
|
||||
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> = {};
|
||||
for (const item of loaded) {
|
||||
if (item) episodes[item[0]] = item[1];
|
||||
}
|
||||
settled.forEach((outcome, index) => {
|
||||
const run = runs[index];
|
||||
if (!run) return;
|
||||
if (outcome.status === 'fulfilled') episodes[run.id] = outcome.value;
|
||||
else console.error(`[pig-demo] dropped run "${run.id}":`, outcome.reason);
|
||||
});
|
||||
|
||||
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. */
|
||||
slug?: string;
|
||||
/** 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,
|
||||
* 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 shell is generic over the demo's board type and only ever calls `adapt`
|
||||
* and renders `Surface`.
|
||||
* the shell only ever calls `adapt` and renders `Surface`.
|
||||
*/
|
||||
export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProps<T>) {
|
||||
export function DemoShell({ slug: slugProp, bundle }: DemoShellProps) {
|
||||
const params = useParams();
|
||||
const slug = slugProp ?? params['slug'] ?? '';
|
||||
const [state, setState] = useState<LoadState<T>>(
|
||||
const [state, setState] = useState<LoadState>(
|
||||
bundle ? { status: 'ready', bundle } : { status: 'loading' },
|
||||
);
|
||||
|
||||
@@ -187,7 +116,7 @@ export function DemoShell<T = unknown>({ slug: slugProp, bundle }: DemoShellProp
|
||||
setState({ status: 'loading' });
|
||||
loadBundle(slug)
|
||||
.then((loaded) => {
|
||||
if (live) setState({ status: 'ready', bundle: loaded as DemoBundle<T> });
|
||||
if (live) setState({ status: 'ready', bundle: loaded });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
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 === 'error') {
|
||||
return (
|
||||
<div role="alert" className="card mx-auto my-16 max-w-xl p-6">
|
||||
<h1 className="text-lg font-semibold">That demo is not here</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted">{state.message}</p>
|
||||
<a
|
||||
href="/"
|
||||
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 gallery
|
||||
</a>
|
||||
</div>
|
||||
<main className={cn(st.shell, 'py-16')}>
|
||||
<div role="alert" className="card max-w-xl p-6">
|
||||
<h1 className={st.h2}>That demo is not here</h1>
|
||||
<p className={cn(st.prose, 'mt-3')}>{state.message}</p>
|
||||
<a href="/gallery" className={cn(st.btnSecondary, 'mt-6')}>
|
||||
Back to the demos
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<DemoBody bundle={state.bundle} />
|
||||
</DemoErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
function DemoBody({ bundle }: { bundle: DemoBundle }) {
|
||||
const { demo, runs, episodes } = bundle;
|
||||
const isDesktop = useIsDesktop();
|
||||
|
||||
// The shell writes `?step=` on every advance, including during playback. It
|
||||
// is `@/lib/url-state`'s job to REPLACE rather than push for these — pushing
|
||||
// would turn a six-step run into six back-button presses.
|
||||
const [runParam, setRunParam] = useUrlState('run', '');
|
||||
const [stepParam, setStepParam] = useUrlState('step', '0');
|
||||
const [tabParam, setTabParam] = useUrlState('tab', '');
|
||||
const [runParam, setRunParam] = useRunParam();
|
||||
const [stepParam, setStepParam] = useStepParam();
|
||||
const [tabParam, setTabParam] = useTabParam(DEFAULT_DETAIL_TAB);
|
||||
const [speedParam, setSpeedParam] = useSpeedParam();
|
||||
|
||||
const run = useMemo(
|
||||
() => 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 steps: DemoStep<T>[] = useMemo(
|
||||
() => (episode ? (demo.adapt(episode) as DemoStep<T>[]) : []),
|
||||
const steps = useMemo<DemoStep<unknown>[]>(
|
||||
() => (episode ? demo.adapt(episode) : []),
|
||||
[demo, episode],
|
||||
);
|
||||
|
||||
const step = clampIndex(Number(stepParam), steps.length);
|
||||
const setStep = useCallback(
|
||||
(next: number) => setStepParam(String(clampIndex(next, steps.length))),
|
||||
[setStepParam, steps.length],
|
||||
);
|
||||
const player = usePlayer(steps, {
|
||||
initialIndex: stepParam,
|
||||
initialSpeed: speedParam,
|
||||
onIndexChange: setStepParam,
|
||||
});
|
||||
|
||||
const playback = useTracePlayback({ stepCount: steps.length, step, onStepChange: setStep });
|
||||
const current = steps[step];
|
||||
// The URL is the other writer of this state — Back, a pasted permalink, the
|
||||
// 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']) =>
|
||||
demo.narrative.beats.some((beat) => beat.surface === surface);
|
||||
const timelineInSplit = !hasBeat('scrubber');
|
||||
const extras = demo.tabs ?? [];
|
||||
const extrasInCustom = hasBeat('custom');
|
||||
const extrasInCustomBeat = hasBeat('custom');
|
||||
|
||||
const tabIds = useMemo(() => {
|
||||
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(
|
||||
const arms = useMemo<RewardArm[]>(
|
||||
() =>
|
||||
runs.map((candidate) => {
|
||||
const armEpisode = episodes[candidate.id];
|
||||
@@ -283,15 +212,6 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
[runs, episodes],
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
arms.map((arm) => ({
|
||||
label: arm.label,
|
||||
total: scoreReward(demo.reward, arm.values).total,
|
||||
})),
|
||||
[arms, demo.reward],
|
||||
);
|
||||
|
||||
const blindPair = useMemo(() => {
|
||||
for (let i = 0; i < runs.length; i += 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 };
|
||||
}
|
||||
}
|
||||
// Two runs on different seeds are two different puzzles; showing them side
|
||||
// by side would be a comparison of luck.
|
||||
return null;
|
||||
}, [runs, episodes]);
|
||||
|
||||
if (!run || !episode || steps.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-canvas px-4 py-16">
|
||||
<h1 className="text-lg font-semibold">{demo.meta.title}</h1>
|
||||
<p className="mt-2 max-w-prose text-sm leading-relaxed text-muted">
|
||||
<main className={cn(st.shell, 'py-16')}>
|
||||
<h1 className={st.h2}>{demo.meta.title}</h1>
|
||||
<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
|
||||
the repository; the traces are produced by the eval command on the demo's provenance
|
||||
card.
|
||||
the repository; the traces come from the eval command on the provenance card.
|
||||
</p>
|
||||
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} className="mt-8" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const Surface = demo.Surface as unknown as React.ComponentType<{
|
||||
state: T;
|
||||
compact?: boolean;
|
||||
}>;
|
||||
const Surface = demo.Surface as ComponentType<{ state: unknown; compact?: boolean }>;
|
||||
const current = steps[player.index];
|
||||
const lastStep = steps[steps.length - 1];
|
||||
|
||||
const heroStats: Stat[] = [
|
||||
{
|
||||
label: 'Outcome',
|
||||
value: episode.outcome,
|
||||
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',
|
||||
value: formatOrDash(scoreReward(demo.reward, episode.rewards).total),
|
||||
value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
|
||||
tone: 'brand',
|
||||
hint: 'Shipped weights',
|
||||
},
|
||||
{ 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 = (
|
||||
<StepTimeline
|
||||
steps={steps}
|
||||
current={step}
|
||||
current={player.index}
|
||||
onSelect={(next) => {
|
||||
playback.setPlaying(false);
|
||||
setStep(next);
|
||||
player.pause();
|
||||
player.seek(next);
|
||||
}}
|
||||
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) {
|
||||
case 'hero':
|
||||
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">
|
||||
<Surface state={(steps[steps.length - 1] as DemoStep<T>).state} />
|
||||
{lastStep ? <Surface state={lastStep.state} /> : null}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<StatStrip stats={heroStats} />
|
||||
@@ -382,9 +340,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
{...(run.intervention ? { intervention: run.intervention } : {})}
|
||||
className="ml-0 w-fit"
|
||||
/>
|
||||
<p className="max-w-prose text-sm leading-relaxed text-muted">
|
||||
{demo.narrative.thesis}
|
||||
</p>
|
||||
<p className={cn(st.prose, 'max-w-prose')}>{demo.narrative.thesis}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -400,90 +356,61 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
runs={runs}
|
||||
activeId={run.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);
|
||||
setStepParam('0');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TracePlayer
|
||||
playing={playback.playing}
|
||||
onPlayingChange={playback.setPlaying}
|
||||
speed={playback.speed}
|
||||
onSpeedChange={playback.setSpeed}
|
||||
onRestart={playback.restart}
|
||||
step={step}
|
||||
playing={player.isPlaying}
|
||||
onPlayingChange={(next) => (next ? player.play() : player.pause())}
|
||||
speed={player.speed}
|
||||
onSpeedChange={(next) => {
|
||||
player.setSpeed(next);
|
||||
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}
|
||||
onStepChange={(next) => {
|
||||
playback.setPlaying(false);
|
||||
setStep(next);
|
||||
player.pause();
|
||||
player.seek(next);
|
||||
}}
|
||||
progress={player.progress}
|
||||
timingIsReal={player.timingIsReal}
|
||||
model={run.model}
|
||||
capturedAt={run.capturedAt}
|
||||
{...(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">
|
||||
{current ? <Surface state={current.state} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
{!isDesktop ? (
|
||||
<ReasoningDrawer
|
||||
reasoning={current?.reasoning ?? null}
|
||||
durationMs={current?.call?.durationMs ?? null}
|
||||
playing={playback.playing}
|
||||
speed={playback.speed}
|
||||
stepIndex={step}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Tabs.Root value={activeTab} onValueChange={setTabParam}>
|
||||
<Tabs.List
|
||||
aria-label="Details for this step"
|
||||
className="flex gap-1 overflow-x-auto rounded-lg bg-surface-2 p-1"
|
||||
>
|
||||
{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 className="min-w-0">
|
||||
<Tabs value={activeTab} onValueChange={setTabParam}>
|
||||
<TabsList aria-label="Details for this step" className="w-full overflow-x-auto">
|
||||
{detailTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{detailTabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
{tab.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -496,6 +423,12 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{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" />
|
||||
</div>
|
||||
);
|
||||
@@ -514,23 +447,24 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
);
|
||||
|
||||
case 'metric': {
|
||||
const currentTotal = scoreReward(demo.reward, episode.rewards).total;
|
||||
const first = totals[0];
|
||||
const series = totals
|
||||
.filter((entry): entry is { label: string; total: number } => entry.total !== null)
|
||||
.map((entry) => ({ x: entry.label, y: entry.total }));
|
||||
const currentTotal = rewardTotal(episode.rewards, demo.reward.components);
|
||||
const points = arms
|
||||
.map((arm) => ({ x: arm.label, y: rewardTotal(arm.values, demo.reward.components) }))
|
||||
.filter((point): point is { x: string; y: number } => point.y !== null);
|
||||
const baselineArm = arms[0];
|
||||
const baselineValue = baselineArm
|
||||
? rewardTotal(baselineArm.values, demo.reward.components)
|
||||
: null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<MetricMover
|
||||
label={`Total reward — ${run.label}`}
|
||||
value={currentTotal ?? 0}
|
||||
{...(first && first.total !== null && first.label !== run.label
|
||||
? { baseline: { value: first.total, label: first.label } }
|
||||
{...(baselineArm && baselineValue !== null && arms.length > 1
|
||||
? { baseline: { value: baselineValue, label: baselineArm.label } }
|
||||
: {})}
|
||||
series={series}
|
||||
caption={
|
||||
'Every point is a recorded run scored by the same grader. Nothing here is a projection.'
|
||||
}
|
||||
series={points}
|
||||
caption="Every point is a recorded run scored by the same grader. Nothing here is a projection."
|
||||
/>
|
||||
{blindPair ? (
|
||||
<BlindCompare
|
||||
@@ -543,8 +477,8 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
...(blindPair.left.intervention
|
||||
? { intervention: blindPair.left.intervention }
|
||||
: {}),
|
||||
steps: demo.adapt(blindPair.leftEpisode) as DemoStep<T>[],
|
||||
total: scoreReward(demo.reward, blindPair.leftEpisode.rewards).total,
|
||||
steps: demo.adapt(blindPair.leftEpisode),
|
||||
total: rewardTotal(blindPair.leftEpisode.rewards, demo.reward.components),
|
||||
}}
|
||||
b={{
|
||||
runId: blindPair.right.id,
|
||||
@@ -553,8 +487,8 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
...(blindPair.right.intervention
|
||||
? { intervention: blindPair.right.intervention }
|
||||
: {}),
|
||||
steps: demo.adapt(blindPair.rightEpisode) as DemoStep<T>[],
|
||||
total: scoreReward(demo.reward, blindPair.rightEpisode.rewards).total,
|
||||
steps: demo.adapt(blindPair.rightEpisode),
|
||||
total: rewardTotal(blindPair.rightEpisode.rewards, demo.reward.components),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
@@ -564,7 +498,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
|
||||
case 'receipt':
|
||||
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} />
|
||||
<CodeReceipt
|
||||
code={demo.reward.source.code}
|
||||
@@ -596,10 +530,10 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
};
|
||||
|
||||
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
|
||||
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.
|
||||
*/}
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
||||
@@ -607,13 +541,9 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
</div>
|
||||
|
||||
<header className="pt-8">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-accent-fg">
|
||||
{demo.meta.vertical.replace(/-/g, ' ')} · for {demo.meta.persona}
|
||||
</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={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
|
||||
<h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
|
||||
<p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
|
||||
<p className="mt-4 max-w-prose border-l-2 border-brand pl-3 text-sm italic leading-relaxed text-fg">
|
||||
{demo.narrative.anxiety}
|
||||
</p>
|
||||
@@ -626,18 +556,7 @@ function DemoBody<T>({ bundle }: { bundle: DemoBundle<T> }) {
|
||||
</BeatSection>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -651,50 +570,38 @@ function RunSwitcher({
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Recorded run"
|
||||
className="flex flex-wrap gap-1 rounded-lg bg-surface-2 p-1"
|
||||
>
|
||||
{runs.map((run) => {
|
||||
const active = run.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
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>
|
||||
<SegmentedControl
|
||||
label="Recorded run"
|
||||
options={runs.map((run) => ({
|
||||
value: run.id,
|
||||
label: run.label,
|
||||
...(run.intervention ? { title: run.intervention } : {}),
|
||||
}))}
|
||||
value={activeId}
|
||||
onChange={onSelect}
|
||||
className="border border-border p-1"
|
||||
optionClassName="tap px-3 text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The loading state. Deliberately shaped like the page it becomes, and with no
|
||||
* spinner: a spinner on this site would imply a live model call, which is the
|
||||
* one thing the whole page is at pains to say is not happening.
|
||||
* The loading state. Shaped like the page it becomes, and with no spinner: a
|
||||
* spinner here would imply a live model call, which is the one thing the whole
|
||||
* page is at pains to say is not happening.
|
||||
*/
|
||||
function ShellSkeleton() {
|
||||
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>
|
||||
<div className="h-8 w-64 rounded-lg bg-surface-2" />
|
||||
<div className="mt-3 h-4 w-96 max-w-full rounded-lg bg-surface-2" />
|
||||
<div className="mt-10 grid gap-3 lg:grid-cols-4">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="mt-3 h-4 w-full max-w-md" />
|
||||
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{[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 className="mt-6 h-64 rounded-xl bg-surface-2" />
|
||||
<Skeleton className="mt-6 h-64" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ArrowRight, ShieldQuestion } from 'lucide-react';
|
||||
import { getDemo } from '@/lib/demo-kit/registry';
|
||||
import type { Limit } from '@/lib/demo-kit/types';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LimitsCalloutProps {
|
||||
@@ -13,8 +15,15 @@ export interface LimitsCalloutProps {
|
||||
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 (
|
||||
<section aria-label={title} className={cn('card p-4', className)}>
|
||||
<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>
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
@@ -47,10 +56,10 @@ export function LimitsCallout({
|
||||
{target ? (
|
||||
<a
|
||||
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}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
<ArrowRight className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
|
||||
@@ -82,7 +82,10 @@ export default function MetricChart({
|
||||
)}
|
||||
/>
|
||||
<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"
|
||||
stroke="hsl(var(--accent))"
|
||||
strokeWidth={2}
|
||||
|
||||
@@ -48,7 +48,11 @@ export function MetricMover({
|
||||
className,
|
||||
}: MetricMoverProps) {
|
||||
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]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Copy, ExternalLink } from 'lucide-react';
|
||||
import type { Provenance, RunRef } from '@/lib/demo-kit/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import * as st from '@/content/styles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { 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">
|
||||
Run it yourself
|
||||
</h4>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="tap" onClick={copy}>
|
||||
{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'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg bg-surface-2 p-3">
|
||||
<code className="font-mono text-xs leading-relaxed">{provenance.command}</code>
|
||||
<pre className={cn(st.codeBlock, 'mt-2')}>
|
||||
<code>{provenance.command}</code>
|
||||
</pre>
|
||||
<p role="status" className="mt-1 text-xs text-muted">
|
||||
{copyState === 'copied'
|
||||
@@ -112,11 +110,11 @@ export function ProvenanceCard({ provenance, run, className }: ProvenanceCardPro
|
||||
<li key={credit.href}>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
{credit.label}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { Drawer } from 'vaul';
|
||||
import { Brain, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ReasoningPanel } from './ReasoningPanel';
|
||||
import type { ReasoningPanelProps } from './ReasoningPanel';
|
||||
@@ -36,48 +44,41 @@ export function ReasoningDrawer({
|
||||
const hasReasoning = (panel.reasoning ?? '').length > 0;
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
<Drawer
|
||||
snapPoints={SNAP_POINTS}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
{...(open === undefined ? {} : { open })}
|
||||
{...(onOpenChange ? { onOpenChange } : {})}
|
||||
>
|
||||
<Drawer.Trigger
|
||||
<DrawerTrigger
|
||||
className={cn(
|
||||
'tap flex w-full items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2',
|
||||
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>
|
||||
<ChevronUp className="ml-auto h-4 w-4 text-muted" aria-hidden="true" />
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-fg/40" />
|
||||
<Drawer.Content
|
||||
className="fixed inset-x-0 bottom-0 z-50 mx-auto flex h-full max-h-[97%] max-w-canvas flex-col rounded-t-xl border border-border bg-surface outline-none"
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-border"
|
||||
/>
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<Drawer.Title className="text-sm font-semibold">
|
||||
{panel.title ?? 'Reasoning'}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description className="text-xs text-muted">
|
||||
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||
</Drawer.Description>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4">
|
||||
{/* The panel keeps its own reserved height inside the sheet so the
|
||||
sheet does not resize as the text streams under the drag. */}
|
||||
<ReasoningPanel {...panel} className="h-full border-0" reservedLines={14} />
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
<ChevronUp className="ml-auto size-4 text-muted" aria-hidden="true" />
|
||||
</DrawerTrigger>
|
||||
{/*
|
||||
`h-full max-h-[97%]` overrides the wrapper's `h-auto max-h-[88svh]`.
|
||||
Snap points size the sheet by translating a FIXED-height panel; on an
|
||||
auto-height one the 0.4 snap and the 0.9 snap look identical.
|
||||
*/}
|
||||
<DrawerContent className="mx-auto h-full max-h-[97%] max-w-canvas">
|
||||
<DrawerHeader className="pb-2">
|
||||
<DrawerTitle className="text-sm">{panel.title ?? 'Reasoning'}</DrawerTitle>
|
||||
<DrawerDescription className="text-xs">
|
||||
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerBody className="overflow-hidden">
|
||||
{/* The panel keeps its own reserved height inside the sheet so the
|
||||
sheet does not resize as the text streams under the drag. */}
|
||||
<ReasoningPanel {...panel} className="h-full border-0" reservedLines={14} />
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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 { Brain } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
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
|
||||
|
||||
@@ -148,7 +148,7 @@ export function RewardBreakdown({
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Diagnostics — reported, never summed
|
||||
</h4>
|
||||
<dl className="mt-1.5 grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
<dl className="mt-1.5 grid grid-cols-1 gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{spec.metrics.map((metric) => (
|
||||
<div key={metric.key} className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-xs text-muted" title={metric.description}>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import * as Slider from '@radix-ui/react-slider';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatNumber, formatOrDash } from './format';
|
||||
import { scoreReward, shippedWeights } from './reward-math';
|
||||
import { EditedChip } from './StatStrip';
|
||||
|
||||
/** One recorded arm — a run, or a group of runs already reduced to one score. */
|
||||
@@ -20,6 +28,7 @@ export interface RewardPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Raw weights, before normalisation. Keyed by component. */
|
||||
weights: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -28,19 +37,21 @@ export interface RewardEditorProps {
|
||||
arms: RewardArm[];
|
||||
/** Two is the right number. More and the visitor reads instead of playing. */
|
||||
presets?: RewardPreset[];
|
||||
onWeightsChange?: (weights: Record<string, number>, edited: boolean) => void;
|
||||
onWeightsChange?: (overrides: WeightOverrides, edited: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const STEP = 0.05;
|
||||
|
||||
/**
|
||||
* Presets built from the spec's own labels, for a demo that does not supply
|
||||
* its own. Both are stated as the choice a buyer would actually argue for in a
|
||||
* Presets built from the spec's own labels, for a demo that does not supply its
|
||||
* own. Each is phrased as a position someone would actually argue for in a
|
||||
* meeting, not as "preset A" and "preset B".
|
||||
*/
|
||||
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 objectives = spec.components.filter((c) => c.role === 'objective');
|
||||
const presets: RewardPreset[] = [
|
||||
@@ -53,23 +64,24 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
|
||||
];
|
||||
|
||||
const firstObjective = objectives[0];
|
||||
if (counterweights.length > 0 && firstObjective) {
|
||||
const onlyObjective = { ...shipped };
|
||||
for (const component of counterweights) onlyObjective[component.key] = 0;
|
||||
const firstCounterweight = counterweights[0];
|
||||
if (firstCounterweight && firstObjective) {
|
||||
const objectiveOnly = { ...shipped };
|
||||
for (const component of counterweights) objectiveOnly[component.key] = 0;
|
||||
presets.push({
|
||||
id: 'objective-only',
|
||||
label: `${firstObjective.label} at any cost`,
|
||||
description: `Drops ${counterweights
|
||||
.map((c) => c.label.toLowerCase())
|
||||
.join(' and ')} to zero.`,
|
||||
weights: onlyObjective,
|
||||
description: `Drops ${counterweights.map((c) => c.label.toLowerCase()).join(' and ')} to zero.`,
|
||||
weights: objectiveOnly,
|
||||
});
|
||||
|
||||
const doubled = { ...shipped };
|
||||
for (const component of counterweights) doubled[component.key] = component.weight * 2;
|
||||
presets.push({
|
||||
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.',
|
||||
weights: doubled,
|
||||
});
|
||||
@@ -77,22 +89,27 @@ function derivePresets(spec: RewardSpec): RewardPreset[] {
|
||||
return presets;
|
||||
}
|
||||
|
||||
function weightsEqual(a: Record<string, number>, b: Record<string, number>): boolean {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const key of keys) {
|
||||
if (Math.abs((a[key] ?? 0) - (b[key] ?? 0)) > 1e-9) return false;
|
||||
}
|
||||
return true;
|
||||
function sameWeights(spec: RewardSpec, overrides: WeightOverrides, preset: RewardPreset): boolean {
|
||||
return spec.components.every((component) => {
|
||||
const current = overrides[component.key] ?? component.weight;
|
||||
const target = preset.weights[component.key] ?? component.weight;
|
||||
return Math.abs(current - target) < 1e-9;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Change what "good" means and watch the ranking move.
|
||||
*
|
||||
* 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
|
||||
* produced over these exact attempts; it cannot show you the different attempts
|
||||
* a model trained on that reward would have made. That distinction is the
|
||||
* permanent caption at the bottom, and it is not collapsible.
|
||||
* is NOT training. It shows the ranking a different reward would have produced
|
||||
* over these exact attempts; it cannot show the different attempts a model
|
||||
* trained on that reward would have made. That distinction is the permanent
|
||||
* caption at the bottom, and it is not collapsible.
|
||||
*
|
||||
* 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({
|
||||
spec,
|
||||
@@ -101,32 +118,33 @@ export function RewardEditor({
|
||||
onWeightsChange,
|
||||
className,
|
||||
}: RewardEditorProps) {
|
||||
const shipped = useMemo(() => shippedWeights(spec), [spec]);
|
||||
const [weights, setWeights] = useState<Record<string, number>>(shipped);
|
||||
const [overrides, setOverrides] = useState<WeightOverrides>({});
|
||||
const effectivePresets = useMemo(() => presets ?? derivePresets(spec), [presets, spec]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
const values = spec.components.map((c) => c.weight);
|
||||
const max = Math.max(2, ...values.map((v) => Math.ceil(Math.abs(v) * 2)));
|
||||
const min = Math.min(0, ...values.map((v) => Math.floor(v)));
|
||||
return { min, max };
|
||||
}, [spec]);
|
||||
const edited = isEdited(overrides, spec.components);
|
||||
const inForce = useMemo(() => reweight(spec.components, overrides), [spec, overrides]);
|
||||
const shippedNormalised = useMemo(() => reweight(spec.components, {}), [spec]);
|
||||
const degenerate = weightsAreDegenerate(inForce);
|
||||
|
||||
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>) => {
|
||||
setWeights(next);
|
||||
onWeightsChange?.(next, !weightsEqual(next, shipped));
|
||||
const apply = (next: WeightOverrides) => {
|
||||
// Pruned before it goes into state: an override that equals the 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 shippedTotals = new Map(
|
||||
arms.map((arm) => [arm.id, scoreReward(spec, arm.values).total]),
|
||||
);
|
||||
const rows = arms.map((arm) => ({
|
||||
arm,
|
||||
total: scoreReward(spec, arm.values, weights).total,
|
||||
shippedTotal: shippedTotals.get(arm.id) ?? null,
|
||||
total: degenerate ? null : rewardTotal(arm.values, inForce),
|
||||
shippedTotal: rewardTotal(arm.values, shippedNormalised),
|
||||
}));
|
||||
// Nulls sort last: an unscored arm is not a zero-scoring arm.
|
||||
const byTotal = (a: { total: number | null }, b: { total: number | null }) => {
|
||||
@@ -143,10 +161,10 @@ export function RewardEditor({
|
||||
rank: index + 1,
|
||||
shippedRank: shippedOrder.indexOf(row.arm.id) + 1,
|
||||
}));
|
||||
}, [arms, spec, weights]);
|
||||
}, [arms, inForce, shippedNormalised, degenerate]);
|
||||
|
||||
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 };
|
||||
const lo = Math.min(0, ...totals);
|
||||
const hi = Math.max(...totals);
|
||||
@@ -156,60 +174,54 @@ export function RewardEditor({
|
||||
const leader = ranked[0];
|
||||
|
||||
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">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Change what good means</h3>
|
||||
{edited ? <EditedChip /> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(shipped)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="tap ml-auto"
|
||||
onClick={() => apply({})}
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{effectivePresets.map((preset) => {
|
||||
const active = weightsEqual(weights, preset.weights);
|
||||
const active = sameWeights(spec, overrides, preset);
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => apply({ ...preset.weights })}
|
||||
variant={active ? 'subtle' : 'outline'}
|
||||
size="touch"
|
||||
aria-pressed={active}
|
||||
title={preset.description ?? preset.label}
|
||||
className={cn(
|
||||
'tap rounded-lg border px-3 text-left text-xs font-medium transition-colors duration-2 ease-enter',
|
||||
active
|
||||
? 'border-brand bg-accent-subtle text-accent-fg'
|
||||
: 'border-border hover:bg-surface-2',
|
||||
)}
|
||||
onClick={() => apply({ ...preset.weights })}
|
||||
className="text-xs"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{spec.components.map((component) => {
|
||||
const value = weights[component.key] ?? component.weight;
|
||||
const changed = Math.abs(value - component.weight) > 1e-9;
|
||||
{spec.components.map((component, index) => {
|
||||
const raw = overrides[component.key] ?? component.weight;
|
||||
const changed = Math.abs(raw - component.weight) > 1e-9;
|
||||
const share = inForce[index]?.weight ?? 0;
|
||||
return (
|
||||
<div key={component.key}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
{/* A <label htmlFor> would point at Radix's root <span>,
|
||||
which is not a labelable element — the association would
|
||||
silently do nothing. The thumb takes its name from this
|
||||
text via aria-labelledby instead. */}
|
||||
<span
|
||||
id={`weight-label-${component.key}`}
|
||||
className="text-sm font-medium text-fg"
|
||||
>
|
||||
{/* A <label htmlFor> would point at Radix's root <span>, which
|
||||
is not a labelable element — the association would silently
|
||||
do nothing. The thumb takes its name from this text. */}
|
||||
<span id={`weight-label-${component.key}`} className="text-sm font-medium text-fg">
|
||||
{component.label}
|
||||
</span>
|
||||
<span
|
||||
@@ -218,90 +230,94 @@ export function RewardEditor({
|
||||
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>
|
||||
</div>
|
||||
<p className="mb-1.5 text-xs leading-snug text-muted">{component.description}</p>
|
||||
<Slider.Root
|
||||
className="relative flex h-6 w-full touch-none select-none items-center"
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
<p className="mb-1 text-xs leading-snug text-muted">{component.description}</p>
|
||||
<Slider
|
||||
min={0}
|
||||
max={max}
|
||||
step={STEP}
|
||||
value={[value]}
|
||||
value={[raw]}
|
||||
onValueChange={(next) =>
|
||||
apply({ ...weights, [component.key]: next[0] ?? component.weight })
|
||||
apply({ ...overrides, [component.key]: next[0] ?? component.weight })
|
||||
}
|
||||
>
|
||||
<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>
|
||||
aria-labelledby={`weight-label-${component.key}`}
|
||||
/>
|
||||
</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 aria-label="Ranking under this reward" className="card flex flex-col p-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Ranking under this reward {edited ? <EditedChip className="ml-1 align-middle" /> : null}
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold">
|
||||
Ranking under this reward
|
||||
{edited ? <EditedChip /> : null}
|
||||
</h3>
|
||||
|
||||
<ol className="mt-3 space-y-2">
|
||||
{ranked.map((row) => {
|
||||
const moved = row.rank - row.shippedRank;
|
||||
const width =
|
||||
row.total === null
|
||||
? 0
|
||||
: Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100);
|
||||
return (
|
||||
<li
|
||||
key={row.arm.id}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<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
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
||||
{degenerate ? (
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
||||
Every weight is zero, so there is no reward left to rank by. That is not a score of
|
||||
nought — it is a reward that expresses no preference at all.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="mt-3 space-y-2">
|
||||
{ranked.map((row) => {
|
||||
const moved = row.rank - row.shippedRank;
|
||||
const width =
|
||||
row.total === null
|
||||
? 0
|
||||
: Math.max(2, ((row.total - span.lo) / (span.hi - span.lo)) * 100);
|
||||
return (
|
||||
<li
|
||||
key={row.arm.id}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors duration-3 ease-enter',
|
||||
row.rank === 1 ? 'border-brand bg-accent-subtle/50' : 'border-border',
|
||||
)}
|
||||
>
|
||||
<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
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
{row.arm.note ? (
|
||||
<p className="mt-1 text-xs text-muted">{row.arm.note}</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
aria-hidden="true"
|
||||
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-surface-2"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-brand transition-[width] duration-3 ease-enter"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
{row.arm.note ? <p className="mt-1 text-xs text-muted">{row.arm.note}</p> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* User-initiated, so it is safe to announce here without fighting the
|
||||
shell's step-change region: the two never fire from one action. */}
|
||||
{/* Safe to announce here without fighting the shell's step-change
|
||||
region: a slider drag and a step advance never fire from one action. */}
|
||||
<p role="status" className="sr-only">
|
||||
{leader
|
||||
{leader && leader.total !== null
|
||||
? `Leading under this reward: ${leader.arm.label}, ${formatOrDash(leader.total)}.`
|
||||
: 'No arms to rank.'}
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<p className="mt-auto pt-3 text-xs leading-relaxed text-muted">
|
||||
@@ -316,24 +332,21 @@ export function RewardEditor({
|
||||
function RankMove({ moved }: { moved: number }) {
|
||||
if (moved === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center text-muted" title="Same rank as shipped">
|
||||
<Minus className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">unchanged</span>
|
||||
<span className="inline-flex items-center text-muted" title="Same rank as the shipped reward">
|
||||
<Minus className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">rank unchanged</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const up = moved < 0;
|
||||
const places = Math.abs(moved);
|
||||
return (
|
||||
<span
|
||||
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 ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{Math.abs(moved)}
|
||||
{up ? <ArrowUp className="size-3.5" aria-hidden="true" /> : <ArrowDown className="size-3.5" aria-hidden="true" />}
|
||||
{places}
|
||||
<span className="sr-only">{up ? ' places up' : ' places down'}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,18 @@ import type { PlaybackSpeed } from '@/lib/demo-kit/player';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
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 {
|
||||
playing: boolean;
|
||||
@@ -103,30 +115,12 @@ export function TracePlayer({
|
||||
{stepCount}
|
||||
</p>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Playback speed"
|
||||
className="flex items-center gap-0.5 rounded-lg bg-surface-2 p-0.5"
|
||||
>
|
||||
{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>
|
||||
<SegmentedControl
|
||||
label="Playback speed"
|
||||
options={SPEED_OPTIONS}
|
||||
value={String(speed)}
|
||||
onChange={(next) => onSpeedChange(toSpeed(next))}
|
||||
/>
|
||||
|
||||
<RecordedBadge
|
||||
model={model}
|
||||
|
||||
@@ -75,22 +75,21 @@ export function humaniseToken(token: string): string {
|
||||
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,
|
||||
* because the server snapshot is explicit: the prerendered HTML is built at the
|
||||
* desktop, motion-allowed default and corrects itself on the client.
|
||||
*/
|
||||
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(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return serverValue;
|
||||
return window.matchMedia(query).matches;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
/** 0–1, 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);
|
||||
@@ -89,11 +89,23 @@ interface DemoModuleShape {
|
||||
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` */
|
||||
function slugFromPath(path: string): string | null {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user