CatalyzeUpDocs
sondely / product

Survey Language Detection

Survey Language Detection

How the public survey experience picks its language, what is broken today, and exactly how the behavior is tested automatically. Written from a live analysis of production (July 2026), not from assumptions.

1. Current behavior (verified live on sondely.com)

Tested by driving real browsers with forced locales against a production survey link:

Browser locale Survey content (title, questions) Take-page chrome ("Take the test", "No account needed…", "Been here before?…")
fr-CA French ("Test de l'indice de bonheur") English (bug)
es-ES Spanish English (bug)
en-US English English

What works. SurveyTakePage already auto-detects the browser language for survey content: on a multi-language survey, with no explicit ?lang= parameter, it reads navigator.language, narrows fr-CA to fr, and re-fetches the survey in that language when the survey supports it. A francophone visitor gets French questions with zero clicks.

What is broken. The landing chrome around the survey (TakeLanding.tsx) contains hardcoded English literals. They stay English no matter the browser, the survey language, or the account preference. The qps hardcoded-string sweep never caught this because its route list does not include /survey/:publicId (that route needs a seeded survey id to visit).

What is undefined. A signed-in user whose account preference (user.locale) is French gets no French on the take page unless their browser is also French. The account preference currently plays no role in survey-content language. That is a resolution-order gap, not a bug in any one line.

2. Required resolution order (survey take page)

First match wins. This extends the platform resolution order (Platform Localization spec §3.3) with the survey-specific sources:

  1. Explicit ?lang= URL parameter — a shared link that pins a language always wins.
  2. The respondent's explicit picker choice on this survey — once they click Français, that choice sticks for the session (and for the account if signed in).
  3. Signed-in user's user.locale — the account preference, when the survey supports that language.
  4. Browser language (navigator.language, narrowed to base code) — when the survey supports it. This is the zero-click default for anonymous respondents.
  5. The survey's default_language.

Constraint: a language is only ever selected from the survey's available_languages. The page chrome follows the selected content language (already the behavior since PR 19), and every chrome string on the take path must come from the message catalogs — no literals.

3. Fixes required

  1. Localize TakeLanding.tsx — move all literals to catalog keys (takeLanding.*), agent-generate FR/ES, review.
  2. Extract the resolution logic into a pure function resolveSurveyLanguage({queryLang, pickedLang, userLocale, browserLang, availableLanguages, defaultLanguage}) so it is unit-testable in isolation instead of living inline in the fetch flow.
  3. Add user.locale (step 3) to the resolution, which the current implementation skips.
  4. Gate the take route in the sweep — add /survey/:publicId (with a seeded survey) to the qps sweep's route list so hardcoded chrome on this path can never ship silently again.

4. How to test this automatically

The question "does the app detect the browser language?" is fully automatable. The browser locale is an input you can force headlessly; no manual Chrome fiddling is required or acceptable as the verification method.

Unit (vitest). resolveSurveyLanguage is a pure function; table-test every rung of the ladder:

  • ?lang=es beats everything, even a French browser and French account.
  • Picker choice beats user.locale and browser.
  • user.locale: fr beats a Spanish browser.
  • Browser fr-CA narrows to fr and is used when available.
  • Browser de (unsupported) falls through to default_language.
  • Single-language survey short-circuits to default_language.

End to end (Python Playwright, scripts/e2e_localization.py, new browser-detect mode). Playwright creates browser contexts with a forced locale — browser.new_context(locale="fr-CA") makes navigator.language report fr-CA exactly like a real French user's Chrome. The mode runs against a seeded multi-language survey and asserts:

  1. locale="fr-CA" context → survey title and questions render in French and the take chrome renders in French.
  2. locale="es-ES" → Spanish.
  3. locale="de-DE" (unsupported) → survey's default language, no crash.
  4. locale="es-ES" but URL has ?lang=fr → French (explicit link wins).
  5. Signed-in session with user.locale: fr in an English-locale context → French (account preference wins over browser).
  6. Click the picker to English in a French context, reload → English sticks.

Exit code 0 only when all six pass. Runs in CI like the other modes.

Sweep coverage. The qps sweep gains the seeded /survey/:publicId route, so the TakeLanding regression class is structurally caught.

5. How to simulate a French browser in Chrome, manually

For hand-testing (the automated tests above are the source of truth):

  • The DevTools Settings → Preferences → Language dropdown does NOT do this. It changes the language of the DevTools panels themselves and has no effect on navigator.language or what pages see. Changing it and seeing no effect on the page is expected.
  • Correct option A — DevTools Sensors panel: DevTools → ⋮ menu → More tools → Sensors → Location → pick or create a location with locale fr-CA. This overrides navigator.language for that tab while DevTools is open. Reload the page.
  • Correct option B — real browser setting: chrome://settings/languages → add French → ⋮ on French → Move to the top → reload. This changes navigator.language and the Accept-Language header for the whole browser.
  • Correct option C — separate instance: launch Chrome with --lang=fr on a fresh profile.