Our test shells have a header of their own: a back link, a title, a pending label while the app exits, and an "Optional" badge. Four strings that belong to the application rather than to the content, repeated above every one of our practice tests.
A large share of our users are outside the UK, the Netherlands is one of the markets we build for deliberately, and several of the providers we cover are Dutch, so the first step toward a Dutch test is making those four strings translatable. Here is the whole change:
/** The header's own words. */ const LABELS: Record<GameLocale, { back: string; exiting: string; optional: string }> = { en: { back: 'Back to Games', exiting: 'Exiting…', optional: 'Optional' }, nl: { back: 'Terug naar de tests', exiting: 'Afsluiten…', optional: 'Optioneel' }, }; with a prop that defaults, so the 187 files rendering this shell carry on exactly as before:
export function GameContainer({ metadata, children, onExit, timer, info, locale = 'en' }: GameContainerProps) { const labels = LABELS[locale]; return ( <div lang={locale} className="bg-background flex h-dvh w-full flex-col overflow-hidden"> No i18n library, no message catalogue, no extraction step. A Record keyed on a union type, which the compiler will refuse to let you leave incomplete the day a third language is added, and lang on the root element so assistive tech reads it in the right voice. Four strings do not need machinery; they need a type that makes the next person fill in every slot.
Two details that are easy to skip:
-
aria-labelandpendingLabelboth come from the same record. The visible label hides below thesmbreakpoint, because at 320px the full label plus the question counter overlapped, so the accessible name is the only place that text exists on a phone. Translating the visible string and not that one would have left the Dutch screen with an English accessible name. -
langis on the shell, not on<html>. The shell is the only subtree whose language this knows about.
The commit that should have come first
GameLocale is three lines in a types file:
/** Languages a game's shell can render its own words in. */ export type GameLocale = 'en' | 'nl'; It landed on 18 October, in a commit of its own containing nothing else. The component that imports it landed on 13 October.
In between, components/games/shared/GameContainer.tsx had this at the top:
import type { GameLocale, GameMetadata } from '@/lib/games/types'; and lib/games/types.ts exported no such name. Check out that commit and git show 0ce561d6:lib/games/types.ts | grep -c GameLocale prints 0.
A type-only import of a name that does not exist is a special kind of mistake, because of who does and does not notice:
- The editor notices immediately, which is presumably how the type got written in the first place. It just never got saved into the commit.
- The test run does not notice. Test transforms strip type-only imports without resolving them, so the suite stays green.
-
tsc --noEmitnotices, and so doesnext build, which type checks by default. That is a failed deployment, which is the part that actually matters: a type error in a file nobody is currently editing is a build that fails for everybody.
So the five days between those two commits were five days in which the repository could not deploy. Everything merged in that window, including a substantial pricing change, went to production only when the three-line commit finally landed and the next build went green. Checking out the tip and running pnpm typecheck would have taken under a minute at any point.
The cheap habit that catches this exact class of bug is a deployment check rather than a local one: pick a string the newest commit changed on a public page, and look for it on the live site. If it is not there, nothing since has shipped, whatever the commit log says.
The second locale has no callers
Here is the honest state of the feature: nothing passes locale="nl". 187 files render this shell and every one of them takes the default. The Dutch labels are in the bundle and unreachable.
I think that is the right order of work, and it is worth saying why rather than presenting it as a finished feature. The hard part of localising a test is not the chrome, it is the content: the instructions, the generated items, the feedback, and the question of whether a verbal reasoning exercise translated out of English is even the same exercise. The chrome is the part you can do in an afternoon and get wrong forever if you do it after everything else, because by then the strings are spread across 187 files instead of one.
What it buys today is a compile-time list of the languages this shell claims to support, and a single place where the fifth string will have to be added.
See it
The shells are live in English today, and the Dutch-market providers they would serve are the reason the type exists:
- cogniprep.app/games/politie covers the Dutch police cognitive test. The page is in English; the test shell underneath it is the component above.
- cogniprep.app/games/ixly covers a Dutch publisher's test toolkit, and is a good illustration of why the chrome is the easy half: the words around a test are ours to translate, the material is not.
- cogniprep.app/games is the full provider list, if you want a sense of how many headers one
Recordof four strings sits above.

