From 1ca797cbeec907a81b62b2032da028f5a820d0d4 Mon Sep 17 00:00:00 2001 From: Ali Al Dallal Date: Wed, 12 Aug 2026 08:43:54 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20copy=20management=20=E2=80=94=20react-i?= =?UTF-8?q?18next=20adopted,=20Settings=20migrated=20(goal=200032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018pkViCNAuZp2vBv2K9AbUh --- docs/SPEC.md | 31 ++++++++ docs/goals/0032-copy-management.md | 86 ++++++++++++++++++++++ docs/goals/BACKLOG.md | 14 ++++ frontend/package-lock.json | 66 +++++++++++++++++ frontend/package.json | 2 + frontend/src/app/i18n.test.ts | 29 ++++++++ frontend/src/app/i18n.ts | 48 +++++++++++++ frontend/src/app/main.tsx | 1 + frontend/src/locales/en/app.json | 1 + frontend/src/locales/en/common.json | 6 ++ frontend/src/locales/en/composition.json | 1 + frontend/src/locales/en/configure.json | 1 + frontend/src/locales/en/views.json | 56 +++++++++++++++ frontend/src/views/SettingsView.tsx | 91 +++++++++++------------- 14 files changed, 383 insertions(+), 50 deletions(-) create mode 100644 docs/goals/0032-copy-management.md create mode 100644 frontend/src/app/i18n.test.ts create mode 100644 frontend/src/app/i18n.ts create mode 100644 frontend/src/locales/en/app.json create mode 100644 frontend/src/locales/en/common.json create mode 100644 frontend/src/locales/en/composition.json create mode 100644 frontend/src/locales/en/configure.json create mode 100644 frontend/src/locales/en/views.json diff --git a/docs/SPEC.md b/docs/SPEC.md index 36e98d8e..a672b312 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -286,6 +286,37 @@ and [`docs/adr/0002-cicd-pipeline-phased-rollout.md`](adr/0002-cicd-pipeline-pha `*.module.css` files (`RunbookView.module.css` shared by `RunbookView` and `ActivityView`, which already reused the same card/list visual language). `frontend/public/style.css` deleted. `LOCKED` +- **Copy management: `react-i18next` v17.0.11 + `i18next` v26.3.6 + adopted, namespace-per-bounded-context JSON — `LOCKED` (partial + migration, goal 0032).** Owner-observed: 40 of 72 `.tsx` files carry + inline hardcoded copy, no i18n library present. Research first + framed this as a headless-CMS question (Decap/Sveltia/Keystatic/ + Tina) — rejected on architecture fit, not quality: every git-native + CMS candidate needs either a hosted OAuth intermediary or a locally- + running backend daemon, disqualified outright by §1.1's no-hosted- + service/no-second-daemon constraint. The owner reframed it mid- + research: this is plain i18n (key → string JSON, no authoring UI, no + CMS product), not localization or centralized authoring — so the + real adopt-vs-build call is a plain i18n library, and `react-i18next` + is MIT, zero-server, and the standard choice for React. Resources + (`frontend/src/locales/en/{common,app,composition,configure, + views}.json`, mirroring `frontend/src`'s own bounded-context folders) + are imported statically and bundled at build time — no runtime + fetch, no network call, matching every hard constraint in §1.1. + Initialized once in `frontend/src/app/i18n.ts`, imported for its + side effect from `main.tsx`; every other folder just calls + `useTranslation()` from the `react-i18next` package directly, never + importing the init module (dependency-cruiser's existing bounded- + context boundaries, ADR-0012, apply unchanged). Migrated as proof: + `SettingsView.tsx` (`views.json`'s `settings` namespace + + `common.json`'s shared action verbs). The remaining ~39 files are + tracked debt, not silently orphaned — see `docs/goals/BACKLOG.md`'s + copy-management entries. `eslint-plugin-i18next` (a lint guard + against new hardcoded strings) was evaluated and deliberately not + added yet: its `no-literal-string` rule would immediately flag every + still-unmigrated file, failing the lint gate repo-wide rather than + just guarding new code — revisit once migration is far enough along + that the false-positive surface is small. - CI: GitHub Actions, all four ADR-0002 phases shipped in `.github/workflows/ci.yml` + `.github/workflows/release.yml`. `golangci-lint` v2, ESLint flat config, Vitest, `go test -race -cover`, diff --git a/docs/goals/0032-copy-management.md b/docs/goals/0032-copy-management.md new file mode 100644 index 00000000..dd347e96 --- /dev/null +++ b/docs/goals/0032-copy-management.md @@ -0,0 +1,86 @@ +# 0032 — Copy management: is Mill's UI text hardcoded, and what fixes it + +## Goal +Owner-observed 2026-08-12: every string in Mill's UI is likely +hand-typed inline JSX. Confirmed by audit: 40 of 72 `.tsx` files carry +inline copy, zero i18n library in `frontend/package.json`, no +locale/copy directory anywhere. Owner's explicit constraint: adopt an +existing lightweight, **no-server** solution — "so we don't rebuild +that locally," per `.claude/rules/architecture.md`'s adopt-over- +hand-roll default and SPEC §1.1's no-hosted-service-dependency hard +constraint. + +**Reframed by the owner mid-research (2026-08-12): this is plain i18n, +not a CMS.** "Authoring a json for [copy] locally is fine just as long +as we don't hardcode text and key to value etc which is i18n" — +confirms the research instinct that "headless CMS" was the wrong frame +for a desktop app's inline copy. The actual want: key→value JSON files ++ an i18n library, no CMS product, no authoring UI layer. Locks the +research's localization-vs-centralization question in favor of +straight i18n tooling (react-i18next/react-intl-class options) — the +CMS candidates (Decap/Sveltia/Keystatic/Tina) are answering a website +question Mill doesn't have. + +## Plan +1. [x] Research DONE 2026-08-12 (all sources primary, GitHub/npm/docs + verified live). Verdict: "headless CMS" is the wrong frame — every + git-native CMS candidate (Decap, Sveltia, Keystatic, Tina) requires + either a hosted OAuth intermediary or a locally-running backend + daemon, disqualified by SPEC §1.1's no-hosted-service/no-second- + daemon constraint regardless of maintenance health (all four are + healthy — rejected on architecture fit, not quality). This is + copy-centralization, not localization, solved by a plain i18n + library. **Adopt: react-i18next v17.0.11 + i18next v26.3.6 (both + MIT, actively maintained, zero server)**, namespace-per-view JSON + (i18next's own documented convention) mirroring Mill's existing + frontend/src/{app,composition,configure,shared,views} structure. + Rejected explicitly, not to be re-proposed: Decap/Sveltia CMS (OAuth + intermediary or decap-server daemon required), Keystatic + (Next.js/Astro/Remix only, no plain-Vite path), TinaCMS (self-hosted + path needs its own GraphQL backend + DB adapter — "Local Mode" + doesn't remove this). +2. [x] Capability map DONE 2026-08-12: today's need is English-only + copy centralization (no language switcher, no plan for one yet); + the realistic future is (a) actual localization if Mill ever ships + non-English UI and (b) faster live-copy iteration without touching + JSX. Schema decided once: namespace-per-bounded-context JSON + (`frontend/src/locales/en/{common,app,composition,configure, + views}.json`, mirroring `frontend/src`'s own folders), nested + per-view keys inside each namespace (e.g. `views.json`'s + `settings.*`) rather than one file per component — i18next's own + documented convention, and it keeps the file count bounded (5 + files, not 72) while still scoping per bounded context if ever + needed later. +3. [x] Migration scope estimate DONE 2026-08-12: staged, not one PR — + this pass ships the library + init + ONE real slice (Settings) as + proof; the remaining ~39 files are tracked as four Standing + tech-debt entries in `docs/goals/BACKLOG.md` (`app/`, `composition/`, + `configure/`, `views/` minus Settings), each with its own DoR/DoD + per `.claude/rules/delivery-discipline.md` — never a bare TODO. +4. [x] Build DONE 2026-08-12 (partial, by design): `react-i18next` + v17.0.11 + `i18next` v26.3.6 installed; `frontend/src/app/i18n.ts` + (init, wired into `main.tsx`); `SettingsView.tsx` fully migrated + (`views.json`'s `settings` namespace + `common.json`'s shared action + verbs) as the proof-of-pattern slice, verified against the existing + `e2e/settings.spec.ts` (unchanged assertions still pass — translated + text matches the original English exactly) plus a new + `app/i18n.test.ts` unit test (init loads, `t()` resolves a known key, + interpolation works). `eslint-plugin-i18next` evaluated as a guard + against new hardcoded strings — deliberately NOT added yet: its + `no-literal-string` rule would fail the lint gate across the ~39 + still-unmigrated files rather than just guarding new code; revisit + once the Standing tech-debt entries close the gap. + +## Acceptance +A capability map and adopt-vs-build decision recorded in SPEC (done — +`docs/SPEC.md`'s copy-management bullet, §1.3); the chosen pattern +lands with a working example, not just a library install (done — +Settings, proven live + by test); remaining hardcoded copy is tracked, +not silently orphaned (done — four Standing tech-debt entries in +`docs/goals/BACKLOG.md`, each independently DoR/DoD-shaped). + +**Status: partially delivered 2026-08-12, intentionally left OPEN, not +archived** — per this goal's own Plan step 3, the ~39-file migration is +staged, ongoing debt, not a silently-dropped scope. Continue by picking +up the next Standing tech-debt entry (any of `app/`, `composition/`, +`configure/`, `views/`), not by re-researching this goal from scratch. diff --git a/docs/goals/BACKLOG.md b/docs/goals/BACKLOG.md index 12a751fe..7788bad2 100644 --- a/docs/goals/BACKLOG.md +++ b/docs/goals/BACKLOG.md @@ -108,6 +108,16 @@ this pipeline and on this code)** 2026-08-12: the guardrailed AI-node family (n8n/Make/Zapier/ Dify taxonomy convergence), Mill's category-defining capability; research not started. +10. [ ] [0032 — Copy management](0032-copy-management.md) — owner-observed + 2026-08-12: 40 of 72 `.tsx` files carried inline hardcoded copy, no + i18n library. Research + adopt decision DONE and library landed + 2026-08-12: `react-i18next` + `i18next`, namespace-per-bounded- + context JSON (`frontend/src/locales/en/`), init wired in + `app/i18n.ts`; `SettingsView.tsx` migrated as the proof-of-pattern + slice. Deliberately left OPEN, not archived: the remaining ~39 + files are real, staged debt — see the four Standing tech-debt + entries above (`app/`, `composition/`, `configure/`, `views/` + minus Settings) for the rest of the migration. **Ratified 2026-08-10 (owner): three groups, A→B→C. 0001 stays standing live-review material, interleaved during owner reviews, not a lane.** @@ -138,6 +148,10 @@ live-review material, interleaved during owner reviews, not a lane.** - [ ] [0021 — MCP dogfood gap closure](0021-mcp-dogfood-gap-closure.md) (owner-mandated 2026-08-11: orchestrator live-probes the MCP surface against the bank use cases, logs ranked gaps, fixes graduate out; phase 1 done — 4 gaps + 1 confirmed-by-design) - [ ] Workflow pins/favorites (tech debt, split from goal 0015's remainder 2026-08-12) — no pin/favorite concept exists anywhere in Mill today (grepped before scoping it out); needs its own small schema decision (which store owns a pin list, per-workflow or a plain ID set) before any build — deliberately not invented ad hoc under 0015's frecency-only ship. Quick Panel's workflow list sorts by frequency alone until this lands. - [ ] ⌘?/⌘/ multi-binding keybinding alias (tech debt, split from goal 0015's remainder 2026-08-12) — the owner's goal-0015 "bind ⌘? (and/or ⌘/) to open the palette too" ask needs a command to carry more than one `KeyCombo`; today's registry (`shared/commands.ts`) is 1:1 (`defaultBinding: KeyCombo | null`). Needs a real schema call (array vs. a small alias table) before it's buildable — real data-model infrastructure, not a self-contained UI change. +- [ ] Copy-management migration — `app/` (tech debt, split from goal 0032 2026-08-12) — extract `app/`'s remaining hardcoded JSX copy (App.tsx's shell chrome, QuickPanel/QuickPanelApp, ApprovalPromptApp, workflowFrecency-adjacent UI, etc. — ~11 files carry inline strings) into `frontend/src/locales/en/app.json` (already scaffolded, currently `{}`) following `SettingsView.tsx`'s established pattern (`useTranslation()`/`t()`, namespace-per-bounded-context). DoR: read `docs/goals/0032-copy-management.md` for the locked i18n pattern before starting — no new library/schema decision needed, this is mechanical extraction. DoD: every `app/*.tsx` file free of inline user-facing string literals in JSX (aria-labels included), `app.json` populated, existing e2e specs touching `app/` still pass unchanged (translated text must match original English exactly). +- [ ] Copy-management migration — `composition/` (tech debt, split from goal 0032 2026-08-12) — same extraction as above, scoped to `composition/`'s ~22 files with inline copy (canvas node cards, palette, validation messages, trigger/schedule UI) into `frontend/src/locales/en/composition.json`. Largest of the four remaining slices — consider whether it splits further once started (per-node-type vs. whole-folder) rather than treating it as one atomic PR. Same DoR/DoD shape as the `app/` entry above. +- [ ] Copy-management migration — `configure/` (tech debt, split from goal 0032 2026-08-12) — same extraction, scoped to `configure/`'s ~14 files (connector/list/MCP-server forms, OpenAPI synth UI) into `frontend/src/locales/en/configure.json`. Same DoR/DoD shape as the `app/` entry above. +- [ ] Copy-management migration — `views/` minus Settings (tech debt, split from goal 0032 2026-08-12) — same extraction, scoped to the remaining `views/*.tsx` files (Settings already migrated into `views.json`'s `settings` namespace) — add sibling namespaces (e.g. `views.json`'s `home`, `activity`, etc. keys) per view. Same DoR/DoD shape as the `app/` entry above. Once all four of these land, revisit `eslint-plugin-i18next`'s `no-literal-string` rule (evaluated and deliberately deferred in goal 0032 — see `docs/SPEC.md`'s copy-management bullet) as a guard against regression. **Delivered** - [x] [0003 — MCP authoring live dogfood](archive/0003-mcp-authoring-dogfood.md) — 2026-08-10 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8bf4ddab..ac99e8db 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,11 +18,13 @@ "cronstrue": "^3.24.0", "elkjs": "^0.12.0", "genson-js": "^0.0.8", + "i18next": "^26.3.6", "papaparse": "^5.5.4", "randexp": "^0.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^20.1.0", + "react-i18next": "^17.0.11", "react-is": "^19.2.8", "react-querybuilder": "^8.22.4", "recharts": "^3.10.1", @@ -3968,6 +3970,15 @@ "integrity": "sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3989,6 +4000,34 @@ "url": "https://opencollective.com/express" } }, + "node_modules/i18next": { + "version": "26.3.6", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz", + "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5220,6 +5259,33 @@ } } }, + "node_modules/react-i18next": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", + "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-intersection-observer": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index e605d4f1..7c2a11e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,11 +23,13 @@ "cronstrue": "^3.24.0", "elkjs": "^0.12.0", "genson-js": "^0.0.8", + "i18next": "^26.3.6", "papaparse": "^5.5.4", "randexp": "^0.5.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^20.1.0", + "react-i18next": "^17.0.11", "react-is": "^19.2.8", "react-querybuilder": "^8.22.4", "recharts": "^3.10.1", diff --git a/frontend/src/app/i18n.test.ts b/frontend/src/app/i18n.test.ts new file mode 100644 index 00000000..da2e4c97 --- /dev/null +++ b/frontend/src/app/i18n.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import i18n from './i18n' + +// docs/goals/0032-copy-management.md's proof-of-pattern slice: confirms +// the init actually wires resources up (a config typo or a missing +// namespace in the `ns` list would otherwise only surface as a blank +// string live, in whichever component happened to call t() first). +describe('i18n init', () => { + it('initializes synchronously with English as the active language', () => { + expect(i18n.isInitialized).toBe(true) + expect(i18n.language).toBe('en') + }) + + it('resolves a known key from the views namespace (Settings slice)', () => { + expect(i18n.t('settings.title', { ns: 'views' })).toBe('Settings') + }) + + it('resolves a known key from the common namespace via the ns-prefixed form', () => { + expect(i18n.t('common:actions.change')).toBe('Change') + }) + + it('interpolates variables into a templated key', () => { + expect(i18n.t('settings.updates.updateAvailable', { ns: 'views', version: '1.2.3' })).toBe('Update available: v1.2.3') + }) + + it('falls back to the key itself for an unknown key, never throwing', () => { + expect(() => i18n.t('settings.doesNotExist', { ns: 'views' })).not.toThrow() + }) +}) diff --git a/frontend/src/app/i18n.ts b/frontend/src/app/i18n.ts new file mode 100644 index 00000000..8343044a --- /dev/null +++ b/frontend/src/app/i18n.ts @@ -0,0 +1,48 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' +import common from '../locales/en/common.json' +import app from '../locales/en/app.json' +import composition from '../locales/en/composition.json' +import configure from '../locales/en/configure.json' +import views from '../locales/en/views.json' + +// docs/goals/0032-copy-management.md's locked research verdict: +// react-i18next + i18next, namespace-per-bounded-context JSON -- +// mirroring frontend/src's own app/composition/configure/shared/views +// folders -- chosen over every git-native headless-CMS candidate +// (Decap/Sveltia/Keystatic/Tina), all of which need either a hosted +// OAuth intermediary or a locally-running backend daemon, disqualified +// by SPEC §1.1's no-hosted-service/no-second-daemon constraint. This +// is copy-CENTRALIZATION (key -> string JSON, no authoring UI, no +// CMS product), not localization -- there is no language switcher and +// no plan for one yet; English is the only shipped locale. Resources +// are imported statically and bundled at build time (Vite's own +// resolveJsonModule support), never fetched at runtime, so this adds +// zero network calls and zero server, matching Mill's own hard +// constraints. +// +// Initialized once here and imported for its side effect from +// app/main.tsx, before the first render -- react-i18next's +// useTranslation() hook reads the already-initialized global i18next +// instance from any bounded-context folder without importing this +// module directly (app/ is the only folder allowed to import this, +// per .claude/rules/frontend.md's dependency-cruiser boundaries; every +// other folder just calls useTranslation() from the 'react-i18next' +// package). +void i18n.use(initReactI18next).init({ + resources: { + en: { common, app, composition, configure, views }, + }, + lng: 'en', + fallbackLng: 'en', + defaultNS: 'common', + ns: ['common', 'app', 'composition', 'configure', 'views'], + interpolation: { + // React already escapes interpolated values when rendering JSX -- + // i18next's own default (HTML-escaping) would double-escape them. + escapeValue: false, + }, + returnNull: false, +}) + +export default i18n diff --git a/frontend/src/app/main.tsx b/frontend/src/app/main.tsx index 47ff3019..ac872de1 100644 --- a/frontend/src/app/main.tsx +++ b/frontend/src/app/main.tsx @@ -1,5 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import './i18n' import './index.css' import '@primer/primitives/dist/css/primitives.css' import '@primer/primitives/dist/css/functional/themes/light.css' diff --git a/frontend/src/locales/en/app.json b/frontend/src/locales/en/app.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/frontend/src/locales/en/app.json @@ -0,0 +1 @@ +{} diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json new file mode 100644 index 00000000..1720e3f5 --- /dev/null +++ b/frontend/src/locales/en/common.json @@ -0,0 +1,6 @@ +{ + "actions": { + "change": "Change", + "clear": "Clear" + } +} diff --git a/frontend/src/locales/en/composition.json b/frontend/src/locales/en/composition.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/frontend/src/locales/en/composition.json @@ -0,0 +1 @@ +{} diff --git a/frontend/src/locales/en/configure.json b/frontend/src/locales/en/configure.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/frontend/src/locales/en/configure.json @@ -0,0 +1 @@ +{} diff --git a/frontend/src/locales/en/views.json b/frontend/src/locales/en/views.json new file mode 100644 index 00000000..d3dda190 --- /dev/null +++ b/frontend/src/locales/en/views.json @@ -0,0 +1,56 @@ +{ + "settings": { + "title": "Settings", + "subtitle": "App-level preferences -- not workflow or Configure-authored data (that lives in Composition/Configure), a UI preference persisted locally to this machine.", + "loadError": "Couldn't load some settings -- the app may need a restart.", + "sections": { + "appearance": "Appearance", + "general": "General", + "keyboardShortcuts": "Keyboard Shortcuts", + "globalHotkey": "Global hotkey", + "mcpAccess": "MCP access", + "notifications": "Notifications", + "updates": "Updates" + }, + "appearance": { + "themeLabel": "Color theme", + "lightLabel": "Light theme", + "darkLabel": "Dark theme", + "systemLabel": "Match system theme" + }, + "general": { + "launchAtLoginLabel": "Launch Mill at login", + "launchAtLoginCaption": "Starts Mill automatically when you log in, same as Raycast/Alfred (docs/SPEC.md §3.7).", + "errorDevBinary": "Not available in this dev build -- only a built .app bundle can be a login item.", + "errorServerMode": "Not available in server mode -- there is no login-item concept without a desktop app to register." + }, + "keyboardShortcuts": { + "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." + }, + "globalHotkey": { + "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).", + "recording": "Press a combo… (Esc to cancel)", + "setShortcut": "Set shortcut", + "reservedError": "{{combo}} is reserved by macOS ({{reason}}) — pick another combo", + "openAccessibilitySettings": "Open Accessibility Settings" + }, + "mcp": { + "allowImportLabel": "Allow MCP clients to import data", + "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.", + "askBeforeImportLabel": "Ask me before each MCP import", + "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." + }, + "notifications": { + "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.", + "awayAfterLabel": "Away after (seconds)", + "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.", + "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." + }, + "updates": { + "checkButton": "Check for updates", + "checking": "Checking…", + "updateAvailable": "Update available: v{{version}}", + "upToDate": "You're on the latest version." + } + } +} diff --git a/frontend/src/views/SettingsView.tsx b/frontend/src/views/SettingsView.tsx index 752c95e7..d7ac0d80 100644 --- a/frontend/src/views/SettingsView.tsx +++ b/frontend/src/views/SettingsView.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { Browser } from '@wailsio/runtime' import { Button, Checkbox, FormControl, Heading, Label, SegmentedControl, Stack, Text, TextInput, useTheme } from '@primer/react' import { SunIcon, MoonIcon, DeviceDesktopIcon, KeyIcon } from '@primer/octicons-react' @@ -33,6 +34,10 @@ const COLOR_MODES = ['light', 'dark', 'auto'] as const // state (a login item, a global hotkey registration), not something the // browser layer can hold on its own. function SettingsView() { + // 'views' is the default namespace (settings.* keys); 'common:' + // prefix reaches the shared common.json namespace explicitly + // (docs/goals/0032-copy-management.md's proof-of-pattern slice). + const { t } = useTranslation('views') const { colorMode, setColorMode } = useTheme() const [launchAtLogin, setLaunchAtLoginState] = useState(null) @@ -114,7 +119,7 @@ function SettingsView() { const reserved = reservedByMacOS(mods, key) if (reserved) { setSummonRecording(false) - setSummonError(`${describeCombo(mods, key)} is reserved by macOS (${reserved}) — pick another combo`) + setSummonError(t('settings.globalHotkey.reservedError', { combo: describeCombo(mods, key), reason: reserved })) return } @@ -164,7 +169,7 @@ function SettingsView() { setUpdateStatus('') SettingsService.CheckForUpdates() .then((result) => { - setUpdateStatus(result.updateAvailable ? `Update available: v${result.version}` : "You're on the latest version.") + setUpdateStatus(result.updateAvailable ? t('settings.updates.updateAvailable', { version: result.version }) : t('settings.updates.upToDate')) }) .catch((err) => setUpdateStatus(String(err))) .finally(() => setUpdateChecking(false)) @@ -172,25 +177,24 @@ function SettingsView() { return ( - Settings + {t('settings.title')} - App-level preferences -- not workflow or Configure-authored data (that lives in Composition/Configure), a - UI preference persisted locally to this machine. + {t('settings.subtitle')} {settingsLoadError && ( - Couldn't load some settings -- the app may need a restart. + {t('settings.loadError')} )} - Appearance - setColorMode(COLOR_MODES[i])}> - - - + {t('settings.sections.appearance')} + setColorMode(COLOR_MODES[i])}> + + + - General + {t('settings.sections.general')} toggleLaunchAtLogin(e.target.checked)} data-testid="launch-at-login-checkbox" /> - Launch Mill at login - Starts Mill automatically when you log in, same as Raycast/Alfred (docs/SPEC.md §3.7). + {t('settings.general.launchAtLoginLabel')} + {t('settings.general.launchAtLoginCaption')} {launchAtLoginError && ( {launchAtLoginError.includes('dev binary') - ? 'Not available in this dev build -- only a built .app bundle can be a login item.' + ? t('settings.general.errorDevBinary') : launchAtLoginError.includes('server mode') - ? 'Not available in server mode -- there is no login-item concept without a desktop app to register.' + ? t('settings.general.errorServerMode') : launchAtLoginError} )} - Keyboard Shortcuts + {t('settings.sections.keyboardShortcuts')} - 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. + {t('settings.keyboardShortcuts.description')} - Global hotkey + {t('settings.sections.globalHotkey')} - 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). + {t('settings.globalHotkey.description')} {summonRecording ? ( - Press a combo… (Esc to cancel) + {t('settings.globalHotkey.recording')} ) : summonBinding ? ( <> - - + + ) : ( )} @@ -244,13 +245,13 @@ function SettingsView() { {summonError} {isAccessibilityError(summonError) && ( )} )} - MCP access + {t('settings.sections.mcpAccess')} - Allow MCP clients to import data + {t('settings.mcp.allowImportLabel')} - 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. + {t('settings.mcp.allowImportCaption')} {mcpWriteEnabled && ( @@ -279,23 +278,19 @@ function SettingsView() { }} data-testid="mcp-write-approval-checkbox" /> - Ask me before each MCP import + {t('settings.mcp.askBeforeImportLabel')} - 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. + {t('settings.mcp.askBeforeImportCaption')} )} - Notifications + {t('settings.sections.notifications')} - 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. + {t('settings.notifications.description')} - Away after (seconds) + {t('settings.notifications.awayAfterLabel')} - 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. + {t('settings.notifications.awayAfterCaption')} - 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. + {t('settings.notifications.alertPermissionNote')} - Updates + {t('settings.sections.updates')} {updateStatus && {updateStatus}}