From e3297e9bc043bbf2a30a6c2f96a4e5a65ebf7e11 Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Tue, 19 May 2026 01:48:24 -0600 Subject: [PATCH] i18n: add language toggle, neutrality policy, and codeowner gate (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) => ( + + ))} + + + {/* Operator Tools */} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts deleted file mode 100644 index be22c69..0000000 --- a/frontend/src/i18n/index.ts +++ /dev/null @@ -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>> = { en, 'zh-CN': zhCN }; - -interface I18nContextValue { - locale: Locale; - setLocale: (locale: Locale) => void; - t: (key: string) => string; -} - -const I18nContext = createContext({ - locale: 'en', - setLocale: () => {}, - t: (key: string) => key, -}); - -function resolve(obj: Record, 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)) { - current = (current as Record)[part]; - } else { - return path; // fallback to key - } - } - return typeof current === 'string' ? current : path; -} - -export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocale] = useState(() => { - 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, key); - return value; - }, - [locale], - ); - - return ( - - {children} - - ); -} - -export function useTranslation() { - return useContext(I18nContext); -} - -export { I18nContext }; diff --git a/frontend/src/i18n/index.tsx b/frontend/src/i18n/index.tsx new file mode 100644 index 0000000..7320bdd --- /dev/null +++ b/frontend/src/i18n/index.tsx @@ -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>> = { 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, 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)) { + current = (current as Record)[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 — 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({ + locale: 'en', + setLocale: () => {}, + t: (key: string) => resolve(en as unknown as Record, key), +}); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(() => { + 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, key); + return value; + }, + [locale], + ); + + return ( + + {children} + + ); +} + +export function useTranslation() { + return useContext(I18nContext); +} + +export { I18nContext };