i18n

c.var.t is always there.

Core seeds every request with a translator over the English each composed capability contributed — so a capability writes a lookup with no null check and no config, and @pithy-sh/i18n replaces that translator with one that negotiated the reader’s locale and merged catalogs behind it.

A project that never composes it is byte-identical to one from before any of this existed. Same strings, same bytes, no negotiation, no merge.

That property is what the whole design is arranged around, and it is why the capability is optional rather than a dependency of core.

The seam pattern, for the third time

The i18n capability is what resolves the seam, supported locales is what ships, and errors is the one payload that deliberately does not translate.

This is the same shape as two other things in the kit, and seeing it as a pattern is worth more than seeing it three times:

SeamInert behaviorFilled by
The translatorThe baked Englishi18n
The audit recorderA no-opaudit
The entitlement resolverDeniespayments

Present and inert, filled by composition. No call site changes when one arrives, because every call site was already calling the seam.

Composing i18n on a live project is therefore a config edit and a redeploy. Every screen, every error and every email starts negotiating, and nothing had to be prepared for it.

Two locales, and only one of them falls back

The reader’s tag does two different jobs, and collapsing them is a real bug rather than a theoretical one.

What it isFalls back?
Catalog localeThe locale whose catalog answers a lookup — the words somebody wroteYes. A regional variant gets the base language, because the base language is what is written
Formatting localeThe locale handed to IntlNo. The variant is passed straight through

So a reader in Buenos Aires gets Spanish sentences and Argentine dates, numbers and currency — from one translator, with nobody writing a regional catalog.

Collapse them and that reader gets Spanish words with American number formatting, which is the bug this table exists to prevent.

Nothing is bundled for formatting

The Workers runtime embeds full ICU, verified at the kit’s own compatibility date.

Never add an Intl polyfill, a formatting library, or locale data, and never move the compatibility date for the sake of Intl. It is already there.

Two negotiation chains

localStorage does not exist in a Worker, and the browser’s language API inside the Workers runtime is a constant. So the two sides have genuinely different links.

Server: the URL parameter, then the signed-in reader’s account, then a cookie, then the request header, then the default.

Browser: the query, then the account, then storage, then what the server said, then the default.

Both orders are configuration, and the default is the last resort whether or not it is listed.

The account outranks the device. A reader who picks Spanish on their phone is not reading French on their laptop because that laptop’s storage holds an older choice. A signed-in reader’s choice is written through to their account rather than only to storage.

The request header is honored as the whole weighted list, not its first entry. A header asking for Portuguese, then Spanish, then English, from a project with no Portuguese, is a request for Spanish — and reading only the head answers English. A malformed header falls through to the default rather than throwing, because a 500 on the request path is not a negotiation strategy.

An override is a merge, not a fork

A lookup walks layers per key: your catalog for the resolved locale, your catalog for the default, the kit’s translation, then every composed capability’s English.

Per key, never per catalog. So overriding one sentence is one entry, and every key you did not mention keeps flowing from the package.

The kit’s translations are never copied into your repository. If they lived in your tree, a typo fix or a new locale could never reach you, and every adopter would become a fork on the day they scaffolded.

Passing a whole locale object is the fork — which is why no eject command is offered for this, and none is needed.

English is the source, not a catalog

The kit ships translations and no English catalog, and that is not an omission.

An error carries its English on the wire. A capability contributes its English through the composition contract. A scaffolded screen carries the English it was scaffolded with. A second copy would be a second place for one sentence to drift.

What stays English, permanently

Error payloads. The message on the wire is English forever, carrying structured parameters beside it — so a translating client renders its own string from the code and falls back to the English when it has none. The code is the translation key, so there is no second identifier to keep in sync.

Operator diagnostics. Logs, CLI output, audit rows. Read by you and by whoever is on call, and a stack trace in a language the responder does not read is worse than useless.

Bring your own stack

Adapters exist for the common i18n libraries, and none of them is a dependency of this package — nor can one become one. You bring yours, or you use the built-in translator.

Writing an adapter for anything else is a supported path.

Coverage is a doctor finding

Not a command of its own, and it fails the exit code — so a locale with gaps is caught in CI rather than by a reader.

That placement is deliberate: coverage is a health question about a project, and health questions live in one report.

ESC