i18n architecture (Sweep S13, #1418)
Status: Accepted (design decisions ratified 2026-06-17). Phase 1 (this ADR + plumbing + one pilot package) in progress.
Problem
Every user-facing string in the UI packages is hardcoded English
(MessageInput → "Type a message...", DataTable → "No data available").
The @happyvertical/smrt-languages package exists — a server-side, async,
DB-backed string registry with a 5-layer override chain (code → file config →
app → tenant → runtime), locale fallback, and AI auto-translation — but no
component imports it. S13 introduces an i18n layer for smrt-svelte and routes
strings through it, package by package.
The core tension: resolveLanguageString is async and DB-backed, but
Svelte component render is synchronous. The design has to bridge the two
without putting await / loading states into every string.
Decisions (HITL)
- API surface — both
$t()and<Trans>, equal first-class.$t('key', vars)— a reactive accessor for the common case (plain strings, attributes likeplaceholder/aria-label/title).<Trans key vars />— a component for the same strings in element bodies, and the carrier for rich interpolation (links,<strong>, nested components) in later phases.
- Async→sync bridge — server snapshot → context.
The server pre-resolves a per-locale dictionary of templates (every
registered key, walked through the override/tenant/locale chain) via
resolveLanguageString, and passes it into the smrt-svelteProvider. TheProviderputs it on a Svelte context;$t/<Trans>read it synchronously on the client and interpolate variables locally. Tenant overrides and AI auto-translation stay entirely server-side; the client never does async string resolution. - Phase 1 scope — this ADR + plumbing + one pilot package (smrt-svelte primitives). Extraction across the remaining ~85 components is phased.
Architecture
SERVER (request/SSR) CLIENT (sync render)
───────────────────── ────────────────────
defineMessages(...) ──registers──▶ LanguageRegistry (global)
│
buildI18nSnapshot({ locale, │ resolve each key's
tenantId, db }) ──────────────────────┘ template for `locale`
→ { locale, messages: { key: template } }
│
▼ passed as a prop
<Provider i18n={snapshot}> ──setI18nContext──▶ Svelte context
│
useI18n() / $t / <Trans> read it
t(key, vars) = renderTemplate(
messages[key] ?? default ?? key, vars)
Why ship templates, not rendered text
resolveLanguageString returns both the rendered text (variables already
substituted) and the raw template. The snapshot ships templates keyed by
message key. Variables are supplied at the call site at render time, so the
client interpolates locally with the same renderTemplate the server uses —
one interpolation contract, no double-rendering, and a value like a live count
or a user name never has to round-trip to the server.
Interpolation contract
Single-brace placeholders: "Showing {count} of {total}". This matches
@happyvertical/smrt-languages's renderTemplate (/\{([^{}]+)\}/g): Date →
ISO string, array/object → JSON, null / undefined → empty string.
The client owns interpolation (src/i18n/render.ts)
The languages package root drags smrt-core, sql, ai, smrt-jobs, … — fine
on the server, wrong for a browser bundle. And because the always-bundled client
path (Provider → /i18n) reaches the interpolation helper, importing the
languages package there would force every smrt-svelte consumer (including
downstream SvelteKit apps) to install that heavy tree — even though languages is
only an optional peer needed for the server bridge.
So the client owns a tiny, dependency-free copy of renderTemplate
(src/i18n/render.ts). The contract is small and stable;
__tests__/render-parity.spec.ts pins the copy against the canonical languages
implementation so the two never drift. Only the server subpath
(/i18n/server) imports the languages root.
Key naming
<package>.<component>.<descriptor>, lowercase, snake_case within a segment.
smrt-svelte primitives use the ui namespace.
ui.data_table.emptyui.pagination.nextchat.message_input.placeholder
Keys are stable identifiers, not English text — renaming the English copy never changes a key. The package prefix keeps catalogs collision-free across the monorepo and makes a key's owner obvious.
Registration & client-side defaults
Each package ships a co-located catalog (src/i18n/strings.ts) that calls
defineMessages({...}) (a thin smrt-svelte wrapper over defineLanguageString)
for its English code defaults. Two consumers of that catalog:
- Server: importing the catalog populates
LanguageRegistry, sobuildI18nSnapshotcan resolve every key. - Client:
defineMessagesalso records the English default in a small client-side map, so$trenders correctly without a server snapshot (standalone components, tests, Storybook). Resolution order int():snapshot template→registered default→the key itself(loud, never-blank fallback).
useI18n() / $t / <Trans>
<script lang="ts">
import { useI18n, Trans } from '@happyvertical/smrt-ui/i18n';
const { t } = useI18n();
let { count, total }: { count: number; total: number } = $props();
</script>
<input placeholder={t('chat.message_input.placeholder')} />
<p><Trans key="ui.pagination.range" vars={{ count, total }} /></p>
useI18n() returns { locale, t }. Used outside a Provider, it degrades to
registered defaults (no throw), so primitives stay usable in isolation.
Enforcement — hardcoded-string lint
scripts/check-hardcoded-strings.mjs (the established ratchet idiom) flags
new prose string literals in .svelte markup — element text nodes and the
user-facing attributes placeholder / title / alt / aria-label — in
strict packages, report-only elsewhere. Conservative by construction (needs
a letter and whitespace; ignores single tokens, bindings, {...} expressions)
to avoid false positives. The pilot package is the first STRICT entry; packages
flip to strict as their extraction completes (mirrors the design-token ratchets).
Phased rollout
- Phase 1 (this PR): ADR,
/runtimesubpath, smrt-svelte i18n core (context/useI18n/$t/<Trans>/defineMessages),buildI18nSnapshotserver helper,Provideri18nprop, the lint rule (report-only + the pilot strict), and one pilot package extracted. - Phase 2+: extract per package (high-traffic UI first: chat, content, users), flipping each to lint-strict as it lands.
- Locale switching & SSR loading in consumer apps (ergot/anytown): the
consumer's load function builds the snapshot for the request locale and
passes it to
Provider. Documented as a consumer recipe; not framework code.
Alternatives considered
- Build-time catalog compilation (Paraglide-style). Fastest runtime, fully static — but bypasses the languages package's runtime tenant/override/AI features entirely, making it dead weight. Rejected: the override chain and auto-translation are the reason the languages package exists.
- Client async resolution (
awaitper string). Keeps everything dynamic but forces loading UI into every label — awkward and a11y-noisy. Rejected. - Import a browser-safe
/runtimesubpath from languages on the client. Cleanest "single source of truth", but the always-bundled client path reaches it, so it would make the otherwise-optional languages package (and its heavy server dep tree) a hard requirement for every smrt-svelte consumer. Rejected: the client owns a tinyrenderTemplatecopy guarded by a parity test instead.