Skip to content

feat(ui): i18n — zh/en language packs with switching (v1.21) - #86

Merged
argszero merged 1 commit into
mainfrom
feat/i18n
Aug 18, 2026
Merged

argszero merged 1 commit into
mainfrom
feat/i18n

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

Frontend i18n for the whole UI, per host rant (timestamp 2026-08-18T20:49:22.514499).

Rant (verbatim): 前端国际化 i18n — 中英文语言包 + 切换机制(宿主 2026-08-18 指示)
现状:<html lang="zh-CN"> 硬编码中文;设置页「界面语言」下拉(中文/English)是纯摆设——无事件绑定、无切换逻辑;仅部分标签有 .en 小字注释(55 处),正文全中文;无语言包 / 无 t() 函数 / 无记忆。
验收标准:语言下拉切换生效且 localStorage 记忆;zh/en 两套文案覆盖全站;后端错误在 en 下显示英文(已知错误映射);分支 feat/i18n,提交 PR 自 merge,版本号 v1.21(UI 版)

Changes

  • ui/js/i18n.js (new): zero-dependency language packs I18N = { zh, en } (563 keys ×2) covering nav / login / view titles / buttons / form labels+placeholders / table headers / toasts / empty states / stat cards / role+status badges / settings / shortcuts help / onboarding tour / errors; t(key, vars) with zh fallback; fmtNum (zh-CN/en-US locale) + relative-time strings; data-i18n static batch replacement (applyStatic); backend error mapping mapErr (15 known Chinese errors → English in en mode); localStorage('atp_lang') persistence; <html lang> sync; atp:langchange event.
  • ui/index.html: data-i18n / data-i18n-ph / data-i18n-title on static text (login page, sidebar, all view headers, marketplace toolbar/table head, sharing form, wallet cards, transactions, settings incl. endpoints/API keys/notify/prefs, admin/ops views, help panel, chat modal, tour pop); settings language dropdown now <select id="prefs-lang"> with zh/en values.
  • ui/js/app.js: all user-facing strings → t('key'); lang-sensitive constants (NAV / VIEW_TITLE / TOUR_STEPS / HELP_KEYS / SHARE_STATUS / RAISE_STATUS / TX_COLUMNS / DAY_LABELS) hold keys resolved at render time so switching re-renders instantly; #prefs-lang bound to I18n.setLang; atp:langchangerenderNav() + renderView(activeView) + document.title (+ tour re-render); provider labels zh/en aware.
  • ui/js/api.js: errors passed through I18n.mapErr() (en shows English for known backend errors like 「该模型暂无可用 key」「点数余额不足」「需要管理员权限」; unknown passthrough; zh passthrough).
  • ui/README.md: i18n conventions section (v1.21).

Acceptance (rant)

  • Language dropdown works and persists via localStorage; zh/en packs cover the whole site
  • Backend errors shown in English when lang=en (known errors mapped)
  • Branch feat/i18n, PR self-merged, UI version v1.21

Tests

  • cargo build / cargo test (102/102) / cargo fmt --check / cargo clippy --all-targets -- -D warnings all green
  • node --check on i18n.js / app.js / api.js
  • Standalone node smoke test: t() zh/en, placeholder interpolation, zh fallback, setLang + localStorage persistence, mapErr known/unknown, fmtNum, fmtRelTime
  • Key-consistency scan: all 496 keys referenced by app.js/index.html exist in both ZH and EN dictionaries

- ui/js/i18n.js (new): zero-dep I18N zh/en dictionaries (563 keys x2),
  t(key, vars) with zh fallback, fmtNum/fmtRelTime localization,
  data-i18n static batch replacement, backend error mapping (mapErr),
  localStorage('atp_lang') persistence, <html lang> sync, atp:langchange
- index.html: data-i18n/data-i18n-ph/data-i18n-title on static text
  (login/nav headers/marketplace/sharing/wallet/transactions/settings/
  admin/ops/help/chat/tour); #prefs-lang dropdown (zh/en values)
- app.js: all user-facing strings -> t(); lang-sensitive constants
  (NAV/VIEW_TITLE/TOUR_STEPS/HELP_KEYS/status maps/TX_COLUMNS) hold keys
  resolved at render time so switching re-renders instantly; settings
  language dropdown bound; atp:langchange -> renderNav+renderView+title
- api.js: errors passed through I18n.mapErr (en shows English for known
  backend errors, e.g. 该模型暂无可用 key / 点数余额不足)
- ui/README.md: i18n conventions section (v1.21)

Rant: 2026-08-18T20:49:22.514499 (前端国际化 i18n — 中英文语言包 + 切换机制)
@argszero
argszero merged commit 68f9f70 into main Aug 18, 2026
1 check passed
@argszero
argszero deleted the feat/i18n branch August 18, 2026 13:28
argszero added a commit that referenced this pull request Sep 11, 2026
The English pack rendered four stat-card sub-labels as raw code
identifiers where the Chinese pack has proper prose:

  ops.stats.keys.sub.on       status=on       -> enabled
  ops.stats.calls.sub         usage_records   -> usage records
  admin.emp.stats.admins.sub  role=admin      -> admin role
  admin.emp.stats.deps.sub    organization    -> organization management

These were introduced with the language packs (#86) and kept on the
English side by the later i18n clean-up (#87), which stripped the
English annotation suffixes from the zh pack but left these values as
they were. `organization` also collided visually with the value of the
adjacent Departments card ("0 departments" + "organization").

Only English values change; the zh pack is byte-identical, so no key,
placeholder or parity change is involved (775 == 775).

Co-authored-by: argszero <argszero@argszerodeMac-mini.local>
argszero added a commit that referenced this pull request Sep 15, 2026
The sidebar prints a keycap digit and a "Shortcut N" tooltip on every nav item,
and one global number-key handler resolves the digit. Both sides must read the
same array:

  const NAV_ORDER = NAV.flatMap((g) => g.items);
  const short = NAV_ORDER.indexOf(item) + 1;     // the keycap
  const item = NAV_ORDER[Number(e.key) - 1];     // the handler

renderNav()'s guest branch hand-built an equivalent-looking literal that is not
a member of NAV_ORDER, so indexOf(item) was -1 and the guest sidebar advertised
keycap 0 with the tooltip "Shortcut 0 . Marketplace" -- while 0 resolves to
NAV_ORDER[-1], i.e. nothing at all. The digit that does open the marketplace is
2, which the guest is never told.

The shape dates from #44 (v1.17 D keyboard accessibility), which added the
keycap machinery without noticing that the guest item is not a registry member;
it survived #86 (i18n) and the recent nav work. ui/README.md documents the
opposite (the badge is NAV_ORDER's index + 1, and the digits are 1..N of that
same array), so this is drift, not a trade-off.

Fix: the guest group draws from the registry it shares with the handler --
NAV_ORDER.filter((it) => GUEST_VIEWS.includes(it.id)).

Gate: state_gate::the_sidebar_advertises_only_digits_that_work -- four rules
with independent teeth: (1) renderNav renders no hand-built nav-item literal;
(2) the keycap is derived once and never branches on the session; (3) exactly
one line indexes the registry and it selects with the pressed digit; (4) the
registry is derived from NAV, not a second hand-written list. A companion test
pins the scanners on synthetic input, comments included.

Also recalibrates the i18n positive controls (T_LITERAL_COUNT 543 -> 542,
T_LITERAL_DISTINCT 434 -> 433): removing that one T("nav.marketplace") call
site is the only change to the T() literal population.
argszero added a commit that referenced this pull request Sep 15, 2026
A sharing key has three reachable statuses (`on` / `paused` / `off`): PATCH
/api/sharings/:id accepts all three, and GET /api/sharings applies no status
filter, so a soft-deleted row stays in the list. The row button labels the
transition from the CURRENT status (Pause / Resume / Re-list), while the
outcome toast picked it from the NEXT status with a two-valued ternary
(`s.status === "on" ? "paused" : "on"`) -- so `off -> on` (re-list) was
reported as a resume: button "Re-list", toast "Resumed sharing of ...".
The key naming that branch, `share.toggle.relisted`, is present in both
language packs and unreachable -- an unreachable key is the fingerprint of a
lost branch. Provenance is drift, not a trade-off: 68f9f70 (#86) introduced
the three-way button, 89963f3 (#94) replaced the mock's three-branch toggle
with the two-branch ternary.

- add `SHARE_TOGGLE` (status -> { label, next, outcome }): the button's action,
  its next status and its outcome text now come from the SAME entry, read by
  both consumers through `shareToggle(s.status)`.
- new static gate
  `state_gate::the_sharing_toggle_outcome_comes_from_the_same_entry_as_its_action`:
  share.toggle.* literals may only live inside the table; the table's key set
  must equal `SHARE_STATUS`'s (both derived from source); the label/outcome
  columns must be unique per entry; and the handler (derived as the endpoint
  that sends a NON-literal status to /api/sharings/) must not name a
  share.toggle.* key nor branch on a status literal, and must share exactly one
  accessor with the button markup. Extractor self-proofs use synthetic inputs.
- i18n positive controls recalibrated from what the gate reported:
  T() literals 542 -> 537, distinct 433 -> 428 (five literal call sites became
  table entries).
- ui/README.md: rule + measured scope; index.html cache-bust for app.js.
argszero added a commit that referenced this pull request Sep 15, 2026
…265)

A sharing key has three reachable statuses (`on` / `paused` / `off`): PATCH
/api/sharings/:id accepts all three, and GET /api/sharings applies no status
filter, so a soft-deleted row stays in the list. The row button labels the
transition from the CURRENT status (Pause / Resume / Re-list), while the
outcome toast picked it from the NEXT status with a two-valued ternary
(`s.status === "on" ? "paused" : "on"`) -- so `off -> on` (re-list) was
reported as a resume: button "Re-list", toast "Resumed sharing of ...".
The key naming that branch, `share.toggle.relisted`, is present in both
language packs and unreachable -- an unreachable key is the fingerprint of a
lost branch. Provenance is drift, not a trade-off: 68f9f70 (#86) introduced
the three-way button, 89963f3 (#94) replaced the mock's three-branch toggle
with the two-branch ternary.

- add `SHARE_TOGGLE` (status -> { label, next, outcome }): the button's action,
  its next status and its outcome text now come from the SAME entry, read by
  both consumers through `shareToggle(s.status)`.
- new static gate
  `state_gate::the_sharing_toggle_outcome_comes_from_the_same_entry_as_its_action`:
  share.toggle.* literals may only live inside the table; the table's key set
  must equal `SHARE_STATUS`'s (both derived from source); the label/outcome
  columns must be unique per entry; and the handler (derived as the endpoint
  that sends a NON-literal status to /api/sharings/) must not name a
  share.toggle.* key nor branch on a status literal, and must share exactly one
  accessor with the button markup. Extractor self-proofs use synthetic inputs.
- i18n positive controls recalibrated from what the gate reported:
  T() literals 542 -> 537, distinct 433 -> 428 (five literal call sites became
  table entries).
- ui/README.md: rule + measured scope; index.html cache-bust for app.js.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant