From d3dbefb86a203d6d32771be6fd929bc6a2026c52 Mon Sep 17 00:00:00 2001 From: alpha Date: Sat, 3 Oct 2026 20:14:11 -0400 Subject: [PATCH 01/20] feat(i18n): add a UI language setting and the Russian locale (#135) 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 --- src/main/store/config.store.ts | 28 +++ src/main/types/ui-language.ts | 38 ++++ src/renderer/components/custom/main-frame.tsx | 7 + .../custom/settings/ui-language-field.tsx | 50 +++++ src/renderer/hooks/use-ui-language.ts | 30 +++ src/renderer/i18n/index.ts | 84 +++++++++ src/renderer/i18n/locales/en.ts | 133 +++++++++++++ src/renderer/i18n/locales/ru.ts | 145 +++++++++++++++ src/renderer/pages/configuration/index.tsx | 29 ++- src/renderer/pages/onboarding/index.tsx | 175 +++++++----------- src/renderer/types/config.ts | 5 + src/renderer/types/ui-language.ts | 46 +++++ test/run.mjs | 3 + test/ui-language.test.mjs | 161 ++++++++++++++++ 14 files changed, 812 insertions(+), 122 deletions(-) create mode 100644 src/main/types/ui-language.ts create mode 100644 src/renderer/components/custom/settings/ui-language-field.tsx create mode 100644 src/renderer/hooks/use-ui-language.ts create mode 100644 src/renderer/i18n/index.ts create mode 100644 src/renderer/i18n/locales/en.ts create mode 100644 src/renderer/i18n/locales/ru.ts create mode 100644 src/renderer/types/ui-language.ts create mode 100644 test/ui-language.test.mjs diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 7a8df018..c5741139 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -7,11 +7,20 @@ import ElectronStore from 'electron-store'; import { OPACITY_DEFAULT } from '../consts.js'; import { DEFAULT_LANGUAGE, Language, resolveLanguage } from '../types/language.js'; +import { DEFAULT_UI_LANGUAGE, resolveUiLanguage, UiLanguage } from '../types/ui-language.js'; // Runtime configuration (matches Config type in frontend) export interface RuntimeConfig { /** Interview language: what the ASR transcribes and what suggestions come back in. */ language: Language; + + /** + * The language the app's own chrome is written in. + * + * A separate key from `language` above, not a view of it. See `types/ui-language.ts` for why + * the two are never derived from each other. + */ + uiLanguage: UiLanguage; sessionToken: string; rememberMe: boolean; email: string; @@ -41,6 +50,14 @@ export interface RuntimeConfig { // Default runtime configuration const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { language: DEFAULT_LANGUAGE, + + // English, rather than a guess at the chrome language from `app.getLocale()`. An install that + // is being upgraded has never been asked this question, and answering it from the OS would + // move a working English UI under a user who did not ask for that - while a new install is + // asked outright on the first step of the first-run wizard, where the options are listed by + // endonym and picking one translates the step itself on the spot. + uiLanguage: DEFAULT_UI_LANGUAGE, + sessionToken: '', rememberMe: true, email: '', @@ -124,6 +141,11 @@ class ConfigStore { // ASR URL and the request bodies. config.language = resolveLanguage(config.language); + // Same treatment, one step further: an unresolved UI language reaches a lookup in the + // locale table rather than a request body, and comes back undefined - which renders an app + // with no text in it at all. + config.uiLanguage = resolveUiLanguage(config.uiLanguage); + return config; } @@ -271,6 +293,12 @@ export const configStore = new ConfigStore(); const legacy = (raw as (StoredRuntime & Record) | undefined)?.professionalMode; migration.hintOnlyMode = typeof legacy === 'boolean' ? legacy : true; } + if (raw?.uiLanguage === undefined) { + // Written explicitly rather than left to the default backfill in `getConfig`, so that an + // install which has been running in English keeps English on disk even if a later release + // changes what a fresh install defaults to. + migration.uiLanguage = DEFAULT_UI_LANGUAGE; + } if (raw?.mockLiveHintsEnabled === undefined) { // `mockLiveSuggestionsEnabled` is the pre-rename name, and it is read forward for the same // reason `professionalMode` is: the setting means what it meant and now defaults the way it diff --git a/src/main/types/ui-language.ts b/src/main/types/ui-language.ts new file mode 100644 index 00000000..34cbe410 --- /dev/null +++ b/src/main/types/ui-language.ts @@ -0,0 +1,38 @@ +/** + * The language the app's own chrome is written in: buttons, headings, dialogs, toasts. + * + * Deliberately not the same setting as `Language` in ./language.ts, which is the language the + * *interview* runs in. The two answer different questions and routinely disagree: a Russian + * speaker interviewing in English wants an English transcript and a Russian app, and inferring + * either from the other gets that user wrong in one direction or the other. So this is asked + * outright - the first step of the first-run wizard - and never derived. + * + * Narrow on purpose. A language belongs here once the chrome is actually translated into it, + * which is a file in `src/renderer/i18n/locales` rather than a code in a list; offering one + * without that file is offering a UI that falls back to English everywhere it matters. + * + * `src/renderer/types/ui-language.ts` carries the same enum plus the display metadata the picker + * needs, the way `Language` and `SuggestionMode` are mirrored across the two processes. + */ +export enum UiLanguage { + English = 'en', + Russian = 'ru', +} + +export const DEFAULT_UI_LANGUAGE = UiLanguage.English; + +const UI_LANGUAGE_CODES = new Set(Object.values(UiLanguage)); + +/** + * Map a stored or incoming value onto the enum, falling back to English. + * + * Same job `resolveLanguage` does for the interview language and for the same reason: the disk + * holds whatever some build wrote, and a locale this build has no translation file for must + * resolve to one it does rather than reaching a lookup that would come back undefined. + */ +export function resolveUiLanguage(raw: string | null | undefined): UiLanguage { + if (!raw) return DEFAULT_UI_LANGUAGE; + + const normalized = raw.trim().toLowerCase(); + return UI_LANGUAGE_CODES.has(normalized) ? (normalized as UiLanguage) : DEFAULT_UI_LANGUAGE; +} diff --git a/src/renderer/components/custom/main-frame.tsx b/src/renderer/components/custom/main-frame.tsx index cce9fb62..5dacfeec 100644 --- a/src/renderer/components/custom/main-frame.tsx +++ b/src/renderer/components/custom/main-frame.tsx @@ -7,6 +7,7 @@ import { useConfigStore } from '@/hooks/use-config-store'; import { MainContainerContext } from '@/hooks/use-main-container'; import { useOnboardingDismissed } from '@/hooks/use-onboarding-dismissed'; import usePointerLockGuard from '@/hooks/use-pointer-lock-guard'; +import { useUiLanguageSync } from '@/i18n'; import type { PushNotification } from '@/types/push-notification'; import { CommandPalette } from './command-palette'; @@ -17,6 +18,12 @@ import { UpdateNotification } from './update-notification'; export default function MainFrame({ children }: { children: React.ReactNode }) { usePointerLockGuard(); + // Mirrors the chosen UI language onto `document.lang` and into the paint-time cache the next + // launch reads before the config store has answered. Here rather than in a provider because + // the strings themselves need no context - `useT` reads the config store directly - and this + // is the one component mounted for every route. + useUiLanguageSync(); + // Loaded here rather than per page. Routes reached directly - a reload on `/configuration`, the // onboarding gate on `/` - all read the config, and every one of them holding its own fetch is // how one of them ends up not having it. Pages that need a *fresh* read still ask for one. diff --git a/src/renderer/components/custom/settings/ui-language-field.tsx b/src/renderer/components/custom/settings/ui-language-field.tsx new file mode 100644 index 00000000..8638c839 --- /dev/null +++ b/src/renderer/components/custom/settings/ui-language-field.tsx @@ -0,0 +1,50 @@ +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useUiLanguage } from '@/hooks/use-ui-language'; +import { useT } from '@/i18n'; +import { UI_LANGUAGES, type UiLanguage } from '@/types/ui-language'; + +/** + * The app's chrome language, on the configuration page and as the first step of the first-run + * wizard. + * + * Deliberately the same control in both places, like every other setting the wizard walks + * through - but it is the only one whose position in that wizard is load-bearing. A user reading + * the first screen of an app in a language they do not speak has one thing to find, and it is + * this; everything after it, including the step this control sits on, re-renders in the language + * they pick the moment they pick it. + * + * Only the endonym is listed. `LanguageField` shows the English name beside it because it is a + * list of 28 that someone may be scanning without having found their own language yet; this list + * is two entries read by someone whose app may currently be in the wrong one, for whom an English + * column is the half of the row they cannot use. + */ +export function UiLanguageField() { + const t = useT(); + const { uiLanguage, setUiLanguage } = useUiLanguage(); + + return ( +
+ + +

{t.uiLanguageField.description}

+
+ ); +} diff --git a/src/renderer/hooks/use-ui-language.ts b/src/renderer/hooks/use-ui-language.ts new file mode 100644 index 00000000..01df0695 --- /dev/null +++ b/src/renderer/hooks/use-ui-language.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; + +import { useUiLanguageCode } from '@/i18n'; +import { getUiLanguageOption, type UiLanguage } from '@/types/ui-language'; + +import { useConfigStore } from './use-config-store'; + +/** + * The app's chrome language, for the picker that sets it. + * + * Much less to it than `useInterviewLanguage`, and the difference is worth naming: the interview + * language is a parameter of a live ASR connection, so changing it tears two sockets down and + * re-opens them and the hook has to carry a switching state, a generation token and a failure + * the user is owed an explanation for. This one is a local store write that re-renders the tree. + * Nothing reconnects, nothing can half-apply, and there is no state to report. + */ +export function useUiLanguage() { + const uiLanguage = useUiLanguageCode(); + const updateConfig = useConfigStore((s) => s.updateConfig); + + const setUiLanguage = useCallback( + async (next: UiLanguage) => { + if (next === uiLanguage) return; + await updateConfig({ uiLanguage: next }); + }, + [uiLanguage, updateConfig] + ); + + return { uiLanguage, option: getUiLanguageOption(uiLanguage), setUiLanguage }; +} diff --git a/src/renderer/i18n/index.ts b/src/renderer/i18n/index.ts new file mode 100644 index 00000000..35b3babf --- /dev/null +++ b/src/renderer/i18n/index.ts @@ -0,0 +1,84 @@ +import { useEffect } from 'react'; + +import { useConfigStore } from '@/hooks/use-config-store'; +import { DEFAULT_UI_LANGUAGE, resolveUiLanguage, UiLanguage } from '@/types/ui-language'; + +import { en, type Translation } from './locales/en'; +import { ru } from './locales/ru'; + +export type { Translation }; + +const TRANSLATIONS: Record = { + [UiLanguage.English]: en, + [UiLanguage.Russian]: ru, +}; + +/** The dictionary for a code, for the few callers that have one in hand rather than a hook. */ +export function translationFor(code: string | null | undefined): Translation { + return TRANSLATIONS[resolveUiLanguage(code)]; +} + +/** + * Where the chosen language is cached for the next launch's first paint. + * + * The config store is the durable copy and the only one anything writes on purpose. But it is + * loaded over IPC from an effect in `MainFrame`, so for the first frames of every launch + * `config` is `undefined` - and a Russian install would open in English and then switch, which + * is the one moment a user is most likely to be deciding whether the app is translated at all. + * This is read synchronously at module load to cover that gap, the same way the theme is. + * + * Deliberately not authoritative: it is only ever read when the store has not answered yet, so a + * stale or hand-edited value survives for a few frames rather than overriding the real setting. + */ +const CACHE_KEY = 'uiLanguage'; + +function readCache(): UiLanguage { + // Throws rather than returning null in a private window or with site data blocked, and this + // runs at module load, where an exception would take the whole renderer down. + try { + return resolveUiLanguage(localStorage.getItem(CACHE_KEY)); + } catch { + return DEFAULT_UI_LANGUAGE; + } +} + +let paintTimeLanguage = readCache(); + +/** The UI language in force, falling back to the paint-time cache until the config arrives. */ +export function useUiLanguageCode(): UiLanguage { + const stored = useConfigStore((s) => s.config?.uiLanguage); + return stored ? resolveUiLanguage(stored) : paintTimeLanguage; +} + +/** + * The strings for the current UI language. + * + * Returns the whole dictionary rather than a lookup function, so every string is reached as a + * property (`t.common.cancel`) and a typo is a build error. The returned object is a module + * constant, so this does not re-render anything on its own. + */ +export function useT(): Translation { + return TRANSLATIONS[useUiLanguageCode()]; +} + +/** + * Keep the paint-time cache and the document's `lang` in step with the setting. + * + * Mounted once, in `MainFrame`. `lang` is not decoration: it picks the font fallback and the + * hyphenation dictionary the renderer uses, and it is what a screen reader reads the chrome with + * - an interface announced in English while it is written in Russian is unusable rather than + * merely wrong. + */ +export function useUiLanguageSync(): void { + const code = useUiLanguageCode(); + + useEffect(() => { + document.documentElement.lang = code; + paintTimeLanguage = code; + try { + localStorage.setItem(CACHE_KEY, code); + } catch { + // A cache miss next launch costs one frame of English. Nothing to report. + } + }, [code]); +} diff --git a/src/renderer/i18n/locales/en.ts b/src/renderer/i18n/locales/en.ts new file mode 100644 index 00000000..d29e3022 --- /dev/null +++ b/src/renderer/i18n/locales/en.ts @@ -0,0 +1,133 @@ +/** + * English, and the shape every other locale is checked against. + * + * `Translation` below is `typeof en`, so a locale file is an object literal assigned to that + * type: a key Russian is missing, or one it spells differently, is a build error rather than a + * string that renders as a key at runtime. That is the whole reason this is a typed object + * instead of i18next - for two locales with no lazy loading and no namespacing, a runtime + * lookup buys nothing and costs a silent fallback on the one screen nobody checked. + * + * Strings that take a value are functions rather than templates with placeholders, which keeps + * them type-checked by their callers and lets a locale put the value where its own grammar + * needs it - including Russian's three plural forms, which no `{{count}}` can express. + * + * Grouped by the surface that renders them, and `common` holds only words that genuinely mean + * the same thing everywhere. A button that happens to read "Close" on two screens is two keys + * if the two could ever want different words. + */ +export const en = { + common: { + cancel: 'Cancel', + close: 'Close', + back: 'Back', + continue: 'Continue', + finish: 'Finish', + retry: 'Retry', + view: 'View', + loading: 'Loading…', + }, + + uiLanguageField: { + label: 'App language', + description: + 'The app itself: buttons, headings and messages. What your interview runs in is a separate setting.', + }, + + onboarding: { + /** Over the progress bar on a first run, where the wizard is compulsory. */ + firstRunEyebrow: (appName: string) => `Set up ${appName}`, + /** The same screen re-opened from Configuration, where it is neither first nor compulsory. */ + guideEyebrow: 'Setup guide', + progress: (current: number, total: number, label: string) => + `Step ${current} of ${total} · ${label}`, + + steps: { + uiLanguage: { + label: 'App language', + title: 'Which language should the app be in?', + description: + 'Pick the language you read most comfortably. Everything from here on, including this step, follows it.', + }, + profile: { + label: 'Profile', + title: 'Tell us who you are', + description: + 'Every suggestion is written from this, in your own experience and your own words. It is the one thing the app cannot run without.', + }, + context: { + label: 'Job context', + title: 'What are you interviewing for?', + description: + 'Optional, and worth the paste: with the job description in hand the assistant answers for that role rather than in general.', + }, + language: { + label: 'Language', + title: 'Pick your interview language', + description: 'This sets both what gets transcribed and what your suggestions come back in.', + }, + microphone: { + label: 'Microphone', + title: 'Choose your microphone', + description: + 'Pick the microphone you will actually be speaking into, then test it. Wear headphones during interviews - on speakers the app hears the interviewer through your microphone and goes quiet.', + }, + mode: { + label: 'Suggestions', + title: 'How should suggestions read?', + description: 'Change your mind at any time, including mid-interview.', + }, + mockHints: { + label: 'Mock interview', + title: 'Hints in a mock interview?', + description: + 'A mock interview is where you practise against the AI interviewer. This decides whether it hands you the answer as well.', + }, + zoom: { + label: 'Size', + title: 'Is this comfortable to read?', + description: + 'The interview window is small on purpose, so it does not cover the call. Size it now, while you can take your time over it, rather than mid-question.', + }, + transcript: { + label: 'Transcript', + title: 'One last thing', + description: 'Whether to keep a live transcript on screen under your suggestions.', + }, + }, + + blocked: { + accountUnreachable: + 'Your account could not be reached, so nothing typed here can be saved yet.', + needName: 'Add your full name to continue.', + needProfile: 'Add your profile to continue.', + }, + + loadingAccount: 'Loading your account…', + accountUnreachable: + 'Could not reach your account. Nothing typed here can be saved until it comes back.', + + skip: 'Skip for now', + skipTooltip: 'You can run setup again later from Configuration', + + allSet: 'You are all set', + profileNotSavedOnSkip: 'Setup skipped, but your profile was not saved. Try again from Account.', + completionNotRecorded: 'Setup skipped, but we could not record that. It may be offered again.', + saveProfileFailed: 'Failed to save your profile', + finishFailed: 'Could not save your setup. Check your connection and try again.', + }, + + configuration: { + title: 'Configuration', + setupGuide: { + title: 'Setup guide', + description: 'Walk through everything on this page, and your profile, one step at a time.', + action: 'Run setup', + }, + hotkeys: { + title: 'Keyboard shortcuts', + description: 'Everything you can reach without touching the app during an interview.', + }, + }, +}; + +export type Translation = typeof en; diff --git a/src/renderer/i18n/locales/ru.ts b/src/renderer/i18n/locales/ru.ts new file mode 100644 index 00000000..bdd56efa --- /dev/null +++ b/src/renderer/i18n/locales/ru.ts @@ -0,0 +1,145 @@ +/** + * Russian. + * + * Typed as `Translation`, so this file cannot drift from `en.ts`: a key that is missing is a + * build error, and so is one that is spelled differently. + * + * Two things about the copy itself. Dashes are hyphens rather than the «—» Russian typography + * would normally use, because this repository's writing rules forbid generating em-dashes and + * the English source uses hyphens in the same positions. And the register matches the English: + * direct, second-person formal, and no more words than the English string uses - Russian runs + * roughly 15% longer than English for the same sentence, and these strings sit in a window whose + * minimum width is 840px. + */ +import type { Translation } from './en'; + +/** + * Russian has three plural forms and which one a number takes is not a property of the number + * alone, so it cannot be expressed as a `{{count}}` placeholder: 1 балл, 2 балла, 5 баллов, and + * 11 балл**ов** again despite ending in 1. + * + * Exported for the locale's own use only. Callers pass a number and get a finished string. + */ +export function plural(n: number, one: string, few: string, many: string): string { + const mod100 = Math.abs(n) % 100; + const mod10 = mod100 % 10; + if (mod100 >= 11 && mod100 <= 14) return many; + if (mod10 === 1) return one; + if (mod10 >= 2 && mod10 <= 4) return few; + return many; +} + +export const ru: Translation = { + common: { + cancel: 'Отмена', + close: 'Закрыть', + back: 'Назад', + continue: 'Далее', + finish: 'Готово', + retry: 'Повторить', + view: 'Открыть', + loading: 'Загрузка…', + }, + + uiLanguageField: { + label: 'Язык приложения', + description: + 'Сам интерфейс: кнопки, заголовки и сообщения. Язык собеседования настраивается отдельно.', + }, + + onboarding: { + firstRunEyebrow: (appName: string) => `Настройка ${appName}`, + guideEyebrow: 'Руководство по настройке', + progress: (current: number, total: number, label: string) => + `Шаг ${current} из ${total} · ${label}`, + + steps: { + uiLanguage: { + label: 'Язык приложения', + title: 'На каком языке показывать приложение?', + description: + 'Выберите язык, на котором вам удобнее читать. Всё дальше, включая этот шаг, будет на нём.', + }, + profile: { + label: 'Профиль', + title: 'Расскажите о себе', + description: + 'Все подсказки пишутся на основе этого - вашим опытом и вашими словами. Без этого приложение работать не сможет.', + }, + context: { + label: 'О вакансии', + title: 'На какую позицию вы проходите собеседование?', + description: + 'Необязательно, но стоит вставить: с описанием вакансии ассистент отвечает именно для этой роли, а не в общем.', + }, + language: { + label: 'Язык', + title: 'Выберите язык собеседования', + description: 'Он задаёт и то, что распознаётся, и то, на каком языке приходят подсказки.', + }, + microphone: { + label: 'Микрофон', + title: 'Выберите микрофон', + description: + 'Выберите микрофон, в который вы действительно будете говорить, и проверьте его. На собеседовании используйте наушники - через динамики приложение слышит интервьюера вашим микрофоном и замолкает.', + }, + mode: { + label: 'Подсказки', + title: 'Какими должны быть подсказки?', + description: 'Это можно изменить в любой момент, в том числе во время собеседования.', + }, + mockHints: { + label: 'Пробное собеседование', + title: 'Показывать подсказки в пробном собеседовании?', + description: + 'Пробное собеседование - это тренировка против ИИ-интервьюера. Здесь вы решаете, будет ли он подсказывать ответы.', + }, + zoom: { + label: 'Размер', + title: 'Так читать удобно?', + description: + 'Окно собеседования небольшое специально, чтобы не закрывать звонок. Настройте размер сейчас, пока есть время, а не посреди вопроса.', + }, + transcript: { + label: 'Расшифровка', + title: 'И последнее', + description: 'Показывать ли расшифровку разговора под подсказками.', + }, + }, + + blocked: { + accountUnreachable: + 'Не удалось связаться с вашей учётной записью, поэтому введённое здесь пока нельзя сохранить.', + needName: 'Укажите полное имя, чтобы продолжить.', + needProfile: 'Добавьте профиль, чтобы продолжить.', + }, + + loadingAccount: 'Загружаем вашу учётную запись…', + accountUnreachable: + 'Не удалось связаться с учётной записью. Введённое здесь нельзя сохранить, пока связь не восстановится.', + + skip: 'Пропустить', + skipTooltip: 'Настройку можно пройти позже в разделе «Настройки»', + + allSet: 'Всё готово', + profileNotSavedOnSkip: + 'Настройка пропущена, но профиль не сохранён. Попробуйте ещё раз в разделе «Учётная запись».', + completionNotRecorded: + 'Настройка пропущена, но сохранить это не удалось. Возможно, она предложится снова.', + saveProfileFailed: 'Не удалось сохранить профиль', + finishFailed: 'Не удалось сохранить настройки. Проверьте подключение и попробуйте снова.', + }, + + configuration: { + title: 'Настройки', + setupGuide: { + title: 'Руководство по настройке', + description: 'Пройдите всё на этой странице, а также профиль, шаг за шагом.', + action: 'Пройти настройку', + }, + hotkeys: { + title: 'Горячие клавиши', + description: 'Всё, до чего можно дотянуться, не трогая приложение во время собеседования.', + }, + }, +}; diff --git a/src/renderer/pages/configuration/index.tsx b/src/renderer/pages/configuration/index.tsx index 7017864f..c356f25a 100644 --- a/src/renderer/pages/configuration/index.tsx +++ b/src/renderer/pages/configuration/index.tsx @@ -9,12 +9,15 @@ import { MicrophoneField } from '@/components/custom/settings/microphone-field'; import { MockHintsField } from '@/components/custom/settings/mock-hints-field'; import { SuggestionModeField } from '@/components/custom/settings/suggestion-mode-field'; import { TranscriptPanelField } from '@/components/custom/settings/transcript-panel-field'; +import { UiLanguageField } from '@/components/custom/settings/ui-language-field'; import { ZoomField } from '@/components/custom/settings/zoom-field'; import { Button } from '@/components/ui/button'; +import { useT } from '@/i18n'; /** * How the interview runs: the microphone, the language, how suggestions read, whether mock - * interviews show them at all, and whether the transcript is docked. + * interviews show them at all, and whether the transcript is docked - and above all of it, the + * language the app itself is in, which is the one setting here that is not about an interview. * * Every control here writes straight through to the config store as it is changed - there is no * Save button, because there is nothing to batch and nothing that could be half-applied. That is @@ -25,14 +28,22 @@ import { Button } from '@/components/ui/button'; * same components, so what the wizard set is what this page shows. */ export default function ConfigurationPage() { + const t = useT(); const navigate = useNavigate(); const [hotkeysOpen, setHotkeysOpen] = useState(false); return (
- +
+ {/* First, and separated from the rest: it is the only control on this page that is not + about how an interview runs, and it is the one someone arrives here looking for when + the app is in a language they do not read. */} + + +
+ @@ -46,27 +57,25 @@ export default function ConfigurationPage() { finishing it simply records the same flag again. */}
-

Setup guide

+

{t.configuration.setupGuide.title}

- Walk through everything on this page, and your profile, one step at a time. + {t.configuration.setupGuide.description}

-

Keyboard shortcuts

-

- Everything you can reach without touching the app during an interview. -

+

{t.configuration.hotkeys.title}

+

{t.configuration.hotkeys.description}

diff --git a/src/renderer/pages/onboarding/index.tsx b/src/renderer/pages/onboarding/index.tsx index e1df424c..c8c91aa4 100644 --- a/src/renderer/pages/onboarding/index.tsx +++ b/src/renderer/pages/onboarding/index.tsx @@ -14,94 +14,43 @@ import { } from '@/components/custom/settings/profile-fields'; import { SuggestionModeField } from '@/components/custom/settings/suggestion-mode-field'; import { TranscriptPanelField } from '@/components/custom/settings/transcript-panel-field'; +import { UiLanguageField } from '@/components/custom/settings/ui-language-field'; import { ZoomField } from '@/components/custom/settings/zoom-field'; import { Button } from '@/components/ui/button'; import { useAccountForm } from '@/hooks/use-account-form'; import { useAppState } from '@/hooks/use-app-state'; import { useOnboardingDismissed } from '@/hooks/use-onboarding-dismissed'; +import { useT } from '@/i18n'; import { APP_NAME } from '@/lib/consts'; import { getElectron } from '@/lib/utils'; -type StepId = - | 'profile' - | 'context' - | 'language' - | 'microphone' - | 'mode' - | 'mock-hints' - | 'zoom' - | 'transcript'; - -interface Step { - id: StepId; - /** Two or three words for the progress line. The heading below says the rest. */ - label: string; - title: string; - description: string; -} - /** - * One thing per step, in the order a first interview needs them: who you are, what you are - * interviewing for, then the six things that decide how a session looks and behaves. + * The steps, in the order a first interview needs them. * - * Profile first because it is the only step that can block a start - the start sequence refuses - * to run without a name and a CV - and the only one that is worth typing rather than picking. + * App language first, ahead of even the profile. Every other step is a question about an + * interview; this one is a question about whether the user can read the questions. A wizard that + * asks for a CV in a language someone does not speak has already failed, and picking here + * re-renders this screen - its own heading included - in the language chosen. + * + * Profile second because it is the only step that can block progress - the start sequence + * refuses to run without a name and a CV - and the only one worth typing rather than picking. + * + * The ids double as keys into `t.onboarding.steps`, so a step cannot be added without its copy: + * a missing entry is a build error in both locales rather than a blank heading at runtime. */ -const STEPS: Step[] = [ - { - id: 'profile', - label: 'Profile', - title: 'Tell us who you are', - description: - 'Every suggestion is written from this, in your own experience and your own words. It is the one thing the app cannot run without.', - }, - { - id: 'context', - label: 'Job context', - title: 'What are you interviewing for?', - description: - 'Optional, and worth the paste: with the job description in hand the assistant answers for that role rather than in general.', - }, - { - id: 'language', - label: 'Language', - title: 'Pick your interview language', - description: 'This sets both what gets transcribed and what your suggestions come back in.', - }, - { - id: 'microphone', - label: 'Microphone', - title: 'Choose your microphone', - description: - 'Pick the microphone you will actually be speaking into, then test it. Wear headphones during interviews - on speakers the app hears the interviewer through your microphone and goes quiet.', - }, - { - id: 'mode', - label: 'Suggestions', - title: 'How should suggestions read?', - description: 'Change your mind at any time, including mid-interview.', - }, - { - id: 'mock-hints', - label: 'Mock interview', - title: 'Hints in a mock interview?', - description: - 'A mock interview is where you practise against the AI interviewer. This decides whether it hands you the answer as well.', - }, - { - id: 'zoom', - label: 'Size', - title: 'Is this comfortable to read?', - description: - 'The interview window is small on purpose, so it does not cover the call. Size it now, while you can take your time over it, rather than mid-question.', - }, - { - id: 'transcript', - label: 'Transcript', - title: 'One last thing', - description: 'Whether to keep a live transcript on screen under your suggestions.', - }, -]; +const STEP_IDS = [ + 'uiLanguage', + 'profile', + 'context', + 'language', + 'microphone', + 'mode', + 'mockHints', + 'zoom', + 'transcript', +] as const; + +type StepId = (typeof STEP_IDS)[number]; /** * First-run setup. @@ -121,6 +70,7 @@ const STEPS: Step[] = [ * it does without any of it being a decision the user has to get right now. */ export default function OnboardingPage() { + const t = useT(); const navigate = useNavigate(); const { appState } = useAppState(); const dismiss = useOnboardingDismissed((s) => s.dismiss); @@ -129,9 +79,10 @@ export default function OnboardingPage() { const [stepIndex, setStepIndex] = useState(0); const [finishing, setFinishing] = useState(false); - const step = STEPS[stepIndex]; + const step: StepId = STEP_IDS[stepIndex]; + const copy = t.onboarding.steps[step]; const isFirst = stepIndex === 0; - const isLast = stepIndex === STEPS.length - 1; + const isLast = stepIndex === STEP_IDS.length - 1; // The same screen serves two arrivals. A first run is compulsory and its way out is Skip; a // run started from Configuration's *Run setup* is neither, and calling that one "first-time @@ -150,7 +101,7 @@ export default function OnboardingPage() { // Only the profile step gates progress: the name and CV are what the start sequence checks // before it will run anything, so letting the wizard past them would only move the failure // later. - const profileBlocked = step.id === 'profile' && !(form.isComplete && form.loaded); + const profileBlocked = step === 'profile' && !(form.isComplete && form.loaded); /** * Why Continue is disabled, named after the thing that is missing. @@ -163,10 +114,10 @@ export default function OnboardingPage() { const blockedReason = !profileBlocked ? null : !form.loaded - ? 'Your account could not be reached, so nothing typed here can be saved yet.' + ? t.onboarding.blocked.accountUnreachable : form.fullName.trim() === '' - ? 'Add your full name to continue.' - : 'Add your profile to continue.'; + ? t.onboarding.blocked.needName + : t.onboarding.blocked.needProfile; /** * Record on the account that setup is done. Reports whether the write landed, so Finish can @@ -194,7 +145,7 @@ export default function OnboardingPage() { dismiss(); // Said out loud, because the screen they land on says nothing about setup, and a wizard that // simply vanishes leaves the user unsure whether it took. - if (finished) toast.success('You are all set'); + if (finished) toast.success(t.onboarding.allSet); navigate('/', { replace: true }); }; @@ -210,7 +161,7 @@ export default function OnboardingPage() { await form.save(); } catch (e) { console.error('Failed to save your profile before skipping setup:', e); - toast.warning('Setup skipped, but your profile was not saved. Try again from Account.'); + toast.warning(t.onboarding.profileNotSavedOnSkip); } } @@ -218,7 +169,7 @@ export default function OnboardingPage() { // wizard is a worse outcome than asking them again next launch, and Skip is the control // whose entire meaning is "let me out". if (!(await complete())) { - toast.warning('Setup skipped, but we could not record that. It may be offered again.'); + toast.warning(t.onboarding.completionNotRecorded); } leave(false); } finally { @@ -235,13 +186,13 @@ export default function OnboardingPage() { // hold the only content in the wizard the user typed, and a window closed on step 4 should // not mean pasting a CV in a second time. Re-saving on the second step, and again if they go // back and forward, costs one idempotent write; the alternative costs the user their CV. - if (step.id === 'profile' || step.id === 'context') { + if (step === 'profile' || step === 'context') { setFinishing(true); try { await form.save(); } catch (error) { console.error('Failed to save your profile:', error); - toast.error(error instanceof Error ? error.message : 'Failed to save your profile'); + toast.error(error instanceof Error ? error.message : t.onboarding.saveProfileFailed); return; } finally { setFinishing(false); @@ -255,7 +206,7 @@ export default function OnboardingPage() { // every step, and leaving on a write that did not land means being asked again from the // top. if (!(await complete())) { - toast.error('Could not save your setup. Check your connection and try again.'); + toast.error(t.onboarding.finishFailed); return; } leave(true); @@ -271,7 +222,7 @@ export default function OnboardingPage() { // Signed out, this screen has no account to read or write and every step would fail. Sent // where `/` sends them, rather than rendering a wizard whose every step fails to save. if (appState?.isLoggedIn === false) return ; - if (appState?.isLoggedIn !== true) return ; + if (appState?.isLoggedIn !== true) return ; return (
@@ -286,14 +237,14 @@ export default function OnboardingPage() { }} >

- {isFirstRun ? `Set up ${APP_NAME}` : 'Setup guide'} + {isFirstRun ? t.onboarding.firstRunEyebrow(APP_NAME) : t.onboarding.guideEyebrow}