Extraction complete: 596 strings wrapped, 0 remaining. Up from 78%.
The 143 the codemod refused were two different things
A third were phrases next to an icon — <Plus /> New rule. The refusal rule was "has siblings", which is broader than the actual danger: what breaks a translation is a sibling that renders text, splitting a sentence into fragments nobody can reorder. An element beside a phrase doesn't. Narrowing the rule let the codemod take 73 more.
The rest were real sentences with values in them, rebuilt by hand as named placeholders — "Your active script “{name}” was written by hand", "Waiting on the server — goes out {when}." Named rather than positional, because translators move the parts around. Counted things go through plural() so Russian and Ukrainian get three forms rather than English's two.
One new primitive: tNode()
Open <code>mailto:</code> links in ihasmail has two obvious treatments and both are wrong. Splitting into two t() calls hands over fragments that can't be reordered. Dropping the <code> keeps the sentence whole but loses the monospace that said this is a literal.
tNode() keeps the sentence whole and makes the element a named hole in it:
A translator sees one sentence with a hole and puts it where their language wants it. The German test asserts exactly that — the same call renders the code first when the catalogue says so.
The coverage number was lying
It counted text inside <code> and translate="no" as untranslated work, plus placeholders like "123456" and "+1 555 0100" — a one-time code and a phone format. None of those will ever be translated, so the report sat at 21 remaining when only 6 were real.
A number with an unreachable floor is something to argue with rather than act on. The tool now applies the same rules the codemod does, and reads 100% because that's true.
Verification
447 web tests, 109 server tests, typecheck and build clean.
Browser sweep across 15 views — this is where the last bulk pass hid a bug the tests couldn't see. No entity leaks, no unfilled {placeholders} in rendered text, no raw t( leaking through. The nine "empty labels" it flagged on Settings are the toggle switches, which are text-free by design and labelled by their adjacent row.
Visual check confirms the earlier Language & region fix, interpolation (Browser default (America/Phoenix)), and plural() (1 selected).
Still no user-visible change — English remains the only language in the picker.
What's next
The machinery is done. What's left is the part the memory from the last attempt is blunt about: a real catalogue, and a native speaker to read it. German first, per the roadmap order. Adding it to UI_LANGUAGES is the one line that makes any of this visible, and should be the last thing that happens, not the first.
Extraction complete: **596 strings wrapped, 0 remaining.** Up from 78%.
## The 143 the codemod refused were two different things
**A third were phrases next to an icon** — `<Plus /> New rule`. The refusal rule was "has siblings", which is broader than the actual danger: what breaks a translation is a sibling that renders *text*, splitting a sentence into fragments nobody can reorder. An element beside a phrase doesn't. Narrowing the rule let the codemod take 73 more.
**The rest were real sentences with values in them**, rebuilt by hand as named placeholders — `"Your active script “{name}” was written by hand"`, `"Waiting on the server — goes out {when}."` Named rather than positional, because translators move the parts around. Counted things go through `plural()` so Russian and Ukrainian get three forms rather than English's two.
## One new primitive: `tNode()`
`Open <code>mailto:</code> links in ihasmail` has two obvious treatments and both are wrong. Splitting into two `t()` calls hands over fragments that can't be reordered. Dropping the `<code>` keeps the sentence whole but loses the monospace that said *this is a literal*.
`tNode()` keeps the sentence whole and makes the element a named hole in it:
```tsx
tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })
```
A translator sees one sentence with a hole and puts it where their language wants it. The German test asserts exactly that — the same call renders the code *first* when the catalogue says so.
## The coverage number was lying
It counted text inside `<code>` and `translate="no"` as untranslated work, plus placeholders like `"123456"` and `"+1 555 0100"` — a one-time code and a phone format. None of those will ever be translated, so the report sat at **21 remaining when only 6 were real**.
A number with an unreachable floor is something to argue with rather than act on. The tool now applies the same rules the codemod does, and reads 100% because that's true.
## Verification
- 447 web tests, 109 server tests, typecheck and build clean.
- **Browser sweep across 15 views** — this is where the last bulk pass hid a bug the tests couldn't see. No entity leaks, no unfilled `{placeholders}` in rendered text, no raw `t(` leaking through. The nine "empty labels" it flagged on Settings are the toggle switches, which are text-free by design and labelled by their adjacent row.
- Visual check confirms the earlier `Language & region` fix, interpolation (`Browser default (America/Phoenix)`), and `plural()` (`1 selected`).
Still no user-visible change — English remains the only language in the picker.
## What's next
The machinery is done. What's left is the part the memory from the last attempt is blunt about: **a real catalogue, and a native speaker to read it.** German first, per the roadmap order. Adding it to `UI_LANGUAGES` is the one line that makes any of this visible, and should be the last thing that happens, not the first.
**Merged** 2026-08-31 as coffey-labs/ihasmail@d0cbfc7870a7
<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.
Extraction complete: 596 strings wrapped, 0 remaining. Up from 78%.
The 143 the codemod refused were two different things
A third were phrases next to an icon —
<Plus /> New rule. The refusal rule was "has siblings", which is broader than the actual danger: what breaks a translation is a sibling that renders text, splitting a sentence into fragments nobody can reorder. An element beside a phrase doesn't. Narrowing the rule let the codemod take 73 more.The rest were real sentences with values in them, rebuilt by hand as named placeholders —
"Your active script “{name}” was written by hand","Waiting on the server — goes out {when}."Named rather than positional, because translators move the parts around. Counted things go throughplural()so Russian and Ukrainian get three forms rather than English's two.One new primitive:
tNode()Open <code>mailto:</code> links in ihasmailhas two obvious treatments and both are wrong. Splitting into twot()calls hands over fragments that can't be reordered. Dropping the<code>keeps the sentence whole but loses the monospace that said this is a literal.tNode()keeps the sentence whole and makes the element a named hole in it:A translator sees one sentence with a hole and puts it where their language wants it. The German test asserts exactly that — the same call renders the code first when the catalogue says so.
The coverage number was lying
It counted text inside
<code>andtranslate="no"as untranslated work, plus placeholders like"123456"and"+1 555 0100"— a one-time code and a phone format. None of those will ever be translated, so the report sat at 21 remaining when only 6 were real.A number with an unreachable floor is something to argue with rather than act on. The tool now applies the same rules the codemod does, and reads 100% because that's true.
Verification
{placeholders}in rendered text, no rawt(leaking through. The nine "empty labels" it flagged on Settings are the toggle switches, which are text-free by design and labelled by their adjacent row.Language & regionfix, interpolation (Browser default (America/Phoenix)), andplural()(1 selected).Still no user-visible change — English remains the only language in the picker.
What's next
The machinery is done. What's left is the part the memory from the last attempt is blunt about: a real catalogue, and a native speaker to read it. German first, per the roadmap order. Adding it to
UI_LANGUAGESis the one line that makes any of this visible, and should be the last thing that happens, not the first.Merged 2026-08-31 as coffey-labs/ihasmail@d0cbfc7870
Rebuilt from: git history, session transcript.