# Internationalization (frontend)

Houston ships in **en**, **es**, **pt**. Every user-facing string flows through i18next. No literal English in JSX, props, placeholders, aria-labels, toast titles, or error messages.

## Stack
- `react-i18next` + `i18next` + `i18next-browser-languagedetector`
- Config: [app/src/lib/i18n.ts](../app/src/lib/i18n.ts) — singleton, initialized synchronously at module load
- Resources: `app/src/locales/{en,es,pt}/<namespace>.json` (15 namespaces today)
- Type augmentation: [app/src/types/react-i18next.d.ts](../app/src/types/react-i18next.d.ts) — `t()` keys are compile-time checked

## Namespaces
`common`, `setup`, `legal`, `shell`, `dashboard`, `settings`, `chat`, `board`, `agents`, `skills`, `routines`, `integrations`, `providers`, `errors`, `events`. Pick the namespace that fits; don't dump everything in `common`.

Add a namespace: create `en/<name>.json`, `es/<name>.json`, `pt/<name>.json`, register in `i18n.ts` (import + resources object + ns array), augment in `react-i18next.d.ts`.

## Patterns

**1. Plain string**
```tsx
const { t } = useTranslation("settings");
<h1>{t("title")}</h1>
```
JSON: `{ "title": "Settings" }`

**2. Variable interpolation** — never concatenate fragments (word order differs across languages)
```tsx
<p>{t("switched", { provider })}</p>
toast({ title: t("deleteTitle", { name }) })
```
JSON: `{ "switched": "Switched to {{provider}}" }`

**3. Pluralization** — i18next picks the right key by `count`
```tsx
<span>{t("appCount", { count })}</span>
```
JSON: `{ "appCount_one": "1 app", "appCount_other": "{{count}} apps" }`

**4. Embedded markup** (bold, links) — use `<Trans>` with named components, never split
```tsx
<Trans i18nKey="settings:provider.description"
  components={{ emph: <strong className="..." /> }} />
```
JSON: `"description": "Houston uses <emph>your own</emph> subscription."`

## Library boundary (ui/)

`ui/@houston-ai/*` packages stay i18n-agnostic. They take **optional `labels?` props with English defaults**. The app passes `t()` results in.

```tsx
// ui/skills/src/skills-grid.tsx
export interface SkillsGridLabels {
  emptyTitle?: string
  addSkill?: string
}
const DEFAULTS: Required<SkillsGridLabels> = {
  emptyTitle: "No skills installed",
  addSkill: "Add skill",
}
export function SkillsGrid({ labels }: { labels?: SkillsGridLabels }) {
  const l = { ...DEFAULTS, ...labels }
  return <Empty>{l.emptyTitle}</Empty>
}

// app/src/components/tabs/skills-tab.tsx
const { t } = useTranslation("skills")
<SkillsGrid labels={{ emptyTitle: t("grid.emptyTitle"), addSkill: t("grid.addSkill") }} />
```

Existing consumers without `labels` keep the English defaults — zero-break refactor.

For deep prop cascades (e.g. `AIBoard > KanbanColumn > KanbanCard`) thread the labels object through each layer (`cardLabels` etc.) and re-export the type from the package's `index.ts`.

## Multi-namespace components
```tsx
const { t } = useTranslation(["skills", "common"]);
t("skills:detail.delete")
t("common:actions.cancel")
```
First entry is the default namespace. Always prefix when in doubt.

## Tab labels for agent shell
The shell renders the five `STANDARD_TABS` from `app/src/agents/standard-tabs.ts` (activity, routines, files, job-description, integrations). Translate by tab id with a fallback to the in-code English label:
```tsx
label: t(`agents:tabLabels.${tab.id}`, { defaultValue: tab.label })
```

## Style rules
- **No em dashes (`—`, U+2014)** in user-facing copy. Use commas or sentence breaks. Validator enforces this.
- Spanish: Latin-American neutral (computador, tú; not vos).
- Portuguese: Brazilian (você, computador).
- Glossary consistency:

| EN | ES | PT |
|----|----|----|
| agent | agente | agente |
| workspace | espacio de trabajo | espaço de trabalho |
| skill | habilidad | habilidade |
| session | sesión | sessão |
| routine | rutina | rotina |
| board | tablero | quadro |
| settings | configuración | configurações |

## Locale source of truth

Two **engine-owned** sources, resolved as: **active-workspace `locale` override → global `locale` preference → `navigator` → `en`**. The engine, never the browser, owns both — so a fresh browser pointed at a headless engine (the `houston webapp` against a container) shows the stored language instead of whatever happens to be in that browser's localStorage.

- **Global default** = engine preference `locale` (BCP-47 base tag `en`/`es`/`pt`), the value the first-run picker writes. Also the fallback for any workspace without an override.
- **Per-workspace override** = `locale` field on the workspace record in `workspaces.json`, set via `PATCH /v1/workspaces/:id/locale`. A workspace that never picked a language has no key (inherits the global default); clearing with `null`/empty reverts to it.
- **Boot-apply (the part that was missing):** [`useLocalePreference`](../app/src/hooks/use-locale-preference.ts), consumed by [LanguageGate](../app/src/components/shell/language-gate.tsx), resolves the effective locale and calls `applyEngineLocale` so the engine value is actually *applied* to the live i18n instance — not just read. Re-applies when the active workspace changes. The gate holds its blank screen until the first apply lands (no flash of the wrong language).
- **First-run picker** ([LanguageGate](../app/src/components/shell/language-gate.tsx)) writes the **global default** (no workspace exists yet) before the disclaimer.
- **Settings → Workspace language picker** ([language.tsx](../app/src/components/settings/sections/language.tsx)) writes the **active workspace's** override (it lives under the Workspace settings tab).
- localStorage cache `houston.locale.cache` is **boot-time flash-avoidance only**, never the source of truth.
- Pure value-logic (`isSupported`, `normalizeLocale`, `resolveEffectiveLocale`, `localeToApply`) lives in [`app/src/lib/locale.ts`](../app/src/lib/locale.ts) — DOM/JSON-free so it unit-tests under bare Node (`app/tests/locale.test.ts`); `i18n.ts` re-exports it.
- Engine itself is locale-agnostic — surfaces both values verbatim.

## Updater release notes (en/es/pt)

The "update available" card shows the release notes in the user's language. Authoring is NOT a `t()` namespace — the notes are markdown written per release under `.github/release-notes/<version>.{md,es.md,pt.md}` (English base + optional translations). The Tauri updater only carries one notes string, so CI packs the translations into a trailing `<!--houston-i18n:{...}-->` comment; [`selectUpdateNotes`](../app/src/lib/update-details.ts) strips it and picks the translation for `i18n.language` (which already encodes the workspace-override → global resolution above), falling back to English. Full mechanism + authoring rules: `.github/release-notes/README.md` and `knowledge-base/production-infra.md` (Auto-updater). Same copy rules apply (no em dashes, Spanish neutral LatAm, Portuguese Brazilian).

## What does NOT translate
- Brand names: Houston, Claude, OpenAI, Anthropic, GitHub, Google, Gemini
- Code identifiers, file paths, CLI commands, env var names (e.g. `GEMINI_API_KEY`)
- Agent catalog content (store listings) — author's language wins; treated like App Store listings
- Engine-side error strings (currently English; future work: error code → i18n key map)
- Console / log messages
- The WebView compatibility gate ([app/public/compat-gate.js](../app/public/compat-gate.js)) — it runs before the bundle (and thus before i18next) to catch engines too old to load the app, so its en/es/pt strings live inline and are picked by `navigator.language`. Keep them in sync with the product voice (no em dashes).

## Pre-commit checks
```bash
cd app
pnpm tsc --noEmit       # types + i18n key validity
pnpm check-locales      # missing keys, shape drift, placeholder parity, em dashes
```
Both must pass. The locale validator catches structural drift between en/es/pt automatically.

## Wiring a new screen — checklist
1. Pick the namespace (or create one).
2. Add the keys to `en/<ns>.json`. Use nested objects, not flat `screen_section_label` keys.
3. Translate to `es` and `pt` in the same shapes (no empty stubs — fall back to en is for runtime resilience, not authoring).
4. `useTranslation("<ns>")` in the component (or `["<ns>", "common"]` for multi-ns).
5. Replace every literal string with `t(...)`.
6. If the component is in `ui/`, add a `labels?` prop instead and pass `t()` from the consumer.
7. Run `pnpm tsc --noEmit && pnpm check-locales`.
8. Commit.

## Provider connect strings

The `providers` namespace covers all provider-picker copy. Per-provider
brand-name interpolation flows through `{{name}}` so a single string
template serves Claude, OpenAI, and Gemini. Notable subtrees:

- `providers.card.*` — provider tile chrome (connect/disconnect/coming-soon labels).
- `providers.setup.*` — CLI-login provider setup copy.
- `providers.signOutConfirm.*` — disconnect confirmation dialog.
- `providers.apiKeyConnect.*` — Gemini-style API-key connect dialog
  (title, three numbered steps, console-open + copy + close buttons,
  failure toasts). Component: `app/src/components/shell/api-key-connect-dialog.tsx`.

Adding a fourth API-key provider reuses every `apiKeyConnect.*` key as
is, just point at a new `apiKeyConsoleUrl` + `apiKeyEnvVar` in
`app/src/lib/providers.ts`.

## Routines labels (dynamic schedule/time copy)

The `routines` namespace covers everything in the Routines tab. `@houston-ai/routines`
is i18n-agnostic: every component takes `labels` props (English defaults in
`ui/routines/src/labels-default.ts`) plus a BCP-47 `locale`. The app builds the
whole bundle once with [`useRoutineLabels`](../app/src/hooks/use-routine-labels.ts)
(`t("routines:<group>", { returnObjects: true })`) and threads `labels.grid`,
`labels.editor`, `labels.schedule`, `labels.nextFire`, `labels.runHistory`,
`labels.rowLabels`, and `labels.locale` into `RoutinesGrid` / `RoutineEditor` /
`ScheduleBuilder`.

Two things make this group different from static namespaces:

- **`{token}` (single brace), not `{{token}}`.** Schedule summaries and relative
  times ("Runs every {n} minutes", "Next run {relative}") interpolate numbers and
  times that are computed inside `ui/`, not at the `t()` call site. We read the
  templates with `returnObjects` and pass no interpolation values, so i18next must
  leave the placeholders intact — double-brace tokens would be eaten. The package's
  `interp(template, vars)` (in `ui/routines/src/labels.ts`) fills the single-brace
  tokens. The locale validator only checks `{{ }}` parity, so single-brace tokens
  are invisible to it; **keep them identical across en/es/pt by hand**. (Deliberate
  exceptions: `schedule.summary.monthly` and `schedule.summary.everyNMonths` use
  `{ordinal}` in en and a plain `{n}` day in es/pt — `interp` is handed both, plus
  `{months}` for the every-N-months interval count.)
- **Day/month names, AM-PM, date order, and list joining all come from
  `Intl.*Format(locale)`**, never from per-language strings. That is why
  `useRoutineLabels` maps the app locale to a region (`pt` → `pt-BR`) and passes it
  down. `formatTime` normalizes the U+202F narrow no-break space ICU puts before
  AM/PM back to a plain space. The custom weekly summary (`everyWeekOnDays`, the
  `{days}` token) joins its localized weekday list with `Intl.ListFormat(locale,
  { type: "conjunction" })`, so the connector localizes ("and" / "y" / "e") and en
  gains the Oxford comma — all of this lives in `ui/routines/src/schedule-format.ts`.
- **Singular/plural unit pills.** The custom "Repeat every N …" picker pluralizes
  its unit names by the count, so `schedule.units.<unit>` is a `{ "one", "other" }`
  pair per unit (minute/minutes …) rather than a flat string. The `weeks` and
  `months` units also feed `schedule.onTheseDays` (the weekday multi-select) and
  `schedule.shortcuts` (Every day / Weekdays / Weekends); `schedule.pickDay` is the
  "no day selected" validation summary.

## Known residuals (search for the literal strings to fix)
- `ChatMessages > ReasoningTrigger` — "Thinking…" / "Thought for N seconds" still English; `getThinkingMessage` prop already exists, needs plumbing through `ChatPanel`.
- Engine errors pass through raw English to toasts.
- A few rare aria-labels in `ui/core` (Spinner default, Carousel) and `ui/layout` (sidebar item rename).
