ADR-027: Admin console i18n — self-contained en/ko/zh/ja dictionary¶
Date: 2026-07-21 Status: Accepted Related: ADR-001 (data-free console), ADR-002 (console CSP)
Context¶
The admin console (/admin/ui/, internal/server/adminui/) shipped English-only,
with a handful of hint paragraphs carrying a hand-maintained Korean duplicate in
an inline <span class="ko">. The user asked for the console to support English,
Korean, Chinese, and Japanese as defaults.
Two constraints from prior ADRs bound the design:
- ADR-002 CSP (
default-src 'self') rules out a CDN-hosted i18n library — any dependency has to ship inside the embeddedstatic/bundle. - ADR-001 data-free console —
adminui_test.gobanslocalStorageanddocument.cookieoutright, and permitssessionStorageonly for the three named OAuth PKCE keys (ADR-026). A persisted language preference would need a fourth storage key, widening that invariant for a low-stakes feature.
adminui_test.go also pins several strings verbatim inside index.html (e.g.
"per gateway instance", "OUTSIDE the audit chain", "fail-closed", "never the
secret value") and two strings inside app.js ("signed in as ", and the
analytics-index degraded-logs message) — any translation mechanism has to
leave those exact English substrings in the served source.
Decision¶
A self-contained i18n.js dictionary, applied at runtime; English stays the
canonical text baked into the HTML/JS source; language choice is never
persisted.
- One new static file,
i18n.js(served alongsideapp.js/style.css,adminui.go'scontentTypesmap gained one entry). No new dependency. MSGis a flat{ "section.key": { en, ko, zh, ja } }table (~225 keys). Every key carries all four locales — checked by hand at authoring time and guarded byTestAdminUI_i18nWired's markers; a missing locale falls back toenat themsg()call site rather than failing.- Static markup opts in via
data-i18n="key"(textContent) anddata-i18n-placeholder="key"(input placeholder).applyLang(lang)walks both attribute sets and rewrites in place, plus sets<html lang>. - Elements
app.jsalso writes to at runtime (health status text, view title, table bodies, status messages, confirm() dialogs, empty-row text) are deliberately never markeddata-i18n— they call the samemsg("section.key")helper directly at render time instead. This avoids a footgun whereapplyLang's blanket sweep would stomp a valueapp.jsjust computed (e.g. resetting a live "unreachable" health status back to a translated "checking" on every language switch). - The translation helper is named
msg(), nott()—app.jsalready usedtpervasively as the loop variable for team-record objects (for (const t of rows),teamLimitsSummary(t),fillTeamForm(t)); naming the global functiontwould have shadowed inside every one of those scopes and called a team object as a function. - Hints containing inline
<code>/<b>children (e.g. "...declared inconfig(policy-as-code)...") are split into multipledata-i18nspans around the untouched child element, rather than replacing the whole paragraph'stextContent(which would delete the child element). Each run of surrounding text gets its own dictionary key. - No persistence.
LANGis detected once per page load fromnavigator.language(prefix match againstko/zh/ja, elseen) and held only in a JS variable. The<select id="lang-select">in the sidebar footer switches it for the rest of the session; a reload re-detects. This keeps the ADR-001 data-free invariant exactly as narrow as it was pre-ADR-026 (three PKCE keys, nothing else) at the cost of the language choice not surviving a reload — an acceptable trade for a low-stakes preference. - Two strings stay hardcoded English in
app.js, matching the pre-existing test pins rather than editing those tests' intent: the"signed in as "identity line (TestSelfServiceWhoamiUI) and the analytics-index-off degraded-logs message (TestAdminUI_logsViewDegradesWhenIndexOffButBodiesOn, which grepsapp.js's own source for the phrase "analytics index"). Moving either intoi18n.jswould make the pinned substring disappear fromapp.jsand fail an existing test that predates this ADR. - One header cell (
<th>key</th>in the budget-alerts table) stays untranslated for the same reason —TestAdminUI_alertsTableShowsKeyColumngreps for that exact four-character substring with no attributes. - The old
<span class="ko">...</span>duplicate-text idiom and its.koCSS rule are removed; the Korean text they carried moved into the dictionary.
Consequences¶
- Adding a fifth language means extending every dictionary entry with one more locale key — no structural change.
- A translator/reviewer can audit coverage by scanning
i18n.jsalone; no string is duplicated betweenindex.htmland the dictionary except the small set of test-pinned English exclusions listed above (each commented in place). - The
#usage-modelsroutable-models placeholder and thestat-*-suboverview labels stay in their initially-rendered language until the next event that re-renders them (a key issued, a metrics fetch); this is the same category of gap as the pinned dynamic strings above and was judged not worth a special-cased refresh path.