feat(i18n): localise the interface, with German as the first language (#337) - #441
feat(i18n): localise the interface, with German as the first language (#337)#441Optic00 wants to merge 3 commits into
Conversation
…stenolabs#337) Adds a UI-chrome localisation layer and a full German translation. English stays the source language; the architecture takes further locales without rework. Two independent i18next instances, one per runtime, over the same JSON files in app/locales/. They share no state and are coordinated by an explicit sequence in main.js: persist -> changeLanguage -> rebuild the app menu -> rebuild the tray -> broadcast to every WebContents. The two native menus are snapshots, so they need that explicit rebuild; buildAppMenu() was extracted from the inline block for exactly this. The renderer bootstraps from a launch argument (webPreferences .additionalArguments) rather than an IPC round trip, and initialises i18next at module scope before React mounts, so the first paint is already in the right language instead of flashing English. Migration semantics, the part worth reviewing: a fresh install follows the OS; an existing config.json without the key migrates to "en". The asymmetry is the point. Treating an absent key as "follow the system" would flip every existing German-OS user's interface to German on upgrade without them asking. Both runtimes implement this independently, because main must read config.json synchronously long before Python can be spawned, and they are verified to agree across the full matrix of file states including corrupt and malformed ones. Date and time formatting deliberately keeps following the OS locale rather than the chosen UI language, which is how macOS itself separates Language from Region. Only language-bearing output moves: plurals (now i18next `count`, no more `${n} item${n !== 1 ? 's' : ''}`), relative time, and spelled-out durations. This also means no existing user's dates change. The four markdown section headings stay English wherever they are stored or shared -- they are a parsing protocol consumed in four places -- and are localised only where they are rendered. The e2e suite is pinned to English via STENOAI_UI_LANGUAGE, gated on the E2E flag. Roughly 108 of its locators match on visible text, and refactoring them onto test ids is a large unrelated change that would make this diff unreviewable. Known limitations, both deliberate: Electron role-based menu items and Chromium's own context menus follow the OS rather than the app setting, and exports plus the macOS permission prompts are untouched -- those are content and build metadata respectively, not UI chrome.
…e app
Four things a running German build surfaced that the tests could not.
The application menu was the real one. It rendered "Einstellungen…" alone in an
otherwise English menu, which reads more broken than either language on its own.
The assumption behind leaving it was Electron's own -- that a role-based item
supplies a platform-appropriate localised label. Measured on a fully German Mac
(getLocale=de, systemLocale=de-DE), it does not: bare roles come out "Undo",
"Cut", "Select All". Setting Chromium's --lang does not move them either; the
labels are not Chromium's to give on macOS. So the composite roles are expanded
and each item carries a translated label while keeping its role, which is what
preserves the behaviour and the accelerators (verified: quit still binds ⌘Q,
paste ⌘V, select-all ⌘A). The wording is Apple's macOS German -- "Ablage" not
"Datei", "Widerrufen", "Einsetzen", "Im Dock ablegen" -- because matching the
platform is the entire reason to use roles.
The transcription-language picker listed "Spanish", "French", "Japanese" in a
German UI. Language pickers have two valid conventions and this one had the
wrong half: endonyms ("Español") exist so a speaker can find their language in a
UI they cannot read, but here the reader is already in German and is saying "my
meetings are in Spanish" -- so exonyms. Labels resolve by code with the English
name as the fallback, so adding a language without a translation degrades to a
real word rather than a key. `auto` keeps two distinct labels on purpose:
Whisper detects per recording, Parakeet is language-agnostic at inference.
The memory badge said "Speicher könnte knapp werden", which German reads as disk
space first, and overflowed its badge. Now "RAM knapp", with the tooltip
carrying the full sentence. It stays a hedge rather than a verdict because the
heuristic behind it deliberately over-warns -- it fires on models that run fine.
The seeded "Shareable summary" template kept an English name. Localised at
display time by its stable id, never in storage, and only while the stored name
still matches the English source -- the moment a user renames it, their name
wins. The other built-ins are deliberately untouched: "Sales Call", "Standup"
and "Product Demo" ARE the German terms for those meetings. Their prompt bodies
stay English too; those are model input, not UI copy, and each already ends with
an instruction to answer in the language of the meeting.
…w gaps
Adds de.lproj/InfoPlist.strings so the microphone and system-audio prompts are
German. Everything macOS draws around them (title, buttons) was already German;
only our two sentences were not, because they live in Info.plist as build
metadata that no runtime translation layer can reach.
The .gitignore change is the load-bearing part of that. `app/build/*` is an
allowlist with explicit exceptions, so without one the new file is invisible to
git: local builds would look right while a clean CI checkout has no de.lproj,
electron-builder only warns, and the signed release ships English prompts.
Exactly the kind of defect that cannot be seen on the machine that wrote it.
Only the German file ships. macOS falls back to mac.extendInfo when no .lproj
matches, so an en.lproj would be a second copy of the same strings with nothing
keeping them in step. Note these follow the SYSTEM language, not Steno's own
setting -- the prompt belongs to the OS, so an English Mac running a German
Steno still sees English here.
Also closes three gaps the same review found:
The reserved title placeholder. A recording is stored as "Note" (optionally
suffixed), which the backend matches with _AUTO_NAMED_PATTERN and later replaces
with a generated title. With auto-summarise off, or after a failed generation, a
German UI showed an English "Note" indefinitely. Localised at display only, and
deliberately NOT in the contentEditable branch of the detail title: saving a
translated placeholder would stop the backend from ever recognising it again.
Never applied where the value is searched, stored or uploaded.
The Help menu was the last item still relying on a role for its label -- the
same thing that made the rest of the menu English -- and `help` is documented as
macOS-specific while the object is also used in the Windows/Linux template.
The collapsed language picker in the live bar showed the English name ("German")
while its own dropdown showed the German one.
Verified on the packaged bundle per the reviewer's checklist: file present,
`plutil -lint` OK, `codesign --verify --deep --strict` passes. That is an ad-hoc
signature, so it is evidence about signing, not about notarisation -- the
release build is the first real test of that.
|
Heads-up on merge order with #440, which touches four of the same files. A test merge of the two branches conflicts in two places:
The second is worth knowing about before either lands. #440 adds roughly 15 new user-facing And the completeness gate in this PR would not catch it. Suggested order: merge #440 first. It is smaller, older, already reviewed, and fixes a The other order works too but is worse: #440 would have to be reworked onto No action needed from you beyond the ordering — happy to do the rebase and extraction the |
|
Thanks for this, and for the #440 merge-order heads-up @Optic00 — that's exactly the fragility worth flagging. Decision for now: we're deferring this rather than merging it into the near-term release train — not rejecting it. Reasoning, so it's on the record: Why defer. Steno is a transcription product, and there are two different "language" axes: transcription/summary language (what we understand and summarize — e.g. #102, #429) vs UI-chrome language (this PR). For a non-English user, accurate German transcription and summaries is worth far more than a localized Settings panel — so that axis is the higher-ROI localization work, and it doesn't carry this PR's ongoing cost. With a small team, the ongoing cost matters: every future renderer PR now has to route strings through The approach itself is sound — i18next is the right choice, and English-fallback means missing keys never break the UI. The reservations are about scope and timing, not craft. Two things that would make it safe to revive:
Open question before we pick it back up: is there real signal for a German UI specifically (a customer, telemetry on locale), or is this contributor-driven from #337? That answer decides priority. Parking as deferred; happy to revisit with the above. |
Yes, though field observation rather than telemetry, and I should be straight about which. I put Steno in front of non-technical people in Germany, and "gibt es das auch auf Deutsch?" is reliably the first question, ahead of anything about accuracy. German users are used to localized software, and an English-only interface reads as "not meant for us". It produces hesitation rather than curiosity, which is expensive in exactly the room where you want someone to try a recording. So I'd frame the two axes as sequential rather than competing. Transcription quality is what convinces someone after they have recorded; UI language decides whether they record at all. And the transcription axis is largely there already, since Parakeet v3 is multilingual and our own docs put it at 25 European languages. The interface is the part still gating the first session. Both of your conditions are right, and I'll take them:
Happy to close this one and reopen it scoped that way. |
Implements the RFC in #337. English stays the source language; German lands first and is
complete (847/847 keys). The architecture takes further locales without rework — a new
language is a JSON file plus one line in a supported-languages list.
Three commits: the feature, a follow-up with four fixes that only surfaced from driving a
packaged German build by hand, and a third closing the gaps from a second review round.
How it works
Two i18next instances, one per runtime, over the same JSON in
app/locales/. Nothingsynchronises them automatically; they are coordinated by an explicit sequence in
applyUiLanguage():The two native menus are snapshots, so
changeLanguage()alone will not relabel them —hence the explicit rebuild.
buildAppMenu()was extracted from an inline block for exactlythis; the tray already had
updateTrayMenu().The renderer bootstraps from a launch argument (
webPreferences.additionalArguments),not an IPC round trip, and initialises i18next at module scope before React mounts. An async
hop would land after first paint, which is the flash of English this avoids. The T2 spec
asserts this rather than just asserting a round-trip, because a round-trip test would still
pass if the app painted English and flipped a frame later.
Three decisions worth your attention
1. Migration semantics — the question that was blocking this.
Fresh install →
"system". An existingconfig.jsonwithout the key →"en". Explicit"system"keeps following the OS.The asymmetry is the point. Treating an absent key as "follow the system" is what performs
the flip the RFC says it avoids: every existing install lacks the key, so a German-OS user's
interface would switch to German on first launch after the update without being asked.
_existed_at_loadalready distinguishes the two cases for the transcription-engine andprivacy migrations.
Both runtimes implement this independently — main reads
config.jsonsynchronously atstartup, far too early to spawn Python — and they are verified to agree across 14 file states
including corrupt, truncated, empty and wrongly-typed. A divergence here was the one critical
finding in review (below).
2. Date formatting deliberately does NOT follow the UI language. This diverges from the
RFC and is the change I would most like you to look at.
The RFC routes dates through the resolved UI locale. This does not: dates, times and numbers
keep following the OS locale, and only language-bearing output moves — plurals (now i18next
count, no more${n} item${n !== 1 ? 's' : ''}), relative time, spelled-out durations.Three reasons:
"en"keepsseeing
25.07.2026, not7/25/2026.US-region Mac shows US dates natively. Following the UI language would make Steno diverge
from every other app on the machine.
Cost, stated plainly: a German UI on a US-region Mac shows US dates. If you would rather have
dates track the UI language it is a one-line change — the locale source is a single exported
function and every call site already goes through it.
3. German tone is
du. Zero code impact; the tone lives entirely inde.json. Happy toflip it if you would rather have
Sie.What stays English on purpose
## Summary/## Key Topics/## Key Points/## Action Itemsare a parsing protocol consumed in four places, so they stay English instored and org-shared markdown and are localised only where they are rendered. Verified
against a real German note: storage has the English headings, the settled view shows
Zusammenfassung / Kernthemen / Kernpunkte / Aufgaben.
instruction to answer in the language of the meeting, so a German meeting already gets a
German summary.
"Product Demo" are the German terms for those meetings; translating them would be worse
German.
notesPdf.ts,notesCopy.ts, the org-share body). Whether an export followsthe UI language or the content language is a content decision, not a UI-i18n one, and I did
not want it answered by accident.
Note(optionallysuffixed), which
_AUTO_NAMED_PATTERNmatches and later replaces with a generated title.It is localised at display only, and deliberately not in the editable branch of the
detail title — saving a translated placeholder would stop the backend recognising it, and
the note would keep its placeholder forever.
macOS permission prompts
app/build/lproj/de.lproj/InfoPlist.stringsships so the microphone and system-audio promptsare German. Everything macOS draws around them was already German; only our two sentences
were not, because they live in
Info.plistas build metadata no runtime translation layercan reach.
Two things worth knowing:
mac.extendInfowhen no.lprojmatches,so an
en.lprojwould be a second copy of the same strings with nothing keeping them instep.
English Mac running a German Steno still sees English here. Correct, but it does mean this
one surface does not follow the in-app picker.
.gitignoreneeded an explicit exception:app/build/*is an allowlist, so without one thefile is invisible to git — local builds look right while a clean CI checkout has no
de.lproj, electron-builder only warns, and the signed release ships English prompts.Verified on the packaged bundle: file present,
plutil -lintOK,codesign --verify --deep --strictpasses. That is an ad-hoc signature, so it is evidenceabout signing, not about notarisation — this release build is the first real test of that,
and it is the part of this PR I would most want a second pair of eyes on.
e2e
The suite is pinned to English via
STENOAI_UI_LANGUAGE, gated on the E2E flag. About 108locators match on visible text; refactoring them onto test ids is a large unrelated change
that would make this diff unreviewable. Pinning is one env var and is reversible the day
someone wants a German lane.
New:
e2e/specs/ui-language.t2.spec.ts(persistence round-trip, rejection of an unsupportedvalue, resolution before first paint, and the migration case),
app/locale-completeness.test.js(English-source completeness enforced, German coveragereported not enforced, plural pairs and placeholder parity enforced for both),
app/ui-language.test.js, plus renderer unit tests for the language-label and template-namehelpers.
Verification
ruff check .→ 29 findings, identical to the count onmain(verified by stashing thebranch and re-running). Zero added.
python -m unittest discover tests→ 504 passed, 7 skipped.npm run test:unit→ 299 node:test + 131 vitest..applaunch: locale JSON loads from insideapp.asar.in 45.9 s wall clock including the Ollama start — German transcript
(
detected_language: "de"), German summary, English headings preserved in storage.Review
Two cross-family review rounds by Codex.
Round one found one critical: the two migration implementations disagreed on a corrupt
config.json(JS resolved"en", Python returned"system"), which would have persistedsystemon the next settings write and flipped a German-OS user's UI on the launch afterthat. Fixed, with the test corrected. Two mediums and one low fixed; one medium rejected with
reasons (the date decision above); one low left open.
Round two was asked to judge the decisions rather than hunt bugs, and agreed with the
migration asymmetry and the date-formatting divergence explicitly. It found two mediums, both
fixed: the
.gitignoregap above (which would have shipped English prompts in the signedbuild while looking correct locally), and the reserved
Noteplaceholder rendering in Englishin a German UI. Plus two lows, both fixed — the Help menu was the last item still relying on a
role for its label, and the collapsed language picker in the live bar showed "German" while
its own dropdown showed "Deutsch".
Still open, deliberately: a failed language write correctly does not switch the running
UI, but the picker snaps back with no explanation. That needs error UI, which is a design
decision rather than a mechanical one.
Known gaps, honestly
menu role reaches the non-darwin branch — but CI is the first real exercise.
detail is covered; the live transcript bar and recording pill are translated and covered by
the English lane, but nobody has watched them in German with a real microphone.
Electron does not supply localised labels for roles on macOS — measured on a fully
German Mac, bare roles render "Undo"/"Cut"/"Select All", and
--langdoes not move them.So the composite roles are expanded and labelled explicitly, each keeping its role so
behaviour and accelerators still come from Electron. The RFC's assumption to the contrary
is worth correcting there.
On the slicing
The RFC suggests staged PRs (scaffolding → formatting → extraction). This is one PR because
the extraction is what proves the scaffolding, and a scaffolding-only PR would have been
unreviewable in a different way — no way to see whether the design survives 800 strings.
Happy to split it if you would rather review it in stages; say the word before I rebase
anything.
Summary by cubic
Localizes the app interface with
i18next, adds a complete German (de) locale, and enables instant language switching without a first‑paint flash. English remains the source; adding another language is one JSON file plus a one‑line registration.New Features
i18nextinstances (main and renderer) shareapp/locales/{en,de}.json. Main persistsui_language, rebuilds the native menu and tray, then broadcasts to all windows; the renderer boots from a launch argument so the first paint is in the right language.app/build/lproj/de.lproj/InfoPlist.strings(they follow the system language).i18nextandreact-i18next.Migration
ui_languagemigrate to "en" to avoid unexpected language flips; main and backend agree across malformed and missing config cases.Written for commit da6c088. Summary will update on new commits.