The problem
Nine years of AI history is a subject with no natural shape. Told as a list it is a Wikipedia page nobody finishes. Told as a timeline it becomes a horizontal scroll with the same card repeated thirty-one times, and a reader learns the dates without ever feeling the change.
The harder problem is that the interesting part of this history is not the sequence of releases. It is the shift in what the field was willing to tell you about itself, and a format that only lists achievements cannot show an absence.
So it was built as a building rather than a page: eight rooms a reader walks through, where the architecture carries the argument and one exhibit is made entirely of missing data.
What was built
Forty-three pages at /ai-museum. Thirty-one milestone exhibits across five era rooms, six pioneer portraits, five artifact essays, a lobby with a floor plan, and a guided tour of eight works for a reader with fifteen minutes rather than an afternoon.
Every room is a full-bleed section with its own wall colour, running warm to cool as time advances, so the early years read as old paper and the present as a contemporary white cube. Each room is entered through a threshold: a centred panel with the era numeral, name, years and work count, which is a deliberate pause rather than a heading.
Content is data, not markup. Three TypeScript arrays and forty-two JSON files drive all forty-three pages, the sitemap, the structured data, the homepage timeline and the guided tour. Nothing is hand-authored twice, which is what makes the museum extendable at all.
The gallery closes on a chart of every disclosed parameter count on a log scale, from the Transformer's 65M to Kimi K3's 2.8T. Three.js spheres mark the entrance and the exit, and navigating from a work in the gallery to its exhibit page morphs the title across the navigation rather than replacing the page.
How it is put together
Next.js App Router, statically generated. Every exhibit is a route built at compile time from the data files, so there is no database and no server doing work at request time. Tailwind v4 with tokens declared in CSS rather than a config file, Framer Motion for reveals, React Three Fiber for the two spheres.
The load-bearing decision is that the era definitions live in exactly one place. Room colour, accent, year bounds and work count are read by the gallery, the sticky signage, the floor plan and every exhibit page. A milestone whose year falls outside every era range renders nowhere and fails silently, which is the one sharp edge in the model and is documented where someone will hit it.
The path of one document
- 01A milestone is one entry in a TypeScript array plus one JSON file matching a typed shape.
- 02Its year, a decimal, places it in exactly one era, which determines the room it hangs in and the accent colour it carries.
- 03The gallery renders one section per era, painted with that era's wall tint, with one work on the feature wall and the rest as catalogue rows.
- 04The exhibit page inherits the room's wall colour, so opening a work does not change rooms.
- 05The same array feeds the sitemap, the JSON-LD, the homepage timeline and the guided tour. None of them holds a second copy.
What a row means
lib/eras.ts → lib/milestones.ts → content/milestones/<slug>.json → /ai-museum/<slug>The chain runs one way and every consumer reads from the top of it. The alternative, which is what most content sites do, is to let a component declare its own copy of the list so it can render something slightly different. That is how a homepage teaser ends up disagreeing with the index it teases. Here the teaser and the index are the same array sliced differently, so they cannot drift.
Module map
- lib/eras.ts
- Five eras: year bounds, wall tint, accent. Consumed by the gallery, the sticky signage, the floor plan and every exhibit page. Never redeclared in a component.
- lib/milestones.ts
- Thirty-one works with a decimal year and an optional parameter count. The optional field is the exhibit; see the decisions below.
- content/milestones/
- Thirty-one JSON files, each typed against an interface. A missing file is a 404, not a half-rendered page.
- components/museum/
- EraTimeline, the three navigation components, RoomPlate, ScaleRibbon, Colophon, and the visit-memory store.
- lib/seo.ts
- The schema.org graph. Article for exhibits, ProfilePage for pioneers, CollectionPage for the museum, breadcrumbs throughout. About 1 KB gzipped per page, measured against production.
The application



Decisions
Each era is a room, and the walls change as time advances.
Five wall colours run from aged light brown to near-white, so the Foundation era feels lit and the Agentic era feels clinical. This is the whole argument of the piece delivered without a sentence of copy. It also does structural work: a reader always knows roughly where they are in nine years by the colour of the wall behind them, which no progress bar achieves. Every wall was contrast-checked against every text tone and accent that appears on it, worst case 5.3:1.
An absent parameter count is the exhibit, and is never estimated.
Sixteen of thirty-one works published a parameter count. Fifteen did not. The chart draws the fifteen as unmarked ticks on the axis: present, unmeasured. Filling those with a rumour would have produced a smoother curve and destroyed the only finding the exhibit has, which is that after 2023 every disclosed figure belongs to an open-weight model and not one closed frontier system has stated its size. The measure did not get harder to take. It stopped being offered.
The chart separates its series by shape, not by colour.
Open weights are filled circles, closed models are hollow ones. The two hues chosen first looked clearly distinct and measured only 1.07:1 apart in relative luminance, which means they merge in greyscale and for a red-green colour-blind reader. Colour is decoration here and the shape carries the meaning. The chart also ships a full data table and an aria-label stating the finding in words, because a chart nobody can see should still be readable.
Contrast is computed, never judged.
The dark lobby was the worst offender and looked fine: its muted text measured 4.03:1, and two greys beneath it 2.58:1 and 1.89:1. One pass of a script converting hex to relative luminance and applying the WCAG formula found four failures that no amount of looking had caught. amber-700 was the documented accent for months at 3.93:1 and is now banned project-wide. Nothing here is a matter of taste; it is arithmetic, and it is cheap to run.
Visit memory reads localStorage as an external store.
Tracking which exhibits a reader has seen is the textbook case for useState plus useEffect, and that version is wrong: it renders once with the empty value and then corrects itself, which is a visible flash and a hydration mismatch waiting to happen. useSyncExternalStore models it correctly, and the server snapshot is empty so hydration always agrees. The subtlety is that getSnapshot must return a stable reference when nothing changed. Returning a freshly parsed array each call type-checks, passes review, and re-renders forever.
function getSnapshot(): string[] {
let raw: string | null = null;
try {
raw = window.localStorage.getItem(KEY);
} catch {
raw = null;
}
if (raw !== cachedRaw) {
cachedRaw = raw;
try {
const parsed = raw ? JSON.parse(raw) : [];
cachedList = Array.isArray(parsed)
? parsed.filter((x): x is string => typeof x === "string")
: EMPTY;
} catch {
cachedList = EMPTY;
}
}
return cachedList;
}Reduced motion needs two mechanisms, because one cannot reach the other.
A CSS prefers-reduced-motion rule cannot stop Framer Motion, which animates via JavaScript transforms and never consults the stylesheet. The View Transitions API does not consult the preference on its own either. So the museum wraps itself in a motion config honouring the user setting, and separately disables the view-transition animations in CSS. Shipping only the CSS half would have looked correct in a code review and done nothing for the reader who asked for it.
A performance win was refused three times, and the refusal is recorded.
content-visibility: auto would genuinely help the thirty-one catalogue rows. It is still not enabled, because those rows reveal via a viewport-triggered animation, and if the browser defers rendering the reveal may never fire and leave a row stuck at zero opacity. That failure is invisible in server HTML and so cannot be caught by the way everything else here is verified. It goes in after somebody watches it in a real browser, and not before.
What it found
4.03:1
The contrast ratio of text that looked perfectly readable.
Found by computing rather than by looking, along with two worse cases at 2.58:1 and 1.89:1 in the same pass. Every text and background pair in the museum now passes WCAG AA. Touch targets went from about 23px to 44px in the same audit, which was also invisible until it was measured.
16 of 31
Works that published a parameter count at all.
The other fifteen are drawn as unmarked ticks rather than omitted or estimated. After 2023, all eight disclosed figures belong to open-weight models. The exhibit is the gap, and it only exists because the data model allows a field to be genuinely absent rather than defaulting it to zero.
43 pages, 3 arrays
Everything is generated from data, nothing is authored twice.
Forty-three routes, the sitemap, the structured data, the homepage timeline and the guided tour all read from the same three TypeScript arrays and forty-two typed JSON files. Adding a work is one array entry plus one file.
What it does not do
- There is no search and no filter across the thirty-one works. A reader who wants a specific model browses to it or uses the floor plan.
- Rooms are not their own routes, so an era cannot be deep-linked as a page and the gallery ships as one long document.
- content-visibility is deliberately not enabled on the catalogue rows, for the reason given above. The performance left on the table is real.
- English only. Three languages were researched and postponed rather than half-built.
- The title morph between gallery and exhibit cannot be verified from server HTML, because the transition name is applied by React at transition time. It is built to degrade to a plain navigation where the browser lacks support.
- The museum is an authority asset, not a funnel. Nobody tours it and then buys anything, and it is not built to make them.
Where it stands
Live at /ai-museum and part of this site. Complete rather than parked: it is extended when the field produces something worth hanging, most recently to bring it current through August 2026, which added nine works and a fifth era.
Every fact in it was researched against primary sources and every citation resolves. Room photography comes from the Pexels API and is credited in a colophon, which is a licence condition of that API rather than a courtesy.