Add persisted locale preference

This commit is contained in:
rcourtman 2026-06-13 07:44:11 +01:00
parent 2a33c3a09a
commit afed9fe2ca
8 changed files with 339 additions and 25 deletions

View file

@ -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

View file

@ -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<SupportedLocale>[] =>
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<GeneralSettingsPanelProps> = (props
return (
<div class="space-y-6">
{/* Appearance Card */}
<SettingsPanel title="Appearance" noPadding bodyClass="divide-y divide-border">
<SettingsPanel
title={t('settings.general.appearance.title')}
noPadding
bodyClass="divide-y divide-border"
>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
{/* Animated theme icon */}
@ -103,21 +122,48 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
</div>
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">Theme preference</p>
<p class="font-medium text-base-content truncate">
{t('settings.general.theme.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
Choose light, dark, or sync with your system theme.
{t('settings.general.theme.description')}
</p>
</div>
</div>
<FilterButtonGroup
class="w-full sm:w-auto sm:shrink-0 max-w-full"
options={THEME_PREFERENCE_OPTIONS}
options={getThemePreferenceOptions()}
value={props.themePreference()}
onChange={props.setThemePreference}
variant="settings"
/>
</div>
{/* Language Selector */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
<div class="shrink-0 p-2.5 rounded-md border border-border bg-surface">
<Languages class="w-5 h-5 text-slate-500" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">
{t('settings.general.language.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
{t('settings.general.language.description')}
</p>
</div>
</div>
<FilterButtonGroup
class="w-full sm:w-auto sm:shrink-0 max-w-full"
options={getLocalePreferenceOptions()}
value={activeLocale()}
onChange={setLocalePreference}
variant="settings"
ariaLabel={t('settings.general.language.ariaLabel')}
/>
</div>
{/* Temperature Unit Selector */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 sm:p-6">
<div class="flex items-center gap-3 min-w-0">
@ -125,9 +171,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<Thermometer class="w-5 h-5" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">Temperature unit</p>
<p class="font-medium text-base-content truncate">
{t('settings.general.temperature.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
Display temperatures in Celsius or Fahrenheit
{t('settings.general.temperature.description')}
</p>
</div>
</div>
@ -147,9 +195,11 @@ export const GeneralSettingsPanel: Component<GeneralSettingsPanelProps> = (props
<Maximize2 class="w-5 h-5 text-slate-500" strokeWidth={2} />
</div>
<div class="text-sm text-muted min-w-0">
<p class="font-medium text-base-content truncate">Full-width mode</p>
<p class="font-medium text-base-content truncate">
{t('settings.general.fullWidth.title')}
</p>
<p class="text-xs text-muted line-clamp-2">
Expand content to use all available screen width on large monitors
{t('settings.general.fullWidth.description')}
</p>
</div>
</div>

View file

@ -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<number | 'custom'>(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(() => <GeneralSettingsPanel {...props} />);
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');
});
});

View file

@ -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"',

View file

@ -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<string, string | number | boolean | null | undefined>;
type LocalePreferenceStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
const [activeLocaleSignal, setActiveLocaleSignal] = createSignal<SupportedLocale>(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<SupportedLocale>(
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';

View file

@ -13,8 +13,8 @@ export const NEXT_LOCALIZATION_LOCALES = ['fr', 'pt-BR', 'ja', 'zh-Hans', 'ko']
export const SUPPORTED_LOCALE_LABELS: Record<SupportedLocale, string> = {
en: 'English',
de: 'German',
es: 'Spanish',
de: 'Deutsch',
es: 'Español',
};
const SUPPORTED_LOCALE_SET = new Set<string>(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;
}

View file

@ -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<SupportedLocale, Record<I18nMessageKey, strin
en: EN_MESSAGES,
de: {
...EN_MESSAGES,
'settings.general.appearance.title': 'Darstellung',
'settings.general.fullWidth.description':
'Nutzen Sie auf grossen Monitoren die gesamte verfuegbare Bildschirmbreite.',
'settings.general.fullWidth.title': 'Volle Breite',
'settings.general.language.ariaLabel': 'App-Sprache',
'settings.general.language.description':
'Verwenden Sie diese Sprache fuer die App-Oberflaeche. Befehle, Ressourcennamen und API-Felder bleiben unveraendert.',
'settings.general.language.title': 'Sprache',
'settings.general.temperature.description': 'Temperaturen in Celsius oder Fahrenheit anzeigen.',
'settings.general.temperature.title': 'Temperatureinheit',
'settings.general.theme.description': 'Waehlen Sie hell, dunkel oder die Systemeinstellung.',
'settings.general.theme.option.dark': 'Dunkel',
'settings.general.theme.option.light': 'Hell',
'settings.general.theme.option.system': 'System',
'settings.general.theme.title': 'Designpraeferenz',
'settings.header.api.description':
'Erstellen und verwalten Sie begrenzte Pulse-Tokens fuer Agents, Automatisierung und externe Integrationen.',
'settings.header.api.title': 'API-Zugriff',
@ -262,6 +292,22 @@ export const I18N_MESSAGES: Record<SupportedLocale, Record<I18nMessageKey, strin
},
es: {
...EN_MESSAGES,
'settings.general.appearance.title': 'Apariencia',
'settings.general.fullWidth.description':
'Expande el contenido para usar todo el ancho disponible en monitores grandes.',
'settings.general.fullWidth.title': 'Modo de ancho completo',
'settings.general.language.ariaLabel': 'Idioma de la app',
'settings.general.language.description':
'Usa este idioma para la interfaz de la app. Los comandos, nombres de recursos y campos API no cambian.',
'settings.general.language.title': 'Idioma',
'settings.general.temperature.description': 'Muestra temperaturas en Celsius o Fahrenheit.',
'settings.general.temperature.title': 'Unidad de temperatura',
'settings.general.theme.description':
'Elige claro, oscuro o sincroniza con el tema del sistema.',
'settings.general.theme.option.dark': 'Oscuro',
'settings.general.theme.option.light': 'Claro',
'settings.general.theme.option.system': 'Sistema',
'settings.general.theme.title': 'Preferencia de tema',
'settings.header.api.description':
'Genera y administra tokens de Pulse con alcance limitado para agentes, automatización e integraciones externas.',
'settings.header.api.title': 'Acceso API',

View file

@ -153,6 +153,7 @@ export const STORAGE_KEYS = {
// UI preferences
THEME_PREFERENCE: 'pulseThemePreference',
LOCALE_PREFERENCE: 'pulseLocalePreference',
SIDEBAR_COLLAPSED: 'sidebarCollapsed',
FULL_WIDTH_MODE: 'fullWidthMode',