Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
86 changes: 86 additions & 0 deletions docs/goals/0032-copy-management.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions docs/goals/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/app/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
48 changes: 48 additions & 0 deletions frontend/src/app/i18n.ts
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions frontend/src/app/main.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
6 changes: 6 additions & 0 deletions frontend/src/locales/en/common.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"actions": {
"change": "Change",
"clear": "Clear"
}
}
1 change: 1 addition & 0 deletions frontend/src/locales/en/composition.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
1 change: 1 addition & 0 deletions frontend/src/locales/en/configure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
Loading
Loading