Skip to content

feat(i18n): localise the interface, with German as the first language (#337) - #441

Open
Optic00 wants to merge 3 commits into
stenolabs:mainfrom
Optic00:feat/337-ui-i18n-de
Open

feat(i18n): localise the interface, with German as the first language (#337)#441
Optic00 wants to merge 3 commits into
stenolabs:mainfrom
Optic00:feat/337-ui-i18n-de

Conversation

@Optic00

@Optic00 Optic00 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

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/. Nothing
synchronises them automatically; they are coordinated by an explicit sequence in
applyUiLanguage():

persist → main changeLanguage → rebuild app menu → rebuild tray → broadcast to every WebContents

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 exactly
this; 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 existing config.json without 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_load already distinguishes the two cases for the transcription-engine and
privacy migrations.

Both runtimes implement this independently — main reads config.json synchronously at
startup, 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:

  • It removes the migration wrinkle rather than trading it. A user migrated to "en" keeps
    seeing 25.07.2026, not 7/25/2026.
  • It is what macOS does. Language and Region are separate controls; a German UI on a
    US-region Mac shows US dates natively. Following the UI language would make Steno diverge
    from every other app on the machine.
  • Zero regression for existing users, which the RFC's version cannot promise.

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 in de.json. Happy to
flip it if you would rather have Sie.

What stays English on purpose

  • The four markdown headings. ## Summary / ## Key Topics / ## Key Points /
    ## Action Items are a parsing protocol consumed in four places, so they stay English in
    stored 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.
  • Built-in template prompts. Model input, not UI copy — and each already ends with an
    instruction to answer in the language of the meeting, so a German meeting already gets a
    German summary.
  • Built-in template names except the seeded sample. "Sales Call", "Standup" and
    "Product Demo" are the German terms for those meetings; translating them would be worse
    German.
  • Exports (notesPdf.ts, notesCopy.ts, the org-share body). Whether an export follows
    the UI language or the content language is a content decision, not a UI-i18n one, and I did
    not want it answered by accident.
  • The reserved title placeholder in storage. A recording is saved as Note (optionally
    suffixed), which _AUTO_NAMED_PATTERN matches 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.strings ships so the microphone and system-audio prompts
are German. Everything macOS draws around them was already German; only our two sentences
were not, because they live in Info.plist as build metadata no runtime translation layer
can reach.

Two things worth knowing:

  • 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.
  • These follow the system language, not Steno's setting — the prompt belongs to the OS. An
    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.

.gitignore needed an explicit exception: app/build/* is an allowlist, so without one the
file 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 -lint OK,
codesign --verify --deep --strict passes. That is an ad-hoc signature, so it is evidence
about 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 108
locators 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 unsupported
value, resolution before first paint, and the migration case),
app/locale-completeness.test.js (English-source completeness enforced, German coverage
reported 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-name
helpers.

Verification

  • ruff check . → 29 findings, identical to the count on main (verified by stashing the
    branch and re-running). Zero added.
  • python -m unittest discover tests → 504 passed, 7 skipped.
  • npm run test:unit → 299 node:test + 131 vitest.
  • typecheck + lint clean (36 pre-existing warnings, 0 errors).
  • T1: 50 passed. T2 model-free: 95 passed, 1 loud skip (summary model absent on this box).
  • Packaged .app launch: locale JSON loads from inside app.asar.
  • Cold start with no Ollama running: 60 s of real German audio through the full pipeline
    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 persisted
system on the next settings write and flipped a German-OS user's UI on the launch after
that. 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 .gitignore gap above (which would have shipped English prompts in the signed
build while looking correct locally), and the reserved Note placeholder rendering in English
in 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

  • Windows has not been run. The platform gating is verified statically — no macOS-only
    menu role reaches the non-darwin branch — but CI is the first real exercise.
  • The recording UI has not been seen in German. Import → transcribe → summarise → note
    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 role-based menu items and Chromium's own context menus: worth knowing that
    Electron does not supply localised labels for roles on macOS — measured on a fully
    German Mac, bare roles render "Undo"/"Cut"/"Select All", and --lang does 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

    • Two i18next instances (main and renderer) share app/locales/{en,de}.json. Main persists ui_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.
    • Interface-language setting: System / English / Deutsch, live-updates across windows; German exonyms, proper plurals and relative time. macOS mic/system-audio prompts are localized via app/build/lproj/de.lproj/InfoPlist.strings (they follow the system language).
    • Locale completeness gate for English keys, renderer unit tests, and an e2e spec that verifies resolution before first paint.
    • Dependencies: add i18next and react-i18next.
  • Migration

    • Fresh installs default to "system". Existing installs without ui_language migrate to "en" to avoid unexpected language flips; main and backend agree across malformed and missing config cases.
    • Dates, times, and numbers follow the OS region; only language-bearing UI text changes.

Written for commit da6c088. Summary will update on new commits.

Review in cubic

Optic00 added 3 commits July 27, 2026 00:06
…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.
@Optic00
Optic00 requested a review from ruzin as a code owner July 27, 2026 14:22
@Optic00

Optic00 commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

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:

  • app/package.json — both extend the test:unit list. Trivial.
  • app/renderer/src/routes/settings/AboutTab.tsx — a real one, and it is not just textual.

The second is worth knowing about before either lands. #440 adds roughly 15 new user-facing
English strings (the About tab's update-error copy, plus the new app/update-error-copy.js,
which builds whole sentences shown to the user). This PR does not know about them, so after a
merge they would render in English inside an otherwise German UI.

And the completeness gate in this PR would not catch it. locale-completeness.test.js
asserts that every key the code uses exists in en.json; it cannot see a string that was
never extracted in the first place. That limitation is documented in the test header — this is
just the first concrete case of it.

Suggested order: merge #440 first. It is smaller, older, already reviewed, and fixes a
visible bug. I will then rebase this branch and extract its new strings as part of the same
pass, so this PR stays the single place that owns "every user-facing string is translatable".

The other order works too but is worse: #440 would have to be reworked onto t() afterwards,
which devalues the review it has already had.

No action needed from you beyond the ordering — happy to do the rebase and extraction the
moment #440 lands.

@ruzin

ruzin commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

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 t() or silently ship English-only, and locale-completeness.test.js structurally can't catch a string that was never extracted.

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:

  1. An ESLint no-literal-string gate so extraction is enforced at the source (CI fails on a hardcoded JSX string). This closes the never-extracted gap you flagged and makes the whole thing robust to merge order.
  2. Scope down to high-traffic surfaces first (Setup, Settings, record flow) rather than all ~1,200 keys across ~60 files at once — smaller merge surface, far less conflict with the feature PRs in flight (Edit a generated note, without losing what the model wrote #446/Share menu: collect the note exports, and send a note to the macOS share sheet #448/perf(live): keep the live transcript's render cost flat, and measure it #452), and coverage grows with proven demand.

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.

@Optic00

Optic00 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

is there real signal for a German UI specifically

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:

  1. The ESLint no-literal-string gate. Agreed, and it closes the gap I flagged myself. Worth landing on its own first, so it guards whatever comes after.
  2. Scoping down. Agreed, with one adjustment: I'd cut to the path to a user's first finished note (setup, record flow, the meeting view) rather than high-traffic surfaces generally. Settings can wait. That is a small fraction of the keys and barely touches the PRs in flight.

Happy to close this one and reopen it scoped that way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants