feat: use plainer English and German wording for sign-in and error messages when plain language is on

This commit is contained in:
Linus Rath
2026-10-29 18:07:44 +01:00
parent b4483b8739
commit bb739c7445
7 changed files with 327 additions and 1 deletions
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { useTranslations } from 'next-intl';
import { IntlProvider } from '../intl-provider';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import enCommon from '@/locales/en/common.json';
import deCommon from '@/locales/de/common.json';
import frCommon from '@/locales/fr/common.json';
import enPlain from '@/locales/en/plain.json';
import dePlain from '@/locales/de/plain.json';
// Plain-language mode lays locales/<en|de>/plain.json over the catalog when the
// setting is on. Every useTranslations call has to pick it up from the one
// provider, live, and only for English and German.
vi.unmock('next-intl');
function Strings() {
const t = useTranslations('login');
return (
<>
<p data-testid="plain">{t('error.invalid_credentials')}</p>
<p data-testid="normal">{t('sign_in')}</p>
</>
);
}
const text = (id: string) => screen.getByTestId(id).textContent;
// Lets lazily imported catalogs land.
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
});
}
describe('plain-language mode', () => {
beforeEach(() => {
// The fallback assertions need a key that has no plain version.
expect((enPlain as { login: Record<string, unknown> }).login.sign_in).toBeUndefined();
expect((dePlain as { login: Record<string, unknown> }).login.sign_in).toBeUndefined();
});
afterEach(() => {
useLocaleStore.setState({ locale: '' });
useSettingsStore.setState({ plainLanguage: false });
});
it('keeps the normal strings while the setting is off', async () => {
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
await settle();
expect(text('plain')).toBe(enCommon.login.error.invalid_credentials);
});
it('uses the plain English string when the setting is on', async () => {
useSettingsStore.setState({ plainLanguage: true });
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy();
expect(enPlain.login.error.invalid_credentials).not.toBe(enCommon.login.error.invalid_credentials);
// A key without a plain version keeps the common.json string.
expect(text('normal')).toBe(enCommon.login.sign_in);
});
it('uses the plain German string for a German UI', async () => {
useSettingsStore.setState({ plainLanguage: true });
render(<IntlProvider locale="de" messages={deCommon}><Strings /></IntlProvider>);
expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy();
expect(text('normal')).toBe(deCommon.login.sign_in);
});
it('leaves other locales on their normal strings', async () => {
useSettingsStore.setState({ plainLanguage: true });
render(<IntlProvider locale="fr" messages={frCommon}><Strings /></IntlProvider>);
await settle();
expect(text('plain')).toBe(frCommon.login.error.invalid_credentials);
});
it('switches live when the setting changes', async () => {
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
await settle();
expect(text('plain')).toBe(enCommon.login.error.invalid_credentials);
act(() => useSettingsStore.setState({ plainLanguage: true }));
expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy();
act(() => useSettingsStore.setState({ plainLanguage: false }));
expect(text('plain')).toBe(enCommon.login.error.invalid_credentials);
});
it('follows a language change while the setting stays on', async () => {
useSettingsStore.setState({ plainLanguage: true });
render(<IntlProvider locale="en" messages={enCommon}><Strings /></IntlProvider>);
expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy();
act(() => useLocaleStore.setState({ locale: 'de' }));
expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy();
act(() => useLocaleStore.setState({ locale: 'fr' }));
expect(await screen.findByText(frCommon.login.error.invalid_credentials)).toBeTruthy();
});
});
+25 -1
View File
@@ -9,6 +9,7 @@ import enMessages from '@/locales/en/common.json';
import { getLocaleDirection } from '@/i18n/direction';
import { mergeMessages } from '@/i18n/merge-messages';
import { detectBrowserLocale } from '@/i18n/detect-locale';
import { PLAIN_LANGUAGE_LOADERS, hasPlainLanguage, withPlainLanguage } from '@/i18n/plain-language';
type Messages = Record<string, unknown>;
@@ -64,6 +65,7 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
const [browserTimeZone, setBrowserTimeZone] = useState<string>('UTC');
const timeZoneSetting = useSettingsStore((state) => state.timeZone);
const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone);
const plainLanguage = useSettingsStore((state) => state.plainLanguage);
// The catalog currently in use. Seeded with the server-provided messages for
// the SSR-resolved locale, so the first render needs no async work and
@@ -116,6 +118,22 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentLocale]);
// Plain-language strings, loaded the first time the setting is on for an
// English or German UI. They arrive after the first render, so hydration
// always matches the server's normal strings.
const [plainCatalogs, setPlainCatalogs] = useState<Record<string, Messages>>({});
const plainLocale = plainLanguage && catalog && hasPlainLanguage(catalog.locale) ? catalog.locale : null;
useEffect(() => {
if (!plainLocale || plainCatalogs[plainLocale]) return;
let stale = false;
PLAIN_LANGUAGE_LOADERS[plainLocale]()
.catch(() => ({} as Messages))
.then((plain) => {
if (!stale) setPlainCatalogs((prev) => ({ ...prev, [plainLocale]: plain }));
});
return () => { stale = true; };
}, [plainLocale, plainCatalogs]);
// Keep <html> lang/dir in sync with the rendered locale (RTL for he/fa).
const activeLocale = catalog?.locale;
useEffect(() => {
@@ -126,13 +144,19 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages,
// Fall back to English for any key the active locale has not translated, so
// untranslated strings show English text instead of a raw message key.
const messages = useMemo(
const baseMessages = useMemo(
() =>
!catalog || catalog.locale === 'en'
? (enMessages as Messages)
: mergeMessages(enMessages as Messages, catalog.messages),
[catalog]
);
// Plain strings win over the catalog; keys without a plain version keep it.
const plainMessages = plainLocale ? plainCatalogs[plainLocale] : undefined;
const messages = useMemo(
() => withPlainLanguage(baseMessages, plainMessages),
[baseMessages, plainMessages]
);
if (!catalog) return null;
+27
View File
@@ -0,0 +1,27 @@
import { mergeMessages } from './merge-messages';
type Messages = Record<string, unknown>;
/**
* Plain-language mode (CO-14): simpler wording for settings and error
* messages. Only English and German have plain versions; every other locale
* keeps its normal strings when the setting is on.
*
* The plain strings live in `locales/<code>/plain.json`, outside common.json,
* so the translation parity test doesn't ask every locale for them. Each file
* holds only the keys that get a simpler version, under the same key paths as
* common.json; anything it leaves out falls back to the normal string.
*/
export const PLAIN_LANGUAGE_LOADERS: Record<string, () => Promise<Messages>> = {
en: () => import('@/locales/en/plain.json').then((m) => m.default as Messages),
de: () => import('@/locales/de/plain.json').then((m) => m.default as Messages),
};
export function hasPlainLanguage(locale: string): boolean {
return Object.prototype.hasOwnProperty.call(PLAIN_LANGUAGE_LOADERS, locale);
}
/** The catalog with the plain strings laid over it, or the catalog unchanged. */
export function withPlainLanguage(messages: Messages, plain: Messages | undefined): Messages {
return plain ? mergeMessages(messages, plain) : messages;
}
+8
View File
@@ -24,4 +24,12 @@ describe('German translations', () => {
.map(([key]) => key);
expect(informal).toEqual([]);
});
it('use the formal "Sie" in the plain-language strings too', () => {
const plain = JSON.parse(readFileSync('locales/de/plain.json', 'utf8'));
const informal = strings(plain)
.filter(([, text]) => INFORMAL.test(text))
.map(([key]) => key);
expect(informal).toEqual([]);
});
});
@@ -0,0 +1,67 @@
// @vitest-environment node
import fs from 'fs';
import path from 'path';
import { describe, expect, it } from 'vitest';
import { PLAIN_LANGUAGE_LOADERS } from '@/i18n/plain-language';
// locales/<code>/plain.json holds simpler versions of common.json strings for
// plain-language mode (CO-14). An override only works while its key path
// exists in common.json: a renamed or removed key would leave a dead override
// that nobody sees, so every path is checked here.
const localesDir = path.resolve(__dirname, '../../locales');
type Tree = Record<string, unknown>;
function leaves(node: unknown, prefix = ''): Array<[string, unknown]> {
if (node && typeof node === 'object' && !Array.isArray(node)) {
return Object.entries(node as Tree).flatMap(([key, value]) => leaves(value, prefix ? `${prefix}.${key}` : key));
}
return [[prefix, node]];
}
const load = (locale: string, file: string): Tree =>
JSON.parse(fs.readFileSync(path.join(localesDir, locale, file), 'utf-8'));
// ICU argument names ({name}, {count, plural, ...}); plural selectors are not arguments.
function icuArgs(text: string): string[] {
const names = [...text.matchAll(/\{\s*(\w+)/g)].map((m) => m[1]);
return [...new Set(names)].filter((n) => !['zero', 'one', 'two', 'few', 'many', 'other'].includes(n)).sort();
}
const plainLocales = fs.readdirSync(localesDir).filter((l) => fs.existsSync(path.join(localesDir, l, 'plain.json')));
describe('plain-language strings', () => {
it('exist for English and German only, each with a loader', () => {
expect(plainLocales.sort()).toEqual(['de', 'en']);
expect(Object.keys(PLAIN_LANGUAGE_LOADERS).sort()).toEqual(['de', 'en']);
});
for (const locale of plainLocales) {
describe(locale, () => {
const plain = leaves(load(locale, 'plain.json'));
const common = new Map(leaves(load(locale, 'common.json')));
it('has strings', () => {
expect(plain.length).toBeGreaterThan(0);
});
it('only overrides keys that exist in common.json', () => {
const dead = plain.map(([key]) => key).filter((key) => !common.has(key));
expect(dead).toEqual([]);
});
it('only holds non-empty strings', () => {
const bad = plain.filter(([, value]) => typeof value !== 'string' || !value.trim()).map(([key]) => key);
expect(bad).toEqual([]);
});
it('keeps the placeholders of the string it replaces', () => {
const mismatched = plain
.filter(([key, value]) => JSON.stringify(icuArgs(String(value))) !== JSON.stringify(icuArgs(String(common.get(key)))))
.map(([key]) => key);
expect(mismatched).toEqual([]);
});
});
}
});
+49
View File
@@ -0,0 +1,49 @@
{
"login": {
"jmap_endpoint_cors_hint": "Der Mailserver muss Anmeldungen von dieser Website erlauben. Ihre Administration kann das einrichten.",
"reconnecting": "Die Verbindung ist weg. Wir versuchen, wieder zu verbinden…",
"error": {
"invalid_credentials": "Die E-Mail-Adresse oder das Passwort ist falsch. Prüfen Sie beides und versuchen Sie es noch einmal.",
"invalid_token": "Der Server hat dieses Zugriffstoken nicht angenommen. Vielleicht ist es nicht vollständig oder nicht mehr gültig. Kopieren Sie es noch einmal oder erstellen Sie ein neues.",
"connection_failed": "Wir erreichen den Mailserver nicht. Prüfen Sie Ihre Internetverbindung und versuchen Sie es noch einmal.",
"cors_blocked": "Der Mailserver läuft, erlaubt aber keine Anmeldung von dieser Website. Bitten Sie Ihre Administration, diese Website auf dem Server zu erlauben.",
"server_error": "Der Mailserver hat gerade ein Problem. Versuchen Sie es in ein paar Minuten noch einmal.",
"generic": "Etwas ist schiefgelaufen. Versuchen Sie es noch einmal. Wenn das öfter passiert, fragen Sie Ihre Administration.",
"totp_required": "Ihr Konto braucht zur Anmeldung einen zweiten Code. Öffnen Sie Ihre Authenticator-App und geben Sie unten den 6-stelligen Code ein.",
"totp_invalid": "Dieser Code ist falsch oder zu alt. Geben Sie den Code ein, den Ihre Authenticator-App gerade anzeigt.",
"oauth_discovery_failed": "Die Anmeldung über Ihre Organisation funktioniert gerade nicht. Versuchen Sie es später noch einmal oder fragen Sie Ihre Administration.",
"token_exchange_failed": "Passwort und Code sind richtig, aber der Mailserver hat diese App nicht hereingelassen. Bitten Sie Ihre Administration, die Anmelde-Einstellungen für diese App zu prüfen."
},
"totp_toggle": "Ich habe einen Code aus meiner Authenticator-App",
"token_hint": "Manche Mailanbieter geben Ihnen ein Token statt eines Passworts. Erstellen Sie es in den Einstellungen Ihres Mailanbieters, kopieren Sie es und fügen Sie es hier ein.",
"config_error": {
"title": "Das Webmail ist nicht richtig eingerichtet",
"fetch_failed": "Das Webmail konnte seine Einstellungen nicht laden. Versuchen Sie es in ein paar Minuten noch einmal.",
"server_not_configured": "Es ist noch kein Mailserver eingerichtet. Bitten Sie Ihre Administration, einen einzurichten."
},
"session_expired": "Sie wurden abgemeldet. Melden Sie sich wieder an, um weiterzumachen.",
"oauth_error": {
"title": "Die Anmeldung hat nicht geklappt",
"invalid_state": "Die Anmeldung wurde unterbrochen. Fangen Sie noch einmal an.",
"missing_params": "Es fehlen Anmeldedaten. Fangen Sie noch einmal an.",
"token_exchange_failed": "Wir konnten die Anmeldung nicht abschließen. Versuchen Sie es noch einmal.",
"access_denied": "Sie dürfen sich hier nicht anmelden. Bitten Sie Ihre Administration um Zugang."
}
},
"totp_reauth": {
"title": "Neuen Code eingeben",
"description": "Ihr letzter Code gilt nicht mehr. Öffnen Sie Ihre Authenticator-App und geben Sie den neuen Code ein, um weiterzumachen.",
"oauth_hint": "Diese Abfrage kommt immer wieder. Ihre Administration kann das abstellen, indem sie eine andere Anmeldeart (OAuth) einschaltet.",
"code_label": "Code aus Ihrer Authenticator-App"
},
"errors": {
"page_error_description": "Diese Seite funktioniert gerade nicht. Versuchen Sie es noch einmal oder gehen Sie zurück zum Posteingang.",
"sidebar_error": "Ihre Ordner konnten nicht geladen werden",
"email_list_error": "Ihre E-Mails konnten nicht geladen werden",
"viewer_error_title": "Diese E-Mail kann nicht angezeigt werden",
"viewer_error_description": "Etwas in dieser E-Mail verhindert, dass sie hier angezeigt wird. Vielleicht nutzt sie ein Format, das wir nicht unterstützen.",
"composer_error": "Das Fenster für neue E-Mails konnte nicht geöffnet werden",
"settings_error_title": "Die Einstellungen konnten nicht geöffnet werden",
"settings_error_description": "Ihre Einstellungen konnten nicht geladen werden. Was Sie jetzt ändern, wird vielleicht nicht gespeichert."
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"login": {
"jmap_endpoint_cors_hint": "The mail server must allow sign-ins from this website. Your administrator can set this up.",
"reconnecting": "The connection is lost. We are trying to connect again…",
"error": {
"invalid_credentials": "The email address or password is wrong. Check both and try again.",
"invalid_token": "The server did not accept this access token. Maybe it is not complete, or it is no longer valid. Copy it again or create a new one.",
"connection_failed": "We can't reach the mail server. Check your internet connection and try again.",
"cors_blocked": "The mail server works, but it does not allow sign-ins from this website. Ask your administrator to allow this website on the server.",
"server_error": "The mail server has a problem right now. Try again in a few minutes.",
"generic": "Something went wrong. Try again. If it keeps happening, ask your administrator for help.",
"totp_required": "Your account needs a second code to sign in. Open your authenticator app and type the 6-digit code below.",
"totp_invalid": "This code is wrong or too old. Type the code your authenticator app shows right now.",
"oauth_discovery_failed": "Sign-in through your organisation does not work right now. Try again later or ask your administrator.",
"token_exchange_failed": "Your password and code are correct, but the mail server did not let this app in. Ask your administrator to check the sign-in settings for this app."
},
"totp_toggle": "I have a code from my authenticator app",
"token_hint": "Some mail providers give you a token instead of a password. Create one in your mail provider's settings, copy it, and paste it here.",
"config_error": {
"title": "The webmail is not set up correctly",
"fetch_failed": "The webmail could not load its settings. Try again in a few minutes.",
"server_not_configured": "No mail server is set up yet. Ask your administrator to set one up."
},
"session_expired": "You were signed out. Sign in again to continue.",
"oauth_error": {
"title": "Sign-in did not work",
"invalid_state": "The sign-in was interrupted. Start again.",
"missing_params": "Some sign-in data is missing. Start again.",
"token_exchange_failed": "We could not finish signing you in. Try again.",
"access_denied": "You are not allowed to sign in here. Ask your administrator for access."
}
},
"totp_reauth": {
"title": "Enter a new code",
"description": "Your last code is no longer valid. Open your authenticator app and type the new code to continue.",
"oauth_hint": "This question comes back from time to time. Your administrator can stop it by turning on a different way to sign in (OAuth).",
"code_label": "Code from your authenticator app"
},
"errors": {
"page_error_description": "This page stopped working. Try again, or go back to your inbox.",
"sidebar_error": "Your folders could not be loaded",
"email_list_error": "Your messages could not be loaded",
"viewer_error_title": "This message can't be shown",
"viewer_error_description": "Something in this message stops it from being shown here. It may use a format we don't support.",
"composer_error": "The editor for new messages could not be opened",
"settings_error_title": "Settings could not be opened",
"settings_error_description": "Your settings could not be loaded. Changes you make now may not be saved."
}
}