Prerender, social cards, and a yellow that reads as yellow
Two silent bugs in the prerender pass, and the second was caused by the fix for
the first.
`waitForSelector('#root > *')` defaults to waiting for VISIBILITY, and the app's
first child is the skip link, which is hidden until focused. So it burned the
full 30s timeout on every one of 17 routes — twelve minutes of a script that
printed nothing, because its output was buffered behind a pipe — while the page
had rendered the whole time. Switching to `state: 'attached'` then fired too
early instead: useSeo writes the head from an effect, so the title was still
index.html's for a tick, and every route would have baked the homepage's head.
That is the exact bug this script exists to prevent. It now waits for `main`,
then for readyState, then settles.
The board's yellow was --warning, 32 95% 31% — darkened until white text cleared
4.5:1, and at that lightness it renders BROWN. On a board where people arrive
knowing this square should be yellow, a brown square reads as a bug in the
scorer, which on a page arguing "the grader is correct" is the worst thing it
could look like. The fill is now a real yellow and the glyph went dark: more
expected AND higher contrast, 10.02:1 against 5.03:1.
Also measured something the Honesty page had honestly declined to claim. It said
our word list is easier than the original's because our dictionary rule keeps
plurals the original's editor removed by hand. Running the same greedy solver
over both pools, 250 sampled words each: original 2,315 needs 3.552 guesses
(opener RAISE, worst 5), ours 4,603 needs 3.700 (opener TARES, worst 6). The
doubled pool outweighs the plurals. Ours is harder, and the page now says so
with the table.
177 gate checks pass. Entry chunk 106.9 kB gzipped against 160 kB.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 165 KiB |
@@ -127,7 +127,22 @@ try {
|
||||
await page.goto(url, { waitUntil: 'load', timeout: 30_000 });
|
||||
// The router's lazy routes resolve a chunk before they render anything, so
|
||||
// "load" is too early for every page but the eagerly-imported home.
|
||||
await page.waitForSelector('#root > *', { timeout: 30_000 }).catch(() => {});
|
||||
// Two things are easy to get wrong here and both fail silently.
|
||||
//
|
||||
// Wait for `main`, not `#root > *`: the app's first child is the skip link,
|
||||
// and `waitForSelector` defaults to waiting for VISIBILITY, so a
|
||||
// visually-hidden first child burns the full timeout on every route while
|
||||
// the page has in fact been rendered the whole time.
|
||||
//
|
||||
// Then settle. `useSeo` writes the head from an effect, so the title is
|
||||
// still the one baked into index.html for a tick after the markup appears.
|
||||
// Reading too early bakes the homepage's head into every route — the exact
|
||||
// bug this script exists to prevent, reintroduced by the fix for the first.
|
||||
await page.waitForSelector('#root main', { timeout: 30_000 }).catch(() => {});
|
||||
await page
|
||||
.waitForFunction(() => document.readyState === 'complete', null, { timeout: 10_000 })
|
||||
.catch(() => {});
|
||||
await page.waitForTimeout(250);
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
||||
// `document.fonts.ready` resolves to a FontFaceSet, which does not survive
|
||||
// serialisation back across the bridge; return a boolean instead.
|
||||
|
||||
@@ -2,8 +2,10 @@ import * as React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, ArrowUpRight, Github, Menu } from 'lucide-react';
|
||||
|
||||
import { listDemos, listVerticals, type VerticalGroup } from '@/lib/demo-kit/registry';
|
||||
import { listDemos } from '@/lib/demo-kit/registry';
|
||||
import type { DemoMeta } from '@/lib/demo-kit/types';
|
||||
import { lineup, routes } from '@/content/lineup';
|
||||
import type { VerticalEntry } from '@/content/verticals';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -33,7 +35,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
import { ContrastToggle } from '@/components/site/ContrastToggle';
|
||||
import { DemoIcon, VERTICAL_ICONS } from '@/components/site/DemoIcon';
|
||||
import { DemoIcon } from '@/components/site/DemoIcon';
|
||||
import { PIG_URL, REPO_URL, VERIFIERS_WORDLE_URL } from '@/components/site/links';
|
||||
import { ThemeToggle } from '@/components/site/ThemeToggle';
|
||||
import { Wordmark } from '@/components/site/Wordmark';
|
||||
@@ -84,10 +86,17 @@ const CONCEPTS: readonly { term: string; gloss: string }[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** A vertical group as the header renders it: the registry's group plus a glyph. */
|
||||
interface HeaderVertical extends VerticalGroup {
|
||||
icon: string;
|
||||
}
|
||||
/**
|
||||
* The header's Verticals panel shows the LINEUP, not the registry's groups.
|
||||
*
|
||||
* `listVerticals()` only ever contains verticals that already have a demo —
|
||||
* today that is one group, `reference`, which `src/content/verticals.ts` is
|
||||
* explicit is not an industry and must never be presented as one. The twelve
|
||||
* proposals each have a real page at `/verticals/<slug>`, so the panel links
|
||||
* there. `VerticalEntry.slug` is the route's slug; the registry's `Vertical`
|
||||
* key is a different, shorter string and is deliberately not used for URLs.
|
||||
*/
|
||||
type HeaderVertical = VerticalEntry;
|
||||
|
||||
function useLineup() {
|
||||
return React.useMemo(() => {
|
||||
@@ -99,10 +108,10 @@ function useLineup() {
|
||||
const live = demos.filter((demo) => demo.status === 'live');
|
||||
const spec = demos.filter((demo) => demo.status === 'spec');
|
||||
|
||||
const verticals: HeaderVertical[] = listVerticals().map((group) => ({
|
||||
...group,
|
||||
icon: VERTICAL_ICONS[group.vertical],
|
||||
}));
|
||||
// Already sorted by rank in `@/content/lineup`. Copied rather than passed
|
||||
// through, because the panel props are mutable arrays and `lineup` is the
|
||||
// one every page reads.
|
||||
const verticals: HeaderVertical[] = [...lineup];
|
||||
|
||||
// The CTA follows the lineup rather than naming a slug. Today the first
|
||||
// live demo IS the word game, so this resolves to the wordle route; when a
|
||||
@@ -230,25 +239,22 @@ function VerticalsPanel({ verticals }: { verticals: HeaderVertical[] }) {
|
||||
<div className="w-[min(92vw,720px)] p-4">
|
||||
{verticals.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
{verticals.map((vertical) => {
|
||||
// The lead demo is the lowest-order one in the vertical; its reward
|
||||
// line is what the vertical is actually promising.
|
||||
const lead = vertical.demos[0]!;
|
||||
return (
|
||||
<NavigationMenuLink asChild key={vertical.vertical}>
|
||||
<Link
|
||||
to={`/demos/${lead.slug}`}
|
||||
className="flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 size-4 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.label}</span>
|
||||
<span className="text-xs leading-relaxed text-muted">{lead.rewardLine}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
);
|
||||
})}
|
||||
{verticals.map((vertical) => (
|
||||
<NavigationMenuLink asChild key={vertical.slug}>
|
||||
<Link
|
||||
to={routes.vertical(vertical.slug)}
|
||||
className="flex gap-3 rounded-lg p-2.5 transition-colors duration-1 ease-enter hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 size-4 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.title}</span>
|
||||
{/* The budget holder, not the reward sentence: a nav row has
|
||||
one line, and "who buys this" is the more useful half. */}
|
||||
<span className="text-xs leading-relaxed text-muted">{vertical.persona}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</NavigationMenuLink>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-2.5 text-xs text-muted">No verticals in the lineup yet.</p>
|
||||
@@ -384,25 +390,22 @@ function MobileNav({
|
||||
<AccordionItem value="verticals">
|
||||
<AccordionTrigger>Verticals</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-0.5">
|
||||
{verticals.map((vertical) => {
|
||||
const lead = vertical.demos[0]!;
|
||||
return (
|
||||
<SheetClose asChild key={vertical.vertical}>
|
||||
<Link
|
||||
to={`/demos/${lead.slug}`}
|
||||
className="flex gap-3 rounded-lg p-2.5 hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.label}</span>
|
||||
<span className="text-xs leading-relaxed text-muted">
|
||||
{lead.rewardLine}
|
||||
</span>
|
||||
{verticals.map((vertical) => (
|
||||
<SheetClose asChild key={vertical.slug}>
|
||||
<Link
|
||||
to={routes.vertical(vertical.slug)}
|
||||
className="flex gap-3 rounded-lg p-2.5 hover:bg-surface-2"
|
||||
>
|
||||
<DemoIcon name={vertical.icon} className="mt-0.5 text-accent-fg" />
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-fg">{vertical.title}</span>
|
||||
<span className="text-xs leading-relaxed text-muted">
|
||||
{vertical.persona}
|
||||
</span>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
</SheetClose>
|
||||
))}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
|
||||
@@ -80,6 +80,20 @@ export const trainingResult = {
|
||||
* headwords and SCOWL's common-American tier, minus a short hand-written
|
||||
* blocklist; guesses are all of Wordnik's five-letter words.
|
||||
*/
|
||||
/**
|
||||
* Same greedy max-entropy solver, run over both answer pools, 250 sampled
|
||||
* targets each. Measured on amd-server, 2026-08-28. This is the only honest way
|
||||
* to compare the two lists: the published 3.4212 optimum for the original list
|
||||
* comes from an exhaustive search, and setting our greedy number against it
|
||||
* would be comparing two different algorithms and calling it a property of the
|
||||
* words.
|
||||
*/
|
||||
export const poolComparison = {
|
||||
original: { size: '2,315', opener: 'RAISE', mean: '3.552', worst: '5' },
|
||||
ours: { size: '4,603', opener: 'TARES', mean: '3.700', worst: '6' },
|
||||
sampled: '250',
|
||||
} as const;
|
||||
|
||||
export const wordList = {
|
||||
answers: 4603,
|
||||
guesses: 11846,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Cpu,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
ListChecks,
|
||||
Puzzle,
|
||||
RadioTower,
|
||||
Scale,
|
||||
@@ -36,6 +37,7 @@ const REGISTRY: Record<string, LucideIcon> = {
|
||||
Cpu,
|
||||
Grid3x3,
|
||||
Headset,
|
||||
ListChecks,
|
||||
Puzzle,
|
||||
RadioTower,
|
||||
Scale,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* The lazy half of a demo: everything that costs bundle size.
|
||||
*
|
||||
* A demo exists because this file and `meta.ts` exist. There is no registry to
|
||||
* edit, no route to add and no import to insert anywhere else — if you find
|
||||
* yourself editing a shared file to make a new demo appear, that is a bug in
|
||||
* `src/lib/demo-kit/registry.ts`, not a missing step here.
|
||||
*
|
||||
* Two rules the checker enforces, both worth understanding rather than
|
||||
* working around:
|
||||
*
|
||||
* · Import ONLY from '@/lib/demo-kit'. The barrel is the contract. Reaching
|
||||
* into '@/lib/demo-kit/player' or '@/components/demo/…' makes the shell
|
||||
* impossible to change without opening every demo (rule 9).
|
||||
* · Every step sets a non-empty `announce`. Reduced motion clamps the
|
||||
* animation to nothing, so for a screen-reader user the announcement IS
|
||||
* the result, not a courtesy (rule 13).
|
||||
*/
|
||||
|
||||
import { defineDemo, type DemoEpisode, type DemoStep, type RewardValues } from '@/lib/demo-kit';
|
||||
|
||||
import meta from './meta';
|
||||
import Board, { empty__Pascal__, type QueueItem, type __Pascal__State } from './surface';
|
||||
// The Python is quoted verbatim beside the number it produced. `?raw` is how
|
||||
// the real file gets into the bundle without being reimplemented in TypeScript.
|
||||
import rewardSource from './reward.py?raw';
|
||||
|
||||
/* ------------------------------------------------------------------ adapt */
|
||||
|
||||
/** Reads the queue off a recorded turn without trusting its shape. */
|
||||
function itemsFrom(info: Record<string, unknown> | undefined): QueueItem[] {
|
||||
const raw = info?.['items'];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.flatMap((entry): QueueItem[] => {
|
||||
if (entry === null || typeof entry !== 'object') return [];
|
||||
const row = entry as Record<string, unknown>;
|
||||
const id = typeof row['id'] === 'string' ? row['id'] : null;
|
||||
if (id === null) return [];
|
||||
return [
|
||||
{
|
||||
id,
|
||||
label: typeof row['label'] === 'string' ? row['label'] : id,
|
||||
risk: typeof row['risk'] === 'number' ? row['risk'] : 0,
|
||||
needsPerson: row['needs_person'] === true || row['needsPerson'] === true,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function escalatedFrom(info: Record<string, unknown> | undefined): string[] {
|
||||
const raw = info?.['escalated'];
|
||||
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded episode becomes the boards the player renders. Pure, and total:
|
||||
* a turn the environment recorded badly must degrade to a readable step, never
|
||||
* throw — the shell has an error boundary, but a page that renders the error
|
||||
* card instead of the run has still lost the argument.
|
||||
*/
|
||||
export function adapt(episode: DemoEpisode): DemoStep<__Pascal__State>[] {
|
||||
let items: QueueItem[] = [];
|
||||
|
||||
return episode.turns.map((turn, index) => {
|
||||
const turnItems = itemsFrom(turn.info);
|
||||
// The queue is only sent once, on the first turn of most recordings, so
|
||||
// carry the last one we saw rather than blanking the board mid-replay.
|
||||
if (turnItems.length > 0) items = turnItems;
|
||||
|
||||
const escalated = escalatedFrom(turn.info);
|
||||
const last = escalated[escalated.length - 1];
|
||||
const lastItem = last === undefined ? undefined : items.find((item) => item.id === last);
|
||||
const isLastTurn = index === episode.turns.length - 1;
|
||||
|
||||
const state: __Pascal__State = {
|
||||
seed: episode.seed,
|
||||
items,
|
||||
escalated,
|
||||
outcome: isLastTurn && episode.outcome !== 'aborted' ? episode.outcome : 'pending',
|
||||
};
|
||||
|
||||
return {
|
||||
index,
|
||||
state,
|
||||
reply: turn.reply,
|
||||
reasoning: turn.reasoning,
|
||||
call: turn.call,
|
||||
announce:
|
||||
lastItem === undefined
|
||||
? `Turn ${index + 1}: nothing escalated, ${items.length} left in the queue.`
|
||||
: `Turn ${index + 1}: escalated ${lastItem.label}. ${escalated.length} of ${items.length} escalated.`,
|
||||
...(lastItem === undefined ? {} : { caption: `Escalated ${lastItem.label}` }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- verify */
|
||||
|
||||
/**
|
||||
* Re-derives the score in the browser, so the page can PROVE the recorded
|
||||
* numbers rather than assert them. Returning null means 'unverifiable' — a
|
||||
* truncated trace — and the badge renders that as neutral, never as zero.
|
||||
*/
|
||||
function recompute(episode: DemoEpisode): RewardValues | null {
|
||||
if (episode.truncated) return null;
|
||||
const steps = adapt(episode);
|
||||
const final = steps[steps.length - 1];
|
||||
if (!final || final.state.items.length === 0) return null;
|
||||
|
||||
const needed = final.state.items.filter((item) => item.needsPerson);
|
||||
const escalated = new Set(final.state.escalated);
|
||||
const noise = [...escalated].filter((id) => !needed.some((item) => item.id === id));
|
||||
|
||||
return {
|
||||
caught: needed.length === 0 ? 1 : needed.filter((item) => escalated.has(item.id)).length / needed.length,
|
||||
restraint: escalated.size === 0 ? 1 : 1 - noise.length / escalated.size,
|
||||
well_formed: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ demo */
|
||||
|
||||
export default defineDemo<__Pascal__State>({
|
||||
meta,
|
||||
|
||||
narrative: {
|
||||
thesis:
|
||||
'The queue is not the problem. Deciding which three of four hundred items are worth a person is the ' +
|
||||
'problem, and that decision is exactly the kind of judgement a reward can be written down for.',
|
||||
anxiety: 'What stops it escalating everything so it never misses one?',
|
||||
beats: [
|
||||
{ id: 'hero', title: 'One queue, one decision', claim: 'Every item is either worth a person or it is not.', surface: 'hero' },
|
||||
{ id: 'anatomy', title: 'What the environment is', claim: 'A task, a fixed action set, a grader, and a score that moves.', surface: 'anatomy' },
|
||||
{ id: 'play', title: 'Watch a recorded run', claim: 'These are recorded turns, not a scripted animation.', surface: 'split-play' },
|
||||
{ id: 'reward', title: 'Move the weights yourself', claim: 'Pay only for catches and the queue gets escalated whole.', surface: 'reward-editor' },
|
||||
{ id: 'receipt', title: 'The code that scored it', claim: 'The number on this page came out of the function below it.', surface: 'receipt' },
|
||||
{ id: 'limits', title: 'What this does not show', claim: 'One queue, one grader, and no cost of being wrong.', surface: 'limits' },
|
||||
],
|
||||
limits: [
|
||||
{
|
||||
text: 'The grader knows which items needed a person because the dataset says so. A real queue has no such column, and building one is most of the work.',
|
||||
},
|
||||
{
|
||||
text: 'Escalating wrongly and missing something both cost the same here. In production they do not, and the weights should say so.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
/**
|
||||
* The four boxes. `check-demos` rule 6 fails an empty one for a `spec` demo
|
||||
* as loudly as for a `live` one: a specification with a blank grader is a
|
||||
* coming-soon card wearing a contract.
|
||||
*/
|
||||
anatomy: {
|
||||
task: 'Work one queue of items and escalate only the ones that genuinely need a person.',
|
||||
actions: 'Escalate an item by id, or end the turn. Any other output is refused and counts as a malformed action.',
|
||||
grader: 'Compares the escalated set to the labelled set. It counts; it does not judge.',
|
||||
score: 'Three fifths for the ones caught, a quarter for not escalating anything else, and the rest is a well-formed-output gate.',
|
||||
},
|
||||
|
||||
reward: {
|
||||
components: [
|
||||
{
|
||||
key: 'caught',
|
||||
label: 'Caught what mattered',
|
||||
description: 'The share of the items that genuinely needed a person which the agent escalated.',
|
||||
weight: 0.6,
|
||||
role: 'objective',
|
||||
},
|
||||
{
|
||||
key: 'restraint',
|
||||
label: 'Left the rest alone',
|
||||
description:
|
||||
'The share of everything it escalated that was worth escalating. This is the term that makes ' +
|
||||
'escalating the whole queue a losing strategy rather than a safe one.',
|
||||
weight: 0.25,
|
||||
role: 'counterweight',
|
||||
},
|
||||
{
|
||||
key: 'well_formed',
|
||||
label: 'Answered in the required form',
|
||||
description: 'One point unless the run emitted an action the environment could not parse.',
|
||||
weight: 0.15,
|
||||
role: 'gate',
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
{
|
||||
key: 'escalations_that_landed',
|
||||
label: 'Escalations that landed',
|
||||
description: 'A count, not a rate. Rendered beside the reward and never summed into it.',
|
||||
},
|
||||
],
|
||||
source: {
|
||||
path: 'src/demos/_template/reward.py',
|
||||
code: rewardSource,
|
||||
marker: 'score',
|
||||
},
|
||||
},
|
||||
|
||||
provenance: {
|
||||
envPackage: '__package__',
|
||||
tasksetId: '__slug__',
|
||||
verifiersVersion: '0.3.2.dev12',
|
||||
command: 'uv run python envs/probe.py --env __package__',
|
||||
credits: [
|
||||
{
|
||||
label: 'verifiers — the environment API this mirrors',
|
||||
href: 'https://github.com/PrimeIntellect-ai/verifiers',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
adapt,
|
||||
Surface: Board,
|
||||
verify: recompute,
|
||||
});
|
||||
|
||||
/** Re-exported so a scaffolded demo's tests can build a board without the shell. */
|
||||
export { empty__Pascal__ };
|
||||
@@ -2,8 +2,9 @@
|
||||
* ── THE TEMPLATE ───────────────────────────────────────────────────────────
|
||||
*
|
||||
* `node scripts/new-demo.mjs <slug>` copies this directory to
|
||||
* `src/demos/<slug>` and substitutes the `__token__` names. Everything here is
|
||||
* a WORKED EXAMPLE, not filler: it is written the way a real demo is written
|
||||
* `src/demos/<slug>` and substitutes the placeholder names (the table is in
|
||||
* that script's header). Everything here is a WORKED EXAMPLE, not filler: it
|
||||
* is written the way a real demo is written
|
||||
* so that a scaffold passes `pnpm check` on the first run and you edit prose
|
||||
* rather than discover the contract one failing rule at a time.
|
||||
*
|
||||
@@ -14,6 +15,9 @@
|
||||
*
|
||||
* `meta.ts` is loaded EAGERLY for every demo on every page, so it stays plain
|
||||
* serialisable data: no React, no lucide component, no imports beyond the kit.
|
||||
*
|
||||
* Delete this block once you have scaffolded from it — it describes the
|
||||
* template, not your demo.
|
||||
*/
|
||||
|
||||
import { defineMeta } from '@/lib/demo-kit';
|
||||
|
||||
@@ -44,15 +44,24 @@
|
||||
* 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.
|
||||
*
|
||||
* The present tile does NOT reuse --warning. That token is 32 95% 31%, darkened
|
||||
* until white text clears 4.5:1 on it, and at that lightness it reads BROWN.
|
||||
* On a board where people arrive with a hard prior that this square is yellow,
|
||||
* a brown square reads as a bug in the scorer rather than as a design choice —
|
||||
* which on a page whose whole argument is "the grader is correct" is the
|
||||
* single worst thing it could look like. So the fill stays a real yellow and
|
||||
* the GLYPH goes dark instead, which is both more expected and higher contrast
|
||||
* than the version that was trying to be careful.
|
||||
*
|
||||
* 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
|
||||
* exact white on 160 84% 24% -> 6.03:1
|
||||
* present near-black on 45 93% 47% -> 10.02: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-present: 45 93% 47%;
|
||||
--tile-present-fg: 240 10% 4%;
|
||||
--tile-absent: 240 4% 46%;
|
||||
--tile-absent-fg: 0 0% 100%;
|
||||
|
||||
|
||||
@@ -124,13 +124,18 @@ function assertEpisode(raw: unknown, runRef: RunRef): DemoEpisode {
|
||||
*
|
||||
* `RunRef` says runs are "listed in public/traces/manifest.json", but nothing in
|
||||
* the contract hands the shell a `RunRef[]` — `DemoModule` has no `runs` field.
|
||||
* So the manifest is the only source, and this is its reader. Three shapes are
|
||||
* accepted because the generator and the shell are written in different places
|
||||
* and a mismatch here would be a blank page rather than a type error:
|
||||
* So the manifest is the only source, and this is its reader. Four shapes are
|
||||
* accepted — the same four `scripts/_lib.mjs` accepts, deliberately, because a
|
||||
* generator and a reader that disagree about the envelope produce a blank page
|
||||
* rather than a type error, and the gate would still be green:
|
||||
*
|
||||
* { "demos": { "wordle-five": [RunRef, ...] } }
|
||||
* { "runs": [ { ...RunRef, "demo": "wordle-five" }, ... ] }
|
||||
* [ { ...RunRef, "demo": "wordle-five" }, ... ]
|
||||
* { "wordle": [RunRef, ...] } <- what the repo ships
|
||||
* { "wordle": { "runs": [RunRef, ...], ... } }
|
||||
* { "demos": { "wordle": [RunRef, ...] } }
|
||||
* { "runs": [ { ...RunRef, "demo": "wordle" }, ... ] } (or a bare array)
|
||||
*
|
||||
* The two envelope keys are unwrapped BEFORE the record branch, or `"runs"` is
|
||||
* read as a demo slug and every real demo reports zero runs.
|
||||
*/
|
||||
export const MANIFEST_PATH = '/traces/manifest.json';
|
||||
|
||||
@@ -171,6 +176,7 @@ function normaliseManifest(raw: unknown): RunManifest {
|
||||
else out[slug] = [run];
|
||||
};
|
||||
|
||||
/** A flat list of runs, each carrying its own `demo`/`slug`. */
|
||||
const flat = (entries: unknown[]): void => {
|
||||
for (const entry of entries) {
|
||||
if (entry === null || typeof entry !== 'object') continue;
|
||||
@@ -187,17 +193,26 @@ function normaliseManifest(raw: unknown): RunManifest {
|
||||
}
|
||||
if (raw === null || typeof raw !== 'object') return out;
|
||||
|
||||
const object = raw as { demos?: unknown; runs?: unknown };
|
||||
if (Array.isArray(object.runs)) flat(object.runs);
|
||||
const envelope = raw as { demos?: unknown; runs?: unknown };
|
||||
let container: unknown = raw;
|
||||
if (envelope.demos !== null && typeof envelope.demos === 'object') container = envelope.demos;
|
||||
else if (Array.isArray(envelope.runs)) container = envelope.runs;
|
||||
|
||||
const demos = object.demos;
|
||||
if (demos !== null && typeof demos === 'object') {
|
||||
for (const [slug, runs] of Object.entries(demos as Record<string, unknown>)) {
|
||||
if (Array.isArray(runs)) {
|
||||
for (const run of runs) {
|
||||
if (run !== null && typeof run === 'object') push(slug, run as RunRef);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(container)) {
|
||||
flat(container);
|
||||
return out;
|
||||
}
|
||||
if (container === null || typeof container !== 'object') return out;
|
||||
|
||||
for (const [slug, value] of Object.entries(container as Record<string, unknown>)) {
|
||||
const runs = Array.isArray(value)
|
||||
? value
|
||||
: value !== null && typeof value === 'object' && Array.isArray((value as { runs?: unknown }).runs)
|
||||
? ((value as { runs: unknown[] }).runs)
|
||||
: null;
|
||||
if (!runs) continue;
|
||||
for (const run of runs) {
|
||||
if (run !== null && typeof run === 'object') push(slug, run as RunRef);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||
import { VERTICAL_LABELS } from '@/lib/demo-kit/registry';
|
||||
import type { Vertical } from '@/lib/demo-kit/types';
|
||||
import * as s from '@/content/styles';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
@@ -26,6 +27,14 @@ function verticalLabel(key: Vertical): string {
|
||||
}
|
||||
|
||||
export default function Gallery() {
|
||||
useSeo({
|
||||
title: pageTitle('Every environment'),
|
||||
description:
|
||||
'Every environment in the lineup: the ones that are built and playable, and the ones published as a specification with the task, the grader and the counterweight written out.',
|
||||
canonical: routes.gallery,
|
||||
ogImage: '/og/gallery.png',
|
||||
});
|
||||
|
||||
/*
|
||||
* The filter lives in the URL, not in component state. A filtered gallery is
|
||||
* the thing somebody pastes into a message, and it also means the back button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { iconFor } from '@/content/icons';
|
||||
import { featuredDemo, lineup, routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||
import * as s from '@/content/styles';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
/**
|
||||
* The four boxes. This is the definition the whole site rests on, so it is
|
||||
@@ -33,6 +34,19 @@ const ANATOMY: readonly { label: string; body: string }[] = [
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
// The head is set here as well as baked by `scripts/prerender.mjs`, and the
|
||||
// two are not redundant: prerender covers the crawler that fetches the
|
||||
// document, this covers the visitor who arrives on another route and clicks
|
||||
// through without ever fetching a new one. Prerender prefers whatever the
|
||||
// app set, so this is also the single source for the baked tags.
|
||||
useSeo({
|
||||
title: pageTitle(),
|
||||
description:
|
||||
'Interactive demos of reinforcement-learning environments for executives. Real verifiers environments, real recorded rollouts, and a reward you can change to see the ranking flip.',
|
||||
canonical: routes.home,
|
||||
ogImage: '/og/home.png',
|
||||
});
|
||||
|
||||
const Featured = featuredDemo;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,9 +2,17 @@ import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
|
||||
import { helloWorldCitations, optimalPlay, reproduce, trainingResult, wordList } from '@/content/evidence';
|
||||
import {
|
||||
helloWorldCitations,
|
||||
optimalPlay,
|
||||
poolComparison,
|
||||
reproduce,
|
||||
trainingResult,
|
||||
wordList,
|
||||
} from '@/content/evidence';
|
||||
import { REPO_URL, routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE } from '@/content/verticals';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
import * as s from '@/content/styles';
|
||||
|
||||
/** External link with the new-tab affordance and the announcement to match. */
|
||||
@@ -33,11 +41,19 @@ const CONTENTS: readonly { id: string; label: string }[] = [
|
||||
{ id: 'editor', label: 'The reward editor does not train' },
|
||||
{ id: 'proposals', label: 'The verticals are proposals' },
|
||||
{ id: 'words', label: 'The word list is ours' },
|
||||
{ id: 'easier', label: 'This game is easier than the original' },
|
||||
{ id: 'harder', label: 'This game is harder than the original' },
|
||||
{ id: 'unmeasured', label: 'What we have not measured' },
|
||||
];
|
||||
|
||||
export default function Honesty() {
|
||||
useSeo({
|
||||
title: pageTitle('Honesty'),
|
||||
description:
|
||||
'What is measured here, what is cited from elsewhere, and what we have deliberately not measured — including where our own word list makes the game easier than the original.',
|
||||
canonical: routes.honesty,
|
||||
ogImage: '/og/honesty.png',
|
||||
});
|
||||
|
||||
return (
|
||||
<main className={`${s.shell} py-10 sm:py-16`}>
|
||||
<p className={s.eyebrow}>Honesty</p>
|
||||
@@ -286,33 +302,60 @@ export default function Honesty() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* ── Easier ─────────────────────────────────────────────────────── */}
|
||||
{/* ── Harder ────────────────────────────────────────────────────── */}
|
||||
<section className="mt-12 max-w-3xl">
|
||||
<Heading id="easier">This environment is materially easier than the original game</Heading>
|
||||
<Heading id="harder">This environment is harder than the original game</Heading>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
Our answer pool comes from a dictionary rule, so it keeps the regular plurals and past
|
||||
tenses that the original game’s editor removed by hand.{' '}
|
||||
Two things pull in opposite directions here, so we measured rather than argued. Our answer
|
||||
pool comes from a dictionary rule, so it keeps the regular plurals and past tenses the
|
||||
original game’s editor removed by hand:{' '}
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInS}</span> of our answers end
|
||||
in a plain <span className="font-mono">S</span> and{' '}
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInEd}</span> end in{' '}
|
||||
<span className="font-mono">-ED</span> —{' '}
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInSorEd}</span> in one or the
|
||||
other.
|
||||
<span className="nums font-semibold text-fg">{wordList.endsInEd}</span> in{' '}
|
||||
<span className="font-mono">-ED</span>. That is an exploitable regularity, and it makes our
|
||||
list easier. Our pool is also roughly twice the size, which makes it harder.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
That is a large, exploitable regularity. A guess that tests a trailing{' '}
|
||||
<span className="font-mono">S</span> splits our pool roughly one-third to two-thirds every
|
||||
single game, and buys information that the same guess simply does not buy in a list where
|
||||
plurals were deliberately stripped out. Nobody has to be clever to use it; a fixed opening
|
||||
pair picks it up for free.
|
||||
The net effect is the second one. We ran the same greedy solver over both pools, on{' '}
|
||||
<span className="nums font-semibold text-fg">{poolComparison.sampled}</span> sampled words
|
||||
each:
|
||||
</p>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[26rem] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-muted">
|
||||
<th className="py-2 pr-4 font-medium">Answer pool</th>
|
||||
<th className="py-2 pr-4 font-medium">Best opener</th>
|
||||
<th className="py-2 pr-4 font-medium">Mean guesses</th>
|
||||
<th className="py-2 font-medium">Worst case</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="nums">
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-2 pr-4">Original, {poolComparison.original.size}</td>
|
||||
<td className="py-2 pr-4 font-mono">{poolComparison.original.opener}</td>
|
||||
<td className="py-2 pr-4">{poolComparison.original.mean}</td>
|
||||
<td className="py-2">{poolComparison.original.worst}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-semibold text-fg">Ours, {poolComparison.ours.size}</td>
|
||||
<td className="py-2 pr-4 font-mono">{poolComparison.ours.opener}</td>
|
||||
<td className="py-2 pr-4 font-semibold text-fg">{poolComparison.ours.mean}</td>
|
||||
<td className="py-2">{poolComparison.ours.worst}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
About a seventh of a guess harder, and one worse in the worst case. Both rows are the same
|
||||
algorithm, which is the only fair comparison available — setting our greedy number against
|
||||
the published{' '}
|
||||
<span className="nums">3.4212</span> optimum for the original list would be comparing two
|
||||
different algorithms and calling the difference a property of the words.
|
||||
</p>
|
||||
<p className={`${s.prose} mt-4`}>
|
||||
One thing cuts the other way, and we would rather say it than be caught leaving it out: our
|
||||
pool is roughly twice the size of the original’s, which is harder. We have not computed an
|
||||
optimal average for our list, so we cannot give you a single number for the net effect. The
|
||||
practical instruction is the same either way — do not compare a guess count from this site
|
||||
to your own statistics from the original game. It is a different game with a different
|
||||
shape.
|
||||
The practical instruction is unchanged: do not compare a guess count from this site to your
|
||||
own statistics from the original game. It is a different game with a different shape.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { allDemos, lineup, REPO_URL, routes } from '@/content/lineup';
|
||||
import * as s from '@/content/styles';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
/**
|
||||
* A 404 with somewhere to go.
|
||||
@@ -13,6 +14,15 @@ import * as s from '@/content/styles';
|
||||
*/
|
||||
export default function NotFound() {
|
||||
const { pathname } = useLocation();
|
||||
// Deliberately no canonical: this component renders at every wrong URL there
|
||||
// will ever be, and a canonical pointing at one of them would ask a crawler
|
||||
// to index the set. `scripts/prerender.mjs` writes `noindex` into 404.html
|
||||
// for the same reason.
|
||||
useSeo({
|
||||
title: pageTitle('Page not found'),
|
||||
description:
|
||||
'That page does not exist. Every demo on this site is a directory in the repository, so a missing one is usually a renamed slug.',
|
||||
});
|
||||
const topVerticals = lineup.slice(0, 4);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,11 +5,24 @@ import { iconFor } from '@/content/icons';
|
||||
import { demosForVertical, featuredDemo, lineup, routes } from '@/content/lineup';
|
||||
import { PROPOSAL_NOTICE, verticalBySlug } from '@/content/verticals';
|
||||
import * as s from '@/content/styles';
|
||||
import { pageTitle, useSeo } from '@/lib/seo';
|
||||
|
||||
export default function VerticalPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const vertical = verticalBySlug(slug);
|
||||
|
||||
// Before the early return, because a hook cannot be called conditionally —
|
||||
// and because the "no such vertical" branch needs a head of its own too, or
|
||||
// it inherits whichever page the visitor came from.
|
||||
useSeo({
|
||||
title: pageTitle(vertical?.title ?? 'No such vertical'),
|
||||
description: vertical
|
||||
? `${vertical.task} ${vertical.reward}`
|
||||
: 'There is no proposal at that address. The twelve we have written are all on the home page.',
|
||||
...(slug ? { canonical: routes.vertical(slug) } : {}),
|
||||
...(vertical ? { ogImage: `/og/vertical-${vertical.slug}.png` } : {}),
|
||||
});
|
||||
|
||||
if (!vertical) {
|
||||
return (
|
||||
<main className={`${s.shell} py-16`}>
|
||||
|
||||