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
+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 */}