Skip to main content

Internationalisation (i18n)

Chativa uses i18next. The widget ships with 20 languages, and you can add or override any of them at runtime.

Supported languages​

CodeLanguageCodeLanguage
enEnglish (source)viVietnamese
trTurkishruRussian
esSpanishukUkrainian
frFrenchjaJapanese
deGermankoKorean
itItalianzh-CNChinese (Simplified)
pt-BRPortuguese (Brazil)zh-TWChinese (Traditional)
plPolishhiHindi
nlDutcharArabic
idIndonesianheHebrew

Each language covers both the chat shell (@chativa/ui: header, input, message list, survey, emoji picker, tool calls) and the built-in GenUI components (@chativa/genui: form, appointment, rating, date picker).

The list is exported as SUPPORTED_LOCALES if you want to build your own language picker:

import { SUPPORTED_LOCALES, i18next } from "@chativa/ui";

SUPPORTED_LOCALES; // ["en", "tr", "es", "fr", "de", "it", "pt-BR", ...]
note

The translations other than English and Turkish were machine-generated and have not yet had a native-speaker review. Corrections are very welcome — see Add or improve a bundled language.

Arabic and Hebrew ship their strings only; right-to-left layout is handled separately.

Set the active language​

By default the language is detected from the browser (query string, cookie, localStorage, then navigator.language, via i18next-browser-languagedetector). To force one:

import { i18next } from "@chativa/ui"; // re-exported from @chativa/core
i18next.changeLanguage("tr");

Or declaratively, before mount:

window.chativaSettings = { locale: "tr" };

All LitElement components that extend I18nMixin (every Chativa component does) re-render automatically when the language changes.

How detected codes resolve​

Browsers report codes like de-AT, pt-PT or zh-Hant-TW. i18next first tries the exact code, then its bare language (de), then the fallback chain Chativa configures:

DetectedUses
pt, pt-PT, any other Portuguesept-BR
zh-Hant, zh-Hant-*, zh-HK, zh-MOzh-TW
zh, zh-Hans, zh-SG, other Chinesezh-CN
iw (legacy Hebrew code)he
Regional variants (de-AT, es-MX, …)the base language (de, es, …)
Anything unsupporteden

A key missing from a bundle also falls back along the same chain, ending at English.

Plurals​

Keys that take a {{count}} use i18next's plural suffixes, which follow each language's CLDR plural categories — not English's one / other:

LanguagesForms
ja, ko, zh-CN, zh-TW, vi, id_other
en, tr, de, nl, hi_one, _other
es, fr, it, pt-BR_one, _many, _other
he_one, _two, _other
ru, uk, pl_one, _few, _many, _other
ar_zero, _one, _two, _few, _many, _other
"starLabel_one": "{{count}} звезда",
"starLabel_few": "{{count}} звезды",
"starLabel_many": "{{count}} звёзд",
"starLabel_other": "{{count}} звезды"

new Intl.PluralRules("ru").resolvedOptions().pluralCategories tells you which forms a language needs.

Add a new language at runtime​

import { i18next } from "@chativa/ui";

i18next.addResourceBundle("sv", "translation", {
header: { title: "Chatt", subtitle: "Vi finns här" },
input: { placeholder: "Skriv ett meddelande…" },
commands: { clear: { description: "Rensa historiken" } },
});

i18next.changeLanguage("sv");

Any key you leave out falls back to English.

Add or improve a bundled language​

To ship a language with Chativa itself (a pull request to this repository):

  1. Copy packages/ui/src/i18n/en.json to packages/ui/src/i18n/<code>.json and translate every value.
  2. In packages/genui/src/i18n/index.ts, copy the EN block to a new const XX: Record<string, string> block, translate it, and add it to GENUI_LOCALES. Keep one "key": "value", entry per line — the check script parses this file.
  3. Import the JSON in packages/ui/src/i18n/i18n.ts and add it to BUNDLED_LOCALES (and to FALLBACK_LNG if other codes should resolve to it).
  4. Add it to the sandbox language picker in apps/sandbox/src/sandbox/sections/FeaturesSection.ts, and to the table at the top of this page.
  5. Run pnpm i18n:check and pnpm --filter @chativa/ui test.

Rules the checks enforce:

  • Same keys as English. Plural keys need the target language's CLDR forms (see Plurals).
  • Identical {{placeholders}} — never translate the name inside the braces.
  • No value left identical to English, except loanwords on a small allowlist (Chat, Emoji, Online, Offline, the brand name, plus a few per-language words such as Spanish Error). The allowlist is ALLOW_IDENTICAL in scripts/i18n-check.mjs.

Please say in the PR whether a native speaker reviewed the translation.

Translation checks​

Parity check script​

scripts/i18n-check.mjs compares every UI bundle and every GenUI block against English. It has no dependencies beyond Node 18+.

pnpm i18n:check # human-readable report
node scripts/i18n-check.mjs --json # machine-readable report

It reports missingKey, extraKey, missingPlural, invalidPlural, placeholder, empty, untranslated, missingLocale (a language in only one of UI / GenUI) and unregistered (a JSON file not imported by i18n.ts), and exits with code 1 when it finds any. The CI workflow runs it on every push and pull request, so a pull request with a gap cannot merge.

Parity tests​

packages/ui/src/i18n/__tests__/locales.test.ts and packages/genui/src/i18n/__tests__/locales.test.ts run the same checker under Vitest. They also resolve every plural key at runtime for a spread of counts in every language, and check that detected codes such as pt-PT or zh-Hant-TW reach the right bundle.

CI translation guard (auto-translate)​

The i18n sync workflow (.github/workflows/i18n-sync.yml) runs on pull requests that touch packages/ui/src/i18n/** or packages/genui/src/i18n/**, and on manual dispatch.

  1. It runs the check script.
  2. If the check fails and the PR branch lives in this repository, it runs Claude Code (anthropics/claude-code-action@v1). Claude adds the missing translations for exactly the reported keys and languages, keeping placeholders and plural forms intact. It then re-runs the check and pushes a chore(i18n): add missing translations [i18n-sync] commit to the PR branch.
  3. For PRs from forks, which get no secrets, and for the default branch, it only fails and prints the report.

The workflow never invokes Claude twice in a row: when the head commit is already an [i18n-sync] commit (or authored by claude[bot]), it only reports. The auto-added strings are machine translations, so review them like any other change in the PR.

Setup (maintainers):

  1. Install the Claude GitHub App on the repository. It needs Contents, Pull requests and Issues read & write access. Running /install-github-app in the Claude Code CLI walks you through this. You must be a repository admin.
  2. Add an ANTHROPIC_API_KEY repository secret (Settings → Secrets and variables → Actions).
  3. The workflow already requests contents: write, pull-requests: write and id-token: write. The last one is required for the action's OIDC exchange with the GitHub App.

See the action's setup and security guides for details. Without the secret, the plain check in CI still blocks incomplete translations; only the automatic fix is skipped.

Right-to-left languages​

Chativa mirrors its layout for right-to-left languages. Switching to Arabic, Hebrew, Persian or Urdu flips the whole widget: bot messages sit on the right and user messages on the left, the send button moves to the left of the composer, and the back arrow, send icon and carousel chevrons point the other way.

How the direction is chosen​

<chat-iva>, <chat-bot-button> and <agent-panel> each carry a dir attribute that follows the active i18next language:

import { i18next } from "@chativa/ui";

await i18next.changeLanguage("ar"); // <chat-iva dir="rtl">
await i18next.changeLanguage("en"); // <chat-iva dir="ltr">

Every shadow root inherits that direction, so you don't need to configure anything per component.

Only the base language subtag is compared, so ar-EG, he-IL and fa_IR count as RTL too. The list and the helpers are exported:

import { RTL_LANGUAGES, isRtlLanguage, getLanguageDirection } from "@chativa/ui";

RTL_LANGUAGES; // ["ar", "he", "fa", "ur"]
isRtlLanguage("ar-EG"); // true
getLanguageDirection("tr"); // "ltr"

The widget only handles layout direction. You still have to load the translations yourself (see Add a new language). Without them, Arabic is laid out RTL but shows the English fallback strings.

Forcing a direction​

A dir you set yourself always wins over the language:

<!-- Always RTL, whatever language is active -->
<chat-iva dir="rtl"></chat-iva>

This works in markup, with el.dir = "rtl" and with el.setAttribute("dir", …), whether you set it before or after the widget mounts. Once you set dir, Chativa leaves it alone, even when your value matches what the language would have chosen. If you remove the attribute, the direction follows the language again. Set dir on each root you mount, for example both <chat-iva> and <chat-bot-button>.

What mirrors and what doesn't​

Mirrors under RTLStays as-is
Message alignment, avatars, bubble "tail" cornersRating stars: 1 → max still reads left-to-right (the usual convention); labels and buttons around them still mirror
Composer order (attach · emoji · text · send)Bar charts keep their x-axis order, matching the SVG line chart
Emoji picker, slash-command popup, typing bubbleCode blocks (pre) are always LTR
Feedback buttons and tool-call trace offsetsThe launcher corner and the popup / side-panel edge (see Theming → Position and RTL)
Conversation-list slide-in, image-gallery arrows and arrow keys

Mixed-direction text​

Message bubbles use dir="auto", so each message gets its base direction from its first strong character. An English reply inside an Arabic UI keeps its punctuation in the right place, and the bubble still sits on the correct side. Links and inline code inside messages are bidi-isolated (unicode-bidi: isolate), and timestamps are wrapped in <bdi>, so URLs, numbers and times don't reorder the sentence around them. The composer input also uses dir="auto".

Custom components​

Built-in components use only CSS logical properties. Custom message types and custom GenUI components are your responsibility, so write them the same way:

Instead ofUse
margin-left / margin-rightmargin-inline-start / margin-inline-end
padding-left / padding-rightpadding-inline-start / padding-inline-end
left: 0 / right: 0inset-inline-start: 0 / inset-inline-end: 0
text-align: lefttext-align: start
border-leftborder-inline-start
border-top-left-radiusborder-start-start-radius

For anything logical properties can't express, such as flipping an arrow icon or the sign of a translateX(), use the :dir() selector from inside your shadow root:

:host(:dir(rtl)) .arrow { transform: scaleX(-1); }

:dir() follows the inherited direction, so it works however deep the component is nested. An attribute selector like [dir="rtl"] only matches the element that carries the attribute.

Override built-in strings​

You can override any key in any language. Two formats are supported via chativaSettings.i18n:

Per-language​

window.chativaSettings = {
i18n: {
all: { header: { title: "Customer Care" } }, // applied to every language
tr: { header: { subtitle: "7/24 buradayız" } }, // tr-only override
},
};

Flat (applies to every registered language)​

window.chativaSettings = {
i18n: { header: { title: "Customer Care" } },
};

applyGlobalSettings() re-applies your overrides on languageChanged, so they survive runtime locale switches.

In your own components​

Extend I18nMixin and use the reactive t() method:

import { LitElement, html } from "lit";
import { I18nMixin } from "@chativa/core";

class HelpButton extends I18nMixin(LitElement) {
render() {
return html`<button>${this.t("commands.help.label")}</button>`;
}
}

Or in slash commands / GenUI components — pass the lazy form so translations are read at execute time, not registration time:

registerCommand({
name: "help",
description: () => t("commands.help.description"), // function → resolved later
usage: () => t("commands.help.usage"),
execute({ args }) { /* ... */ },
});

See domain/ports/ISlashCommand.ts → resolveText().