Widget languages (i18n)

The widget picks a language for each visitor automatically and can be pinned to one explicitly. It ships copy for the 16 most widely spoken languages in Europe, all inside the single bundle — no extra request, so switching is instant and the widget never renders in the wrong language while a file loads.

Supported languages

Ordered by number of speakers in Europe. The code is the BCP-47 primary subtag; the label is what the in-panel picker shows (each language in its own name).

# Code Language Picker label
1 en English English
2 ru Russian Русский
3 de German Deutsch
4 fr French Français
5 it Italian Italiano
6 es Spanish Español
7 pl Polish Polski
8 uk Ukrainian Українська
9 ro Romanian Română
10 nl Dutch Nederlands
11 tr Turkish Türkçe
12 pt Portuguese Português
13 el Greek Ελληνικά
14 cs Czech Čeština
15 sv Swedish Svenska
16 hu Hungarian Magyar

en is both the first entry and the fallback: anything unrecognised lands there rather than showing raw message keys.

How the language is chosen

First match wins:

  1. Explicit override on the embed — data-lang, the lang boot option, or window.hotlineSettings.lang.
  2. Instance default — branding.defaultLanguage, set per widget instance in the dashboard.
  3. The visitor's browser — navigator.language.
  4. English.

Region subtags are normalised to the base language, so fr-CA → fr, pt-BR → pt, de-AT → de. Case and _/- separators do not matter (pt_BR, SV, and el all resolve).

A visitor can still change it themselves with the picker in the panel header; that choice re-renders the UI immediately and does not persist.

Pinning a language

Per embed (highest precedence) — useful when the page already knows its audience:

<script
  src="https://cdn.hotline.red/hotline.js"
  data-public-key="pk_live_..."
  data-lang="fr"
  type="module"
></script>

Per widget instance — set branding.defaultLanguage in the dashboard's config editor. Applies everywhere that instance is embedded, unless a page overrides it as above.

Leave both unset to follow each visitor's own browser, which is the default and usually what you want.

Adding a language

Everything is checked by the type system or a spec, so the compiler and the suite will tell you if you miss a step.

  1. Add the code to the shared enum — packages/contracts/src/enums.ts, LocaleSchema. This is the single source of truth; the widget's tables are typed as Record<Locale, …>, so the build fails until step 3 is done.

  2. Add the picker label — apps/widget/src/i18n/index.ts, LOCALES (in speaker order) and LOCALE_LABELS. Use the endonym — the language's own name, Français not French — since visitors scan the list for their own.

  3. Add the message table — same file, MESSAGES. Copy the en block and translate every key. Notes that matter in practice:

    • These are buttons and labels in a small panel, not prose. Keep them short; an over-long button breaks the layout.
    • Keep the punctuation: the … on progress strings (sending, capturing, connecting, listening) is what marks them as transient, and a spec asserts it survives translation.
    • Use the register real consumer software uses in that language, and be consistent about formal vs informal address.
    • poweredBy is followed by the brand name, so it must read as a phrase that precedes one.
    • you / agent / system label who spoke in a transcript; filing / lookup / tool are short status labels.
  4. Update the spec's expected set — apps/widget/src/i18n/index.test.ts lists the supported codes, and language.spec.ts asserts the count. Both should name the new language.

  5. Run the checks.

    pnpm --filter @hotline/widget test && pnpm --filter @hotline/widget typecheck
    

    The specs verify that no language is missing a key (a gap would reach a visitor as a raw key like msgPlaceholder), that a language is not wholesale English left untranslated, and that progress strings keep their ellipsis.

  6. Check the size cost. Each language adds roughly 500 bytes gzipped to a bundle every embedding site downloads:

    pnpm --filter @hotline/widget build
    gzip -c apps/widget/dist/hotline.js | wc -c
    

Optionally add the language to docs/i18n.md (this table) and AGENTS.md.

Why every language ships in one file

All 16 tables are inline. The alternative — fetching a locale file on demand — would mean a network round trip before the panel could render, and a visible flash of the wrong language on a slow connection, in exchange for bytes that compress extremely well.

The measured cost of going from 6 to 16 languages was +5.1 KB gzipped (19.9 → 25.0 KB). We also measured packing the tables into positional arrays to strip the repeated key names: it saved only 522 bytes gzipped, because gzip already collapses that repetition, and it would have made every table position-sensitive and error-prone to edit. The readable form was kept deliberately.

Reply emails are a separate, smaller set

apps/backend/src/email/email.templates.ts keeps its own SUPPORTED_LOCALES and falls back to English for anything else. This is intentional: shipping widget copy is cheap and self-contained, but an outbound email nobody has proofread is not. Widening the widget's languages does not widen the email templates — do that separately and deliberately.