feat(i18n): language foundation with en and pt-BR

Nothing in the project was translatable — every string sat inline in English.

en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.

Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
This commit is contained in:
2026-08-31 12:24:50 -03:00
parent 89e13441c8
commit 688a1335cb
9 changed files with 298 additions and 33 deletions
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { ProfileActivity } from '../ui/ProfileActivity';
import { useT } from '../../i18n';
import { useActivityStore } from '../../stores/activityStore';
import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore';
@@ -66,6 +67,7 @@ export function UserProfileModal() {
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
const currentUser = useAuthStore((s) => s.user);
const t = useT();
const [user, setUser] = useState<User | null>(null);
const [userOrigin, setUserOrigin] = useState('');
const [activeTab, setActiveTab] = useState<Tab>('about');
@@ -352,7 +354,7 @@ export function UserProfileModal() {
{user.bio && (
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
About Me
{t('profile.aboutMe')}
</span>
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
<ReactMarkdown
@@ -376,7 +378,7 @@ export function UserProfileModal() {
{/* Member Since */}
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
Member Since
{t('profile.memberSince')}
</span>
<div className="text-[13px] text-txt-secondary mt-1">
{new Date(user.createdAt).toLocaleDateString(undefined, {
@@ -519,7 +521,7 @@ export function UserProfileModal() {
onClick={handleSendMessage}
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors"
>
Send Message
{t('profile.sendMessage')}
</button>
{friendship.state === 'none' && (
@@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore';
import { AccountPanel } from './settingsPanels/AccountPanel';
import { VoicePanel } from './settingsPanels/VoicePanel';
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
import { LanguagePanel } from './settingsPanels/LanguagePanel';
import { useT } from '../../i18n';
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
import { DesktopPanel } from './settingsPanels/DesktopPanel';
import { InstancePanel } from './settingsPanels/InstancePanel';
@@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
import { isElectron } from '../../platform/platform';
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance';
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance';
function SidebarSubLinks() {
const ctx = useSettingsSectionsContext();
@@ -60,6 +62,7 @@ export function UserSettingsModal() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const t = useT();
const [tab, setTab] = useState<SettingsTab>('account');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint
@@ -81,7 +84,7 @@ export function UserSettingsModal() {
useEffect(() => {
if (isOpen) {
const requested = modalData.tab as SettingsTab | undefined;
if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) {
if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'language', 'instance'].includes(requested)) {
// Only allow instance tab for admins
if (requested === 'instance' && !isAdmin) {
setTab('account');
@@ -135,14 +138,15 @@ export function UserSettingsModal() {
{/* Nav list */}
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice &amp; Video</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && (
@@ -192,14 +196,15 @@ export function UserSettingsModal() {
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice &amp; Video</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isAdmin && (
@@ -249,6 +254,7 @@ export function UserSettingsModal() {
{tab === 'privacy' && <PrivacyPanel />}
{tab === 'connections' && <ConnectionsPanel />}
{tab === 'keybinds' && <KeybindsPanel />}
{tab === 'language' && <LanguagePanel />}
{tab === 'desktop' && <DesktopPanel />}
{tab === 'instance' && isAdmin && <InstancePanel />}
</div>
@@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore';
import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices';
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
import { useT } from '../../../i18n';
export function AudioInputSection() {
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
const t = useT();
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
const [listOpen, setListOpen] = useState(false);
@@ -96,7 +98,7 @@ export function AudioInputSection() {
setMicTestError('');
const ok = await am.startMicTest();
if (!ok) {
setMicTestError('Could not open the microphone. Check the device and its permission.');
setMicTestError(t('settings.voice.micTest.failed'));
return;
}
setMicTesting(true);
@@ -125,7 +127,7 @@ export function AudioInputSection() {
if (permState === 'unknown') {
return (
<SectionShell title="Input Device">
<SectionShell title={t('settings.voice.input.title')}>
<div className="text-sm text-txt-tertiary">Checking microphone access</div>
</SectionShell>
);
@@ -133,7 +135,7 @@ export function AudioInputSection() {
if (permState === 'denied') {
return (
<SectionShell title="Input Device">
<SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-2">
<div className="text-sm text-txt-primary"> Microphone access denied</div>
<div className="text-xs text-txt-tertiary">
@@ -152,7 +154,7 @@ export function AudioInputSection() {
if (permState === 'prompt') {
return (
<SectionShell title="Input Device">
<SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-3">
<div className="text-xs text-txt-tertiary">
Microphone permission needed to list and choose an input device.
@@ -186,7 +188,7 @@ export function AudioInputSection() {
const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
return (
<SectionShell title="Input Device">
<SectionShell title={t('settings.voice.input.title')}>
<div className="space-y-3">
<div ref={dropdownRef}>
<button
@@ -223,7 +225,7 @@ export function AudioInputSection() {
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[13px] font-medium text-txt-primary">Input Volume</div>
<div className="text-[13px] font-medium text-txt-primary">{t('settings.voice.input.volume')}</div>
<div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
</div>
<input
@@ -258,14 +260,14 @@ export function AudioInputSection() {
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{micTesting ? 'Stop Testing' : "Let's Check"}
{micTesting ? t('settings.voice.micTest.stop') : t('settings.voice.micTest.start')}
</button>
<span className="text-xs text-txt-tertiary">
{micTesting
? 'Playing your mic back to you — say something.'
? t('settings.voice.micTest.playing')
: isLiveKitConnected
? 'The level meter is live while you are in a call.'
: 'Test your mic without joining a call.'}
? t('settings.voice.micTest.inCall')
: t('settings.voice.micTest.idle')}
</span>
</div>
{micTestError && (
@@ -0,0 +1,48 @@
import { SectionShell } from './_shared/SettingsPickerPrimitives';
import { useLocaleStore, useT, LOCALES, type Locale } from '../../../i18n';
import type { TranslationKey } from '../../../i18n';
const LOCALE_LABEL: Record<Locale, TranslationKey> = {
en: 'settings.language.en',
'pt-BR': 'settings.language.ptBR',
};
/**
* Language picker. Each option is labelled in the active language rather than
* in its own — a reader who cannot find their way back out of a language they
* picked by mistake is the one failure this screen must not have.
*/
export function LanguagePanel() {
const t = useT();
const locale = useLocaleStore((s) => s.locale);
const setLocale = useLocaleStore((s) => s.setLocale);
return (
<SectionShell title={t('settings.language.title')}>
<p className="text-[13px] text-txt-tertiary mb-3">
{t('settings.language.description')}
</p>
<div className="flex flex-col gap-1.5">
{LOCALES.map((option) => (
<button
key={option}
type="button"
onClick={() => setLocale(option)}
className={`flex items-center justify-between px-3 py-2 rounded-md text-[14px] text-left transition-colors ${
option === locale
? 'bg-interactive-selected text-txt-primary'
: 'text-txt-secondary hover:bg-interactive-hover'
}`}
>
<span>{t(LOCALE_LABEL[option])}</span>
{option === locale && (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)}
</button>
))}
</div>
</SectionShell>
);
}
@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
import type { Activity } from '@backspace/shared';
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
import { useT, type TranslationKey } from '../../i18n';
interface ProfileActivityProps {
activities: Activity[];
}
const VERB: Record<Activity['type'], string> = {
playing: 'Playing',
listening: 'Listening to',
watching: 'Watching',
streaming: 'Streaming',
custom: '',
const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, TranslationKey> = {
playing: 'profile.activity.playing',
listening: 'profile.activity.listening',
watching: 'profile.activity.watching',
streaming: 'profile.activity.streaming',
};
function formatClock(ms: number): string {
@@ -34,6 +34,7 @@ function formatClock(ms: number): string {
* producer would fill in.
*/
export function ProfileActivity({ activities }: ProfileActivityProps) {
const t = useT();
const primary = getPrimaryActivity(activities);
const start = primary?.timestamps?.start;
const end = primary?.timestamps?.end;
@@ -60,7 +61,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
return (
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
{VERB[primary.type]} {primary.name}
{t(VERB_KEY[primary.type])} {primary.name}
</span>
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
{artSrc && (
@@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
</div>
) : start ? (
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
{formatClock(elapsed)} elapsed
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
</div>
) : null}
</div>