Scaffold: Vite + React 19 + Tailwind 3, PIG's token layer, the demo contract

Foundation for a gallery of RL-environment demos. Three decisions worth
recording here rather than in a commit nobody reads:

The word lists are built, not copied. `envs/wordle_five/words/build_words.py`
intersects Wordnik (MIT, 11,846 five-letter words) with SCOWL's common-American
tier to produce 4,603 answers. The intersection is the point: the list is
derived from two permissive sources by a stated rule rather than copied from
anyone's editorial selection, and both inputs are committed so a rebuild is
byte-identical.

The design tokens are PIG's, inlined as literals. PIG writes its accent onto
the root at runtime because a user picks it; this site has no such choice, so
the runtime theme layer would be a moving part buying nothing. Board tiles get
their own named tokens with measured contrast ratios, because the board is the
one place where colour carries meaning.

pnpm 11 no longer reads the "pnpm" field in package.json. Settings live in
pnpm-workspace.yaml, and an unapproved build script makes `pnpm install` exit 1
rather than warn — so this would have failed CI on a clean checkout, not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
This commit is contained in:
karti-ai
2026-08-28 15:28:57 -07:00
commit 5a9ff8dda9
19 changed files with 207563 additions and 0 deletions
+160
View File
@@ -0,0 +1,160 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'Manrope Variable';
font-style: normal;
font-display: swap;
font-weight: 200 800;
src: url('@fontsource-variable/manrope/files/manrope-latin-wght-normal.woff2') format('woff2-variations');
}
/*
* PIG's token layer, with the accent inlined as literals rather than written
* onto the root at runtime. This site has no user-chosen accent, so a runtime
* theme module would be a moving part that buys nothing here.
*
* Every colour is an HSL channel triple — `H S% L%` — so Tailwind can compose
* it with an opacity modifier (`bg-brand/10`). Writing `hsl(...)` into the
* variable breaks that silently: the class compiles and the opacity is ignored.
*/
:root {
--bg: 240 5% 98%;
--surface: 0 0% 100%;
--surface-2: 240 5% 97%;
--border: 240 6% 90%;
--fg: 240 10% 4%;
--muted: 240 4% 46%;
--accent: 262 83% 55%;
--accent-fg: 263 70% 46%;
--accent-on: 0 0% 100%;
--accent-subtle: 270 100% 97%;
--positive: 160 84% 24%;
--warning: 32 95% 31%;
--danger: 0 72% 45%;
--info: 201 90% 32%;
--shadow: 240 10% 4%;
/*
* Board tiles. Foregrounds are stated explicitly and measured, because the
* board is the one place on the site where colour carries meaning: an unlit
* tile and a grey tile differ only by fill, and a letter that fails contrast
* against its own tile makes the result unreadable exactly when it matters.
*
* Measured against each tile's own fill:
* exact white on 160 84% 24% -> 6.03:1
* present white on 32 95% 31% -> 5.03:1
* absent white on 240 4% 46% -> 4.61:1
*/
--tile-exact: 160 84% 24%;
--tile-exact-fg: 0 0% 100%;
--tile-present: 32 95% 31%;
--tile-present-fg: 0 0% 100%;
--tile-absent: 240 4% 46%;
--tile-absent-fg: 0 0% 100%;
--dur-1: 120ms;
--dur-2: 180ms;
--dur-3: 240ms;
--ease-out: cubic-bezier(0.2, 0, 0, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
--app-header-h: calc(3.5rem + var(--safe-top));
}
@media (min-width: 1024px) {
:root { --app-header-h: calc(4rem + var(--safe-top)); }
}
:root[data-theme='dark'] {
--bg: 240 10% 4%;
--surface: 240 8% 7%;
--surface-2: 240 6% 11%;
--border: 240 5% 18%;
--fg: 0 0% 98%;
--muted: 240 5% 60%;
--accent: 258 90% 70%;
--accent-fg: 255 92% 78%;
--accent-on: 264 80% 12%;
--accent-subtle: 260 35% 20%;
--positive: 158 64% 48%;
--warning: 38 92% 58%;
--danger: 0 84% 65%;
--info: 199 89% 58%;
/*
* Dark tiles lighten and take a dark glyph. Keeping the light theme's dark
* fills would have made the board the only near-black object on a near-black
* page, so a solved row read as an empty one.
* exact 240 10% 4% on 158 64% 48% -> 7.31:1
* present 240 10% 4% on 38 92% 58% -> 9.24:1
* absent 0 0% 98% on 240 5% 28% -> 8.94:1
*/
--tile-exact: 158 64% 48%;
--tile-exact-fg: 240 10% 4%;
--tile-present: 38 92% 58%;
--tile-present-fg: 240 10% 4%;
--tile-absent: 240 5% 28%;
--tile-absent-fg: 0 0% 98%;
}
/*
* High contrast. Green/yellow/grey is a colour-only distinction, which is
* exactly why the original game ships a high-contrast mode. Toggled from the
* header and persisted per-viewer; the tiles also carry a glyph in this mode,
* so the result survives being printed, screenshotted or seen by someone with
* deuteranopia.
*/
:root[data-contrast='high'] {
--tile-exact: 209 100% 40%;
--tile-present: 27 100% 42%;
--tile-absent: 240 4% 38%;
--tile-exact-fg: 0 0% 100%;
--tile-present-fg: 0 0% 100%;
--tile-absent-fg: 0 0% 100%;
}
@layer base {
* { @apply border-border; }
body {
@apply bg-bg text-fg font-sans antialiased;
/* Painted explicitly: the viewer's chrome shows through a transparent body
and the page would borrow the host's theme instead of its own. */
text-rendering: optimizeLegibility;
}
/* Tabular figures everywhere a number is compared to another number. */
.nums { font-variant-numeric: tabular-nums; }
:focus-visible { @apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-bg; }
}
@layer components {
.card { @apply rounded-xl border border-border bg-surface; }
/* 44px minimum touch target — the board keys and step chips are the two
places this is load-bearing on a phone. */
.tap { @apply min-h-11 min-w-11; }
}
/*
* Reduced motion clamps everything to effectively zero. That is correct, and
* it is also why every state change in this app must ALSO be announced: with
* the flip gone, colour alone carries the result, and colour alone is not a
* result for a screen-reader user.
*/
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+259
View File
@@ -0,0 +1,259 @@
/**
* The demo contract.
*
* Every demo in this repo is one directory under `src/demos/<slug>/` that
* satisfies these interfaces and imports nothing from the shared shell except
* `@/lib/demo-kit`. The shell renders any demo that does; a demo that reaches
* deeper is caught by `scripts/check-demos.mjs`, not by review.
*
* There is no runtime code in this file. It is types only, so a demo can
* depend on the contract without pulling the player, the registry or the
* verifier into its chunk.
*/
/** A vertical the demo lineup is organised by. Purely presentational grouping. */
export type Vertical =
| 'reference'
| 'support'
| 'healthcare'
| 'insurance'
| 'financial-crime'
| 'energy'
| 'logistics'
| 'code'
| 'retail'
| 'telecom'
| 'data'
| 'legal';
/**
* `live` demos are interactive and ship a real environment. `spec` demos are a
* published specification with no interactive surface — a real task, action
* set, grader, counterweight and eval command, not a coming-soon card.
* `check-demos` refuses a `spec` whose specification is incomplete.
*/
export type DemoStatus = 'live' | 'spec';
/**
* The eagerly-loaded half of a demo.
*
* This is imported for EVERY demo on every page load, because the header, the
* gallery and the router are all generated from it. It must therefore be
* serialisable and cheap: no React, no component imports, and `icon` is a
* lucide icon NAME rather than a component, or one demo's icon would pull
* lucide into the entry chunk for all of them.
*/
export interface DemoMeta {
/** URL segment and registry key. Must equal the directory name. */
slug: string;
/** Shown in the header, the gallery card and the page title. */
title: string;
/** One line, exec-facing. Says what the agent DOES, not how it works. */
tagline: string;
vertical: Vertical;
status: DemoStatus;
/** Sort order within a vertical. Ties break on slug. */
order: number;
/** A lucide-react icon name, e.g. 'Grid3x3'. Resolved by the shell. */
icon: string;
/** The job title of the person this demo is aimed at. */
persona: string;
/** Six words on what the reward pays for, and what it takes away. */
rewardLine: string;
/** Absolute path to the social card, e.g. '/og/wordle.png'. */
ogImage: string;
}
/** One weighted term of a reward, as the environment actually computes it. */
export interface RewardComponent {
key: string;
/** Plain English, as an executive would say it. Not the variable name. */
label: string;
/** What it measures, one sentence. Shown in the breakdown and the editor. */
description: string;
/** Shipped weight. The editor varies this; the environment ships this. */
weight: number;
/**
* `objective` is what the task is for. `counterweight` is what stops the
* objective being maximised the crude way, and must be in genuine tension
* with it — a component every good policy also scores 1.0 on is a gate, not
* a counterweight, and must be declared `gate`.
*/
role: 'objective' | 'counterweight' | 'gate';
}
/** The reward, as a whole, plus the source that proves it. */
export interface RewardSpec {
components: RewardComponent[];
/** Unweighted diagnostics. Rendered, never summed. */
metrics?: { key: string; label: string; description: string }[];
/** The Python that computes it, imported with `?raw` and quoted verbatim. */
source: { path: string; code: string; marker?: string };
}
/** One model call inside a step, straight off the recorded trace. */
export interface ModelCall {
promptTokens: number | null;
completionTokens: number | null;
reasoningTokens: number | null;
/** Real elapsed milliseconds, as recorded. Never invented for playback. */
durationMs: number | null;
finishReason: string | null;
}
/**
* One turn of a recorded episode, already adapted to this demo's board type.
*
* `state` is whatever the demo's surface renders. The shell never inspects it.
*/
export interface DemoStep<TState = unknown> {
index: number;
/** The board AFTER this step. Steps are snapshots, not deltas. */
state: TState;
/** The model's visible reply, verbatim. */
reply: string | null;
/** Reasoning content, if the recorded run had thinking enabled. */
reasoning: string | null;
call: ModelCall | null;
/**
* What a screen reader announces when this step lands. REQUIRED, because
* reduced-motion clamps every animation to nothing and colour alone cannot
* carry the result.
*/
announce: string;
/** Rendered under the step chip. Six words at most. */
caption?: string;
}
/** A scored reward on a recorded run. `null` means not scored, never zero. */
export type RewardValues = Record<string, number | null>;
/** Pointer to one recorded run, listed in `public/traces/manifest.json`. */
export interface RunRef {
id: string;
/** Exec-facing arm label, e.g. 'Out of the box'. */
label: string;
/** Where the JSON lives, absolute from the site root. */
path: string;
/**
* `recorded` is a plain rollout. `intervened` had something done to it, and
* `intervention` is then REQUIRED — so a prompt change can never be
* presented as a training result by omission.
*/
kind: 'recorded' | 'intervened' | 'generated';
intervention?: string;
model: string;
/** ISO date. Rendered in the permanent provenance badge. */
capturedAt: string;
seed: number;
}
/** A recorded episode as it sits on disk. */
export interface DemoEpisode {
runId: string;
seed: number;
model: string;
capturedAt: string;
/** Verbatim from the environment. `null` values render 'not scored'. */
rewards: RewardValues;
/** Unweighted diagnostics from the environment. */
metrics?: Record<string, number | null>;
/** True when the run ended without reaching a terminal state. */
truncated?: boolean;
outcome: 'solved' | 'failed' | 'aborted';
/** Raw turns. The demo's adapter turns these into DemoStep[]. */
turns: {
reply: string | null;
reasoning: string | null;
call: ModelCall | null;
info?: Record<string, unknown>;
}[];
}
/** One beat of the exec narrative. The shell renders these in order. */
export interface StoryBeat {
id: string;
title: string;
/** One sentence, asserted as a claim the page then demonstrates. */
claim: string;
/** Which shared surface renders it. */
surface:
| 'hero'
| 'anatomy'
| 'split-play'
| 'scrubber'
| 'reward-editor'
| 'metric'
| 'receipt'
| 'limits'
| 'custom';
}
/** What this demo deliberately does not teach, and which demo answers it. */
export interface Limit {
text: string;
/** Slug of the demo that closes this gap, if one is planned. */
answeredBy?: string;
}
export interface Narrative {
/** The one-paragraph thesis, exec-facing. */
thesis: string;
/** The question in the buyer's head when they land. */
anxiety: string;
beats: StoryBeat[];
limits: Limit[];
}
/** Everything needed to reproduce the recorded runs. Rendered verbatim. */
export interface Provenance {
/** The environment package, e.g. 'wordle_five'. */
envPackage: string;
tasksetId: string;
verifiersVersion: string;
/** The literal command that produced the fixtures. Copy-pasteable. */
command: string;
/** Upstream work this mirrors or builds on, with URLs. */
credits: { label: string; href: string }[];
}
/**
* The lazily-loaded half of a demo: everything that costs bundle size.
*
* `TState` is the demo's own board type. The shell is generic over it and
* never reaches inside.
*/
export interface DemoModule<TState = unknown> {
meta: DemoMeta;
narrative: Narrative;
reward: RewardSpec;
provenance: Provenance;
/** The four boxes: task, legal moves, grader, and the score that moves. */
anatomy: { task: string; actions: string; grader: string; score: string };
/** Turns a recorded episode into renderable steps. Pure. */
adapt: (episode: DemoEpisode) => DemoStep<TState>[];
/** Renders one board state. Used by play, replay AND the gallery card. */
Surface: React.ComponentType<{ state: TState; compact?: boolean }>;
/**
* Optional interactive mode: lets the visitor play rather than watch. A demo
* without one still renders every other surface.
*/
interactive?: {
/** Fresh state for a seed. Must be deterministic in the seed. */
init: (seed: number) => TState;
/** The keyboard/controls component, wired to the demo's own state. */
Controls: React.ComponentType<{
state: TState;
onChange: (next: TState) => void;
seed: number;
}>;
};
/**
* Re-derives the reward from a recorded episode, in the browser, so the page
* can prove the recorded numbers rather than assert them. Returning null
* means 'unverifiable' — a truncated trace — and must never render as zero.
*/
verify?: (episode: DemoEpisode) => RewardValues | null;
/** Extra tabs beside the default ones. */
tabs?: { id: string; label: string; Component: React.ComponentType }[];
}