Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d3dbefb
feat(i18n): add a UI language setting and the Russian locale (#135)
alpha5611331 Oct 4, 2026
263d00b
feat(i18n): translate the auth pages, home and account (#135)
alpha5611331 Oct 4, 2026
e69b7a5
feat(i18n): translate the settings fields, their dialogs and the hotk…
alpha5611331 Oct 4, 2026
60b881f
feat(i18n): translate the titlebar, command palette and live control …
alpha5611331 Oct 4, 2026
dc1add5
feat(i18n): translate the panels, the save-history dialog and the not…
alpha5611331 Oct 4, 2026
488336d
feat(i18n): translate the mock interview flow and the documentation p…
alpha5611331 Oct 4, 2026
f4b5c96
feat(i18n): translate the payment page and its three tabs (#135)
alpha5611331 Oct 4, 2026
27db70e
feat(i18n): translate the setting toasts and main's own chrome (#135)
alpha5611331 Oct 4, 2026
098f516
style(i18n): drop prettier churn in two untouched hooks
alpha5611331 Oct 4, 2026
619327a
feat(i18n): translate the last three toast fallbacks (#135)
alpha5611331 Oct 4, 2026
af8b779
docs: record the interface language, and retire the rule it replaces …
alpha5611331 Oct 4, 2026
7e50cc8
fix(i18n): correct Russian grammar, terminology drift and one overflo…
alpha5611331 Oct 4, 2026
b8739a3
test(i18n): pin the two fixed-width slots, and harden the paint-time …
alpha5611331 Oct 4, 2026
7eee76f
fix(i18n): translate the trial notice, and pin the gap that hid it (#…
alpha5611331 Oct 4, 2026
4cdc5de
fix(i18n): translate the three native save dialogs (#135)
alpha5611331 Oct 4, 2026
670a657
test(i18n): pin that the migration does not clobber a stored language…
alpha5611331 Oct 4, 2026
84f8643
fix(i18n): translate the main-process messages the sweep missed (#135)
alpha5611331 Oct 4, 2026
75eb607
feat(i18n): put the app language in the titlebar menu (#135)
alpha5611331 Oct 4, 2026
7cb6289
fix(i18n): translate the rest of main's own user-facing strings (#135)
alpha5611331 Oct 4, 2026
988cbc8
test(i18n): run the locales instead of only type-checking them (#135)
alpha5611331 Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,72 @@ Menu items carry an explicit `textValue` of the **English** name. Radix runs its

**Arabic and Hebrew need a text direction, and the panels are laid out left-to-right.** Every block `SafeMarkdown` emits carries `dir="auto"`, as do the transcript lines and both panels' question lines. The defect without it is not that RTL text renders left-to-right - it does not - it is that the neutrals go the wrong way: sentence-final punctuation takes the *paragraph's* direction, so the question mark lands at the wrong end, and a technical answer reorders at every switch of script, which is every answer since the prompts keep product names and code in Latin. Per block rather than once on a wrapper, because `auto` resolves from the first strong character it contains. Block code is pinned to `dir="ltr"` instead: code is left-to-right in every language and one RTL comment in a fence flips the whole block. `test/rtl-rendering.test.mjs` pins it, and it is a no-op in every language that shipped before the picker.

The app's own chrome is **not** localised, deliberately: an English button on a Spanish interview is an inconvenience, an English transcript of Spanish speech is a wrong answer read out loud.
**The app's own chrome follows a second setting, and is never derived from this one.** The old rule here was that the chrome is not localised at all, on the grounds that an English button on a Spanish interview is an inconvenience while an English transcript of Spanish speech is a wrong answer read out loud. The second half of that is still why nothing infers one language from the other: a Russian speaker interviewing in English wants an English transcript and a Russian app, and guessing either way gets that user wrong. The first half only ever argued for not guessing. So `uiLanguage` is its own config key, asked outright - the first step of the first-run wizard and the first row on the configuration page - and the interview language is left alone by it. See **Interface language** below.

**The exported report is the one exception**, because it is the one artifact that leaves the machine and is handed to someone who was not there. The summarize prompt translates the headings *it* writes; the five words the client wraps around them - Transcripts, Suggestions, Suggestion, Interviewer, Date/Time - live in [export-labels.ts](src/main/utils/export-labels.ts) and follow the same setting, or the export is the half-translated document that prompt exists to avoid. The candidate is named rather than labelled, and timestamps stay on the machine's locale. `test/tools-export.test.mjs` pins that every enum member has a full set and that an unknown code falls back to English rather than throwing.

### Interface language

`uiLanguage` ([src/main/types/ui-language.ts](src/main/types/ui-language.ts), mirrored in
[src/renderer/types/ui-language.ts](src/renderer/types/ui-language.ts) the way `Language` is) is
the language the app's buttons, headings, dialogs and toasts are written in. English and Russian
today. It is **not** `language`: see the note at the end of **Interview language** for why neither
is ever derived from the other.

**A typed dictionary, not i18next.** `src/renderer/i18n/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 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. A language belongs in the enum once it has a file in
`locales`, not before: offering one without that file is offering a UI that falls back to English
everywhere it matters.

**Strings that take a value are functions, not templates with placeholders.** That is what makes
Russian's three plural forms expressible at all - `1 кредит`, `2 кредита`, `5 кредитов`, and `11
кредитов` again despite ending in 1 - and it is why `CreditsDisplay` hands the locale two integers
rather than a formatted `2 hours 15 mins`. A `{{count}}` scheme would need a plural-rule engine to
say what a one-line function says. The compiler covers the other half: `noUnusedParameters` is on,
so a locale that declares `(email: string)` and writes a sentence without it does not compile.

`useT()` reads the config store directly, so there is no provider. The chosen code is also cached
in `localStorage` and read synchronously at module load, because the store is loaded from an effect
in `MainFrame` - without that cache a Russian install opens in English for the first frames of every
launch, which is the one moment a user is deciding whether the app is translated at all. The cache
is never authoritative: it is read only while the store has not answered.

**`currentTranslation()` is for callbacks that must stay referentially stable.** Several setting
hooks say in their own comments that the global hotkey listeners subscribe to them once rather than
resubscribing on every config change; `useT()` inside such a callback would freeze the dictionary at
creation, and adding `t` to the dependency array would defeat that stability. It is also what the
callers that cannot call a hook at all use - `showExportSuccessToast`, and the zustand store in
`use-assistant-service`.

**Main has its own table**, [ui-strings.ts](src/main/utils/ui-strings.ts), for the strings it writes
itself: the placeholder panel copy it 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. 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.
`appStateService.refreshPlaceholderLanguage()` is what keeps the two in step - the placeholder is
written on launch and after a Clear, so a language changed between those two would otherwise leave
English sample copy in the panels of a Russian app. It is a no-op once a real interview has written
to the history, which is the half that matters.

**Backend error text is passed through untranslated.** The client cannot translate a string it did
not write, and replacing a specific server message with 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.

`test/ui-language.test.mjs` covers what the types cannot: an unknown stored code (which would reach
the lookup and come back `undefined` - an app with no text in it), the two enums drifting, and a
Russian string that is still its English original. That last one type-checks perfectly, ships, and
is otherwise only ever caught by a Russian speaker reading the screen; the check is Latin letters
with no Cyrillic in the same string, with an allowlist whose every entry is a written-out claim
that one string is correct in Latin script (`Markdown`, `Pro`, `Junior`, a domain).

Russian copy uses hyphens where Russian typography would use `—`, because this repository's writing
rules forbid generating em-dashes and the English source uses hyphens in the same positions.

### Headphones

`ch_0` is a loopback of the system's render endpoint, which is the same sound the speakers are
Expand Down
14 changes: 12 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,27 @@ A length the balance cannot cover is disabled in the question-count picker with

The default. Restructures both live and triggered suggestions into a bold one-line core answer plus one bullet per point, however many the answer needs - the same answer full-sentence mode would give, reorganised so the eye finds each point in one pass and stripped of its padding. Bullets stay full speakable sentences rather than keywords, so the candidate can read one out loud as it stands. Switched from the control panel, the configuration page, or with `Ctrl+Shift+F7`, which keeps it reachable in stealth mode. Persisted locally as `hintOnlyMode`; sent to the backend as `mode` on the suggestion request, whose wire values are still `normal` / `professional`.

### Interface Language

The app's own chrome - buttons, headings, dialogs, toasts - follows `uiLanguage`, which is English or Russian. A **separate setting** from the interview language, asked outright on the first step of the first-run wizard and settable on the configuration page, and never inferred from the other: a Russian speaker interviewing in English wants an English transcript and a Russian app.

A typed dictionary rather than i18next, so a missing or misspelled translation is a build error rather than a key rendered on screen ([src/renderer/i18n](src/renderer/i18n)). Strings that take a value are functions, which is what makes Russian's three plural forms expressible. Main keeps its own table for the copy it writes itself - the panel placeholders and the push notifications it raises as toasts ([src/main/utils/ui-strings.ts](src/main/utils/ui-strings.ts)).

Two things deliberately do not follow it. The exported report follows the **interview** language, because it is handed to someone who was not there. And backend error text is passed through untranslated, because the client cannot translate a string it did not write.

### First-Run Setup

A user who has not been through setup is sent to `/onboarding` before they can reach anything else, and asked once for the seven things a first interview needs: profile, job context, language, microphone (with a live level test), suggestion style, interface size, and whether the transcript panel is docked. Each step renders the same component the account and configuration pages use.
A user who has not been through setup is sent to `/onboarding` before they can reach anything else, and asked once for the eight things a first interview needs: **app language**, profile, job context, interview language, microphone (with a live level test), suggestion style, interface size, and whether the transcript panel is docked. Each step renders the same component the account and configuration pages use.

App language is first, ahead of even the profile. Every other step asks about an interview; that one asks whether the user can read the questions, and picking there re-renders the step itself in the language chosen.

Gated on the account's `onboarding_completed`, written through `PATCH /api/users/me/onboarding` - on the account rather than on the machine, so it follows the user to a new device and a second account on a shared one gets its own run of it. The gate waits for `interviewConfigLoaded` as well as the flag, since before the account has been read the flag is a default rather than an answer.

Nothing in it is a trap: Skip is on every step, every setting has a working default, and Configuration can re-run the whole thing (`/onboarding` renders regardless of the flag). The only step that blocks is the profile, because the start sequence refuses to run without a name and a CV - and it says which of the two is missing rather than only disabling the button. Page: [src/renderer/pages/onboarding/index.tsx](src/renderer/pages/onboarding/index.tsx).

### Navigation

`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - microphone, language, suggestion style, interface size, transcript panel), or buy credits.
`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - app language, microphone, interview language, suggestion style, interface size, transcript panel), or buy credits.

**It is the only place a session begins.** Both launch buttons start one; neither implements starting one. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence; mock hands off to `/mock-interview` with the setup its dialog collected. `/main` itself carries only Stop - it is the live assistant, not a place to choose one - and shows a way back to `/` on the rare idle visit (a start cancelled at the headphone notice, or the route opened directly). Stopping asks whether to save the interview, clears it, and returns to `/`. See [docs/ux-conventions.md](docs/ux-conventions.md) for where a new capability belongs.

Expand Down
14 changes: 8 additions & 6 deletions src/main/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import os from 'os';

import { BACKEND_BASE_URL } from '../consts.js';
import { configStore } from '../store/config.store.js';
import { uiStrings } from '../utils/ui-strings.js';

function buildUserAgent(): string {
return `PowerInterviewAI/${app.getVersion()} (${process.platform}; ${process.arch}; ${os.release()})`;
Expand Down Expand Up @@ -101,7 +102,8 @@ export class ApiClient {
status: 0,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network request failed',
message:
error instanceof Error ? error.message : uiStrings().transportErrors.networkFailed,
},
};
}
Expand Down Expand Up @@ -166,10 +168,10 @@ export class ApiClient {
const timedOut = error instanceof Error && error.name === 'TimeoutError';
throw new ApiRequestError(
timedOut
? 'The request timed out'
? uiStrings().transportErrors.timedOut
: error instanceof Error
? error.message
: 'Network request failed',
: uiStrings().transportErrors.networkFailed,
0,
null
);
Expand Down Expand Up @@ -234,10 +236,10 @@ export class ApiClient {
error: {
code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR',
message: timedOut
? 'The request timed out'
? uiStrings().transportErrors.timedOut
: error instanceof Error
? error.message
: 'Network request failed',
: uiStrings().transportErrors.networkFailed,
},
};
}
Expand Down Expand Up @@ -304,7 +306,7 @@ export class ApiClient {

console.error('[ApiClient] Streaming request error:', { method, url, error });
throw new ApiRequestError(
error instanceof Error ? error.message : 'Network request failed',
error instanceof Error ? error.message : uiStrings().transportErrors.networkFailed,
0,
null
);
Expand Down
11 changes: 10 additions & 1 deletion src/main/ipc/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ipcMain } from 'electron';

import { appStateService } from '../services/app-state.service.js';
import { configStore, RuntimeConfig } from '../store/config.store.js';

export function registerConfigHandlers(): void {
Expand All @@ -16,7 +17,15 @@ export function registerConfigHandlers(): void {
// Handle config updates
ipcMain.handle('config:update', async (_event, updates: Partial<RuntimeConfig>) => {
try {
return configStore.updateConfig(updates);
const config = configStore.updateConfig(updates);

// The placeholder panel copy is written by main and only rewritten on launch and after a
// Clear, so a chrome language changed between those two would leave English sample text in
// the panels of an otherwise translated app. Keyed on the update rather than on the value
// changing, which is enough: the re-seed is a no-op once a real interview has started.
if (updates.uiLanguage !== undefined) appStateService.refreshPlaceholderLanguage();

return config;
} catch (error) {
console.error('Failed to update config:', error);
throw error;
Expand Down
6 changes: 4 additions & 2 deletions src/main/ipc/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'fs/promises';

import { toolsService } from '../services/tools.service.js';
import { ExportFormat } from '../types/export.js';
import { uiStrings } from '../utils/ui-strings.js';

export function registerToolsHandlers(): void {
ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => {
Expand All @@ -20,10 +21,11 @@ export function registerToolsHandlers(): void {
ipcMain.handle(
'tools:save-image',
async (_event, { filename, data }: { filename: string; data: number[] }) => {
const strings = uiStrings();
const { canceled, filePath } = await dialog.showSaveDialog({
title: 'Save Image',
title: strings.saveImageTitle,
defaultPath: filename,
filters: [{ name: 'PNG Image', extensions: ['png'] }],
filters: [{ name: strings.pngFilter, extensions: ['png'] }],
});
if (canceled || !filePath) return { filePath: null };
await fs.writeFile(filePath, Buffer.from(data));
Expand Down
25 changes: 15 additions & 10 deletions src/main/services/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '../store/config.store.js';
import { UserAccount } from '../types/account.js';
import { InterviewConfig } from '../types/app-state.js';
import { uiStrings } from '../utils/ui-strings.js';
import { appStateService } from './app-state.service.js';

/**
Expand Down Expand Up @@ -86,7 +87,7 @@ export class AccountService {
const migration = await this.migrateLegacyConfig(account._id, generation);
if (migration === 'migrated') return { success: true };
if (migration === 'failed') {
return { success: false, error: 'Failed to migrate local configuration' };
return { success: false, error: uiStrings().accountErrors.migrateFailed };
}
}

Expand All @@ -104,7 +105,7 @@ export class AccountService {
if (interviewConfig) this.discardLegacyConfigIfOwned(account._id);
return { success: true };
} catch {
return { success: false, error: 'Failed to fetch account' };
return { success: false, error: uiStrings().accountErrors.fetchFailed };
}
}

Expand Down Expand Up @@ -190,7 +191,10 @@ export class AccountService {
context,
});
if (response.error) {
return { success: false, error: response.error.message || 'Failed to update account' };
return {
success: false,
error: response.error.message || uiStrings().accountErrors.updateFailed,
};
}

// Mirror what the backend stored, not what was sent: it truncates oversized fields,
Expand All @@ -207,7 +211,7 @@ export class AccountService {
});
return { success: true };
} catch {
return { success: false, error: 'Failed to update account' };
return { success: false, error: uiStrings().accountErrors.updateFailed };
}
}

Expand All @@ -234,7 +238,7 @@ export class AccountService {
return {
success: fresh,
data: state.interviewConfig,
error: fresh ? undefined : pull.error || 'Failed to load configuration',
error: fresh ? undefined : pull.error || uiStrings().accountErrors.loadConfigFailed,
};
}

Expand Down Expand Up @@ -269,13 +273,14 @@ export class AccountService {
* user in the wizard on a failed write, so the one screen the deployment cannot support was
* also the one screen they could not leave except by skipping it.
*/
async setOnboardingCompleted(
completed: boolean
): Promise<{ success: boolean; error?: string }> {
async setOnboardingCompleted(completed: boolean): Promise<{ success: boolean; error?: string }> {
try {
const response = await this.client.updateOnboarding({ completed });
if (response.error && response.status !== 404) {
return { success: false, error: response.error.message || 'Failed to save your setup' };
return {
success: false,
error: response.error.message || uiStrings().accountErrors.onboardingFailed,
};
}

// Bumped for the same reason `updateConfig` bumps it: a pull that started before this
Expand All @@ -287,7 +292,7 @@ export class AccountService {
});
return { success: true };
} catch {
return { success: false, error: 'Failed to save your setup' };
return { success: false, error: uiStrings().accountErrors.onboardingFailed };
}
}
}
Expand Down
Loading
Loading