Repository navigation
Ppw stack/汉化支持 - #6627
Hidden character warning
Ppw stack/汉化支持#6627ppw-stack wants to merge 26 commits into
Conversation
- Force-added under docs/superpowers/specs/ which is gitignored by default - The path matches the brainstorming skill's default spec location convention - Date-prefixed filename for chronological ordering
17 tasks across 5 phases: - Phase 1: Foundation (deps, Metro, AsyncStorage prefs) - Phase 2: i18n core (init, I18nProvider, T, useT) with TDD - Phase 3: Settings UI (language screen + entry + root wiring) - Phase 4: Screen migration (23 .tsx files in 6 grouped commits) - Phase 5: Verification (tests, lint, manual smoke) Force-added under docs/superpowers/plans/ which is gitignored by default.
This reverts commit 5a34e22.
📝 WalkthroughWalkthroughThis PR implements Chinese ( 🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✅ Passed checks (2 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 16
🧹 Nitpick comments (2)
mobile/package.json (1)
46-47: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winAlign
reactandreact-dominmobile/package.json.reactis ranged as^19.2.6whilereact-domis pinned to19.2.6; keeping the same spec avoids future install drift.mobile/src/storage/preferences.test.ts (1)
151-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd the write-failure case here too.
saveUiLanguage()is intentionally non-throwing whenAsyncStorage.setItemrejects (mobile/src/storage/preferences.ts:203-212), but this suite only verifies the success path. Add one rejection test so a future refactor can't block language switching on a storage error.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: fd392509-0a4e-4f9c-a88a-9b37113f6cfb
⛔ Files ignored due to path filters (1)
mobile/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (42)
docs/superpowers/plans/2026-06-28-mobile-i18n.mddocs/superpowers/specs/2026-06-28-mobile-i18n-design.mdmobile/app/_layout.tsxmobile/app/about.tsxmobile/app/browser-settings.tsxmobile/app/h/[hostId]/accounts.tsxmobile/app/h/[hostId]/history/[worktreeId].tsxmobile/app/h/[hostId]/index.tsxmobile/app/h/[hostId]/session/[worktreeId].tsxmobile/app/h/[hostId]/tasks.tsxmobile/app/index.tsxmobile/app/language-settings.tsxmobile/app/notifications.tsxmobile/app/pair-confirm.tsxmobile/app/pair-scan-steps.tsxmobile/app/pair-scan.tsxmobile/app/pair.tsxmobile/app/settings.tsxmobile/app/terminal-settings.tsxmobile/app/troubleshoot-sections.tsxmobile/app/troubleshoot.tsxmobile/app/voice-settings.tsxmobile/metro.config.jsmobile/package.jsonmobile/src/components/CustomKeyModal.tsxmobile/src/components/MobileDiffReviewFileSummary.tsxmobile/src/components/MobileDiffReviewFooter.tsxmobile/src/components/MobileHtmlPreview.tsxmobile/src/components/NewWorktreeModal.tsxmobile/src/components/ProtocolBlockScreen.tsxmobile/src/components/TerminalShortcutSettings.tsxmobile/src/components/TextInputModal.tsxmobile/src/components/VoiceModelList.tsxmobile/src/components/custom-key-modal-data.tsmobile/src/i18n/I18nProvider.tsxmobile/src/i18n/README.mdmobile/src/i18n/T.tsxmobile/src/i18n/init.test.tsmobile/src/i18n/init.tsmobile/src/i18n/useT.tsmobile/src/storage/preferences.test.tsmobile/src/storage/preferences.ts
| useEffect(() => { | ||
| void (async () => { | ||
| try { | ||
| const lang = await loadUiLanguage() | ||
| await initI18n(lang) | ||
| } catch (error) { | ||
| console.warn('[i18n] init failed; defaulting to en', error) | ||
| try { | ||
| await initI18n('en') | ||
| } catch (fallbackError) { | ||
| console.error('[i18n] en fallback also failed', fallbackError) | ||
| } | ||
| } | ||
| setReady(true) | ||
| })() |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
The English fallback cannot actually recover from a failed first init.
This code assumes initI18n('en') will retry after the first initI18n(lang) throws, but mobile/src/i18n/init.ts memoizes instance before awaiting .init(...). After the first failure, the fallback call returns that already-assigned singleton instead of reinitializing in English, so boot can continue with broken i18n state.
| <Stack.Screen name="pair-scan" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="pair" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="pair-confirm" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="settings" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="terminal-settings" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="browser-settings" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="voice-settings" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="notifications" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="troubleshoot" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="about" options={{ headerShown: false }} /> | ||
| <Stack.Screen name="h" options={{ headerShown: false }} /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Register language-settings with headerShown: false.
mobile/app/language-settings.tsx renders its own top row/back button like the other settings subpages, but this stack omits that route. Expo Router will fall back to the default stack header there, which gives the new screen a duplicate header.
Proposed fix
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
+ <Stack.Screen name="language-settings" options={{ headerShown: false }} />
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Stack.Screen name="pair-scan" options={{ headerShown: false }} /> | |
| <Stack.Screen name="pair" options={{ headerShown: false }} /> | |
| <Stack.Screen name="pair-confirm" options={{ headerShown: false }} /> | |
| <Stack.Screen name="settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="terminal-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="browser-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="voice-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="notifications" options={{ headerShown: false }} /> | |
| <Stack.Screen name="troubleshoot" options={{ headerShown: false }} /> | |
| <Stack.Screen name="about" options={{ headerShown: false }} /> | |
| <Stack.Screen name="h" options={{ headerShown: false }} /> | |
| <Stack.Screen name="pair-scan" options={{ headerShown: false }} /> | |
| <Stack.Screen name="pair" options={{ headerShown: false }} /> | |
| <Stack.Screen name="pair-confirm" options={{ headerShown: false }} /> | |
| <Stack.Screen name="settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="terminal-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="browser-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="language-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="voice-settings" options={{ headerShown: false }} /> | |
| <Stack.Screen name="notifications" options={{ headerShown: false }} /> | |
| <Stack.Screen name="troubleshoot" options={{ headerShown: false }} /> | |
| <Stack.Screen name="about" options={{ headerShown: false }} /> | |
| <Stack.Screen name="h" options={{ headerShown: false }} /> |
| <T style={styles.heading}>Browser</T> | ||
| </View> | ||
|
|
||
| <ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}> | ||
| <Text style={styles.groupHeading}>LINKS</Text> | ||
| <Text style={styles.groupDescription}> | ||
| <T style={styles.groupHeading}>LINKS</T> | ||
| <T style={styles.groupDescription}> | ||
| Choose where HTTP(S) links tapped in terminal output open. | ||
| </Text> | ||
| </T> | ||
| <View style={[styles.section, styles.sectionTopGap]}> | ||
| <Pressable | ||
| style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} | ||
| onPress={() => setPickerOpen(true)} | ||
| > | ||
| <Globe size={16} color={colors.textSecondary} /> | ||
| <View style={styles.rowContent}> | ||
| <Text style={styles.rowLabel}>Open terminal links</Text> | ||
| <T style={styles.rowLabel}>Open terminal links</T> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
<T> is a no-op here without i18nKey.
mobile/src/i18n/T.tsx only calls t(...) when i18nKey is set. These replacements still render the English children verbatim, so this screen will not localize.
Suggested fix
- <T style={styles.heading}>Browser</T>
+ <T i18nKey="Browser" style={styles.heading}>Browser</T>
...
- <T style={styles.rowLabel}>Open terminal links</T>
+ <T i18nKey="Open terminal links" style={styles.rowLabel}>Open terminal links</T>| const LANGUAGE_CHOICES: Array<{ value: MobileUiLanguage; label: string }> = [ | ||
| { value: 'system', label: 'Follow System' }, | ||
| { value: 'en', label: 'English' }, | ||
| { value: 'zh', label: '中文(简体)' } | ||
| ] |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
These labels still bypass i18next.
mobile/src/i18n/T.tsx only translates when i18nKey is set, and the choice rows are plain Text, so this screen will keep showing hardcoded strings instead of switching with the selected locale. Give the heading and each option a real translation key.
Also applies to: 40-52
| const onPick = useCallback(async (next: MobileUiLanguage) => { | ||
| setLang(next) | ||
| await saveUiLanguage(next) | ||
| const i18n = getI18n() | ||
| await i18n.changeLanguage(resolveLanguage(next)) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Don't block the language switch on AsyncStorage.
saveUiLanguage() is best-effort persistence, but awaiting it here delays changeLanguage() on every tap. Switch i18n first and persist in the background so the UI updates immediately.
Proposed fix
const onPick = useCallback(async (next: MobileUiLanguage) => {
setLang(next)
- await saveUiLanguage(next)
const i18n = getI18n()
await i18n.changeLanguage(resolveLanguage(next))
+ void saveUiLanguage(next)
}, [])📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const onPick = useCallback(async (next: MobileUiLanguage) => { | |
| setLang(next) | |
| await saveUiLanguage(next) | |
| const i18n = getI18n() | |
| await i18n.changeLanguage(resolveLanguage(next)) | |
| const onPick = useCallback(async (next: MobileUiLanguage) => { | |
| setLang(next) | |
| const i18n = getI18n() | |
| await i18n.changeLanguage(resolveLanguage(next)) | |
| void saveUiLanguage(next) |
| {item.isReviewed ? <T style={styles.reviewedPill}>Reviewed</T> : null} | ||
| {item.changedSinceReview ? <T style={styles.stalePill}>Changed</T> : null} |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
These new status labels won't localize.
These T usages are also children-only, so Reviewed, Changed, Stale, and Hunk render as raw English instead of going through i18next. Add explicit i18nKey values for each label.
Also applies to: 101-101, 115-115, 125-125
| onPress={onCancel} | ||
| > | ||
| <Text style={styles.cancelText}>Cancel</Text> | ||
| <T style={styles.cancelText}>Cancel</T> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Cancel still won't translate in this shared modal.
T only translates when i18nKey is provided, so this new label remains hard-coded English across every TextInputModal caller.
| describe('initI18n', () => { | ||
| it('initializes with the requested language', async () => { | ||
| const i18n = await initI18n('zh') | ||
| expect(i18n.language).toBe('zh') | ||
| }) | ||
|
|
||
| it('initializes with the resolved system language', async () => { | ||
| vi.mocked(Localization.getLocales).mockReturnValue([ | ||
| { | ||
| languageCode: 'zh', | ||
| languageTag: 'zh-CN', | ||
| regionCode: 'CN', | ||
| textDirection: 'ltr', | ||
| decimalSeparator: '.', | ||
| digitGroupingSeparator: ',' | ||
| } | ||
| ] as unknown as Array<{ languageCode: string }>) | ||
| const i18n = await initI18n('system') | ||
| expect(i18n.language).toBe('zh') | ||
| }) | ||
|
|
||
| it('returns the same instance on repeated calls', async () => { | ||
| const a = await initI18n('en') | ||
| const b = await initI18n('zh') | ||
| expect(a).toBe(b) | ||
| }) | ||
| }) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reset the module singleton between initI18n cases.
Line 71 seeds the module-scoped singleton from mobile/src/i18n/init.ts:44-66, so the later cases never exercise a fresh initialization. The assertion at Line 86 will still pass even if the system-language branch regresses, as long as the first test already initialized 'zh'. Re-import the module per test or add a reset hook before each initI18n case.
| export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> { | ||
| // Why: single-caller contract — _layout.tsx is the only invoker at boot, | ||
| // so this short-circuit is sufficient. A concurrent caller would race past | ||
| // the guard and trip i18next's "already initialized" error. | ||
| if (instance) { | ||
| return instance | ||
| } | ||
| const resolved = resolveLanguage(language) | ||
| instance = i18next.createInstance() | ||
| await instance.use(initReactI18next).init({ | ||
| lng: resolved, | ||
| fallbackLng: FALLBACK_LANGUAGE, | ||
| defaultNS: 'translation', | ||
| interpolation: { escapeValue: false }, | ||
| returnEmptyString: false, | ||
| resources: { | ||
| zh: { translation: zh }, | ||
| en: { translation: en } | ||
| } | ||
| }) | ||
| return instance |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Don't publish the singleton before init completes.
Line 54 stores instance before the awaited init(). If that call rejects, the English fallback in mobile/app/_layout.tsx:37-59 immediately hits if (instance) and reuses a half-initialized object instead of retrying; a second caller can observe the same broken instance before initialization finishes. Keep an in-flight promise separate from the ready singleton, and clear/reset state on failure.
Suggested fix
let instance: I18nInstance | null = null
+let initPromise: Promise<I18nInstance> | null = null
export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> {
- // Why: single-caller contract — _layout.tsx is the only invoker at boot,
- // so this short-circuit is sufficient. A concurrent caller would race past
- // the guard and trip i18next's "already initialized" error.
if (instance) {
return instance
}
- const resolved = resolveLanguage(language)
- instance = i18next.createInstance()
- await instance.use(initReactI18next).init({
- lng: resolved,
- fallbackLng: FALLBACK_LANGUAGE,
- defaultNS: 'translation',
- interpolation: { escapeValue: false },
- returnEmptyString: false,
- resources: {
- zh: { translation: zh },
- en: { translation: en }
- }
- })
- return instance
+ if (initPromise) {
+ return initPromise
+ }
+
+ initPromise = (async () => {
+ const resolved = resolveLanguage(language)
+ const next = i18next.createInstance()
+
+ try {
+ await next.use(initReactI18next).init({
+ lng: resolved,
+ fallbackLng: FALLBACK_LANGUAGE,
+ defaultNS: 'translation',
+ interpolation: { escapeValue: false },
+ returnEmptyString: false,
+ resources: {
+ zh: { translation: zh },
+ en: { translation: en }
+ }
+ })
+ instance = next
+ return next
+ } catch (error) {
+ instance = null
+ initPromise = null
+ throw error
+ }
+ })()
+
+ return initPromise
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> { | |
| // Why: single-caller contract — _layout.tsx is the only invoker at boot, | |
| // so this short-circuit is sufficient. A concurrent caller would race past | |
| // the guard and trip i18next's "already initialized" error. | |
| if (instance) { | |
| return instance | |
| } | |
| const resolved = resolveLanguage(language) | |
| instance = i18next.createInstance() | |
| await instance.use(initReactI18next).init({ | |
| lng: resolved, | |
| fallbackLng: FALLBACK_LANGUAGE, | |
| defaultNS: 'translation', | |
| interpolation: { escapeValue: false }, | |
| returnEmptyString: false, | |
| resources: { | |
| zh: { translation: zh }, | |
| en: { translation: en } | |
| } | |
| }) | |
| return instance | |
| let instance: I18nInstance | null = null | |
| let initPromise: Promise<I18nInstance> | null = null | |
| export async function initI18n(language: MobileUiLanguage): Promise<I18nInstance> { | |
| if (instance) { | |
| return instance | |
| } | |
| if (initPromise) { | |
| return initPromise | |
| } | |
| initPromise = (async () => { | |
| const resolved = resolveLanguage(language) | |
| const next = i18next.createInstance() | |
| try { | |
| await next.use(initReactI18next).init({ | |
| lng: resolved, | |
| fallbackLng: FALLBACK_LANGUAGE, | |
| defaultNS: 'translation', | |
| interpolation: { escapeValue: false }, | |
| returnEmptyString: false, | |
| resources: { | |
| zh: { translation: zh }, | |
| en: { translation: en } | |
| } | |
| }) | |
| instance = next | |
| return next | |
| } catch (error) { | |
| instance = null | |
| initPromise = null | |
| throw error | |
| } | |
| })() | |
| return initPromise | |
| } |
| export function T({ i18nKey, children, values, ns, ...rest }: TProps) { | ||
| const { t } = useTranslation(ns ? [ns] : undefined) | ||
| if (i18nKey) { | ||
| const translated = t(i18nKey, { defaultValue: children, ...values }) | ||
| return <Text {...rest}>{translated}</Text> | ||
| } | ||
| return <Text {...rest}>{children}</Text> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Children-only <T> calls never translate.
When i18nKey is omitted, this returns children directly instead of consulting i18next. Screens already use <T>Tasks</T> (for example mobile/app/index.tsx:643-695), so those labels stay English after switching to zh. Either route the no-key path through t() or make i18nKey mandatory.
Proposed fix
export function T({ i18nKey, children, values, ns, ...rest }: TProps) {
const { t } = useTranslation(ns ? [ns] : undefined)
- if (i18nKey) {
- const translated = t(i18nKey, { defaultValue: children, ...values })
- return <Text {...rest}>{translated}</Text>
- }
- return <Text {...rest}>{children}</Text>
+ const key = i18nKey ?? children
+ const translated = t(key, { defaultValue: children, ...values })
+ return <Text {...rest}>{translated}</Text>
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export function T({ i18nKey, children, values, ns, ...rest }: TProps) { | |
| const { t } = useTranslation(ns ? [ns] : undefined) | |
| if (i18nKey) { | |
| const translated = t(i18nKey, { defaultValue: children, ...values }) | |
| return <Text {...rest}>{translated}</Text> | |
| } | |
| return <Text {...rest}>{children}</Text> | |
| export function T({ i18nKey, children, values, ns, ...rest }: TProps) { | |
| const { t } = useTranslation(ns ? [ns] : undefined) | |
| const key = i18nKey ?? children | |
| const translated = t(key, { defaultValue: children, ...values }) | |
| return <Text {...rest}>{translated}</Text> | |
| } |
|
Closing this PR to re-design. Two fundamental issues that mean the current
Thanks to @coderabbitai for surfacing both issues. The |
|
Tip For best results, initiate chat on the files or code changes.
Those two constraints are decisive:
A v2 with an enforced (。•́︿•̀。)🐇 ✏️ Learnings added
|
Replaces the v1 no-op <T> wrapper (closed at PR stablyai#6627) with a translate() function pattern mirroring the desktop i18n flow (src/renderer/src/i18n/i18n.ts:43). Every mobile screen, component, and modal now renders through the same useTranslate() hook used by the desktop app. What ships in this PR: - mobile/src/i18n/{init,translate,useTranslate,I18nProvider}.{ts,tsx} + init test (F11 — translate() returns fallback when uninit) - 1004 mobile.* keys added to all 5 locales (en, zh, es, ja, ko) with full parity. Single-brace {{var}} → {var} interpolation fixed in 3 pair error keys (F1). - All mobile screens migrated (app/about.tsx, browser-settings.tsx, notifications.tsx, troubleshoot.tsx, voice-settings.tsx, terminal-settings.tsx, pair-confirm.tsx, index.tsx, h/[hostId]/{index,accounts,history/[worktreeId],tasks,session/[worktreeId]}.tsx). - All mobile components migrated (NewWorktreeModal, CustomKeyModal, MobileMarkdown, TerminalShortcutSettings, MobileDictationSetupSheet, WorktreeListRow, MobileDiffReview*, VoiceModelList, ProtocolBlockScreen, ConnectionLog, TextInputModal + 8 pr-sidebar files). - Language picker at app/language-settings.tsx with race-safe async changeLanguage (F3). - storage/preferences.ts: loadUiLanguage uses normalizeUiLanguage to coerce legacy or corrupt values to 'system' (F12). - metro.config.js: narrowed watchFolders to src/shared + i18n locales to avoid hot-reload cache thrash (F6). - config/scripts/audit-localization-coverage.mjs: extended to scan mobile/ with allowlist for prop-driven strings (F4, P20). - Desktop zh.json: full Chinese translation of 236 keys (F5). Japanese and Korean translations across all mobile.* blocks. Mobile: 1004/1004 zh keys (60 byte-identical to en — all brand names). Verified on Android emulator (Pixel, Android 14, x86_64): - language switch via UI Settings → language-settings → 中/英 - persistence across app cold restart (AsyncStorage round-trip) - all migrated screens render Chinese - coverage gate passes (0 candidates, 198 allowlist entries with per- sub-block reasons) Refs: supersedes stablyai#6627 (closed: <T> no-op bug, desktop had zero mobile.* keys). PR2 plan: docs/superpowers/plans/2026-06-29-mobile-i18n-v2-pr2.md Design spec: docs/superpowers/specs/2026-06-29-mobile-i18n-v2-design.md
概述
为 Orca 移动端添加中文(zh)汉化能力,与桌面端共享同一份翻译资源。
关键决策:使用 i18next + react-i18next(与桌面端同款),通过自定义
<T>React Native 包装组件实现渐进式迁移,通过 MetrowatchFolders直接引用桌面端zh.json(零冗余)。主要改动
新增(7 个文件)
mobile/src/i18n/init.ts— i18next 启动模块(幂等)mobile/src/i18n/I18nProvider.tsx— react-i18next<I18nextProvider>薄封装mobile/src/i18n/T.tsx— 核心抽象:<T>RN 包装组件,children-as-fallback 模式mobile/src/i18n/useT.ts— 组件外用的翻译 hookmobile/src/i18n/README.md— i18n 模块说明文档mobile/app/language-settings.tsx— 语言切换 UI 屏(系统 / 英文 / 中文)troubleshoot-sections.tsx,pair-scan-steps.tsx,custom-key-modal-data.ts)— 仅为满足 max-lines 限制修改(约 40 个文件)
<T><T>mobile/app/_layout.tsx— 启动时初始化 i18n(带 try/catch 降级)mobile/app/settings.tsx— 新增 Language 入口mobile/src/storage/preferences.ts— 新增loadUiLanguage/saveUiLanguagemobile/metro.config.js—watchFolders: ['..']支持跨包引用mobile/package.json— 新增 3 个依赖(i18next, react-i18next, expo-localization)架构亮点
src/renderer/src/i18n/locales/zh.json<T>接收 string literal 作为 fallback,编译期保证缺失 key 时显示英文MobileUiLanguage严格枚举('system' | 'en' | 'zh'),无运行时传错空间测试
pnpm test145 文件 / 973 通过 / 2 跳过(无回归)pnpm oxlint0 errorspnpm oxfmt --check0 errorspnpm exec tsc --noEmit无新增 errors(pre-existing tsconfigbaseUrl弃用警告无关)待手动验证(评审者)
按 plan Task 17 步骤:
src/renderer/src/i18n/locales/zh.json→ 移动端热重载生效已知限制(v1 范围外)
@testing-library/react-native)。<T>和useT跳过单测,依赖手动 smoke check。详见mobile/src/i18n/README.md。mobile/src/components/下的子组件仍有硬编码英文(如AccountUsage,PickerModal,MobileDictationSetupSheet)。这些在 v1 范围外,需要后续单独 PR。Spec / Plan
docs/superpowers/specs/2026-06-28-mobile-i18n-design.mddocs/superpowers/plans/2026-06-28-mobile-i18n.md