From afed9fe2ca3d19db99ee99d30c42d68417e57549 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Jun 2026 07:44:11 +0100 Subject: [PATCH] Add persisted locale preference --- .../subsystems/frontend-primitives.md | 15 ++-- .../Settings/GeneralSettingsPanel.tsx | 74 +++++++++++++--- ...GeneralSettingsPanel.localization.test.tsx | 87 ++++++++++++++++++ .../src/i18n/__tests__/i18n.test.ts | 39 ++++++++ frontend-modern/src/i18n/index.ts | 88 ++++++++++++++++++- frontend-modern/src/i18n/locales.ts | 14 +-- frontend-modern/src/i18n/messages.ts | 46 ++++++++++ frontend-modern/src/utils/localStorage.ts | 1 + 8 files changed, 339 insertions(+), 25 deletions(-) create mode 100644 frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 4ab32c3f0b..fd9cd32b21 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -177,12 +177,15 @@ work extends shared components instead of creating new local variants. Frontend localization is a shared primitive boundary. Locale support must flow through typed message catalogs with an English fallback and explicit seed -locale coverage rather than page-local string switches. Customer-facing shell, -navigation, settings, first-run, empty-state, and alert copy may be localized, -but machine-facing values must remain stable: commands, environment variables, -API fields, config keys, log lines, error codes, hostnames, resource names, and -vendor object names stay untranslated unless the owning runtime contract -explicitly says otherwise. +locale coverage rather than page-local string switches. The active app locale +is a shared user preference initialized from stored or browser language and +exposed through Settings > General; individual surfaces must consume that +shared preference instead of creating local language toggles. Customer-facing +shell, navigation, settings, first-run, empty-state, and alert copy may be +localized, but machine-facing values must remain stable: commands, environment +variables, API fields, config keys, log lines, error codes, hostnames, resource +names, and vendor object names stay untranslated unless the owning runtime +contract explicitly says otherwise. Alert thresholds consume the shared FilterBar primitive and route state, while the alerts subsystem owns the resource data and platform-specific threshold diff --git a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx index 7fa272502e..b38a7f7891 100644 --- a/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx +++ b/frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx @@ -8,8 +8,17 @@ import type { TelemetryPreviewResponse } from '@/api/settings'; import { DockerRuntimeSettingsCard } from './DockerRuntimeSettingsCard'; import Sun from 'lucide-solid/icons/sun'; import Moon from 'lucide-solid/icons/moon'; +import Languages from 'lucide-solid/icons/languages'; import Thermometer from 'lucide-solid/icons/thermometer'; import Maximize2 from 'lucide-solid/icons/maximize-2'; +import { + activeLocale, + setLocalePreference, + SUPPORTED_LOCALE_LABELS, + SUPPORTED_LOCALES, + t, + type SupportedLocale, +} from '@/i18n'; import { temperatureStore } from '@/utils/temperature'; import { layoutStore } from '@/utils/layout'; import { @@ -21,12 +30,18 @@ import { PRIVACY_DOC_URL } from '@/utils/docsLinks'; import Laptop from 'lucide-solid/icons/laptop'; -const THEME_PREFERENCE_OPTIONS: FilterOption<'light' | 'dark' | 'system'>[] = [ - { value: 'light', label: 'Light', icon: Sun }, - { value: 'dark', label: 'Dark', icon: Moon }, - { value: 'system', label: 'System', icon: Laptop }, +const getThemePreferenceOptions = (): FilterOption<'light' | 'dark' | 'system'>[] => [ + { value: 'light', label: t('settings.general.theme.option.light'), icon: Sun }, + { value: 'dark', label: t('settings.general.theme.option.dark'), icon: Moon }, + { value: 'system', label: t('settings.general.theme.option.system'), icon: Laptop }, ]; +const getLocalePreferenceOptions = (): FilterOption[] => + SUPPORTED_LOCALES.map((locale) => ({ + value: locale, + label: SUPPORTED_LOCALE_LABELS[locale], + })); + const TEMPERATURE_UNIT_OPTIONS: FilterOption<'celsius' | 'fahrenheit'>[] = [ { value: 'celsius', label: 'Celsius' }, { value: 'fahrenheit', label: 'Fahrenheit' }, @@ -84,7 +99,11 @@ export const GeneralSettingsPanel: Component = (props return (
{/* Appearance Card */} - +
{/* Animated theme icon */} @@ -103,21 +122,48 @@ export const GeneralSettingsPanel: Component = (props
-

Theme preference

+

+ {t('settings.general.theme.title')} +

- Choose light, dark, or sync with your system theme. + {t('settings.general.theme.description')}

+ {/* Language Selector */} +
+
+
+ +
+
+

+ {t('settings.general.language.title')} +

+

+ {t('settings.general.language.description')} +

+
+
+ +
+ {/* Temperature Unit Selector */}
@@ -125,9 +171,11 @@ export const GeneralSettingsPanel: Component = (props
-

Temperature unit

+

+ {t('settings.general.temperature.title')} +

- Display temperatures in Celsius or Fahrenheit + {t('settings.general.temperature.description')}

@@ -147,9 +195,11 @@ export const GeneralSettingsPanel: Component = (props
-

Full-width mode

+

+ {t('settings.general.fullWidth.title')} +

- Expand content to use all available screen width on large monitors + {t('settings.general.fullWidth.description')}

diff --git a/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx new file mode 100644 index 0000000000..693adb0f49 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/GeneralSettingsPanel.localization.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from '@solidjs/testing-library'; +import { createSignal } from 'solid-js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_LOCALE, getActiveLocale, setActiveLocale } from '@/i18n'; +import { STORAGE_KEYS } from '@/utils/localStorage'; +import { GeneralSettingsPanel, type GeneralSettingsPanelProps } from '../GeneralSettingsPanel'; + +function renderGeneralSettingsPanel() { + const [themePreference, setThemePreference] = createSignal<'light' | 'dark' | 'system'>('system'); + const [pvePollingInterval, setPVEPollingInterval] = createSignal(30); + const [pvePollingSelection, setPVEPollingSelection] = createSignal(30); + const [pvePollingCustomSeconds, setPVEPollingCustomSeconds] = createSignal(30); + const [, setHasUnsavedChanges] = createSignal(false); + const [telemetryEnabled] = createSignal(true); + const [disableDockerUpdateActions] = createSignal(false); + + const props: GeneralSettingsPanelProps = { + darkMode: () => false, + themePreference, + setThemePreference, + pvePollingInterval, + setPVEPollingInterval, + pvePollingSelection, + setPVEPollingSelection, + pvePollingCustomSeconds, + setPVEPollingCustomSeconds, + pvePollingEnvLocked: () => false, + setHasUnsavedChanges, + telemetryEnabled, + telemetryEnabledLocked: () => false, + savingTelemetry: () => false, + handleTelemetryEnabledChange: vi.fn(), + telemetryPreview: () => null, + telemetryPreviewEnabled: telemetryEnabled, + telemetryPreviewPayload: () => '', + loadingTelemetryPreview: () => false, + resettingTelemetryInstallID: () => false, + handleLoadTelemetryPreview: vi.fn(), + handleCopyTelemetryPreview: vi.fn(), + handleResetTelemetryInstallID: vi.fn(), + disableDockerUpdateActions, + disableDockerUpdateActionsLocked: () => false, + savingDockerUpdateActions: () => false, + handleDisableDockerUpdateActionsChange: vi.fn(), + }; + + const result = render(() => ); + + return result; +} + +describe('GeneralSettingsPanel localization', () => { + beforeEach(() => { + localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE); + setActiveLocale(DEFAULT_LOCALE); + }); + + afterEach(() => { + localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE); + setActiveLocale(DEFAULT_LOCALE); + }); + + it('surfaces the shared locale preference in Appearance settings', () => { + renderGeneralSettingsPanel(); + + expect(screen.getByText('Appearance')).toBeInTheDocument(); + expect(screen.getByText('Language')).toBeInTheDocument(); + expect( + screen.getByText(/Commands, resource names, and API fields stay unchanged/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'English' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Deutsch' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Español' })).toBeInTheDocument(); + }); + + it('persists locale selection and immediately re-renders localized settings copy', () => { + renderGeneralSettingsPanel(); + + fireEvent.click(screen.getByRole('button', { name: 'Deutsch' })); + + expect(getActiveLocale()).toBe('de'); + expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de'); + expect(screen.getByText('Darstellung')).toBeInTheDocument(); + expect(screen.getByText('Sprache')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Deutsch' })).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/frontend-modern/src/i18n/__tests__/i18n.test.ts b/frontend-modern/src/i18n/__tests__/i18n.test.ts index f7f02134b3..df5e345425 100644 --- a/frontend-modern/src/i18n/__tests__/i18n.test.ts +++ b/frontend-modern/src/i18n/__tests__/i18n.test.ts @@ -5,15 +5,21 @@ import { I18N_MESSAGES, NEXT_LOCALIZATION_LOCALES, SUPPORTED_LOCALES, + detectBrowserLocale, getActiveLocale, + getInitialLocalePreference, + getStoredLocalePreference, normalizeLocale, setActiveLocale, + setLocalePreference, t, type I18nMessageKey, } from '@/i18n'; +import { STORAGE_KEYS } from '@/utils/localStorage'; describe('i18n foundation', () => { afterEach(() => { + localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE); setActiveLocale(DEFAULT_LOCALE); }); @@ -30,6 +36,39 @@ describe('i18n foundation', () => { expect(t('settings.shell.navigationTitle')).toBe('Ajustes'); }); + it('detects initial locale from stored preference before browser language', () => { + localStorage.setItem(STORAGE_KEYS.LOCALE_PREFERENCE, 'es-MX'); + + expect(getStoredLocalePreference(localStorage)).toBe('es'); + expect( + getInitialLocalePreference({ + storage: localStorage, + browserLocales: ['de-DE'], + }), + ).toBe('es'); + }); + + it('uses the first supported browser locale when no preference is stored', () => { + localStorage.removeItem(STORAGE_KEYS.LOCALE_PREFERENCE); + + expect(detectBrowserLocale(['fr-FR', 'es-ES', 'de-DE'])).toBe('es'); + expect( + getInitialLocalePreference({ + storage: localStorage, + browserLocales: ['fr-FR', 'de-AT'], + }), + ).toBe('de'); + }); + + it('persists explicit locale preferences separately from in-memory locale changes', () => { + expect(setLocalePreference('de-DE', localStorage)).toBe('de'); + expect(getActiveLocale()).toBe('de'); + expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de'); + + expect(setActiveLocale('es')).toBe('es'); + expect(localStorage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE)).toBe('de'); + }); + it('falls back to the English catalog for unsupported locales and interpolates params', () => { expect(t('settings.shell.searchEmpty', { query: 'alerts' }, 'pt-BR')).toBe( 'No settings found for "alerts"', diff --git a/frontend-modern/src/i18n/index.ts b/frontend-modern/src/i18n/index.ts index 73bddeb02e..64ebc3d442 100644 --- a/frontend-modern/src/i18n/index.ts +++ b/frontend-modern/src/i18n/index.ts @@ -1,10 +1,84 @@ import { createSignal } from 'solid-js'; -import { DEFAULT_LOCALE, normalizeLocale, type SupportedLocale } from './locales'; +import { STORAGE_KEYS } from '@/utils/localStorage'; +import { + DEFAULT_LOCALE, + normalizeLocale, + resolveSupportedLocale, + type SupportedLocale, +} from './locales'; import { EN_MESSAGES, I18N_MESSAGES, type I18nMessageKey } from './messages'; type MessageParams = Record; +type LocalePreferenceStorage = Pick; -const [activeLocaleSignal, setActiveLocaleSignal] = createSignal(DEFAULT_LOCALE); +function getDefaultStorage(): LocalePreferenceStorage | null { + if (typeof window === 'undefined') return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function safeGetLocalePreference(storage: LocalePreferenceStorage | null): string | null { + if (!storage) return null; + try { + return storage.getItem(STORAGE_KEYS.LOCALE_PREFERENCE); + } catch { + return null; + } +} + +function safeSetLocalePreference( + locale: SupportedLocale, + storage: LocalePreferenceStorage | null, +): void { + if (!storage) return; + try { + storage.setItem(STORAGE_KEYS.LOCALE_PREFERENCE, locale); + } catch { + // Locale selection remains active in-memory when storage is unavailable. + } +} + +function getNavigatorLocaleCandidates(): string[] { + if (typeof navigator === 'undefined') return []; + const candidates = Array.isArray(navigator.languages) ? [...navigator.languages] : []; + if (navigator.language) { + candidates.push(navigator.language); + } + return candidates; +} + +export function getStoredLocalePreference( + storage: LocalePreferenceStorage | null = getDefaultStorage(), +): SupportedLocale | null { + return resolveSupportedLocale(safeGetLocalePreference(storage)); +} + +export function detectBrowserLocale( + candidates: readonly string[] = getNavigatorLocaleCandidates(), +) { + for (const candidate of candidates) { + const locale = resolveSupportedLocale(candidate); + if (locale) return locale; + } + return DEFAULT_LOCALE; +} + +export function getInitialLocalePreference({ + storage = getDefaultStorage(), + browserLocales, +}: { + storage?: LocalePreferenceStorage | null; + browserLocales?: readonly string[]; +} = {}): SupportedLocale { + return getStoredLocalePreference(storage) ?? detectBrowserLocale(browserLocales); +} + +const [activeLocaleSignal, setActiveLocaleSignal] = createSignal( + getInitialLocalePreference(), +); export const activeLocale = activeLocaleSignal; @@ -18,6 +92,15 @@ export function setActiveLocale(locale: string | null | undefined): SupportedLoc return normalized; } +export function setLocalePreference( + locale: string | null | undefined, + storage: LocalePreferenceStorage | null = getDefaultStorage(), +): SupportedLocale { + const normalized = setActiveLocale(locale); + safeSetLocalePreference(normalized, storage); + return normalized; +} + export function translateMessage( key: I18nMessageKey, params: MessageParams = {}, @@ -43,5 +126,6 @@ export { SUPPORTED_LOCALES, isSupportedLocale, normalizeLocale, + resolveSupportedLocale, type SupportedLocale, } from './locales'; diff --git a/frontend-modern/src/i18n/locales.ts b/frontend-modern/src/i18n/locales.ts index eb0b591193..86d190e6ed 100644 --- a/frontend-modern/src/i18n/locales.ts +++ b/frontend-modern/src/i18n/locales.ts @@ -13,8 +13,8 @@ export const NEXT_LOCALIZATION_LOCALES = ['fr', 'pt-BR', 'ja', 'zh-Hans', 'ko'] export const SUPPORTED_LOCALE_LABELS: Record = { en: 'English', - de: 'German', - es: 'Spanish', + de: 'Deutsch', + es: 'Español', }; const SUPPORTED_LOCALE_SET = new Set(SUPPORTED_LOCALES); @@ -39,12 +39,16 @@ export function isSupportedLocale(value: string): value is SupportedLocale { return SUPPORTED_LOCALE_SET.has(value); } -export function normalizeLocale(value: string | null | undefined): SupportedLocale { +export function resolveSupportedLocale(value: string | null | undefined): SupportedLocale | null { const normalized = value?.trim().replace('_', '-').toLowerCase(); - if (!normalized) return DEFAULT_LOCALE; + if (!normalized) return null; if (isSupportedLocale(normalized)) return normalized; if (LOCALE_ALIASES[normalized]) return LOCALE_ALIASES[normalized]; const baseLocale = normalized.split('-')[0] ?? ''; - return isSupportedLocale(baseLocale) ? baseLocale : DEFAULT_LOCALE; + return isSupportedLocale(baseLocale) ? baseLocale : null; +} + +export function normalizeLocale(value: string | null | undefined): SupportedLocale { + return resolveSupportedLocale(value) ?? DEFAULT_LOCALE; } diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index 0d653943f3..9a69f0cd4f 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -1,6 +1,21 @@ import type { SupportedLocale } from './locales'; export const EN_MESSAGES = { + 'settings.general.appearance.title': 'Appearance', + 'settings.general.fullWidth.description': + 'Expand content to use all available screen width on large monitors.', + 'settings.general.fullWidth.title': 'Full-width mode', + 'settings.general.language.ariaLabel': 'App language', + 'settings.general.language.description': + 'Use this language for the app shell. Commands, resource names, and API fields stay unchanged.', + 'settings.general.language.title': 'Language', + 'settings.general.temperature.description': 'Display temperatures in Celsius or Fahrenheit.', + 'settings.general.temperature.title': 'Temperature unit', + 'settings.general.theme.description': 'Choose light, dark, or sync with your system theme.', + 'settings.general.theme.option.dark': 'Dark', + 'settings.general.theme.option.light': 'Light', + 'settings.general.theme.option.system': 'System', + 'settings.general.theme.title': 'Theme preference', 'settings.header.api.description': 'Generate and manage scoped Pulse tokens for agents, automation, and external integrations.', 'settings.header.api.title': 'API Access', @@ -133,6 +148,21 @@ export const I18N_MESSAGES: Record