You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Two related defects in the topbar language toggle (#lang-toggle) surfaced during issue #13 QA on 2026-08-30:
Doesn't persist across reload. Clicking the toggle flips the UI labels, but on the next page load the language reverts (typically to en, sometimes to whatever the server last accepted — which is whatever the last non-silent-fail POST landed).
UX is non-standard and confusing. A single ghost button labelled "PT" / "EN" with no dropdown, no flag, no full label, no keyboard-accessible name beyond aria-label="Change language", no indication of current vs next language, and a misleading hardcoded "PT" before the first applyTranslations() runs.
Both are reported as out of scope for issue #13 (which lists "Language toggle, version badge, logout button unchanged" as an acceptance criterion) and need a dedicated issue to be triaged into the lane sequence.
Hardcoded PT text, no data-i18n, no id linking to the Settings selector.
app/static/app.js:1548-1570 — click handler
$("#lang-toggle").addEventListener("click",async()=>{appState.language=appState.language==="pt" ? "en" : "pt";$("#lang-toggle").textContent=appState.language==="pt" ? "PT" : "EN";updateHtmlLang();applyTranslations();writeLocalPrefs();// Re-render the active data view so table rows pick up the new languageconstactiveTab=$(".tab.active");if(activeTab){constview=activeTab.dataset.view;
...
try{awaitapi("/api/settings",{method: "POST",body: JSON.stringify({values: {language: appState.language}})});awaitloadSettings();}catch{/* best effort */}}});
app/static/app.js:1389-1393 — settings load overwrites client value
The persistence chain has three competing sources of truth (localStorage, server /api/settings, appState), with two failure modes:
POST /api/settings silently swallows errors (line1569: catch { /* best effort */ }). If the backend rejects the language update — CSRF mismatch, 401 after session expiry, validation error on language, network blip — the client thinks it persisted, the server didn't update, and on next load loadSettings() overwrites appState.language from the server's stale value. writeLocalPrefs() then overwrites localStorage with that server value.
loadSettings() is called after every successful POST (line1568) and unconditionally writes the server's value back into appState. This is the right behavior for a multi-device scenario but means a client click that the server rejects gets silently reverted on the next page load.
localStorage is read on init (line 88-94) before/api/settings is fetched (line 1503-1505 in init()). So the first paint uses the localStorage value, then loadSettings() overwrites it from the server — if the server disagrees, the user sees a flash of the right language followed by a flicker to the wrong one.
The hardcoded "PT" text in index.html:35 (no data-i18n) is a separate visual bug: on first paint in EN the button still says "PT" until applyTranslations() runs.
UX target
A standard, accessible language selector that:
Clearly shows the current language (not the next-target).
Exposes both PT and EN options with full labels (e.g. "Português" / "English"), not abbreviations.
Works on keyboard alone (arrow keys to switch, or Tab + Enter/Space).
Has a visible label or aria-label that screen readers can announce ("Language: Português, English").
Persists across reload, page navigation, and login session changes.
Surfaces backend persistence failures (translated toast or inline error) instead of silently swallowing.
Matches the visual weight of #phase-badge and #version-badge in the topbar — neither louder nor quieter.
Suggested visual treatment (any of these is acceptable; lane assignee picks):
<select> with data-i18n for the label, full language names as options. Simplest, most standard, accessible by default.
Segmented control (two buttons "PT" / "EN") with the current one highlighted via .active class. Keyboard-arrow friendly.
Dropdown with the current language as the button label and a menu of options. More chrome but matches the cmd+K palette pattern.
Whichever shape is chosen, the label must come from app/i18n.py so the toggle itself is translated in each language (no English-only labels in PT mode).
Scope (proposed for this issue)
Redesign #lang-toggle per the UX target above.
Surface backend persistence failures instead of swallowing them in the catch { /* best effort */ } block.
Audit the three sources of truth (localStorage, server /api/settings, appState) and pick one canonical flow:
On page load: read localStorage first (fast first paint), then /api/settings (server override).
On toggle: optimistically flip appState, write localStorage, POST /api/settings, on failure show a translated inline error and revert localStorage to the server value so the next reload matches the server.
Add data-i18n to the new toggle so its label is translated.
Add tests/test_lang_toggle_vm.mjs (mirror tests/test_shell_nav_vm.mjs) covering: click flips labels, click persists to localStorage, simulated server failure reverts localStorage, first paint in EN doesn't show "PT".
Update #nav-overflow-style hardcoded overflow menu items if any related to language toggle (none today, but flag if introduced).
Out of scope
Adding new languages (only PT and EN exist; "Adding new language" is its own tracker item if anyone proposes one).
Adding language-aware number/date formatting beyond what Intl.DateTimeFormat(locale, …) already does in formatTime() at app/static/app.js:257-283.
Translating third-party plugin strings (there are none per AGENTS.md's dependency-free rule).
Translating the cmd+K palette placeholders (separate issue if needed).
Acceptance criteria
Toggle clearly shows the current language (not the next-target).
Both PT and EN options are exposed with full labels and accessible names.
On click, the language persists across:
hard reload (Ctrl/⌘+Shift+R),
login/logout cycle,
multi-tab session.
On click, the server /api/settings POST is awaited and any failure surfaces a translated inline error AND reverts localStorage to the server value.
First paint in EN does not show the hardcoded "PT" label.
On first paint in PT, the toggle reads "Português" (or equivalent translated current-language label) not "PT".
Keyboard-only: Tab to the toggle, arrow/Enter/Space switches language, focus stays on the toggle.
Screen reader announces the toggle as a language selector with the current value.
app/i18n.py has new keys (suggested: language_toggle_label, language_pt, language_en, language_save_error) for PT and EN, all distinct (existing test_no_key_maps_to_itself invariant preserved).
No new HTTP endpoints. No new pyproject.toml deps. No new CSS framework imports.
make check and node tests/test_lang_toggle_vm.mjs pass.
Test plan
tests/test_lang_toggle_vm.mjs (new): load real app/static/app.js under node:vm with a stub that records localStorage.setItem calls, captures /api/settings POSTs, and exposes a controllable failure switch. Scenarios:
Click toggle → appState.language flips and localStorage is updated synchronously.
Click toggle → server POST is awaited; on success appState.language stays and a second toggle click flips back without flicker.
Click toggle → server POST returns 4xx/5xx → translated error shown AND localStorage reverts to server value.
Initial load with localStorage="pt" and server="en" → first paint shows PT, then settles to EN with no flicker (or the documented opposite, depending on chosen design).
First paint in EN shows the correct EN toggle label, not "PT".
Keyboard: Tab to toggle, Enter activates, Space activates, focus stays.
make check (full pytest) green, no regressions.
Manual smoke: login → toggle → reload → toggle stays. Repeat with a 30-second logout cycle. Repeat with a second browser tab.
Visual QA
1280px PT and EN, screenshot of topbar showing the redesigned toggle in both languages.
~768px tablet, both languages.
Keyboard-only walkthrough recorded or screenshot-stitched.
Dependencies
None. This is a single-section UI change.
Rollout / compatibility
The flight-geofence:lang localStorage key is read in app/static/app.js:88-94 and written in app/static/app.js:99-100. The redesign should keep that key for back-compat.
The /api/settings POST body shape { values: { language: "pt" } } is the existing contract; don't change it.
The fallback in app/static/app.js:1549 (appState.language === "pt" ? "en" : "pt") is a degenerate two-state toggle and should be replaced by an explicit setter that reads the chosen option.
Open questions
Visual shape: <select> vs segmented control vs dropdown. Lane assignee chooses based on the surrounding topbar density and the cmd+K palette pattern.
Where in the topbar? Today #lang-toggle sits between #phase-badge and #logout. Should it stay there, move next to #phase-badge, or sit at the far right next to logout? Default: leave in place.
Should the redesigned toggle remove the abbreviation fallback ("PT"/"EN" both shown in current state)? Default: show full name only.
Summary
Two related defects in the topbar language toggle (
#lang-toggle) surfaced during issue #13 QA on 2026-08-30:en, sometimes to whatever the server last accepted — which is whatever the last non-silent-fail POST landed).aria-label="Change language", no indication of current vs next language, and a misleading hardcoded "PT" before the firstapplyTranslations()runs.Both are reported as out of scope for issue #13 (which lists "Language toggle, version badge, logout button unchanged" as an acceptance criterion) and need a dedicated issue to be triaged into the lane sequence.
Current state
app/static/index.html:35Hardcoded
PTtext, nodata-i18n, noidlinking to the Settings selector.app/static/app.js:1548-1570— click handlerapp/static/app.js:1389-1393— settings load overwrites client valueapp/static/app.js:88-94— localStorage readapp/static/app.js:95-103— localStorage writeapp/static/app.js:1374-1377— toggle button text updateBug analysis — persistence
The persistence chain has three competing sources of truth (localStorage, server
/api/settings,appState), with two failure modes:POST /api/settingssilently swallows errors (line1569:catch { /* best effort */ }). If the backend rejects the language update — CSRF mismatch, 401 after session expiry, validation error onlanguage, network blip — the client thinks it persisted, the server didn't update, and on next loadloadSettings()overwritesappState.languagefrom the server's stale value.writeLocalPrefs()then overwrites localStorage with that server value.loadSettings()is called after every successful POST (line1568) and unconditionally writes the server's value back intoappState. This is the right behavior for a multi-device scenario but means a client click that the server rejects gets silently reverted on the next page load./api/settingsis fetched (line 1503-1505 ininit()). So the first paint uses the localStorage value, thenloadSettings()overwrites it from the server — if the server disagrees, the user sees a flash of the right language followed by a flicker to the wrong one.The hardcoded "PT" text in
index.html:35(nodata-i18n) is a separate visual bug: on first paint in EN the button still says "PT" untilapplyTranslations()runs.UX target
A standard, accessible language selector that:
Tab+Enter/Space).aria-labelthat screen readers can announce ("Language: Português, English").#phase-badgeand#version-badgein the topbar — neither louder nor quieter.Suggested visual treatment (any of these is acceptable; lane assignee picks):
<select>withdata-i18nfor the label, full language names as options. Simplest, most standard, accessible by default..activeclass. Keyboard-arrow friendly.Whichever shape is chosen, the label must come from
app/i18n.pyso the toggle itself is translated in each language (no English-only labels in PT mode).Scope (proposed for this issue)
#lang-toggleper the UX target above.catch { /* best effort */ }block./api/settings,appState) and pick one canonical flow:/api/settings(server override).appState, write localStorage, POST/api/settings, on failure show a translated inline error and revert localStorage to the server value so the next reload matches the server.data-i18nto the new toggle so its label is translated.tests/test_lang_toggle_vm.mjs(mirrortests/test_shell_nav_vm.mjs) covering: click flips labels, click persists to localStorage, simulated server failure reverts localStorage, first paint in EN doesn't show "PT".#nav-overflow-style hardcoded overflow menu items if any related to language toggle (none today, but flag if introduced).Out of scope
Intl.DateTimeFormat(locale, …)already does informatTime()atapp/static/app.js:257-283.Acceptance criteria
/api/settingsPOST is awaited and any failure surfaces a translated inline error AND reverts localStorage to the server value.app/i18n.pyhas new keys (suggested:language_toggle_label,language_pt,language_en,language_save_error) for PT and EN, all distinct (existingtest_no_key_maps_to_itselfinvariant preserved).pyproject.tomldeps. No new CSS framework imports.make checkandnode tests/test_lang_toggle_vm.mjspass.Test plan
tests/test_lang_toggle_vm.mjs(new): load realapp/static/app.jsundernode:vmwith a stub that recordslocalStorage.setItemcalls, captures/api/settingsPOSTs, and exposes a controllable failure switch. Scenarios:appState.languageflips and localStorage is updated synchronously.appState.languagestays and a second toggle click flips back without flicker.Tabto toggle,Enteractivates,Spaceactivates, focus stays.make check(full pytest) green, no regressions.Visual QA
Dependencies
None. This is a single-section UI change.
Rollout / compatibility
flight-geofence:langlocalStorage key is read inapp/static/app.js:88-94and written inapp/static/app.js:99-100. The redesign should keep that key for back-compat./api/settingsPOST body shape{ values: { language: "pt" } }is the existing contract; don't change it.app/static/app.js:1549(appState.language === "pt" ? "en" : "pt") is a degenerate two-state toggle and should be replaced by an explicit setter that reads the chosen option.Open questions
<select>vs segmented control vs dropdown. Lane assignee chooses based on the surrounding topbar density and the cmd+K palette pattern.#lang-togglesits between#phase-badgeand#logout. Should it stay there, move next to#phase-badge, or sit at the far right next to logout? Default: leave in place.Reported by
QA during issue #13 review, 2026-08-30.
Related
mainafter [UX Redesign] Recompose dashboard as map-first Monitoramento workspace #14 is merged. [UX Redesign] Reorganize Configurações into clear operational sections #19 atomically moves manual actions from Monitoramento to Configurações › Operação and establishes the Settings section architecture") — this issue may slot into the Configurações lane or become its own lane; lane-owner decides.