Localise the app chrome, with Russian beside English - #136
Merged
Merged
Conversation
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>
#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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdrecorded the opposite rule, and half of its reasoning still holds: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.tsis the source of truth and exportsTranslation = 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.greetingon 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:
The compiler covers the other direction:
noUnusedParametersis 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
uiLanguageis resolved on the way out ofgetConfig(), the same as the interview language and for a sharper reason: an unresolved code reaches a lookup in the locale table and comes backundefined, 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 inlocalStorageand read synchronously at module load, because the store is loaded from an effect inMainFrame- 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 asexport-labels.tsand 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
export-labels.ts.errorsblocks are fallbacks, for a failure that arrived without a message.'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 intest/run.mjs. It covers what the types cannot:UiLanguagemirrors staying in step, the waylanguage.test.mjspinsLanguage;'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;uiStrings()following the stored language and the placeholder re-seed in both directions.test/mock-transcript-turns.test.mjsanchored on the literal'No answer'in the panel source; it now anchors ont.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
—, 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.Junior/Middle/Senior/Staff+andStarter/Pro/Enterpriseare 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.readysaidЯ готов- 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 isHint-onlyin all five. They agree now, withКраткоkept only for the status pill and a comment saying why. The rename also resolved a collision:languageGroup.suggestionsOnlymeans "only the suggestions switched" and was rendering as the mode's own name.A whole untranslated component.
TrialUserNoticeis on screen for every trial user on/mainand 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.mjsis 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'stitleis the OS window title and the filternameis the file-type dropdown, and all three were English literals. Same distinction asexport-labels.tsone 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 fitRunningIndicator'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 tow-28, andtest/ui-language.test.mjsnow 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в.charactersLeftsaidОсталось 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-dialogread the partial mock copy table by castingreasoninto a narrower key union - asserting something false and then relying on the??to catch it; it reads through aPartial<Record<...>>view now. AnduseUiLanguageCodekeyed 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 addingru_RUwants 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 highfoundIt 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.tsexists for.auth.service.tswas the worst of it.useAuthwas translated asresult?.error || t.auth.errors.*, but on these pathsresult.erroris 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 inaccount.service.ts, which surfaces on the Account page and in the wizard.suggestion-error.tswrites ontosuggestion.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.tswrites onto the session'serror, which/mock-interviewdeliberately 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 ast.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.startAssistant, rendered in English by the same toast whose generic fallback was already translated.test/ui-text-routing.test.mjsgrew 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\bwas 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 puttingproceedLabel="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'serrorfield:payment.service.tshad the whole set - twelve sites across six methods, every one the messageusePayment'sresult?.error || t.payment.errors.*can never fall back past.account.service.tshad four more at the||sites, beside thecatchblocks already fixed.'The model returned an empty response.'onto a card rendered verbatim mid-interview, andsuggestion-actionwrote its own'The response timed out...'beside the identical string already sitting unused in the table.api/client.tswas the one with reach. On a timeout it setsresponse.error.messageto 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/mainnow scans clean - no client-authored user-facing English outsideexport-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, threetoLocaleStringcalls and the twodurationbuilders all run real logic, reached only by the bundler.Translation = typeof endescribes the shape, which leaves two things that type-check perfectly and still break on screen: a leaf that isundefinedat runtime, and a parameterised string that throws when called - which takes the screen down rather than showing the wrong words.test/locale-runtime.test.mjstranspiles both locales withts.transpileModule(one call, no bundler,typescriptis 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 thedurationbuilders branch on each part being truthy and0is the path a drained balance hits.pluralis 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 naiven % 10 === 1rule gets wrong.Verified by breaking it both ways - a leaf written as
undefined as unknown as stringand a function made to throw, each reported by key.Final: 840 passing checks, both typecheck configs, eslint, production bundle.
🤖 Generated with Claude Code