Merge pull request #22 from awizemann/claude/pedantic-kare-1edf13

feat(i18n): enable String Catalog + locale-aware numeric formatters
This commit is contained in:
Alan Wizemann
2026-04-20 17:51:09 -07:00
committed by GitHub
12 changed files with 1833 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 = ( knownRegions = (
en, en,
Base, Base,
"zh-Hans",
de,
fr,
); );
mainGroup = 534959372F7B83B600BD31AD; mainGroup = 534959372F7B83B600BD31AD;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
@@ -86,8 +86,8 @@ enum WidgetValue: Codable, Sendable, Hashable {
case .string(let s): return s case .string(let s): return s
case .number(let n): case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0 return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n)) ? Int(n).formatted(.number)
: String(format: "%.1f", n) : 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. /// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? { var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil } 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.). /// Display-friendly context window ("200K", "1M", etc.).
@@ -45,7 +45,8 @@ struct SessionInfoBar: View {
} }
if let cost = session.displayCostUSD { 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()) .contentTransition(.numericText())
} }
@@ -75,11 +76,6 @@ struct SessionInfoBar: View {
} }
private func formatTokens(_ count: Int) -> String { private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 { count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
return String(format: "%.1fM", Double(count) / 1_000_000)
} else if count >= 1_000 {
return String(format: "%.1fK", Double(count) / 1_000)
}
return "\(count)"
} }
} }
@@ -114,7 +114,7 @@ struct DashboardView: View {
StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens)) StatCard(label: "Tokens", value: formatTokens(viewModel.stats.totalInputTokens + viewModel.stats.totalOutputTokens))
let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD let cost = viewModel.stats.totalActualCostUSD > 0 ? viewModel.stats.totalActualCostUSD : viewModel.stats.totalCostUSD
if cost > 0 { 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.messageCount)", systemImage: "bubble.left")
Label("\(session.toolCallCount)", systemImage: "wrench") Label("\(session.toolCallCount)", systemImage: "wrench")
if let cost = session.displayCostUSD, cost > 0 { 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) .font(.caption)
@@ -61,10 +61,10 @@ struct InsightsView: View {
InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens)) InsightCard(label: "Cache Write", value: formatTokens(viewModel.totalCacheWriteTokens))
InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens)) InsightCard(label: "Reasoning Tokens", value: formatTokens(viewModel.totalReasoningTokens))
InsightCard(label: "Total Tokens", value: formatTokens(viewModel.totalTokens)) 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: "Active Time", value: formatDuration(viewModel.activeTime))
InsightCard(label: "Avg Session", value: formatDuration(viewModel.avgSessionDuration)) 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 dbPath = context.paths.stateDB
let fileSize: String let fileSize: String
if let stat = context.makeTransport().stat(dbPath) { if let stat = context.makeTransport().stat(dbPath) {
let size = Double(stat.size) fileSize = Int64(stat.size).formatted(.byteCount(style: .file))
if size >= FileSizeUnit.megabyte {
fileSize = String(format: "%.1f MB", size / FileSizeUnit.megabyte)
} else {
fileSize = String(format: "%.0f KB", size / FileSizeUnit.kilobyte)
}
} else { } else {
fileSize = "unknown" fileSize = "unknown"
} }
@@ -60,7 +60,8 @@ struct SessionDetailView: View {
Label("\(session.reasoningTokens) reasoning", systemImage: "brain") Label("\(session.reasoningTokens) reasoning", systemImage: "brain")
} }
if let cost = session.displayCostUSD { 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 { if let date = session.startedAt {
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
@@ -224,7 +224,7 @@ struct DoubleStepperRow: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 160, alignment: .trailing) .frame(width: 160, alignment: .trailing)
Text(String(format: "%.2f", value)) Text(value.formatted(.number.precision(.fractionLength(2))))
.font(.system(.caption, design: .monospaced)) .font(.system(.caption, design: .monospaced))
.frame(width: 70, alignment: .leading) .frame(width: 70, alignment: .leading)
Stepper("", value: Binding( 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