Skip to content

Language toggle: persist across reload + redesign UX #23

Description

@luandro

Summary

Two related defects in the topbar language toggle (#lang-toggle) surfaced during issue #13 QA on 2026-08-30:

  1. 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).
  2. 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.

Current state

app/static/index.html:35

<button id="lang-toggle" class="button ghost lang-toggle" aria-label="Change language">PT</button>

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 language
  const activeTab = $(".tab.active");
  if (activeTab) {
    const view = activeTab.dataset.view;
    ...
    try {
      await api("/api/settings", { method: "POST", body: JSON.stringify({ values: { language: appState.language } }) });
      await loadSettings();
    } catch { /* best effort */ }
  }
});

app/static/app.js:1389-1393 — settings load overwrites client value

if (settings.language && settings.language.value) {
  appState.language = settings.language.value;
}

app/static/app.js:88-94 — localStorage read

try {
  const lang = typeof localStorage !== "undefined" ? localStorage.getItem("flight-geofence:lang") : null;
  const tz = typeof localStorage !== "undefined" ? localStorage.getItem("flight-geofence:timezone") : null;
  if (lang === "pt" || lang === "en") appState.language = lang;
  ...
} catch {}

app/static/app.js:95-103 — localStorage write

function writeLocalPrefs() {
  try {
    if (typeof localStorage !== "undefined") {
      localStorage.setItem("flight-geofence:lang", appState.language);
      localStorage.setItem("flight-geofence:timezone", appState.timezone);
    }
  } catch {}
}

app/static/app.js:1374-1377 — toggle button text update

updateHtmlLang();
const langBtn = $("#lang-toggle");
if (langBtn) langBtn.textContent = appState.language === "pt" ? "PT" : "EN";

Bug analysis — persistence

The persistence chain has three competing sources of truth (localStorage, server /api/settings, appState), with two failure modes:

  1. 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.
  2. 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.
  3. 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:
    1. Click toggle → appState.language flips and localStorage is updated synchronously.
    2. Click toggle → server POST is awaited; on success appState.language stays and a second toggle click flips back without flicker.
    3. Click toggle → server POST returns 4xx/5xx → translated error shown AND localStorage reverts to server value.
    4. 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).
    5. First paint in EN shows the correct EN toggle label, not "PT".
    6. 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

  1. Visual shape: <select> vs segmented control vs dropdown. Lane assignee chooses based on the surrounding topbar density and the cmd+K palette pattern.
  2. 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.
  3. Should the redesigned toggle remove the abbreviation fallback ("PT"/"EN" both shown in current state)? Default: show full name only.

Reported by

QA during issue #13 review, 2026-08-30.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions