From ea8bde94214d580c81d8f2ee03e2ab6397a24d14 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:51:25 -0700 Subject: [PATCH 01/24] docs: add UI localization (i18n) implementation plan Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-03-001-feat-ui-localization-i18n-plan.md | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md 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 new file mode 100644 index 0000000000..f580868dd0 --- /dev/null +++ b/docs/plans/2026-06-03-001-feat-ui-localization-i18n-plan.md @@ -0,0 +1,429 @@ +--- +title: "feat: Add UI localization (i18n) across dashboard and terminal UI" +type: feat +status: active +date: 2026-06-03 +deepened: 2026-06-03 +--- + +# feat: Add UI localization (i18n) across dashboard and terminal UI + +## Summary + +Stand up a react-i18next localization foundation that serves both Fusion UI surfaces — the React web dashboard (`packages/dashboard/app`, inherited by the Capacitor mobile wrapper) and the Ink terminal UI (`packages/cli`) — migrate user-facing strings into translation catalogs, ship four non-English locales (`zh-CN`, `zh-TW`, `fr`, `es`) atop an English source-of-truth base, and establish an `i18next-cli`-driven workflow so adding a future language is a near-zero-code, translate-only operation. + +--- + +## Problem Frame + +Fusion's UI is currently English-only with strings hardcoded inline across ~464 dashboard `.tsx` files and the Ink TUI components. There is **no i18n library anywhere** in the monorepo (greenfield — confirmed across `packages/` and `plugins/`), and locale-sensitive formatting at ~45 dashboard call sites relies on the implicit browser default (`toLocaleDateString(undefined, …)`), with zero `navigator.language` or `Intl.*` usage. + +Fusion's strategy targets a developer audience "across surfaces and machines," and the published CLI (`@runfusion/fusion`) plus the dashboard are the two surfaces users actually read. Supporting Simplified Chinese, Traditional Chinese, French, and Spanish — and lowering the cost of every future language — widens reach without betting the product on any one locale. Because this is greenfield, there is no prior library to reconcile or migrate away from; the cost is entirely in standing up the foundation and the one-time string extraction. + +--- + +## Requirements + +### Localization foundation + +- R1. A single localization runtime (`react-i18next` + `i18next`) powers both the dashboard and the Ink TUI from one shared catalog source-of-truth, with English as the source language. +- R2. Translation catalogs are organized as `{locale}/{namespace}.json` with nested, ID-style keys (not natural-language keys), split into namespaces so feature areas and the CLI-only surface can be loaded independently. +- R3. Supported locales are a single typed list — `en`, `zh-CN`, `zh-TW`, `fr`, `es` — defined once in `@fusion/core` and consumed by every surface. `en` is the fallback for all. +- R4. Pluralization uses i18next's native CLDR plural categories (via `Intl.PluralRules`); date/number/relative-time formatting uses i18next's built-in `Intl` formatters bound to the active locale. No ICU message format. + +### Dashboard (web + mobile) + +- R5. The dashboard resolves the active locale at startup with precedence `localStorage → navigator → en`, constrained to `supportedLngs`, and sets `document.documentElement.lang`. +- R6. Only the active locale's catalogs load on first paint; the other four locales are code-split and fetched on demand, consistent with the existing lazy-load/prefetch bundle discipline. +- R7. Users can change language from the Settings UI; the choice persists to `localStorage` and writes through to server-side `GlobalSettings.language`, mirroring the existing theme-preference pattern. +- R8. Locale-sensitive date/number formatting in the dashboard reflects the active i18n locale rather than the implicit browser default. + +### Terminal UI (CLI) + +- R9. The Ink TUI renders translated strings via the same library, using a separate Node-side i18next instance with statically bundled catalogs and synchronous init (first frame is localized). +- R10. The CLI resolves locale with precedence `--lang flag → persisted GlobalSettings → environment (LC_ALL/LANG/…) → en`. +- R11. CJK and accented text render without breaking the TUI's width-sensitive layouts; single-letter keybinding accelerators are never translated. + +### Translations and contributor workflow + +- R12. Complete `zh-CN`, `zh-TW`, `fr`, and `es` catalogs are shipped, each independently localized (no auto-conversion between the two Chinese scripts). +- R13. An `i18next-cli` workflow extracts keys from source, syncs missing/orphaned keys across all locales, generates key types, and reports per-locale completion; CI fails on missing keys, stale types, or incomplete catalogs. +- R14. Adding a new language requires no feature code beyond registering the locale code and providing translated catalogs; the procedure is documented for contributors. + +--- + +## Key Technical Decisions + +- KTD1. **Library: `react-i18next` + `i18next` v26 for both surfaces.** react-i18next is framework-agnostic and runs in Node, so the same stack powers the Vite SPA and the Ink React tree — no second i18n system. v26's built-in `Intl` formatter is always active and its native pluralization uses CLDR categories, covering our formatting and plural needs without `i18next-icu`. (Note v26 breaking changes: the legacy `interpolation.format` function is removed; `TFunction` imports from `i18next`, not `react-i18next`.) + +- KTD2. **Catalogs: `{locale}/{namespace}.json`, nested ID-style keys, multiple namespaces.** Namespaces are i18next's lazy-load unit, so splitting by feature area enables per-surface and per-route code-splitting (a `cli` namespace the web bundle skips; an `errors`/`common`/`app` split for the dashboard). ID-style keys (`settings.appearance.languageLabel`) survive English copy rewrites without orphaning translations — the maintainability the request asks for. English text lives as `defaultValue`/extraction, not as the key. + +- KTD3. **Single catalog source-of-truth in a dedicated `@fusion/i18n` package, consumed differently per surface.** Catalogs live in a new `packages/i18n` workspace package (`@fusion/i18n`) — *not* in `@fusion/core`. Putting presentation strings in the domain/settings root would be a layering inversion (core sits under engine and CLI and has no other UI-string responsibility). `@fusion/i18n` owns the authored `locales/{lng}/{ns}.json` source-of-truth, the shared i18next config (namespace list, fallback chain, plural setup), and namespace key types. It `import type`s `Locale` from `@fusion/core` (type-only, no runtime cycle); locale primitives and the persisted `GlobalSettings.language` field stay in core (KTD8). The dashboard lazy-loads the active locale via Vite code-splitting from catalogs generated into its own tree (KTD3a); the CLI statically inlines all catalogs directly from `@fusion/i18n` via tsup (`noExternal: [/^@fusion\//]` already bundles `@fusion/*` from source). Rejected alternatives: catalogs *authored* in `@fusion/core` (layering inversion — the domain root has no UI-string responsibility); catalogs *authored* in `packages/dashboard/app/locales/` and imported across the boundary by the CLI (dependency inversion — terminal UI reaching into the SPA's internals). Note these are distinct from KTD3a's primary wiring, which *generates* catalogs into the dashboard tree from the `@fusion/i18n` source — the dashboard never authors them and the CLI never imports the generated copy. The dashboard's i18n init calls `i18next-resources-to-backend` directly at the (app-relative) call site; no separate factory abstraction is introduced. + +- KTD3a. **Dashboard per-locale code-splitting requires a *relative*, package-local dynamic-import template; the authored source-of-truth stays in `@fusion/i18n` and is generated into the dashboard tree at build time.** Vite 6's variable-dynamic-import analysis (via `@rollup/plugin-dynamic-import-vars`) only code-splits `import(\`./locales/${lng}/${ns}.json\`)` when the prefix is relative *to the importing file* and each variable is exactly one path segment. An aliased, bare, or cross-package/workspace specifier in a *variable* `import()` defeats static analysis — Vite eager-bundles with a build warning or fails at runtime — and `import.meta.glob` refuses to cross the `node_modules` boundary. A relative template living inside `@fusion/i18n`'s own source would still resolve through the workspace symlink at the dashboard build; that *can* slip through but is **not a documented guarantee**, and the dashboard has zero existing variable-dynamic-import precedent to lean on. **Decision (primary wiring):** `@fusion/i18n` remains the single authored source-of-truth, but `i18next-cli sync` generates the catalog tree into a gitignored `packages/dashboard/app/locales/` (mirroring the engine `dist` convention — a `predev`/`prebuild` step in the dashboard package), and the dashboard's backend factory imports them with a plainly app-relative `import(\`./locales/${lng}/${ns}.json\`)`. This makes per-locale splitting *guaranteed* rather than dependent on undocumented symlink behavior. The cross-package symlink path (import template inside `@fusion/i18n`, no generation step) is recorded only as an optional optimization to prove out later, not the shipped path. The CLI is unaffected — tsup statically inlines catalogs directly from `@fusion/i18n` across the package boundary (KTD5). U3 keeps a build assertion that per-locale chunks emit, as a regression guard on the generated-catalog wiring. + +- KTD4. **Dashboard lazy-loading via `i18next-resources-to-backend` + dynamic `import()`.** Preferred over `i18next-http-backend` for a Vite SPA: hashed assets, offline-capable, no separate static-serving concern. Only the active locale's loaded namespaces touch the main path; switching language fetches the new chunk. Add a `vendor-i18n` `manualChunk` alongside the existing `vendor-react`/`vendor-xterm` splits. + +- KTD5. **CLI init is synchronous with statically bundled catalogs (`initImmediate: false`).** Lazy loading is pointless in Node; synchronous init guarantees the first rendered frame is already localized. The CLI gets its own i18next instance (no DOM, no HTTP backend, locale from env/flag/settings — not `localStorage`). + +- KTD6. **Tooling: `i18next-cli`, not `i18next-parser`.** `i18next-parser` was archived Feb 2026; `i18next-cli` (SWC-based) unifies `extract`, `sync`, `types`, `status`, and `lint`. Its AST-based `lint` for hardcoded strings is the primary guardrail (CI, warn-first), avoiding a noisy 464-file ESLint sweep. A scoped `eslint-plugin-i18next`/`no-restricted-syntax` rule is deferred (see Scope Boundaries) — the repo's existing `no-restricted-syntax` precedent makes it easy to add later if editor squiggles are wanted. + +- KTD7. **zh-CN and zh-TW are fully independent catalogs.** Different script *and* vocabulary — never auto-convert one to the other. Configure script-aware fallback (`zh-Hans→zh-CN`, `zh-Hant→zh-TW`, `zh→zh-CN`, default `en`) and keep `load: 'currentOnly'` so the two never collapse into a generic `zh`. Both have a single CLDR plural category (`other`); French and Spanish carry `one`/`many`/`other`. + +- KTD8. **Persisted-locale plumbing extends the existing theme-preference pattern.** Add `language?: Locale` to `GlobalSettings` in core (registered in `DEFAULT_GLOBAL_SETTINGS`, `GLOBAL_SETTINGS_KEYS`, schema/validation). Dashboard persistence mirrors `useTheme.ts`: a `localStorage` cache (using the neighbor-consistent `kb-dashboard-*` key prefix — do **not** pre-empt the kb→fn rename here) plus `updateGlobalSettings` write-through. The CLI reads the same `GlobalSettings.language` server-side. + +- KTD9. **Brand tokens stay as interpolation variables in catalogs.** Because the kb→fn rename is in flight, extract product-name occurrences as `{{brand}}`-style variables (or leave brand-only keys untranslated) so the rename can sweep them later without churning all five catalogs. + +--- + +## High-Level Technical Design + +### Surface and catalog topology + +```mermaid +flowchart TB + subgraph core["@fusion/core (domain/settings root)"] + LOC["Locale, SUPPORTED_LOCALES, DEFAULT_LOCALE,
GlobalSettings.language"] + end + + subgraph i18n["@fusion/i18n (new package — authored source-of-truth)"] + CAT["locales/{lng}/{ns}.json"] + CFG["shared config + generated cli import map"] + end + + subgraph dash["packages/dashboard/app (Vite SPA)"] + DI["i18n init (browser instance)"] + DP["I18nextProvider in App.tsx stack"] + SW["Language switcher (SettingsModal)"] + FMT["Intl formatters / toLocale* sites"] + end + + subgraph cli["packages/cli (Ink TUI)"] + CI["i18n init (Node instance, sync)"] + CP["I18nextProvider at Ink render()"] + end + + mobile["@fusion/mobile (Capacitor)"] + + LOC -.->|"import type Locale"| i18n + LOC --> DI & CI & SW + CAT -->|"i18next-cli sync → gitignored app/locales/
app-relative import() → per-locale chunks"| DI + CAT -->|"statically inlined via tsup (all locales)"| CI + DI --> DP --> FMT + CI --> CP + dash -->|"built dist inherited"| mobile +``` + +### Dashboard language-switch flow + +```mermaid +sequenceDiagram + participant U as User + participant S as SettingsModal (Appearance) + participant H as useLanguage hook + participant I as i18next (browser) + participant LS as localStorage + participant API as updateGlobalSettings + + U->>S: select locale (e.g. zh-TW) + S->>H: setLanguage("zh-TW") + H->>I: changeLanguage("zh-TW") + I-->>I: lazy-fetch zh-TW namespace chunk(s) + I-->>S: re-render translated tree + H->>LS: cache "zh-TW" (kb-dashboard-language) + H->>API: write-through GlobalSettings.language + H->>I: set document.documentElement.lang +``` + +### CLI locale resolution precedence + +```mermaid +flowchart LR + A["--lang flag"] -->|set| Z["active locale"] + A -->|unset| B["GlobalSettings.language"] + B -->|set| Z + B -->|unset| C["env LC_ALL/LC_MESSAGES/LANG/LANGUAGE"] + C -->|parsed & supported| Z + C -->|none| D["en (fallback)"] + D --> Z +``` + +--- + +## Output Structure + +New and notably-modified files (per-unit `Files` sections remain authoritative): + +``` +packages/ + i18n/ (new @fusion/i18n package — authored source-of-truth) + package.json (source export condition, JSON support) + locales/ (authored catalogs — translators edit here) + en/ common.json app.json errors.json cli.json + zh-CN/ common.json app.json errors.json cli.json + zh-TW/ ... + fr/ ... + es/ ... + src/ + config.ts (shared i18next config, namespace + key types) + cli-catalogs.ts (generated static import map for the CLI, from SUPPORTED_LOCALES) + index.ts + core/ + src/ + types.ts (+ Locale, SUPPORTED_LOCALES, DEFAULT_LOCALE, GlobalSettings.language) + settings-schema.ts (+ language default & key) + settings-validation.ts + index.ts (+ exports) + dashboard/ + app/ + locales/ (GITIGNORED — generated from @fusion/i18n by i18next-cli sync) + en/ … zh-CN/ … zh-TW/ … fr/ … es/ + i18n/ + index.ts (browser init: app-relative resources-to-backend, detector, formatters, fallback) + useLanguage.ts (switch + persist hook) + components/ + LanguageSelector.tsx + package.json (predev/prebuild → i18next-cli sync into app/locales) + vite.config.ts (vendor-i18n chunk) + cli/ + src/ + i18n/ + index.ts (Node sync init, static catalogs, env/flag detection) +i18next.config.ts (extract/sync/types/status/lint config) +docs/ + i18n-contributing.md (adding a language) +``` + +--- + +## Implementation Units + +Phased: **A. Foundation** (U1–U2) → **B. Dashboard** (U3–U5) → **C. Terminal UI** (U6–U7) → **D. Translations & workflow** (U8). Each unit is independently landable. + +### U1. Core locale primitives and settings field + +- Goal: Define the single source of locale truth and the persisted preference field in `@fusion/core`. +- Requirements: R3, R7 (settings field), R10 (shared field for CLI). +- Dependencies: none. +- Files: + - `packages/core/src/types.ts` — add `Locale` union, `SUPPORTED_LOCALES` tuple (`["en","zh-CN","zh-TW","fr","es"]`), `DEFAULT_LOCALE`, and `language?: Locale` on `GlobalSettings` (near line 2439, beside `dashboardFontScalePct`). Locale primitives must live in `types.ts` specifically — the dashboard Vite alias resolves `@fusion/core` to `types.ts` only. + - `packages/core/src/settings-schema.ts` — register `language` in `DEFAULT_GLOBAL_SETTINGS` (line ~18) so it flows into `GLOBAL_SETTINGS_KEYS`. + - `packages/core/src/settings-validation.ts` — validate `language` against `SUPPORTED_LOCALES`. + - `packages/core/src/index.ts` — export `Locale`, `SUPPORTED_LOCALES`, `DEFAULT_LOCALE`. + - `packages/core/src/__tests__/settings-schema.test.ts` (or matching existing settings test) — coverage. +- Approach: Pure type + constant additions; no runtime behavior beyond settings validation. `language` is optional so existing settings files remain valid (absence → resolve at runtime, not persisted default-en, to keep "follow the browser/env" behavior until the user chooses). +- Patterns to follow: `THEME_MODES`/`COLOR_THEMES` const tuples and the `themeMode`/`colorTheme` optional fields; `isGlobalSettingsKey` validation flow. +- Test scenarios: + - `SUPPORTED_LOCALES` contains exactly the five codes; `DEFAULT_LOCALE` is `en`. + - `isGlobalSettingsKey("language")` returns true; `language` appears in `GLOBAL_SETTINGS_KEYS`. + - Validation accepts each supported code and rejects an unsupported code (e.g. `"de"`, `"zh"`). + - A settings object without `language` round-trips through load/merge unchanged (backward compatibility). +- Verification: `@fusion/core` typechecks and its settings tests pass; the new symbols are importable from the barrel. + +### U2. `@fusion/i18n` package, catalog source-of-truth, and i18next-cli tooling + +- Goal: Create the `@fusion/i18n` package with shared config and authored `en` base catalogs, the generated CLI import map, and the extract/sync/types/status/lint workflow with a CI gate. +- Requirements: R1 (shared source), R2 (layout/keys/namespaces), R13 (tooling + CI), R14 (drop-in language config). +- Dependencies: U1. +- Files: + - `packages/i18n/package.json` — new `@fusion/i18n` workspace package; `source` export condition (so the CLI's tsup `conditions: ["source"]` inlines `.ts`/`.json` from source like other `@fusion/*`); JSON import support in its build. Zero runtime internal deps; `import type` only from `@fusion/core` for `Locale`. + - `packages/i18n/src/config.ts` (shared i18next config: namespace list, script-aware fallback chain, plural setup), `packages/i18n/src/index.ts`. + - `packages/i18n/src/cli-catalogs.ts` — **generated** static import map (one `import` per `{locale}/{ns}`) produced from `SUPPORTED_LOCALES` by a codegen step, so the CLI's static-bundle path (KTD5) gains a new locale with no hand-edited import block — this is what keeps R14's "near-zero-code" promise true for the terminal surface, not just the dashboard. + - `packages/i18n/locales/en/{common,app,errors,cli}.json` — initial namespaces (seeded from already-centralized constants like `COLUMN_LABELS`/`COLUMN_DESCRIPTIONS` as the first real keys). + - `packages/i18n/locales/{zh-CN,zh-TW,fr,es}/*.json` — scaffolded (synced) placeholders so structure exists before U8 fills them. + - `i18next.config.ts` (repo root) — `defineConfig` with `locales`, `extract.input` globs covering `packages/dashboard/app/**` and `packages/cli/src/**`, `extract.output` → `packages/i18n/locales/{{language}}/{{namespace}}.json`, `types.output`, per-input-tree default-namespace mapping (dashboard globs → `app`/`common`/`errors`; CLI globs → `cli`) so extracted keys land in the right namespace, and `lint` accepted tags/attrs to cut false positives. + - root `package.json` — scripts: `i18n:extract`, `i18n:sync`, `i18n:types`, `i18n:status`, `i18n:lint`, plus the CLI import-map codegen. + - `packages/cli/scripts/prepare-publish-manifest.mjs` — add `@fusion/i18n` to the private-dep strip list (alongside the existing `@fusion/core`/`@fusion/engine` handling) so `npm install @runfusion/fusion` does not 404 on the private workspace dep; declare `@fusion/i18n` as a CLI devDependency (inlined via `noExternal`), matching the existing pattern. + - CI workflow (existing `.github/workflows/*`) — add a job running `extract --ci`, `types --ci`, and `status`. (`lint` runs here too but is the same `i18next-cli lint` named the primary guardrail in KTD6 — not a duplicate ESLint pass.) + - `.changeset/*.md` — `@runfusion/fusion` minor (published surface gains i18n; `@fusion/i18n` is private/bundled, so no changeset for it per AGENTS.md). +- Approach: `en` is primary; `i18next-cli sync` scaffolds the other four with correct per-locale plural suffixes so contributors never hand-author plural categories. Keep the published-CLI footprint lean (`i18next` + `react-i18next` + `@fusion/i18n` catalogs only; tooling is devDependencies). **CI gate vs. incremental key growth:** because U5/U7 continuously add `en` keys (each re-opening gaps in the four locales), the 100%-completion gate (U8) must not deadlock CI during the migration window. Resolve by having `status` gate at 100% only for a tracked *shipped* set, with newly-added-but-untranslated keys allowed via an explicit pending-keys allowlist (or a per-namespace freeze) until U8 — `extract --ci` (no missing `en` key) and `types --ci` (no stale types) stay hard gates throughout. +- Patterns to follow: existing `@fusion/core` package shape (`source` export condition, tsc build); the engine `dist` gitignored-generated-artifact convention for the dashboard catalog generation (U3); existing root scripts and the docs-sync self-checking test idiom (`app/__tests__/lazy-loaded-views-docs.test.ts`). +- Test scenarios: + - `i18n:status` exits non-zero when a non-`en` catalog is missing a *shipped* key present in `en`; a key on the pending allowlist does not trip the gate. + - `i18n:extract --ci` exits non-zero when a `t()` key in source is absent from the `en` catalog. + - `i18n:sync` adds a newly-introduced `en` key to all four locales and removes an orphaned key from all; a dashboard-namespace key and a `cli`-namespace key route to their correct namespace files. + - Generated key types include a representative nested key and reject a typo'd key (compile-fail fixture). + - The generated `cli-catalogs.ts` import map contains exactly one entry per `SUPPORTED_LOCALES × namespace`; regenerating after adding a locale code adds its entries with no hand edit. + - Covers R13. Covers R14: adding a sixth locale code to `i18next.config.ts` + `SUPPORTED_LOCALES`, then running `sync` + codegen, produces a fully-scaffolded catalog set AND a CLI import map that picks up the locale — no hand-edited feature code on either surface. +- Verification: All `i18n:*` scripts run locally; the CI job is green with `en` populated and the four locales synced (even if untranslated, structure matches); `npm pack` on the CLI produces a manifest with no unresolved `@fusion/*` deps. + +### U3. Dashboard i18n runtime + +- Goal: Initialize the browser i18next instance with lazy catalog loading, detection, fallback, and Intl formatters; mount the provider. +- Requirements: R1, R4, R5, R6. +- Dependencies: U1, U2. +- Files: + - `packages/dashboard/app/i18n/index.ts` — init: `initReactI18next`, `i18next-browser-languagedetector` (order `localStorage → navigator → htmlTag`, `supportedLngs`, `caches: ['localStorage']`), `i18next-resources-to-backend` with an **app-relative** `import(\`./locales/${lng}/${ns}.json\`)` over the generated `app/locales/` tree (KTD3a), script-aware `fallbackLng` (KTD7), `load: 'currentOnly'`, and shared config imported from `@fusion/i18n`. + - `packages/dashboard/package.json` — `predev`/`prebuild` step running `i18next-cli sync` to generate `app/locales/` from the `@fusion/i18n` source (gitignored), mirroring the engine `dist` convention. + - `packages/dashboard/app/main.tsx` — import the i18n side-effect before `createRoot`; gate first paint on i18next `ready` (Suspense boundary or a top-level loading state) so the UI never renders raw keys during the first catalog fetch (avoids flash-of-untranslated-content); set `document.documentElement.lang` on `languageChanged`. + - `packages/dashboard/app/App.tsx` — wrap the provider stack (around lines 2044–2058) with `` so toasts/dialogs are translatable. + - `packages/dashboard/vite.config.ts` — add a `vendor-i18n` `manualChunk` for the `i18next`/`react-i18next` runtime. Note: catalogs are natural async chunks (not `node_modules`), so `manualChunks` needs **no** change to keep them split — record this as an explicit non-change. + - `packages/dashboard/app/i18n/__tests__/i18n.test.ts` — init/detection/fallback coverage. + - Build-assertion (the KTD3a verification gate): a check (test or build script) asserting per-locale catalog chunks are emitted to `dist/client/assets` and are NOT folded into the main/entry chunk. +- Approach: The active locale's namespaces load for first paint; other locales become separate chunks fetched on `changeLanguage`. The per-locale `import()` template is **app-relative** over the generated `app/locales/` tree (KTD3a), so Vite static-analyzes and splits it — no dynamic-string specifier or `@vite-ignore` in app code (AGENTS.md), and no dependency on undocumented cross-package symlink resolution. The build assertion is a regression guard that the split actually holds. First paint gates on i18next `ready` so no raw-key flash occurs (the catalog fetch is the only async step before the tree is translatable). +- Patterns to follow: `prefetchLazyViews()` idle-prefetch idiom and `React.lazy("./...")` relative-literal import style in `App.tsx` (the exact shape the locale `import()` mirrors); existing provider-nesting convention (`context/XContext.tsx` + `use*` hook); the engine `dist` gitignored-generated convention for `app/locales/`. +- Test scenarios: + - With no stored preference and `navigator.language = "fr"`, the resolved locale is `fr`; with `navigator.language = "de"` (unsupported), it falls back to `en`. + - A stored `localStorage` locale wins over `navigator`. + - `zh` (generic) resolves to `zh-CN` via fallback; `zh-Hant` resolves to `zh-TW`; `zh-CN` does not collapse to a generic `zh`. + - Switching to a not-yet-loaded locale triggers exactly one catalog fetch and re-renders translated text. + - A missing key in `fr` falls back to the `en` value, not the raw key. + - First paint shows the loading state (not raw keys) until the active catalog resolves. + - Build assertion: each non-`en` locale produces its own async chunk; the main entry chunk contains no non-`en` catalog payload. + - Covers R5, R6. +- Verification: Dashboard boots showing the loading state then the resolved locale (no raw-key flash); switching locale at runtime updates rendered strings; the build assertion confirms per-locale chunks exist and the initial chunk carries only the active locale. + +### U4. Dashboard language switcher and persistence + +- Goal: Let users pick a language in Settings and persist it across reloads and to the server. +- Requirements: R7. +- Dependencies: U3. +- Files: + - `packages/dashboard/app/hooks/useLanguage.ts` — read/set active locale; `localStorage` cache (`kb-dashboard-language`, neighbor-consistent prefix per KTD8) + `updateGlobalSettings({ language })` write-through; hydrate from `fetchGlobalSettings` on mount. + - `packages/dashboard/app/components/LanguageSelector.tsx` — option list modeled on `ThemeSelector.tsx` (`{value, label}` with native locale endonyms: 简体中文 / 繁體中文 / Français / Español / English). + - `packages/dashboard/app/components/SettingsModal.tsx` — render `LanguageSelector` in the Appearance section (case `"appearance"`, line ~3691, beside `ThemeSelector`). + - `packages/dashboard/app/hooks/__tests__/useLanguage.test.ts`, `packages/dashboard/app/components/__tests__/LanguageSelector.test.tsx` (lands in the `settings-*` test shard). +- Approach: Three-tier persistence exactly as `useTheme.ts`: instant `localStorage` pre-hydration read, server write-through, hydrate-from-server on mount. **Switching applies in place via `changeLanguage` — no full-page reload** (a reload would drop unsaved form state and disrupt in-flight agent views; the sequence diagram reflects this). This requires components to read copy through `useTranslation`/`t()` rather than caching resolved strings outside the React tree, so the re-render propagates. Endonyms are intentionally untranslated (each language names itself). Placement beside `ThemeSelector` in Appearance is the pragmatic home; if Settings grows a General/Preferences section later, language is a content (not visual) preference and would migrate there — noted, not blocking. +- Patterns to follow: `useTheme.ts` (three-tier persistence) and `ThemeSelector.tsx` (switcher UI); Appearance-section rendering in `SettingsModal.tsx`. +- Test scenarios: + - Selecting a locale calls `changeLanguage`, writes the `localStorage` key, and calls `updateGlobalSettings` once with the new code. + - Switching language updates rendered copy in place with no `window.location.reload` (assert no reload occurs). + - On mount, a server `GlobalSettings.language` value hydrates the active locale when no fresher `localStorage` value exists. + - `localStorage`-unavailable path degrades gracefully (no throw; server write still attempted). + - Selector shows all five endonyms and marks the active one. + - Covers R7. +- Verification: Changing language in Settings persists across a reload and is reflected in server settings; mobile (Capacitor) build inherits the switcher with no extra work. + +### U5. Dashboard string migration and locale-aware formatting + +- Goal: Replace hardcoded user-facing strings with `t()` keys and thread the active locale into date/number formatting — incrementally. +- Requirements: R8, and the dashboard portion of R2 (real keys in catalogs). +- Dependencies: U3, U4. +- Files (incremental — representative, not exhaustive): + - `packages/core/src/index.ts` consumers of `COLUMN_LABELS`/`COLUMN_DESCRIPTIONS` — route through i18n keys first (already centralized → cheapest win). + - `packages/dashboard/app/components/SettingsModal.tsx`, `ThemeSelector.tsx`, and Appearance/Settings copy — first migrated view cluster. + - High-traffic shells: `App.tsx` toast/error strings (e.g. lines ~1036, ~1327), primary navigation and board column UI. + - The ~45 `toLocale*` call sites (e.g. `ActivityFeed.tsx`, `AgentDetailView.tsx`, `ActivityLogModal.tsx`) — pass the active locale or route through a shared `formatDate`/`formatNumber` helper bound to i18next's Intl formatters. + - `packages/i18n/locales/en/*.json` — grows with each migrated cluster. +- Approach: **Incremental per-view migration**, starting with already-centralized constants and the Settings surface, then high-traffic views. Do not attempt a big-bang sweep of all 464 files in one unit — land clusters as separate commits under this unit. Keep diffs JSX-surgical (text → `t("key")`); do not disturb component CSS imports. Run `i18n:extract` after each cluster to keep `en` in sync. Brand tokens become `{{brand}}` variables (KTD9). +- Completion criterion (unit exit gate): this unit is done when a **named set of high-traffic/centralized surfaces** — board column UI (`COLUMN_LABELS`/`COLUMN_DESCRIPTIONS`), Settings, primary navigation, and `App.tsx` toast/error strings — are fully migrated and `i18n:status` reports the agreed coverage threshold for that named set. The low-traffic long tail is explicitly out of this unit (Scope Boundaries). Without this gate U5 has no reviewable done state; the threshold is the exit signal, not "all 464 files." +- Execution note: Lead each cluster by adding the keys to the `en` catalog (via `i18n:extract`) and a render test asserting the translated output, then swap the source strings. +- Patterns to follow: existing `useTranslation`-style hook usage; the centralized-constants pattern in core. +- Test scenarios: + - A migrated component renders the `en` value identical to its prior hardcoded string (no visible regression). + - The same component renders the `fr` value when locale is `fr` (fixture catalog). + - A date rendered via the shared helper formats per active locale (`en` vs `fr` vs `zh-CN` differ as expected). + - A pluralized string (e.g. "N tasks") selects the correct CLDR category in `en`, `fr`, and `zh-CN` (single `other`). + - A width-constrained component (board column header, sidebar item, badge/chip) with a long translated string (e.g. a ~35% longer `fr`/`de`-shaped fixture) truncates/wraps per its CSS rather than overflowing or breaking layout. + - Covers R8. +- Verification: Migrated clusters show no English regression in `en`; switching to a populated fixture locale changes the rendered copy and date/number formatting; width-constrained components hold their layout under longer strings; `i18n:status` hits the named-set threshold. + +### U6. CLI i18n runtime and CJK-safe rendering + +- Goal: Stand up the Node-side i18next instance for the Ink TUI with env/flag detection and width-safe layout. +- Requirements: R9, R10, R11. +- Dependencies: U1, U2. +- Files: + - `packages/cli/src/i18n/index.ts` — synchronous init (`initImmediate: false`), `initReactI18next`, catalogs from the **generated** `@fusion/i18n` `cli-catalogs.ts` import map (`cli` + shared namespaces; tsup inlines via existing `noExternal: [/^@fusion\//]` + `splitting: false`), `fallbackLng: 'en'`, env detection (`LC_ALL → LC_MESSAGES → LANG → LANGUAGE`). Using the generated map (not a hand-edited import block) is what makes a new locale drop-in here, satisfying R14 on the CLI surface. + - `packages/cli/src/commands/dashboard-tui/controller.ts` — wrap `DashboardApp` with `` at the `render(...)` call (line 685); resolve locale via precedence flag → `GlobalSettings.language` → env → `en`. + - `packages/cli/src/bin.ts` and the relevant command(s) — accept a `--lang` flag and pass it into init. + - Ink width handling: decide between **upgrading `ink` 6.8 → 7.0** (correct CJK double-width measurement built in) or staying on 6.8 with explicit `string-width` math at hand-built layout sites. If upgrading: bump `react`/`@types/react` in `packages/cli/package.json` from `^19.0.0` to `^19.2.0` to match Ink 7's declared peer floor, and add `string-width` only if staying on 6.8. See Risks for the trade-off. + - `packages/cli/src/i18n/__tests__/i18n.test.ts`, plus a TUI width/snapshot test. +- Execution note: **Spike first** — before committing the runtime, verify `useTranslation` re-renders on `changeLanguage` and `` interpolation behave under Ink's custom reconciler (not react-dom). The single-runtime decision (KTD1) assumes this works; there is no cited precedent. If `` misbehaves under Ink, prefer plain `t()` calls over `` in TUI components (the synchronous `initImmediate: false` init already avoids the Suspense path). +- Approach: Separate instance from the browser (no DOM/HTTP backend). Catalogs bundle into the published binary via tsup; keep them small. Detection precedence is explicit and unit-tested. Never translate single-letter keybinding accelerators (`C`/`V`/`X`/`P`/…) — translate only their labels. +- Patterns to follow: existing Ink render setup in `controller.ts`; existing command/flag parsing in `commands/`. +- Test scenarios: + - `--lang zh-TW` overrides a conflicting `GlobalSettings.language` and a conflicting `LANG` env value. + - With no flag and no setting, `LANG=fr_FR.UTF-8` resolves to `fr`; an unsupported `LANG=de_DE` falls back to `en`. + - First rendered frame is already localized (synchronous init — no English flash). + - `changeLanguage` under the Ink reconciler re-renders translated labels (validates the single-runtime assumption). + - A CJK-translated label in a bordered/columnar layout does not misalign borders (width measured as double-width). + - Keybinding accelerators remain ASCII single letters regardless of locale. + - Covers R9, R10, R11. +- Verification: `fusion` TUI launches localized per env/flag/settings; CJK labels render without breaking section layouts; a newly-added locale appears in the TUI after regen with no hand-edited CLI source; published CLI dependency footprint stays minimal. + +### U7. CLI string migration + +- Goal: Migrate Ink TUI user-facing labels into the `cli` (and shared) namespaces. +- Requirements: R12 readiness for the CLI surface; CLI portion of R2. +- Dependencies: U6. +- Files (representative): + - `packages/cli/src/commands/dashboard-tui/app.tsx` and TUI sub-components — `` literals → `t()`. + - `packages/cli/src/commands/dashboard-tui/state.ts` — `SECTION_ORDER`/section labels routed through keys. + - `packages/cli/src/commands/*.ts` — top-level CLI command output strings (help/status lines) as warranted. + - `packages/i18n/locales/en/cli.json` — grows with migrated keys. +- Approach: Same JSX-surgical discipline as U5. Translate labels and prose; leave accelerators, file paths, and special-cased QR/SVG payloads untouched. Run `i18n:extract` after each cluster. +- Patterns to follow: U5's migration approach; existing Ink component structure. +- Test scenarios: + - A migrated TUI screen renders identical `en` output to its prior hardcoded form. + - The same screen renders translated output under a fixture locale. + - Width-sensitive sections still align with the longest translated label (CJK fixture). + - Covers the CLI portion of R2. +- Verification: TUI screens render correctly in `en` and a populated fixture locale; `i18n:status` reflects `cli` namespace coverage. + +### U8. Translations and contributor workflow + +- Goal: Ship complete `zh-CN`, `zh-TW`, `fr`, `es` catalogs and document the drop-in language process. +- Requirements: R12, R14 (documentation), R13 (status gate enforced full). +- Dependencies: U5, U7 (catalogs must be key-complete before translation). +- Files: + - `packages/i18n/locales/{zh-CN,zh-TW,fr,es}/*.json` — filled translations (each independently localized; zh-CN and zh-TW never auto-converted). + - `docs/i18n-contributing.md` — "Adding a language" + "Updating translations" guide: the `i18next.config.ts` + `SUPPORTED_LOCALES` two-line registration, `i18n:sync` to scaffold, translate, `i18n:status` to verify. + - `docs/contributing.md` and `docs/cli-reference.md` — cross-link the i18n guide; document the `--lang` flag and the Settings language control. + - CI — flip the `status` gate to require 100% completion for the **shipped key set** per locale; newly-added migration keys land on the pending allowlist (U2) until translated, so adding `en` keys never deadlocks CI mid-migration. +- Approach: Translations may be sourced however the team prefers (in-house, vendor, community) — the unit's engineering deliverable is key-complete, plural-correct, structurally-synced catalogs plus the gate and docs. **Precondition:** because the 100% gate cannot pass without real translations, name the sourcing owner/path before U8 starts; until then the four locales stay on the pending allowlist (not "shipped") so the rest of the plan ships an English-only-but-localization-ready product without a red CI gate. Endonyms and brand `{{brand}}` variables handled per KTD9. +- Patterns to follow: existing `docs/` topical-guide structure. +- Test scenarios: + - `i18n:status` reports 100% for all four locales (CI gate passes). + - Each locale loads and renders in the dashboard and TUI without missing-key fallback to `en` for shipped namespaces. + - French/Spanish plural-bearing keys carry the `one`/`many`/`other` forms; both Chinese carry only `other`. + - Test expectation for the docs file: none -- documentation only. + - Covers R12, R14. +- Verification: All four locales render end-to-end on both surfaces; the "add a language" doc walks a contributor from zero to a synced catalog set with no feature-code edits. + +--- + +## Scope Boundaries + +In scope: the localization runtime for both surfaces, English extraction of user-facing strings (incremental), the four target locales, locale-aware formatting, persistence, and the contributor workflow. + +### Deferred to Follow-Up Work + +- A dedicated `eslint-plugin-i18next`/`no-restricted-syntax` editor-time guardrail for hardcoded strings (KTD6 uses `i18next-cli lint` in CI as the primary guard; the ESLint rule is additive polish and noisy across 464 files). +- Migrating the **long tail** of low-traffic dashboard views — U5 establishes the pattern and covers high-traffic/centralized surfaces; remaining views are mechanical follow-ups gated by `i18n:status` rather than blocking this plan. +- Server-side / API error message localization beyond what surfaces in the UI (the Express API in `packages/dashboard/src` is not a user-reading surface). +- RTL language support (Arabic/Hebrew) — none of the target locales are RTL; layout mirroring is out of scope until an RTL locale is requested. +- Switching the persisted-locale `localStorage` key from the `kb-` to `fn-` prefix — owned by the kb→fn rename track, not this plan (KTD8). + +### Outside this work + +- Translating documentation, marketing, or README content (this is product-UI localization). +- A translation-management SaaS integration (Locize, Crowdin, etc.) — the `i18next-cli` file-based workflow is the chosen path. + +--- + +## 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. +- **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. +- **Published-package resolution.** `@fusion/i18n` is a private workspace dep inlined into the published CLI; without stripping it from the publish manifest, `npm install @runfusion/fusion` would 404. Mitigation: U2 adds `@fusion/i18n` to `prepare-publish-manifest.mjs` and verifies via `npm pack`; core `i18next` + `react-i18next` only in the CLI runtime, tooling in devDependencies, plus the required changeset. +- **Translation sourcing & quality / zh-CN vs zh-TW.** The two Chinese catalogs must be independently localized; auto-conversion produces unnatural text (KTD7). The 100% CI gate cannot pass without real translations and every migrated `en` key re-opens locale gaps — mitigated by the pending-keys allowlist (U2/U8) and by naming a sourcing owner before U8 starts. Dependency: actual translation sourcing (process, not engineering). + +--- + +## Sources / Research + +- i18next v26 (built-in Intl formatter always active; legacy `interpolation.format` removed; `TFunction` from `i18next`) — i18next migration guide & TypeScript docs. +- `i18next-cli` (SWC-based extract/sync/types/status/lint) replaces the archived `i18next-parser` (archived Feb 2026) — i18next-cli GitHub. +- Lazy catalogs via `i18next-resources-to-backend` + Vite dynamic import — i18next "add or load translations"; lazy-loading guidance. +- Script-aware Chinese fallback + `load: 'currentOnly'` for zh-CN/zh-TW separation — i18next fallback principles, issue #1467. +- Ink 7.0 CJK/`string-width` rendering fixes — Ink GitHub; Ink 7.0 release writeup. +- Env-based CLI detection (`LC_ALL/LANG/…`) — `i18next-cli-language-detector`. +- Vite 6 variable-dynamic-import code-splitting rules (relative-prefix requirement, one `*` per segment, no alias/bare/node_modules resolution via `@rollup/plugin-dynamic-import-vars`; `import.meta.glob` cross-package limitation) — Vite Features docs; `vitejs/vite` issues #2390, #5728, #12180; `i18next-resources-to-backend` recommended loader shape (i18next docs). Drives KTD3a and the U3 build-assertion gate. +- `@fusion/i18n` as the catalog home (vs. `@fusion/core` layering inversion, vs. dashboard-app-tree dependency inversion) — repo seams: dashboard `@fusion/core` type-only alias `packages/dashboard/vite.config.ts:124`; CLI `noExternal: [/^@fusion\//]` + `splitting:false` + `conditions:["source"]` in `packages/cli/tsup.config.ts`; core `files:["dist","README.md"]` in `packages/core/package.json`. +- Repo seams: provider stack `packages/dashboard/app/App.tsx:2044`; settings `packages/core/src/types.ts:2433`, `packages/core/src/settings-schema.ts:18`; theme-persistence pattern `packages/dashboard/app/hooks/useTheme.ts`; Settings Appearance `packages/dashboard/app/components/SettingsModal.tsx:3691`; Ink mount `packages/cli/src/commands/dashboard-tui/controller.ts:685`; ESLint `no-restricted-syntax` precedent `eslint.config.mjs`; AGENTS.md changeset (published-only) and static-import rules. From b8045b6f2b4a666c2528f671f04d19c60fc3abec Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 07:51:25 -0700 Subject: [PATCH 02/24] feat(core): add locale primitives and GlobalSettings.language (U1) Add SUPPORTED_LOCALES (en, zh-CN, zh-TW, fr, es), Locale, DEFAULT_LOCALE, isLocale, and validateLocale to @fusion/core, plus an optional GlobalSettings.language field registered in the schema defaults and keys. Locale primitives live in types.ts so the dashboard's @fusion/core->types.ts Vite alias can reach them. language defaults to undefined (resolve-at-runtime). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/locale-settings.test.ts | 67 +++++++++++++++++++ packages/core/src/index.ts | 5 +- packages/core/src/settings-schema.ts | 1 + packages/core/src/settings-validation.ts | 10 +++ packages/core/src/types.ts | 22 ++++++ 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/__tests__/locale-settings.test.ts diff --git a/packages/core/src/__tests__/locale-settings.test.ts b/packages/core/src/__tests__/locale-settings.test.ts new file mode 100644 index 0000000000..f9d8a7d65b --- /dev/null +++ b/packages/core/src/__tests__/locale-settings.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + DEFAULT_GLOBAL_SETTINGS, + DEFAULT_LOCALE, + GLOBAL_SETTINGS_KEYS, + isGlobalSettingsKey, + isLocale, + isProjectSettingsKey, + type Locale, + SUPPORTED_LOCALES, + validateLocale, +} from "../index.js"; + +describe("locale primitives", () => { + it("exposes exactly the five supported locale codes", () => { + expect([...SUPPORTED_LOCALES]).toEqual(["en", "zh-CN", "zh-TW", "fr", "es"]); + }); + + it("uses en as the default/source locale", () => { + expect(DEFAULT_LOCALE).toBe("en"); + expectTypeOf().toEqualTypeOf<"en" | "zh-CN" | "zh-TW" | "fr" | "es">(); + }); + + it("narrows supported codes and rejects everything else via isLocale", () => { + for (const code of SUPPORTED_LOCALES) { + expect(isLocale(code)).toBe(true); + } + expect(isLocale("zh")).toBe(false); + expect(isLocale("de")).toBe(false); + expect(isLocale("")).toBe(false); + expect(isLocale(undefined)).toBe(false); + expect(isLocale(42)).toBe(false); + }); +}); + +describe("language global setting", () => { + it("registers language as a global-only settings key", () => { + expect(isGlobalSettingsKey("language")).toBe(true); + expect(isProjectSettingsKey("language")).toBe(false); + expect(GLOBAL_SETTINGS_KEYS).toContain("language"); + }); + + it("defaults language to undefined (resolve-at-runtime, not persisted en)", () => { + expect(DEFAULT_GLOBAL_SETTINGS.language).toBeUndefined(); + }); + + it("validates supported codes and rejects unsupported ones", () => { + expect(validateLocale("en")).toBe("en"); + expect(validateLocale("zh-CN")).toBe("zh-CN"); + expect(validateLocale("zh-TW")).toBe("zh-TW"); + expect(validateLocale("fr")).toBe("fr"); + expect(validateLocale("es")).toBe("es"); + expect(validateLocale(undefined)).toBeUndefined(); + expect(validateLocale("zh")).toBeUndefined(); + expect(validateLocale("de")).toBeUndefined(); + expect(validateLocale("")).toBeUndefined(); + expect(validateLocale(null)).toBeUndefined(); + expect(validateLocale(42)).toBeUndefined(); + }); + + it("round-trips a settings object without language unchanged (backward compat)", () => { + const legacy = { themeMode: "dark", colorTheme: "default" } as const; + const roundTripped = JSON.parse(JSON.stringify(legacy)) as Record; + expect("language" in roundTripped).toBe(false); + expect(validateLocale(roundTripped.language)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a00a01622f..941a6394fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js"; +export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; +export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -436,6 +436,7 @@ export { validateDirectMergeCommitStrategy, validateGithubAuthMode, validateGithubRepoSlug, + validateLocale, validateSandboxBackendName, validateSandboxFailureMode, validateSandboxPolicy, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 0f77561dc0..2ee12e8583 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -19,6 +19,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { themeMode: "dark", colorTheme: "default", dashboardFontScalePct: 100, + language: undefined, defaultProvider: undefined, defaultModelId: undefined, testMode: undefined, diff --git a/packages/core/src/settings-validation.ts b/packages/core/src/settings-validation.ts index 33952de85c..2ca592b6a9 100644 --- a/packages/core/src/settings-validation.ts +++ b/packages/core/src/settings-validation.ts @@ -3,12 +3,14 @@ import type { GithubAuthMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, + Locale, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, UnavailableNodePolicy, } from "./types.js"; +import { isLocale } from "./types.js"; const UNAVAILABLE_NODE_POLICIES: readonly UnavailableNodePolicy[] = ["block", "fallback-local"] as const; const DIRECT_MERGE_COMMIT_STRATEGIES: readonly DirectMergeCommitStrategy[] = ["auto", "always-squash", "always-rebase"] as const; @@ -52,6 +54,14 @@ export function validateUnavailableNodePolicy(value: unknown): UnavailableNodePo : undefined; } +/** Returns a validated UI locale for global settings, otherwise undefined. */ +export function validateLocale(value: unknown): Locale | undefined { + if (value === undefined) { + return undefined; + } + return isLocale(value) ? value : undefined; +} + /** Returns a validated direct-merge commit strategy for project settings, otherwise undefined. */ export function validateDirectMergeCommitStrategy(value: unknown): DirectMergeCommitStrategy | undefined { if (value === undefined) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec127dc005..1534acaeef 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -207,6 +207,24 @@ export const COLOR_THEMES = [ ] as const; export type ColorTheme = (typeof COLOR_THEMES)[number]; +/** UI locales supported across the dashboard and terminal UI. `en` is the + * source-of-truth language and the fallback for all others. Adding a locale + * here (plus translated catalogs) is the only code change a new language + * needs — see `@fusion/i18n`. zh-CN and zh-TW are independent catalogs and + * are never auto-converted between scripts. */ +export const SUPPORTED_LOCALES = ["en", "zh-CN", "zh-TW", "fr", "es"] as const; +export type Locale = (typeof SUPPORTED_LOCALES)[number]; +/** Source-of-truth language and the fallback for all locales. */ +export const DEFAULT_LOCALE: Locale = "en"; + +/** Narrow an arbitrary value to a supported `Locale`. */ +export function isLocale(value: unknown): value is Locale { + return ( + typeof value === "string" && + (SUPPORTED_LOCALES as readonly string[]).includes(value) + ); +} + export type PrStatus = "open" | "closed" | "merged" | "draft"; export type MergeStrategy = "direct" | "pull-request"; export type MergeIntegrationWorktreeMode = @@ -2437,6 +2455,10 @@ export interface GlobalSettings { colorTheme?: ColorTheme; /** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */ dashboardFontScalePct?: number; + /** Active UI locale (e.g. `"en"`, `"zh-CN"`, `"fr"`). One of `SUPPORTED_LOCALES`. + * When unset, each surface resolves the locale at runtime (browser/env + * detection) and falls back to `DEFAULT_LOCALE` ("en"). */ + language?: Locale; /** Default AI model provider name (e.g. `"anthropic"`, `"openai"`). * Must be set together with `defaultModelId`. When both are undefined, * the engine uses pi's automatic model resolution. */ From 9072d71306966002d9097b306d48fb78ced07c4f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:00:31 -0700 Subject: [PATCH 03/24] feat(i18n): add @fusion/i18n package, catalogs, and i18next-cli tooling (U2) Create the @fusion/i18n package as the authored source-of-truth: shared i18next config (namespace split, script-aware zh-CN/zh-TW fallback, plural setup), en base catalogs, and a generated CLI static-import map so the terminal surface is drop-in for new locales. Add the i18next-cli workflow (extract/sync/types/status/lint) wired as root i18n:* scripts, install the i18next stack into dashboard + CLI, strip @fusion/i18n from the published CLI manifest, gitignore the generated dashboard catalog tree, and add a changeset. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/i18n-localization-foundation.md | 9 + .gitignore | 4 + i18next.config.ts | 46 + package.json | 7 + packages/cli/package.json | 5 +- .../cli/scripts/prepare-publish-manifest.mjs | 1 + packages/dashboard/package.json | 15 +- packages/i18n/locales/en/app.json | 9 + packages/i18n/locales/en/cli.json | 6 + packages/i18n/locales/en/common.json | 15 + packages/i18n/locales/en/errors.json | 4 + packages/i18n/locales/es/app.json | 9 + packages/i18n/locales/es/cli.json | 6 + packages/i18n/locales/es/common.json | 15 + packages/i18n/locales/es/errors.json | 4 + packages/i18n/locales/fr/app.json | 9 + packages/i18n/locales/fr/cli.json | 6 + packages/i18n/locales/fr/common.json | 15 + packages/i18n/locales/fr/errors.json | 4 + packages/i18n/locales/zh-CN/app.json | 9 + packages/i18n/locales/zh-CN/cli.json | 6 + packages/i18n/locales/zh-CN/common.json | 15 + packages/i18n/locales/zh-CN/errors.json | 4 + packages/i18n/locales/zh-TW/app.json | 9 + packages/i18n/locales/zh-TW/cli.json | 6 + packages/i18n/locales/zh-TW/common.json | 15 + packages/i18n/locales/zh-TW/errors.json | 4 + packages/i18n/package.json | 37 + packages/i18n/scripts/gen-cli-catalogs.mjs | 49 + packages/i18n/src/__tests__/config.test.ts | 51 + packages/i18n/src/cli-catalogs.ts | 45 + packages/i18n/src/config.ts | 54 + packages/i18n/src/index.ts | 3 + packages/i18n/tsconfig.json | 11 + packages/i18n/vitest.config.ts | 14 + pnpm-lock.yaml | 929 +++++++++++++++++- 36 files changed, 1419 insertions(+), 31 deletions(-) create mode 100644 .changeset/i18n-localization-foundation.md create mode 100644 i18next.config.ts create mode 100644 packages/i18n/locales/en/app.json create mode 100644 packages/i18n/locales/en/cli.json create mode 100644 packages/i18n/locales/en/common.json create mode 100644 packages/i18n/locales/en/errors.json create mode 100644 packages/i18n/locales/es/app.json create mode 100644 packages/i18n/locales/es/cli.json create mode 100644 packages/i18n/locales/es/common.json create mode 100644 packages/i18n/locales/es/errors.json create mode 100644 packages/i18n/locales/fr/app.json create mode 100644 packages/i18n/locales/fr/cli.json create mode 100644 packages/i18n/locales/fr/common.json create mode 100644 packages/i18n/locales/fr/errors.json create mode 100644 packages/i18n/locales/zh-CN/app.json create mode 100644 packages/i18n/locales/zh-CN/cli.json create mode 100644 packages/i18n/locales/zh-CN/common.json create mode 100644 packages/i18n/locales/zh-CN/errors.json create mode 100644 packages/i18n/locales/zh-TW/app.json create mode 100644 packages/i18n/locales/zh-TW/cli.json create mode 100644 packages/i18n/locales/zh-TW/common.json create mode 100644 packages/i18n/locales/zh-TW/errors.json create mode 100644 packages/i18n/package.json create mode 100644 packages/i18n/scripts/gen-cli-catalogs.mjs create mode 100644 packages/i18n/src/__tests__/config.test.ts create mode 100644 packages/i18n/src/cli-catalogs.ts create mode 100644 packages/i18n/src/config.ts create mode 100644 packages/i18n/src/index.ts create mode 100644 packages/i18n/tsconfig.json create mode 100644 packages/i18n/vitest.config.ts diff --git a/.changeset/i18n-localization-foundation.md b/.changeset/i18n-localization-foundation.md new file mode 100644 index 0000000000..5b2d8a4618 --- /dev/null +++ b/.changeset/i18n-localization-foundation.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Add a localization (i18n) foundation across the UI. Introduces react-i18next-backed translation for both the dashboard and the terminal UI, with English as the source language and Simplified Chinese, Traditional Chinese, French, and Spanish as target locales. + +- New `@fusion/i18n` package holding the authored catalogs and shared i18next configuration (namespace split, script-aware zh-CN/zh-TW fallback, plural setup). +- A `language` preference (`fusion settings`) and a Settings language switcher; the CLI resolves locale from `--lang`, settings, then environment. +- An `i18next-cli` workflow (`extract`/`sync`/`types`/`status`/`lint`) so adding a future language is a translate-only, near-zero-code operation. diff --git a/.gitignore b/.gitignore index 7362b6f83b..149f014bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ dist/ dist-electron/ *.tsbuildinfo +# Generated i18n catalogs synced into the dashboard tree from @fusion/i18n +# (authored source-of-truth lives in packages/i18n/locales/) +packages/dashboard/app/locales/ + # Desktop packaging artifacts *.dmg *.dmg.blockmap diff --git a/i18next.config.ts b/i18next.config.ts new file mode 100644 index 0000000000..03283d9f7d --- /dev/null +++ b/i18next.config.ts @@ -0,0 +1,46 @@ +import { + defineConfig, + recommendedAcceptedAttributes, + recommendedAcceptedTags, +} from "i18next-cli"; + +/** + * i18next-cli workflow config for the whole monorepo. + * + * - `extract` pulls t()/ keys from the dashboard and CLI source into the + * authored `en` catalogs under @fusion/i18n. + * - `sync` propagates the `en` key structure to the four other locales. + * - `types` regenerates key types from the `en` catalogs. + * - `status` reports per-locale completion (CI gate). + * - `lint` flags hardcoded user-facing strings (primary guardrail). + * + * Namespaces are routed by the `ns:` prefix in keys / `useTranslation(ns)` in + * source, not by file path. `common` is the default namespace. + */ +export default defineConfig({ + locales: ["en", "zh-CN", "zh-TW", "fr", "es"], + extract: { + input: [ + "packages/dashboard/app/**/*.{ts,tsx}", + "packages/cli/src/**/*.{ts,tsx}", + "!**/__tests__/**", + "!**/*.test.*", + ], + output: "packages/i18n/locales/{{language}}/{{namespace}}.json", + primaryLanguage: "en", + defaultNS: "common", + keySeparator: ".", + nsSeparator: ":", + // Untranslated secondary-locale keys stay empty so `status` can measure + // real completion; the active locale falls back to `en` at runtime. + defaultValue: "", + }, + types: { + input: ["packages/i18n/locales/en/*.json"], + output: "packages/i18n/src/i18next-resources.d.ts", + }, + lint: { + acceptedTags: recommendedAcceptedTags, + acceptedAttributes: recommendedAcceptedAttributes, + }, +}); diff --git a/package.json b/package.json index 5029049d85..2274f588fd 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,12 @@ "test:coverage:dashboard": "pnpm --filter @fusion/dashboard exec vitest run --silent=passed-only --reporter=dot --coverage", "test:slow-cli": "pnpm --filter @runfusion/fusion test:slow-cli", "typecheck": "pnpm -r --filter=!@fusion/desktop --filter=!@fusion/mobile typecheck", + "i18n:extract": "i18next-cli extract", + "i18n:sync": "i18next-cli sync", + "i18n:types": "i18next-cli types", + "i18n:status": "i18next-cli status", + "i18n:lint": "i18next-cli lint", + "i18n:gen-cli": "pnpm --filter @fusion/i18n gen:cli-catalogs", "changeset": "changeset", "version": "changeset version", "release": "node scripts/release.mjs", @@ -76,6 +82,7 @@ "@eslint/js": "^9.0.0", "eslint": "^9.0.0", "fast-glob": "^3.3.3", + "i18next-cli": "^1.59.1", "react-devtools-core": "^7.0.1", "tsx": "^4.19.0", "typescript": "^5.7.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index c0ae9e86dd..1d3c52eb36 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,13 +59,15 @@ "@earendil-works/pi-coding-agent": "^0.78.0", "dockerode": "^4.0.12", "express": "^5.1.0", + "i18next": "^26.3.1", "ink": "^6.8.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "ioredis": "^5.6.0", "multer": "^2.1.1", "node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1", - "react": "^19.0.0" + "react": "^19.0.0", + "react-i18next": "^17.0.8" }, "peerDependencies": { "@earendil-works/pi-ai": "*", @@ -87,6 +89,7 @@ "@fusion/core": "workspace:*", "@fusion/dashboard": "workspace:*", "@fusion/engine": "workspace:*", + "@fusion/i18n": "workspace:*", "@fusion/pi-claude-cli": "workspace:*", "@fusion/pi-llama-cpp": "workspace:*", "@types/node": "^22.0.0", diff --git a/packages/cli/scripts/prepare-publish-manifest.mjs b/packages/cli/scripts/prepare-publish-manifest.mjs index a19a9d523f..8a872f646d 100644 --- a/packages/cli/scripts/prepare-publish-manifest.mjs +++ b/packages/cli/scripts/prepare-publish-manifest.mjs @@ -8,6 +8,7 @@ export function applyPrepackTransform(pkg) { delete devDependencies["@fusion/core"]; delete devDependencies["@fusion/dashboard"]; delete devDependencies["@fusion/engine"]; + delete devDependencies["@fusion/i18n"]; delete devDependencies["@fusion/pi-claude-cli"]; delete devDependencies["@fusion/pi-llama-cpp"]; delete devDependencies["@fusion-plugin-examples/roadmap"]; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 436fd0fb7a..7ff2cd7be2 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -90,17 +90,18 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.36.4", + "@earendil-works/pi-coding-agent": "^0.78.0", + "@fusion-plugin-examples/cli-printing-press": "workspace:*", + "@fusion-plugin-examples/cursor-runtime": "workspace:*", "@fusion-plugin-examples/dependency-graph": "workspace:*", - "@fusion-plugin-examples/roadmap": "workspace:*", + "@fusion-plugin-examples/droid-runtime": "workspace:*", "@fusion-plugin-examples/hermes-runtime": "workspace:*", "@fusion-plugin-examples/openclaw-runtime": "workspace:*", - "@fusion-plugin-examples/droid-runtime": "workspace:*", - "@fusion-plugin-examples/cursor-runtime": "workspace:*", - "@fusion-plugin-examples/cli-printing-press": "workspace:*", "@fusion-plugin-examples/paperclip-runtime": "workspace:*", + "@fusion-plugin-examples/roadmap": "workspace:*", "@fusion/core": "workspace:*", "@fusion/engine": "workspace:*", - "@earendil-works/pi-coding-agent": "^0.78.0", + "@fusion/i18n": "workspace:*", "@types/multer": "^2.1.0", "@xterm/addon-fit": "^0.10.0", "@xterm/addon-search": "^0.15.0", @@ -109,6 +110,9 @@ "@xterm/xterm": "^5.5.0", "archiver": "^7.0.1", "express": "^5.1.0", + "i18next": "^26.3.1", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-resources-to-backend": "^1.2.1", "ioredis": "^5.6.0", "lucide-react": "^1.7.0", "multer": "^2.1.1", @@ -116,6 +120,7 @@ "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "ws": "^8.18.0", diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json new file mode 100644 index 0000000000..63cf775b30 --- /dev/null +++ b/packages/i18n/locales/en/app.json @@ -0,0 +1,9 @@ +{ + "settings": { + "appearance": { + "title": "Appearance", + "language": "Language", + "languageHint": "Choose the language for the {{brand}} interface." + } + } +} diff --git a/packages/i18n/locales/en/cli.json b/packages/i18n/locales/en/cli.json new file mode 100644 index 0000000000..7bfc7fe5c3 --- /dev/null +++ b/packages/i18n/locales/en/cli.json @@ -0,0 +1,6 @@ +{ + "tui": { + "loading": "Loading…", + "quit": "Quit" + } +} diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json new file mode 100644 index 0000000000..5536dda5d3 --- /dev/null +++ b/packages/i18n/locales/en/common.json @@ -0,0 +1,15 @@ +{ + "columns": { + "triage": "Planning", + "todo": "Todo", + "in-progress": "In Progress", + "in-review": "In Review", + "done": "Done", + "archived": "Archived" + }, + "actions": { + "save": "Save", + "cancel": "Cancel", + "close": "Close" + } +} diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json new file mode 100644 index 0000000000..0abd051afd --- /dev/null +++ b/packages/i18n/locales/en/errors.json @@ -0,0 +1,4 @@ +{ + "fetchProjectsFailed": "Failed to fetch projects", + "openTaskLogsFailed": "Failed to open task logs: {{detail}}" +} diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json new file mode 100644 index 0000000000..0b4940a283 --- /dev/null +++ b/packages/i18n/locales/es/app.json @@ -0,0 +1,9 @@ +{ + "settings": { + "appearance": { + "title": "", + "language": "", + "languageHint": "" + } + } +} diff --git a/packages/i18n/locales/es/cli.json b/packages/i18n/locales/es/cli.json new file mode 100644 index 0000000000..8ccc6193b3 --- /dev/null +++ b/packages/i18n/locales/es/cli.json @@ -0,0 +1,6 @@ +{ + "tui": { + "loading": "", + "quit": "" + } +} diff --git a/packages/i18n/locales/es/common.json b/packages/i18n/locales/es/common.json new file mode 100644 index 0000000000..4593684237 --- /dev/null +++ b/packages/i18n/locales/es/common.json @@ -0,0 +1,15 @@ +{ + "columns": { + "triage": "", + "todo": "", + "in-progress": "", + "in-review": "", + "done": "", + "archived": "" + }, + "actions": { + "save": "", + "cancel": "", + "close": "" + } +} diff --git a/packages/i18n/locales/es/errors.json b/packages/i18n/locales/es/errors.json new file mode 100644 index 0000000000..0af69d4b5d --- /dev/null +++ b/packages/i18n/locales/es/errors.json @@ -0,0 +1,4 @@ +{ + "fetchProjectsFailed": "", + "openTaskLogsFailed": "" +} diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json new file mode 100644 index 0000000000..0b4940a283 --- /dev/null +++ b/packages/i18n/locales/fr/app.json @@ -0,0 +1,9 @@ +{ + "settings": { + "appearance": { + "title": "", + "language": "", + "languageHint": "" + } + } +} diff --git a/packages/i18n/locales/fr/cli.json b/packages/i18n/locales/fr/cli.json new file mode 100644 index 0000000000..8ccc6193b3 --- /dev/null +++ b/packages/i18n/locales/fr/cli.json @@ -0,0 +1,6 @@ +{ + "tui": { + "loading": "", + "quit": "" + } +} diff --git a/packages/i18n/locales/fr/common.json b/packages/i18n/locales/fr/common.json new file mode 100644 index 0000000000..4593684237 --- /dev/null +++ b/packages/i18n/locales/fr/common.json @@ -0,0 +1,15 @@ +{ + "columns": { + "triage": "", + "todo": "", + "in-progress": "", + "in-review": "", + "done": "", + "archived": "" + }, + "actions": { + "save": "", + "cancel": "", + "close": "" + } +} diff --git a/packages/i18n/locales/fr/errors.json b/packages/i18n/locales/fr/errors.json new file mode 100644 index 0000000000..0af69d4b5d --- /dev/null +++ b/packages/i18n/locales/fr/errors.json @@ -0,0 +1,4 @@ +{ + "fetchProjectsFailed": "", + "openTaskLogsFailed": "" +} diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json new file mode 100644 index 0000000000..0b4940a283 --- /dev/null +++ b/packages/i18n/locales/zh-CN/app.json @@ -0,0 +1,9 @@ +{ + "settings": { + "appearance": { + "title": "", + "language": "", + "languageHint": "" + } + } +} diff --git a/packages/i18n/locales/zh-CN/cli.json b/packages/i18n/locales/zh-CN/cli.json new file mode 100644 index 0000000000..8ccc6193b3 --- /dev/null +++ b/packages/i18n/locales/zh-CN/cli.json @@ -0,0 +1,6 @@ +{ + "tui": { + "loading": "", + "quit": "" + } +} diff --git a/packages/i18n/locales/zh-CN/common.json b/packages/i18n/locales/zh-CN/common.json new file mode 100644 index 0000000000..4593684237 --- /dev/null +++ b/packages/i18n/locales/zh-CN/common.json @@ -0,0 +1,15 @@ +{ + "columns": { + "triage": "", + "todo": "", + "in-progress": "", + "in-review": "", + "done": "", + "archived": "" + }, + "actions": { + "save": "", + "cancel": "", + "close": "" + } +} diff --git a/packages/i18n/locales/zh-CN/errors.json b/packages/i18n/locales/zh-CN/errors.json new file mode 100644 index 0000000000..0af69d4b5d --- /dev/null +++ b/packages/i18n/locales/zh-CN/errors.json @@ -0,0 +1,4 @@ +{ + "fetchProjectsFailed": "", + "openTaskLogsFailed": "" +} diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json new file mode 100644 index 0000000000..0b4940a283 --- /dev/null +++ b/packages/i18n/locales/zh-TW/app.json @@ -0,0 +1,9 @@ +{ + "settings": { + "appearance": { + "title": "", + "language": "", + "languageHint": "" + } + } +} diff --git a/packages/i18n/locales/zh-TW/cli.json b/packages/i18n/locales/zh-TW/cli.json new file mode 100644 index 0000000000..8ccc6193b3 --- /dev/null +++ b/packages/i18n/locales/zh-TW/cli.json @@ -0,0 +1,6 @@ +{ + "tui": { + "loading": "", + "quit": "" + } +} diff --git a/packages/i18n/locales/zh-TW/common.json b/packages/i18n/locales/zh-TW/common.json new file mode 100644 index 0000000000..4593684237 --- /dev/null +++ b/packages/i18n/locales/zh-TW/common.json @@ -0,0 +1,15 @@ +{ + "columns": { + "triage": "", + "todo": "", + "in-progress": "", + "in-review": "", + "done": "", + "archived": "" + }, + "actions": { + "save": "", + "cancel": "", + "close": "" + } +} diff --git a/packages/i18n/locales/zh-TW/errors.json b/packages/i18n/locales/zh-TW/errors.json new file mode 100644 index 0000000000..0af69d4b5d --- /dev/null +++ b/packages/i18n/locales/zh-TW/errors.json @@ -0,0 +1,4 @@ +{ + "fetchProjectsFailed": "", + "openTaskLogsFailed": "" +} diff --git a/packages/i18n/package.json b/packages/i18n/package.json new file mode 100644 index 0000000000..5f05137e5a --- /dev/null +++ b/packages/i18n/package.json @@ -0,0 +1,37 @@ +{ + "name": "@fusion/i18n", + "version": "0.39.0", + "license": "MIT", + "description": "Fusion i18n: authored translation catalogs and shared i18next configuration for the Fusion dashboard and terminal UI.", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "source": "./src/index.ts", + "import": "./src/index.ts" + }, + "./config": { + "types": "./src/config.ts", + "source": "./src/config.ts", + "import": "./src/config.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "gen:cli-catalogs": "node scripts/gen-cli-catalogs.mjs", + "test": "vitest run --silent=passed-only --reporter=dot" + }, + "dependencies": { + "@fusion/core": "workspace:*", + "i18next": "^26.3.1" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.7.0", + "vitest": "^3.1.0" + }, + "engines": { + "node": ">=22.5.0" + }, + "private": true +} diff --git a/packages/i18n/scripts/gen-cli-catalogs.mjs b/packages/i18n/scripts/gen-cli-catalogs.mjs new file mode 100644 index 0000000000..7e878bfdad --- /dev/null +++ b/packages/i18n/scripts/gen-cli-catalogs.mjs @@ -0,0 +1,49 @@ +// Generates src/cli-catalogs.ts: a static import map of the CLI-relevant +// catalogs for every locale present under locales/. The terminal UI bundles +// catalogs statically (tsup, no lazy loading), so it needs an explicit import +// map rather than a dynamic loader. Driving this off the locales/ directory +// listing keeps "add a language" a no-code operation on the CLI side: add the +// locale (via `i18next-cli sync`), regenerate, done. +import { readdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, ".."); +const localesDir = join(root, "locales"); + +// Keep in sync with CLI_NAMESPACES in src/config.ts. +const CLI_NAMESPACES = ["common", "cli", "errors"]; + +const locales = readdirSync(localesDir, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + +const ident = (s) => s.replace(/[^a-zA-Z0-9]/g, "_"); + +const imports = []; +const entries = []; +for (const lng of locales) { + const nsLines = []; + for (const ns of CLI_NAMESPACES) { + const id = `${ident(lng)}_${ns}`; + imports.push(`import ${id} from "../locales/${lng}/${ns}.json";`); + nsLines.push(` ${ns}: ${id},`); + } + entries.push(` "${lng}": {\n${nsLines.join("\n")}\n },`); +} + +const out = [ + "// GENERATED by scripts/gen-cli-catalogs.mjs — do not edit by hand.", + "// Run `pnpm --filter @fusion/i18n gen:cli-catalogs` to regenerate.", + ...imports, + "", + "export const cliResources = {", + ...entries, + "} as const;", + "", +].join("\n"); + +writeFileSync(join(root, "src", "cli-catalogs.ts"), out); +console.log(`Generated cli-catalogs.ts for ${locales.length} locale(s): ${locales.join(", ")}`); diff --git a/packages/i18n/src/__tests__/config.test.ts b/packages/i18n/src/__tests__/config.test.ts new file mode 100644 index 0000000000..91836e5c90 --- /dev/null +++ b/packages/i18n/src/__tests__/config.test.ts @@ -0,0 +1,51 @@ +import { SUPPORTED_LOCALES } from "@fusion/core"; +import { describe, expect, it } from "vitest"; +import { cliResources } from "../cli-catalogs.js"; +import { + baseInitOptions, + CLI_NAMESPACES, + DASHBOARD_NAMESPACES, + DEFAULT_NAMESPACE, + FALLBACK_LNG, + NAMESPACES, +} from "../config.js"; + +describe("@fusion/i18n config", () => { + it("dashboard and cli namespaces are subsets of NAMESPACES", () => { + for (const ns of [...DASHBOARD_NAMESPACES, ...CLI_NAMESPACES]) { + expect(NAMESPACES).toContain(ns); + } + }); + + it("defaults to the common namespace", () => { + expect(DEFAULT_NAMESPACE).toBe("common"); + }); + + it("keeps zh-CN and zh-TW separate (load: currentOnly)", () => { + const opts = baseInitOptions(); + expect(opts.load).toBe("currentOnly"); + expect(opts.supportedLngs).toEqual([...SUPPORTED_LOCALES]); + expect(opts.interpolation?.escapeValue).toBe(false); + }); + + it("routes Chinese scripts and defaults everything else to en", () => { + expect(FALLBACK_LNG.zh).toEqual(["zh-CN"]); + expect(FALLBACK_LNG["zh-Hans"]).toEqual(["zh-CN"]); + expect(FALLBACK_LNG["zh-Hant"]).toEqual(["zh-TW"]); + expect(FALLBACK_LNG.default).toEqual(["en"]); + }); + + it("ships a CLI catalog map for every supported locale and namespace", () => { + for (const lng of SUPPORTED_LOCALES) { + expect(cliResources).toHaveProperty(lng); + for (const ns of CLI_NAMESPACES) { + expect(cliResources[lng]).toHaveProperty(ns); + } + } + }); + + it("has real en content (catalogs wired, not empty)", () => { + expect(cliResources.en.cli).toMatchObject({ tui: { loading: expect.any(String) } }); + expect(cliResources.en.common).toMatchObject({ columns: { done: "Done" } }); + }); +}); diff --git a/packages/i18n/src/cli-catalogs.ts b/packages/i18n/src/cli-catalogs.ts new file mode 100644 index 0000000000..354c3bc256 --- /dev/null +++ b/packages/i18n/src/cli-catalogs.ts @@ -0,0 +1,45 @@ +// GENERATED by scripts/gen-cli-catalogs.mjs — do not edit by hand. +// Run `pnpm --filter @fusion/i18n gen:cli-catalogs` to regenerate. +import en_common from "../locales/en/common.json"; +import en_cli from "../locales/en/cli.json"; +import en_errors from "../locales/en/errors.json"; +import es_common from "../locales/es/common.json"; +import es_cli from "../locales/es/cli.json"; +import es_errors from "../locales/es/errors.json"; +import fr_common from "../locales/fr/common.json"; +import fr_cli from "../locales/fr/cli.json"; +import fr_errors from "../locales/fr/errors.json"; +import zh_CN_common from "../locales/zh-CN/common.json"; +import zh_CN_cli from "../locales/zh-CN/cli.json"; +import zh_CN_errors from "../locales/zh-CN/errors.json"; +import zh_TW_common from "../locales/zh-TW/common.json"; +import zh_TW_cli from "../locales/zh-TW/cli.json"; +import zh_TW_errors from "../locales/zh-TW/errors.json"; + +export const cliResources = { + "en": { + common: en_common, + cli: en_cli, + errors: en_errors, + }, + "es": { + common: es_common, + cli: es_cli, + errors: es_errors, + }, + "fr": { + common: fr_common, + cli: fr_cli, + errors: fr_errors, + }, + "zh-CN": { + common: zh_CN_common, + cli: zh_CN_cli, + errors: zh_CN_errors, + }, + "zh-TW": { + common: zh_TW_common, + cli: zh_TW_cli, + errors: zh_TW_errors, + }, +} as const; diff --git a/packages/i18n/src/config.ts b/packages/i18n/src/config.ts new file mode 100644 index 0000000000..016ddec593 --- /dev/null +++ b/packages/i18n/src/config.ts @@ -0,0 +1,54 @@ +import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@fusion/core"; +import type { FallbackLngObjList, InitOptions } from "i18next"; + +/** + * Shared, framework-agnostic i18next configuration for both Fusion UI surfaces. + * + * The dashboard (browser) and the terminal UI (Node) each build their own + * i18next instance, but they share the locale list, namespace split, fallback + * chain, and base options defined here so the two surfaces stay consistent. + */ + +/** All translation namespaces. Split so each surface loads only what it needs. */ +export const NAMESPACES = ["common", "app", "errors", "cli"] as const; +export type Namespace = (typeof NAMESPACES)[number]; + +/** Default namespace keys resolve against when none is specified. */ +export const DEFAULT_NAMESPACE: Namespace = "common"; + +/** Namespaces the browser dashboard loads (skips the terminal-only `cli`). */ +export const DASHBOARD_NAMESPACES: readonly Namespace[] = ["common", "app", "errors"]; + +/** Namespaces the terminal UI loads (skips the dashboard-only `app`). */ +export const CLI_NAMESPACES: readonly Namespace[] = ["common", "cli", "errors"]; + +/** + * Script-aware fallback chain. A generic `zh` resolves to Simplified, the + * Han-script tags resolve to their region catalog, and everything else falls + * back to the source language. Combined with `load: "currentOnly"` this keeps + * `zh-CN` and `zh-TW` from ever collapsing into a single generic `zh`. + */ +export const FALLBACK_LNG: FallbackLngObjList = { + "zh-Hans": ["zh-CN"], + "zh-Hant": ["zh-TW"], + zh: ["zh-CN"], + default: [DEFAULT_LOCALE], +}; + +/** + * Base init options shared by every surface. Each surface spreads these and + * adds its own resource-loading strategy (lazy backend for the dashboard, + * static `resources` for the CLI) plus framework plugins. + */ +export function baseInitOptions(): InitOptions { + return { + supportedLngs: [...SUPPORTED_LOCALES], + fallbackLng: FALLBACK_LNG, + // Never collapse zh-CN/zh-TW into a generic `zh`. + load: "currentOnly", + nonExplicitSupportedLngs: false, + // React (and Ink) escape on render; double-escaping mangles output. + interpolation: { escapeValue: false }, + returnNull: false, + }; +} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts new file mode 100644 index 0000000000..38192f8e46 --- /dev/null +++ b/packages/i18n/src/index.ts @@ -0,0 +1,3 @@ +export * from "./config.js"; +export { cliResources } from "./cli-catalogs.js"; +export type { Locale } from "@fusion/core"; diff --git a/packages/i18n/tsconfig.json b/packages/i18n/tsconfig.json new file mode 100644 index 0000000000..b55e80cea4 --- /dev/null +++ b/packages/i18n/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "rootDir": ".", + "types": ["node", "vitest/globals"], + "resolveJsonModule": true + }, + "include": ["src/**/*", "locales/**/*.json"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/i18n/vitest.config.ts b/packages/i18n/vitest.config.ts new file mode 100644 index 0000000000..3d2ce17782 --- /dev/null +++ b/packages/i18n/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + // Use @fusion/core's TypeScript source so tests don't require a dist build. + "@fusion/core": resolve(__dirname, "../core/src/index.ts"), + }, + }, + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 659232f24d..cab0352575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: fast-glob: specifier: ^3.3.3 version: 3.3.3 + i18next-cli: + specifier: ^1.59.1 + version: 1.59.1(@types/node@25.5.2)(typescript@5.9.3) react-devtools-core: specifier: ^7.0.1 version: 7.0.1 @@ -43,16 +46,19 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@earendil-works/pi-coding-agent': specifier: ^0.78.0 - version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + version: 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) dockerode: specifier: ^4.0.12 version: 4.0.12 express: specifier: ^5.1.0 version: 5.2.1 + i18next: + specifier: ^26.3.1 + version: 26.3.1(typescript@5.9.3) ink: specifier: ^6.8.0 version: 6.8.0(@types/react@19.2.14)(react-devtools-core@7.0.1)(react@19.2.4) @@ -74,6 +80,9 @@ importers: react: specifier: ^19.0.0 version: 19.2.4 + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) devDependencies: '@fusion/core': specifier: workspace:* @@ -84,6 +93,9 @@ importers: '@fusion/engine': specifier: workspace:* version: link:../engine + '@fusion/i18n': + specifier: workspace:* + version: link:../i18n '@fusion/pi-claude-cli': specifier: workspace:* version: link:../pi-claude-cli @@ -110,7 +122,7 @@ importers: version: 4.0.0(@types/react@19.2.14) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) tsx: specifier: ^4.19.0 version: 4.21.0 @@ -236,6 +248,9 @@ importers: '@fusion/engine': specifier: workspace:* version: link:../engine + '@fusion/i18n': + specifier: workspace:* + version: link:../i18n '@types/multer': specifier: ^2.1.0 version: 2.1.0 @@ -260,6 +275,15 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + i18next: + specifier: ^26.3.1 + version: 26.3.1(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 + i18next-resources-to-backend: + specifier: ^1.2.1 + version: 1.2.1 ioredis: specifier: ^5.6.0 version: 5.10.1 @@ -281,6 +305,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.14)(react@19.2.4) @@ -472,6 +499,25 @@ importers: specifier: ^3.1.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0) + packages/i18n: + dependencies: + '@fusion/core': + specifier: workspace:* + version: link:../core + i18next: + specifier: ^26.3.1 + version: 26.3.1(typescript@5.9.3) + devDependencies: + '@types/node': + specifier: ^25.5.2 + version: 25.5.2 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.2)(jiti@2.7.0)(jsdom@29.0.1)(tsx@4.21.0)(yaml@2.9.0) + packages/mobile: dependencies: '@capacitor/app': @@ -516,10 +562,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) devDependencies: '@types/node': specifier: ^25.5.2 @@ -1334,6 +1380,12 @@ packages: '@codemirror/view@6.40.0': resolution: {integrity: sha512-WA0zdU7xfF10+5I3HhUUq3kqOx3KjqmtQ9lqZjfK7jtYk4G72YW9rezcSywpaUMCWOMlq+6E0pO1IWg1TNIhtg==} + '@croct/json5-parser@0.2.2': + resolution: {integrity: sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw==} + + '@croct/json@2.1.0': + resolution: {integrity: sha512-UrWfjNQVlBxN+OVcFwHmkjARMW55MBN04E9KfGac8ac8z1QnFVuiOOFtMWXCk3UwsyRqhsNaFoYLZC+xxqsVjQ==} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -1987,6 +2039,55 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -1996,6 +2097,91 @@ packages: '@types/node': optional: true + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@ionic/cli-framework-output@2.2.8': resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} engines: {node: '>=16.0.0'} @@ -2406,6 +2592,9 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@silvia-odwyer/photon-node@0.3.4': resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} @@ -2413,6 +2602,10 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@smithy/core@3.24.5': resolution: {integrity: sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==} engines: {node: '>=18.0.0'} @@ -2453,6 +2646,99 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} + '@swc/core-darwin-arm64@1.15.40': + resolution: {integrity: sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.40': + resolution: {integrity: sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.40': + resolution: {integrity: sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.40': + resolution: {integrity: sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.15.40': + resolution: {integrity: sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.40': + resolution: {integrity: sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-s390x-gnu@1.15.40': + resolution: {integrity: sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.40': + resolution: {integrity: sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.15.40': + resolution: {integrity: sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.15.40': + resolution: {integrity: sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.40': + resolution: {integrity: sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.40': + resolution: {integrity: sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.40': + resolution: {integrity: sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} + '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} @@ -3242,6 +3528,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -3272,10 +3562,18 @@ packages: resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -3284,6 +3582,10 @@ packages: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} @@ -3334,6 +3636,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3817,6 +4123,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} engines: {node: '>=6'} @@ -3869,9 +4179,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -3898,6 +4217,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -4037,6 +4360,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -4156,6 +4483,9 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4182,6 +4512,33 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next-cli@1.59.1: + resolution: {integrity: sha512-Zr65j0BVePuFzMz7kz0h7TdDEZn2msWt4+F59UhvQghH6nHAu0xMlzOmPR7ddxcKjpK0HFJfRoTJX3QIlZToEg==} + engines: {node: '>=22'} + hasBin: true + + i18next-resources-for-ts@2.1.0: + resolution: {integrity: sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow==} + hasBin: true + + i18next-resources-to-backend@1.2.1: + resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} + + i18next@26.3.1: + resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -4275,6 +4632,15 @@ packages: inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + inquirer@13.4.3: + resolution: {integrity: sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + peerDependencies: + '@types/node': ^25.5.2 + peerDependenciesMeta: + '@types/node': + optional: true + ioredis@5.10.1: resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} @@ -4332,6 +4698,10 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4350,6 +4720,10 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + is-subdir@1.2.0: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} @@ -4358,6 +4732,10 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -4484,6 +4862,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -4582,6 +4963,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4834,6 +5219,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -4932,6 +5321,10 @@ packages: resolution: {integrity: sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==} engines: {node: '>=10'} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -5012,6 +5405,10 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5042,6 +5439,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -5069,6 +5470,10 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -5125,6 +5530,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -5155,6 +5564,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} @@ -5278,6 +5691,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + proc-log@5.0.0: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -5386,6 +5803,22 @@ packages: peerDependencies: react: ^19.2.4 + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -5409,6 +5842,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + read-binary-file-arch@1.0.6: resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} hasBin: true @@ -5439,6 +5876,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -5507,6 +5948,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -5542,9 +5987,16 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-async@4.0.6: + resolution: {integrity: sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==} + engines: {node: '>=0.12.0'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -5764,6 +6216,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -5808,6 +6264,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -6093,6 +6553,10 @@ packages: resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} engines: {node: '>=22.19.0'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6144,6 +6608,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} @@ -6242,6 +6711,10 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -6410,6 +6883,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} @@ -7124,6 +7601,12 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@croct/json5-parser@0.2.2': + dependencies: + '@croct/json': 2.1.0 + + '@croct/json@2.1.0': {} + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -7167,9 +7650,9 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -7181,9 +7664,9 @@ snapshots: - ws - zod - '@earendil-works/pi-agent-core@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) ignore: 7.0.5 typebox: 1.1.38 yaml: 2.9.0 @@ -7243,16 +7726,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@4.3.6) + openai: 6.26.0(ws@8.20.0)(zod@3.25.76) partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -7263,16 +7746,16 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - openai: 6.26.0(ws@8.20.0)(zod@3.25.76) + openai: 6.26.0(ws@8.20.0)(zod@4.3.6) partial-json: 0.1.7 typebox: 1.1.38 transitivePeerDependencies: @@ -7352,10 +7835,10 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: - '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) - '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-tui': 0.77.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 @@ -7381,11 +7864,11 @@ snapshots: - ws - zod - '@earendil-works/pi-coding-agent@0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@earendil-works/pi-agent-core': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-ai': 0.78.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) - '@earendil-works/pi-tui': 0.78.0 + '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@earendil-works/pi-tui': 0.77.0 '@silvia-odwyer/photon-node': 0.3.4 chalk: 5.6.2 cross-spawn: 7.0.6 @@ -7946,6 +8429,51 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@inquirer/ansi@2.0.7': {} + + '@inquirer/checkbox@5.2.1(@types/node@25.5.2)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/confirm@6.1.1(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/core@11.2.1(@types/node@25.5.2)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.5.2) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/editor@5.2.2(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/external-editor': 3.0.3(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/expand@5.1.1(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + '@inquirer/external-editor@1.0.3(@types/node@25.5.2)': dependencies: chardet: 2.1.1 @@ -7953,6 +8481,80 @@ snapshots: optionalDependencies: '@types/node': 25.5.2 + '@inquirer/external-editor@3.0.3(@types/node@25.5.2)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/figures@2.0.7': {} + + '@inquirer/input@5.1.2(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/number@4.1.1(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/password@5.1.1(@types/node@25.5.2)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/prompts@8.5.2(@types/node@25.5.2)': + dependencies: + '@inquirer/checkbox': 5.2.1(@types/node@25.5.2) + '@inquirer/confirm': 6.1.1(@types/node@25.5.2) + '@inquirer/editor': 5.2.2(@types/node@25.5.2) + '@inquirer/expand': 5.1.1(@types/node@25.5.2) + '@inquirer/input': 5.1.2(@types/node@25.5.2) + '@inquirer/number': 4.1.1(@types/node@25.5.2) + '@inquirer/password': 5.1.1(@types/node@25.5.2) + '@inquirer/rawlist': 5.3.1(@types/node@25.5.2) + '@inquirer/search': 4.2.1(@types/node@25.5.2) + '@inquirer/select': 5.2.1(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/rawlist@5.3.1(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/search@4.2.1(@types/node@25.5.2)': + dependencies: + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/select@5.2.1(@types/node@25.5.2)': + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.5.2) + optionalDependencies: + '@types/node': 25.5.2 + + '@inquirer/type@4.0.7(@types/node@25.5.2)': + optionalDependencies: + '@types/node': 25.5.2 + '@ionic/cli-framework-output@2.2.8': dependencies: '@ionic/utils-terminal': 2.3.5 @@ -8384,10 +8986,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true + '@sec-ant/readable-stream@0.4.1': {} + '@silvia-odwyer/photon-node@0.3.4': {} '@sindresorhus/is@4.6.0': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.5': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -8442,6 +9048,66 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 + '@swc/core-darwin-arm64@1.15.40': + optional: true + + '@swc/core-darwin-x64@1.15.40': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.40': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.40': + optional: true + + '@swc/core-linux-arm64-musl@1.15.40': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.40': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.40': + optional: true + + '@swc/core-linux-x64-gnu@1.15.40': + optional: true + + '@swc/core-linux-x64-musl@1.15.40': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.40': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.40': + optional: true + + '@swc/core-win32-x64-msvc@1.15.40': + optional: true + + '@swc/core@1.15.40': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.26 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.40 + '@swc/core-darwin-x64': 1.15.40 + '@swc/core-linux-arm-gnueabihf': 1.15.40 + '@swc/core-linux-arm64-gnu': 1.15.40 + '@swc/core-linux-arm64-musl': 1.15.40 + '@swc/core-linux-ppc64-gnu': 1.15.40 + '@swc/core-linux-s390x-gnu': 1.15.40 + '@swc/core-linux-x64-gnu': 1.15.40 + '@swc/core-linux-x64-musl': 1.15.40 + '@swc/core-win32-arm64-msvc': 1.15.40 + '@swc/core-win32-ia32-msvc': 1.15.40 + '@swc/core-win32-x64-msvc': 1.15.40 + + '@swc/counter@0.1.3': {} + + '@swc/types@0.1.26': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@4.0.6': dependencies: defer-to-connect: 2.0.1 @@ -9426,6 +10092,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: {} chownr@3.0.0: {} @@ -9446,8 +10116,14 @@ snapshots: dependencies: restore-cursor: 4.0.0 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + cli-spinners@2.9.2: {} + cli-spinners@3.4.0: {} + cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -9459,6 +10135,8 @@ snapshots: slice-ansi: 8.0.0 string-width: 8.2.0 + cli-width@4.1.0: {} + cliui@6.0.0: dependencies: string-width: 4.2.3 @@ -9509,6 +10187,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@4.1.1: {} commander@5.1.0: {} @@ -10071,6 +10751,21 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expand-template@2.0.3: {} expect-type@1.3.0: {} @@ -10148,8 +10843,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -10179,6 +10884,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -10344,6 +11053,11 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -10509,6 +11223,10 @@ snapshots: html-escaper@2.0.2: {} + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + html-url-attributes@3.0.1: {} http-cache-semantics@4.2.0: {} @@ -10542,6 +11260,55 @@ snapshots: human-id@4.1.3: {} + human-signals@8.0.1: {} + + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.29.2 + + i18next-cli@1.59.1(@types/node@25.5.2)(typescript@5.9.3): + dependencies: + '@croct/json5-parser': 0.2.2 + '@swc/core': 1.15.40 + chokidar: 5.0.0 + commander: 14.0.3 + execa: 9.6.1 + glob: 13.0.6 + i18next: 26.3.1(typescript@5.9.3) + i18next-resources-for-ts: 2.1.0 + inquirer: 13.4.3(@types/node@25.5.2) + jiti: 2.7.0 + jsonc-parser: 3.3.1 + magic-string: 0.30.21 + minimatch: 10.2.5 + ora: 9.4.0 + react: 19.2.7 + react-i18next: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3) + yaml: 2.9.0 + transitivePeerDependencies: + - '@swc/helpers' + - '@types/node' + - react-dom + - react-native + - typescript + + i18next-resources-for-ts@2.1.0: + dependencies: + '@babel/runtime': 7.29.2 + '@swc/core': 1.15.40 + chokidar: 5.0.0 + yaml: 2.9.0 + transitivePeerDependencies: + - '@swc/helpers' + + i18next-resources-to-backend@1.2.1: + dependencies: + '@babel/runtime': 7.29.2 + + i18next@26.3.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 @@ -10638,6 +11405,18 @@ snapshots: inline-style-parser@0.2.7: {} + inquirer@13.4.3(@types/node@25.5.2): + dependencies: + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.5.2) + '@inquirer/prompts': 8.5.2(@types/node@25.5.2) + '@inquirer/type': 4.0.7(@types/node@25.5.2) + mute-stream: 3.0.0 + run-async: 4.0.6 + rxjs: 7.8.2 + optionalDependencies: + '@types/node': 25.5.2 + ioredis@5.10.1: dependencies: '@ioredis/commands': 1.5.1 @@ -10687,6 +11466,8 @@ snapshots: is-interactive@1.0.0: {} + is-interactive@2.0.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -10697,12 +11478,16 @@ snapshots: is-stream@2.0.1: {} + is-stream@4.0.1: {} + is-subdir@1.2.0: dependencies: better-path-resolve: 1.0.0 is-unicode-supported@0.1.0: {} + is-unicode-supported@2.1.0: {} + is-windows@1.0.2: {} is-wsl@2.2.0: @@ -10827,6 +11612,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@3.3.1: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -10921,6 +11708,11 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + long@5.3.2: {} longest-streak@3.1.0: {} @@ -11369,6 +12161,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} @@ -11476,6 +12270,8 @@ snapshots: transitivePeerDependencies: - supports-color + mute-stream@3.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -11564,6 +12360,11 @@ snapshots: normalize-url@6.1.0: {} + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -11592,6 +12393,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -11635,6 +12440,17 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.0 + outdent@0.5.0: {} p-cancelable@2.1.1: {} @@ -11690,6 +12506,8 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-ms@4.0.0: {} + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -11708,6 +12526,8 @@ snapshots: path-key@3.1.1: {} + path-key@4.0.0: {} + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 @@ -11823,6 +12643,10 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + proc-log@5.0.0: {} process-nextick-args@2.0.1: {} @@ -11952,6 +12776,27 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-i18next@17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.3.1(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-i18next@17.0.8(i18next@26.3.1(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 26.3.1(typescript@5.9.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + typescript: 5.9.3 + react-is@17.0.2: {} react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): @@ -11981,6 +12826,8 @@ snapshots: react@19.2.4: {} + react@19.2.7: {} + read-binary-file-arch@1.0.6: dependencies: debug: 4.4.3 @@ -12028,6 +12875,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + real-require@0.2.0: {} redent@3.0.0: @@ -12107,6 +12956,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + retry@0.12.0: {} retry@0.13.1: {} @@ -12173,10 +13027,16 @@ snapshots: transitivePeerDependencies: - supports-color + run-async@4.0.6: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} @@ -12422,6 +13282,8 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.3.2: {} + streamsearch@1.1.0: {} streamx@2.25.0: @@ -12479,6 +13341,8 @@ snapshots: strip-bom@3.0.0: {} + strip-final-newline@4.0.0: {} + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -12689,7 +13553,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -12709,6 +13573,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: + '@swc/core': 1.15.40 postcss: 8.5.8 typescript: 5.9.3 transitivePeerDependencies: @@ -12781,6 +13646,8 @@ snapshots: undici@8.3.0: {} + unicorn-magic@0.3.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -12840,6 +13707,14 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + utf8-byte-length@1.0.5: {} util-deprecate@1.0.2: {} @@ -13023,6 +13898,8 @@ snapshots: - tsx - yaml + void-elements@3.1.0: {} + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: @@ -13165,6 +14042,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + yoga-layout@3.2.1: {} zip-stream@6.0.1: From cd71b9293075bdaa8b46c07272f45097346adcaa Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:16:08 -0700 Subject: [PATCH 04/24] feat(dashboard): add i18n runtime with per-locale code-splitting (U3) Initialize the browser i18next instance with lazy per-locale catalog loading, localStorage->navigator->htmlTag detection, script-aware zh fallback, and the shared @fusion/i18n config. Catalogs are synced from @fusion/i18n into a gitignored app/locales/ (predev/prebuild) and imported app-relative so Vite emits one chunk per locale/namespace (verified: 15 chunks, none inlined into the main bundle). First paint is gated on i18nReady to avoid raw-key flashes; wraps the App provider stack; vendor-i18n manualChunk added. A build-assertion script (verify:locale-chunks) guards the KTD3a splitting invariant. Fallback/namespace behavior is covered by @fusion/i18n config tests; the live instance is verified via the build assertion rather than a unit test (the dynamic catalog backend is impractical to exercise in vitest). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/dashboard/app/App.tsx | 26 +++++---- packages/dashboard/app/i18n/index.ts | 55 +++++++++++++++++++ packages/dashboard/app/main.tsx | 27 +++++---- packages/dashboard/package.json | 4 ++ .../scripts/assert-locale-chunks.mjs | 50 +++++++++++++++++ packages/dashboard/scripts/sync-locales.mjs | 30 ++++++++++ packages/dashboard/vite.config.ts | 9 +++ 7 files changed, 180 insertions(+), 21 deletions(-) create mode 100644 packages/dashboard/app/i18n/index.ts create mode 100644 packages/dashboard/scripts/assert-locale-chunks.mjs create mode 100644 packages/dashboard/scripts/sync-locales.mjs diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 7c0a2d7c51..c923c418bb 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -49,6 +49,8 @@ import { useProjects } from "./hooks/useProjects"; import { useAgents } from "./hooks/useAgents"; import { useNodes } from "./hooks/useNodes"; import { useCurrentProject } from "./hooks/useCurrentProject"; +import { I18nextProvider } from "react-i18next"; +import i18n from "./i18n"; import { ToastProvider, useToast } from "./hooks/useToast"; import { ConfirmDialogProvider } from "./hooks/useConfirm"; import { useTheme } from "./hooks/useTheme"; @@ -2043,16 +2045,18 @@ function AppInner() { export function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/packages/dashboard/app/i18n/index.ts b/packages/dashboard/app/i18n/index.ts new file mode 100644 index 0000000000..ef1d498f92 --- /dev/null +++ b/packages/dashboard/app/i18n/index.ts @@ -0,0 +1,55 @@ +import { baseInitOptions, DASHBOARD_NAMESPACES, DEFAULT_NAMESPACE } from "@fusion/i18n/config"; +import i18next from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import resourcesToBackend from "i18next-resources-to-backend"; +import { initReactI18next } from "react-i18next"; + +/** + * Browser i18next instance for the dashboard. + * + * Catalogs are loaded lazily per locale: only the active locale's namespaces + * are fetched on first paint; switching language fetches the new locale's + * chunk on demand. The dynamic import is **app-relative** over the generated + * `app/locales/` tree (synced from @fusion/i18n by scripts/sync-locales.mjs) + * so Vite statically analyses it and emits one chunk per locale/namespace. + */ + +/** localStorage key for the persisted language. Uses the neighbor-consistent + * `kb-dashboard-*` prefix (see useTheme.ts) — not changed to `fn-` here; that + * belongs to the brand-rename track. */ +export const LANGUAGE_STORAGE_KEY = "kb-dashboard-language"; + +i18next + .use(LanguageDetector) + .use( + resourcesToBackend( + (language: string, namespace: string) => + import(`../locales/${language}/${namespace}.json`), + ), + ) + .use(initReactI18next); + +export const i18nReady = i18next.init({ + ...baseInitOptions(), + ns: [...DASHBOARD_NAMESPACES], + defaultNS: DEFAULT_NAMESPACE, + detection: { + order: ["localStorage", "navigator", "htmlTag"], + lookupLocalStorage: LANGUAGE_STORAGE_KEY, + caches: ["localStorage"], + }, + react: { + // First paint is gated on `i18nReady` in main.tsx, so Suspense is not + // needed to avoid raw-key flashes and would otherwise require a boundary + // around every translated subtree. + useSuspense: false, + }, +}); + +i18next.on("languageChanged", (language) => { + if (typeof document !== "undefined") { + document.documentElement.lang = language; + } +}); + +export default i18next; diff --git a/packages/dashboard/app/main.tsx b/packages/dashboard/app/main.tsx index 4af2daf17e..c40723db0a 100644 --- a/packages/dashboard/app/main.tsx +++ b/packages/dashboard/app/main.tsx @@ -8,6 +8,7 @@ import { installVersionCheck } from "./versionCheck"; import { installSwUpdate } from "./swUpdate"; import { bootstrapShellHostContext } from "./shell-host"; import { registerBundledPluginViews } from "./plugins/registerBundledPluginViews"; +import { i18nReady } from "./i18n"; import "./styles.css"; // Install the bearer-token fetch wrapper before React mounts so every API @@ -19,14 +20,20 @@ installVersionCheck(); bootstrapShellHostContext(); registerBundledPluginViews(); -createRoot(document.getElementById("root")!).render( - - - - - - - , -); +// Gate first paint on the active locale's catalogs so the UI never flashes raw +// translation keys. The catalog is a small local chunk, so this is a brief +// wait; `.finally` ensures we still render if i18n init fails (strings then +// fall back to keys/en rather than blocking the app). +void i18nReady.finally(() => { + createRoot(document.getElementById("root")!).render( + + + + + + + , + ); -installSwUpdate(); + installSwUpdate(); +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 7ff2cd7be2..65169a169e 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -52,8 +52,12 @@ "README.md" ], "scripts": { + "gen:locales": "node scripts/sync-locales.mjs", + "verify:locale-chunks": "node scripts/assert-locale-chunks.mjs", + "prebuild": "node scripts/sync-locales.mjs", "build": "vite build && tsc", "build:client": "vite build", + "predev:serve": "node scripts/sync-locales.mjs", "dev": "pnpm build && pnpm typecheck && pnpm dev:serve", "dev:serve": "vite dev", "pretest": "node ../../scripts/ensure-test-artifacts.mjs", diff --git a/packages/dashboard/scripts/assert-locale-chunks.mjs b/packages/dashboard/scripts/assert-locale-chunks.mjs new file mode 100644 index 0000000000..37cf38c71f --- /dev/null +++ b/packages/dashboard/scripts/assert-locale-chunks.mjs @@ -0,0 +1,50 @@ +// KTD3a regression guard: after a client build, assert that each locale's +// catalogs are emitted as their own async chunks and are NOT folded into the +// main entry chunk. If the app-relative dynamic import ever stops being +// statically analysable, Vite silently inlines every catalog into the main +// bundle with only a build warning — this check turns that into a hard failure. +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const assetsDir = join(here, "..", "dist", "client", "assets"); +const namespaces = ["common", "app", "errors"]; + +if (!existsSync(assetsDir)) { + console.error(`assert-locale-chunks: ${assetsDir} not found — run the client build first.`); + process.exit(1); +} + +const files = readdirSync(assetsDir); +const errors = []; + +// One chunk per dashboard namespace per locale (5 locales) → at least 5 each. +for (const ns of namespaces) { + const matches = files.filter((f) => new RegExp(`^${ns}-[^/]+\\.js$`).test(f)); + if (matches.length < 5) { + errors.push( + `expected >=5 split chunks for namespace "${ns}" (one per locale), found ${matches.length}`, + ); + } +} + +// The main entry chunk must not carry catalog payloads — a translated marker +// string from a non-en catalog appearing in index-*.js means splitting broke. +const indexFile = files.find((f) => /^index-[^/]+\.js$/.test(f)); +if (indexFile) { + const body = readFileSync(join(assetsDir, indexFile), "utf8"); + // i18next-resources-to-backend chunks are referenced by dynamic import, not + // inlined; a literal catalog object in index would show the column labels. + if (/"in-review":"In Review"/.test(body) && /"archived":"Archived"/.test(body)) { + errors.push(`catalog content found inlined in ${indexFile} — locale chunks were not split`); + } +} + +if (errors.length) { + console.error("assert-locale-chunks FAILED:"); + for (const e of errors) console.error(` - ${e}`); + process.exit(1); +} + +console.log("assert-locale-chunks: per-locale catalog chunks emitted correctly."); diff --git a/packages/dashboard/scripts/sync-locales.mjs b/packages/dashboard/scripts/sync-locales.mjs new file mode 100644 index 0000000000..e442f47141 --- /dev/null +++ b/packages/dashboard/scripts/sync-locales.mjs @@ -0,0 +1,30 @@ +// Copies the authored @fusion/i18n catalogs into the dashboard tree so Vite can +// code-split them per locale via a plainly app-relative dynamic import. The +// generated app/locales/ dir is gitignored — @fusion/i18n/locales is the +// source-of-truth. Only the dashboard namespaces are copied (the terminal-only +// `cli` namespace is skipped). Runs as a predev/prebuild step. +import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const dashboardRoot = join(here, ".."); +const srcLocales = join(dashboardRoot, "..", "i18n", "locales"); +const destLocales = join(dashboardRoot, "app", "locales"); + +// Keep in sync with DASHBOARD_NAMESPACES in @fusion/i18n config.ts. +const DASHBOARD_NAMESPACES = ["common", "app", "errors"]; + +const locales = readdirSync(srcLocales, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + +rmSync(destLocales, { recursive: true, force: true }); +for (const lng of locales) { + mkdirSync(join(destLocales, lng), { recursive: true }); + for (const ns of DASHBOARD_NAMESPACES) { + cpSync(join(srcLocales, lng, `${ns}.json`), join(destLocales, lng, `${ns}.json`)); + } +} + +console.log(`Synced ${locales.length} locale(s) into app/locales: ${locales.join(", ")}`); diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts index b7e9adc6cc..f973f74394 100644 --- a/packages/dashboard/vite.config.ts +++ b/packages/dashboard/vite.config.ts @@ -168,6 +168,15 @@ export default defineConfig({ return "vendor-codemirror"; } + if ( + id.includes("/node_modules/i18next/") || + id.includes("/node_modules/react-i18next/") || + id.includes("/node_modules/i18next-browser-languagedetector/") || + id.includes("/node_modules/i18next-resources-to-backend/") + ) { + return "vendor-i18n"; + } + return undefined; }, }, From 3ae1fc38309ec47c84197e792d633465c15cdf27 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 08:19:44 -0700 Subject: [PATCH 05/24] feat(dashboard): add language switcher and persistence (U4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add useLanguage (three-tier persistence mirroring useTheme: localStorage cache + server GlobalSettings write-through + hydrate-on-mount, local choice wins) and a LanguageSelector rendered in the Settings Appearance section. Switching applies in place via i18n.changeLanguage — no reload, so unsaved state and in-flight agent views survive. Endonyms name each language in its own script. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/LanguageSelector.css | 42 +++++++ .../app/components/LanguageSelector.tsx | 40 ++++++ .../app/components/SettingsModal.tsx | 2 + .../__tests__/LanguageSelector.test.tsx | 43 +++++++ .../app/hooks/__tests__/useLanguage.test.ts | 115 ++++++++++++++++++ packages/dashboard/app/hooks/useLanguage.ts | 94 ++++++++++++++ 6 files changed, 336 insertions(+) create mode 100644 packages/dashboard/app/components/LanguageSelector.css create mode 100644 packages/dashboard/app/components/LanguageSelector.tsx create mode 100644 packages/dashboard/app/components/__tests__/LanguageSelector.test.tsx create mode 100644 packages/dashboard/app/hooks/__tests__/useLanguage.test.ts create mode 100644 packages/dashboard/app/hooks/useLanguage.ts diff --git a/packages/dashboard/app/components/LanguageSelector.css b/packages/dashboard/app/components/LanguageSelector.css new file mode 100644 index 0000000000..c57276eeec --- /dev/null +++ b/packages/dashboard/app/components/LanguageSelector.css @@ -0,0 +1,42 @@ +.language-selector { + display: flex; + flex-direction: column; + gap: 8px; +} + +.language-selector-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #888); +} + +.language-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.language-option { + display: inline-flex; + align-items: center; + padding: 6px 12px; + border: 1px solid var(--border-color, #333); + border-radius: 6px; + background: var(--bg-secondary, transparent); + color: var(--text-primary, inherit); + font-size: 0.875rem; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.language-option:hover { + border-color: var(--accent-color, #4a9eff); +} + +.language-option.active { + border-color: var(--accent-color, #4a9eff); + background: var(--accent-color-subtle, rgba(74, 158, 255, 0.12)); + font-weight: 600; +} diff --git a/packages/dashboard/app/components/LanguageSelector.tsx b/packages/dashboard/app/components/LanguageSelector.tsx new file mode 100644 index 0000000000..e905d4fb9e --- /dev/null +++ b/packages/dashboard/app/components/LanguageSelector.tsx @@ -0,0 +1,40 @@ +import "./LanguageSelector.css"; +import type { Locale } from "@fusion/core"; +import { useTranslation } from "react-i18next"; +import { useLanguage } from "../hooks/useLanguage"; + +/** Each language names itself (endonyms), intentionally untranslated. */ +const ENDONYMS: Record = { + en: "English", + "zh-CN": "简体中文", + "zh-TW": "繁體中文", + fr: "Français", + es: "Español", +}; + +/** Settings control for choosing the UI language. Applies in place (no reload). */ +export function LanguageSelector() { + const { t } = useTranslation("app"); + const { language, supportedLocales, setLanguage } = useLanguage(); + const label = t("settings.appearance.language", "Language"); + + return ( +
+
{label}
+
+ {supportedLocales.map((locale) => ( + + ))} +
+
+ ); +} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 069ac8b8b2..5ecb66aadf 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -20,6 +20,7 @@ import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; import { ThemeSelector } from "./ThemeSelector"; +import { LanguageSelector } from "./LanguageSelector"; import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref"; import "./SettingsModal.css"; import { CustomModelDropdown } from "./CustomModelDropdown"; @@ -3710,6 +3711,7 @@ export function SettingsModal({ onDashboardFontScaleChange?.(scalePct); }} /> +