Summary
Reduce the offline web bundle by subsetting the CJK/non-Latin fallback fonts, and rework how the app supplies fallback fonts so that every locale renders offline with zero CDN requests while online users can still fetch rare glyphs on demand.
The product must work with no network (router firmware, served from /www on a squashfs image), so all fonts required by the interface must ship in the build. The full Noto Sans CJK set was ~12.7MB; the interface only uses ~1,700 glyphs.
Result: CJK fonts 12.7MB → 2.14MB (84% smaller), all 26 locales render offline, glyph shapes stay correct per language, and there are 0 fonts.gstatic.com requests for interface text.
Problem
- Offline build mirrored the full Noto Sans CJK woff2 chunks locally (~12.7MB) plus other engine fallbacks.
- The interface strings are a small, fixed set — bundling full CJK fonts was wasteful.
- Naive subsetting broke in several non-obvious ways (documented below) that each caused stray CDN requests or offline tofu (□).
Solution — "A+" strategy
- Subset the 5 Noto CJK fonts (SC/TC/HK/JP/KR) to the interface charset → ~2.1MB total, one font per language (merging languages corrupts glyph shapes; see below).
- Bundle full (small) non-CJK fallbacks the primary font lacks: Thai, Arabic, Latin-ext (Greek/Cyrillic/Vietnamese), plus Roboto (the engine's default global fallback).
- Declare all of them eager under pubspec
fonts: so CanvasKit registers them before the first frame.
- Keep
fontFallbackBaseUrl on the CDN so that online, glyphs outside the subset (e.g. user-typed rare Han) are still fetched on demand.
Root causes fixed along the way
AppText bypassed the fallback — it resolves styles from ui_kit's own appTextTheme constant, not ThemeData.textTheme, so app-level fallback never reached it. Fixed by injecting a locale-aware fallback inside AppText.resolve().
- Subset coverage gaps — the charset must union ARB values + full CJK punctuation/fullwidth blocks + language-picker native names + hardcoded CJK literals in Dart source. Missing any class caused stray CDN fetches.
- Language picker renders all languages' native names at once; per-locale loading can't cover it. Fixed by wrapping each row in
Localizations.override(locale).
- Lazy
FontLoader first-paint race — async loading meant the first frame hit the CDN before local fonts registered. Fixed by switching to eager pubspec fonts: declarations.
- Engine default fallback (Roboto) — was CDN-probed on startup. Fixed by bundling Roboto eager under its bare family name.
- Double package prefix — ui_kit's base
TextStyle sets package: ui_kit_library, which auto-prefixes fontFamilyFallback entries. The app resolver must emit bare names for ui_kit injection and prefixed names for ThemeData.textTheme (raw Text). Mixing them produced packages/ui_kit_library/packages/ui_kit_library/..., matching nothing → offline blank text.
Architecture (final)
- ui_kit (v2.28.1): ships an injectable
LocaleFallbackFont.resolver hook — no fonts, no hardcoded mapping; AppText.resolve() applies it. Removed dead NoToSans fallback list.
- App:
lib/localization/fallback_font_resolver.dart is the single source of the locale→family mapping; install() injects it into ui_kit at startup; app.dart also adds it to ThemeData.textTheme for raw Text.
- Fonts:
assets/fonts/fallback/ (8 subset/full woff2 + Roboto), declared eager in pubspec.yaml.
- Tooling:
tools/font_subset/ regenerates the subsets from the current charset (regenerate.sh).
⚠️ Maintenance rule
Re-run tools/font_subset/regenerate.sh after any change that can add a CJK/kana/hangul glyph to interface text — new/edited ARB strings, a new language name in lib/util/languages.dart, or a hardcoded CJK literal in Dart. Otherwise the subset silently misses the glyph (offline tofu / online CDN fetch).
Verification
- ✅ Offline (gstatic blocked): 6+ locales (ja/zh-TW/ko/th/ar/ru) login page + language picker render fully.
- ✅ Online: interface pages issue 0
fonts.gstatic.com/s/notosans* requests (netlog verified).
- ✅ Online rare glyphs (e.g.
龘饕餮) are fetched from the CDN on demand (A+ works).
- ✅ Deployed to a router
/www and verified on-device.
Related side findings
- Two CanvasKit variants (standard 6.9MB + chromium 5.5MB) cannot be dropped while supporting all browsers — the engine auto-selects per browser. The real waste is
build/web/canvaskit/ (~37MB of unused skwasm/wimp/experimental variants), already handled in the Jenkins build.
- NOTICES (1.46MB) needs no flash optimization — the router serves
/www from a squashfs image, which already compresses text. (Confirmed via SSH.)
Commits (branch feature/offline-font-subset-poc)
feat(fonts): bundle CJK/non-Latin subset fonts for offline rendering
chore(fonts): remove redundant full CJK engine-fallback fonts (~13MB)
chore(tools): add CJK subset font regeneration tooling
Depends on ui_kit v2.28.1 (injectable LocaleFallbackFont hook).
Summary
Reduce the offline web bundle by subsetting the CJK/non-Latin fallback fonts, and rework how the app supplies fallback fonts so that every locale renders offline with zero CDN requests while online users can still fetch rare glyphs on demand.
The product must work with no network (router firmware, served from
/wwwon a squashfs image), so all fonts required by the interface must ship in the build. The full Noto Sans CJK set was ~12.7MB; the interface only uses ~1,700 glyphs.Result: CJK fonts 12.7MB → 2.14MB (84% smaller), all 26 locales render offline, glyph shapes stay correct per language, and there are 0
fonts.gstatic.comrequests for interface text.Problem
Solution — "A+" strategy
fonts:so CanvasKit registers them before the first frame.fontFallbackBaseUrlon the CDN so that online, glyphs outside the subset (e.g. user-typed rare Han) are still fetched on demand.Root causes fixed along the way
AppTextbypassed the fallback — it resolves styles from ui_kit's ownappTextThemeconstant, notThemeData.textTheme, so app-level fallback never reached it. Fixed by injecting a locale-aware fallback insideAppText.resolve().Localizations.override(locale).FontLoaderfirst-paint race — async loading meant the first frame hit the CDN before local fonts registered. Fixed by switching to eager pubspecfonts:declarations.TextStylesetspackage: ui_kit_library, which auto-prefixesfontFamilyFallbackentries. The app resolver must emit bare names for ui_kit injection and prefixed names forThemeData.textTheme(rawText). Mixing them producedpackages/ui_kit_library/packages/ui_kit_library/..., matching nothing → offline blank text.Architecture (final)
LocaleFallbackFont.resolverhook — no fonts, no hardcoded mapping;AppText.resolve()applies it. Removed deadNoToSansfallback list.lib/localization/fallback_font_resolver.dartis the single source of the locale→family mapping;install()injects it into ui_kit at startup;app.dartalso adds it toThemeData.textThemefor rawText.assets/fonts/fallback/(8 subset/full woff2 + Roboto), declared eager inpubspec.yaml.tools/font_subset/regenerates the subsets from the current charset (regenerate.sh).Re-run
tools/font_subset/regenerate.shafter any change that can add a CJK/kana/hangul glyph to interface text — new/edited ARB strings, a new language name inlib/util/languages.dart, or a hardcoded CJK literal in Dart. Otherwise the subset silently misses the glyph (offline tofu / online CDN fetch).Verification
fonts.gstatic.com/s/notosans*requests (netlog verified).龘饕餮) are fetched from the CDN on demand (A+ works)./wwwand verified on-device.Related side findings
build/web/canvaskit/(~37MB of unused skwasm/wimp/experimental variants), already handled in the Jenkins build./wwwfrom a squashfs image, which already compresses text. (Confirmed via SSH.)Commits (branch
feature/offline-font-subset-poc)feat(fonts): bundle CJK/non-Latin subset fonts for offline renderingchore(fonts): remove redundant full CJK engine-fallback fonts (~13MB)chore(tools): add CJK subset font regeneration toolingDepends on ui_kit v2.28.1 (injectable
LocaleFallbackFonthook).