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
This commit is contained in:
karti-ai
2026-08-28 16:27:26 -07:00
parent eb88138d15
commit 6301a1d174
33 changed files with 779 additions and 92 deletions
+50 -47
View File
@@ -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>