Thank you for helping habitable reach more people. A tool a Spanish-speaking, Haitian-Creole-speaking, or Vietnamese-speaking tenant cannot operate has failed at its purpose — accessibility and bilingual/multilingual reach are not optional here, they are a project invariant. This guide is how to add and test a language, how to write copy that sounds like a person rather than a lawyer or a machine, and — importantly — which strings you must never soften or reinterpret.
The app (app/) is plain HTML/CSS/JS with no build step. All user-facing text
lives in per-language JSON bundles under app/i18n/:
app/i18n/
├── en.json # the SOURCE OF TRUTH — every key originates here
└── es.json # Spanish; same keys, translated values
Each bundle is a flat object of "key": "translated string". The English
bundle, en.json, is the source of truth: every key that exists must exist
there first, and every other language mirrors its key set exactly. At runtime
app/app.js fetches i18n/<lang>.json, looks up each element's data-i18n
(and data-i18n-aria) attribute, and substitutes the string. The set of
selectable languages is the SUPPORTED array in app/app.js, and each has a
language button in app/index.html.
tests/test_app_i18n.py enforces three things, and the build fails if any
breaks:
- Key parity — every language has exactly the same keys as
en.json. A missing key (or an extra one) failsmake verify. This is what guarantees no user is ever shown a half-translated screen. - No empty values — every string is non-empty after trimming.
- Actually translated — a sanity check that a non-English bundle is not just a copy of English (at least half the shared strings must differ).
So the workflow has a hard, automatic floor: you cannot ship an incomplete language. Use that — run the test early and often.
-
Copy the source bundle. Start from
app/i18n/en.jsonso you have the complete, correct key set:$ cp app/i18n/en.json app/i18n/<code>.json # e.g. ht.json, vi.json, fr.jsonUse the standard two-letter (or BCP 47) language code; it must match what you register in step 3.
-
Translate the values only — never rename a key. Keys are the contract the code and the parity test depend on. Leave every key exactly as it is; change only the text to the right of the colon. Keep any placeholder syntax, punctuation that carries meaning (the
…ellipsis on loading states), and capitalization intent. -
Register the language in the app. Add the code to the
SUPPORTEDarray inapp/app.js, and add a language button inapp/index.htmlmodeled on the existinglang-en/lang-esbuttons (data-lang="<code>", an accessible label). Without this, the bundle exists but no one can select it. -
Test it.
$ uv run pytest tests/test_app_i18n.py # key parity, no-empties, actually-translated $ make verify # the full gate, incl. accessibility $ uv run habitable app # then switch to your language in the UI and read every screen
The accessibility gate (axe-core) runs against the app; a translation that breaks structure or leaves a control unlabeled can fail it. Reading every screen in your language, end to end, is the step automation cannot do for you.
The people using habitable are often documenting a problem at midnight after a fight with a landlord. Stress lowers effective reading level. The persona research is blunt about this: the Spanish was correct but "sounded like a lawyer," and tenants wanted it to "sound like a person." Aim for that.
- Translate the meaning, not the words. A literal, word-for-word rendering of English idiom usually reads as machine output. Say what a careful, warm human who speaks the language would actually say.
- Plain over formal. Prefer everyday vocabulary and short sentences. Match the calm, reassuring register of the existing strings (e.g. the footer "Nothing leaves your computer").
- Reassure and point to the next step. Status and error strings should leave the reader knowing they are okay and what to do next, not stranded. (A tenant left "Awaiting timestamp" for a day, afraid to close the screen — that is the failure mode to write against.)
- Avoid jargon, or gloss it. "Chain of custody," "fixity," "hash" are terms of art. Where the UI must use one, prefer a plain phrasing or a short explanation in your language over a stiff calque.
- But do not weaken the legally-sensitive strings (next section). Plain is not the same as soft. "Not legal advice" must stay an unambiguous, honest warning — just an unambiguous, honest warning in natural language.
Some strings are not just UI text: they are honest-limits and warning language that protects the tenant. The whole credibility of the tool rests on saying plainly what it does not do. These must be translated faithfully — accurate, equally strong, and equally clear. Never soften, hedge, omit, or "improve" them, and never make them sound more reassuring than the English. If a faithful translation is hard, ask in the PR rather than guessing.
Current legally-sensitive keys in app/i18n/en.json (re-scan on every release —
this list can grow):
| Key | English | Why it is sensitive |
|---|---|---|
alpha_warning |
"Alpha software — do not rely on this for real legal matters yet." | The core honesty caveat. Must stay a real warning, not a soft "still improving." |
alpha_label |
"Alpha software warning" | The accessible label for the warning; must read as a warning to AT users too. |
alpha_tag |
"Alpha" | A status badge; keep it as a clear maturity marker, not omitted. |
verify_ready / verify_intact_untrusted / verify_failed / verify_awaiting |
Ready / intact-but-untrusted / failed / awaiting | Integrity, authority trust, and readiness are separate claims. Never round token presence or structural integrity up to evidence readiness. |
custody_broken |
"Broken — verify the record" | A tamper/integrity warning. Must stay a clear "something is wrong, check it," never softened to "minor issue." |
custody_intact |
"Intact" | Its honest counterpart; do not overstate it as a guarantee of truth. |
capture_timestamped_no |
"not timestamped" | Reflects a real, weaker evidentiary state. Do not phrase it as if equivalent to "timestamped." |
footer_note |
"habitable runs entirely on this device. Nothing leaves your computer." | A privacy claim users rely on; translate it precisely, neither stronger nor weaker. |
Beyond these exact keys, the same rule applies to any future string that states a limit, a warning, a privacy/security property, or an evidence verdict. When in doubt, treat it as sensitive and flag it.
These mirror the honest-limits discipline of the README's Honest limits and the hard rule that we say what the tool does not do. A translation that quietly makes the tool sound safer or more authoritative than it is would put a tenant at risk in exactly the moment the warning exists for.
habitable has had an initial RTL-readiness pass (R-48): the plumbing below is in place, but a native-speaker visual QA is still required before an RTL or heavily-expanding bundle ships. Flag any breakage you find rather than assuming it is fully solved.
- Text expansion. Many languages run noticeably longer than English (German,
French, Spanish in places). Buttons, badges, and labels now use
white-space: normal/overflow-wrap/flex-wrap, so long strings wrap rather than overflow, and a static pseudo-locale check keeps compact-UI labels bounded. Still check your longest strings against narrow layouts — the app is tested down to a 320px reflow, so test there. - Right-to-left (RTL). The CSS has been audited to use only logical
(
*-inline-*,inset-inline-*,text-align: start) properties,<html>carries an explicitdir="ltr"default, and the app flipsdirtortlfor RTL scripts (ar,he,fa,ur) alongsidelangwhen the language changes. No RTL bundle ships yet, so before shipping one, get a native speaker to visually QA every screen — automated checks catch physical- direction regressions, not mirroring nuances (icons, progress direction). - Dates and numbers. These are already rendered through
Intl.DateTimeFormat/Intl.NumberFormat/Intl.PluralRuleskeyed to the active language, so date order and decimal/thousands separators follow the locale, not an English convention. Confirm they read correctly in your language and flag anything that still looks hard-coded.
We recommend each language ship a short glossary of terms of art — the handful of recurring domain words and how this project renders them in that language: timestamp, chain of custody, hash / content hash, packet, seal / sealed original, verify, evidence, duress mode, recovery. The goals:
- Consistency — the same concept gets the same word everywhere in the bundle, not three near-synonyms.
- Honesty — the chosen rendering preserves the precise (and limited) meaning; e.g. a "timestamp" word that does not imply the photo's authorship or what it depicts, only when the content existed.
- Handoff — the next translator into that language inherits your decisions instead of re-litigating them.
Keep the glossary short and put it where the next contributor will find it (a
note in the PR, or a small app/i18n/glossary-<code>.md). A glossary is a
recommendation, not a gate — but it is how a language stays coherent as it grows.
- Run
uv run pytest tests/test_app_i18n.pyandmake verifyuntil green. - Read every screen in your language in the running app.
- Call out any legally-sensitive string you were unsure how to render, and any text-expansion / RTL / format breakage you hit.
- Sign off your commits (
git commit -s); see../CONTRIBUTING.md.