From 2d6b18c9f8dbd5bdbdb412493a5ac3b7f6c4d274 Mon Sep 17 00:00:00 2001 From: DrMaks22 <191687254+DrMaks22@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:12:05 +0300 Subject: [PATCH 1/5] Add Russian UI localization --- .../SettingsSectionAppearance.svelte | 27 ++ src/lib/i18n/en.ts | 293 ++++++++++++++++++ src/lib/i18n/messages.ts | 29 ++ src/lib/i18n/ru.ts | 293 ++++++++++++++++++ src/lib/i18n/runtime.test.ts | 41 +++ src/lib/i18n/runtime.ts | 210 +++++++++++++ src/lib/stores/ui.svelte.ts | 24 ++ src/routes/+layout.svelte | 4 + 8 files changed, 921 insertions(+) create mode 100644 src/lib/i18n/en.ts create mode 100644 src/lib/i18n/messages.ts create mode 100644 src/lib/i18n/ru.ts create mode 100644 src/lib/i18n/runtime.test.ts create mode 100644 src/lib/i18n/runtime.ts diff --git a/src/lib/components/SettingsSectionAppearance.svelte b/src/lib/components/SettingsSectionAppearance.svelte index e30e6a6..511a2cc 100644 --- a/src/lib/components/SettingsSectionAppearance.svelte +++ b/src/lib/components/SettingsSectionAppearance.svelte @@ -13,6 +13,7 @@ import { ui, VIBRANCY_MATERIALS, type VibrancyMaterial } from "$lib/stores/ui.svelte"; import { settings } from "$lib/stores/settings.svelte"; + import type { LocalePreference } from "$lib/i18n/messages"; import type { SidebarSection, ThemePreference } from "$lib/types"; /** Sections the user can pick as their default landing page. Mirrors the @@ -27,6 +28,12 @@ { value: "activity", label: "Activity" }, ]; + const LANGUAGES: { value: LocalePreference; label: string }[] = [ + { value: "system", label: "System language" }, + { value: "en", label: "English" }, + { value: "ru", label: "Russian" }, + ]; + function onSectionChange(e: Event) { const value = (e.currentTarget as HTMLSelectElement).value as SidebarSection; ui.setDefaultSection(value); @@ -35,6 +42,11 @@ const value = (e.currentTarget as HTMLSelectElement).value as VibrancyMaterial; ui.setVibrancyMaterial(value); } + function onLanguageChange(e: Event) { + const value = (e.currentTarget as HTMLSelectElement).value as LocalePreference; + ui.setLocalePreference(value); + window.location.reload(); + } function pickTheme(t: ThemePreference) { ui.setTheme(t); } // Phase 13 — AI Features master toggle. Saved through the persisted @@ -86,6 +98,21 @@

Follows the macOS theme when set to System.

+
+ + +

Changing the language reloads brew-browser.

+
+
0} @@ -154,9 +168,9 @@
{#if totalItems === 0} -

No results.

+

{t("palette.empty", ui.locale)}

{:else} -
+
{#each groups as g (g.label)}
@@ -174,7 +188,7 @@ {#if item.kind === "package"} {item.name} - {item.pkgKind}{#if item.installed} · installed{/if} + {item.pkgKind === "formula" ? t("package.kind.formula", ui.locale) : t("package.kind.cask", ui.locale)}{#if item.installed} · {t("package.installed", ui.locale)}{/if} {:else} {item.label} @@ -189,9 +203,9 @@
- ↑↓ navigate - open - Esc close + ↑↓ {t("keyboard.navigate", ui.locale)} + {t("keyboard.open", ui.locale)} + Esc {t("keyboard.close", ui.locale)}
{/if} diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 798da17..f0a4588 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -33,21 +33,22 @@ import SettingsSectionBrew from "./SettingsSectionBrew.svelte"; import SettingsSectionActivity from "./SettingsSectionActivity.svelte"; import SettingsSectionAbout from "./SettingsSectionAbout.svelte"; + import { t, type MessageKey } from "$lib/i18n/messages"; import type { SettingsSection } from "$lib/types"; interface NavEntry { id: SettingsSection; - label: string; + labelKey: MessageKey; icon: typeof Paintbrush; } const NAV: NavEntry[] = [ - { id: "appearance", label: "Appearance", icon: Paintbrush }, - { id: "network", label: "Network", icon: Globe }, - { id: "github", label: "GitHub", icon: Github }, - { id: "brew", label: "Brew", icon: Beer }, - { id: "activity", label: "Activity", icon: Activity }, - { id: "about", label: "About", icon: Info }, + { id: "appearance", labelKey: "settings.section.appearance", icon: Paintbrush }, + { id: "network", labelKey: "settings.section.network", icon: Globe }, + { id: "github", labelKey: "settings.section.github", icon: Github }, + { id: "brew", labelKey: "settings.section.brew", icon: Beer }, + { id: "activity", labelKey: "settings.section.activity", icon: Activity }, + { id: "about", labelKey: "settings.section.about", icon: Info }, ]; let activeSection: SettingsSection = $state("appearance"); @@ -99,6 +100,10 @@ window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }); + + function activeSectionLabel(): string { + return t(NAV.find((entry) => entry.id === activeSection)?.labelKey ?? "settings.title", ui.locale); + } {#if ui.settingsOpen} @@ -109,15 +114,15 @@ class="settings-wrap" role="dialog" aria-modal="true" - aria-label="Settings" + aria-label={t("settings.title", ui.locale)} >
-
- + -

Changing the language reloads brew-browser.

+

{t("language.hint.reload", ui.locale)}

- + -

Which section opens when you launch brew-browser.

+

{t("Which section opens when you launch brew-browser.", ui.locale)}

- + -

Requires app restart to take effect. The default - (HudWindow) matches the rest of macOS.

+

{t("Requires app restart to take effect. The default (HudWindow) matches the rest of macOS.", ui.locale)}

- +

- When on, brew-browser shows extra metadata generated at build time - by AI: friendly names, expanded descriptions, use cases, similar - package suggestions, and category tags. - Zero LLM calls are made from your machine — all - enrichment is baked into the app binary. + {t("When on, brew-browser shows extra metadata generated at build time by AI: friendly names, expanded descriptions, use cases, similar package suggestions, and category tags.", ui.locale)} + {t("Zero LLM calls are made from your machine", ui.locale)} + {t("— all enrichment is baked into the app binary.", ui.locale)}

- When off, only Homebrew's native metadata appears. Categories - (sidebar tile grid, donut chart, chip filters) are also hidden - because they're AI-generated. + {t("When off, only Homebrew's native metadata appears. Categories (sidebar tile grid, donut chart, chip filters) are also hidden because they're AI-generated.", ui.locale)}

diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 4b8157a..e9f9ad9 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -18,13 +18,20 @@ import { search } from "$lib/stores/search.svelte"; import { settings } from "$lib/stores/settings.svelte"; import { vulnerabilities } from "$lib/stores/vulnerabilities.svelte"; + import { + formatBrewOperationsRunning, + formatVulnerablePackageLabel, + formatVulnerablePackages, + t, + type MessageKey, + } from "$lib/i18n/messages"; import { normalizeServiceStatus, type PackageKind, type SearchHit } from "$lib/types"; import Pill from "./Pill.svelte"; import type { SidebarSection } from "$lib/types"; interface NavItem { id: SidebarSection; - label: string; + labelKey: MessageKey; shortcut: string; icon: typeof Boxes; } @@ -33,13 +40,13 @@ so ⌘0 maps to it and it gets the visual primacy without a dedicated brand row eating sidebar space. */ const nav: NavItem[] = [ - { id: "dashboard", label: "Dashboard", shortcut: "⌘0", icon: LayoutDashboard }, - { id: "library", label: "Library", shortcut: "⌘1", icon: Boxes }, - { id: "discover", label: "Discover", shortcut: "⌘2", icon: Compass }, - { id: "trending", label: "Trending", shortcut: "⌘3", icon: TrendingUp }, - { id: "snapshots", label: "Snapshots", shortcut: "⌘4", icon: Archive }, - { id: "services", label: "Services", shortcut: "⌘5", icon: Server }, - { id: "activity", label: "Activity", shortcut: "⌘6", icon: Activity }, + { id: "dashboard", labelKey: "nav.dashboard", shortcut: "⌘0", icon: LayoutDashboard }, + { id: "library", labelKey: "nav.library", shortcut: "⌘1", icon: Boxes }, + { id: "discover", labelKey: "nav.discover", shortcut: "⌘2", icon: Compass }, + { id: "trending", labelKey: "nav.trending", shortcut: "⌘3", icon: TrendingUp }, + { id: "snapshots", labelKey: "nav.snapshots", shortcut: "⌘4", icon: Archive }, + { id: "services", labelKey: "nav.services", shortcut: "⌘5", icon: Server }, + { id: "activity", labelKey: "nav.activity", shortcut: "⌘6", icon: Activity }, ]; // ───────── Sidebar type-ahead search ───────── @@ -153,7 +160,7 @@ const base = env.summary; if (activity.runningCount > 0) { const n = activity.runningCount; - return `${base}\n${n} brew operation${n === 1 ? "" : "s"} running`; + return `${base}\n${formatBrewOperationsRunning(n, ui.locale)}`; } return base; }); @@ -183,8 +190,7 @@ vulnerabilities.severityCounts.vulnerablePackages, ); const vulnBadgeTooltip = $derived.by(() => { - const n = vulnBadgeCount; - return `${n} package${n === 1 ? "" : "s"} with known vulnerabilities`; + return formatVulnerablePackages(vulnBadgeCount, ui.locale); }); function openVulnDashboard() { @@ -194,7 +200,7 @@ } -
diff --git a/src/lib/components/ActivityDrawer.svelte b/src/lib/components/ActivityDrawer.svelte index d5ef460..6ca3336 100644 --- a/src/lib/components/ActivityDrawer.svelte +++ b/src/lib/components/ActivityDrawer.svelte @@ -12,6 +12,7 @@ import { activity } from "$lib/stores/activity.svelte"; import { ui } from "$lib/stores/ui.svelte"; import { toast } from "$lib/stores/toast.svelte"; + import { t, type MessageKey } from "$lib/i18n/messages"; import type { ActivityLine } from "$lib/types"; import { openReportIssueFromJob } from "$lib/util/reportIssue"; import { @@ -51,12 +52,23 @@ activity.handleEvent(evt); }); } catch (e) { - toast.error("Couldn't run that action", String(e)); + toast.error(ui.locale === "ru" ? "Не удалось выполнить действие" : "Couldn't run that action", String(e)); } finally { recoveryBusy = false; } } + function recoveryText(source: string): string { + return t(source as MessageKey, ui.locale); + } + + function recoveryReasonText(opt: RecoveryOption): string { + if (opt.action === "install") { + return t("recovery.reason.alreadyInstalled", ui.locale, { name: opt.name }); + } + return t("recovery.reason.stillRequired", ui.locale, { name: opt.name }); + } + // ─── Adaptive aria-live for high-volume install streams (security audit §N4). // // Brew installs can dump hundreds of lines (downloads, tar progress, post- @@ -153,9 +165,9 @@ // it changes; empty string while running emits nothing. let completionAnnounce = $derived.by(() => { if (!activeJob || activeJob.status === "running") return ""; - if (activeJob.status === "succeeded") return `${activeJob.label}: done.`; - if (activeJob.status === "failed") return `${activeJob.label}: failed.`; - if (activeJob.status === "canceled") return `${activeJob.label}: canceled.`; + if (activeJob.status === "succeeded") return ui.locale === "ru" ? `${activeJob.label}: готово.` : `${activeJob.label}: done.`; + if (activeJob.status === "failed") return ui.locale === "ru" ? `${activeJob.label}: ошибка.` : `${activeJob.label}: failed.`; + if (activeJob.status === "canceled") return ui.locale === "ru" ? `${activeJob.label}: отменено.` : `${activeJob.label}: canceled.`; return ""; }); @@ -186,9 +198,9 @@ const text = activeJob.lines.map((l: ActivityLine) => l.text).join("\n"); try { await navigator.clipboard.writeText(text); - toast.success("Copied log to clipboard"); + toast.success(ui.locale === "ru" ? "Лог скопирован в буфер обмена" : "Copied log to clipboard"); } catch { - toast.error("Copy failed"); + toast.error(ui.locale === "ru" ? "Не удалось скопировать лог" : "Copy failed"); } } @@ -206,15 +218,20 @@ } /** Shorter "4.2s" / "1m 14s" form for the quiet completion footer. */ function formatElapsed(ms: number): string { - if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 10_000) return ui.locale === "ru" ? `${(ms / 1000).toFixed(1)} с` : `${(ms / 1000).toFixed(1)}s`; const totalSec = Math.round(ms / 1000); const m = Math.floor(totalSec / 60); const s = totalSec % 60; + if (ui.locale === "ru") return m > 0 ? `${m} мин ${s} с` : `${s} с`; return m > 0 ? `${m}m ${s}s` : `${s}s`; } function failedFooter(job: typeof activeJob): string { - if (!job) return "Failed."; + if (!job) return ui.locale === "ru" ? "Не удалось выполнить." : "Failed."; + if (ui.locale === "ru") { + const base = `Не удалось выполнить${job.durationMs ? ` за ${formatElapsed(job.durationMs)}` : ""}.`; + return `${base} См. уведомление справа.`; + } const base = `Failed${job.durationMs ? ` after ${formatElapsed(job.durationMs)}` : ""}.`; // The failure card on the right always renders a notice now (friendly // message, Homebrew-error guidance, or app-error report), so always point there. @@ -222,19 +239,19 @@ } function failureTitle(label: string): string { - if (label.startsWith("Upgrading ")) return "Upgrade failed"; - if (label.startsWith("Installing ")) return "Install failed"; - if (label.startsWith("Uninstalling ")) return "Uninstall failed"; - return `${label} failed`; + if (label.startsWith("Upgrading ") || label.startsWith("Обновляем ")) return ui.locale === "ru" ? "Не удалось обновить" : "Upgrade failed"; + if (label.startsWith("Installing ") || label.startsWith("Устанавливаем ")) return ui.locale === "ru" ? "Не удалось установить" : "Install failed"; + if (label.startsWith("Uninstalling ") || label.startsWith("Удаляем ")) return ui.locale === "ru" ? "Не удалось удалить" : "Uninstall failed"; + return ui.locale === "ru" ? `Не удалось выполнить: ${label}` : `${label} failed`; } {#if ui.drawerOpen}
-
{#if activeJob?.status === "running"} - {/if} - -
@@ -275,7 +292,7 @@ >
- {p.phase}{p.package ? ` ${p.package}` : ""}{p.total ? ` (${p.current} of ${p.total})` : ""} + {p.phase}{p.package ? ` ${p.package}` : ""}{p.total ? (ui.locale === "ru" ? ` (${p.current} из ${p.total})` : ` (${p.current} of ${p.total})`) : ""} {/if} @@ -292,9 +309,9 @@
{#if !activeJob} -

Quiet. brew commands run by brew-browser appear here.

+

{ui.locale === "ru" ? "Тихо. Команды brew, запущенные из brew-browser, появятся здесь." : "Quiet. brew commands run by brew-browser appear here."}

{:else if activeJob.lines.length === 0} -

Waiting for output…

+

{ui.locale === "ru" ? "Ждём вывод…" : "Waiting for output…"}

{:else} {#each activeJob.lines as line, i (i)} {@const cls = classifyLine(line.text)} @@ -307,11 +324,11 @@ (and reliably) even when the streaming log was muted mid-surge. --> {/if} @@ -319,7 +336,7 @@
{#if activeJob?.status === "failed"} -