Skip to content

Fix flash of unstyled text (FOUT) on login page#14476

Open
rtibbles wants to merge 2 commits intolearningequality:release-v0.19.xfrom
rtibbles:claude/investigate-issue-01TfyVAF85fk7RGAfLj1N9Yj
Open

Fix flash of unstyled text (FOUT) on login page#14476
rtibbles wants to merge 2 commits intolearningequality:release-v0.19.xfrom
rtibbles:claude/investigate-issue-01TfyVAF85fk7RGAfLj1N9Yj

Conversation

@rtibbles
Copy link
Copy Markdown
Member

Summary

Fix flash of unstyled text (FOUT) on login page.

font-display: swap tells 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: fallback instead blocks rendering briefly (~100ms) to allow the data URI decode to complete, which eliminates the flash. Full fonts (loaded as separate files) keep swap since they genuinely need background loading time.

Previously, browsers supporting font-display: swap used loadFullFontsImmediately(), which added the full-fonts-loaded class 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

  • The bulk of the diff is regenerated font CSS files (kolibri/core/static_fonts/) — the only meaningful changes are in build_tools/i18n/fonts.py and packages/kolibri/utils/internal/setupAndLoadFonts.js.
  • To test: load the Mac app and watch for a flash of system fonts before Noto renders on the login page. The flash should be gone.
  • font-display: fallback has 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.

claude and others added 2 commits March 31, 2026 08:52
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>
@github-actions github-actions bot added DEV: backend Python, databases, networking, filesystem... SIZE: medium labels Mar 31, 2026
@rtibbles rtibbles marked this pull request as ready for review March 31, 2026 18:01
@rtibbles rtibbles requested a review from rtibblesbot March 31, 2026 19:36
Copy link
Copy Markdown
Contributor

@rtibblesbot rtibblesbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.x rather than develop. The PR description mentions #7487, which is a regression from v0.14. Is this intentional for a point release, or should it target develop?
  • 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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we are only using it for data-URI fonts.

@rtibbles
Copy link
Copy Markdown
Member Author

The PR targets release-v0.19.x rather than develop. The PR description mentions #7487, which is a regression from v0.14. Is this intentional for a point release, or should it target develop?

This is a long overdue regression fix, it's low risk and eminently testable, perfect for a patch release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants