Pin seed->word across both languages with a shared hash
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import type { ModelCall } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatInt, formatMs, humaniseToken } from './format';
|
||||
|
||||
export interface ModelCallPanelProps {
|
||||
call: ModelCall | null;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
label: string;
|
||||
value: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model call, straight off the trace.
|
||||
*
|
||||
* Nothing here is computed, averaged or estimated. Every field is a value the
|
||||
* recorder wrote down, and a field the recorder did not write down renders as
|
||||
* an em dash — never as zero, and never quietly omitted. A provider that does
|
||||
* not report reasoning tokens is a fact about the trace, and hiding the row
|
||||
* would turn "we do not know" into "there were none".
|
||||
*/
|
||||
export function ModelCallPanel({ call, className, title = 'Model call' }: ModelCallPanelProps) {
|
||||
const rows: Row[] = [
|
||||
{
|
||||
label: 'finish_reason',
|
||||
value: call?.finishReason ? humaniseToken(call.finishReason) : DASH,
|
||||
hint: 'Why the model stopped generating',
|
||||
},
|
||||
{
|
||||
label: 'Prompt tokens',
|
||||
value: formatInt(call?.promptTokens ?? null),
|
||||
hint: 'Everything sent in: system, board, history',
|
||||
},
|
||||
{
|
||||
label: 'Completion tokens',
|
||||
value: formatInt(call?.completionTokens ?? null),
|
||||
hint: 'The visible reply',
|
||||
},
|
||||
{
|
||||
label: 'Reasoning tokens',
|
||||
value: formatInt(call?.reasoningTokens ?? null),
|
||||
hint: 'Billed thinking, when the provider reports it',
|
||||
},
|
||||
{
|
||||
label: 'Latency',
|
||||
value: formatMs(call?.durationMs ?? null),
|
||||
hint: 'Real elapsed time when the run was recorded',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-label={title} className={cn('card overflow-hidden', className)}>
|
||||
<header className="border-b border-border px-3 py-2">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</header>
|
||||
{call === null ? (
|
||||
<p className="px-3 py-3 text-sm text-muted">
|
||||
This step did not involve a model call — it is a state change the environment made on
|
||||
its own.
|
||||
</p>
|
||||
) : (
|
||||
<dl className="divide-y divide-border">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="flex items-baseline gap-3 px-3 py-2">
|
||||
<dt className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{row.label}</span>
|
||||
{row.hint ? (
|
||||
<span className="block text-xs leading-snug text-muted">{row.hint}</span>
|
||||
) : null}
|
||||
</dt>
|
||||
<dd className="nums shrink-0 font-mono text-sm text-fg">{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Drawer } from 'vaul';
|
||||
import { Brain, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ReasoningPanel } from './ReasoningPanel';
|
||||
import type { ReasoningPanelProps } from './ReasoningPanel';
|
||||
|
||||
const SNAP_POINTS = [0.4, 0.9];
|
||||
|
||||
export interface ReasoningDrawerProps extends ReasoningPanelProps {
|
||||
/** Controlled from the shell when it wants the drawer open on a step change. */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoning panel, for a phone.
|
||||
*
|
||||
* Below `lg` there is no room for a column beside the board, and putting the
|
||||
* reasoning under the board means the visitor watches the run with the thinking
|
||||
* off-screen. A drawer at 40% shows the first few lines without covering the
|
||||
* board; dragging to 90% is the "let me actually read this" gesture.
|
||||
*
|
||||
* Rendering is caller-gated rather than CSS-gated: mounting a vaul drawer on
|
||||
* desktop and hiding it with `lg:hidden` still locks body scroll when it opens,
|
||||
* so the shell mounts this only under `lg`.
|
||||
*/
|
||||
export function ReasoningDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
triggerClassName,
|
||||
...panel
|
||||
}: ReasoningDrawerProps) {
|
||||
const [snap, setSnap] = useState<number | string | null>(SNAP_POINTS[0] ?? 0.4);
|
||||
const hasReasoning = (panel.reasoning ?? '').length > 0;
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
snapPoints={SNAP_POINTS}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
{...(open === undefined ? {} : { open })}
|
||||
{...(onOpenChange ? { onOpenChange } : {})}
|
||||
>
|
||||
<Drawer.Trigger
|
||||
className={cn(
|
||||
'tap flex w-full items-center gap-2 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium transition-colors duration-2 ease-enter hover:bg-surface-2',
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<Brain className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
<span>{hasReasoning ? 'Read the reasoning' : 'No reasoning on this step'}</span>
|
||||
<ChevronUp className="ml-auto h-4 w-4 text-muted" aria-hidden="true" />
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-40 bg-fg/40" />
|
||||
<Drawer.Content
|
||||
className="fixed inset-x-0 bottom-0 z-50 mx-auto flex h-full max-h-[97%] max-w-canvas flex-col rounded-t-xl border border-border bg-surface outline-none"
|
||||
style={{ paddingBottom: 'var(--safe-bottom)' }}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto mt-2 h-1.5 w-12 shrink-0 rounded-full bg-border"
|
||||
/>
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
<Drawer.Title className="text-sm font-semibold">
|
||||
{panel.title ?? 'Reasoning'}
|
||||
</Drawer.Title>
|
||||
<Drawer.Description className="text-xs text-muted">
|
||||
Recorded verbatim from step {panel.stepIndex + 1} of this run.
|
||||
</Drawer.Description>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden px-4 pb-4">
|
||||
{/* The panel keeps its own reserved height inside the sheet so the
|
||||
sheet does not resize as the text streams under the drag. */}
|
||||
<ReasoningPanel {...panel} className="h-full border-0" reservedLines={14} />
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import { Brain } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrefersReducedMotion } from './format';
|
||||
import type { PlaybackSpeed } from './TracePlayer';
|
||||
|
||||
/**
|
||||
* Characters per second, clamped. A 40-character reasoning trace recorded over
|
||||
* 30 seconds would otherwise crawl at 1.3 chars/s and read as a hung page,
|
||||
* and a 6,000-character one recorded in 800 ms would flash past unread.
|
||||
*/
|
||||
const MIN_CPS = 24;
|
||||
const MAX_CPS = 900;
|
||||
const FALLBACK_CPS = 90;
|
||||
|
||||
export interface ReasoningPanelProps {
|
||||
reasoning: string | null;
|
||||
/** The recorded latency of the call this reasoning came from. */
|
||||
durationMs: number | null;
|
||||
playing: boolean;
|
||||
speed: PlaybackSpeed;
|
||||
/** Changing this restarts the stream. Pass the step index. */
|
||||
stepIndex: number;
|
||||
/**
|
||||
* Lines of height held open whether or not there is text. Reserving the box
|
||||
* is not a nicety: this panel sits beside the board, and letting it grow as
|
||||
* the text arrives shoves the board down the page mid-run.
|
||||
*/
|
||||
reservedLines?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function charsPerSecond(length: number, durationMs: number | null): number {
|
||||
if (!durationMs || durationMs <= 0 || length === 0) return FALLBACK_CPS;
|
||||
return Math.min(Math.max((length / durationMs) * 1000, MIN_CPS), MAX_CPS);
|
||||
}
|
||||
|
||||
export function ReasoningPanel({
|
||||
reasoning,
|
||||
durationMs,
|
||||
playing,
|
||||
speed,
|
||||
stepIndex,
|
||||
reservedLines = 10,
|
||||
title = 'Reasoning',
|
||||
className,
|
||||
}: ReasoningPanelProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const full = reasoning ?? '';
|
||||
const [visible, setVisible] = useState(full.length);
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Whether this step's text should stream at all. Scrubbing to a step while
|
||||
// paused shows it whole — someone reading at their own pace is not asking to
|
||||
// be typed at.
|
||||
const shouldStream = playing && speed !== 'instant' && !reducedMotion && full.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldStream) {
|
||||
setVisible(full.length);
|
||||
return;
|
||||
}
|
||||
setVisible(0);
|
||||
const cps = charsPerSecond(full.length, durationMs) * (typeof speed === 'number' ? speed : 1);
|
||||
const started = performance.now();
|
||||
let frame = 0;
|
||||
let last = -1;
|
||||
const tick = (now: number) => {
|
||||
const next = Math.min(Math.floor(((now - started) / 1000) * cps), full.length);
|
||||
// Only re-render when a character actually lands; at 120 Hz the naive
|
||||
// version re-renders the whole panel twice per revealed character.
|
||||
if (next !== last) {
|
||||
last = next;
|
||||
setVisible(next);
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport) viewport.scrollTop = viewport.scrollHeight;
|
||||
}
|
||||
if (next < full.length) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [shouldStream, full, durationMs, speed, stepIndex]);
|
||||
|
||||
const streaming = visible < full.length;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={title}
|
||||
className={cn('card flex flex-col overflow-hidden', className)}
|
||||
>
|
||||
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<Brain className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{streaming ? (
|
||||
<span className="nums ml-auto text-xs text-muted">
|
||||
{visible}/{full.length}
|
||||
</span>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<ScrollArea.Root
|
||||
type="auto"
|
||||
className="min-h-0 flex-1"
|
||||
// Height, not min-height: the panel is the same size on every step,
|
||||
// including the ones with no reasoning at all.
|
||||
style={{ height: `${reservedLines * 1.45}rem` }}
|
||||
>
|
||||
<ScrollArea.Viewport
|
||||
ref={viewportRef}
|
||||
className="h-full w-full px-3 py-2.5"
|
||||
// The shell owns the page's single polite live region. A streaming
|
||||
// region here would read every partial word over the top of it.
|
||||
aria-live="off"
|
||||
>
|
||||
{full.length === 0 ? (
|
||||
<p className="text-sm leading-relaxed text-muted">
|
||||
This step has no recorded reasoning. The run was captured with thinking disabled, so
|
||||
there is nothing to show here — which is different from the model having thought
|
||||
nothing.
|
||||
</p>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap font-mono text-[13px] leading-relaxed text-fg">
|
||||
{full.slice(0, visible)}
|
||||
{streaming ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-px inline-block h-[1em] w-[0.5ch] translate-y-[0.15em] animate-pulse bg-brand align-baseline"
|
||||
/>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</ScrollArea.Viewport>
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
className="flex w-2 touch-none select-none p-0.5"
|
||||
>
|
||||
<ScrollArea.Thumb className="flex-1 rounded-full bg-border" />
|
||||
</ScrollArea.Scrollbar>
|
||||
</ScrollArea.Root>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Scale, Target, Weight } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatNumber, formatOrDash } from './format';
|
||||
import { scoreReward } from './reward-math';
|
||||
import { EditedChip } from './StatStrip';
|
||||
|
||||
const ROLE_META: Record<RewardComponent['role'], { label: string; Icon: LucideIcon }> = {
|
||||
objective: { label: 'Objective', Icon: Target },
|
||||
counterweight: { label: 'Counterweight', Icon: Weight },
|
||||
gate: { label: 'Gate', Icon: Scale },
|
||||
};
|
||||
|
||||
export interface RewardBreakdownProps {
|
||||
spec: RewardSpec;
|
||||
values: RewardValues;
|
||||
/** Unweighted diagnostics. Rendered, never summed — the contract is explicit. */
|
||||
metrics?: Record<string, number | null>;
|
||||
/** Overridden weights from the editor. Absent means the shipped weights. */
|
||||
weights?: Record<string, number>;
|
||||
/** Set when `weights` came from the visitor rather than the environment. */
|
||||
edited?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `score x weight = value`, per component, plus the total.
|
||||
*
|
||||
* The counterweight row is called out because it is the row that makes the
|
||||
* reward an opinion rather than a scoreboard. Everyone understands "pay for
|
||||
* solving it". The interesting engineering is the term that takes points away,
|
||||
* and an exec who leaves this page understanding only that has got the point.
|
||||
*/
|
||||
export function RewardBreakdown({
|
||||
spec,
|
||||
values,
|
||||
metrics,
|
||||
weights,
|
||||
edited = false,
|
||||
className,
|
||||
}: RewardBreakdownProps) {
|
||||
const { rows, total } = scoreReward(spec, values, weights);
|
||||
const counterweights = rows.filter((row) => row.component.role === 'counterweight');
|
||||
|
||||
return (
|
||||
<div className={cn('card overflow-hidden', className)}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<caption className="sr-only">
|
||||
Reward components, their weights and their contribution to the total score
|
||||
</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted">
|
||||
<th scope="col" className="px-3 py-2 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Score
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">
|
||||
Weight
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const role = ROLE_META[row.component.role];
|
||||
const isCounterweight = row.component.role === 'counterweight';
|
||||
return (
|
||||
<tr
|
||||
key={row.component.key}
|
||||
className={cn(isCounterweight && 'bg-accent-subtle/40')}
|
||||
>
|
||||
<th scope="row" className="max-w-0 px-3 py-2.5 text-left font-normal">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-fg">{row.component.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
|
||||
isCounterweight
|
||||
? 'bg-brand/15 text-accent-fg'
|
||||
: 'bg-surface-2 text-muted',
|
||||
)}
|
||||
>
|
||||
<role.Icon className="h-3 w-3" aria-hidden="true" />
|
||||
{role.label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-snug text-muted">
|
||||
{row.component.description}
|
||||
</span>
|
||||
<span className="mt-0.5 block font-mono text-[11px] text-muted">
|
||||
{row.component.key}
|
||||
</span>
|
||||
</th>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
{row.score === null ? (
|
||||
<span className="text-muted" title="The environment did not score this run">
|
||||
not scored
|
||||
</span>
|
||||
) : (
|
||||
formatNumber(row.score)
|
||||
)}
|
||||
</td>
|
||||
<td className="nums px-2 py-2.5 text-right align-top font-mono">
|
||||
<span className={cn(edited && 'text-accent-fg')}>
|
||||
{formatNumber(row.weight, 2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="nums px-3 py-2.5 text-right align-top font-mono font-semibold">
|
||||
{row.value === null ? DASH : formatNumber(row.value)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-border bg-surface-2">
|
||||
<th scope="row" className="px-3 py-2.5 text-left">
|
||||
<span className="flex items-center gap-2 font-semibold">
|
||||
Total reward
|
||||
{edited ? <EditedChip /> : null}
|
||||
</span>
|
||||
</th>
|
||||
<td colSpan={2} />
|
||||
<td className="nums px-3 py-2.5 text-right font-mono text-base font-semibold">
|
||||
{total === null ? (
|
||||
<span className="text-muted">not scored</span>
|
||||
) : (
|
||||
formatOrDash(total)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{counterweights.length > 0 ? (
|
||||
<p className="border-t border-border bg-accent-subtle/40 px-3 py-2.5 text-xs leading-relaxed text-fg">
|
||||
<span className="font-semibold">
|
||||
{counterweights.map((row) => row.component.label).join(' and ')}
|
||||
</span>{' '}
|
||||
{counterweights.length > 1 ? 'are counterweights' : 'is the counterweight'}: without a
|
||||
term pulling the other way, the cheapest way to maximise the objective is a behaviour
|
||||
you would never ship, and the model will find it.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{spec.metrics && spec.metrics.length > 0 ? (
|
||||
<div className="border-t border-border px-3 py-2.5">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
Diagnostics — reported, never summed
|
||||
</h4>
|
||||
<dl className="mt-1.5 grid gap-x-4 gap-y-1 sm:grid-cols-2">
|
||||
{spec.metrics.map((metric) => (
|
||||
<div key={metric.key} className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-xs text-muted" title={metric.description}>
|
||||
{metric.label}
|
||||
</dt>
|
||||
<dd className="nums font-mono text-xs">
|
||||
{formatOrDash(metrics?.[metric.key] ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ComponentType, KeyboardEvent } from 'react';
|
||||
import type { DemoStep } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatInt, formatMs, humaniseToken, usePrefersReducedMotion } from './format';
|
||||
|
||||
export interface StepTimelineProps<T> {
|
||||
steps: DemoStep<T>[];
|
||||
current: number;
|
||||
onSelect: (index: number) => void;
|
||||
/** The demo's own board, drawn small. Omit and the chips are text only. */
|
||||
Surface?: ComponentType<{ state: T; compact?: boolean }>;
|
||||
/** Bound to Space, per the transport convention on the rest of the page. */
|
||||
onTogglePlay?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrubber: one chip per recorded step, each carrying its own board
|
||||
* preview and its own real numbers.
|
||||
*
|
||||
* Why numbers on a chip at all — the chips are the only place the cost of the
|
||||
* run is visible without opening a panel, and "this took 6 calls and 4,200
|
||||
* tokens" is the sentence a buyer repeats to their CFO.
|
||||
*
|
||||
* Keyboard: this is a radiogroup, so arrows move AND select, which is the
|
||||
* standard pattern. Space is bound to play/pause rather than to select — a
|
||||
* deliberate break from the radio pattern, because the timeline sits under a
|
||||
* transport bar where Space means play everywhere else, and being internally
|
||||
* consistent beats being technically canonical. It is advertised on the group
|
||||
* with `aria-keyshortcuts`.
|
||||
*/
|
||||
export function StepTimeline<T>({
|
||||
steps,
|
||||
current,
|
||||
onSelect,
|
||||
Surface,
|
||||
onTogglePlay,
|
||||
className,
|
||||
}: StepTimelineProps<T>) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const chipRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const chip = chipRefs.current[current];
|
||||
const scroller = scrollerRef.current;
|
||||
if (!chip || !scroller) return;
|
||||
// `scrollIntoView` on the element would also scroll the PAGE to the
|
||||
// timeline on every step, which is intolerable while the run plays. Scroll
|
||||
// the strip only.
|
||||
const chipBox = chip.getBoundingClientRect();
|
||||
const viewBox = scroller.getBoundingClientRect();
|
||||
const delta = chipBox.left - viewBox.left - (viewBox.width - chipBox.width) / 2;
|
||||
scroller.scrollBy({ left: delta, behavior: reducedMotion ? 'auto' : 'smooth' });
|
||||
}, [current, reducedMotion]);
|
||||
|
||||
const move = (next: number, event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
const clamped = Math.min(Math.max(next, 0), steps.length - 1);
|
||||
onSelect(clamped);
|
||||
chipRefs.current[clamped]?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
move(current + 1, event);
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
move(current - 1, event);
|
||||
break;
|
||||
case 'Home':
|
||||
move(0, event);
|
||||
break;
|
||||
case 'End':
|
||||
move(steps.length - 1, event);
|
||||
break;
|
||||
case ' ':
|
||||
case 'Spacebar':
|
||||
if (onTogglePlay) {
|
||||
event.preventDefault();
|
||||
onTogglePlay();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
role="radiogroup"
|
||||
aria-label="Steps in the recorded run"
|
||||
aria-keyshortcuts="ArrowLeft ArrowRight Home End Space"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn('flex snap-x gap-2 overflow-x-auto pb-2', className)}
|
||||
>
|
||||
{steps.map((step, index) => {
|
||||
const selected = index === current;
|
||||
const call = step.call;
|
||||
return (
|
||||
// A div rather than a button: the chip embeds the demo's own Surface,
|
||||
// and a Surface is a grid of divs. Nesting flow content inside a
|
||||
// <button> is invalid HTML and browsers reflow it unpredictably. The
|
||||
// radio role plus the group's key handling gives the same semantics.
|
||||
<div
|
||||
key={step.index}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
// Roving tabindex: one stop for the whole strip, arrows inside it.
|
||||
tabIndex={selected ? 0 : -1}
|
||||
ref={(node) => {
|
||||
chipRefs.current[index] = node;
|
||||
}}
|
||||
onClick={() => onSelect(index)}
|
||||
className={cn(
|
||||
'tap w-[9.5rem] shrink-0 snap-center rounded-lg border p-2 text-left transition-colors duration-2 ease-enter',
|
||||
selected
|
||||
? 'border-brand bg-accent-subtle/60'
|
||||
: 'border-border bg-surface hover:bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline justify-between gap-1">
|
||||
<span className="nums text-xs font-semibold">Step {index + 1}</span>
|
||||
{call?.finishReason ? (
|
||||
<span
|
||||
className="max-w-[4.5rem] truncate text-[10px] uppercase tracking-wide text-muted"
|
||||
title={call.finishReason}
|
||||
>
|
||||
{humaniseToken(call.finishReason)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
{Surface ? (
|
||||
<span className="mt-1.5 block overflow-hidden rounded-md bg-surface-2 p-1.5">
|
||||
<Surface state={step.state} compact />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{step.caption ? (
|
||||
<span className="mt-1.5 block truncate text-xs text-muted" title={step.caption}>
|
||||
{step.caption}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<span className="nums mt-1 flex flex-wrap gap-x-2 text-[10px] text-muted">
|
||||
<span title="Completion tokens">
|
||||
{call?.completionTokens === null || call?.completionTokens === undefined
|
||||
? DASH
|
||||
: `${formatInt(call.completionTokens)} tok`}
|
||||
</span>
|
||||
<span title="Recorded latency">{formatMs(call?.durationMs ?? null)}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CheckCircle2, ChevronDown, HelpCircle, XCircle } from 'lucide-react';
|
||||
import { verifyEpisode } from '@/lib/demo-kit';
|
||||
import type { DemoEpisode, DemoModule, RewardValues } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DASH, formatDelta, formatNumber, formatOrDash } from './format';
|
||||
import { rewardDelta, scoreReward } from './reward-math';
|
||||
|
||||
/**
|
||||
* Float tolerance for "the browser agrees with Python".
|
||||
*
|
||||
* 1e-9 would be theatre: the two runtimes accumulate a sum in a different
|
||||
* order, and IEEE-754 does not promise associativity. 1e-6 is well below any
|
||||
* difference a reward change would produce and well above the noise.
|
||||
*/
|
||||
const EPSILON = 1e-6;
|
||||
|
||||
type Verdict =
|
||||
| { kind: 'match'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[] }
|
||||
| { kind: 'mismatch'; recomputed: RewardValues; recordedTotal: number | null; recomputedTotal: number | null; maxDelta: number; rows: VerifyRow[]; guilty: string[] }
|
||||
| { kind: 'unverifiable'; reason: string };
|
||||
|
||||
interface VerifyRow {
|
||||
key: string;
|
||||
label: string;
|
||||
recorded: number | null;
|
||||
recomputed: number | null;
|
||||
delta: number;
|
||||
}
|
||||
|
||||
export interface VerifyBadgeProps<T> {
|
||||
demo: DemoModule<T>;
|
||||
episode: DemoEpisode;
|
||||
className?: string;
|
||||
/** Open the receipt on load. The sceptic we are writing for opens it anyway. */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt.
|
||||
*
|
||||
* This object exists for one person: the engineer sitting next to the CEO who
|
||||
* assumes the numbers on a vendor's demo page are hard-coded. It re-runs every
|
||||
* recorded move through the TypeScript engine in the visitor's own browser,
|
||||
* rescores it, and prints the comparison — including the delta, to seven
|
||||
* decimals, because a comparison without a delta is an assertion.
|
||||
*
|
||||
* It must therefore be allowed to FAIL loudly. A verifier that silently
|
||||
* degrades to "verified" when it cannot check anything is worse than no
|
||||
* verifier: it teaches the sceptic that the badge is decoration.
|
||||
*/
|
||||
export function VerifyBadge<T>({ demo, episode, className, defaultOpen = false }: VerifyBadgeProps<T>) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
const verdict = useMemo<Verdict>(() => {
|
||||
if (!demo.verify) {
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason:
|
||||
'This demo does not ship a browser-side engine, so the recorded scores cannot be re-derived here. The Python that produced them is in the repository and the eval command is below.',
|
||||
};
|
||||
}
|
||||
|
||||
let recomputed: RewardValues | null;
|
||||
try {
|
||||
recomputed = verifyEpisode(demo, episode);
|
||||
} catch (error) {
|
||||
// A verifier that throws is a bug on our side, not a failed run. Say so
|
||||
// rather than showing a red mismatch that blames the recorded numbers.
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason: `The in-browser verifier threw while re-running this episode: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (recomputed === null) {
|
||||
return {
|
||||
kind: 'unverifiable',
|
||||
reason: episode.truncated
|
||||
? 'This run was truncated before the environment reached a terminal state, so there is nothing complete to re-score. The recorded partial numbers are shown as they were captured.'
|
||||
: 'The environment could not re-derive this episode from the recorded transcript. Nothing here is being asserted as verified.',
|
||||
};
|
||||
}
|
||||
|
||||
const deltas = rewardDelta(episode.rewards, recomputed);
|
||||
const labels = new Map(demo.reward.components.map((c) => [c.key, c.label]));
|
||||
const rows: VerifyRow[] = deltas
|
||||
.map(({ key, delta }) => ({
|
||||
key,
|
||||
label: labels.get(key) ?? key,
|
||||
recorded: episode.rewards[key] ?? null,
|
||||
recomputed: recomputed[key] ?? null,
|
||||
delta,
|
||||
}))
|
||||
.sort((a, b) => b.delta - a.delta || a.key.localeCompare(b.key));
|
||||
|
||||
const recordedTotal = scoreReward(demo.reward, episode.rewards).total;
|
||||
const recomputedTotal = scoreReward(demo.reward, recomputed).total;
|
||||
const totalDelta =
|
||||
recordedTotal === null || recomputedTotal === null
|
||||
? recordedTotal === recomputedTotal
|
||||
? 0
|
||||
: Number.POSITIVE_INFINITY
|
||||
: Math.abs(recordedTotal - recomputedTotal);
|
||||
const maxDelta = rows.reduce((worst, row) => Math.max(worst, row.delta), totalDelta);
|
||||
const guilty = rows.filter((row) => row.delta > EPSILON).map((row) => row.label);
|
||||
|
||||
if (guilty.length === 0 && maxDelta <= EPSILON) {
|
||||
return { kind: 'match', recomputed, recordedTotal, recomputedTotal, maxDelta, rows };
|
||||
}
|
||||
return { kind: 'mismatch', recomputed, recordedTotal, recomputedTotal, maxDelta, rows, guilty };
|
||||
}, [demo, episode]);
|
||||
|
||||
if (verdict.kind === 'unverifiable') {
|
||||
return (
|
||||
<section
|
||||
aria-label="Verification"
|
||||
className={cn('card border-border bg-surface-2 p-3', className)}
|
||||
>
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<HelpCircle className="mt-0.5 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span>
|
||||
<span className="font-semibold">Unverifiable in your browser.</span>{' '}
|
||||
<span className="text-muted">{verdict.reason}</span>
|
||||
</span>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const matched = verdict.kind === 'match';
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Verification"
|
||||
className={cn(
|
||||
'card overflow-hidden',
|
||||
matched ? 'border-positive/40' : 'border-danger',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-3', matched ? 'bg-positive/10' : 'bg-danger/10')}>
|
||||
<p className="flex items-start gap-2 text-sm leading-relaxed">
|
||||
{matched ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-positive" aria-hidden="true" />
|
||||
) : (
|
||||
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-danger" aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{matched ? (
|
||||
<>
|
||||
<span className="font-semibold text-positive">Verified in your browser</span>{' '}
|
||||
<span className="text-fg">
|
||||
— re-ran every move through the TypeScript engine and rescored.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-danger">
|
||||
Mismatch on {verdict.guilty.join(', ')}
|
||||
</span>{' '}
|
||||
<span className="text-fg">
|
||||
— the browser re-run disagrees with the recorded score. Trust the source, not
|
||||
this page.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="nums mt-1.5 pl-6 font-mono text-xs text-muted">
|
||||
Recomputed {formatOrDash(verdict.recomputedTotal)} · recorded{' '}
|
||||
{formatOrDash(verdict.recordedTotal)} · Δ {formatDelta(verdict.maxDelta)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((was) => !was)}
|
||||
aria-expanded={open}
|
||||
className="tap flex w-full items-center gap-1.5 border-t border-border px-3 text-left text-xs font-medium text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn('h-4 w-4 transition-transform duration-2 ease-enter', open && 'rotate-180')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{open ? 'Hide the component-by-component receipt' : 'Show the component-by-component receipt'}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="overflow-x-auto border-t border-border">
|
||||
<table className="nums w-full border-collapse font-mono text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-muted">
|
||||
<th scope="col" className="px-3 py-1.5 font-medium">
|
||||
Component
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recorded
|
||||
</th>
|
||||
<th scope="col" className="px-2 py-1.5 text-right font-medium">
|
||||
Recomputed
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-1.5 text-right font-medium">
|
||||
Δ
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{verdict.rows.map((row) => {
|
||||
const bad = row.delta > EPSILON;
|
||||
return (
|
||||
<tr key={row.key} className={cn(bad && 'bg-danger/10')}>
|
||||
<th scope="row" className="px-3 py-1.5 text-left font-normal">
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recorded === null ? DASH : formatNumber(row.recorded, 6)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{row.recomputed === null ? DASH : formatNumber(row.recomputed, 6)}
|
||||
</td>
|
||||
<td
|
||||
className={cn('px-3 py-1.5 text-right', bad ? 'text-danger' : 'text-muted')}
|
||||
>
|
||||
{Number.isFinite(row.delta) ? formatDelta(row.delta) : 'not comparable'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="px-3 py-2 text-[11px] leading-relaxed text-muted">
|
||||
Tolerance {EPSILON.toExponential()}. The browser engine and the Python environment
|
||||
can sum the same terms in a different order, and IEEE-754 addition is not
|
||||
associative, so the comparison is made within a tolerance rather than demanding
|
||||
bit-identical floats.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { RewardComponent, RewardSpec, RewardValues } from '@/lib/demo-kit/types';
|
||||
|
||||
export interface ScoredRow {
|
||||
component: RewardComponent;
|
||||
/** The environment's raw per-component score. `null` means NOT SCORED. */
|
||||
score: number | null;
|
||||
/** The weight in force — shipped, or the visitor's edit. */
|
||||
weight: number;
|
||||
/** `score x weight`, or null when the component was not scored. */
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface ScoredReward {
|
||||
rows: ScoredRow[];
|
||||
/**
|
||||
* The weighted sum over components that were actually scored. `null` when
|
||||
* none of them were: a total of 0 would claim the run scored nothing, which
|
||||
* is a different and much stronger statement than "we could not score it".
|
||||
*/
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
/** The weights the environment ships, as a plain map the editor can copy. */
|
||||
export function shippedWeights(spec: RewardSpec): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const component of spec.components) out[component.key] = component.weight;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function scoreReward(
|
||||
spec: RewardSpec,
|
||||
values: RewardValues,
|
||||
weights?: Record<string, number>,
|
||||
): ScoredReward {
|
||||
let total = 0;
|
||||
let anyScored = false;
|
||||
const rows = spec.components.map((component) => {
|
||||
const raw = values[component.key];
|
||||
const score = raw === undefined ? null : raw;
|
||||
const weight = weights?.[component.key] ?? component.weight;
|
||||
const value = score === null ? null : score * weight;
|
||||
if (value !== null) {
|
||||
total += value;
|
||||
anyScored = true;
|
||||
}
|
||||
return { component, score, weight, value };
|
||||
});
|
||||
return { rows, total: anyScored ? total : null };
|
||||
}
|
||||
|
||||
/** True when two reward maps agree to within float noise on every key. */
|
||||
export function rewardDelta(a: RewardValues, b: RewardValues): { key: string; delta: number }[] {
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
const out: { key: string; delta: number }[] = [];
|
||||
for (const key of keys) {
|
||||
const left = a[key];
|
||||
const right = b[key];
|
||||
// Both absent or both explicitly not-scored is agreement, not a zero
|
||||
// delta on a number nobody produced.
|
||||
if ((left === null || left === undefined) && (right === null || right === undefined)) {
|
||||
out.push({ key, delta: 0 });
|
||||
continue;
|
||||
}
|
||||
if (left === null || left === undefined || right === null || right === undefined) {
|
||||
// One side scored and the other did not. That is a real disagreement and
|
||||
// it has no numeric magnitude, so flag it as infinite rather than as 0.
|
||||
out.push({ key, delta: Number.POSITIVE_INFINITY });
|
||||
continue;
|
||||
}
|
||||
out.push({ key, delta: Math.abs(left - right) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,90 +1,38 @@
|
||||
import {
|
||||
Boxes,
|
||||
BookOpen,
|
||||
Code2,
|
||||
Database,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plane,
|
||||
RadioTower,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Target,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { Vertical } from '@/lib/demo-kit/types';
|
||||
import { iconFor } from '@/content/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `DemoMeta.icon` is a lucide NAME, not a component — that is deliberate, and
|
||||
* types.ts says why: importing the component in the meta would drag lucide into
|
||||
* the entry chunk for every demo at once.
|
||||
*
|
||||
* Resolving the name therefore has to happen against a static map. A dynamic
|
||||
* `import * as lucide` here would work and would also pull all 1,500 icons into
|
||||
* this chunk, which is the exact cost the contract was avoiding. So: named
|
||||
* imports, tree-shaken to what is listed, and an unknown name falls back to a
|
||||
* neutral glyph rather than rendering nothing. If you add a demo whose icon
|
||||
* lands on the fallback, add the name here — that is the one line the header
|
||||
* ever needs.
|
||||
* `DemoMeta.icon` is a lucide NAME, not a component — types.ts explains why:
|
||||
* a component in the meta would drag lucide into the entry chunk for every
|
||||
* demo at once. `@/content/icons` is the one place that turns a name back into
|
||||
* a component, by name, so it stays tree-shaken. Resolve through it rather than
|
||||
* growing a second map here, or the same icon name renders as two different
|
||||
* glyphs depending on which surface you are looking at.
|
||||
*/
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
BookOpen,
|
||||
Boxes,
|
||||
Code2,
|
||||
Database,
|
||||
FlaskConical,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
Network,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plane,
|
||||
RadioTower,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Target,
|
||||
Truck,
|
||||
Wallet,
|
||||
Zap,
|
||||
};
|
||||
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
||||
const Icon = iconFor(name);
|
||||
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
||||
}
|
||||
|
||||
/** Used when a vertical has no icon of its own to offer. */
|
||||
export const VERTICAL_ICONS: Record<string, string> = {
|
||||
/**
|
||||
* The registry groups demos by vertical and gives each group a label, but no
|
||||
* icon — an icon belongs to a demo, not to a taxonomy key. The header wants one
|
||||
* anyway, so this is the mapping, and it is exhaustive over the union: adding a
|
||||
* thirteenth vertical to the contract fails the build here rather than shipping
|
||||
* a blank square in the menu.
|
||||
*/
|
||||
export const VERTICAL_ICONS: Readonly<Record<Vertical, string>> = {
|
||||
reference: 'Grid3x3',
|
||||
support: 'Headset',
|
||||
healthcare: 'Stethoscope',
|
||||
insurance: 'ShieldCheck',
|
||||
'financial-crime': 'Landmark',
|
||||
'financial-crime': 'Siren',
|
||||
energy: 'Zap',
|
||||
logistics: 'Truck',
|
||||
code: 'Code2',
|
||||
retail: 'ShoppingCart',
|
||||
code: 'Braces',
|
||||
retail: 'Tag',
|
||||
telecom: 'RadioTower',
|
||||
data: 'Database',
|
||||
data: 'Table2',
|
||||
legal: 'Scale',
|
||||
};
|
||||
|
||||
export function DemoIcon({ name, className }: { name: string; className?: string }) {
|
||||
const Icon = ICONS[name] ?? Boxes;
|
||||
return <Icon aria-hidden="true" className={cn('size-4 shrink-0', className)} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ArrowUpRight, Github, Menu } from 'lucide-react';
|
||||
|
||||
import { listDemos, listVerticals, type VerticalGroup } from '@/lib/demo-kit/registry';
|
||||
import type { DemoMeta } from '@/lib/demo-kit/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import {
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
||||
import { DemoIcon, VERTICAL_ICONS } from '@/components/site/DemoIcon';
|
||||
import { PIG_URL, REPO_URL, VERIFIERS_WORDLE_URL } from '@/components/site/links';
|
||||
import { ThemeToggle } from '@/components/site/ThemeToggle';
|
||||
import { Wordmark } from '@/components/site/Wordmark';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/*
|
||||
* This header is GENERATED. Nothing in it names a demo.
|
||||
*
|
||||
* Adding `src/demos/<slug>/` puts that demo in the Demos panel, in its
|
||||
* vertical, and (if it is the first live one) behind the CTA, with zero edits
|
||||
* to this file. The only hand-written lists here are the five concepts under
|
||||
* "How it works", which are properties of the idea rather than of the lineup.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The real upstream taskset, quoted rather than paraphrased.
|
||||
*
|
||||
* A mega-menu that contains source code instead of more links is the cheapest
|
||||
* signal on the whole site that this is not a brochure — it is the first thing
|
||||
* a technical buyer sees, and it is true before they have clicked anything.
|
||||
*/
|
||||
const TASKSET_SOURCE = `class WordleConfig(TextArenaConfig):
|
||||
game: Literal["Wordle-v0"] = "Wordle-v0"
|
||||
|
||||
class WordleTaskset(TextArenaTaskset, vf.Taskset[TextArenaTask, WordleConfig]):
|
||||
pass`;
|
||||
|
||||
const CONCEPTS: readonly { term: string; gloss: string }[] = [
|
||||
{
|
||||
term: 'Environment',
|
||||
gloss: 'The task, the legal moves and the grader, packaged so anyone can install and run it.',
|
||||
},
|
||||
{
|
||||
term: 'Rollout',
|
||||
gloss: 'One episode. The model acts, the environment answers, and every turn is recorded.',
|
||||
},
|
||||
{
|
||||
term: 'Reward',
|
||||
gloss: 'The number the run is scored on. You write it, so you decide what "good" means.',
|
||||
},
|
||||
{
|
||||
term: 'Harness',
|
||||
gloss: 'The runner that plays a taskset against a model and keeps the receipts.',
|
||||
},
|
||||
{
|
||||
term: 'Held-out grading',
|
||||
gloss: 'Scored on problems the model has never seen, which is the only way the score means anything.',
|
||||
},
|
||||
];
|
||||
|
||||
/** A vertical group as the header renders it: the registry's group plus a glyph. */
|
||||
interface HeaderVertical extends VerticalGroup {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
function useLineup() {
|
||||
return React.useMemo(() => {
|
||||
// `listDemos` already returns a fresh array sorted by order then slug, and
|
||||
// `listVerticals` already drops the empty verticals. Neither needs redoing
|
||||
// here — if this file starts re-sorting the lineup, the header and the
|
||||
// gallery will eventually disagree about what "first" means.
|
||||
const demos = listDemos();
|
||||
const live = demos.filter((demo) => demo.status === 'live');
|
||||
const spec = demos.filter((demo) => demo.status === 'spec');
|
||||
|
||||
const verticals: HeaderVertical[] = listVerticals().map((group) => ({
|
||||
...group,
|
||||
icon: VERTICAL_ICONS[group.vertical],
|
||||
}));
|
||||
|
||||
// The CTA follows the lineup rather than naming a slug. Today the first
|
||||
// live demo IS the word game, so this resolves to the wordle route; when a
|
||||
// second one ships ahead of it, the button moves with it and this file does
|
||||
// not change.
|
||||
const primary = live[0] ?? demos[0];
|
||||
const ctaHref = primary ? `/demos/${primary.slug}` : '/demos';
|
||||
|
||||
return { demos, live, spec, verticals, ctaHref };
|
||||
}, []);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ panels */
|
||||
|
||||
function DemoRow({ demo, muted = false }: { demo: DemoMeta; muted?: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/demos/${demo.slug}`}
|
||||
className={cn(
|
||||
'group/row flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2',
|
||||
muted && 'opacity-70 hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<DemoIcon name={demo.icon} className="mt-0.5 size-4 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-fg">
|
||||
{demo.title}
|
||||
{demo.status === 'spec' ? (
|
||||
<Badge variant="outline" className="font-normal">
|
||||
Spec
|
||||
</Badge>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-muted">
|
||||
{/*
|
||||
A spec demo shows the BUYER, not the technology: "Head of Claims"
|
||||
says who is meant to care, where a taskset name says nothing to the
|
||||
person reading this in a boardroom.
|
||||
*/}
|
||||
{demo.status === 'spec' ? `For the ${demo.persona}` : demo.tagline}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelHeading({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="px-2.5 pb-1 text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function DemosPanel({ live, spec }: { live: DemoMeta[]; spec: DemoMeta[] }) {
|
||||
return (
|
||||
<div className="w-[min(92vw,720px)]">
|
||||
<div className="grid grid-cols-2 gap-4 p-4">
|
||||
<section aria-label="Live now" className="flex flex-col gap-0.5">
|
||||
<PanelHeading>Live now</PanelHeading>
|
||||
{live.length > 0 ? (
|
||||
live.map((demo) => (
|
||||
<NavigationMenuLink asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} />
|
||||
</NavigationMenuLink>
|
||||
))
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">No interactive demos published yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
aria-label="Shipping next"
|
||||
className="flex flex-col gap-0.5 border-l border-border pl-4"
|
||||
>
|
||||
<PanelHeading>Shipping next</PanelHeading>
|
||||
{spec.length > 0 ? (
|
||||
spec.map((demo) => (
|
||||
<NavigationMenuLink asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} muted />
|
||||
</NavigationMenuLink>
|
||||
))
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">Nothing queued.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border bg-surface-2 px-5 py-3">
|
||||
<p className="text-xs text-muted">
|
||||
Every demo replays a real rollout from a real environment.
|
||||
</p>
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
to="/demos"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
Browse all demos
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalsPanel({ verticals }: { verticals: HeaderVertical[] }) {
|
||||
return (
|
||||
<div className="w-[min(92vw,720px)] p-4">
|
||||
{verticals.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
{verticals.map((vertical) => {
|
||||
// The lead demo is the lowest-order one in the vertical; its reward
|
||||
// line is what the vertical is actually promising.
|
||||
const lead = vertical.demos[0]!;
|
||||
return (
|
||||
<NavigationMenuLink asChild key={vertical.vertical}>
|
||||
<Link
|
||||
to={`/demos/${lead.slug}`}
|
||||
className="flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 size-4 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.label}</span>
|
||||
<span className="text-xs leading-relaxed text-muted">{lead.rewardLine}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">No verticals in the lineup yet.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksetSource({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg border border-border bg-bg p-3', className)}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
|
||||
The actual taskset
|
||||
</p>
|
||||
<pre className="overflow-x-auto text-[11px] leading-relaxed text-fg">
|
||||
<code className="font-mono">{TASKSET_SOURCE}</code>
|
||||
</pre>
|
||||
<a
|
||||
href={VERIFIERS_WORDLE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
verifiers/environments/wordle
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HowItWorksPanel({ ctaHref }: { ctaHref: string }) {
|
||||
return (
|
||||
<div className="w-[min(92vw,760px)] p-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<dl className="flex flex-col gap-3">
|
||||
{CONCEPTS.map((concept) => (
|
||||
<div key={concept.term} className="flex flex-col gap-0.5">
|
||||
<dt className="text-sm font-medium text-fg">{concept.term}</dt>
|
||||
<dd className="text-xs leading-relaxed text-muted">{concept.gloss}</dd>
|
||||
</div>
|
||||
))}
|
||||
<NavigationMenuLink asChild>
|
||||
<Link
|
||||
to={ctaHref}
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-accent-fg underline-offset-4 hover:underline"
|
||||
>
|
||||
See all five on one recorded run
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
</dl>
|
||||
<TasksetSource />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ mobile */
|
||||
|
||||
function MobileNav({
|
||||
live,
|
||||
spec,
|
||||
verticals,
|
||||
ctaHref,
|
||||
}: {
|
||||
live: DemoMeta[];
|
||||
spec: DemoMeta[];
|
||||
verticals: HeaderVertical[];
|
||||
ctaHref: string;
|
||||
}) {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon-touch" aria-label="Open menu">
|
||||
<Menu aria-hidden="true" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{/*
|
||||
The sheet is a flex column: header, a scrolling body, then a pinned
|
||||
footer. Scrolling the BODY rather than the content root is what keeps a
|
||||
long lineup reachable on a short phone, and `overscroll-contain` on it
|
||||
stops the flick chaining into the page underneath.
|
||||
*/}
|
||||
<SheetContent side="right" className="w-[min(92vw,24rem)]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Demos, verticals, and how these environments work.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody>
|
||||
<Accordion type="multiple" defaultValue={['demos']}>
|
||||
<AccordionItem value="demos">
|
||||
<AccordionTrigger>Demos</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-0.5">
|
||||
<PanelHeading>Live now</PanelHeading>
|
||||
{live.map((demo) => (
|
||||
<SheetClose asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} />
|
||||
</SheetClose>
|
||||
))}
|
||||
<PanelHeading>Shipping next</PanelHeading>
|
||||
{spec.map((demo) => (
|
||||
<SheetClose asChild key={demo.slug}>
|
||||
<DemoRow demo={demo} muted />
|
||||
</SheetClose>
|
||||
))}
|
||||
<SheetClose asChild>
|
||||
<Link
|
||||
to="/demos"
|
||||
className="tap inline-flex items-center gap-1 p-2.5 text-xs font-medium text-accent-fg"
|
||||
>
|
||||
Browse all demos
|
||||
<ArrowRight aria-hidden="true" className="size-3.5" />
|
||||
</Link>
|
||||
</SheetClose>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="verticals">
|
||||
<AccordionTrigger>Verticals</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-0.5">
|
||||
{verticals.map((vertical) => {
|
||||
const lead = vertical.demos[0]!;
|
||||
return (
|
||||
<SheetClose asChild key={vertical.vertical}>
|
||||
<Link
|
||||
to={`/demos/${lead.slug}`}
|
||||
className="flex gap-3 rounded-lg p-2.5 hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.label}</span>
|
||||
<span className="text-xs leading-relaxed text-muted">
|
||||
{lead.rewardLine}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
);
|
||||
})}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="how">
|
||||
<AccordionTrigger>How it works</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-3">
|
||||
<dl className="flex flex-col gap-3">
|
||||
{CONCEPTS.map((concept) => (
|
||||
<div key={concept.term} className="flex flex-col gap-0.5">
|
||||
<dt className="text-sm font-medium text-fg">{concept.term}</dt>
|
||||
<dd className="text-xs leading-relaxed text-muted">{concept.gloss}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<TasksetSource />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<a
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="tap mt-2 flex items-center justify-between border-b border-border py-3 text-sm font-medium text-fg"
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
<ArrowUpRight aria-hidden="true" className="size-4 text-muted" />
|
||||
</a>
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="tap flex items-center justify-between border-b border-border py-3 text-sm font-medium text-fg"
|
||||
>
|
||||
Source on GitHub
|
||||
<Github aria-hidden="true" className="size-4 text-muted" />
|
||||
</a>
|
||||
|
||||
<div className="flex items-center gap-1 pt-3">
|
||||
<ThemeToggle />
|
||||
<ContrastToggle />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button asChild size="lg" className="w-full">
|
||||
<Link to={ctaHref}>Play the demo</Link>
|
||||
</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ header */
|
||||
|
||||
export function SiteHeader() {
|
||||
const { live, spec, verticals, ctaHref } = useLineup();
|
||||
|
||||
return (
|
||||
<header
|
||||
// The height token already folds in --safe-top, so the padding and the
|
||||
// height come from the same source and a notch cannot push the row off
|
||||
// the bottom edge of the bar.
|
||||
className="sticky top-0 z-50 h-[var(--app-header-h)] border-b border-border bg-surface/80 pt-[var(--safe-top)] backdrop-blur-md supports-[backdrop-filter]:bg-surface/70"
|
||||
>
|
||||
<div className="mx-auto flex h-full max-w-canvas items-center gap-2 px-4 pl-[max(1rem,var(--safe-left))] pr-[max(1rem,var(--safe-right))]">
|
||||
<Link
|
||||
to="/"
|
||||
className="tap flex shrink-0 items-center rounded-md pr-2 text-base"
|
||||
aria-label="PIG demo, home"
|
||||
>
|
||||
<Wordmark />
|
||||
</Link>
|
||||
|
||||
{/*
|
||||
`h-full` is not cosmetic. The viewport hangs off the Root's
|
||||
`top-full`, so a Root only as tall as its 36px triggers drops the
|
||||
panel INSIDE the header, over its own bottom border.
|
||||
*/}
|
||||
<NavigationMenu className="hidden h-full lg:flex" delayDuration={120}>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Demos</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<DemosPanel live={live} spec={spec} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>Verticals</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<VerticalsPanel verticals={verticals} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuTrigger>How it works</NavigationMenuTrigger>
|
||||
<NavigationMenuContent>
|
||||
<HowItWorksPanel ctaHref={ctaHref} />
|
||||
</NavigationMenuContent>
|
||||
</NavigationMenuItem>
|
||||
|
||||
<NavigationMenuItem>
|
||||
<NavigationMenuLink
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={cn(navigationMenuTriggerStyle(), 'text-muted hover:text-fg')}
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
<ArrowUpRight aria-hidden="true" className="size-3.5" />
|
||||
</NavigationMenuLink>
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
<ThemeToggle />
|
||||
<ContrastToggle />
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label="Source on GitHub"
|
||||
>
|
||||
<Github aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button asChild size="touch" className="hidden lg:inline-flex">
|
||||
<Link to={ctaHref}>Play the demo</Link>
|
||||
</Button>
|
||||
|
||||
<div className="lg:hidden">
|
||||
<MobileNav live={live} spec={spec} verticals={verticals} ctaHref={ctaHref} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user