Appearance
Internationalisation (i18n) β
The app is translated into five locales using @nuxtjs/i18n with a no_prefix routing strategy and a split catalog model: 18 per-section TypeScript files per locale, registered through a single locales.config.ts source of truth.
Routing Strategy β
no_prefix keeps the URL unchanged across all locales. Locale state lives in a cookie and in the Accept-Language header β not in the URL.
| Aspect | App (no_prefix) | Website (prefix_except_default) |
|---|---|---|
| English URL | /dashboard | /dashboard |
| Spanish URL | /dashboard (same) | /es/dashboard |
| Locale source | Cookie i18n_redirected + Accept-Language | URL prefix |
| Why | Auth app β no SEO/hreflang need. Avoids rewriting auth.global.ts + 18 Playwright path specs | Public site needs crawlable per-locale URLs |
auth.global.ts publicRoutes[] and all Playwright path specs are locale-neutral β do not add locale prefixes to them.
Locale Set β
| Code | Display name | BCP-47 tag | Default currency |
|---|---|---|---|
en (default) | English | en-US | USD |
es | EspaΓ±ol | es-ES | EUR |
pt | PortuguΓͺs | pt-PT | EUR |
fr | FranΓ§ais | fr-FR | EUR |
de | Deutsch | de-DE | EUR |
APP_LOCALES in i18n/locales.config.ts is the canonical list. The parity test (tests/i18n-catalog-parity) derives its locale set from this array β adding a sixth locale here automatically extends the test.
Locale Detection Order β
On every request @nuxtjs/i18n resolves the active locale in this order:
- Cookie
i18n_redirected(set bysetLocale()on switch) Accept-Languagerequest header- Fallback to
'en'
fallbackLocale: 'en' in nuxt.config.ts ensures any key absent in a non-EN catalog silently falls back to English β no raw key path ever reaches the user.
Locale Switch β
Use setLocale() from useI18n() β never switchLocalePath().
switchLocalePath() only builds the URL; it does not write the i18n_redirected cookie. Under no_prefix, the URL does not change, so the only locale-change side-effect is the cookie write β which switchLocalePath() skips. On the next navigation detectBrowserLanguage reads the stale cookie and reverts to the previous locale.
typescript
const { setLocale } = useI18n()
// Correct:
await setLocale('es')
// Wrong β do not use:
// const path = switchLocalePath('es')
// await navigateTo(path)SharedLanguageSwitcher uses this pattern internally. All mount points call through it.
Catalog Structure β
The catalog is split into 18 section files per locale under i18n/locales/<locale>/. EN is source of truth; the other four locales mirror it exactly.
i18n/
locales.config.ts # single SoT β section file lists + APP_LOCALES
i18n.config.ts # datetimeFormats + numberFormats, keyed by locale code
locales/
en/
common.ts nav.ts auth.ts dashboard.ts gnosaris.ts chat.ts
sessions.ts knowledge.ts traits.ts entities.ts entityTypes.ts qrCodes.ts
chatThemes.ts settings.ts subscription.ts widget.ts embed.ts build.ts
es/ <same 18 files β full Castilian translation>
pt/ <same 18 files>
fr/ <same 18 files>
de/ <same 18 files>locales.config.ts exports EN_SECTION_FILES, ES_SECTION_FILES, β¦ DE_SECTION_FILES (each an array of 18 paths) and APP_LOCALES (the { code, name, language, files } array). This file is imported by both nuxt.config.ts and tests/i18n-catalog-parity.test.ts β it is the only place to register a new section or locale.
@nuxtjs/i18n deep-merges all files in the files array under the same locale key, so sections compose transparently.
Key naming convention: section.subsection.key β lowercase, dot-separated, no spaces. The section corresponds to the file name (e.g. keys for the chat surface live in chat.ts and are prefixed with chat.).
SharedLanguageSwitcher β
app/components/shared/LanguageSwitcher.vue (<SharedLanguageSwitcher>) is the locale picker for the app chrome. It is self-contained β no props required.
| Aspect | Detail |
|---|---|
| Component path | app/components/shared/LanguageSwitcher.vue |
| Auto-registered as | <SharedLanguageSwitcher> (Nuxt pathPrefix: true) |
| Data source | useI18n().locales β the 5 configured entries; never a hardcoded list |
| Switch mechanism | useI18n().setLocale(code) (see Locale Switch section above) |
| Props | None required. Self-contained via useI18n() |
| Emits | None. Side-effect is the global locale change |
| Mount points | Sidebar user area (SidebarUser.vue) and settings header |
See app/components/shared/README.md for the catalog entry and recorded deviation on the loading prop omission.
Accept-Language Wiring β
The active locale is forwarded to the backend as an Accept-Language header on a bounded set of routes.
In scope β
| File | How |
|---|---|
app/composables/useApi.ts | Injected inside the composable from useI18n().locale captured at factory time. NOT via a caller-passed header β the composable's allow-list drops caller headers other than Content-Type/Accept. |
server/api/gnosari.ts | Reads the header from the request and forwards it upstream. The catch-all server/api/[...path].ts re-exports this implementation. |
Explicitly out of scope β
| File | Why |
|---|---|
server/api/auth.post.ts | Standalone login proxy β no backend response localisation today |
server/api/auth/google.*, auth/user.get.ts | Google auth handlers |
server/api/oauth/*, verify-urls.post.ts | OAuth handlers |
Raw $fetch in signup.vue, Step1Auth.vue, useGoogleAuth, useOAuthFlow, useUrlVerification, useAuth.ts | The composable allow-list silently drops any caller-passed Accept-Language; injecting it into raw $fetch is deferred until backend response localisation ships |
Locale-Reactive Shared Content β
Some content is sourced from a shared package rather than the local catalog and needs its own locale-reactivity pattern.
| Symbol | Where | Shape |
|---|---|---|
useWizardPurposes() | app/composables/features/useAgentWizard.ts | useI18n()-wrapping composable β returns ComputedRef<AgentPurpose[]> |
matchBusiness(input, locale) | app/composables/features/useBuildV2Matcher.ts | Plain util β takes locale as an explicit parameter, no useI18n() inside |
useWizardPurposes() computes over useI18n().locale and resolves each purpose from the shared @neomanex/gnosari-agent-purposes package per id via getPurposeById(id, locale.value) β never getAllPurposes(). Resolving per id (rather than taking the package's own list) is deliberate: it preserves the raw declaration order the /build grid already renders, instead of inheriting whatever order the package exports. Because it wraps useI18n(), it MUST be constructed in synchronous setup scope β see the Gotcha in the project CLAUDE.md.
matchBusiness(input, locale = 'en') takes the opposite shape: it's a plain utility, not a composable, so it has no useI18n() call to protect and takes locale as an explicit parameter instead. Keyword matching runs on locale-invariant purpose ids; only the returned display objects (resolved via getPurposeById) are localized.
See documentation/standards/ui/11-i18n.md Β§ Shared Content Packages for the underlying private-npm-content-package pattern both symbols consume.
Dates, Numbers, and Currency β
In templates β
Use the $d() and $n() helpers β they read datetimeFormats and numberFormats from i18n/i18n.config.ts.
| Helper | Format keys | Example |
|---|---|---|
$d(date, 'short') | short, full, long, time | Jun 29, 2026 |
$n(value, 'currency') | currency, decimal, percent | $12.00 / β¬12,00 |
Currency defaults: USD for en, EUR for es/pt/fr/de. At runtime plan.currency from the API always overrides the format default β pass it explicitly: $n(amount, 'currency', { currency: plan.currency }).
Outside templates (composables / utilities) β
Use app/utils/formatters/locale-intl.ts β a pure helper that maps the active vue-i18n locale code to a BCP-47 tag for Intl APIs.
| vue-i18n code | BCP-47 tag |
|---|---|
en | en-US |
es | es-ES |
pt | pt-PT |
fr | fr-FR |
de | de-DE |
Adding a New Locale β
All registration flows through i18n/locales.config.ts.
- Add
{CODE}_SECTION_FILESconstant β array of 18 paths'{code}/common.ts', '{code}/nav.ts', β¦mirroringEN_SECTION_FILESwith the new locale prefix. - Add an entry to
APP_LOCALES:{ code: 'it', name: 'Italiano', language: 'it-IT', files: IT_SECTION_FILES }. - Create
i18n/locales/{code}/<section>.tsfor each of the 18 sections β full key mirror of the corresponding EN file. - Add
datetimeFormatsandnumberFormatsentries for the new code ini18n/i18n.config.ts. - Add the code to
LOCALE_TO_INTLinapp/utils/formatters/locale-intl.ts. - Run
tests/i18n-catalog-parityβ it auto-detects the new locale fromAPP_LOCALESand must pass. - Run the
translation-quality-judgeagent before enabling the locale in production.
Adding or Translating Strings β
- Add the key to the correct section file in
i18n/locales/en/<section>.ts. Key naming:section.subsection.key(lowercase, dot-separated). - Add the same key to the matching section file in all four non-EN locales (
es/<section>.ts,pt/<section>.ts,fr/<section>.ts,de/<section>.ts). Use the EN value as a placeholder until translated βfallbackLocale: 'en'prevents a raw-key flash, but the catalog-parity test enforces identical key sets across all locales. - In templates, use
$t('section.subsection.key'). For aria attributes::aria-label="$t('section.subsection.key')". For strings that contain markup:<i18n-t tag="span" keypath="section.subsection.key">. - Run
tests/i18n-catalog-parityβ this is a blocking CI gate. A missing key in any non-EN locale fails the build.