Internationalization
The plan puts i18n inside M7 rather than after it, with a reason worth restating: "retrofitting i18n is the most
expensive deferred decision at this size." M7 shipped without it, so this is that debt paid — and, as with
docs/accessibility.md, the section that makes this page worth reading is the one listing what is not done.
The line
Display is localised. The command grammar is not.
| Locale-aware? | |
|---|---|
| Labels, tooltips, announcements, refusal reasons | Yes — Translator.t() |
| Numbers, percentages, counts on screen | Yes — Intl.NumberFormat via Translator.number() / .plural() |
parseCadCommand (WALL 0,0 @5<0) |
No, deliberately |
parseLength, parseDynConstraint |
Comma accepted as a decimal separator; no locale consulted |
A command invocation is serialisable by design — that is what makes macros, the audit log, replay and the eventual
CRDT path work. A locale-dependent parser would make a recorded WALL 0,0 3,5 draw one wall in Berlin and a
different one in Boston. See docs/adr/0011-decimal-comma-and-the-coordinate-grammar.md; it is the substantive
decision on this page.
If you write decimal commas: every single-number field accepts them (2,5, <30,5, a height of 2,7). For
coordinates, use ; between components — 3,5;7,2 is (3.5, 7.2). Without it, 3,5 is the point (3, 5), which is
what the grammar has always meant.
Using it
import { DE, createTranslator } from "@massing/i18n";
import { createRibbon } from "@massing/ribbon";
const locale = navigator.language.split("-")[0] ?? "en";
const i18n = createTranslator({ locale, catalogue: locale === "de" ? DE : {} });
createRibbon(container, { handlers, translate: i18n });
translate is optional. Omitting it gives byte-identical English output, which is what keeps M9 from depending on
massing adopting i18n first.
Three properties worth knowing, because each is a decision rather than an accident:
- A missing string falls back, to the locale's English, then to the item's own literal. A ribbon renders three
sources of label — the inherited tool table, plugin manifests, and contextual tabs from an IFC class — and only
the first is catalogued. Rendering
tool.some-plugin-verb.labelat a user would be worse than English. Translator.missing()reports fallbacks as data, so a test can assert how far a translation reaches. Noticing untranslated text by looking at the screen does not scale past one language.plural()asksIntl.PluralRules, so Polish gets its four categories.PluralKeyis derived from the.otherkeys, so a typo in a plural call is a compile error.
Adding a message
- Add the key and English text to
packages/i18n/src/en.ts.MessageKeyiskeyof typeof EN, so every call site knows about it immediately. - Use it.
t()for a sentence,plural()for anything counted. npm run gate:messages.
For a plural, add .one and .other at minimum — .other is required, because it is the fallback every other
locale relies on.
Adding a locale
Copy the shape of packages/i18n/src/de.ts. It is typed Catalogue — Partial<Record<MessageKey, string>> — so
a partial translation compiles and a stale key does not.
Translate the availability.* keys first. Availability.reason is a required field precisely so that a dimmed
control always explains itself, and a refusal that falls back to English in a localised UI undermines the one
feature it belongs to.
The gate
npm run gate:messages checks the three things neither the compiler nor a unit test can:
- Plural completeness per locale. CLDR categories are a runtime fact, so
MessageKeycannot know Polish needsfewandmany. A partially-pluralised message renders the wrong grammar while looking finished. A locale that has not started a message is a translation gap, not a plural bug, and is not gated. - The tool catalogue tracking the tool table. A tool added upstream arrives with an English label and no key, and silently stops being translatable. Checked in both directions, so a stale key fails too.
- Orphan keys — defined and never looked up. Real translator effort for no user-visible result.
Translation coverage is printed, never gated. Gating it would mean either blocking a release on a language's last string, or committing machine output to turn the number green — and the second is worse than the gap, because it reads as reviewed and nobody looks again.
That last sentence is why the printed line carries two facts, not one:
de: 99/99 (100%) translated, not native-reviewed
When German was 32%, the percentage was the "somebody still needs to look at this" signal. Completing the
catalogue did not answer the concern behind that signal — it removed the signal — so the review status is now
printed alongside it and a locale joins REVIEWED in scripts/check-messages.mjs in the same commit as a
reviewer's corrections. A claim of review that is traceable to a diff is worth something; a percentage is not.
All three checks were sabotage-tested: dropping a German plural form, adding an unused key, and removing a tool key
each failed the gate with the right message. The third failed twice — once in tsc (the German catalogue's now
stale key) and once in the gate (the missing English key), which is the two-directional check working.
What is not done
| Gap | Status |
|---|---|
| The German strings were written by a developer, not reviewed by a native speaker. German is 99/99 keys, and completeness is not correctness. | Printed on every gate run as not native-reviewed, so the 100% cannot be misread. The review itself is made tractable rather than requested in the abstract: de.ts carries a table of the twelve terminology decisions — Bauteil vs Element, Decke vs Geschossdecke, Stütze vs Säule, Geschoss vs Etage, spelled-out axis labels vs E,N,Z — because each propagates across many strings. A drafter confirming twelve decisions is a review that happens; a drafter asked to read ninety-nine strings is one that does not. |
Kernel refusal messages are not catalogued. KernelFailure.message is English prose. |
Partly unfixable and already designed for: RemoteKernel forwards the service's own text, which no catalogue in this repository can reach. That is exactly why the conformance suite asserts failure codes and not messages. Cataloguing the LocalKernel half is possible and unscheduled. |
| Panels outside the ribbon are not translated — the demo's Model, Plan, Markup and Selection panels build strings inline. | apps/demo is a demo. The counts went through plural() because they were the ones that would teach the wrong habit; the rest is unconverted and would be real work in apps/shell. |
No test measures translated text at a real width. The reachability property test covers 320→3840 px against layoutTab, which knows tool counts and size classes and nothing about text; the ribbon's German tests run in happy-dom, which has no layout engine. |
So a German compound that overflows at 320 px would not be caught by anything but a human looking. The fix is one Playwright case: locale=de at the mobile viewport, asserting no tool's label is clipped. Unscheduled, and named here rather than implied by the German tests that do exist. |
Every catalogue is statically imported, so an English user downloads the German one. Measured: 1.3 KB brotli of the demo's entry bundle is de.ts. |
Negligible at two locales and linear in the number of them — at ten it is the largest single thing in the bundle that nobody asked for. The fix is a dynamic import() keyed on the resolved locale, which is a small change made awkward only by createTranslator being synchronous today. Measured rather than assumed, and recorded now so the tenth locale is not the moment it is discovered. |
No RTL support. No logical CSS properties, no dir handling, no mirrored ribbon. |
Untested and unclaimed. A first RTL locale is where this gets designed, not before. |
No locale switcher. The demo reads navigator.language once at start-up. |
A setting needs the settings surface, which is M6/M7 UI work. |
Dates and times are unlocalised — the PDF writer emits a fixed D:20260101000000Z. |
That one is deliberate and must not change: the PDF is byte-deterministic so the fixture gate can assert it regenerates identically. A displayed date would use Intl.DateTimeFormat; there are none yet. |