9.8 KiB
Agent Note: The Settings language a fresh browser opens in comes from the browser
Status: implemented
English | 中文
Problem
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 fixes readers whose browser names a registered language, but the product still needs a stable residual when the current catalog has no match. With only the built-in catalog, a browser asking for neither zh nor en (fr, de) reaches that case, and those readers are the least likely to read Chinese.
Decision
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 after each language-catalog change, expressing the browser/fallback order over the definitions currently registered. The nonblocking settings lifecycle then applies optional locale.preference from $DSH_HOME/settings.yaml; absence leaves the browser-derived value active, while an unavailable saved id remains pending and takes effect if that language registers later.
One constant serves both the opening residual and the dictionary-chain terminus. FALLBACK_LOCALE answers both "which language does the UI open in when the browser names none registered" and "where must every declared dictionary fallback chain end". Those are different questions, and splitting them into two constants would be right if either answer had to differ. External languages may contribute partial dictionaries and declare intermediate fallbacks; every chain still reaches en. Every built-in zh/en pair declares identical key sets, so its final fallback resolves, while scripts/locale-dictionary-parity.spec.ts rejects a key added to only one built-in side instead of letting it surface later as a bare key such as list.aria in a running UI.
Browser matching uses the registered catalog and the browser's ordered list. detectBrowserLocale() walks [...(navigator.languages ?? []), navigator.language]. Each browser tag first matches a registered id exactly and then by primary subtag, so a registered pt-BR wins for that exact request, while zh-Hans-CN and an unmatched zh-TW land on the built-in zh, and en-GB lands on en. A browser asking only for unregistered languages (fr, de with the built-in catalog) yields nothing and leaves FALLBACK_LOCALE in charge. Registering or removing a language recomputes this provisional result. navigator.language trails the list and covers its absence on hosts that ship a Navigator without languages; tolerating that runtime omission follows the same environment-boundary distrust as the localStorage guards.
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.
<html lang> 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. An external language id is already its BCP 47 tag and reaches the attribute unchanged; the built-in zh shorthand remains the sole exception and declares zh-CN, because zh alone leaves the script ambiguous.
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().localeor a singlenavigator.languageread: 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): language registrations provide explicit ids, while dictionary fallback is separately explicit. Exact-id then primary-subtag matching preserves the built-in behavior without inventing an implicit distance policy between externally registered variants. - 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
Configfields 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
zhas the dictionary fallback while opening inen: it reads as the conservative choice, but with symmetric dictionaries it never resolves a key thatenwould not, so it buys nothing; and where it would matter — a key present only inzh— 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
<html lang>per request, or leaving the static attribute alone: computing it server-side would need the request'sAccept-Languageto 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 chooses the first registered language matched from the browser's ordered list. With only the built-in catalog, an English browser lands in English, a Chinese browser in Chinese, and a browser naming neither lands in English rather than Chinese; external registrations join the same Language row and matching process.
- Dictionary resolution ends at
en: a built-inzhmiss reaches it directly, while an external language follows its declared per-key chain first. Symmetric built-in dictionaries keep shipped copy complete, which is why the parity gate exists. <html lang>now reports the language on screen in both directions, which closes #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 setsetLocale('zh')explicitly on the runtime they construct; a suite-levelusePinnedBrowserLanguages('zh-CN')only works in files that also declare@vitest-environment jsdom, because without awindowthe detection path never readsnavigatorat all. Seven*.client.spec.tsfiles carried such a dead pin and were relying on the oldzhfallback instead. - Detection cost is one array walk per service construction or language-catalog change and no implicit settings write; an explicit Host preference may cause one live convergence after plugin activation or when its pending language registers.