1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106 | # Suite i18n — how every ardegazu app speaks en/ro/hu
> **CLJS-canon note (2026-08):** this repo is now ClojureScript — the
> vendored runtime lives at `client/src/game1/i18n/runtime.cljs` (the
> CLJS-line canon; byte-identical rule applies to CLJS apps) with per-app
> catalogs `i18n/en.cljs` / `ro.cljs` / `hu.cljs` as CLJS maps, keys and
> strings verbatim from the TS catalogs. The TS-era `MsgKey` completeness
> type-check is replaced by `client/test/i18n.test.mjs` (en/ro/hu key
> parity fails the suite). **The TS line is gone**: chat, board and home —
> its last consumers — have all ported, so `runtime.cljs` here is the single
> suite canon. Fix here, replicate (byte-identical modulo the namespace
> rename). Every rule below applies unchanged.
Every app ships all its UI languages in the bundle. Nothing is fetched at
runtime (offline PWAs), nothing waits on the network at boot, and the entire
mechanism is ~150 lines vendored per app. This doc is canonical, like
NEW-GAME-PROMPT.md next to it.
## The shape
```
client/src/<app>/i18n/
runtime.cljs # VENDORED — byte-identical in every app (modulo the
# namespace rename), like net/ and id/boot.cljs. Fix here
# (neon-grid is canonical), replicate.
en.cljs # source of truth — the key set every language owes
ro.cljs hu.cljs # key parity with en asserted by client/test/i18n.test.mjs
client/src/<app>/i18n.cljs # per-app binder: binds runtime to this app's
# ns + catalogs, hydrates static HTML, exposes t/tn
```
`i18n/` sits beside `net/` and `id/` — it survives the "gut client/src/game/*"
step of scaffolding a new game.
## Rules
- **English is baked into index.html.** `data-i18n="key"` /
`data-i18n-attr="title:key"` tags mark what hydrates; the default language
paints with zero flash and `hydrateHtml()` swaps textContent once for the
rest. The markup's English text and `en.cljs` must move together.
- **Translated strings never enter innerHTML.** textContent / setAttribute /
placeholder only.
- **Plurals go through `tn(key, n)`**, never `${n === 1 ? "" : "s"}`.
Message variants per language, picked by `Intl.PluralRules`:
- en: `{ one: "{n} rider", other: "{n} riders" }`
- ro: `{ one: "{n} pilot", few: "{n} piloți", other: "{n} de piloți" }` —
ro's `other` fires exactly where "de" belongs (20+, except n%100 in 1–19),
so the rule lives in the text, not in code
- hu: `{ other: "{n} versenyző" }` — numerals never inflect the noun
- **Dates/numbers**: `fmtNum` / `fmtDate` / `fmtTime`, never bare
`toLocaleString()` — those follow the browser, not the chosen language.
- **Never translate**: brand names (NEON GRID, SÉANCE, sueta, …), anything
inside signed bytes (leaderboard receipts, `mode:"match"`), wire protocol
strings, music notation. Emoji fingerprints are language-neutral identity.
- **The `social.*` block** in each catalog mirrors
`ardegazu-social-kit`'s `src/ardegazu/social/text.cljs` (`SocialTextKey`,
generated into `types/text.d.ts` by `scripts/gen-text-dts.mjs`). The kit
falls back to
its built-in EN for anything an app doesn't cover. Pass `t` into
`joinPasteChip({ t })`, `attachSocial({ …, t })`, `inviteToast(inv, { …, t })`.
- **Invites carry a code, not prose**: `sendInvite(…, label, { k: "join" })`.
The label stays the built-in EN prose (`enText(…)`) for old receivers —
never localize the wire label; receivers render the code in their own
language.
## Where the preference lives
Suite-wide, like hue/glyph: `lang` on the identity record (id-kit ≥1.1).
Per app it mirrors under `<ns>:lang` — the mirror always wins at boot
(synchronous), the bridge reconciles later:
```clojure
(:require [game1.i18n :as i18n]) ;; first text-bearing require in main.cljs
…
;; after bootIdentity resolves:
(i18n/absorb-suite-lang (unchecked-get profile "lang")) ; suite record → this app
```
The picker (`langPickerEl`) persists locally via `setLang`; its `onPick`
pushes suite-wide: `void ident.bridge.putProfile({ lang })`.
## Voice — for translators
Lowercase sentence openings, dry, em-dashes, second person. The product must
read like the same product in every language. Romanian uses comma-below
diacritics (ș ț, never ş ţ). Hungarian keeps compounds natural, no
anglicisms where a plain word exists. When in doubt, translate the meaning
in the house voice — not the words.
## Adding a language
1. Add the code to `LANGS` in `runtime.cljs` — then re-replicate the file to
every app (byte-identical modulo the namespace rename).
2. Add `xx.cljs` per app; wire it into the `i18n.cljs` binder's catalogs.
`client/test/i18n.test.mjs` lists every key you owe.
3. Add `"xx": { tagline, description }` entries under each `i18n` field in
home's `client/catalog.json` (+ bundled snapshot).
4. Add the language to each `site/index.html` inline dict.
## Verifying
- `npm test` — proves catalog completeness (the i18n suite's key parity).
- Cycle en→ro→hu→en in the UI; check plural sites at n = 1, 2, 20, 101
(ro: "2 piloți", "20 de piloți", "101 piloți").
- `<html lang>` must follow the switch (screen readers key off it).
- Longer ro/hu strings must not overflow HUD chips or buttons.
|