wordle-five: the engine, the reward, the solver and the probe that checks them
The Python is the source of truth; src/demos/wordle/engine.ts will be a port of it, and CI gates the two against a SHA-256 over all 21.2M (guess, answer) pattern pairs rather than a hand-picked vector file — a vector file only ever catches the cases somebody thought of. The reward is three weighted components, and the third one is the reason this demo is worth building. `solved` and `economy` pull toward winning. `consistency` pulls against them, because a player maximising information deliberately guesses words that cannot win — a word that splits the remaining candidates evenly teaches more than a word that might happen to be right. That is good play, and it costs consistency. The probe ladder proves the tension is real rather than asserted: inaction 0.0000 crude 0.0111 plausible 0.1224 candidate_only 0.8925 exhaustive 0.9031 oracle 0.9458 The two good policies are 0.05 apart and neither dominates — the entropy oracle takes 1.00 economy and 0.73 consistency, the candidate-only player takes 0.75 and 1.00. Which one wins is a decision about what you want, which is the whole argument the site exists to make. probe.py fails CI if either starts dominating. Two traps found by building it. `consistency` is scored over turns SPENT, not guesses accepted: counting only legal guesses hands a free 1.0 to a policy that plays one word and then jams the parser five times — one guess, no contradictions, perfect score. And `economy`'s denominator is the depth the SHIPPED solver reaches, not a depth-optimal search: entropy-greedy is not depth-optimal, so grading it against an exact optimum would make the oracle rung fail its own assertion on some seeds. The word lists are built from Wordnik (MIT) intersected with SCOWL, never from the original game's 2,315 answers. 4,603 answers makes this materially harder than the original, so the published SALET/3.4212 results are cited as belonging to that list and our own reference player's TARES/3.72 is measured here. verifiers is an optional extra. The engine, reward, solver and probe all run — and gate — without an RL stack resolvable. 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,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { StoryBeat } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface BeatSectionProps {
|
||||
beat: StoryBeat;
|
||||
/** 1-based. The narrative is numbered so a reader can be told "see beat 3". */
|
||||
number: number;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One beat of the exec narrative: a number, a title, a claim, and the surface
|
||||
* that makes the claim true.
|
||||
*
|
||||
* The claim is typeset as an assertion — large, high contrast, above the
|
||||
* evidence — because the failure mode of a demo site is a visitor watching a
|
||||
* pretty animation and never learning what it was supposed to prove.
|
||||
*/
|
||||
export function BeatSection({ beat, number, children, className }: BeatSectionProps) {
|
||||
const headingId = `beat-${beat.id}-title`;
|
||||
return (
|
||||
<section
|
||||
id={beat.id}
|
||||
aria-labelledby={headingId}
|
||||
data-surface={beat.surface}
|
||||
className={cn('scroll-mt-[var(--app-header-h)] py-10 lg:py-14', className)}
|
||||
>
|
||||
<header className="mb-6 lg:mb-8">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="nums select-none text-sm font-semibold tabular-nums text-accent-fg"
|
||||
>
|
||||
{String(number).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 id={headingId} className="text-xl font-semibold tracking-tight lg:text-2xl">
|
||||
{beat.title}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-3 max-w-2xl text-pretty text-lg leading-snug text-fg lg:text-xl">
|
||||
{beat.claim}
|
||||
</p>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, ExternalLink, RotateCcw } from 'lucide-react';
|
||||
|
||||
const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
|
||||
export interface DemoErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** Named in the fallback copy, so the visitor knows what broke. */
|
||||
demoTitle?: string;
|
||||
/** Link to the exact source, if the caller knows it. Falls back to the repo. */
|
||||
sourceHref?: string;
|
||||
/** Called when the visitor asks to try again; use it to reset shell state. */
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
interface DemoErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One broken demo must never take the site down.
|
||||
*
|
||||
* This is a class because there is still no hook for `componentDidCatch`; that
|
||||
* is the entire reason for the exception to the function-component rule here.
|
||||
*
|
||||
* The fallback is deliberately calm and specific. A site whose pitch is
|
||||
* "here are the receipts" cannot answer a crash with a shrug: it names the
|
||||
* demo, links the source, and lets the visitor retry without a full reload.
|
||||
*/
|
||||
export class DemoErrorBoundary extends Component<DemoErrorBoundaryProps, DemoErrorBoundaryState> {
|
||||
override state: DemoErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): DemoErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
override componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// No telemetry endpoint on a static site, and none is wanted. The console
|
||||
// is the only place a maintainer can see this, so keep the component stack.
|
||||
console.error('[pig-demo] a demo surface threw', error, info.componentStack);
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ error: null });
|
||||
this.props.onReset?.();
|
||||
};
|
||||
|
||||
override render() {
|
||||
const { error } = this.state;
|
||||
if (!error) return this.props.children;
|
||||
|
||||
const { demoTitle, sourceHref } = this.props;
|
||||
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" />
|
||||
<h2 className="text-base font-semibold">
|
||||
{demoTitle ? `${demoTitle} failed to render` : 'This demo failed to render'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-relaxed text-muted">
|
||||
Something in this demo threw while drawing. The rest of the site is unaffected — every
|
||||
other demo is a separate module. The environment and the recorded runs behind this page
|
||||
are in the repository either way, and you can run them yourself.
|
||||
</p>
|
||||
<p className="mt-3 break-words rounded-lg bg-surface-2 px-3 py-2 font-mono text-xs text-muted">
|
||||
{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" />
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { ArrowRight, ListChecks, Scale, Target, TrendingUp } from 'lucide-react';
|
||||
import type { DemoModule } from '@/lib/demo-kit/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type Anatomy = DemoModule['anatomy'];
|
||||
|
||||
export interface EnvAnatomyProps {
|
||||
anatomy: Anatomy;
|
||||
/** `DemoMeta.rewardLine` — six words on what the reward pays for. */
|
||||
rewardLine?: string;
|
||||
/** Set on the gallery/overview page, where the four boxes are context rather
|
||||
* than the lesson, to drop the closing line and tighten the type. */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
key: keyof Anatomy;
|
||||
kicker: string;
|
||||
question: string;
|
||||
Icon: LucideIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four boxes, in the order an executive builds the mental model: what is
|
||||
* being asked, what the agent is allowed to do, who decides whether it was
|
||||
* good, and what number that decision moves.
|
||||
*
|
||||
* The kickers are deliberately generic. This object is the site's one piece of
|
||||
* transferable explanation: someone who learns the machine on a word game
|
||||
* should read the fraud demo as "same machine, different grader", and that only
|
||||
* works if the four labels never change between demos.
|
||||
*/
|
||||
const BOXES: Box[] = [
|
||||
{ key: 'task', kicker: 'The task', question: 'What is the agent asked to do?', Icon: Target },
|
||||
{
|
||||
key: 'actions',
|
||||
kicker: 'Legal actions',
|
||||
question: 'What is it allowed to do?',
|
||||
Icon: ListChecks,
|
||||
},
|
||||
{
|
||||
key: 'grader',
|
||||
kicker: 'The grader',
|
||||
question: 'Who decides whether it was good?',
|
||||
Icon: Scale,
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
kicker: 'The score that moves',
|
||||
question: 'What number does that produce?',
|
||||
Icon: TrendingUp,
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvAnatomy({ anatomy, rewardLine, compact = false, className }: EnvAnatomyProps) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<ol className="flex flex-col lg:flex-row lg:items-stretch">
|
||||
{BOXES.map((box, index) => (
|
||||
<li
|
||||
key={box.key}
|
||||
className="flex flex-col items-stretch lg:flex-1 lg:flex-row lg:items-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'card flex flex-1 flex-col gap-2 p-4',
|
||||
// The grader is the box every later demo differs on. It is the
|
||||
// one the eye should land on second, after the task.
|
||||
box.key === 'grader' && 'border-brand/40 bg-accent-subtle/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-accent-subtle text-accent-fg">
|
||||
<box.Icon className="h-4 w-4" strokeWidth={2} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
<span className="nums mr-1.5 text-muted">{index + 1}</span>
|
||||
{box.kicker}
|
||||
</p>
|
||||
{!compact ? (
|
||||
<p className="text-xs leading-tight text-muted">{box.question}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className={cn('text-pretty text-fg', compact ? 'text-xs' : 'text-sm')}>
|
||||
{anatomy[box.key]}
|
||||
</p>
|
||||
</div>
|
||||
{index < BOXES.length - 1 ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex items-center justify-center py-2 text-muted lg:px-2 lg:py-0"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4 rotate-90 lg:rotate-0" />
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{rewardLine ? (
|
||||
<p className="text-sm text-muted">
|
||||
<span className="font-medium text-fg">This reward: </span>
|
||||
{rewardLine}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!compact ? (
|
||||
<p className="max-w-3xl text-pretty text-sm leading-relaxed text-muted">
|
||||
Every demo on this site is that same machine. The task changes, the legal actions
|
||||
change, and the grader changes — but the grader is always code you can read, and the
|
||||
score is always a number you can watch move. That is what makes an environment
|
||||
different from an eval:{' '}
|
||||
<span className="font-medium text-fg">
|
||||
an environment is an eval you can take the gradient of.
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The sanctioned extension seam.
|
||||
*
|
||||
* A demo that needs something the shell does not have has two options: fork the
|
||||
* shell, or drop it into a slot. This is the second one. Slots are named and
|
||||
* finite, so `check-demos` can see what a demo added and reviewers can see it
|
||||
* in a diff — which is the whole reason this exists rather than letting demos
|
||||
* pass arbitrary children into arbitrary components.
|
||||
*
|
||||
* A slot with nothing in it renders NOTHING, not an empty box: the layout must
|
||||
* not shift depending on whether a demo opted in.
|
||||
*/
|
||||
export type SlotId =
|
||||
| 'hero-aside'
|
||||
| 'below-board'
|
||||
| 'beside-reward'
|
||||
| 'below-timeline'
|
||||
| 'before-limits'
|
||||
| 'after-receipts';
|
||||
|
||||
export interface SlotRegionProps {
|
||||
id: SlotId;
|
||||
/**
|
||||
* Announced to assistive tech when the slot has content. Omit for purely
|
||||
* decorative additions; a region with no label is not exposed as a landmark.
|
||||
*/
|
||||
label?: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlotRegion({ id, label, children, className }: SlotRegionProps) {
|
||||
// `children` can be `false`/`null` from a demo's own conditional. Treat those
|
||||
// as "no slot content" rather than rendering a labelled empty region.
|
||||
if (children === null || children === undefined || children === false) return null;
|
||||
|
||||
if (!label) {
|
||||
return (
|
||||
<div data-slot={id} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-slot={id} aria-label={label} className={cn('contents', className)}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type StatTone = 'default' | 'positive' | 'warning' | 'danger' | 'info' | 'brand';
|
||||
|
||||
export interface Stat {
|
||||
/** Short. Two or three words; it sits above the number. */
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
/** One clarifying line, shown under the number at a smaller size. */
|
||||
hint?: string;
|
||||
tone?: StatTone;
|
||||
/** Set when this number was derived under an edited reward, not recorded. */
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export interface StatStripProps {
|
||||
stats: Stat[];
|
||||
className?: string;
|
||||
/** Announce changes as they happen. Off by default — the shell owns the
|
||||
* page's single live region and two competing ones talk over each other. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
const TONE: Record<StatTone, string> = {
|
||||
default: 'text-fg',
|
||||
positive: 'text-positive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
brand: 'text-accent-fg',
|
||||
};
|
||||
|
||||
/**
|
||||
* A row of headline numbers. Scrolls horizontally on a phone rather than
|
||||
* wrapping into a ragged grid: four stats reflowing to 2x2 at 390px puts the
|
||||
* least important number in the most prominent corner.
|
||||
*/
|
||||
export function StatStrip({ stats, className, live = false }: StatStripProps) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
'flex snap-x snap-mandatory gap-3 overflow-x-auto pb-1',
|
||||
'sm:grid sm:snap-none sm:overflow-visible sm:pb-0',
|
||||
stats.length <= 2 ? 'sm:grid-cols-2' : 'sm:grid-cols-3 lg:grid-cols-4',
|
||||
className,
|
||||
)}
|
||||
{...(live ? { 'aria-live': 'polite' as const } : {})}
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="card min-w-[9.5rem] flex-1 shrink-0 snap-start px-4 py-3"
|
||||
>
|
||||
<dt className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted">
|
||||
<span className="truncate">{stat.label}</span>
|
||||
{stat.edited ? <EditedChip /> : null}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'nums mt-1 text-2xl font-semibold leading-tight',
|
||||
TONE[stat.tone ?? 'default'],
|
||||
)}
|
||||
>
|
||||
{stat.value}
|
||||
</dd>
|
||||
{stat.hint ? <dd className="mt-0.5 text-xs text-muted">{stat.hint}</dd> : null}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a number the visitor caused rather than one we recorded. It appears on
|
||||
* every derived value in the reward editor; without it, an edited ranking
|
||||
* screenshots identically to a measured one.
|
||||
*/
|
||||
export function EditedChip({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md bg-accent-subtle px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-accent-fg',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
edited
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Circle, Pause, Play, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDate, usePrefersReducedMotion } from './format';
|
||||
|
||||
/** `instant` is not "very fast": it is "do not animate, show me the end". */
|
||||
export type PlaybackSpeed = 1 | 2 | 4 | 'instant';
|
||||
|
||||
export const PLAYBACK_SPEEDS: readonly PlaybackSpeed[] = [1, 2, 4, 'instant'];
|
||||
|
||||
/** Wall-clock dwell on a step at 1x. Not the model's real latency — see below. */
|
||||
const BASE_STEP_MS = 1800;
|
||||
|
||||
export interface UseTracePlaybackOptions {
|
||||
stepCount: number;
|
||||
step: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/**
|
||||
* Dwell time for one step at 1x, in ms. Defaults to a fixed cadence rather
|
||||
* than the recorded `durationMs`, and that is deliberate: real calls run from
|
||||
* 300 ms to half a minute, so replaying at true latency produces a player
|
||||
* that appears frozen. The recorded latency is still shown, verbatim, in the
|
||||
* model-call panel — it is reported, just not used as a timeline.
|
||||
*/
|
||||
stepDurationMs?: (index: number) => number;
|
||||
initialSpeed?: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export interface TracePlayback {
|
||||
playing: boolean;
|
||||
speed: PlaybackSpeed;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setSpeed: (speed: PlaybackSpeed) => void;
|
||||
toggle: () => void;
|
||||
restart: () => void;
|
||||
atEnd: boolean;
|
||||
}
|
||||
|
||||
export function useTracePlayback({
|
||||
stepCount,
|
||||
step,
|
||||
onStepChange,
|
||||
stepDurationMs,
|
||||
initialSpeed = 1,
|
||||
}: UseTracePlaybackOptions): TracePlayback {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeedState] = useState<PlaybackSpeed>(initialSpeed);
|
||||
const atEnd = step >= stepCount - 1;
|
||||
|
||||
// The callback identity changes on every render of the shell; holding it in a
|
||||
// ref keeps it out of the timer effect's deps, or the timer restarts on every
|
||||
// render and the step never lands.
|
||||
const onStepChangeRef = useRef(onStepChange);
|
||||
onStepChangeRef.current = onStepChange;
|
||||
|
||||
const setSpeed = useCallback(
|
||||
(next: PlaybackSpeed) => {
|
||||
setSpeedState(next);
|
||||
if (next === 'instant') {
|
||||
setPlaying(false);
|
||||
onStepChangeRef.current(Math.max(stepCount - 1, 0));
|
||||
}
|
||||
},
|
||||
[stepCount],
|
||||
);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
onStepChangeRef.current(0);
|
||||
setPlaying(stepCount > 1);
|
||||
}, [stepCount]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (stepCount <= 1) return;
|
||||
setPlaying((was) => {
|
||||
if (was) return false;
|
||||
// Pressing play at the end replays from the top rather than doing
|
||||
// nothing, which is what every visitor expects and nobody says out loud.
|
||||
if (step >= stepCount - 1) onStepChangeRef.current(0);
|
||||
return true;
|
||||
});
|
||||
}, [step, stepCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || speed === 'instant' || stepCount <= 1) return;
|
||||
if (step >= stepCount - 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const base = stepDurationMs?.(step) ?? BASE_STEP_MS;
|
||||
const timer = window.setTimeout(() => {
|
||||
onStepChangeRef.current(step + 1);
|
||||
}, Math.max(base / speed, 120));
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [playing, speed, step, stepCount, stepDurationMs]);
|
||||
|
||||
return { playing, speed, setPlaying, setSpeed, toggle, restart, atEnd };
|
||||
}
|
||||
|
||||
export interface TracePlayerProps {
|
||||
playing: boolean;
|
||||
onPlayingChange: (playing: boolean) => void;
|
||||
speed: PlaybackSpeed;
|
||||
onSpeedChange: (speed: PlaybackSpeed) => void;
|
||||
onRestart: () => void;
|
||||
step: number;
|
||||
stepCount: number;
|
||||
onStepChange: (next: number) => void;
|
||||
/** Straight off the run: never a marketing name for the model. */
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
/** Present only on an `intervened` run; the contract requires it there. */
|
||||
intervention?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport controls for a recorded rollout.
|
||||
*
|
||||
* There is no spinner anywhere in this component and there never should be. A
|
||||
* spinner implies a request is in flight; nothing here is live, and an exec who
|
||||
* believes they are watching a model think in real time has been misled by the
|
||||
* UI rather than the copy. Hence the permanent badge — it is not a disclosure
|
||||
* we tuck into a footnote, it sits in the transport bar for the whole session.
|
||||
*/
|
||||
export function TracePlayer({
|
||||
playing,
|
||||
onPlayingChange,
|
||||
speed,
|
||||
onSpeedChange,
|
||||
onRestart,
|
||||
step,
|
||||
stepCount,
|
||||
onStepChange,
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
className,
|
||||
}: TracePlayerProps) {
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const canPlay = stepCount > 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'card flex flex-wrap items-center gap-x-3 gap-y-2 p-2 sm:gap-x-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.max(step - 1, 0))}
|
||||
disabled={step <= 0}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg bg-primary px-4 text-primary-foreground transition-colors duration-2 ease-enter hover:bg-primary/90 disabled:opacity-40"
|
||||
onClick={() => onPlayingChange(!playing)}
|
||||
disabled={!canPlay}
|
||||
aria-label={playing ? 'Pause the recorded run' : 'Play the recorded run'}
|
||||
aria-keyshortcuts="Space"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||||
onClick={() => onStepChange(Math.min(step + 1, Math.max(stepCount - 1, 0)))}
|
||||
disabled={step >= stepCount - 1}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tap grid place-items-center rounded-lg text-muted transition-colors duration-2 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
onClick={onRestart}
|
||||
aria-label="Restart from the first step"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="nums text-sm text-muted">
|
||||
Step <span className="font-semibold text-fg">{Math.min(step + 1, stepCount)}</span> of{' '}
|
||||
{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>
|
||||
|
||||
<RecordedBadge model={model} capturedAt={capturedAt} intervention={intervention} />
|
||||
|
||||
{reducedMotion ? (
|
||||
// Not an apology — a statement that the page is behaving as asked. The
|
||||
// steps still advance; only the tile flips and slides are gone.
|
||||
<p className="sr-only">
|
||||
Reduced motion is on. Steps still advance and every change is announced.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface RecordedBadgeProps {
|
||||
model: string;
|
||||
capturedAt: string;
|
||||
intervention?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecordedBadge({
|
||||
model,
|
||||
capturedAt,
|
||||
intervention,
|
||||
className,
|
||||
}: RecordedBadgeProps) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
'ml-auto flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-surface-2 px-2.5 py-1.5 text-xs text-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Circle className="h-2 w-2 shrink-0 fill-muted text-muted" aria-hidden="true" />
|
||||
<span className="font-medium text-fg">Recorded run</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="nums font-mono">{model}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className="nums">{formatDate(capturedAt)}</span>
|
||||
{intervention ? (
|
||||
<span className="rounded-md bg-accent-subtle px-1.5 py-0.5 font-medium text-accent-fg">
|
||||
{intervention}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Formatting and environment hooks shared by every shell component.
|
||||
*
|
||||
* Numbers on this site are evidence, so formatting is centralised: an exec who
|
||||
* sees `0.81` in one panel and `0.812` in the next assumes one of them is
|
||||
* rounded in someone's favour. Everything that renders a recorded number goes
|
||||
* through here, and everything that renders one wears `.nums`.
|
||||
*/
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
|
||||
/**
|
||||
* A missing recorded number is an em dash, never a zero. `0` is a measurement;
|
||||
* `—` is the absence of one, and the difference is the whole point of the site.
|
||||
*/
|
||||
export const DASH = '—';
|
||||
|
||||
export function formatNumber(value: number, digits = 3): string {
|
||||
if (!Number.isFinite(value)) return DASH;
|
||||
return value.toFixed(digits);
|
||||
}
|
||||
|
||||
export function formatOrDash(value: number | null | undefined, digits = 3): string {
|
||||
return value === null || value === undefined ? DASH : formatNumber(value, digits);
|
||||
}
|
||||
|
||||
export function formatInt(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return DASH;
|
||||
// en-US grouping is pinned rather than taken from the visitor: the page is
|
||||
// prerendered, and a locale-dependent separator makes the built HTML and the
|
||||
// hydrated DOM disagree.
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatMs(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || !Number.isFinite(ms)) return DASH;
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)} s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verification delta. Seven decimals is not decoration: it is the number
|
||||
* that tells a sceptical engineer we compared floats rather than strings.
|
||||
*/
|
||||
export function formatDelta(delta: number): string {
|
||||
if (!Number.isFinite(delta)) return DASH;
|
||||
// -0 prints as "-0.0000000" and reads like a failure. Normalise it.
|
||||
const normalised = Object.is(delta, -0) ? 0 : delta;
|
||||
return normalised.toFixed(7);
|
||||
}
|
||||
|
||||
export function formatSigned(value: number, digits = 2): string {
|
||||
if (!Number.isFinite(value)) return DASH;
|
||||
const sign = value > 0 ? '+' : '';
|
||||
return `${sign}${value.toFixed(digits)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `2026-08-12` and `2026-08-12T09:31:00Z` both render as `12 Aug 2026`.
|
||||
* Formatted in UTC on purpose: a bare ISO date parses as midnight UTC, and a
|
||||
* visitor west of Greenwich would otherwise see the day before the capture.
|
||||
*/
|
||||
export function formatDate(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
/** `finish_reason` values are snake_case off the wire; humans read spaces. */
|
||||
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 getSnapshot = useCallback(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return serverValue;
|
||||
return window.matchMedia(query).matches;
|
||||
}, [query, serverValue]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => serverValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduced motion is not only a CSS concern here. The CSS clamps transitions,
|
||||
* but the trace player and the reasoning stream are JS timers: they have to
|
||||
* resolve to their final state immediately, or a visitor who asked for no
|
||||
* motion gets the animation anyway, just without the easing.
|
||||
*/
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||
}
|
||||
|
||||
/** The one breakpoint the shell branches on: the drawer/panel split. */
|
||||
export function useIsDesktop(): boolean {
|
||||
return useMediaQuery('(min-width: 1024px)', true);
|
||||
}
|
||||
|
||||
/** Clamp that also copes with a NaN out of `Number(searchParam)`. */
|
||||
export function clampIndex(value: number, length: number): number {
|
||||
if (!Number.isFinite(value) || length <= 0) return 0;
|
||||
return Math.min(Math.max(Math.trunc(value), 0), length - 1);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Resolving `DemoMeta.icon` — a lucide export NAME — to a component.
|
||||
*
|
||||
* The obvious implementations are both wrong, and both were tried:
|
||||
*
|
||||
* `import * as lucide from 'lucide-react'` — kills tree-shaking. Every icon
|
||||
* in the library (~1,500) lands in a chunk to render twelve of them.
|
||||
*
|
||||
* `import('lucide-react/dynamicIconImports')` — correct at runtime, but the
|
||||
* map holds a dynamic import per icon, so Rollup emits ~1,500 chunk files
|
||||
* into `dist/` for a static site that serves twelve.
|
||||
*
|
||||
* So the shell keeps an explicit registry. Adding a demo means adding its icon
|
||||
* here; that is one line, and in exchange the entry chunk stays honest. An
|
||||
* unknown name renders the neutral fallback rather than throwing, because a
|
||||
* typo in a demo's metadata must not take the gallery down.
|
||||
*/
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
const REGISTRY: Record<string, LucideIcon> = {
|
||||
Blocks,
|
||||
Boxes,
|
||||
Braces,
|
||||
Building2,
|
||||
ClipboardCheck,
|
||||
Code,
|
||||
Cpu,
|
||||
Database,
|
||||
FileSearch,
|
||||
Gauge,
|
||||
Grid3x3,
|
||||
HeartPulse,
|
||||
Landmark,
|
||||
LifeBuoy,
|
||||
MessagesSquare,
|
||||
Package,
|
||||
PhoneCall,
|
||||
Plug,
|
||||
Radio,
|
||||
Receipt,
|
||||
Scale,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
Stethoscope,
|
||||
Truck,
|
||||
Wallet,
|
||||
Workflow,
|
||||
Zap,
|
||||
};
|
||||
|
||||
export const FallbackDemoIcon: LucideIcon = Boxes;
|
||||
|
||||
/** Every icon name the shell can render, for `check-demos` to assert against. */
|
||||
export const KNOWN_ICON_NAMES: readonly string[] = Object.keys(REGISTRY);
|
||||
|
||||
export function resolveDemoIcon(name: string | undefined): LucideIcon {
|
||||
if (!name) return FallbackDemoIcon;
|
||||
return REGISTRY[name] ?? FallbackDemoIcon;
|
||||
}
|
||||
|
||||
export interface DemoIconProps {
|
||||
/** A lucide export name from `DemoMeta.icon`, e.g. `Grid3x3`. */
|
||||
name: string | undefined;
|
||||
className?: string;
|
||||
/** Icons here are always decorative — the label beside them carries the name. */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function DemoIcon({ name, className, strokeWidth = 1.75 }: DemoIconProps) {
|
||||
const Icon = resolveDemoIcon(name);
|
||||
return <Icon className={className} strokeWidth={strokeWidth} aria-hidden="true" />;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from 'react';
|
||||
import { Contrast } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const STORAGE_KEY = 'pig-demo:contrast';
|
||||
|
||||
/**
|
||||
* `localStorage` is not always readable. In a cross-origin iframe with third-
|
||||
* party storage blocked, and in Safari private mode, the getter itself THROWS
|
||||
* rather than returning null — so every access has to be wrapped, not just
|
||||
* null-checked. Unreadable storage means "off", never a crash.
|
||||
*/
|
||||
function readStored(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'high';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(high: boolean): void {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, high ? 'high' : 'normal');
|
||||
} catch {
|
||||
/* Preference is session-only here. The toggle still works. */
|
||||
}
|
||||
}
|
||||
|
||||
export function ContrastToggle({ className }: { className?: string }) {
|
||||
const [high, setHigh] = React.useState(false);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = readStored();
|
||||
setHigh(stored);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const root = document.documentElement;
|
||||
// Removing the attribute rather than setting it to "normal": the CSS keys
|
||||
// off `:root[data-contrast='high']`, and leaving a stale attribute behind
|
||||
// makes the DOM lie about the palette that is actually applied.
|
||||
if (high) root.setAttribute('data-contrast', 'high');
|
||||
else root.removeAttribute('data-contrast');
|
||||
}, [high, mounted]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', high && 'bg-accent-subtle text-accent-fg', className)}
|
||||
aria-pressed={high}
|
||||
aria-label={
|
||||
high ? 'High contrast tiles on. Turn off.' : 'High contrast tiles off. Turn on.'
|
||||
}
|
||||
title="High contrast tiles"
|
||||
onClick={() => {
|
||||
const next = !high;
|
||||
setHigh(next);
|
||||
writeStored(next);
|
||||
}}
|
||||
>
|
||||
<Contrast aria-hidden="true" />
|
||||
<span aria-live="polite" className="sr-only">
|
||||
{mounted ? (high ? 'High contrast on' : 'High contrast off') : ''}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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 { 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.
|
||||
*/
|
||||
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,
|
||||
};
|
||||
|
||||
/** Used when a vertical has no icon of its own to offer. */
|
||||
export const VERTICAL_ICONS: Record<string, string> = {
|
||||
reference: 'Grid3x3',
|
||||
support: 'Headset',
|
||||
healthcare: 'Stethoscope',
|
||||
insurance: 'ShieldCheck',
|
||||
'financial-crime': 'Landmark',
|
||||
energy: 'Zap',
|
||||
logistics: 'Truck',
|
||||
code: 'Code2',
|
||||
retail: 'ShoppingCart',
|
||||
telecom: 'RadioTower',
|
||||
data: 'Database',
|
||||
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,80 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Github } from 'lucide-react';
|
||||
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { PIG_URL, REPO_URL } from '@/components/site/links';
|
||||
import { Wordmark } from '@/components/site/Wordmark';
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-border bg-surface">
|
||||
<div className="mx-auto max-w-canvas px-4 pb-[max(2rem,var(--safe-bottom))] pl-[max(1rem,var(--safe-left))] pr-[max(1rem,var(--safe-right))] pt-8">
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Wordmark className="text-base" />
|
||||
<p className="max-w-sm text-sm text-muted">
|
||||
An environment is an eval you can take the gradient of.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Footer" className="flex flex-col gap-1 text-sm">
|
||||
<a
|
||||
className="tap inline-flex items-center gap-2 py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
href={REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<Github aria-hidden="true" className="size-4" />
|
||||
Source on GitHub
|
||||
</a>
|
||||
<Link
|
||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
to="/honesty"
|
||||
>
|
||||
What we are not claiming
|
||||
</Link>
|
||||
<a
|
||||
className="tap inline-flex items-center py-1 text-fg underline-offset-4 hover:text-accent-fg hover:underline"
|
||||
href={PIG_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
primeintellectgrowth.com
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex flex-col gap-2 text-xs leading-relaxed text-muted">
|
||||
<p>
|
||||
Every number on this site is reproducible from the repository: the environments, the
|
||||
recorded rollouts and the command that produced them all ship with the source.
|
||||
</p>
|
||||
<p>
|
||||
Licensed{' '}
|
||||
<a
|
||||
className="underline underline-offset-2 hover:text-accent-fg"
|
||||
href={`${REPO_URL}/blob/main/LICENSE`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
Apache-2.0
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{/*
|
||||
Stated plainly and kept in the footer of every page. The word game
|
||||
demo is an independent implementation of a public game mechanic; the
|
||||
trademark belongs to someone else and this site must never read as
|
||||
if it were theirs.
|
||||
*/}
|
||||
<p>
|
||||
Not affiliated with, endorsed by, or connected to The New York Times Company. Wordle is
|
||||
their trademark.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* First focusable thing on the page. `sr-only` until focused, then a real,
|
||||
* visible control — it sits above the sticky header's z-50, because a skip link
|
||||
* that focuses behind the header is worse than none at all.
|
||||
*/
|
||||
export function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only focus-visible:not-sr-only focus-visible:fixed focus-visible:left-3 focus-visible:top-[max(0.75rem,var(--safe-top))] focus-visible:z-[60] focus-visible:inline-flex focus-visible:min-h-11 focus-visible:items-center focus-visible:rounded-md focus-visible:border focus-visible:border-border focus-visible:bg-surface focus-visible:px-4 focus-visible:text-sm focus-visible:font-medium focus-visible:text-fg focus-visible:shadow-lg"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Laptop, Moon, Sun } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Mode = 'light' | 'dark' | 'system';
|
||||
|
||||
const ORDER: readonly Mode[] = ['light', 'dark', 'system'] as const;
|
||||
|
||||
const LABEL: Record<Mode, string> = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
system: 'Match system',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cycles light -> dark -> system.
|
||||
*
|
||||
* The mounted guard is not ceremony: `theme` is `undefined` on the server and
|
||||
* on the first client render, so painting an icon before that resolves renders
|
||||
* the wrong one and then swaps it — the flash this component exists to avoid.
|
||||
* The placeholder is the same size as the button so the header does not reflow
|
||||
* when it resolves.
|
||||
*/
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
React.useEffect(() => setMounted(true), []);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className={cn('size-11 lg:size-9', className)} aria-hidden="true" />;
|
||||
}
|
||||
|
||||
const current: Mode = theme === 'dark' || theme === 'light' ? theme : 'system';
|
||||
const next = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length] ?? 'system';
|
||||
const Icon = current === 'light' ? Sun : current === 'dark' ? Moon : Laptop;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className={cn('lg:size-9', className)}
|
||||
onClick={() => setTheme(next)}
|
||||
// The label states where you ARE and where the press takes you, because
|
||||
// an icon-only toggle announced as just "Theme" tells a screen-reader
|
||||
// user nothing about what pressing it will do.
|
||||
aria-label={`Theme: ${LABEL[current]}. Switch to ${LABEL[next].toLowerCase()}.`}
|
||||
title={`Theme: ${LABEL[current]}`}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Two words, one tone shift: the brand carries the accent, the qualifier does
|
||||
* not. Rendered as a single string for a screen reader so it is announced
|
||||
* "PIG demo" rather than as two unrelated fragments.
|
||||
*/
|
||||
export function Wordmark({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-baseline gap-1 font-semibold tracking-tight', className)}>
|
||||
<span aria-hidden="true" className="text-accent-fg">
|
||||
PIG
|
||||
</span>
|
||||
<span aria-hidden="true" className="font-normal lowercase text-muted">
|
||||
demo
|
||||
</span>
|
||||
<span className="sr-only">PIG demo</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The three off-site destinations the chrome links to, in one place so the
|
||||
* header and the footer can never disagree about them.
|
||||
*/
|
||||
export const REPO_URL = 'https://github.com/karti-ai/PIG-Demo';
|
||||
export const PIG_URL = 'https://primeintellectgrowth.com';
|
||||
export const VERIFIERS_WORDLE_URL =
|
||||
'https://github.com/PrimeIntellect-ai/verifiers/tree/main/environments/wordle';
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import * as AccordionPrimitive from '@radix-ui/react-accordion';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Accordion = AccordionPrimitive.Root;
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return <AccordionPrimitive.Item className={cn('border-b border-border', className)} {...props} />;
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap flex flex-1 items-center justify-between gap-3 py-3 text-left text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:text-accent-fg [&[data-state=open]>svg]:rotate-180',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-muted transition-transform duration-2 ease-enter"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no height animation here on purpose.
|
||||
*
|
||||
* shadcn's accordion animates height with `accordion-down` / `accordion-up`
|
||||
* keyframes that its CLI writes into tailwind.config.js. That file is
|
||||
* hand-maintained in this repo and is not ours to edit, so those keyframes do
|
||||
* not exist. A `transition-[height]` on `--radix-accordion-content-height`
|
||||
* looks like a substitute and is not one: Radix's Presence waits for an
|
||||
* `animationend`, so with no animation-name the node unmounts the instant you
|
||||
* collapse it and the closing transition never plays. Fade + slide is a real
|
||||
* animation, so Presence holds the node, and it degrades correctly under
|
||||
* reduced motion.
|
||||
*/
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
className="overflow-hidden duration-2 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('pb-3 pt-0', className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium leading-5 transition-colors duration-1 ease-enter',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-accent-subtle text-accent-fg',
|
||||
outline: 'border-border text-muted',
|
||||
solid: 'border-transparent bg-brand text-accent-on',
|
||||
positive: 'border-transparent bg-positive/10 text-positive',
|
||||
warning: 'border-transparent bg-warning/10 text-warning',
|
||||
danger: 'border-transparent bg-danger/10 text-danger',
|
||||
info: 'border-transparent bg-info/10 text-info',
|
||||
muted: 'border-border bg-surface-2 text-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* shadcn "new-york" button, retuned to PIG's tokens.
|
||||
*
|
||||
* Note `accent` in tailwind.config.js is the SUBTLE hover surface, not the
|
||||
* brand — that mapping is deliberate and documented there. So the solid CTA
|
||||
* uses `bg-brand text-accent-on`, never `bg-accent`.
|
||||
*/
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-brand text-accent-on shadow-sm hover:bg-brand/90',
|
||||
secondary: 'bg-surface-2 text-fg hover:bg-surface-2/70',
|
||||
outline: 'border border-border bg-surface text-fg hover:bg-surface-2',
|
||||
ghost: 'text-fg hover:bg-surface-2',
|
||||
subtle: 'bg-accent-subtle text-accent-fg hover:bg-accent-subtle/70',
|
||||
destructive: 'bg-danger text-white shadow-sm hover:bg-danger/90',
|
||||
link: 'text-accent-fg underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
// `h-9` is 36px, which is fine for a mouse and too small for a thumb.
|
||||
// Anything a phone visitor taps gets `size="touch"` or the `.tap`
|
||||
// helper on top — see SiteHeader.
|
||||
sm: 'h-8 rounded-md px-3 text-xs [&_svg]:size-3.5',
|
||||
default: 'h-9 px-4 py-2 [&_svg]:size-4',
|
||||
lg: 'h-11 rounded-lg px-6 [&_svg]:size-4',
|
||||
touch: 'min-h-11 px-4 py-2 [&_svg]:size-4',
|
||||
icon: 'size-9 [&_svg]:size-4',
|
||||
'icon-touch': 'size-11 [&_svg]:size-[1.125rem]',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return <Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** `.card` is defined in src/index.css so the shell and the demos agree on
|
||||
* one surface treatment; this component is the React face of it. */
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('card text-fg', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-1.5 p-5', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'h3'>) {
|
||||
return <h3 className={cn('font-semibold leading-tight tracking-tight', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return <p className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex items-center p-5 pt-0', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
|
||||
@@ -0,0 +1,98 @@
|
||||
import * as React from 'react';
|
||||
import { Drawer as DrawerPrimitive } from 'vaul';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* vaul, not Radix Dialog: this is the sheet you drag, used where a phone
|
||||
* visitor expects to flick a panel away (the reward editor, step detail).
|
||||
* `shouldScaleBackground` is off — it transforms `body`, which breaks
|
||||
* `position: fixed` on the sticky header underneath it.
|
||||
*/
|
||||
function Drawer({
|
||||
shouldScaleBackground = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||
}
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return <DrawerPrimitive.Overlay className={cn('fixed inset-0 z-50 bg-fg/40', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
className={cn(
|
||||
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto max-h-[88svh] flex-col rounded-t-xl border border-border bg-surface pb-[var(--safe-bottom)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* The grab handle is decorative; the drawer is also closable with
|
||||
Escape and by the close control the caller renders. */}
|
||||
<div className="mx-auto mt-3 h-1.5 w-12 shrink-0 rounded-full bg-border" aria-hidden="true" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('grid gap-1 p-4 text-left', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title className={cn('text-base font-semibold text-fg', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerTrigger,
|
||||
DrawerPortal,
|
||||
DrawerClose,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerBody,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react';
|
||||
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Tailwind 3.4 port of shadcn's navigation-menu.
|
||||
*
|
||||
* The registry version on ui.shadcn.com now targets Tailwind 4 and leans on
|
||||
* v4-only pieces (`size-*` everywhere, `@theme` tokens, the CSS-first config).
|
||||
* Everything below is expressible in 3.4 with tailwindcss-animate, which is
|
||||
* already a plugin here. Three things break if you change them carelessly:
|
||||
*
|
||||
* 1. VIEWPORT POSITIONING. The viewport is not inside the trigger — Radix
|
||||
* hoists every open panel into one shared box. It only lands under the menu
|
||||
* because it sits in an `absolute left-0 top-full` wrapper that is a child
|
||||
* of the *Root*, and because the Root is `relative`. Move the wrapper out of
|
||||
* the Root, or drop `relative`, and the panel positions against the page.
|
||||
* 2. WIDTH. `--radix-navigation-menu-viewport-width` is the measured width of
|
||||
* the open panel. Without that binding the viewport shrink-wraps to nothing
|
||||
* on the first frame and the panel visibly snaps to size.
|
||||
* 3. Z-INDEX. The header that hosts this is `position: sticky` with a
|
||||
* `backdrop-filter`, which makes it a stacking context, so the viewport's
|
||||
* z-index competes only inside the header — but it must still clear the
|
||||
* header's own translucent background, hence z-50 rather than the z-10 the
|
||||
* upstream recipe uses.
|
||||
*/
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
className={cn('relative z-50 flex max-w-max flex-1 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuViewport />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuItem = NavigationMenuPrimitive.Item;
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
'group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md px-3 py-2 text-sm font-medium text-fg transition-colors duration-1 ease-enter hover:bg-surface-2 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-surface-2',
|
||||
);
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
className={cn(navigationMenuTriggerStyle(), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className="relative top-px size-3.5 text-muted transition-transform duration-3 ease-enter group-data-[state=open]:rotate-180"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `data-motion` is set by Radix when you move sideways from one open panel to
|
||||
* the next; the slide utilities below are what make that read as one surface
|
||||
* sliding rather than two panels blinking. The `md:absolute` flip is the
|
||||
* upstream trick that lets the content measure itself at full width before the
|
||||
* viewport adopts that width.
|
||||
*/
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
className={cn(
|
||||
'left-0 top-0 w-full duration-3 ease-enter data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const NavigationMenuLink = NavigationMenuPrimitive.Link;
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div className="absolute left-0 top-full z-50 flex justify-center">
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
className={cn(
|
||||
'relative mt-2 h-[var(--radix-navigation-menu-viewport-height)] w-full origin-top overflow-hidden rounded-xl border border-border bg-surface text-fg shadow-xl',
|
||||
'transition-[width,height] duration-3 ease-enter',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'md:w-[var(--radix-navigation-menu-viewport-width)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
className={cn(
|
||||
'top-full z-50 flex h-2 items-end justify-center overflow-hidden duration-3 ease-enter data-[state=hidden]:animate-out data-[state=visible]:animate-in data-[state=hidden]:fade-out data-[state=visible]:fade-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Rotated square, half-clipped by the parent's overflow — a caret that
|
||||
inherits the panel's border and surface without a second SVG. */}
|
||||
<div className="relative top-[60%] size-2 rotate-45 rounded-tl-sm border-l border-t border-border bg-surface" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors duration-1 ease-enter',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative overflow-hidden', className)} {...props}>
|
||||
{/*
|
||||
`h-full w-full` on the viewport is load-bearing: Radix renders a
|
||||
`display:table` element inside it, which will happily grow past a
|
||||
max-height and leave you with a scroll area that never scrolls.
|
||||
*/}
|
||||
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
@@ -0,0 +1,141 @@
|
||||
import * as React from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-fg/40 backdrop-blur-[2px] duration-3 ease-enter data-[state=closed]:animate-out data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed z-50 flex flex-col gap-0 bg-surface shadow-xl duration-3 ease-enter data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b border-border pt-[var(--safe-top)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t border-border pb-[var(--safe-bottom)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-[min(88vw,22rem)] border-r border-border pl-[var(--safe-left)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-[min(88vw,22rem)] border-l border-border pr-[var(--safe-right)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||
},
|
||||
},
|
||||
defaultVariants: { side: 'right' },
|
||||
},
|
||||
);
|
||||
|
||||
export interface SheetContentProps
|
||||
extends React.ComponentProps<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
/** Set false when the sheet supplies its own close affordance. */
|
||||
showClose?: boolean;
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
className,
|
||||
children,
|
||||
showClose = true,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content className={cn(sheetVariants({ side }), className)} {...props}>
|
||||
{children}
|
||||
{showClose ? (
|
||||
<SheetPrimitive.Close
|
||||
className="tap absolute right-3 top-3 inline-flex items-center justify-center rounded-md p-2 text-muted transition-colors duration-1 ease-enter hover:bg-surface-2 hover:text-fg"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</SheetPrimitive.Close>
|
||||
) : null}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-1 border-b border-border px-4 pb-3 pt-[max(1rem,var(--safe-top))]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scrolling half of the sheet. Keeping the scroll on an inner element
|
||||
* rather than on the content root is what stops a long menu from clipping on a
|
||||
* short phone; `overscroll-contain` stops the flick from chaining through to
|
||||
* the page behind the overlay.
|
||||
*/
|
||||
function SheetBody({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col gap-2 border-t border-border px-4 pb-[max(1rem,var(--safe-bottom))] pt-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return <SheetPrimitive.Description className={cn('text-sm text-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* `bg-muted` would be wrong here: in this palette `muted` is the muted TEXT
|
||||
* colour, a mid grey that reads as a filled block rather than a placeholder.
|
||||
* The placeholder surface is `surface-2`.
|
||||
*/
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn('animate-pulse rounded-md bg-surface-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* One thumb per value, not one hard-coded thumb: the reward editor drives this
|
||||
* with a single weight today and a range tomorrow, and a single-thumb slider
|
||||
* fed a two-value array silently drops the second value.
|
||||
*/
|
||||
function Slider({
|
||||
className,
|
||||
value,
|
||||
defaultValue,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const thumbCount = (value ?? defaultValue ?? [0]).length;
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
className={cn('relative flex w-full touch-none select-none items-center py-2', className)}
|
||||
{...(value === undefined ? {} : { value })}
|
||||
{...(defaultValue === undefined ? {} : { defaultValue })}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-surface-2">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-brand" />
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: thumbCount }, (_, i) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={i}
|
||||
className="block size-5 rounded-full border-2 border-brand bg-surface shadow-sm transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
'inline-flex items-center justify-start gap-1 rounded-lg border border-border bg-surface-2 p-1 text-muted',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
className={cn(
|
||||
'tap inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-1 ease-enter disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-fg data-[state=active]:shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return <TabsPrimitive.Content className={cn('mt-4', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 6,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
// z-50 and not z-10: the tooltip has to clear the sticky header,
|
||||
// which is itself z-50 and creates a stacking context of its own.
|
||||
'z-50 overflow-hidden rounded-md border border-border bg-surface px-2.5 py-1.5 text-xs text-fg shadow-md',
|
||||
'duration-1 ease-enter animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
Reference in New Issue
Block a user