npm run i18n:coverage reports 100%. It reported 100% while roughly a hundred strings rendered English in all nine languages, including in production for the last few weeks.
It was not wrong about what it measured. It reads JSX text, and none of these were JSX text:
toast.success("Folder deleted");confirmDialog({title:"Delete this event?",confirmLabel:"Delete",danger:true});toast.error(`Could not save filters: ${(errasError).message}`);title={answered?"Replied":"Forwarded"}
Every one is built from an expression the codemod cannot read. 176 source strings and 15 plural sets now go through t() / plural(), translated into all nine languages — 1,700-odd new catalogue entries.
Not a mechanical wrap
Where English put a word in a slot, the sentence is spelled out per branch:
-toast.success(`Filter ${verb} — it will run on new mail`);// verb = "created" | "saved"
+toast.success(saved?t("Filter saved — it will run on new mail")+:t("Filter created — it will run on new mail"));
A verb slot works in English and nowhere else: which words agree with the verb, and where they sit, is not English's to decide for the other eight. Same reasoning turned every ${n} message${n === 1 ? "" : "s"} into a plural() call, so Russian and Ukrainian get their three forms and Japanese and Chinese get the single form they actually have.
Two conventions worth stating
Both cost me a round trip, and both are now enforced rather than remembered:
Plural entries are keyed on the English other form, not one. I keyed 14 of them on one and the checker called all 14 stale — correctly. other is the form every language has; one is one English happens to have and Japanese does not.
A constant table holding English, translated at the render site, is fine.SECTIONS holds label: "About" and SettingsView renders {t(s.label)}. The literal is a key, not a leak. 71 strings look unwrapped and are not.
The check
scripts/i18n-literals.mjs encodes exactly that. A string passes if it is wrapped where it is written, or is a catalogue key somewhere. It fails if it is neither — a string no catalogue has a key for, which therefore cannot be translated at all, however many languages ship. It skips equality operands, because rule.name === "New filter" compares against a sentinel stored in the Sieve script and translating it would break the comparison rather than help a reader.
It found twenty more than my own sweep had, including the stale-folder toast that showed up in production. It now runs as part of npm run i18n:check.
Also fixed
The catalogue is awaited before the first paint. The tree is rebuilt when a catalogue lands, so components recover on their own — but a string computed in an effect does not: a toast fired in that gap is emitted in English and stays English, in an interface that is otherwise German. Costs nothing visible, since the session bootstrap already shows a spinner and English resolves immediately.
A Japanese spacing habit."{date} からの予定" was written with the English reflex of spacing around a placeholder; Japanese does not.
Verified
npx tsc --noEmit, npm run i18n:check (all nine at 978/993, no stale keys, no literals), 109 node tests, 463 web tests, npm run build. Swept all nine catalogues for out-of-script characters: none.
One thing I could not confirm live. Driving the stale-folder toast in the mock server showed English even after the fix. The built bundle disagrees — fe.show(p("That folder no longer exists…")) is wrapped, and the key is in all nine catalogues — and my attempts to probe the running module kept hitting a second Vite module instance with its own empty catalogue, so I could not get a clean reading either way. I believe it was dev-server state rather than the app, and the first-paint fix above addresses the one real mechanism I could identify. Worth a look on prod after deploy rather than taking my word for it.
`npm run i18n:coverage` reports **100%**. It reported 100% while roughly a hundred strings rendered English in all nine languages, including in production for the last few weeks.
It was not wrong about what it measured. It reads JSX text, and none of these were JSX text:
```js
toast.success("Folder deleted");
confirmDialog({ title: "Delete this event?", confirmLabel: "Delete", danger: true });
toast.error(`Could not save filters: ${(err as Error).message}`);
title={answered ? "Replied" : "Forwarded"}
```
Every one is built from an expression the codemod cannot read. **176 source strings and 15 plural sets** now go through `t()` / `plural()`, translated into all nine languages — 1,700-odd new catalogue entries.
## Not a mechanical wrap
Where English put a word in a slot, the sentence is spelled out per branch:
```js
- toast.success(`Filter ${verb} — it will run on new mail`); // verb = "created" | "saved"
+ toast.success(saved ? t("Filter saved — it will run on new mail")
+ : t("Filter created — it will run on new mail"));
```
A verb slot works in English and nowhere else: which words agree with the verb, and where they sit, is not English's to decide for the other eight. Same reasoning turned every `${n} message${n === 1 ? "" : "s"}` into a `plural()` call, so Russian and Ukrainian get their three forms and Japanese and Chinese get the single form they actually have.
## Two conventions worth stating
Both cost me a round trip, and both are now enforced rather than remembered:
- **Plural entries are keyed on the English `other` form**, not `one`. I keyed 14 of them on `one` and the checker called all 14 stale — correctly. `other` is the form every language has; `one` is one English happens to have and Japanese does not.
- **A constant table holding English, translated at the render site, is fine.** `SECTIONS` holds `label: "About"` and `SettingsView` renders `{t(s.label)}`. The literal is a key, not a leak. 71 strings look unwrapped and are not.
## The check
`scripts/i18n-literals.mjs` encodes exactly that. A string passes if it is wrapped where it is written, **or** is a catalogue key somewhere. It fails if it is neither — a string no catalogue has a key for, which therefore cannot be translated at all, however many languages ship. It skips equality operands, because `rule.name === "New filter"` compares against a sentinel stored in the Sieve script and translating it would break the comparison rather than help a reader.
It found **twenty more** than my own sweep had, including the stale-folder toast that showed up in production. It now runs as part of `npm run i18n:check`.
## Also fixed
**The catalogue is awaited before the first paint.** The tree is rebuilt when a catalogue lands, so components recover on their own — but a string computed in an effect does not: a toast fired in that gap is emitted in English and stays English, in an interface that is otherwise German. Costs nothing visible, since the session bootstrap already shows a spinner and English resolves immediately.
**A Japanese spacing habit.** `"{date} からの予定"` was written with the English reflex of spacing around a placeholder; Japanese does not.
## Verified
`npx tsc --noEmit`, `npm run i18n:check` (all nine at 978/993, no stale keys, no literals), 109 node tests, 463 web tests, `npm run build`. Swept all nine catalogues for out-of-script characters: none.
**One thing I could not confirm live.** Driving the stale-folder toast in the mock server showed English even after the fix. The built bundle disagrees — `fe.show(p("That folder no longer exists…"))` is wrapped, and the key is in all nine catalogues — and my attempts to probe the running module kept hitting a second Vite module instance with its own empty catalogue, so I could not get a clean reading either way. I believe it was dev-server state rather than the app, and the first-paint fix above addresses the one real mechanism I could identify. Worth a look on prod after deploy rather than taking my word for it.
**Merged** 2026-08-31 as coffey-labs/ihasmail@67f5a88ff6c8
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
npm run i18n:coveragereports 100%. It reported 100% while roughly a hundred strings rendered English in all nine languages, including in production for the last few weeks.It was not wrong about what it measured. It reads JSX text, and none of these were JSX text:
Every one is built from an expression the codemod cannot read. 176 source strings and 15 plural sets now go through
t()/plural(), translated into all nine languages — 1,700-odd new catalogue entries.Not a mechanical wrap
Where English put a word in a slot, the sentence is spelled out per branch:
A verb slot works in English and nowhere else: which words agree with the verb, and where they sit, is not English's to decide for the other eight. Same reasoning turned every
${n} message${n === 1 ? "" : "s"}into aplural()call, so Russian and Ukrainian get their three forms and Japanese and Chinese get the single form they actually have.Two conventions worth stating
Both cost me a round trip, and both are now enforced rather than remembered:
otherform, notone. I keyed 14 of them ononeand the checker called all 14 stale — correctly.otheris the form every language has;oneis one English happens to have and Japanese does not.SECTIONSholdslabel: "About"andSettingsViewrenders{t(s.label)}. The literal is a key, not a leak. 71 strings look unwrapped and are not.The check
scripts/i18n-literals.mjsencodes exactly that. A string passes if it is wrapped where it is written, or is a catalogue key somewhere. It fails if it is neither — a string no catalogue has a key for, which therefore cannot be translated at all, however many languages ship. It skips equality operands, becauserule.name === "New filter"compares against a sentinel stored in the Sieve script and translating it would break the comparison rather than help a reader.It found twenty more than my own sweep had, including the stale-folder toast that showed up in production. It now runs as part of
npm run i18n:check.Also fixed
The catalogue is awaited before the first paint. The tree is rebuilt when a catalogue lands, so components recover on their own — but a string computed in an effect does not: a toast fired in that gap is emitted in English and stays English, in an interface that is otherwise German. Costs nothing visible, since the session bootstrap already shows a spinner and English resolves immediately.
A Japanese spacing habit.
"{date} からの予定"was written with the English reflex of spacing around a placeholder; Japanese does not.Verified
npx tsc --noEmit,npm run i18n:check(all nine at 978/993, no stale keys, no literals), 109 node tests, 463 web tests,npm run build. Swept all nine catalogues for out-of-script characters: none.One thing I could not confirm live. Driving the stale-folder toast in the mock server showed English even after the fix. The built bundle disagrees —
fe.show(p("That folder no longer exists…"))is wrapped, and the key is in all nine catalogues — and my attempts to probe the running module kept hitting a second Vite module instance with its own empty catalogue, so I could not get a clean reading either way. I believe it was dev-server state rather than the app, and the first-paint fix above addresses the one real mechanism I could identify. Worth a look on prod after deploy rather than taking my word for it.Merged 2026-08-31 as coffey-labs/ihasmail@67f5a88ff6
Rebuilt from: git history, session transcript.