i18n: add language toggle, neutrality policy, and codeowner gate (#238)

PR #226 landed the i18n infrastructure and Chinese (zh-CN) translations.
This follow-up adds the safeguards that make accepting community
translations sustainable without exposing the project to subtle
state-aligned framing in future translation PRs.

Changes:

  frontend/src/i18n/index.tsx (renamed from .ts)
    - Add LOCALES registry: a single source of truth for available
      languages and their NATIVE display names ("English", "中文 (简体)").
      Adding a new language is now a one-entry change here plus a
      JSON file.
    - Add isLocale() guard so an unknown value in localStorage falls
      through to navigator.language detection instead of corrupting
      state.
    - File renamed to .tsx because it contains JSX. Next.js tolerated
      JSX in .ts but Vite/Oxc (used by vitest) does not.

  frontend/src/components/SettingsPanel.tsx
    Add a UI language picker to the Settings header — a small <select>
    populated from LOCALES. Users no longer need the dev console to
    switch languages. Locale change remains 100% client-side
    (localStorage), no network call, no telemetry.

  CONTRIBUTING.md (new)
    Documents the translation-neutrality requirement that applies
    symmetrically to all source countries:
      - Translations must be technically faithful to the English source.
      - Substitutions aligned with state propaganda from ANY country
        (PRC, Russia, US, EU, etc.) will be rejected.
      - The test is: "would a translator working strictly from the
        English source produce this rendering?"
    Also explains how translation PRs are reviewed and how to add
    a new language.

  .github/CODEOWNERS (new)
    Auto-requests maintainer review on:
      - /frontend/src/i18n/  (translation safety)
      - /backend/auth.py, /backend/routers/wormhole.py,
        /backend/services/mesh/, /backend/services/fetchers/
        (the same paths recent security audits flagged as sensitive)
      - /.github/workflows/, /.gitlab-ci.yml, /docker-compose*.yml,
        /helm/  (build/deploy)
      - /CONTRIBUTING.md, /.github/CODEOWNERS  (policy itself)

  frontend/src/__tests__/i18n/i18nProvider.test.tsx (new, 8 tests)
    Locks in the i18n contract:
      - LOCALES has both en and zh-CN with non-empty native labels
      - Default English when navigator is English
      - Auto-detect zh-CN when navigator language starts with "zh"
      - localStorage preference overrides auto-detect
      - setLocale persists to localStorage
      - Unknown stored locale falls back to auto-detect
      - Renders a real zh-CN translation (catches large-scale
        translation removal in future PRs)
      - Missing key falls back to the key itself

  Note: i18n/index.tsx, the language toggle UI, the translation
  policy, and the test suite together form a defense-in-depth setup.
  The structural safety guarantee (no network calls, static JSON
  bundled at build) is intact; this PR makes the social contract
  around translations explicit and enforceable via branch
  protection on CODEOWNERS-marked paths.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Shadowbroker
2026-05-19 01:48:24 -06:00
committed by GitHub
parent 9ae0b189ba
commit e3297e9bc0
6 changed files with 393 additions and 81 deletions
@@ -0,0 +1,131 @@
import React from 'react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider, LOCALES, useTranslation, type Locale } from '@/i18n';
/**
* Renders a tiny consumer so we can drive the I18nContext from tests.
*/
function Probe({ keyToRender }: { keyToRender: string }) {
const { locale, setLocale, t } = useTranslation();
return (
<div>
<span data-testid="locale">{locale}</span>
<span data-testid="translated">{t(keyToRender)}</span>
<button onClick={() => setLocale('zh-CN')} data-testid="to-zh">go zh</button>
<button onClick={() => setLocale('en')} data-testid="to-en">go en</button>
</div>
);
}
describe('I18nProvider', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('exposes a non-empty LOCALES registry with en and zh-CN', () => {
const codes = LOCALES.map((l) => l.code);
expect(codes).toContain('en');
expect(codes).toContain('zh-CN');
// Native labels — used by the language picker. These must be set
// so the picker shows the native language name regardless of
// current UI locale.
for (const entry of LOCALES) {
expect(entry.label.length).toBeGreaterThan(0);
}
});
it('defaults to English when no localStorage and English browser', () => {
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
expect(screen.getByTestId('locale').textContent).toBe('en');
});
it('auto-detects zh-CN when browser language starts with "zh"', () => {
Object.defineProperty(navigator, 'language', { value: 'zh-TW', configurable: true });
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
// "zh-TW" should match the zh prefix and resolve to our zh-CN bundle
// (we ship only one Chinese variant for now).
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
});
it('honors a previously saved localStorage choice over auto-detect', () => {
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
localStorage.setItem('sb_locale', 'en');
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
expect(screen.getByTestId('locale').textContent).toBe('en');
});
it('persists setLocale to localStorage', () => {
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
act(() => {
screen.getByTestId('to-zh').click();
});
expect(screen.getByTestId('locale').textContent).toBe('zh-CN');
expect(localStorage.getItem('sb_locale')).toBe('zh-CN');
});
it('falls back to auto-detect when localStorage holds an unknown locale', () => {
// Pre-poison localStorage with a value that isn't in LOCALES. The
// isLocale guard at provider init should ignore it and fall through
// to navigator.language detection.
Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true });
localStorage.setItem('sb_locale', 'klingon' as unknown as Locale);
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
expect(screen.getByTestId('locale').textContent).toBe('en');
});
it('renders a real translated string from the zh-CN bundle', () => {
Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true });
render(
<I18nProvider>
<Probe keyToRender="settings.title" />
</I18nProvider>,
);
// The zh-CN bundle has settings.title = "设置". If this assertion
// ever fails after a translation PR, it's a signal that the
// translation surface was significantly altered.
expect(screen.getByTestId('translated').textContent).toBe('设置');
});
it('falls back to the key when a translation is missing', () => {
render(
<I18nProvider>
<Probe keyToRender="this.key.intentionally.does.not.exist" />
</I18nProvider>,
);
expect(screen.getByTestId('translated').textContent).toBe(
'this.key.intentionally.does.not.exist',
);
});
});
+36 -8
View File
@@ -95,7 +95,7 @@ import {
setPrivacyStrictPreference,
setSessionModePreference,
} from '@/lib/privacyBrowserStorage';
import { useTranslation } from '@/i18n';
import { useTranslation, LOCALES, type Locale } from '@/i18n';
interface ApiEntry {
id: string;
@@ -246,7 +246,7 @@ const SettingsPanel = React.memo(function SettingsPanel({
// settings are authenticated through Rust-side admin-key ownership. The
// browser admin-session flow is unnecessary and unavailable in packaged mode.
const nativeProtected = isNativeProtectedSettingsReady();
const { t } = useTranslation();
const { t, locale, setLocale } = useTranslation();
// --- Admin Key (for protected endpoints) ---
const [adminKey, setAdminKey] = useState('');
@@ -1136,12 +1136,40 @@ const SettingsPanel = React.memo(function SettingsPanel({
</span>
</div>
</div>
<button
onClick={onClose}
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
<div className="flex items-center gap-2">
{/*
UI language toggle. Locale change is purely client-side
(persists to localStorage('sb_locale')) — no network call,
no telemetry. See frontend/src/i18n/index.ts for the list
of available locales and CONTRIBUTING.md for the
translation-neutrality policy.
*/}
<label
htmlFor="sb-locale-select"
className="text-[11px] tracking-[0.18em] uppercase text-[var(--text-muted)] font-mono"
>
LANG
</label>
<select
id="sb-locale-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
aria-label="UI language"
className="h-8 px-2 border border-[var(--border-primary)] bg-[var(--bg-primary)]/60 text-[12px] font-mono text-[var(--text-secondary)] tracking-wider hover:border-cyan-500/50 focus:outline-none focus:border-cyan-500/80 transition-colors"
>
{LOCALES.map((entry) => (
<option key={entry.code} value={entry.code}>
{entry.label}
</option>
))}
</select>
<button
onClick={onClose}
className="w-8 h-8 border border-[var(--border-primary)] hover:border-red-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-red-400 transition-all hover:bg-red-950/20"
>
<X size={14} />
</button>
</div>
</div>
{/* Operator Tools */}
-73
View File
@@ -1,73 +0,0 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import en from './translations/en.json';
import zhCN from './translations/zh-CN.json';
export type Locale = 'en' | 'zh-CN';
const translations: Record<Locale, Record<string, Record<string, string>>> = { en, 'zh-CN': zhCN };
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
const I18nContext = createContext<I18nContextValue>({
locale: 'en',
setLocale: () => {},
t: (key: string) => key,
});
function resolve(obj: Record<string, unknown>, path: string): string {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
current = (current as Record<string, unknown>)[part];
} else {
return path; // fallback to key
}
}
return typeof current === 'string' ? current : path;
}
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
if (typeof window === 'undefined') return 'en';
const saved = localStorage.getItem('sb_locale');
if (saved === 'zh-CN' || saved === 'en') return saved;
// Auto-detect browser language
const browserLang = navigator.language || '';
return browserLang.startsWith('zh') ? 'zh-CN' : 'en';
});
const handleSetLocale = useCallback((newLocale: Locale) => {
setLocale(newLocale);
if (typeof window !== 'undefined') {
localStorage.setItem('sb_locale', newLocale);
}
}, []);
const t = useCallback(
(key: string): string => {
const dict = translations[locale] ?? translations.en;
const value = resolve(dict as unknown as Record<string, unknown>, key);
return value;
},
[locale],
);
return (
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslation() {
return useContext(I18nContext);
}
export { I18nContext };
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import en from './translations/en.json';
import zhCN from './translations/zh-CN.json';
export type Locale = 'en' | 'zh-CN';
/**
* Registry of available locales for the UI language toggle.
*
* `label` is the language's NATIVE display name (always rendered in
* itself, regardless of which language the user is currently in) —
* this is the standard convention so the user can recognize their
* own language even when the rest of the UI is unfamiliar.
*
* When adding a new locale:
* 1. Add the translation JSON under translations/
* 2. Import it above and add to `translations` below
* 3. Add an entry here
* 4. Extend the `Locale` type
* 5. Read CONTRIBUTING.md — translations must be technically faithful
* to the English source. Politically loaded substitutions or
* framing aligned with state propaganda from ANY country will
* be rejected.
*/
export const LOCALES: ReadonlyArray<{ code: Locale; label: string }> = [
{ code: 'en', label: 'English' },
{ code: 'zh-CN', label: '中文 (简体)' },
];
const translations: Record<Locale, Record<string, Record<string, string>>> = { en, 'zh-CN': zhCN };
function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && LOCALES.some((entry) => entry.code === value);
}
function resolve(obj: Record<string, unknown>, path: string): string {
const parts = path.split('.');
let current: unknown = obj;
for (const part of parts) {
if (current && typeof current === 'object' && part in (current as Record<string, unknown>)) {
current = (current as Record<string, unknown>)[part];
} else {
return path; // fallback to key
}
}
return typeof current === 'string' ? current : path;
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string) => string;
}
/**
* Default context value when useTranslation() is called outside an
* I18nProvider. Resolves keys against the bundled English JSON so
* unwrapped components (and tests that render in isolation) still
* show real English text instead of raw i18n keys.
*
* Without this fallback, every test that renders a translated component
* would need to wrap it in <I18nProvider> — a real maintenance burden,
* and a footgun because tests would silently start matching "key.path"
* strings instead of failing loud.
*
* This does not hide bugs: if a key is missing from en.json, resolve()
* still returns the literal key (same behavior as the previous default).
*/
const I18nContext = createContext<I18nContextValue>({
locale: 'en',
setLocale: () => {},
t: (key: string) => resolve(en as unknown as Record<string, unknown>, key),
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocale] = useState<Locale>(() => {
if (typeof window === 'undefined') return 'en';
const saved = localStorage.getItem('sb_locale');
if (isLocale(saved)) return saved;
// Auto-detect browser language. Only matches locales we actually
// ship — anything else falls through to English.
const browserLang = (navigator.language || '').toLowerCase();
const match = LOCALES.find((entry) =>
entry.code !== 'en' && browserLang.startsWith(entry.code.toLowerCase().split('-')[0]),
);
return match ? match.code : 'en';
});
const handleSetLocale = useCallback((newLocale: Locale) => {
if (!isLocale(newLocale)) return;
setLocale(newLocale);
if (typeof window !== 'undefined') {
localStorage.setItem('sb_locale', newLocale);
}
}, []);
const t = useCallback(
(key: string): string => {
const dict = translations[locale] ?? translations.en;
const value = resolve(dict as unknown as Record<string, unknown>, key);
return value;
},
[locale],
);
return (
<I18nContext.Provider value={{ locale, setLocale: handleSetLocale, t }}>
{children}
</I18nContext.Provider>
);
}
export function useTranslation() {
return useContext(I18nContext);
}
export { I18nContext };