# Internationalization

> Voltro's i18n layer (@voltro/i18n) — an opinionated wrap over react-intl, auto-wired from a single app.config.ts field, with cookie + Accept-Language locale resolution.



---

<!-- source: en/i18n/overview.md -->
## Overview

_Voltro's i18n layer (@voltro/i18n) — an opinionated wrap over react-intl, auto-wired from a single app.config.ts field, with cookie + Accept-Language locale resolution._

Voltro ships internationalization in **`@voltro/i18n`** — an opinionated, thin wrap over [`react-intl`](https://formatjs.io/docs/react-intl/) plus framework auto-wiring. A web app gets a working `<I18nProvider>` from a single field in `app.config.ts`. You never write the provider, and you never import `@voltro/i18n` in your layout.

The public surface is intentionally small — `<I18nProvider>`, `<T>`, `useT`, `useTFn`, `useLocale`, `useMessages`, `defineCatalog`, `defineLocale`, `pickCatalog`. Power users who need an API the wrap doesn't expose (custom formatters, rich-text with React-element values, `IntlProvider`'s `timeZone` / `formats` props) `import { … } from 'react-intl'` directly. The wrap is **opt-in, not lock-in** — the library is in your `node_modules`, the wrap is optional.

## When to enable

Set `locales` in `app.config.ts` **the moment** your app ships more than one user-facing language, or expects to soon. The infrastructure has **zero cost when `locales` is unset** — no provider is generated, no bundle overhead. Enabling early avoids a painful retro-fit when the first translation request lands.

Don't enable just because the app *could* theoretically be translated. The framework's empty-i18n state is fine for English-only apps, and adding the wiring later is a one-config-field change.

## Setup — auto-wiring

```typescript
// apps/<project>/<app>/app.config.ts
export default {
  type:  'web' as const,
  name:  'myApp',
  port:  5191,
  // The languages this app serves. Drives `<html lang>`, the resolved
  // locale, and the `locale` your page `meta` receives.
  locales: ['en', 'de'] as const,
  // Fallback locale when no cookie / Accept-Language matches a
  // supported one. MUST be in `locales`. Pick your source-of-truth
  // language — typically English.
  defaultLocale: 'en' as const,
  // The zone every date/time formatter renders in. Omit it and each
  // runtime uses its own — the pod's on the server, the viewer's in the
  // browser — which is a hydration mismatch on every SSR timestamp.
  // An IANA name pins one zone for everyone; 'viewer' resolves it per
  // request from the `voltro:tz` cookie.
  timeZone: 'viewer' as const,
  defaultTimeZone: 'UTC' as const,
}
```

The framework's generated `.framework/app.tsx` wraps the Router in `<I18nProvider>` automatically when your app **ships catalogs**. You don't write the provider yourself; you don't import `@voltro/i18n` in your layout.

### Declaring languages is not the same as adopting the catalogs

`locales:` states which languages the app serves. Whether `@voltro/i18n` gets
wired is decided separately, by whether `src/locales/<code>.ts` exists for every
listed code:

| `locales:` | `src/locales/*` | what you get |
|---|---|---|
| declared | present | `<I18nProvider>` wired, `useT()` works, plus the language facts |
| declared | absent | the language facts ONLY — `<html lang>`, the resolved locale, `meta.locale`. Bring your own i18n stack. |
| absent | — | everything falls back to `'en'` |

The second row exists because the two used to be one switch, and that made
`locales:` unusable for an app with its own i18n: declaring it demanded catalog
files and failed the boot, so the option got left out — and then `<html lang>`
was `"en"` and every page's `meta` received `locale: 'en'`, for every visitor, in
an app that is not English. That is worse than useless: a page that trusted the
value would have rendered the wrong language.

`voltro dev` prints which mode it is in, so a declared-but-unwired app is never a
silent surprise.

## Locale resolution order

Server-side, the active locale is determined by, in priority order:

1. **`voltro:locale` cookie** — the user's explicit choice (written by `@voltro/ui-shadcn`'s ProfileMenu).
2. **`Accept-Language` header** — the browser/OS preference, q-weighted and sorted per RFC 4647.
3. **`defaultLocale`** — last-resort fallback.

The resolved locale is **guaranteed** to be one of the codes in `locales`. Any unsupported value (a cookie pointing at a code you no longer ship, a browser asking for `xx-YY`) falls through to the next signal. RFC 4647 lookup strips subtags one segment at a time — `de-CH-1996` → `de-CH` → `de` — so a `de` catalog serves a `de-CH` browser.

The client **adopts what the server resolved**, reading it from the `<html lang>` attribute the server render sets, then falling back to the cookie and the default. `Accept-Language` is never read in the browser: `navigator.languages` can diverge from what the server saw.

> Earlier versions said the client "mirrors cookie and default for hydration safety". That was the opposite of what happened. Dropping the `Accept-Language` signal is not the same as agreeing with the server about it — on a first visit, with no cookie yet, the server negotiated `Accept-Language` while the client fell through to `defaultLocale`. An English browser on a German-default app therefore hydrated `de` over an `en` tree and React discarded the entire server render, which is exactly what SSR was enabled to avoid. It stopped as soon as anything wrote the cookie, so one language switch made it un-reproducible for that developer.

`<html lang>` carries the same resolved locale — the value the `<I18nProvider>` renders with, on the same request. That matters on its own: it is what a screen reader pronounces in, what Chrome offers to translate *from*, and what hyphenation uses.

### The same contract carries the render zone and the render clock

`lang` is one of three answers the server decides and publishes so the client does not form its own:

| attribute | what it carries |
|---|---|
| `lang` | the resolved locale |
| `data-voltro-tz` | the IANA zone every date/time formatter renders in — set `timeZone` in `app.config.ts` |
| `data-voltro-now` | the server's render instant, so `useRelativeTime` produces the same string in the hydration pass |

Locale was already agreed; the zone and the clock were each read from the ambient runtime, which meant a server-rendered timestamp was a hydration mismatch waiting for a wide enough offset or a slow enough connection. See [Plurals & formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference) — that is the page to read before you migrate hand-rolled `toLocaleString()` calls onto the hooks.

See [Catalogs](/docs/i18n/catalogs) for the type-safe catalog convention and the component hooks, [Plurals & formatting](/docs/i18n/formatting) for CLDR plural selection and the `Intl`-backed date / number / relative-time hooks, and [URL strategies](/docs/i18n/url-strategies) for cookie-only vs URL-prefix routing.

## The cookie names are exported

`LOCALE_COOKIE` and `THEME_COOKIE` come from **`@voltro/i18n`** (and from `@voltro/ui-shadcn` if you use the kit):

```ts
import { LOCALE_COOKIE, THEME_COOKIE } from '@voltro/i18n'
```

Import them rather than retyping `'voltro:locale'`. A cookie name the framework READS and your app WRITES is a public API, and it is the only kind where both sides can disagree without anything failing: nothing throws, no page breaks, the resolver simply finds nothing and falls back to `Accept-Language`. The symptom is a preference that stops working for the subset of users whose browser language differs from their choice — the least likely thing anyone tests.

> Until 0.31.0 the only package exporting these was `@voltro/ui-shadcn`, and `voltro doctor` told you to import from there. An app on this package and not on the shadcn kit had no constant to reach for, and would have had to adopt a UI kit for two strings. Reported by a deployment, who added that the rule "does not fire for us, and we think that is correct-by-accident".

## `Could not find required 'intl' object` during SSR

This throw has two causes that produce byte-identical output: there is no provider above the component, or there IS one and it was built from a **different physical copy** of `react-intl`. React contexts are object identities, so a provider from one copy is invisible to a `useT()` from the other.

`voltro dev` knows whether it supplied a provider for that request, and since 0.31.0 it also counts the live copies. The diagnosis printed with the failure names both:

```text
── voltro diagnosis ───────────────────────────────────────────────────
ssr-dev DID wrap this render in <I18nProvider>, and this process
holds 2 live copies of @voltro/i18n bound to
2 distinct react-intl instances.
```

**Do not use `pnpm ls` to rule this out.** It enumerates versions ON DISK; the failure is module INSTANCES in a running process, and one file loaded down two paths is two instances. A deployment reported a 500 on every SSR page with exactly one version of each installed — they were right, and the check we had published could not observe the cause. If the count says one of each, it is a framework bug and the diagnosis says so.

The framework keeps both packages on one SSR instance by bundling them together (`ssr.noExternal`) and deduping them, in `voltro dev`, `voltro build` and `voltro start` alike. The remaining way to get a second copy is in your own code: building a provider from `react-intl` **directly** rather than from `@voltro/i18n`.



---

<!-- source: en/i18n/catalogs.md -->
## Catalogs & hooks

_Type-safe message catalogs with defineCatalog + defineLocale (parity-enforced), reading translations with useT / <T> / useLocale, ICU placeholders, code-splitting with defineCatalogs + LazyI18nProvider, and the react-intl escape hatch._

A **catalog** is a flat `Record<string, string>` of message ID → ICU MessageFormat template. Voltro picks flat-string format (instead of react-intl's `{ defaultMessage, description }` objects) because translation tools (Crowdin / Lokalise / Phrase) import flat string maps natively, the format diffs cleanly in code review, and the base catalog *is* the source of truth — `defaultMessage` becomes redundant.

## Catalog files — type-safe convention

```typescript
// src/locales/en.ts — base catalog, source of truth
import { defineCatalog } from '@voltro/i18n'

export default defineCatalog({
  'header.cta':     'Get started',
  'home.greeting':  'Hello, {name}',
  'errors.network': 'Connection lost. Retry?',
} as const)
```

```typescript
// src/locales/de.ts — MUST mirror en exactly
import { defineLocale } from '@voltro/i18n'
import en from './en'

export default defineLocale<typeof en>()({
  'header.cta':     'Loslegen',
  'home.greeting':  'Hallo, {name}',
  'errors.network': 'Verbindung verloren. Erneut versuchen?',
})
```

`defineCatalog` is an identity function that pins the catalog's literal-key type. `defineLocale<typeof en>()` returns a **curried** function whose argument **must mirror the base catalog's keys exactly** — missing or extra keys fail at `tsc --noEmit`, not at runtime. This is what prevents "translation drift", where non-base locales silently fall out of sync as the base catalog grows.

This parity check is the whole point of having a base catalog. **Don't skip `defineLocale<typeof en>()`** for non-base catalogs — plain `defineCatalog(...)` works but loses the type-level enforcement.

## ICU placeholders

Keep ICU MessageFormat placeholders **identical across locales** — `react-intl` validates them at render time. That covers simple interpolation (`{name}`) and the full ICU surface — plurals, select, ordinals:

```typescript
// en.ts
export default defineCatalog({
  'cart.items': '{count, plural, one {# item} other {# items}}',
} as const)
```

```typescript
// de.ts
export default defineLocale<typeof en>()({
  'cart.items': '{count, plural, one {# Artikel} other {# Artikel}}',
})
```

**Don't roll your own ICU MessageFormat.** Pluralization, gender, and ordinals across 50+ languages is a solved problem; reimplementing it produces bugs that only surface in specific locales (Turkish dotted-i, Arabic plurals, Finnish cases).

**Check placeholder parity, not just key parity.** `defineLocale` enforces that every locale has the same KEYS, but not that each message has the same `{var}` set — a translation that drops or renames a `{var}` compiles and boots, then throws `The intl string context variable "date" was not provided` only in that locale, only when the message renders. `assertCatalogParity` catches it up front:

```ts
import { assertCatalogParity } from '@voltro/i18n'
import en from './locales/en'
import de from './locales/de'

assertCatalogParity({ en, de }) // throws, listing every {var} drift
```

Call it in a test (or at boot with `{ onMismatch: 'warn' }`). Plural argument names are compared; a plural's inner `{# item}` branches are not mistaken for placeholders.

## Reading translations in components

```tsx
import { useT, useTFn, T, useLocale } from '@voltro/i18n'

// JSX form — preserves rich-text capabilities (component injection).
<T id="home.greeting" values={{ name: 'Mario' }} />

// Imperative form — for non-JSX contexts (aria-label, placeholder,
// document.title, toast). Returns a plain string.
const cta = useT('header.cta')
const greeting = useT('home.greeting', { name: 'Mario' })

// Function form — capture once, replay inside helpers / list maps /
// conditional branches without N useIntl() calls per render.
const t = useTFn()
const labels = items.map((i) => t('cart.items', { count: i.count }))

// Active locale code (for switching CSS rules, locale-aware date
// pickers, etc.).
const locale = useLocale()
```

- **`<T id="…" />`** — JSX form. Use this whenever you're rendering a string into the tree; it preserves rich-text (React-element values).
- **`useT(id, values?)`** — imperative form returning a plain string, for non-JSX call sites: `placeholder`, `aria-label`, `document.title`, toasts, error messages.
- **`useTFn()`** — returns a `(id, values?) => string` translator the component captures once. Use when one component needs `t` inside helper functions or `.map()` callbacks — `react-intl` runs once, the closure replays.
- **`useLocale()`** — the active locale code, as React state inside the provider.
- **`useMessages()`** — the active locale's RAW catalog (`useMessages()['some.id']`): the unformatted ICU template, not the formatted output. For when you need the raw string — e.g. to feed your own formatter, or a lookup that must not run ICU.

## Compile-time keys AND params — `createTypedMessages`

`defineLocale` catches a missing KEY at compile time, but the bare `useT` / `useTFn` don't type the KEY or the PARAMS at the *call site* — so `useT('home.greeting')` (a message that needs `{name}`) compiles and then throws at render (`The intl string context variable "name" was not provided`). `createTypedMessages<typeof en>()` binds the catalog's literal message types to `useT` / `useTFn` / `<T>` so both are compile errors:

```tsx
// src/i18n.ts — call once with your base catalog, re-export the result
import { createTypedMessages } from '@voltro/i18n'
import type en from './locales/en'

export const { useT, useTFn, T } = createTypedMessages<typeof en>()
```

```tsx
// now, everywhere you import from './i18n' instead of '@voltro/i18n':
useT('home.greeting')              // ✗ compile error — expected { name: … }
useT('home.greeting', { name })    // ✓
useT('header.search')              // ✓ no placeholders → no values arg
useT('does.not.exist')             // ✗ compile error — not a catalog key
```

It's almost pure type refinement — the runtime is the same `useT` / `useTFn` / `<T>` (plus a `.dynamic` escape, below), only the signatures narrow to your catalog. Requires the base catalog to be `as const` (so its message strings survive as literal types).

**Scope.** Simple `{name}` and single-argument `{count, number}` placeholders are extracted and required. A *nested* inline ICU message (`{count, plural, one {…} other {…}}` / `select`) is fully parsed: the **top-level arg** (`count`) AND a real var nested inside a branch are both required, while a branch's literal text is never mistaken for a var. So for `'{count, plural, one {# blocker in {discipline}} other {# blockers in {discipline}}}'`, `t('blockers', { count, discipline })` is required — omit `discipline` and it's a compile error, not a render-time throw:

```tsx
t('blockers', { count: n, discipline })   // ✓ both required — discipline lives inside the branches
```

`<T>` gets a typed key with loose values, because its rich-text `<tag>` renderers can't be modelled by placeholder extraction.

### Runtime-computed keys — `t.dynamic`

For a key you build at runtime, do NOT cast it to the catalog key union — that union spans placeholder-bearing keys, so the call then demands a spurious 2nd ICU arg. Use `t.dynamic`, a plain-string escape with no forced args, on both `useT` and the `useTFn()` result:

```tsx
import { useTFn } from '@app/messages' // your createTypedMessages() barrel — `.dynamic` lives on these
const t = useTFn()
t.dynamic(`status.${row.state}`)                 // ✓ plain string, no forced arg
t.dynamic(`greeting.${kind}`, { name })          // values still allowed, but loose
```

### Passing `t` across a package boundary — `LooseTFunction`

Type a `t` pass-through param as `TypedTFunction<C>` to keep it strict — the strict type propagates down through helper signatures. At a boundary that *can't* import your catalog (a shared UI package), type the param `LooseTFunction` (`(id: string, values?) => string`) instead of `(...args: any[]) => string`, and pass `t.dynamic` — which IS a `LooseTFunction`. A strict `TypedTFunction` is deliberately NOT assignable to the loose one (a narrowed key param can't satisfy a wider one, and silently allowing it would erase the checking).

```tsx
// a shared package that can't see the app catalog
import type { LooseTFunction } from '@voltro/i18n'
export const formatError = (t: LooseTFunction, e: AppError): string => t(e.messageKey)

// in the app, at the boundary:
formatError(t.dynamic, err)
```

A convenient app-wide barrel captures the bound types once:

```tsx
// src/i18n.ts
import { createTypedMessages, type TypedTFunction } from '@voltro/i18n'
import type en from './locales/en'

export const { useT, useTFn, T } = createTypedMessages<typeof en>()
export type AppTFunction = TypedTFunction<typeof en>        // for helper / prop params
export type AppMessageKey = Parameters<AppTFunction>[0]     // the catalog's key union
```

## Reading a message outside React — `meta({ locale })`

`useT` is a hook — it only runs inside a component, against the *active* locale. A page's `meta({ locale })` runs OUTSIDE React (at build / SSR meta-resolution time) and is handed an arbitrary locale string. Use **`pickCatalog`** to resolve a message there:

```ts
// src/locales/index.ts
import { pickCatalog } from '@voltro/i18n'
import en from './en'
import de from './de'

export const getCatalog = (locale?: string) => pickCatalog({ en, de }, locale, 'en')
```

```tsx
// any page — meta gets the active locale (from the URL prefix, or the voltro:locale cookie)
import { getCatalog } from '../locales'

export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
  title: getCatalog(locale)['meta.home.title'],
})
```

`pickCatalog(catalogs, locale, defaultLocale)` returns the concrete catalog type, so a known-key lookup is `string` (not `string | undefined`) — exactly what `PageMeta.title` needs. An unknown or `undefined` locale falls back to `defaultLocale`.

## Code-splitting catalogs — `defineCatalogs`

### Why `pickCatalog` cannot split

`pickCatalog({ en, de, fr }, locale)` is a **static import map**. Every catalog is a value-level `import` of the module that builds the map, so the bundler has no choice but to put all of them in one chunk — a visitor who will only ever see German downloads English and French too. At real catalog sizes that becomes the single largest client chunk in the app, and it **grows linearly with every locale you add**.

No provider-side change can fix this. The cost is paid at *import* time, before any React code runs — by the time a provider knows which locale is active, all of them are already in the bundle.

The only thing a bundler treats as a chunk boundary is a **dynamic `import()`**. So the catalog map becomes a map of *loaders*:

```ts
// src/locales/index.ts
import { defineCatalogs } from '@voltro/i18n'

export const catalogs = defineCatalogs({
  en: () => import('./en'),
  de: () => import('./de'),
}, 'en')
```

Each arrow is its own chunk; a session fetches exactly the locales it uses. Loaders accept either an `export default` catalog or a bare map, so the catalog files from the top of this page work unchanged.

`defineCatalogs(loaders, defaultLocale)` returns:

- **`locales`** — the declared locales, in declaration order.
- **`defaultLocale`** — the base locale. Its type is constrained to the loader keys, so a `defaultLocale` you never declared a loader for is a **compile error**, not a runtime blank page.
- **`load(locale)`** — resolves the catalog, importing its chunk on first use. Concurrent callers share one import: two components mounting in the same tick will not race two fetches.
- **`peek(locale)`** — the catalog *if already loaded*, else `undefined`. Never triggers a fetch. This is what lets a provider render synchronously.
- **`preload(locale)`** — fire-and-forget cache warming (server boot, hover intent, a route transition that is about to switch locale).

An unknown locale resolves to `defaultLocale` rather than throwing — a stale cookie or a hand-typed URL prefix degrades to the base language instead of blanking the app. A **failed** load is not cached, so the next attempt retries: a 404'd chunk on a flaky network is recoverable.

### `<LazyI18nProvider>`

```tsx
import { LazyI18nProvider } from '@voltro/i18n'
import { catalogs } from './locales'

<LazyI18nProvider catalogs={catalogs} locale={locale} fallback={<AppSkeleton />}>
  <App />
</LazyI18nProvider>
```

A resolved catalog is cached per loader map, so switching back to a locale is synchronous and re-renders never re-import.

### The trade-off, stated honestly

The active catalog is now **asynchronous**, and first paint needs it resolved. There are two ways to keep that from becoming a flash of untranslated UI, and you should pick one deliberately:

- **`catalogs.preload(locale)` on the server, or before `hydrateRoot`.** The catalog is already in the cache, `peek()` hits, and the provider renders in the same tick as a static catalog would — no suspense boundary, no flash. This is the **right default**, and it costs no waterfall: the locale is known from the cookie or the URL prefix before React starts.
- **`fallback`.** Rendered for the one tick it takes to load a catalog that genuinely isn't in memory. It defaults to `null` — deliberately blank rather than a screen of untranslated message IDs.

So: **`fallback` is for a locale SWITCH, not for first paint.** On a switch the user already has a rendered page and a brief placeholder is fine. If your `fallback` is showing on first load, the preload is missing — fix the preload, don't dress up the fallback.

`meta({ locale })` is synchronous and runs outside React, so it still needs the static `pickCatalog` form. Keep that map in a module the client bundle doesn't import, or the static graph pulls every locale back into the browser chunk and undoes the split.

## The react-intl escape hatch

For features the wrap doesn't expose — custom formatters, `Intl` options, rich-text with React-element values — import from `react-intl` directly:

```tsx
import { useIntl, FormattedMessage, FormattedNumber, FormattedDate } from 'react-intl'
```

The wrap deliberately doesn't re-export everything, to keep the framework's blessed API small. The library is already in your `node_modules`.

## Anti-patterns

- **Don't bake locale-specific strings into shadcn primitives, hooks, or anything in `packages/*`.** Strings live in the consuming app's `src/locales/`. Framework and kit code stay string-free.
- **Don't read the cookie directly in components.** Use `useT` / `useLocale` — the active locale is React state, not browser state, inside the provider.
- **Don't use translated strings as React keys** or as switch discriminators. Use IDs / enums for control flow; translations are presentation only.
- **Don't translate code samples in markdown.** Comments inside code fences stay in the source language — readers copy code as-is, and translated identifiers don't compile.



---

<!-- source: en/i18n/url-strategies.md -->
## URL strategies

_Cookie-only vs URL-prefix routing (Strategy A / B), the URL-prefix integration sketch, resolveLocale on the /server subpath, the voltro:locale / voltro:theme cookie convention._

There are two ways an i18n app can encode the active locale. Pick **one**, based on what the app's URLs are *for*.

## Strategy A — cookie-only (default for product apps)

- All locales are served from the **same** URL (`/dashboard`, `/settings`).
- The `voltro:locale` cookie + `Accept-Language` determine which catalog renders.
- The ProfileMenu's language switch — or the standalone `<LocaleSwitcher>` from `@voltro/ui-shadcn` — writes the cookie and reloads.
- Use when URLs are **functional** (`/dashboard/projects/42/deployments`) and don't need to encode the language. This is most apps.

This is the zero-config default — the framework's auto-wired `<I18nProvider>` is cookie-driven, so Strategy A needs nothing beyond setting `locales` in `app.config.ts`.

## Strategy B — URL-prefix (default for docs / marketing)

- The default locale lives at the **bare** path (`/docs/foo`).
- Other locales **prepend** `/<code>` (`/de/docs/foo`).
- Each translated page has its own crawlable URL → better SEO, shareable language-specific links.
- Use when URLs **are** the product — search engines index them, links get shared with specific language intent.
- Requires extra wiring in the layout (below).

The two strategies can coexist across sibling apps: voltro-cloud's dashboard uses cookie-only; its docs app (the one you're reading) uses URL-prefix.

## Render mode constrains the choice

Strategy A isn't always *available* — it depends on how the page renders. Cookie i18n only shows the chosen language two ways: (1) the page **hydrates** on the client (`interactive: 'full'`, the default) and re-renders from the cookie, or (2) the page is **`ssr` / `isr`** and the server resolves the cookie per request.

A page that is `renderMode: 'static'` **and** `interactive: 'none'` / `'islands'` runs neither — its HTML is pre-rendered once in the default locale and never re-renders. Cookie i18n there is a **dead switcher**: it writes the cookie, reloads, and shows the same default-language HTML. Zero-JS static sites and islands pages therefore **must** use Strategy B, whose `[locale]` mirrors make `voltro build` pre-render one HTML file per locale — genuinely bilingual with no client JS.

| Page | i18n strategy |
| --- | --- |
| hydrated (`interactive: 'full'`) or `ssr` / `isr` | Strategy A (cookie) works |
| `static` + `interactive: 'none'` / `'islands'` | Strategy B (URL-prefix) required |

The init-templates follow exactly this split: the hydrated/SSR shells (`app`, `blank`, `spa`, `ssr`, `ssr-api`, `admin`, `dashboard`) ship cookie i18n; the static/islands ones (`landing`, `docs`, `static-blog`, `changelog`, `contact`) ship URL-prefix i18n.

## URL-prefix integration (Strategy B)

The framework's auto-wired `<I18nProvider>` is cookie-driven. For URL-prefix routing you need an **inner** provider that's URL-driven, so client-side navigation between `/docs/x` and `/de/docs/x` swaps the catalog without a full reload.

```tsx
// src/lib/locale.ts
import { useLocation } from '@voltro/web'

export const SUPPORTED = ['en', 'de'] as const
export const DEFAULT = 'en'
type Locale = (typeof SUPPORTED)[number]

const isSupported = (v: string): v is Locale =>
  (SUPPORTED as ReadonlyArray<string>).includes(v)

export const localeFromPathname = (p: string): Locale => {
  const m = /^\/([a-z]{2})(?:\/|$)/.exec(p)
  return m && isSupported(m[1]!) ? m[1] as Locale : DEFAULT
}

export const useUrlLocale = (): Locale => localeFromPathname(useLocation())

export const withLocalePrefix = (path: string, locale: string): string => {
  if (locale === DEFAULT) return path
  const p = path.startsWith('/') ? path : `/${path}`
  return p === '/' ? `/${locale}` : `/${locale}${p}`
}

export const stripLocalePrefix = (p: string): string => {
  const m = /^\/([a-z]{2})(\/.*|$)/.exec(p)
  return m && isSupported(m[1]!) ? (m[2] || '/') : p
}
```

```tsx
// src/pages/layout.tsx
import { I18nProvider } from '@voltro/i18n'
import en from '../locales/en'
import de from '../locales/de'
import { useUrlLocale, DEFAULT } from '../lib/locale'

const CATALOGS = { en, de } as const

const Body = ({ children }) => {
  // EVERY useT() / <T> in here reads from the inner provider
  // (URL-driven). useLocation in useUrlLocale triggers re-render
  // on client-side nav → inner provider re-renders with the new
  // catalog → all useT lookups update.
  return <>{children}</>
}

export default function Layout({ children }) {
  const locale = useUrlLocale()
  return (
    <I18nProvider locale={locale} messages={CATALOGS[locale]} defaultLocale={DEFAULT}>
      <Body>{children}</Body>
    </I18nProvider>
  )
}
```

Pair this with locale-prefixed page files: `src/pages/[locale]/page.tsx`, `src/pages/[locale]/docs/[...slug]/page.tsx`, etc. Each re-exports the default-locale query's component, which reads `useUrlLocale()` to decide which catalog data to query.

Reference implementations: `voltro-dev/apps/voltro-dev/docs/` (URL-prefix on dynamic + static content) and `voltro-dev/apps/voltro-dev/landing/` (URL-prefix on a pure static marketing site — 10 default-locale pages, 10 locale-prefixed mirrors, one combined `voltro build` run).

## SSG emission per locale — what `voltro build` does for you

When `locales` is set in `app.config.ts` AND the page tree includes `[locale]/…` mirror files, `voltro build` automatically emits per-locale static HTML at the URL-prefixed paths:

```
dist/index.html                        ← default locale (en)
dist/de/index.html                     ← de mirror
dist/features/foo/index.html           ← default locale
dist/de/features/foo/index.html        ← de mirror
```

Each variant ships with the right `<I18nProvider>`-wrapped body **and** the right per-locale `<title>` / `<meta description>` / `<link rel="canonical">` / OG tags — **if** the page's `meta` is exported as a function of `({ locale })`. With a plain static `meta: PageMeta` object, the body is correctly localised but the head tags stay default-locale on every variant.

```tsx
// src/pages/features/foo/page.tsx — meta as a function of locale
import { getCatalog } from '../lib/locale'
import { localeCanonicalUrl, ogTags, standardLinks } from '../lib/seo'

export const meta = ({ locale }: { readonly locale: string }) => {
  const c = getCatalog(locale)
  const title = c['seo.features.foo.title'] as string
  const description = c['seo.features.foo.description'] as string
  return {
    title,
    description,
    canonical: localeCanonicalUrl('/features/foo', locale),
    tags: ogTags({ title, description }),
    links: standardLinks('/features/foo'),
  }
}
```

The `meta(ctx)` callback runs once per (page × locale) at build time. The `ctx.locale` value comes from `params.locale` for the `[locale]/…` mirror routes; for the bare-path variant it's the app's `defaultLocale`.

### Mechanism

At build time the SSG pipeline pre-loads every catalog and wraps each rendered page in a per-locale `<I18nProvider>`. The `useT()` calls inside pages, layouts, and shared components resolve against the active locale's catalog — no IntlProvider context error, no client-side flash of untranslated content. This is invariant: it fires whether you set `locales` for client-routing reasons (Strategy A: cookies) or full SSG (Strategy B: URL-prefix).

The `[locale]/…` mirror is the trigger for SSG **output** — without mirror files, only the default-locale URLs are emitted (the framework won't guess that `/de/foo` should also exist). With mirror files, their `getStaticPaths` enumerate which locale-prefixed paths to render, and the build produces one HTML file per (page × locale) combination.

### Mirror file boilerplate

12 lines per page. Re-export the canonical page's `default`, `renderMode`, `interactive`, `meta`, plus a `getStaticPaths` that enumerates non-default locales:

```tsx
// src/pages/[locale]/features/foo/page.tsx
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../../lib/locale'
export { default } from '../../features/foo'
export { renderMode, interactive, meta } from '../../features/foo'

export const getStaticPaths = async (): Promise<
  Array<{ params: { locale: string } }>
> =>
  SUPPORTED_LOCALES
    .filter((l) => l !== DEFAULT_LOCALE)
    .map((locale) => ({ params: { locale } }))
```

Cloud-docs and voltro-dev both use this exact pattern — see them for the lib/locale.ts helpers (`SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `getCatalog`, `useUrlLocale`, `withLocalePrefix`, `stripLocalePrefix`).

## Server-side resolution — the `/server` subpath

```typescript
import { resolveLocale } from '@voltro/i18n/server'

// In any SSR-side layout, lib, or middleware:
const locale = resolveLocale({
  cookieHeader: ssr.headers.cookie,
  acceptLanguageHeader: ssr.headers['accept-language'],
  supported: ['en', 'de'],
  defaultLocale: 'en',
})
```

`resolveLocale` is what the framework's auto-wired entry uses under the hood — exposed here for consumers who need locale info **before** the React tree mounts: setting a `lang` attribute on `<html>`, emitting `hreflang` link tags, picking a locale-specific OG image. It applies the same priority order — cookie → `Accept-Language` → default — and the returned locale is guaranteed to be in `supported`.

The `/server` subpath is convention. Both that path and the main entry are pure JS (no Node-only APIs); the split exists so future server-only helpers stay out of browser bundles.

## Cookie convention

The framework reads + writes **`voltro:locale`** for the active language choice (lowercase IETF tag: `en`, `de`, `fr-CA`). `@voltro/ui-shadcn`'s ProfileMenu and the framework's auto-wired resolver agree on this name — **don't pick a different one in app code.**

For theme the parallel cookie is **`voltro:theme`** (values `'system' | 'light' | 'dark'`). Both are managed by the kit's ProfileMenu out of the box; the helpers live in `@voltro/ui-shadcn` (`THEME_COOKIE`, `LOCALE_COOKIE`, `getCookie`, `setCookie`, `deleteCookie`, `parsePreferenceCookies`, `applyTheme`).

## Anti-patterns

- **Don't mix strategies within one app.** Pick cookie-only *or* URL-prefix per app; mixing them produces ambiguous canonical URLs and broken language switching.
- **Don't read `Accept-Language` on the client.** It's server-only — `navigator.languages` can diverge from what the server saw and cause a hydration mismatch.
- **Don't invent your own cookie name.** The kit and the resolver only agree on `voltro:locale` / `voltro:theme`.
- **Don't hardcode the name either — import the constant.** `LOCALE_COOKIE` / `THEME_COOKIE` from `@voltro/ui-shadcn`. A cookie name the framework READS and your app WRITES is a public API, and it is the only kind where both sides can disagree with nothing failing: the resolver finds nothing, falls back to `Accept-Language`, and a user's language choice quietly stops working — but only for the users whose browser language differs from their choice, which is the least likely case anyone tests. With the constant, a rename in the framework is a compile error in your app. `voltro doctor` flags a written literal for exactly this reason.



---

<!-- source: en/i18n/formatting.md -->
## Plurals & formatting

_Locale-aware plural selection (CLDR via Intl.PluralRules) and Intl-backed formatters — plural, usePlural, useFormatDate, useRelativeTime, useFormatNumber, useFormatCurrency, useFormatters._

Two things go wrong in every app that ships `useLocale()` but no formatters.

The first is pluralization by string surgery: `` `${count} epic(s)` ``. That literal `(s)` is a guess that only reads as acceptable in English — and it isn't even correct there ("1 epic(s)"). Outside English and German it is simply wrong: Polish needs three forms for what English does with two, and no amount of parentheses expresses that.

The second is relative time. "3 minutes ago" looks trivial, so it gets written inline — and then again in another component, and again with a date library, until one app carries four divergent helpers, one of them hardcoded German. They disagree on rounding, on the sub-second case, and on the language.

`@voltro/i18n` closes both with `Intl`-backed primitives that resolve the **active** locale from the provider. Nothing to pin, nothing to hand-roll, and no dependency — `Intl.PluralRules` / `DateTimeFormat` / `NumberFormat` / `RelativeTimeFormat` are in every runtime the framework targets.

## `plural` — the pure core

```ts
import { plural } from '@voltro/i18n'

plural('en', 1, { one: '{count} epic', other: '{count} epics' })   // "1 epic"
plural('en', 3, { one: '{count} epic', other: '{count} epics' })   // "3 epics"
plural('en', 0, { one: '{count} epic', other: '{count} epics' })   // "0 epics"
```

`plural(locale, count, forms, options?)` selects the form using the locale's **real CLDR rules** via `Intl.PluralRules`, then substitutes every `{count}` occurrence. It takes the locale as an argument and touches no React, so it works in `meta({ locale })`, in a server handler, or in a test — the hook below is a thin binding of it.

`forms` accepts `zero`, `one`, `two`, `few`, `many` and `other`. **Only `other` is required**: it is the fallback for every category the caller didn't supply and for every category a locale doesn't distinguish.

### One/other is not enough — the Polish proof

```ts
const files = {
  one:   '{count} plik',
  few:   '{count} pliki',
  many:  '{count} plików',
  other: '{count} pliku',
}

plural('pl', 1, files)   // "1 plik"
plural('pl', 3, files)   // "3 pliki"    → few
plural('pl', 7, files)   // "7 plików"   → many
```

Polish distinguishes `few` (2–4) from `many` (5+). This is the exact case a hardcoded `(s)` or a hand-written `count === 1 ? a : b` cannot express — and it is not an exotic edge case, it is a language with 40 million speakers. Supply the categories the locale needs; the ones you omit fall through to `other`:

```ts
plural('pl', 3, { one: '{count} epic', other: '{count} epics' })   // "3 epics" — no `few` given
```

### Explicit zero

```ts
plural('en', 0, { one: '{count} epic', other: '{count} epics', zero: 'no epics' })   // "no epics"
plural('en', 1, { one: '{count} epic', other: '{count} epics', zero: 'no epics' })   // "1 epic"
```

`zero` is honoured for an **exact 0** even in locales whose CLDR category for 0 is `other` (English). Apps overwhelmingly want "no items" there rather than "0 items", and opting out is just omitting the key.

### Ordinals

Pass `Intl.PluralRules` options through as the fourth argument:

```ts
const ord = { one: '{count}st', two: '{count}nd', few: '{count}rd', other: '{count}th' }

plural('en', 1, ord, { type: 'ordinal' })   // "1st"
plural('en', 2, ord, { type: 'ordinal' })   // "2nd"
plural('en', 3, ord, { type: 'ordinal' })   // "3rd"
plural('en', 4, ord, { type: 'ordinal' })   // "4th"
```

An unknown locale tag falls back to `other` instead of throwing — a stale cookie renders English-ish output, not a crash.

## The hooks

Every hook below reads the active locale from the provider via `useLocale()` and returns a stable callback.

### `usePlural`

`plural` bound to the active locale — same `(count, forms, options?)` signature minus the leading locale:

```tsx
import { usePlural } from '@voltro/i18n'

function EpicCount({ count }: { readonly count: number }) {
  const plural = usePlural()
  return <span>{plural(count, { one: '{count} epic', other: '{count} epics', zero: 'no epics' })}</span>
}
```

### `useFormatDate`

```tsx
const formatDate = useFormatDate()

formatDate(order.createdAt, { dateStyle: 'medium' })
formatDate(order.createdAt, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'Europe/Berlin' })
```

`(value, options?) => string`, where `value` is a `Date`, a timestamp number, or a date string, and `options` is `Intl.DateTimeFormatOptions`.

**The zone comes from the provider, which you configure once** — `timeZone` in the web `app.config.ts` (see [Timezones under SSR](#timezones-under-ssr-the-setting-that-is-not-a-preference)). Pass `timeZone` in the options only when the value genuinely belongs to a fixed zone regardless of who is looking (a store's opening hours, a scheduled broadcast); that overrides the provider for one call.

**With `timeZone` unset in `app.config.ts` there is no zone at all, and each runtime falls back to its own.** On a server-rendered page that is the pod's zone for the markup and the viewer's for the hydration render — a mismatch on every timestamp, and a different calendar day across midnight. `useTimeZone()` returns `undefined` in exactly that state, so you can assert on it.

### `useTimeZone`

```tsx
const timeZone = useTimeZone()   // 'Europe/Berlin' — or undefined when none is pinned
```

`undefined` is a real answer and worth branching on: it means nobody decided, so the server and the browser are each using their own zone. It is not the same as `'UTC'`.

### `useRelativeTime`

```tsx
const relativeTime = useRelativeTime()

relativeTime(comment.postedAt)                        // "3 minutes ago" / "vor 3 Minuten"
relativeTime(job.runsAt)                              // "in 2 days"
relativeTime(comment.postedAt, { numeric: 'always' }) // "1 day ago" instead of "yesterday"
relativeTime(comment.postedAt, { now: renderedAt })   // measure against a fixed base
```

`(value, options?) => string`. Options are `Intl.RelativeTimeFormatOptions` plus a `now` override (a `Date`, number, or string) for deterministic rendering and tests.

**Hydration-safe by default.** Without an explicit `now`, the base is the server's render instant for the server render *and* the hydration pass that has to match it — published as `<html data-voltro-now>` — then the live clock once hydration commits. So "3 minutes ago" cannot become "4 minutes ago" between the HTML and the first client render just because the network was slow, which is what a plain `Date.now()` base does whenever a unit boundary falls in the gap.

It picks the **largest unit that fits**, so a 90-minute delta reads "1 hour ago", not "90 minutes ago". Anything under a second renders through the `second` unit at 0 — "now" — which avoids the "0 seconds ago" flicker hand-rolled versions produce. `numeric: 'auto'` is the default, so English gets "yesterday" rather than "1 day ago".

**It is not a drop-in replacement for a hand-rolled helper.** Apps that wrote their own usually picked abbreviated, app-specific wording — "2 hr ago", "vor 2 Std." — whereas this hook emits `Intl.RelativeTimeFormat` output: "2 hours ago" / "vor 2 Stunden". Adopting it is a **visible copy change**, so treat it as a design decision rather than a find-and-replace. What you get in exchange is every locale for free and the unit-selection edge cases handled; what you give up is control over the exact phrasing.

### `useFormatNumber` and `useFormatCurrency`

```tsx
const formatNumber = useFormatNumber()

formatNumber(1234.5)                                        // "1,234.5" / "1.234,5"
formatNumber(0.42, { style: 'percent' })                    // "42%"
formatNumber(1_200_000, { notation: 'compact' })            // "1.2M"

const formatEur = useFormatCurrency('EUR')

formatEur(19.9)                                             // "€19.90" / "19,90 €"
formatEur(19.9, { maximumFractionDigits: 0 })               // options merge over the currency defaults
```

`useFormatNumber()` is `(value, options?) => string` over `Intl.NumberFormatOptions`. `useFormatCurrency(currency)` takes the ISO code up front and applies `{ style: 'currency', currency }`; any options you pass are merged on top, so you can still override fraction digits or notation.

Note that the currency **code** is not the locale — `useFormatCurrency('EUR')` renders `€19.90` for an English viewer and `19,90 €` for a German one. The amount's currency and the viewer's language are independent, and this keeps them that way.

### `useFormatters`

For a component that needs several at once, without stacking five hook calls:

```tsx
import { useFormatters } from '@voltro/i18n'

function ActivityRow({ entry }: { readonly entry: Entry }) {
  const { locale, timeZone, formatDate, relativeTime, formatNumber, plural } = useFormatters()

  return (
    <li lang={locale}>
      <time dateTime={entry.at.toISOString()} title={formatDate(entry.at, { dateStyle: 'full' })}>
        {relativeTime(entry.at)}
      </time>
      {plural(entry.changes, { one: '{count} change', other: '{count} changes' })}
      <span>{formatNumber(entry.score)}</span>
    </li>
  )
}
```

It returns the active `locale` and `timeZone` plus `formatDate`, `relativeTime`, `formatNumber` and `plural` — memoized together. Currency is not in the bundle because it needs its ISO code up front; call `useFormatCurrency(code)` alongside it when you need one.

## Timezones under SSR — the setting that is not a preference

A formatter is deterministic given the value, the locale, the zone and the clock. The provider supplies all four, and the two beyond locale are the ones that differ between the server and the browser:

| | Where it came from before | What that means under SSR |
|---|---|---|
| **locale** | the provider, both sides | agreed already — the server publishes `<html lang>` and the client reads the attribute rather than `navigator.languages` |
| **zone** | the runtime | the POD on the server (UTC on a container with no `TZ`), the VIEWER's machine in the browser |
| **clock** | `Date.now()` | two numbers, differing by the network latency |

So a server-rendered timestamp was a hydration mismatch (React error #418) waiting for a wide enough offset or a slow enough connection, and across midnight it was a different calendar **day**. The fix is the one the locale already used: **the server decides, publishes its answer, and the client reads the answer instead of forming its own.**

### Configure it once

```ts
// apps/<project>/web/app.config.ts
export default {
  type: 'web' as const,
  name: 'myApp',
  port: 5191,
  locales: ['de', 'en'] as const,
  defaultLocale: 'de' as const,

  timeZone: 'Europe/Berlin' as const,   // one zone for every viewer
  // …or:
  // timeZone: 'viewer' as const,       // resolve per request, per user
  // defaultTimeZone: 'UTC' as const,   // before the viewer's zone is known
}
```

Whatever it resolves to is stamped on the document as `<html data-voltro-tz>`, and the generated client entry reads that attribute. Both sides then format against one value — which is the property that removes the mismatch, whether or not the value is the viewer's true zone. Being *wrong together* is repairable after mount; being *different* is not.

`timeZone` requires `locales`, because the zone rides the `<I18nProvider>` the framework generates from it.

### `timeZone: 'viewer'` — how the server learns the zone

Through the `voltro:tz` cookie, which has two writers and wants both:

1. **The framework's script**, injected into `<head>`, seeds it from `Intl.DateTimeFormat().resolvedOptions().timeZone` when the cookie is absent. From the second request onward the server renders in the browser's zone with no login and no app code. It never overwrites an existing value and never reloads the page.
2. **Your app, at login** — overwriting it with the zone you hold for the signed-in user. That is the authoritative one: a profile field or an identity provider's `timeZone` claim beats the machine a user happens to be sitting at.

Write it from `middleware.ts`, which runs per request and can return cookies:

```ts
// apps/<project>/web/middleware.ts
import { defineMiddleware } from '@voltro/web/middleware'
import { TIMEZONE_COOKIE, isSupportedTimeZone } from '@voltro/i18n'

export const userTimeZone = defineMiddleware({
  run: async (req) => {
    const zone = await zoneForSession(req.cookies)   // your session → the user's own zone
    if (!isSupportedTimeZone(zone) || req.cookies[TIMEZONE_COOKIE] === zone) return undefined
    return {
      setCookies: [
        { name: TIMEZONE_COOKIE, value: zone, path: '/', maxAge: 31_536_000, sameSite: 'lax' as const },
      ],
    }
  },
})
```

A cookie set here is applied to the jar the SAME render reads, so the zone takes
effect on the response that sets it rather than the one after.

Validate before you write. An unusable zone is dropped on the way in (a stale cookie, a typo in the config, a runtime with a trimmed ICU) rather than forwarded — `Intl.DateTimeFormat` throws on an unknown zone, and one bad value would otherwise degrade every timestamp in the app to a raw `Date` string.

### Prerendered pages

A `renderMode: 'static'` page is one artefact for every viewer, so `'viewer'` cannot mean the viewer there — it resolves to `defaultTimeZone`. The build publishes that value and its own build instant, so the markup and the first client render still agree; a relative time in a prerendered page corrects itself in one frame after mount rather than mismatching.

## Formatters vs. ICU in the catalog

Both can pluralize, and they are not competitors — pick by where the string lives:

- **ICU in the catalog** (`'{count, plural, one {# item} other {# items}}'`) is right when the whole sentence is translator-owned. Translators see the plural structure in their tool and can add the categories their language needs without a code change. This is the default for user-facing prose.
- **`plural` / `usePlural`** is right when the forms are decided in code — a pure helper outside React, a `meta({ locale })` title, a test asserting CLDR behaviour, or a count rendered next to non-string content.

For dates, numbers and relative time the hooks are the blessed path; reach for `react-intl`'s `<FormattedDate>` / `<FormattedNumber>` only when you want the JSX form.

## Anti-patterns

- **Don't write `(s)`, `count === 1 ? 'x' : 'xs'`, or a `+ 's'` suffix.** It is wrong in most languages and cannot be fixed by a translator. Use `plural` / `usePlural` or ICU in the catalog.
- **Don't hand-roll "X minutes ago".** `useRelativeTime` is one hook, is localized, and handles the sub-second and unit-selection cases that inline versions get wrong.
- **Don't pass `timeZone` at every call site to work around a missing config.** Set `timeZone` in `app.config.ts` once. A per-call convention is a rule every new call has to remember, and the ones that forget are invisible until a viewer in another zone reads a wrong date.
- **Don't call `Intl.DateTimeFormat().resolvedOptions().timeZone` in a component to "fix" SSR.** It is the viewer's true zone and therefore the wrong value: the server could not know it, so the server did not render with it, and using it on the client guarantees the mismatch. Let the server decide and publish — that is what `timeZone: 'viewer'` does.
- **Don't pass a locale-formatted string to a machine consumer.** Formatted output is presentation — send ISO strings and raw numbers to APIs, `dateTime` attributes and sort keys.
- **Don't format inside a `.map()` by constructing `Intl` objects yourself.** The hooks memoize per locale; a fresh `new Intl.NumberFormat(...)` per row is the slow path.



---

<!-- source: en/i18n/datetime.md -->
## Dates & timezones

_"@voltro/datetime — UTC-instant storage, timezone-aware arithmetic/formatting on TC39 Temporal, and the request-scoped timezone-context seam."_

`@voltro/datetime` is the framework's opinion on time: **store UTC instants,
render in the viewer's timezone, never do zone math by hand.** It is built on the
[TC39 Temporal](https://tc39.es/proposal-temporal/docs/) standard (via a
polyfill, so the API is identical on the server and in the browser) and ships
pure, browser-safe helpers plus a request-scoped timezone seam.

The `.` entry is browser-safe — no `effect`, no `node:*` — so a route or
component can import it directly. The Effect seam lives in a separate subpath,
`@voltro/datetime/context`, so the pure surface carries no `effect` dependency.

> **Phase 1.** This is the storage + timezone + formatting layer. Schema-DSL
> temporal column types, `interval()`, and `rrule()` are later phases; use
> `timestamp()` columns and these helpers today.

## The storage contract: a `Date` is a UTC instant

`timestamp()` stores `TIMESTAMPTZ`, and the driver hands your app code a plain JS
`Date`. A `Date` is a bare epoch-millisecond count with no zone of its own, so
the ONLY correct reading of it is "the UTC instant it points at". These helpers
make that reading explicit and lossless in both directions:

```ts
import { toInstant, toUTCString, isValidTimeZone } from '@voltro/datetime'

// A `Date` from a query IS a UTC instant — read it as one, losslessly.
const when = toInstant(row.createdAt)   // Temporal.Instant
toUTCString(row.createdAt)              // "2026-08-06T12:00:00Z" — the wire/storage form

isValidTimeZone('Europe/Berlin')        // true
isValidTimeZone('Mars/Phobos')          // false
```

`toInstant` accepts a `Temporal.Instant`, a JS `Date`, or an ISO-8601 string —
but a string MUST carry an explicit offset or `Z`. A naive `2026-01-01T12:00`
denotes no instant and throws, by design: the whole point is that there is no
silent zone-guessing. Bridge back with `toDate(instant)` for storage or interop.

## Timezone resolution — the framework convention

Which zone should a given request render in? Resolve it in priority order, each
candidate validated as an IANA name, falling back to `UTC`:

```ts
import { resolveTimezone } from '@voltro/datetime'

const tz = resolveTimezone({
  userTimeZone:    user.timezone,        // 1. the viewing user's profile
  tenantTimeZone:  tenant.timezone,      // 2. the tenant / org default
  browserTimeZone: 'Europe/Berlin',      // 3. the browser-reported zone
})                                        // → a validated IANA name, else 'UTC'
```

This mirrors `@voltro/i18n`'s `resolveLocale` in spirit — pure, framework-agnostic,
no Effect or RPC — so it runs in any request pipeline. An invalid or absent
candidate falls through to the next signal; the result is GUARANTEED valid.

### Carrying it through a request — the context seam

The resolved zone travels through a request via an Effect context tag in
`@voltro/datetime/context`, so any route, mutation, workflow, or agent can read it:

```ts
import { currentTimezone, withTimezone } from '@voltro/datetime/context'
import { Effect } from 'effect'

const program = Effect.gen(function* () {
  const tz = yield* currentTimezone   // the request's zone, or 'UTC' when none set
  return tz
})

// Provide an explicit zone for a sub-computation:
program.pipe(withTimezone('Europe/Berlin'))
```

`currentTimezone` never fails — an absent context is the documented `UTC`
default, so call sites don't handle a missing-service error.

> **Two zones, and only one of them is wired. Keep them apart.**
>
> The **render zone** — what a date LOOKS like in the UI — is wired end to end.
> Set `timeZone` in the web `app.config.ts`, and the framework resolves it per
> request, publishes it on the document, and every `@voltro/i18n` formatter on
> both sides of the hydration boundary uses it. Read it with `useTimeZone()`
> from `@voltro/i18n`. See
> [Formatting → Timezones under SSR](/docs/i18n/formatting#timezones-under-ssr--the-setting-that-is-not-a-preference).
>
> The **server-side compute zone** — what `startOfDay` or a workflow's "same
> time tomorrow" resolves against inside a handler — is still the SEAM only.
> `@voltro/datetime/context` exports `currentTimezone`, `withTimezone` and
> `resolvedTimezoneLayer`; the framework does **not** install the layer per
> request, so resolve the zone yourself (`resolveTimezone`) and provide
> `resolvedTimezoneLayer` around the work that needs it, or pass an explicit
> `timeZone` argument. Without a provided layer every call reads the `UTC`
> default.
>
> Rendering a date correctly does NOT give a route handler the user's zone, and
> a handler that has it does NOT change what the browser renders. They are
> separate values today and a `timeZone` in `app.config.ts` configures the
> first one only.

## Arithmetic — the DST split is in the names

Every operation that is ambiguous without a zone REQUIRES an IANA `timeZone`
argument. There is no implicit "system zone", so a call site cannot silently do
the wrong thing on a differently-configured box. The DST distinction is
deliberate and encoded in the method names:

```ts
import { addDays, addHours, startOfDay, endOfDay } from '@voltro/datetime'

// WALL-CLOCK: same local time, one calendar day later in Berlin.
// Across a DST boundary the elapsed real time is 23h or 25h. Needs a zone.
addDays(when, 1, 'Europe/Berlin')

// EXACT elapsed time: 24 × 3600 seconds, DST-oblivious. No zone.
addHours(when, 24)

// Zone-relative day boundaries.
startOfDay(when, 'Europe/Berlin')
endOfDay(when, 'Europe/Berlin')
```

`addDays` / `addMonths` are wall-clock ("same local time, N days on"); `addHours`
is exact elapsed time. Comparisons that measure absolute instants (`isAfter`,
`isBefore`) take no zone; `isSameDay` does, because "same day" is a wall-clock
question. For a calendar date with no time and no zone — a birthday, a holiday, a
due date — use `plainDate(year, month, day)` / `parseDate('2026-08-06')`.

## Formatting — locale- and timezone-aware

Formatting is where the viewer's timezone and locale are APPLIED. Both are
explicit arguments — this layer carries no ambient locale (the web layer resolves
`useLocale()` and passes it in):

```ts
import { formatDate, formatDateTime, formatRelativeTime } from '@voltro/datetime'

formatDate(row.createdAt, 'Europe/Berlin', { locale: 'de' })        // "6. Aug. 2026"
formatDateTime(row.createdAt, 'America/New_York', { locale: 'en' })
formatRelativeTime(row.createdAt, { locale: 'en' })                 // "3 hours ago"
```

`FormatOptions` is `{ locale? }` plus any `Intl.DateTimeFormatOptions` override,
so `formatDate(when, tz, { locale, dateStyle: 'full' })` works. `formatRelativeTime`
picks the largest unit that fits the signed distance from `now` (default: the
current instant) and is zone-independent — it measures elapsed real time, not
wall-clock days.

> These helpers are the STORAGE/TIMEZONE layer. `@voltro/i18n`'s
> [`useFormatDate` / `useRelativeTime`](/docs/i18n/formatting) hooks are the
> React binding that read the active locale from the provider; reach for those in
> components, and for `@voltro/datetime` in server code, loaders, and tests.

## Temporal directly

The exact standard `Temporal` types are re-exported, so you can drop to the full
API when a helper doesn't cover your case:

```ts
import { Temporal } from '@voltro/datetime'

const noon = Temporal.PlainTime.from('12:00')
```

Import `Temporal` from `@voltro/datetime`, never from the polyfill directly — that
keeps the eventual switch to the native global (once it is universal in browsers)
a one-line change for the whole codebase.
