Navi's web client speaks en / uk / ru. The rules themselves live in the platform handbook (handbook.gnexus → 10-platform/i18n); this file only records how navi follows them and what is still untranslated.
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.
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).{ one, few, many, other }) driven only by params.n; ru/uk use one/few/many, en uses one/other.<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.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).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.
The framework and the language block shipped first; from there the interface was translated area by area, biggest first, one commit per area. Every area is done: 348 slugs, the same 348 keys in all three dictionaries, and no slug a language lacks — a slug with no translation falls back to English, so a gap would show as English inside a translated screen rather than as a blank.
| Area | Strings | Covered by |
|---|---|---|
settings.* |
132 (+ 41 across common.*, col.*, confirm.*, toast.*) |
the settings screen |
artifacts.* |
39 (+ 11 in col.*, 10 in common.*) |
the artifacts drawer |
sidebar.* |
27 (+ 5 in common.*, 1 in confirm.*) |
the sidebar |
messages.* |
23 (+ 6 in common.*, 6 reused from artifacts.*) |
what a message is made of |
chat.* |
20 (+ 4 in toast.*, 2 in common.*) |
the chat screen |
ui.* |
24 (+ 4 read from common.*) |
the small UI components |
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:
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.*.AssistantMessage's header and from the MCP-connected toast, so it is common.toolsCount, not a copy under messages.*; common.task is the same word the chat header and the background-task toast both need. common.close came the same way — the sidebar's and the lightbox's close buttons say the same thing — and common.yes / common.no serve the confirm dialog and the form's checkbox summary.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.
Two strings of the chat area are translated but cannot reach a screen today: App.vue puts WelcomeScreen in front of ChatArea whenever no session is open, so ChatArea's own .empty-chat branch — chat.nothingOpen and chat.startConversation — never renders. They keep their slugs so the branch is translated if it is ever unshadowed.