Skip to content

Localise the app chrome, with Russian beside English - #136

Merged
alpha5611331 merged 20 commits into
mainfrom
feat/i18n-russian-ui
Oct 4, 2026
Merged

alpha5611331 merged 20 commits into
mainfrom
feat/i18n-russian-ui

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

Closes #135.

What

The app chrome is translatable, and Russian ships beside English. The chrome language is its own setting, asked rather than inferred - the first step of the first-run wizard, and the first row on the configuration page.

CLAUDE.md recorded the opposite rule, and half of its reasoning still holds:

an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud

That is why nothing here derives one language from the other. A Russian speaker interviewing in English wants an English transcript and a Russian app, and guessing in either direction gets that user wrong. The other half only argued for not guessing, and this does not guess.

Why a typed dictionary and not i18next

locales/en.ts is the source of truth and exports Translation = typeof en. Every other locale is an object literal assigned to that type, so a key Russian is missing, or spells differently, fails the build. i18next's documented behaviour for a missing key is to render the key itself - home.greeting on screen, in production, for the one user who would notice least. For two locales with no lazy loading and no namespacing, that runtime is the only thing being bought.

Strings that take a value are functions, not templates with placeholders. That is what makes Russian expressible at all:

// en
charactersLeft: (n: number) => `${n.toLocaleString()} characters left`,
// ru - 1 символ, 2 символа, 5 символов, and 11 символов again despite ending in 1
charactersLeft: (n) => `Осталось ${n.toLocaleString('ru-RU')} ${plural(n, 'символ', 'символа', 'символов')}`,

The compiler covers the other direction: noUnusedParameters is already on, so a locale that declares (email: string) and then writes a sentence without it does not compile. That replaced an earlier ${}-slot parity check in the test, which forbade exactly the legitimate case above.

No new dependency.

Where the setting lives

  • Wizard step 1, ahead of even the profile. Every other step asks about an interview; this one asks whether the user can read the questions. Picking re-renders that step in the chosen language on the spot, so one English screen is the whole cost of discovery - and only endonyms are listed, since the reader may not speak the language the app is currently in.
  • Configuration page, first row, separated by a rule from the interview settings - it is the only control there that is not about how an interview runs.
  • uiLanguage is resolved on the way out of getConfig(), the same as the interview language and for a sharper reason: an unresolved code reaches a lookup in the locale table and comes back undefined, which is an app with no text in it.

useT() reads the config store directly, so there is no provider. The code is also cached in localStorage and read synchronously at module load, because the store is loaded from an effect in MainFrame - without it a Russian install opens in English for the first frames of every launch.

Main process

src/main/utils/ui-strings.ts, a table keyed by language - same shape as export-labels.ts and deliberately not that table. The report follows the interview language because it is handed to someone who was not there; this follows the chrome language because it is read by the person using the app. It covers the placeholder panel copy main seeds and the push notifications it raises as toasts from paths the renderer cannot see: a global hotkey pressed while the window is hidden, a session that expires, stealth refused at the IPC boundary.

appStateService.refreshPlaceholderLanguage() closes the gap that opens: the placeholder is written on launch and after a Clear, so a language changed between those two would leave English sample copy in the panels of a Russian app. A no-op once a real interview has written to the history - a re-seed there would throw the interview away.

Out of scope, deliberately

  • The exported report keeps following the interview language through export-labels.ts.
  • Model-written content - suggestions, mock questions, scoring - follows the interview language by design.
  • Backend error text is passed through untranslated. The client cannot translate a string it did not write, and swapping a specific server message for a generic local one loses the only useful half of it. The locale's errors blocks are fallbacks, for a failure that arrived without a message.
  • Two internal faults stay in English: 'Electron API not available' (a state a working build cannot reach) and 'Some services did not stop cleanly' (written to a store field no component reads - the toast beside it is translated).

Tests

test/ui-language.test.mjs, 29 checks, registered in test/run.mjs. It covers what the types cannot:

  • an unknown stored code resolves to English rather than reaching the lookup;
  • the two UiLanguage mirrors staying in step, the way language.test.mjs pins Language;
  • a Russian string that is still its English original - which type-checks perfectly, ships, and is otherwise only caught by a Russian speaker reading the screen. The rule is Latin letters with no Cyrillic in the same string, skipping quoted property names ('mock-done' must not be translated) and stripping ${...}. The allowlist is a list rather than a rule, and each entry is a written-out claim: Markdown, Word, Pro, Junior, ru-RU, a domain;
  • the same check over main's table, plus uiStrings() following the stored language and the placeholder re-seed in both directions.

test/mock-transcript-turns.test.mjs anchored on the literal 'No answer' in the panel source; it now anchors on t.panels.noAnswer. That the panel reaches for a label at all is the invariant worth pinning.

Verified

pnpm test:main (all checks pass), tsc -b, tsc -p tsconfig.electron.json, eslint . - all clean, which is the whole of what CI runs.

Two things worth a reviewer's opinion

  1. Dashes. The Russian copy uses hyphens where Russian typography wants —, because this repository's writing rules forbid generating em-dashes and the English source uses hyphens in the same positions. It reads slightly flat to a native eye. Say the word and I will switch the Russian strings to — and note the exception in the writing rules.
  2. Seniority and plan names stay Latin. Junior / Middle / Senior / Staff+ and Starter / Pro / Enterprise are untranslated on purpose - the first set is what Russian-language job ads use essentially without exception, the second are purchasable SKUs. Both are in the test's allowlist with that reasoning attached.

Review notes for the translation itself: the copy was written rather than machine-translated, matching the English register (direct, second-person formal, no more words than the English uses). Russian runs roughly 15% longer than English and these strings sit in a window whose minimum width is 840px, so the long ones were kept tight - the control-bar tooltips and the fixed-height mock status row are where that matters most.


Review loop

Four passes after the implementation. What they turned up, because most of it is the kind of thing a line-by-line check against the English does not see:

A gendered form. mock.session.ready said Я готов - the masculine participle, on the button a candidate presses to start answering. A woman using the app was being misgendered by the interface at the one moment it asks her to speak. Russian has no short genderless participle here, so the label now says what the button does rather than who is pressing it: Можно начинать.

Terminology drift across surfaces. One suggestion mode, five places that name it, and Russian had named it four different ways - Только подсказки on the configuration card, Краткие подсказки in the control-bar tooltip, Кратко on the status badge, Кратко / полными фразами in the hotkey sheet. English is Hint-only in all five. They agree now, with Кратко kept only for the status pill and a comment saying why. The rename also resolved a collision: languageGroup.suggestionsOnly means "only the suggestions switched" and was rendering as the mode's own name.

A whole untranslated component. TrialUserNotice is on screen for every trial user on /main and was still entirely in English after six passes, because its sentence is split across lines by two <span>s and a <br /> - no single line held anything that looked like a string. test/ui-text-routing.test.mjs is the guard: it matches text between two tags, which in a .tsx file is a JSX child or nothing, and it fails on exactly that shape. Verified by putting the English back and watching it fail.

Three native save dialogs. showSaveDialog's title is the OS window title and the filter name is the file-type dropdown, and all three were English literals. Same distinction as export-labels.ts one layer up: the document inside follows the interview language, the window asking where to put it follows the chrome language.

One real overflow. ОСТАНОВКА is nine characters of uppercase bold Cyrillic and does not fit RunningIndicator's 96px badge. It wrapped rather than clipped, which makes the badge taller - and in stealth mode that badge is the only thing on screen. Widened to w-28, and test/ui-language.test.mjs now pins the two genuinely fixed-width slots with the budget written down and the measurement printed ("Остановка", 9/10).

Grammar and government, about a dozen: отвечает для этой роли and пишутся вашим опытом are the wrong case for their verb, нет кнопки, по которой можно нажать wants на которую, ответы за ним renders "the answers behind it" spatially in six save-history entries, тренировка против ИИ-интервьюера reads adversarial for what is practice with an interviewer, and two app-name sentences were missing в. charactersLeft said Осталось N символов, which is right for every N except 1 - Russian wants Остался 1 символ, and the verb agrees with the numeral too, which a plural helper picking only the noun cannot reach. Rephrased to Ещё N символов, which has no verb to agree.

Two code-quality fixes while in there. save-history-dialog read the partial mock copy table by casting reason into a narrower key union - asserting something false and then relying on the ?? to catch it; it reads through a Partial<Record<...>> view now. And useUiLanguageCode keyed on the stored value being truthy, which read a config carrying an unknown language as "not loaded yet" and sat on the localStorage cache instead of resolving to English.

Not verified

The Russian UI has not been looked at running. Everything above is static: types, lint, the 818-check suite, the production bundle, and reading the copy as a Russian speaker. Width and wrapping were reasoned about and the two unforgiving slots are now pinned by test, but a pass with the app open against a backend is the one check I could not do here - say the word and I will run it.

The NSIS installer is still installerLanguages: ['en_US']. Out of scope on purpose: it is not app chrome, and adding ru_RU wants a real installer run to verify.

Sign-out keeps the chrome language, deliberately: it lives in the config store rather than on the account, like the interview language, because it is a property of the person at the keyboard. The next account to sign in gets the wizard, whose first step asks again.

Pass 5: what /code-review high found

It cleared the plumbing - the resolver, the migration, the paint-time cache, the t-in-deps decisions and the placeholder re-seed guard all held up under it. What it found was five more English strings, and the common cause is worth stating because it was a reasoning error rather than an oversight:

The pass drew the line at "backend error text is passed through untranslated", which is right, and then applied it to strings main itself wrote - which is the opposite of right, and is what ui-strings.ts exists for.

  • auth.service.ts was the worst of it. useAuth was translated as result?.error || t.auth.errors.*, but on these paths result.error is always set, so the new fallbacks were unreachable and "Invalid email or password" - the most frequently read error in the app - stayed English. Same shape in account.service.ts, which surfaces on the Account page and in the wizard.
  • suggestion-error.ts writes onto suggestion.error, rendered verbatim on the card the candidate is reading: a rate-limited or stalled suggestion put an English sentence mid-interview into a Russian UI.
  • mock-interview.service.ts writes onto the session's error, which /mock-interview deliberately surfaces - including the "check the microphone" explanation a candidate is owed for a session that skipped its way through questions they were already billed for. One of its lines was the same sentence as t.assistant.mockRunning, so one route into that refusal was Russian and the other English.
  • proceedLabel="Continue" on /main's startup permission gate: translated title, rows and Cancel beside an English primary button.
  • A screen-capture timeout thrown into startAssistant, rendered in English by the same toast whose generic fallback was already translated.

test/ui-text-routing.test.mjs grew the check that would have caught the fourth: copy-bearing attributes must be expressions, not string literals - a rule about the shape rather than the words, so a one-word literal too short for any prose heuristic still fails. Its \b was also written inside a template literal, where it is a backspace character rather than a word boundary, so the attribute check passed vacuously until that was fixed. Verified by putting proceedLabel="Continue" back.

The two decorative logos lost their alt="Logo" on the way past - they sit beside the app's name in text, so an empty alt is correct, and it was the last English word in the accessibility tree.

Final state: 821 passing checks, both typecheck configs, eslint, and the production bundle.

Passes 6-7: the rest of main, and running the locales

Pass 6 found 22 more strings of the same class the code review named, because the previous sweep had only re-scanned the renderer. All of them are strings main itself wrote reaching the screen through a { success: false, error } return or a suggestion's error field:

  • payment.service.ts had the whole set - twelve sites across six methods, every one the message usePayment's result?.error || t.payment.errors.* can never fall back past.
  • account.service.ts had four more at the || sites, beside the catch blocks already fixed.
  • The three suggestion services each wrote 'The model returned an empty response.' onto a card rendered verbatim mid-interview, and suggestion-action wrote its own 'The response timed out...' beside the identical string already sitting unused in the table.
  • api/client.ts was the one with reach. On a timeout it sets response.error.message to its own 'The request timed out' - so that English string won over every translated fallback behind it, in auth, account and payment alike, for the most common failure the app has.

A message thrown by the runtime (fetch failed) is still passed through: we did not write it, which is the line already drawn for backend text. src/main now scans clean - no client-authored user-facing English outside export-labels.ts, which follows the interview language by design, and diagnostics that cannot reach a screen ('Electron API not available', the websocket generation-guard throws, 'Synthesis returned no audio' - each verified as caught locally or unreachable).

Pass 7 closed the one axis with no coverage at all: until now, no test had ever executed a locale. plural, three toLocaleString calls and the two duration builders all run real logic, reached only by the bundler. Translation = typeof en describes the shape, which leaves two things that type-check perfectly and still break on screen: a leaf that is undefined at runtime, and a parameterised string that throws when called - which takes the screen down rather than showing the wrong words.

test/locale-runtime.test.mjs transpiles both locales with ts.transpileModule (one call, no bundler, typescript is already a devDependency - both files are self-contained once types are stripped) and walks every leaf. Each function is called twice, with 1 and with 0, because the duration builders branch on each part being truthy and 0 is the path a drained balance hits. plural is pinned directly on the numbers that separate its three forms, including 11, which takes the third despite ending in 1 and is exactly what a naive n % 10 === 1 rule gets wrong.

Verified by breaking it both ways - a leaf written as undefined as unknown as string and a function made to throw, each reported by key.

Final: 840 passing checks, both typecheck configs, eslint, production bundle.

🤖 Generated with Claude Code

alpha5611331 and others added 11 commits October 3, 2026 20:14
The chrome is English only, and `CLAUDE.md` records that as deliberate. The reasoning
holds for never *inferring* the chrome language from the interview language; it does
not hold for having no chrome language at all. So this adds one, asked outright.

A typed dictionary rather than i18next. `ru` is declared as `typeof en`, so a missing
or misspelled Russian key fails the build - i18next's answer for a missing key is to
render the key, which for two locales with no lazy loading and no namespacing is the
only thing its runtime would have bought. Parameterised strings are functions, so
Russian's three plural forms are written where they apply rather than configured.

`uiLanguage` is its own config key, resolved on the way out of `getConfig` for the
same reason the interview language is: a code with no locale file reaches the lookup
and comes back undefined, which is an app with no text in it.

The picker is the first step of the first-run wizard and the first row on the
configuration page. First in the wizard because every other step asks about an
interview and this one asks whether the user can read the questions - and picking
re-renders that step in the chosen language on the spot. Only endonyms are listed:
the reader may not speak the language the app is currently in.

`useT` reads the config store directly, so there is no provider. The chosen code is
cached in localStorage and read synchronously at module load, because the store is
loaded from an effect and a Russian install would otherwise open in English for the
first frames of every launch - the one moment a user is deciding whether the app is
translated at all.

`test/ui-language.test.mjs` covers what the compiler cannot see: an unknown stored
code, the two enums drifting, and a Russian string that is still its English
original. That last one type-checks perfectly and is only ever caught by a Russian
speaker reading the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything a user reads before they have an interview, which is also everything
reachable while signed out - the three auth wizards, the launch hub, the account
page and the three shared profile fields.

`useAuth`'s error strings are its own fallbacks, for a failure the backend did not
describe. Anything it does send is still passed through untranslated: the client
cannot translate a string it did not write, and replacing a specific server message
with a generic local one would lose the only useful half of it.

`charactersLeft` is the first string to earn the decision to make parameterised
strings functions. English interpolates one value; Russian interpolates two, because
which of три plural forms `символ` takes is a property of the number and cannot be
written as a placeholder. A `{{count}}` scheme would need a plural rule engine to say
what a one-line function says here.

That also retires the test's `${}`-slot parity check, which forbade exactly that:
`noUnusedParameters` is already on, so a locale that declares `(email: string)` and
writes a sentence without it fails the build. The compiler pins the half that matters
and allows the half that is legitimate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ey sheet (#135)

Every control the configuration page and the first-run wizard render, plus the two
dialogs they open and the password field shared with the auth forms.

The hotkey table is split rather than translated in place. `lib/hotkeys.ts` keeps the
combos, because a key name is notation and the only thing in that file that varies by
platform rather than by reader; the titles and descriptions move to `t.hotkeys.keys`,
keyed by the `Hotkey` enum, and `HOTKEY_GROUPS` carries an id into `t.hotkeys.groups`
instead of a label. `HotkeyInfo` is down to `combo`, which is all any other caller was
ever reading off it.

`MicLevelMeter` takes `t` as an effect dependency, which looks redundant and is not:
it writes the level hint straight to the DOM from a `requestAnimationFrame` loop
rather than through React, so without it a language changed while the meter is on
screen keeps saying "Silent" until the stream changes under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bar (#135)

The chrome that is on screen during an interview, which is also the densest: most of
it is a tooltip or an aria-label rather than visible text, and those are the strings
a translation pass is most likely to walk past.

`common.withCombo` carries the "Label (Ctrl+Shift+F8)" shape the bar uses a dozen
times. Written out per tooltip it would be a dozen near-identical keys whose only
variable is a combo the locale has no opinion about; as one function the separator is
a single decision and the labels stay reusable - the transcript toggle's label is the
same string in its button, its tooltip and the command palette.

`CreditsDisplay` splits a duration into hours and minutes and lets the locale
assemble it, rather than formatting `2 hours 15 mins` and handing over a finished
string. 2 and 22 take the same Russian plural form, 5 and 25 take another, and 11
takes the second despite ending in 1 - none of which survives being passed a
pre-formatted English phrase.

`RunningIndicator`'s labels move too. They read as internal state names and are not:
that badge is the only thing on screen that says a session is live, and in stealth
mode it is the only thing on screen at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ices (#135)

The save-history dialog's two copy tables move wholesale. There are fourteen entries
across the live and mock subjects and they are the most consequential strings in the
app: each one is the last thing a candidate reads before an interview is destroyed,
and the button that goes through with it is named after the specific loss rather than
saying "Discard" four different ways. The mock table stays partial for the same
reason it was partial before - `mock-done` and `mock-again` are raised only from the
report screen and are already written for it.

Two callers cannot use `useT`, and both are handled rather than left in English.
`showExportSuccessToast` is a plain function called from a click handler, so it reads
the language off the config store through `translationFor`. `UpdateNotification`
raises toasts from an effect instead of rendering them, so `t` is an effect
dependency - without it, a language changed mid-download keeps the old wording until
the download ends.

`test/mock-transcript-turns.test.mjs` anchored on the literal "No answer" in the
panel source. It now anchors on `t.panels.noAnswer`: that the panel reaches for a
label at all is the invariant worth pinning, and whether both locales have one is a
build error rather than something to grep for.

The parity check gets a sharper rule with it. Flagging a Russian string for having no
Cyrillic caught separators, a template made only of its own interpolations, and the
quoted keys `'mock-done'` and `'mock-again'` - which must *not* be translated, since
they match `en.ts`. It now flags Latin letters with no Cyrillic in the same string,
skips quoted property names, and strips `${...}` before looking, so the allowlist
holds only genuine claims: `Markdown`, `Word`, `ru-RU`, `./en`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…age (#135)

The setup dialog's two option tables stop carrying their own copy. `DIFFICULTIES` and
`SENIORITIES` are now enum values paired with a locale key, so a difficulty cannot be
added with its description left behind in one language - the same shape the onboarding
step ids take.

Seniority is the one set of labels Russian keeps in Latin script. Junior, Middle,
Senior and Staff+ are what Russian-language job ads and recruiters use essentially
without exception, so they are in the parity check's allowlist with that as the
stated reason rather than translated into words no candidate would recognise.

`thinkingLabels` is a function of `t` rather than a module constant, so a language
changed while the screen is waiting on "Scoring the interview" reaches it. Same for
`scoreVerdict`, which takes `t` as its first argument.

The documentation page gains a section it did not need before: the interview language
and the app language are two settings, they are routinely different for one user, and
nothing infers either from the other. That is worth saying in full somewhere a reader
goes looking, and the language section is where they look.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`getStatusLabel` takes the dictionary as its first argument rather than calling
`useT`: it is reached from a table cell's render and from a memoised card, a plain
function in both cases. Its fallback stays the raw status string, which is what a
backend ahead of this build sends - an untranslated code on a badge is poor, and a
blank badge where the status should be is worse.

Plan names stay in Latin in both locales and the parity check says why: Starter, Pro
and Enterprise are purchasable SKUs, and `Pro` is a plan someone has bought by that
name. Only the descriptions are prose.

The balance line was assembled out of JSX fragments - `{hours} hour{s}` next to
`{mins} minute{s}` with a conditional space between them. That shape cannot express
Russian at all: each number takes its own plural form and which one is a property of
the number, so the hours and minutes now go to the locale as two integers and come
back as a finished phrase.

`MemoQr` takes `t` and compares it in its memo predicate. Without that the Download
button keeps the language it was first rendered in, since nothing else about the QR
code changes when the language does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two different problems, and they want opposite solutions.

The setting hooks keep their callbacks referentially stable on purpose, and say so in
their own comments: the global hotkey listeners subscribe once rather than
resubscribing on every config change. `useT()` inside such a callback would leave it
holding whichever dictionary existed when the callback was created, and adding `t` to
the dependency array would defeat exactly the stability those comments describe. So
`currentTranslation()` reads the language at call time, the same way those callbacks
already read the config. It is also what the two callers that cannot call a hook at
all use - `showExportSuccessToast` and the zustand store in `use-assistant-service`.

Main's share is a table keyed by language in `utils/ui-strings.ts`, the same shape
`export-labels.ts` uses - and deliberately not that table. The exported report follows
the *interview* language because it is handed to someone who was not there; this
follows the *chrome* language because it is read by the person using the app. It
covers the placeholder panel copy main seeds and the push notifications it raises
from paths the renderer cannot see: a global hotkey pressed while the window is
hidden, a session that expires, stealth refused at the IPC boundary.

`refreshPlaceholderLanguage()` closes the gap that table opens. The placeholder is
written on launch and after a Clear, so a language changed between those two would
leave English sample copy in the panels of an otherwise Russian app. It is a no-op
once a real interview has written to the history, which is the half worth having: a
re-seed there would throw the interview away.

Backend error text is still passed through untranslated, and the handful of "Electron
API not available" faults are left in English on purpose - they are diagnostics for a
state a working build never reaches, and a user who hits one is better served by a
string that can be grepped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`use-account-form.ts` and `use-permissions.ts` have no i18n changes in them. The
previous commit formatted the whole hooks directory rather than the files it edited,
and `pnpm format` is not enforced anywhere, so reformatting a file this branch has no
business in only makes the diff harder to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by scanning the renderer for string literals that read as prose rather than by
walking files, which is how all three survived six passes: each is the `: 'fallback'`
half of an `error instanceof Error ? error.message : ...`, so it only renders for a
failure that arrived without a message.

Two remain in English on purpose. `'Electron API not available'` is a fault a working
build cannot reach, and `'Some services did not stop cleanly'` is written to a store
field no component reads - the toast beside it is the one the user sees, and that one
is translated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…135)

`CLAUDE.md` said the chrome is not localised, deliberately. That is now false, and
the half of the reasoning that still holds is worth keeping rather than deleting: an
English transcript of Spanish speech is a wrong answer read out loud, which is why
nothing infers one language from the other. The other half only ever argued for not
guessing, and this feature does not guess - it asks.

The new section covers the parts a later change would break without a symptom: why a
language belongs in the enum only once it has a locale file, why parameterised strings
are functions, why `currentTranslation()` exists beside `useT`, why main's table is
not `export-labels.ts`, and what the parity test covers that the compiler cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Gitar is working

Gitar

alpha5611331 and others added 9 commits October 3, 2026 21:07
#135)

Review pass over the Russian copy, reading it as a Russian speaker rather than
checking it against the English line by line. Three kinds of problem, and the second
is the one the type system is structurally unable to see.

**Grammar and government.** `отвечает для этой роли` and `пишутся вашим опытом` are
both the wrong case for their verb; `нет кнопки, по которой можно нажать` wants
`на которую`; `ответы за ним` is "the answers behind it" rendered spatially, in six
save-history entries; `тренировка против ИИ-интервьюера` reads adversarial for what
is practice *with* an interviewer; two app-name sentences were missing the preposition
`в`.

`charactersLeft` was `Осталось N символов`, which is right for every N except 1 -
Russian wants `Остался 1 символ`, because the verb agrees with the numeral too and a
plural helper that only picks the noun cannot reach it. Rephrased to `Ещё N символов`,
which has no verb to agree.

**`mock.session.ready` said `Я готов`** - the masculine form, on the button a
candidate presses to start answering. A woman using the app was being misgendered by
the interface at the one moment it asks her to speak. Russian has no short genderless
participle for this, so the label now says what the button does rather than who is
pressing it: `Можно начинать`.

**Terminology drift.** One suggestion mode, five surfaces, and Russian named it four
different ways - `Только подсказки` on the configuration card, `Краткие подсказки` in
the control-bar tooltip, `Кратко` on the status badge, `Кратко / полными фразами` in
the hotkey sheet. English is `Hint-only` in all five. They now agree on
`Краткие подсказки`, with `Кратко` kept only for the status pill and a comment saying
why - that one shares a 32px row with the credits readout. The rename also resolves a
collision: `languageGroup.suggestionsOnly` means "only the suggestions switched" and
was rendering as the mode's own name.

**One real overflow.** `ОСТАНОВКА` is nine characters of uppercase bold Cyrillic and
does not fit `RunningIndicator`'s 96px badge once the dot and padding are out. It
wrapped rather than clipped, which makes the badge taller - and in stealth mode that
badge is on the only row on screen. `w-24` to `w-28`, with the fixed width's reason
written down so the next person does not shrink it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…path (#135)

Two slots on the control bar have a fixed width and no truncation, so a label that
does not fit does not clip - it wraps, and the control gets taller. Both sit in
single-row layouts where that moves everything beside them, and `RunningIndicator` is
the only thing on screen at all in stealth mode. Russian found one of them already;
the next locale will find them again, so the budget is written down rather than
measured by eye. The check prints the measurement either way
(`"Остановка", 9/10`), which is the number whoever adds a locale actually wants.

`useUiLanguageCode` now keys on whether the config has loaded rather than on the
stored value being truthy. A config carrying a language this build has no locale for
must resolve to English, and testing the value read that case as "not loaded yet" and
sat on the localStorage cache instead.

`document.documentElement.lang` is also set at module load, not only from the sync
effect. `lang` picks the font fallback, so setting it after the first paint means the
first frames are laid out with the wrong one and then reflow.

And `save-history-dialog` reads the partial mock copy table through a
`Partial<Record<...>>` view instead of casting `reason` into the narrower key union.
The cast asserted something false - `reason` really can be `mock-done`, which that
object really does not have - and then relied on the `??` catching it anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
)

`TrialUserNotice` is on screen for every trial user on `/main` and was still entirely
in English. It survived six translation passes and a literal-scanning sweep because
its sentence is split across lines by two `<span>`s and a `<br />`, so no single line
held anything that looked like a string.

`test/ui-text-routing.test.mjs` is the guard. It matches text sitting between two
tags, which in a .tsx file is a JSX child or nothing, so it cannot trip over code -
and it fails on exactly the shape that got past everything else. Verified by putting
the English back and watching it fail.

The notice itself is three locale entries per line rather than one sentence with a
`<strong>` in the middle, because where the emphasised words sit is a property of the
language: English puts "free tier" before the noun it qualifies, Russian puts its
equivalent after, and the tail absorbs the difference.

Its dismiss button also had no accessible name - it is an icon-only button with the
word already in the locale, so it got one on the way past.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`showSaveDialog`'s `title` is the window title the OS puts on the dialog, and the
filter `name` is what the file-type dropdown shows. Both were English literals in
`tools.service.ts` and `ipc/tools.ts`, which made the save dialog the one surface a
Russian user met in English on the way out of every export.

The distinction is the same one `export-labels.ts` draws, one layer up: the document
inside the file follows the *interview* language, because it is handed to someone who
was not there. The window asking where to put it is being read by the person at the
keyboard, so it follows the chrome language. The extensions stay as they are - `.md`
is not a word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#135)

The way a config backfill goes wrong is the way `autoScroll` went wrong once already,
and this file's own comment says so: written unconditionally rather than only when the
key is absent, so every launch resets the user's choice. For this setting that is a
Russian install reverting to English on every start, with nothing to see but the
language moving.

Driven by loading a second copy of `config.store` so its import-time migration re-runs
against what is on disk. Both directions: a stored `ru` survives the next launch, and
an install that predates the setting gets `en` written rather than only defaulted on
read. Verified by making the branch unconditional and watching the first one fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the code review. The pass drew the line at "backend error text is passed through
untranslated", which is right, and then applied it to strings **main itself wrote** -
which is the opposite of right, and is exactly what `ui-strings.ts` exists for.

`auth.service.ts` is the worst of it. `useAuth` was translated as
`result?.error || t.auth.errors.*`, but on these paths `result.error` is *always* set,
so the new fallbacks are unreachable and "Invalid email or password" - the most
frequently read error in the app - stayed English on a Russian install. Same shape in
`account.service.ts`, which surfaces on the Account page and in the wizard. Both now
author their messages from the table, and anything the backend sent is still preferred
over both and passed through untouched.

`suggestion-error.ts` writes onto `suggestion.error`, which the panel renders verbatim
on the card the candidate is reading: a rate-limited or stalled suggestion put an
English sentence mid-interview into a Russian UI. `mock-interview.service.ts` writes
onto the session's `error`, which `/mock-interview` deliberately surfaces - including
the "check the microphone" explanation a candidate is owed for a session that skipped
its way through questions they were already billed for. One of its lines was the same
sentence as `t.assistant.mockRunning`, so one route into that refusal was Russian and
the other English.

Two renderer sites with it. `proceedLabel="Continue"` on `/main`'s startup permission
gate showed a translated title, rows and Cancel beside an English primary button; and
a screen-capture timeout thrown into `startAssistant` was rendered in English by the
same toast whose generic fallback was already translated.

`test/ui-text-routing.test.mjs` grows the check that would have caught the first of
those: the copy-bearing attributes must be expressions, not string literals - a rule
about the shape rather than the words, so a one-word literal too short for any prose
heuristic still fails. Verified by putting `proceedLabel="Continue"` back. Its `\b`
was also written in a template literal, where it is a backspace character rather than
a word boundary, so the attribute check was passing vacuously until that was fixed.

The two decorative logos lose their `alt="Logo"` on the way past. They sit directly
beside the app's name in text, so an empty alt is correct - and it was the last
English word left in the accessibility tree of a Russian app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A third access point, and the one that reaches the screens the other two cannot. The
configuration page is behind a sign-in and behind the navigation lock; the wizard's
step 1 is behind a sign-in too, and is passed once. This menu renders on the login
form, on the signup wizard and on a half-finished first run - which are exactly the
screens where someone who cannot read the app has nowhere else to go. So the submenu
sits outside both the `isLoggedIn` block and the `inSetup` one.

A submenu with a radio group rather than a toggle beside the theme one. Two languages
would toggle, but the list only grows and a toggle labelled with the language you are
*not* in is the shape that stops working at three. The radio dot also answers "which
am I in" without the reader having to recognise the answer. Endonyms only, for the
same reason `UiLanguageField` lists only endonyms.

**`DropdownMenuSubContent` had to be portalled first**, and this is the first submenu
in the app so nothing had found it. Radix's own guidance is to wrap `SubContent` in a
Portal "for correct positioning"; here it is load-bearing rather than advisory,
because the parent `Content` carries `overflow-hidden` *and* the enter/exit
animation's transform - and a transformed ancestor becomes the containing block for a
`position: fixed` descendant, so the submenu would be positioned against the menu it
opened from and clipped by it. Portalled into `useMainContainer()`, the same container
`DropdownMenuContent` already uses and for the same reason.

That fix is in a vendored shadcn primitive, so a later re-sync from upstream will
silently revert it - upstream does not portal `SubContent`. The docstring says why it
is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by re-running the leftover-string scan over `src/main` rather than only the
renderer, which is where the previous pass stopped. Twenty-two more of exactly the
class the code review named: strings **this process wrote** reaching the screen
through a `{ success: false, error }` return or a suggestion's `error` field.

`payment.service.ts` had the whole set - twelve sites across six methods, every one of
them the message `usePayment`'s `result?.error || t.payment.errors.*` can never fall
back past. `account.service.ts` had four more at the `||` sites, beside the `catch`
blocks already fixed. The three suggestion services each wrote
`'The model returned an empty response.'` onto a card rendered verbatim mid-interview,
and `suggestion-action` wrote its own `'The response timed out...'` beside the
identical string already sitting unused in the table.

**`api/client.ts` was the one with reach.** On a timeout it sets
`response.error.message` to its own `'The request timed out'` - so that English string
won over *every* translated fallback behind it, in auth, account and payment alike,
for the most common failure the app has. A message thrown by the runtime
(`fetch failed`) is still passed through: we did not write it, which is the same line
already drawn for backend text.

`src/main` now has no client-authored user-facing English outside `export-labels.ts`,
which follows the interview language by design, and the diagnostics that cannot reach
a screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second review round. The string hunt is exhausted - main and the renderer both scan
clean now - so this closes the axis that was never covered at all: until this file,
no test had *executed* a locale. `plural`, three `toLocaleString` calls and the two
`duration` builders all run real logic, and all of it was reached only by the bundler.

`Translation = typeof en` describes the shape, which leaves two things that type-check
perfectly and still break on screen: a leaf that is `undefined` at runtime, which
renders as nothing and leaves a blank label; and a parameterised string that throws
when called, which takes the screen down rather than showing the wrong words.

Transpiled with `ts.transpileModule` - one call, no bundler, and `typescript` is
already a devDependency. Both locale files are self-contained once types are stripped:
`en.ts` imports nothing and `ru.ts`'s only import is `import type`, which is elided.
Every leaf is walked; every function is called twice, with 1 and with 0, because the
`duration` builders branch on each part being truthy and `0` is the path a drained
balance hits.

`plural` is pinned directly on the numbers that separate its three forms - including
11, which takes the third despite ending in 1 and is exactly what a naive
`n % 10 === 1` rule gets wrong.

Verified by breaking it both ways: a leaf written as `undefined as unknown as string`
and a function made to throw, each reported by key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331
alpha5611331 merged commit 8a2ad87 into main Oct 4, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the feat/i18n-russian-ui branch October 4, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Localise the app chrome, with Russian as the first language beside English

1 participant