Translate three lists the code was rendering raw

A native speaker reviewing the German catalogue reported strings appearing in
English (#247). Three of the reported areas turned out not to be missing
translations at all: the strings were there, and the code was rendering the
English source instead of asking for one.

The Sieve rule dialog rendered HEADER_CHOICES and HEADER_OPS labels directly.
All sixteen are already in every catalogue -- "Subject" has been "Betreff" in
de.ts all along, which is exactly the inconsistency the reporter noticed
against the Out-of-office page, where it already reads Betreff. Wrapping the
two dropdowns fixes nine languages at once and adds nothing to any catalogue.

The keyboard shortcut panel rendered each binding's group and description
directly. Those are registered in English at the call sites and should stay
that way -- the binding table is data and the English is the catalogue key --
so the panel translates them at render instead. A binding added anywhere is
then translatable without its registrar knowing i18n exists.

The palette grid marks every name translate="no". That is right for ihasmail,
Dracula, Gruvbox, Rosé Pine and Tokyo Night, which are names. "Classic" is an
adjective describing the theme, not a name, so PaletteMeta gains a
`translatable` flag for the one entry that is a word. Flagging the exception
beats dropping the attribute from all six.

No catalogue changes here: the strings the first two need are already present
in all nine. "Classic" needs an entry, which lands with each language.
This commit is contained in:
2026-09-03 11:37:05 -07:00
parent 3ffee1224f
commit b10149fd54
4 changed files with 24 additions and 6 deletions
+11 -1
View File
@@ -23,10 +23,20 @@ export interface PaletteMeta {
name: string; name: string;
/** Shown in Settings and in NOTICE; who to credit and under what. */ /** Shown in Settings and in NOTICE; who to credit and under what. */
credit?: string; credit?: string;
/**
* Whether the name is a word rather than a name.
*
* Five of these six are proper names -- ihasmail, Dracula, Gruvbox, Rosé
* Pine, Tokyo Night -- and are rendered translate="no" so a page translator
* leaves them alone. "Classic" is not a name, it is an adjective describing
* the theme, and a German reader should see "Klassisch". Reported by a
* native speaker reviewing the German catalogue (#247).
*/
translatable?: boolean;
} }
export const PALETTES: PaletteMeta[] = [ export const PALETTES: PaletteMeta[] = [
{ id: "default", name: "Classic" }, { id: "default", name: "Classic", translatable: true },
{ id: "ihasmail", name: "ihasmail" }, { id: "ihasmail", name: "ihasmail" },
{ id: "dracula", name: "Dracula", credit: "Dracula Theme (MIT) — dark: Dracula, light: Alucard" }, { id: "dracula", name: "Dracula", credit: "Dracula Theme (MIT) — dark: Dracula, light: Alucard" },
{ id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" }, { id: "gruvbox", name: "Gruvbox", credit: "gruvbox by morhetz (MIT)" },
@@ -75,7 +75,9 @@ export function AppearanceSettings() {
return ( return (
<button key={p.id} className={`theme-card ${s.palette === p.id ? "active" : ""}`} onClick={() => update({ palette: p.id })}> <button key={p.id} className={`theme-card ${s.palette === p.id ? "active" : ""}`} onClick={() => update({ palette: p.id })}>
<div className="preview" style={{ background: PALETTE_PREVIEW[p.id][shown] || PALETTE_PREVIEW[p.id].dark }} /> <div className="preview" style={{ background: PALETTE_PREVIEW[p.id][shown] || PALETTE_PREVIEW[p.id].dark }} />
<span className="notranslate" translate="no">{p.name}</span> {p.translatable
? <span>{translate(p.name)}</span>
: <span className="notranslate" translate="no">{p.name}</span>}
</button> </button>
); );
})} })}
+2 -2
View File
@@ -60,7 +60,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" }); else if (v === "address") setTest(i, { type: "address", header: "from", part: "domain", op: "is", value: "" });
else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" }); else setTest(i, { type: "header", header: v === "__custom__" ? "" : v, op: "contains", value: "" });
}}> }}>
{HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{h.label}</option>)} {HEADER_CHOICES.map((h) => <option key={h.value} value={h.value}>{translate(h.label)}</option>)}
<option value="address">{translate("Sender domain")}</option> <option value="address">{translate("Sender domain")}</option>
<option value="size">{translate("Message size")}</option> <option value="size">{translate("Message size")}</option>
<option value="body">{translate("Body text")}</option> <option value="body">{translate("Body text")}</option>
@@ -75,7 +75,7 @@ export function RuleDialog({ rule, onClose, onSave, applyMailbox, applyByDefault
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">{translate("contains")}</option><option value="notcontains">{translate("does not contain")}</option></select> <select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as "contains" | "notcontains" })}><option value="contains">{translate("contains")}</option><option value="notcontains">{translate("does not contain")}</option></select>
) : t.type === "true" ? <span /> : ( ) : t.type === "true" ? <span /> : (
<select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}> <select className="select" value={t.op} onChange={(e) => setTest(i, { ...t, op: e.target.value as SieveTest extends { op: infer O } ? O : never })}>
{HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)} {HEADER_OPS.map((o) => <option key={o.value} value={o.value}>{translate(o.label)}</option>)}
</select> </select>
)} )}
{t.type === "size" ? ( {t.type === "size" ? (
+8 -2
View File
@@ -21,9 +21,15 @@ export function ShortcutsSettings() {
<div className="shortcut-grid"> <div className="shortcut-grid">
{groups.map(([group, items]) => ( {groups.map(([group, items]) => (
<div key={group}> <div key={group}>
<h3>{group}</h3> {/* Group names and descriptions are registered in English at the
call sites -- see views/Shortcuts.tsx -- because the binding
table is data, not markup, and the English is the catalogue
key. Translating at render keeps the registration simple and
means a binding added anywhere is translatable without the
registrar knowing about i18n. */}
<h3>{t(group)}</h3>
{items.map((b) => ( {items.map((b) => (
<div key={b.keys} className="shortcut-row"><span>{b.description}</span><Kbd keys={b.keys} /></div> <div key={b.keys} className="shortcut-row"><span>{t(b.description)}</span><Kbd keys={b.keys} /></div>
))} ))}
</div> </div>
))} ))}