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') && (