feat(i18n): enable String Catalog + locale-aware numeric formatters

Lays the groundwork for zh-Hans / de / fr translations on an English base.
No user-visible English-locale behavior changes. See scarf/docs/I18N.md for
the full plan and remaining audit follow-ups.

- Localizable.xcstrings seeded with 538 keys auto-extracted via
  `xcstringstool sync` from the Swift sources
- InfoPlist.xcstrings carrying NSMicrophoneUsageDescription
- knownRegions += zh-Hans, de, fr
- Currency / byte-count / compact-number String(format:) sites migrated to
  Locale.current-aware .formatted() style (currency, byteCount(.file),
  compactName notation) — previously rendered POSIX separators + English
  unit names regardless of user locale

Refs #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-20 17:19:06 -07:00
parent c8208dedb1
commit 89748fdfee
12 changed files with 1812 additions and 22 deletions
+107
View File
@@ -0,0 +1,107 @@
# Internationalization (i18n)
Scarf uses Apple's modern **String Catalog** workflow. Source strings are auto-extracted from `Text("…")` and `String(localized: …)` literals into [`scarf/scarf/Localizable.xcstrings`](../scarf/Localizable.xcstrings) at build time (when built in Xcode.app; `xcodebuild` alone emits per-source `.stringsdata` but does not merge back into the catalog). Info.plist keys are localized via [`scarf/scarf/InfoPlist.xcstrings`](../scarf/InfoPlist.xcstrings).
## Languages
| Locale | Status |
|---|---|
| `en` (English) | Base / source |
| `zh-Hans` (Simplified Chinese) | Region enabled, translations pending |
| `de` (German) | Region enabled, translations pending |
| `fr` (French) | Region enabled, translations pending |
Canadian French users are served by base `fr`. `fr-CA` will be added only if a concrete Québec-specific bug is reported.
## Adding a new language
1. Xcode → Project → Info → Localizations → `+` (add locale).
2. Ensure the locale code is also listed in `knownRegions` of `scarf.xcodeproj/project.pbxproj`.
3. Open `Localizable.xcstrings` in Xcode; the new locale appears as an empty column — translate or use Xcode's AI suggestions.
4. Repeat for `InfoPlist.xcstrings` (microphone usage, etc.).
5. Smoke-test via scheme language override (Edit Scheme → Run → App Language).
## Adding translations (AI-first workflow)
For the three supported non-English locales we use Xcode's built-in AI translation:
1. Open `Localizable.xcstrings` in Xcode.
2. Select untranslated rows for a locale → right-click → **Translate** (Xcode 26+ provides GPT-backed suggestions with context from the surrounding code comment).
3. Review each suggestion before marking **Translated**.
4. For terms that should NOT translate (proper nouns like *Scarf*, *Hermes*, *Anthropic*; env var names; file paths), wrap the source site in `Text(verbatim: "…")` so the key never hits the catalog.
## Guardrails when writing new UI code
`Text("literal")` auto-localizes. These patterns **silently leak English** and need explicit handling:
| Pattern | Fix |
|---|---|
| `Text(someStringVar)` | `Text(LocalizedStringResource("key"))` or pass a `LocalizedStringKey` down the view tree |
| `"Hello " + name` | `String(localized: "Hello \(name)")` |
| `String(format: "$%.2f", cost)` | `cost.formatted(.currency(code: "USD").precision(.fractionLength(2)))` |
| `String(format: "%.1f MB", size)` | `Int64(size).formatted(.byteCount(style: .file))` |
| `String(format: "%.1fM", n)` | `n.formatted(.number.notation(.compactName))` |
| Custom `DateFormatter` with fixed `dateFormat` | `date.formatted(.dateTime.month().day().year())` |
| `.help(stringVar)` | Compute a `LocalizedStringKey` or use `.help(Text(…))` |
| `Button(stringVar)` | `Button(LocalizedStringResource("key")) { … }` |
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
## Outstanding audit follow-ups
The [initial i18n PR](#) enabled the catalog infrastructure and migrated clear locale-bug formatter sites. The following patterns remain in the codebase and should be refactored **before** translations ship in Phase 2 — otherwise their English copy will leak through regardless of locale:
### Category A — UI copy held in a `String` variable (needs `LocalizedStringResource`)
High-priority (ternary UI copy, visible status chrome):
- `Features/Health/Views/HealthView.swift:135``"Hermes Running"` / `"Hermes Stopped"` ternary
- `Features/Chat/Views/ChatView.swift:125``"Active"` fallback
- `Features/Chat/Views/ChatView.swift:241``"Voice On" / "Voice Off"`
- `Features/Chat/Views/ChatView.swift:256``"TTS On" / "TTS Off"`
- `Features/Chat/Views/ChatView.swift:271``"Recording..." / "Push to Talk"`
- `Features/MCPServers/Views/MCPServerTestResultView.swift:13``"Test passed" / "Test failed"`
- `Features/Profiles/Views/ProfilesView.swift:145``"Active profile" / "Inactive"`
- `Features/Platforms/Views/PlatformSetup/SignalSetupView.swift:54``"signal-cli is available on PATH"` / not-found message
- `Features/QuickCommands/Views/QuickCommandsView.swift:148``"Add Quick Command"` / `"Edit /\(name)"` ternary
- `Features/MCPServers/Views/MCPServersView.swift:131``.help("\(count) tools")` vs `"Test failed"` ternary
- `Features/MCPServers/Views/MCPServerDetailView.swift:157, 185` — masked-value placeholder
Medium-priority (enum `.rawValue` → display):
- `Features/Logs/Views/LogsView.swift:30,38,48,69` — file / component / level display
- `Features/Projects/Views/ProjectsView.swift:153` — tab display
- `Features/Skills/Views/SkillsView.swift:37` — tab display
- `Features/Insights/Views/InsightsView.swift:40, 201` — period picker, day-of-week names (see also Category C)
- `Features/CredentialPools/Views/CredentialPoolsView.swift:265` — type display
- `Features/Activity/Views/ActivityView.swift:117` — kind display
Lower-priority (displayName passthroughs from services that could be wrapped once at the source):
- `Features/Platforms/Views/PlatformsView.swift:43, 91`, `Features/Tools/Views/ToolsView.swift:46``platform.displayName`
- `Features/Gateway/Views/GatewayView.swift:105``platform.name.capitalized`
- `Features/MCPServers/Views/*``transport.displayName`, preset names / descriptions
- `Features/Servers/Views/ServerSwitcherToolbar.swift:40`, `ManageServersView.swift:96``server.displayName`
### Category B — Composite format strings with translatable suffixes
- `Features/Settings/Views/Components/ModelPickerSheet.swift:105``ctx + " ctx"` — literal " ctx" needs keying
- `Features/Insights/Views/InsightsView.swift:93``formatTokens(...) + " tokens"` — literal " tokens" needs keying
- `Features/MCPServers/Views/MCPServerTestResultView.swift:15``"%.1fs · %d tools"` — needs splitting into a `String(localized: "\(elapsed)s · \(count) tools")`
- `Features/Insights/Views/InsightsView.swift:167``"%.1f%%"` — needs `.formatted(.percent)` after verifying the input range (0…1 vs 0…100)
### Category C — Hard-coded day / month name arrays
- `Features/Insights/Views/InsightsView.swift:196``["Mon", "Tue", …]` literal — use `Calendar.current.shortWeekdaySymbols` (locale-aware).
### Category D — `.help(stringVar)` sites
- `Features/Servers/Views/ConnectionStatusPill.swift:42``.help(tooltip)`; refactor `tooltip` to return `LocalizedStringKey` / `LocalizedStringResource`.
### Non-blocking (intentional verbatim)
The following are correct as-is because they pass user data or machine-readable content through to the UI:
- Session titles, message content, memory / skill / YAML file contents, log lines, shell commands, file paths, session IDs, model IDs, credential sources, URL strings.
If we later need to badge these (e.g. "(empty)" placeholder), the badge itself becomes a localizable key while the data passthrough stays verbatim.
+3
View File
@@ -214,6 +214,9 @@
knownRegions = (
en,
Base,
"zh-Hans",
de,
fr,
);
mainGroup = 534959372F7B83B600BD31AD;
minimizedProjectReferenceProxies = 1;
@@ -86,8 +86,8 @@ enum WidgetValue: Codable, Sendable, Hashable {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(format: "%.1f", n)
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
@@ -20,7 +20,8 @@ struct HermesModelInfo: Sendable, Identifiable, Hashable {
/// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil }
return String(format: "$%.2f / $%.2f", input, output)
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
return "\(input.formatted(currency)) / \(output.formatted(currency))"
}
/// Display-friendly context window ("200K", "1M", etc.).
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
.contentTransition(.numericText())
}
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
}
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
}
}
@@ -114,7 +114,7 @@ struct DashboardView: View {
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 {
StatCard(label: "Cost", value: String(format: "$%.2f", cost))
StatCard(label: "Cost", value: cost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
}
}
}
@@ -217,7 +217,7 @@ struct SessionRow: View {
Label("\(session.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 {
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
Label(cost.formatted(.currency(code: "USD").precision(.fractionLength(4))), systemImage: "dollarsign.circle")
}
}
.font(.caption)
@@ -61,10 +61,10 @@ struct InsightsView: View {
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens))
InsightCard(label: "Total Cost", value: String(format: "$%.2f", viewModel.totalCost))
InsightCard(label: "Total Cost", value: viewModel.totalCost.formatted(.currency(code: "USD").precision(.fractionLength(2))))
InsightCard(label: "Active Time", value: formatDuration(viewModel.activeTime))
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration))
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : String(format: "%.1f", Double(viewModel.totalMessages) / Double(viewModel.sessions.count)))
InsightCard(label: "Avg Msgs/Session", value: viewModel.sessions.isEmpty ? "0" : (Double(viewModel.totalMessages) / Double(viewModel.sessions.count)).formatted(.number.precision(.fractionLength(1))))
}
}
}
@@ -159,12 +159,7 @@ final class SessionsViewModel {
let dbPath = context.paths.stateDB
let fileSize: String
if let stat = context.makeTransport().stat(dbPath) {
let size = Double(stat.size)
if size >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
}
fileSize = Int64(stat.size).formatted(.byteCount(style: .file))
} else {
fileSize = "unknown"
}
@@ -60,7 +60,8 @@ struct SessionDetailView: View {
Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
}
if let cost = session.displayCostUSD {
Label(String(format: "$%.4f%@", cost, session.costIsActual ? "" : " est."), systemImage: "dollarsign.circle")
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
}
if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
@@ -224,7 +224,7 @@ struct DoubleStepperRow: View {
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing)
Text(String(format: "%.2f", value))
Text(value.formatted(.number.precision(.fractionLength(2))))
.font(.system(.caption, design: .monospaced))
.frame(width: 70, alignment: .leading)
Stepper("", value: Binding(
+18
View File
@@ -0,0 +1,18 @@
{
"sourceLanguage" : "en",
"strings" : {
"NSMicrophoneUsageDescription" : {
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Scarf uses the microphone for Hermes voice chat."
}
}
}
}
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff