Status: draft (M3 — community-translation foundation)
Owner: OpenQuackApp/Localizable.xcstrings (new) + OpenQuackKit
Last updated: 2026-05-07
OpenQuack’s UI is English-only today. The app already transcribes 99 Whisper languages, and we have organic non-English signal (Chinese audience trickling from awesome-mac). Localised UI lets users dictate in their language and read the menus, settings, and onboarding in their language — closing the loop.
This spec ships the infrastructure, not the translations. Empty
.xcstrings files for each target locale are the deliverable; the
actual translations come from community contributors via PR (per the
docs/CONTRIBUTING.md translation flow and the README.zh-CN.md /
README.ja.md / etc. precedent).
docs/*.md) in this SPEC. README
translations exist (zh-CN, ja, ko, fr, es, de via auto-translation
with native-speaker-correction PRs). Other docs follow if there’s
signal.Match the language picker in Sources/OpenQuackApp/SettingsView.swift
(Settings → General → Transcription language) — same locales the
app already understands for Whisper:
zh-Hans (Simplified Chinese)ja (Japanese)ko (Korean)fr (French)es (Spanish)de (German)it (Italian)pt (Portuguese)zh-Hant (Traditional Chinese) deferred — Whisper’s Chinese output
already has a script picker (zh-Hans / zh-Hant / auto); we
default zh- to zh-Hans for UI strings until traditional-script signal
shows up.
The infrastructure ships empty for all locales. Translations land as PRs.
Sources/OpenQuackApp/Resources/
└── Localizable.xcstrings # NEW — String catalog file
.xcstrings is the modern Xcode 15+ string-catalog format. Single
file, all locales co-resident, JSON-backed. Replaces the older
per-locale .strings + .stringsdict directory layout.
Every visible string in the app gets converted from a literal to a localised lookup:
// Before
Text("Send feedback…")
// After
Text(String(localized: "settings.menu.send_feedback", defaultValue: "Send feedback…"))
// Or in SwiftUI shorthand:
Text("Send feedback…", comment: "Status-item right-click menu item")
The comment: parameter is what translators see — make every comment
descriptive (where the string appears, what action it represents).
The defaultValue (or the literal string itself) is the English
source of truth.
Key files to migrate (~20-30 strings each):
Sources/OpenQuackApp/MenuBarContent.swiftSources/OpenQuackApp/SettingsView.swiftSources/OpenQuackApp/RecordingOverlay.swiftSources/OpenQuackApp/OnboardingFlow.swiftSources/OpenQuackApp/OpenQuackApp.swift (right-click menu)Sources/OpenQuackApp/SettingsWindowController.swiftTotal estimated: ~150-200 user-visible strings across the app.
Default behaviour: follow the system locale. macOS gives us
Locale.current.language.languageCode?.identifier — if it matches a
target locale, use it; otherwise fall back to English.
Override surface: a new picker in Settings → General:
@AppStorage("displayLanguage") private var displayLanguage: String = "system"
Picker("Display language", selection: $displayLanguage) {
Text("System").tag("system")
Text("English").tag("en")
Text("简体中文").tag("zh-Hans")
Text("日本語").tag("ja")
// …
}
This is distinct from the existing “Transcription language” picker (which controls Whisper) — display language is for the UI, transcription language is for the speech model. Both live in Settings → General.
The displayLanguage UserDefault is read at app launch via
Bundle.main.preferredLocalizations override (set
AppleLanguages defaults key). Locale change requires app restart;
surface a “restart to apply” hint when the user changes it.
Swift’s String(localized:) supports plurals via .xcstrings plural
variations. Strings that pluralize (e.g., “%d transcript” /
“%d transcripts”) use the variation form per locale.
Example:
Text("\(history.count) recorded", comment: "History count, plural-aware")
Translators handle the plural rules per locale (e.g., Russian has 3 plural categories, Polish has 4, Japanese has 1).
Localisation is pure UI; no audio, no network, no telemetry. The
privacy contract in docs/VISION.md#privacy-contract is unaffected.
Atomic PRs:
| # | Title | Branch | Effort | Files |
|---|---|---|---|---|
| 1 | Add Localizable.xcstrings (empty) + Settings → Display Language picker |
feat/spec-019-infra |
S | Sources/OpenQuackApp/Resources/Localizable.xcstrings (new), Sources/OpenQuackApp/SettingsView.swift (edit) |
| 2 | Migrate MenuBarContent.swift strings to localised lookups |
feat/spec-019-menubar |
S | MenuBarContent.swift (edit), Localizable.xcstrings (auto-extracted) |
| 3 | Migrate SettingsView.swift strings |
feat/spec-019-settings |
M | SettingsView.swift (edit) — biggest single file |
| 4 | Migrate RecordingOverlay.swift + OnboardingFlow.swift strings |
feat/spec-019-overlay-onboarding |
S | both files |
| 5 | Migrate OpenQuackApp.swift (right-click menu) + remaining surfaces |
feat/spec-019-app-menus |
S | OpenQuackApp.swift + any leftovers |
| 6 | First community translation lands (e.g., zh-Hans) | external PR | — | Localizable.xcstrings (translator fills the zh-Hans column) |
PRs 1-5 are infrastructure (we write them). PR 6+ are community contributions.
After PR 5 lands, add a CI check that fails if a SwiftUI Text("…")
literal appears outside a String(localized:) / commented form. Cheap
guard against regressions.
Documented in docs/CONTRIBUTING.md and the language switcher
section of each README.<lang>.md:
Sources/OpenQuackApp/Resources/Localizable.xcstrings in
Xcode 15+ (renders as a string-catalog editor with per-locale
columns).i18n: translate to <locale-name>.Maintainer responsibilities:
AppleLanguages defaults. Our override picker writes that key
and prompts restart. Verify on macOS 13 (our minimum) — the API
is stable but the key name has had variations.comment: where the layout is
tight.String.LocalizationValue
— the API surface.docs/CONTRIBUTING.md — community-side guidance for translators.docs/VISION.md#privacy-contract — preserved (UI-only change).