# Frontend Internationalisation (i18n) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a zero-dependency EN ↔ 中 language toggle to the Sidebar (left of the existing theme button) that switches all UI framework strings across every page at runtime, defaulting to English on every load.

**Architecture:** Custom `LanguageContext` (mirrors `ThemeContext` pattern) exposes `{ lang, t, toggleLang }`. Two TypeScript locale files (`locales/en.ts`, `locales/zh.ts`) export the same `Translations` shape. Pages call `useLanguage()` and replace hardcoded strings with `t.section.key`. No external libraries added.

**Tech Stack:** React 19, TypeScript, Vite — all already in the project.

---

## File Map

| File | Action |
|------|--------|
| `frontend/src/locales/en.ts` | **Create** — full English `Translations` object |
| `frontend/src/locales/zh.ts` | **Create** — full Chinese `Translations` object |
| `frontend/src/contexts/LanguageContext.tsx` | **Create** — `LanguageProvider`, `useLanguage()` |
| `frontend/src/contexts/index.ts` | **Modify** — export `LanguageProvider`, `useLanguage`, `Translations` |
| `frontend/src/App.tsx` | **Modify** — wrap with `<LanguageProvider>` |
| `frontend/src/components/layout/Sidebar.tsx` | **Modify** — lang toggle button + translated nav labels |
| `frontend/src/pages/Overview/OverviewPage.tsx` | **Modify** — `t.overview.*` |
| `frontend/src/pages/Perception/PerceptionPage.tsx` | **Modify** — `t.signals.*` |
| `frontend/src/pages/Status/StatusPage.tsx` | **Modify** — `t.status.*` |
| `frontend/src/pages/Docs/DocsPage.tsx` | **Modify** — `t.docs.*` |
| `frontend/src/pages/Compliance/CompliancePage.tsx` | **Modify** — `t.compliance.*` |
| `frontend/src/pages/Compliance/HistoryRail.tsx` | **Modify** — `t.compliance.*` |
| `frontend/src/pages/Compliance/FindingChatDrawer.tsx` | **Modify** — `t.compliance.*` |
| `frontend/src/pages/RagChat/RagChatPage.tsx` | **Modify** — `t.ragchat.*` |

---

## Task 1: Create locale files (`en.ts` and `zh.ts`)

**Files:**
- Create: `frontend/src/locales/en.ts`
- Create: `frontend/src/locales/zh.ts`

- [ ] **Step 1: Create `frontend/src/locales/en.ts`**

```ts
// English translations — default language
export interface Translations {
  nav: {
    groupMain: string;
    groupWorkbench: string;
    groupChat: string;
    overview: string;
    signals: string;
    status: string;
    documents: string;
    compliance: string;
    chat: string;
  };
  sidebar: {
    toggleTheme: string;
    toggleLang: string;
    signOut: string;
  };
  overview: {
    eyebrow: string;
    heroTitle: string;
    heroDesc: string;
    openDashboard: string;
    jumpToChat: string;
    sectionHowItWorks: string;
    sectionScreens: string;
    statScreens: string;
    statFlows: string;
    statReviewPosture: string;
    stepUpload: string; stepUploadDesc: string;
    stepProcess: string; stepProcessDesc: string;
    stepMonitor: string; stepMonitorDesc: string;
    stepAnalyze: string; stepAnalyzeDesc: string;
    stepReview: string; stepReviewDesc: string;
    stepChat: string; stepChatDesc: string;
    screenStatus: string; screenStatusDesc: string;
    screenSignals: string; screenSignalsDesc: string;
    screenDocuments: string; screenDocumentsDesc: string;
    screenCompliance: string; screenComplianceDesc: string;
    screenChat: string; screenChatDesc: string;
    screenAnalytics: string; screenAnalyticsDesc: string;
  };
  signals: {
    topbarTitle: string;
    topbarSub: string;
    searchPlaceholder: string;
    refreshBtn: string;
    crawlingBtn: string;
    statTotal: string;
    statHigh: string;
    statMedium: string;
    statLast90: string;
    badgeFinal: string;
    badgeDraft: string;
    badgeUrgent: string;
    badgePublished: string;
    emptySelectSignal: string;
    runAnalysis: string;
    stopBtn: string;
    sourceLink: string;
    tabOverview: string;
    tabObligations: string;
    tabImpact: string;
    tabChanges: string;
    cardScopeHeader: string;
    cardObligationsHeader: string;
    obligationsEmpty: string;
    colObligationDesc: string;
    colSubject: string;
    colType: string;
    colDeadline: string;
    deadlinePending: string;
    cardAffectedDocs: string;
    noAffectedDocs: string;
    cardAIImpact: string;
    footerText: string;
    statusConnecting: string;
    statusNoStream: string;
    statusCrawling: string;
    statusProcessing: string;
    statusComplete: string;
    statusUpdateComplete: string;
    statusError: string;
    statusConnFailed: string;
    diffOld: string;
    diffNew: string;
    diffCardHeader: string;
  };
  status: {
    topbarTitle: string;
    searchPlaceholder: string;
    exportBtn: string;
    refreshBtn: string;
    newUploadBtn: string;
    statTotal: string;
    statIndexed: string;
    statFailed: string;
    statChunks: string;
    statCoverage: string;
    cardHealth: string;
    badgeOnline: string;
    badgeError: string;
    badgeDegraded: string;
    badgeUnknown: string;
    healthEndpointError: string;
    serviceEnabled: string;
    serviceDisabled: string;
    serviceNotLoaded: string;
    cardConfig: string;
    labelLLMProvider: string;
    labelLLMModel: string;
    labelEmbeddingModel: string;
    labelEmbeddingDim: string;
    labelMilvusCollection: string;
    labelParserBackend: string;
    labelChunkBackend: string;
    labelParserFailureMode: string;
    configLoadError: string;
    cardBreakdown: string;
    breakdownIndexed: string;
    breakdownProcessing: string;
    breakdownFailed: string;
    cardRuntime: string;
    labelActiveSessions: string;
    labelSessionCapacity: string;
    labelReranker: string;
    labelBM25: string;
    statusActive: string;
    statusUnavailable: string;
    footerAllOk: string;
    footerDegraded: string;
    footerChecking: string;
    totalChunks: string;
  };
  docs: {
    topbarTitle: string;
    searchPlaceholder: string;
    refreshBtn: string;
    uploadBtn: string;
    confirmDeleteTitle: string;
    cancelBtn: string;
    deleteBtn: string;
    filterAll: string;
    filterReady: string;
    filterProcessing: string;
    filterFailed: string;
    filterPending: string;
    filterAllTypes: string;
    deleteSelected: string;
    colName: string;
    colStatus: string;
    colUploaded: string;
    colChunks: string;
    colSize: string;
    colType: string;
    colActions: string;
    loading: string;
    emptyNoDocuments: string;
    emptyNoMatch: string;
    titleDownload: string;
    titleRetry: string;
    titleDelete: string;
  };
  compliance: {
    topbarTitle: string;
    searchPlaceholder: string;
    clearBtn: string;
    exportBtn: string;
    exportJSON: string;
    exportText: string;
    newAnalysisBtn: string;
    statusAnalyzing: string;
    statusComplete: string;
    statusError: string;
    emptyTitle: string;
    emptyDesc: string;
    retrievingMsg: string;
    defaultRegulation: string;
    matchSuffix: string;
    colParagraph: string;
    extractingMsg: string;
    noTextExtracted: string;
    stagesHeader: string;
    stageExtraction: string;
    stageClauseSplit: string;
    stageRetrieval: string;
    stageSynthesis: string;
    gapInProgress: string;
    askAIBtn: string;
    chatBtn: string;
    conclusionHeader: string;
    riskScoreTooltip: string;
    statusCovered: string;
    statusGap: string;
    statusCritical: string;
    statusInfo: string;
    sourceTypePasted: string;
    sourceTypeIndexed: string;
    sourceTypeUploaded: string;
    chatSidebarHeader: string;
    chatThinking: string;
    quickQ1: string;
    quickQ2: string;
    quickQ3: string;
    chatPlaceholder: string;
    sendBtn: string;
    analysisFailed: string;
    exportReportHeader: string;
    exportSectionParagraph: string;
    exportSectionFindings: string;
    exportSectionConclusion: string;
    exportSectionActions: string;
    historyHeader: string;
    downloadReport: string;
    historyEmpty: string;
    historyDeleteConfirm: string;
    drawerClose: string;
    drawerChatEmpty: string;
    drawerSuggestionsHeader: string;
  };
  ragchat: {
    topbarTitle: string;
    exportBtn: string;
    quickPromptsHeader: string;
    inputPlaceholder: string;
    citationsHeader: string;
    citationsEmpty: string;
    apiError: string;
  };
}

export const en: Translations = {
  nav: {
    groupMain: 'Main',
    groupWorkbench: 'Workbench',
    groupChat: 'Chat',
    overview: 'Overview',
    signals: 'Regulatory Signals',
    status: 'System Status',
    documents: 'Documents',
    compliance: 'Compliance Analysis',
    chat: 'Regulation Q&A',
  },
  sidebar: {
    toggleTheme: 'Toggle theme',
    toggleLang: 'Switch language',
    signOut: 'Sign out',
  },
  overview: {
    eyebrow: 'T-Systems · AI Regulation Hub',
    heroTitle: 'AI Compliance,\nAutomated end-to-end',
    heroDesc: 'Monitor global AI regulations, analyze document compliance gaps, and get cited answers — all in one platform.',
    openDashboard: 'Open dashboard',
    jumpToChat: 'Jump to regulation chat',
    sectionHowItWorks: 'How it works',
    sectionScreens: 'Screens',
    statScreens: 'Screens',
    statFlows: 'Backend-aware flows',
    statReviewPosture: 'Review posture',
    stepUpload: 'Upload', stepUploadDesc: 'Ingest regulation documents',
    stepProcess: 'Process', stepProcessDesc: 'Embed and chunk via vector DB',
    stepMonitor: 'Monitor', stepMonitorDesc: 'Watch regulatory signal feed',
    stepAnalyze: 'Analyze', stepAnalyzeDesc: 'Run compliance gap analysis',
    stepReview: 'Review', stepReviewDesc: 'Inspect findings with AI assist',
    stepChat: 'Chat', stepChatDesc: 'Ask questions with cited answers',
    screenStatus: 'System Status', screenStatusDesc: 'Live health and workflow queue',
    screenSignals: 'Regulatory Signals', screenSignalsDesc: 'AI-detected regulatory changes',
    screenDocuments: 'Document Management', screenDocumentsDesc: 'Upload and inspect documents',
    screenCompliance: 'Compliance Analysis', screenComplianceDesc: 'Three-column compliance workspace',
    screenChat: 'Regulation Q&A', screenChatDesc: 'Chat with cited regulation sources',
    screenAnalytics: 'Analytics', screenAnalyticsDesc: 'KPIs and coverage metrics',
  },
  signals: {
    topbarTitle: 'Regulatory Signals',
    topbarSub: 'ai-powered · live feed',
    searchPlaceholder: 'Search signals...',
    refreshBtn: 'Refresh Sources',
    crawlingBtn: 'Crawling...',
    statTotal: 'Total signals',
    statHigh: 'High impact',
    statMedium: 'Medium impact',
    statLast90: 'Last 90 days',
    badgeFinal: 'Final',
    badgeDraft: 'Draft',
    badgeUrgent: 'Urgent',
    badgePublished: 'Published',
    emptySelectSignal: 'Select a signal to run impact analysis',
    runAnalysis: 'Run impact analysis',
    stopBtn: 'Stop',
    sourceLink: 'Source',
    tabOverview: 'Overview',
    tabObligations: 'Obligations',
    tabImpact: 'Impact Assessment',
    tabChanges: 'Change Comparison',
    cardScopeHeader: 'Scope & Summary',
    cardObligationsHeader: 'Obligations',
    obligationsEmpty: 'No structured data yet. Click "Run impact analysis" to extract.',
    colObligationDesc: 'Obligation',
    colSubject: 'Subject',
    colType: 'Type',
    colDeadline: 'Deadlines',
    deadlinePending: 'Pending',
    cardAffectedDocs: 'Affected documents',
    noAffectedDocs: 'No affected documents found.',
    cardAIImpact: 'AI Impact Analysis',
    footerText: 'Live feed · Regulation Hub',
    statusConnecting: 'Connecting to data sources...',
    statusNoStream: 'No stream',
    statusCrawling: 'Crawling...',
    statusProcessing: 'Processing {count} items...',
    statusComplete: 'Done +{count} items',
    statusUpdateComplete: 'Update complete — {new} added, {updated} updated',
    statusError: 'Error: {message}',
    statusConnFailed: 'Connection failed: {message}',
    diffOld: 'Previous',
    diffNew: 'Current',
    diffCardHeader: 'Change Comparison',
  },
  status: {
    topbarTitle: 'System Status',
    searchPlaceholder: 'Search...',
    exportBtn: 'Export',
    refreshBtn: 'Refresh',
    newUploadBtn: 'New upload',
    statTotal: 'Documents total',
    statIndexed: 'Indexed',
    statFailed: 'Failed',
    statChunks: 'Vector chunks',
    statCoverage: 'Index coverage',
    cardHealth: 'System health',
    badgeOnline: 'Online',
    badgeError: 'Error',
    badgeDegraded: 'Degraded',
    badgeUnknown: 'Unknown',
    healthEndpointError: 'Could not reach health endpoint',
    serviceEnabled: 'Enabled',
    serviceDisabled: 'Disabled',
    serviceNotLoaded: 'Not loaded',
    cardConfig: 'System configuration',
    labelLLMProvider: 'LLM provider',
    labelLLMModel: 'LLM model',
    labelEmbeddingModel: 'Embedding model',
    labelEmbeddingDim: 'Embedding dim',
    labelMilvusCollection: 'Milvus collection',
    labelParserBackend: 'Parser backend',
    labelChunkBackend: 'Chunk backend',
    labelParserFailureMode: 'Parser failure mode',
    configLoadError: 'Could not load config',
    cardBreakdown: 'Document breakdown',
    breakdownIndexed: 'Indexed',
    breakdownProcessing: 'Processing / Parsed',
    breakdownFailed: 'Failed',
    cardRuntime: 'Runtime info',
    labelActiveSessions: 'Active chat sessions',
    labelSessionCapacity: 'Session capacity',
    labelReranker: 'Cross-encoder reranker',
    labelBM25: 'BM25 hybrid retrieval',
    statusActive: 'Active',
    statusUnavailable: 'Unavailable',
    footerAllOk: 'All systems operational',
    footerDegraded: 'Degraded',
    footerChecking: 'Checking…',
    totalChunks: 'Total vector chunks',
  },
  docs: {
    topbarTitle: 'Document Management',
    searchPlaceholder: 'Search documents...',
    refreshBtn: 'Refresh',
    uploadBtn: 'Upload document',
    confirmDeleteTitle: 'Confirm deletion',
    cancelBtn: 'Cancel',
    deleteBtn: 'Delete',
    filterAll: 'All',
    filterReady: 'Ready',
    filterProcessing: 'Processing',
    filterFailed: 'Failed',
    filterPending: 'Pending',
    filterAllTypes: 'All types',
    deleteSelected: 'Delete selected',
    colName: 'Document name',
    colStatus: 'Status',
    colUploaded: 'Uploaded',
    colChunks: 'Chunks',
    colSize: 'Size',
    colType: 'Type',
    colActions: 'Actions',
    loading: 'Loading documents…',
    emptyNoDocuments: 'No documents yet. Upload a document to get started.',
    emptyNoMatch: 'No documents match the current filters.',
    titleDownload: 'Download original file',
    titleRetry: 'Retry processing',
    titleDelete: 'Delete document',
  },
  compliance: {
    topbarTitle: 'Compliance Analysis',
    searchPlaceholder: 'Search analyses...',
    clearBtn: 'Clear',
    exportBtn: 'Export',
    exportJSON: 'Export JSON',
    exportText: 'Export Text',
    newAnalysisBtn: 'New analysis',
    statusAnalyzing: 'Analyzing…',
    statusComplete: 'Analysis complete',
    statusError: 'Error',
    emptyTitle: 'No analysis running',
    emptyDesc: 'Click New analysis to start a compliance gap review against your indexed regulations.',
    retrievingMsg: 'Retrieving relevant regulations…',
    defaultRegulation: 'Regulation',
    matchSuffix: '% match',
    colParagraph: 'Paragraph Under Review',
    extractingMsg: 'Extracting and analyzing text…',
    noTextExtracted: 'No text extracted',
    stagesHeader: 'Analysis stages',
    stageExtraction: 'Text extraction',
    stageClauseSplit: 'Clause splitting',
    stageRetrieval: 'Regulation retrieval',
    stageSynthesis: 'Conclusion synthesis',
    gapInProgress: 'Gap analysis in progress…',
    askAIBtn: 'Ask AI',
    chatBtn: 'Chat',
    conclusionHeader: 'Conclusion',
    riskScoreTooltip: 'Risk score (0=safe, 100=critical)',
    statusCovered: 'Covered',
    statusGap: 'Gap',
    statusCritical: 'Critical',
    statusInfo: 'Info',
    sourceTypePasted: 'Pasted Text',
    sourceTypeIndexed: 'Indexed Document',
    sourceTypeUploaded: 'Uploaded File',
    chatSidebarHeader: 'AI Compliance Q&A',
    chatThinking: 'Thinking▋',
    quickQ1: 'What regulation applies?',
    quickQ2: 'How to remediate?',
    quickQ3: 'What is the risk?',
    chatPlaceholder: 'Ask about this finding…',
    sendBtn: 'Send',
    analysisFailed: 'Analysis failed',
    exportReportHeader: 'COMPLIANCE ANALYSIS REPORT',
    exportSectionParagraph: '── PARAGRAPH UNDER REVIEW ──',
    exportSectionFindings: '── FINDINGS ──',
    exportSectionConclusion: '── CONCLUSION ──',
    exportSectionActions: '── RECOMMENDED ACTIONS ──',
    historyHeader: 'History',
    downloadReport: 'Download report',
    historyEmpty: 'No analyses yet.',
    historyDeleteConfirm: 'Delete this analysis record? This cannot be undone.',
    drawerClose: 'Close',
    drawerChatEmpty: 'No messages yet. Ask a question below.',
    drawerSuggestionsHeader: 'Suggested questions',
  },
  ragchat: {
    topbarTitle: 'Regulation Q&A',
    exportBtn: 'Export chat',
    quickPromptsHeader: 'Quick prompts',
    inputPlaceholder: 'Ask about your regulations…',
    citationsHeader: 'Sources',
    citationsEmpty: 'Citations will appear here after a response is generated.',
    apiError: 'Could not reach the RAG API. Please check the backend.',
  },
};
```

- [ ] **Step 2: Create `frontend/src/locales/zh.ts`**

```ts
import type { Translations } from './en';

export const zh: Translations = {
  nav: {
    groupMain: '主菜单',
    groupWorkbench: '工作台',
    groupChat: '对话',
    overview: '概览',
    signals: '法规信号',
    status: '系统状态',
    documents: '文档管理',
    compliance: '合规分析',
    chat: '法规问答',
  },
  sidebar: {
    toggleTheme: '切换主题',
    toggleLang: '切换语言',
    signOut: '退出',
  },
  overview: {
    eyebrow: 'T-Systems · AI 法规中心',
    heroTitle: 'AI 合规，\n端到端自动化',
    heroDesc: '监控全球 AI 法规，分析文档合规差距，获取有引用来源的回答——一站式平台。',
    openDashboard: '打开仪表盘',
    jumpToChat: '跳转到法规对话',
    sectionHowItWorks: '工作流程',
    sectionScreens: '功能页面',
    statScreens: '功能页面',
    statFlows: '后端感知流程',
    statReviewPosture: '审查状态',
    stepUpload: '上传', stepUploadDesc: '导入法规文档',
    stepProcess: '处理', stepProcessDesc: '向量化与分块',
    stepMonitor: '监控', stepMonitorDesc: '监控法规信号流',
    stepAnalyze: '分析', stepAnalyzeDesc: '运行合规差距分析',
    stepReview: '审查', stepReviewDesc: 'AI 辅助审查发现',
    stepChat: '对话', stepChatDesc: '带引用来源的问答',
    screenStatus: '系统状态', screenStatusDesc: '实时健康与任务队列',
    screenSignals: '法规信号', screenSignalsDesc: 'AI 检测法规变更',
    screenDocuments: '文档管理', screenDocumentsDesc: '上传与查阅文档',
    screenCompliance: '合规分析', screenComplianceDesc: '三栏合规工作台',
    screenChat: '法规问答', screenChatDesc: '带引用来源的法规对话',
    screenAnalytics: '数据分析', screenAnalyticsDesc: 'KPI 与覆盖指标',
  },
  signals: {
    topbarTitle: '法规信号',
    topbarSub: 'AI 驱动 · 实时订阅',
    searchPlaceholder: '搜索信号...',
    refreshBtn: '刷新数据源',
    crawlingBtn: '抓取中...',
    statTotal: '信号总数',
    statHigh: '高影响',
    statMedium: '中影响',
    statLast90: '近 90 天',
    badgeFinal: '已发布',
    badgeDraft: '草案',
    badgeUrgent: '紧急',
    badgePublished: '已发布',
    emptySelectSignal: '选择信号以运行影响分析',
    runAnalysis: '运行影响分析',
    stopBtn: '停止',
    sourceLink: '来源',
    tabOverview: '概览',
    tabObligations: '义务条款',
    tabImpact: '影响评估',
    tabChanges: '变更对比',
    cardScopeHeader: '范围与摘要',
    cardObligationsHeader: '义务条款',
    obligationsEmpty: '暂无结构化数据。点击"运行影响分析"触发提取。',
    colObligationDesc: '义务描述',
    colSubject: '主体',
    colType: '类型',
    colDeadline: '截止日期',
    deadlinePending: '待定',
    cardAffectedDocs: '受影响文档',
    noAffectedDocs: '未找到受影响文档。',
    cardAIImpact: 'AI 影响分析',
    footerText: '实时订阅 · 法规中心',
    statusConnecting: '正在连接数据源...',
    statusNoStream: '无数据流',
    statusCrawling: '抓取中...',
    statusProcessing: '处理 {count} 条...',
    statusComplete: '完成 +{count} 条',
    statusUpdateComplete: '更新完成 — 新增 {new} 条，更新 {updated} 条',
    statusError: '错误: {message}',
    statusConnFailed: '连接失败: {message}',
    diffOld: '旧版',
    diffNew: '新版',
    diffCardHeader: '变更对比',
  },
  status: {
    topbarTitle: '系统状态',
    searchPlaceholder: '搜索...',
    exportBtn: '导出',
    refreshBtn: '刷新',
    newUploadBtn: '上传文档',
    statTotal: '文档总数',
    statIndexed: '已索引',
    statFailed: '失败',
    statChunks: '向量分块数',
    statCoverage: '索引覆盖率',
    cardHealth: '系统健康',
    badgeOnline: '在线',
    badgeError: '错误',
    badgeDegraded: '降级',
    badgeUnknown: '未知',
    healthEndpointError: '无法访问健康检查端点',
    serviceEnabled: '已启用',
    serviceDisabled: '已禁用',
    serviceNotLoaded: '未加载',
    cardConfig: '系统配置',
    labelLLMProvider: 'LLM 提供商',
    labelLLMModel: 'LLM 模型',
    labelEmbeddingModel: '向量模型',
    labelEmbeddingDim: '向量维度',
    labelMilvusCollection: 'Milvus 集合',
    labelParserBackend: '解析后端',
    labelChunkBackend: '分块后端',
    labelParserFailureMode: '解析失败模式',
    configLoadError: '无法加载配置',
    cardBreakdown: '文档分布',
    breakdownIndexed: '已索引',
    breakdownProcessing: '处理中 / 已解析',
    breakdownFailed: '失败',
    cardRuntime: '运行时信息',
    labelActiveSessions: '活跃对话会话',
    labelSessionCapacity: '会话容量',
    labelReranker: '交叉编码器重排序',
    labelBM25: 'BM25 混合检索',
    statusActive: '活跃',
    statusUnavailable: '不可用',
    footerAllOk: '所有系统正常',
    footerDegraded: '降级运行',
    footerChecking: '检查中…',
    totalChunks: '向量分块总数',
  },
  docs: {
    topbarTitle: '文档管理',
    searchPlaceholder: '搜索文档...',
    refreshBtn: '刷新',
    uploadBtn: '上传文档',
    confirmDeleteTitle: '确认删除',
    cancelBtn: '取消',
    deleteBtn: '删除',
    filterAll: '全部',
    filterReady: '就绪',
    filterProcessing: '处理中',
    filterFailed: '失败',
    filterPending: '待处理',
    filterAllTypes: '所有类型',
    deleteSelected: '删除所选',
    colName: '文档名称',
    colStatus: '状态',
    colUploaded: '上传时间',
    colChunks: '分块数',
    colSize: '大小',
    colType: '类型',
    colActions: '操作',
    loading: '加载文档中…',
    emptyNoDocuments: '暂无文档。请上传文档以开始使用。',
    emptyNoMatch: '没有文档符合当前筛选条件。',
    titleDownload: '下载原始文件',
    titleRetry: '重试处理',
    titleDelete: '删除文档',
  },
  compliance: {
    topbarTitle: '合规分析',
    searchPlaceholder: '搜索分析记录...',
    clearBtn: '清除',
    exportBtn: '导出',
    exportJSON: '导出 JSON',
    exportText: '导出文本',
    newAnalysisBtn: '新建分析',
    statusAnalyzing: '分析中…',
    statusComplete: '分析完成',
    statusError: '错误',
    emptyTitle: '暂无分析任务',
    emptyDesc: '点击"新建分析"对已索引法规进行合规差距审查。',
    retrievingMsg: '正在检索相关法规…',
    defaultRegulation: '法规',
    matchSuffix: '% 匹配',
    colParagraph: '待审查段落',
    extractingMsg: '正在提取并分析文本…',
    noTextExtracted: '未提取到文本',
    stagesHeader: '分析阶段',
    stageExtraction: '文本提取',
    stageClauseSplit: '条款分割',
    stageRetrieval: '法规检索',
    stageSynthesis: '结论综合',
    gapInProgress: '差距分析进行中…',
    askAIBtn: '问 AI',
    chatBtn: '对话',
    conclusionHeader: '结论',
    riskScoreTooltip: '风险评分（0=安全，100=严重）',
    statusCovered: '已覆盖',
    statusGap: '存在差距',
    statusCritical: '严重',
    statusInfo: '信息',
    sourceTypePasted: '粘贴文本',
    sourceTypeIndexed: '已索引文档',
    sourceTypeUploaded: '上传文件',
    chatSidebarHeader: 'AI 合规问答',
    chatThinking: '思考中▋',
    quickQ1: '适用哪条法规？',
    quickQ2: '如何整改？',
    quickQ3: '风险等级如何？',
    chatPlaceholder: '针对此发现提问…',
    sendBtn: '发送',
    analysisFailed: '分析失败',
    exportReportHeader: '合规分析报告',
    exportSectionParagraph: '── 待审查段落 ──',
    exportSectionFindings: '── 发现 ──',
    exportSectionConclusion: '── 结论 ──',
    exportSectionActions: '── 建议行动 ──',
    historyHeader: '历史记录',
    downloadReport: '下载报告',
    historyEmpty: '暂无分析记录。',
    historyDeleteConfirm: '删除此分析记录？此操作不可撤销。',
    drawerClose: '关闭',
    drawerChatEmpty: '暂无消息。请在下方提问。',
    drawerSuggestionsHeader: '建议问题',
  },
  ragchat: {
    topbarTitle: '法规问答',
    exportBtn: '导出对话',
    quickPromptsHeader: '快捷问题',
    inputPlaceholder: '请输入关于法规的问题…',
    citationsHeader: '引用来源',
    citationsEmpty: '生成回答后，引用来源将显示在此处。',
    apiError: '无法连接到 RAG API，请检查后端服务。',
  },
};
```

- [ ] **Step 3: Verify TypeScript sees both files**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors (or only pre-existing errors, none from the new locale files).

---

## Task 2: Create `LanguageContext.tsx`

**Files:**
- Create: `frontend/src/contexts/LanguageContext.tsx`

- [ ] **Step 1: Write `LanguageContext.tsx`**

```tsx
import React, { createContext, useContext, useState } from 'react';
import { en } from '../locales/en';
import type { Translations } from '../locales/en';
import { zh } from '../locales/zh';

export type Lang = 'en' | 'zh';

interface LanguageContextValue {
  lang: Lang;
  t: Translations;
  toggleLang: () => void;
}

const LanguageContext = createContext<LanguageContextValue>({
  lang: 'en',
  t: en,
  toggleLang: () => {},
});

export function LanguageProvider({ children }: { children: React.ReactNode }) {
  const [lang, setLang] = useState<Lang>('en');

  const toggleLang = () => setLang(l => (l === 'en' ? 'zh' : 'en'));

  const t = lang === 'en' ? en : zh;

  return (
    <LanguageContext.Provider value={{ lang, t, toggleLang }}>
      {children}
    </LanguageContext.Provider>
  );
}

export function useLanguage() {
  return useContext(LanguageContext);
}
```

- [ ] **Step 2: Export from `contexts/index.ts`**

Add these lines to `frontend/src/contexts/index.ts`:

```ts
export { LanguageProvider, useLanguage } from './LanguageContext';
export type { Lang } from './LanguageContext';
```

Full updated file:

```ts
export { ThemeProvider, useTheme } from './ThemeContext';
export { AuthProvider, useAuth } from './AuthContext';
export type { AuthUser } from './AuthContext';
export { PageStateProvider, usePageState } from './PageStateContext';
export { LanguageProvider, useLanguage } from './LanguageContext';
export type { Lang } from './LanguageContext';
export type {
  RagChatState,
  RagMessage,
  RagCitation,
  ComplianceState,
  ComplianceStatus,
  ComplianceSourceEvent,
  ComplianceFindingEvent,
  ComplianceDonePayload,
  ComplianceMeta,
  ComplianceActionItem,
  PerceptionPageState,
  PerceptionSignal,
} from './PageStateContext';
```

- [ ] **Step 3: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 3: Wire `LanguageProvider` in `App.tsx` and add toggle button to Sidebar

**Files:**
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/components/layout/Sidebar.tsx`

- [ ] **Step 1: Update `App.tsx`**

Replace the full file content:

```tsx
import './styles/globals.css';
import { ThemeProvider, AuthProvider, PageStateProvider, LanguageProvider } from './contexts';
import { AppRouter } from './router/AppRouter';

function App() {
  return (
    <LanguageProvider>
      <ThemeProvider>
        <AuthProvider>
          <PageStateProvider>
            <AppRouter />
          </PageStateProvider>
        </AuthProvider>
      </ThemeProvider>
    </LanguageProvider>
  );
}

export default App;
```

- [ ] **Step 2: Update `Sidebar.tsx`**

Replace the full file content:

```tsx
import { NavLink } from 'react-router-dom';
import {
  LayoutDashboard, Radio, Monitor, FileText,
  Shield, MessageSquare, Sun, Moon, LogOut
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useAuth } from '../../contexts/AuthContext';
import { useLanguage } from '../../contexts/LanguageContext';

interface NavItem {
  to: string;
  icon: React.ReactNode;
  label: string;
  badge?: number;
}

function NavGroup({ title, items }: { title: string; items: NavItem[] }) {
  return (
    <div className="nav-group">
      <div className="nav-group-label">{title}</div>
      {items.map(item => (
        <NavLink
          key={item.to}
          to={item.to}
          end={item.to === '/'}
          className={({ isActive }) => `nav-item${isActive ? ' active' : ''}`}
        >
          <span className="nav-icon">{item.icon}</span>
          <span className="nav-label">{item.label}</span>
          {item.badge !== undefined && item.badge > 0 && (
            <span className="nav-badge">{item.badge}</span>
          )}
        </NavLink>
      ))}
    </div>
  );
}

/** Avatar initials from username (up to 2 chars). */
function initials(name: string): string {
  const parts = name.trim().split(/[\s_-]+/);
  if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
  return name.slice(0, 2).toUpperCase();
}

export function Sidebar() {
  const { theme, toggleTheme } = useTheme();
  const { user, logout } = useAuth();
  const { lang, t, toggleLang } = useLanguage();

  const mainNav: NavItem[] = [
    { to: '/', icon: <LayoutDashboard size={16} />, label: t.nav.overview },
    { to: '/signals', icon: <Radio size={16} />, label: t.nav.signals },
    { to: '/status', icon: <Monitor size={16} />, label: t.nav.status },
  ];

  const workbenchNav: NavItem[] = [
    { to: '/documents', icon: <FileText size={16} />, label: t.nav.documents },
    { to: '/compliance', icon: <Shield size={16} />, label: t.nav.compliance },
  ];

  const chatNav: NavItem[] = [
    { to: '/chat', icon: <MessageSquare size={16} />, label: t.nav.chat },
  ];

  return (
    <aside className="sidebar">
      <div className="sidebar-brand">
        <img src="/company-logo.ico" alt="T-Systems" className="brand-logo" />
        <div className="brand-text">
          <div className="brand-name">T-Systems</div>
          <div className="brand-sub">Regulation Hub</div>
        </div>
      </div>

      <nav className="sidebar-nav">
        <NavGroup title={t.nav.groupMain} items={mainNav} />
        <NavGroup title={t.nav.groupWorkbench} items={workbenchNav} />
        <NavGroup title={t.nav.groupChat} items={chatNav} />
      </nav>

      <div className="sidebar-footer">
        <div className="sidebar-user">
          <div className="user-avatar">{user ? initials(user.username) : 'TS'}</div>
          <div className="user-info">
            <div className="user-name">{user?.username ?? 'Analyst'}</div>
            <div className="user-role">
              {user ? (
                <span className="user-badge">{user.role}</span>
              ) : (
                'T-Systems'
              )}
            </div>
          </div>
        </div>
        <div style={{ display: 'flex', gap: 4 }}>
          <button
            className="theme-btn"
            onClick={toggleLang}
            title={t.sidebar.toggleLang}
            style={{ fontSize: 12, fontWeight: 600 }}
          >
            {lang === 'en' ? 'EN' : '中'}
          </button>
          <button className="theme-btn" onClick={toggleTheme} title={t.sidebar.toggleTheme}>
            {theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
          </button>
          {user && (
            <button className="logout-btn" onClick={logout} title={t.sidebar.signOut}>
              <LogOut size={14} />
            </button>
          )}
        </div>
      </div>
    </aside>
  );
}
```

- [ ] **Step 3: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

- [ ] **Step 4: Manual check — open the app**

Start the dev server (if not running): `cd frontend && npm run dev`

Open http://localhost:5173 and verify:
1. Sidebar shows `EN` button left of the sun/moon button
2. Clicking `EN` toggles to `中` and nav labels switch to Chinese
3. Clicking again returns to English

---

## Task 4: Translate `OverviewPage.tsx`

**Files:**
- Modify: `frontend/src/pages/Overview/OverviewPage.tsx`

- [ ] **Step 1: Replace `OverviewPage.tsx`**

```tsx
import { useNavigate } from 'react-router-dom';
import { ArrowRight, BarChart2, Eye, FileText, Shield, MessageSquare, Monitor } from 'lucide-react';
import { useLanguage } from '../../contexts/LanguageContext';

export function OverviewPage() {
  const navigate = useNavigate();
  const { t } = useLanguage();

  const SCREENS = [
    { id: 'status', label: t.overview.screenStatus, icon: <Monitor size={20} />, to: '/status', desc: t.overview.screenStatusDesc },
    { id: 'signals', label: t.overview.screenSignals, icon: <Eye size={20} />, to: '/signals', desc: t.overview.screenSignalsDesc },
    { id: 'documents', label: t.overview.screenDocuments, icon: <FileText size={20} />, to: '/documents', desc: t.overview.screenDocumentsDesc },
    { id: 'compliance', label: t.overview.screenCompliance, icon: <Shield size={20} />, to: '/compliance', desc: t.overview.screenComplianceDesc },
    { id: 'chat', label: t.overview.screenChat, icon: <MessageSquare size={20} />, to: '/chat', desc: t.overview.screenChatDesc },
    { id: 'analytics', label: t.overview.screenAnalytics, icon: <BarChart2 size={20} />, to: '/status', desc: t.overview.screenAnalyticsDesc },
  ];

  const STEPS = [
    { num: '01', label: t.overview.stepUpload, desc: t.overview.stepUploadDesc },
    { num: '02', label: t.overview.stepProcess, desc: t.overview.stepProcessDesc },
    { num: '03', label: t.overview.stepMonitor, desc: t.overview.stepMonitorDesc },
    { num: '04', label: t.overview.stepAnalyze, desc: t.overview.stepAnalyzeDesc },
    { num: '05', label: t.overview.stepReview, desc: t.overview.stepReviewDesc },
    { num: '06', label: t.overview.stepChat, desc: t.overview.stepChatDesc },
  ];

  return (
    <div className="overview-scroll-wrapper">
      <div className="overview-page">
        <section className="overview-hero">
          <p className="hero-eyebrow">{t.overview.eyebrow}</p>
          <h1 className="hero-title">{t.overview.heroTitle.split('\n').map((line, i) => (
            <span key={i}>{line}{i === 0 && <br />}</span>
          ))}</h1>
          <p className="hero-desc">{t.overview.heroDesc}</p>
          <div className="hero-actions">
            <button className="btn primary" onClick={() => navigate('/status')}>
              {t.overview.openDashboard} <ArrowRight size={14} />
            </button>
            <button className="btn" onClick={() => navigate('/chat')}>
              {t.overview.jumpToChat}
            </button>
          </div>
        </section>

        <div className="overview-summary card">
          <div className="summary-item">
            <span className="summary-num">6</span>
            <span className="summary-label">{t.overview.statScreens}</span>
          </div>
          <div className="summary-divider" />
          <div className="summary-item">
            <span className="summary-num">5</span>
            <span className="summary-label">{t.overview.statFlows}</span>
          </div>
          <div className="summary-divider" />
          <div className="summary-item">
            <span className="summary-num">AI</span>
            <span className="summary-label">{t.overview.statReviewPosture}</span>
          </div>
        </div>

        <section className="overview-workflow">
          <h2 className="section-title">{t.overview.sectionHowItWorks}</h2>
          <div className="workflow-steps">
            {STEPS.map(s => (
              <div key={s.num} className="workflow-step">
                <div className="step-num">{s.num}</div>
                <div className="step-label">{s.label}</div>
                <div className="step-desc">{s.desc}</div>
              </div>
            ))}
          </div>
        </section>

        <section className="overview-screens">
          <h2 className="section-title">{t.overview.sectionScreens}</h2>
          <div className="screen-grid">
            {SCREENS.map(s => (
              <button key={s.id} className="screen-card card" onClick={() => navigate(s.to)}>
                <div className="screen-icon">{s.icon}</div>
                <div className="screen-label">{s.label}</div>
                <div className="screen-desc">{s.desc}</div>
              </button>
            ))}
          </div>
        </section>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 5: Translate `StatusPage.tsx`

**Files:**
- Modify: `frontend/src/pages/Status/StatusPage.tsx`

- [ ] **Step 1: Add `useLanguage` import and hook call**

At the top of the file, add the import:

```tsx
import { useLanguage } from '../../contexts/LanguageContext';
```

Inside `StatusPage()` function, add at the top:

```tsx
const { t } = useLanguage();
```

- [ ] **Step 2: Replace hardcoded strings — `Topbar` actions**

Find:
```tsx
<Topbar
  title="System Status"
  actions={
    <>
      <div className="search-box">
        <Search size={13} />
        <input placeholder="Search..." />
      </div>
      <button className="btn sm" onClick={handleExport}>
        <Download size={13} />Export
      </button>
      <button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
        <RefreshCw size={13} />Refresh
      </button>
      <button className="btn sm primary" onClick={() => setShowUpload(true)}>
        <Upload size={13} />New upload
      </button>
    </>
  }
/>
```

Replace with:
```tsx
<Topbar
  title={t.status.topbarTitle}
  actions={
    <>
      <div className="search-box">
        <Search size={13} />
        <input placeholder={t.status.searchPlaceholder} />
      </div>
      <button className="btn sm" onClick={handleExport}>
        <Download size={13} />{t.status.exportBtn}
      </button>
      <button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
        <RefreshCw size={13} />{t.status.refreshBtn}
      </button>
      <button className="btn sm primary" onClick={() => setShowUpload(true)}>
        <Upload size={13} />{t.status.newUploadBtn}
      </button>
    </>
  }
/>
```

- [ ] **Step 3: Replace stat labels**

Find:
```tsx
<div className="stat-label">Documents total</div>
```
Replace: `<div className="stat-label">{t.status.statTotal}</div>`

Find: `<div className="stat-label">Indexed</div>` (first occurrence in stats grid)
Replace: `<div className="stat-label">{t.status.statIndexed}</div>`

Find: `<div className="stat-label">Failed</div>` (in stats grid)
Replace: `<div className="stat-label">{t.status.statFailed}</div>`

Find: `<div className="stat-label">Vector chunks</div>`
Replace: `<div className="stat-label">{t.status.statChunks}</div>`

Find: `<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>Index coverage</span>`
Replace: `<span style={{ fontSize: 12, color: 'var(--muted)', whiteSpace: 'nowrap' }}>{t.status.statCoverage}</span>`

- [ ] **Step 4: Replace `ServiceRow` status strings**

The `ServiceRow` component renders `'Online'`, `'Error'`, `'Degraded'`, `'Unknown'` from a ternary. Replace the inline string map in the `ServiceRow` `<span>`:

Find inside `ServiceRow`:
```tsx
{status === 'ok' ? 'Online' : status === 'error' ? 'Error' : status === 'warn' ? 'Degraded' : 'Unknown'}
```

This requires `t` inside `ServiceRow`. Convert `ServiceRow` to accept a `t` prop, **or** (simpler) pass the label string externally. The simpler approach: add a `label` prop with a default:

Replace `ServiceRow` component definition:

```tsx
function ServiceRow({ name, status, detail }: { name: string; status: 'ok' | 'error' | 'warn' | 'info'; detail?: string }) {
  const { t } = useLanguage();
  return (
    <div className="service-row">
      <StatusIcon status={status} />
      <span className="service-name" style={{ marginLeft: 8 }}>{name}</span>
      {detail && <span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 6 }}>{detail}</span>}
      <span className={`status ${status}`} style={{ marginLeft: 'auto' }}>
        {status === 'ok' ? t.status.badgeOnline : status === 'error' ? t.status.badgeError : status === 'warn' ? t.status.badgeDegraded : t.status.badgeUnknown}
      </span>
    </div>
  );
}
```

- [ ] **Step 5: Replace card headers, service names, and config labels**

Find: `<span>System health</span>`
Replace: `<span>{t.status.cardHealth}</span>`

Find: `Could not reach health endpoint`
Replace: `{t.status.healthEndpointError}`

Find (in `ServiceRow` calls):
```tsx
detail={health.bm25.available ? undefined : 'Not loaded'}
```
Replace:
```tsx
detail={health.bm25.available ? undefined : t.status.serviceNotLoaded}
```

Find:
```tsx
detail={health.reranker.enabled ? 'Enabled' : 'Disabled'}
```
Replace:
```tsx
detail={health.reranker.enabled ? t.status.serviceEnabled : t.status.serviceDisabled}
```

Find: `<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>System configuration</div>`
Replace: `<div className="card-header" style={{ margin: 0, padding: 0, flex: 1, textAlign: 'left' }}>{t.status.cardConfig}</div>`

Replace each `ConfigRow label=` string:
- `"LLM provider"` → `{t.status.labelLLMProvider}`
- `"LLM model"` → `{t.status.labelLLMModel}`
- `"Embedding model"` → `{t.status.labelEmbeddingModel}`
- `"Embedding dim"` → `{t.status.labelEmbeddingDim}`
- `"Milvus collection"` → `{t.status.labelMilvusCollection}`
- `"Parser backend"` → `{t.status.labelParserBackend}`
- `"Chunk backend"` → `{t.status.labelChunkBackend}`
- `"Parser failure mode"` → `{t.status.labelParserFailureMode}`

Find: `<div style={{ color: 'var(--muted)', fontSize: 13 }}>Could not load config</div>`
Replace: `<div style={{ color: 'var(--muted)', fontSize: 13 }}>{t.status.configLoadError}</div>`

Find: `<div className="card-header">Document breakdown</div>`
Replace: `<div className="card-header">{t.status.cardBreakdown}</div>`

Find breakdown row labels array `'Indexed'`, `'Processing / Parsed'`, `'Failed'`:
```tsx
{ label: 'Indexed', ...},
{ label: 'Processing / Parsed', ...},
{ label: 'Failed', ...},
```
Replace:
```tsx
{ label: t.status.breakdownIndexed, ...},
{ label: t.status.breakdownProcessing, ...},
{ label: t.status.breakdownFailed, ...},
```

Find: `<span style={{ color: 'var(--muted)' }}>Total vector chunks</span>`
Replace: `<span style={{ color: 'var(--muted)' }}>{t.status.totalChunks}</span>`

Find: `<div className="card-header">Runtime info</div>`
Replace: `<div className="card-header">{t.status.cardRuntime}</div>`

Replace the runtime info row labels:
- `'Active chat sessions'` → `{t.status.labelActiveSessions}`
- `'Session capacity'` → `{t.status.labelSessionCapacity}`
- `'Cross-encoder reranker'` → `{t.status.labelReranker}`
- `'BM25 hybrid retrieval'` → `{t.status.labelBM25}`

Replace runtime status values:
- `'Active'` (BM25 true) → `{t.status.statusActive}`
- `'Unavailable'` → `{t.status.statusUnavailable}`

Find footer:
```tsx
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? 'All systems operational' : 'Degraded') : 'Checking…'}</span>
```
Replace:
```tsx
<span>Regulation Hub · T-Systems AI · {health ? (health.milvus.status === 'ok' && health.minio.connected ? t.status.footerAllOk : t.status.footerDegraded) : t.status.footerChecking}</span>
```

- [ ] **Step 6: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 6: Translate `PerceptionPage.tsx`

**Files:**
- Modify: `frontend/src/pages/Perception/PerceptionPage.tsx`

- [ ] **Step 1: Add `useLanguage` import and hook**

Add import:
```tsx
import { useLanguage } from '../../contexts/LanguageContext';
```

Add at the top of `PerceptionPage()`:
```tsx
const { t } = useLanguage();
```

- [ ] **Step 2: Replace `Topbar` props**

Find:
```tsx
<Topbar
  title="Regulatory Signals"
  subtitle="ai-powered · live feed"
  actions={
    <>
      <div className="search-box">
        <input
          placeholder="Search signals..."
```
Replace `title`, `subtitle`, `placeholder`:
```tsx
<Topbar
  title={t.signals.topbarTitle}
  subtitle={t.signals.topbarSub}
  actions={
    <>
      <div className="search-box">
        <input
          placeholder={t.signals.searchPlaceholder}
```

- [ ] **Step 3: Replace button labels**

Find:
```tsx
{crawling ? 'Crawling...' : 'Refresh Sources'}
```
Replace:
```tsx
{crawling ? t.signals.crawlingBtn : t.signals.refreshBtn}
```

- [ ] **Step 4: Replace stat labels**

Find `'Total signals'` → `{t.signals.statTotal}`
Find `'High impact'` → `{t.signals.statHigh}`
Find `'Medium impact'` → `{t.signals.statMedium}`
Find `'Last 90 days'` → `{t.signals.statLast90}`

- [ ] **Step 5: Replace signal status badges**

Find:
```tsx
{sig.status === 'ok' ? 'Final' : sig.status === 'warn' ? 'Draft' : sig.status === 'risk' ? 'Urgent' : 'Published'}
```
Replace:
```tsx
{sig.status === 'ok' ? t.signals.badgeFinal : sig.status === 'warn' ? t.signals.badgeDraft : sig.status === 'risk' ? t.signals.badgeUrgent : t.signals.badgePublished}
```

Do the same for the detail card status badge (second occurrence with `selected.status`).

- [ ] **Step 6: Replace empty state, buttons, and tab labels**

Find: `<p>Select a signal to run impact analysis</p>`
Replace: `<p>{t.signals.emptySelectSignal}</p>`

Find: `<button className="btn sm primary" onClick={runAnalysis}><Play size={12} />Run impact analysis</button>`
Replace: `<button className="btn sm primary" onClick={runAnalysis}><Play size={12} />{t.signals.runAnalysis}</button>`

Find: `<button className="btn sm" onClick={stopAnalysis}><Square size={12} />Stop</button>`
Replace: `<button className="btn sm" onClick={stopAnalysis}><Square size={12} />{t.signals.stopBtn}</button>`

Find: `<ExternalLink size={12} />Source`
Replace: `<ExternalLink size={12} />{t.signals.sourceLink}`

- [ ] **Step 7: Replace tab labels**

Find:
```tsx
{tab === 'overview' ? '概览' : tab === 'obligations' ? '义务条款' : tab === 'assessment' ? '影响评估' : '变更对比'}
```
Replace:
```tsx
{tab === 'overview' ? t.signals.tabOverview : tab === 'obligations' ? t.signals.tabObligations : tab === 'assessment' ? t.signals.tabImpact : t.signals.tabChanges}
```

- [ ] **Step 8: Replace card headers and table headers**

Find: `<div className="card-header">Scope &amp; Summary</div>`
Replace: `<div className="card-header">{t.signals.cardScopeHeader}</div>`

Find: `<div className="card-header">义务条款</div>`
Replace: `<div className="card-header">{t.signals.cardObligationsHeader}</div>`

Find: `<p className="detail-summary" style={{ marginTop: 8 }}>暂无结构化数据。点击右上角"Run impact analysis"触发提取。</p>`
Replace: `<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.obligationsEmpty}</p>`

Find: `<th style={{ textAlign: 'left', padding: '4px 8px' }}>义务描述</th>`
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px' }}>{t.signals.colObligationDesc}</th>`

Find: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>主体</th>`
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 80 }}>{t.signals.colSubject}</th>`

Find: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>类型</th>`
Replace: `<th style={{ textAlign: 'left', padding: '4px 8px', width: 60 }}>{t.signals.colType}</th>`

Find: `<div className="card-header">截止日期</div>`
Replace: `<div className="card-header">{t.signals.colDeadline}</div>`

Find: `{d.date || '待定'}`
Replace: `{d.date || t.signals.deadlinePending}`

Find: `<div className="card-header">Affected documents</div>`
Replace: `<div className="card-header">{t.signals.cardAffectedDocs}</div>`

Find: `<p className="detail-summary" style={{ marginTop: 8 }}>No affected documents found.</p>`
Replace: `<p className="detail-summary" style={{ marginTop: 8 }}>{t.signals.noAffectedDocs}</p>`

Find: `<div className="card-header">AI Impact Analysis</div>`
Replace: `<div className="card-header">{t.signals.cardAIImpact}</div>`

- [ ] **Step 9: Replace diff tab headers**

Find: `<div className="card-header">变更对比</div>`
Replace: `<div className="card-header">{t.signals.diffCardHeader}</div>`

Find: `<div style={{ fontWeight: 600, marginBottom: 4 }}>旧版</div>`
Replace: `<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffOld}</div>`

Find: `<div style={{ fontWeight: 600, marginBottom: 4 }}>新版</div>`
Replace: `<div style={{ fontWeight: 600, marginBottom: 4 }}>{t.signals.diffNew}</div>`

- [ ] **Step 10: Replace crawl status strings in `runCrawl()`**

Find: `crawlStatus: '正在连接数据源...'`
Replace: `crawlStatus: t.signals.statusConnecting`

Find:
```tsx
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? '抓取中...' : d.stage === 'processing' ? `处理 ${d.fetched} 条...` : `完成 +${d.new} 条`}`,
```
Replace:
```tsx
crawlStatus: `${d.source}: ${d.stage === 'fetching' ? t.signals.statusCrawling : d.stage === 'processing' ? t.signals.statusProcessing.replace('{count}', String(d.fetched)) : t.signals.statusComplete.replace('{count}', String(d.new))}`,
```

Find: `` crawlStatus: `更新完成 — 新增 ${d.total_new} 条，更新 ${d.total_updated} 条` ``
Replace:
```tsx
crawlStatus: t.signals.statusUpdateComplete.replace('{new}', String(d.total_new)).replace('{updated}', String(d.total_updated))
```

Find: `` crawlStatus: `错误: ${typeof d === 'string' ? d : d.message}` ``
Replace:
```tsx
crawlStatus: t.signals.statusError.replace('{message}', typeof d === 'string' ? d : String(d.message))
```

Find: `` crawlStatus: `连接失败: ${e instanceof Error ? e.message : String(e)}` ``
Replace:
```tsx
crawlStatus: t.signals.statusConnFailed.replace('{message}', e instanceof Error ? e.message : String(e))
```

- [ ] **Step 11: Replace footer**

Find: `<span>Live feed · Regulation Hub</span>`
Replace: `<span>{t.signals.footerText}</span>`

- [ ] **Step 12: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 7: Translate `DocsPage.tsx`

**Files:**
- Modify: `frontend/src/pages/Docs/DocsPage.tsx`

- [ ] **Step 1: Add `useLanguage` import**

```tsx
import { useLanguage } from '../../contexts/LanguageContext';
```

- [ ] **Step 2: Add hook in `ConfirmDialog` and `DocsPage`**

`ConfirmDialog` uses `'Confirm deletion'`, `'Cancel'`, `'Delete'`. Convert it to accept a `t` prop **or** call `useLanguage()` inside it. Use `useLanguage()` directly inside (same pattern as `ServiceRow`):

```tsx
function ConfirmDialog({ message, onConfirm, onCancel }: {
  message: string;
  onConfirm: () => void;
  onCancel: () => void;
}) {
  const { t } = useLanguage();
  return (
    <div className="modal-overlay" onClick={onCancel}>
      <div
        style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 14, padding: '28px 32px', maxWidth: 400, width: '100%', boxShadow: '0 12px 40px rgba(0,0,0,.2)' }}
        onClick={e => e.stopPropagation()}
      >
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
          <AlertTriangle size={18} color="var(--danger)" />
          <span style={{ fontWeight: 600, fontSize: 15 }}>{t.docs.confirmDeleteTitle}</span>
        </div>
        <p style={{ fontSize: 13, color: 'var(--muted)', lineHeight: 1.6, marginBottom: 20 }}>{message}</p>
        <div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
          <button className="btn sm" onClick={onCancel}>{t.docs.cancelBtn}</button>
          <button className="btn sm" style={{ background: 'var(--danger)', color: '#fff', borderColor: 'var(--danger)' }} onClick={onConfirm}>
            {t.docs.deleteBtn}
          </button>
        </div>
      </div>
    </div>
  );
}
```

Add `const { t } = useLanguage();` at top of `DocsPage()`.

- [ ] **Step 3: Replace `STATUS_FILTERS` array and topbar**

The `STATUS_FILTERS` array is used in chip rendering and filter logic. The filter logic uses the English strings as keys (through `STATUS_MAP`). Keep the filter keys as English internally, only translate the displayed chip label.

Update the chip rendering:
```tsx
{STATUS_FILTERS.map(f => (
  <button
    key={f}
    className={`chip${statusF === f ? ' active' : ''}`}
    onClick={() => setStatusF(f)}
  >
    {f === 'All' ? t.docs.filterAll
      : f === 'Ready' ? t.docs.filterReady
      : f === 'Processing' ? t.docs.filterProcessing
      : f === 'Failed' ? t.docs.filterFailed
      : t.docs.filterPending}
  </button>
))}
```

Replace the `'All types'` dropdown option display (keep value as English for filter logic):
```tsx
<select className="select-input" value={typeF} onChange={e => setTypeF(e.target.value)}>
  {typeOpts.map(o => (
    <option key={o} value={o}>{o === 'All types' ? t.docs.filterAllTypes : o}</option>
  ))}
</select>
```

Replace `Topbar`:
```tsx
<Topbar
  title={t.docs.topbarTitle}
  actions={
    <>
      <div className="search-box">
        <Search size={13} />
        <input
          placeholder={t.docs.searchPlaceholder}
          value={search}
          onChange={e => setSearch(e.target.value)}
        />
      </div>
      <button className="btn sm" onClick={() => setRefreshKey(k => k + 1)}>
        <RefreshCw size={13} />{t.docs.refreshBtn}
      </button>
      <button className="btn sm primary" onClick={() => setShowUpload(true)}>
        <Upload size={13} />{t.docs.uploadBtn}
      </button>
    </>
  }
/>
```

- [ ] **Step 4: Replace batch bar and table headers**

Find: `<span>{selected.size} document{selected.size > 1 ? 's' : ''} selected</span>`

This string has pluralisation logic. Keep it simple — just use the count:
```tsx
<span>{selected.size} {t.docs.filterAll === 'All' ? 'document(s)' : '份文档'} selected</span>
```

Actually, simpler — since the text is "X documents selected", we can just do:
```tsx
<span>{selected.size} {selected.size > 1 ? (t.docs.colName === 'Document name' ? 'documents' : '份文档') : (t.docs.colName === 'Document name' ? 'document' : '份文档')} selected</span>
```

Cleanest approach — add a helper:

Find the batch bar span and replace with:
```tsx
<span>
  {selected.size}{' '}
  {t.docs.colName === 'Document name'
    ? `document${selected.size > 1 ? 's' : ''} selected`
    : `份文档已选择`}
</span>
```

Find: `<Trash2 size={12} />Delete selected`
Replace: `<Trash2 size={12} />{t.docs.deleteSelected}`

Replace table header spans:
```tsx
<span>{t.docs.colName}</span>
<span>{t.docs.colStatus}</span>
<span>{t.docs.colUploaded}</span>
<span>{t.docs.colChunks}</span>
<span>{t.docs.colSize}</span>
<span>{t.docs.colType}</span>
<span>{t.docs.colActions}</span>
```

Find: `Loading documents…`
Replace: `{t.docs.loading}`

Find: `docs.length === 0 ? 'No documents yet. Upload a document to get started.' : 'No documents match the current filters.'`
Replace: `docs.length === 0 ? t.docs.emptyNoDocuments : t.docs.emptyNoMatch`

Replace `title` attributes on action buttons:
- `"Download original file"` → `{t.docs.titleDownload}`
- `"Retry processing"` → `{t.docs.titleRetry}`
- `"Delete document"` → `{t.docs.titleDelete}`

Replace confirm dialog messages:
```tsx
message={
  confirmDelete.ids.length === 1
    ? `Delete "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
    : `Delete ${confirmDelete.ids.length} documents? This will remove them and all their chunks from the vector store. This action cannot be undone.`
}
```
Replace with (keep the English detail text as it is system info):
```tsx
message={
  confirmDelete.ids.length === 1
    ? `${t.docs.deleteBtn} "${confirmDelete.names[0]}"? This will remove the document, all its chunks, and embeddings from the vector store. This action cannot be undone.`
    : `${t.docs.deleteBtn} ${confirmDelete.ids.length} ${t.docs.colName === 'Document name' ? 'documents' : '份文档'}? This will remove them and all their chunks from the vector store. This action cannot be undone.`
}
```

- [ ] **Step 5: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 8: Translate `CompliancePage.tsx`, `HistoryRail.tsx`, `FindingChatDrawer.tsx`

**Files:**
- Modify: `frontend/src/pages/Compliance/CompliancePage.tsx`
- Modify: `frontend/src/pages/Compliance/HistoryRail.tsx`
- Modify: `frontend/src/pages/Compliance/FindingChatDrawer.tsx`

- [ ] **Step 1: Add `useLanguage` to `CompliancePage.tsx`**

Add import:
```tsx
import { useLanguage } from '../../contexts/LanguageContext';
```

Add at top of `CompliancePage()`:
```tsx
const { t } = useLanguage();
```

Replace all hardcoded UI strings using `t.compliance.*`. Key replacements (search and replace each):

| Find | Replace |
|------|---------|
| `title="Compliance Analysis"` (Topbar) | `title={t.compliance.topbarTitle}` |
| `placeholder="Search analyses..."` | `placeholder={t.compliance.searchPlaceholder}` |
| `'Clear'` (button) | `{t.compliance.clearBtn}` |
| `'Export'` (button label) | `{t.compliance.exportBtn}` |
| `'Export JSON'` | `{t.compliance.exportJSON}` |
| `'Export Text'` | `{t.compliance.exportText}` |
| `'New analysis'` | `{t.compliance.newAnalysisBtn}` |
| `'Analyzing…'` | `{t.compliance.statusAnalyzing}` |
| `'Analysis complete'` | `{t.compliance.statusComplete}` |
| `'No analysis running'` | `{t.compliance.emptyTitle}` |
| `'Click New analysis to start...'` | `{t.compliance.emptyDesc}` |
| `'Retrieving relevant regulations…'` | `{t.compliance.retrievingMsg}` |
| `'Regulation'` (default name) | `{t.compliance.defaultRegulation}` |
| `'% match'` | `{t.compliance.matchSuffix}` |
| `'Paragraph Under Review'` | `{t.compliance.colParagraph}` |
| `'Extracting and analyzing text…'` | `{t.compliance.extractingMsg}` |
| `'No text extracted'` | `{t.compliance.noTextExtracted}` |
| `'Analysis stages'` | `{t.compliance.stagesHeader}` |
| `'Text extraction'` | `{t.compliance.stageExtraction}` |
| `'Clause splitting'` | `{t.compliance.stageClauseSplit}` |
| `'Regulation retrieval'` | `{t.compliance.stageRetrieval}` |
| `'Conclusion synthesis'` | `{t.compliance.stageSynthesis}` |
| `'Gap analysis in progress…'` | `{t.compliance.gapInProgress}` |
| `'Ask AI'` | `{t.compliance.askAIBtn}` |
| `'Chat'` (button) | `{t.compliance.chatBtn}` |
| `'Conclusion'` (header) | `{t.compliance.conclusionHeader}` |
| `title="Risk score (0=safe, 100=critical)"` | `title={t.compliance.riskScoreTooltip}` |
| `'Covered'` | `{t.compliance.statusCovered}` |
| `'Gap'` | `{t.compliance.statusGap}` |
| `'Critical'` | `{t.compliance.statusCritical}` |
| `'Info'` (status) | `{t.compliance.statusInfo}` |
| `'Pasted Text'` | `{t.compliance.sourceTypePasted}` |
| `'Indexed Document'` | `{t.compliance.sourceTypeIndexed}` |
| `'Uploaded File'` | `{t.compliance.sourceTypeUploaded}` |
| `'AI Compliance Q&A'` | `{t.compliance.chatSidebarHeader}` |
| `'Thinking▋'` | `{t.compliance.chatThinking}` |
| `'What regulation applies?'` | `{t.compliance.quickQ1}` |
| `'How to remediate?'` | `{t.compliance.quickQ2}` |
| `'What is the risk?'` | `{t.compliance.quickQ3}` |
| `placeholder="Ask about this finding…"` | `placeholder={t.compliance.chatPlaceholder}` |
| `'Send'` (button) | `{t.compliance.sendBtn}` |
| `'Analysis failed'` | `{t.compliance.analysisFailed}` |
| `'COMPLIANCE ANALYSIS REPORT'` (export) | `t.compliance.exportReportHeader` |
| `'── PARAGRAPH UNDER REVIEW ──'` | `t.compliance.exportSectionParagraph` |
| `'── FINDINGS ──'` | `t.compliance.exportSectionFindings` |
| `'── CONCLUSION ──'` | `t.compliance.exportSectionConclusion` |
| `'── RECOMMENDED ACTIONS ──'` | `t.compliance.exportSectionActions` |

Column headers with counts use string interpolation — use template literals:
- `'Retrieved Regulations {count}'` rendered as: `` `${t.compliance.stageRetrieval} (${count})` `` or `` `Retrieved Regulations (${count})` `` — keep the count logic, wrap the label.
- `'Findings {count}'` → same pattern.

- [ ] **Step 2: Translate `HistoryRail.tsx`**

Add import + hook:
```tsx
import { useLanguage } from '../../contexts/LanguageContext';
// Inside component:
const { t } = useLanguage();
```

Replace:
- `'History'` (header) → `{t.compliance.historyHeader}`
- `'Download report'` / `'Download'` → `{t.compliance.downloadReport}`
- `'No analyses yet.'` → `{t.compliance.historyEmpty}`
- `window.confirm(...)` message → `t.compliance.historyDeleteConfirm`

- [ ] **Step 3: Translate `FindingChatDrawer.tsx`**

Add import + hook:
```tsx
import { useLanguage } from '../../contexts/LanguageContext';
// Inside component:
const { t } = useLanguage();
```

Replace:
- `'Close'` button → `{t.compliance.drawerClose}`
- Empty chat state message → `{t.compliance.drawerChatEmpty}`
- `'Suggested questions'` header (if present) → `{t.compliance.drawerSuggestionsHeader}`
- `'Send'` button → `{t.compliance.sendBtn}`

- [ ] **Step 4: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 9: Translate `RagChatPage.tsx`

**Files:**
- Modify: `frontend/src/pages/RagChat/RagChatPage.tsx`

- [ ] **Step 1: Add `useLanguage` import and hook**

```tsx
import { useLanguage } from '../../contexts/LanguageContext';
// Inside RagChatPage():
const { t } = useLanguage();
```

- [ ] **Step 2: Replace `Topbar` and export button**

Find:
```tsx
<Topbar
  title="Regulation Q&A"
  actions={
    <button ...>
      <Download size={13} />Export chat
    </button>
  }
/>
```
Replace:
```tsx
<Topbar
  title={t.ragchat.topbarTitle}
  actions={
    <button ...>
      <Download size={13} />{t.ragchat.exportBtn}
    </button>
  }
/>
```

- [ ] **Step 3: Replace history pane header**

Find: `<div className="history-header">Quick prompts</div>`
Replace: `<div className="history-header">{t.ragchat.quickPromptsHeader}</div>`

- [ ] **Step 4: Replace textarea placeholder**

Find: `placeholder="Ask about your regulations…"`
Replace: `placeholder={t.ragchat.inputPlaceholder}`

- [ ] **Step 5: Replace citations header and empty state**

Find:
```tsx
<div className="citation-header">
  Sources {citations.length > 0 && `(${citations.length})`}
</div>
```
Replace:
```tsx
<div className="citation-header">
  {t.ragchat.citationsHeader}{citations.length > 0 && ` (${citations.length})`}
</div>
```

Find: `Citations will appear here after a response is generated.`
Replace: `{t.ragchat.citationsEmpty}`

- [ ] **Step 6: Replace error messages in `send()`**

Find: `'Could not reach the RAG API. Please check the backend.'`
Replace: `t.ragchat.apiError`

- [ ] **Step 7: Verify TypeScript**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck 2>&1 | head -20
```

Expected: 0 errors.

---

## Task 10: Final verification

- [ ] **Step 1: Run TypeScript check on entire frontend**

```bash
cd frontend && npx tsc --noEmit --skipLibCheck
```

Expected: 0 errors.

- [ ] **Step 2: Manual browser verification**

Open http://localhost:5173 and verify all pages:

1. **Sidebar** — `EN` button left of theme toggle; clicking cycles EN ↔ 中 instantly for nav labels and group headers.
2. **Overview** (`/`) — hero text, step labels, screen cards, stat bar all switch language.
3. **Regulatory Signals** (`/signals`) — topbar title, refresh button, stat bar, tab labels, card headers switch language. Crawl status messages use correct language.
4. **System Status** (`/status`) — topbar, stat labels, service rows ("Online"/"在线"), config labels, runtime info, footer message switch language.
5. **Documents** (`/documents`) — topbar, filter chips, table headers, action button tooltips, confirm dialog switch language.
6. **Compliance** (`/compliance`) — topbar, new analysis button, stage labels, finding status badges, export menu, chat sidebar, history rail switch language.
7. **Regulation Q&A** (`/chat`) — topbar, "Quick prompts" header, input placeholder, citations header, empty citations message switch language.
8. **Language persists within a session** — navigate between pages, language stays consistent.
9. **Language resets on reload** — refresh the browser, language returns to `EN`.
