From 5c3a12f91c7d94269bfd4c02a696330a4edfe41c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 07:04:35 +0100 Subject: [PATCH] feat: use the plain wording for everyone and drop the plain language switch The plain-language test asserted that the normal strings show while the switch is off and that changing the switch swaps them live. With no switch left, it now asserts the plain strings show from the first render and through a language change. --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/admin/layout.tsx | 5 +- app/(main)/setup/layout.tsx | 5 +- .../intl-provider-plain-language.test.tsx | 75 ++++++++----------- components/providers/intl-provider.tsx | 51 +++++-------- .../settings/accessibility-settings.tsx | 3 - i18n/plain-language.ts | 20 ++++- i18n/request.ts | 3 + lib/accessibility-settings.ts | 4 +- lib/admin/policy-settings.ts | 1 - lib/plugin-sandbox/host-api-app.ts | 2 +- lib/settings-schema.ts | 1 - lib/settings-search.ts | 1 - locales/ar/common.json | 4 - locales/ca/common.json | 4 - locales/cs/common.json | 4 - locales/da/common.json | 4 - locales/de/common.json | 4 - locales/en/common.json | 4 - locales/es/common.json | 4 - locales/et/common.json | 4 - locales/fa/common.json | 4 - locales/fr/common.json | 4 - locales/he/common.json | 4 - locales/hu/common.json | 4 - locales/it/common.json | 4 - locales/ja/common.json | 4 - locales/ko/common.json | 4 - locales/lv/common.json | 4 - locales/mn/common.json | 4 - locales/nb/common.json | 4 - locales/nl/common.json | 4 - locales/pl/common.json | 4 - locales/pt/common.json | 4 - locales/ro/common.json | 4 - locales/ru/common.json | 4 - locales/sk/common.json | 4 - locales/th/common.json | 4 - locales/tr/common.json | 4 - locales/uk/common.json | 4 - locales/vi/common.json | 4 - locales/zh-TW/common.json | 4 - locales/zh/common.json | 4 - .../__tests__/settings-plain-language.test.ts | 25 +++++++ stores/settings-store.ts | 2 + 45 files changed, 105 insertions(+), 215 deletions(-) create mode 100644 stores/__tests__/settings-plain-language.test.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index ebd3dc38a..a51cbc2a8 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { AppLockGate } from "@/components/app-lock/app-lock-gate"; import { IntlProvider } from "@/components/providers/intl-provider"; +import { addPlainLanguage } from "@/i18n/plain-language"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { CalendarAlertProvider } from "@/components/providers/calendar-alert-provider"; import { PushBindingsHost } from "@/components/providers/push-bindings-host"; @@ -77,6 +78,7 @@ export default async function LocaleLayout({ } catch { notFound(); } + messages = await addPlainLanguage(locale, messages); } return ( diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index 4ba067930..eccd80ad5 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -3,6 +3,7 @@ import { IntlProvider } from '@/components/providers/intl-provider'; import { ThemeProvider } from '@/components/providers/theme-provider'; import { TourProvider } from '@/components/tour/tour-provider'; import { defaultLocale } from '@/i18n/routing'; +import { addPlainLanguage } from '@/i18n/plain-language'; import { AdminLayoutClient } from './_layout-client'; import { WindowMenuBar } from '@/components/layout/window-menu-bar'; @@ -18,9 +19,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN const locale = await getLocale(); let messages; try { - messages = (await import(`@/locales/${locale}/common.json`)).default; + messages = await addPlainLanguage(locale, (await import(`@/locales/${locale}/common.json`)).default); } catch { - messages = (await import(`@/locales/${defaultLocale}/common.json`)).default; + messages = await addPlainLanguage(defaultLocale, (await import(`@/locales/${defaultLocale}/common.json`)).default); } return ( diff --git a/app/(main)/setup/layout.tsx b/app/(main)/setup/layout.tsx index bd7f7b9f8..393e9a35b 100644 --- a/app/(main)/setup/layout.tsx +++ b/app/(main)/setup/layout.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { getLocale } from 'next-intl/server'; import { IntlProvider } from '@/components/providers/intl-provider'; import { defaultLocale } from '@/i18n/routing'; +import { addPlainLanguage } from '@/i18n/plain-language'; /** * /setup sits outside the [locale] tree, so it loads the message catalog @@ -13,9 +14,9 @@ export default async function SetupLayout({ children }: { children: ReactNode }) const locale = await getLocale(); let messages; try { - messages = (await import(`@/locales/${locale}/common.json`)).default; + messages = await addPlainLanguage(locale, (await import(`@/locales/${locale}/common.json`)).default); } catch { - messages = (await import(`@/locales/${defaultLocale}/common.json`)).default; + messages = await addPlainLanguage(defaultLocale, (await import(`@/locales/${defaultLocale}/common.json`)).default); } return ( diff --git a/components/providers/__tests__/intl-provider-plain-language.test.tsx b/components/providers/__tests__/intl-provider-plain-language.test.tsx index d171316b0..5b805fe83 100644 --- a/components/providers/__tests__/intl-provider-plain-language.test.tsx +++ b/components/providers/__tests__/intl-provider-plain-language.test.tsx @@ -3,16 +3,17 @@ 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'; +import { addPlainLanguage } from '@/i18n/plain-language'; -// Plain-language mode lays locales//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. +// The plain wording in locales//plain.json is the only wording +// (usability pass 2026-11-04, settings-15): it is laid over the catalog for +// English, German and Vietnamese with no setting, and other locales keep +// their own strings. vi.unmock('next-intl'); @@ -35,74 +36,60 @@ async function settle() { }); } -describe('plain-language mode', () => { +describe('plain wording', () => { beforeEach(() => { - // The fallback assertions need a key that has no plain version. + // The fallback assertions need a key that has no plain version, and the + // plain assertions one whose plain version differs. expect((enPlain as { login: Record }).login.sign_in).toBeUndefined(); expect((dePlain as { login: Record }).login.sign_in).toBeUndefined(); + expect(enPlain.login.error.invalid_credentials).not.toBe(enCommon.login.error.invalid_credentials); + expect(dePlain.login.error.invalid_credentials).not.toBe(deCommon.login.error.invalid_credentials); }); afterEach(() => { useLocaleStore.setState({ locale: '' }); - useSettingsStore.setState({ plainLanguage: false }); }); - it('keeps the normal strings while the setting is off', async () => { - render(); - 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(); - expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy(); - expect(enPlain.login.error.invalid_credentials).not.toBe(enCommon.login.error.invalid_credentials); + it('shows the plain English string from the first render, with no setting', () => { + render(); + expect(text('plain')).toBe(enPlain.login.error.invalid_credentials); // A key without a plain version keeps the common.json string. expect(text('normal')).toBe(enCommon.login.sign_in); }); + it('adds the plain strings to a server catalog for German, not French', async () => { + const de = (await addPlainLanguage('de', deCommon)) as typeof deCommon; + expect(de.login.error.invalid_credentials).toBe(dePlain.login.error.invalid_credentials); + expect(await addPlainLanguage('fr', frCommon)).toBe(frCommon); + }); + it('uses the plain German string for a German UI', async () => { - useSettingsStore.setState({ plainLanguage: true }); - render(); - expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy(); + const messages = await addPlainLanguage('de', deCommon); + render(); + expect(text('plain')).toBe(dePlain.login.error.invalid_credentials); expect(text('normal')).toBe(deCommon.login.sign_in); }); - it('works without server messages, as in the Lite export', async () => { - useSettingsStore.setState({ plainLanguage: true }); + it('loads the plain German strings without server messages, as in the Lite export', async () => { render(); - expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy(); + // The German catalog is a lazy chunk, slow to import under load. + expect(await screen.findByText(dePlain.login.error.invalid_credentials, {}, { timeout: 8000 })).toBeTruthy(); }); - it('leaves other locales on their normal strings', async () => { - useSettingsStore.setState({ plainLanguage: true }); + it('leaves other locales on their own strings', async () => { render(); await settle(); expect(text('plain')).toBe(frCommon.login.error.invalid_credentials); }); - it('switches live when the setting changes', async () => { - render(); - 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(); - expect(await screen.findByText(enPlain.login.error.invalid_credentials)).toBeTruthy(); + it('keeps the plain wording across a language change', async () => { + render(); + expect(text('plain')).toBe(enPlain.login.error.invalid_credentials); act(() => useLocaleStore.setState({ locale: 'de' })); - expect(await screen.findByText(dePlain.login.error.invalid_credentials)).toBeTruthy(); + expect(await screen.findByText(dePlain.login.error.invalid_credentials, {}, { timeout: 8000 })).toBeTruthy(); act(() => useLocaleStore.setState({ locale: 'fr' })); - expect(await screen.findByText(frCommon.login.error.invalid_credentials)).toBeTruthy(); + expect(await screen.findByText(frCommon.login.error.invalid_credentials, {}, { timeout: 8000 })).toBeTruthy(); }); }); diff --git a/components/providers/intl-provider.tsx b/components/providers/intl-provider.tsx index ace1f1018..f50db9b47 100644 --- a/components/providers/intl-provider.tsx +++ b/components/providers/intl-provider.tsx @@ -9,14 +9,19 @@ import { locales } from '@/i18n/routing'; import { useLocaleStore } from '@/stores/locale-store'; import { useSettingsStore } from '@/stores/settings-store'; import { getBrowserTimeZone, resolveTimeZone } from '@/lib/timezone'; -import enMessages from '@/locales/en/common.json'; +import enCommon from '@/locales/en/common.json'; +import enPlain from '@/locales/en/plain.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'; +import { addPlainLanguage, withPlainLanguage } from '@/i18n/plain-language'; type Messages = Record; +// English with its plain wording laid over it: the default catalog and the +// fallback base. The plain strings are the only wording, with no setting. +const enMessages = withPlainLanguage(enCommon as Messages, enPlain as Messages); + // Per-locale dynamic loaders so each catalog is its own lazy chunk. Importing // them all statically put every locale (~3 MB) in the critical client bundle, // which dominated time-to-mail-list. English stays a static import: it is the @@ -28,8 +33,8 @@ export const LOCALE_LOADERS: Record Promise> = { ca: () => import('@/locales/ca/common.json').then((m) => m.default), cs: () => import('@/locales/cs/common.json').then((m) => m.default), da: () => import('@/locales/da/common.json').then((m) => m.default), - de: () => import('@/locales/de/common.json').then((m) => m.default), - en: () => Promise.resolve(enMessages as Messages), + de: () => import('@/locales/de/common.json').then((m) => addPlainLanguage('de', m.default)), + en: () => Promise.resolve(enMessages), es: () => import('@/locales/es/common.json').then((m) => m.default), he: () => import('@/locales/he/common.json').then((m) => m.default), et: () => import('@/locales/et/common.json').then((m) => m.default), @@ -51,7 +56,7 @@ export const LOCALE_LOADERS: Record Promise> = { th: () => import('@/locales/th/common.json').then((m) => m.default), tr: () => import('@/locales/tr/common.json').then((m) => m.default), uk: () => import('@/locales/uk/common.json').then((m) => m.default), - vi: () => import('@/locales/vi/common.json').then((m) => m.default), + vi: () => import('@/locales/vi/common.json').then((m) => addPlainLanguage('vi', m.default)), zh: () => import('@/locales/zh/common.json').then((m) => m.default), 'zh-TW': () => import('@/locales/zh-TW/common.json').then((m) => m.default), }; @@ -70,7 +75,6 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, const [browserTimeZone, setBrowserTimeZone] = useState('UTC'); const timeZoneSetting = useSettingsStore((state) => state.timeZone); const timeZone = resolveTimeZone(timeZoneSetting, browserTimeZone); - const plainLanguage = useSettingsStore((state) => state.plainLanguage); const pathname = usePathname(); // The catalog currently in use. Seeded with the server-provided messages for @@ -79,12 +83,12 @@ export function IntlProvider({ locale: initialLocale, messages: initialMessages, // lazily loaded catalog arrives, never out of sync. Without server messages // (Lite) only English is at hand; any other locale renders nothing, on the // server and on the client alike, until its chunk arrives. - const seed = initialMessages ?? (initialLocale === 'en' ? (enMessages as Messages) : undefined); + const seed = initialMessages ?? (initialLocale === 'en' ? enMessages : undefined); const [catalog, setCatalog] = useState<{ locale: string; messages: Messages } | null>( seed ? { locale: initialLocale, messages: seed } : null, ); const loadedRef = useRef>({ - en: enMessages as Messages, + en: enMessages, ...(seed ? { [initialLocale]: seed } : {}), }); @@ -124,22 +128,6 @@ 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>({}); - 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 lang/dir in sync with the rendered locale (RTL for he/fa). const activeLocale = catalog?.locale; useEffect(() => { @@ -150,19 +138,16 @@ 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 baseMessages = useMemo( + // Server-provided catalogs already carry their plain strings (the layouts + // add them), and so do the client loaders, so first render and hydration + // show the same wording. + const messages = useMemo( () => !catalog || catalog.locale === 'en' - ? (enMessages as Messages) - : mergeMessages(enMessages as Messages, catalog.messages), + ? enMessages + : mergeMessages(enMessages, 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) { // Lite without this locale's catalog yet: no text can be drawn, but the diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 3f6f606e2..56b89cb58 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -268,9 +268,6 @@ export function AccessibilitySettings() { set('readingMask', checked)} /> - - set('plainLanguage', checked)} /> - {!isSettingHidden('density') && (