diff --git a/AGENTS.md b/AGENTS.md index 3a674d487e..df44417d87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme - `./docs/soft-delete-verification-matrix.md` — mandatory soft-delete verification matrix. - `./docs/cli-reference.md` — CLI and terminal UI reference. - `./docs/contributing.md` — contributing conventions and release-adjacent context. -- `./docs/solutions/` — documented solutions to past problems (bugs, architecture patterns, best practices), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. +- `./docs/solutions/` — documented solutions to past problems (bugs, architecture patterns, best practices), organized by category with YAML frontmatter (`category`, `module`, `tags`, `problem_type`, `applies_when`). Relevant when implementing or debugging in documented areas. - `./CONCEPTS.md` — shared domain vocabulary (entities, named processes, status concepts). Relevant when orienting to the codebase or discussing domain concepts. ### Lazy-Loaded Heavy Views diff --git a/docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md b/docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md index 0fbbc493e8..5ebd423e46 100644 --- a/docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md +++ b/docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md @@ -404,10 +404,9 @@ In scope: the localization runtime for both surfaces, English extraction of user ## Risks & Dependencies -- **Ink 6.8 → 7.0 upgrade for CJK width (U6).** Ink 7.0 measures CJK as double-width (via `string-width`) and fixes column/border alignment, but it also reworked input handling — a major bump that needs a TUI regression pass. Mitigation: prefer the upgrade for correctness, but the fallback (stay on 6.8 + explicit `string-width` math at hand-built layout sites) is viable if the input-handling changes prove disruptive. Decide early in U6; treat as the unit's primary risk. +- **Ink 6.8 → 7.0 upgrade for CJK width (U6).** Ink 7.0 measures CJK as double-width (via `string-width`) and fixes column/border alignment, but it also reworked input handling (a major bump needing a TUI regression pass) and raises the React peer floor to 19.2. Mitigation: prefer the upgrade for correctness with the React peer bump (U6); fallback is staying on 6.8 with explicit `string-width` math at hand-built layout sites if the input-handling changes prove disruptive. Decide early in U6; treat as the unit's primary risk. - **Vite variable dynamic-import code-splitting (KTD3a/U3).** Vite 6 only code-splits a variable `import()` when the prefix is *relative to the importing file*; an aliased, bare, or cross-package specifier defeats `@rollup/plugin-dynamic-import-vars`, and `import.meta.glob` won't cross `node_modules`. The plan resolves this by making the **app-local generated-catalog path primary** (catalogs synced into a gitignored `app/locales/`, imported by a plainly app-relative template) rather than relying on undocumented cross-package symlink resolution. Residual risk: the dashboard has no existing variable-dynamic-import precedent, so U3 keeps a build assertion that per-locale chunks actually emit, as a regression guard before U5 builds on the runtime. - **react-i18next under the Ink reconciler (KTD1/U6).** The single-runtime decision assumes `useTranslation`/`` work under Ink's custom (non-react-dom) reconciler; there is no cited precedent. Mitigation: U6 spikes this before committing the runtime; synchronous `initImmediate: false` init already sidesteps Suspense, and plain `t()` is the fallback if `` misbehaves. -- **Ink 6.8 → 7.0 upgrade for CJK width (U6).** Ink 7.0 measures CJK as double-width and fixes alignment, but reworked input handling (a major bump needing a TUI regression pass) and raises the React peer floor to 19.2. Mitigation: prefer the upgrade with the peer bump (U6); fallback is staying on 6.8 with explicit `string-width` math. - **CLI ships all locales in the published binary.** tsup `splitting: false` + `noExternal` inlines every catalog into `@runfusion/fusion`'s `dist/bin.js`, growing linearly with translation volume and locale count. Mitigation: accepted tradeoff for v1; keep the `cli` namespace separate so dashboard strings aren't dragged in; lazy fs-loading of copied catalog assets is a future optimization if size becomes material. - **String-extraction surface is large (~464 dashboard files).** Mitigation: incremental per-view migration (U5/U7) with a named-set completion gate, not a big-bang sweep; the plan ships a working foundation + high-traffic coverage and defers the long tail. - **kb→fn rename collision.** Touching user-facing strings during the in-flight rename risks churn. Mitigation: KTD9 (brand tokens as `{{brand}}` variables) and KTD8 (reuse `kb-` localStorage prefix) keep this plan rename-neutral. diff --git a/docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md b/docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md index 93c427c8bc..bfd42abaa4 100644 --- a/docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md +++ b/docs/solutions/architecture-patterns/i18n-foundation-vite-ink-monorepo-code-split-catalogs.md @@ -68,6 +68,7 @@ export function normalizeToSupportedLocale(tag: string): Locale | undefined { if (isLocale(norm)) return norm; const lower = norm.toLowerCase(); if (lower.startsWith("zh")) { + if (lower.includes("hans")) return "zh-CN"; // explicit script wins over region (zh-Hans-HK) if (lower.includes("hant") || lower.includes("-tw") || lower.includes("-hk") || lower.includes("-mo")) return "zh-TW"; return "zh-CN"; diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 276de7699f..b6dacfb95e 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -420,7 +420,7 @@ Options: --interactive Interactive mode (port selection for dashboard, issue selection for import) --paused Start with engine paused (automation disabled) --dev Start dashboard only (no AI engine) - --lang UI locale for this run (en, zh-CN, zh-TW, fr, es) + --lang Terminal-UI locale for this run (en, zh-CN, zh-TW, fr, es); the browser dashboard resolves its own language --attach Attach file(s) on task create (repeatable) --depends Declare dependency on task create (repeatable) --no-dedup Bypass deterministic duplicate guard on task create @@ -736,6 +736,15 @@ async function main() { const token = dashTokenIdx !== -1 && dashTokenIdx + 1 < args.length ? args[dashTokenIdx + 1] : undefined; const dashLangIdx = args.indexOf("--lang"); const lang = dashLangIdx !== -1 && dashLangIdx + 1 < args.length ? args[dashLangIdx + 1] : undefined; + if (lang !== undefined) { + // Fail loudly on a bad explicit flag instead of silently falling back + // to setting/env resolution inside the TUI. + const { isLocale, SUPPORTED_LOCALES } = await import("@fusion/core"); + if (!isLocale(lang)) { + console.error(`Invalid --lang "${lang}". Supported: ${SUPPORTED_LOCALES.join(", ")}`); + process.exit(1); + } + } await runDashboard(port, { paused, dev, interactive, host, noAuth, token, lang }); break; } diff --git a/packages/cli/src/i18n/__tests__/i18n.test.tsx b/packages/cli/src/i18n/__tests__/i18n.test.tsx index 4321401ae5..05f2f42541 100644 --- a/packages/cli/src/i18n/__tests__/i18n.test.tsx +++ b/packages/cli/src/i18n/__tests__/i18n.test.tsx @@ -71,6 +71,23 @@ describe("react-i18next under the Ink reconciler", () => { expect(lastFrame()).toContain("Loading…"); }); + it("renders from a real catalog, not the defaultValue fallback", async () => { + // t("tui.loading", "Loading…") can't distinguish a loaded catalog from the + // inline defaultValue. A non-en lookup with no defaultValue proves the + // bundled catalog itself resolved. + const i18n = initCliI18n("en"); + await i18n.changeLanguage("fr"); + function FrLoading() { + const { t } = useTranslation("cli"); + return createElement(Text, null, t("tui.loading")); + } + const { lastFrame } = render( + createElement(I18nextProvider, { i18n }, createElement(FrLoading)), + ); + expect(lastFrame()).toContain("Chargement…"); + await i18n.changeLanguage("en"); + }); + it("re-renders on changeLanguage", async () => { const i18n = initCliI18n("en"); cliI18n.addResourceBundle("zh-CN", "cli", { tui: { loading: "加载中…" } }, true, true); diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index d5abfe73a6..f2aa7353bb 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -65,6 +65,10 @@ export function initCliI18n(locale: Locale): I18nInstance { }); initialized = true; } else if (i18next.language !== locale) { + // changeLanguage is async; the instance carries the previous locale until + // the microtask queue drains. Benign in production (called once per + // process) — callers that need the settled locale before rendering should + // await i18next.changeLanguage(locale) directly. void i18next.changeLanguage(locale); } return i18next; diff --git a/packages/dashboard/app/components/LanguageSelector.tsx b/packages/dashboard/app/components/LanguageSelector.tsx index e905d4fb9e..e8ca1b68d1 100644 --- a/packages/dashboard/app/components/LanguageSelector.tsx +++ b/packages/dashboard/app/components/LanguageSelector.tsx @@ -21,7 +21,9 @@ export function LanguageSelector() { return (
{label}
-
+ {/* role="group" + aria-pressed: toggle-button semantics (radiogroup would + conflict with aria-pressed and confuse screen readers). */} +
{supportedLocales.map((locale) => (