German, generated by AI and marked Beta until somebody signs it off

The first language, and the first one where the honest thing to say is not
flattering: no native speaker has read it. That is stated in the app rather
than in a commit nobody reads, because it is the fact a reader needs to judge
what they are looking at. Somebody told a translation is unchecked forgives an
odd sentence and reports it; somebody told it was reviewed reasonably concludes
the product is sloppy. The setting carries a link straight to a report, which
is the whole review process here.

`beta` is a property of the language, not of the catalogue's completeness. A
file can be word-for-word finished and still read like a machine wrote it, and
that is what the flag marks. Removing it is a person's decision.

Register is "Sie", consistently, and written down in the file so the next
language and the next contributor inherit the decision rather than re-take it.
Thunderbird and Outlook use it; ihasmail is as often a company's mail as
somebody's own, where "du" from software the workplace deployed reads as
presumptuous. Where a string can dodge the question it does, which is ordinary
good German UI. The glossary at the top of the file fixes the vocabulary once
-- Posteingang, Papierkorb, Entwürfe, archivieren -- because inconsistency
reads as amateur far more than an imperfect word choice does. "Label" and
"Spam" stay English, since translating them would name things no German mail
client calls that.

766 of 781 strings. The fifteen left are product names, bare URLs and example
addresses, which should stay English and now do.

Two things this turned up that the earlier work had hidden:

Labels defined as module-level constants -- the entire settings navigation,
the theme cards, the swipe choices, the date formats, the sharing permissions
-- are evaluated once, before any catalogue loads, so they could only ever be
English. Nothing failed; the German build simply had an English sidebar. They
are translated where they render now, which keeps the constant as data and
makes its English text the key.

And the codemod's narrowed rule, which let it take 73 more strings last time,
was too broad after all: text stranded after an inline <a> or <strong> came
through as sentence fragments -- ", and what a new account starts on." Eight
of them, rebuilt with tNode so the sentence stays whole and the element is a
named hole a translator can move.

scripts/i18n-catalog-check.mjs is new and earned itself immediately: it found
three keys invented that the code never asks for, which is the silent failure
in a catalogue -- a translation that looks right, is never looked up, and
renders English for ever. It also had to be taught about t(variable), because
it cried wolf 33 times over the constants above, and a check that cries wolf
gets switched off.

Verified in the browser rather than only in tests, which is where the settings
sidebar being English was visible and nowhere else.
This commit is contained in:
2026-08-31 11:06:54 -07:00
parent d0cbfc7870
commit 87383440bb
19 changed files with 1058 additions and 33 deletions
+2 -2
View File
@@ -81,7 +81,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</Link>
<SearchBar />
<div className="topbar-actions">
<span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
<span className="push-status hide-mobile" role="img" aria-label={t(PUSH_LABEL[pushState])} title={t(PUSH_LABEL[pushState])}>
<span className={`push-dot ${pushState}`} />
</span>
<button className="icon-btn hide-mobile" aria-label={t("Keyboard shortcuts")} title={t("Keyboard shortcuts (?)")} onClick={() => setHelpOpen(true)}>
@@ -132,7 +132,7 @@ export function AppShell({ children }: { children: ReactNode }) {
}}
>
{section === "files" ? <Upload size={22} /> : section === "calendar" || section === "contacts" ? <Plus size={22} /> : <PenSquare size={22} />}
<span>{section === "calendar" ? "New event" : section === "contacts" ? "New contact" : section === "files" ? "Upload" : "Compose"}</span>
<span>{section === "calendar" ? t("New event") : section === "contacts" ? t("New contact") : section === "files" ? t("Upload") : t("Compose")}</span>
</button>
<div className="sidebar-scroll">
{(section === "mail" || section === "search") && <MailboxTree />}
+2 -2
View File
@@ -8,7 +8,7 @@ import { RuleDialog } from "../settings/RuleDialog";
import { toast } from "@/ui/toast";
import { Spinner } from "@/ui/misc";
import { Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
/** "Filter messages like this…" — creates a Sieve rule seeded from a message, optionally applying it to the current folder. */
export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email: Email; mailboxId: Id | null; onClose: () => void }) {
@@ -47,7 +47,7 @@ export function FilterFromMessageDialog({ email, mailboxId, onClose }: { email:
{damage ? (
<p>{t("Your filter script {damage}, so only part of it arrived. Adding a rule would write that part back over the whole thing. Reload the page and try again.", { damage })}</p>
) : loaded ? (
<p>{t("Your active Sieve script was written by hand, so rules can't be added automatically. Open")} <b>{t("Settings → Filters & rules")}</b> {t("to edit the script or switch to managed rules.")}</p>
<p>{tNode("Your active Sieve script was written by hand, so rules can't be added automatically. Open {where} to edit the script or switch to managed rules.", { where: <b>{t("Settings → Filters & rules")}</b> })}</p>
) : (
<p>{t("Your filter script couldn't be read just now, so adding a rule would risk overwriting it. Reload the page and try again.")}</p>
)}
+3 -3
View File
@@ -16,7 +16,7 @@ import { confirmDialog } from "@/ui/dialog";
import { toast } from "@/ui/toast";
import { isUnknownMailbox } from "@/lib/mailboxRoute";
import { scheduledMailboxIdFrom, useScheduled } from "@/store/scheduled";
import { t as translate } from "@/lib/i18n";
import { plural, t as translate, tNode } from "@/lib/i18n";
export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; threadId?: string; search?: boolean }) {
const [, navigate] = useLocation();
@@ -353,8 +353,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string;
) : (
<div className="no-thread">
<img src="/img/logo.png" alt="" />
<div>{list?.total ? `${list.total} conversation${list.total === 1 ? "" : "s"}` : "No conversation selected"}</div>
<div className="hint">{translate("Select a conversation to read it here · Press")} <kbd className="kbd">?</kbd> {translate("for shortcuts")}</div>
<div>{list?.total ? plural(list.total, { one: "{n} conversation", other: "{n} conversations" }) : translate("No conversation selected")}</div>
<div className="hint">{tNode("Select a conversation to read it here · Press {key} for shortcuts", { key: <kbd className="kbd">?</kbd> })}</div>
</div>
)}
</div>
+1 -1
View File
@@ -143,7 +143,7 @@ export function MailboxTree() {
if (id) void moveFolder(id, null);
}}
>
<span>{draggingId && canDropOn(null) ? "Drop here for the top level" : "Folders"}</span>
<span>{draggingId && canDropOn(null) ? t("Drop here for the top level") : t("Folders")}</span>
<button className="icon-btn" title={t("New folder")} aria-label={t("New folder")} onClick={() => void createFolder(null)}>
<Plus size={16} />
</button>
+2 -2
View File
@@ -24,7 +24,7 @@ import { useScheduled } from "@/store/scheduled";
import { formatScheduleTime } from "@/lib/schedule";
import { mdnDecision, refusalText } from "@/lib/mdn";
import { sendReadReceipt } from "@/store/mdn";
import { t as translate } from "@/lib/i18n";
import { t as translate, tNode } from "@/lib/i18n";
interface Props {
email: Email;
@@ -226,7 +226,7 @@ export const MessageView = memo(function MessageView({ email: e, expanded, wasUn
{translate("The sender asked for a read receipt.")}
{receipt.redirected && (
<> {translate("It would go to")} <strong>{receipt.to!.email}</strong>{translate(", which is not where the message came from.")}</>
<>{tNode("It would go to {address}, which is not where the message came from.", { address: <strong className="notranslate" translate="no">{receipt.to!.email}</strong> })}</>
)}
</span>
<button
+25 -9
View File
@@ -1,8 +1,8 @@
import { useSettings } from "@/store/settings";
import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { UI_LANGUAGES } from "@/lib/languages";
import { t as translate } from "@/lib/i18n";
import { TRANSLATION_ISSUE_URL, UI_LANGUAGES } from "@/lib/languages";
import { t as translate, tNode } from "@/lib/i18n";
/**
* The theme cards, each previewing the background it actually paints. Kept as
@@ -31,6 +31,8 @@ export function AppearanceSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const isTouch = useIsTouch();
const chosen = UI_LANGUAGES.find((l) => l.tag === s.uiLanguage);
const betaChosen = Boolean(chosen?.beta);
return (
<div>
<h1>{translate("Appearance")}</h1>
@@ -40,12 +42,12 @@ export function AppearanceSettings() {
{THEMES.map((t) => (
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
<div className="preview" style={{ background: t.preview }} />
{t.label}
{translate(t.label)}
</button>
))}
</div>
<p className="hint" style={{ marginTop: 10 }}>
<strong>{translate("ihasmail")}</strong> {translate("is the palette from")} <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>{translate(", and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.")}
{tNode("{name} is the palette from {site}, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.", { name: <strong className="notranslate" translate="no">ihasmail</strong>, site: <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a> })}
</p>
<Switch
checked={s.themeMessageBody}
@@ -84,7 +86,7 @@ export function AppearanceSettings() {
<label htmlFor="ui-language">{translate("Interface language")}</label>
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
{UI_LANGUAGES.map((l) => (
<option key={l.tag} value={l.tag}>{l.name}</option>
<option key={l.tag} value={l.tag}>{l.beta ? `${l.name} (Beta)` : l.name}</option>
))}
</select>
</div>
@@ -93,13 +95,27 @@ export function AppearanceSettings() {
looks broken; a picker with one entry and a sentence explaining that
more are coming is a roadmap.
*/}
{/*
Said plainly rather than buried. A machine translation presented as a
finished one is the version of this that does harm: a reader told it was
unchecked forgives an odd sentence and reports it, while a reader told
it was reviewed reasonably concludes the product is sloppy. The report
link is the entire review process, so it belongs one click from the
thing being complained about.
*/}
{betaChosen && (
<p className="hint">
{tNode("This translation was generated by AI and has not been checked by a native speaker, so it is marked Beta until somebody who speaks it signs it off. Anything that reads wrongly is worth reporting — {report}.", {
report: <a href={`${TRANSLATION_ISSUE_URL}${encodeURIComponent(chosen?.name ?? "")}`} target="_blank" rel="noopener noreferrer">{translate("tell us about it")}</a>,
})}
</p>
)}
<p className="hint">
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
</p>
<p className="hint">
{translate("This is separate from")} <strong>{translate("Language & region")}</strong> {translate("in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.")}
{tNode("This is separate from {setting} in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.", { setting: <strong>{translate("Language & region")}</strong> })}
</p>
<h2>{translate("Swiping")}</h2>
@@ -112,7 +128,7 @@ export function AppearanceSettings() {
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
<option key={c.value} value={c.value}>{translate(c.label)}</option>
))}
</select>
</div>
@@ -120,7 +136,7 @@ export function AppearanceSettings() {
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
<option key={c.value} value={c.value}>{translate(c.label)}</option>
))}
</select>
</div>
+1 -1
View File
@@ -159,7 +159,7 @@ export function GeneralSettings() {
<select className="select" value={s.dateFormat} onChange={(e) => update({ dateFormat: e.target.value as DateFormat })}>
{DATE_FORMATS.map((f) => (
<option key={f.value} value={f.value}>
{f.label} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
{t(f.label)} ({withPrefs({ locale: s.locale, dateFormat: f.value }, () => formatDate(SAMPLE))})
</option>
))}
</select>
+2 -2
View File
@@ -3,7 +3,7 @@ import { Plus, Trash2 } from "lucide-react";
import { useSettings } from "@/store/settings";
import { CALENDAR_COLORS, ColorSwatches } from "@/ui/misc";
import { promptDialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
export function LabelsSettings() {
const labels = useSettings((s) => s.settings.labels);
@@ -39,7 +39,7 @@ export function LabelsSettings() {
</div>
))}
<button className="btn" onClick={() => void add()}><Plus size={16} /> {t("New label")}</button>
<p className="hint mt-8">{t("Tip: press")} <kbd className="kbd">l</kbd> {t("on a conversation to apply labels. Search with")} <code>label:name</code>.</p>
<p className="hint mt-8">{tNode("Tip: press {key} on a conversation to apply labels. Search with {operator}.", { key: <kbd className="kbd">l</kbd>, operator: <code>label:name</code> })}</p>
</div>
);
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
import { confirmDialog, Dialog } from "@/ui/dialog";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
interface SessionRow {
id: string;
@@ -59,7 +59,7 @@ export function SecuritySettings() {
return (
<div>
<h1>{t("Security & sessions")}</h1>
<p className="lead">{t("You're signed in as")} <b>{session?.username}</b>{t(". Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.")}</p>
<p className="lead">{tNode("You're signed in as {user}. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.", { user: <b className="notranslate" translate="no">{session?.username}</b> })}</p>
<h2>{t("Password")}</h2>
{unsupported ? (
@@ -303,7 +303,7 @@ function AppPasswords({ state, reload }: { state: SecurityState | null; reload:
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>{t("Done")}</button>}>
{issued && (
<div>
<p>{t("Copy it into")} <b>{issued.description}</b> {t("now — it isn't shown again.")}</p>
<p>{tNode("Copy it into {name} now — it isn't shown again.", { name: <b>{issued.description}</b> })}</p>
<CopyableSecret value={issued.secret} />
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> {t("Use your usual address as the username.")}</p>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@ export function SettingsView({ section }: { section?: string }) {
{SECTIONS.map((s) => (
<Link key={s.id} href={`/settings/${s.id}`} className={`nav-item ${section === s.id ? "active" : ""}`}>
{s.icon}
<span className="nav-label">{s.label}</span>
<span className="nav-label">{t(s.label)}</span>
</Link>
))}
<div className="nav-section" style={{ paddingLeft: 8 }}><span>{t("Shortcuts")}</span></div>
+1 -1
View File
@@ -142,7 +142,7 @@ export function ShareDialog({ kind, id, name, shareWith, onClose }: { kind: Kind
{RIGHTS[kind].map((rt) => (
<label key={rt.key} className="check" style={{ padding: "2px 6px" }}>
<input type="checkbox" checked={Boolean(r[rt.key])} onChange={(e) => setRights({ ...rights, [pid]: { ...r, [rt.key]: e.target.checked } })} />
<span className="small">{rt.label}</span>
<span className="small">{t(rt.label)}</span>
</label>
))}
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { keyboard } from "@/lib/keyboard";
import { Kbd } from "@/ui/misc";
import { t } from "@/lib/i18n";
import { t, tNode } from "@/lib/i18n";
export function ShortcutsSettings() {
const list = useMemo(() => keyboard.list(), []);
@@ -17,7 +17,7 @@ export function ShortcutsSettings() {
return (
<div>
<h1>{t("Keyboard shortcuts")}</h1>
<p className="lead">{t("Gmail-style shortcuts are always on. Press")} <kbd className="kbd">?</kbd> {t("anywhere to see this list.")}</p>
<p className="lead">{tNode("Gmail-style shortcuts are always on. Press {key} anywhere to see this list.", { key: <kbd className="kbd">?</kbd> })}</p>
<div className="shortcut-grid">
{groups.map(([group, items]) => (
<div key={group}>