Fix flash of unstyled text (FOUT) on login page#14476
Fix flash of unstyled text (FOUT) on login page#14476rtibbles wants to merge 2 commits intolearningequality:release-v0.19.xfrom
Conversation
Fixes learningequality#7487 The issue was caused by all fonts using font-display: swap, including the subset and common fonts loaded in the HTML head. This caused browsers to show system fonts while decoding the data URI fonts, resulting in a flash when swapping to Noto fonts. This fix uses a two-pronged approach: 1. **font-display: fallback for subset/common fonts** (fonts.py) - Prevents initial FOUT by blocking briefly (~100ms) for data URI decode - Data URI fonts decode quickly enough to avoid visible delay - Limits swap window to ~3s, keeping subset fonts on slow connections 2. **font-display: swap for full fonts** (fonts.py) - Allows graceful background loading of larger font files - FontFaceObserver controls when to switch from subset to full fonts 3. **Progressive loading for all browsers** (setupAndLoadFonts.js) - Uses FontFaceObserver to wait for full fonts before adding class - Ensures smooth, invisible transition from subset to full fonts - Prevents FOUT even on modern browsers that support font-display: swap Result: Users see subset fonts immediately (no flash), then seamlessly transition to full fonts when loaded. On slow connections, they continue seeing acceptable subset fonts rather than experiencing late swaps. Note: Font CSS files need to be regenerated with `make i18n-regenerate-fonts` to apply the font-display changes (requires source fonts via Git LFS).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Build Artifacts
Smoke test screenshot |
rtibblesbot
left a comment
There was a problem hiding this comment.
Clean, well-targeted fix for a long-standing FOUT regression (#7487). The two meaningful source changes are correct and the approach is sound.
CI passing. No UI files changed beyond generated font CSS — Phase 3 skipped.
- suggestion: The PR targets
release-v0.19.xrather thandevelop. The PR description mentions #7487, which is a regression from v0.14. Is this intentional for a point release, or should it targetdevelop? - praise: Good decision to unify all browsers onto the progressive FontFaceObserver path — the old split where modern browsers used
loadFullFontsImmediately()was the actual source of the FOUT, and the explanation in the PR description is clear.
@rtibblesbot's comments are generated by an LLM, and should be evaluated accordingly
How was this generated?
Reviewed the pull request diff checking for:
- Correctness: bugs, edge cases, undocumented behavior, resource leaks, hardcoded values
- Design: unnecessary complexity, naming, readability, comment accuracy, redundant state
- Architecture: duplicated concerns, minimal interfaces, composition over inheritance
- Testing: behavior-based assertions, mocks only at hard boundaries, accurate coverage
- Completeness: missing dependencies, unupdated usages, i18n, accessibility, security
- Principles: DRY (same reason to change), SRP, Rule of Three (no premature abstraction)
- Checked CI status and linked issue acceptance criteria
- For UI changes: inspected screenshots for layout, visual completeness, and consistency
| // font-display: fallback on subset/common fonts (prevents initial flash) and | ||
| // font-display: swap on full fonts (allows graceful background loading). | ||
| // We explicitly control when to switch from subset to full fonts. | ||
| loadFullFontsProgressively(); |
There was a problem hiding this comment.
praise: Moving loadFullFontsProgressively() outside the if/else so all browsers use it is the key fix. The old code's loadFullFontsImmediately() for modern browsers was the root cause — it added full-fonts-loaded before fonts were actually ready, defeating the purpose of the subset fonts.
|
|
||
|
|
||
| def _gen_font_face(family, url, weight, unicodes): | ||
| def _gen_font_face(family, url, weight, unicodes, display="swap"): |
There was a problem hiding this comment.
praise: Good API design — making display a parameter with swap as default keeps full fonts on swap without changing any call sites, while letting _write_inline_font explicitly pass fallback for the subset/common case.
| url=data_uri, | ||
| weight=weight, | ||
| unicodes=_fmt_range(glyphs), | ||
| display="fallback", # Use fallback for subset/common fonts to prevent FOUT |
There was a problem hiding this comment.
nitpick: The comment is helpful but could note that fallback has a ~100ms block period followed by a ~3s swap window, which is why it works well for data-URI fonts that decode near-instantly but would be risky for network-loaded fonts.
There was a problem hiding this comment.
Yes, we are only using it for data-URI fonts.
This is a long overdue regression fix, it's low risk and eminently testable, perfect for a patch release. |
Summary
Fix flash of unstyled text (FOUT) on login page.
font-display: swaptells the browser to immediately show text in a system font, then swap to the web font once loaded. This applies even to the subset/common fonts that are inlined as data URIs in the HTML head — despite decoding almost instantly, the browser still briefly renders system fonts before swapping, causing a visible flash.Changing subset/common fonts to
font-display: fallbackinstead blocks rendering briefly (~100ms) to allow the data URI decode to complete, which eliminates the flash. Full fonts (loaded as separate files) keepswapsince they genuinely need background loading time.Previously, browsers supporting
font-display: swapusedloadFullFontsImmediately(), which added thefull-fonts-loadedclass right away and relied on the browser's native swap behavior — this was actually the source of the FOUT. Browsers without swap support used FontFaceObserver-based progressive loading, which ironically produced better results by waiting for fonts to fully load before switching. Now all browsers use the progressive FontFaceObserver path. The browser capability check is still used to select the appropriate CSS file, but the loading strategy no longer needs to differ.References
Closes #7487
Reviewer guidance
kolibri/core/static_fonts/) — the only meaningful changes are inbuild_tools/i18n/fonts.pyandpackages/kolibri/utils/internal/setupAndLoadFonts.js.font-display: fallbackhas a ~3s swap window — on very slow connections, if the data URI decode somehow exceeds this, text would stay in system fonts rather than swapping late. This is the intended tradeoff.AI usage
Claude Code implemented the fix and regenerated font CSS files. Changes were reviewed for correctness and unnecessary complexity before committing.