title: Internationalization (i18n) description: How Navigator serves the firm in more than one language — Spanish first.
Internationalization
Navigator is an access-to-justice product. A person who reads Spanish at the kitchen table should meet the same firm, in
the same voice, as a person who reads English. This document is the architecture for making that literal. It is
Spanish-first: the design below ships es end to end, and is shaped so Korean (ko), Chinese (zh), and
Vietnamese (vi) drop in later without a rewrite.
This design was pressure-tested by all three councils — engineering (/council), legal (legal-council), and client
(client-council) — and their findings are folded into the decisions below. This document is the durable record; the
deliberation itself is not kept.
The shape in one paragraph
There are two kinds of user-visible text, and they get two mechanisms. Chrome — navbar, buttons, the banner, error
pages, the language switcher — is short, repeated, and engineer-owned; it lives in per-locale YAML catalogs and is
looked up with a Rails-style t(locale, "key") helper. Prose — the marketing product pages and the mission letter —
is long, edited by non-engineers, and already lives in Markdown; it stays Markdown, with a parallel localized tree
(web/content/marketing/es/*.md). A request to /es/... resolves to Locale::Es, which drives <html lang="es">, the
chrome catalog, the Markdown subtree, the hreflang alternates, and the navbar language switcher. Anything missing in
es falls back to English — never to a raw key, never to a 404.
Scope — English-first, two localized surfaces
English is the official language and the default everywhere. Localization is deliberately narrow: only marketing
pages (the /es Tier-A pages + the mission letter) and questionnaire intake prompts are ever served in another
language. The chrome catalog exists to dress those marketing pages — navbar, switcher, footer — not to localize the app
at large. Everything else stays English: the portal, the /docs tree, transactional email, and the legal template
bodies a client signs (the binding artifact is English even when the questionnaire that gathered the answers was
localized). This is the firm's standing rule — see CLAUDE.md.
Locale: one type, three faces
views::i18n::Locale is the single source of truth. The same value yields all three things a request needs, so "locale"
is never stringly-typed or overloaded:
pub enum Locale { En, Es }
impl Locale {
pub fn code(self) -> &'static str; // "en" | "es" → <html lang>, hreflang
pub fn path_prefix(self) -> &'static str; // "" | "/es" → URL routing
pub fn endonym(self) -> &'static str; // "English" | "Español" → the switcher label
pub fn from_path(path: &str) -> Locale; // "/es/..." → Es, else En
}
En is the source locale and the universal fallback. Adding ko/zh/vi means adding enum variants and catalog files
— no call-site changes.
Rails-style t() for chrome
The kickoff permits "Fluent or compile-time string catalogs." For Spanish-only chrome we choose the compile-time
catalog: it is Rust-native, adds no dependency, and reads like Rails I18n.t. We revisit Fluent only if the key count
explodes or we need gender/plural rules a flat catalog can't express.
- Catalogs:
views/locales/en.ymlandviews/locales/es.yml, baked into the binary withinclude_str!and parsed once into aLazyLock<Translations>. A translator edits YAML, never Rust. - Keys are dotted namespaces so they document themselves:
nav.home,nav.services,auth.sign_in,cta.email,switcher.label,error.not_found. - Lookup:
t(locale, "nav.home")returns theesvalue, or theenvalue if the key is absent ines, or the key itself only as a last-resort dev signal (never expected in production becauseenis complete). - Interpolation:
t_args(locale, "footer.copyright", &[("year", "2026")])substitutes%{year}— the same%{name}convention Rails uses.
Example catalog slice:
# views/locales/es.yml
nav:
home: Inicio
services: Servicios
about: Acerca de
auth:
sign_in: Iniciar sesión
sign_out: Cerrar sesión
switcher:
label: English # the *other* language, in its own name
Prose: parallel localized Markdown
Marketing pages and the mission letter are already Markdown loaded by web::marketing::loader. We add a sibling tree
rather than replace the loader:
web/content/marketing/ # English (source)
home.md nest.md northstar.md nexus.md litigation.md nautilus.md services.md
mission.md # English mission letter (transcreated twin in es/)
web/content/marketing/es/ # Spanish
home.md nest.md northstar.md nexus.md litigation.md nautilus.md services.md
mission.md # Spanish mission letter (transcreated, not literal)
MarketingIndex becomes locale-aware: find(slug, locale) returns the es doc when present, else the en doc. A page
that hasn't been translated yet renders in English under a lang="es" shell rather than 404 — graceful degradation the
client council insisted on.
The mission letter is transcreated, not translated: a word-for-word Spanish mission that loses the letter's cadence and reads as pitying the reader is a defect (it violates the bold-rights-fighter brand voice). Same rule for the marketing hero copy.
Locale detection + routing
URL-prefix wins. / is English; /es/... is Spanish. This mirrors how web already namespaces (/foundation/...,
/portal/...), gives every localized page a distinct canonical URL for SEO, and is trivially shareable and cacheable.
We do not branch on Accept-Language for the canonical URL (it makes one URL serve two bodies, which breaks caching
and sharing); a future enhancement may redirect a first-time visitor from / to /es based on Accept-Language + a
lang cookie, but the prefix remains the source of truth.
For each localizable page the layout emits, keyed off the locale-less canonical path:
<html lang="es">(oren).<link rel="alternate" hreflang="en" href="…">,hreflang="es", andhreflang="x-default"(English) so search engines and screen readers pair the twins.- A navbar language switcher, one tap, on every localizable page, labeled with the target language in its own name
(
Español/English) — never a flag (flags are countries, not languages). It links to the current page's twin via the canonical path.
Internal nav hrefs are localized through a small allowlist (i18n::localize_href): a path that has a Spanish twin is
/es-prefixed; a path that doesn't (yet) falls back to its English target, so the nav never dead-ends.
The attorney-review gate (two tiers)
Translated legal copy is attorney advertising and potential unauthorized-practice exposure in CA, NV, and WA — in each language. A machine draft is a starting point, never a shippable legal artifact. The build ships the fallback, so an un-reviewed legal string never reaches a client surface.
- Tier A — marketing prose + the mission letter. Ships in Spanish only after a licensed attorney reviews it in-language. These are the Phase-1 proof pages.
- Tier B — the footer legal strip (disclaimer, bar-admission connective prose) and engagement/legal copy. Highest
UPL exposure. Stays English-source until an attorney-reviewed Spanish version exists; until then it falls back to
English even on a
lang="es"page. Alang="es"page with an English statutory footer is defensible; a machine-translated disclaimer is not.
Facts and proper nouns are not translated in any locale — they are carried verbatim: the state names in the bar-admission strip, "Shook Law PLLC", the USPTO trademark link, the postal addresses, the bar numbers, and the fees.
Pricing and formatting
Uniform flat pricing is unchanged by locale: the fee is USD and identical everywhere ($3,333 once, $1,111 a year, $2,222 a month). We localize number and date formatting only — never the amount, and never a purchasing-power adjustment. (The one PPP carve-out, Northstar, is a product-pricing decision independent of locale and is unaffected by i18n.)
Fonts
Spanish is the cheap case. Noto Serif already covers Latin, including every Spanish accent and punctuation mark (the
acute vowels, ñ, and the inverted ¿/¡), and it is already the firm typeface, preloaded and self-hosted under
web/public/fonts/noto-serif/ (views/src/layout.rs). No font work, no new VENDOR.toml entry, and no Typst/PDF
change is required for Spanish.
The CJK/Hangul weight is a later-locale concern, recorded here so it isn't forgotten:
- Vietnamese is extended Latin — likely only needs the Vietnamese
unicode-rangesubset added tonoto-serif.cssand preloaded forvi. - Korean needs Noto Serif KR; Chinese needs Noto Serif SC (and TC if Traditional is added). These are
large; they need a subsetting + per-locale-preload strategy so an English visitor never downloads CJK. Each would be
added to
web/public/VENDOR.tomlwithsha256+upstream_url, self-hosted, no CDN. The Typst render path (web/src/signature_render.rs) would bundle the matching Noto Serif CJK TTF.
The zh-Hans / zh-Hant go/no-go
US Chinese-reading communities are mixed Simplified and Traditional. This is a decision for the user, not a guess,
and it is out of scope for the Spanish-first build. Default proposal when Chinese lands: start with Simplified
(zh-Hans), leave room for zh-Hant. Surface it explicitly at that time.
Phasing
- Phase 1 — infra + Spanish proof (this build).
Locale+t()+ the two YAML catalogs; locale-awarePageLayout(<html lang>,hreflang, navbar switcher, translated chrome) with English output byte-identical to today;/esrouting over the firm marketing surface; the mission letter + the marketing product pages transcreated into Spanish (Tier A). Footer legal strip stays English (Tier B fallback). - Phase 2 — questionnaire intake prompts (shipped for
es). Aquestion_translationstable of attorney-reviewed localized prompts keyed onpersons.preferred_language;notation_sessionrenders each prompt in the person's language, falling back to English when absent. Gated onlegal-council+ attorney sign-off in-language; translation never bypasses thestaff_reviewgate. The prompt is the only localized surface in intake — the template body stays English. - Out of scope by rule. The portal,
/docs, transactional email, and legal template bodies are not localized (see Scope above). An earlier draft of this doc planned portal + email localization; that is explicitly not the direction. - Later locales.
ko/zh/vireuse the entire architecture for the two localized surfaces; their only net-new work is the font weight and thezhvariant go/no-go above.
Engineering invariants
- English is byte-identical. The default-locale (
En) render path produces exactly today's bytes; a regression test pins<!DOCTYPE html><html lang="en"…>and the footer. This contains the blast radius of every i18n change. - No broken
render(...)callers. Newrender_in(content, auth, locale)variants carry the locale; the existingrender(content, auth)delegates withLocale::En, so the ~30 in-file view tests and every call site keep working unchanged. - Rust-only, GCP-only, self-hosted. No JS i18n runtime, no translation CDN, no font CDN. Catalogs are baked into the binary; fonts stay vendored.
Where things live
views/src/i18n.rs—Locale, theTranslationscatalog,t()/t_args(),localize_href.views/locales/en.yml,views/locales/es.yml— the chrome catalogs.views/src/layout.rs—PageLayout::with_locale,<html lang>,hreflang, the navbar switcher.web/content/marketing/es/*.md(includinges/mission.md) — Spanish prose.web/src/marketing/— locale-awareMarketingIndex::find(slug, locale).web/src/lib.rs— the/esroute table.