Landing picks an environment; each environment is a tabbed page opening on Play
ci / web (push) Successful in 3m10s
ci / python (push) Successful in 2m32s

The environment page was a linear scroll of eight narrative beats. That is an
essay, and it is the wrong shape for somebody who has just chosen an
environment and wants to use it. It is now four tabs — Play, Watch, Reward,
Evidence — opening on Play, with the board above the fold at 390x844 and the
anatomy strip directly beneath it. The landing page leads with the picker
instead of burying it under the thesis.

The contract changed rather than layering tabs over beats. `Narrative.beats` is
gone; `claims: Record<DemoTabId, string>` replaces it, one required sentence per
tab. Writing the claim is how an author discovers whether a tab has anything to
say — a tab whose claim is hard to write is usually a tab with nothing in it.
Doing this now costs one migration; doing it after eleven more environments
costs twelve.

Tabs are derived, never declared: Play iff the demo ships an `interactive` mode,
Watch iff it has recorded runs. A demo that could name its own tabs would mean
environment seven inventing a fifth one and the site ceasing to be one product.

One thing the browser caught that no gate would have. The header stat strip
describes the RECORDED RUN, and on Play it sat above the visitor's own empty
board reading "Outcome: failed" — which parses as your game having already
failed before you touch a key. It now renders only on the tabs whose subject is
that run, which also moved the board 54px up the page.

The picker is honest about the shape of the lineup by construction: one built
environment gets its own block and the demo's real board as its thumbnail,
twelve written specifications render dimmed with a Spec badge, and every count
on the page is derived from the data rather than typed.

206 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 18:07:34 -07:00
parent f5f45df224
commit 5bbf913664
23 changed files with 2005 additions and 866 deletions
+256 -201
View File
@@ -7,14 +7,14 @@ 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 type { DemoEpisode, DemoStep, DemoTabId, RunRef } 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';
import { CodeReceipt } from './CodeReceipt';
import { DemoErrorBoundary } from './DemoErrorBoundary';
import { DemoTabBar, TabClaim, resolveTab, visibleTabs } from './DemoTabs';
import { EnvAnatomy } from './EnvAnatomy';
import { LimitsCallout } from './LimitsCallout';
import { MetricMover } from './MetricMover';
@@ -28,17 +28,24 @@ import { RewardEditor } from './RewardEditor';
import type { RewardArm } from './RewardEditor';
import { SegmentedControl } from './SegmentedControl';
import { SlotRegion } from './SlotRegion';
import { StepTimeline } from './StepTimeline';
import { StatStrip } from './StatStrip';
import type { Stat } from './StatStrip';
import { StepTimeline } from './StepTimeline';
import { RecordedBadge, TracePlayer } from './TracePlayer';
import { VerifyBadge } from './VerifyBadge';
import { formatOrDash, useIsDesktop } from './format';
const REPO_BLOB = 'https://git.karti.ai/PIG/PIG-Demo/src/branch/main/';
/** The tab the step-detail strip opens on. Kept out of the URL when it is this. */
const DEFAULT_DETAIL_TAB = 'reasoning';
/**
* The panel the step-detail strip inside the Watch tab opens on.
*
* This control is deliberately NOT in the URL. `?tab=` now belongs to the page's
* four top-level tabs, and one param cannot address two nested controls without
* one of them silently winning; a permalink to `?tab=call` would land the reader
* on a page with no such top-level tab.
*/
const DEFAULT_DETAIL_PANEL = 'reasoning';
/** Reserved slug for the shell's own hand-written demo. Dev builds only. */
const MOCK_SLUG = '__mock';
@@ -96,7 +103,7 @@ export interface DemoShellProps {
/**
* The route component every demo is rendered through.
*
* It owns four things and no more: loading, the narrative beats, the URL state,
* It owns four things and no more: loading, which tabs exist, 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 only ever calls `adapt` and renders `Surface`.
@@ -162,7 +169,6 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
const [runParam, setRunParam] = useRunParam();
const [stepParam, setStepParam] = useStepParam();
const [tabParam, setTabParam] = useTabParam(DEFAULT_DETAIL_TAB);
const [speedParam, setSpeedParam] = useSpeedParam();
const run = useMemo(
@@ -192,11 +198,22 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
// 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 extrasInCustomBeat = hasBeat('custom');
// Derived, never declared. A demo that ships no interactive mode has no Play
// tab and opens on Watch; one whose traces failed to load has no Watch tab
// and opens on Reward.
const hasRecording = Boolean(run && episode && steps.length > 0);
const tabs = useMemo(
() => visibleTabs({ play: Boolean(demo.interactive), watch: hasRecording }),
[demo.interactive, hasRecording],
);
// `visibleTabs` always keeps `reward` and `evidence`, so index 0 exists; the
// fallback is here only so the type does not need an assertion.
const defaultTab: DemoTabId = tabs[0] ?? 'evidence';
const [tabParam, setTabParam] = useTabParam(defaultTab);
const activeTab = resolveTab(tabParam, tabs, defaultTab);
// React-only, not a URL param. See DEFAULT_DETAIL_PANEL.
const [detailPanel, setDetailPanel] = useState(DEFAULT_DETAIL_PANEL);
const arms = useMemo<RewardArm[]>(
() =>
@@ -230,54 +247,39 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
return null;
}, [runs, episodes]);
if (!run || !episode || steps.length === 0) {
return (
<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 come from the eval command on the provenance card.
</p>
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} className="mt-8" />
</main>
);
}
const Surface = demo.Surface as ComponentType<{ state: unknown; compact?: boolean }>;
const current = steps[player.index];
const lastStep = steps[steps.length - 1];
const claims = demo.narrative.claims;
const extras = demo.tabs ?? [];
const heroStats: Stat[] = [
{
label: 'Outcome',
value: episode.outcome,
tone: episode.outcome === 'solved' ? 'positive' : 'warning',
...(episode.truncated ? { hint: 'Truncated before a terminal state' } : {}),
},
{
label: 'Total reward',
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: 'The same seed reproduces this board' },
];
// The seed is the shared coordinate between the visitor's board and the
// agent's. With no recording to match, the demo's own first board will do.
const playSeed = run?.seed ?? 0;
const timeline = (
<StepTimeline
steps={steps}
current={player.index}
onSelect={(next) => {
player.pause();
player.seek(next);
}}
Surface={Surface}
onTogglePlay={player.toggle}
/>
);
const headerStats: Stat[] = episode
? [
{
label: 'Outcome',
value: episode.outcome,
tone: episode.outcome === 'solved' ? 'positive' : 'warning',
...(episode.truncated ? { title: 'Truncated before a terminal state' } : {}),
},
{
label: 'Reward',
value: formatOrDash(rewardTotal(episode.rewards, demo.reward.components)),
tone: 'brand',
title: 'Total under the shipped weights',
},
{ label: 'Steps', value: steps.length, title: 'Model calls in this run' },
{
label: 'Seed',
value: episode.seed,
title: 'The same seed reproduces this board',
},
]
: [];
const detailTabs: { id: string; label: string; content: ReactNode }[] = [
const detailPanels: { id: string; label: string; content: ReactNode }[] = [
{
id: 'reasoning',
label: 'Reasoning',
@@ -319,51 +321,100 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
</div>
),
},
...(extrasInCustomBeat
? []
: extras.map((tab) => ({ id: tab.id, label: tab.label, content: <tab.Component /> }))),
// A demo's own extra panels ride alongside the step detail, where they sit
// next to the step they are almost always about. With no recording there is
// no step detail, so the Evidence tab picks them up instead.
...(hasRecording
? 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 activeDetail = detailPanels.some((panel) => panel.id === detailPanel)
? detailPanel
: DEFAULT_DETAIL_PANEL;
const renderSurface = (beat: StoryBeat): ReactNode => {
switch (beat.surface) {
case 'hero':
return (
<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">
{lastStep ? <Surface state={lastStep.state} /> : null}
</div>
<div className="space-y-3">
<StatStrip stats={heroStats} />
<RecordedBadge
model={run.model}
capturedAt={run.capturedAt}
{...(run.intervention ? { intervention: run.intervention } : {})}
className="ml-0 w-fit"
/>
<p className={cn(st.prose, 'max-w-prose')}>{demo.narrative.thesis}</p>
</div>
const timeline = (
<StepTimeline
steps={steps}
current={player.index}
onSelect={(next) => {
player.pause();
player.seek(next);
}}
Surface={Surface}
onTogglePlay={player.toggle}
/>
);
return (
<main className={cn(st.shell, 'pb-16')}>
{/*
The page's ONE live region. Every step change lands here and nowhere
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">
{current?.announce ?? ''}
</div>
<header className="pt-6 sm:pt-8">
<p className={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
<h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
<p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
{/* The buyer's question, kept quiet on purpose: it is the thing they
walked in with, not the thing this page is asserting. */}
<p className="mt-2 max-w-prose text-sm italic leading-relaxed text-muted">
{demo.narrative.anxiety}
</p>
{/*
The stats describe the RECORDED RUN, so they only belong on the tabs
whose subject is that run. On Play they sat above the visitor's own
empty board reading "Outcome: failed", which parses as *your* game
having already failed before you have touched a key.
*/}
{run && headerStats.length > 0 && activeTab !== 'play' ? (
// One line that scrolls itself rather than a block that wraps: every
// row this header spends is a row of the interactive board pushed
// below the fold on a 390px phone.
<div className="mt-3 flex items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-visible sm:pb-0">
<StatStrip stats={headerStats} />
<RecordedBadge
model={run.model}
capturedAt={run.capturedAt}
{...(run.intervention ? { intervention: run.intervention } : {})}
className="ml-0 shrink-0 flex-nowrap"
/>
</div>
);
) : null}
<SlotRegion id="hero-aside" />
</header>
case 'anatomy':
return <EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} />;
<Tabs value={activeTab} onValueChange={setTabParam} className="mt-4 sm:mt-5">
<DemoTabBar tabs={tabs} />
case 'split-play':
return (
<div className="space-y-3">
{demo.interactive ? (
<>
<PlayYourself demo={demo} seed={run.seed} />
<div className="space-y-1 pt-4">
<h3 className={st.h3}>What the model did</h3>
<p className={cn(st.prose, 'max-w-prose text-sm')}>
Same hidden answer, same rules, same budget replayed at the
speed it actually happened.
</p>
</div>
</>
) : null}
{tabs.includes('play') ? (
// `forceMount` keeps the visitor's half-finished board alive while
// they read the other tabs, so a game in progress survives a trip to
// Reward and back. Radix leaves the hiding to the author under
// `forceMount`, which is what the `data-[state=inactive]` class does —
// it is load-bearing, not belt-and-braces.
<TabsContent
value="play"
forceMount
className="mt-5 space-y-5 data-[state=inactive]:hidden sm:mt-6 sm:space-y-6"
>
<TabClaim>{claims.play}</TabClaim>
<PlayYourself demo={demo} seed={playSeed} />
<SlotRegion id="below-board" />
<div className="space-y-2">
<h2 className={st.h3}>The machine you are inside</h2>
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} compact />
</div>
</TabsContent>
) : null}
{tabs.includes('watch') && run && episode ? (
<TabsContent value="watch" className="mt-6 space-y-4">
<TabClaim>{claims.watch}</TabClaim>
{runs.length > 1 ? (
<RunSwitcher
@@ -411,31 +462,23 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
</div>
<div className="min-w-0">
<Tabs value={activeTab} onValueChange={setTabParam}>
<Tabs value={activeDetail} onValueChange={setDetailPanel}>
<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}
{detailPanels.map((panel) => (
<TabsTrigger key={panel.id} value={panel.id}>
{panel.label}
</TabsTrigger>
))}
</TabsList>
{detailTabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id}>
{tab.content}
{detailPanels.map((panel) => (
<TabsContent key={panel.id} value={panel.id}>
{panel.content}
</TabsContent>
))}
</Tabs>
</div>
</div>
{timelineInSplit ? timeline : null}
<SlotRegion id="below-board" />
</div>
);
case 'scrubber':
return (
<div className="space-y-3">
{timeline}
{player.timingIsReal ? null : (
<p className="text-xs text-muted">
@@ -444,42 +487,7 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
</p>
)}
<SlotRegion id="below-timeline" />
</div>
);
case 'reward-editor':
return (
<div className="space-y-4">
<RewardBreakdown
spec={demo.reward}
values={episode.rewards}
{...(episode.metrics ? { metrics: episode.metrics } : {})}
/>
<VerifyBadge demo={demo} episode={episode} />
{arms.length > 1 ? <RewardEditor spec={demo.reward} arms={arms} /> : null}
</div>
);
case 'metric': {
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}
{...(baselineArm && baselineValue !== null && arms.length > 1
? { baseline: { value: baselineValue, label: baselineArm.label } }
: {})}
series={points}
caption="Every point is a recorded run scored by the same grader. Nothing here is a projection."
/>
{blindPair ? (
<BlindCompare
seed={blindPair.left.seed}
@@ -506,14 +514,63 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
}}
/>
) : null}
</div>
);
}
</TabsContent>
) : null}
<TabsContent value="reward" className="mt-6 space-y-4">
<TabClaim>{claims.reward}</TabClaim>
{episode ? (
<RewardBreakdown
spec={demo.reward}
values={episode.rewards}
{...(episode.metrics ? { metrics: episode.metrics } : {})}
/>
) : (
<>
<p className={cn(st.prose, 'max-w-prose')}>
No recorded run has been scored for this environment yet, so every term below
reads as not scored rather than as zero. The weights are the ones the environment
ships.
</p>
<RewardBreakdown spec={demo.reward} values={{}} />
</>
)}
{/* The editor carries the ranking it re-orders in its own right-hand
column, so the two are never on screen apart. */}
{arms.length > 1 ? <RewardEditor spec={demo.reward} arms={arms} /> : null}
{episode ? <VerifyBadge demo={demo} episode={episode} /> : null}
{run && episode ? (
<HeadlineMetric
label={`Total reward — ${run.label}`}
arms={arms}
components={demo.reward.components}
currentRewards={episode.rewards}
/>
) : null}
<SlotRegion id="beside-reward" />
</TabsContent>
<TabsContent value="evidence" className="mt-6 space-y-6">
<TabClaim>{claims.evidence}</TabClaim>
{/*
`narrative.thesis` is a required field of the contract and the only
paragraph on a demo that argues for the environment as a whole
rather than for one tab. It has to be SOMEWHERE, and this is the
tab a visitor opens to read rather than to do — Play stays a board
above the fold, which is the one thing a paragraph here would cost.
*/}
<p className={cn(st.prose, 'max-w-prose')}>{demo.narrative.thesis}</p>
<EnvAnatomy anatomy={demo.anatomy} rewardLine={demo.meta.rewardLine} />
case 'receipt':
return (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-start">
<ProvenanceCard provenance={demo.provenance} run={run} />
<ProvenanceCard provenance={demo.provenance} {...(run ? { run } : {})} />
<CodeReceipt
code={demo.reward.source.code}
path={demo.reward.source.path}
@@ -522,55 +579,57 @@ function DemoBody({ bundle }: { bundle: DemoBundle }) {
/>
<SlotRegion id="after-receipts" className="lg:col-span-2" />
</div>
);
case 'limits':
return <LimitsCallout limits={demo.narrative.limits} />;
{!hasRecording && extras.length > 0 ? (
<div className="space-y-4">
{extras.map((tab) => (
<tab.Component key={tab.id} />
))}
</div>
) : null}
case 'custom':
return extras.length > 0 ? (
<div className="space-y-4">
{extras.map((tab) => (
<tab.Component key={tab.id} />
))}
</div>
) : (
<SlotRegion id="before-limits" />
);
<LimitsCallout limits={demo.narrative.limits} />
</TabsContent>
</Tabs>
</main>
);
}
default:
return null;
}
};
/**
* The headline metric, with every recorded arm on the same line.
*
* Arms that were never scored are dropped rather than plotted at zero — the
* difference between "scored badly" and "not scored" is the site's whole
* argument, and a chart is the easiest place in the world to lose it.
*/
function HeadlineMetric({
label,
arms,
components,
currentRewards,
}: {
label: string;
arms: RewardArm[];
components: AnyDemoModule['reward']['components'];
currentRewards: DemoEpisode['rewards'];
}) {
const points = arms
.map((arm) => ({ x: arm.label, y: rewardTotal(arm.values, components) }))
.filter((point): point is { x: string; y: number } => point.y !== null);
const baselineArm = arms[0];
const baselineValue = baselineArm ? rewardTotal(baselineArm.values, components) : null;
return (
<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 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">
{current?.announce ?? ''}
</div>
<header className="pt-8">
<p className={cn(st.eyebrow, 'text-accent-fg')}>For {demo.meta.persona}</p>
<h1 className={cn(st.h2, 'mt-1')}>{demo.meta.title}</h1>
<p className={cn(st.lede, 'mt-2 max-w-prose')}>{demo.meta.tagline}</p>
<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>
</header>
<div className="divide-y divide-border">
{demo.narrative.beats.map((beat, index) => (
<BeatSection key={beat.id} beat={beat} number={index + 1}>
{renderSurface(beat)}
</BeatSection>
))}
</div>
</main>
<MetricMover
label={label}
value={rewardTotal(currentRewards, components) ?? 0}
{...(baselineArm && baselineValue !== null && arms.length > 1
? { baseline: { value: baselineValue, label: baselineArm.label } }
: {})}
series={points}
caption="Every point is a recorded run scored by the same grader. Nothing here is a projection."
/>
);
}
@@ -647,7 +706,6 @@ function RunSwitcher({
);
}
/**
* 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
@@ -659,12 +717,9 @@ function ShellSkeleton() {
<p className="sr-only">Loading the recorded run.</p>
<Skeleton className="h-8 w-64" />
<Skeleton className="mt-3 h-4 w-full max-w-md" />
<div className="mt-10 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[0, 1, 2, 3].map((index) => (
<Skeleton key={index} className="h-28" />
))}
</div>
<Skeleton className="mt-6 h-64" />
<Skeleton className="mt-4 h-10 w-full max-w-sm" />
<Skeleton className="mt-6 h-11 w-full max-w-md" />
<Skeleton className="mt-6 h-80" />
</div>
);
}