Skip to content

Commit 96ea5ae

Browse files
alicodingclaude
andauthored
feat: copy management — react-i18next adopted, Settings migrated (goal 0032) (#19)
Owner-observed 40/72 .tsx files carrying inline hardcoded copy with no i18n library. Research settled on plain i18n over a headless CMS (every git-native CMS candidate needs a hosted OAuth intermediary or a local daemon, disqualified by SPEC §1.1): react-i18next v17.0.11 + i18next v26.3.6, namespace-per-bounded-context JSON under frontend/src/locales/en/ mirroring frontend/src's own folders, init in app/i18n.ts wired from main.tsx. Migrated SettingsView.tsx as the proof-of-pattern slice (views.json's settings namespace + common.json's shared verbs); existing e2e/settings.spec.ts assertions pass unchanged since translated text matches the original English exactly. Added app/i18n.test.ts (init loads, t() resolves keys, interpolation). The remaining ~39 files are tracked as four Standing tech-debt entries in BACKLOG.md (app/, composition/, configure/, views/ minus Settings), each independently DoR/DoD-shaped. eslint-plugin-i18next evaluated and deliberately deferred — its no-literal-string rule would fail the lint gate across every still-unmigrated file rather than guard new code alone. Goal 0032 left OPEN (not archived) per its own Plan step 3 — the migration is intentionally staged, not silently dropped. Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ad8d08f commit 96ea5ae

11 files changed

Lines changed: 252 additions & 50 deletions

File tree

frontend/package-lock.json

Lines changed: 66 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@
2323
"cronstrue": "^3.24.0",
2424
"elkjs": "^0.12.0",
2525
"genson-js": "^0.0.8",
26+
"i18next": "^26.3.6",
2627
"papaparse": "^5.5.4",
2728
"randexp": "^0.5.3",
2829
"react": "^18.2.0",
2930
"react-dom": "^18.2.0",
3031
"react-dropzone": "^20.1.0",
32+
"react-i18next": "^17.0.11",
3133
"react-is": "^19.2.8",
3234
"react-querybuilder": "^8.22.4",
3335
"recharts": "^3.10.1",

frontend/src/app/i18n.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
import i18n from './i18n'
3+
4+
// docs/goals/0032-copy-management.md's proof-of-pattern slice: confirms
5+
// the init actually wires resources up (a config typo or a missing
6+
// namespace in the `ns` list would otherwise only surface as a blank
7+
// string live, in whichever component happened to call t() first).
8+
describe('i18n init', () => {
9+
it('initializes synchronously with English as the active language', () => {
10+
expect(i18n.isInitialized).toBe(true)
11+
expect(i18n.language).toBe('en')
12+
})
13+
14+
it('resolves a known key from the views namespace (Settings slice)', () => {
15+
expect(i18n.t('settings.title', { ns: 'views' })).toBe('Settings')
16+
})
17+
18+
it('resolves a known key from the common namespace via the ns-prefixed form', () => {
19+
expect(i18n.t('common:actions.change')).toBe('Change')
20+
})
21+
22+
it('interpolates variables into a templated key', () => {
23+
expect(i18n.t('settings.updates.updateAvailable', { ns: 'views', version: '1.2.3' })).toBe('Update available: v1.2.3')
24+
})
25+
26+
it('falls back to the key itself for an unknown key, never throwing', () => {
27+
expect(() => i18n.t('settings.doesNotExist', { ns: 'views' })).not.toThrow()
28+
})
29+
})

frontend/src/app/i18n.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import i18n from 'i18next'
2+
import { initReactI18next } from 'react-i18next'
3+
import common from '../locales/en/common.json'
4+
import app from '../locales/en/app.json'
5+
import composition from '../locales/en/composition.json'
6+
import configure from '../locales/en/configure.json'
7+
import views from '../locales/en/views.json'
8+
9+
// docs/goals/0032-copy-management.md's locked research verdict:
10+
// react-i18next + i18next, namespace-per-bounded-context JSON --
11+
// mirroring frontend/src's own app/composition/configure/shared/views
12+
// folders -- chosen over every git-native headless-CMS candidate
13+
// (Decap/Sveltia/Keystatic/Tina), all of which need either a hosted
14+
// OAuth intermediary or a locally-running backend daemon, disqualified
15+
// by SPEC §1.1's no-hosted-service/no-second-daemon constraint. This
16+
// is copy-CENTRALIZATION (key -> string JSON, no authoring UI, no
17+
// CMS product), not localization -- there is no language switcher and
18+
// no plan for one yet; English is the only shipped locale. Resources
19+
// are imported statically and bundled at build time (Vite's own
20+
// resolveJsonModule support), never fetched at runtime, so this adds
21+
// zero network calls and zero server, matching Mill's own hard
22+
// constraints.
23+
//
24+
// Initialized once here and imported for its side effect from
25+
// app/main.tsx, before the first render -- react-i18next's
26+
// useTranslation() hook reads the already-initialized global i18next
27+
// instance from any bounded-context folder without importing this
28+
// module directly (app/ is the only folder allowed to import this,
29+
// per .claude/rules/frontend.md's dependency-cruiser boundaries; every
30+
// other folder just calls useTranslation() from the 'react-i18next'
31+
// package).
32+
void i18n.use(initReactI18next).init({
33+
resources: {
34+
en: { common, app, composition, configure, views },
35+
},
36+
lng: 'en',
37+
fallbackLng: 'en',
38+
defaultNS: 'common',
39+
ns: ['common', 'app', 'composition', 'configure', 'views'],
40+
interpolation: {
41+
// React already escapes interpolated values when rendering JSX --
42+
// i18next's own default (HTML-escaping) would double-escape them.
43+
escapeValue: false,
44+
},
45+
returnNull: false,
46+
})
47+
48+
export default i18n

frontend/src/app/main.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react'
22
import ReactDOM from 'react-dom/client'
3+
import './i18n'
34
import './index.css'
45
import '@primer/primitives/dist/css/primitives.css'
56
import '@primer/primitives/dist/css/functional/themes/light.css'

frontend/src/locales/en/app.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"actions": {
3+
"change": "Change",
4+
"clear": "Clear"
5+
}
6+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{}

frontend/src/locales/en/views.json

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"settings": {
3+
"title": "Settings",
4+
"subtitle": "App-level preferences -- not workflow or Configure-authored data (that lives in Composition/Configure), a UI preference persisted locally to this machine.",
5+
"loadError": "Couldn't load some settings -- the app may need a restart.",
6+
"sections": {
7+
"appearance": "Appearance",
8+
"general": "General",
9+
"keyboardShortcuts": "Keyboard Shortcuts",
10+
"globalHotkey": "Global hotkey",
11+
"mcpAccess": "MCP access",
12+
"notifications": "Notifications",
13+
"updates": "Updates"
14+
},
15+
"appearance": {
16+
"themeLabel": "Color theme",
17+
"lightLabel": "Light theme",
18+
"darkLabel": "Dark theme",
19+
"systemLabel": "Match system theme"
20+
},
21+
"general": {
22+
"launchAtLoginLabel": "Launch Mill at login",
23+
"launchAtLoginCaption": "Starts Mill automatically when you log in, same as Raycast/Alfred (docs/SPEC.md §3.7).",
24+
"errorDevBinary": "Not available in this dev build -- only a built .app bundle can be a login item.",
25+
"errorServerMode": "Not available in server mode -- there is no login-item concept without a desktop app to register."
26+
},
27+
"keyboardShortcuts": {
28+
"description": "Every in-window command Mill dispatches (docs/goals/0016-keymap-system.md) -- rebind by clicking a combo and pressing a new one, the same recorder used for a workflow's own trigger hotkey below."
29+
},
30+
"globalHotkey": {
31+
"description": "Opens Mill's Quick Panel from anywhere, like Raycast's ⌥Space or Alfred's own shortcut -- search and run a workflow, or jump into Mill itself, without leaving what you were doing. Press again to dismiss it. Distinct from a specific workflow's own trigger hotkey (set per-workflow on its canvas).",
32+
"recording": "Press a combo… (Esc to cancel)",
33+
"setShortcut": "Set shortcut",
34+
"reservedError": "{{combo}} is reserved by macOS ({{reason}}) — pick another combo",
35+
"openAccessibilitySettings": "Open Accessibility Settings"
36+
},
37+
"mcp": {
38+
"allowImportLabel": "Allow MCP clients to import data",
39+
"allowImportCaption": "Off by default (docs/adr/0017): when on, an external MCP client connected to Mill's local MCP server can create workflows, integrations, lists, and MCP-server entries via the import tools -- reading/exporting is always allowed and never includes secrets. Applies immediately, no restart.",
40+
"askBeforeImportLabel": "Ask me before each MCP import",
41+
"askBeforeImportCaption": "On by default (docs/adr/0022): each import parks until you approve it in Mill's window (or times out after 2 minutes, which denies it). Turning this off lets an enabled MCP client import without a per-write click -- enabling writes shouldn't silently mean unattended writes."
42+
},
43+
"notifications": {
44+
"description": "A parked guardrail ask or MCP write notifies you and shows a floating approval prompt (docs/adr/0032, docs/goals/0023) once you're away -- not merely unfocused, but idle past the threshold below, or genuinely unfocused. A present, actively-using-Mill window is never double-noised.",
45+
"awayAfterLabel": "Away after (seconds)",
46+
"awayAfterCaption": "How long the Mac must sit idle (no keyboard/mouse/trackpad input) while Mill is focused before you're treated as away -- 300s (5 minutes) by default, matching Teams' own away-status default. Losing focus entirely always counts as away regardless of this number.",
47+
"alertPermissionNote": "For the notification to alert instead of only appearing quietly in Notification Center, allow it in System Settings → Notifications → Mill → Alerts (docs/goals/0023 item 3) -- Mill requests notification permission on first launch, but macOS still defaults new apps to Banners, which auto-dismiss."
48+
},
49+
"updates": {
50+
"checkButton": "Check for updates",
51+
"checking": "Checking…",
52+
"updateAvailable": "Update available: v{{version}}",
53+
"upToDate": "You're on the latest version."
54+
}
55+
}
56+
}

0 commit comments

Comments
 (0)