Skip to content

feat(i18n): runtime UI translation layer + Brazilian Portuguese (pt-BR) - #2269

Closed
tchellow wants to merge 16 commits into
Kpa-clawbot:masterfrom
tchellow:feat/i18n
Closed

tchellow wants to merge 16 commits into
Kpa-clawbot:masterfrom
tchellow:feat/i18n

Conversation

@tchellow

Copy link
Copy Markdown

Follow-up to #1550. That issue was closed because there was no i18n layer and no one to own a locale. This PR adds the layer, translates every page to Brazilian Portuguese (pt-BR), and I'm volunteering to maintain the pt-BR locale.

English users see no change: English stays the source text, and any missing translation falls back to it.

How it works

  • No build step, no dependencies. public/i18n.js defines window.I18n, tr(text, params) and trn(count, singular, plural, params).
  • The English text is the key. Developers keep writing English inline and wrap it in tr('…'). Nothing is extracted into IDs, so an English-only contributor's workflow barely changes.
  • Dictionaries live in public/locales/<code>.json (flat "English": "translation"). Plural forms are { "one": …, "other": … } objects, resolved with Intl.PluralRules.
  • Fallback is English, per string. A partly translated locale still works, and a string added tomorrow shows in English until someone translates it.
  • Static markup uses data-i18n, data-i18n-title, data-i18n-aria-label and data-i18n-placeholder, applied when the dictionary loads. app.js waits for the dictionary (max 3 s) before the first route renders, so there is no flash of English.
  • Language picker: Customizer → Display → "Language". The choice is stored per browser in localStorage["meshcore-lang"]; auto follows the browser. Changing it reloads the page.
  • Markup never enters a translation. Links, <strong>, codes and node names go in through {placeholders}. Values from nodes or MQTT are escaped by the caller before they are passed in, as before.

What is deliberately not translated

Following the triage notes in #1550:

  • Protocol tokens and firmware enums: ADVERT, GRP_TXT, TXT_MSG, TRACE, FLOOD, TRANSPORT_FLOOD, …
  • Packet-filter syntax, field names and config keys (hashRegions, pathTrust.minHashBytesForMapping, …)
  • Units and technical terms: SNR, RSSI, dBm, Hz, MIDI, BPM; on the Perf page, Go/SQLite runtime terms (Goroutines, Heap, RSS, WAL, …)
  • Operator-written content: home steps, FAQ, footer links. Only the built-in defaults are translated, and only when the operator has not changed them.

Safety rails

  • tests/unit/test-i18n.js (in test-all.sh) checks that every listed language has a dictionary and vice versa. For every entry it checks that the translation keeps the English key's {placeholders} and links and adds no HTML tags. It also covers language detection, the English fallback (missing entry, failed fetch, 404), plural rules, the DOM attributes and the language switch.
  • node scripts/i18n-report.js pt-BR lists strings in the code with no translation and dictionary keys no longer used. pt-BR is at 2005/2005.
  • English output is unchanged. Source-reading tests that looked for English literals were extended to accept the tr('…') form, not loosened. One visible difference: a counter that showed "0 hop" now reads "0 hops".
  • A few places that put unescaped e.message into innerHTML are now escaped, since they were touched anyway.

Adding a language

  1. Copy public/locales/pt-BR.json to public/locales/<code>.json and translate the values. Keys must stay exactly as they are.
  2. Add one entry to LANGUAGES in public/i18n.js, for example { code: 'de', name: 'Deutsch' }.
  3. Run node tests/unit/test-i18n.js and node scripts/i18n-report.js <code>.

The flat JSON format can be loaded directly into Weblate or Crowdin later, as suggested in #1550.

Commits

One commit per page, each with its own dictionary entries and test updates, so it can be reviewed page by page:

  1. Runtime layer + pt-BR pilot (nav, home, Customizer language picker, test-i18n.js, i18n-report.js)
  2. Default home texts
  3. Packets
  4. Map
  5. Live
  6. Nodes
  7. Node Analytics / Reach
  8. Channels
  9. Tools
  10. Observers (incl. observer detail, compare, MQTT panel)
  11. Analytics (all 17 tabs)
  12. Scope Audit
  13. Perf
  14. Audio Lab
  15. Customizer
  16. RX Coverage

Testing

  • Every suite in test-all.sh passes, except test-preflight-xss-gate.js, which needs Python; my dev machine doesn't have it, so please let CI confirm that one.
  • ESLint: no new warnings in any touched file.
  • Browser: every page was reviewed in pt-BR and in English on a test instance fed by live MQTT data.
  • Perf: tr() is a single dictionary lookup plus placeholder substitution, called while HTML strings are built. It adds no requests and no loops; the dictionary is one ~190 KB JSON (before gzip), fetched once with the usual cache buster. In English nothing is fetched at all.

Not in this PR

  • Syncing the language choice across devices: the settings-sync allowlist is server-side (cmd/server/settings_allowlist.go).
  • An operator-level default language in config.json.
  • A lint gate that flags new untranslated literals in changed files. i18n-report.js is the basis for one; happy to add it as a follow-up if wanted.
  • Note: two leaderboard column tooltips on the RX Coverage page are Dutch in the original source. They are left as they are and translated for pt-BR.

🤖 Generated with Claude Code

tchellow and others added 16 commits October 23, 2026 19:44
Adds a build-free translation layer and a Brazilian Portuguese
dictionary, piloted on the navigation chrome and the home page.

- public/i18n.js: tr()/trn() with {placeholders} and CLDR plurals,
  English text as the key with English fallback, language from the
  saved choice (meshcore-lang), else the browser, else English.
  Translates static markup through data-i18n* attributes.
- index.html loads i18n.js in <head> so the dictionary fetch starts
  early; app.js waits for I18n.ready (3 s cap) before the first route.
- Customizer > Display: language picker (reloads on change).
- Translated: top nav, bottom nav + More sheet, nav drawer, home page,
  timeAgo().
- tests/unit/test-i18n.js: detection, tr/trn, loading/fallback, DOM
  application, and dictionary rules (same placeholders, no markup).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Operator home text (hero, steps, FAQ, footer labels) now goes through
tr(). The built-in defaults and the config.example.json defaults are in
the pt-BR dictionary, so instances that kept them get them translated;
text an operator wrote has no entry and renders exactly as written.
Legacy "<emoji> Label" footer labels keep the emoji and translate the
words.

test-i18n: translations must keep the English entry's links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Packets page fully routed through tr()/trn(): toolbar and filters,
table headers and column picker, row tooltips, detail pane, byte
breakdown, observations, BYOP decoder and the standalone packet page.
Shared pieces used here are translated too: hop names/conflict popover
and path-symbols legend (hop-display), region and area filters, and
the filter help, autocomplete, context menu and saved filters
(filter-ux; packet-filter.js descriptions are translated at render,
the engine is untouched).

- PANEL_CLOSE_HTML becomes panelCloseHtml(): it was built at load,
  before the dictionary arrives.
- Modules that unit tests load on their own get a two-line English
  tr()/trn() fallback; two tests that slice packets.js source are
  updated for the tr() calls.
- scripts/i18n-report.js: per-language coverage (missing keys, keys not
  found as literals) for translators.
- pt-BR: 322 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Map page routed through tr()/trn(): map controls (node types with
active/uncertain/stale counts, byte size, display toggles, status,
scope config states and their explanations, region scope filter and
hint, neighbours, important links, last heard, quick jump), node and
observer popups, path inspector, route trust message, affinity debug
and important-link popups, cluster and marker aria labels.
Also translated: estimated-position popups (map-estimated.js) and the
packet-route renderer's popups, legend and labels (route-render.js).

- SCOPE_STATES and ROLE_LABELS stay English data, translated where
  they are shown (both are built at load, before the dictionary).
- map.js, map-estimated.js and route-render.js get the two-line
  English tr()/trn() fallback for tests that load them alone;
  test-issue-1356 accepts the translated forms of its source checks.
- pt-BR: 180 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Live page routed through tr()/trn(): header stats, display toggles and
their screen-reader descriptions, node filter, audio controls, feed
(empty state, detail card), legend (packet-type descriptions, node
roles, marker styles), panel corner buttons and announcements, VCR bar
(buttons, state labels, timeline scopes, missed-packets prompt),
fullscreen/settings toggles, node detail panel and its paths list.

- Packet-type short names stay English (filter tokens); their long
  descriptions from payload-labels.js are translated where shown.
- The LCD readout (LIVE / PAUSE / PLAY) keeps its device-style English.
- live.js gets the English tr()/trn() fallback; two tests that run
  slices of live.js get a pass-through tr (test-xss-escape-sinks'
  evalTemplate now provides tr/trn to every extracted template).
- pt-BR: 111 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nodes page routed through tr()/trn(): search, role counts, tabs,
status/last-heard/silent/LoRa filters, table headers, side pane and
slide-over, full node page (status explanations, relay activity,
traffic-share and bridge scores with their tooltips, scopes, recent
adverts, direct/relay "Heard by", neighbours, affinity debug, paths,
clock skew and its evidence), not-found/error states.
Also translated: clock-skew badge, sparkline and the Coverage nav link
(roles.js), self-reported repeaters (reported-nodes.js) and LoRa
parameters (rf-params.js).

- Data labels (tabs, confidence, skew severity, advert groups) stay
  English and are translated where shown.
- Modules get the English tr()/trn() fallback; three source-check
  tests accept the translated forms (same English text in tr()).
- pt-BR: 192 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Per-node subpages routed through tr()/trn(): Analytics (coverage
notice, stat cards, chart titles/descriptions/axes/legends, uptime
heatmap day names, battery chart and status, peer table), hop count at
this node (node-hop-analytics: filters, summary, empty/ambiguous
notes, axes) and Reach (header, scope line, stat cards, link filters,
coverage legend, link table, counts, busy/empty/error states) with its
map legend and coverage tooltips.

- coverageNotice's parameter `tr` (the time range) is renamed `range`
  so it no longer shadows the translation function.
- Data labels (day names, hop filter nouns, link tiers, stat cards)
  stay English and are translated where shown.
- test-coverage-gate's sandbox gets a pass-through tr.
- pt-BR: 136 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Channels page routed through tr()/trn(): sidebar (sections, catalogue,
row badges, share/remove/colour controls, unread counts), Add Channel
modal (generate PSK, add private key, scan QR, monitor hashtag,
propose for everyone), Share modal, message view (headers with counts,
loading/empty/error/wrong-key states, message meta, scope chip),
sender tooltip and node panel, add/remove/share status messages.
Also translated: QR rendering and camera scan (channel-qr.js), colour
picker, and channel proposals (validation messages, statuses, list).

- English output is unchanged (e.g. the "2-bytes" hash-size label).
- The 'Encrypted — click to decrypt' sentinel stays data: map.js
  compares it and it is never displayed.
- PRIVATE_CHANNEL_LABEL, proposal MSG/STATUS_LABELS stay English data,
  translated where shown.
- emoji-allowlist: channel-qr.js comment moved from line 133 to 135.
- Three source-check tests accept the tr() forms; two sandboxes get a
  pass-through tr/trn.
- pt-BR: 133 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tools landing (app.js), Path Inspector (input help, validation
messages, results table, per-hop evidence, stats) and Trace Viewer
(search, summary stats, path graph and its legend, propagation
timeline, empty/error states), plus the unknown-route page.

- validatePrefixes() returns translated messages; map.js's inspector
  pane already passes them through tr().
- test-xss-escape-sinks: the TRACE-1 template gets a pass-through tr
  and the unknown-route check accepts the tr() form.
- pt-BR: 30 new entries; 100% of the strings in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap user-visible strings in tr()/trn() across observers.js,
observer-detail.js, mqtt-status-panel.js, observer-airtime.js and
compare.js; add 136 pt-BR entries. English output is unchanged.

The list error message now escapes e.message. Source-reading tests
(XSS sinks, headings, firmware columns, healthStatus) accept the tr()
form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap user-visible strings in tr()/trn() across all 17 Analytics tabs
in analytics.js and add 516 pt-BR entries. English output is unchanged
except "0 hop" now reads "0 hops". Byte-size labels use real plurals
in pt-BR.

Error messages from subpath loads are now escaped. The repeater
dashboard and scatter tests pass tr/trn into their sandboxes and
accept the tr() form of the tab labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap user-visible strings in scope-audit.js in tr()/trn(), covering
the declared and transport views, the chip and caveat tooltips, the
empty state and the search counts. Add 92 pt-BR entries. English
output is unchanged; markup inside sentences enters through
placeholders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap the Performance Dashboard's headings, card labels, table headers,
buttons and messages in tr(); add 47 pt-BR entries. Runtime terms
(Goroutines, GC, Heap, RSS, WAL, Syscalls, Event Loop, P50/P95) stay
in English. English output is unchanged.

The error message is now escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap the Audio Lab's controls, sections, table headers, sound-mapping
explanations and messages in tr()/trn(); add 47 pt-BR entries,
including the four scale names. Synth terms (MIDI, BPM, Hz, oscillator
type names) and the formulas stay as they are. English output is
unchanged.

The error message is now escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap the customizer's tabs, field labels, color and hint tables, theme
presets, colorblind options, display settings, home editor, GeoFilter
tab and modal, export tab and status messages in tr()/trn(). Label
tables built at load stay English and are translated where they are
shown. Add 233 pt-BR entries.

Operator content (home steps, FAQ, footer links) is not translated.
English output is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Wrap the opt-in coverage page's title, subtitle, layer and gap
toggles, legends, cell tooltips, leaderboard headers and messages in
tr()/trn(); add 35 pt-BR entries. The two leaderboard column tooltips
are Dutch upstream; they stay as they are in English and get pt-BR
entries keyed on the Dutch text.

The escape test passes tr into its sandbox. English output is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@efiten

efiten commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Thank you for this, and for doing the pt-BR translation itself, not only the plumbing.

We are keeping the UI English-only for now, so I am closing this PR. The reason is maintenance, not the quality of the work. Once a translation layer is in, every new feature, popup and tooltip needs a key and a translation before the language stays complete. Today the frontend changes in most merged PRs, and nobody is set up to keep a second language in step with that. A partially translated UI is harder to use than an English one.

When we closed #1550 we said we would reopen if a translator volunteered. You did, so to be clear about what changed: the blocker is the per-feature cost for every contributor, not a missing translator.

Things that would change this decision, for later:

  • the frontend settles down enough that new strings are rare;
  • a lint gate that fails CI on an untranslated string, plus a fallback rule everyone agrees on;
  • more than one person committing to maintain a language.

Your branch stays available. If those conditions are met, it is the starting point.

@efiten efiten closed this Oct 25, 2026
efiten added a commit that referenced this pull request Oct 25, 2026
…s columns (#2280)

## Problem

The RX coverage leaderboard has two column tooltips in Dutch, since
#1728 (`public/rx-coverage.js:340` and `:342`). The rest of the UI is
English.

## Change

- **score:** now says what `cmd/server/rx_dashboard.go:362-366`
computes. Each ~150 m cell (`leaderboardHexRes = 13`,
`rx_dashboard.go:264`) adds 1 divided by the number of observers that
heard something there, so an unshared cell counts 1 and a cell shared
with 3 others counts 0.25.
- **cells:** a plain translation.

No logic changes, so there is no new test. `sh test-all.sh` passes. Not
done: no browser check of the tooltips.

Noticed in the review of #2269, which had picked the Dutch text up as an
English key.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants