habitable is an in-scope internationalization repo under
INTERNATIONALIZATION-STANDARD.md.
The tool ships a bilingual English + Spanish UI because a habitability-evidence
tool a Spanish-speaking tenant cannot operate has failed at its purpose;
bilingual reach is a project invariant, not a nice-to-have. This document records
which mechanical AUTO-GATES (§4) are live, which are N/A-until-used, and
which are deferred to a later phase, so the i18n posture is a declared
decision rather than a silent state.
For translator-facing guidance (how to add a language, how to write copy, which
strings must never be softened) see
localization-guide.md.
- Format: per-language flat JSON bundles under
app/i18n/—en.json(the source of truth) andes.json. Every key originates inen.json; each other locale mirrors its key set exactly. The app (app/) is plain HTML/CSS/JS with no build step;app/app.jsfetchesi18n/<lang>.jsonand substitutes each element'sdata-i18n/data-i18n-ariaattribute at runtime. - Authored language tags:
en,es— the bundle filenames, theSUPPORTEDarray andDEFAULT_LANGinapp/app.js,<html lang>, and thedata-langbuttons. Both are validated as BCP 47 by G3 (below). - Why JSON, not gettext
.po: per the standard's §11 roadmap habitable's task is to wire the gates on the existing JSON catalog, not migrate to gettext. The web layer stays JSON; a.polayer would only be added if/when Python-side user-facing strings appear (see [I18N-MIGRATION-PLANdecision 2] upstream).
All live gates run in make verify (the i18n target) and in
.github/workflows/i18n.yml, byte-for-byte identical so local == CI. The
checkers are standard-library only and offline, so CI needs no dependency install.
| # | Gate | Status | Where |
|---|---|---|---|
| G1 | UTF-8 encoding of all tracked text files | LIVE | scripts/check_i18n_utf8.py |
| G2 | No hardcoded UI strings (extraction) | DEFERRED (later phase) | — |
| G3 | BCP 47 tag validity (well-formed + registered) | LIVE | scripts/check_bcp47.py |
| G4 | HTML root lang (WCAG 3.1.1) — html-has-lang + html-lang-valid |
LIVE (pre-existing) | axe-core scan tests/test_app_axe.py; structural tests/test_app_accessibility.py; reinforced offline by G3 |
| G5 | Completeness + placeholder + plural parity | LIVE | scripts/check_i18n_parity.py (extended by FIX-12 to enforce plural + placeholder parity, not just key parity) |
| G6 | EN/ES key-parity | LIVE (pre-existing) | scripts/check_i18n_parity.py; tests/test_app_i18n.py |
| G7 | PO compilation (msgfmt) |
N/A — no gettext .po catalogs |
— |
| G8 | XLIFF schema validity | N/A — no .xlf files committed |
— |
| G9 | Pseudolocale overflow | DEFERRED (frontend-depth phase) | — |
| G10 | RTL: no physical-direction CSS | PARTIAL (RTL-readiness pass, R-48): CSS uses only logical properties (*-inline-*, text-align:start); dir is set on <html> and flipped per active language (RTL_LANGS); dates/numbers already format via Intl keyed to the locale. No RTL bundle ships yet, so a native-speaker visual QA is still required before one does. |
tests/test_app_i18n.py (physical-direction + dir guards) |
| G11 | Vary: Accept-Language |
N/A — local-first app; the UI is served from the device, not language-negotiated | — |
| G12 | CLDR/tzdata freshness | N/A by design — see below and ADR 0005 | — |
The HTML root-lang gate is pre-existing and merge-blocking, enforced three
ways: the axe-core browser scan (tests/test_app_axe.py) runs the default axe
ruleset, which includes html-has-lang and html-lang-valid (WCAG 3.1.1) and
blocks on any moderate/serious/critical violation in both the EN and ES
renderings; the always-on structural test
(tests/test_app_accessibility.py::test_language_title_viewport_manifest) asserts
<html> carries a lang; and the new G3 gate additionally validates every
committed <html lang> value offline in make verify, independent of the
browser. This graduation from advisory is tracked in
ACCESSIBILITY-STANDARD §1.
Updated 2026-07-05 — the reason below superseded the original
"no locale-aware formatting" rationale, which FIX-12 invalidated (habitable now
does format numbers and dates per locale). See
docs/adr/0005-i18n-g12-cldr-na-by-design.md
for the full decision; summary: i18n.py's format_number/format_date use a
small hand-rolled EN/ES separator and month-name table, not babel.numbers /
babel.dates / PyICU or any CLDR/ICU data source, so there is no CLDR/ICU
dependency for G12 to pin a floor on. G12 pins a CLDR/ICU floor (babel>=2.16,
CLDR/ICU ≥ 48.2, tzdata ≥ 2026a) only for repos that render CLDR-formatted
numbers/dates; habitable deliberately isn't one, today. If a third locale or
richer plural-category needs push the hand-rolled tables past their limit
(ADR 0005's revisit trigger), this gate becomes live: add the pinned dependency
to pyproject.toml, assert the floor, and adopt the CLDR upgrade cadence below.
CLDR upgrade cadence (when G12 goes live): bump at minimum once per major CLDR
release cycle; Renovate opens the bump PR (digest-pinned actions,
minimumReleaseAge per SECURITY-AND-SUPPLY-CHAIN-STANDARD) and the i18n gates
prove the upgrade is non-breaking.
- G2 no-hardcoded-strings extraction is deliberately deferred to a later phase — it is the string-inventory forcing gate and is scoped separately from these mechanical gates.
- Review-gates R1–R7 (§5: lang-of-parts, RTL QA, translation workflow, locale-acceptance, negotiation correctness, civic-obligations, equitable-quality) are human-judgment artifacts, tracked per release, not part of this mechanical-gate wiring.