title: Internationalization (i18n) description: How Neon Law Navigator serves the firm in more than one language — Spanish first.

Internationalization

Neon Law 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.

English marketing and public Foundation prose never moves alone. If a PR changes English copy on a localized marketing or Foundation surface, the matching Spanish Markdown or chrome string changes in the same PR. Spanish is not a post-merge follow-up for these surfaces; it is part of the acceptance criteria. This paired-edit rule does not expand localization into the portal, /docs, email, or legal template bodies — those remain English by rule.

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/chrome.yml
nav:
  home: Inicio
  services: Servicios
  about: Acerca de
auth:
  sign_in: Iniciar sesión
  sign_out: Cerrar sesión
switcher:
  aria: Ver esta página en inglés   # describes the action, in the current language

The static key-check (navigator dev i18n)

The catalog is the single source of UI copy only if code and catalog stay in agreement. A typo'd key renders its own name in production where t is used, or panics under t_strict; a catalog key nothing renders is dead copy that drifts and bloats the es-parity surface. navigator dev i18n is the i18n-tasks analog that catches both, in Rust — a subcommand, not a fourth CI workflow, wired into the ci gate next to validate. It reconciles compiled Rust against the catalog — codebase reconciliation, not content linting — so it lives under the machine-bound dev family rather than validate.

It scans the Rust render and test sources (views/src, web/src, web/tests) for the recognized key call forms — t(locale, "key"), t_args, t_strict, and the assert_renders! / assert_absent! macros — and reconciles the keys it finds against the English catalog (En is the complete source; see views::i18n::en_catalog_keys). It reports, and fails the gate on, two kinds of drift:

A key reached indirectly through a product-code match (web::product_description_key) or a navbar-label map (views::i18n::nav_label) still carries its destination key as a string literal in that source, so the unused check sees it. Comments are ignored, so a key shown in a doc-comment example never counts as a reference. The sentinel keys the resolver's own unit tests use to exercise the missing-key path (e.g. does.not.exist) are exempted by an explicit, documented waiver in cli::i18n_audit — never a silent pass. This is the same "catalog/code agreement enforced by a check" pattern as the es-parity guard below, pointed the other way (code ↔ catalog rather than en ↔ es).

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. The site's purchased GORP Serif web faces cover the Latin text used by the English and Spanish marketing surfaces and are preloaded from the deployment asset origin (views/src/layout.rs). No additional webfont or Typst/PDF change is required for Spanish. The GORP web license is proprietary and deployment-specific; operators must supply their own license and asset-bucket files as described in the operating workshop.

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