diff --git a/.gitignore b/.gitignore
index 2cf45d00..dca48d0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,12 @@ build/
!app/build/
app/build/*
!app/build/entitlements.mac.plist
+# Localised macOS permission-prompt bodies (#337). app/build/* above is an
+# allowlist, so these need an explicit exception - without it a clean CI
+# checkout has no de.lproj, electron-builder only warns, and the signed
+# release ships English prompts while local builds look correct.
+!app/build/lproj/
+!app/build/lproj/**
!app/build/icon.icns
!app/build/icon-dragonfly.icns
!app/build/icon.ico
diff --git a/app/build/lproj/de.lproj/InfoPlist.strings b/app/build/lproj/de.lproj/InfoPlist.strings
new file mode 100644
index 00000000..60c56e6a
--- /dev/null
+++ b/app/build/lproj/de.lproj/InfoPlist.strings
@@ -0,0 +1,3 @@
+/* Siehe en.lproj/InfoPlist.strings fuer die Herkunft dieser Datei. */
+"NSMicrophoneUsageDescription" = "Steno braucht Zugriff auf das Mikrofon, um Meetings aufzunehmen und zu transkribieren.";
+"NSAudioCaptureUsageDescription" = "Steno braucht Zugriff auf den Systemton, um den Ton aus Videokonferenzen aufzunehmen.";
diff --git a/app/i18n.js b/app/i18n.js
new file mode 100644
index 00000000..4906a217
--- /dev/null
+++ b/app/i18n.js
@@ -0,0 +1,164 @@
+/*
+ * UI-chrome localisation for the MAIN process (issue #337).
+ *
+ * This is one of two independent i18next instances. The renderer runs its own
+ * (app/renderer/src/lib/i18n.ts). Nothing synchronises them automatically —
+ * they share these JSON files and are coordinated by an explicit protocol in
+ * main.js (persist → main changeLanguage → rebuild menu + tray → tell every
+ * renderer). Changing one without the other leaves the app half-translated.
+ *
+ * Not to be confused with the transcription/content language, which is the
+ * `language` config key and the get-language/set-language IPC pair. This
+ * module only ever touches `ui_language`.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const i18next = require('i18next');
+
+// Keep in sync with VALID_UI_LANGUAGES in src/config.py.
+const SUPPORTED_UI_LANGUAGES = ['en', 'de'];
+const FALLBACK_UI_LANGUAGE = 'en';
+// The stored preference may also be this sentinel, which means "follow the OS".
+const SYSTEM_SENTINEL = 'system';
+
+// Resolved unconditionally from __dirname rather than through the packaged-
+// resources branch other assets use: locales/ is inside the electron-builder
+// `files` set, so it lands inside app.asar on macOS (Electron's patched fs
+// reads straight through it) and as plain files under resources/app on
+// Windows, where `win.asar` is false. Both are __dirname-relative.
+const LOCALES_DIR = path.join(__dirname, 'locales');
+
+function loadResources() {
+ const resources = {};
+ for (const lng of SUPPORTED_UI_LANGUAGES) {
+ try {
+ const raw = fs.readFileSync(path.join(LOCALES_DIR, `${lng}.json`), 'utf-8');
+ resources[lng] = { translation: JSON.parse(raw) };
+ } catch (err) {
+ // A missing or corrupt non-English file degrades to fallback text. A
+ // missing English file is a build defect, so let that one be loud.
+ if (lng === FALLBACK_UI_LANGUAGE) throw err;
+ resources[lng] = { translation: {} };
+ }
+ }
+ return resources;
+}
+
+/*
+ * Pick the best supported language for an ordered list of user preferences,
+ * e.g. Electron's app.getPreferredSystemLanguages() → ['de-DE', 'en-GB'].
+ *
+ * Ordered, not "first tag wins": a user whose list is ['fr-FR', 'de-DE'] has
+ * no French UI available here and should get German rather than falling
+ * through to English. Region subtags are dropped — we ship language-level
+ * translations only.
+ */
+function negotiateSystemLanguage(preferred, supported = SUPPORTED_UI_LANGUAGES) {
+ if (!Array.isArray(preferred)) return FALLBACK_UI_LANGUAGE;
+ for (const tag of preferred) {
+ if (typeof tag !== 'string' || !tag) continue;
+ const base = tag.toLowerCase().split(/[-_]/)[0];
+ if (supported.includes(base)) return base;
+ }
+ return FALLBACK_UI_LANGUAGE;
+}
+
+/*
+ * Read the stored preference straight from config.json.
+ *
+ * Same sync-JSON-read-at-startup pattern as loadShowMenuBarIconEnabled() and
+ * friends in main.js: the language has to be known before the application menu
+ * is built and before the first window loads, which is far too early to wait on
+ * a Python subprocess. The Python config remains the source of truth for
+ * writes.
+ *
+ * The absent-key branch mirrors _migrate_ui_language() in src/config.py and the
+ * asymmetry is deliberate: a config.json that exists but predates this key
+ * belongs to an install that has been running an English UI, and must keep it.
+ * Defaulting those to "system" would flip a German-OS user's interface to
+ * German without them ever asking. Only a genuinely fresh install follows the
+ * OS. If you change this, change the Python migration in the same commit.
+ */
+function readStoredUiLanguage(userDataDir) {
+ try {
+ const cfgPath = path.join(userDataDir, 'config.json');
+ if (!fs.existsSync(cfgPath)) return SYSTEM_SENTINEL; // fresh install
+ const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
+ const stored = cfg.ui_language;
+ if (stored === SYSTEM_SENTINEL || SUPPORTED_UI_LANGUAGES.includes(stored)) {
+ return stored;
+ }
+ return FALLBACK_UI_LANGUAGE; // existing install predating the key
+ } catch (_) {
+ return FALLBACK_UI_LANGUAGE;
+ }
+}
+
+/*
+ * Turn the stored preference into a concrete language tag.
+ * `preferredSystemLanguages` is injected so this stays testable without an
+ * Electron app object; main.js passes app.getPreferredSystemLanguages().
+ */
+function resolveUiLanguage(stored, preferredSystemLanguages) {
+ if (stored === SYSTEM_SENTINEL) {
+ return negotiateSystemLanguage(preferredSystemLanguages);
+ }
+ if (SUPPORTED_UI_LANGUAGES.includes(stored)) return stored;
+ return FALLBACK_UI_LANGUAGE;
+}
+
+let initialized = false;
+
+async function initMainI18n(language) {
+ const lng = SUPPORTED_UI_LANGUAGES.includes(language) ? language : FALLBACK_UI_LANGUAGE;
+ if (initialized) {
+ await i18next.changeLanguage(lng);
+ return i18next;
+ }
+ await i18next.init({
+ lng,
+ fallbackLng: FALLBACK_UI_LANGUAGE,
+ resources: loadResources(),
+ interpolation: {
+ // Menu labels and notification bodies are plain strings handed to native
+ // APIs, never injected as HTML, so i18next's HTML escaping would only
+ // mangle characters like & in "&File".
+ escapeValue: false,
+ },
+ returnNull: false,
+ });
+ initialized = true;
+ return i18next;
+}
+
+// Bound at call time, not captured — changeLanguage() must be visible to every
+// later t() without callers re-importing anything.
+function t(key, options) {
+ if (!initialized) return key;
+ return i18next.t(key, options);
+}
+
+async function changeMainLanguage(language) {
+ const lng = SUPPORTED_UI_LANGUAGES.includes(language) ? language : FALLBACK_UI_LANGUAGE;
+ if (!initialized) return initMainI18n(lng);
+ await i18next.changeLanguage(lng);
+ return i18next;
+}
+
+function currentLanguage() {
+ return initialized ? i18next.language : FALLBACK_UI_LANGUAGE;
+}
+
+module.exports = {
+ SUPPORTED_UI_LANGUAGES,
+ FALLBACK_UI_LANGUAGE,
+ SYSTEM_SENTINEL,
+ negotiateSystemLanguage,
+ readStoredUiLanguage,
+ resolveUiLanguage,
+ initMainI18n,
+ changeMainLanguage,
+ currentLanguage,
+ t,
+};
diff --git a/app/locale-completeness.test.js b/app/locale-completeness.test.js
new file mode 100644
index 00000000..1b510717
--- /dev/null
+++ b/app/locale-completeness.test.js
@@ -0,0 +1,169 @@
+'use strict';
+
+/**
+ * Locale completeness gate (#337).
+ *
+ * English is the source language, so the gate that matters is **English-source
+ * completeness**: every key the code asks for must exist in `en.json`. A missing
+ * English key renders as the raw key string ("settings.general.name.label") in
+ * the UI, which is a visible defect.
+ *
+ * German coverage is deliberately REPORTED, NOT ENFORCED. i18next falls back to
+ * English for a missing key, so a partially translated locale degrades to
+ * readable English rather than breaking — which is exactly what makes it safe
+ * for a locale to arrive incrementally from a community contributor. Making
+ * parity a hard gate would block every English copy change on a translator.
+ *
+ * What this canNOT prove: that every hardcoded string was extracted in the first
+ * place. It only proves the keys that ARE used resolve. Catching un-extracted
+ * strings needs a lint rule against raw user-facing literals, which is a
+ * separate piece of work.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const path = require('path');
+
+const LOCALES = path.join(__dirname, 'locales');
+const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/;
+
+function flatten(obj, prefix = '') {
+ const out = new Set();
+ for (const [k, v] of Object.entries(obj)) {
+ const key = `${prefix}${k}`;
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
+ for (const nested of flatten(v, `${key}.`)) out.add(nested);
+ } else {
+ out.add(key);
+ }
+ }
+ return out;
+}
+
+function loadLocale(lng) {
+ return JSON.parse(fs.readFileSync(path.join(LOCALES, `${lng}.json`), 'utf-8'));
+}
+
+function sourceFiles() {
+ const files = [];
+ const walk = (dir) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
+ walk(full);
+ } else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.includes('.test.')) {
+ files.push(full);
+ }
+ }
+ };
+ walk(path.join(__dirname, 'renderer', 'src'));
+ // The main-process instance reads the same bundles.
+ files.push(path.join(__dirname, 'main.js'), path.join(__dirname, 'settings-ipc.js'));
+ return files;
+}
+
+/*
+ * Only literal keys are collectable. Keys built from a template
+ * (`t(`palette.settings.${row.key}.title`)`) are invisible to a text scan, so
+ * this is a floor on coverage, not a complete picture — which is why the
+ * reverse check (defined-but-unused) is reported rather than asserted.
+ */
+function usedKeys() {
+ const literal = /(?:\bt|i18n\.t)\(\s*['"]([a-zA-Z][\w.]*)['"]/g;
+ const transComponent = /i18nKey=\s*['"]([\w.]+)['"]/g;
+ const used = new Set();
+ for (const file of sourceFiles()) {
+ const src = fs.readFileSync(file, 'utf-8');
+ for (const m of src.matchAll(literal)) used.add(m[1]);
+ for (const m of src.matchAll(transComponent)) used.add(m[1]);
+ }
+ return used;
+}
+
+/** i18next resolves `key` against `key_one`/`key_other`, so both count as defined. */
+function resolvableKeys(locale) {
+ const defined = flatten(locale);
+ const resolvable = new Set(defined);
+ for (const key of defined) {
+ if (PLURAL_SUFFIX.test(key)) resolvable.add(key.replace(PLURAL_SUFFIX, ''));
+ }
+ return resolvable;
+}
+
+test('every translation key used in the code exists in en.json', () => {
+ const resolvable = resolvableKeys(loadLocale('en'));
+ const missing = [...usedKeys()].filter((k) => !resolvable.has(k)).sort();
+ assert.deepStrictEqual(
+ missing,
+ [],
+ `keys used in code but absent from en.json (these render as raw key strings): ${missing.join(', ')}`,
+ );
+});
+
+test('German defines no key that English does not (English is the source)', () => {
+ // The direction that is a real bug: a German-only key can never resolve,
+ // because nothing in the code asks for a key that has no English source. It
+ // is almost always a typo in the German file.
+ const en = flatten(loadLocale('en'));
+ const orphans = [...flatten(loadLocale('de'))].filter((k) => !en.has(k)).sort();
+ assert.deepStrictEqual(orphans, [], `German keys with no English counterpart: ${orphans.join(', ')}`);
+});
+
+test('every plural key ships both forms in both locales', () => {
+ // A key with `_one` but no `_other` throws at the plural boundary rather than
+ // falling back, so this one IS enforced for German too.
+ for (const lng of ['en', 'de']) {
+ const keys = flatten(loadLocale(lng));
+ const broken = [];
+ for (const key of keys) {
+ if (!PLURAL_SUFFIX.test(key)) continue;
+ const base = key.replace(PLURAL_SUFFIX, '');
+ if (!keys.has(`${base}_one`) || !keys.has(`${base}_other`)) broken.push(key);
+ }
+ assert.deepStrictEqual(broken.sort(), [], `${lng}.json has half a plural pair: ${broken.join(', ')}`);
+ }
+});
+
+test('interpolation placeholders match between English and German', () => {
+ // A renamed or dropped placeholder is silent: i18next just leaves the literal
+ // {{name}} in the output, or interpolates nothing. Only a comparison catches it.
+ const en = loadLocale('en');
+ const de = loadLocale('de');
+ const flat = (obj, prefix = '', out = {}) => {
+ for (const [k, v] of Object.entries(obj)) {
+ const key = `${prefix}${k}`;
+ if (v && typeof v === 'object') flat(v, `${key}.`, out);
+ else out[key] = v;
+ }
+ return out;
+ };
+ const enFlat = flat(en);
+ const deFlat = flat(de);
+ const placeholders = (s) =>
+ new Set([...String(s).matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]));
+
+ const mismatches = [];
+ for (const [key, deValue] of Object.entries(deFlat)) {
+ const enValue = enFlat[key];
+ if (enValue === undefined) continue; // covered by the orphan test above
+ const a = placeholders(enValue);
+ const b = placeholders(deValue);
+ if (a.size !== b.size || [...a].some((p) => !b.has(p))) {
+ mismatches.push(`${key}: en{${[...a]}} de{${[...b]}}`);
+ }
+ }
+ assert.deepStrictEqual(mismatches.sort(), [], `placeholder drift:\n ${mismatches.join('\n ')}`);
+});
+
+test('German coverage is reported, not enforced', () => {
+ // Informational on purpose — see the file header. This prints a number so a
+ // reviewer can see coverage move; it must never fail a build.
+ const en = flatten(loadLocale('en'));
+ const de = flatten(loadLocale('de'));
+ const translated = [...en].filter((k) => de.has(k)).length;
+ const pct = ((translated / en.size) * 100).toFixed(1);
+ console.log(` German coverage: ${translated}/${en.size} keys (${pct}%)`);
+ assert.ok(en.size > 0);
+});
diff --git a/app/locales/de.json b/app/locales/de.json
new file mode 100644
index 00000000..c9fad977
--- /dev/null
+++ b/app/locales/de.json
@@ -0,0 +1,1208 @@
+{
+ "app": {
+ "actions": "Aktionen",
+ "date": {
+ "today": "Heute",
+ "yesterday": "Gestern"
+ },
+ "dropBlocked": "Aufnahme stoppen, um zu importieren",
+ "dropToImport": "Audio zum Importieren ablegen",
+ "loading": "Wird geladen…",
+ "rename": "Umbenennen"
+ },
+ "chat": {
+ "askAbout": "Fragen zu {{name}}",
+ "askAcross": "Übergreifend fragen…",
+ "askAi": "KI fragen",
+ "askAria": "Fragen zu diesem Meeting",
+ "backToChat": "Zurück zum Chat",
+ "bucket": {
+ "last2Weeks": "Letzte 2 Wochen",
+ "thisMonth": "Diesen Monat",
+ "thisWeek": "Diese Woche",
+ "today": "Heute",
+ "yesterday": "Gestern"
+ },
+ "chatActions": "Chat-Aktionen",
+ "chips": {
+ "actionItems": "Aufgaben",
+ "keyDecisions": "Wichtigste Entscheidungen zusammenfassen",
+ "mainTopics": "Hauptthemen"
+ },
+ "collapse": "Einklappen",
+ "composerPlaceholder": "Frag einfach los /",
+ "copyTranscript": "Transkript kopieren",
+ "deleteChat": "Chat {{name}} löschen",
+ "emptyResponse": "(leere Antwort)",
+ "failedToSend": "Senden fehlgeschlagen",
+ "greeting": "Frag einfach los",
+ "greetingNamed": "Hi {{name}} , frag einfach los",
+ "hideTranscript": "Transkript ausblenden",
+ "history": "Verlauf",
+ "mayOmitOlderNotes": "· ältere Notizen können fehlen",
+ "model": {
+ "auto": "Auto",
+ "cloud": "Cloud",
+ "organisation": "Organisation",
+ "remoteOllama": "Remote-Ollama"
+ },
+ "newChat": "Neuer Chat",
+ "noOtherChats": "Noch keine anderen Chats.",
+ "noRecents": "Deine bisherigen Chats erscheinen hier.",
+ "noSavedChats": "Noch keine gespeicherten Chats.",
+ "notFound": "Chat nicht gefunden.",
+ "notFoundHint": "Diese Unterhaltung wurde vielleicht gelöscht.",
+ "orgScopeHint": "Notizübergreifender Chat über die geteilten Notizen von {{org}}",
+ "placeholderAsk": "Frag etwas zu diesem Meeting…",
+ "placeholderContinue": "Chat fortsetzen…",
+ "placeholderRecording": "Chat nach der Aufnahme verfügbar",
+ "presets": "Vorschläge",
+ "providerRequired": "Dein KI-Anbieter ist noch nicht bereit für den Chat - hinterlege einen Cloud-API-Key, melde dich bei deiner Organisation an oder trage deine Remote-Ollama-URL unter Einstellungen → KI ein.",
+ "providerRequiredPlaceholder": "KI-Anbieter in den Einstellungen einrichten, um übergreifend zu fragen",
+ "queryFailed": "Abfrage fehlgeschlagen",
+ "recents": "Zuletzt",
+ "relative": {
+ "days": "{{n}}T",
+ "hours": "{{n}}Std",
+ "minutes": "{{n}}Min",
+ "months": "{{n}}Mon",
+ "now": "jetzt",
+ "weeks": "{{n}}W",
+ "years": "{{n}}J"
+ },
+ "resume": "Fortsetzen",
+ "resumeAria": "Aufnahme für diese Notiz fortsetzen",
+ "resumeTitle": "Aufnahme fortsetzen - das neue Audio wird an diese Notiz angehängt",
+ "scopeAria": "Bereich: {{scope}}",
+ "seeAll": "Alle anzeigen",
+ "send": "Senden",
+ "showLess": "Weniger anzeigen",
+ "showTranscript": "Transkript anzeigen",
+ "skills": "Skills",
+ "stop": "Stopp",
+ "streamError": "Fehler: {{error}}",
+ "switchChat": "Chat wechseln",
+ "thinking": "Denkt nach",
+ "thinkingStream": "Denkt nach…",
+ "transcript": "Transkript",
+ "untitled": "Chat ohne Titel"
+ },
+ "common": {
+ "appName": "Steno",
+ "cancel": "Abbrechen",
+ "close": "Schließen",
+ "confirm": "Bestätigen",
+ "delete": "Löschen",
+ "done": "Fertig",
+ "retry": "Erneut versuchen",
+ "save": "Sichern",
+ "working": "Einen Moment..."
+ },
+ "dialog": {
+ "filter": {
+ "audio": "Audiodateien",
+ "markdown": "Markdown",
+ "pdf": "PDF",
+ "text": "Text"
+ }
+ },
+ "dock": {
+ "changeLanguage": "Transkriptsprache ändern",
+ "copyTranscript": "Transkript kopieren",
+ "hideTranscript": "Transkript ausblenden",
+ "languageAria": "Sprache: {{language}}",
+ "languageMulti": "Multi",
+ "listening": "Hört zu…",
+ "listeningHint": "Sprich einfach los - fertige Sätze erscheinen hier.",
+ "liveUnavailable": "Live-Transkription nicht verfügbar",
+ "minimizeTranscript": "Transkript minimieren",
+ "noMatches": "Keine Treffer",
+ "noMatchesHint": "Nichts passt zu deinem Filter.",
+ "paused": "Pausiert",
+ "preparing": "Transkription wird vorbereitet…",
+ "preparingHint": "Parakeet wärmt sich auf. Audio wird bereits aufgenommen.",
+ "preparingShort": "Wird vorbereitet…",
+ "preparingSlow": "Wärmt sich noch auf - der erste Start kann einen Moment dauern. Audio wird bereits aufgenommen.",
+ "recording": "Aufnahme",
+ "resumeRecording": "Aufnahme fortsetzen",
+ "resumed": "Fortgesetzt",
+ "searchTranscript": "Transkript durchsuchen",
+ "showTranscript": "Transkript anzeigen",
+ "stage": "Phase: {{stage}}",
+ "stageWithMessage": "{{stage}}: {{message}}",
+ "stillPreparing": "Wird noch vorbereitet…",
+ "stop": "Stopp",
+ "stopRecording": "Aufnahme stoppen",
+ "transcript": "Transkript",
+ "transcriptHeader": "Transkript-Kopfzeile"
+ },
+ "folders": {
+ "changeIcon": "Ordnersymbol ändern",
+ "create": "Ordner erstellen",
+ "deleteNoMeetings": "Es werden keine Aufnahmen oder Transkripte gelöscht.",
+ "deleteTitle": "Ordner „{{name}}“ löschen?",
+ "deleteWithMeetings_one": "{{count}} Meeting wird zurück nach „Alle Notizen“ verschoben. Es werden keine Aufnahmen oder Transkripte gelöscht.",
+ "deleteWithMeetings_other": "{{count}} Meetings werden zurück nach „Alle Notizen“ verschoben. Es werden keine Aufnahmen oder Transkripte gelöscht.",
+ "emptyHint": "Notizen, die du in diesem Ordner speicherst, erscheinen hier.",
+ "emptyTitle": "Noch nichts hier",
+ "loading": "Ordner wird geladen…",
+ "meetingCount_one": "{{count}} Meeting",
+ "meetingCount_other": "{{count}} Meetings",
+ "namePlaceholder": "z. B. Acme Corp",
+ "newFolder": "Neuer Ordner",
+ "newFolderDescription": "Fasse zusammengehörige Meetings zusammen. Ordnernamen sind nur für dich sichtbar.",
+ "noIconsMatch": "Keine Symbole passen zu „{{query}}“",
+ "notFound": "Ordner nicht gefunden.",
+ "notFoundHint": "Dieser Ordner wurde vielleicht gelöscht. Zurück zum Start .",
+ "notes": "Notizen",
+ "searchIcons": "Symbole suchen…"
+ },
+ "hero": {
+ "headline": {
+ "clearDay": "Dein Tag ist frei",
+ "inMeeting": "Gerade in einem Meeting",
+ "nextInHours_one": "Nächstes Meeting in {{count}} Stunde",
+ "nextInHours_other": "Nächstes Meeting in {{count}} Stunden",
+ "nextInMinutes_one": "Nächstes Meeting in {{count}} Minute",
+ "nextInMinutes_other": "Nächstes Meeting in {{count}} Minuten",
+ "paused": "Aufnahme pausiert",
+ "processing": "Deine Notiz wird erstellt",
+ "ready": "Bereit für richtig gute Notizen",
+ "recording": "Aufnahme läuft"
+ },
+ "recordingHint": "Die Aufnahme startest du oben rechts oder von überall mit {{shortcut}}.",
+ "subtitle": {
+ "inMeeting": "Mit {{shortcut}} startest du die Aufnahme, oder du klickst unten auf eine Meeting-Karte.",
+ "inProgressFallback": "Läuft gerade",
+ "nextSoon": "{{title}} um {{time}}. {{shortcut}}, wenn du bereit bist.",
+ "paused": "Aufnahme pausiert. Zum Weitermachen unten in der Leiste auf „Fortsetzen“ klicken.",
+ "processing": "Gleich ist deine Notiz fertig.",
+ "recording": "{{title}} · {{shortcut}} zum Stoppen",
+ "tomorrow": "Als Nächstes: {{title}} morgen um {{time}}."
+ }
+ },
+ "home": {
+ "allDay": {
+ "toggle_one": "+ {{count}} ganztägiger Termin heute",
+ "toggle_other": "+ {{count}} ganztägige Termine heute"
+ },
+ "calendarNudge": {
+ "connectLabel": "Verbinden:",
+ "connecting": "Verbinde mit {{provider}}…",
+ "dismiss": "Ausblenden",
+ "prompt": "Verbinde deinen Kalender, um die Meetings von heute zu sehen."
+ },
+ "day": {
+ "earlier": "Früher",
+ "today": "Heute",
+ "tomorrow": "Morgen",
+ "yesterday": "Gestern"
+ },
+ "duration": {
+ "hoursMinutes": "{{hours}} Std. {{minutes}} Min.",
+ "minutes": "{{minutes}} Min.",
+ "seconds": "{{seconds}} Sek."
+ },
+ "empty": {
+ "consent": "Hol dir immer das Einverständnis, wenn du andere transkribierst.",
+ "newNote": "Neue Notiz",
+ "quickStartLabel": "Schnellstart:",
+ "quickStartSuffix": "von überall",
+ "stopRecording": "Aufnahme stoppen",
+ "tagline": "KI für deine vertraulichen Workflows.",
+ "title": "Willkommen bei Steno."
+ },
+ "loading": "Meetings werden geladen…",
+ "previousRow": {
+ "participants_one": "{{count}} Person",
+ "participants_other": "{{count}} Personen",
+ "processingBadge": "Verarbeitung",
+ "recordingBadge": "Aufnahme",
+ "untitledNote": "Notiz ohne Titel"
+ },
+ "relative": {
+ "days_one": "{{count}} Tag",
+ "days_other": "{{count}} Tage",
+ "hours_one": "{{count}} Std.",
+ "hours_other": "{{count}} Std.",
+ "minutes_one": "{{count}} Min.",
+ "minutes_other": "{{count}} Min.",
+ "now": "Jetzt"
+ },
+ "search": {
+ "clear": "Suche zurücksetzen",
+ "noMatches": "Keine Treffer für „{{query}}“.",
+ "placeholder": "Notizen durchsuchen"
+ },
+ "sections": {
+ "allNotes": "Alle Notizen",
+ "comingUp": "Demnächst",
+ "previous": "Frühere Notizen",
+ "today": "Heute",
+ "tomorrow": "Morgen"
+ },
+ "upcoming": {
+ "nextPage": "Weiter",
+ "prevPage": "Zurück",
+ "refresh": "Nach neuen Kalendereinträgen suchen"
+ },
+ "upcomingCard": {
+ "allDay": "Ganztägig",
+ "endsIn_one": "Endet in {{count}} Min.",
+ "endsIn_other": "Endet in {{count}} Min.",
+ "join": "Beitreten",
+ "justStarted": "Gerade gestartet",
+ "startNow": "Jetzt starten",
+ "startedAgo_one": "Vor {{count}} Min. gestartet",
+ "startedAgo_other": "Vor {{count}} Min. gestartet",
+ "untitled": "Meeting ohne Titel"
+ }
+ },
+ "meeting": {
+ "action": {
+ "backToHome": "Zurück zur Startseite",
+ "copied": "Kopiert",
+ "copiedConfirm": "Kopiert!",
+ "copyNotes": "Notizen kopieren",
+ "copyTranscript": "Transkript kopieren",
+ "deleteNote": "Notiz löschen",
+ "generateNotes": "Notizen erzeugen",
+ "home": "Start",
+ "moreOptions": "Weitere Optionen",
+ "regenerateTitle": "Titel neu erzeugen",
+ "retranscribeRecording": "Aufnahme neu transkribieren",
+ "saveNotesPdf": "Notizen als PDF speichern…",
+ "saveTranscriptMarkdown": "Transkript als .md speichern…",
+ "viewContainingFolder": "Speicherort anzeigen"
+ },
+ "backToMeetings": "Zurück zu den Meetings",
+ "backup": {
+ "backingUp": "Backup läuft…",
+ "failed": "Diese Notiz wurde nicht gesichert. Zum Wiederholen klicken.",
+ "failedWithReason": "Letztes Backup fehlgeschlagen: {{error}}. Zum Wiederholen klicken.",
+ "notBackedUp": "Nicht gesichert"
+ },
+ "error": {
+ "copyTranscript": "Transkript konnte nicht kopiert werden: {{error}}",
+ "deleteFailed": "Löschen fehlgeschlagen: {{error}}",
+ "generic": "Da ist etwas schiefgelaufen.",
+ "saveNotes": "Notizen konnten nicht gespeichert werden: {{error}}",
+ "saveTranscript": "Transkript konnte nicht gespeichert werden: {{error}}",
+ "unknown": "unbekannter Fehler"
+ },
+ "folder": {
+ "addToFolder": "Zu Ordner hinzufügen",
+ "namePlaceholder": "Ordnername...",
+ "new": "Neuer Ordner...",
+ "none": "Kein Ordner"
+ },
+ "loadError": {
+ "body": "Beim Laden dieser Notiz ist ein Fehler aufgetreten.",
+ "title": "Notiz konnte nicht geladen werden."
+ },
+ "loading": "Meeting wird geladen…",
+ "noNotesYet": {
+ "body": "Diese Aufnahme wurde transkribiert, Notizen wurden aber nicht automatisch erzeugt. Mit Notizen erzeugen unten kannst du sie jetzt erstellen, oder du kopierst bzw. speicherst das Transkript über die Aktionen oben.",
+ "title": "Noch keine Notizen"
+ },
+ "noSummary": "Für dieses Meeting gibt es keine Zusammenfassung.",
+ "notFound": {
+ "body": "Diese Aufnahme wurde möglicherweise gelöscht. Wähle in der Seitenleiste eine andere aus.",
+ "title": "Notiz nicht gefunden."
+ },
+ "notes": {
+ "placeholder": "Notizen schreiben…"
+ },
+ "participantCount_one": "{{count}} Person",
+ "participantCount_other": "{{count}} Personen",
+ "placeholderTitle": {
+ "meeting": "Meeting",
+ "note": "Notiz"
+ },
+ "processing": {
+ "body": "Dein Transkript ist gesichert. Im Hintergrund wird es verfeinert und es entstehen die Notizen. Meine Notizen kannst du jetzt schon lesen und bearbeiten.",
+ "title": "Fast fertig"
+ },
+ "report": {
+ "deleteBody": "Dieser erzeugte Bericht wird endgültig gelöscht. Das Transkript und andere Berichte bleiben unberührt.",
+ "deleteLabel": "Bericht {{name}} löschen",
+ "deleteTitle": "Bericht „{{name}}“ löschen?"
+ },
+ "reprocessFailed": {
+ "body": "Das hat diesmal nicht geklappt, versuch es noch einmal. Wenn es bei einem langen Meeting immer wieder fehlschlägt, wechsle in den Einstellungen zu einem kleineren Modell.",
+ "title": "Notizen wurden nicht erzeugt"
+ },
+ "retranscribeDialog": {
+ "body": "Die Transkription läuft mit deinen aktuellen Transkriptionseinstellungen erneut. Das Transkript wird ersetzt und die Zusammenfassung neu erzeugt.",
+ "confirm": "Neu transkribieren",
+ "title": "Diese Aufnahme neu transkribieren?"
+ },
+ "section": {
+ "actionItems": "Aufgaben",
+ "keyPoints": "Kernpunkte",
+ "keyTopics": "Kernthemen",
+ "participants": "Teilnehmende",
+ "summary": "Zusammenfassung"
+ },
+ "share": {
+ "failed": "Teilen fehlgeschlagen: {{error}}",
+ "shareWith": "Mit {{org}} teilen",
+ "sharing": "Wird geteilt…",
+ "unshareFrom": "Nicht mehr mit {{org}} teilen"
+ },
+ "stream": {
+ "analysing": "Transkript wird analysiert",
+ "generatingNotes": "Notizen werden erzeugt",
+ "summarisingPart": "Teil {{step}}/{{total}} wird zusammengefasst"
+ },
+ "transcriptionFailed": {
+ "body": "Für diese Aufnahme konnten keine Notizen erzeugt werden. Dein Audio wurde behalten und nicht gelöscht, es ist also nichts verloren.",
+ "details": "Details: {{error}}",
+ "title": "Transkription fehlgeschlagen"
+ },
+ "unshareDialog": {
+ "body": "Die geteilte Kopie wird aus deiner Organisation entfernt. Deine lokale Notiz bleibt auf diesem Gerät. Du kannst sie jederzeit wieder teilen.",
+ "confirm": "Nicht mehr teilen",
+ "pending": "Teilen wird beendet…",
+ "title": "Nicht mehr mit {{org}} teilen?",
+ "yourOrg": "deiner Organisation"
+ },
+ "view": {
+ "chooseViewOrTemplate": "Ansicht oder Vorlage wählen",
+ "generateFromTemplate": "Aus Vorlage erzeugen",
+ "generating": "Wird erzeugt…",
+ "myNotes": "Meine Notizen",
+ "summary": "Zusammenfassung",
+ "tablistLabel": "Notizansicht"
+ }
+ },
+ "menu": {
+ "about": "Über {{app}}",
+ "close": "Fenster schließen",
+ "copy": "Kopieren",
+ "cut": "Ausschneiden",
+ "delete": "Löschen",
+ "edit": "Bearbeiten",
+ "file": "&Datei",
+ "fileMac": "Ablage",
+ "forceReload": "Neu laden erzwingen",
+ "front": "Alle nach vorne bringen",
+ "help": "Hilfe",
+ "hide": "{{app}} ausblenden",
+ "hideOthers": "Andere ausblenden",
+ "learnMore": "Mehr erfahren",
+ "minimize": "Im Dock ablegen",
+ "paste": "Einsetzen",
+ "pasteAndMatchStyle": "Einsetzen und Stil anpassen",
+ "quit": "{{app}} beenden",
+ "redo": "Wiederholen",
+ "reload": "Neu laden",
+ "reportBug": "Fehler melden",
+ "resetZoom": "Originalgröße",
+ "selectAll": "Alles auswählen",
+ "services": "Dienste",
+ "settings": "Einstellungen…",
+ "speech": "Sprachausgabe",
+ "startSpeaking": "Sprachausgabe starten",
+ "stopSpeaking": "Sprachausgabe stoppen",
+ "substitutions": "Ersetzungen",
+ "toggleDevTools": "Entwicklerwerkzeuge ein- oder ausblenden",
+ "toggleFullScreen": "Vollbild ein oder aus",
+ "undo": "Widerrufen",
+ "unhide": "Alle einblenden",
+ "view": "Darstellung",
+ "window": "Fenster",
+ "zoom": "Zoomen",
+ "zoomIn": "Vergrößern",
+ "zoomOut": "Verkleinern"
+ },
+ "nav": {
+ "allNotes": "Alle Notizen",
+ "chat": "Chat",
+ "folders": "Ordner",
+ "help": "Hilfe",
+ "home": "Start",
+ "search": "Suchen",
+ "searchNotes": "Notizen suchen",
+ "settings": "Einstellungen",
+ "sharedAcross": "Geteilt in {{org}}",
+ "sharedNotes": "Geteilte Notizen"
+ },
+ "notification": {
+ "meetingDetected": {
+ "action": "Notizen machen",
+ "title": "Meeting erkannt"
+ },
+ "meetingEnded": {
+ "action": "Zusammenfassen",
+ "title": "Meeting beendet"
+ },
+ "micOnly": {
+ "body": "Der Systemton konnte nicht aufgenommen werden. Prüfe in den Systemeinstellungen Stenos Zugriff auf Bildschirm- und Systemtonaufnahme.",
+ "title": "Aufnahme nur über Mikrofon"
+ },
+ "noteReady": {
+ "bodyDone": "Deine Notiz ist fertig verarbeitet",
+ "bodyFailedTitled": "Steno konnte „{{title}}“ nicht verarbeiten.",
+ "bodyFailedUntitled": "Steno konnte deine Notiz nicht verarbeiten.",
+ "bodyPreserved": "Deine Aufnahme ist erhalten geblieben - öffne die Notiz für Details.",
+ "title": "Notiz fertig",
+ "titleProcessingFailed": "Verarbeitung fehlgeschlagen",
+ "titleTranscriptionFailed": "Transkription fehlgeschlagen"
+ },
+ "preMeeting": {
+ "titleFallback": "Meeting beginnt"
+ },
+ "recordingFailed": {
+ "body": "Aufnahme konnte nicht gestartet werden.",
+ "bodyWithReason": "Aufnahme konnte nicht gestartet werden: {{reason}}",
+ "title": "Steno"
+ },
+ "shortcuts": {
+ "alreadyRecording": "Es läuft bereits eine Aufnahme",
+ "invalidUrl": "Ungültige Kurzbefehl-URL",
+ "title": "Steno Kurzbefehle"
+ },
+ "silenceAutoStop": {
+ "bodyNamed_one": "{{sessionName}} - {{count}} Minute Stille",
+ "bodyNamed_other": "{{sessionName}} - {{count}} Minuten Stille",
+ "body_one": "{{count}} Minute Stille - deine Notiz wird verarbeitet.",
+ "body_other": "{{count}} Minuten Stille - deine Notiz wird verarbeitet.",
+ "title": "Aufnahme gestoppt"
+ },
+ "sleepPaused": {
+ "action": "Fortsetzen",
+ "body": "Pausiert, während dein Rechner im Ruhezustand war. Fortsetzen, um weiter aufzunehmen.",
+ "title": "Aufnahme pausiert"
+ }
+ },
+ "org": {
+ "connectDescription": "Melde dich bei deinem Steno-Enterprise-Adapter an, um von Kolleginnen und Kollegen geteilte Notizen zu sehen und übergreifend darüber zu chatten.",
+ "connectTitle": "Organisation verbinden",
+ "emptyState": "Noch keine geteilten Notizen - teile eines deiner Meetings mit {{org}} , damit es hier erscheint.",
+ "fromS3": "aus S3",
+ "fromS3Title": "Der Inhalt liegt im S3-Bucket deiner Organisation; der Adapter hat ihn serverseitig geladen. Er wird nie auf dieses Gerät geschrieben.",
+ "loadingNotes": "Notizen werden geladen…",
+ "noBody": "(kein Inhalt)",
+ "noteActions": "Notiz-Aktionen",
+ "noteCount_one": "{{count}} Notiz",
+ "noteCount_other": "{{count}} Notizen",
+ "openSettings": "Einstellungen → Organisation öffnen",
+ "orgLabel": "Org",
+ "sharedBy": "geteilt von {{email}}",
+ "sharedNotes": "Geteilte Notizen",
+ "signIn": "Bei Org anmelden",
+ "signInHint": "Melde dich an, um Notizen mit deiner Organisation zu teilen",
+ "signOut": "Abmelden",
+ "todayAt": "heute, {{time}}",
+ "unshare": "Freigabe aufheben",
+ "you": "du",
+ "yourOrg": "deine Org"
+ },
+ "palette": {
+ "hintClose": "esc schließen",
+ "hintNavigate": "↑↓ navigieren",
+ "hintOpen": "↵ öffnen",
+ "noNotes": "Noch keine Notizen",
+ "noNotesMatch": "Keine Notizen passen zu „{{query}}“",
+ "noSettings": "Keine Einstellungen",
+ "noSettingsMatch": "Keine Einstellungen passen zu „{{query}}“",
+ "results": "Suchergebnisse",
+ "searchNotes": "Notizen suchen",
+ "searchNotesPlaceholder": "Notizen suchen…",
+ "searchSettings": "Einstellungen suchen",
+ "searchSettingsPlaceholder": "Einstellungen suchen…",
+ "settings": {
+ "about": {
+ "sub": "Version, Release Notes, nach Updates suchen",
+ "title": "Über"
+ },
+ "aiProvider": {
+ "sub": "Lokal, privater Server, Cloud oder Organisation",
+ "title": "KI-Anbieter"
+ },
+ "analytics": {
+ "sub": "Ein- oder ausschalten",
+ "title": "Anonyme Nutzungsdaten"
+ },
+ "autoDetect": {
+ "sub": "Benachrichtigen, wenn eine andere App das Mikrofon nutzt",
+ "title": "Automatisch erkannte Meetings"
+ },
+ "autoNotes": {
+ "sub": "Nach der Transkription zusammenfassen",
+ "title": "Notizen automatisch erzeugen"
+ },
+ "calendar": {
+ "sub": "Google, Outlook",
+ "title": "Kalender verbinden"
+ },
+ "clearRecordingState": {
+ "sub": "Hängende Aufnahme zurücksetzen",
+ "title": "Aufnahmestatus löschen"
+ },
+ "developer": {
+ "sub": "Diagnose und Logs",
+ "title": "Entwickler"
+ },
+ "discord": {
+ "sub": "Community beitreten, Fragen stellen, Feedback geben",
+ "title": "Discord"
+ },
+ "dockIcon": {
+ "sub": "Nur Menüleisten-/Tray-Symbol",
+ "title": "Dock-Symbol ausblenden"
+ },
+ "language": {
+ "sub": "Sprache für Transkription und Zusammenfassung",
+ "title": "Sprache"
+ },
+ "launch": {
+ "sub": "Steno automatisch starten",
+ "title": "Beim Login starten"
+ },
+ "menuBar": {
+ "sub": "Schnellzugriff in der Menüleiste oder im System-Tray",
+ "title": "In der Menüleiste anzeigen"
+ },
+ "microphone": {
+ "sub": "Eingabegerät",
+ "title": "Mikrofon"
+ },
+ "name": {
+ "sub": "Begrüßung in der App",
+ "title": "Dein Name"
+ },
+ "notifications": {
+ "sub": "Desktop-Benachrichtigung, wenn Notizen fertig sind",
+ "title": "Benachrichtigungen nach Meetings"
+ },
+ "organisation": {
+ "sub": "Anmelden und Notizen in deiner Org sichern",
+ "title": "Organisation"
+ },
+ "saveRecordings": {
+ "sub": "Audiodateien nach der Transkription behalten",
+ "title": "Aufnahmen speichern"
+ },
+ "scheduled": {
+ "sub": "Anstehende Kalendertermine",
+ "title": "Geplante Meetings"
+ },
+ "setupWizard": {
+ "sub": "Ersteinrichtung erneut ausführen",
+ "title": "Einrichtungsassistent"
+ },
+ "silence": {
+ "sub": "Aufnahme beenden, wenn es still wird",
+ "title": "Auto-Stopp bei Stille"
+ },
+ "storage": {
+ "sub": "Wo Notizen und Aufnahmen gespeichert werden",
+ "title": "Speicherort"
+ },
+ "systemAudio": {
+ "sub": "Andere Teilnehmende mit aufnehmen",
+ "title": "Systemaudio aufnehmen"
+ },
+ "systemTray": {
+ "sub": "Schnellzugriff in der Menüleiste oder im System-Tray",
+ "title": "Im System-Tray anzeigen"
+ },
+ "templates": {
+ "sub": "Eigene Notizformate",
+ "title": "Vorlagen"
+ },
+ "theme": {
+ "sub": "Hell, dunkel oder Systemdesign",
+ "title": "Erscheinungsbild"
+ },
+ "transcriptionModel": {
+ "sub": "Parakeet oder Whisper",
+ "title": "Transkriptionsmodell"
+ }
+ },
+ "untitledMeeting": "Meeting ohne Titel"
+ },
+ "privacy": {
+ "acknowledge": "Verstanden",
+ "description": "Damit Fehler gefunden und behoben werden können, sendet Steno anonyme Nutzungsdaten - niemals deine Aufnahmen, Transkripte oder Notizen. Außerdem startet Steno automatisch, wenn du dich anmeldest. Beides ist standardmäßig aktiv und du kannst beides jederzeit einzeln in den Einstellungen ändern.",
+ "launch": {
+ "description": "Steno startet automatisch, wenn du dich anmeldest (versteckt in der Menüleiste).",
+ "label": "Beim Anmelden starten"
+ },
+ "telemetry": {
+ "description": "Nur Absturz- und Nutzungssignale. Meeting-Inhalte werden nie gesendet.",
+ "label": "Anonyme Nutzungsdaten"
+ },
+ "title": "Kurz zum Datenschutz"
+ },
+ "processing": {
+ "backToHome": "Zurück zur Startseite",
+ "chip": "Verarbeitung",
+ "duration": {
+ "hoursMinutes_one": "{{hours}} Std. {{count}} Min.",
+ "hoursMinutes_other": "{{hours}} Std. {{count}} Min.",
+ "hours_one": "{{count}} Std.",
+ "hours_other": "{{count}} Std.",
+ "minutes_one": "{{count}} Min.",
+ "minutes_other": "{{count}} Min.",
+ "seconds_one": "{{count}} Sek.",
+ "seconds_other": "{{count}} Sek."
+ },
+ "error": {
+ "canRetry": "Über „Erneut versuchen“ läuft die Verarbeitung dieser Aufnahme noch einmal.",
+ "cannotRetry": "Diese Aufnahme konnte nicht automatisch wiederhergestellt werden. Importiere die Audiodatei am besten noch einmal über die Startseite.",
+ "restartFailed": "Die Verarbeitung konnte nicht neu gestartet werden. Bitte versuche es noch einmal.",
+ "retrying": "Neuer Versuch…",
+ "tryAgain": "Erneut versuchen"
+ },
+ "home": "Start",
+ "myNotes": "Meine Notizen",
+ "progress": {
+ "merging": "Zusammenfassungen werden zusammengeführt…",
+ "part": "Teil {{step}} von {{total}} wird zusammengefasst…"
+ },
+ "stage": {
+ "error": "Diese Aufnahme konnte nicht verarbeitet werden.",
+ "finalizing": "Fast fertig…",
+ "summarizing": "Notizen werden erstellt",
+ "transcribing": "Transkript wird analysiert"
+ },
+ "untitledNote": "Notiz"
+ },
+ "quit": {
+ "processing": {
+ "body_one": "{{count}} Aufnahme wird noch verarbeitet. Beim Beenden wird die Verarbeitung abgebrochen.",
+ "body_other": "{{count}} Aufnahmen werden noch verarbeitet. Beim Beenden wird die Verarbeitung abgebrochen.",
+ "confirm": "Trotzdem beenden",
+ "title": "Verarbeitung läuft"
+ },
+ "recording": {
+ "body": "Beim Beenden wird die laufende Aufnahme gestoppt und gespeichert.",
+ "confirm": "Stoppen & beenden",
+ "title": "Aufnahme läuft"
+ }
+ },
+ "recording": {
+ "addToFolder": "Zu Ordner hinzufügen",
+ "backToHome": "Zurück zur Startseite",
+ "home": "Start",
+ "myNotes": "Meine Notizen",
+ "notesPlaceholder": "Schreib auf, was du festhalten willst: Entscheidungen, Fragen, offene Punkte. Um das Transkript kümmert sich Steno.",
+ "startedAt": "Gestartet um {{time}}",
+ "titlePlaceholder": "Neue Notiz"
+ },
+ "settings": {
+ "about": {
+ "checkFailed": "Prüfung fehlgeschlagen",
+ "checkForUpdates": "Nach Updates suchen",
+ "checking": "Suche nach Updates",
+ "discord": {
+ "description": "Der Community beitreten, Fragen stellen, Feedback geben"
+ },
+ "downloadFailed": "Update-Download fehlgeschlagen: {{error}}",
+ "downloading": "Update wird geladen…",
+ "github": {
+ "description": "Steno ist Open Source - im Code stöbern, Issues melden"
+ },
+ "join": "Beitreten",
+ "privacy": "Datenschutzerklärung",
+ "releaseNotes": {
+ "description": "Neuerungen ansehen",
+ "label": "Versionshinweise"
+ },
+ "restartToUpdate": "Neu starten und aktualisieren (v{{version}})",
+ "terms": "Nutzungsbedingungen",
+ "upToDate": "Du hast die aktuelle Version",
+ "version": "Version {{version}}",
+ "versionUpdateAvailable": "Version {{version}} - Update verfügbar (v{{latest}})",
+ "versionUpdateBlocked": "Version {{version}} - v{{latest}} benötigt eine neuere macOS-Version",
+ "view": "Öffnen",
+ "viewRelease": "Release ansehen"
+ },
+ "advanced": {
+ "anonymousId": {
+ "description": "Kennzeichnet diese Installation in der Nutzungsstatistik. Hilfreich, wenn du einen Fehler meldest.",
+ "label": "Anonyme ID"
+ },
+ "clearState": {
+ "clear": "Zurücksetzen",
+ "clearing": "Wird zurückgesetzt…",
+ "description": "Hängende Aufnahmen oder Verarbeitungen beheben",
+ "label": "Aufnahmestatus zurücksetzen"
+ },
+ "copied": "Kopiert",
+ "copy": "In die Zwischenablage kopieren",
+ "setupWizard": {
+ "description": "Abhängigkeiten neu installieren oder Konfiguration reparieren",
+ "label": "Einrichtungsassistent",
+ "run": "Starten"
+ },
+ "storage": {
+ "choose": "Auswählen…",
+ "description": "Wo deine Notizen und Aufnahmen gespeichert werden",
+ "label": "Speicherort",
+ "reset": "Zurücksetzen"
+ },
+ "telemetry": {
+ "description": "Hilf mit, Steno zu verbessern - Meeting-Inhalte werden nie gesendet",
+ "label": "Anonyme Nutzungsstatistik"
+ }
+ },
+ "ai": {
+ "adapter": {
+ "signedIn": "Zusammenfassungen, Titel und Chat laufen über den Adapter deiner Organisation. Modell und API-Schlüssel legt deine Organisation fest - hier musst du nichts einrichten.",
+ "signedOut": "Du bist bei keiner Organisation angemeldet. Melde dich unter Einstellungen > Organisation an oder stelle diesen Anbieter zurück auf Lokal / Privater Server / Cloud-API."
+ },
+ "autoSummarize": {
+ "description": "Fasst jede Aufnahme direkt nach der Transkription zusammen. Schalte das aus, um beim Transkript zu bleiben und Notizen nur bei Bedarf zu erzeugen.",
+ "label": "Notizen automatisch erzeugen"
+ },
+ "cloud": {
+ "apiKeyLabel": "API-Schlüssel",
+ "apiKeyPlaceholderBedrock": "Bedrock-API-Schlüssel (Bearer-Token)",
+ "apiUrlLabel": "API-Basis-URL",
+ "custom": "Eigene (OpenAI-kompatibel)",
+ "customOption": "Eigene…",
+ "disclaimer": "Transkripte werden an einen Cloud-Dienst eines Drittanbieters gesendet. Audiodateien verlassen dein Gerät nicht.",
+ "inferenceProfileLabel": "Inferenzprofil (optional)",
+ "modelLabel": "Modell",
+ "modelsAvailable_one": "{{count}} Modell verfügbar",
+ "modelsAvailable_other": "{{count}} Modelle verfügbar",
+ "pickFromList": "Aus Liste wählen",
+ "regionLabel": "AWS-Region",
+ "selectModelPlaceholder": "Modell auswählen",
+ "serviceLabel": "Dienst",
+ "testToLoadModels": "Teste die Verbindung, um die Liste der verfügbaren Modelle zu laden."
+ },
+ "connection": {
+ "connected": "Verbunden",
+ "failed": "Fehlgeschlagen",
+ "test": "Verbindung testen",
+ "testing": "Test läuft…"
+ },
+ "engine": {
+ "parakeet": "Am schnellsten - Englisch und europäische Sprachen",
+ "whisper": "Am genauesten - 99 Sprachen"
+ },
+ "keepRecordings": {
+ "description": "Speichert Audiodateien nach der Verarbeitung an deinem Speicherort (siehe Erweitert). Braucht je nach Aufnahmemodus 1-10 MB pro Minute.",
+ "label": "Aufnahmen speichern"
+ },
+ "language": {
+ "description": "Wird standardmäßig automatisch erkannt. Wähle eine aus, um sie festzulegen.",
+ "label": "Sprache der Aufnahmen"
+ },
+ "model": {
+ "description": "Welches Speech-to-Text-Modell deine Aufnahmen transkribiert.",
+ "label": "Modell",
+ "loadError": "Modelle konnten nicht geladen werden."
+ },
+ "models": {
+ "deleteDescriptionBoth": "{{name}} und den schnelleren Build ({{size}}) löschen, um Speicherplatz freizugeben? Du kannst sie jederzeit erneut laden.",
+ "deleteDescriptionOne": "{{name}} ({{size}}) löschen, um Speicherplatz freizugeben? Du kannst es jederzeit erneut laden.",
+ "deleteFasterBuildDescription": "{{name}} ({{size}}) wird nicht mehr gebraucht, seit der schnellere Build aktiv ist. Löschen, um Speicherplatz freizugeben?",
+ "deleteTitle": "Modell löschen?",
+ "hideDeprecated": "Veraltete Modelle ausblenden",
+ "loading": "Modelle werden geladen…",
+ "none": "Keine Modelle verfügbar.",
+ "ollamaUnreachable": "Ollama ist nicht erreichbar. Starte den Einrichtungsassistenten.",
+ "qualityNote": "Qualität: {{value}}",
+ "showDeprecated": "Veraltete Modelle einblenden",
+ "speedNote": "Geschwindigkeit: {{value}}",
+ "unknownSize": "Größe unbekannt"
+ },
+ "provider": {
+ "adapter": "Organisation",
+ "adapterDescription": "Nutzt den KI-Schlüssel deiner Organisation. Keine Einrichtung nötig.",
+ "adapterDisabledDescription": "Melde dich bei deiner Organisation an, um diese Option zu aktivieren.",
+ "cloud": "Cloud-API",
+ "cloudDescription": "Nutzt OpenAI, Anthropic oder eine kompatible API. Beste Qualität, erfordert einen kostenpflichtigen Schlüssel.",
+ "description": "Wo die Modelle laufen. Lokal bleiben alle Daten auf deinem Gerät.",
+ "label": "KI-Anbieter",
+ "local": "Lokal (auf dem Gerät)",
+ "localDescription": "Läuft vollständig auf deinem Gerät. Privat und kostenlos, ohne Internet.",
+ "orgManaged": "Wird von deiner Organisation verwaltet, solange du angemeldet bist. Melde dich unter Einstellungen > Organisation ab, um das zu ändern.",
+ "remote": "Privater Server",
+ "remoteDescription": "Verbindet sich mit deinem eigenen Ollama-Server. Die Daten bleiben in deinem Netzwerk."
+ },
+ "remote": {
+ "urlLabel": "URL des Ollama-Servers"
+ },
+ "summarisation": {
+ "heading": "Zusammenfassung und Chat",
+ "intro": "Macht aus deinem Transkript Notizen und beantwortet deine Fragen. Das ist der einzige Schritt, der lokal oder in der Cloud laufen kann - bei einem Cloud-Anbieter wird nur der Transkripttext gesendet, nie Audio."
+ },
+ "summaryModel": {
+ "description": "Welches Modell deine Zusammenfassungen, Titel und Chat-Antworten erzeugt.",
+ "label": "Modell"
+ },
+ "transcription": {
+ "heading": "Transkription",
+ "intro": "Die Spracherkennung läuft immer auf deinem Gerät - dein Audio verlässt deinen Computer nie."
+ }
+ },
+ "developer": {
+ "clear": "Leeren",
+ "console": {
+ "description": "Live-Logausgabe der Backend-Prozesse.",
+ "label": "Debug-Konsole"
+ },
+ "copy": "Kopieren",
+ "placeholder": "Steno Debug-Konsole\nSitzung gestartet - warte auf Aktivität…\n",
+ "save": "Speichern",
+ "saveFailed": "Diagnosedaten konnten nicht gespeichert werden: {{error}}",
+ "unknownError": "unbekannter Fehler"
+ },
+ "general": {
+ "appearance": {
+ "dark": "Dunkel",
+ "description": "Hell, dunkel oder passend zum System",
+ "label": "Erscheinungsbild",
+ "light": "Hell",
+ "system": "System"
+ },
+ "autoInstall": {
+ "description": "Lädt Updates im Hintergrund, wenn die App inaktiv ist und nicht aufnimmt, installiert sie und startet dann neu. Du wirst weiterhin benachrichtigt, sobald ein Update verfügbar ist.",
+ "label": "Updates automatisch installieren"
+ },
+ "bothIconsHidden": "Damit sind Dock-Symbol und Menüleisten-Symbol beide ausgeblendet. Öffne Steno über „Programme“ oder Spotlight, um das Fenster zurückzuholen.",
+ "calendar": {
+ "connected": "Verbunden mit {{account}}",
+ "description": "Zeigt anstehende Meetings auf dem Startbildschirm",
+ "disconnect": "Trennen",
+ "heading": "Kalender",
+ "label": "Kalender verbinden"
+ },
+ "dockIcon": {
+ "description": "Nur als Menüleisten-App laufen lassen",
+ "label": "Dock-Symbol ausblenden"
+ },
+ "launchOnLogin": {
+ "description": "Startet Steno automatisch bei der Anmeldung, versteckt in der Menüleiste. Schalte das aus, um Steno von Hand zu starten.",
+ "label": "Bei der Anmeldung starten"
+ },
+ "microphone": {
+ "description": "Von welchem Eingabegerät Steno aufnimmt. Deine Wahl wird festgehalten, damit ein Wechsel des System-Standards (z. B. wenn sich AirPods verbinden) nicht unbemerkt ändert, was aufgenommen wird. Gilt ab der nächsten Aufnahme.",
+ "label": "Mikrofon",
+ "numbered": "Mikrofon {{index}}",
+ "systemDefault": "Systemstandard",
+ "unknownDevice": "Unbekanntes Gerät (getrennt)"
+ },
+ "name": {
+ "description": "Nur der Vorname - wird für Begrüßungen in der App genutzt. Wird lokal gespeichert.",
+ "label": "Dein Name",
+ "placeholder": "Dein Name"
+ },
+ "notifications": {
+ "autoDetected": {
+ "description": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt - inklusive Schaltfläche, die die Aufnahme mit einem Klick startet.",
+ "label": "Automatisch erkannte Meetings",
+ "unsupported": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt. Erfordert macOS 14 (Sonoma) oder neuer.",
+ "unsupportedVersion": "Achtet darauf, ob andere Apps dein Mikrofon nutzen, und benachrichtigt dich, wenn ein Anruf beginnt. Erfordert macOS 14 (Sonoma) oder neuer, du nutzt {{version}}."
+ },
+ "heading": "Meeting-Benachrichtigungen",
+ "post": {
+ "description": "Benachrichtigt dich, wenn deine Notizen fertig sind oder eine Aufnahme wegen Stille automatisch stoppt.",
+ "label": "Benachrichtigungen nach dem Meeting"
+ },
+ "scheduled": {
+ "description": "Zeigt eine Benachrichtigung vor Beginn eines Meetings, basierend auf deinem Kalender.",
+ "label": "Geplante Meetings"
+ }
+ },
+ "oauth": {
+ "connectingTitle": "Verbindung zu {{provider}}",
+ "errorFallback": "Die Autorisierung wurde nicht abgeschlossen.",
+ "errorTitle": "Verbindung zu {{provider}} fehlgeschlagen",
+ "pendingDescription": "Schließe die Autorisierung in deinem Browser ab. Dieses Fenster schließt sich automatisch, sobald der Zugriff erteilt ist.",
+ "tryAgain": "Erneut versuchen",
+ "waiting": "Warte auf Autorisierung…"
+ },
+ "recording": {
+ "heading": "Aufnahme"
+ },
+ "silence": {
+ "description": "Beendet die Aufnahme und startet die Verarbeitung, sobald Mikrofon und Systemton für die gewählte Dauer still waren. Praktisch, wenn du nach einem Meeting vergisst zu stoppen.",
+ "label": "Bei Stille automatisch stoppen",
+ "minutes_one": "{{count}} Minute",
+ "minutes_other": "{{count}} Minuten"
+ },
+ "system": {
+ "heading": "System"
+ },
+ "systemAudio": {
+ "description": "Nimmt beide Seiten eines Gesprächs auf. Schalte das aus, um nur dein Mikrofon aufzunehmen.",
+ "label": "Systemton aufnehmen",
+ "unknownVersion": "eine ältere Version",
+ "unsupported": "Nimmt beide Seiten eines Gesprächs auf (erfordert macOS 14.4+, du nutzt {{version}}). Aufnahmen nur mit Mikrofon funktionieren weiterhin."
+ },
+ "trayIcon": {
+ "descriptionMac": "Zeigt ein Steno-Symbol für den schnellen Zugriff in der Menüleiste.",
+ "descriptionWindows": "Zeigt ein Steno-Symbol für den schnellen Zugriff im Infobereich.",
+ "labelMac": "In der Menüleiste anzeigen",
+ "labelWindows": "Im Infobereich anzeigen"
+ }
+ },
+ "language": {
+ "de": "Deutsch",
+ "description": "Die Sprache, in der Stenos eigene Oberfläche erscheint. Die Sprache deiner Notizen und Transkripte ändert sich dadurch nicht.",
+ "en": "English",
+ "label": "Sprache der Oberfläche",
+ "partial": "teilweise übersetzt",
+ "system": "Systemsprache"
+ },
+ "languages": {
+ "ar": "Arabisch",
+ "auto": "Automatisch",
+ "autoMulti": "Mehrsprachig",
+ "de": "Deutsch",
+ "en": "Englisch",
+ "es": "Spanisch",
+ "fr": "Französisch",
+ "hi": "Hindi",
+ "hint": {
+ "auto": "Erkennt pro Aufnahme automatisch (europäische Sprachen)",
+ "de": "Auf Deutsch transkribieren und zusammenfassen",
+ "en": "Beste Genauigkeit, wenn Meetings immer auf Englisch sind",
+ "es": "Auf Spanisch transkribieren und zusammenfassen",
+ "fr": "Auf Französisch transkribieren und zusammenfassen",
+ "nl": "Auf Niederländisch transkribieren und zusammenfassen",
+ "pt": "Auf Portugiesisch transkribieren und zusammenfassen"
+ },
+ "ja": "Japanisch",
+ "ko": "Koreanisch",
+ "nl": "Niederländisch",
+ "pt": "Portugiesisch",
+ "zh-Hans": "Chinesisch (vereinfacht)",
+ "zh-Hant": "Chinesisch (traditionell)"
+ },
+ "model": {
+ "default": "Standard",
+ "deleteAria": "Modell löschen",
+ "deleteTitle": "Dieses Modell löschen, um Speicherplatz freizugeben",
+ "deprecated": "Veraltet",
+ "downloading": "Wird geladen",
+ "fasterBuildAvailable": "Schnellerer Build verfügbar",
+ "fasterBuildBlocked": "Erst den laufenden Wechsel abschließen",
+ "fasterBuildRetry": "Erneut versuchen: zum schnelleren Build wechseln",
+ "fasterBuildSwitch": "Zum schnelleren Build wechseln",
+ "memoryWarningBadge": "RAM knapp",
+ "memoryWarningTitle": "Dieses Modell braucht womöglich mehr Arbeitsspeicher, als auf deinem Mac verfügbar ist, und läuft dann langsam oder schlägt fehl.",
+ "mlxBadge": "MLX-Modell",
+ "mlxTitleDirect": "Direkt als MLX-Build ({{tag}}) heruntergeladen - {{name}} wurde nie geladen",
+ "mlxTitleGguf": "Läuft als MLX-Build ({{tag}}) statt als {{name}}",
+ "part": "Teil {{part}}",
+ "select": "Auswählen",
+ "selected": "Ausgewählt",
+ "verifying": "Wird geprüft…"
+ },
+ "nav": {
+ "about": "Über",
+ "advanced": "Erweitert",
+ "ai": "KI",
+ "back": "Zurück",
+ "developer": "Entwickler",
+ "general": "Allgemein",
+ "groupSystem": "System",
+ "groupWorkspace": "Arbeitsbereich",
+ "organisation": "Organisation",
+ "sections": "Einstellungsbereiche",
+ "templates": "Vorlagen",
+ "title": "Einstellungen"
+ },
+ "organisation": {
+ "adapterUrl": "Adapter-URL",
+ "autoBackup": {
+ "aria": "Neue Notizen automatisch in der Organisation sichern",
+ "description": "Lädt jede neue Notiz nach der Zusammenfassung in das S3 deiner Organisation. Bei einzelnen Notizen kannst du die Freigabe in der Ansicht „Geteilte Notizen“ weiterhin aufheben.",
+ "label": "Neue Notizen automatisch sichern"
+ },
+ "email": "E-Mail",
+ "intro": "Verbinde dich mit Steno Enterprise für deine Organisation.",
+ "or": "oder",
+ "org": "Org",
+ "password": "Passwort",
+ "signInWithGoogle": "Mit Google anmelden",
+ "signInWithPassword": "Mit Passwort anmelden",
+ "signOut": "Abmelden",
+ "signedInAs": "Angemeldet als {{name}}",
+ "signingIn": "Anmeldung läuft…",
+ "ssoHint": "Single Sign-on über das Google Workspace deiner Organisation.",
+ "waitingForBrowser": "Warte auf den Browser…"
+ },
+ "templates": {
+ "backAria": "Zurück zu den Vorlagen",
+ "builtin": "Integriert",
+ "default": "Standard",
+ "defaultTitle": "Wird für neue Meetings automatisch genutzt, solange du keine andere wählst",
+ "deleteAria": "{{name}} löschen",
+ "deleteDescription": "Damit wird die Vorlage endgültig gelöscht. Bereits erzeugte Reports bleiben erhalten.",
+ "deleteFailed": "Vorlage konnte nicht gelöscht werden.",
+ "deleteTitle": "Vorlage „{{name}}“ löschen?",
+ "editTitle": "Vorlage bearbeiten",
+ "editorSubtitle": "Lege fest, wie deine Meetings zusammengefasst werden",
+ "intro": "Vorlagen sind die Anweisungen, denen deine KI folgt, wenn sie aus einem Transkript eine Zusammenfassung macht -",
+ "language": "Sprache",
+ "learnMore": "mehr erfahren",
+ "locked": "Gesperrt",
+ "lockedTitle": "Integrierte Vorlage - vor Bearbeitung und Löschen geschützt",
+ "makeDefault": "Als Standard festlegen",
+ "markdownSupported": "Markdown wird unterstützt",
+ "name": "Name",
+ "namePlaceholder": "z. B. Wochenmeeting, Management-Zusammenfassung...",
+ "new": "Neue Vorlage",
+ "newDescription": "Erstelle eigene Prompts, um zu bestimmen, wie deine Meetings zusammengefasst werden.",
+ "newTitle": "Neue Vorlage",
+ "noPrompt": "Kein Prompt hinterlegt.",
+ "promptPlaceholder": "Schreibe einen Prompt, der der KI vorgibt, wie sie die Zusammenfassung aufbauen soll...",
+ "reset": "Zurücksetzen",
+ "resetTitle": "Deine Änderungen verwerfen und zur mitgelieferten Version dieser Vorlage zurückkehren",
+ "save": "Vorlage speichern",
+ "saveFailed": "Speichern fehlgeschlagen",
+ "saving": "Wird gespeichert…",
+ "seeded": {
+ "shareableSummary": "Weitergabe-Zusammenfassung"
+ },
+ "systemPrompt": "System-Prompt",
+ "usesStructuredFormat": "Nutzt strukturiertes Format"
+ }
+ },
+ "setup": {
+ "actions": {
+ "begin": "Einrichtung starten",
+ "continueToApp": "Weiter zur App",
+ "needKeyHint": "Gib deinen API-Schlüssel ein, um fortzufahren.",
+ "needKeyTitle": "Zuerst den Cloud-API-Schlüssel eingeben",
+ "settingUp": "Einrichtung läuft..."
+ },
+ "badge": {
+ "done": "Fertig",
+ "failed": "Fehlgeschlagen",
+ "running": "Läuft",
+ "waiting": "Wartet"
+ },
+ "chooser": {
+ "cloud": "Cloud",
+ "cloudHint": "Schnell. Höhere Qualität. Eigener API-Schlüssel nötig.",
+ "local": "Lokal",
+ "localHint": "Privat. Kostenlos. ~2 GB Download.",
+ "title": "Wie soll Steno Meetings zusammenfassen?"
+ },
+ "cloud": {
+ "apiKeyHint": "Wird lokal auf diesem Gerät gespeichert. Nie synchronisiert und nirgendwo hingesendet außer an den Anbieter, den du auswählst.",
+ "apiKeyLabel": "API-Schlüssel",
+ "apiUrlLabel": "API-Basis-URL",
+ "profileLabel": "Inferenzprofil (optional)",
+ "providerCustom": "Benutzerdefiniert (OpenAI-kompatibel)",
+ "providerLabel": "Anbieter",
+ "regionLabel": "AWS-Region"
+ },
+ "debug": {
+ "empty": "Steno-Einrichtung\nBefehle und Ausgaben erscheinen hier...\n",
+ "toggle": "Debug-Konsole"
+ },
+ "launch": {
+ "description": "Steno startet automatisch, wenn du dich anmeldest (versteckt in der Menüleiste). Du kannst das jederzeit in den Einstellungen ändern.",
+ "title": "Beim Anmelden starten"
+ },
+ "name": {
+ "hint": "Nur der Vorname, für die Begrüßung in der App. Wird lokal gespeichert.",
+ "label": "Wie sollen wir dich nennen?",
+ "placeholder": "Dein Name"
+ },
+ "progress": {
+ "ollamaAriaLabel": "Download-Fortschritt des Zusammenfassungsmodells",
+ "ollamaFallback": "Modell wird heruntergeladen...",
+ "transcriptionLabel": "Modell wird heruntergeladen und vorbereitet..."
+ },
+ "reportIssue": "Problem melden",
+ "status": {
+ "checkingPermission": "Berechtigung wird geprüft...",
+ "checkingTranscriptionModel": "Transkriptionsmodell wird geprüft...",
+ "connectedTo": "Verbunden mit {{provider}}",
+ "downloadingModel": "Modell wird heruntergeladen (~2 GB)...",
+ "downloadingParakeet": "Parakeet TDT v3 wird heruntergeladen ({{size}})...",
+ "modelInstalled": "Modell installiert",
+ "permissionDeniedMac": "Berechtigung verweigert. Erteile sie in den Systemeinstellungen.",
+ "permissionDeniedWindows": "Berechtigung verweigert. Erteile sie unter Einstellungen > Datenschutz und Sicherheit > Mikrofon.",
+ "permissionGranted": "Berechtigung erteilt",
+ "requestingPermission": "Berechtigung wird angefragt...",
+ "savingCloudCredentials": "Cloud-Zugangsdaten werden gespeichert...",
+ "stepFailed": "Einrichtungsschritt fehlgeschlagen",
+ "testingConnection": "Verbindung wird getestet...",
+ "transcriptionModelReady": "Transkriptionsmodell bereit"
+ },
+ "steps": {
+ "microphone": {
+ "description": "Nötig, um Meetings aufzunehmen",
+ "title": "Mikrofonzugriff"
+ },
+ "summarization": {
+ "descriptionCloud": "Cloud-API: schnell, kein Download",
+ "descriptionLocal": "Lokales Modell (~2 GB): privat, läuft auf deinem Gerät",
+ "title": "Modell für Zusammenfassungen"
+ },
+ "transcription": {
+ "description": "Wandelt Sprache lokal in Text um",
+ "title": "Transkriptionsmodell"
+ }
+ },
+ "subtitle": "Wir richten zusammen alles ein, was Steno für deine Meetings braucht.",
+ "telemetry": {
+ "description": "Hilf mit, Steno zu verbessern - Meeting-Inhalte werden nie gesendet. Du kannst das jederzeit unter Einstellungen → Erweitert ändern.",
+ "title": "Anonyme Nutzungsanalyse"
+ },
+ "title": "Willkommen bei Steno"
+ },
+ "toast": {
+ "notification": {
+ "close": "Schließen",
+ "join": "Beitreten & mitschreiben"
+ },
+ "undoDelete": {
+ "dismiss": "Ausblenden und endgültig löschen",
+ "title": "Notiz gelöscht",
+ "undo": "Rückgängig"
+ },
+ "update": {
+ "dismiss": "Update-Hinweis ausblenden",
+ "ready": "Update v{{version}} bereit",
+ "restart": "Neu starten"
+ }
+ },
+ "toolbar": {
+ "hideSidebar": "Seitenleiste ausblenden",
+ "importAudio": "Audiodatei importieren…",
+ "importAudioBlocked": "Stoppe die laufende Aufnahme, um eine Datei zu importieren.",
+ "importAudioHint": "Eine vorhandene Aufnahme transkribieren und zusammenfassen. Sie erscheint während der Verarbeitung in der Liste.",
+ "newChat": "Neuer Chat",
+ "newNote": "Neue Notiz",
+ "openRecording": "Laufende Aufnahme öffnen",
+ "paused": "Pausiert",
+ "recordSystemAudio": "Systemaudio aufnehmen",
+ "recordSystemAudioHint": "Beide Seiten von Anrufen aufnehmen. Ausschalten, um nur dein Mikrofon aufzunehmen.",
+ "recording": "Aufnahme",
+ "recordingOptions": "Aufnahmeoptionen",
+ "recordingOptionsHint": "Auch Deep Links und das Tray-Menü starten und stoppen Aufnahmen.",
+ "showSidebar": "Seitenleiste anzeigen"
+ },
+ "transcript": {
+ "empty": "Kein Transkript verfügbar.",
+ "loading": "Wird geladen…",
+ "searchPlaceholder": "Transkript durchsuchen"
+ },
+ "tray": {
+ "hide": "Steno ausblenden",
+ "open": "Steno öffnen",
+ "quit": "Steno beenden",
+ "reportBug": "Fehler melden",
+ "settings": "Einstellungen",
+ "startRecording": "Aufnahme starten",
+ "stopRecording": "Aufnahme stoppen",
+ "tooltip": "Steno",
+ "tooltipRecording": "Steno - Aufnahme läuft",
+ "version": "Steno v{{version}}"
+ }
+}
diff --git a/app/locales/en.json b/app/locales/en.json
new file mode 100644
index 00000000..8969c3aa
--- /dev/null
+++ b/app/locales/en.json
@@ -0,0 +1,1208 @@
+{
+ "app": {
+ "actions": "Actions",
+ "date": {
+ "today": "Today",
+ "yesterday": "Yesterday"
+ },
+ "dropBlocked": "Stop recording to import",
+ "dropToImport": "Drop audio to import",
+ "loading": "Loading…",
+ "rename": "Rename"
+ },
+ "chat": {
+ "askAbout": "Ask about {{name}}",
+ "askAcross": "Ask across…",
+ "askAi": "Ask AI",
+ "askAria": "Ask about this meeting",
+ "backToChat": "Back to Chat",
+ "bucket": {
+ "last2Weeks": "Last 2 weeks",
+ "thisMonth": "This month",
+ "thisWeek": "This week",
+ "today": "Today",
+ "yesterday": "Yesterday"
+ },
+ "chatActions": "Chat actions",
+ "chips": {
+ "actionItems": "Action items",
+ "keyDecisions": "Summarize key decisions",
+ "mainTopics": "Main topics"
+ },
+ "collapse": "Collapse",
+ "composerPlaceholder": "Ask anything /",
+ "copyTranscript": "Copy transcript",
+ "deleteChat": "Delete chat {{name}}",
+ "emptyResponse": "(empty response)",
+ "failedToSend": "Failed to send",
+ "greeting": "Ask anything",
+ "greetingNamed": "Hi {{name}} , ask anything",
+ "hideTranscript": "Hide transcript",
+ "history": "History",
+ "mayOmitOlderNotes": "· may omit older notes",
+ "model": {
+ "auto": "Auto",
+ "cloud": "Cloud",
+ "organisation": "Organisation",
+ "remoteOllama": "Remote Ollama"
+ },
+ "newChat": "New chat",
+ "noOtherChats": "No other chats yet.",
+ "noRecents": "Your past chats will show up here.",
+ "noSavedChats": "No saved chats yet.",
+ "notFound": "Chat not found.",
+ "notFoundHint": "This conversation may have been deleted.",
+ "orgScopeHint": "Cross-note chat against {{org}}'s shared notes",
+ "placeholderAsk": "Ask anything about this meeting…",
+ "placeholderContinue": "Continue chat…",
+ "placeholderRecording": "Chat available after recording",
+ "presets": "Presets",
+ "providerRequired": "Your AI provider isn't ready for chat yet — add a cloud API key, sign in to your Organisation, or set your remote Ollama URL in Settings → AI .",
+ "providerRequiredPlaceholder": "Set up an AI provider in Settings to ask across notes",
+ "queryFailed": "query failed",
+ "recents": "Recents",
+ "relative": {
+ "days": "{{n}}d",
+ "hours": "{{n}}h",
+ "minutes": "{{n}}m",
+ "months": "{{n}}mo",
+ "now": "now",
+ "weeks": "{{n}}w",
+ "years": "{{n}}y"
+ },
+ "resume": "Resume",
+ "resumeAria": "Resume recording on this note",
+ "resumeTitle": "Resume recording — the new audio is appended to this note",
+ "scopeAria": "Scope: {{scope}}",
+ "seeAll": "See all",
+ "send": "Send",
+ "showLess": "Show less",
+ "showTranscript": "Show transcript",
+ "skills": "Skills",
+ "stop": "Stop",
+ "streamError": "Error: {{error}}",
+ "switchChat": "Switch chat",
+ "thinking": "Thinking",
+ "thinkingStream": "Thinking…",
+ "transcript": "Transcript",
+ "untitled": "Untitled chat"
+ },
+ "common": {
+ "appName": "Steno",
+ "cancel": "Cancel",
+ "close": "Close",
+ "confirm": "Confirm",
+ "delete": "Delete",
+ "done": "Done",
+ "retry": "Retry",
+ "save": "Save",
+ "working": "Working..."
+ },
+ "dialog": {
+ "filter": {
+ "audio": "Audio Files",
+ "markdown": "Markdown",
+ "pdf": "PDF",
+ "text": "Text"
+ }
+ },
+ "dock": {
+ "changeLanguage": "Change transcript language",
+ "copyTranscript": "Copy transcript",
+ "hideTranscript": "Hide transcript",
+ "languageAria": "Language: {{language}}",
+ "languageMulti": "Multi",
+ "listening": "Listening…",
+ "listeningHint": "Start speaking — finalised sentences will appear here.",
+ "liveUnavailable": "Live transcription unavailable",
+ "minimizeTranscript": "Minimize transcript",
+ "noMatches": "No matches",
+ "noMatchesHint": "Nothing matches your filter yet.",
+ "paused": "Paused",
+ "preparing": "Preparing transcription…",
+ "preparingHint": "Parakeet is warming up. Audio is being captured.",
+ "preparingShort": "Preparing…",
+ "preparingSlow": "Still warming up — first launch can take a moment. Audio is being captured.",
+ "recording": "Recording",
+ "resumeRecording": "Resume recording",
+ "resumed": "Resumed",
+ "searchTranscript": "Search transcript",
+ "showTranscript": "Show transcript",
+ "stage": "Stage: {{stage}}",
+ "stageWithMessage": "{{stage}}: {{message}}",
+ "stillPreparing": "Still preparing…",
+ "stop": "Stop",
+ "stopRecording": "Stop recording",
+ "transcript": "Transcript",
+ "transcriptHeader": "Transcript header"
+ },
+ "folders": {
+ "changeIcon": "Change folder icon",
+ "create": "Create folder",
+ "deleteNoMeetings": "No recordings or transcripts will be deleted.",
+ "deleteTitle": "Delete folder \"{{name}}\"?",
+ "deleteWithMeetings_one": "{{count}} meeting will be moved back to All Notes. No recordings or transcripts will be deleted.",
+ "deleteWithMeetings_other": "{{count}} meetings will be moved back to All Notes. No recordings or transcripts will be deleted.",
+ "emptyHint": "Notes you save to this folder will show up here.",
+ "emptyTitle": "Nothing here yet",
+ "loading": "Loading folder…",
+ "meetingCount_one": "{{count}} meeting",
+ "meetingCount_other": "{{count}} meetings",
+ "namePlaceholder": "e.g. Acme Corp",
+ "newFolder": "New folder",
+ "newFolderDescription": "Group related meetings together. Folder names are only visible to you.",
+ "noIconsMatch": "No icons match \"{{query}}\"",
+ "notFound": "Folder not found.",
+ "notFoundHint": "This folder may have been deleted. Back to Home .",
+ "notes": "Notes",
+ "searchIcons": "Search icons…"
+ },
+ "hero": {
+ "headline": {
+ "clearDay": "Clear day ahead",
+ "inMeeting": "In a meeting now",
+ "nextInHours_one": "Next meeting in {{count}} hr",
+ "nextInHours_other": "Next meeting in {{count}} hrs",
+ "nextInMinutes_one": "Next meeting in {{count}} min",
+ "nextInMinutes_other": "Next meeting in {{count}} mins",
+ "paused": "Recording paused",
+ "processing": "Processing your note",
+ "ready": "Ready to capture beautiful notes",
+ "recording": "Recording"
+ },
+ "recordingHint": "Start recording from the top-right, or from anywhere with {{shortcut}}.",
+ "subtitle": {
+ "inMeeting": "Press {{shortcut}} to start recording — or tap a meeting card below.",
+ "inProgressFallback": "In progress",
+ "nextSoon": "{{title}} at {{time}} — {{shortcut}} when you're ready.",
+ "paused": "Recording paused. Tap resume on the bar below to continue.",
+ "processing": "We'll have your note ready in a moment.",
+ "recording": "{{title}} · {{shortcut}} to stop",
+ "tomorrow": "Next up: {{title}} tomorrow at {{time}}."
+ }
+ },
+ "home": {
+ "allDay": {
+ "toggle_one": "+ {{count}} all-day event today",
+ "toggle_other": "+ {{count}} all-day events today"
+ },
+ "calendarNudge": {
+ "connectLabel": "Connect:",
+ "connecting": "Connecting to {{provider}}…",
+ "dismiss": "Dismiss",
+ "prompt": "Connect your calendar to see today's meetings."
+ },
+ "day": {
+ "earlier": "Earlier",
+ "today": "Today",
+ "tomorrow": "Tomorrow",
+ "yesterday": "Yesterday"
+ },
+ "duration": {
+ "hoursMinutes": "{{hours}}h {{minutes}}m",
+ "minutes": "{{minutes}}m",
+ "seconds": "{{seconds}}s"
+ },
+ "empty": {
+ "consent": "Always get consent when transcribing others.",
+ "newNote": "New note",
+ "quickStartLabel": "Quick start:",
+ "quickStartSuffix": "from anywhere",
+ "stopRecording": "Stop recording",
+ "tagline": "AI for your confidential workflows.",
+ "title": "Welcome to Steno."
+ },
+ "loading": "Loading meetings…",
+ "previousRow": {
+ "participants_one": "{{count}} person",
+ "participants_other": "{{count}} people",
+ "processingBadge": "Processing",
+ "recordingBadge": "Recording",
+ "untitledNote": "Untitled note"
+ },
+ "relative": {
+ "days_one": "{{count}} day",
+ "days_other": "{{count}} days",
+ "hours_one": "{{count}} hr",
+ "hours_other": "{{count}} hrs",
+ "minutes_one": "{{count}} min",
+ "minutes_other": "{{count}} mins",
+ "now": "Now"
+ },
+ "search": {
+ "clear": "Clear search",
+ "noMatches": "No meetings match “{{query}}”.",
+ "placeholder": "Search notes"
+ },
+ "sections": {
+ "allNotes": "All notes",
+ "comingUp": "Coming up",
+ "previous": "Previous",
+ "today": "Today",
+ "tomorrow": "Tomorrow"
+ },
+ "upcoming": {
+ "nextPage": "Next",
+ "prevPage": "Previous",
+ "refresh": "Check for new calendar events"
+ },
+ "upcomingCard": {
+ "allDay": "All day",
+ "endsIn_one": "Ends in {{count}} min",
+ "endsIn_other": "Ends in {{count}} mins",
+ "join": "Join",
+ "justStarted": "Just started",
+ "startNow": "Start now",
+ "startedAgo_one": "Started {{count}} min ago",
+ "startedAgo_other": "Started {{count}} mins ago",
+ "untitled": "Untitled meeting"
+ }
+ },
+ "meeting": {
+ "action": {
+ "backToHome": "Back to home",
+ "copied": "Copied",
+ "copiedConfirm": "Copied!",
+ "copyNotes": "Copy notes",
+ "copyTranscript": "Copy transcript",
+ "deleteNote": "Delete note",
+ "generateNotes": "Generate notes",
+ "home": "Home",
+ "moreOptions": "More options",
+ "regenerateTitle": "Regenerate title",
+ "retranscribeRecording": "Re-transcribe recording",
+ "saveNotesPdf": "Save notes as PDF…",
+ "saveTranscriptMarkdown": "Save transcript as .md…",
+ "viewContainingFolder": "View containing folder"
+ },
+ "backToMeetings": "Back to meetings",
+ "backup": {
+ "backingUp": "Backing up…",
+ "failed": "This note has not been backed up. Click to retry.",
+ "failedWithReason": "Last backup failed: {{error}}. Click to retry.",
+ "notBackedUp": "Not backed up"
+ },
+ "error": {
+ "copyTranscript": "Couldn't copy transcript: {{error}}",
+ "deleteFailed": "Delete failed: {{error}}",
+ "generic": "Something went wrong.",
+ "saveNotes": "Couldn't save notes: {{error}}",
+ "saveTranscript": "Couldn't save transcript: {{error}}",
+ "unknown": "unknown error"
+ },
+ "folder": {
+ "addToFolder": "Add to folder",
+ "namePlaceholder": "Folder name...",
+ "new": "New folder...",
+ "none": "No folder"
+ },
+ "loadError": {
+ "body": "An error occurred loading this note.",
+ "title": "Couldn't load note."
+ },
+ "loading": "Loading meeting…",
+ "noNotesYet": {
+ "body": "This recording was transcribed but notes were not generated automatically. Use the Generate notes button below to create them, or copy or save the transcript from the actions above.",
+ "title": "No notes yet"
+ },
+ "noSummary": "No summary available for this meeting.",
+ "notFound": {
+ "body": "This recording may have been deleted. Pick another from the sidebar.",
+ "title": "Note not found."
+ },
+ "notes": {
+ "placeholder": "Write notes…"
+ },
+ "participantCount_one": "{{count}} person",
+ "participantCount_other": "{{count}} people",
+ "placeholderTitle": {
+ "meeting": "Meeting",
+ "note": "Note"
+ },
+ "processing": {
+ "body": "Your transcript is captured — refining it and generating notes in the background. You can read and edit My notes now.",
+ "title": "Finishing up"
+ },
+ "report": {
+ "deleteBody": "This permanently deletes this generated report. The transcript and other reports are not affected.",
+ "deleteLabel": "Delete report {{name}}",
+ "deleteTitle": "Delete report \"{{name}}\"?"
+ },
+ "reprocessFailed": {
+ "body": "That didn’t work this time — give it another go. If it keeps failing on a long meeting, switch to a smaller model in Settings.",
+ "title": "Notes weren’t generated"
+ },
+ "retranscribeDialog": {
+ "body": "This re-runs transcription with your current transcription settings, replacing the transcript and regenerating the summary.",
+ "confirm": "Re-transcribe",
+ "title": "Re-transcribe this recording?"
+ },
+ "section": {
+ "actionItems": "Action items",
+ "keyPoints": "Key points",
+ "keyTopics": "Key topics",
+ "participants": "Participants",
+ "summary": "Summary"
+ },
+ "share": {
+ "failed": "Share failed: {{error}}",
+ "shareWith": "Share with {{org}}",
+ "sharing": "Sharing…",
+ "unshareFrom": "Unshare from {{org}}"
+ },
+ "stream": {
+ "analysing": "Analysing transcript",
+ "generatingNotes": "Generating notes",
+ "summarisingPart": "Summarising part {{step}}/{{total}}"
+ },
+ "transcriptionFailed": {
+ "body": "No notes could be generated for this recording. Your audio was preserved (not deleted), so nothing was lost.",
+ "details": "Details: {{error}}",
+ "title": "Transcription failed"
+ },
+ "unshareDialog": {
+ "body": "The shared copy will be removed from your organisation. Your local note stays on this device. You can re-share at any time.",
+ "confirm": "Unshare",
+ "pending": "Unsharing…",
+ "title": "Unshare from {{org}}?",
+ "yourOrg": "your org"
+ },
+ "view": {
+ "chooseViewOrTemplate": "Choose view or template",
+ "generateFromTemplate": "Generate from template",
+ "generating": "Generating…",
+ "myNotes": "My notes",
+ "summary": "Summary",
+ "tablistLabel": "Note view"
+ }
+ },
+ "menu": {
+ "about": "About {{app}}",
+ "close": "Close Window",
+ "copy": "Copy",
+ "cut": "Cut",
+ "delete": "Delete",
+ "edit": "Edit",
+ "file": "&File",
+ "fileMac": "File",
+ "forceReload": "Force Reload",
+ "front": "Bring All to Front",
+ "help": "Help",
+ "hide": "Hide {{app}}",
+ "hideOthers": "Hide Others",
+ "learnMore": "Learn More",
+ "minimize": "Minimize",
+ "paste": "Paste",
+ "pasteAndMatchStyle": "Paste and Match Style",
+ "quit": "Quit {{app}}",
+ "redo": "Redo",
+ "reload": "Reload",
+ "reportBug": "Report a Bug",
+ "resetZoom": "Actual Size",
+ "selectAll": "Select All",
+ "services": "Services",
+ "settings": "Settings…",
+ "speech": "Speech",
+ "startSpeaking": "Start Speaking",
+ "stopSpeaking": "Stop Speaking",
+ "substitutions": "Substitutions",
+ "toggleDevTools": "Toggle Developer Tools",
+ "toggleFullScreen": "Toggle Full Screen",
+ "undo": "Undo",
+ "unhide": "Show All",
+ "view": "View",
+ "window": "Window",
+ "zoom": "Zoom",
+ "zoomIn": "Zoom In",
+ "zoomOut": "Zoom Out"
+ },
+ "nav": {
+ "allNotes": "All notes",
+ "chat": "Chat",
+ "folders": "Folders",
+ "help": "Help",
+ "home": "Home",
+ "search": "Search",
+ "searchNotes": "Search notes",
+ "settings": "Settings",
+ "sharedAcross": "Shared across {{org}}",
+ "sharedNotes": "Shared notes"
+ },
+ "notification": {
+ "meetingDetected": {
+ "action": "Take Notes",
+ "title": "Meeting detected"
+ },
+ "meetingEnded": {
+ "action": "Summarise",
+ "title": "Meeting ended"
+ },
+ "micOnly": {
+ "body": "System audio could not be captured. Check Steno’s Screen & System Audio Recording access in System Settings.",
+ "title": "Recording mic-only"
+ },
+ "noteReady": {
+ "bodyDone": "Your note has finished processing",
+ "bodyFailedTitled": "Steno couldn’t process \"{{title}}\".",
+ "bodyFailedUntitled": "Steno couldn’t process your note.",
+ "bodyPreserved": "Your recording was preserved — open the note for details.",
+ "title": "Note ready",
+ "titleProcessingFailed": "Processing failed",
+ "titleTranscriptionFailed": "Transcription failed"
+ },
+ "preMeeting": {
+ "titleFallback": "Meeting starting"
+ },
+ "recordingFailed": {
+ "body": "Recording couldn't start.",
+ "bodyWithReason": "Recording couldn’t start: {{reason}}",
+ "title": "Steno"
+ },
+ "shortcuts": {
+ "alreadyRecording": "Recording already in progress",
+ "invalidUrl": "Invalid shortcut URL",
+ "title": "Steno Shortcuts"
+ },
+ "silenceAutoStop": {
+ "bodyNamed_one": "{{sessionName}} — {{count}} minute of silence",
+ "bodyNamed_other": "{{sessionName}} — {{count}} minutes of silence",
+ "body_one": "{{count}} minute of silence — your note is being processed.",
+ "body_other": "{{count}} minutes of silence — your note is being processed.",
+ "title": "Recording stopped"
+ },
+ "sleepPaused": {
+ "action": "Resume",
+ "body": "Paused while your computer was asleep. Resume to keep capturing.",
+ "title": "Recording paused"
+ }
+ },
+ "org": {
+ "connectDescription": "Sign in to your Steno enterprise adapter to see notes shared by your colleagues and chat across them.",
+ "connectTitle": "Connect your organisation",
+ "emptyState": "No shared notes yet — share one of your meetings with {{org}} to see it here.",
+ "fromS3": "from S3",
+ "fromS3Title": "Body lives in your org's S3 bucket; the adapter fetched it server-side. Never written to this device.",
+ "loadingNotes": "Loading notes…",
+ "noBody": "(no body)",
+ "noteActions": "Note actions",
+ "noteCount_one": "{{count}} note",
+ "noteCount_other": "{{count}} notes",
+ "openSettings": "Open Settings → Organisation",
+ "orgLabel": "org",
+ "sharedBy": "shared by {{email}}",
+ "sharedNotes": "Shared notes",
+ "signIn": "Sign in to org",
+ "signInHint": "Sign in to share notes with your organisation",
+ "signOut": "Sign out",
+ "todayAt": "today, {{time}}",
+ "unshare": "Unshare",
+ "you": "you",
+ "yourOrg": "your org"
+ },
+ "palette": {
+ "hintClose": "esc close",
+ "hintNavigate": "↑↓ navigate",
+ "hintOpen": "↵ open",
+ "noNotes": "No notes yet",
+ "noNotesMatch": "No notes match “{{query}}”",
+ "noSettings": "No settings",
+ "noSettingsMatch": "No settings match “{{query}}”",
+ "results": "Search results",
+ "searchNotes": "Search notes",
+ "searchNotesPlaceholder": "Search notes…",
+ "searchSettings": "Search settings",
+ "searchSettingsPlaceholder": "Search settings…",
+ "settings": {
+ "about": {
+ "sub": "Version, release notes, check for updates",
+ "title": "About"
+ },
+ "aiProvider": {
+ "sub": "Local, private server, cloud, or organisation",
+ "title": "AI provider"
+ },
+ "analytics": {
+ "sub": "Opt in or out",
+ "title": "Anonymous usage analytics"
+ },
+ "autoDetect": {
+ "sub": "Notify when another app uses the microphone",
+ "title": "Auto-detected meetings"
+ },
+ "autoNotes": {
+ "sub": "Summarise after transcription",
+ "title": "Generate notes automatically"
+ },
+ "calendar": {
+ "sub": "Google, Outlook",
+ "title": "Connect calendar"
+ },
+ "clearRecordingState": {
+ "sub": "Reset a stuck recording",
+ "title": "Clear recording state"
+ },
+ "developer": {
+ "sub": "Diagnostics and logs",
+ "title": "Developer"
+ },
+ "discord": {
+ "sub": "Join the community, ask questions, share feedback",
+ "title": "Discord"
+ },
+ "dockIcon": {
+ "sub": "Menu bar / tray icon only",
+ "title": "Hide dock icon"
+ },
+ "language": {
+ "sub": "Transcription and summary language",
+ "title": "Language"
+ },
+ "launch": {
+ "sub": "Start Steno automatically",
+ "title": "Launch on login"
+ },
+ "menuBar": {
+ "sub": "Quick-access icon in the menu bar or system tray",
+ "title": "Show in menu bar"
+ },
+ "microphone": {
+ "sub": "Input device",
+ "title": "Microphone"
+ },
+ "name": {
+ "sub": "In-app greeting",
+ "title": "Your name"
+ },
+ "notifications": {
+ "sub": "Desktop notifications when notes are ready",
+ "title": "Post meeting notifications"
+ },
+ "organisation": {
+ "sub": "Sign in and back up notes to your org",
+ "title": "Organisation"
+ },
+ "saveRecordings": {
+ "sub": "Keep the audio files after transcription",
+ "title": "Save recordings"
+ },
+ "scheduled": {
+ "sub": "Upcoming calendar events",
+ "title": "Scheduled meetings"
+ },
+ "setupWizard": {
+ "sub": "Re-run first-time setup",
+ "title": "Setup wizard"
+ },
+ "silence": {
+ "sub": "End a recording when it goes quiet",
+ "title": "Auto-stop on silence"
+ },
+ "storage": {
+ "sub": "Where notes and recordings are saved",
+ "title": "Storage location"
+ },
+ "systemAudio": {
+ "sub": "Capture other participants",
+ "title": "Record system audio"
+ },
+ "systemTray": {
+ "sub": "Quick-access icon in the menu bar or system tray",
+ "title": "Show in system tray"
+ },
+ "templates": {
+ "sub": "Custom note formats",
+ "title": "Templates"
+ },
+ "theme": {
+ "sub": "Light, dark, or system theme",
+ "title": "Appearance"
+ },
+ "transcriptionModel": {
+ "sub": "Parakeet or Whisper",
+ "title": "Transcription model"
+ }
+ },
+ "untitledMeeting": "Untitled Meeting"
+ },
+ "privacy": {
+ "acknowledge": "Got it",
+ "description": "To help find and fix failures, Steno sends anonymous usage data — never your recordings, transcripts, or notes. Steno also starts automatically when you log in. Both are on by default and you can change either one anytime in Settings.",
+ "launch": {
+ "description": "Start Steno automatically when you log in (hidden in the menu bar).",
+ "label": "Launch on login"
+ },
+ "telemetry": {
+ "description": "Crash and usage signals only. Meeting content is never sent.",
+ "label": "Anonymous usage data"
+ },
+ "title": "A quick note on privacy"
+ },
+ "processing": {
+ "backToHome": "Back to home",
+ "chip": "Processing",
+ "duration": {
+ "hoursMinutes_one": "{{hours}} h {{count}} min",
+ "hoursMinutes_other": "{{hours}} h {{count}} min",
+ "hours_one": "{{count}} h",
+ "hours_other": "{{count}} h",
+ "minutes_one": "{{count}} min",
+ "minutes_other": "{{count}} min",
+ "seconds_one": "{{count}} sec",
+ "seconds_other": "{{count}} sec"
+ },
+ "error": {
+ "canRetry": "Try again to re-run processing on this recording.",
+ "cannotRetry": "This recording couldn’t be recovered automatically. Try importing the audio file again from Home.",
+ "restartFailed": "Couldn’t restart processing. Please try again.",
+ "retrying": "Retrying…",
+ "tryAgain": "Try again"
+ },
+ "home": "Home",
+ "myNotes": "My notes",
+ "progress": {
+ "merging": "Merging summaries…",
+ "part": "Summarizing part {{step}} of {{total}}…"
+ },
+ "stage": {
+ "error": "Couldn’t process this recording.",
+ "finalizing": "Almost done…",
+ "summarizing": "Generating notes",
+ "transcribing": "Analyzing transcript"
+ },
+ "untitledNote": "Note"
+ },
+ "quit": {
+ "processing": {
+ "body_one": "{{count}} recording is still being processed. Quitting will cancel processing.",
+ "body_other": "{{count}} recordings are still being processed. Quitting will cancel processing.",
+ "confirm": "Quit anyway",
+ "title": "Processing in progress"
+ },
+ "recording": {
+ "body": "Quitting will stop and save the current recording.",
+ "confirm": "Stop & quit",
+ "title": "Recording in progress"
+ }
+ },
+ "recording": {
+ "addToFolder": "Add to folder",
+ "backToHome": "Back to home",
+ "home": "Home",
+ "myNotes": "My notes",
+ "notesPlaceholder": "Type anything you want to capture — decisions, questions, follow-ups. Steno handles the transcript.",
+ "startedAt": "Started {{time}}",
+ "titlePlaceholder": "New note"
+ },
+ "settings": {
+ "about": {
+ "checkFailed": "Check failed",
+ "checkForUpdates": "Check for Updates",
+ "checking": "Checking for Updates",
+ "discord": {
+ "description": "Join the community, ask questions, share feedback"
+ },
+ "downloadFailed": "Update download failed: {{error}}",
+ "downloading": "Downloading update…",
+ "github": {
+ "description": "Steno is open source — browse the code, file issues"
+ },
+ "join": "Join",
+ "privacy": "Privacy Policy",
+ "releaseNotes": {
+ "description": "See what's new",
+ "label": "Release notes"
+ },
+ "restartToUpdate": "Restart to Update (v{{version}})",
+ "terms": "Terms of Service",
+ "upToDate": "You're on the latest version",
+ "version": "Version {{version}}",
+ "versionUpdateAvailable": "Version {{version}} — Update available (v{{latest}})",
+ "versionUpdateBlocked": "Version {{version}} — v{{latest}} requires a newer version of macOS",
+ "view": "View",
+ "viewRelease": "View release"
+ },
+ "advanced": {
+ "anonymousId": {
+ "description": "Identifies this install in analytics. Useful when reporting bugs.",
+ "label": "Anonymous ID"
+ },
+ "clearState": {
+ "clear": "Clear",
+ "clearing": "Clearing…",
+ "description": "Fix stuck recordings or processing",
+ "label": "Clear recording state"
+ },
+ "copied": "Copied",
+ "copy": "Copy to clipboard",
+ "setupWizard": {
+ "description": "Reinstall dependencies or fix configuration",
+ "label": "Setup wizard",
+ "run": "Run"
+ },
+ "storage": {
+ "choose": "Choose…",
+ "description": "Where your notes and recordings are saved",
+ "label": "Storage location",
+ "reset": "Reset"
+ },
+ "telemetry": {
+ "description": "Help improve Steno — no meeting content is ever sent",
+ "label": "Anonymous usage analytics"
+ }
+ },
+ "ai": {
+ "adapter": {
+ "signedIn": "Summaries, titles, and chat are routed through your organisation's adapter. The model and API key are configured by your organisation — no setup needed here.",
+ "signedOut": "You are not signed in to an organisation. Sign in under Settings > Organisation , or switch this provider back to Local / Private Server / Cloud API."
+ },
+ "autoSummarize": {
+ "description": "Summarise each recording right after transcription. Turn off to stop at a transcript and generate notes on demand.",
+ "label": "Generate notes automatically"
+ },
+ "cloud": {
+ "apiKeyLabel": "API key",
+ "apiKeyPlaceholderBedrock": "Bedrock API key (bearer token)",
+ "apiUrlLabel": "API base URL",
+ "custom": "Custom (OpenAI-compatible)",
+ "customOption": "Custom…",
+ "disclaimer": "Transcripts will be sent to a third-party cloud service. No audio files leave your device.",
+ "inferenceProfileLabel": "Inference profile (optional)",
+ "modelLabel": "Model",
+ "modelsAvailable_one": "{{count}} model available",
+ "modelsAvailable_other": "{{count}} models available",
+ "pickFromList": "Pick from list",
+ "regionLabel": "AWS region",
+ "selectModelPlaceholder": "Select a model",
+ "serviceLabel": "Service",
+ "testToLoadModels": "Test connection to load the list of available models."
+ },
+ "connection": {
+ "connected": "Connected",
+ "failed": "Failed",
+ "test": "Test connection",
+ "testing": "Testing…"
+ },
+ "engine": {
+ "parakeet": "Fastest — English + European languages",
+ "whisper": "Most accurate — 99 languages"
+ },
+ "keepRecordings": {
+ "description": "Save audio files to your storage location (see Advanced) after processing. Uses 1–10 MB per minute depending on capture mode.",
+ "label": "Save recordings"
+ },
+ "language": {
+ "description": "Auto-detects by default. Pick one to pin it.",
+ "label": "Language"
+ },
+ "model": {
+ "description": "Which speech-to-text model transcribes your recordings.",
+ "label": "Model",
+ "loadError": "Could not load models."
+ },
+ "models": {
+ "deleteDescriptionBoth": "Delete {{name}} and its faster build ({{size}}) to free up disk space? You can re-download them anytime.",
+ "deleteDescriptionOne": "Delete {{name}} ({{size}}) to free up disk space? You can re-download it anytime.",
+ "deleteFasterBuildDescription": "{{name}} ({{size}}) is no longer needed now that the faster build is active. Delete it to free up disk space?",
+ "deleteTitle": "Delete model?",
+ "hideDeprecated": "Hide deprecated models",
+ "loading": "Loading models…",
+ "none": "No models available.",
+ "ollamaUnreachable": "Could not reach Ollama. Run the setup wizard.",
+ "qualityNote": "{{value}} quality",
+ "showDeprecated": "Show deprecated models",
+ "speedNote": "{{value}} speed",
+ "unknownSize": "unknown size"
+ },
+ "provider": {
+ "adapter": "Organisation",
+ "adapterDescription": "Uses your organisation's AI key. No setup needed.",
+ "adapterDisabledDescription": "Sign in to your organisation to enable this option.",
+ "cloud": "Cloud API",
+ "cloudDescription": "Use OpenAI, Anthropic, or a compatible API. Best quality, requires a paid key.",
+ "description": "Where models run. Local keeps all data on your device.",
+ "label": "AI provider",
+ "local": "Local (on-device)",
+ "localDescription": "Runs entirely on your device. Private and free, no internet required.",
+ "orgManaged": "Managed by your organisation while you're signed in. Sign out under Settings > Organisation to change it.",
+ "remote": "Private Server",
+ "remoteDescription": "Connect to your own Ollama server. Data stays within your network."
+ },
+ "remote": {
+ "urlLabel": "Ollama server URL"
+ },
+ "summarisation": {
+ "heading": "Summarisation & Chat",
+ "intro": "Turns your transcript into notes and answers your questions. This is the one step that can run locally or in the cloud — if you choose a cloud provider, only the text transcript is sent, never audio."
+ },
+ "summaryModel": {
+ "description": "Which model generates your summaries, titles, and chat answers.",
+ "label": "Model"
+ },
+ "transcription": {
+ "heading": "Transcription",
+ "intro": "Speech-to-text always runs on your device — your audio never leaves your computer."
+ }
+ },
+ "developer": {
+ "clear": "Clear",
+ "console": {
+ "description": "Real-time log output from backend processes.",
+ "label": "Debug console"
+ },
+ "copy": "Copy",
+ "placeholder": "Steno debug console\nSession started — waiting for activity…\n",
+ "save": "Save",
+ "saveFailed": "Couldn't save diagnostics: {{error}}",
+ "unknownError": "unknown error"
+ },
+ "general": {
+ "appearance": {
+ "dark": "Dark",
+ "description": "Choose light, dark, or match your system",
+ "label": "Appearance",
+ "light": "Light",
+ "system": "System"
+ },
+ "autoInstall": {
+ "description": "When the app is idle and not recording, download and install updates in the background, then restart. You'll still be notified when an update is available.",
+ "label": "Install updates automatically"
+ },
+ "bothIconsHidden": "Both your dock icon and menu bar icon will be hidden. Reopen Steno from Applications or Spotlight to bring the window back.",
+ "calendar": {
+ "connected": "Connected to {{account}}",
+ "description": "Show upcoming meetings on the home screen",
+ "disconnect": "Disconnect",
+ "heading": "Calendar",
+ "label": "Connect calendar"
+ },
+ "dockIcon": {
+ "description": "Run as menu bar app only",
+ "label": "Hide dock icon"
+ },
+ "launchOnLogin": {
+ "description": "Start Steno automatically when you log in, hidden in the menu bar. Turn off to launch it manually.",
+ "label": "Launch on login"
+ },
+ "microphone": {
+ "description": "Which input device Steno records from. Pins your choice so the OS switching its default (e.g. AirPods connecting) doesn't silently change what gets recorded. Applies the next time you start a recording.",
+ "label": "Microphone",
+ "numbered": "Microphone {{index}}",
+ "systemDefault": "System Default",
+ "unknownDevice": "Unknown device (disconnected)"
+ },
+ "name": {
+ "description": "First name only — used for in-app greetings. Stored locally.",
+ "label": "Your name",
+ "placeholder": "Your name"
+ },
+ "notifications": {
+ "autoDetected": {
+ "description": "Watch for other apps using your microphone and notify you when a call starts, with a one-click button to record.",
+ "label": "Auto-detected meetings",
+ "unsupported": "Watch for other apps using your microphone and notify you when a call starts. Requires macOS 14 (Sonoma) or later.",
+ "unsupportedVersion": "Watch for other apps using your microphone and notify you when a call starts. Requires macOS 14 (Sonoma) or later, you're on {{version}}."
+ },
+ "heading": "Meeting notifications",
+ "post": {
+ "description": "Notify when your notes are ready or a recording auto-stops from silence.",
+ "label": "Post meeting notifications"
+ },
+ "scheduled": {
+ "description": "Show a notification before meetings start, based on your calendar.",
+ "label": "Scheduled meetings"
+ }
+ },
+ "oauth": {
+ "connectingTitle": "Connecting to {{provider}}",
+ "errorFallback": "The authorization flow did not complete.",
+ "errorTitle": "Couldn't connect to {{provider}}",
+ "pendingDescription": "Complete the authorization in your browser. This dialog will close automatically once access is granted.",
+ "tryAgain": "Try again",
+ "waiting": "Waiting for authorization…"
+ },
+ "recording": {
+ "heading": "Recording"
+ },
+ "silence": {
+ "description": "End the recording and start processing it once both the mic and system audio have been silent for the chosen duration. Useful when you forget to stop after a meeting ends.",
+ "label": "Auto-stop on silence",
+ "minutes_one": "{{count}} minute",
+ "minutes_other": "{{count}} minutes"
+ },
+ "system": {
+ "heading": "System"
+ },
+ "systemAudio": {
+ "description": "Capture both sides of a call. Turn off to record your mic only.",
+ "label": "Record system audio",
+ "unknownVersion": "an older version",
+ "unsupported": "Capture both sides of a call (requires macOS 14.4+, you're on {{version}}). Mic-only recording still works."
+ },
+ "trayIcon": {
+ "descriptionMac": "Show a Steno icon in the menu bar for quick access.",
+ "descriptionWindows": "Show a Steno icon in the system tray for quick access.",
+ "labelMac": "Show in menu bar",
+ "labelWindows": "Show in system tray"
+ }
+ },
+ "language": {
+ "de": "Deutsch",
+ "description": "The language Steno's own interface is shown in. This does not change the language of your notes or transcripts.",
+ "en": "English",
+ "label": "Interface language",
+ "partial": "partly translated",
+ "system": "System default"
+ },
+ "languages": {
+ "ar": "Arabic",
+ "auto": "Auto (detect)",
+ "autoMulti": "Multi-language",
+ "de": "German",
+ "en": "English",
+ "es": "Spanish",
+ "fr": "French",
+ "hi": "Hindi",
+ "hint": {
+ "auto": "Auto-detect per recording (European languages)",
+ "de": "Transcribe and summarise in German",
+ "en": "Best accuracy when meetings are always in English",
+ "es": "Transcribe and summarise in Spanish",
+ "fr": "Transcribe and summarise in French",
+ "nl": "Transcribe and summarise in Dutch",
+ "pt": "Transcribe and summarise in Portuguese"
+ },
+ "ja": "Japanese",
+ "ko": "Korean",
+ "nl": "Dutch",
+ "pt": "Portuguese",
+ "zh-Hans": "Chinese (Simplified)",
+ "zh-Hant": "Chinese (Traditional)"
+ },
+ "model": {
+ "default": "Default",
+ "deleteAria": "Delete model",
+ "deleteTitle": "Delete this model to free up disk space",
+ "deprecated": "Deprecated",
+ "downloading": "Downloading",
+ "fasterBuildAvailable": "Faster build available",
+ "fasterBuildBlocked": "Finish the current switch first",
+ "fasterBuildRetry": "Retry: switch to faster build",
+ "fasterBuildSwitch": "Switch to faster build",
+ "memoryWarningBadge": "May exceed memory",
+ "memoryWarningTitle": "This model may exceed your Mac's available memory and could run slowly or fail.",
+ "mlxBadge": "MLX model",
+ "mlxTitleDirect": "Downloaded directly as the MLX build ({{tag}}) -- {{name}} was never pulled",
+ "mlxTitleGguf": "Running the MLX build ({{tag}}) instead of {{name}}",
+ "part": "Part {{part}}",
+ "select": "Select",
+ "selected": "Selected",
+ "verifying": "Verifying…"
+ },
+ "nav": {
+ "about": "About",
+ "advanced": "Advanced",
+ "ai": "AI",
+ "back": "Back",
+ "developer": "Developer",
+ "general": "Preferences",
+ "groupSystem": "System",
+ "groupWorkspace": "Workspace",
+ "organisation": "Organisation",
+ "sections": "Settings sections",
+ "templates": "Templates",
+ "title": "Settings"
+ },
+ "organisation": {
+ "adapterUrl": "Adapter URL",
+ "autoBackup": {
+ "aria": "Auto-back up new notes to org",
+ "description": "Push every new note to your org's S3 once summarisation finishes. You can still unshare individual notes from the Shared notes view.",
+ "label": "Auto-back up new notes"
+ },
+ "email": "Email",
+ "intro": "Connect to Steno Enterprise for your organisation.",
+ "or": "or",
+ "org": "org",
+ "password": "Password",
+ "signInWithGoogle": "Sign in with Google",
+ "signInWithPassword": "Sign in with password",
+ "signOut": "Sign out",
+ "signedInAs": "Signed in as {{name}}",
+ "signingIn": "Signing in…",
+ "ssoHint": "Single sign-on via your organisation's Google Workspace.",
+ "waitingForBrowser": "Waiting for browser…"
+ },
+ "templates": {
+ "backAria": "Back to templates",
+ "builtin": "Built-in",
+ "default": "Default",
+ "defaultTitle": "Used automatically for new meetings unless you pick a different one",
+ "deleteAria": "Delete {{name}}",
+ "deleteDescription": "This permanently deletes the template. Reports already generated from it are not affected.",
+ "deleteFailed": "Failed to delete template.",
+ "deleteTitle": "Delete template \"{{name}}\"?",
+ "editTitle": "Edit template",
+ "editorSubtitle": "Configure how your meetings should be summarized",
+ "intro": "Templates are the instructions your AI follows when turning a transcript into a summary —",
+ "language": "Language",
+ "learnMore": "learn more",
+ "locked": "Locked",
+ "lockedTitle": "Built-in template — protected from editing and deletion",
+ "makeDefault": "Make Default",
+ "markdownSupported": "Markdown supported",
+ "name": "Name",
+ "namePlaceholder": "e.g. Weekly Sync, Executive Summary...",
+ "new": "New Template",
+ "newDescription": "Create custom prompts to tailor how your meetings are summarised.",
+ "newTitle": "New template",
+ "noPrompt": "No prompt provided.",
+ "promptPlaceholder": "Write a prompt instructing the AI how to structure the meeting summary...",
+ "reset": "Reset",
+ "resetTitle": "Discard your edits and revert to Steno's shipped version of this template",
+ "save": "Save Template",
+ "saveFailed": "Save failed",
+ "saving": "Saving…",
+ "seeded": {
+ "shareableSummary": "Shareable summary"
+ },
+ "systemPrompt": "System Prompt",
+ "usesStructuredFormat": "Uses structured format"
+ }
+ },
+ "setup": {
+ "actions": {
+ "begin": "Begin setup",
+ "continueToApp": "Continue to app",
+ "needKeyHint": "Enter your API key to continue.",
+ "needKeyTitle": "Enter your cloud API key first",
+ "settingUp": "Setting up..."
+ },
+ "badge": {
+ "done": "Done",
+ "failed": "Failed",
+ "running": "Running",
+ "waiting": "Waiting"
+ },
+ "chooser": {
+ "cloud": "Cloud",
+ "cloudHint": "Fast. Higher quality. Bring your own API key.",
+ "local": "Local",
+ "localHint": "Private. Free. ~2 GB download.",
+ "title": "How should Steno summarize meetings?"
+ },
+ "cloud": {
+ "apiKeyHint": "Stored locally on this device. Never synced or sent anywhere except the provider you select.",
+ "apiKeyLabel": "API key",
+ "apiUrlLabel": "API base URL",
+ "profileLabel": "Inference profile (optional)",
+ "providerCustom": "Custom (OpenAI-compatible)",
+ "providerLabel": "Provider",
+ "regionLabel": "AWS region"
+ },
+ "debug": {
+ "empty": "Steno Setup\nCommands and output will appear here...\n",
+ "toggle": "Debug console"
+ },
+ "launch": {
+ "description": "Start Steno automatically when you log in (hidden in the menu bar). You can change this any time in Settings.",
+ "title": "Launch on login"
+ },
+ "name": {
+ "hint": "First name only — used for in-app greetings. Stored locally.",
+ "label": "What should we call you?",
+ "placeholder": "Your name"
+ },
+ "progress": {
+ "ollamaAriaLabel": "Summarization model download progress",
+ "ollamaFallback": "Downloading model...",
+ "transcriptionLabel": "Downloading and preparing model..."
+ },
+ "reportIssue": "Report an issue",
+ "status": {
+ "checkingPermission": "Checking permission...",
+ "checkingTranscriptionModel": "Checking transcription model...",
+ "connectedTo": "Connected to {{provider}}",
+ "downloadingModel": "Downloading model (~2 GB)...",
+ "downloadingParakeet": "Downloading Parakeet TDT v3 ({{size}})...",
+ "modelInstalled": "Model installed",
+ "permissionDeniedMac": "Permission denied. Grant it in System Settings.",
+ "permissionDeniedWindows": "Permission denied. Grant it in Settings > Privacy & security > Microphone.",
+ "permissionGranted": "Permission granted",
+ "requestingPermission": "Requesting permission...",
+ "savingCloudCredentials": "Saving cloud credentials...",
+ "stepFailed": "Setup step failed",
+ "testingConnection": "Testing connection...",
+ "transcriptionModelReady": "Transcription model ready"
+ },
+ "steps": {
+ "microphone": {
+ "description": "Required for recording meetings",
+ "title": "Microphone Access"
+ },
+ "summarization": {
+ "descriptionCloud": "Cloud API — fast, no download",
+ "descriptionLocal": "Local model (~2 GB) — private, runs on your device",
+ "title": "Summarization Engine"
+ },
+ "transcription": {
+ "description": "Converts speech to text locally",
+ "title": "Transcription Model"
+ }
+ },
+ "subtitle": "We'll help you set up everything needed for meeting intelligence.",
+ "telemetry": {
+ "description": "Help improve Steno — meeting content is never sent. You can change this any time in Settings → Advanced.",
+ "title": "Anonymous usage analytics"
+ },
+ "title": "Welcome to Steno"
+ },
+ "toast": {
+ "notification": {
+ "close": "Close",
+ "join": "Join & take notes"
+ },
+ "undoDelete": {
+ "dismiss": "Dismiss and delete permanently",
+ "title": "Note deleted",
+ "undo": "Undo"
+ },
+ "update": {
+ "dismiss": "Dismiss update notification",
+ "ready": "Update v{{version}} ready",
+ "restart": "Restart"
+ }
+ },
+ "toolbar": {
+ "hideSidebar": "Hide sidebar",
+ "importAudio": "Import audio file…",
+ "importAudioBlocked": "Stop the current recording to import a file.",
+ "importAudioHint": "Transcribe and summarise an existing recording. It will appear in the list while it processes.",
+ "newChat": "New chat",
+ "newNote": "New note",
+ "openRecording": "Open recording in progress",
+ "paused": "Paused",
+ "recordSystemAudio": "Record system audio",
+ "recordSystemAudioHint": "Capture both sides of calls. Turn off to record your mic only.",
+ "recording": "Recording",
+ "recordingOptions": "Recording options",
+ "recordingOptionsHint": "Deep links and the tray menu also start and stop recording.",
+ "showSidebar": "Show sidebar"
+ },
+ "transcript": {
+ "empty": "No transcript available.",
+ "loading": "Loading…",
+ "searchPlaceholder": "Search transcript"
+ },
+ "tray": {
+ "hide": "Hide Steno",
+ "open": "Open Steno",
+ "quit": "Quit Steno",
+ "reportBug": "Report a Bug",
+ "settings": "Settings",
+ "startRecording": "Start Recording",
+ "stopRecording": "Stop Recording",
+ "tooltip": "Steno",
+ "tooltipRecording": "Steno - Recording",
+ "version": "Steno v{{version}}"
+ }
+}
diff --git a/app/main.js b/app/main.js
index f8b9df6b..a1e91e15 100644
--- a/app/main.js
+++ b/app/main.js
@@ -1,4 +1,4 @@
-const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session } = require('electron');
+const { app, BrowserWindow, ipcMain, dialog, shell, systemPreferences, globalShortcut, Tray, Menu, nativeImage, powerMonitor, net, session, webContents } = require('electron');
// safeStorage is accessed lazily via getSafeStorage(), NOT destructured from the
// require above. On macOS, merely retrieving the safeStorage binding at load
@@ -102,6 +102,7 @@ const { EXPORT_CANCELED } = require('./ipc-sentinels');
const { PostHog } = require('posthog-node');
const { initMain } = require('electron-audio-loopback');
const { autoUpdater } = require('electron-updater');
+const i18n = require('./i18n');
// E2E test-harness hooks. Set via env vars; production sees none of these.
// STENOAI_USER_DATA_DIR — per-test temp userData dir (must be set before app.whenReady)
@@ -328,6 +329,9 @@ class Notification extends EventEmitter {
contextIsolation: true,
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
+ // Same bootstrap as the main window — the toast is its own renderer and
+ // would otherwise start in English regardless of the chosen language.
+ additionalArguments: [`--stenoai-ui-language=${resolvedUiLanguage}`],
},
});
notificationWindow = win;
@@ -574,7 +578,7 @@ async function showShortcutNotification(body) {
}
const notif = new Notification({
- title: 'Steno Shortcuts',
+ title: i18n.t('notification.shortcuts.title'),
body
});
trackNotificationLifecycle(notif, 'shortcut');
@@ -624,7 +628,7 @@ async function handleShortcutUrl(incomingUrl) {
if (parsedAction.type === 'invalid') {
sendDebugLog(`Ignored invalid shortcut URL (${parsedAction.reason}): ${safeShortcutUrl}`);
- await showShortcutNotification('Invalid shortcut URL');
+ await showShortcutNotification(i18n.t('notification.shortcuts.invalidUrl'));
launchedByShortcut = false;
return;
}
@@ -634,7 +638,7 @@ async function handleShortcutUrl(incomingUrl) {
if (parsedAction.type === 'start') {
if (recording) {
- await showShortcutNotification('Recording already in progress');
+ await showShortcutNotification(i18n.t('notification.shortcuts.alreadyRecording'));
launchedByShortcut = false;
return;
}
@@ -1511,6 +1515,12 @@ function createWindow(options = {}) {
sandbox: true,
preload: path.join(__dirname, 'preload.js'),
scrollBounce: true,
+ // Hand the resolved UI language over as a launch argument rather than
+ // making the renderer ask for it. An async IPC round trip would land
+ // after the first paint, which is exactly the flash of English this
+ // avoids; process.argv is readable synchronously even in a sandboxed
+ // preload.
+ additionalArguments: [`--stenoai-ui-language=${resolvedUiLanguage}`],
},
// Windows/Linux render the Electron application menu as an in-window menu
// bar (File/Edit/View/…); macOS puts it in the global bar. Hide it off-mac
@@ -1603,6 +1613,192 @@ function createWindow(options = {}) {
});
}
+// The concrete language tag in force ('en' | 'de'), as opposed to the stored
+// preference, which may be the 'system' sentinel. Resolved once at startup and
+// again on every switch; handed to each renderer as a launch argument.
+let resolvedUiLanguage = i18n.FALLBACK_UI_LANGUAGE;
+
+/*
+ * The live-switch sequence, in this order for a reason:
+ *
+ * main changeLanguage → rebuild menu → rebuild tray → tell every renderer
+ *
+ * The two native menus are snapshots, so they need an explicit rebuild that
+ * changeLanguage() will not do for them. Renderers come last because they
+ * repaint fast and a stale menu next to a fresh UI is the more visible seam.
+ * Every open WebContents is notified, not just the main window — the
+ * notification toast lives in its own window and would otherwise keep the old
+ * language until it was next recreated.
+ *
+ * Persistence is the caller's job: it goes through the Python config so the
+ * atomic/locked write is not duplicated in JS.
+ */
+async function applyUiLanguage(storedPreference) {
+ // Takes the stored preference, not a concrete tag, so the 'system' sentinel
+ // is re-resolved against the OS here rather than by every caller.
+ resolvedUiLanguage = i18n.resolveUiLanguage(
+ storedPreference,
+ app.getPreferredSystemLanguages()
+ );
+ await i18n.changeMainLanguage(resolvedUiLanguage);
+ buildAppMenu();
+ updateTrayMenu();
+ if (tray) {
+ const isRecording = currentRecordingProcess !== null || systemAudioRecordingActive;
+ tray.setToolTip(isRecording ? i18n.t('tray.tooltipRecording') : i18n.t('tray.tooltip'));
+ }
+ for (const wc of webContents.getAllWebContents()) {
+ try {
+ if (!wc.isDestroyed()) wc.send('ui-language-changed', resolvedUiLanguage);
+ } catch (_) {
+ // A window torn down mid-broadcast is not an error worth surfacing.
+ }
+ }
+ return resolvedUiLanguage;
+}
+
+/*
+ * Application menu. macOS uses the global menu bar with mac-only roles
+ * (services/hide/unhide). Windows/Linux get a slimmer, platform-correct menu —
+ * kept (editing accelerators, Settings, Help) but hidden by default via
+ * autoHideMenuBar so it doesn't clash with the app's custom toolbar; Alt
+ * reveals it (standard Windows behaviour).
+ *
+ * A built menu is a snapshot, not a live view: changeLanguage() alone will not
+ * relabel it. So this is a function rather than the inline block it used to be,
+ * and applyUiLanguage() calls it again after a language switch. Role-based
+ * items are deliberately left on their roles — Electron supplies the
+ * platform-appropriate localised label for those itself, better than we could.
+ */
+function buildAppMenu() {
+ const settingsItem = {
+ label: i18n.t('menu.settings'),
+ accelerator: 'CmdOrCtrl+,',
+ click: () => {
+ showAndFocusWindow();
+ if (mainWindow) {
+ mainWindow.webContents.send('tray-open-settings');
+ }
+ }
+ };
+ const helpSubmenu = {
+ // A plain submenu rather than { role: 'help' }: the role's own label does
+ // not follow the chosen language (same measured behaviour as the other
+ // roles), and `help` is documented as macOS-specific while this object is
+ // also used in the Windows/Linux template.
+ label: i18n.t('menu.help'),
+ submenu: [
+ { label: i18n.t('menu.learnMore'), click: () => shell.openExternal('https://github.com/ruzin/stenoai') },
+ { label: i18n.t('menu.reportBug'), click: () => shell.openExternal('https://discord.gg/DZ6vcQnxxu') }
+ ]
+ };
+ /*
+ * The composite roles (fileMenu/editMenu/viewMenu/windowMenu) are expanded
+ * into their items so each can carry a translated label.
+ *
+ * This looks like it should be unnecessary — the assumption when this landed
+ * was Electron's, that a role supplies a platform-appropriate localised
+ * label. Measured on a fully German Mac (getLocale=de, systemLocale=de-DE),
+ * it does not: bare roles render "Undo", "Cut", "Select All" in English. So
+ * a German UI showed one German "Einstellungen…" in an otherwise English
+ * menu, which reads more broken than either language on its own.
+ *
+ * Every item keeps its `role`, so behaviour and the platform accelerator
+ * still come from Electron — only the text is ours. Verified that overriding
+ * a role's label leaves its accelerator intact (quit still binds ⌘Q).
+ *
+ * The German wording is Apple's own macOS terminology: "Ablage" rather than
+ * "Datei" for File, "Widerrufen" for Undo, "Einsetzen" for Paste, "Im Dock
+ * ablegen" for Minimize. Windows/Linux use "Datei", which is correct there.
+ */
+ const appName = app.name;
+ const editSubmenu = [
+ { role: 'undo', label: i18n.t('menu.undo') },
+ { role: 'redo', label: i18n.t('menu.redo') },
+ { type: 'separator' },
+ { role: 'cut', label: i18n.t('menu.cut') },
+ { role: 'copy', label: i18n.t('menu.copy') },
+ { role: 'paste', label: i18n.t('menu.paste') },
+ ...(process.platform === 'darwin'
+ ? [
+ { role: 'pasteAndMatchStyle', label: i18n.t('menu.pasteAndMatchStyle') },
+ { role: 'delete', label: i18n.t('menu.delete') },
+ { role: 'selectAll', label: i18n.t('menu.selectAll') },
+ { type: 'separator' },
+ {
+ label: i18n.t('menu.speech'),
+ submenu: [
+ { role: 'startSpeaking', label: i18n.t('menu.startSpeaking') },
+ { role: 'stopSpeaking', label: i18n.t('menu.stopSpeaking') }
+ ]
+ }
+ ]
+ : [
+ { role: 'delete', label: i18n.t('menu.delete') },
+ { type: 'separator' },
+ { role: 'selectAll', label: i18n.t('menu.selectAll') }
+ ])
+ ];
+ const viewSubmenu = [
+ { role: 'reload', label: i18n.t('menu.reload') },
+ { role: 'forceReload', label: i18n.t('menu.forceReload') },
+ { role: 'toggleDevTools', label: i18n.t('menu.toggleDevTools') },
+ { type: 'separator' },
+ { role: 'resetZoom', label: i18n.t('menu.resetZoom') },
+ { role: 'zoomIn', label: i18n.t('menu.zoomIn') },
+ { role: 'zoomOut', label: i18n.t('menu.zoomOut') },
+ { type: 'separator' },
+ { role: 'togglefullscreen', label: i18n.t('menu.toggleFullScreen') }
+ ];
+ const windowSubmenu = [
+ { role: 'minimize', label: i18n.t('menu.minimize') },
+ ...(process.platform === 'darwin'
+ ? [
+ { role: 'zoom', label: i18n.t('menu.zoom') },
+ { type: 'separator' },
+ { role: 'front', label: i18n.t('menu.front') }
+ ]
+ : [{ role: 'close', label: i18n.t('menu.close') }])
+ ];
+
+ const appMenu = Menu.buildFromTemplate(
+ process.platform === 'darwin'
+ ? [
+ {
+ // Custom appMenu to add Settings… with the conventional ⌘,
+ // shortcut (the default `{ role: 'appMenu' }` omits Settings).
+ label: appName,
+ submenu: [
+ { role: 'about', label: i18n.t('menu.about', { app: appName }) },
+ { type: 'separator' },
+ settingsItem,
+ { type: 'separator' },
+ { role: 'services', label: i18n.t('menu.services') },
+ { type: 'separator' },
+ { role: 'hide', label: i18n.t('menu.hide', { app: appName }) },
+ { role: 'hideOthers', label: i18n.t('menu.hideOthers') },
+ { role: 'unhide', label: i18n.t('menu.unhide') },
+ { type: 'separator' },
+ { role: 'quit', label: i18n.t('menu.quit', { app: appName }) }
+ ]
+ },
+ { label: i18n.t('menu.fileMac'), submenu: [{ role: 'close', label: i18n.t('menu.close') }] },
+ { label: i18n.t('menu.edit'), submenu: editSubmenu },
+ { label: i18n.t('menu.view'), submenu: viewSubmenu },
+ { label: i18n.t('menu.window'), submenu: windowSubmenu },
+ helpSubmenu
+ ]
+ : [
+ { label: i18n.t('menu.file'), submenu: [settingsItem, { type: 'separator' }, { role: 'quit', label: i18n.t('menu.quit', { app: appName }) }] },
+ { label: i18n.t('menu.edit'), submenu: editSubmenu },
+ { label: i18n.t('menu.view'), submenu: viewSubmenu },
+ { label: i18n.t('menu.window'), submenu: windowSubmenu },
+ helpSubmenu
+ ]
+ );
+ Menu.setApplicationMenu(appMenu);
+}
+
function getTrayIconPath(recording) {
const iconName = recording ? 'trayIconRecordingTemplate' : 'trayIconTemplate';
if (app.isPackaged) {
@@ -1615,7 +1811,7 @@ function createTray() {
const icon = nativeImage.createFromPath(getTrayIconPath(false));
icon.setTemplateImage(true);
tray = new Tray(icon);
- tray.setToolTip('Steno');
+ tray.setToolTip(i18n.t('tray.tooltip'));
updateTrayMenu();
}
@@ -1625,7 +1821,7 @@ function updateTrayIcon(recording) {
const icon = nativeImage.createFromPath(getTrayIconPath(recording));
icon.setTemplateImage(true);
tray.setImage(icon);
- tray.setToolTip(recording ? 'Steno - Recording' : 'Steno');
+ tray.setToolTip(recording ? i18n.t('tray.tooltipRecording') : i18n.t('tray.tooltip'));
updateTrayMenu();
}
@@ -1644,11 +1840,11 @@ function updateTrayMenu() {
const contextMenu = Menu.buildFromTemplate([
{
- label: 'Open Steno',
+ label: i18n.t('tray.open'),
click: showAndFocusWindow
},
{
- label: isRecording ? 'Stop Recording' : 'Start Recording',
+ label: isRecording ? i18n.t('tray.stopRecording') : i18n.t('tray.startRecording'),
click: () => {
if (mainWindow) {
mainWindow.webContents.send(isRecording ? 'tray-stop-recording' : 'tray-start-recording');
@@ -1656,7 +1852,7 @@ function updateTrayMenu() {
}
},
{
- label: 'Settings',
+ label: i18n.t('tray.settings'),
click: () => {
showAndFocusWindow();
if (mainWindow) {
@@ -1665,25 +1861,25 @@ function updateTrayMenu() {
}
},
{
- label: 'Hide Steno',
+ label: i18n.t('tray.hide'),
click: () => {
if (mainWindow) mainWindow.hide();
}
},
{ type: 'separator' },
{
- label: `Steno v${appVersion}`,
+ label: i18n.t('tray.version', { version: appVersion }),
enabled: false
},
{
- label: 'Report a Bug',
+ label: i18n.t('tray.reportBug'),
click: () => {
shell.openExternal('https://discord.gg/DZ6vcQnxxu');
}
},
{ type: 'separator' },
{
- label: 'Quit Steno',
+ label: i18n.t('tray.quit'),
click: () => {
app.quit();
}
@@ -1891,64 +2087,45 @@ if (!gotSingleInstanceLock) {
console.warn('processing-log init failed (non-fatal):', e?.message);
}
- // Application menu. macOS uses the global menu bar with mac-only roles
- // (services/hide/unhide). Windows/Linux get a slimmer, platform-correct
- // menu — kept (editing accelerators, Settings, Help) but hidden by default
- // via autoHideMenuBar so it doesn't clash with the app's custom toolbar;
- // Alt reveals it (standard Windows behaviour).
- const settingsItem = {
- label: 'Settings…',
- accelerator: 'CmdOrCtrl+,',
- click: () => {
- showAndFocusWindow();
- if (mainWindow) {
- mainWindow.webContents.send('tray-open-settings');
- }
+ // Resolve the UI language before anything user-visible is built. The menu
+ // below and the tray are snapshots taken in whatever language is active at
+ // build time, and the window created further down carries the resolved tag
+ // to the renderer as a launch argument — so this has to happen first, or
+ // the first paint is English and then flips.
+ //
+ // getPreferredSystemLanguages() honours the user's *ordered* language list
+ // rather than a regional-format locale, and is only meaningful after ready.
+ try {
+ // E2E pins the whole suite to English: ~108 Playwright locators match on
+ // visible text, so a German run would fail most of them for reasons that
+ // have nothing to do with what they test. Gated on IS_E2E so it cannot be
+ // reached in production, and set via env rather than config.json so it
+ // does not fight the specs that deliberately seed their own config.
+ const stored =
+ IS_E2E && process.env.STENOAI_UI_LANGUAGE
+ ? process.env.STENOAI_UI_LANGUAGE
+ : i18n.readStoredUiLanguage(getUserDataDir());
+ resolvedUiLanguage = i18n.resolveUiLanguage(stored, app.getPreferredSystemLanguages());
+ await i18n.initMainI18n(resolvedUiLanguage);
+ processingLog.logLine('app', `ui-language stored=${stored} resolved=${resolvedUiLanguage}`);
+ } catch (e) {
+ // Only reachable if en.json itself is missing or unparseable — a broken
+ // de.json degrades to English inside loadResources() and never lands here.
+ //
+ // Launching beats not launching, but be honest about the state: with no
+ // resources loaded, t() returns the key, so the menu and tray render as
+ // "menu.settings" / "tray.open". That is a build defect, not a runtime
+ // condition — locale-completeness.test.js fails the suite before such a
+ // build could ship — so it is logged loudly rather than papered over.
+ console.error('ui i18n init failed — native chrome will show raw keys:', e?.message);
+ try {
+ processingLog.logLine('app', `ui-language init FAILED: ${e?.message}`);
+ } catch (_) {
+ /* the diagnostic log is best-effort */
}
- };
- const helpSubmenu = {
- role: 'help',
- submenu: [
- { label: 'Learn More', click: () => shell.openExternal('https://github.com/ruzin/stenoai') },
- { label: 'Report a Bug', click: () => shell.openExternal('https://discord.gg/DZ6vcQnxxu') }
- ]
- };
- const appMenu = Menu.buildFromTemplate(
- process.platform === 'darwin'
- ? [
- {
- // Custom appMenu to add Settings… with the conventional ⌘,
- // shortcut (the default `{ role: 'appMenu' }` omits Settings).
- label: app.name,
- submenu: [
- { role: 'about' },
- { type: 'separator' },
- settingsItem,
- { type: 'separator' },
- { role: 'services' },
- { type: 'separator' },
- { role: 'hide' },
- { role: 'hideOthers' },
- { role: 'unhide' },
- { type: 'separator' },
- { role: 'quit' }
- ]
- },
- { role: 'fileMenu' },
- { role: 'editMenu' },
- { role: 'viewMenu' },
- { role: 'windowMenu' },
- helpSubmenu
- ]
- : [
- { label: '&File', submenu: [settingsItem, { type: 'separator' }, { role: 'quit' }] },
- { role: 'editMenu' },
- { role: 'viewMenu' },
- { role: 'windowMenu' },
- helpSubmenu
- ]
- );
- Menu.setApplicationMenu(appMenu);
+ }
+
+ buildAppMenu();
if (process.platform === 'darwin') {
try {
@@ -2484,7 +2661,7 @@ ipcMain.handle('select-audio-file', async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile'],
filters: [
- { name: 'Audio Files', extensions: IMPORT_AUDIO_EXTENSIONS }
+ { name: i18n.t('dialog.filter.audio'), extensions: IMPORT_AUDIO_EXTENSIONS }
]
});
@@ -3700,8 +3877,8 @@ ipcMain.handle('export-transcript', async (event, defaultFilename, content) => {
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: suggested,
filters: [
- { name: 'Markdown', extensions: ['md'] },
- { name: 'Text', extensions: ['txt'] },
+ { name: i18n.t('dialog.filter.markdown'), extensions: ['md'] },
+ { name: i18n.t('dialog.filter.text'), extensions: ['txt'] },
],
});
if (result.canceled || !result.filePath) {
@@ -3822,7 +3999,7 @@ ipcMain.handle('export-note-pdf', async (event, defaultFilename, html) => {
: 'notes.pdf';
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: suggested,
- filters: [{ name: 'PDF', extensions: ['pdf'] }],
+ filters: [{ name: i18n.t('dialog.filter.pdf'), extensions: ['pdf'] }],
});
if (result.canceled || !result.filePath) {
return { success: false, error: EXPORT_CANCELED };
@@ -3877,7 +4054,7 @@ ipcMain.handle('save-diagnostics', async (event, defaultFilename, content) => {
: 'stenoai-diagnostics.txt';
const result = await dialog.showSaveDialog(mainWindow, {
defaultPath: suggested,
- filters: [{ name: 'Text', extensions: ['txt'] }],
+ filters: [{ name: i18n.t('dialog.filter.text'), extensions: ['txt'] }],
});
if (result.canceled || !result.filePath) {
return { success: false, error: EXPORT_CANCELED };
@@ -5945,12 +6122,12 @@ function showSleepPausedNotification() {
// convenience notifications (meeting detected, note ready), this one is
// state-critical — the user believes they're capturing and they are not.
const notif = new Notification({
- title: 'Recording paused',
- body: 'Paused while your computer was asleep. Resume to keep capturing.',
+ title: i18n.t('notification.sleepPaused.title'),
+ body: i18n.t('notification.sleepPaused.body'),
iconType: 'alert',
// The Resume action button is always rendered by the custom toast (both
// platforms); the click handler below covers a body tap as well.
- actions: [{ type: 'button', text: 'Resume' }],
+ actions: [{ type: 'button', text: i18n.t('notification.sleepPaused.action') }],
});
const resume = () => {
sleepPausedNotif = null;
@@ -6612,9 +6789,9 @@ function showMeetingDetectedNotification(appName, originatingEvt, calEvent) {
// dropdown instead of showing the action inline. Leaving closeButtonText
// unset gives us Granola's layout — single inline button to the right.
const notif = new Notification({
- title: 'Meeting detected',
+ title: i18n.t('notification.meetingDetected.title'),
body: calEvent?.title || appName,
- actions: [{ type: 'button', text: 'Take Notes' }],
+ actions: [{ type: 'button', text: i18n.t('notification.meetingDetected.action') }],
});
const trigger = () => requestAutoRecord(appName, originatingEvt, calEvent);
notif.on('action', (_evt, _index) => trigger()); // shown when banner style = Alerts
@@ -6625,9 +6802,9 @@ function showMeetingDetectedNotification(appName, originatingEvt, calEvent) {
function showMeetingEndedNotification(appName) {
const notif = new Notification({
- title: 'Meeting ended',
+ title: i18n.t('notification.meetingEnded.title'),
body: appName,
- actions: [{ type: 'button', text: 'Summarise' }],
+ actions: [{ type: 'button', text: i18n.t('notification.meetingEnded.action') }],
});
// Only the explicit Summarise button commits — body click just opens
// Steno so the user can decide (summarise / resume / leave paused) from
@@ -7781,7 +7958,13 @@ ipcMain.handle('pull-parakeet-model', async (event, modelId) => {
// behavior are identical to the inline handlers this replaces. Settings-shaped
// handlers coupled to another domain (telemetry, models, mic-monitor, calendar,
// tray) deliberately stay in main.js until that domain's own extraction.
-registerSettingsIpc({ ipcMain, runPythonScript, sendDebugLog });
+registerSettingsIpc({
+ ipcMain,
+ runPythonScript,
+ sendDebugLog,
+ applyUiLanguage,
+ currentUiLanguage: () => resolvedUiLanguage,
+});
// Fired by the renderer's silence detector. The renderer has already
// asked main to stop the recording via pause/stop; this just surfaces
@@ -7802,10 +7985,10 @@ ipcMain.handle('show-silence-auto-stop-notification', async (_event, payload) =>
const minutes = typeof payload === 'number' ? payload : payload?.minutes;
const sessionName = typeof payload === 'object' ? payload?.sessionName : null;
const body = sessionName
- ? `${sessionName} — ${minutes} minutes of silence`
- : `${minutes} minutes of silence — your note is being processed.`;
+ ? i18n.t('notification.silenceAutoStop.bodyNamed', { count: minutes, sessionName })
+ : i18n.t('notification.silenceAutoStop.body', { count: minutes });
const notif = new Notification({
- title: 'Recording stopped',
+ title: i18n.t('notification.silenceAutoStop.title'),
body,
iconType: 'recording',
});
@@ -7833,8 +8016,8 @@ ipcMain.handle('show-system-audio-mic-only-notification', async () => {
try {
if (!(await notificationsEnabled())) return { success: true, shown: false };
const notif = new Notification({
- title: 'Recording mic-only',
- body: 'System audio could not be captured. Check Steno’s Screen & System Audio Recording access in System Settings.',
+ title: i18n.t('notification.micOnly.title'),
+ body: i18n.t('notification.micOnly.body'),
iconType: 'alert',
});
notif.on('click', () => {
@@ -7877,15 +8060,17 @@ ipcMain.handle('show-note-ready-notification', async (_event, payload) => {
// - otherwise: the note is genuinely ready.
const notif = new Notification({
title: hardFailure
- ? 'Processing failed'
+ ? i18n.t('notification.noteReady.titleProcessingFailed')
: failed
- ? 'Transcription failed'
- : 'Note ready',
+ ? i18n.t('notification.noteReady.titleTranscriptionFailed')
+ : i18n.t('notification.noteReady.title'),
body: hardFailure
- ? `Steno couldn't process ${title ? `"${title}"` : 'your note'}.`
+ ? (title
+ ? i18n.t('notification.noteReady.bodyFailedTitled', { title })
+ : i18n.t('notification.noteReady.bodyFailedUntitled'))
: failed
- ? 'Your recording was preserved — open the note for details.'
- : (title || 'Your note has finished processing'),
+ ? i18n.t('notification.noteReady.bodyPreserved')
+ : (title || i18n.t('notification.noteReady.bodyDone')),
iconType: (hardFailure || failed) ? 'alert' : 'success',
});
notif.on('click', () => {
@@ -8902,8 +9087,8 @@ function showRecordingFailedNotification(body) {
try {
if (!Notification.isSupported()) return;
new Notification({
- title: 'Steno',
- body: body || "Recording couldn't start.",
+ title: i18n.t('notification.recordingFailed.title'),
+ body: body || i18n.t('notification.recordingFailed.body'),
iconType: 'alert',
}).show();
} catch (error) {
@@ -8914,7 +9099,9 @@ function showRecordingFailedNotification(body) {
ipcMain.on('recording-capture-error', (_event, message) => {
sendDebugLog(`[sysaudio] capture error: ${message}`);
showRecordingFailedNotification(
- message ? `Recording couldn't start: ${message}` : "Recording couldn't start.",
+ message
+ ? i18n.t('notification.recordingFailed.bodyWithReason', { reason: message })
+ : i18n.t('notification.recordingFailed.body'),
);
});
@@ -10972,7 +11159,7 @@ async function firePreMeetingNotification(event) {
return false;
}
- const notif = new Notification({ title: event.title || 'Meeting starting' });
+ const notif = new Notification({ title: event.title || i18n.t('notification.preMeeting.titleFallback') });
// The pre-meeting toast carries a richer payload (time / meeting URL /
// attendees) and keeps its legacy renderer-side handlers (Join & take notes,
// focus-on-body-tap) plus its own click/dismiss analytics. `premeeting: true`
diff --git a/app/package-lock.json b/app/package-lock.json
index 5bab0517..4fb34cb6 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "stenoai",
- "version": "0.6.3",
+ "version": "0.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stenoai",
- "version": "0.6.3",
+ "version": "0.6.5",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -24,10 +24,12 @@
"clsx": "^2.1.1",
"electron-audio-loopback": "^1.0.6",
"electron-updater": "^6.8.3",
+ "i18next": "^26.3.6",
"lucide-react": "^1.8.0",
"posthog-node": "^4.18.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.2",
"tailwind-merge": "^3.5.0",
@@ -356,7 +358,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -7399,6 +7400,15 @@
"node": ">=18"
}
},
+ "node_modules/html-parse-stringify": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
+ "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://locize.com"
+ }
+ },
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -7458,6 +7468,34 @@
"node": ">= 14"
}
},
+ "node_modules/i18next": {
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/iconv-corefoundation": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
@@ -10408,6 +10446,33 @@
"react": "^19.2.6"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.11",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
+ "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^4.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.2.0",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6 || ^7"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -12198,7 +12263,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -12424,6 +12489,15 @@
}
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
diff --git a/app/package.json b/app/package.json
index 46f3463d..5a146e35 100644
--- a/app/package.json
+++ b/app/package.json
@@ -19,7 +19,7 @@
"typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit",
"lint:renderer": "eslint --config renderer/eslint.config.mjs renderer/src",
"format:renderer": "prettier --write renderer/src",
- "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js && vitest run",
+ "test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js ui-language.test.js locale-completeness.test.js && vitest run",
"build": "npm run build:renderer && electron-builder",
"pack:unsigned": "npm run build:renderer && electron-builder --dir --config electron-builder.ci.yml",
"build-mac": "npm run build:renderer && electron-builder --mac",
@@ -94,10 +94,12 @@
"clsx": "^2.1.1",
"electron-audio-loopback": "^1.0.6",
"electron-updater": "^6.8.3",
+ "i18next": "^26.3.6",
"lucide-react": "^1.8.0",
"posthog-node": "^4.18.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
+ "react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.14.2",
"tailwind-merge": "^3.5.0",
@@ -161,6 +163,10 @@
{
"from": "../bin/mic-monitor",
"to": "mic-monitor"
+ },
+ {
+ "from": "build/lproj/de.lproj/InfoPlist.strings",
+ "to": "de.lproj/InfoPlist.strings"
}
],
"extendInfo": {
diff --git a/app/preload.js b/app/preload.js
index 9abee106..3bdd230f 100644
--- a/app/preload.js
+++ b/app/preload.js
@@ -53,8 +53,24 @@ const subscribeQueryStream = (queryId, { onChunk, onDone, onError } = {}) => {
};
};
+/*
+ * The UI language main resolved for this window, delivered on process.argv via
+ * webPreferences.additionalArguments rather than fetched over IPC.
+ *
+ * It is a plain value, not a function, because the renderer needs it before its
+ * first paint: i18next is initialised at module scope in lib/i18n.ts, and any
+ * async hop there would either delay the ready-to-show signal or paint English
+ * first and then flip. Later changes arrive via events.uiLanguageChanged.
+ */
+const uiLanguageFromArgv = () => {
+ const prefix = '--stenoai-ui-language=';
+ const arg = process.argv.find((a) => a.startsWith(prefix));
+ return arg ? arg.slice(prefix.length) : 'en';
+};
+
const stenoai = {
version: VERSION,
+ uiLanguage: uiLanguageFromArgv(),
app: {
getVersion: () => invoke('get-app-version'),
@@ -264,8 +280,13 @@ const stenoai = {
// Design-for-test seam: the production fire path is the main-side scheduler
// timer; this lets e2e drive the gate + suppression deterministically.
showPremeetingNotification: (payload) => invoke('show-premeeting-notification', payload),
+ // Transcription/content language — NOT the interface language. The pair
+ // below is the UI one; keeping both here makes the distinction visible at
+ // the only place a caller picks between them.
getLanguage: () => invoke('get-language'),
setLanguage: (code) => invoke('set-language', code),
+ getUiLanguage: () => invoke('get-ui-language'),
+ setUiLanguage: (code) => invoke('set-ui-language', code),
getMicrophone: () => invoke('get-microphone'),
setMicrophone: (deviceId, label) => invoke('set-microphone', deviceId, label),
getUserName: () => invoke('get-user-name'),
@@ -356,6 +377,7 @@ const stenoai = {
// All main-driven events. Every subscribe returns an unsubscribe fn.
on: {
debugLog: (cb) => subscribe('debug-log', cb),
+ uiLanguageChanged: (cb) => subscribe('ui-language-changed', cb),
setupFlowTriggered: (cb) => subscribe('trigger-setup-flow', cb),
toggleRecordingHotkey: (cb) => subscribe('toggle-recording-hotkey', cb),
summaryChunk: (cb) => subscribe('summary-chunk', cb),
diff --git a/app/renderer/src/App.tsx b/app/renderer/src/App.tsx
index 8ea30552..88b598a3 100644
--- a/app/renderer/src/App.tsx
+++ b/app/renderer/src/App.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { useTheme } from '@/hooks/useTheme';
+import { useUiLanguageSync } from '@/hooks/useUiLanguageSync';
import { Sandbox } from '@/routes/Sandbox';
import { Settings } from '@/routes/Settings';
import { Setup } from '@/routes/Setup';
@@ -38,6 +39,7 @@ import { primeDebugLogs } from '@/lib/debugLogs';
export function App() {
useTheme();
+ useUiLanguageSync();
const route = useRoute();
// One-time privacy disclosure for upgraders. Show it only when the marker is
diff --git a/app/renderer/src/components/AskBar.tsx b/app/renderer/src/components/AskBar.tsx
index c021ded5..06d93001 100644
--- a/app/renderer/src/components/AskBar.tsx
+++ b/app/renderer/src/components/AskBar.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import {
ArrowUp,
Check,
@@ -30,6 +31,7 @@ import { buildTranscriptBundle } from '@/lib/transcriptBundle';
// ---------------------------------------------------------------------------
export function TranscriptBar() {
+ const { t } = useTranslation();
const { activeSummaryFile, activeMeetingName, activeOrgMeeting, transcriptOpen, setTranscriptOpen } =
useAskBar();
const meeting = useMeeting(activeSummaryFile ?? undefined);
@@ -95,13 +97,13 @@ export function TranscriptBar() {
- Transcript
+ {t('chat.transcript')}
void copyTranscript()}
- aria-label="Copy transcript"
- title="Copy transcript"
+ aria-label={t('chat.copyTranscript')}
+ title={t('chat.copyTranscript')}
>
{copied ? : }
@@ -109,8 +111,8 @@ export function TranscriptBar() {
type="button"
className="mv-chat-tool"
onClick={() => setTranscriptOpen(false)}
- aria-label="Hide transcript"
- title="Hide transcript"
+ aria-label={t('chat.hideTranscript')}
+ title={t('chat.hideTranscript')}
>
@@ -132,12 +134,12 @@ export function TranscriptBar() {
type="button"
onClick={onResume}
data-testid="resume-recording-button"
- aria-label="Resume recording on this note"
- title="Resume recording — the new audio is appended to this note"
+ aria-label={t('chat.resumeAria')}
+ title={t('chat.resumeTitle')}
className="inline-flex h-8 cursor-pointer items-center rounded-full border-0 px-3.5 text-[13px] font-medium transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ background: 'var(--surface-sunken)', color: 'var(--fg-1)' }}
>
- Resume
+ {t('chat.resume')}
)}
@@ -151,6 +153,7 @@ export function TranscriptBar() {
* Shown only for a meeting that actually has a transcript.
*/
export function TranscriptToggle() {
+ const { t } = useTranslation();
const { activeSummaryFile, activeOrgMeeting, transcriptOpen, setTranscriptOpen } = useAskBar();
const [hover, setHover] = React.useState(false);
const hasTranscript =
@@ -164,9 +167,9 @@ export function TranscriptToggle() {
onClick={() => setTranscriptOpen(!transcriptOpen)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
- aria-label={transcriptOpen ? 'Hide transcript' : 'Show transcript'}
+ aria-label={transcriptOpen ? t('chat.hideTranscript') : t('chat.showTranscript')}
aria-pressed={transcriptOpen}
- title="Transcript"
+ title={t('chat.transcript')}
className="pointer-events-auto inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-0.5 rounded-full border-0 px-3 transition-colors"
style={{
background: transcriptOpen ? 'var(--surface-active)' : 'var(--surface-raised)',
@@ -208,6 +211,7 @@ export function TranscriptToggle() {
* pill always has the bar beside it.
*/
export function AskBar({ disabled = false }: { disabled?: boolean }) {
+ const { t } = useTranslation();
const {
activeSummaryFile,
activeMeetingName,
@@ -300,14 +304,14 @@ export function AskBar({ disabled = false }: { disabled?: boolean }) {
const content =
stream.text.trim() ||
(stream.status === 'error'
- ? `Error: ${stream.error ?? 'query failed'}`
- : '(empty response)');
+ ? t('chat.streamError', { error: stream.error ?? t('chat.queryFailed') })
+ : t('chat.emptyResponse'));
const message: ChatMessage = { role: 'assistant', content, ts: Date.now() };
void chat.appendMessage(sessionId, message);
pendingPersistRef.current = null;
streaming.clearStream(activeStreamId);
setActiveStreamId(null);
- }, [activeStreamId, streaming, chat]);
+ }, [activeStreamId, streaming, chat, t]);
// Re-entrancy guard. submitPrompt awaits createSession/appendMessage; rapid
// suggestion-chip clicks (or Enter) before those resolve would otherwise
@@ -438,13 +442,13 @@ export function AskBar({ disabled = false }: { disabled?: boolean }) {
>
{SUGGESTION_CHIPS.map((chip) => (
void submitPrompt(chip.prompt)}
className="rounded-lg border px-2.5 py-1 text-xs transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]"
style={{ borderColor: 'var(--border-subtle)', color: 'var(--fg-2)' }}
>
- {chip.label}
+ {t(chip.labelKey)}
))}
@@ -480,12 +484,12 @@ export function AskBar({ disabled = false }: { disabled?: boolean }) {
}}
placeholder={
disabled
- ? 'Chat available after recording'
+ ? t('chat.placeholderRecording')
: hasMessages
- ? 'Continue chat…'
- : 'Ask anything about this meeting…'
+ ? t('chat.placeholderContinue')
+ : t('chat.placeholderAsk')
}
- aria-label="Ask about this meeting"
+ aria-label={t('chat.askAria')}
/>
{/* Send / stop */}
@@ -494,7 +498,7 @@ export function AskBar({ disabled = false }: { disabled?: boolean }) {
type="button"
className="mv-chat-send active"
onClick={stop}
- aria-label="Stop"
+ aria-label={t('chat.stop')}
>
@@ -503,7 +507,7 @@ export function AskBar({ disabled = false }: { disabled?: boolean }) {
type="submit"
className={cn('mv-chat-send', canSend && 'active')}
disabled={!canSend}
- aria-label="Send"
+ aria-label={t('chat.send')}
>
@@ -542,6 +546,7 @@ function ChatHeader({
onNewSession,
onCollapse,
}: ChatHeaderProps) {
+ const { t } = useTranslation();
return (
@@ -555,7 +560,8 @@ function ChatHeader({
style={{ color: 'var(--fg-1)' }}
>
- {session?.name ?? (meetingName ? `Ask about ${meetingName}` : 'Ask AI')}
+ {session?.name ??
+ (meetingName ? t('chat.askAbout', { name: meetingName }) : t('chat.askAi'))}
- New chat
+ {t('chat.newChat')}
@@ -608,6 +614,7 @@ interface SessionDropdownProps {
}
function SessionDropdown({ sessions, activeId, onPick, onDelete }: SessionDropdownProps) {
+ const { t } = useTranslation();
return (
{sessions.length === 0 ? (
-
No saved chats yet.
+
{t('chat.noSavedChats')}
) : (
sessions.map((s) => {
const isActive = s.id === activeId;
@@ -639,7 +646,7 @@ function SessionDropdown({ sessions, activeId, onPick, onDelete }: SessionDropdo
onDelete(s.id)}
- aria-label={`Delete chat ${s.name}`}
+ aria-label={t('chat.deleteChat', { name: s.name })}
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
style={{ color: 'var(--fg-muted)' }}
>
@@ -664,6 +671,7 @@ interface MessageListProps {
}
function MessageList({ messages, liveText, streaming }: MessageListProps) {
+ const { t } = useTranslation();
return (
{messages.map((m, i) => (
@@ -678,7 +686,7 @@ function MessageList({ messages, liveText, streaming }: MessageListProps) {
) : (
-
Thinking
+
{t('chat.thinking')}
@@ -716,10 +724,12 @@ function MessageBubble({ message }: { message: ChatMessage }) {
// Markdown rendering moved to lib/markdown.tsx so the Chat tab can share it.
-const SUGGESTION_CHIPS: { label: string; prompt: string }[] = [
- { label: 'Summarize key decisions', prompt: 'Summarize the key decisions made' },
- { label: 'Action items', prompt: 'What action items were discussed?' },
- { label: 'Main topics', prompt: 'What were the main topics covered?' },
+// `prompt` is what gets sent to the model, so it deliberately stays English —
+// only the visible chip label is translated (same as lib/chatPresets).
+const SUGGESTION_CHIPS: { labelKey: string; prompt: string }[] = [
+ { labelKey: 'chat.chips.keyDecisions', prompt: 'Summarize the key decisions made' },
+ { labelKey: 'chat.chips.actionItems', prompt: 'What action items were discussed?' },
+ { labelKey: 'chat.chips.mainTopics', prompt: 'What were the main topics covered?' },
];
function deriveSessionName(prompt: string): string {
diff --git a/app/renderer/src/components/ChatHistoryRow.tsx b/app/renderer/src/components/ChatHistoryRow.tsx
index 2da6d32f..e5866877 100644
--- a/app/renderer/src/components/ChatHistoryRow.tsx
+++ b/app/renderer/src/components/ChatHistoryRow.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { MessageSquare, MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
import {
Popover,
@@ -46,6 +47,7 @@ export function ChatHistoryRow({
onRename,
onDelete,
}: ChatHistoryRowProps) {
+ const { t } = useTranslation();
const [menuOpen, setMenuOpen] = React.useState(false);
const [renaming, setRenaming] = React.useState(false);
const [draft, setDraft] = React.useState(session.name);
@@ -121,7 +123,7 @@ export function ChatHistoryRow({
onClick={navigateToChat}
className="flex-1 truncate text-left"
>
- {session.name || 'Untitled chat'}
+ {session.name || t('chat.untitled')}
)}
{showTime && !renaming && (
@@ -138,8 +140,8 @@ export function ChatHistoryRow({
e.stopPropagation()}
- aria-label="Chat actions"
- title="Actions"
+ aria-label={t('chat.chatActions')}
+ title={t('app.actions')}
className={cn(
'inline-flex size-6 shrink-0 items-center justify-center rounded transition-opacity hover:bg-[color:var(--surface-active)]',
menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus:opacity-100',
@@ -168,7 +170,7 @@ export function ChatHistoryRow({
style={{ color: 'var(--fg-1)' }}
>
- Rename
+ {t('app.rename')}
- Delete
+ {t('common.delete')}
diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx
index 6eab7dc5..417a9651 100644
--- a/app/renderer/src/components/CommandPalette.tsx
+++ b/app/renderer/src/components/CommandPalette.tsx
@@ -1,4 +1,7 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+import { meetingDisplayTitle } from '@/lib/meetingTitle';
+import type { TFunction } from 'i18next';
import { Search } from 'lucide-react';
import { useMeetings, LIVE_SUMMARY_PREFIX } from '@/hooks/useMeetings';
import { searchNotes, snippet } from '@/lib/noteSearch';
@@ -38,11 +41,6 @@ interface SettingsEntry {
tab: string;
title: string;
sub: string;
- /** Settings that only render on macOS (behind `isMac` in GeneralTab):
- * "Record system audio" and "Hide dock icon". On Windows those rows don't
- * exist, so indexing them would jump to a tab where nothing's there —
- * filtered out below when not on mac (#405). */
- macOnly?: boolean;
}
// Searchable index of the app's settings, mapped to the tab each one lives on
@@ -50,51 +48,64 @@ interface SettingsEntry {
// settings now live on the AI tab, so they map to `ai`. Adapted from @Vassista's
// PR #349 and retargeted to the current tab layout.
//
-// Keep titles in sync with the rendered setting labels (GeneralTab/AiTab/
-// AboutTab etc.) — the T1 spec asserts a few titles still match, to catch the
-// index drifting out from under a renamed control.
-const SETTINGS_INDEX: SettingsEntry[] = [
- { id: 'general-name', tab: 'general', title: 'Your name', sub: 'In-app greeting' },
- { id: 'general-theme', tab: 'general', title: 'Appearance', sub: 'Light, dark, or system theme' },
- { id: 'general-calendar', tab: 'general', title: 'Connect calendar', sub: 'Google, Outlook' },
- { id: 'general-scheduled', tab: 'general', title: 'Scheduled meetings', sub: 'Upcoming calendar events' },
- { id: 'general-autodetect', tab: 'general', title: 'Auto-detected meetings', sub: 'Notify when another app uses the microphone' },
- { id: 'general-notifications', tab: 'general', title: 'Post meeting notifications', sub: 'Desktop notifications when notes are ready' },
- { id: 'general-mic', tab: 'general', title: 'Microphone', sub: 'Input device' },
- { id: 'general-system-audio', tab: 'general', title: 'Record system audio', sub: 'Capture other participants', macOnly: true },
- { id: 'general-silence', tab: 'general', title: 'Auto-stop on silence', sub: 'End a recording when it goes quiet' },
- { id: 'general-launch', tab: 'general', title: 'Launch on login', sub: 'Start Steno automatically' },
+// `key` names the `palette.settings.
` copy block (`.title` + `.sub`); `id`
+// stays a stable, untranslated row identity. Keep the copy in sync with the
+// rendered setting labels (GeneralTab/AiTab/AboutTab etc.) — the T1 spec asserts
+// a few titles still match, to catch the index drifting out from under a
+// renamed control.
+const SETTINGS_ROWS: Array<{
+ id: string;
+ tab: string;
+ key: string;
+ /** Settings that only render on macOS (behind `isMac` in GeneralTab):
+ * "Record system audio" and "Hide dock icon". On Windows those rows don't
+ * exist, so indexing them would jump to a tab where nothing's there —
+ * filtered out below when not on mac (#405). */
+ macOnly?: boolean;
+}> = [
+ { id: 'general-name', tab: 'general', key: 'name' },
+ { id: 'general-theme', tab: 'general', key: 'theme' },
+ { id: 'general-calendar', tab: 'general', key: 'calendar' },
+ { id: 'general-scheduled', tab: 'general', key: 'scheduled' },
+ { id: 'general-autodetect', tab: 'general', key: 'autoDetect' },
+ { id: 'general-notifications', tab: 'general', key: 'notifications' },
+ { id: 'general-mic', tab: 'general', key: 'microphone' },
+ { id: 'general-system-audio', tab: 'general', key: 'systemAudio', macOnly: true },
+ { id: 'general-silence', tab: 'general', key: 'silence' },
+ { id: 'general-launch', tab: 'general', key: 'launch' },
// Cross-platform row (Electron's Tray covers the macOS menu bar and the
- // Windows system tray); its rendered label switches on platform, so mirror
- // that here so the title matches whatever GeneralTab shows.
- {
- id: 'general-menubar',
- tab: 'general',
- title: isMac ? 'Show in menu bar' : 'Show in system tray',
- sub: 'Quick-access icon in the menu bar or system tray',
- },
- { id: 'general-dock', tab: 'general', title: 'Hide dock icon', sub: 'Menu bar / tray icon only', macOnly: true },
- { id: 'ai-language', tab: 'ai', title: 'Language', sub: 'Transcription and summary language' },
- { id: 'ai-transcription', tab: 'ai', title: 'Transcription model', sub: 'Parakeet or Whisper' },
- { id: 'ai-save-recordings', tab: 'ai', title: 'Save recordings', sub: 'Keep the audio files after transcription' },
- { id: 'ai-autonotes', tab: 'ai', title: 'Generate notes automatically', sub: 'Summarise after transcription' },
- { id: 'ai-provider', tab: 'ai', title: 'AI provider', sub: 'Local, private server, cloud, or organisation' },
- { id: 'templates', tab: 'templates', title: 'Templates', sub: 'Custom note formats' },
- { id: 'org', tab: 'organisation', title: 'Organisation', sub: 'Sign in and back up notes to your org' },
- { id: 'advanced-storage', tab: 'advanced', title: 'Storage location', sub: 'Where notes and recordings are saved' },
- { id: 'advanced-setup', tab: 'advanced', title: 'Setup wizard', sub: 'Re-run first-time setup' },
- { id: 'advanced-clear', tab: 'advanced', title: 'Clear recording state', sub: 'Reset a stuck recording' },
- { id: 'advanced-analytics', tab: 'advanced', title: 'Anonymous usage analytics', sub: 'Opt in or out' },
- { id: 'developer', tab: 'developer', title: 'Developer', sub: 'Diagnostics and logs' },
- { id: 'about', tab: 'about', title: 'About', sub: 'Version, release notes, check for updates' },
- { id: 'about-discord', tab: 'about', title: 'Discord', sub: 'Join the community, ask questions, share feedback' },
+ // Windows system tray); its rendered label switches on platform, so the
+ // title key below does the same so it matches whatever GeneralTab shows.
+ { id: 'general-menubar', tab: 'general', key: isMac ? 'menuBar' : 'systemTray' },
+ { id: 'general-dock', tab: 'general', key: 'dockIcon', macOnly: true },
+ { id: 'ai-language', tab: 'ai', key: 'language' },
+ { id: 'ai-transcription', tab: 'ai', key: 'transcriptionModel' },
+ { id: 'ai-save-recordings', tab: 'ai', key: 'saveRecordings' },
+ { id: 'ai-autonotes', tab: 'ai', key: 'autoNotes' },
+ { id: 'ai-provider', tab: 'ai', key: 'aiProvider' },
+ { id: 'templates', tab: 'templates', key: 'templates' },
+ { id: 'org', tab: 'organisation', key: 'organisation' },
+ { id: 'advanced-storage', tab: 'advanced', key: 'storage' },
+ { id: 'advanced-setup', tab: 'advanced', key: 'setupWizard' },
+ { id: 'advanced-clear', tab: 'advanced', key: 'clearRecordingState' },
+ { id: 'advanced-analytics', tab: 'advanced', key: 'analytics' },
+ { id: 'developer', tab: 'developer', key: 'developer' },
+ { id: 'about', tab: 'about', key: 'about' },
+ { id: 'about-discord', tab: 'about', key: 'discord' },
];
// Only the settings that actually render on this platform. macOS-only rows
// ("Record system audio", "Hide dock icon") don't exist on Windows/Linux, so
// they're dropped from the index there — otherwise selecting one would jump to
// a tab where the row isn't shown (#405).
-const AVAILABLE_SETTINGS = SETTINGS_INDEX.filter((s) => !s.macOnly || isMac);
+function buildSettingsIndex(t: TFunction): SettingsEntry[] {
+ return SETTINGS_ROWS.filter((s) => !s.macOnly || isMac).map((s) => ({
+ id: s.id,
+ tab: s.tab,
+ title: t(`palette.settings.${s.key}.title`),
+ sub: t(`palette.settings.${s.key}.sub`),
+ }));
+}
/**
* Global ⌘K search. Provides `open()` to descendants (the sidebar trigger) and
@@ -113,6 +124,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode
}
function CommandPalette({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
// Context-aware: while the Settings page is open, ⌘K searches settings and
// jumps to the tab each one lives on; everywhere else it searches notes.
const currentRoute = useRoute();
@@ -146,14 +158,16 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
return () => prev?.focus?.();
}, []);
+ const availableSettings = React.useMemo(() => buildSettingsIndex(t), [t]);
+
const settingsResults = React.useMemo(() => {
if (!isSettingsMode) return [];
- if (!query.trim()) return AVAILABLE_SETTINGS;
+ if (!query.trim()) return availableSettings;
const q = query.trim().toLowerCase();
- return AVAILABLE_SETTINGS.filter(
+ return availableSettings.filter(
(s) => s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q),
);
- }, [isSettingsMode, query]);
+ }, [isSettingsMode, query, availableSettings]);
const noteResults = React.useMemo(() => {
if (isSettingsMode) return [];
@@ -230,7 +244,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
e.stopPropagation()}
@@ -246,8 +260,10 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
data-testid="command-palette-input"
className="w-full bg-transparent text-[14px] outline-none"
style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}
- placeholder={isSettingsMode ? 'Search settings…' : 'Search notes…'}
- aria-label={isSettingsMode ? 'Search settings' : 'Search notes'}
+ placeholder={
+ isSettingsMode ? t('palette.searchSettingsPlaceholder') : t('palette.searchNotesPlaceholder')
+ }
+ aria-label={isSettingsMode ? t('palette.searchSettings') : t('palette.searchNotes')}
role="combobox"
aria-expanded="true"
aria-controls="cmdk-listbox"
@@ -264,7 +280,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
ref={listRef}
id="cmdk-listbox"
role="listbox"
- aria-label="Search results"
+ aria-label={t('palette.results')}
className="scrollbar-clean max-h-[50vh] overflow-auto py-1"
>
{resultCount === 0 ? (
@@ -273,10 +289,12 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
style={{ color: 'var(--fg-muted)' }}
>
{query.trim()
- ? `No ${isSettingsMode ? 'settings' : 'notes'} match “${query.trim()}”`
+ ? isSettingsMode
+ ? t('palette.noSettingsMatch', { query: query.trim() })
+ : t('palette.noNotesMatch', { query: query.trim() })
: isSettingsMode
- ? 'No settings'
- : 'No notes yet'}
+ ? t('palette.noSettings')
+ : t('palette.noNotes')}
) : isSettingsMode ? (
settingsResults.map((s, i) => (
@@ -305,7 +323,8 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
))
) : (
noteResults.map((m, i) => {
- const title = m.session_info.name || 'Untitled Meeting';
+ const title =
+ meetingDisplayTitle(m.session_info.name) || t('palette.untitledMeeting');
const sub = snippet(m.summary, query);
return (
void }) {
fontFamily: 'var(--font-sans)',
}}
>
- ↑↓ navigate
- ↵ open
- esc close
+ {t('palette.hintNavigate')}
+ {t('palette.hintOpen')}
+ {t('palette.hintClose')}
diff --git a/app/renderer/src/components/FolderScopePicker.tsx b/app/renderer/src/components/FolderScopePicker.tsx
index 3dcc5823..5c5dfdc9 100644
--- a/app/renderer/src/components/FolderScopePicker.tsx
+++ b/app/renderer/src/components/FolderScopePicker.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { ChevronDown, Folder as FolderIcon, Globe, Inbox } from 'lucide-react';
import {
Popover,
@@ -27,6 +28,7 @@ interface FolderScopePickerProps {
* passes it to startGlobalStream.
*/
export function FolderScopePicker({ value, onChange }: FolderScopePickerProps) {
+ const { t } = useTranslation();
const folders = useFolders();
const orgSession = useOrgSession();
const orgSignedIn = orgSession.data?.signedIn ?? false;
@@ -57,15 +59,15 @@ export function FolderScopePicker({ value, onChange }: FolderScopePickerProps) {
}, [value, folders.data, folder, orgSessionSettled, orgSignedIn, onChange]);
const isOrg = value === ORG_SHARED_SCOPE;
- const label = isOrg ? 'Shared notes' : folder ? folder.name : 'All notes';
+ const label = isOrg ? t('nav.sharedNotes') : folder ? folder.name : t('nav.allNotes');
return (
@@ -82,7 +84,7 @@ export function FolderScopePicker({ value, onChange }: FolderScopePickerProps) {
- Ask across…
+ {t('chat.askAcross')}
- All notes
+ {t('nav.allNotes')}
{orgSignedIn && (
- Shared notes
+ {t('nav.sharedNotes')}
)}
{(folders.data ?? []).length > 0 && (
diff --git a/app/renderer/src/components/IconPicker.tsx b/app/renderer/src/components/IconPicker.tsx
index 4bbead0b..5ce66111 100644
--- a/app/renderer/src/components/IconPicker.tsx
+++ b/app/renderer/src/components/IconPicker.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import * as ReactDOM from 'react-dom';
import * as LucideIcons from 'lucide-react';
import { Search, X } from 'lucide-react';
@@ -185,6 +186,7 @@ interface IconPickerProps {
}
export function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) {
+ const { t } = useTranslation();
const [query, setQuery] = React.useState('');
const inputRef = React.useRef(null);
const panelRef = React.useRef(null);
@@ -279,7 +281,7 @@ export function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) {
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
- placeholder="Search icons…"
+ placeholder={t('folders.searchIcons')}
style={{
flex: 1,
background: 'transparent',
@@ -324,7 +326,7 @@ export function IconPicker({ anchorRect, onSelect, onClose }: IconPickerProps) {
fontFamily: 'var(--font-sans)',
}}
>
- No icons match "{query}"
+ {t('folders.noIconsMatch', { query })}
) : (
)}
- {isRecording ? 'Stop recording to import' : 'Drop audio to import'}
+ {isRecording ? t('app.dropBlocked') : t('app.dropToImport')}
diff --git a/app/renderer/src/components/LiveDock.tsx b/app/renderer/src/components/LiveDock.tsx
index bd94d825..d9d4322d 100644
--- a/app/renderer/src/components/LiveDock.tsx
+++ b/app/renderer/src/components/LiveDock.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { ChevronUp, Play, Square } from 'lucide-react';
import { AudioWave } from '@/components/AudioWave';
import { useRecording } from '@/hooks/useRecording';
@@ -20,6 +21,7 @@ import { useLiveTranscriptAvailable } from '@/hooks/useModels';
* drop) — without it an auto-paused recording would be stranded.
*/
export function LiveDock() {
+ const { t } = useTranslation();
const recording = useRecording();
const liveAvailable = useLiveTranscriptAvailable();
const transcriptOpen = useLiveTranscriptOpen((s) => s.open);
@@ -52,8 +54,8 @@ export function LiveDock() {
}, [loadingModel]);
const prepareLabel = showPreparing
? live.slow
- ? 'Still preparing…'
- : 'Preparing…'
+ ? t('dock.stillPreparing')
+ : t('dock.preparingShort')
: null;
const onResume = () => {
@@ -76,7 +78,7 @@ export function LiveDock() {
>
@@ -122,9 +124,9 @@ export function LiveDock() {
type="button"
onClick={toggleTranscript}
disabled={stopped}
- aria-label={transcriptOpen ? 'Hide transcript' : 'Show transcript'}
+ aria-label={transcriptOpen ? t('dock.hideTranscript') : t('dock.showTranscript')}
aria-pressed={transcriptOpen}
- title={transcriptOpen ? 'Hide transcript' : 'Show transcript'}
+ title={transcriptOpen ? t('dock.hideTranscript') : t('dock.showTranscript')}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border-0 transition-colors hover:bg-[color:var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'transparent', color: 'var(--fg-1)' }}
>
@@ -135,8 +137,8 @@ export function LiveDock() {
type="button"
onClick={onStop}
disabled={stopped}
- aria-label="Stop recording"
- title="Stop recording"
+ aria-label={t('dock.stopRecording')}
+ title={t('dock.stopRecording')}
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-full border-0 transition-colors hover:bg-[color:var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: 'transparent', color: 'var(--recording)' }}
>
diff --git a/app/renderer/src/components/LiveTranscriptBar.tsx b/app/renderer/src/components/LiveTranscriptBar.tsx
index 1d126330..65e5e614 100644
--- a/app/renderer/src/components/LiveTranscriptBar.tsx
+++ b/app/renderer/src/components/LiveTranscriptBar.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, Copy, Play, Search as SearchIcon, Square } from 'lucide-react';
import { AudioWave } from '@/components/AudioWave';
import { Input } from '@/components/ui/input';
@@ -7,7 +8,7 @@ import { cn } from '@/lib/utils';
import { useLiveTranscript } from '@/hooks/useLiveTranscript';
import { useRecording } from '@/hooks/useRecording';
import { useLanguageSetting, useSetLanguage } from '@/hooks/useSettings';
-import { PARAKEET_LANGUAGES } from '@/lib/transcription-languages';
+import { PARAKEET_LANGUAGES, languageHint, languageLabel } from '@/lib/transcription-languages';
import { useLiveTranscriptOpen } from '@/hooks/liveTranscriptOpenStore';
import { formatElapsed } from '@/lib/utils';
@@ -39,6 +40,7 @@ function fmtTimestamp(seconds: number): string {
* Closing returns to the standard LiveDock pill.
*/
export function LiveTranscriptBar() {
+ const { t } = useTranslation();
const recording = useRecording();
const sessionName = recording.sessionName;
const paused = recording.status === 'paused';
@@ -120,7 +122,7 @@ export function LiveTranscriptBar() {
action button, not nested. (Nesting `` inside ``
is invalid HTML and breaks both keyboard navigation and
assistive-tech focus order.) */}
-
+
{/* Static (non-animated) wave for the header — the "is anything
happening?" cue lives in the footer's recording indicator. */}
@@ -132,13 +134,13 @@ export function LiveTranscriptBar() {
- Transcript
+ {t('dock.transcript')}
void copyAll()}
- aria-label="Copy transcript"
- title="Copy transcript"
+ aria-label={t('dock.copyTranscript')}
+ title={t('dock.copyTranscript')}
>
{copied ? : }
@@ -146,8 +148,8 @@ export function LiveTranscriptBar() {
type="button"
className="mv-chat-tool"
onClick={() => setOpen(false)}
- aria-label="Minimize transcript"
- title="Minimize transcript"
+ aria-label={t('dock.minimizeTranscript')}
+ title={t('dock.minimizeTranscript')}
>
@@ -162,7 +164,7 @@ export function LiveTranscriptBar() {
variant="sunken"
size="sm"
iconStart={ }
- placeholder="Search transcript"
+ placeholder={t('dock.searchTranscript')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
@@ -204,8 +206,8 @@ export function LiveTranscriptBar() {
@@ -215,13 +217,13 @@ export function LiveTranscriptBar() {
- Stop
+ {t('dock.stop')}
@@ -255,35 +257,32 @@ function LiveTranscriptBodyState({
slow,
dividerAfter,
}: BodyStateProps) {
+ const { t } = useTranslation();
if (status === 'error' && error) {
return (
);
}
if (status === 'loading') {
return (
);
}
if (segments.length === 0) {
return (
);
}
@@ -312,7 +311,7 @@ function LiveTranscriptBodyState({
className="text-[10px] font-medium uppercase tracking-wide"
style={{ color: 'var(--fg-2)' }}
>
- Resumed
+ {t('dock.resumed')}
@@ -376,6 +375,7 @@ interface LanguageOption {
const LANGUAGE_OPTIONS: LanguageOption[] = PARAKEET_LANGUAGES.map((l) => ({ ...l }));
function LanguageSelector() {
+ const { t } = useTranslation();
const language = useLanguageSetting();
const setLanguage = useSetLanguage();
const [popoverOpen, setPopoverOpen] = React.useState(false);
@@ -385,7 +385,12 @@ function LanguageSelector() {
// Concrete pins show their name; 'auto' shows the compact "Multi". An
// out-of-list pin (e.g. a Whisper-only language set in Settings) shows its
// code rather than being mislabelled "Multi" and silently reset.
- const display = current === 'auto' ? 'Multi' : (selected?.label ?? current.toUpperCase());
+ const display =
+ current === 'auto'
+ ? t('dock.languageMulti')
+ : selected
+ ? languageLabel(selected.code, selected.label, 'multi')
+ : current.toUpperCase();
const pick = (code: string) => {
setLanguage.mutate(code);
@@ -402,8 +407,8 @@ function LanguageSelector() {
'cursor-pointer transition-colors hover:bg-[color:var(--surface-hover)]'
)}
style={{ color: 'var(--fg-2)' }}
- aria-label={`Language: ${display}`}
- title="Change transcript language"
+ aria-label={t('dock.languageAria', { language: display })}
+ title={t('dock.changeLanguage')}
>
{display}
@@ -426,11 +431,11 @@ function LanguageSelector() {
className="flex w-full items-center justify-between text-[13px] font-medium"
style={{ color: 'var(--fg-1)' }}
>
- {opt.label}
+ {languageLabel(opt.code, opt.label, 'multi')}
{active &&
}
- {opt.hint}
+ {languageHint(opt.code, opt.hint)}
);
@@ -451,7 +456,8 @@ function RecordingStatusChip({
paused: boolean;
elapsedSeconds: number;
}) {
- const label = paused ? 'Paused' : 'Recording';
+ const { t } = useTranslation();
+ const label = paused ? t('dock.paused') : t('dock.recording');
return (
{isRecording ? (
@@ -145,17 +147,17 @@ export function MainToolbar({
>
{formatElapsed(elapsedSeconds)}
-
{isPaused ? 'Paused' : 'Recording'}
+
{isPaused ? t('toolbar.paused') : t('toolbar.recording')}
>
) : showChatPrimary ? (
<>
- New chat
+ {t('toolbar.newChat')}
>
) : (
<>
- New note
+ {t('toolbar.newNote')}
>
)}
@@ -172,6 +174,7 @@ function RecordingOptionsPopover({
importAudio: UseMutationResult
;
disabled: boolean;
}) {
+ const { t } = useTranslation();
const systemAudio = useSystemAudioSetting();
const setSystemAudio = useSetSystemAudio();
const systemAudioSupport = useSystemAudioSupport();
@@ -194,8 +197,8 @@ function RecordingOptionsPopover({
variant="ghost"
size="icon"
className="size-8"
- aria-label="Recording options"
- title="Recording options"
+ aria-label={t('toolbar.recordingOptions')}
+ title={t('toolbar.recordingOptions')}
>
@@ -203,9 +206,9 @@ function RecordingOptionsPopover({
-
Recording options
+
{t('toolbar.recordingOptions')}
- Deep links and the tray menu also start and stop recording.
+ {t('toolbar.recordingOptionsHint')}
@@ -221,7 +224,7 @@ function RecordingOptionsPopover({
htmlFor="maintoolbar-system-audio"
className="text-sm font-medium"
>
- Record system audio
+ {t('toolbar.recordSystemAudio')}
- Capture both sides of calls. Turn off to record your mic only.
+ {t('toolbar.recordSystemAudioHint')}
@@ -249,11 +252,11 @@ function RecordingOptionsPopover({
>
-
Import audio file…
+
{t('toolbar.importAudio')}
{disabled
- ? 'Stop the current recording to import a file.'
- : 'Transcribe and summarise an existing recording. It will appear in the list while it processes.'}
+ ? t('toolbar.importAudioBlocked')
+ : t('toolbar.importAudioHint')}
diff --git a/app/renderer/src/components/MeetingsShell.tsx b/app/renderer/src/components/MeetingsShell.tsx
index 41fd4e04..8e3387d4 100644
--- a/app/renderer/src/components/MeetingsShell.tsx
+++ b/app/renderer/src/components/MeetingsShell.tsx
@@ -1,4 +1,6 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+import { meetingDisplayTitle } from '@/lib/meetingTitle';
import { AppShell } from '@/components/AppShell';
import {
Sidebar,
@@ -32,6 +34,7 @@ import {
} from '@/hooks/useFolders';
import { useRecording } from '@/hooks/useRecording';
import { navigate, useRoute } from '@/lib/router';
+import i18n from '@/lib/i18n';
import type { Meeting } from '@/lib/ipc';
interface MeetingsShellProps {
@@ -60,6 +63,7 @@ export function MeetingsShell({
bleed = false,
children,
}: MeetingsShellProps) {
+ const { t } = useTranslation();
const meetings = useMeetings();
const folders = useFolders();
const recording = useRecording();
@@ -245,19 +249,13 @@ export function MeetingsShell({
!o && setDeleteTarget(null)}
- title={deleteTarget ? `Delete folder "${deleteTarget.name}"?` : ''}
+ title={deleteTarget ? t('folders.deleteTitle', { name: deleteTarget.name }) : ''}
description={
- deleteTarget && deleteTarget.meetingCount > 0 ? (
- <>
- {deleteTarget.meetingCount} meeting
- {deleteTarget.meetingCount === 1 ? '' : 's'} will be moved back to All Notes. No
- recordings or transcripts will be deleted.
- >
- ) : (
- <>No recordings or transcripts will be deleted.>
- )
+ deleteTarget && deleteTarget.meetingCount > 0
+ ? t('folders.deleteWithMeetings', { count: deleteTarget.meetingCount })
+ : t('folders.deleteNoMeetings')
}
- confirmLabel="Delete"
+ confirmLabel={t('common.delete')}
destructive
onConfirm={handleConfirmDeleteFolder}
isPending={deleteFolder.isPending}
@@ -266,15 +264,15 @@ export function MeetingsShell({
- New folder
+ {t('folders.newFolder')}
- Group related meetings together. Folder names are only visible to you.
+ {t('folders.newFolderDescription')}
setNewFolderName(e.target.value)}
- placeholder="e.g. Acme Corp"
+ placeholder={t('folders.namePlaceholder')}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') void handleCreateFolder();
@@ -282,9 +280,9 @@ export function MeetingsShell({
/>
- Cancel
+ {t('common.cancel')}
- void handleCreateFolder()}>Create folder
+ void handleCreateFolder()}>{t('folders.create')}
@@ -327,6 +325,7 @@ function ContextMenu({
folders,
meetings,
}: ContextMenuProps) {
+ const { t } = useTranslation();
const ref = React.useRef(null);
React.useEffect(() => {
const onDoc = (e: MouseEvent) => {
@@ -360,14 +359,14 @@ function ContextMenu({
className="flex w-full items-center rounded px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={() => onRename(currentLabel)}
>
- Rename
+ {t('app.rename')}
- Delete
+ {t('common.delete')}
);
@@ -482,7 +481,7 @@ function buildSidebar({ meetings, folders, search, activeSummaryFile }: BuildArg
function meetingToSidebar(meeting: Meeting, activeSummaryFile: string | null): SidebarMeeting {
return {
summaryFile: meeting.session_info.summary_file,
- title: meeting.session_info.name,
+ title: meetingDisplayTitle(meeting.session_info.name),
dateLabel: formatDateLabel(meeting.session_info),
active: meeting.session_info.summary_file === activeSummaryFile,
};
@@ -498,14 +497,14 @@ export function formatDateLabel(info: Meeting['session_info']): string | undefin
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
- if (sameDay) return 'Today';
+ if (sameDay) return i18n.t('app.date.today');
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const wasYesterday =
d.getFullYear() === yesterday.getFullYear() &&
d.getMonth() === yesterday.getMonth() &&
d.getDate() === yesterday.getDate();
- if (wasYesterday) return 'Yesterday';
+ if (wasYesterday) return i18n.t('app.date.yesterday');
if (now.getTime() - d.getTime() < 7 * 24 * 60 * 60 * 1000) {
return d.toLocaleDateString(undefined, { weekday: 'short' });
}
diff --git a/app/renderer/src/components/NotificationToast.tsx b/app/renderer/src/components/NotificationToast.tsx
index e841ce1c..f79865d5 100644
--- a/app/renderer/src/components/NotificationToast.tsx
+++ b/app/renderer/src/components/NotificationToast.tsx
@@ -1,6 +1,8 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { ipc } from '@/lib/ipc';
import { useTheme } from '@/hooks/useTheme';
+import { useUiLanguageSync } from '@/hooks/useUiLanguageSync';
import { AppIcon } from '@/components/ui/app-icon';
import { AlertCircle, CheckCircle2, Mic, Info } from 'lucide-react';
@@ -54,7 +56,13 @@ export function notificationIconMeta(
}
export function NotificationToast() {
+ const { t } = useTranslation();
useTheme();
+ // This toast is its own window, mounted straight from main.tsx without the
+ // App tree, so it does not inherit App's language sync. Without this a toast
+ // already on screen when the user switches language would keep its old
+ // strings until it expired.
+ useUiLanguageSync();
const [data, setData] = React.useState(null);
React.useLayoutEffect(() => {
@@ -138,7 +146,7 @@ export function NotificationToast() {
@@ -181,7 +189,7 @@ export function NotificationToast() {
className="flex items-center gap-2 rounded-[10px] border border-gray-200 bg-white px-3 py-1.5 text-[13px] font-medium text-gray-900 transition-all hover:bg-gray-50 hover:shadow-sm active:bg-gray-100 active:scale-[0.98] shrink-0 dark:border-white/10 dark:bg-[#2C2C2E] dark:text-gray-100 dark:hover:bg-[#3C3C3E] dark:active:bg-[#1C1C1E]"
>
- Join & take notes
+ {t('toast.notification.join')}
) : null
) : (
diff --git a/app/renderer/src/components/PrivacyConsentModal.tsx b/app/renderer/src/components/PrivacyConsentModal.tsx
index 8a934187..b7a03f5b 100644
--- a/app/renderer/src/components/PrivacyConsentModal.tsx
+++ b/app/renderer/src/components/PrivacyConsentModal.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import {
Dialog,
@@ -32,6 +33,7 @@ import {
* onboarding isn't disclosed to twice).
*/
export function PrivacyConsentModal({ open }: { open: boolean }) {
+ const { t } = useTranslation();
const { mutateAsync: markNoticeSeen } = useMarkPrivacyNoticeSeen();
const telemetry = useTelemetrySetting();
@@ -69,38 +71,33 @@ export function PrivacyConsentModal({ open }: { open: boolean }) {
>
- A quick note on privacy
-
- To help find and fix failures, Steno sends anonymous usage data —
- never your recordings, transcripts, or notes. Steno also starts
- automatically when you log in. Both are on by default and you can
- change either one anytime in Settings.
-
+ {t('privacy.title')}
+ {t('privacy.description')}
setTelemetry.mutate({ enabled: v, source: 'consent' })}
disabled={telemetry.data === undefined}
- aria-label="Anonymous usage data"
+ aria-label={t('privacy.telemetry.label')}
data-privacy-telemetry
/>
setLaunchOnLogin.mutate(v)}
disabled={launchOnLogin.data === undefined}
- aria-label="Launch on login"
+ aria-label={t('privacy.launch.label')}
data-privacy-launch
/>
@@ -108,7 +105,7 @@ export function PrivacyConsentModal({ open }: { open: boolean }) {
void acknowledge()} data-privacy-ack>
- Got it
+ {t('privacy.acknowledge')}
diff --git a/app/renderer/src/components/QuitDialog.tsx b/app/renderer/src/components/QuitDialog.tsx
index 3db1a335..6e01cd96 100644
--- a/app/renderer/src/components/QuitDialog.tsx
+++ b/app/renderer/src/components/QuitDialog.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
import { CircleAlert } from 'lucide-react';
import { ipc } from '@/lib/ipc';
@@ -9,6 +10,7 @@ interface DialogState {
}
export function QuitDialog() {
+ const { t } = useTranslation();
const [mounted, setMounted] = React.useState(false);
const [visible, setVisible] = React.useState(false);
const [state, setState] = React.useState
({ type: 'recording' });
@@ -48,12 +50,17 @@ export function QuitDialog() {
if (!host) return null;
const isRecording = state.type === 'recording';
- const title = isRecording ? 'Recording in progress' : 'Processing in progress';
const count = state.jobCount ?? 1;
+ // Each plural form is one whole sentence in the catalogue — the English copy
+ // switches the verb ("is"/"are") along with the noun, and other languages
+ // rearrange more than that.
+ const title = isRecording ? t('quit.recording.title') : t('quit.processing.title');
const body = isRecording
- ? 'Quitting will stop and save the current recording.'
- : `${count} recording${count !== 1 ? 's are' : ' is'} still being processed. Quitting will cancel processing.`;
- const confirmLabel = isRecording ? 'Stop & quit' : 'Quit anyway';
+ ? t('quit.recording.body')
+ : t('quit.processing.body', { count });
+ const confirmLabel = isRecording
+ ? t('quit.recording.confirm')
+ : t('quit.processing.confirm');
return createPortal(
void }) {
+ const { t } = useTranslation();
const [hovered, setHovered] = React.useState(false);
return (
void }) {
transition: 'background 120ms cubic-bezier(0.2,0,0,1)',
}}
>
- Cancel
+ {t('common.cancel')}
);
}
diff --git a/app/renderer/src/components/Sidebar.tsx b/app/renderer/src/components/Sidebar.tsx
index e58c2015..21d8a19f 100644
--- a/app/renderer/src/components/Sidebar.tsx
+++ b/app/renderer/src/components/Sidebar.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import {
ChevronDown,
Globe,
@@ -160,6 +161,7 @@ export function Sidebar({
onContextAction,
currentRoute,
}: SidebarProps) {
+ const { t } = useTranslation();
const palette = useCommandPalette();
const [foldersOpen, setFoldersOpen] = React.useState(true);
const [dragOverFolder, setDragOverFolder] = React.useState
(null);
@@ -321,9 +323,9 @@ export function Sidebar({
onClick={() => palette.open()}
className="flex h-[30px] w-full items-center rounded-md border-0 px-[10px] pl-[30px] text-left text-[13px] outline-none transition-colors hover:shadow-[inset_0_0_0_1px_hsl(var(--border))] focus-visible:shadow-[inset_0_0_0_1px_hsl(var(--border))]"
style={{ background: 'rgba(27,27,25,0.04)', color: 'var(--fg-muted)', fontFamily: 'var(--font-sans)' }}
- aria-label="Search notes"
+ aria-label={t('nav.searchNotes')}
>
- Search
+ {t('nav.search')}
navigate('/')}
>
- Home
+ {t('nav.home')}
navigate('/meetings')}
>
-
All notes
+
{t('nav.allNotes')}
{totalMeetings > 0 && (
{totalMeetings}
@@ -383,7 +385,7 @@ export function Sidebar({
onClick={() => navigate('/chat')}
>
- Chat
+ {t('nav.chat')}
{sharedNotes.enabled && (
@@ -391,10 +393,12 @@ export function Sidebar({
type="button"
className={cn('sb-row', isOrgSharedActive && 'active')}
onClick={() => navigate('/org/shared')}
- title={`Shared across ${orgSession.data?.orgId ?? 'your org'}`}
+ title={t('nav.sharedAcross', {
+ org: orgSession.data?.orgId ?? t('org.yourOrg'),
+ })}
>
- Shared notes
+ {t('nav.sharedNotes')}
)}
@@ -407,13 +411,13 @@ export function Sidebar({
>
- Folders
+ {t('nav.folders')}
{ e.stopPropagation(); onNewFolder(); }}
- aria-label="New folder"
+ aria-label={t('folders.newFolder')}
style={{ color: 'var(--fg-2)' }}
>
@@ -452,7 +456,7 @@ export function Sidebar({
{
@@ -503,10 +507,10 @@ export function Sidebar({
}}
className="inline-flex h-[26px] min-w-0 items-center gap-1.5 rounded-md px-2 text-[12px] transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ color: 'var(--fg-1)' }}
- title="Sign in to share notes with your organisation"
+ title={t('org.signInHint')}
>
- Sign in to org
+ {t('org.signIn')}
) : (
@@ -519,8 +523,8 @@ export function Sidebar({
void ipc().shell.openExternal('https://docs.stenoai.co')}
- aria-label="Help"
- title="Help"
+ aria-label={t('nav.help')}
+ title={t('nav.help')}
className="inline-flex h-[26px] w-7 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]"
style={{ color: 'var(--fg-2)' }}
>
@@ -529,8 +533,8 @@ export function Sidebar({
toggleSettings(currentRoute)}
- aria-label="Settings"
- title="Settings"
+ aria-label={t('nav.settings')}
+ title={t('nav.settings')}
// startsWith so the cog still reads "active" on deep-link routes
// like /settings?tab=organisation, not just bare /settings.
aria-pressed={currentRoute.startsWith('/settings')}
@@ -594,6 +598,7 @@ interface ProfileChipProps {
}
function ProfileChip({ email, name, orgId, onSignOut }: ProfileChipProps) {
+ const { t } = useTranslation();
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
@@ -650,7 +655,7 @@ function ProfileChip({ email, name, orgId, onSignOut }: ProfileChipProps) {
{email}
- org · {orgId}
+ {t('org.orgLabel')} · {orgId}
@@ -664,7 +669,7 @@ function ProfileChip({ email, name, orgId, onSignOut }: ProfileChipProps) {
onSignOut();
}}
>
- Sign out
+ {t('org.signOut')}
diff --git a/app/renderer/src/components/TranscriptPanel.tsx b/app/renderer/src/components/TranscriptPanel.tsx
index 1bfe659f..e0936536 100644
--- a/app/renderer/src/components/TranscriptPanel.tsx
+++ b/app/renderer/src/components/TranscriptPanel.tsx
@@ -1,6 +1,7 @@
import * as React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Search as SearchIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { useMeeting } from '@/hooks/useMeetings';
@@ -14,13 +15,14 @@ export function TranscriptPanelContent({
summaryFile: string;
onClose?: () => void;
}) {
+ const { t } = useTranslation();
const meeting = useMeeting(summaryFile);
if (meeting.isLoading) {
- return Loading…
;
+ return {t('transcript.loading')}
;
}
if (!meeting.data) {
- return No transcript available.
;
+ return {t('transcript.empty')}
;
}
const m = meeting.data;
const isDiarised = !!(m.is_diarised && m.diarised_text);
@@ -34,8 +36,9 @@ export function TranscriptPanelContent({
* doesn't currently round-trip an explicit flag (avoids another
* enterprise schema change). */
export function OrgTranscriptPanelContent({ transcript }: { transcript: string }) {
+ const { t } = useTranslation();
if (!transcript.trim()) {
- return No transcript available.
;
+ return {t('transcript.empty')}
;
}
const isDiarised = /\[(?:You|Others)\]/.test(transcript);
return ;
@@ -43,6 +46,7 @@ export function OrgTranscriptPanelContent({ transcript }: { transcript: string }
function TranscriptBody({ text, isDiarised }: { text: string; isDiarised: boolean }) {
+ const { t } = useTranslation();
const segments = React.useMemo(() => parseTranscript(text, isDiarised), [text, isDiarised]);
const [query, setQuery] = React.useState('');
@@ -71,9 +75,7 @@ function TranscriptBody({ text, isDiarised }: { text: string; isDiarised: boolea
});
if (segments.length === 0) {
- return (
- No transcript available.
- );
+ return {t('transcript.empty')}
;
}
return (
@@ -83,7 +85,7 @@ function TranscriptBody({ text, isDiarised }: { text: string; isDiarised: boolea
variant="sunken"
size="sm"
iconStart={ }
- placeholder="Search transcript"
+ placeholder={t('transcript.searchPlaceholder')}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="flex-1"
diff --git a/app/renderer/src/components/UndoDeleteToast.tsx b/app/renderer/src/components/UndoDeleteToast.tsx
index 51595398..33b7685d 100644
--- a/app/renderer/src/components/UndoDeleteToast.tsx
+++ b/app/renderer/src/components/UndoDeleteToast.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
import { Trash2, X } from 'lucide-react';
import { ipc } from '@/lib/ipc';
import { useUndoDeleteStore, type UndoDeleteEntry } from '@/hooks/undoDeleteStore';
@@ -38,6 +39,7 @@ function UndoDeleteToastItem({
onUndo: () => void;
onExpire: () => void;
}) {
+ const { t } = useTranslation();
// Keep the expire callback in a ref so the auto-dismiss timer runs exactly once
// and isn't reset by re-renders (it must expire relative to main's deadline,
// not the last render).
@@ -92,7 +94,7 @@ function UndoDeleteToastItem({
-
Note deleted
+
{t('toast.undoDelete.title')}
{name && (
{name}
@@ -105,12 +107,12 @@ function UndoDeleteToastItem({
className="cursor-pointer rounded-full border-0 px-2.5 py-1 text-[12px] font-medium"
style={{ background: 'var(--fg-1)', color: 'var(--fg-inverse)' }}
>
- Undo
+ {t('toast.undoDelete.undo')}
diff --git a/app/renderer/src/components/UpdateToast.tsx b/app/renderer/src/components/UpdateToast.tsx
index 5fdbe0b2..892f999f 100644
--- a/app/renderer/src/components/UpdateToast.tsx
+++ b/app/renderer/src/components/UpdateToast.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { ArrowDownToLine, X } from 'lucide-react';
import { ipc } from '@/lib/ipc';
@@ -11,6 +12,7 @@ import { ipc } from '@/lib/ipc';
* version is still pending.
*/
export function UpdateToast() {
+ const { t } = useTranslation();
const [pendingVersion, setPendingVersion] = React.useState(null);
const [dismissed, setDismissed] = React.useState(false);
@@ -45,8 +47,11 @@ export function UpdateToast() {
aria-live="polite"
>
-
- Update v{pendingVersion} ready
+ {/* tabular-nums sits on the whole sentence rather than a nested span so
+ the version number stays a placeholder a translator can move; the
+ feature only affects digits, so the surrounding words are unchanged. */}
+
+ {t('toast.update.ready', { version: pendingVersion })}
- Restart
+ {t('toast.update.restart')}
setDismissed(true)}
- aria-label="Dismiss update notification"
+ aria-label={t('toast.update.dismiss')}
className="ml-0.5 inline-flex cursor-pointer items-center justify-center rounded-full border-0 bg-transparent p-1"
style={{ color: 'var(--fg-2)' }}
>
diff --git a/app/renderer/src/components/home/PreviousRow.tsx b/app/renderer/src/components/home/PreviousRow.tsx
index 5647cc5c..0612b191 100644
--- a/app/renderer/src/components/home/PreviousRow.tsx
+++ b/app/renderer/src/components/home/PreviousRow.tsx
@@ -1,4 +1,7 @@
import { Folder as FolderIcon, Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { meetingDisplayTitle } from '@/lib/meetingTitle';
+import type { TFunction } from 'i18next';
import type { Meeting } from '@/lib/ipc';
import { navigate } from '@/lib/router';
import { useMeetingsList } from '@/lib/meetingsListContext';
@@ -10,9 +13,10 @@ interface PreviousRowProps {
}
export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
+ const { t } = useTranslation();
const info = meeting.session_info;
const when = formatTime(info.processed_at ?? info.updated_at);
- const duration = formatDuration(info.duration_seconds);
+ const duration = formatDuration(info.duration_seconds, t);
const preview = previewText(meeting);
const participants = Array.isArray(meeting.participants)
? meeting.participants.length
@@ -31,7 +35,7 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
? '/meetings/processing'
: `/meetings/${encodeURIComponent(info.summary_file)}`;
- const title = info.name || 'Untitled note';
+ const title = meetingDisplayTitle(info.name) || t('home.previousRow.untitledNote');
return (
0 && (
<>
- {participants} {participants === 1 ? 'person' : 'people'}
+ {t('home.previousRow.participants', { count: participants })}
{showPreview && · }
>
@@ -115,7 +119,7 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
className="flex flex-col items-end gap-1.5 pl-4 text-[12.5px] tabular-nums"
style={{ color: 'var(--fg-2)' }}
>
- {isSynthetic ? 'Now' : (when ?? '')}
+ {isSynthetic ? t('home.relative.now') : (when ?? '')}
{duration && {duration} }
@@ -123,6 +127,7 @@ export function PreviousRow({ meeting, folderName }: PreviousRowProps) {
}
function LiveBadge() {
+ const { t } = useTranslation();
return (
- Recording
+ {t('home.previousRow.recordingBadge')}
);
}
function ProcessingBadge() {
+ const { t } = useTranslation();
return (
- Processing
+ {t('home.previousRow.processingBadge')}
);
}
@@ -165,14 +171,19 @@ function formatTime(iso?: string): string | undefined {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
-function formatDuration(seconds?: number): string | undefined {
+function formatDuration(
+ seconds: number | undefined,
+ t: TFunction,
+): string | undefined {
if (!seconds || seconds <= 0) return undefined;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
- if (h > 0) return `${h}h ${m}m`;
- if (m > 0) return `${m}m`;
- return `${s}s`;
+ // Unit letters are language, not number format — German shortens to "Std."
+ // rather than "h" — so they go through the catalogue too.
+ if (h > 0) return t('home.duration.hoursMinutes', { hours: h, minutes: m });
+ if (m > 0) return t('home.duration.minutes', { minutes: m });
+ return t('home.duration.seconds', { seconds: s });
}
function previewText(meeting: Meeting): string | undefined {
diff --git a/app/renderer/src/components/home/UpcomingCard.tsx b/app/renderer/src/components/home/UpcomingCard.tsx
index 5f3f113c..44670d14 100644
--- a/app/renderer/src/components/home/UpcomingCard.tsx
+++ b/app/renderer/src/components/home/UpcomingCard.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
import { Video } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
import type { CalendarEvent } from '@/lib/ipc';
import { ipc } from '@/lib/ipc';
import { cn } from '@/lib/utils';
@@ -10,13 +12,19 @@ interface UpcomingCardProps {
}
export function UpcomingCard({ event }: UpcomingCardProps) {
+ const { t } = useTranslation();
const isAllDay = event.is_all_day === true;
const relative = isAllDay
- ? ({ prefix: null, value: 'All day', urgent: false, state: 'later' } as const)
- : relativeLabel(event.start);
+ ? ({
+ prefix: null,
+ value: t('home.upcomingCard.allDay'),
+ urgent: false,
+ state: 'later',
+ } as const)
+ : relativeLabel(event.start, t);
const meta = isAllDay
- ? ({ primary: 'Today', timeRange: '' } as const)
- : formatMeta(event.start, event.end, relative.state);
+ ? ({ primary: t('home.day.today'), timeRange: '' } as const)
+ : formatMeta(event.start, event.end, relative.state, t);
const meetingUrl = event.meeting_url?.trim();
const recording = useRecording();
const isLive = relative.state === 'now';
@@ -73,7 +81,7 @@ export function UpcomingCard({ event }: UpcomingCardProps) {
className="truncate text-[13.5px] font-medium tracking-[-0.005em]"
style={{ color: 'var(--fg-1)' }}
>
- {event.title || 'Untitled meeting'}
+ {event.title || t('home.upcomingCard.untitled')}
{(isLive && !!meetingUrl) && (
- Start now
+ {t('home.upcomingCard.startNow')}
) : (
- Join
+ {t('home.upcomingCard.join')}
)
) : null}
@@ -134,7 +142,10 @@ export function UpcomingCard({ event }: UpcomingCardProps) {
type RelativeState = 'now' | 'soon' | 'later';
-function relativeLabel(startIso: string): {
+function relativeLabel(
+ startIso: string,
+ t: TFunction,
+): {
prefix: string | null;
value: string;
urgent: boolean;
@@ -145,11 +156,12 @@ function relativeLabel(startIso: string): {
return { prefix: null, value: '—', urgent: false, state: 'later' };
const diffMs = start.getTime() - Date.now();
const diffMins = Math.round(diffMs / 60000);
- if (diffMins <= 0) return { prefix: null, value: 'Now', urgent: true, state: 'now' };
+ if (diffMins <= 0)
+ return { prefix: null, value: t('home.relative.now'), urgent: true, state: 'now' };
if (diffMins < 60)
return {
prefix: 'In',
- value: `${diffMins} min${diffMins === 1 ? '' : 's'}`,
+ value: t('home.relative.minutes', { count: diffMins }),
urgent: diffMins <= 15,
state: diffMins <= 15 ? 'soon' : 'later',
};
@@ -157,14 +169,14 @@ function relativeLabel(startIso: string): {
if (hrs < 24)
return {
prefix: 'In',
- value: `${hrs} hr${hrs === 1 ? '' : 's'}`,
+ value: t('home.relative.hours', { count: hrs }),
urgent: false,
state: 'later',
};
const days = Math.round(hrs / 24);
return {
prefix: 'In',
- value: `${days} day${days === 1 ? '' : 's'}`,
+ value: t('home.relative.days', { count: days }),
urgent: false,
state: 'later',
};
@@ -181,6 +193,7 @@ function formatMeta(
startIso: string,
endIso: string,
state: RelativeState,
+ t: TFunction,
): { primary: string; timeRange: string } {
const start = new Date(startIso);
const end = endIso ? new Date(endIso) : null;
@@ -202,7 +215,7 @@ function formatMeta(
if (state === 'now' && end && !Number.isNaN(end.getTime())) {
const endsInMins = Math.round((end.getTime() - Date.now()) / 60000);
if (endsInMins > 0 && endsInMins <= 15) {
- primary = `Ends in ${endsInMins} min${endsInMins === 1 ? '' : 's'}`;
+ primary = t('home.upcomingCard.endsIn', { count: endsInMins });
} else {
const startedAgoMins = Math.max(
0,
@@ -210,13 +223,13 @@ function formatMeta(
);
primary =
startedAgoMins === 0
- ? 'Just started'
- : `Started ${startedAgoMins} min${startedAgoMins === 1 ? '' : 's'} ago`;
+ ? t('home.upcomingCard.justStarted')
+ : t('home.upcomingCard.startedAgo', { count: startedAgoMins });
}
} else if (sameDay(start, now)) {
- primary = 'Today';
+ primary = t('home.day.today');
} else if (sameDay(start, tomorrow)) {
- primary = 'Tomorrow';
+ primary = t('home.day.tomorrow');
} else {
primary = start.toLocaleDateString(undefined, {
weekday: 'short',
diff --git a/app/renderer/src/components/ui/confirm-dialog.tsx b/app/renderer/src/components/ui/confirm-dialog.tsx
index 244354d6..2922a56d 100644
--- a/app/renderer/src/components/ui/confirm-dialog.tsx
+++ b/app/renderer/src/components/ui/confirm-dialog.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogClose,
@@ -27,14 +28,21 @@ export function ConfirmDialog({
onOpenChange,
title,
description,
- confirmLabel = 'Confirm',
- cancelLabel = 'Cancel',
+ confirmLabel,
+ cancelLabel,
destructive,
onConfirm,
isPending,
}: ConfirmDialogProps) {
+ const { t } = useTranslation();
const [busy, setBusy] = React.useState(false);
const pending = busy || isPending;
+ // Defaults resolved here rather than in the parameter list: a default
+ // parameter is evaluated once per render with whatever `t` was in scope, but
+ // more importantly it cannot call a hook at all. Callers that pass their own
+ // label still win.
+ const confirmText = confirmLabel ?? t('common.confirm');
+ const cancelText = cancelLabel ?? t('common.cancel');
const handleConfirm = async () => {
setBusy(true);
@@ -55,7 +63,7 @@ export function ConfirmDialog({
- {cancelLabel}
+ {cancelText}
void handleConfirm()}
disabled={pending}
>
- {pending ? 'Working...' : confirmLabel}
+ {pending ? t('common.working') : confirmText}
diff --git a/app/renderer/src/hooks/useSettings.ts b/app/renderer/src/hooks/useSettings.ts
index 3cbea190..aa930f80 100644
--- a/app/renderer/src/hooks/useSettings.ts
+++ b/app/renderer/src/hooks/useSettings.ts
@@ -16,6 +16,7 @@ export const settingsKeys = {
launchOnLogin: () => [...settingsKeys.all, 'launchOnLogin'] as const,
silenceAutoStop: () => [...settingsKeys.all, 'silenceAutoStop'] as const,
language: () => [...settingsKeys.all, 'language'] as const,
+ uiLanguage: () => [...settingsKeys.all, 'uiLanguage'] as const,
microphone: () => [...settingsKeys.all, 'microphone'] as const,
storagePath: () => [...settingsKeys.all, 'storagePath'] as const,
appVersion: () => [...settingsKeys.all, 'appVersion'] as const,
@@ -202,6 +203,36 @@ export function useSetLanguage() {
});
}
+/*
+ * Interface language (#337) — not to be confused with useLanguageSetting above,
+ * which is the transcription/content language.
+ *
+ * Returns the stored preference plus the concrete tag in force, because those
+ * differ whenever the preference is 'system'.
+ */
+export function useUiLanguageSetting() {
+ return useQuery({
+ queryKey: settingsKeys.uiLanguage(),
+ queryFn: async () => {
+ const res = unwrap(await ipc().settings.getUiLanguage());
+ return { preference: res.ui_language, resolved: res.resolved };
+ },
+ });
+}
+
+export function useSetUiLanguage() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (code: string) => unwrap(await ipc().settings.setUiLanguage(code)),
+ // The renderer's own i18next switch is NOT done here. Main broadcasts
+ // 'ui-language-changed' to every window after it has relabelled the native
+ // menus, and useUiLanguageSync applies it — so the window that made the
+ // change takes the same path as every other one, and there is only one
+ // place where the language actually flips.
+ onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.uiLanguage() }),
+ });
+}
+
export function useMicrophoneSetting() {
return useQuery({
queryKey: settingsKeys.microphone(),
diff --git a/app/renderer/src/hooks/useUiLanguageSync.ts b/app/renderer/src/hooks/useUiLanguageSync.ts
new file mode 100644
index 00000000..1d3cbb1b
--- /dev/null
+++ b/app/renderer/src/hooks/useUiLanguageSync.ts
@@ -0,0 +1,34 @@
+import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { ipc } from '@/lib/ipc';
+import { applyUiLanguage } from '@/lib/i18n';
+
+/*
+ * Keeps this window's i18next instance in step with the main process (#337).
+ *
+ * Main owns the language: it persists the preference, resolves the 'system'
+ * sentinel against the OS, relabels the native menu and tray, and only then
+ * broadcasts 'ui-language-changed' to every open window. This hook is the
+ * receiving end.
+ *
+ * Every window listens, including the one whose Settings screen triggered the
+ * change — so there is exactly one code path that flips the language, rather
+ * than the originating window switching itself optimistically and the others
+ * arriving via IPC.
+ *
+ * useTranslation() is called for its side effect only: it subscribes the
+ * component to i18next, so mounting this hook re-renders the tree when the
+ * language changes. Without it changeLanguage() would update the store and
+ * leave the UI showing the old strings until something else re-rendered.
+ */
+export function useUiLanguageSync(): void {
+ useTranslation();
+
+ useEffect(() => {
+ if (!window.stenoai) return;
+ return ipc().on.uiLanguageChanged((language: string) => {
+ void applyUiLanguage(language);
+ });
+ }, []);
+}
diff --git a/app/renderer/src/lib/chat.ts b/app/renderer/src/lib/chat.ts
index 3d44a9bd..7e990500 100644
--- a/app/renderer/src/lib/chat.ts
+++ b/app/renderer/src/lib/chat.ts
@@ -1,5 +1,6 @@
// Shared helpers for the Chat tab + conversation view.
+import i18n from '@/lib/i18n';
import type { AiProvider, CloudProvider } from '@/lib/ipc';
/** The AI-provider config fields the active-model label reads. */
@@ -45,18 +46,23 @@ export function chatProviderReady(p: ChatProviderFields | undefined): boolean {
* label can't lie. Returns 'Auto' until provider config has loaded.
*/
export function formatActiveModel(p: ActiveModelFields | undefined): string {
- if (!p) return 'Auto';
+ if (!p) return i18n.t('chat.model.auto');
// Guard each interpolation: an optimistic provider switch can briefly leave the
// cache holding {ai_provider} without the model fields, which would otherwise
// render "Ollama · undefined" for a frame.
switch (p.ai_provider) {
case 'cloud':
- return [p.cloud_provider, p.cloud_model].filter(Boolean).join(' · ') || 'Cloud';
+ return (
+ [p.cloud_provider, p.cloud_model].filter(Boolean).join(' · ') ||
+ i18n.t('chat.model.cloud')
+ );
case 'remote':
- return p.model ? `Remote Ollama · ${p.model}` : 'Remote Ollama';
+ return p.model
+ ? `${i18n.t('chat.model.remoteOllama')} · ${p.model}`
+ : i18n.t('chat.model.remoteOllama');
case 'adapter':
// The org adapter brokers the model server-side; the desktop has no id.
- return 'Organisation';
+ return i18n.t('chat.model.organisation');
case 'local':
default:
return p.model ? `Ollama · ${p.model}` : 'Ollama';
@@ -111,11 +117,11 @@ export function bucketKey(ts: number, now: number = Date.now()): string {
}
export function toBucketLabel(key: string): string {
- if (key === 'today') return 'Today';
- if (key === 'yesterday') return 'Yesterday';
- if (key === 'this-week') return 'This week';
- if (key === 'last-2-weeks') return 'Last 2 weeks';
- if (key === 'this-month') return 'This month';
+ if (key === 'today') return i18n.t('chat.bucket.today');
+ if (key === 'yesterday') return i18n.t('chat.bucket.yesterday');
+ if (key === 'this-week') return i18n.t('chat.bucket.thisWeek');
+ if (key === 'last-2-weeks') return i18n.t('chat.bucket.last2Weeks');
+ if (key === 'this-month') return i18n.t('chat.bucket.thisMonth');
if (key.startsWith('month-')) {
const m = parseInt(key.slice(6), 10);
return new Date(2000, m, 1).toLocaleString(undefined, { month: 'long' });
@@ -128,15 +134,15 @@ export function relativeTime(ts: number): string {
const now = Date.now();
const diff = Math.max(0, now - ts);
const minutes = Math.floor(diff / 60000);
- if (minutes < 1) return 'now';
- if (minutes < 60) return `${minutes}m`;
+ if (minutes < 1) return i18n.t('chat.relative.now');
+ if (minutes < 60) return i18n.t('chat.relative.minutes', { n: minutes });
const hours = Math.floor(minutes / 60);
- if (hours < 24) return `${hours}h`;
+ if (hours < 24) return i18n.t('chat.relative.hours', { n: hours });
const days = Math.floor(hours / 24);
- if (days < 7) return `${days}d`;
+ if (days < 7) return i18n.t('chat.relative.days', { n: days });
const weeks = Math.floor(days / 7);
- if (weeks < 5) return `${weeks}w`;
+ if (weeks < 5) return i18n.t('chat.relative.weeks', { n: weeks });
const months = Math.floor(days / 30);
- if (months < 12) return `${months}mo`;
- return `${Math.floor(months / 12)}y`;
+ if (months < 12) return i18n.t('chat.relative.months', { n: months });
+ return i18n.t('chat.relative.years', { n: Math.floor(months / 12) });
}
diff --git a/app/renderer/src/lib/hero.ts b/app/renderer/src/lib/hero.ts
index 8aa0be1a..b9056463 100644
--- a/app/renderer/src/lib/hero.ts
+++ b/app/renderer/src/lib/hero.ts
@@ -1,3 +1,4 @@
+import i18n from '@/lib/i18n';
import { type CalendarEvent } from '@/lib/ipc';
import { shortcut } from '@/lib/utils';
@@ -5,10 +6,15 @@ import { shortcut } from '@/lib/utils';
// headline/subtitle string-building can be unit-tested without mounting the
// React route. Recording state always wins over calendar state.
-// Default subtitle — also used as the empty/idle fallback. Cached so it
-// renders the same string each call without rebuilding the shortcut.
+// The shortcut glyphs are platform, not language — they read the same in every
+// UI language, so this stays a module constant.
const RECORD_SHORTCUT = shortcut('⌘⇧R', 'Ctrl+Shift+R');
-const RECORDING_HINT = `Start recording from the top-right, or from anywhere with ${RECORD_SHORTCUT}.`;
+
+// Default subtitle — also used as the empty/idle fallback. Resolved per call
+// rather than cached at module load, so a mid-session language change is
+// picked up on the next render.
+const recordingHint = () =>
+ i18n.t('hero.recordingHint', { shortcut: RECORD_SHORTCUT });
// Cached at module load to avoid rebuilding on every render. We don't
// react to system-locale changes mid-session — that would require a full
@@ -51,17 +57,17 @@ function eventIsNow(e: CalendarEvent, nowMs: number): boolean {
export function heroHeadline(s: HeroState): string {
switch (s.status) {
case 'recording':
- return 'Recording';
+ return i18n.t('hero.headline.recording');
case 'paused':
- return 'Recording paused';
+ return i18n.t('hero.headline.paused');
case 'processing':
- return 'Processing your note';
+ return i18n.t('hero.headline.processing');
}
// Only present-tense when the meeting has truly started — not during the
// early-join grace, which would tell the user they're "in" a meeting that
// hasn't begun. The pre-start case falls through to "Next meeting in N min".
if (s.inProgressEvent && eventIsNow(s.inProgressEvent, s.now)) {
- return 'In a meeting now';
+ return i18n.t('hero.headline.inMeeting');
}
if (s.nextSoonEvent) {
const startMs = new Date(s.nextSoonEvent.start).getTime();
@@ -73,16 +79,16 @@ export function heroHeadline(s: HeroState): string {
// Math.max(1) keeps the headline non-zero in the last 30 seconds
// before start.
const mins = Math.max(1, Math.ceil(deltaMs / MIN_MS));
- if (mins < 60) return `Next meeting in ${mins} min${mins === 1 ? '' : 's'}`;
+ if (mins < 60) return i18n.t('hero.headline.nextInMinutes', { count: mins });
const hrs = Math.max(1, Math.round(deltaMs / HOUR_MS));
- return `Next meeting in ${hrs} hr${hrs === 1 ? '' : 's'}`;
+ return i18n.t('hero.headline.nextInHours', { count: hrs });
}
}
// Reaching here means nothing is live or upcoming today. Only call the day
// "clear" when the calendar is actually connected — otherwise we don't know,
// so keep the neutral invitation.
- if (s.calendarConnected) return 'Clear day ahead';
- return 'Ready to capture beautiful notes';
+ if (s.calendarConnected) return i18n.t('hero.headline.clearDay');
+ return i18n.t('hero.headline.ready');
}
// Subtitle. Mirrors the headline cases. Keeps the recording shortcut hint
@@ -96,22 +102,27 @@ export function heroSubtitle(s: HeroState): string {
// record on. ⌘⇧R is a record-toggle per main.js's global shortcut
// so "to stop" is accurate when already recording.
const title =
- s.sessionName?.trim() || s.inProgressEvent?.title?.trim() || 'In progress';
- return `${title} · ${RECORD_SHORTCUT} to stop`;
+ s.sessionName?.trim() ||
+ s.inProgressEvent?.title?.trim() ||
+ i18n.t('hero.subtitle.inProgressFallback');
+ return i18n.t('hero.subtitle.recording', {
+ title,
+ shortcut: RECORD_SHORTCUT,
+ });
}
if (s.status === 'paused') {
// ⌘⇧R is a record-toggle: while paused it STOPS (finalizes) the recording
// rather than resuming. Resume is a click-only action on the bottom bar,
// so point there instead of advertising a shortcut that would end the note.
- return 'Recording paused. Tap resume on the bar below to continue.';
+ return i18n.t('hero.subtitle.paused');
}
if (s.status === 'processing') {
- return `We'll have your note ready in a moment.`;
+ return i18n.t('hero.subtitle.processing');
}
// Only when the meeting has truly started (mirrors the headline gate) — the
// pre-start grace falls through to the timed "starts at …" line below.
if (s.inProgressEvent && eventIsNow(s.inProgressEvent, s.now)) {
- return `Press ${RECORD_SHORTCUT} to start recording — or tap a meeting card below.`;
+ return i18n.t('hero.subtitle.inMeeting', { shortcut: RECORD_SHORTCUT });
}
if (s.nextSoonEvent) {
const startMs = new Date(s.nextSoonEvent.start).getTime();
@@ -122,17 +133,24 @@ export function heroSubtitle(s: HeroState): string {
const mins = Math.max(1, Math.ceil((startMs - s.now) / MIN_MS));
if (mins < 60) {
const at = HERO_TIME_FMT.format(new Date(startMs));
- return `${s.nextSoonEvent.title} at ${at} — ${RECORD_SHORTCUT} when you're ready.`;
+ return i18n.t('hero.subtitle.nextSoon', {
+ title: s.nextSoonEvent.title,
+ time: at,
+ shortcut: RECORD_SHORTCUT,
+ });
}
}
- return RECORDING_HINT;
+ return recordingHint();
}
if (s.tomorrowPreview) {
const startMs = new Date(s.tomorrowPreview.start).getTime();
if (!Number.isNaN(startMs)) {
const at = HERO_TIME_FMT.format(new Date(startMs));
- return `Next up: ${s.tomorrowPreview.title} tomorrow at ${at}.`;
+ return i18n.t('hero.subtitle.tomorrow', {
+ title: s.tomorrowPreview.title,
+ time: at,
+ });
}
}
- return RECORDING_HINT;
+ return recordingHint();
}
diff --git a/app/renderer/src/lib/i18n.ts b/app/renderer/src/lib/i18n.ts
new file mode 100644
index 00000000..a65fd21b
--- /dev/null
+++ b/app/renderer/src/lib/i18n.ts
@@ -0,0 +1,68 @@
+/*
+ * UI-chrome localisation for the RENDERER (issue #337).
+ *
+ * The second of two independent i18next instances; the main process runs its
+ * own (app/i18n.js) over the same JSON files. See that file for the protocol
+ * that keeps them in step.
+ *
+ * Initialised at module scope, on purpose. main.tsx imports this before it
+ * mounts React, so the very first paint is already in the right language and
+ * there is no flash of English. That works because the resources are inlined
+ * by Vite (no network, no backend plugin) and the bootstrap language arrives
+ * synchronously on process.argv via the preload — no IPC round trip to await.
+ */
+
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import en from '@locales/en.json';
+import de from '@locales/de.json';
+
+export const SUPPORTED_UI_LANGUAGES = ['en', 'de'] as const;
+export type UiLanguage = (typeof SUPPORTED_UI_LANGUAGES)[number];
+export const FALLBACK_UI_LANGUAGE: UiLanguage = 'en';
+
+function isSupported(value: unknown): value is UiLanguage {
+ return typeof value === 'string' && (SUPPORTED_UI_LANGUAGES as readonly string[]).includes(value);
+}
+
+/*
+ * The language main resolved for this window, handed over as a launch argument
+ * rather than fetched. Falls back to English if the bridge is missing, which is
+ * the case in unit tests that render a component without the preload.
+ */
+function bootstrapLanguage(): UiLanguage {
+ const fromBridge = (window as { stenoai?: { uiLanguage?: unknown } }).stenoai?.uiLanguage;
+ return isSupported(fromBridge) ? fromBridge : FALLBACK_UI_LANGUAGE;
+}
+
+i18n.use(initReactI18next).init({
+ lng: bootstrapLanguage(),
+ fallbackLng: FALLBACK_UI_LANGUAGE,
+ resources: {
+ en: { translation: en },
+ de: { translation: de },
+ },
+ interpolation: {
+ // React already escapes anything rendered through JSX, so i18next doing it
+ // again would double-escape apostrophes and ampersands in the copy.
+ escapeValue: false,
+ },
+ returnNull: false,
+});
+
+document.documentElement.lang = i18n.language;
+
+/*
+ * Applies a language the user just picked, or one main pushed after it was
+ * changed in another window. Kept here rather than in the component so the
+ * document lang attribute cannot drift from the active i18next language.
+ */
+export async function applyUiLanguage(language: string): Promise {
+ const next = isSupported(language) ? language : FALLBACK_UI_LANGUAGE;
+ if (i18n.language === next) return;
+ await i18n.changeLanguage(next);
+ document.documentElement.lang = next;
+}
+
+export default i18n;
diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts
index b0bdbe1d..4b0f7535 100644
--- a/app/renderer/src/lib/ipc.ts
+++ b/app/renderer/src/lib/ipc.ts
@@ -531,6 +531,15 @@ export type GetSilenceAutoStopResponse = Result<{
export type SetSilenceAutoStopEnabledResponse = Result<{ silence_auto_stop_enabled: boolean }>;
export type SetSilenceAutoStopMinutesResponse = Result<{ silence_auto_stop_minutes: number }>;
export type GetLanguageResponse = Result<{ language: string }>;
+
+/*
+ * Interface language (#337). `ui_language` is the stored preference and may be
+ * the 'system' sentinel; `resolved` is the concrete tag the app is actually
+ * rendering in. The picker needs both — it shows "System default" as selected
+ * while the UI itself runs in whatever that resolved to.
+ */
+export type GetUiLanguageResponse = Result<{ ui_language: string; resolved: string }>;
+export type SetUiLanguageResponse = Result<{ ui_language: string; resolved: string }>;
export type GetMicrophoneResponse = Result<{
device_id: string | null;
label: string | null;
@@ -772,6 +781,14 @@ type Subscribe = (cb: (payload: P) => void) => () => void;
export interface StenoaiBridge {
version: number;
+ /*
+ * The UI language main resolved for this window, delivered as a launch
+ * argument so lib/i18n.ts can initialise before React mounts. A value, not a
+ * call — anything async here would reintroduce the flash of English it exists
+ * to prevent.
+ */
+ uiLanguage: string;
+
app: {
getVersion: RequestFn<[], AppVersionResponse>;
};
@@ -1027,6 +1044,8 @@ export interface StenoaiBridge {
>;
getLanguage: RequestFn<[], GetLanguageResponse>;
setLanguage: RequestFn<[code: string], Result>>;
+ getUiLanguage: RequestFn<[], GetUiLanguageResponse>;
+ setUiLanguage: RequestFn<[code: string], SetUiLanguageResponse>;
getMicrophone: RequestFn<[], GetMicrophoneResponse>;
setMicrophone: RequestFn<[deviceId: string, label: string], GetMicrophoneResponse>;
getUserName: RequestFn<[], GetUserNameResponse>;
@@ -1084,6 +1103,7 @@ export interface StenoaiBridge {
on: {
debugLog: Subscribe;
+ uiLanguageChanged: Subscribe;
setupFlowTriggered: Subscribe;
toggleRecordingHotkey: Subscribe;
summaryChunk: Subscribe;
diff --git a/app/renderer/src/lib/meetingTitle.test.ts b/app/renderer/src/lib/meetingTitle.test.ts
new file mode 100644
index 00000000..ad264bbc
--- /dev/null
+++ b/app/renderer/src/lib/meetingTitle.test.ts
@@ -0,0 +1,57 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+
+import i18n from '@/lib/i18n';
+import { meetingDisplayTitle } from '@/lib/meetingTitle';
+
+/**
+ * The placeholder title is a protocol token the backend matches with
+ * _AUTO_NAMED_PATTERN and replaces with a generated title (#337). Localising it
+ * is display-only; the storage value must stay byte-identical or the backend
+ * stops recognising it and the note keeps its placeholder forever.
+ */
+describe('meetingDisplayTitle', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('de');
+ });
+
+ test('translates the bare placeholder', () => {
+ expect(meetingDisplayTitle('Note')).toBe('Notiz');
+ });
+
+ test('keeps the disambiguating suffix', () => {
+ // Back-to-back recordings rely on the suffix to be distinguishable in the
+ // list, so it must survive the translation.
+ expect(meetingDisplayTitle('Note-A1B2C3')).toBe('Notiz-A1B2C3');
+ });
+
+ test('leaves a real title alone', () => {
+ expect(meetingDisplayTitle('Quartalsplanung')).toBe('Quartalsplanung');
+ expect(meetingDisplayTitle('Notes from the offsite')).toBe('Notes from the offsite');
+ });
+
+ test('does not touch the name-plus-timestamp form', () => {
+ // The backend pattern also matches " — ", but that is a
+ // user-named session, not a placeholder.
+ expect(meetingDisplayTitle('Standup — 2026-07-27 09:15')).toBe(
+ 'Standup — 2026-07-27 09:15',
+ );
+ });
+
+ test('rejects near-misses rather than translating them', () => {
+ for (const name of ['Note-abc123', 'Note-A1B2C', 'Notebook', 'My Note']) {
+ expect(meetingDisplayTitle(name)).toBe(name);
+ }
+ });
+
+ test('an empty or missing name yields an empty string for the caller to handle', () => {
+ expect(meetingDisplayTitle('')).toBe('');
+ expect(meetingDisplayTitle(null)).toBe('');
+ expect(meetingDisplayTitle(undefined)).toBe('');
+ });
+
+ test('English renders the stored word unchanged', async () => {
+ await i18n.changeLanguage('en');
+ expect(meetingDisplayTitle('Note')).toBe('Note');
+ expect(meetingDisplayTitle('Meeting-ZZ9999')).toBe('Meeting-ZZ9999');
+ });
+});
diff --git a/app/renderer/src/lib/meetingTitle.ts b/app/renderer/src/lib/meetingTitle.ts
new file mode 100644
index 00000000..e7080779
--- /dev/null
+++ b/app/renderer/src/lib/meetingTitle.ts
@@ -0,0 +1,38 @@
+import i18n from '@/lib/i18n';
+
+/*
+ * Display title for a meeting whose real title has not been generated yet (#337).
+ *
+ * A recording is stored under the placeholder "Note" (optionally suffixed, e.g.
+ * "Note-A1B2C3"). That string is a protocol token, not a name: the backend
+ * matches it with _AUTO_NAMED_PATTERN (simple_recorder.py) and replaces it with
+ * an AI-generated title once the summary exists. So it must stay exactly that in
+ * storage — but a German user with auto-summarise off, or after a failed title
+ * generation, would otherwise stare at an English "Note" forever.
+ *
+ * Same storage-vs-display split as the section headings and the seeded template
+ * name: canonical English on disk, translated only where it is rendered.
+ *
+ * Deliberately narrow:
+ * - only the exact reserved tokens match, so a note a user actually named
+ * "Note" keeps their name (it is stored identically, and treating it as a
+ * placeholder is the lesser evil of the two — the backend already does the
+ * same thing when it decides whether to overwrite the title).
+ * - the backend pattern also covers " — ", which is a
+ * user-named session and must NOT be touched here.
+ * - never use this where the value is stored, searched or uploaded. It is for
+ * rendering only.
+ */
+const PLACEHOLDER = /^(Meeting|Note)(-[A-Z0-9]{6})?$/;
+
+export function meetingDisplayTitle(name: string | null | undefined): string {
+ if (!name) return '';
+ const match = PLACEHOLDER.exec(name);
+ if (!match) return name;
+ const base = i18n.t(`meeting.placeholderTitle.${match[1].toLowerCase()}`, {
+ defaultValue: match[1],
+ });
+ // Keep the disambiguating suffix: back-to-back recordings rely on it to tell
+ // otherwise-identical placeholder titles apart in the list.
+ return `${base}${match[2] ?? ''}`;
+}
diff --git a/app/renderer/src/lib/templateName.test.ts b/app/renderer/src/lib/templateName.test.ts
new file mode 100644
index 00000000..2a5912cf
--- /dev/null
+++ b/app/renderer/src/lib/templateName.test.ts
@@ -0,0 +1,51 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+
+import i18n from '@/lib/i18n';
+import { templateDisplayName } from '@/lib/templateName';
+
+/**
+ * Template names are user data (#337): built-ins can be overridden and the
+ * seeded sample is written into config.json on first run. So the localisation
+ * is display-only, and it must get out of the way the moment a user has renamed
+ * anything.
+ */
+describe('templateDisplayName', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('de');
+ });
+
+ test('localises the seeded sample while its name is untouched', () => {
+ expect(templateDisplayName({ id: 'shareable-summary', name: 'Shareable summary' })).toBe(
+ 'Weitergabe-Zusammenfassung',
+ );
+ });
+
+ test('a renamed template keeps the user’s name, never ours', () => {
+ // The load-bearing case: once someone edits the template, the stored name
+ // is theirs and must survive a language switch untouched.
+ expect(
+ templateDisplayName({ id: 'shareable-summary', name: 'Mein Weiterleitungs-Text' }),
+ ).toBe('Mein Weiterleitungs-Text');
+ });
+
+ test('the jargon built-ins are deliberately left in English', () => {
+ // "Standup" and "Sales Call" ARE the German business terms; translating
+ // them would be worse German. This asserts the narrow scope on purpose, so
+ // a future well-meaning change has to argue with a failing test.
+ for (const name of ['Product Demo', 'Sales Call', 'Standup', '1:1']) {
+ const id = name.toLowerCase().replace(/[^a-z]+/g, '-');
+ expect(templateDisplayName({ id, name })).toBe(name);
+ }
+ });
+
+ test('a template with no id falls through unchanged', () => {
+ expect(templateDisplayName({ name: 'Shareable summary' })).toBe('Shareable summary');
+ });
+
+ test('English shows the English name, not a key', async () => {
+ await i18n.changeLanguage('en');
+ expect(templateDisplayName({ id: 'shareable-summary', name: 'Shareable summary' })).toBe(
+ 'Shareable summary',
+ );
+ });
+});
diff --git a/app/renderer/src/lib/templateName.ts b/app/renderer/src/lib/templateName.ts
new file mode 100644
index 00000000..a68743f8
--- /dev/null
+++ b/app/renderer/src/lib/templateName.ts
@@ -0,0 +1,34 @@
+import i18n from '@/lib/i18n';
+
+/*
+ * Display name for a report template (#337).
+ *
+ * Template names are *user data*: built-ins can be overridden and the seeded
+ * "Shareable summary" is written into config.json on first run and belongs to
+ * the user from then on. So nothing here changes what is stored — the storage
+ * stays canonical English, and only the render is localised, the same way the
+ * four summary headings are handled.
+ *
+ * Scope is deliberately narrow. "Product Demo", "Sales Call", "Standup" and
+ * "1:1" are left alone because those ARE the terms German speakers use for
+ * these meetings; translating "Standup" to "Tagesbesprechung" would be worse
+ * German, not better. Only names that read as untranslated English are mapped,
+ * which today is the seeded sample.
+ *
+ * The localised name applies ONLY while the stored name still matches the
+ * English source. The moment a user renames the template, their name wins —
+ * which is why the comparison is against the English bundle rather than a
+ * literal copied into this file, so the two cannot drift.
+ */
+const LOCALISED_TEMPLATE_IDS: Record = {
+ 'shareable-summary': 'settings.templates.seeded.shareableSummary',
+};
+
+export function templateDisplayName(template: { id?: string; name: string }): string {
+ const key = template.id ? LOCALISED_TEMPLATE_IDS[template.id] : undefined;
+ if (!key) return template.name;
+ // Untouched by the user? Then it is still ours to present.
+ const englishSource = i18n.getFixedT('en')(key);
+ if (template.name !== englishSource) return template.name;
+ return i18n.t(key);
+}
diff --git a/app/renderer/src/lib/transcription-languages.test.ts b/app/renderer/src/lib/transcription-languages.test.ts
new file mode 100644
index 00000000..57ff50e9
--- /dev/null
+++ b/app/renderer/src/lib/transcription-languages.test.ts
@@ -0,0 +1,62 @@
+import { describe, test, expect, beforeEach } from 'vitest';
+
+import i18n from '@/lib/i18n';
+import {
+ PARAKEET_LANGUAGES,
+ languageHint,
+ languageLabel,
+} from '@/lib/transcription-languages';
+import { LANGUAGES_WHISPER } from '@/routes/settings/languages';
+
+/**
+ * The picker labels are display-only (#337) — the stored value is always the
+ * code — so localising them cannot affect transcription behaviour. These tests
+ * guard the two things that could still go wrong: a code with no translation
+ * silently rendering a key, and the two different meanings of `auto` collapsing
+ * into one.
+ */
+describe('language picker labels', () => {
+ beforeEach(async () => {
+ await i18n.changeLanguage('de');
+ });
+
+ test('German uses exonyms, not the English name and not the endonym', () => {
+ expect(languageLabel('es', 'Spanish')).toBe('Spanisch');
+ expect(languageLabel('fr', 'French')).toBe('Französisch');
+ expect(languageLabel('zh-Hans', 'Chinese (Simplified)')).toBe('Chinesisch (vereinfacht)');
+ });
+
+ test('auto has two distinct labels because the engines mean different things', () => {
+ // Whisper detects per recording; Parakeet is language-agnostic at inference.
+ expect(languageLabel('auto', 'Auto (detect)')).toBe('Automatisch');
+ expect(languageLabel('auto', 'Multi-language', 'multi')).toBe('Mehrsprachig');
+ });
+
+ test('every shipped Whisper code resolves to a real word, never a key', () => {
+ for (const option of LANGUAGES_WHISPER) {
+ const label = languageLabel(option.value, option.label);
+ expect(label).not.toContain('settings.languages');
+ expect(label.length).toBeGreaterThan(0);
+ }
+ });
+
+ test('every shipped Parakeet code resolves a label and a hint', () => {
+ for (const option of PARAKEET_LANGUAGES) {
+ expect(languageLabel(option.code, option.label, 'multi')).not.toContain('settings.languages');
+ expect(languageHint(option.code, option.hint)).not.toContain('settings.languages');
+ }
+ });
+
+ test('an unknown code falls back to the English label rather than a key', () => {
+ // Adding a language to the array without adding its key must degrade to the
+ // English word, not to "settings.languages.sv".
+ expect(languageLabel('sv', 'Swedish')).toBe('Swedish');
+ expect(languageHint('sv', 'Transcribe in Swedish')).toBe('Transcribe in Swedish');
+ });
+
+ test('English shows the English names', async () => {
+ await i18n.changeLanguage('en');
+ expect(languageLabel('es', 'Spanish')).toBe('Spanish');
+ expect(languageLabel('auto', 'Auto (detect)')).toBe('Auto (detect)');
+ });
+});
diff --git a/app/renderer/src/lib/transcription-languages.ts b/app/renderer/src/lib/transcription-languages.ts
index e2f95371..039affd9 100644
--- a/app/renderer/src/lib/transcription-languages.ts
+++ b/app/renderer/src/lib/transcription-languages.ts
@@ -1,3 +1,5 @@
+import i18n from '@/lib/i18n';
+
// Single source of truth for which languages the Parakeet engine offers.
//
// Parakeet TDT v3 is language-agnostic at inference (the decoder ignores the
@@ -36,3 +38,36 @@ export const PARAKEET_LANGUAGES: readonly ParakeetLanguageOption[] = [
export const PARAKEET_LANGUAGE_CODES: ReadonlySet = new Set(
PARAKEET_LANGUAGES.map((l) => l.code),
);
+
+/*
+ * Display labels for the language pickers (#337).
+ *
+ * The arrays above stay the source of truth for WHICH languages exist and what
+ * their codes are; only the presentation is localised, keyed by code. The
+ * English `label`/`hint` fields remain as the fallback, so a code without a
+ * translation still renders a real word rather than a key.
+ *
+ * German uses exonyms ("Spanisch"), not endonyms ("Español"). The convention
+ * differs by purpose: an OS language picker shows endonyms so speakers can find
+ * their own language in a UI they cannot yet read. Here the reader is already in
+ * a German UI, saying "my meetings are in Spanish" — so they scan for the German
+ * word.
+ *
+ * `auto` deliberately has two labels: the Whisper picker calls it
+ * "Auto (detect)", the Parakeet one "Multi-language", because Parakeet is
+ * language-agnostic at inference rather than detecting per recording.
+ */
+export function languageLabel(
+ code: string,
+ fallback: string,
+ variant: 'detect' | 'multi' = 'detect',
+): string {
+ const key = code === 'auto' && variant === 'multi' ? 'autoMulti' : code;
+ const translated = i18n.t(`settings.languages.${key}`, { defaultValue: '' });
+ return translated || fallback;
+}
+
+export function languageHint(code: string, fallback: string): string {
+ const translated = i18n.t(`settings.languages.hint.${code}`, { defaultValue: '' });
+ return translated || fallback;
+}
diff --git a/app/renderer/src/main.tsx b/app/renderer/src/main.tsx
index 4fc2a0da..ae3bd77c 100644
--- a/app/renderer/src/main.tsx
+++ b/app/renderer/src/main.tsx
@@ -2,6 +2,11 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import './globals.css';
+// Side-effect import, and it has to stay above the render calls below: i18next
+// is initialised at this module's scope, so the very first paint is already in
+// the resolved language. Both mount paths in this file (the app and the
+// notification toast, which is its own window) are covered by this one import.
+import './lib/i18n';
import { App } from './App';
import { isMac } from './lib/utils';
import { queryClient } from './lib/queryClient';
diff --git a/app/renderer/src/routes/Chat.tsx b/app/renderer/src/routes/Chat.tsx
index 3292f5fa..25fd4a2b 100644
--- a/app/renderer/src/routes/Chat.tsx
+++ b/app/renderer/src/routes/Chat.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
import {
ArrowUp,
ChevronRight,
@@ -100,6 +101,7 @@ function TypewriterPlaceholder({ index, setIndex }: { index: number, setIndex: R
}
export function Chat() {
+ const { t } = useTranslation();
const allSessions = useAllChatSessions();
// Reuse useChatSessions's persist/createSession with the global sentinel
// so saves go through the same atomic-write path.
@@ -208,7 +210,7 @@ export function Chat() {
// (disk full, IPC error, cloud-key revoked). Surface the error,
// restore the user's text so they don't have to retype, and roll
// back the empty session so it doesn't appear in History/Recents.
- const message = err instanceof Error ? err.message : 'Failed to send';
+ const message = err instanceof Error ? err.message : t('chat.failedToSend');
setSubmitError(message);
setInput(q);
if (createdSessionId) {
@@ -251,11 +253,15 @@ export function Chat() {
}}
>
{greetingName ? (
- <>
- Hi {greetingName} , ask anything
- >
+ ,
+ }}
+ />
) : (
- 'Ask anything'
+ t('chat.greeting')
)}
@@ -331,7 +337,9 @@ export function Chat() {
}
}}
disabled={!ready}
- placeholder={ready ? (typeof navigator !== 'undefined' && navigator.webdriver ? 'Summarise my meetings this week /' : (isFocused && !input ? `/ ${PRESETS[suggestedIndex].label}` : '')) : 'Set up an AI provider in Settings to ask across notes'}
+ // The webdriver branch is a deterministic fixture for the e2e
+ // suite (which always runs in English), so it stays literal.
+ placeholder={ready ? (typeof navigator !== 'undefined' && navigator.webdriver ? 'Summarise my meetings this week /' : (isFocused && !input ? `/ ${PRESETS[suggestedIndex].label}` : '')) : t('chat.providerRequiredPlaceholder')}
className="block w-full bg-transparent px-3 pb-4 pt-2.5 outline-none disabled:cursor-not-allowed placeholder:text-[color:var(--fg-muted)]"
style={{ fontSize: 16, color: 'var(--fg-1)', fontFamily: 'var(--font-sans)', fontWeight: 400 }}
/>
@@ -357,7 +365,7 @@ export function Chat() {
className="text-[12px]"
style={{ color: 'var(--fg-muted)' }}
>
- · may omit older notes
+ {t('chat.mayOmitOlderNotes')}
)}
@@ -367,7 +375,7 @@ export function Chat() {
disabled={!input.trim() || !ready}
className="inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-40"
style={{ color: 'var(--fg-1)' }}
- aria-label="Send"
+ aria-label={t('chat.send')}
>
@@ -384,7 +392,7 @@ export function Chat() {
onOpenAutoFocus={(e) => e.preventDefault()}
>
- Skills
+ {t('chat.skills')}
{PRESETS.map((p, idx) => {
@@ -412,7 +420,7 @@ export function Chat() {
- {recentsExpanded ? 'Show less' : 'See all'}
+ {recentsExpanded ? t('chat.showLess') : t('chat.seeAll')}
@@ -439,7 +447,7 @@ export function Chat() {
- Your past chats will show up here.
+ {t('chat.noRecents')}
) : recentsExpanded && groupedRecents ? (
@@ -519,17 +527,19 @@ function ProviderRequiredBanner() {
>
- Your AI provider isn't ready for chat yet — add a cloud API key, sign in
- to your Organisation, or set your remote Ollama URL in{' '}
- navigate('/settings')}
- style={{ color: 'var(--fg-1)' }}
- >
- Settings → AI
-
- .
+ navigate('/settings')}
+ style={{ color: 'var(--fg-1)' }}
+ />
+ ),
+ }}
+ />
);
diff --git a/app/renderer/src/routes/ChatConversation.tsx b/app/renderer/src/routes/ChatConversation.tsx
index 4648b8dd..700d39cd 100644
--- a/app/renderer/src/routes/ChatConversation.tsx
+++ b/app/renderer/src/routes/ChatConversation.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual';
import {
ArrowLeft,
@@ -45,6 +46,7 @@ interface ChatConversationProps {
}
export function ChatConversation({ sessionId }: ChatConversationProps) {
+ const { t } = useTranslation();
const allSessions = useAllChatSessions();
const chat = useChatSessions(GLOBAL_SCOPE, null);
const streaming = useGlobalStreaming();
@@ -138,8 +140,8 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
const content =
stream.text.trim() ||
(stream.status === 'error'
- ? `Error: ${stream.error ?? 'query failed'}`
- : '(empty response)');
+ ? t('chat.streamError', { error: stream.error ?? t('chat.queryFailed') })
+ : t('chat.emptyResponse'));
const message: ChatMessage = {
role: 'assistant',
content,
@@ -149,7 +151,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
pendingPersistRef.current = null;
streaming.clearStream(activeStreamId);
setActiveStreamId(null);
- }, [activeStreamId, streaming, chat]);
+ }, [activeStreamId, streaming, chat, t]);
// Virtualized message list. Long meetings produce thousands of messages
// (diarised replay + long prompts), and rendering them all on every
@@ -166,7 +168,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
[session?.messages],
);
const streamingContent = isStreaming
- ? activeStream?.text || 'Thinking…'
+ ? activeStream?.text || t('chat.thinkingStream')
: null;
const totalItems = messages.length + (streamingContent !== null ? 1 : 0);
@@ -221,7 +223,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
// Only restore the input if nothing made it to disk — once the user
// message is persisted it's already visible in the thread, and
// re-populating the box would duplicate it on the next submit.
- const message = err instanceof Error ? err.message : 'Failed to send';
+ const message = err instanceof Error ? err.message : t('chat.failedToSend');
setSubmitError(message);
if (!appended) setInput(q);
} finally {
@@ -246,9 +248,9 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
return (
-
Chat not found.
+
{t('chat.notFound')}
- This conversation may have been deleted.
+ {t('chat.notFoundHint')}
- Back to Chat
+ {t('chat.backToChat')}
@@ -279,8 +281,8 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
onClick={() => navigate('/chat')}
className="inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ color: 'var(--fg-2)' }}
- aria-label="Back to Chat"
- title="Back to Chat"
+ aria-label={t('chat.backToChat')}
+ title={t('chat.backToChat')}
>
@@ -295,16 +297,16 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
color: 'var(--fg-1)',
background: 'var(--surface-raised)',
}}
- aria-label="Switch chat"
+ aria-label={t('chat.switchChat')}
>
- History
+ {t('chat.history')}
{otherSessions.length === 0 ? (
- No other chats yet.
+ {t('chat.noOtherChats')}
) : (
@@ -466,7 +468,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
}
}}
disabled={!ready || isStreaming}
- placeholder="Ask anything /"
+ placeholder={t('chat.composerPlaceholder')}
className="block w-full bg-transparent px-2 pb-3 pt-1 outline-none disabled:cursor-not-allowed"
style={{ fontSize: 15, color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}
/>
@@ -486,7 +488,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
className="text-[12px]"
style={{ color: 'var(--fg-muted)' }}
>
- · may omit older notes
+ {t('chat.mayOmitOlderNotes')}
)}
@@ -497,7 +499,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
onClick={stop}
className="inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ color: 'var(--fg-1)' }}
- aria-label="Stop"
+ aria-label={t('chat.stop')}
>
@@ -507,7 +509,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
disabled={!input.trim() || !ready}
className="inline-flex size-7 items-center justify-center rounded-full transition-colors hover:bg-[color:var(--surface-hover)] disabled:opacity-40"
style={{ color: 'var(--fg-1)' }}
- aria-label="Send"
+ aria-label={t('chat.send')}
>
@@ -527,7 +529,7 @@ export function ChatConversation({ sessionId }: ChatConversationProps) {
className="px-2 pb-1 pt-0.5 text-[11px] font-medium"
style={{ color: 'var(--fg-muted)' }}
>
- Presets
+ {t('chat.presets')}
{PRESETS.map((p) => (
diff --git a/app/renderer/src/routes/FolderDetail.tsx b/app/renderer/src/routes/FolderDetail.tsx
index e08a1acc..330f650e 100644
--- a/app/renderer/src/routes/FolderDetail.tsx
+++ b/app/renderer/src/routes/FolderDetail.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
import { MeetingsShell } from '@/components/MeetingsShell';
import { PreviousRow } from '@/components/home/PreviousRow';
import { useMeetings } from '@/hooks/useMeetings';
@@ -11,6 +12,7 @@ interface FolderDetailProps {
}
export function FolderDetail({ folderId }: FolderDetailProps) {
+ const { t } = useTranslation();
const meetings = useMeetings();
const folders = useFolders();
const updateIcon = useUpdateFolderIcon();
@@ -27,22 +29,25 @@ export function FolderDetail({ folderId }: FolderDetailProps) {
{isLoading ? (
- Loading folder…
+ {t('folders.loading')}
) : !folder ? (
-
Folder not found.
+
{t('folders.notFound')}
- This folder may have been deleted.{' '}
- navigate('/')}
- style={{ color: 'var(--fg-1)' }}
- >
- Back to Home
-
- .
+ navigate('/')}
+ style={{ color: 'var(--fg-1)' }}
+ />
+ ),
+ }}
+ />
) : (
@@ -52,7 +57,7 @@ export function FolderDetail({ folderId }: FolderDetailProps) {
setIconPickerAnchor(e.currentTarget.getBoundingClientRect())}
@@ -72,7 +77,7 @@ export function FolderDetail({ folderId }: FolderDetailProps) {
className="pb-2 text-[13px] tabular-nums"
style={{ color: 'var(--fg-2)' }}
>
- {filtered.length} {filtered.length === 1 ? 'meeting' : 'meetings'}
+ {t('folders.meetingCount', { count: filtered.length })}
@@ -84,7 +89,7 @@ export function FolderDetail({ folderId }: FolderDetailProps) {
className="text-sm font-medium tracking-[-0.005em]"
style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }}
>
- Notes
+ {t('folders.notes')}
- Nothing here yet
+ {t('folders.emptyTitle')}
- Notes you save to this folder will show up here.
+ {t('folders.emptyHint')}
) : (
diff --git a/app/renderer/src/routes/Home.tsx b/app/renderer/src/routes/Home.tsx
index 566868bd..2ca6f77e 100644
--- a/app/renderer/src/routes/Home.tsx
+++ b/app/renderer/src/routes/Home.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
import { Calendar, ChevronLeft, ChevronRight, PencilLine, RefreshCw, Search, Square, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
import { cn, isMac } from '@/lib/utils';
import { MeetingsShell } from '@/components/MeetingsShell';
import { UpcomingCard } from '@/components/home/UpcomingCard';
@@ -26,6 +28,7 @@ interface HomeProps {
}
export function Home({ mode }: HomeProps) {
+ const { t } = useTranslation();
const meetings = useMeetings();
const folders = useFolders();
const calendar = useCalendarEvents();
@@ -209,7 +212,9 @@ export function Home({ mode }: HomeProps) {
if (!search.trim()) return previous;
return searchNotes(previous, search);
}, [mode, previous, search]);
- const groups = React.useMemo(() => groupPrevious(filtered), [filtered]);
+ // `t` is a dep on purpose: react-i18next hands back a new `t` when the
+ // language changes, which is what re-labels the groups without a remount.
+ const groups = React.useMemo(() => groupPrevious(filtered, t), [filtered, t]);
// Calendar-connect nudge: most new users don't realise Steno can
// surface their meetings until something tells them. Show a small
@@ -336,7 +341,7 @@ export function Home({ mode }: HomeProps) {
style={{ color: 'var(--fg-muted)' }}
>
- Connect your calendar to see today's meetings.
+ {t('home.calendarNudge.prompt')}
{!withDismiss && (
)}
@@ -348,8 +353,9 @@ export function Home({ mode }: HomeProps) {
style={{ color: 'var(--fg-muted)' }}
/>
- Connecting to {pendingProvider === 'google' ? 'Google' : 'Outlook'}
- …
+ {t('home.calendarNudge.connecting', {
+ provider: pendingProvider === 'google' ? 'Google' : 'Outlook',
+ })}
- Cancel
+ {t('common.cancel')}
>
) : (
@@ -366,7 +372,7 @@ export function Home({ mode }: HomeProps) {
className="size-3.5 flex-shrink-0"
style={{ color: 'var(--fg-muted)' }}
/>
- Connect:
+ {t('home.calendarNudge.connectLabel')}
startConnect('google')}
@@ -389,8 +395,8 @@ export function Home({ mode }: HomeProps) {
@@ -470,7 +476,7 @@ export function Home({ mode }: HomeProps) {
>
{meetings.isLoading ? (
- Loading meetings…
+ {t('home.loading')}
) : emptyState ? (
@@ -485,16 +491,16 @@ export function Home({ mode }: HomeProps) {
color: 'var(--fg-1)',
}}
>
- Welcome to Steno.
+ {t('home.empty.title')}
- AI for your confidential workflows.
+ {t('home.empty.tagline')}
- Always get consent when transcribing others.
+ {t('home.empty.consent')}
@@ -504,17 +510,19 @@ export function Home({ mode }: HomeProps) {
className="gap-2"
>
{isRecording ?
:
}
- {isRecording ? 'Stop recording' : 'New note'}
+ {isRecording ? t('home.empty.stopRecording') : t('home.empty.newNote')}
- Quick start:
+ {/* Split around the run rather than a single interpolated
+ sentence — the keys are real elements, not text. */}
+ {t('home.empty.quickStartLabel')}
{isMac ? '⌘' : 'Ctrl'}
{isMac ? '⇧' : 'Shift'}
R
- from anywhere
+ {t('home.empty.quickStartSuffix')}
{emptyStateCalendarNudge && (
@@ -570,7 +578,7 @@ export function Home({ mode }: HomeProps) {
return (
setUpcomingPage((p) => Math.max(0, p - 1))}
disabled={!canPagePrev}
style={{ color: 'var(--fg-2)' }}
@@ -590,7 +598,7 @@ export function Home({ mode }: HomeProps) {
setUpcomingPage((p) => Math.min(upcomingPageCount - 1, p + 1))}
disabled={!canPageNext}
style={{ color: 'var(--fg-2)' }}
@@ -602,7 +610,7 @@ export function Home({ mode }: HomeProps) {
calendar.refetch()}
disabled={calendar.isFetching}
style={{ color: 'var(--fg-2)' }}
@@ -664,7 +672,7 @@ export function Home({ mode }: HomeProps) {
{upcomingToday.length === 0 && tomorrowPreview && mode === 'home' && (
-
+
0 && mode === 'home' && (
-
+
setSearch(e.target.value)}
- placeholder="Search notes"
- aria-label="Search notes"
+ placeholder={t('home.search.placeholder')}
+ aria-label={t('home.search.placeholder')}
className="h-[26px] w-[180px] rounded-md border-0 pl-7 pr-7 text-[12.5px] outline-none transition-colors focus:shadow-[inset_0_0_0_1px_hsl(var(--border))]"
style={{
background: 'rgba(27,27,25,0.04)',
@@ -722,7 +734,7 @@ export function Home({ mode }: HomeProps) {
setSearch('');
searchInputRef.current?.focus();
}}
- aria-label="Clear search"
+ aria-label={t('home.search.clear')}
className="absolute right-1.5 top-1/2 -translate-y-1/2 inline-flex size-4 items-center justify-center rounded transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ color: 'var(--fg-muted)' }}
>
@@ -738,7 +750,7 @@ export function Home({ mode }: HomeProps) {
className="px-6 py-12 text-center text-[13px]"
style={{ color: 'var(--fg-2)' }}
>
- No meetings match “{search.trim()}”.
+ {t('home.search.noMatches', { query: search.trim() })}
) : (
groups.map((g) => (
@@ -810,6 +822,7 @@ interface AllDayInlineProps {
}
function AllDayInline({ events, expanded, onToggle }: AllDayInlineProps) {
+ const { t } = useTranslation();
if (events.length === 0) return null;
return (
@@ -820,7 +833,7 @@ function AllDayInline({ events, expanded, onToggle }: AllDayInlineProps) {
className="-mx-1 self-start rounded px-1 py-0.5 text-xs transition-colors hover:bg-[color:var(--surface-hover)]"
style={{ color: 'var(--fg-2)' }}
>
- + {events.length} all-day event{events.length === 1 ? '' : 's'} today
+ {t('home.allDay.toggle', { count: events.length })}
{expanded && (
// Render as full UpcomingCards so all-day events match the visual
@@ -852,7 +865,7 @@ interface Group {
items: Meeting[];
}
-function groupPrevious(meetings: Meeting[]): Group[] {
+function groupPrevious(meetings: Meeting[], t: TFunction): Group[] {
const groups: Record
= {};
const order: string[] = [];
const now = new Date();
@@ -863,7 +876,7 @@ function groupPrevious(meetings: Meeting[]): Group[] {
});
for (const m of sorted) {
const raw = m.session_info.processed_at ?? m.session_info.updated_at;
- const label = raw ? groupLabel(new Date(raw), now) : 'Earlier';
+ const label = raw ? groupLabel(new Date(raw), now, t) : t('home.day.earlier');
if (!groups[label]) {
groups[label] = [];
order.push(label);
@@ -873,15 +886,15 @@ function groupPrevious(meetings: Meeting[]): Group[] {
return order.map((label) => ({ label, items: groups[label] }));
}
-function groupLabel(d: Date, now: Date): string {
+function groupLabel(d: Date, now: Date, t: TFunction): string {
const sameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
- if (sameDay(d, now)) return 'Today';
+ if (sameDay(d, now)) return t('home.day.today');
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
- if (sameDay(d, yesterday)) return 'Yesterday';
+ if (sameDay(d, yesterday)) return t('home.day.yesterday');
const age = now.getTime() - d.getTime();
if (age < 7 * 24 * 60 * 60 * 1000) {
return d.toLocaleDateString(undefined, { weekday: 'long' });
diff --git a/app/renderer/src/routes/MeetingDetail.tsx b/app/renderer/src/routes/MeetingDetail.tsx
index 60bf3cac..43566303 100644
--- a/app/renderer/src/routes/MeetingDetail.tsx
+++ b/app/renderer/src/routes/MeetingDetail.tsx
@@ -1,5 +1,8 @@
import * as React from 'react';
import ReactMarkdown from 'react-markdown';
+import { Trans, useTranslation } from 'react-i18next';
+import { templateDisplayName } from '@/lib/templateName';
+import { meetingDisplayTitle } from '@/lib/meetingTitle';
import {
Calendar as CalendarIcon,
Check,
@@ -67,6 +70,7 @@ import { ipc, type Meeting, type Report, type Template } from '@/lib/ipc';
import { buildTranscriptBundle, defaultExportFilename } from '@/lib/transcriptBundle';
import { buildNotesCopyText, type StructuredNoteSections } from '@/lib/notesCopy';
import { buildNotesHtml, hasNotesContent } from '@/lib/notesPdf';
+import i18n from '@/lib/i18n';
import { unwrap } from '@/lib/result';
import { cn } from '@/lib/utils';
import { navigate } from '@/lib/router';
@@ -90,6 +94,7 @@ interface MeetingDetailProps {
}
export function MeetingDetail({ summaryFile }: MeetingDetailProps) {
+ const { t } = useTranslation();
const meeting = useMeeting(summaryFile);
useActiveMeeting(summaryFile, meeting.data?.session_info.name ?? null);
@@ -103,26 +108,26 @@ export function MeetingDetail({ summaryFile }: MeetingDetailProps) {
{meeting.isLoading || (meeting.isFetching && !meeting.data) ? (
- Loading meeting…
+ {t('meeting.loading')}
) : meeting.isError ? (
-
Couldn't load note.
+
{t('meeting.loadError.title')}
- {(meeting.error as Error)?.message ?? 'An error occurred loading this note.'}
+ {(meeting.error as Error)?.message ?? t('meeting.loadError.body')}
navigate('/meetings')}>
- Back to meetings
+ {t('meeting.backToMeetings')}
) : !meeting.data ? (
-
Note not found.
+
{t('meeting.notFound.title')}
- This recording may have been deleted. Pick another from the sidebar.
+ {t('meeting.notFound.body')}
navigate('/meetings')}>
- Back to meetings
+ {t('meeting.backToMeetings')}
) : (
@@ -146,6 +151,7 @@ function DetailContent({
* the backend keeps info.summary_file. */
routeSummaryFile: string;
}) {
+ const { t } = useTranslation();
const info = meeting.session_info;
const summaryFile = info.summary_file;
const date = formatDetailDate(info);
@@ -534,7 +540,7 @@ function DetailContent({
setCopiedTranscript(true);
setTimeout(() => setCopiedTranscript(false), 1500);
} catch (error) {
- setExportError(`Couldn't copy transcript: ${getErrorMessage(error)}`);
+ setExportError(t('meeting.error.copyTranscript', { error: getErrorMessage(error) }));
}
};
@@ -550,10 +556,12 @@ function DetailContent({
transcriptBundle
);
if (!res.success && res.error !== EXPORT_CANCELED_ERROR) {
- setExportError(`Couldn't save transcript: ${res.error || 'unknown error'}`);
+ setExportError(
+ t('meeting.error.saveTranscript', { error: res.error || t('meeting.error.unknown') })
+ );
}
} catch (error) {
- setExportError(`Couldn't save transcript: ${getErrorMessage(error)}`);
+ setExportError(t('meeting.error.saveTranscript', { error: getErrorMessage(error) }));
}
};
@@ -593,10 +601,12 @@ function DetailContent({
buildNotesHtml(noteSections)
);
if (!res.success && res.error !== EXPORT_CANCELED_ERROR) {
- setExportError(`Couldn't save notes: ${res.error || 'unknown error'}`);
+ setExportError(
+ t('meeting.error.saveNotes', { error: res.error || t('meeting.error.unknown') })
+ );
}
} catch (error) {
- setExportError(`Couldn't save notes: ${getErrorMessage(error)}`);
+ setExportError(t('meeting.error.saveNotes', { error: getErrorMessage(error) }));
}
};
@@ -688,7 +698,7 @@ function DetailContent({
streaming: reprocessStreaming,
// Always "Generate notes" — no separate Regenerate wording. Every
// record/continue → stop leaves this one CTA.
- label: 'Generate notes',
+ label: t('meeting.action.generateNotes'),
start: stableStartReprocess,
});
} else {
@@ -704,6 +714,7 @@ function DetailContent({
stableStartReprocess,
publishReprocess,
clearReprocess,
+ t,
]);
// My notes tab: an always-available editable notes layer, independent of
@@ -722,12 +733,12 @@ function DetailContent({
navigate('/')}
- aria-label="Back to home"
+ aria-label={t('meeting.action.backToHome')}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[12.5px] transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)]"
style={{ color: 'var(--fg-2)' }}
>
- Home
+ {t('meeting.action.home')}
@@ -736,19 +747,25 @@ function DetailContent({
clipboard would otherwise get the old note while the body
shows the in-flux streamed text. */}
{copied ? : }
- {copied ? 'Copied!' : 'Copy notes'}
+
+ {copied ? t('meeting.action.copiedConfirm') : t('meeting.action.copyNotes')}
+
void copyTranscriptForAi()}
disabled={!transcriptBundle}
>
@@ -760,7 +777,9 @@ function DetailContent({
- {copiedTranscript ? 'Copied!' : 'Copy transcript'}
+ {copiedTranscript
+ ? t('meeting.action.copiedConfirm')
+ : t('meeting.action.copyTranscript')}
{/* Re-runs summarisation on the existing transcript. A
@@ -771,7 +790,7 @@ function DetailContent({
- Generate notes
+ {t('meeting.action.generateNotes')}
)}
@@ -815,7 +834,7 @@ function DetailContent({
}}
>
- View containing folder
+ {t('meeting.action.viewContainingFolder')}
- Save transcript as .md…
+ {t('meeting.action.saveTranscriptMarkdown')}
- Save notes as PDF…
+ {t('meeting.action.saveNotesPdf')}
{/* Re-transcribe (#266): only when the source recording still
exists (keep-recordings was on). Disabled while a stream is on
@@ -855,7 +874,7 @@ function DetailContent({
}
>
- Re-transcribe recording
+ {t('meeting.action.retranscribeRecording')}
)}
{orgSession.data?.signedIn &&
@@ -866,10 +885,10 @@ function DetailContent({
style={{ color: 'var(--fg-1)' }}
onClick={() => setUnshareOpen(true)}
disabled={isUnsharing}
- title={`Unshare from ${orgSession.data.orgId}`}
+ title={t('meeting.share.unshareFrom', { org: orgSession.data.orgId })}
>
- {`Unshare from ${orgSession.data.orgId}`}
+ {t('meeting.share.unshareFrom', { org: orgSession.data.orgId })}
) : (
{isSharing
- ? 'Sharing…'
+ ? t('meeting.share.sharing')
: shareError
- ? `Share failed: ${shareError}`
- : `Share with ${orgSession.data.orgId}`}
+ ? t('meeting.share.failed', { error: shareError })
+ : t('meeting.share.shareWith', { org: orgSession.data.orgId })}
))}
{/* Deletes straight away, no confirm step: the delete is a
@@ -907,7 +926,9 @@ function DetailContent({
await deleteMeeting.mutateAsync(meeting);
} catch (err) {
setDeleteError(
- `Delete failed: ${err instanceof Error ? err.message : String(err)}`,
+ t('meeting.error.deleteFailed', {
+ error: err instanceof Error ? err.message : String(err),
+ })
);
return;
}
@@ -915,7 +936,7 @@ function DetailContent({
}}
>
- Delete note
+ {t('meeting.action.deleteNote')}
@@ -965,8 +986,8 @@ function DetailContent({
disabled={
titleRegening || reprocess.isPending || streamPhase !== 'idle' || isEditingTitle
}
- aria-label="Regenerate title"
- title="Regenerate title"
+ aria-label={t('meeting.action.regenerateTitle')}
+ title={t('meeting.action.regenerateTitle')}
className={cn(
'inline-flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100 disabled:pointer-events-none',
titleRegening && 'opacity-100'
@@ -1011,7 +1032,11 @@ function DetailContent({
) : (
setIsEditingTitle(true)}>
- {info.name}
+ {/* Display only. The contentEditable branch above deliberately
+ keeps info.name raw: the placeholder is a protocol token the
+ backend replaces with a generated title, and saving a
+ translated version would break that match forever. */}
+ {meetingDisplayTitle(info.name)}
)}
@@ -1025,7 +1050,7 @@ function DetailContent({
/>
{participants.length > 0 && (
}>
- {participants.length} {participants.length === 1 ? 'person' : 'people'}
+ {t('meeting.participantCount', { count: participants.length })}
)}
{/* Quiet, non-alarming backup status for org users — a calm
@@ -1037,11 +1062,11 @@ function DetailContent({
onClick={() => void onShareToOrg()}
title={
backupError
- ? `Last backup failed: ${backupError}. Click to retry.`
- : 'This note has not been backed up. Click to retry.'
+ ? t('meeting.backup.failedWithReason', { error: backupError })
+ : t('meeting.backup.failed')
}
>
- {isSharing ? 'Backing up…' : 'Not backed up'}
+ {isSharing ? t('meeting.backup.backingUp') : t('meeting.backup.notBackedUp')}
)}
@@ -1078,21 +1103,20 @@ function DetailContent({
data-testid="reprocess-retry"
>
- Notes weren’t generated
+ {t('meeting.reprocessFailed.title')}
- That didn’t work this time — give it another go. If it keeps failing on a long
- meeting, switch to a smaller model in Settings.
+ {t('meeting.reprocessFailed.body')}
- Generate notes
+ {t('meeting.action.generateNotes')}
)}
@@ -1118,21 +1142,20 @@ function DetailContent({
data-testid="transcription-failed-notice"
>
- Transcription failed
+ {t('meeting.transcriptionFailed.title')}
- No notes could be generated for this recording. Your audio was preserved (not
- deleted), so nothing was lost.
+ {t('meeting.transcriptionFailed.body')}
{transcriptionError && (
- Details: {transcriptionError}
+ {t('meeting.transcriptionFailed.details', { error: transcriptionError })}
)}
@@ -1149,7 +1172,7 @@ function DetailContent({
className="flex items-center gap-1.5 text-[15px] font-medium"
style={{ color: 'var(--fg-1)' }}
>
- Finishing up
+ {t('meeting.processing.title')}
@@ -1158,13 +1181,12 @@ function DetailContent({
className="text-[14px] leading-[1.6]"
style={{ color: 'var(--fg-2)', maxWidth: '64ch' }}
>
- Your transcript is captured — refining it and generating notes in the
- background. You can read and edit My notes now.
+ }} />
) : summary ? (
- Summary
+ {t('meeting.section.summary')}
{stripReasoning(summary)
.split(/\n{2,}/)
@@ -1189,26 +1211,24 @@ function DetailContent({
data-testid="no-notes-yet"
>
- No notes yet
+ {t('meeting.noNotesYet.title')}
- This recording was transcribed but notes were not generated automatically. Use
- the Generate notes button below to create them, or copy or save
- the transcript from the actions above.
+ }} />
) : (
- No summary available for this meeting.
+ {t('meeting.noSummary')}
)}
{discussionAreas.length > 0 && (
- Key topics
+ {t('meeting.section.keyTopics')}
{discussionAreas.map((area, i) => (
@@ -1222,7 +1242,7 @@ function DetailContent({
{keyPoints.length > 0 && (
- Key points
+ {t('meeting.section.keyPoints')}
{keyPoints.map((p, i) => (
{p}
@@ -1233,7 +1253,7 @@ function DetailContent({
{actionItems.length > 0 && (
- Action items
+ {t('meeting.section.actionItems')}
{actionItems.map((a, i) => (
{a}
@@ -1244,7 +1264,7 @@ function DetailContent({
{participants.length > 0 && (
- Participants
+ {t('meeting.section.participants')}
{participants.map((p, i) => (
{p}
@@ -1265,12 +1285,13 @@ function DetailContent({
- Unshare from {orgSession.data?.signedIn ? orgSession.data.orgId : 'your org'}?
+ {t('meeting.unshareDialog.title', {
+ org: orgSession.data?.signedIn
+ ? orgSession.data.orgId
+ : t('meeting.unshareDialog.yourOrg'),
+ })}
-
- The shared copy will be removed from your organisation. Your local note stays on this
- device. You can re-share at any time.
-
+ {t('meeting.unshareDialog.body')}
{shareError && (
@@ -1279,14 +1300,16 @@ function DetailContent({
)}
- Cancel
+ {t('common.cancel')}
void onUnshareFromOrg()}
>
- {isUnsharing ? 'Unsharing…' : 'Unshare'}
+ {isUnsharing
+ ? t('meeting.unshareDialog.pending')
+ : t('meeting.unshareDialog.confirm')}
@@ -1295,9 +1318,10 @@ function DetailContent({
{
setRetranscribeOpen(false);
@@ -1347,14 +1371,15 @@ function NoteViewToggle({
onGenerate: (templateId: string) => void;
generating: boolean;
}) {
+ const { t } = useTranslation();
const [menuOpen, setMenuOpen] = React.useState(false);
const [deleteTarget, setDeleteTarget] = React.useState(null);
const notesActive = tab === 'notes';
const summaryActive = tab === 'summary';
const activeLabel =
activeReportId === null
- ? 'Summary'
- : (reports.find((r) => r.id === activeReportId)?.template_name ?? 'Summary');
+ ? t('meeting.view.summary')
+ : (reports.find((r) => r.id === activeReportId)?.template_name ?? t('meeting.view.summary'));
const selectView = (id: string | null) => {
setMenuOpen(false);
@@ -1371,7 +1396,7 @@ function NoteViewToggle({
@@ -1389,7 +1414,7 @@ function NoteViewToggle({
}}
>
- My notes
+ {t('meeting.view.myNotes')}
{hasNotes && !notesActive && (
- {generating ? 'Generating…' : activeLabel}
+ {generating ? t('meeting.view.generating') : activeLabel}
selectView(null)}
>
- Summary
+ {t('meeting.view.summary')}
{reports.map((r) => {
const meta = [r.model, formatReportDate(r.created_at)].filter(Boolean).join(' · ');
@@ -1464,7 +1489,7 @@ function NoteViewToggle({
{
e.stopPropagation();
setDeleteTarget(r);
@@ -1484,7 +1509,7 @@ function NoteViewToggle({
className="px-3 pb-1 pt-1.5 text-[11px] font-medium"
style={{ color: 'var(--fg-muted)' }}
>
- Generate from template
+ {t('meeting.view.generateFromTemplate')}
{templates.map((t) => (
- {t.name}
+ {templateDisplayName(t)}
))}
>
@@ -1509,9 +1534,12 @@ function NoteViewToggle({
!o && setDeleteTarget(null)}
- title={deleteTarget ? `Delete report "${deleteTarget.template_name}"?` : ''}
- description="This permanently deletes this generated report. The transcript and other reports are not affected."
- confirmLabel="Delete"
+ title={
+ deleteTarget ? t('meeting.report.deleteTitle', { name: deleteTarget.template_name }) : ''
+ }
+ description={t('meeting.report.deleteBody')}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
destructive
onConfirm={() => {
if (!deleteTarget) return;
@@ -1559,6 +1587,7 @@ function MyNotesEditor({
summaryFile: string;
initialNotes: string;
}) {
+ const { t } = useTranslation();
const [value, setValue] = React.useState(initialNotes);
const save = useUpdateUserNotes();
const timerRef = React.useRef(null);
@@ -1591,7 +1620,7 @@ function MyNotesEditor({
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={flush}
- placeholder="Write notes…"
+ placeholder={t('meeting.notes.placeholder')}
spellCheck
data-testid="my-notes-input"
className="block w-full resize-none border-0 bg-transparent text-[15.5px] outline-none"
@@ -1714,6 +1743,7 @@ function StreamingView({
phase: StreamPhase;
chunkProgress?: { step: number; total: number } | null;
}) {
+ const { t } = useTranslation();
const blocks = parseMarkdownBlocks(stripReasoning(text));
const isStreaming = phase === 'analyzing' || phase === 'generating';
@@ -1820,10 +1850,13 @@ function StreamingView({
);
const indicatorLabel = chunkProgress
- ? `Summarising part ${chunkProgress.step}/${chunkProgress.total}`
+ ? t('meeting.stream.summarisingPart', {
+ step: chunkProgress.step,
+ total: chunkProgress.total,
+ })
: phase === 'analyzing'
- ? 'Analysing transcript'
- : 'Generating notes';
+ ? t('meeting.stream.analysing')
+ : t('meeting.stream.generatingNotes');
return (
@@ -1891,6 +1924,7 @@ function FolderPicker({
summaryFile: string;
assignedFolderIds: string[];
}) {
+ const { t } = useTranslation();
const folders = useFolders();
const addMeeting = useAddMeetingToFolder();
const removeMeeting = useRemoveMeetingFromFolder();
@@ -1993,7 +2027,7 @@ function FolderPicker({
}
}}
onBlur={() => void submitNewFolder()}
- placeholder="Folder name..."
+ placeholder={t('meeting.folder.namePlaceholder')}
className="w-28 bg-transparent outline-none placeholder:text-muted-foreground"
/>
@@ -2002,7 +2036,7 @@ function FolderPicker({
);
}
- const currentFolderLabel = currentFolder?.name ?? 'Add to folder';
+ const currentFolderLabel = currentFolder?.name ?? t('meeting.folder.addToFolder');
return (
@@ -2013,7 +2047,7 @@ function FolderPicker({
- No folder
+ {t('meeting.folder.none')}
{allFolders.map((f) => (
@@ -2021,7 +2055,7 @@ function FolderPicker({
))}
- New folder...
+ {t('meeting.folder.new')}
{folderError &&
{folderError}
}
@@ -2107,10 +2141,12 @@ function asDiscussionAreas(value: unknown): DiscussionArea[] {
.filter((v): v is DiscussionArea => v !== null);
}
+// Not a component, so it reaches for the shared i18next instance rather than
+// the hook — the fallback below is the only user-visible string in here.
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error.trim()) return error;
- return 'Something went wrong.';
+ return i18n.t('meeting.error.generic');
}
/** Pick which transcript flavour to ship to org. Diarised text (with
diff --git a/app/renderer/src/routes/OrgShared.tsx b/app/renderer/src/routes/OrgShared.tsx
index 5b922b66..71b0cffc 100644
--- a/app/renderer/src/routes/OrgShared.tsx
+++ b/app/renderer/src/routes/OrgShared.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { Trans, useTranslation } from 'react-i18next';
import { ArrowLeft, Globe, Loader2, Lock, MoreHorizontal, Trash2, Users } from 'lucide-react';
import { MeetingsShell } from '@/components/MeetingsShell';
import { Button } from '@/components/ui/button';
@@ -11,6 +12,7 @@ import { cn } from '@/lib/utils';
import { useActiveOrgMeeting } from '@/lib/askBarContext';
import { renderMarkdown } from '@/lib/markdown';
import { navigate } from '@/lib/router';
+import i18n from '@/lib/i18n';
import {
useOrgMeeting,
useOrgMeetings,
@@ -22,11 +24,16 @@ function formatDate(epoch: number): string {
const d = new Date(epoch * 1000);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
- if (sameDay) return 'today, ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ if (sameDay) {
+ return i18n.t('org.todayAt', {
+ time: d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
+ });
+ }
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function NotSignedIn() {
+ const { t } = useTranslation();
return (
@@ -37,14 +44,13 @@ function NotSignedIn() {
- Connect your organisation
+ {t('org.connectTitle')}
- Sign in to your Steno enterprise adapter to see notes shared by your
- colleagues and chat across them.
+ {t('org.connectDescription')}
navigate('/settings')} className="mt-2">
- Open Settings → Organisation
+ {t('org.openSettings')}
@@ -56,13 +62,14 @@ function NotSignedIn() {
// ----------------------------------------------------------------------------
export function OrgShared() {
+ const { t } = useTranslation();
const session = useOrgSession();
const meetings = useOrgMeetings(session.data?.signedIn ?? false);
if (session.isLoading) {
return (
- Loading…
+ {t('app.loading')}
);
}
@@ -79,15 +86,15 @@ export function OrgShared() {
className="m-0 text-[28px] font-normal"
style={{ fontFamily: 'var(--font-serif)', letterSpacing: '-0.02em', color: 'var(--fg-1)' }}
>
- Shared notes
+ {t('org.sharedNotes')}
- {session.data.orgId} · {rows.length} {rows.length === 1 ? 'note' : 'notes'}
+ {session.data.orgId} · {t('org.noteCount', { count: rows.length })}
{meetings.isLoading ? (
- Loading notes…
+ {t('org.loadingNotes')}
) : meetings.error ? (
{(meetings.error as Error).message}
@@ -101,9 +108,13 @@ export function OrgShared() {
border: '1px dashed var(--border-subtle)',
}}
>
- No shared notes yet — share one of your meetings with{' '}
- {session.data.orgId} {' '}
- to see it here.
+ ,
+ }}
+ />
) : (
@@ -130,6 +141,7 @@ export function OrgShared() {
// ----------------------------------------------------------------------------
export function OrgSharedDetail({ id }: { id: string }) {
+ const { t } = useTranslation();
const session = useOrgSession();
// Don't fire the GET /meetings/:id call until we know the user is
// signed in — otherwise the query will 401 (and clear the session)
@@ -174,11 +186,11 @@ export function OrgSharedDetail({ id }: { id: string }) {
className="mb-5 inline-flex items-center gap-1.5 text-[12px] hover:text-[color:var(--fg-1)]"
style={{ color: 'var(--fg-2)' }}
>
- Shared notes
+ {t('org.sharedNotes')}
{meeting.isLoading ? (
- Loading…
+ {t('app.loading')}
) : meeting.error ? (
{(meeting.error as Error).message}
@@ -193,14 +205,14 @@ export function OrgSharedDetail({ id }: { id: string }) {
{meeting.data.title}
- shared by {meeting.data.owner_email}
+ {t('org.sharedBy', { email: meeting.data.owner_email })}
·
{formatDate(meeting.data.created_at)}
{meeting.data.has_artifact && (
<>
·
-
- from S3
+
+ {t('org.fromS3')}
>
)}
@@ -219,7 +231,7 @@ export function OrgSharedDetail({ id }: { id: string }) {
{renderMarkdown(bodyText)}
) : (
- (no body)
+ {t('org.noBody')}
)}
{/* Bottom of the page — buffer so the global AskBar doesn't
overlap the last paragraph when the conversation expands. */}
@@ -244,6 +256,7 @@ interface SharedRowProps {
}
function SharedRow({ id, title, visibility, ownerEmail, createdAt, isOwner }: SharedRowProps) {
+ const { t } = useTranslation();
const unshare = useUnshareOrgMeeting();
const [menuOpen, setMenuOpen] = React.useState(false);
const [error, setError] = React.useState(null);
@@ -269,7 +282,7 @@ function SharedRow({ id, title, visibility, ownerEmail, createdAt, isOwner }: Sh
{visibility === 'org' ?
:
}
-
{isOwner ? 'you' : ownerEmail}
+
{isOwner ? t('org.you') : ownerEmail}
·
{formatDate(createdAt)}
{error && (
@@ -286,8 +299,8 @@ function SharedRow({ id, title, visibility, ownerEmail, createdAt, isOwner }: Sh
e.stopPropagation()}
- aria-label="Note actions"
- title="Actions"
+ aria-label={t('org.noteActions')}
+ title={t('app.actions')}
className={cn(
'inline-flex size-7 shrink-0 items-center justify-center rounded-md transition-opacity hover:bg-[color:var(--surface-active)]',
menuOpen ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus:opacity-100',
@@ -314,7 +327,7 @@ function SharedRow({ id, title, visibility, ownerEmail, createdAt, isOwner }: Sh
style={{ color: 'var(--danger)' }}
>
- Unshare
+ {t('org.unshare')}
diff --git a/app/renderer/src/routes/Processing.tsx b/app/renderer/src/routes/Processing.tsx
index 05472be6..350b1942 100644
--- a/app/renderer/src/routes/Processing.tsx
+++ b/app/renderer/src/routes/Processing.tsx
@@ -1,4 +1,6 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
import {
Calendar as CalendarIcon,
ChevronLeft,
@@ -17,14 +19,22 @@ import { stripReasoning } from '@/lib/markdown';
type ProcessingStage = 'transcribing' | 'summarizing' | 'finalizing' | 'error';
-const STAGE_LABEL: Record
= {
- transcribing: 'Analyzing transcript',
- summarizing: 'Generating notes',
- finalizing: 'Almost done…',
- error: 'Couldn’t process this recording.',
+/** Map-reduce sub-progress inside the summarizing stage. */
+type ChunkProgress =
+ | { kind: 'reducing' }
+ | { kind: 'part'; step: number; total: number };
+
+/** Translation key per stage — resolved through `t` at render time so a
+ * language switch re-labels the running spinner. */
+const STAGE_LABEL_KEY: Record = {
+ transcribing: 'processing.stage.transcribing',
+ summarizing: 'processing.stage.summarizing',
+ finalizing: 'processing.stage.finalizing',
+ error: 'processing.stage.error',
};
export function Processing() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const recording = useRecording();
const updateMeeting = useUpdateMeeting();
@@ -46,7 +56,10 @@ export function Processing() {
const [stage, setStage] = React.useState('transcribing');
- const [chunkProgress, setChunkProgress] = React.useState(null);
+ // Held structurally rather than as a pre-formatted sentence so the IPC
+ // subscription below never has to depend on `t` (re-subscribing the stream
+ // listeners on a language switch could drop an in-flight chunk).
+ const [chunkProgress, setChunkProgress] = React.useState(null);
const [streamText, setStreamText] = React.useState('');
const [streamedTitle, setStreamedTitle] = React.useState(null);
// Preserved source-audio path from a hard processing crash — the only handle
@@ -120,11 +133,11 @@ export function Processing() {
if (e.summaryFile) return;
const raw = e.line.replace(/^PROGRESS:summarize:/, '');
if (raw === 'reducing') {
- setChunkProgress('Merging summaries…');
+ setChunkProgress({ kind: 'reducing' });
} else {
const [step, total] = raw.split('/').map(Number);
if (!Number.isNaN(step) && !Number.isNaN(total)) {
- setChunkProgress(`Summarizing part ${step} of ${total}…`);
+ setChunkProgress({ kind: 'part', step, total });
}
}
setStage((s) => (s === 'transcribing' ? 'summarizing' : s));
@@ -145,7 +158,7 @@ export function Processing() {
try {
const res = await ipc().recording.processFile(retryAudioFile, activeSession);
if (!res.success) {
- setRetryError(res.error || 'Couldn’t restart processing. Please try again.');
+ setRetryError(res.error || t('processing.error.restartFailed'));
return;
}
pendingChunkRef.current = '';
@@ -156,15 +169,15 @@ export function Processing() {
setStage('transcribing');
} catch (err) {
setRetryError(
- err instanceof Error ? err.message : 'Couldn’t restart processing. Please try again.',
+ err instanceof Error ? err.message : t('processing.error.restartFailed'),
);
} finally {
setRetrying(false);
}
- }, [retryAudioFile, activeSession, retrying]);
+ }, [retryAudioFile, activeSession, retrying, t]);
const displayTitle =
- streamedTitle ?? draft?.title ?? activeSession ?? 'Note';
+ streamedTitle ?? draft?.title ?? activeSession ?? t('processing.untitledNote');
return (
@@ -175,10 +188,10 @@ export function Processing() {
onClick={() => navigate('/')}
className="mb-6 inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent text-[13px] transition-colors hover:text-[color:var(--fg-1)]"
style={{ color: 'var(--fg-2)' }}
- aria-label="Back to home"
+ aria-label={t('processing.backToHome')}
>
- Home
+ {t('processing.home')}
- My notes
+ {t('processing.myNotes')}
- {chunkProgress && stage === 'summarizing' ? chunkProgress : STAGE_LABEL[stage]}
+ {chunkProgress && stage === 'summarizing'
+ ? formatChunkProgress(chunkProgress, t)
+ : t(STAGE_LABEL_KEY[stage])}
@@ -360,21 +376,20 @@ function ErrorPanel({
retrying: boolean;
error: string | null;
}) {
+ const { t } = useTranslation();
return (
- {STAGE_LABEL.error}
+ {t(STAGE_LABEL_KEY.error)}
- {canRetry
- ? 'Try again to re-run processing on this recording.'
- : 'This recording couldn’t be recovered automatically. Try importing the audio file again from Home.'}
+ {canRetry ? t('processing.error.canRetry') : t('processing.error.cannotRetry')}
{error && (
{retrying && }
- {retrying ? 'Retrying…' : 'Try again'}
+ {retrying ? t('processing.error.retrying') : t('processing.error.tryAgain')}
);
}
function ProcessingChip() {
+ const { t } = useTranslation();
return (
- Processing
+ {t('processing.chip')}
);
}
@@ -446,6 +462,7 @@ function Chip({
// ---------------------------------------------------------------------------
export function ProcessingDock() {
+ const { t } = useTranslation();
const recording = useRecording();
const sessionName = recording.sessionName;
const draft = useLiveDraftStore((s) =>
@@ -473,7 +490,7 @@ export function ProcessingDock() {
size={14}
style={{ color: 'var(--fg-2)' }}
/>
- Processing
+ {t('processing.chip')}
Date.now());
React.useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
@@ -505,7 +523,7 @@ function ElapsedTimer({ startedAt, fallbackElapsed }: { startedAt: Date | null,
? Math.max(0, Math.floor((now - startedAt.getTime()) / 1000))
: fallbackElapsed;
- return <>{formatDurationEnglish(totalElapsedSeconds)}>;
+ return <>{formatDuration(totalElapsedSeconds, t)}>;
}
function formatDate(d: Date): string {
@@ -517,13 +535,22 @@ function formatDate(d: Date): string {
});
}
-/** Plain-English duration ("12 min", "1 h 4 min"). Mono is reserved for the live timer. */
-function formatDurationEnglish(seconds: number): string {
- if (seconds < 60) return `${seconds} sec`;
+/** Localised prose duration ("12 min", "1 h 4 min"). Mono is reserved for the
+ * live timer. Pure — `t` is passed in so this stays callable outside a hook. */
+function formatDuration(seconds: number, t: TFunction): string {
+ if (seconds < 60) return t('processing.duration.seconds', { count: seconds });
const totalMinutes = Math.floor(seconds / 60);
- if (totalMinutes < 60) return `${totalMinutes} min`;
+ if (totalMinutes < 60) return t('processing.duration.minutes', { count: totalMinutes });
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
- if (m === 0) return `${h} h`;
- return `${h} h ${m} min`;
+ if (m === 0) return t('processing.duration.hours', { count: h });
+ // Pluralised on the trailing minutes; the hour count is a plain placeholder.
+ return t('processing.duration.hoursMinutes', { hours: h, count: m });
+}
+
+/** Map-reduce sub-progress as one sentence. Pure, same reasoning as above. */
+function formatChunkProgress(progress: ChunkProgress, t: TFunction): string {
+ return progress.kind === 'reducing'
+ ? t('processing.progress.merging')
+ : t('processing.progress.part', { step: progress.step, total: progress.total });
}
diff --git a/app/renderer/src/routes/Recording.tsx b/app/renderer/src/routes/Recording.tsx
index d26fe2a7..1ea0f05c 100644
--- a/app/renderer/src/routes/Recording.tsx
+++ b/app/renderer/src/routes/Recording.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import {
Calendar as CalendarIcon,
ChevronLeft,
@@ -12,6 +13,7 @@ import { useRecording } from '@/hooks/useRecording';
import { useLiveMeeting } from '@/hooks/useLiveMeeting';
export function Recording() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const recording = useRecording();
const live = useLiveMeeting();
@@ -57,16 +59,16 @@ export function Recording() {
onClick={() => navigate('/')}
className="mb-6 inline-flex cursor-pointer items-center gap-1 border-0 bg-transparent text-[13px] transition-colors hover:text-[color:var(--fg-1)]"
style={{ color: 'var(--fg-2)' }}
- aria-label="Back to home"
+ aria-label={t('recording.backToHome')}
>
- Home
+ {t('recording.home')}
@@ -74,10 +76,10 @@ export function Recording() {
{formatDate(startedAt)}
}>
- Started {formatTime(startedAt)}
+ {t('recording.startedAt', { time: formatTime(startedAt) })}
} dashed>
- Add to folder
+ {t('recording.addToFolder')}
@@ -88,12 +90,12 @@ export function Recording() {
style={{ color: 'var(--fg-2)' }}
>
- My notes
+ {t('recording.myNotes')}
diff --git a/app/renderer/src/routes/Setup.tsx b/app/renderer/src/routes/Setup.tsx
index 5fa33203..0c357569 100644
--- a/app/renderer/src/routes/Setup.tsx
+++ b/app/renderer/src/routes/Setup.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { Check, Cloud, HardDrive, Mic, MessageSquare, Zap, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -53,11 +54,12 @@ interface Step {
* begins - the status label carries the current phase so the bar never reads
* as a single misleading aggregate. */
function OllamaProgressBar({ status, pct }: { status: string; pct: number }) {
+ const { t } = useTranslation();
const clamped = Math.max(0, Math.min(100, Math.round(pct)));
return (
- {status || 'Downloading model...'}
+ {status || t('setup.progress.ollamaFallback')}
{clamped}%
>({
microphone: 'waiting',
@@ -313,21 +310,21 @@ export function Setup() {
const snapshot = statuses;
try {
if (snapshot.microphone !== 'done') {
- setStatus('microphone', 'running', 'Checking permission...');
+ setStatus('microphone', 'running', t('setup.status.checkingPermission'));
const existing = await checkMic.mutateAsync();
if (existing === 'granted') {
- setStatus('microphone', 'done', 'Permission granted');
+ setStatus('microphone', 'done', t('setup.status.permissionGranted'));
} else {
- setStatus('microphone', 'running', 'Requesting permission...');
+ setStatus('microphone', 'running', t('setup.status.requestingPermission'));
const granted = await requestMic.mutateAsync();
- if (granted) setStatus('microphone', 'done', 'Permission granted');
+ if (granted) setStatus('microphone', 'done', t('setup.status.permissionGranted'));
else {
setStatus(
'microphone',
'failed',
isMac
- ? 'Permission denied. Grant it in System Settings.'
- : 'Permission denied. Grant it in Settings > Privacy & security > Microphone.',
+ ? t('setup.status.permissionDeniedMac')
+ : t('setup.status.permissionDeniedWindows'),
);
setRunning(false);
return;
@@ -336,7 +333,7 @@ export function Setup() {
}
if (snapshot.transcription !== 'done') {
- setStatus('transcription', 'running', 'Checking transcription model...');
+ setStatus('transcription', 'running', t('setup.status.checkingTranscriptionModel'));
// Skip the install if any ASR engine is already on disk — covers
// existing Whisper users running setup again and Parakeet users
// rerunning to fix a different step.
@@ -352,12 +349,16 @@ export function Setup() {
(m) => (m as { installed?: boolean }).installed === true,
);
if (parakeetInstalled || anyWhisperInstalled) {
- setStatus('transcription', 'done', 'Transcription model ready');
+ setStatus('transcription', 'done', t('setup.status.transcriptionModelReady'));
} else {
- setStatus('transcription', 'running', `Downloading Parakeet TDT v3 (${isMac ? '~572 MB' : '~670 MB'})...`);
+ setStatus(
+ 'transcription',
+ 'running',
+ t('setup.status.downloadingParakeet', { size: isMac ? '~572 MB' : '~670 MB' }),
+ );
await parakeetStep.mutateAsync();
setParakeetStage(null);
- setStatus('transcription', 'done', 'Transcription model ready');
+ setStatus('transcription', 'done', t('setup.status.transcriptionModelReady'));
}
}
@@ -367,7 +368,7 @@ export function Setup() {
setStatus('ollama', 'running', '');
if (summaryMode === 'cloud') {
- setStatus('ollama', 'running', 'Saving cloud credentials...');
+ setStatus('ollama', 'running', t('setup.status.savingCloudCredentials'));
// Persist provider preference + key, then verify with a small ping
// call so the user gets immediate feedback if the key is bad.
await setAiProvider.mutateAsync('cloud');
@@ -380,20 +381,20 @@ export function Setup() {
if (apiUrl) await setCloudUrl.mutateAsync(apiUrl.trim());
}
await setCloudKeyMut.mutateAsync(cloudApiKey.trim());
- setStatus('ollama', 'running', 'Testing connection...');
+ setStatus('ollama', 'running', t('setup.status.testingConnection'));
// unwrap throws on { success: false } so reaching this line means the
// provider responded successfully — no extra check needed.
await testCloudApi.mutateAsync();
- setStatus('ollama', 'done', `Connected to ${cloudProvider}`);
+ setStatus('ollama', 'done', t('setup.status.connectedTo', { provider: cloudProvider }));
} else {
// Make sure provider is local in case the user previously had cloud
// configured and is re-running the wizard to switch back.
await setAiProvider.mutateAsync('local');
ipc().analytics.track('ai_provider_selected', { provider: 'local' });
- setStatus('ollama', 'running', 'Downloading model (~2 GB)...');
+ setStatus('ollama', 'running', t('setup.status.downloadingModel'));
await ollamaStep.mutateAsync();
setOllamaProgress(null);
- setStatus('ollama', 'done', 'Model installed');
+ setStatus('ollama', 'done', t('setup.status.modelInstalled'));
}
// Fire unconditionally regardless of which path was taken above -- this
@@ -416,7 +417,7 @@ export function Setup() {
setDone(true);
} catch (err) {
- const message = err instanceof Error ? err.message : 'Setup step failed';
+ const message = err instanceof Error ? err.message : t('setup.status.stepFailed');
// Clear the bars on failure - a failed step keeps its Failed badge +
// error detail, not a frozen progress bar.
setParakeetStage(null);
@@ -435,31 +436,31 @@ export function Setup() {
const steps: Step[] = [
{
id: 'microphone',
- title: 'Microphone Access',
- description: 'Required for recording meetings',
+ title: t('setup.steps.microphone.title'),
+ description: t('setup.steps.microphone.description'),
icon: Mic,
status: statuses.microphone,
detail: details.microphone,
},
{
id: 'transcription',
- title: 'Transcription Model',
- description: 'Converts speech to text locally',
+ title: t('setup.steps.transcription.title'),
+ description: t('setup.steps.transcription.description'),
icon: MessageSquare,
status: statuses.transcription,
detail: details.transcription,
progressNode:
statuses.transcription === 'running' && parakeetStage !== null ? (
-
+
) : undefined,
},
{
id: 'ollama',
- title: 'Summarization Engine',
+ title: t('setup.steps.summarization.title'),
description:
summaryMode === 'cloud'
- ? 'Cloud API — fast, no download'
- : 'Local model (~2 GB) — private, runs on your device',
+ ? t('setup.steps.summarization.descriptionCloud')
+ : t('setup.steps.summarization.descriptionLocal'),
icon: summaryMode === 'cloud' ? Cloud : Zap,
status: statuses.ollama,
detail: details.ollama,
@@ -481,8 +482,8 @@ export function Setup() {
- Welcome to Steno
- We'll help you set up everything needed for meeting intelligence.
+ {t('setup.title')}
+ {t('setup.subtitle')}
- What should we call you?
+ {t('setup.name.label')}
-
- First name only — used for in-app greetings. Stored locally.
-
+ {t('setup.name.hint')}
@@ -527,7 +526,7 @@ export function Setup() {
data-setup-summary-chooser
>
- How should Steno summarize meetings?
+ {t('setup.chooser.title')}
- Local
+ {t('setup.chooser.local')}
{summaryMode === 'local' && }
-
- Private. Free. ~2 GB download.
-
+
{t('setup.chooser.localHint')}
- Cloud
+ {t('setup.chooser.cloud')}
{summaryMode === 'cloud' && }
-
- Fast. Higher quality. Bring your own API key.
-
+
{t('setup.chooser.cloudHint')}
@@ -583,7 +578,7 @@ export function Setup() {
className="mb-1 block text-[12px] font-medium text-foreground"
htmlFor="setup-cloud-provider"
>
- Provider
+ {t('setup.cloud.providerLabel')}
OpenAI
Anthropic (Claude)
AWS Bedrock (Claude)
- Custom (OpenAI-compatible)
+ {t('setup.cloud.providerCustom')}
@@ -607,7 +602,7 @@ export function Setup() {
className="mb-1 block text-[12px] font-medium text-foreground"
htmlFor="setup-bedrock-region"
>
- AWS region
+ {t('setup.cloud.regionLabel')}
- Inference profile (optional)
+ {t('setup.cloud.profileLabel')}
- API base URL
+ {t('setup.cloud.apiUrlLabel')}
- API key
+ {t('setup.cloud.apiKeyLabel')}
-
- Stored locally on this device. Never synced or sent
- anywhere except the provider you select.
-
+
{t('setup.cloud.apiKeyHint')}
)}
@@ -686,18 +678,15 @@ export function Setup() {
>
- Anonymous usage analytics
+ {t('setup.telemetry.title')}
-
- Help improve Steno — meeting content is never sent. You can
- change this any time in Settings → Advanced.
-
+
{t('setup.telemetry.description')}
setTelemetry.mutate({ enabled: v, source: 'setup' })}
disabled={telemetry.data === undefined}
- aria-label="Anonymous usage analytics"
+ aria-label={t('setup.telemetry.title')}
/>
@@ -707,44 +696,35 @@ export function Setup() {
>
- Launch on login
+ {t('setup.launch.title')}
-
- Start Steno automatically when you log in (hidden in the menu
- bar). You can change this any time in Settings.
-
+
{t('setup.launch.description')}
setLaunchOnLogin.mutate(v)}
disabled={launchOnLogin.data === undefined}
- aria-label="Launch on login"
+ aria-label={t('setup.launch.title')}
/>
{done ? (
void finishOnboarding()}>
- Continue to app
+ {t('setup.actions.continueToApp')}
) : (
- {running ? 'Setting up...' : 'Begin setup'}
+ {running ? t('setup.actions.settingUp') : t('setup.actions.begin')}
)}
{!done && !running && !canBegin && (
-
- Enter your API key to continue.
-
+ {t('setup.actions.needKeyHint')}
)}
@@ -754,12 +734,12 @@ export function Setup() {
onClick={() => setDebugOpen((o) => !o)}
className="flex w-full items-center justify-between text-xs font-medium text-muted-foreground hover:text-foreground"
>
- Debug console
+ {t('setup.debug.toggle')}
{debugOpen ? '−' : '+'}
{debugOpen && (
- {logs.length === 0 ? 'Steno Setup\nCommands and output will appear here...\n' : logs.join('\n')}
+ {logs.length === 0 ? t('setup.debug.empty') : logs.join('\n')}
)}
@@ -771,7 +751,7 @@ export function Setup() {
rel="noreferrer"
className="hover:text-foreground"
>
- Report an issue
+ {t('setup.reportIssue')}
diff --git a/app/renderer/src/routes/settings/AboutTab.tsx b/app/renderer/src/routes/settings/AboutTab.tsx
index 0d23377f..6c850a2b 100644
--- a/app/renderer/src/routes/settings/AboutTab.tsx
+++ b/app/renderer/src/routes/settings/AboutTab.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Check, ExternalLink, Loader2, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { ipc } from '@/lib/ipc';
import { useAppVersion } from '@/hooks/useSettings';
@@ -45,6 +46,7 @@ type CheckState =
| { kind: 'update-blocked-os'; version: string };
export function AboutTab() {
+ const { t } = useTranslation();
const version = useAppVersion();
const [checkState, setCheckState] = React.useState({ kind: 'idle' });
const [downloadPercent, setDownloadPercent] = React.useState(null);
@@ -145,7 +147,7 @@ export function AboutTab() {
} catch (e) {
setCheckState({
kind: 'error',
- message: e instanceof Error ? e.message : 'Check failed',
+ message: e instanceof Error ? e.message : t('settings.about.checkFailed'),
});
}
};
@@ -165,13 +167,19 @@ export function AboutTab() {
// on the button itself (Checking for Updates -> You're on the latest
// version / Check failed), so the description only needs to add the
// persistent, actionable update-available case.
- const versionLabel = `Version ${version.data?.version ?? '—'}`;
+ const installed = version.data?.version ?? '—';
const checkDescription =
checkState.kind === 'update-available'
- ? `${versionLabel} — Update available (v${checkState.version})`
+ ? t('settings.about.versionUpdateAvailable', {
+ version: installed,
+ latest: checkState.version,
+ })
: checkState.kind === 'update-blocked-os'
- ? `${versionLabel} — v${checkState.version} requires a newer version of macOS`
- : versionLabel;
+ ? t('settings.about.versionUpdateBlocked', {
+ version: installed,
+ latest: checkState.version,
+ })
+ : t('settings.about.version', { version: installed });
return (
@@ -184,7 +192,7 @@ export function AboutTab() {
className={COMPACT_BTN}
onClick={() => void ipc().updates.openReleasePage(checkState.releaseUrl)}
>
- View release
+ {t('settings.about.viewRelease')}
)}
- Checking for Updates
+ {t('settings.about.checking')}
>
) : checkState.kind === 'up-to-date' ? (
<>
- You're on the latest version
+ {t('settings.about.upToDate')}
>
) : checkState.kind === 'error' ? (
<>
- Check failed
+ {t('settings.about.checkFailed')}
>
) : (
- 'Check for Updates'
+ t('settings.about.checkForUpdates')
)}
@@ -223,7 +231,7 @@ export function AboutTab() {
className="mb-1.5 flex items-center justify-between text-[12px]"
style={{ color: 'var(--fg-2)' }}
>
- Downloading update…
+ {t('settings.about.downloading')}
{downloadPercent}%
- Update download failed: {downloadError}
+ {t('settings.about.downloadFailed', { error: downloadError })}
)}
{downloadedVersion && (
ipc().updates.install()}>
- Restart to Update (v{downloadedVersion})
+ {t('settings.about.restartToUpdate', { version: downloadedVersion })}
)}
-
+
void ipc().shell.openExternal(CHANGELOG_URL)}
/>
-
+
void ipc().shell.openExternal(DISCORD_URL)}
/>
void ipc().shell.openExternal(GITHUB_URL)}
/>
@@ -288,7 +299,7 @@ export function AboutTab() {
onClick={() => void ipc().shell.openExternal(TERMS_URL)}
className="hover:underline"
>
- Terms of Service
+ {t('settings.about.terms')}
·
void ipc().shell.openExternal(PRIVACY_URL)}
className="hover:underline"
>
- Privacy Policy
+ {t('settings.about.privacy')}
diff --git a/app/renderer/src/routes/settings/AdvancedTab.tsx b/app/renderer/src/routes/settings/AdvancedTab.tsx
index 01ea9269..0277f3aa 100644
--- a/app/renderer/src/routes/settings/AdvancedTab.tsx
+++ b/app/renderer/src/routes/settings/AdvancedTab.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Check, Copy } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
@@ -17,6 +18,7 @@ import { COMPACT_BTN, SettingRow } from './primitives';
/** A read-only value with a click-to-copy button. Used for paths and IDs that
* users frequently need to paste into bug reports or terminal sessions. */
function CopyableValue({ value, mono = false }: { value: string; mono?: boolean }) {
+ const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
const onCopy = async () => {
try {
@@ -45,8 +47,8 @@ function CopyableValue({ value, mono = false }: { value: string; mono?: boolean
@@ -57,6 +59,7 @@ function CopyableValue({ value, mono = false }: { value: string; mono?: boolean
}
export function AdvancedTab() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const storage = useStoragePath();
const setStorage = useSetStoragePath();
@@ -96,13 +99,13 @@ export function AdvancedTab() {
className="text-[14px] font-normal"
style={{ color: 'var(--fg-1)', marginBottom: 2 }}
>
- Storage location
+ {t('settings.advanced.storage.label')}
- Where your notes and recordings are saved
+ {t('settings.advanced.storage.description')}
{path && }
@@ -113,7 +116,7 @@ export function AdvancedTab() {
className={COMPACT_BTN}
onClick={chooseFolder}
>
- Choose…
+ {t('settings.advanced.storage.choose')}
{custom && (
- Reset
+ {t('settings.advanced.storage.reset')}
)}
navigate('/setup')}
>
- Run
+ {t('settings.advanced.setupWizard.run')}
- {clearState.isPending ? 'Clearing…' : 'Clear'}
+ {clearState.isPending
+ ? t('settings.advanced.clearState.clearing')
+ : t('settings.advanced.clearState.clear')}
- Anonymous ID
+ {t('settings.advanced.anonymousId.label')}
- Identifies this install in analytics. Useful when reporting bugs.
+ {t('settings.advanced.anonymousId.description')}
diff --git a/app/renderer/src/routes/settings/AiTab.tsx b/app/renderer/src/routes/settings/AiTab.tsx
index b529a7b0..65e5afad 100644
--- a/app/renderer/src/routes/settings/AiTab.tsx
+++ b/app/renderer/src/routes/settings/AiTab.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { Building2, Check, ChevronDown, ChevronRight, Cloud, Laptop, Loader2, Server, X } from 'lucide-react';
+import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Input } from '@/components/ui/input';
@@ -60,28 +61,27 @@ import { COMPACT_BTN, COMPACT_INPUT, COMPACT_TRIGGER, SectionHeading, SettingRow
import { ModelCard, formatModelSize, isDefaultModel, parsePullPercent } from './model-card';
import { modelMayExceedMemory } from './model-memory';
import { LANGUAGES_PARAKEET, LANGUAGES_WHISPER } from './languages';
+import { languageLabel } from '@/lib/transcription-languages';
export function AiTab() {
+ const { t } = useTranslation();
return (
- Transcription
+ {t('settings.ai.transcription.heading')}
- Speech-to-text always runs on your device — your audio never leaves
- your computer.
+ {t('settings.ai.transcription.intro')}
- Summarisation & Chat
+ {t('settings.ai.summarisation.heading')}
- Turns your transcript into notes and answers your questions. This is
- the one step that can run locally or in the cloud — if you choose a
- cloud provider, only the text transcript is sent, never audio.
+ {t('settings.ai.summarisation.intro')}
@@ -89,6 +89,7 @@ export function AiTab() {
}
function TranscriptionSection() {
+ const { t } = useTranslation();
const language = useLanguageSetting();
const setLanguage = useSetLanguage();
const keepRecordings = useKeepRecordingsSetting();
@@ -111,8 +112,8 @@ function TranscriptionSection() {
// nested wrapper (the page-level section is now data-settings-tab="ai").
{options.map((l) => (
- {l.label}
+ {languageLabel(l.value, l.label)}
))}
@@ -136,8 +137,8 @@ function TranscriptionSection() {
= {
- parakeet: 'Fastest — English + European languages',
- whisper: 'Most accurate — 99 languages',
+// SUPPORTED_WHISPER_MODELS in the Python registries). Values are translation
+// keys, resolved where the dropdown is rendered.
+const ENGINE_TAGLINE_KEY: Record<'parakeet' | 'whisper', string> = {
+ parakeet: 'settings.ai.engine.parakeet',
+ whisper: 'settings.ai.engine.whisper',
};
/**
@@ -178,6 +180,7 @@ const ENGINE_TAGLINE: Record<'parakeet' | 'whisper', string> = {
* last silently wins.
*/
function TranscriptionModelList() {
+ const { t } = useTranslation();
const parakeet = useParakeetModels();
const whisper = useWhisperModels();
const engine = useTranscriptionEngine();
@@ -195,8 +198,8 @@ function TranscriptionModelList() {
if (isLoading) {
return (
- Loading models…
+ {t('settings.ai.models.loading')}
);
@@ -212,12 +215,12 @@ function TranscriptionModelList() {
if (isError) {
return (
- Could not load models.
+ {t('settings.ai.model.loadError')}
);
@@ -267,8 +270,8 @@ function TranscriptionModelList() {
return (
@@ -296,7 +299,11 @@ function TranscriptionModelList() {
{options.map((o) => (
-
+
{o.icon}
{o.model.displayName ?? o.model.name}
@@ -310,6 +317,7 @@ function TranscriptionModelList() {
}
function SummarisationSection() {
+ const { t } = useTranslation();
const provider = useAiProvider();
const setProvider = useSetAiProvider();
const orgSession = useOrgSession();
@@ -321,8 +329,8 @@ function SummarisationSection() {
return (
<>
Organisation to change it."
- : 'Where models run. Local keeps all data on your device.'
+ ? t('settings.ai.provider.orgManaged')
+ : t('settings.ai.provider.description')
}
// The Model section right below (local provider) has no divider of
// its own — Remote/Cloud/Adapter's config blocks do (their own
@@ -359,23 +367,23 @@ function SummarisationSection() {
}
- description="Runs entirely on your device. Private and free, no internet required."
+ description={t('settings.ai.provider.localDescription')}
>
- Local (on-device)
+ {t('settings.ai.provider.local')}
}
- description="Connect to your own Ollama server. Data stays within your network."
+ description={t('settings.ai.provider.remoteDescription')}
>
- Private Server
+ {t('settings.ai.provider.remote')}
}
- description="Use OpenAI, Anthropic, or a compatible API. Best quality, requires a paid key."
+ description={t('settings.ai.provider.cloudDescription')}
>
- Cloud API
+ {t('settings.ai.provider.cloud')}
}
description={
orgSignedIn
- ? "Uses your organisation's AI key. No setup needed."
- : 'Sign in to your organisation to enable this option.'
+ ? t('settings.ai.provider.adapterDescription')
+ : t('settings.ai.provider.adapterDisabledDescription')
}
>
- Organisation
+ {t('settings.ai.provider.adapter')}
@@ -408,10 +416,10 @@ function SummarisationSection() {
{current !== 'cloud' && current !== 'adapter' && !orgSignedIn && (
- Model
+ {t('settings.ai.summaryModel.label')}
- Which model generates your summaries, titles, and chat answers.
+ {t('settings.ai.summaryModel.description')}
@@ -425,22 +433,23 @@ function SummarisationSection() {
* reassures the user it's working (or warns them if their session has
* lapsed, in which case summarisation would fall back to an error). */
function AdapterProviderInfo({ signedIn }: { signedIn: boolean }) {
+ const { t } = useTranslation();
return (
{signedIn ? (
-
- Summaries, titles, and chat are routed through your organisation's
- adapter. The model and API key are configured by your organisation
- — no setup needed here.
-
+
{t('settings.ai.adapter.signedIn')}
) : (
- You are not signed in to an organisation. Sign in under{' '}
- Settings > Organisation , or switch this provider
- back to Local / Private Server / Cloud API.
+ {/* Trans, not two t() calls: the emphasised settings path sits mid
+ sentence, and splitting it into prefix/suffix keys would strand
+ translators with fragments no other language can reorder. */}
+ }}
+ />
)}
@@ -448,6 +457,7 @@ function AdapterProviderInfo({ signedIn }: { signedIn: boolean }) {
}
function RemoteProviderConfig() {
+ const { t } = useTranslation();
const provider = useAiProvider();
const setUrl = useSetRemoteOllamaUrl();
const testConnection = useTestRemoteOllama();
@@ -469,7 +479,7 @@ function RemoteProviderConfig() {
className="mb-1 block text-[12px] font-medium uppercase"
style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}
>
- Ollama server URL
+ {t('settings.ai.remote.urlLabel')}
testConnection.mutate(url)}
disabled={!url || testConnection.isPending}
>
- {testConnection.isPending ? 'Testing…' : 'Test connection'}
+ {testConnection.isPending
+ ? t('settings.ai.connection.testing')
+ : t('settings.ai.connection.test')}
@@ -512,6 +524,7 @@ const CLOUD_SERVICE_ICON: Record = {
};
function CloudProviderConfig() {
+ const { t } = useTranslation();
const provider = useAiProvider();
const setCloudProvider = useSetCloudProvider();
const setCloudUrl = useSetCloudApiUrl();
@@ -607,7 +620,7 @@ function CloudProviderConfig() {
className="mb-1 block text-[12px] font-medium uppercase"
style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}
>
- Service
+ {t('settings.ai.cloud.serviceLabel')}
- Custom (OpenAI-compatible)
+ {t('settings.ai.cloud.custom')}
@@ -646,7 +659,7 @@ function CloudProviderConfig() {
className="mb-1 block text-[12px] font-medium uppercase"
style={{ letterSpacing: '0.06em', color: 'var(--fg-muted)' }}
>
- AWS region
+ {t('settings.ai.cloud.regionLabel')}
- Inference profile (optional)
+ {t('settings.ai.cloud.inferenceProfileLabel')}
- API base URL
+ {t('settings.ai.cloud.apiUrlLabel')}
- API key
+ {t('settings.ai.cloud.apiKeyLabel')}
- Model
+ {t('settings.ai.cloud.modelLabel')}
{showModelDropdown ? (
-
+
{/* If the persisted model isn't in the fetched list (e.g. a
@@ -740,7 +753,7 @@ function CloudProviderConfig() {
{m}
))}
- Custom…
+ {t('settings.ai.cloud.customOption')}
) : (
@@ -765,14 +778,14 @@ function CloudProviderConfig() {
className={COMPACT_BTN}
onClick={() => setCustomModelMode(false)}
>
- Pick from list
+ {t('settings.ai.cloud.pickFromList')}
)}
)}
{availableModels.length === 0 && cloudProvider !== 'bedrock' && (
- Test connection to load the list of available models.
+ {t('settings.ai.cloud.testToLoadModels')}
)}
@@ -784,7 +797,9 @@ function CloudProviderConfig() {
onClick={onTest}
disabled={testConnection.isPending}
>
- {testConnection.isPending ? 'Testing…' : 'Test connection'}
+ {testConnection.isPending
+ ? t('settings.ai.connection.testing')
+ : t('settings.ai.connection.test')}
- Transcripts will be sent to a third-party cloud service. No audio files
- leave your device.
+ {t('settings.ai.cloud.disclaimer')}
);
@@ -814,6 +830,7 @@ function ConnectionStatus({
ok: boolean | undefined;
message?: string;
}) {
+ const { t } = useTranslation();
if (ok === undefined) return null;
return (
{ok ? : }
- {message ?? (ok ? 'Connected' : 'Failed')}
+ {message ??
+ (ok ? t('settings.ai.connection.connected') : t('settings.ai.connection.failed'))}
);
}
@@ -840,6 +858,7 @@ function getOllamaModelIcon(modelId: string): React.ReactNode | undefined {
}
function ModelList() {
+ const { t } = useTranslation();
const models = useModels();
const current = useCurrentModel();
const setCurrent = useSetCurrentModel();
@@ -859,7 +878,10 @@ function ModelList() {
if (match) {
setDeleteCandidate({
tags: [match.name],
- description: `${match.name} (${formatModelSize(match.size_gb) ?? 'unknown size'}) is no longer needed now that the faster build is active. Delete it to free up disk space?`,
+ description: t('settings.ai.models.deleteFasterBuildDescription', {
+ name: match.name,
+ size: formatModelSize(match.size_gb) ?? t('settings.ai.models.unknownSize'),
+ }),
});
}
});
@@ -871,21 +893,21 @@ function ModelList() {
style={{ color: 'var(--fg-2)' }}
>
- Loading models…
+ {t('settings.ai.models.loading')}
);
}
if (models.isError) {
return (
- Could not reach Ollama. Run the setup wizard.
+ {t('settings.ai.models.ollamaUnreachable')}
);
}
if (!models.data?.models?.length) {
return (
- No models available.
+ {t('settings.ai.models.none')}
);
}
@@ -928,9 +950,11 @@ function ModelList() {
if (isRemote && m.description) {
note = m.description;
} else if (!isRemote) {
+ // The speed/quality VALUES ("fast", "excellent") come from the backend
+ // registry and stay as-is; only the frame around them is translated.
const parts: string[] = [];
- if (m.speed) parts.push(`${m.speed} speed`);
- if (m.quality) parts.push(`${m.quality} quality`);
+ if (m.speed) parts.push(t('settings.ai.models.speedNote', { value: m.speed }));
+ if (m.quality) parts.push(t('settings.ai.models.qualityNote', { value: m.quality }));
note = parts.length ? parts.join(' · ') : undefined;
}
@@ -965,11 +989,21 @@ function ModelList() {
if (m.ggufInstalled) tags.push(m.name);
if (m.mlxInstalled && m.mlxTag) tags.push(m.mlxTag);
if (tags.length === 0) return;
- const label = tags.length > 1 ? `${m.name} and its faster build` : tags[0];
- const pronoun = tags.length > 1 ? 'them' : 'it';
+ // Two whole-sentence keys rather than one with a pronoun/label hole —
+ // "it"/"them" and "X and its faster build" don't survive translation as
+ // interchangeable fragments.
setDeleteCandidate({
tags,
- description: `Delete ${label} (${sizeLabel ?? 'unknown size'}) to free up disk space? You can re-download ${pronoun} anytime.`,
+ description:
+ tags.length > 1
+ ? t('settings.ai.models.deleteDescriptionBoth', {
+ name: m.name,
+ size: sizeLabel ?? t('settings.ai.models.unknownSize'),
+ })
+ : t('settings.ai.models.deleteDescriptionOne', {
+ name: tags[0],
+ size: sizeLabel ?? t('settings.ai.models.unknownSize'),
+ }),
});
};
@@ -1024,7 +1058,9 @@ function ModelList() {
) : (
)}
- {showDeprecated ? 'Hide' : 'Show'} deprecated models
+ {showDeprecated
+ ? t('settings.ai.models.hideDeprecated')
+ : t('settings.ai.models.showDeprecated')}
{showDeprecated && (
@@ -1042,9 +1078,9 @@ function ModelList() {
fasterBuild.reset();
}
}}
- title="Delete model?"
+ title={t('settings.ai.models.deleteTitle')}
description={deleteCandidate.description}
- confirmLabel="Delete"
+ confirmLabel={t('common.delete')}
destructive
onConfirm={async () => {
// finally, not just a trailing statement: a delete call
diff --git a/app/renderer/src/routes/settings/DeveloperTab.tsx b/app/renderer/src/routes/settings/DeveloperTab.tsx
index 3db4ab2d..9fdf977b 100644
--- a/app/renderer/src/routes/settings/DeveloperTab.tsx
+++ b/app/renderer/src/routes/settings/DeveloperTab.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
clearDebugLogs,
@@ -24,6 +25,7 @@ import { useTranscriptionEngine } from '@/hooks/useModels';
const DIAGNOSTICS_CANCELED = 'canceled';
export function DeveloperTab() {
+ const { t } = useTranslation();
// Read from the global store so we get the full session backlog, not just
// lines emitted after this tab mounted.
const logs = React.useSyncExternalStore(
@@ -100,17 +102,22 @@ export function DeveloperTab() {
content,
);
if (!res.success && res.error !== DIAGNOSTICS_CANCELED) {
- setSaveError(`Couldn't save diagnostics: ${res.error || 'unknown error'}`);
+ setSaveError(
+ t('settings.developer.saveFailed', {
+ error: res.error || t('settings.developer.unknownError'),
+ }),
+ );
}
} catch (err) {
setSaveError(
- `Couldn't save diagnostics: ${err instanceof Error ? err.message : String(err)}`,
+ t('settings.developer.saveFailed', {
+ error: err instanceof Error ? err.message : String(err),
+ }),
);
}
};
- const placeholder =
- 'Steno debug console\nSession started — waiting for activity…\n';
+ const placeholder = t('settings.developer.placeholder');
return (
@@ -120,10 +127,10 @@ export function DeveloperTab() {
className="text-[14px] font-medium"
style={{ color: 'var(--fg-1)', marginBottom: 2 }}
>
- Debug console
+ {t('settings.developer.console.label')}
- Real-time log output from backend processes.
+ {t('settings.developer.console.description')}
@@ -133,7 +140,7 @@ export function DeveloperTab() {
className="h-7 px-2.5 text-[13px]"
onClick={clearDebugLogs}
>
- Clear
+ {t('settings.developer.clear')}
- Copy
+ {t('settings.developer.copy')}
void saveLogs()}
disabled={!logs.length}
>
- Save
+ {t('settings.developer.save')}
diff --git a/app/renderer/src/routes/settings/GeneralTab.test.tsx b/app/renderer/src/routes/settings/GeneralTab.test.tsx
index 61cb263f..057013d6 100644
--- a/app/renderer/src/routes/settings/GeneralTab.test.tsx
+++ b/app/renderer/src/routes/settings/GeneralTab.test.tsx
@@ -116,6 +116,11 @@ vi.mock('@/hooks/useSettings', () => {
useSetShowMenuBarIcon: m,
useUserName: () => h.userName,
useSetUserName: () => h.setUserName,
+ // Interface-language picker (#337). Mocked like every other setting
+ // here so the row renders; this file's races are about calendar OAuth
+ // and the name field, not about language.
+ useUiLanguageSetting: () => q({ preference: 'system', resolved: 'en' }),
+ useSetUiLanguage: () => m(),
useMicrophoneSetting: () => q(h.microphone.data),
useSetMicrophone: () => h.setMicrophone,
};
diff --git a/app/renderer/src/routes/settings/GeneralTab.tsx b/app/renderer/src/routes/settings/GeneralTab.tsx
index 2a434a3b..f3d39eeb 100644
--- a/app/renderer/src/routes/settings/GeneralTab.tsx
+++ b/app/renderer/src/routes/settings/GeneralTab.tsx
@@ -1,5 +1,6 @@
import * as React from 'react';
import { ExternalLink, Loader2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -41,11 +42,13 @@ import {
useSetSilenceAutoStopEnabled,
useSetSilenceAutoStopMinutes,
useSetSystemAudio,
+ useSetUiLanguage,
useSetUserName,
useShowMenuBarIconSetting,
useSilenceAutoStopSetting,
useSystemAudioSetting,
useSystemAudioSupport,
+ useUiLanguageSetting,
useUserName,
} from '@/hooks/useSettings';
import { useAudioInputDevices } from '@/hooks/useAudioInputDevices';
@@ -58,7 +61,10 @@ import { COMPACT_BTN, COMPACT_TRIGGER, SectionHeading, SettingRow } from './prim
const DEFAULT_MIC_VALUE = 'default';
export function GeneralTab() {
+ const { t } = useTranslation();
const { theme, setTheme } = useTheme();
+ const uiLanguage = useUiLanguageSetting();
+ const setUiLanguage = useSetUiLanguage();
const notifications = useNotificationsSetting();
const setNotifications = useSetNotifications();
const premeetingNotifications = usePremeetingNotificationsSetting();
@@ -68,9 +74,13 @@ export function GeneralTab() {
const systemAudioSupport = useSystemAudioSupport();
const systemAudioDescription = (() => {
if (systemAudioSupport.data && !systemAudioSupport.data.supported) {
- return `Capture both sides of a call (requires macOS 14.4+, you're on ${systemAudioSupport.data.osVersion || 'an older version'}). Mic-only recording still works.`;
+ return t('settings.general.systemAudio.unsupported', {
+ version:
+ systemAudioSupport.data.osVersion ||
+ t('settings.general.systemAudio.unknownVersion'),
+ });
}
- return 'Capture both sides of a call. Turn off to record your mic only.';
+ return t('settings.general.systemAudio.description');
})();
const autoDetect = useAutoDetectMeetingsSetting();
const setAutoDetect = useSetAutoDetectMeetings();
@@ -239,8 +249,8 @@ export function GeneralTab() {
return (
- System
- Light
- Dark
+ {t('settings.general.appearance.system')}
+ {t('settings.general.appearance.light')}
+ {t('settings.general.appearance.dark')}
- Calendar
+ {/*
+ Interface language (#337). Sits next to Appearance because it is the
+ same kind of setting - how the app presents itself - and deliberately
+ NOT next to the transcription-language picker on the AI tab, which
+ governs what language your notes come out in. The description says so,
+ because "language" appearing in two places is otherwise a coin flip.
+ "System default" is stored as the sentinel 'system', not resolved to a
+ concrete tag, so the choice keeps following the OS if it changes later.
+ */}
+ setUiLanguage.mutate(v)}
+ disabled={uiLanguage.isLoading || setUiLanguage.isPending}
+ >
+
+
+
+
+ {t('settings.language.system')}
+ {t('settings.language.en')}
+ {t('settings.language.de')}
+
+
+
+
+ {t('settings.general.calendar.heading')}
+
+
@@ -313,7 +356,7 @@ export function GeneralTab() {
) : (
)}
- Disconnect
+ {t('settings.general.calendar.disconnect')}
) : (
@@ -345,11 +388,11 @@ export function GeneralTab() {
onRetry={() => oauth && void startConnect(oauth.provider)}
/>
-
Meeting notifications
+
{t('settings.general.notifications.heading')}
- Recording
+ {t('settings.general.recording.heading')}
- System Default
+
+ {t('settings.general.microphone.systemDefault')}
+
{audioInputDevices.map((d, i) => (
- {d.label || `Microphone ${i + 1}`}
+ {d.label || t('settings.general.microphone.numbered', { index: i + 1 })}
))}
{/* The selected device was unplugged / isn't in the current device
@@ -428,7 +475,7 @@ export function GeneralTab() {
{microphone.data?.device_id &&
!audioInputDevices.some((d) => d.deviceId === microphone.data?.device_id) && (
- {microphone.data.label || 'Unknown device (disconnected)'}
+ {microphone.data.label || t('settings.general.microphone.unknownDevice')}
)}
@@ -438,7 +485,10 @@ export function GeneralTab() {
{/* macOS only: chooses mic-only vs mic+system. Windows always records
mic+system (toggle hidden), so this control isn't shown there. */}
{isMac && (
-
+
setSystemAudio.mutate(v)}
@@ -448,8 +498,8 @@ export function GeneralTab() {
)}
@@ -466,7 +516,7 @@ export function GeneralTab() {
{(silenceAutoStop.data?.supportedMinutes ?? [2, 5, 10, 15, 30]).map((m) => (
- {m} minutes
+ {t('settings.general.silence.minutes', { count: m })}
))}
@@ -479,11 +529,11 @@ export function GeneralTab() {
- System
+ {t('settings.general.system.heading')}
@@ -559,7 +613,9 @@ interface OAuthPromptProps {
}
function OAuthPrompt({ state, onClose, onRetry }: OAuthPromptProps) {
+ const { t } = useTranslation();
const open = !!state;
+ // Provider brand names are not translated.
const providerName = state?.provider === 'outlook' ? 'Outlook' : 'Google';
return (
!o && onClose()}>
@@ -567,20 +623,20 @@ function OAuthPrompt({ state, onClose, onRetry }: OAuthPromptProps) {
{state?.state === 'error'
- ? `Couldn't connect to ${providerName}`
- : `Connecting to ${providerName}`}
+ ? t('settings.general.oauth.errorTitle', { provider: providerName })
+ : t('settings.general.oauth.connectingTitle', { provider: providerName })}
{state?.state === 'error'
- ? state.message || 'The authorization flow did not complete.'
- : 'Complete the authorization in your browser. This dialog will close automatically once access is granted.'}
+ ? state.message || t('settings.general.oauth.errorFallback')
+ : t('settings.general.oauth.pendingDescription')}
{state?.state === 'pending' && (
- Waiting for authorization…
+ {t('settings.general.oauth.waiting')}
)}
@@ -589,13 +645,13 @@ function OAuthPrompt({ state, onClose, onRetry }: OAuthPromptProps) {
{state?.state === 'error' ? (
<>
- Close
+ {t('common.close')}
- Try again
+ {t('settings.general.oauth.tryAgain')}
>
) : (
- Cancel
+ {t('common.cancel')}
)}
diff --git a/app/renderer/src/routes/settings/OrganisationTab.tsx b/app/renderer/src/routes/settings/OrganisationTab.tsx
index 84486703..ad4a1b27 100644
--- a/app/renderer/src/routes/settings/OrganisationTab.tsx
+++ b/app/renderer/src/routes/settings/OrganisationTab.tsx
@@ -1,4 +1,5 @@
import * as React from 'react';
+import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -54,6 +55,7 @@ function GoogleGlyph({ size = 14 }: { size?: number }) {
}
export function OrganisationTab() {
+ const { t } = useTranslation();
// useOrgSession + the login/logout mutations all share the same TanStack
// Query key, so every other consumer (sidebar 'Shared notes' row, profile
// chip, AskBar gating) reacts immediately to a sign-in or sign-out here.
@@ -141,10 +143,11 @@ export function OrganisationTab() {
- Signed in as {status.name}
+ {t('settings.organisation.signedInAs', { name: status.name })}
- {status.email} · org {status.orgId}
+ {status.email} · {t('settings.organisation.org')}{' '}
+ {status.orgId}
- Sign out
+ {t('settings.organisation.signOut')}
- Auto-back up new notes
+ {t('settings.organisation.autoBackup.label')}
- Push every new note to your org's S3 once summarisation finishes. You can still
- unshare individual notes from the Shared notes view.
+ {t('settings.organisation.autoBackup.description')}
setAutoBackup.mutate(v)}
disabled={autoBackupQuery.data === undefined || setAutoBackup.isPending}
- aria-label="Auto-back up new notes to org"
+ aria-label={t('settings.organisation.autoBackup.aria')}
/>
@@ -199,7 +201,7 @@ export function OrganisationTab() {
- Adapter URL
+ {t('settings.organisation.adapterUrl')}
- {ssoGoogleMutation.isPending ? 'Waiting for browser…' : 'Sign in with Google'}
+ {ssoGoogleMutation.isPending
+ ? t('settings.organisation.waitingForBrowser')
+ : t('settings.organisation.signInWithGoogle')}
- Single sign-on via your organisation's Google Workspace.
+ {t('settings.organisation.ssoHint')}
- or
+ {t('settings.organisation.or')}
@@ -240,7 +244,7 @@ export function OrganisationTab() {
- Email
+ {t('settings.organisation.email')}
- Password
+ {t('settings.organisation.password')}
- {loginMutation.isPending ? 'Signing in…' : 'Sign in with password'}
+ {loginMutation.isPending
+ ? t('settings.organisation.signingIn')
+ : t('settings.organisation.signInWithPassword')}
{error && (
diff --git a/app/renderer/src/routes/settings/SettingsNav.tsx b/app/renderer/src/routes/settings/SettingsNav.tsx
index 2dcfb2f2..dd5c753b 100644
--- a/app/renderer/src/routes/settings/SettingsNav.tsx
+++ b/app/renderer/src/routes/settings/SettingsNav.tsx
@@ -9,6 +9,7 @@ import {
Wrench,
type LucideIcon,
} from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
// The full set of nav rail destinations. Distinct from Settings.tsx's
@@ -24,44 +25,46 @@ export type SettingsTabId =
| 'developer'
| 'about';
+// Labels are translation KEYS, not literals — the rail is rendered inside a
+// component that has `t`, and Settings.tsx translates the lookup below itself.
interface NavItem {
id: SettingsTabId;
- label: string;
+ labelKey: string;
icon: LucideIcon;
}
interface NavGroup {
- header?: string;
+ headerKey?: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
items: [
- { id: 'general', label: 'Preferences', icon: Settings2 },
- { id: 'ai', label: 'AI', icon: Sparkles },
- { id: 'templates', label: 'Templates', icon: LayoutTemplate },
+ { id: 'general', labelKey: 'settings.nav.general', icon: Settings2 },
+ { id: 'ai', labelKey: 'settings.nav.ai', icon: Sparkles },
+ { id: 'templates', labelKey: 'settings.nav.templates', icon: LayoutTemplate },
],
},
{
- header: 'Workspace',
- items: [{ id: 'organisation', label: 'Organisation', icon: Building2 }],
+ headerKey: 'settings.nav.groupWorkspace',
+ items: [{ id: 'organisation', labelKey: 'settings.nav.organisation', icon: Building2 }],
},
{
- header: 'System',
+ headerKey: 'settings.nav.groupSystem',
items: [
- { id: 'advanced', label: 'Advanced', icon: Wrench },
- { id: 'developer', label: 'Developer', icon: Code2 },
- { id: 'about', label: 'About', icon: Info },
+ { id: 'advanced', labelKey: 'settings.nav.advanced', icon: Wrench },
+ { id: 'developer', labelKey: 'settings.nav.developer', icon: Code2 },
+ { id: 'about', labelKey: 'settings.nav.about', icon: Info },
],
},
];
-// Flat id -> label lookup so Settings.tsx can show the active tab's own name
-// as the page title (matches the Granola reference: the header names the
+// Flat id -> label KEY lookup so Settings.tsx can show the active tab's own
+// name as the page title (matches the Granola reference: the header names the
// section, it isn't a static "Settings" caption).
-export const SETTINGS_TAB_LABELS: Record = Object.fromEntries(
- NAV_GROUPS.flatMap((g) => g.items).map((item) => [item.id, item.label]),
+export const SETTINGS_TAB_LABEL_KEYS: Record = Object.fromEntries(
+ NAV_GROUPS.flatMap((g) => g.items).map((item) => [item.id, item.labelKey]),
) as Record;
interface SettingsNavProps {
@@ -72,6 +75,7 @@ interface SettingsNavProps {
}
export function SettingsNav({ activeTab, onSelect, onBack, version }: SettingsNavProps) {
+ const { t } = useTranslation();
return (
(Sidebar.tsx) — AppShell
@@ -105,7 +109,7 @@ export function SettingsNav({ activeTab, onSelect, onBack, version }: SettingsNa
@@ -115,18 +119,18 @@ export function SettingsNav({ activeTab, onSelect, onBack, version }: SettingsNa
className="text-[13px] font-medium"
style={{ color: 'var(--fg-1)' }}
>
- Settings
+ {t('settings.nav.title')}
{NAV_GROUPS.map((group, i) => (
-
- {group.header && (
+
+ {group.headerKey && (
// Matches the main Sidebar's own group label (.sb-group-head on
// the "Folders" header) exactly — sentence case, fg-2, no
// uppercase/letter-spacing treatment — rather than reusing the
@@ -135,7 +139,7 @@ export function SettingsNav({ activeTab, onSelect, onBack, version }: SettingsNa
className="mt-3.5 px-2.5 py-1.5 text-[11.5px] font-medium tracking-[0.02em]"
style={{ color: 'var(--fg-2)' }}
>
- {group.header}
+ {t(group.headerKey)}
)}
{group.items.map((item) => {
@@ -151,7 +155,7 @@ export function SettingsNav({ activeTab, onSelect, onBack, version }: SettingsNa
className={cn('sb-row', active && 'active')}
>
-
{item.label}
+
{t(item.labelKey)}
);
})}
diff --git a/app/renderer/src/routes/settings/TemplatesTab.tsx b/app/renderer/src/routes/settings/TemplatesTab.tsx
index b14f58a4..f3a6420b 100644
--- a/app/renderer/src/routes/settings/TemplatesTab.tsx
+++ b/app/renderer/src/routes/settings/TemplatesTab.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
import { Check, ChevronLeft, Loader2, Lock, Plus, Trash2 } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { templateDisplayName } from '@/lib/templateName';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { Input, Textarea } from '@/components/ui/input';
@@ -21,6 +23,7 @@ import {
} from '@/hooks/useTemplates';
import { COMPACT_BTN } from './primitives';
import { LANGUAGES_WHISPER, type LangOption } from './languages';
+import { languageLabel } from '@/lib/transcription-languages';
// ---------------------------------------------------------------------------
// Templates tab — manage summary report templates (CRUD + default pick) as a
@@ -47,6 +50,7 @@ export function TemplatesTab({
// redundant leftover from the list view above it.
onEditingChange?: (editing: boolean) => void;
} = {}) {
+ const { t: translate } = useTranslation();
const { templates, defaultId } = useTemplates();
const setDefault = useSetDefaultTemplate();
const del = useDeleteTemplate();
@@ -89,10 +93,10 @@ export function TemplatesTab({
- New Template
+ {translate('settings.templates.new')}
- Create custom prompts to tailor how your meetings are summarised.
+ {translate('settings.templates.newDescription')}
@@ -149,26 +153,26 @@ export function TemplatesTab({
className="truncate text-[13px] font-medium"
style={{ color: 'var(--fg-1)' }}
>
- {t.name}
+ {templateDisplayName(t)}
{isDefault && (
- Default
+ {translate('settings.templates.default')}
)}
{t.locked && !isDefault && (
- Locked
+ {translate('settings.templates.locked')}
)}
{t.builtin && !t.locked && !isDefault && (
@@ -179,7 +183,7 @@ export function TemplatesTab({
border: '1px solid var(--border-subtle)',
}}
>
- Built-in
+ {translate('settings.templates.builtin')}
)}
@@ -192,7 +196,10 @@ export function TemplatesTab({
style={{ color: 'var(--fg-muted)', opacity: t.prompt ? 1 : 0.6 }}
title={t.prompt}
>
- {t.prompt || (t.builtin ? 'Uses structured format' : 'No prompt provided.')}
+ {t.prompt ||
+ (t.builtin
+ ? translate('settings.templates.usesStructuredFormat')
+ : translate('settings.templates.noPrompt'))}
@@ -211,7 +218,7 @@ export function TemplatesTab({
disabled={setDefault.isPending}
onClick={() => setDefault.mutate(t.id)}
>
- Make Default
+ {translate('settings.templates.makeDefault')}
)}
{!t.builtin && (
@@ -226,7 +233,7 @@ export function TemplatesTab({
setDeleteError(null);
setDeleteTarget(t);
}}
- aria-label={`Delete ${t.name}`}
+ aria-label={translate('settings.templates.deleteAria', { name: t.name })}
>
@@ -246,11 +253,14 @@ export function TemplatesTab({
setDeleteError(null);
}
}}
- title={deleteTarget ? `Delete template "${deleteTarget.name}"?` : ''}
+ title={
+ deleteTarget
+ ? translate('settings.templates.deleteTitle', { name: deleteTarget.name })
+ : ''
+ }
description={
<>
- This permanently deletes the template. Reports already generated
- from it are not affected.
+ {translate('settings.templates.deleteDescription')}
{deleteError && (
}
- confirmLabel="Delete"
+ confirmLabel={translate('common.delete')}
destructive
isPending={del.isPending}
onConfirm={async () => {
@@ -277,7 +287,7 @@ export function TemplatesTab({
} catch (e) {
// Keep the dialog open so the user can retry; surface why.
setDeleteError(
- e instanceof Error ? e.message : 'Failed to delete template.',
+ e instanceof Error ? e.message : translate('settings.templates.deleteFailed'),
);
}
}}
@@ -295,6 +305,7 @@ function TemplateEditor({
editing: Partial
| null;
onClose: () => void;
}) {
+ const { t } = useTranslation();
const save = useSaveTemplate();
const reset = useResetTemplate();
const { defaultId } = useTemplates();
@@ -317,7 +328,8 @@ function TemplateEditor({
{ id: editing?.id, name, prompt, language },
{
onSuccess: () => onClose(),
- onError: (e) => setError(e instanceof Error ? e.message : 'Save failed'),
+ onError: (e) =>
+ setError(e instanceof Error ? e.message : t('settings.templates.saveFailed')),
},
);
};
@@ -334,7 +346,7 @@ function TemplateEditor({
type="button"
onClick={onClose}
disabled={busy}
- aria-label="Back to templates"
+ aria-label={t('settings.templates.backAria')}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-[color:var(--surface-hover)] hover:text-[color:var(--fg-1)] disabled:pointer-events-none disabled:opacity-50"
style={{ color: 'var(--fg-2)' }}
>
@@ -342,10 +354,12 @@ function TemplateEditor({
- {editing?.id ? 'Edit template' : 'New template'}
+ {editing?.id
+ ? t('settings.templates.editTitle')
+ : t('settings.templates.newTitle')}
- Configure how your meetings should be summarized
+ {t('settings.templates.editorSubtitle')}
@@ -361,7 +375,7 @@ function TemplateEditor({
if (editing.id) setDefault.mutate(editing.id);
}}
>
- Make Default
+ {t('settings.templates.makeDefault')}
)}
{editing?.id && editing.builtin && !editing.locked && (
@@ -370,12 +384,12 @@ function TemplateEditor({
size="sm"
className={COMPACT_BTN}
disabled={busy}
- title="Discard your edits and revert to Steno's shipped version of this template"
+ title={t('settings.templates.resetTitle')}
onClick={() => {
if (editing.id) reset.mutate(editing.id, { onSuccess: () => onClose() });
}}
>
- Reset
+ {t('settings.templates.reset')}
)}
- Saving…
+ {t('settings.templates.saving')}
>
) : (
- 'Save Template'
+ t('settings.templates.save')
)}
@@ -400,18 +414,18 @@ function TemplateEditor({
@@ -196,6 +200,7 @@ export function ModelCard({
onCancelFasterBuild,
memoryWarning = false,
}: ModelCardProps) {
+ const { t } = useTranslation();
return (
- Default
+ {t('settings.model.default')}
)}
{deprecated && (
@@ -261,7 +266,7 @@ export function ModelCard({
border: '1px solid var(--border-subtle)',
}}
>
- Deprecated
+ {t('settings.model.deprecated')}
)}
{fasterBuildTag && fasterBuildInstalled && (
@@ -269,8 +274,8 @@ export function ModelCard({
className="rounded-[3px] px-1.5 py-px text-[11px]"
title={
ggufInstalled
- ? `Running the MLX build (${fasterBuildTag}) instead of ${name}`
- : `Downloaded directly as the MLX build (${fasterBuildTag}) -- ${name} was never pulled`
+ ? t('settings.model.mlxTitleGguf', { tag: fasterBuildTag, name })
+ : t('settings.model.mlxTitleDirect', { tag: fasterBuildTag, name })
}
style={{
background: 'var(--surface-sunken)',
@@ -278,20 +283,20 @@ export function ModelCard({
border: '1px solid var(--border)',
}}
>
- MLX model
+ {t('settings.model.mlxBadge')}
)}
{memoryWarning && (
- May exceed memory
+ {t('settings.model.memoryWarningBadge')}
)}
@@ -319,7 +324,7 @@ export function ModelCard({
style={{ color: 'var(--fg-1)' }}
>
- Selected
+ {t('settings.model.selected')}
) : !deprecated ? (
@@ -327,8 +332,8 @@ export function ModelCard({
@@ -349,16 +354,16 @@ export function ModelCard({
onCancelDownload ? (
<>
- Cancel
+ {t('common.cancel')}
>
) : (
<>
- Downloading
+ {t('settings.model.downloading')}
>
)
) : (
- 'Select'
+ t('settings.model.select')
)}
@@ -376,7 +381,7 @@ export function ModelCard({
border: '1px solid var(--border-subtle)',
}}
>
- Faster build available
+ {t('settings.model.fasterBuildAvailable')}
{fasterBuildState === 'pulling' ? (
- {fasterBuildState === 'verifying' && 'Verifying…'}
- {fasterBuildState === 'error' && 'Retry: switch to faster build'}
- {(fasterBuildState === 'idle' || fasterBuildState === 'done') && 'Switch to faster build'}
+ {fasterBuildState === 'verifying' && t('settings.model.verifying')}
+ {fasterBuildState === 'error' && t('settings.model.fasterBuildRetry')}
+ {(fasterBuildState === 'idle' || fasterBuildState === 'done') &&
+ t('settings.model.fasterBuildSwitch')}
)}
diff --git a/app/renderer/src/test-setup.ts b/app/renderer/src/test-setup.ts
new file mode 100644
index 00000000..92766ac6
--- /dev/null
+++ b/app/renderer/src/test-setup.ts
@@ -0,0 +1,13 @@
+/*
+ * Vitest setup. Initialises i18next for every renderer unit test.
+ *
+ * In the real app this happens because main.tsx imports lib/i18n before it
+ * mounts React. A unit test renders a component directly, so nothing pulls that
+ * module in — and an uninitialised react-i18next returns the key instead of the
+ * string, which would turn every existing English assertion into a failure
+ * against text like "settings.general.title".
+ *
+ * The bootstrap has no window.stenoai bridge here, so it falls back to English,
+ * which is what the existing assertions expect.
+ */
+import '@/lib/i18n';
diff --git a/app/renderer/tsconfig.json b/app/renderer/tsconfig.json
index 9ecb78f0..900477d2 100644
--- a/app/renderer/tsconfig.json
+++ b/app/renderer/tsconfig.json
@@ -23,10 +23,11 @@
"types": ["vite/client"],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": ["./src/*"],
+ "@locales/*": ["../locales/*"]
},
"noEmit": true
},
- "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"]
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "../locales/*.json"]
}
diff --git a/app/settings-ipc.js b/app/settings-ipc.js
index d03bf6c5..aa1118c8 100644
--- a/app/settings-ipc.js
+++ b/app/settings-ipc.js
@@ -20,9 +20,19 @@
* them:
* - runPythonScript the bundled-backend invoker (backend-cli seam)
* - sendDebugLog the debug-panel log sink (debug-log seam)
+ * - applyUiLanguage the native-chrome relabel + renderer broadcast (#337).
+ * Injected rather than imported because rebuilding the menu and tray is
+ * main.js's composition-root job, not this module's.
+ * - currentUiLanguage reads back the concrete tag in force
*/
-function registerSettingsIpc({ ipcMain, runPythonScript, sendDebugLog }) {
+function registerSettingsIpc({
+ ipcMain,
+ runPythonScript,
+ sendDebugLog,
+ applyUiLanguage,
+ currentUiLanguage,
+}) {
ipcMain.handle('get-keep-recordings', async () => {
try {
const result = await runPythonScript('simple_recorder.py', ['get-keep-recordings'], true);
@@ -178,6 +188,43 @@ function registerSettingsIpc({ ipcMain, runPythonScript, sendDebugLog }) {
}
});
+ /*
+ * Interface language (#337) — distinct from get-language/set-language above,
+ * which is the transcription/content language. The stored value may be the
+ * 'system' sentinel; `resolved` is the concrete tag actually in force, which
+ * is what the Settings UI needs to show alongside a "System default" choice.
+ */
+ ipcMain.handle('get-ui-language', async () => {
+ try {
+ const result = await runPythonScript('simple_recorder.py', ['get-ui-language'], true);
+ const jsonData = JSON.parse(result.trim());
+ return { success: true, ...jsonData, resolved: currentUiLanguage() };
+ } catch (error) {
+ sendDebugLog(`Error getting UI language setting: ${error.message}`);
+ return { success: false, error: error.message };
+ }
+ });
+
+ ipcMain.handle('set-ui-language', async (event, languageCode) => {
+ try {
+ sendDebugLog(`Setting UI language to: ${languageCode}`);
+ const result = await runPythonScript('simple_recorder.py', ['set-ui-language', languageCode]);
+ const jsonData = JSON.parse(result.trim());
+ if (!jsonData.success) {
+ // Python rejected the value; do not switch the running UI to something
+ // that will not survive a restart.
+ return { success: false, error: jsonData.error || 'Failed to persist UI language' };
+ }
+ // Persist first, then switch — so a crash between the two leaves the app
+ // agreeing with disk rather than showing a language it forgot.
+ const resolved = await applyUiLanguage(languageCode);
+ return { success: true, ...jsonData, resolved };
+ } catch (error) {
+ sendDebugLog(`Error setting UI language: ${error.message}`);
+ return { success: false, error: error.message };
+ }
+ });
+
// Microphone selection IPC handlers
ipcMain.handle('get-microphone', async () => {
try {
diff --git a/app/settings-ipc.test.js b/app/settings-ipc.test.js
index da097437..734654e9 100644
--- a/app/settings-ipc.test.js
+++ b/app/settings-ipc.test.js
@@ -40,11 +40,13 @@ const CHANNELS = [
'get-privacy-notice-seen', 'set-privacy-notice-seen',
'get-system-audio', 'set-system-audio',
'get-language', 'set-language',
+ // Interface language (#337) - distinct from the transcription language above.
+ 'get-ui-language', 'set-ui-language',
'get-microphone', 'set-microphone',
'get-user-name', 'set-user-name',
];
-test('registers exactly the 19 settings-toggle handlers', () => {
+test('registers exactly the 21 settings-toggle handlers', () => {
const { handlers } = harness();
assert.deepStrictEqual(Object.keys(handlers).sort(), [...CHANNELS].sort());
});
diff --git a/app/ui-language.test.js b/app/ui-language.test.js
new file mode 100644
index 00000000..48518d90
--- /dev/null
+++ b/app/ui-language.test.js
@@ -0,0 +1,166 @@
+'use strict';
+
+/**
+ * UI-language resolution (#337).
+ *
+ * The load-bearing case is the migration asymmetry: a config.json that exists
+ * but has no `ui_language` key belongs to an install that has been showing an
+ * English UI, and must keep showing one. Only a genuinely fresh install follows
+ * the OS. Getting that backwards would flip every German-OS user's interface to
+ * German on upgrade without them asking — which is exactly what the RFC's
+ * original "absence means system" wording would have done.
+ *
+ * This mirrors _migrate_ui_language() in src/config.py. The two implementations
+ * are independent (main reads config.json synchronously at startup, far too
+ * early to wait on a Python subprocess), so they are tested independently and
+ * must be changed together.
+ */
+
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const i18n = require('./i18n');
+
+function tempDirWithConfig(contents) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stenoai-uilang-'));
+ if (contents !== null) {
+ fs.writeFileSync(path.join(dir, 'config.json'), contents, 'utf-8');
+ }
+ return dir;
+}
+
+// --- negotiateSystemLanguage -------------------------------------------------
+
+test('system negotiation honours the ordered preference list, not just the first tag', () => {
+ // A user whose list is French-then-German has no French UI available here and
+ // should land on German rather than falling through to English.
+ assert.strictEqual(i18n.negotiateSystemLanguage(['fr-FR', 'de-DE', 'en-US']), 'de');
+});
+
+test('system negotiation drops region subtags', () => {
+ assert.strictEqual(i18n.negotiateSystemLanguage(['de-AT']), 'de');
+ assert.strictEqual(i18n.negotiateSystemLanguage(['de_DE']), 'de');
+ assert.strictEqual(i18n.negotiateSystemLanguage(['DE']), 'de');
+});
+
+test('system negotiation falls back to English when nothing is supported', () => {
+ assert.strictEqual(i18n.negotiateSystemLanguage(['ja-JP', 'ko-KR']), 'en');
+});
+
+test('system negotiation survives a missing or malformed preference list', () => {
+ assert.strictEqual(i18n.negotiateSystemLanguage(undefined), 'en');
+ assert.strictEqual(i18n.negotiateSystemLanguage([]), 'en');
+ assert.strictEqual(i18n.negotiateSystemLanguage([null, '', 'de']), 'de');
+});
+
+// --- readStoredUiLanguage: the migration asymmetry ---------------------------
+
+test('a fresh install (no config.json) follows the system', () => {
+ const dir = tempDirWithConfig(null);
+ assert.strictEqual(i18n.readStoredUiLanguage(dir), 'system');
+});
+
+test('an existing config.json without the key stays English, NOT system', () => {
+ // The whole point of the asymmetry. This install has been running an English
+ // UI; resolving it to 'system' would silently switch a German-OS user.
+ const dir = tempDirWithConfig(JSON.stringify({ model: 'gemma4:e4b-it-qat', language: 'de' }));
+ assert.strictEqual(i18n.readStoredUiLanguage(dir), 'en');
+});
+
+test('an explicit stored preference is returned unchanged', () => {
+ for (const stored of ['system', 'en', 'de']) {
+ const dir = tempDirWithConfig(JSON.stringify({ ui_language: stored }));
+ assert.strictEqual(i18n.readStoredUiLanguage(dir), stored);
+ }
+});
+
+test('an unsupported stored value degrades to English rather than throwing', () => {
+ const dir = tempDirWithConfig(JSON.stringify({ ui_language: 'fr' }));
+ assert.strictEqual(i18n.readStoredUiLanguage(dir), 'en');
+});
+
+test('a corrupt config.json degrades to English rather than crashing startup', () => {
+ const dir = tempDirWithConfig('{ this is not json');
+ assert.strictEqual(i18n.readStoredUiLanguage(dir), 'en');
+});
+
+test('a null, empty or wrongly-typed value is treated like an absent key', () => {
+ // These are the cases where the two implementations could most easily drift:
+ // Python's `in VALID_UI_LANGUAGES` and this file's `includes()` have to agree
+ // on non-strings too. Verified against the real Config class over the same
+ // matrix — if you change either side, re-check the other.
+ for (const value of [null, '', 42]) {
+ const dir = tempDirWithConfig(JSON.stringify({ ui_language: value }));
+ assert.strictEqual(
+ i18n.readStoredUiLanguage(dir),
+ 'en',
+ `ui_language=${JSON.stringify(value)} should resolve like an absent key`,
+ );
+ }
+});
+
+// --- resolveUiLanguage -------------------------------------------------------
+
+test('the system sentinel resolves against the OS preference list', () => {
+ assert.strictEqual(i18n.resolveUiLanguage('system', ['de-DE']), 'de');
+ assert.strictEqual(i18n.resolveUiLanguage('system', ['en-GB']), 'en');
+ assert.strictEqual(i18n.resolveUiLanguage('system', ['it-IT']), 'en');
+});
+
+test('an explicit preference ignores the OS entirely', () => {
+ // Someone who picked English on a German Mac keeps English.
+ assert.strictEqual(i18n.resolveUiLanguage('en', ['de-DE']), 'en');
+ assert.strictEqual(i18n.resolveUiLanguage('de', ['en-US']), 'de');
+});
+
+test('an unknown preference resolves to English', () => {
+ assert.strictEqual(i18n.resolveUiLanguage('klingon', ['de-DE']), 'en');
+});
+
+// --- the resource bundle itself ---------------------------------------------
+
+test('both shipped locales load and German actually differs from English', () => {
+ // Guards against a de.json that silently failed to parse and fell back to {},
+ // which would look like a working German build showing English text.
+ const enPath = path.join(__dirname, 'locales', 'en.json');
+ const dePath = path.join(__dirname, 'locales', 'de.json');
+ const en = JSON.parse(fs.readFileSync(enPath, 'utf-8'));
+ const de = JSON.parse(fs.readFileSync(dePath, 'utf-8'));
+ assert.ok(en.tray && de.tray, 'both bundles carry the tray group');
+ assert.notStrictEqual(de.tray.quit, en.tray.quit, 'German is a real translation, not a copy');
+});
+
+test('every key used in German exists in English (English is the source)', () => {
+ // The completeness direction that matters: i18next falls back to English for
+ // a missing German key, so a German-only key is a typo that can never render.
+ const flatten = (obj, prefix = '') =>
+ Object.entries(obj).flatMap(([k, v]) =>
+ v && typeof v === 'object' ? flatten(v, `${prefix}${k}.`) : [`${prefix}${k}`]
+ );
+ const en = JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'en.json'), 'utf-8'));
+ const de = JSON.parse(fs.readFileSync(path.join(__dirname, 'locales', 'de.json'), 'utf-8'));
+ const enKeys = new Set(flatten(en));
+ const orphans = flatten(de).filter((k) => !enKeys.has(k));
+ assert.deepStrictEqual(orphans, [], `German keys with no English source: ${orphans.join(', ')}`);
+});
+
+// --- the i18next instance ----------------------------------------------------
+
+test('initMainI18n renders German and falls back to English for a missing key', async () => {
+ await i18n.initMainI18n('de');
+ assert.strictEqual(i18n.currentLanguage(), 'de');
+ assert.strictEqual(i18n.t('tray.quit'), 'Steno beenden');
+
+ await i18n.changeMainLanguage('en');
+ assert.strictEqual(i18n.t('tray.quit'), 'Quit Steno');
+});
+
+test('interpolation is not HTML-escaped (menu labels are handed to native APIs)', async () => {
+ // "&File" on Windows must survive as-is; i18next's default escaping would
+ // turn an interpolated & into & in a native menu label.
+ await i18n.initMainI18n('en');
+ assert.strictEqual(i18n.t('tray.version', { version: '0.6.5' }), 'Steno v0.6.5');
+});
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 3df22cad..267bc03a 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -9,6 +9,10 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'renderer/src'),
+ // Single source of truth for both runtimes: the main process reads these
+ // same files from disk (app/i18n.js). Keeping one copy is why this points
+ // outside the Vite root instead of duplicating them under renderer/src.
+ '@locales': path.resolve(__dirname, 'locales'),
},
},
build: {
@@ -22,5 +26,10 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
+ fs: {
+ // locales/ sits above the Vite root (renderer/), so the dev server needs
+ // explicit permission to serve it. `vite build` resolves it either way.
+ allow: [path.resolve(__dirname, 'renderer'), path.resolve(__dirname, 'locales')],
+ },
},
});
diff --git a/app/vitest.config.ts b/app/vitest.config.ts
index 600a03df..d8839b2d 100644
--- a/app/vitest.config.ts
+++ b/app/vitest.config.ts
@@ -11,11 +11,13 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'renderer/src'),
+ '@locales': path.resolve(__dirname, 'locales'),
},
},
test: {
environment: 'jsdom',
globals: true,
include: ['renderer/src/**/*.test.{ts,tsx}'],
+ setupFiles: ['renderer/src/test-setup.ts'],
},
});
diff --git a/e2e/fixtures/electron.ts b/e2e/fixtures/electron.ts
index d231eb2f..0b3f2582 100644
--- a/e2e/fixtures/electron.ts
+++ b/e2e/fixtures/electron.ts
@@ -52,6 +52,12 @@ export const test = base.extend({
...(process.env as Record),
STENOAI_E2E: '1',
STENOAI_USER_DATA_DIR: userDataDir,
+ // Pin the UI language (#337). Most locators in this suite match on
+ // visible text, so a run that picked up a German OS — or a spec that
+ // happened to seed a German preference — would fail them for reasons
+ // unrelated to what they assert. Listed before opts.env so a spec that
+ // genuinely wants another language can override it.
+ STENOAI_UI_LANGUAGE: 'en',
...(opts.mockIpc ? { STENOAI_E2E_MOCK_IPC: '1' } : {}),
...(opts.env ?? {}),
};
diff --git a/e2e/specs/ui-language.t2.spec.ts b/e2e/specs/ui-language.t2.spec.ts
new file mode 100644
index 00000000..f05cc469
--- /dev/null
+++ b/e2e/specs/ui-language.t2.spec.ts
@@ -0,0 +1,118 @@
+import { test, expect } from '../fixtures/electron';
+import { readUserConfig, writeUserConfig } from '../fixtures/user-config';
+
+/**
+ * T2 — interface language (#337).
+ *
+ * Model-free: drives the real backend's ui-language IPC and asserts the value
+ * lands on the right `config.json` key, then relaunches to prove the language
+ * is resolved BEFORE first paint rather than applied afterwards.
+ *
+ * That ordering is the part worth a test. A round-trip alone would still pass
+ * if the app painted English and then flipped to German a frame later, which is
+ * exactly the flash the launch-argument bootstrap exists to prevent.
+ *
+ * Note these specs override `STENOAI_UI_LANGUAGE`, which the shared fixture
+ * pins to 'en' for every other spec in the suite.
+ */
+
+type UiLanguageBridge = {
+ uiLanguage: string;
+ settings: {
+ getUiLanguage: () => Promise<{
+ success: boolean;
+ ui_language?: string;
+ resolved?: string;
+ }>;
+ setUiLanguage: (code: string) => Promise<{
+ success: boolean;
+ ui_language?: string;
+ resolved?: string;
+ error?: string;
+ }>;
+ };
+};
+type StenoWindow = Window & { stenoai: UiLanguageBridge };
+
+test('set-ui-language persists to config.json and reports the resolved tag', async ({
+ launchApp,
+ userDataDir,
+}) => {
+ const { page } = await launchApp();
+
+ const result = await page.evaluate(
+ () => (window as unknown as StenoWindow).stenoai.settings.setUiLanguage('de'),
+ );
+ expect(result.success).toBe(true);
+ expect(result.resolved).toBe('de');
+
+ // The write goes through the Python config's atomic/locked persistence, so
+ // asserting the file is asserting the real storage path, not a JS shortcut.
+ expect(readUserConfig(userDataDir).ui_language).toBe('de');
+
+ const readBack = await page.evaluate(
+ () => (window as unknown as StenoWindow).stenoai.settings.getUiLanguage(),
+ );
+ expect(readBack.ui_language).toBe('de');
+ expect(readBack.resolved).toBe('de');
+});
+
+test('an unsupported language is rejected and leaves the stored value alone', async ({
+ launchApp,
+ userDataDir,
+}) => {
+ const { page } = await launchApp();
+
+ await page.evaluate(() =>
+ (window as unknown as StenoWindow).stenoai.settings.setUiLanguage('de'),
+ );
+ const rejected = await page.evaluate(
+ () => (window as unknown as StenoWindow).stenoai.settings.setUiLanguage('klingon'),
+ );
+
+ expect(rejected.success).toBe(false);
+ // The running UI must not switch to something that would not survive a
+ // restart, so the stored value stays where it was.
+ expect(readUserConfig(userDataDir).ui_language).toBe('de');
+});
+
+test('a stored German preference is in force at first paint, not applied after it', async ({
+ launchApp,
+ userDataDir,
+}) => {
+ // Seed before launch so the app reads it during startup, the way a returning
+ // user's config would be read.
+ writeUserConfig(userDataDir, { ui_language: 'de' });
+
+ // Clear the suite-wide English pin for this spec only.
+ const { page } = await launchApp({ env: { STENOAI_UI_LANGUAGE: '' } });
+
+ // The launch argument is what the renderer bootstraps i18next from. If this
+ // is right, no English frame was ever rendered — the alternative design (ask
+ // main over IPC after mount) could not satisfy this assertion.
+ const bootstrap = await page.evaluate(
+ () => (window as unknown as StenoWindow).stenoai.uiLanguage,
+ );
+ expect(bootstrap).toBe('de');
+
+ // Set synchronously by lib/i18n.ts at module scope, before React mounts.
+ await expect(page.locator('html')).toHaveAttribute('lang', 'de');
+});
+
+test('an existing config without the key keeps English rather than following the OS', async ({
+ launchApp,
+ userDataDir,
+}) => {
+ // The migration case the whole design turns on: this install has been showing
+ // an English UI, and an upgrade must not silently switch it just because the
+ // machine's OS language is not English.
+ writeUserConfig(userDataDir, { model: 'gemma4:e4b-it-qat' });
+
+ const { page } = await launchApp({ env: { STENOAI_UI_LANGUAGE: '' } });
+
+ const bootstrap = await page.evaluate(
+ () => (window as unknown as StenoWindow).stenoai.uiLanguage,
+ );
+ expect(bootstrap).toBe('en');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+});
diff --git a/simple_recorder.py b/simple_recorder.py
index 9215e5cc..c1f53e47 100644
--- a/simple_recorder.py
+++ b/simple_recorder.py
@@ -1670,6 +1670,41 @@ def set_keep_recordings_cmd(enabled: bool):
print(json.dumps({"success": False, "error": "Failed to persist setting"}))
+@cli.command(name='get-ui-language')
+def get_ui_language_cmd():
+ """Get the interface language ("system" follows the OS locale)."""
+ from src.config import get_config
+ config = get_config()
+ print(json.dumps({"ui_language": config.get_ui_language()}))
+
+
+@cli.command(name='set-ui-language')
+@click.argument('ui_language')
+def set_ui_language_cmd(ui_language: str):
+ """Set the interface language ("system", "en" or "de")."""
+ from src.config import get_config
+ config = get_config()
+ if ui_language not in config.VALID_UI_LANGUAGES:
+ print(json.dumps({
+ "success": False,
+ "ui_language": config.get_ui_language(),
+ "error": (
+ f"Unsupported UI language: {ui_language}. "
+ f"Supported: {', '.join(config.VALID_UI_LANGUAGES)}"
+ ),
+ }))
+ return
+
+ if config.set_ui_language(ui_language):
+ print(json.dumps({"success": True, "ui_language": ui_language}))
+ else:
+ print(json.dumps({
+ "success": False,
+ "ui_language": config.get_ui_language(),
+ "error": "Failed to persist setting",
+ }))
+
+
@cli.command(name='get-auto-install-when-idle')
def get_auto_install_when_idle_cmd():
"""Get whether updates auto-install when the app is idle."""
diff --git a/src/config.py b/src/config.py
index 162571e4..230c8455 100644
--- a/src/config.py
+++ b/src/config.py
@@ -257,6 +257,11 @@ class Config:
VALID_TRANSCRIPTION_ENGINES = ("parakeet", "whisper")
+ # Language of the app's own interface (chrome), independent of the
+ # "language" setting above, which is the transcription/summary language.
+ # "system" follows the OS locale; "en"/"de" pin it.
+ VALID_UI_LANGUAGES = ("system", "en", "de")
+
def __init__(self, config_path: Optional[Path] = None):
"""
Initialize configuration manager.
@@ -300,6 +305,7 @@ def __init__(self, config_path: Optional[Path] = None):
self._migrate_transcription_engine()
self._migrate_language_zh()
self._migrate_privacy_notice_seen()
+ self._migrate_ui_language()
self._normalize_templates()
self._seed_sample_template()
@@ -336,6 +342,24 @@ def _migrate_transcription_engine(self) -> None:
)
self._save()
+ def _migrate_ui_language(self) -> None:
+ """Decide the interface language on first launch of a version that has
+ this field.
+
+ The two branches are deliberately different, and the asymmetry is the
+ point: a fresh install has no prior interface to change, so it follows
+ the OS ("system"). An existing config predates this key, and today's
+ behaviour for that user is an English interface; migrating them to
+ "system" would flip a German-OS user's app to German without them
+ asking. They stay on "en" until they pick something in Settings.
+ """
+ if self._load_failed:
+ return # never persist defaults over a corrupt-but-recoverable file
+ if self._config.get("ui_language") in self.VALID_UI_LANGUAGES:
+ return
+ self._config["ui_language"] = "en" if self._existed_at_load else "system"
+ self._save()
+
def _migrate_privacy_notice_seen(self) -> None:
"""Seed the one-time privacy notice marker for fresh and existing installs.
@@ -711,6 +735,10 @@ def _get_default_config(self) -> Dict[str, Any]:
"auto_install_when_idle": True,
"whisper_model": "large-v3-turbo",
"transcription_engine": "parakeet",
+ # Interface language (app chrome), not the transcription language.
+ # "system" follows the OS locale on fresh installs; existing
+ # configs are migrated to "en" by _migrate_ui_language().
+ "ui_language": "system",
"version": "1.0"
}
@@ -1236,6 +1264,47 @@ def set_language(self, language_code: str) -> bool:
self._config["language"] = language_code
return self._save()
+ def get_ui_language(self) -> str:
+ """Get the configured interface language.
+
+ Separate from get_language(), which is the transcription/summary
+ language. "system" means follow the OS locale; the caller resolves it.
+
+ The corrupt-file branch exists because _migrate_ui_language() refuses to
+ run when the load failed -- correctly, so defaults never get written over
+ a file a user might still recover. But refusing to *write* must not
+ change what we *read*: a file that exists, however broken, belongs to an
+ install that has been showing an English interface, so it reads as "en"
+ exactly like an existing config that predates the key.
+
+ Without this branch the in-memory default ("system") leaks out here, and
+ app/i18n.js -- which resolves the same corrupt file to "en" for the
+ startup read -- would disagree. The two would then diverge on the next
+ save, which persists the defaults and silently flips a German-OS user's
+ interface to German on the following launch. Change this and
+ readStoredUiLanguage() in app/i18n.js together.
+ """
+ if self._load_failed:
+ return "en"
+ return self._config.get("ui_language", "system")
+
+ def set_ui_language(self, ui_language: str) -> bool:
+ """
+ Set the language of the app interface.
+
+ Args:
+ ui_language: One of VALID_UI_LANGUAGES ("system", "en", "de")
+
+ Returns:
+ True if saved successfully, False otherwise
+ """
+ if ui_language not in self.VALID_UI_LANGUAGES:
+ logger.error(f"Unsupported UI language: {ui_language}")
+ return False
+
+ self._config["ui_language"] = ui_language
+ return self._save()
+
def get_language_name(self, language_code: Optional[str] = None) -> str:
"""Get the display name for a language code."""
if language_code is None:
diff --git a/tests/test_config.py b/tests/test_config.py
index ecee3e12..db1ed21b 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -121,6 +121,98 @@ def test_non_chinese_language_has_no_variant_and_passes_asr_code(self):
self.assertEqual(config.get_whisper_language(), "de")
+class ConfigUiLanguageTests(unittest.TestCase):
+ """ui_language is the interface (chrome) language, separate from the
+ "language" setting that drives transcription and summaries (#337)."""
+
+ def test_fresh_install_follows_the_system_locale(self):
+ # No config.json at load: nothing to preserve, so follow the OS.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ config = Config(config_path=Path(tmp_dir) / "config.json")
+ self.assertEqual(config.get_ui_language(), "system")
+
+ def test_existing_config_without_key_migrates_to_english(self):
+ # The load-bearing case: an install that predates ui_language has an
+ # English interface today. It must NOT become "system", which would
+ # flip a German-OS user's app to German without them asking.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "config.json"
+ path.write_text(json.dumps({"model": Config.DEFAULT_MODEL}))
+
+ config = Config(config_path=path)
+
+ self.assertEqual(config.get_ui_language(), "en")
+ # Persisted so the migration doesn't re-run and can't be
+ # re-decided by a later version.
+ self.assertEqual(json.loads(path.read_text())["ui_language"], "en")
+ self.assertEqual(Config(config_path=path).get_ui_language(), "en")
+
+ def test_explicit_system_choice_survives_migration(self):
+ # A user who deliberately picked "system" keeps it; the migration
+ # only fills in values outside VALID_UI_LANGUAGES.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "config.json"
+ path.write_text(json.dumps({"ui_language": "system"}))
+
+ config = Config(config_path=path)
+
+ self.assertEqual(config.get_ui_language(), "system")
+ self.assertEqual(json.loads(path.read_text())["ui_language"], "system")
+
+ def test_set_ui_language_accepts_every_supported_value(self):
+ for choice in Config.VALID_UI_LANGUAGES:
+ with self.subTest(choice=choice):
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "config.json"
+ config = Config(config_path=path)
+
+ self.assertTrue(config.set_ui_language(choice))
+ self.assertEqual(config.get_ui_language(), choice)
+ self.assertEqual(json.loads(path.read_text())["ui_language"], choice)
+ self.assertEqual(Config(config_path=path).get_ui_language(), choice)
+
+ def test_set_ui_language_rejects_unknown_value(self):
+ # An unsupported code must be refused outright, not stored and then
+ # left for the renderer to fall over on.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "config.json"
+ config = Config(config_path=path)
+ self.assertTrue(config.set_ui_language("de"))
+
+ self.assertFalse(config.set_ui_language("fr"))
+
+ self.assertEqual(config.get_ui_language(), "de")
+ self.assertEqual(json.loads(path.read_text())["ui_language"], "de")
+
+ def test_set_ui_language_rejects_transcription_language_codes(self):
+ # get_language()'s vocabulary is much wider ("auto", "nl", "zh-Hans");
+ # none of it leaks into the UI setting.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ config = Config(config_path=Path(tmp_dir) / "config.json")
+ for code in ("auto", "nl", "zh-Hans"):
+ with self.subTest(code=code):
+ self.assertFalse(config.set_ui_language(code))
+ self.assertEqual(config.get_ui_language(), "system")
+
+ def test_corrupt_config_never_persisted_by_migration(self):
+ # A torn or corrupt file stays byte-identical on disk so it remains
+ # recoverable, and the migration must not write defaults over it.
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ path = Path(tmp_dir) / "config.json"
+ path.write_text("{not json")
+
+ config = Config(config_path=path)
+
+ # "en", not the in-memory "system" default: the file EXISTS, so this
+ # is an existing install that has been showing an English interface,
+ # and it must keep showing one. Reading "system" here would disagree
+ # with readStoredUiLanguage() in app/i18n.js, which resolves the same
+ # corrupt file to "en" during startup -- and the next save would then
+ # persist "system" and flip a German-OS user's UI to German.
+ self.assertEqual(config.get_ui_language(), "en")
+ self.assertEqual(path.read_text(), "{not json")
+
+
class ConfigMicrophoneTests(unittest.TestCase):
def test_default_microphone_is_system_default(self):
with tempfile.TemporaryDirectory() as tmp_dir: