# Orb speaks five languages, and types commands in one

Last verified: October 5, 2026 against the local preview, `npm run i18n:test` and
`npm run languages:test:ui`; the section on server messages was added October 10, 2026.

Orb ships in English, Brazilian Portuguese, Spanish, French and Mandarin. Two mechanisms carry the
copy, and they behave differently, so both are measured rather than assumed.

## The two mechanisms

**A keyed dictionary** in `components/orb-i18n.tsx`. `t("communities")` reads `copy[locale]` for
Portuguese and English, and `partialCopy[locale]` for the others, falling back to English **per key**.
A language can ship at 40% without ever showing Portuguese to someone who does not read it.

**Inline two-argument calls** written beside the JSX: `t("Communities","Comunidades")`. Those carry
their own Portuguese text, so only one language needs a table: `lib/i18n-inline.ts` is keyed on the
**English** string and holds the Spanish, French and Mandarin answer on the same row.

```
  "Adjust banner": { es: "Ajustar banner", fr: "Ajuster la bannière", zh: "调整横幅" },
```

Both mechanisms resolve through `lib/locales.ts`, which defines the five locales once, and through
one fallback rule: **anything untranslated becomes English, never Portuguese.** The old binary
`locale === 'en' ? english : portuguese` would have shown Portuguese to a Spanish visitor, which is
the bug this whole module exists to fix.

Two helper names exist because the codebase grew both orders: `inlineT(locale)` takes
`(english, portuguese)`, `inlineTPt(locale)` takes `(portuguese, english)`. Supporting both was
cheaper than rewriting 1.3k call sites, and the picker keeps working either way.

## Measured coverage

Fresh run of `node scripts/i18n-coverage.mjs` on October 5, 2026. The dictionary count
moves when a feature is added or deleted — the `.nad` removal took its keys with it —
so treat these numbers as a snapshot and re-run the command rather than quoting them.

```
inline call sites=4242 unique strings=2721 files=403
es: 2721/2721 (100%)
fr: 2721/2721 (100%)
zh: 2721/2721 (100%)

dictionary keys=257
es: 257/257 (100%)
fr: 257/257 (100%)
zh: 257/257 (100%)
```

`npm run i18n:test` runs `scripts/i18n-lint.mjs` (no empty row, no CJK characters in a Latin column)
and then `scripts/i18n-coverage.mjs`, which **exits non-zero** when a language still answers a string
in English or when a component bypasses the catalog. A percentage nobody enforces is a caption. Add
`--missing` to the coverage script for the list a language still answers in English.

A JavaScript template literal (`t(\`${n} messages\`, …)`) cannot be a catalog key: the text changes
with the number. Sentences with a count inside them use `fill()` instead, so the *whole sentence* is
one key and only the number moves in — `"Orb Passport · {total} anchored proofs"` is a row, and
French or Mandarin get to put `{total}` wherever their grammar wants it.

## What the first 100% missed

The first time this section read `1705/1705 (100%)` it was measuring a smaller surface than the app.
Two classes of bug made that possible, and both are now build failures rather than surprises:

**A component that only knows two languages.** Many files bound their own helper beside the hook:

```ts
const {locale}=useOrbLocale(),en=locale!=='pt-BR',t=(a,b)=>en?a:b;   // answers es, fr and zh in English
```

That shape has the same call signature as the catalog translator, so every one of its ~700 call sites
read as translated. Twenty-four of them across nineteen components — the wallet, the perpetual desk,
missions, the council, the arena, the strategy board, the trading cards — now bind
`t=inlineT(locale)` instead, and `i18n-coverage` fails if any such helper comes back.

**Copy that reaches the translator as data.** `MUSIC_COMMANDS` keeps its help text in
`help: ['English','Português']` tuples and hands the pair in as variables, so no pattern over call
sites can see the keys — which is why the command picker served English help in Spanish while the
report said 100%. `ATTESTATION_LABELS`, `CHANNEL_RIGHT_LABELS`, the bot scope labels and the media
device names are the same shape. The collector now reads `[string, string]` copy tables directly, and
each label table is English-first so the key it contributes is the half the catalog is keyed on.


## Commands are English in every language

Members type `/play`, `/queue`, `/skip`, `/kick`, `/ban`, `/role` — English only, in every locale,
the way they would type an API. One spelling to learn, autocomplete that does not change when a
member switches languages, and documentation that stays copy-pasteable. The old Portuguese verbs
(`/tocar`, `/fila`, `/pular`, `/expulsar`) are gone rather than kept as aliases, because two
spellings for one action is how a command surface becomes folklore.

What a command **says back** follows the member's locale. `runMusicCommand`, `runAppCommand` and
`availableCommands` take a `locale`, the route reads it from the request body through
`readLocale()`, and the command help and notices resolve through the same catalog as the interface.

## Messages from the server and the libraries are English

An error from the API, a refusal from a library, a line in a log: English is the canonical text, the same way
it is for commands, the API and these documents. A member who reads Portuguese gets the original Portuguese
back, and nobody else is shown Portuguese by accident.

- **The wording.** Server code throws English (`AppError('Your session has expired.', 401)`). It never
  receives a locale, so it cannot choose a language, and a log line, a test and a support answer all quote
  one spelling.
- **The table.** `lib/server-messages-pt.ts` maps each English sentence to its Portuguese original, and
  sentences with a value in them (`Room full. The limit is ${limit} people in this channel.`) to patterns.
  It is fetched only for a visitor whose language is Portuguese.
- **The lookup.** `lib/message-locale.ts` gives `localizeMessage(english)`, for code in the browser that
  has no locale of its own (the call engine, the profile image upload), and a wrapper around `fetch` that
  translates the `error` of an error answer from Orb's own API before any screen reads it. Answers that are
  not errors, not JSON or not from this site pass through untouched, and so does every language other than
  Portuguese: Spanish, French and Mandarin read the English, which is the fallback everywhere else too.
- **The guards.** `npm run check:source:language` fails when server code throws or sends a Portuguese message
  that is not a translated pair, and `scripts/test-message-locale.mjs` fails when a sentence in the table is no
  longer written anywhere in the code (a rename that forgot the table) or has two Portuguese answers.

Still pending, and listed as such in `scripts/test-source-language.mjs`: the voice stage's messages
(`lib/stage.ts`) and the permission labels of the role editor (`lib/permissions.ts`) are being written in another
change, and the page that moves a profile out of the old Órbita app is Portuguese by design.

## Adding a string

A message thrown by the server or a library is not a string of the interface: write the English, and add its Portuguese to `lib/server-messages-pt.ts`. For the interface itself, write the call with English first (or use the existing `t(portuguese, english)` shape), run
`node scripts/i18n-coverage.mjs --missing`, and add one row to `lib/i18n-inline.ts`. Nothing else
has to change: the row is looked up by the English literal, and a missing row falls back to English
instead of leaking Portuguese.
