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.

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:

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.

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:

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

Engineering invariants

Where things live