The story
Notebook

Notebook

EnglishEspañol

How it's built

A storyboard first

Astro builds every page ahead of time, into plain HTML and CSS. The home is first a storyboard: each chapter a still frame, its cards in order, every result written out. One script sets it moving with the scroll; without it, the whole story is still there. The logic lives in small pure modules with their tests, and JavaScript runs only in the story, in the notebook’s panel and on /finding.

A scene engine of its own

The story moves on a scene engine written for it, in TypeScript. Tracks hold values keyed to the scroll; the engine eases between them, so nothing starts or stops with a jolt, and frames the camera for the shape of each screen. One loop reads the native scroll and paints what the tracks say: nothing captures the wheel or the finger, and only transforms, opacity and colours change.

/** Quadratic ease-in-out: starts and ends at rest, so no move begins or stops with a jolt. */
export function easeInOut(t: number): number {
  const x = clamp(t, 0, 1);
  return x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2;
}

It mixes colours by hue, in OKLCH, never channel by channel. That is why the story’s day has a sunrise:

The story’s sky, from dawn to nightfall, as the engine paints it

Dawn straight to morning, each colour channel mixed on its own

Dawn straight to morning, turning the hue, as the engine does

Each swatch is a colour the engine worked out while this page was built.

A test fails if the sky ever turns grey, or if any surface changes too fast to look smooth.

The lock

Part of the site stayed closed until the working paper was public on SSRN. Until then, that part was left out of the build, not hidden: a plugin swapped each locked component for an empty stub, and a script read every built file and failed if any trace of it got through. The lock is open now; the plugin and the script still run on every build. The rule is one line:

export function findingUnlocked(ssrn: string, dev: boolean): boolean {
  return !isPending(ssrn) || dev;
}

Fractions, not decimals

Payoffs and probabilities are kept as fractions of whole numbers: 5/6 stays 5/6 through every step. The only rounding comes at the end, when a share is shown out of 100, and even that uses whole numbers:

/** A non-negative fraction on a 0–100 scale, rounded half up to an integer, in integer arithmetic. */
export function outOf100(f: Fraction): number {
  if (f.num < 0) throw new RangeError('outOf100 takes a non-negative fraction');
  return Math.floor((200 * f.num + f.den) / (2 * f.den));
}

Checks that stop the build

Two languages, one set of keys. A key in one language and not the other makes the type checker reject the code:

const esCoversEn: Record<keyof typeof en, string> = es;
const enCoversEs: Record<keyof typeof es, string> = en;

A register of figures. Every number the page may say is listed with its source; the story’s captions hold none, only names that the build fills from the code. A test fails on any other number, or on a citation that matches no source.

Only adding. A test fails if this notebook repeats a sentence of the story.

Forbidden phrases. Another test fails on a short list of phrases the page must never say.

Access

Every choice is a native button that works from the keyboard, and each result is spoken through a live region. With reduced motion, the story cuts between still frames and every game still works. Contrast is tested at every point of the day’s light, and the cast stays apart for the two commonest kinds of colour blindness.

Weight and speed

The home’s script, its fonts and its first load each have a ceiling, set before the story was written; a script checks every build against them.

The story’s first load, against its ceilings
What is measuredCeilingMeasured
Its script40 KiB21.1 KiB
Its fonts160 KiB118.6 KiB
Everything it loads450 KiB184.2 KiB
Its largest paint (LCP)2.5 s2.1 s

Sizes as they travel, compressed, weighed by the same script that checks every build. The largest paint: Lighthouse 13.5.0, October 10, 2026: an emulated moto g power (2022) on a simulated slow 4G network, its processor slowed 4 times; the median of 3 runs.

A log of decisions

Every decision that shaped the page is a short written record: what was decided, why, and what it replaced. A decision changes only through a new record; the old one is archived, never rewritten.

The engine

The curve comes from a simulation engine of its own, written in TypeScript with no runtime dependencies. Four choices shape it:

/** Rolls only if it is strictly better. At a tie, Don't. */
export function choose(spec, sens, match, cap) {
  const roll = utility(spec, "roll", sens, match, cap);
  const dont = utility(spec, "dont", sens, match, cap);
  return roll > dont ? "roll" : "dont";
}

The engine exports the curve with its provenance; on this side, a test fails if the curve changes or if the file is missing. github.com/Montse2308/promises-to-whom-engine (opens in a new tab) (DOI 10.5281/zenodo.23222610 (opens in a new tab))

← Back to “Closing”