# Interface language (i18n)

Navi's web client speaks **en / uk / ru**. The rules themselves live in the
platform handbook ([handbook.gnexus](https://handbook.gnexus) → `10-platform/i18n`);
this file only records how navi follows them and what is still untranslated.

## Where the language comes from

`locale_effective` from `GET /auth/me` — the choice made in Settings
(`navi_users.locale_override`) → the gnexus-auth account locale
(`navi_users.locale`, a straight mirror kept by the `user.profile_updated`
webhook) → `en`.

The picker is the first block of **Settings → Account**
(`webclient/src/components/settings/AccountPanel.vue`) and offers
Auto / English / Українська / Русский — each language named in its own language.
A choice is written with `PATCH /auth/me`, so it follows the account to other
devices and survives logout.

With `NAVI_AUTH_ENABLED=false` there is no account to follow: the picker drops
Auto, the choice is kept in `localStorage` under `navi.locale`, and the same key
is used when the visitor is not signed in.

## The module

`webclient/src/i18n/` — vue-i18n is deliberately not pulled in:

| File | Role |
|---|---|
| `index.js` | `locale` ref, `t(slug, params)`, `setLocale`, `normalizeTag`, `pluralCategory`, and the `navi.locale` storage helpers |
| `messages/en.js`, `uk.js`, `ru.js` | flat dictionaries, the same slugs in all three |

- `t()` interpolates `{param}` and falls back to the **English** string when the
  chosen language has no slug yet (or to the slug itself when nobody has it).
- Plurals are dictionaries (`{ one, few, many, other }`) driven only by
  `params.n`; ru/uk use one/few/many, en uses one/other.
- A slug is `<area>.<name>` and an area is a screen or a component group —
  `settings.*`, `sidebar.*`, `messages.*`, `chat.*`, `artifacts.*`, `ui.*`,
  `profile.*`, `app.*`. Slugs are shared with the other gnexus services where
  they mean the same thing.
- Four areas are vocabulary rather than screens and are filled in as the
  screens that need them are translated: `common.*` (save / cancel / delete /
  revoke…), `col.*` (table headers), `confirm.*` (dialog titles and texts) and
  `toast.*` (titles of the notifications that pop up after an action).
- `SUPPORTED_LOCALES` is the single list: adding a language means adding
  `messages/<code>.js` there, plus `navi/locales.py` on the backend (and the
  same slug in `navi_users.locale_override` constraints on the client side).
- Lists of options or nav items must be **computed**, not module-level
  constants, or their labels freeze in the language that was active at import.

The backend side is `navi/locales.py` (`normalize_locale`, `effective_locale`),
mirroring the frontend's normalisation: `en-US`/`en_US` → `en`, anything not
shipped → `None`.

## Translation status

The framework and the language block shipped first; from there the interface is
translated area by area, biggest first, one commit per area. An area not yet
done is hard-coded English and looks exactly as it did before — the two kinds of
screen sit side by side without either noticing.

| Area | Strings | Status |
|---|---|---|
| `settings.*` | 125 (+ 41 across `common.*`, `col.*`, `confirm.*`, `toast.*`) | done |
| `artifacts.*` | 39 (+ 11 in `col.*`, 10 in `common.*`) | done |
| `sidebar.*` | 28 (+ 4 in `common.*`, 1 in `confirm.*`) | done |
| `messages.*` | 23 (+ 5 in `common.*`, 6 reused from `artifacts.*`) | done |
| `chat.*` | ~21 | pending |
| `ui.*` | ~20 | pending |

A parenthetical counts the words outside the area that the area's screens also
render — a shared word is written once in the vocabulary area and read from
there, so the same slug can appear in two parentheticals.

A slug can reach `t()` in three shapes — a plain literal, one arm of a ternary
(`t(server.has_key ? 'settings.mcpKeyPlaceholder' : …)`) and a template literal —
so counting occurrences means walking the whole argument list of every `t(`
call rather than its first quoted string. The remaining strings are counted in
templates (text nodes, `placeholder`, `title`, `aria-label`, `label`, `alt`)
plus keyed strings in `<script setup>`.

One screen is shared on purpose rather than duplicated:

- The message list's `ContentCard` shows the same published-file widget as the
  artifacts drawer, so it reads `artifacts.openPreview`, `artifacts.showPreview`,
  `artifacts.showSource`, `artifacts.copyFileLink`, `artifacts.loadingSource` and
  `artifacts.sourceFailed` instead of carrying six copies under `messages.*`.
  Its two tool-output labels — `Result` and `Live output` — are the same two
  words the tool card uses, so they live in `common.*`.

The settings screen keeps a single `settings.*` area with panel-prefixed names
(`settings.keysTitle`, `settings.mcpTitle`), rather than one area per panel: that
is what the other gnexus clients do, and a slug has to be greppable from the
screen it belongs to.

Backend-generated text (tool results, system prompts, WS event payloads) is
still English and is out of scope for now — the interface language does not
change what the agent answers. The same goes for labels the UI kit bakes into
its own components rather than taking as a prop (e.g. `GnSearchField`'s
`aria-label="Clear search"` in `vendor/gnexus-ui-kit/src/vue/components/`);
they are English on every gnexus client and are fixed in the kit, not here.
