diff --git a/AGENTS.md b/AGENTS.md index 429febc..b6de3fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2368,7 +2368,7 @@ similar are internal endpoints/hook names, not credits — leave them.) is now **multi-way** — en/es stay key-for-key identical (unchanged), `th` (and `ja`) may be partial and are reported as **coverage %**; a `th`/`ja` key absent from `en` fails. -- **Font:** `@fontsource/kanit` (Kanit) imported in `main.tsx` and added to the +- **Font:** `@fontsource/kanit` (Kanit), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow have **no Thai glyphs**, so without it Thai silently falls back to a system font. - **Detection:** a Thai-device browser (`th*`) starts in `th` (`detectLanguage`). @@ -2388,7 +2388,9 @@ similar are internal endpoints/hook names, not credits — leave them.) bloats the app. Each language is its own on-demand chunk. - **Fonts:** added **Noto Sans KR** (Korean) and **Inter** (Cyrillic/Russian) to the CSS + Tailwind stacks; JP/KR/TH each need their own font (Outfit/Inter - lack CJK). + lack CJK). **(2026-10-12) CJK fonts are lazy-loaded per locale** (`lib/cjkFonts.ts`, + wired in `contexts/LanguageContext.tsx`) — they're ~16 MB of font payload that + non-CJK users no longer download at boot. - `check-i18n.mjs` now reports **all 12**; new locales are *partial* (coverage %) until complete. Adding a locale = register in `language.ts` (`Language` union + `LANGUAGE_LOADERS`), `LanguageContext.detectLanguage`, `LanguageToggle`, @@ -2408,7 +2410,7 @@ similar are internal endpoints/hook names, not credits — leave them.) Both partial-locale rules below still hold for any *future* keys. - **Partial by design** (same as `th`): a missing `ja` key falls back to English. `check-i18n.mjs` reports `ja` coverage and fails if a `ja` key is absent from `en`. -- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP) imported in `main.tsx` and +- **Font:** `@fontsource/noto-sans-jp` (Noto Sans JP), **lazy-loaded per locale** (`lib/cjkFonts.ts`) and added to the CSS + Tailwind font stacks — ⚠️ Outfit/Inter/Bebas/Barlow/Kanit have **no Japanese glyphs**, so without it Japanese falls back to a system font. - **Detection:** a Japanese-device browser (`ja*`) starts in `ja` (`detectLanguage`). diff --git a/src/contexts/LanguageContext.tsx b/src/contexts/LanguageContext.tsx index 8e7ae0d..ac9beb3 100644 --- a/src/contexts/LanguageContext.tsx +++ b/src/contexts/LanguageContext.tsx @@ -10,6 +10,7 @@ import { type Translations, } from './language'; import { setDateLocale } from '@/lib/dateFnsI18n'; +import { loadCjkFont } from '@/lib/cjkFonts'; interface LanguageProviderProps { children: ReactNode; @@ -76,6 +77,12 @@ export function LanguageProvider({ children }: LanguageProviderProps) { return () => { active = false; }; }, [language]); + // Lazy-load the CJK display font for the active locale (ja/ko/th) — large font + // files that non-CJK users no longer have to download at boot. + useEffect(() => { + loadCjkFont(language); + }, [language]); + const setLanguage = useCallback((lang: Language) => { setLanguageState(lang); setStoredLanguage(lang); diff --git a/src/lib/cjkFonts.ts b/src/lib/cjkFonts.ts new file mode 100644 index 0000000..7299d8e --- /dev/null +++ b/src/lib/cjkFonts.ts @@ -0,0 +1,38 @@ +import type { Language } from '@/contexts/language'; + +const loaded = new Set(); + +/** + * Lazy-load the CJK display font for the active locale. + * + * The Thai (Kanit), Japanese (Noto Sans JP) and Korean (Noto Sans KR) font + * families are large and only needed for users on those locales — but they were + * imported statically in main.tsx, so every visitor downloaded them. They are + * now loaded on demand, once per locale, and non-CJK users never fetch them. + * Text briefly falls back to a system font until the stylesheet resolves. + */ +export function loadCjkFont(language: Language): void { + if (loaded.has(language)) return; + loaded.add(language); + + if (language === 'ja') { + void Promise.all([ + import('@fontsource/noto-sans-jp/400.css'), + import('@fontsource/noto-sans-jp/500.css'), + import('@fontsource/noto-sans-jp/700.css'), + ]).catch(() => {}); + } else if (language === 'ko') { + void Promise.all([ + import('@fontsource/noto-sans-kr/400.css'), + import('@fontsource/noto-sans-kr/500.css'), + import('@fontsource/noto-sans-kr/700.css'), + ]).catch(() => {}); + } else if (language === 'th') { + void Promise.all([ + import('@fontsource/kanit/400.css'), + import('@fontsource/kanit/500.css'), + import('@fontsource/kanit/600.css'), + import('@fontsource/kanit/700.css'), + ]).catch(() => {}); + } +} diff --git a/src/main.tsx b/src/main.tsx index acdf922..bc1877e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -34,19 +34,9 @@ import '@fontsource/bebas-neue'; import '@fontsource/barlow-condensed/400.css'; import '@fontsource/barlow-condensed/600.css'; import '@fontsource/barlow-condensed/700.css'; -// Kanit — Thai glyphs (Latin/Thai stack fallback; the other fonts have no Thai) -import '@fontsource/kanit/400.css'; -import '@fontsource/kanit/500.css'; -import '@fontsource/kanit/600.css'; -import '@fontsource/kanit/700.css'; -// Japanese glyphs — Noto Sans JP (Outfit/Inter/Bebas/Barlow/Kanit have none) -import '@fontsource/noto-sans-jp/400.css'; -import '@fontsource/noto-sans-jp/500.css'; -import '@fontsource/noto-sans-jp/700.css'; -// Korean glyphs — Noto Sans KR -import '@fontsource/noto-sans-kr/400.css'; -import '@fontsource/noto-sans-kr/500.css'; -import '@fontsource/noto-sans-kr/700.css'; +// CJK fonts (Thai Kanit, Noto Sans JP, Noto Sans KR) are NOT imported here — +// they're large and only needed for ja/ko/th users, so they're lazy-loaded per +// locale (lib/cjkFonts.ts, wired in contexts/LanguageContext.tsx). // Cyrillic (Russian) — Inter has Cyrillic; Outfit does not import '@fontsource-variable/inter';