Skip to content

Commit dd35bac

Browse files
kuworkMrLesk
authored andcommitted
BACK-478 - Web-UI-i18n-support
1 parent d7a71f1 commit dd35bac

44 files changed

Lines changed: 3521 additions & 695 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
---
2+
id: BACK-478
3+
title: Web UI i18n support
4+
status: Done
5+
assignee:
6+
- Kimi
7+
created_date: '2026-05-16 15:20'
8+
updated_date: '2026-05-16 18:37'
9+
labels:
10+
- feature
11+
- web-ui
12+
- i18n
13+
- browser
14+
dependencies: []
15+
priority: medium
16+
ordinal: 7600
17+
---
18+
19+
## Description
20+
21+
<!-- SECTION:DESCRIPTION:BEGIN -->
22+
Add internationalization (i18n) support to the Web UI.
23+
24+
Currently, all user-facing text in the Web UI is hardcoded in English across ~20+ React components (Board, TaskList, Settings, Milestones, Navigation, Modal, etc.). This task extracts all hardcoded strings into a type-safe translation dictionary and allows users to switch languages via Settings.
25+
26+
**Scope:** Web UI only. CLI and TUI are out of scope.
27+
**Languages:** English (`en`), Japanese (`ja`), Chinese Simplified (`zh-CN`), Chinese Traditional (`zh-TW`). Default to `en`.
28+
**Approach:** Zero-dependency lightweight i18n (custom React Context + hook), avoiding heavy libraries like i18next to keep the compiled binary small.
29+
<!-- SECTION:DESCRIPTION:END -->
30+
31+
## Implementation Plan
32+
33+
<!-- SECTION:PLAN:BEGIN -->
34+
### 1. Infrastructure
35+
36+
Create a lightweight i18n layer under `src/web/`:
37+
38+
```
39+
src/web/
40+
├── locales/
41+
│ ├── index.ts # Locale union type, loader, and fallback logic
42+
│ ├── en.ts # English dictionary (default)
43+
│ ├── ja.ts # Japanese dictionary
44+
│ ├── zh-CN.ts # Chinese Simplified dictionary
45+
│ └── zh-TW.ts # Chinese Traditional dictionary
46+
├── contexts/
47+
│ └── I18nContext.tsx # Provides locale state and t() function
48+
└── hooks/
49+
└── useI18n.ts # Hook for consuming translations
50+
```
51+
52+
Requirements:
53+
- Full TypeScript type safety: accessing a missing key should be a compile-time error.
54+
- Support simple string interpolation (e.g., `t.tasks.assignedTo(name)`).
55+
- Compile-time embedding: import translation files as modules so they are inlined into the single-file binary by `bun build --compile`.
56+
57+
### 2. Config Integration
58+
59+
- Add `locale?: string` to `BacklogConfig` in `src/types/index.ts`.
60+
- Expose the field through the existing `/config` API (no backend changes needed if the field is passed through JSON).
61+
- In `Settings.tsx`, add a language selector dropdown (`English` / `日本語` / `简体中文` / `繁體中文`).
62+
- Default behavior: if `locale` is unset, fall back to `en` (or optionally detect `navigator.language`).
63+
64+
### 3. Text Extraction & Replacement
65+
66+
Systematically replace hardcoded English strings in all Web UI components. Key components to cover:
67+
68+
- `Board.tsx` / `BoardPage.tsx` — "Kanban Board", "Loading tasks...", filter labels, swimlane titles
69+
- `TaskList.tsx` — column headers, empty states, search placeholder
70+
- `TaskCard.tsx` / `TaskColumn.tsx` — status labels, priority labels, button tooltips
71+
- `TaskDetailsModal.tsx` — form labels, section headers, action buttons
72+
- `Settings.tsx` — all setting labels, validation messages, success toasts
73+
- `MilestonesPage.tsx` — milestone labels, pool titles, archive section
74+
- `Navigation.tsx` / `SideNavigation.tsx` — nav item labels
75+
- `DocumentationDetail.tsx` / `DecisionDetail.tsx` — editor labels, metadata labels
76+
- `DraftsList.tsx` — page title, empty state
77+
- `Statistics.tsx` — chart labels, metric names
78+
- `CleanupModal.tsx` / `FilePreviewModal.tsx` / `InitializationScreen.tsx` — titles, descriptions, buttons
79+
- `ErrorBoundary.tsx` — error messages
80+
- `SuccessToast.tsx` — toast messages
81+
82+
**Estimated translation keys:** ~300 (4 languages).
83+
84+
### 4. Testing & Validation
85+
86+
- Verify `bunx tsc --noEmit` passes (type-safe keys prevent typos).
87+
- Run `bun run check .` for formatting/linting.
88+
- Manual QA: switch language in Settings and verify all visible text updates without page reload.
89+
- Ensure fallback works when `locale` is missing or invalid.
90+
91+
### 5. Build Verification
92+
93+
- Confirm `bun run build` still produces a working single-file binary.
94+
- Confirm translation dictionaries are embedded (no runtime filesystem reads).
95+
<!-- SECTION:PLAN:END -->
96+
97+
## Implementation Notes
98+
99+
<!-- SECTION:NOTES:BEGIN -->
100+
### Terminology Decisions
101+
- **Definition of Done****完成检查项** (Chinese): renamed from "完成定义" to be more intuitive for non-Agile users.
102+
- **poweredBy** → kept as "Powered by" in Chinese (Traditional) to match common UI conventions.
103+
104+
### Challenges
105+
- **C disk space (ENOSPC)**: `bun build --compile` requires large temp space. Resolved by clearing `%TEMP%` and old `dist/` builds before compiling.
106+
- **Locale persistence**: `locale` field is stored in `BacklogConfig` and serialized to `backlog.config.yml` via `parseConfig`/`serializeConfig`.
107+
- **Settings page i18n lag**: initially only the language selector was i18n'd; all remaining hardcoded labels (project name, port, editor, DoD, etc.) were converted in a follow-up pass.
108+
- **Wiki detail page parity**: `Cancel`/`Edit` buttons and placeholder text were aligned to use the same `t.common.*` keys as the document detail page.
109+
110+
### Components Fully Internationalized
111+
Board, TaskList, TaskDetailsModal, TaskCard, TaskColumn, SideNavigation, Navigation, Settings, Statistics, CleanupModal, InitializationScreen, MilestonesPage, DraftsList, WikiDetail, DocumentationDetail, DecisionDetail, Modal, Toast, ErrorBoundary, HealthIndicator, LabelFilter, AcceptanceCriteria, DependencyInput, FilePreview, PasteAwareMDEditor, MermaidMarkdown.
112+
<!-- DOD:END -->
113+
<!-- SECTION:NOTES:END -->
114+
115+
## Definition of Done
116+
117+
<!-- DOD:BEGIN -->
118+
- [x] #1 Lightweight i18n infrastructure exists (`locales/`, `I18nContext`, `useI18n`) with full TS type safety
119+
- [x] #2 `BacklogConfig` includes optional `locale` field; Settings page has language selector
120+
- [x] #3 All user-facing text in `src/web/components/` is extracted into translation dictionaries
121+
- [x] #4 Default language is English; Japanese, Simplified Chinese and Traditional Chinese are selectable and fully translated
122+
- [x] #5 `bunx tsc --noEmit` passes
123+
- [x] #6 `bun run check .` passes
124+
- [x] #7 `bun run build` produces a working binary with embedded translations

src/core/backlog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ import { getTaskFilename, getTaskPath, normalizeTaskId, taskIdsEqual } from "../
5353
import { attachSubtaskSummaries } from "../utils/task-subtasks.ts";
5454
import { upsertTaskUpdatedDate } from "../utils/task-updated-date.ts";
5555
import { isTerminalStatus } from "../utils/terminal-status.ts";
56+
import { AssetManager } from "./assets.ts";
5657
import { migrateConfig, needsMigration } from "./config-migration.ts";
5758
import { ContentStore } from "./content-store.ts";
5859
import { migrateDraftPrefixes, needsDraftPrefixMigration } from "./prefix-migration.ts";
5960
import { calculateNewOrdinal, DEFAULT_ORDINAL_STEP, resolveOrdinalConflicts } from "./reorder.ts";
6061
import { SearchService } from "./search-service.ts";
61-
import { AssetManager } from "./assets.ts";
6262
import { computeSequences, planMoveToSequence, planMoveToUnsequenced } from "./sequences.ts";
6363
import {
6464
type BranchTaskStateEntry,

src/file-system/operations.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,6 +1430,9 @@ ${description || `Milestone: ${title}`}`,
14301430
case "backlogDirectory":
14311431
config.backlogDirectory = value.replace(/['"]/g, "");
14321432
break;
1433+
case "locale":
1434+
config.locale = value.replace(/['"]/g, "");
1435+
break;
14331436
}
14341437
}
14351438

@@ -1456,6 +1459,7 @@ ${description || `Milestone: ${title}`}`,
14561459
onStatusChange: config.onStatusChange,
14571460
prefixes: config.prefixes,
14581461
backlogDirectory: config.backlogDirectory,
1462+
locale: config.locale,
14591463
};
14601464
}
14611465

@@ -1488,6 +1492,7 @@ ${description || `Milestone: ${title}`}`,
14881492
...(config.onStatusChange ? [`onStatusChange: '${config.onStatusChange}'`] : []),
14891493
...(config.prefixes?.task ? [`task_prefix: "${config.prefixes.task}"`] : []),
14901494
...(config.backlogDirectory ? [`backlog_directory: "${config.backlogDirectory}"`] : []),
1495+
...(config.locale ? [`locale: "${config.locale}"`] : []),
14911496
];
14921497

14931498
return `${lines.join("\n")}\n`;

src/test/assets.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it, beforeEach, afterEach } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
22
import { mkdir, utimes } from "node:fs/promises";
33
import { join } from "node:path";
44
import { AssetManager } from "../core/assets.ts";
@@ -8,7 +8,6 @@ let TEST_DIR: string;
88
let assetsRoot: string;
99
let manager: AssetManager;
1010

11-
1211
describe("AssetManager", () => {
1312
beforeEach(async () => {
1413
TEST_DIR = createUniqueTestDir("assets");

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export interface BacklogConfig {
309309
defaultEditor?: string;
310310
autoOpenBrowser?: boolean;
311311
defaultPort?: number;
312+
locale?: string;
312313
remoteOperations?: boolean;
313314
autoCommit?: boolean;
314315
/** Disable all Git integration for filesystem-only projects. */

src/web/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
} from '../types';
2929
import { apiClient } from './lib/api';
3030
import { useHealthCheckContext } from './contexts/HealthCheckContext';
31+
import { useI18nContext } from './contexts/I18nContext';
32+
import { isValidLocale } from './locales';
3133
import { getWebVersion } from './utils/version';
3234
import { collectArchivedMilestoneKeys, collectMilestoneIds, milestoneKey } from './utils/milestones';
3335

@@ -187,6 +189,7 @@ function App() {
187189
const [isLoading, setIsLoading] = useState(true);
188190

189191
const { isOnline } = useHealthCheckContext();
192+
const { setLocale } = useI18nContext();
190193
const previousOnlineRef = useRef<boolean | null>(null);
191194
const hasBeenRunningRef = useRef(false);
192195

@@ -283,6 +286,9 @@ function App() {
283286
setProjectName(configData.projectName);
284287
setAvailableLabels(configData.labels || []);
285288
setConfig(configData);
289+
if (configData.locale && isValidLocale(configData.locale)) {
290+
setLocale(configData.locale);
291+
}
286292
setMilestoneEntities(milestonesData);
287293
setArchivedMilestones(archivedMilestonesData);
288294
setMilestones(

src/web/components/AcceptanceCriteriaEditor.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useEffect, useRef, useState } from "react";
22
import { type AcceptanceCriterion } from "../../types";
3+
import { useI18n } from '../hooks/useI18n';
34

45
interface Props {
56
criteria: AcceptanceCriterion[];
@@ -12,12 +13,13 @@ interface Props {
1213
const AcceptanceCriteriaEditor: React.FC<Props> = ({
1314
criteria: initial,
1415
onChange,
15-
label = "Acceptance Criteria",
16+
label,
1617
preserveIndices = false,
1718
disableToggle = false,
1819
}) => {
1920
const [criteria, setCriteria] = useState<AcceptanceCriterion[]>(initial || []);
2021
const [newCriterion, setNewCriterion] = useState("");
22+
const { t } = useI18n();
2123

2224
// Refs to auto-resize textareas
2325
const itemRefs = useRef<Record<number, HTMLTextAreaElement | null>>({});
@@ -79,7 +81,7 @@ const AcceptanceCriteriaEditor: React.FC<Props> = ({
7981
return (
8082
<div className="space-y-2">
8183
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 transition-colors duration-200">
82-
{label}
84+
{label ?? t.acceptanceCriteria.label}
8385
</label>
8486
<ul className="space-y-2">
8587
{criteria.map((c) => (
@@ -104,7 +106,7 @@ const AcceptanceCriteriaEditor: React.FC<Props> = ({
104106
onClick={() => handleRemove(c.index)}
105107
className="px-2 py-1 text-sm text-red-600 dark:text-red-400 hover:underline"
106108
>
107-
Remove
109+
{t.common.remove}
108110
</button>
109111
</li>
110112
))}
@@ -115,15 +117,15 @@ const AcceptanceCriteriaEditor: React.FC<Props> = ({
115117
value={newCriterion}
116118
onChange={(e) => setNewCriterion(e.target.value)}
117119
onInput={(e) => autoResize(e.currentTarget)}
118-
placeholder="New criterion"
120+
placeholder={t.acceptanceCriteria.placeholder}
119121
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-stone-500 dark:focus:ring-stone-400 focus:border-transparent transition-colors duration-200 resize-none overflow-hidden leading-5"
120122
/>
121123
<button
122124
type="button"
123125
onClick={handleAdd}
124126
className="px-2 py-1 text-sm bg-blue-500 dark:bg-blue-600 text-white rounded-md hover:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-blue-400 dark:focus:ring-blue-500 transition-colors duration-200"
125127
>
126-
Add
128+
{t.common.add}
127129
</button>
128130
</li>
129131
</ul>

0 commit comments

Comments
 (0)