From 94135092a5f775fb99a9a177e4ed5b8ec4efdd6b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 17 Aug 2026 18:55:28 +0800 Subject: [PATCH 1/8] fix(locale): open in English when the browser names no shipped language The provisional locale fell back to zh, so a browser asking for neither zh nor en (fr, de) opened the product in Chinese. Resolve to en instead, and use en as the dictionary fallback: the shipped zh/en dictionaries declare identical key sets, so one constant serves both roles. Add scripts/locale-dictionary-parity.spec.ts to gate that symmetry, and set the asserted locale explicitly in specs that had relied on the old zh fallback through a dead usePinnedBrowserLanguages call (those files declare no jsdom environment, so browser detection never ran there). --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 20 ++- ...07-31-browser-derived-initial-locale.zh.md | 20 ++- apps/web/index.html | 2 +- apps/web/tests/settings-chrome.e2e.ts | 28 +++- packages/client/locale/src/client/index.ts | 14 +- .../client/locale/tests/apply.client.spec.ts | 33 +++-- .../client/locale/tests/locale.client.spec.ts | 56 +++++-- .../tests/apply.client.spec.ts | 9 +- .../tests/apply.client.spec.ts | 8 +- .../tests/browser-plugin.client.spec.ts | 4 + .../tests/browser-plugin.client.spec.ts | 7 +- .../tests/apply.client.spec.ts | 8 +- .../tests/apply.client.spec.ts | 9 +- .../tests/apply.client.spec.ts | 9 +- .../ui-theme/tests/apply.client.spec.ts | 9 +- .../ui-workspace/tests/apply.client.spec.ts | 8 +- scripts/locale-dictionary-parity.spec.ts | 137 ++++++++++++++++++ 18 files changed, 306 insertions(+), 79 deletions(-) create mode 100644 scripts/locale-dictionary-parity.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index ea66531864..c1ac4af1e9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 072f91b730cfc9eaeead7701d2b20d12b443acb3 -2026-07-31-browser-derived-initial-locale.zh.md: 97f0f0007474c21fb618a085b506bc919586f624 +2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380 +2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 072f91b730..94f32b136f 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -8,29 +8,35 @@ English | [中文](2026-07-31-browser-derived-initial-locale.zh.md) The Settings Language row opened every first visit in Chinese: `LocaleRuntime` read `dsh.locale` from localStorage and fell straight back to `zh` when nothing was stored. The browser already states which languages its user reads — `navigator.languages` is that statement — and the app ignored it, so an English reader met a Chinese product and had to find a Chinese-labelled settings row to escape it. The fallback was doing two jobs at once: the last resort for an unresolvable locale, and the answer for every user who had simply never chosen. +Reading the browser fixed the readers whose browser names a language this app ships, but left the residual case wrong: a browser asking for neither `zh` nor `en` (`fr`, `de`) still fell back to `zh`. Those readers are the least likely to read Chinese. + ## Decision -**The provisional locale resolves through the browser, then `FALLBACK_LOCALE`; an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. +**The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. + +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. -**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language (`en-US` on the CI runners), so gating on `navigator` would have let a node boot of the client tree resolve to `en` instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. +**`window`, not `navigator`, is the browser test.** Node ≥ 21 exposes a global `navigator` reporting the machine's own language, so gating on `navigator` would let a node boot of the client tree resolve to the machine's language instead of the documented fallback. Gating on `window` keeps every non-browser run on `FALLBACK_LOCALE`. **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. -**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale and asserts its English browser produces an English settings surface—the assembled-app proof of this feature. +**The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` or a single `navigator.language` read**: both collapse the user's ordered preference list to one tag, so a `['de', 'en', 'zh']` reader gets zh instead of en. The list is the part of the browser statement worth reading. - **Persisting the detected locale on first boot**: it would make detection a one-time event and let a stale first visit outlive a changed browser language, and it destroys the distinction the resolution order rests on — a stored value would no longer mean "the user chose this". - **Full BCP 47 negotiation (`Intl.LocaleMatcher`-style lookup, region and script weighting)**: with exactly two shipped locales that differ in language, primary-subtag matching is the whole of the correct answer; a negotiation layer would be untestable surface with no behavior to justify it. -- **A cordis config key for the default locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **A cordis config key for the fallback locale**: the deployment does not vary here — the fallback is the product's answer for "no signal at all", not a knob. Repo policy reserves `Config` fields for deployment-varying choices with a current consumer. +- **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. +- **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. ## Consequences -- A first visit from an English browser lands in English, and the Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. -- `FALLBACK_LOCALE` narrows to its real job — the dictionary fallback and the no-signal answer — and stops standing in for "the user has not chosen". -- Tests that construct a `LocaleRuntime` under jsdom now depend on the environment's `navigator`: specs asserting localized copy declare their browser with one suite-level `usePinnedBrowserLanguages('zh-CN')` (dsh-client-test-runtime), and any future spec asserting a default must do the same. This package's own specs stub the globals directly, because they need shapes the helper deliberately cannot express (absent `languages`, a list decoupled from `language`, no `window` at all). +- A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. +- Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 97f0f00074..8d879b9b11 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -8,29 +8,35 @@ Status: implemented 设置里的语言行在每一次首访时都以中文开场:`LocaleRuntime` 从 localStorage 读取 `dsh.locale`,读不到就直接回落到 `zh`。浏览器本已声明其使用者阅读哪些语言——`navigator.languages` 就是这份声明——而应用对此视而不见,于是英文读者迎面撞上一个中文产品,还得先找到一行中文标签的设置项才能脱身。回落值当时同时承担两份职责:既是无法解析出 locale 时的最后兜底,也是所有从未做过选择的用户拿到的答案。 +读取浏览器修好了那些浏览器声明了本应用所提供语言的读者,但残余情形依然是错的:既不请求 `zh` 也不请求 `en` 的浏览器(`fr`、`de`)仍会回落到 `zh`。这些读者恰恰最不可能阅读中文。 + ## Decision -**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE` 解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 +**暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 + +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 -**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言(CI runner 上是 `en-US`),因此以 `navigator` 把关会让 node 启动客户端树时解析成 `en`,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 +**判定浏览器用的是 `window` 而非 `navigator`。** Node ≥ 21 暴露全局 `navigator` 并报告机器自身语言,因此以 `navigator` 把关会让 node 启动客户端树时解析成机器语言,而非文档约定的回落值。以 `window` 把关可使所有非浏览器运行都停留在 `FALLBACK_LOCALE`。 **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 -**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 使用没有显式 locale 的全新 Host home,断言其英文浏览器会生成英文 settings 界面:这是本功能在组装后应用中的证据。 +**浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered - **`Intl.DateTimeFormat().resolvedOptions().locale` 或单读 `navigator.language`**:两者都把用户的有序偏好列表塌缩成一个标签,于是 `['de', 'en', 'zh']` 的读者拿到的是 zh 而非 en。列表恰恰是浏览器这份声明里最值得读的部分。 - **首次启动即持久化探测结果**:那会把探测变成一次性事件,让一次陈旧的首访凌驾于此后改变的浏览器语言之上,也摧毁了整个解析顺序所依赖的区分——存储值将不再意味着「用户选了它」。 - **完整的 BCP 47 协商(`Intl.LocaleMatcher` 式查找、地区与文字权重)**:在只提供两个语言互异的 locale 时,主子标签匹配就是正确答案的全部;协商层只会带来无行为支撑、也无从测试的表面积。 -- **为默认 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **为回落 locale 增加一个 Cordis 配置键**:此处部署之间并无差异——回落值是产品对「完全没有信号」给出的答案,不是旋钮。仓库策略把 `Config` 字段留给有当前消费方、且随部署变化的选择。 +- **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 +- **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 ## Consequences -- 来自英文浏览器的首访落在英文界面,而语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 -- `FALLBACK_LOCALE` 收窄回它真正的职责——字典回落与无信号时的答案——不再兼职充当「用户尚未选择」。 -- 在 jsdom 下构造 `LocaleRuntime` 的测试现在依赖环境的 `navigator`:断言本地化文案的用例以一行套件级 `usePinnedBrowserLanguages('zh-CN')`(dsh-client-test-runtime)声明其浏览器,今后任何断言默认值的用例同样如此。本包自己的用例直接给全局打桩,因为它们需要该 helper 刻意不表达的形状(`languages` 缺失、列表与 `language` 解耦、完全没有 `window`)。 +- 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 +- 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/apps/web/index.html b/apps/web/index.html index a14de72d40..1ce5ff35ff 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -1,5 +1,5 @@ - + diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 876574a009..74c47d7a40 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -455,7 +455,9 @@ describe('web e2e: settings modal and General preferences', () => { it('opens an English browser in English without any stored preference', async () => { // A fresh Host home has no locale preference, so its surface follows the - // browser rather than the product fallback. + // browser. English is also FALLBACK_LOCALE, so this scenario alone cannot + // distinguish detection from the default — the zh scenarios above supply + // the discriminating half (a Chinese browser must NOT land on the default). const fresh = await launchWebScaffold({}) const enPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) const enTripwire = watchConsole(enPage) @@ -478,6 +480,30 @@ describe('web e2e: settings modal and General preferences', () => { } }, 90_000) + it('opens a browser asking for no shipped language in English', async () => { + // The product default for "no usable signal": a French browser ships + // neither zh nor en, so resolution falls to FALLBACK_LOCALE (en) rather + // than to Chinese. + const fresh = await launchWebScaffold({}) + const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' }) + const frTripwire = watchConsole(frPage) + onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language')) + try { + await frPage.goto(fresh.baseUrl, { waitUntil: 'load' }) + await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + expect(await frPage.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() + await frPage.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = frPage.getByRole('dialog', { name: 'Settings' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + expect(frTripwire.pageErrors).toEqual([]) + expect(frTripwire.warnings).toEqual([]) + } finally { + await frPage.close() + await fresh.close() + } + }, 90_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 8291187175..abea65ac9e 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -86,8 +86,14 @@ declare module '@deepseek-ai/cordis' { } } -/** Fallback locale consulted after the active locale misses (also the last-resort initial locale). */ -export const FALLBACK_LOCALE: LocaleId = 'zh' +/** + * English is both the locale the UI opens in when the browser names no shipped + * language (and for non-browser runs), and the dictionary consulted after the + * active locale misses a key. One constant serves both because the shipped + * `zh`/`en` dictionaries carry identical key sets, so neither direction can + * leave a key unresolved; English is the source language of the copy. + */ +export const FALLBACK_LOCALE: LocaleId = 'en' /** Shared namespace for shell-level texts. */ export const COMMON_NS = 'common' @@ -103,8 +109,8 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ /** * Dictionary registry plus locale preference. Lookup chain per key: the - * entry's namespace in the active locale -> that namespace's zh fallback -> - * the shared common namespace (active, then zh) -> the key itself (missing + * entry's namespace in the active locale -> that namespace's en fallback -> + * the shared common namespace (active, then en) -> the key itself (missing * text stays visible, fail loud in the UI rather than blank). Reads go * through {@link getLocale}; writes only through {@link setLocale}; * continuous sync through the `locale/change` event, or through the diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index dd38786073..a792bfe53a 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' @@ -73,12 +73,10 @@ function faceOf(slots: SlotRegistry) { } describe('locale apply', () => { - // A fresh service opens in the browser's language, so these wiring specs - // pin one to keep their zh baseline independent of the test environment. - beforeEach(() => { - vi.stubGlobal('navigator', { languages: ['zh-CN'], language: 'zh-CN' }) - }) - + // These are wiring specs, not default-language specs: each one that reads + // localized copy sets its locale explicitly via setLocale/Host preference + // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, + // so there is no `window` and no browser-language detection to stub. afterEach(() => { vi.unstubAllGlobals() }) @@ -95,6 +93,9 @@ describe('locale apply', () => { // Base dictionaries are registered: the (ns, locale) seats are occupied. expect(() => locale.register('common', 'zh', {})).toThrow('already has locale') expect(() => locale.register('common', 'en', {})).toThrow('already has locale') + // Both dictionaries resolve; read each under its own active locale. + expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language') + locale.setLocale('zh') expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言') const entry = before.slots.entries(SLOT).find(e => e.component === LanguageRow)! expect(entry.options).toMatchObject({ id: 'language', order: 0 }) @@ -110,9 +111,14 @@ describe('locale apply', () => { it('projects service snapshots into the row store and routes face writes back', async () => { const b = await bench() + // Open at zh so the pre-inject switch to en below is a real change: with + // FALLBACK_LOCALE = en it would otherwise be a no-op and never exercise + // the unbound-actions arm or persist. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) // An event ahead of any inject hits the unbound-actions arm. locale.setLocale('en') @@ -133,17 +139,20 @@ describe('locale apply', () => { it('loads and refreshes the explicit Host preference after nonblocking activation', async () => { const b = await bench() - b.setHostPreference('en') + // Preference must differ from the provisional locale (FALLBACK_LOCALE = en + // with no window), or clearing it below would be unobservable. + b.setHostPreference('zh') declareItems(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const locale = b.ctx.get('locale') as LocaleRuntime - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) + // Cleared preference falls back to the provisional locale. b.setHostPreference(undefined) b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) - await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) - b.setHostPreference('en') - b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) await vi.waitFor(() => { expect(locale.getLocale().active).toBe('en') }) + b.setHostPreference('zh') + b.ctx.remote.$dispatch('settings/document-updated', [LOCALE_SETTINGS_NAMESPACE, 0]) + await vi.waitFor(() => { expect(locale.getLocale().active).toBe('zh') }) expect(b.describe).toHaveBeenCalledTimes(3) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index 86f1ce2922..eb279f1295 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' -import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' - +import { FALLBACK_LOCALE, LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' const make = (host?: StubSettingsScope): { ctx: Context svc: LocaleRuntime @@ -37,31 +36,34 @@ describe('LocaleRuntime', () => { vi.unstubAllGlobals() }) - it('translates through the active-locale -> zh -> key chain', () => { + it('translates through the active-locale -> en -> key chain', () => { const { svc } = make() - svc.register('ns', 'zh', { hello: '你好', onlyZh: '仅中文' }) - svc.register('ns', 'en', { hello: 'Hello' }) + svc.register('ns', 'zh', { hello: '你好' }) + svc.register('ns', 'en', { hello: 'Hello', onlyEn: 'English only' }) const t = svc.bind('ns') expect(svc.getLocale().active).toBe('zh') expect(t('hello')).toBe('你好') + // The active locale misses this key; the en fallback supplies it. + expect(t('onlyEn')).toBe('English only') svc.setLocale('en') expect(t('hello')).toBe('Hello') - expect(t('onlyZh')).toBe('仅中文') expect(t('missing.key')).toBe('missing.key') }) it('falls through to the common vocabulary after the namespace misses (production keys)', () => { const { svc } = make() // The shipped common pair is registered by apply; the bench registers it - // directly to pin the production chain: ns -> common -> zh -> key. + // directly to pin the production chain: ns -> common -> en -> key. svc.register('common', 'zh', { retry: '重试' }) svc.register('common', 'en', { retry: 'Retry' }) - svc.register('ns', 'zh', { own: '自有' }) + svc.register('ns', 'en', { own: 'Own' }) const t = svc.bind('ns') expect(t('retry')).toBe('重试') + // zh is active and `ns` has no zh dictionary at all: the en fallback answers. + expect(t('own')).toBe('Own') svc.setLocale('en') expect(t('retry')).toBe('Retry') - expect(t('own')).toBe('自有') + expect(t('own')).toBe('Own') // common itself must not recurse: a miss inside common echoes the key. // (Wide-string ns hits the untyped bind overload — the typed one rejects // unknown keys at compile time, which is the point of the typed registry contract.) @@ -206,21 +208,21 @@ describe('LocaleRuntime', () => { expect(make().svc.getLocale().active).toBe('en') vi.stubGlobal('navigator', { language: 'en-US' }) expect(make().svc.getLocale().active).toBe('en') - // No shipped language anywhere in the browser's preferences: zh remains - // the product default rather than an arbitrary near-match. + // No shipped language anywhere in the browser's preferences: en is the + // product default rather than an arbitrary near-match. stubLanguages('fr-FR', 'de') - expect(make().svc.getLocale().active).toBe('zh') + expect(make().svc.getLocale().active).toBe('en') }) - it('runs outside a browser (node boots): the fallback decides and the machine language does not', () => { + it('runs outside a browser (node boots): the default decides and the machine language does not', () => { vi.stubGlobal('window', undefined) // Node exposes its own global navigator; without a window it must not // reach the resolution at all. - stubLanguages('en-US') + stubLanguages('zh-CN') const { svc } = make() - expect(svc.getLocale().active).toBe('zh') - svc.setLocale('en') expect(svc.getLocale().active).toBe('en') + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') }) it('lets an explicit in-process preference replace the browser-derived value', () => { @@ -230,6 +232,28 @@ describe('LocaleRuntime', () => { expect(svc.getLocale().active).toBe('zh') }) + it('serves English as both the opening locale and the dictionary fallback', () => { + // One constant covers both jobs: the locale the UI opens in with no usable + // browser signal, and the dictionary backing a key the active locale + // misses. Safe to share only because the shipped zh/en dictionaries carry + // identical key sets (asserted below on a registered pair). + expect(FALLBACK_LOCALE).toBe('en') + vi.stubGlobal('window', undefined) + const { svc } = make() + // A key present only in en resolves for a zh reader through the fallback. + svc.register('ns', 'zh', {}) + svc.register('ns', 'en', { onlyEn: 'English only' }) + svc.setLocale('zh') + expect(svc.getLocale().active).toBe('zh') + expect(svc.bind('ns')('onlyEn')).toBe('English only') + // The reverse no longer resolves: a zh-only key is unreachable from en, so + // the key itself surfaces (fail loud) rather than silently rendering zh. + svc.register('ns2', 'zh', { onlyZh: '仅中文' }) + svc.register('ns2', 'en', {}) + svc.setLocale('en') + expect(svc.bind('ns2')('onlyZh')).toBe('onlyZh') + }) + it('exposes the two shipped locales with self-described labels', () => { const { svc } = make() expect(svc.getLocale().locales).toEqual([ diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7ff4741648..7d998c7e11 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' @@ -21,9 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const ROSTER_ONE = { rpcId: 'r', @@ -77,6 +74,10 @@ async function bench() { const moveDefault = (): void => { ROSTER = ROSTER_MOVED } await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index fa3f141283..f5d04fcfd6 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -7,15 +7,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { createScope, scopeOf, SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const sid = (k: string): SessionId => k as SessionId @@ -37,6 +33,10 @@ async function bench() { scopeOf: (c: Context) => scopeOf(c), }) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots, locale } } diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index cf5d506024..18e19d7e40 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -39,6 +39,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + // These specs assert the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + ctx.locale.setLocale('zh') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, fiber } diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 03bb3aa535..aad5dda614 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -104,7 +104,12 @@ async function bench() { return () => { seats.delete(options.name) } }, }) - ctx.provide('locale', new LocaleRuntime(ctx)) + const localeRuntime = new LocaleRuntime(ctx) + // This spec asserts the shipped Chinese copy. There is no jsdom `window` in + // this lane, so browser-language detection never runs and the locale comes + // from FALLBACK_LOCALE (en): state the asserted locale explicitly. + localeRuntime.setLocale('zh') + ctx.provide('locale', localeRuntime) const scopes = new Map() const addressed = new Set() ctx.provide('sessions', { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index d6c3ffff02..537bead7af 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -4,16 +4,12 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ @@ -28,6 +24,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const settingsDescribe = vi.fn(() => Promise.resolve({ rpcId: 'settings-general' as never, diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 39ba3e4b65..507c4f9aaf 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -4,20 +4,21 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject, refreshIfLoaded } from '@deepseek-ai/dsh-client-ui-settings-models/client' import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) // The plugins inject `remote`; forwarded events reach them through the // same `$dispatch` handoff the connection sink makes. diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 2934097b94..14b82dac66 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -5,16 +5,13 @@ import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') /** * @param served - namespaces the Host describes; omitted answers a failed read, @@ -24,6 +21,10 @@ async function bench(served?: string[]) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index fb84c9860d..60aa1f349b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, SETTINGS_NS } from '@deepseek-ai/dsh-client-ui-theme/client' import type { AppearanceRowInjected, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -13,9 +13,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') const SLOT = 'settings.general.item' @@ -29,6 +26,10 @@ async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) let preference = 'system' const namespace = () => ({ diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 016af313f8..4a819c8587 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -2,15 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' -import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' -// The service reads its initial locale from the browser; these specs assert -// the shipped Chinese copy, so they state the browser they assume. -usePinnedBrowserLanguages('zh-CN') async function bench() { const ctx = new Context() @@ -37,6 +33,10 @@ async function bench() { } as never) ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never) const locale = new LocaleRuntime(ctx) + // These specs assert the shipped Chinese copy. There is no jsdom `window` + // in this lane, so browser-language detection never runs and the locale + // comes from FALLBACK_LOCALE (en): state the asserted locale explicitly. + locale.setLocale('zh') ctx.provide('locale', locale) return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename, diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts new file mode 100644 index 0000000000..d48fc3e808 --- /dev/null +++ b/scripts/locale-dictionary-parity.spec.ts @@ -0,0 +1,137 @@ +/** + * Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary + * declares the same keys in `zh` and `en`. + * + * The locale runtime resolves a key through the active locale, then through + * the single fallback locale (`en`), then surfaces the key itself. With + * symmetric dictionaries that middle step always resolves, so one constant can + * serve as both the opening locale and the dictionary fallback. A key added to + * only one side breaks that: a reader of the other language sees a bare key + * such as `list.aria` instead of text. This gate fails on the asymmetry rather + * than waiting for the bare key to reach a UI. + */ + +import type { Dirent } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +/** Every `locales*.ts` module under a client package's `src/`. */ +function dictionaryModules(): string[] { + const files: string[] = [] + for (const group of ['client', 'extensions']) { + const groupRoot = resolve(root, 'packages', group) + let packages: string[] + try { + packages = readdirSync(groupRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + } catch { + continue + } + for (const pkg of packages) { + const srcRoot = resolve(groupRoot, pkg, 'src') + walk(srcRoot, files) + } + } + return files.sort() +} + +function walk(dir: string, out: string[]): void { + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { + walk(full, out) + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) + } + } +} + +/** + * Keys of every top-level `export const ...= { ... }` object literal, + * read from the AST so the gate never executes package code. + * @param file - absolute path of the dictionary module. + * @returns exported dictionary name mapped to its declared keys. + */ +function exportedDictionaries(file: string): Map { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) + const found = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true + if (!exported) continue + for (const decl of statement.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue + const initializer = unwrap(decl.initializer) + if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue + const keys: string[] = [] + for (const prop of initializer.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + found.set(decl.name.text, keys.sort()) + } + } + return found +} + +/** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ +function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { + let current = node + while ( + current !== undefined + && (ts.isSatisfiesExpression(current) || ts.isAsExpression(current) || ts.isParenthesizedExpression(current)) + ) { + current = current.expression + } + return current +} + +/** Pair a `zh` export with the `en` export covering the same namespace. */ +function counterpart(name: string): string | undefined { + if (name === 'zh') return 'en' + if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` + if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` + return undefined +} + +describe('shipped locale dictionaries', () => { + it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { + const modules = dictionaryModules() + // Guard the discovery itself: an empty sweep would pass every assertion + // below while checking nothing. + expect(modules.length).toBeGreaterThan(20) + + const mismatches: string[] = [] + let comparedPairs = 0 + for (const file of modules) { + const dicts = exportedDictionaries(file) + for (const [name, zhKeys] of dicts) { + const enName = counterpart(name) + if (enName === undefined) continue + const enKeys = dicts.get(enName) + if (enKeys === undefined) continue + comparedPairs++ + const rel = file.slice(root.length) + const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) + const enOnly = enKeys.filter(key => !zhKeys.includes(key)) + if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + } + } + + expect(comparedPairs).toBeGreaterThan(20) + expect(mismatches).toEqual([]) + }) +}) From bf4cb507f1e42c9f48f1d06dbd3a821d04612bc3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 11:25:39 +0800 Subject: [PATCH 2/8] fix(locale): track the active locale in The document language attribute was a static value in the served markup, so it reported zh-CN for an English UI and would have reported en for a Chinese one once the resolved default changed. Set it from the active locale at plugin activation and on every switch, carrying a BCP 47 tag (zh-CN / en). Drop the now-unused dsh-client-test-runtime devDependency from ui-settings-general: removing its dead browser-language pin left the package with no remaining use of it, which knip reports as an error. --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +-- ...26-07-31-browser-derived-initial-locale.md | 4 +++ ...07-31-browser-derived-initial-locale.zh.md | 4 +++ packages/client/locale/src/client/index.ts | 27 +++++++++++++++++++ .../tests/document-language.client.spec.ts | 5 ++-- .../client/ui-settings-general/package.json | 1 - pnpm-lock.yaml | 3 --- 7 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index c1ac4af1e9..f1168d973c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 94f32b136f20c7ab7fb8241a0ac9adf6249a4380 -2026-07-31-browser-derived-initial-locale.zh.md: 8d879b9b11ad42feed9ffd2ec3e5a16d1dcd9b8c +2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee +2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 94f32b136f..6fcd799b9c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -22,6 +22,8 @@ Reading the browser fixed the readers whose browser names a language this app sh **An explicit choice is durable.** `setLocale` writes through the Host settings API, so a user who picked a language keeps it across browser origins and system languages that share the same DSH home. Nothing writes the detected locale back: detection is re-derived every boot and stays invisible to the “has the user chosen?” question. +**`` follows the resolved locale, and the served markup cannot.** `apps/web/index.html` is one static file serving every visitor, so whatever it declares is wrong for somebody: resolution happens in the client, after the document is parsed. The locale plugin therefore sets `document.documentElement.lang` from the active locale — once at activation, because detection or an adopted Host preference may already disagree with the markup, and again on every switch. The markup declares the product default (`en`) so the pre-boot document is not actively misleading. Assistive technology and browser features (pronunciation rules, translation offers, font fallback, spell check) read this attribute, so a stale value misreports the document language rather than merely looking untidy. The attribute carries a BCP 47 tag rather than the app's locale id: `zh` alone leaves the script ambiguous, so the shipped Chinese copy declares `zh-CN`. + **The browser e2e lane pins browser language.** Scenarios asserting Chinese copy (`access-confirmation`, `models-settings`, `onboarding-deepseek-config`, `settings-chrome`) open their page with `locale: ZH_BROWSER_LOCALE` from `apps/web/tests/support.ts`; `newEnglishPage` advertises `en-US`. `settings-chrome.e2e.ts` opens a fresh Host home with no explicit locale twice: an `en-US` browser and an `fr-FR` one both reach an English surface. The `fr-FR` scenario is the one that pins the fallback — an `en-US` browser would land on English under detection or fallback alike, so only an unshipped language distinguishes them, and the zh scenarios prove detection still overrides the fallback. ## Alternatives considered @@ -33,10 +35,12 @@ Reading the browser fixed the readers whose browser names a language this app sh - **Two constants, one for the opening locale and one for the dictionary fallback**: it separates two genuinely different questions, and would be required if the answers differed. They do not: the dictionaries are symmetric, so both are `en`, and a second constant would be two names for one value plus a rule nothing enforces. The symmetry itself is worth enforcing, so it is gated directly instead. - **Keeping `zh` as the dictionary fallback while opening in `en`**: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key that `en` would not, so it buys nothing; and where it would matter — a key present only in `zh` — rendering Chinese text inside an otherwise English UI is worse than the bare key a reviewer would notice. - **Keeping the e2e lane's zh scenarios on storage pinning (`dsh.locale=zh`)**: it would keep the suite green while removing the only place the browser-derived path runs in an assembled app; pinning the browser language instead exercises the new resolution end to end. +- **Serving `` per request, or leaving the static attribute alone**: computing it server-side would need the request's `Accept-Language` to re-derive what the client resolves anyway, duplicating the rule in two places and still losing to a stored preference the server does not read. Leaving it static is what made the attribute permanently wrong for one language or the other. Setting it from the resolved locale keeps one source of truth. ## Consequences - A first visit from an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese. The Language row still shows the same two self-described options, so the escape hatch is unchanged in either direction. - Dictionary resolution reverses direction: a key missing from the active locale now falls to `en`, not `zh`. With symmetric dictionaries no shipped key changes behavior, which is why the parity gate exists — it is the assumption that reversal rests on. +- `` now reports the language on screen in both directions, which closes [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160). A client that never activates the locale plugin keeps the served default, so the attribute degrades to the old static behavior rather than to a blank value. - Non-browser runs of the client tree (node boots, the non-jsdom unit lane) now open in `en`. Specs that assert shipped Chinese copy must set `setLocale('zh')` explicitly on the runtime they construct; a suite-level `usePinnedBrowserLanguages('zh-CN')` only works in files that also declare `@vitest-environment jsdom`, because without a `window` the detection path never reads `navigator` at all. Seven `*.client.spec.ts` files carried such a dead pin and were relying on the old `zh` fallback instead. - Detection cost is one array walk per service construction and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 8d879b9b11..73f2c825e1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -22,6 +22,8 @@ Status: implemented **显式选择具有持久性。** `setLocale` 通过 Host settings API 写入,因此选过语言的用户可在共享同一 DSH home 的不同浏览器 origin 与系统语言之间保留原选择。没有任何代码把探测到的 locale 写回:探测在每次启动时重新推导,对「用户是否做过选择」这一问题始终不可见。 +**`` 跟随解析出的 locale,而所服务的 markup 做不到这一点。** `apps/web/index.html` 是一份静态文件,服务所有访问者,因此它声明什么都必然对某些人是错的:解析发生在客户端,在文档被解析之后。于是由 locale 插件依据当前 locale 设置 `document.documentElement.lang`——激活时设置一次,因为探测结果或已采纳的 Host 偏好可能已与 markup 不一致;此后每次切换再设置一次。markup 声明产品默认值(`en`),使启动前的文档不至于主动误导。无障碍技术与浏览器功能(发音规则、翻译提示、字体回退、拼写检查)都读取该属性,因此陈旧的值是在误报文档语言,而不只是看起来不整齐。该属性承载 BCP 47 标签而非应用内部的 locale id:单独的 `zh` 会使文字(script)含义不明,因此已提供的中文文案声明 `zh-CN`。 + **浏览器 e2e 车道固定浏览器语言。** 断言中文文案的场景(`access-confirmation`、`models-settings`、`onboarding-deepseek-config`、`settings-chrome`)以 `apps/web/tests/support.ts` 的 `locale: ZH_BROWSER_LOCALE` 打开页面;`newEnglishPage` 声明 `en-US`。`settings-chrome.e2e.ts` 两次使用没有显式 locale 的全新 Host home:`en-US` 浏览器与 `fr-FR` 浏览器都会抵达英文界面。真正钉住回落值的是 `fr-FR` 那个场景——`en-US` 浏览器无论走探测还是走回落都会落在英文,因此只有本应用不提供的语言才能区分二者,而中文场景则证明探测仍然覆盖回落值。 ## Alternatives considered @@ -33,10 +35,12 @@ Status: implemented - **拆成两个常量,一个管开场 locale、一个管字典回落**:它区分了两个确实不同的问题,若两个答案不同也确有必要。但它们并不不同:字典是对称的,因此两者都是 `en`,第二个常量只会是同一个值的两个名字,外加一条无人强制的规则。对称性本身值得强制,所以直接为它设门禁。 - **开场用 `en`、字典回落仍保留 `zh`**:这看起来是保守选择,但在字典对称的前提下,它能解析的 key 与 `en` 完全相同,因此毫无收益;而在它真正会起作用的情形——某个 key 只存在于 `zh`——在整体英文的界面里渲染出中文文本,比让 reviewer 一眼看见裸 key 更糟。 - **让 e2e 车道的中文场景继续钉存储项(`dsh.locale=zh`)**:那会让套件保持绿色,却抹掉浏览器推导路径在组装后应用中唯一的运行处;改钉浏览器语言才能端到端地演练新的解析过程。 +- **按请求服务 ``,或干脆不管这个静态属性**:在服务端计算它需要用请求的 `Accept-Language` 去重新推导客户端本就会解析的结果,使同一条规则在两处重复,而且仍会输给服务端并不读取的存储偏好。放任其保持静态,正是该属性对某一种语言永远错误的原因。依据解析出的 locale 来设置,可保持单一真源。 ## Consequences - 来自英文浏览器的首访落在英文界面,中文浏览器落在中文界面,而两者皆未声明的浏览器落在英文而非中文界面。语言行依然呈现同样两个以自身语言自述的选项,两个方向的脱身通道都未改变。 - 字典解析方向发生反转:当前 locale 缺失的 key 现在回落到 `en` 而非 `zh`。在字典对称的前提下,没有任何已提供的 key 行为发生变化——这正是那道对称性门禁存在的原因:它是这次反转所依赖的前提。 +- `` 现在在两个方向上都如实报告屏幕上的语言,这也关闭了 [#2160](https://github.com/deepseek-harness/deepseek-harness/issues/2160)。若某个客户端从未激活 locale 插件,则保留所服务的默认值,因此该属性退化为旧的静态行为,而不会退化为空值。 - 客户端树的非浏览器运行(node 启动、非 jsdom 单测车道)现在以 `en` 开场。断言已提供中文文案的用例必须在其构造的 runtime 上显式调用 `setLocale('zh')`;套件级的 `usePinnedBrowserLanguages('zh-CN')` 仅在同时声明了 `@vitest-environment jsdom` 的文件中生效,因为没有 `window` 时探测路径根本不会读取 `navigator`。此前有七个 `*.client.spec.ts` 文件带着这样一条失效的固定语句,实际依赖的是旧的 `zh` 回落值。 - 探测的代价是每次服务构造遍历一次数组,且不会隐式写入 settings;插件激活后,显式 Host 偏好可能引发一次实时收敛。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index abea65ac9e..ab84f054ad 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -107,6 +107,28 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([ { id: 'en', label: 'English' }, ]) +/** + * `` tag per shipped locale. The locale id is the app's own + * vocabulary (primary subtag); the document attribute wants a BCP 47 tag, + * which assistive technology and browser features (pronunciation rules, + * translation offers, font fallback, spell check) read to pick their own + * behavior. `zh` alone leaves the script ambiguous, so the shipped Chinese + * copy names the variant it actually is. + */ +const DOCUMENT_LANGUAGE: Record = { zh: 'zh-CN', en: 'en' } + +/** + * Point `` at the active locale. Called on every locale change, + * so the attribute tracks the UI instead of standing at whatever the served + * markup happened to declare. + * @param active - the active locale id. + */ +function syncDocumentLanguage(active: LocaleId): void { + // Non-browser runs (node boots of the client tree) have no document. + if (typeof document === 'undefined') return + document.documentElement.lang = DOCUMENT_LANGUAGE[active] +} + /** * Dictionary registry plus locale preference. Lookup chain per key: the * entry's namespace in the active locale -> that namespace's en fallback -> @@ -371,6 +393,7 @@ export function apply(ctx: ClientContext): void { const store = createLanguageRowStore() let bound: BoundActions | undefined const sync = (snapshot: LocaleSnapshot): void => { + syncDocumentLanguage(snapshot.active) bound?.sync( snapshot.active, snapshot.locales.map(l => ({ id: l.id, label: l.label })), @@ -378,6 +401,10 @@ export function apply(ctx: ClientContext): void { ) } ctx.on('locale/change', sync) + // The served markup declares one language; the resolved locale may differ + // (browser detection, or a stored preference adopted after activation), so + // state it once at activation rather than waiting for the first change. + syncDocumentLanguage(locale.getLocale().active) const injected = (actions: BoundActions): LanguageRowInjected => { bound = actions // Re-sync from the getter so no event is lost between registration and diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index 891e22e5f5..b10a3e8e69 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -11,7 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' -import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/client' +import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' +import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { apply, inject } from '@deepseek-ai/dsh-client-locale/client' import type { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' @@ -43,7 +44,7 @@ async function bench(preference?: string) { ctx.provide('connection', { api: { settings: { describe: describeRpc, mutate } }, isLoopback: true } as never) // The settings transport and the forwarded-event port the plugin injects. new TestRemote(ctx) - await ctx.plugin(SettingsScopeBinder).await() + await ctx.plugin(SettingsScopeBinder, new SettingsSchemaService(ctx)).await() await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, locale: ctx.get('locale') as LocaleRuntime } } diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 59a7694633..14e826630e 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -67,7 +67,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ce67604757..154a76c92f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2490,9 +2490,6 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime - '@deepseek-ai/dsh-client-test-runtime': - specifier: workspace:^ - version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives From 9de06952ab68ccd4fd6e0ae6f04b57cc307e278a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 12:18:03 +0800 Subject: [PATCH 3/8] fix(locale): persist an explicit pick of the provisional locale setLocale returned early when the id already matched the active locale, so choosing the language already on screen wrote nothing. That value may be a provisional browser-derived or fallback resolution nothing has stored, so a different browser sharing the DSH home still resolved on its own. Write unconditionally; keep the render publish conditional. Broaden the dictionary parity gate to every workspace package, pair zh/en across sibling files and inline registrations, and fail when a dictionary has no counterpart. It previously scanned only packages/client and packages/ extensions, compared within a single module, and silently skipped unpaired dictionaries -- so the split locales/zh.ts + en.ts common pair, the inline directory-picker-browse dictionary, and session-log-export were unchecked. Normalize paths at ingestion so the sweep does not narrow on Windows. Regenerate the client API catalog and update the locale README pair: both described the old zh fallback direction. Add the English fallback dialog golden, and drop a dead afterEach plus the blank lines left where the dead browser-language pins were removed. --- apps/web/tests/settings-chrome.e2e.ts | 9 +- .../settings-chrome/dialog-en.expected.md | 45 ++++ packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/src/client/index.ts | 11 +- .../client/locale/tests/apply.client.spec.ts | 6 +- .../client/locale/tests/locale.client.spec.ts | 22 +- .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 2 - .../ui-theme/tests/apply.client.spec.ts | 1 - .../ui-workspace/tests/apply.client.spec.ts | 1 - .../src/client/api-catalog.ts | 2 +- scripts/locale-dictionary-parity.spec.ts | 243 +++++++++++++----- 17 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f3cf4b3bbe..216dae4dbb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -24,6 +24,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +// The English fallback surface: a browser naming no shipped language. +const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() @@ -496,6 +498,11 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // Golden of the English fallback dialog — the visible output this change + // produces. The zh golden above covers the detected-locale surface, so + // the pair pins both directions of the resolution. + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) } finally { @@ -506,6 +513,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md new file mode 100644 index 0000000000..605e2fe328 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md @@ -0,0 +1,45 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General": + - img + - text: General + - button "Models": + - img + - text: Models + - button "Plugins": + - img + - text: Plugins + - button "Agent presets": + - img + - text: Agent presets + - button "Open configuration file" + - button "Close": + - img + - text: Close + - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. + - button "Standard mode": + - text: Standard mode + - img + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write": + - text: Workspace Write + - img + - text: Language + - button "English": + - text: English + - img + - text: Appearance + - button "Light": + - img + - text: Light + - button "Dark": + - img + - text: Dark + - button "System" [pressed]: + - img + - text: System + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - button "Queue": + - text: Queue + - img diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 126c4ee685..e1cc2a1c88 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: a63807f093dc12831a41196e61008151868e3205 -README.zh.md: b302e6055ba30d2db0948c0a6f1551c4a9dcb24c +README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 +README.zh.md: 4f08344d6f030e408e570ff0ad31d0b0d4de3ecc diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index a63807f093..3fb5cce334 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index b302e6055b..4f08344d6f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ab84f054ad..3f14acf216 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -197,13 +197,20 @@ export class LocaleRuntime { /** * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) - if (this.snapshot.active === match.id) return - this.publish(match.id, true) + if (this.snapshot.active !== match.id) this.publish(match.id, true) void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a5c70ed624..4f4d08a951 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' @@ -78,10 +78,6 @@ describe('locale apply', () => { // localized copy sets its locale explicitly via setLocale/Host preference // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, // so there is no `window` and no browser-language detection to stub. - afterEach(() => { - vi.unstubAllGlobals() - }) - it('declares the slot service', () => { expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index eb279f1295..a945ccd07e 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -138,7 +138,7 @@ describe('LocaleRuntime', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale writes through the scope and republishes only on a real change', () => { const host = stubSettingsScope() const { svc, events } = make(host) svc.setLocale('en') @@ -147,9 +147,27 @@ describe('LocaleRuntime', () => { expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) + // Re-selecting the active locale publishes nothing (no subscriber churn) + // but still writes: the active value may be a provisional browser-derived + // resolution nothing has stored, and picking it is an explicit choice that + // must outlive this browser. svc.setLocale('en') expect(events).toHaveLength(1) - expect(host.set).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenLastCalledWith('preference', 'en') + }) + + it('persists an explicit pick of the provisional locale, so a shared DSH home agrees', () => { + // A browser naming no shipped language opens at FALLBACK_LOCALE with + // nothing stored. Choosing that same language in the menu must become + // durable, or a Chinese browser sharing the home still opens Chinese. + stubLanguages('fr-FR') + const host = stubSettingsScope() + const { svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.set).not.toHaveBeenCalled() + svc.setLocale('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') }) it('setLocale without a host scope stays process-local', () => { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7d998c7e11..e0d5c69e4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -21,7 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' - const ROSTER_ONE = { rpcId: 'r', result: { diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index f5d04fcfd6..e66fb73262 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -12,7 +12,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' - const sid = (k: string): SessionId => k as SessionId async function bench() { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 79d597f9bc..f8c7407e5a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -10,7 +10,6 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' - /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index d73950a6f7..385f726d36 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -11,7 +11,6 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' - async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index d8022887fa..c3987e38a5 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -13,7 +13,6 @@ import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' - /** * @param served - namespaces the Host describes; omitted answers a failed read, * which is what most of these specs want (no card has anything to render). @@ -87,7 +86,6 @@ describe('ui-settings-plugins apply', () => { expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) }) - it('injects a live tab projection, the card directory, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 0ac3dc32b8..fa20e0dd3b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -14,7 +14,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' - const SLOT = 'settings.general.item' function deferred() { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 4a819c8587..abba4371c2 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -7,7 +7,6 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' - async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 58bbbdee3f..c6539aa46a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'locale', summary: 'Dictionary registry plus locale preference.', - description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', + description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', methods: [ { signature: 'getLocale(): LocaleSnapshot', diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index d48fc3e808..ea40919fd9 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -9,83 +9,144 @@ * only one side breaks that: a reader of the other language sees a bare key * such as `list.aria` instead of text. This gate fails on the asymmetry rather * than waiting for the bare key to reach a UI. + * + * Discovery is deliberately broad, because a gate that silently narrows is + * worse than no gate. It sweeps every workspace package (not just + * `packages/client`), reads dictionaries wherever they are declared — + * `locales.ts`, a `locales/` directory, or inline in the plugin body — and + * pairs `zh`/`en` across sibling files as well as within one module. A `zh` + * dictionary whose `en` counterpart cannot be found anywhere is an error, not + * a skip. */ import type { Dirent } from 'node:fs' -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' const root = fileURLToPath(new URL('..', import.meta.url)) -/** Every `locales*.ts` module under a client package's `src/`. */ -function dictionaryModules(): string[] { +/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */ +function relative(file: string): string { + return file.slice(root.length).replaceAll('\\', '/') +} + +/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */ +function sourceFiles(): string[] { const files: string[] = [] - for (const group of ['client', 'extensions']) { - const groupRoot = resolve(root, 'packages', group) - let packages: string[] - try { - packages = readdirSync(groupRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - } catch { - continue - } - for (const pkg of packages) { - const srcRoot = resolve(groupRoot, pkg, 'src') - walk(srcRoot, files) + const packagesRoot = resolve(root, 'packages') + for (const group of directories(packagesRoot)) { + for (const pkg of directories(resolve(packagesRoot, group))) { + walk(resolve(packagesRoot, group, pkg, 'src'), files) } } return files.sort() } -function walk(dir: string, out: string[]): void { +/** Immediate subdirectory names, or none when the path is not a directory. */ +function directories(dir: string): string[] { + if (!existsSync(dir)) return [] let entries: Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { + // Swallows only the race between existsSync and readdirSync (a package + // directory removed mid-sweep); readdirSync is the sole statement in the + // try, so no other failure can reach here. + return [] + } + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +function walk(dir: string, out: string[]): void { + if (!existsSync(dir)) return + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + // Same narrow race as `directories`: readdirSync is the only statement + // guarded, so this cannot mask a parse or assertion failure. return } for (const entry of entries) { const full = resolve(dir, entry.name) - if (entry.isDirectory()) { - walk(full, out) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { - if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) - } + if (entry.isDirectory()) walk(full, out) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) } } +/** One discovered dictionary: which file and export name declared it. */ +interface Dictionary { + /** Repo-relative declaring file. */ + file: string + /** Export name, or the registration site for an inline literal. */ + name: string + /** Declared keys, sorted. */ + keys: string[] +} + /** - * Keys of every top-level `export const ...= { ... }` object literal, - * read from the AST so the gate never executes package code. - * @param file - absolute path of the dictionary module. - * @returns exported dictionary name mapped to its declared keys. + * Keys of every top-level `export const = { ... }` object literal whose + * name identifies a locale dictionary, plus inline `register(ns, locale, {...})` + * literals. Read from the AST so the gate never executes package code. + * @param file - absolute path of a candidate module. + * @returns discovered dictionaries, keyed by locale-bearing name. */ -function exportedDictionaries(file: string): Map { - const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) - const found = new Map() +function dictionariesIn(file: string): Dictionary[] { + const text = readFileSync(file, 'utf8') + // Cheap pre-filter: parsing every package source is wasteful, and a file + // with no locale token cannot declare a dictionary under any shape below. + if (!/\b(zh|en)\b/.test(text)) return [] + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) + const found: Dictionary[] = [] + const rel = relative(file) + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue - const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true - if (!exported) continue + if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue for (const decl of statement.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue - const initializer = unwrap(decl.initializer) - if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue - const keys: string[] = [] - for (const prop of initializer.properties) { - if (!ts.isPropertyAssignment(prop)) continue - if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) - } - found.set(decl.name.text, keys.sort()) + const literal = unwrap(decl.initializer) + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (localeOf(decl.name.text) === undefined) continue + found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) }) } } + + // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a + // registration loop in the plugin body. Both halves key off the enclosing + // array's line so they pair with each other and not across sites. + const visit = (node: ts.Node): void => { + if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { + const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + for (const element of node.elements) { + if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue + const [tag, dict] = element.elements + const literal = unwrap(dict) + if (tag === undefined || !ts.isStringLiteral(tag)) continue + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (tag.text !== 'zh' && tag.text !== 'en') continue + found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) }) + } + } + ts.forEachChild(node, visit) + } + visit(source) return found } +/** Declared property names of an object literal, sorted. */ +function keysOf(literal: ts.ObjectLiteralExpression): string[] { + const keys: string[] = [] + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + return keys.sort() +} + /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { let current = node @@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { return current } -/** Pair a `zh` export with the `en` export covering the same namespace. */ -function counterpart(name: string): string | undefined { - if (name === 'zh') return 'en' - if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` - if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` +/** + * The locale a dictionary name declares, and the namespace-ish remainder that + * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, + * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * @param name - export name or synthetic inline name. + * @returns locale plus pair key, or undefined when the name names no locale. + */ +function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined { + for (const locale of ['zh', 'en'] as const) { + const other = locale === 'zh' ? 'Zh' : 'En' + if (name === locale) return { locale, pair: '' } + if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + return { locale, pair: name.slice(2) } + } + if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } + } return undefined } describe('shipped locale dictionaries', () => { it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { - const modules = dictionaryModules() - // Guard the discovery itself: an empty sweep would pass every assertion - // below while checking nothing. - expect(modules.length).toBeGreaterThan(20) + const files = sourceFiles() + // Guard the discovery itself: an empty or narrowed sweep would pass every + // assertion below while checking nothing. + expect(files.length).toBeGreaterThan(500) - const mismatches: string[] = [] - let comparedPairs = 0 - for (const file of modules) { - const dicts = exportedDictionaries(file) - for (const [name, zhKeys] of dicts) { - const enName = counterpart(name) - if (enName === undefined) continue - const enKeys = dicts.get(enName) - if (enKeys === undefined) continue - comparedPairs++ - const rel = file.slice(root.length) - const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) - const enOnly = enKeys.filter(key => !zhKeys.includes(key)) - if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) - if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + // Pair within a file first; a dictionary whose counterpart is not in the + // same module then pairs with a sibling in the same directory. Both shapes + // ship here: `locales/settings.ts` exports zh+en together, while + // `locales/zh.ts` + `locales/en.ts` split the common pair across files. + const perFile = new Map() + for (const file of files) { + const dicts = dictionariesIn(file) + if (dicts.length > 0) perFile.set(relative(file), dicts) + } + + const groups = new Map>() + const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => { + const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>() + if (slot.has(locale)) { + throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`) + } + slot.set(locale, dict) + groups.set(key, slot) + } + + for (const [rel, dicts] of perFile) { + for (const dict of dicts) { + const parsed = localeOf(dict.name) + if (parsed === undefined) continue + const sameFileCounterpart = dicts.some((other) => { + const otherParsed = localeOf(other.name) + return otherParsed !== undefined + && otherParsed.pair === parsed.pair + && otherParsed.locale !== parsed.locale + }) + // Same-file pairs key by file so two pairs in one directory stay + // distinct; split pairs key by directory so siblings meet. + const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}` + place(key, parsed.locale, dict) } } - expect(comparedPairs).toBeGreaterThan(20) - expect(mismatches).toEqual([]) + const problems: string[] = [] + let comparedPairs = 0 + for (const [key, slot] of [...groups].sort()) { + const zh = slot.get('zh') + const en = slot.get('en') + if (zh === undefined || en === undefined) { + const present = zh ?? en + problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`) + continue + } + comparedPairs++ + const zhOnly = zh.keys.filter(k => !en.keys.includes(k)) + const enOnly = en.keys.filter(k => !zh.keys.includes(k)) + if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) + } + + // The shipped dictionary count only grows; a collapse means discovery or + // pairing broke, which would hide real asymmetry. + expect(comparedPairs).toBeGreaterThan(25) + expect(problems).toEqual([]) }) }) From 6e9b2560a334f4df9d96d46e6bc14d385f0ad602 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 12:25:09 +0800 Subject: [PATCH 4/8] fix(locale): keep the test-runtime devDependency and narrow the parity gate catch Restore @deepseek-ai/dsh-client-test-runtime in ui-settings-general: the package still imports bindSnapshotSelector from it in tests/components.client.spec.tsx, so removing it was manifest drift. The earlier knip report predated that file arriving on this branch. Swallow only ENOENT when reading a directory in the parity gate. A broad catch treated EACCES or an I/O failure as "absent", which would narrow the sweep and let the gate pass while checking less. --- .../client/ui-settings-general/package.json | 1 + pnpm-lock.yaml | 3 ++ scripts/locale-dictionary-parity.spec.ts | 37 +++++++++---------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 14e826630e..59a7694633 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -67,6 +67,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 154a76c92f..ce67604757 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2490,6 +2490,9 @@ importers: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../../test-support/client-runtime '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index ea40919fd9..5284801ae4 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -20,7 +20,7 @@ */ import type { Dirent } from 'node:fs' -import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { readdirSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' @@ -47,30 +47,27 @@ function sourceFiles(): string[] { /** Immediate subdirectory names, or none when the path is not a directory. */ function directories(dir: string): string[] { - if (!existsSync(dir)) return [] - let entries: Dirent[] + return readEntries(dir).filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +/** + * Directory entries, treating only a genuinely absent directory as empty. + * Any other failure (`EACCES`, I/O) rethrows: silently reading it as "absent" + * would narrow the sweep and let the gate pass while checking less. + * @param dir - absolute directory path. + * @returns entries, or none when the directory does not exist. + */ +function readEntries(dir: string): Dirent[] { try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - // Swallows only the race between existsSync and readdirSync (a package - // directory removed mid-sweep); readdirSync is the sole statement in the - // try, so no other failure can reach here. - return [] + return readdirSync(dir, { withFileTypes: true }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error } - return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) } function walk(dir: string, out: string[]): void { - if (!existsSync(dir)) return - let entries: Dirent[] - try { - entries = readdirSync(dir, { withFileTypes: true }) - } catch { - // Same narrow race as `directories`: readdirSync is the only statement - // guarded, so this cannot mask a parse or assertion failure. - return - } - for (const entry of entries) { + for (const entry of readEntries(dir)) { const full = resolve(dir, entry.name) if (entry.isDirectory()) walk(full, out) else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) From 8e2785d9eb4f8ca3181a781fbdfb25437b23cf0f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 13:28:18 +0800 Subject: [PATCH 5/8] fix(locale): cover direct register() dictionaries and assert assembled The parity gate recognized only a `[['zh',{...}],['en',{...}]]` array, so the two separate ctx.locale.register(NS, 'zh'|'en', {...}) calls in ui-permission-presets were unchecked: deleting a key from one side left the gate green. Pair those calls by their namespace argument. Widen the pre-filter to admit zhSettings/accessZh spellings, which a bare \b(zh|en)\b misses and would have skipped before parsing. Assert document.documentElement.lang in the assembled app. The served markup already ships lang="en", so the fr-FR scenario passes whether or not the sync runs; the zh scenario is the discriminating half and now asserts zh-CN before the switch and en after it. Drop the dead vi.unstubAllGlobals() from the document-language spec, which manages navigator with defineProperty and never calls vi.stubGlobal. --- apps/web/tests/settings-chrome.e2e.ts | 11 ++++++ .../tests/document-language.client.spec.ts | 3 +- scripts/locale-dictionary-parity.spec.ts | 38 +++++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 216dae4dbb..61f5af88c5 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -400,6 +400,11 @@ describe('web e2e: settings modal and General preferences', () => { await page.getByRole('button', { name: '设置', exact: true }).click() const zhDialog = page.getByRole('dialog', { name: '设置' }) await zhDialog.waitFor({ timeout: 10_000 }) + // The document language follows the active locale in the assembled app, not + // only on a directly-mounted plugin. This is a zh browser, so the served + // markup's `en` must already have been replaced — asserting it here (rather + // than only in an English scenario) is what makes the check discriminating. + expect(await page.evaluate(() => document.documentElement.lang)).toBe('zh-CN') // The Language selector pill shows the active locale's own name. const selector = zhDialog.getByRole('button', { name: '中文' }) expect(await selector.getAttribute('aria-haspopup')).toBe('menu') @@ -410,6 +415,8 @@ describe('web e2e: settings modal and General preferences', () => { // the rest of the app's copy is intentionally out of this row's scope.) const enDialog = page.getByRole('dialog', { name: 'Settings' }) await enDialog.waitFor({ timeout: 10_000 }) + // ...and the attribute follows that switch, in the assembled app. + await expect.poll(() => page.evaluate(() => document.documentElement.lang), { timeout: 5_000 }).toBe('en') expect(await enDialog.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true') await expect.poll(() => enDialog.getByText('Appearance', { exact: true }).count(), { timeout: 5_000 }).toBe(1) expect(await page.evaluate(() => localStorage.getItem('dsh.locale'))).toBeNull() @@ -498,6 +505,10 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // The markup already ships `en`, so this alone cannot prove the sync ran + // — the zh scenario above is the discriminating half. Asserted here too + // so a future change that resolves en but writes the wrong tag is caught. + expect(await frPage.evaluate(() => document.documentElement.lang)).toBe('en') // Golden of the English fallback dialog — the visible output this change // produces. The zh golden above covers the detected-locale surface, so // the pair pins both directions of the resolution. diff --git a/packages/client/locale/tests/document-language.client.spec.ts b/packages/client/locale/tests/document-language.client.spec.ts index b10a3e8e69..dc375ec4ce 100644 --- a/packages/client/locale/tests/document-language.client.spec.ts +++ b/packages/client/locale/tests/document-language.client.spec.ts @@ -61,7 +61,8 @@ describe('document language', () => { }) afterEach(() => { - vi.unstubAllGlobals() + // navigator properties are installed with defineProperty above, so they + // are removed the same way; nothing here goes through vi.stubGlobal. const own = navigator as unknown as Record delete own.languages delete own.language diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index 5284801ae4..b240c564ae 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -93,9 +93,12 @@ interface Dictionary { */ function dictionariesIn(file: string): Dictionary[] { const text = readFileSync(file, 'utf8') - // Cheap pre-filter: parsing every package source is wasteful, and a file - // with no locale token cannot declare a dictionary under any shape below. - if (!/\b(zh|en)\b/.test(text)) return [] + // Cheap pre-filter: parsing every package source is wasteful. The pattern + // must admit every shape `localeOf` accepts, or a file would be skipped + // before parsing — the silent narrowing this gate exists to prevent. A bare + // `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold + // between `h` and an uppercase letter. + if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return [] const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) const found: Dictionary[] = [] const rel = relative(file) @@ -112,10 +115,29 @@ function dictionariesIn(file: string): Dictionary[] { } } - // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a - // registration loop in the plugin body. Both halves key off the enclosing - // array's line so they pair with each other and not across sites. + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair + // handed to a registration loop keys off the enclosing array; separate + // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the + // namespace argument, so the two calls pair with each other. const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const callee = node.expression + const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + if (name === 'register' && node.arguments.length >= 3) { + const [ns, tag, dict] = node.arguments + const literal = unwrap(dict) + if ( + ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) + && (tag.text === 'zh' || tag.text === 'en') + && literal !== undefined && ts.isObjectLiteralExpression(literal) + ) { + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + } + } + } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 for (const element of node.elements) { @@ -167,7 +189,9 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin for (const locale of ['zh', 'en'] as const) { const other = locale === 'zh' ? 'Zh' : 'En' if (name === locale) return { locale, pair: '' } - if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + // Synthetic names for inline shapes carry their own pair key after the + // first ':' (the enclosing array's line, or the namespace expression). + if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { return { locale, pair: name.slice(2) } } From 0fe31f11a33661fd52fdca2d1d7b80cf8e06da79 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:39:06 +0800 Subject: [PATCH 6/8] fix(locale): correct two stale product-default-Chinese comments Chinese is no longer the product default since FALLBACK_LOCALE moved to en in this branch. connectFreshWorkspaceZh and the access-confirmation scenario both reach the Chinese surface by advertising ZH_BROWSER_LOCALE, not by inheriting a default, so their comments must say so. --- apps/web/tests/access-confirmation.e2e.ts | 5 +++-- apps/web/tests/support.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index aea33f14f1..a173f8399e 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -30,8 +30,9 @@ describe('web e2e: Full access confirmation', () => { // is temporarily unavailable. const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) - // Keep the product default Chinese locale: the golden pins the actual - // registered dictionary rather than a test-local translation callback. + // Keep the Chinese surface via {@link ZH_BROWSER_LOCALE}: the golden pins + // the actual registered dictionary rather than a test-local translation + // callback. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 38d0849784..3a1cd94782 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -88,9 +88,10 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo } /** - * {@link connectFreshWorkspace} over the product default Chinese locale: the - * English helper's anchors assume the locale every other scenario boots, so a - * scenario that deliberately keeps zh needs the localized picker copy. + * {@link connectFreshWorkspace} over a page that advertises + * {@link ZH_BROWSER_LOCALE}: the English helper's anchors assume the locale + * most other scenarios boot, so a scenario that deliberately keeps zh needs + * the localized picker copy. * @param page - the browser page under test. * @param root - workspace parent directory. * @param name - directory created under `root` and connected. From 9301def7ebd2ba0b457cfce95299053f38b61d91 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 17:55:10 +0800 Subject: [PATCH 7/8] fix(locale): tighten parity gate and correct copy-source wording Address the three open review threads on the dictionary parity gate. Regex/TEXT: localeOf now requires an uppercase ASCII [A-Z] flat-letter at the third position of a name-prefix shape, so zh2Foo/zh_probe are no longer treated as dictionaries in localeOf while the admission pre-filter skips them. The two now agree exactly. register detection now also admits a bare register identifier callee in addition to a property access, covering a future destructured register(NS, 'zh'|'en', dict) call instead of silently dropping it. A 3-arg register whose dictionary argument is a local variable is resolved through module-scope const initializers; one that cannot be resolved to an object literal makes the gate refuse with a named error rather than skipping the registration and narrowing the sweep. Also restate the FALLBACK_LOCALE rationale: the residual case points at English because a browser naming neither shipped language is the reader least likely to read Chinese, not because English is the copy's source language (Chinese is; packages/client/AGENTS.md). Sync the identical claim in the bilingual Agent Note and re-record its .i18n.yaml pairing. --- ...1-browser-derived-initial-locale.i18n.yaml | 4 +- ...26-07-31-browser-derived-initial-locale.md | 2 +- ...07-31-browser-derived-initial-locale.zh.md | 2 +- packages/client/locale/src/client/index.ts | 4 +- scripts/locale-dictionary-parity.spec.ts | 65 +++++++++++++++---- 5 files changed, 59 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml index f1168d973c..9aa0372a26 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md -2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee -2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5 +2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782 +2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211 diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md index 6fcd799b9c..66fd56327a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md @@ -14,7 +14,7 @@ Reading the browser fixed the readers whose browser names a language this app sh **The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active. -**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. +**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI. **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express. diff --git a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md index 73f2c825e1..721a785aa4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md @@ -14,7 +14,7 @@ Status: implemented **暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。 -**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 +**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index 3f14acf216..5b1d6c72b4 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -91,7 +91,9 @@ declare module '@deepseek-ai/cordis' { * language (and for non-browser runs), and the dictionary consulted after the * active locale misses a key. One constant serves both because the shipped * `zh`/`en` dictionaries carry identical key sets, so neither direction can - * leave a key unresolved; English is the source language of the copy. + * leave a key unresolved; the residual case points at English rather than + * zh because a browser naming neither shipped language is the reader least + * likely to read Chinese. */ export const FALLBACK_LOCALE: LocaleId = 'en' diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index b240c564ae..b51630f105 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -103,6 +103,20 @@ function dictionariesIn(file: string): Dictionary[] { const found: Dictionary[] = [] const rel = relative(file) + // Module-scope variable declarations, keyed by name. A 3-arg + // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier — + // e.g. a local dictionary variable rather than an inline literal — resolves + // through here so the gate still verifies its symmetry. + const moduleConsts = new Map() + for (const statement of source.statements) { + if (!ts.isVariableStatement(statement)) continue + for (const decl of statement.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) { + moduleConsts.set(decl.name.text, decl.initializer) + } + } + } + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue @@ -115,6 +129,14 @@ function dictionariesIn(file: string): Dictionary[] { } } + // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we + // cannot turn into an object literal. We refuse instead of skipping: a + // registration we cannot measure is exactly the silent narrowing this gate + // exists to catch. + const refuse = (ns: string, tag: string, why: string): never => { + throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`) + } + // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair // handed to a registration loop keys off the enclosing array; separate // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the @@ -122,20 +144,34 @@ function dictionariesIn(file: string): Dictionary[] { const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const callee = node.expression - const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined + const name = ts.isPropertyAccessExpression(callee) + ? callee.name.text + : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined if (name === 'register' && node.arguments.length >= 3) { const [ns, tag, dict] = node.arguments - const literal = unwrap(dict) - if ( - ns !== undefined && tag !== undefined && ts.isStringLiteral(tag) - && (tag.text === 'zh' || tag.text === 'en') - && literal !== undefined && ts.isObjectLiteralExpression(literal) - ) { - // The namespace expression's source text identifies the pair, so the - // zh and en calls for one namespace meet and calls for different - // namespaces stay apart. - found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) }) + if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return + if (tag.text !== 'zh' && tag.text !== 'en') return + const raw = unwrap(dict) + const literal = raw !== undefined && ts.isIdentifier(raw) + ? (() => { + const resolved = moduleConsts.get(raw.text) + return resolved === undefined ? undefined : unwrap(resolved) + })() + : raw + const why = raw !== undefined && ts.isIdentifier(raw) + ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal` + : 'third argument is neither an object literal nor a resolvable dictionary variable' + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) { + // The dictionary argument must resolve to an object literal; the + // gate refuses rather than skips, so the symmetry it verifies never + // silently narrows. + refuse(ns.getText(source), tag.text, why) } + const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression + // The namespace expression's source text identifies the pair, so the + // zh and en calls for one namespace meet and calls for different + // namespaces stay apart. + found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) }) } } if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { @@ -181,7 +217,10 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { /** * The locale a dictionary name declares, and the namespace-ish remainder that * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, - * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix + * shape requires an uppercase ASCII letter at the third position (`[A-Z]`), + * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe` + * cannot be treated as dictionaries in one place and skipped in another. * @param name - export name or synthetic inline name. * @returns locale plus pair key, or undefined when the name names no locale. */ @@ -192,7 +231,7 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin // Synthetic names for inline shapes carry their own pair key after the // first ':' (the enclosing array's line, or the namespace expression). if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) } - if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) { return { locale, pair: name.slice(2) } } if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } From 3967df95f78061de815f9e62630a85bdafb8436b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 18:19:30 +0800 Subject: [PATCH 8/8] docs(locale): clarify full-rollout note on the en fallback default The Consequences bullet named the zh copy surface the 'zh default', which could be read as the product's opening locale. It covers component-copy coverage only; the opening/fallback locale (browser naming no shipped language, or a non-browser run) is en since FALLBACK_LOCALE moved. State that explicitly and cross-link the browser-derived-initial-locale note, in both languages, and re-record the .i18n.yaml pairing. --- .../2026-07-30-client-locale-full-rollout.i18n.yaml | 4 ++-- .../architecture/2026-07-30-client-locale-full-rollout.md | 2 +- .../architecture/2026-07-30-client-locale-full-rollout.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index ecca8ab2b0..007b9c0d73 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c6c5a8f2faffd3e03462eaad159ae94c53c735ce -2026-07-30-client-locale-full-rollout.zh.md: 8d6220784104944f5d07b533e4f107ceffdfcfea +2026-07-30-client-locale-full-rollout.md: 6701aefa451786d3ca6ac27d7214824a6d903bab +2026-07-30-client-locale-full-rollout.zh.md: 0c05ec9699700d88d786bc661f7013f2ed09ebb2 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c6c5a8f2fa..6701aefa45 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -42,4 +42,4 @@ The "apply layer subscribes to `locale/change` and re-registers for fresh labels - A language switch refreshes the whole UI instantly with zero re-registration; adopting a new package is three steps (dictionary + declare-merge + `locale: NS`), no hand-written glue. - Cost: list-label consumers must know `resolveSlotLabel` (a raw `options.label` read can now hold a function); the `SlotLabel` type catches most misuse statically. - ui-primitives' Chinese defaults still render Chinese under the English locale **until a consumer passes labels** — the unmigrated JsonTree consumer (ui-trajectory) showing its English defaults happens to match that package's all-English status quo. -- Pinning e2e to English means the zh default is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. +- Pinning e2e to English means the zh copy surface is covered mainly by package-level component specs and the settings language-switch scenario; browser e2e no longer asserts zh copy. The opening/fallback locale (a browser naming no shipped language, or a non-browser run) is `en`, not zh — see [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md). diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 8d62207841..0c05ec9699 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -42,4 +42,4 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` - 语言切换全 UI 即时刷新且零重注册;新包接入 = 字典 + declare-merge + `locale: NS` 三步,无手写胶水。 - 代价:list label 的消费方必须知道 `resolveSlotLabel`(裸读 `options.label` 现在可能拿到函数);类型上 `SlotLabel` 已挡住多数误用。 - ui-primitives 的中文默认值在英文语言下依旧是中文,**直到消费方传入 labels**——未迁移的 JsonTree 消费方(ui-trajectory)显示其英文默认值,恰好符合其整包英文现状。 -- e2e 英文钉死意味着 zh 默认态主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。 +- e2e 英文钉死意味着 zh 文案面主要靠包级组件测试与 settings 语言切换用例覆盖,浏览器 e2e 不再验证 zh 文案。开场/回落 locale(声明了本应用都不支持语言的浏览器,或非浏览器运行)是 `en` 而非 `zh`,见 [browser-derived initial locale](../feature/2026-07-31-browser-derived-initial-locale.md)。