Skip to content

fix(web): add self-hosted Noto Sans Cyrillic fallback after Albert Sans - #6498

Open
Sid-Qin wants to merge 3 commits into
nexu-io:mainfrom
Sid-Qin:fix/6478-cyrillic-sans-fallback
Open

fix(web): add self-hosted Noto Sans Cyrillic fallback after Albert Sans#6498
Sid-Qin wants to merge 3 commits into
nexu-io:mainfrom
Sid-Qin:fix/6478-cyrillic-sans-fallback

Conversation

@Sid-Qin

@Sid-Qin Sid-Qin commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6478

Why

After #6142, both --serif and --sans start with Albert Sans, but the bundled Albert Sans variable TTF has no basic Russian Cyrillic glyphs (0/66 Russian letters). Russian UI text therefore falls through to PingFang SC / Microsoft YaHei / generic sans-serif and becomes OS-dependent, while Latin stays Albert Sans.

This is intentionally not a re-open of #6085 / #6089 (Source Serif). Maintainer triage on #6478 agreed: keep Albert Sans first and add a same-origin, Cyrillic-capable fallback.

Issue author recommended vendoring four Noto Sans variable WOFF2 subsets from @fontsource-variable/noto-sans@5.3.0 (~198 KB) with OFL metadata rather than adding a Fontsource runtime dependency — this PR implements that shape.

What users will see

  • Russian / Cyrillic UI text uses a bundled Noto Sans face offline (no CDN).
  • Latin UI text still uses Albert Sans (Noto is unicode-range limited to Cyrillic).
  • Packaged od:// sessions recover Albert and Noto faces via font-recovery, including late Cyrillic loads after an English startup / locale switch.

Surface area

  • UI — typography tokens / self-hosted fonts
  • Keyboard shortcut
  • CLI / env var
  • API / contract
  • Extension point
  • i18n keys
  • New top-level dependency
  • Default behavior change — Cyrillic text resolves to bundled Noto Sans instead of OS fonts
  • None

What changed

Path Change
apps/web/public/fonts/noto-sans-cyrillic*.woff2 4 range-limited variable subsets (197,672 bytes)
apps/web/public/fonts/OFL-NotoSans.txt SIL OFL-1.1
apps/web/public/fonts/SOURCE-NotoSansCyrillic.md provenance
apps/web/src/styles/base.css @font-face for Noto Sans + Cyrillic/Cyrillic-ext ranges
apps/web/src/styles/tokens.css --serif / --sans: Albert → Noto Sans → CJK → generic
apps/web/src/runtime/font-recovery.ts recover Noto faces; also on FontFaceSet loadingerror for late range-limited failures
tests stack order, assets, Russian alphabet range coverage, recovery incl. late loadingerror

Validation

Unit coverage confirms the stacked tokens keep Albert Sans first, the four vendored WOFF2 subsets declare Cyrillic unicode-ranges that cover all 66 basic Russian letters, and font-recovery registers Noto faces both on the timed startup sweeps and on a late loadingerror after those timers have finished (English → Russian / packaged od:// path). No Fontsource npm dependency is introduced.

pnpm --filter @open-design/web exec vitest run \
  tests/styles/cyrillic-font-fallback.test.ts \
  tests/styles/default-background.test.ts \
  tests/runtime/font-recovery.test.ts
# 14 passed (includes late loadingerror regression)

pnpm --filter @open-design/web exec tsc -p tsconfig.json --noEmit
# clean

Bug fix verification

  • Test path: apps/web/tests/styles/cyrillic-font-fallback.test.ts
    • four WOFF2 + OFL/SOURCE present; total size 197_672
    • CSS declares Noto with Cyrillic unicode-ranges; no Google Fonts CDN
    • Albert Sans precedes Noto Sans in both tokens
    • all 66 basic Russian letters covered by declared ranges
  • Packaged recovery: apps/web/tests/runtime/font-recovery.test.ts
    • recovers four Noto faces when already errored at startup
    • recovers four Noto faces when they error after the 0/4/15/45s sweeps (late loadingerror)
    • cancel removes the listener so cancelled installs do not recover
  • Did the test go red/green? late-loadingerror case fails without the listener; green after wiring

Risks / notes

  • Visual: Noto Sans vs Albert Sans for Cyrillic only — expected; Latin unchanged.
  • No npm font dependency — assets checked in like Albert Sans / JiduMono Pro.
  • Needs QA / design sign-off per needs-validation / needs-design-review labels (ru locale narrow + desktop).

Screenshots

N/A in this environment. Suggest verifying ru locale headings/body at narrow + desktop viewports, including switching locale after English cold start on packaged od://.

Albert Sans has no Russian glyphs, so ru UI fell through to OS fonts.
Vendor four unicode-range-limited Noto Sans variable WOFF2 subsets
(~198 KB, OFL-1.1) and place Noto Sans immediately after Albert Sans
in --serif/--sans so Latin stays Albert and Cyrillic is deterministic
offline.

Also recover the Noto faces on od:// via font-recovery and add
regression coverage for assets, token order, and Russian alphabet
unicode-range coverage.

Fixes nexu-io#6478
@lefarcen

lefarcen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Thanks @Sid-Qin — keeping Albert Sans first while adding a bundled Cyrillic fallback for #6478 looks like the right direction. I'm routing the PR now and will follow up once the required checks are in place.

💡 To drive this PR to merge hands-free, paste this to your AI coding agent (Claude Code / Codex / opencode / Cursor …):
Take over nexu-io/open-design#6498 until it merges — read https://raw.githubusercontent.com/nexu-io/looper/main/skills/pr-takeover/SKILL.md and follow it.

@lefarcen
lefarcen requested a review from nettee August 6, 2026 02:48
@lefarcen lefarcen added size/L PR changes 300-700 lines risk/medium Medium risk: regular code changes type/bugfix Bug fix labels Aug 6, 2026
@lefarcen lefarcen added needs-design-review Awaiting design review (external UI change); cleared by the Odcrew App applying design-approved needs-validation Runtime change detected; needs human or /explore agent validation. labels Aug 6, 2026
@lefarcen

lefarcen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🧪 This PR has changes that need a manual QA pass before merge — please hold off self-merging for now; we'll loop QA in once it's merge-ready (and design/product have signed off, where applicable).

@lefarcen lefarcen left a comment

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.

Hey @Sid-Qin, the overall PR write-up is already strong, especially the why and the surface-area callout. One small template nudge before pool review: could you add a short prose validation summary alongside the command block so the Validation section reads as complete to the template checker?

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Visual regression review

Head: 8d971e3 · Base: fef0ddf

7 changed · 39 unchanged · 0 new without baseline · 0 failed

Changed cases

Case Main PR Diff
visual-home
1,796 px (0.14%)
main pr diff
visual-home-plugin-use-staged
3,387 px (0.26%)
main pr diff
visual-home-staged-attachment
2,460 px (0.19%)
main pr diff
visual-topbar-byok-switcher
2,029 px (0.16%)
main pr diff
visual-topbar-byok-switcher-popover
12,218 px (28.97%)
main pr diff
visual-topbar-execution-switcher
1,111 px (0.09%)
main pr diff
visual-topbar-open-design-model-picker
7,289 px (0.56%)
main pr diff
Unchanged cases
Case Main PR Diff
visual-avatar-local-agent-list
142 px (0.01%)
main pr diff
visual-avatar-local-agent-list-panel
34 px (0.04%)
main pr diff
visual-avatar-menu
0 px (0.00%)
main pr diff
visual-avatar-menu-panel
0 px (0.00%)
main pr diff
visual-avatar-open-design-model-picker
109 px (0.01%)
main pr diff
visual-critical-settings
0 px (0.00%)
main pr diff
visual-critical-workspace
244 px (0.02%)
main pr diff
visual-critical-workspace-preview
0 px (0.00%)
main pr diff
visual-design-system-detail
0 px (0.00%)
main pr diff
visual-design-systems
0 px (0.00%)
main pr diff
visual-home-catalog
0 px (0.00%)
main pr diff
visual-home-context-picker
134 px (0.01%)
main pr diff
visual-home-context-picker-popover
0 px (0.00%)
main pr diff
visual-home-plugin-filter
0 px (0.00%)
main pr diff
visual-home-plugin-use-with-query
0 px (0.00%)
main pr diff
visual-integrations-use-everywhere
0 px (0.00%)
main pr diff
visual-new-project-modal
0 px (0.00%)
main pr diff
visual-plugin-details
0 px (0.00%)
main pr diff
visual-plugin-share-menu
0 px (0.00%)
main pr diff
visual-plugin-share-menu-popover
0 px (0.00%)
main pr diff

Visual diff is advisory only and does not block merging.

@nettee nettee left a comment

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.

@Sid-Qin, the vendored subsets, Unicode ranges, token ordering, focused tests, and production build all look good. I found one packaged-mode recovery gap in the new Noto wiring; the blocking detail is inline.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

},
// Cyrillic-only fallback faces (#6478). Same od:// recovery path as Albert.
{
family: 'Noto Sans',

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.

Blocking: recover Noto faces when they fail after startup

These new faces are only recovered when installFontRecovery sees their family in status === 'error', but that function samples doc.fonts only at 0/4/15/45 seconds. Because the Noto faces are Cyrillic-range-limited, an app that starts in English leaves them unloaded; switching to ru after the final sweep is the first request, and Chromium then changes the face to error. No sweep runs after that, so packaged od:// sessions still fall through to the OS CJK/generic fonts—the exact fallback this PR is meant to remove. I reproduced this in Electron: Albert stayed loaded, Noto stayed unloaded for Latin text, then became error when the body changed to Russian.

Subscribe to doc.fonts loadingerror and trigger sweep() (removing the listener in the returned cancel function), or invoke a recovery sweep from the locale-change path. Add a regression test that starts with an unloaded Noto face, advances past the scheduled timers, then triggers the later load error and asserts the four JS faces are registered.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in the follow-up commit.

installFontRecovery now subscribes to doc.fonts loadingerror (removed on cancel) so a late Cyrillic-range load failure after the timed sweeps still runs recovery. Added a regression test that drains the startup timers with Noto still unloaded, then flips the face to error and dispatches loadingerror, asserting all four JS faces are registered.

@lefarcen

lefarcen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Hey @Sid-Qin — nettee's packaged-mode recovery note is the main blocker on the current head, and it lines up with the new font-recovery.ts path this PR adds. Once that late loadingerror case is covered in the recovery flow and regression test, this should be back in good shape.

Cyrillic-range Noto faces often stay unloaded until the first Russian
glyph request. On packaged od:// that late load can error after the
0/4/15/45s recovery timers finished, so Russian text still fell through
to OS fonts.

Subscribe to FontFaceSet loadingerror to re-run recovery sweeps after
locale switches, remove the listener on cancel, and cover the late-error
path with a regression test.

Addresses review on nexu-io#6498 / nexu-io#6478.
@Sid-Qin

Sid-Qin commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the packaged-mode recovery gap from @nettee's review.

Fix

installFontRecovery now also listens for FontFaceSet loadingerror and re-runs the recovery sweep. That covers Cyrillic-range Noto faces that stay unloaded during an English cold start and only error when the first Russian glyphs are requested after the 0/4/15/45s timers have finished. Cancel removes the listener.

Test

recovers Noto faces on a late loadingerror after startup sweeps finished — unload → drain timers → mark error + dispatch loadingerror → asserts all four Noto faces are registered via the JS FontFace path.

Also expanded the PR Validation section with a short prose summary alongside the command block.

@nettee nettee left a comment

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.

@Sid-Qin

This head cleanly adds the bundled Cyrillic Noto Sans fallback while preserving Albert Sans for Latin text, including the late packaged loadingerror recovery path. I verified the focused regression suite (14/14), the full web suite (598 files, 6,226 tests with 11 skipped), web typecheck, production build, repository guard, and workspace typecheck all pass.

Thanks for the careful follow-up on the packaged-mode review feedback—the recovery listener and cancellation coverage make this a solid fix for the locale-switch case.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@lefarcen

lefarcen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@Sid-Qin — nettee's approval is in on the current head, good work on the packaged-mode recovery fix. Two CI checks failed on 52d196b though:

Worth investigating those before design + QA can complete their sign-off.

@Sid-Qin

Sid-Qin commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Re-triggered CI with an empty commit.

The prior red checks were:

  • UI P0 (workspace-restoration) — flaky e2e (app-restoration.test.ts file-workspace not visible after reload). Unrelated to the Cyrillic font stack / font-recovery path; other UI P0 shards + visual checks were green.
  • Validate workspace — rollup of the UI P0 failure above.

No product code change in this push; waiting for a clean re-run so design/QA can continue.

@lefarcen

Copy link
Copy Markdown
Contributor

Hey @Sid-Qin — the current blocker is the refresh requested above: rebase this onto the current main, then add fresh current-head screenshots plus the browser font-inspector / Rendered Fonts evidence from the updated UI. Once that updated proof is on the PR, design can re-review this head.

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

Labels

needs-design-review Awaiting design review (external UI change); cleared by the Odcrew App applying design-approved needs-validation Runtime change detected; needs human or /explore agent validation. risk/medium Medium risk: regular code changes size/L PR changes 300-700 lines type/bugfix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Bundled Albert Sans has no Cyrillic coverage, causing OS-dependent Russian fallback

3 participants