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
+31 -16
View File
@@ -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);
}
}