arabic.css:95 · Zain 166 · Cairo 291
Why is my Arabic font rendering my English text?
Because the font file is not what its name suggests. Open any Arabic webfont with fontTools and count the codepoints it declares between U+0041 and U+024F: Zain, the face this site ships now, has 166; Cairo, the face it replaced, has 291; Noto Kufi Arabic has 248. All three carry the complete 95-codepoint printable ASCII set on top of that. An Arabic font containing no Latin at all is the exception rather than the rule.
Font matching then does the rest, and it does it per character rather than per run. For each codepoint the browser walks the font-family list and stops at the first family holding a glyph for it — there is no step anywhere in the algorithm that asks what script the character belongs to. Put an Arabic family first in a blanket rule and it wins the Latin too, silently, because the result is legible and only slightly wrong.
This repository said otherwise for years. The header of src/styles/locale/arabic.css gave the reason for putting Cairo first in every stack: the files ‘carry only Arabic glyphs — Latin falls through to Quicksand and the site’s own identity holds for names and code.’ It was measured false. Every project name, every HTML and CSS chip and the string Google AI Essentials on the Arabic pages had been rendering in Cairo, 381 Latin runs of them, for the life of the build.
| Font | Printable ASCII, U+0020 to U+007E | Arabic, U+0600 to U+06FF | Latin, U+0041 to U+024F |
|---|---|---|---|
| Zain Regular, Bold, ExtraBold | 95 | 109 | 166 |
| Cairo Regular, SemiBold | 95 | 102 | 291 |
| Noto Kufi Arabic Bold | 95 | 256 | 248 |
| Quicksand Regular | 95 | 0 | 357 |
The last row of the table is the one that matters twice. Quicksand carries zero Arabic codepoints, which is why an Arabic run whose stack names no Arabic family has nowhere to go — that is the second half of this article, and the same site shipped it at the same time, in the opposite direction.
CSS Fonts 4 §4.5 · initial U+0-10FFFF
unicode-range is not the file, not the family, and not the document
The descriptor answers exactly one question — may this face be used for this codepoint — and CSS Fonts 4 gives it two jobs in a single paragraph. The union of the declared ranges is ‘a hint for user agents when deciding whether or not to download a font resource for a given text run’, and then, much harder: ‘User agents must not download or use the font for codepoints outside this set.’ The initial value is U+0-10FFFF, so a face with no range declared is allowed to answer for the whole of Unicode, which is what every face on this site did until August.
It is not the file’s contents, and the spec goes out of its way to say so: ‘the effective character map is the intersection of the codepoints defined by unicode-range with the font’s character map.’ The four Zain faces here declare 1,264 codepoints and cover 252 of them, 19.9 per cent. Three of the declared blocks are completely empty in the file, and so is U+200F, the right-to-left mark. Nothing breaks, because intersection is the rule and the spec blesses declaring broad ranges without checking the font.
It is not subsetting either, and that confusion costs more than a vocabulary point. Subsetting is a build step that rewrites the font, and on this project it was measured to destroy the script: Arabic dots are separate glyphs composed at render time, and a subsetter that drops what it judges unreachable drops them. The decision was taken for Cairo and carries to Zain unchanged. These files are full fonts on purpose, with a restricted range in front of them.
| Declared range | Codepoints declared | Codepoints covered |
|---|---|---|
| U+0600-06FF, Arabic | 256 | 109 |
| U+0750-077F, Arabic Supplement | 48 | 0 |
| U+0870-088E, Arabic Extended-B | 31 | 0 |
| U+08A0-08FF, Arabic Extended-A | 96 | 0 |
| U+200F, right-to-left mark | 1 | 0 |
| U+FB50-FDFF, Presentation Forms-A | 688 | 54 |
| U+FE70-FEFF, Presentation Forms-B | 144 | 89 |
| All seven, as declared on every face | 1,264 | 252 |
Two codepoints run the other way — present in the file, absent from the range. U+0020 and U+0030 are both in Zain’s character map and neither falls inside the declared union, so the browser is forbidden from using Zain for a space or a zero. That is deliberate, it is why the gap between two Arabic words resolves to Quicksand, and it has a consequence in the last section that nobody here has measured yet.
nav-lang.css · 5 faces · 70 routes
Why the same technique failed on the site this one replaced
The predecessor to this site tried unicode-range first and concluded it did not work. It hung Arabic-range faces under the site’s own family names — Cairo under Quicksand, Cairo again under Quicksand Label, Noto Kufi Arabic under Quicksand Display — precisely to avoid a cascade fight with a 183 KB shared stylesheet. Five faces, on all 70 routes. Its own comment records the outcome: ‘Chromium kept resolving Arabic runs through the full-range Quicksand faces and falling to the system naskh.’
The measurement was right and the conclusion drawn from it was wrong. unicode-range is consulted after a face has already been chosen by style, not before, and faces collapse into one composite face for that step only when their other descriptors are identical: ‘A group of faces defined via @font-face rules with identical font descriptor values but differing unicode-range values are considered to be a single composite font face for this step.’ These were not identical. The site’s Latin faces declared weight ranges of 200 300, 400 and 500 900; the Arabic ones 100 500 and 501 900.
So weight-narrowing left more than one face standing, and the spec then hands the decision away. The user agent ‘must choose a single font from the matching set’, and ‘the choice of which font to choose can differ between multiple user agents and multiple operating system platforms’. When it chose a Latin face the Arabic glyph was not there, and the next sentence shuts the door: ‘Glyphs from other faces in the family are not considered.’ The family is abandoned whole, the next family name is tried, and at the end of the list the browser runs installed-font fallback, whose result ‘can vary across user agents’. That is the system naskh.
/* the Arabic face, hung under the site’s own family name */
@font-face {
font-family: 'Quicksand';
src: url('Cairo-SemiBold.ar.v2.woff2') format('woff2');
font-weight: 501 900;
unicode-range: U+0600-06FF, U+0750-077F, U+08A0-08FF,
U+FB50-FDFF, U+FE70-FEFF, U+200C-200F;
}
/* and, elsewhere in the same 183 KB sheet, the real Quicksand */
@font-face {
font-family: 'Quicksand';
src: url('Quicksand-SemiBold.woff2') format('woff2');
font-weight: 500 900;
/* no unicode-range: the initial value is all of Unicode */
}
/* the descriptors differ, so these are two faces, not one */
/* composite face. Style narrowing runs first and may keep both. */
The same repository ships the counter-example, and it is worth putting beside the failure. Ten demo font directories carry Google Fonts’ own latin and latin-ext split, generated by tools/localize-demo-fonts.mjs: 109 faces kept, 100 other-script faces omitted. Those faces do have identical descriptors apart from the range, so they form one composite face and the outcome is deterministic in every engine. Google splits one design across ranges. The Golden Master hung a different design under an existing name at a different weight, which is the one arrangement the composite rule does not cover.
legacy 11-route probe · comment said 2.5 KB
One Arabic word cost 50,096 bytes on every English page
The second bill from that arrangement was measured with a fetch probe across eleven routes of the old site. Cairo-SemiBold.ar.v2.woff2, 50,096 bytes, was never preloaded anywhere and was fetched on all eleven routes — including every English and German one. It arrived with initiatorType css between 610 and 640 ms on five ordinary routes, at 1,118.4 ms on the 404 page and at 1,464.2 ms on the homepage.
The trigger was one string: the language switcher’s Arabic label, four characters inside a nav menu nobody had opened. The comment sitting beside the code claimed that English and German pages download about 2.5 KB once for it. The architecture record states the discrepancy plainly — off by roughly twenty times — and adds the part that makes it a defect rather than a curiosity: anyone optimising against the comment rather than the measurement will not find the 50 KB.
| File | Preloaded on | Fetched on | Bytes |
|---|---|---|---|
| Quicksand-Regular.woff2 | en, de, ar | 11 of 11 | 30,844 |
| Quicksand-Medium.woff2 | en, de | 10 of 11 | 30,784 |
| Quicksand-SemiBold.woff2 | en, de | 11 of 11 | 31,084 |
| Quicksand-Light.woff2 | never | 0 of 11 | 29,000 |
| Cairo-Regular.ar.v2.woff2 | ar | 1 of 11 | 49,084 |
| Cairo-SemiBold.ar.v2.woff2 | never | 11 of 11 | 50,096 |
| NotoKufiArabic-Bold.ar.v2.woff2 | ar | 1 of 11 | 60,720 |
| All seven files | — | — | 281,612 |
Whether a hidden string costs anything is a question about boxes, not about visibility. The old menu panel’s base rule in the shipped stylesheet carried the open clip-path; the closed state, display none and a flattened ellipse, was written as an inline style by the runtime at boot. The panel was therefore laid out at first paint and hidden afterwards, and a laid-out text run is a text run the download rule applies to.
This build declares the closed state statically — the panel carries display none in the shipped sheet, described in its own comment as the settled state and the emitted state. That should mean no boxes and no fetch until the menu opens. It has not been measured on this build, and the previous build measured the equivalent claim false, so treat it as a mechanism with a check attached rather than as a result. Open the Network panel, filter to Font, load an English page, then open the menu and watch.
arabic.css:104-146 · 4 faces, one range
The range as shipped, and the hole it opened the same day
The rule went in with the typeface swap, and the reason was drift. Zain’s Latin is a different design from Cairo’s: cap height 69.5 per cent of the em against Cairo’s 69.1, but x-height 46.0 against 50.0 — 8 per cent smaller at essentially the same cap. An undetected capture would have got visibly worse under the new face, so the change that introduced Zain also introduced the declaration that stops Zain answering for Latin at all.
Four faces, two synthetic families, one identical range on all of them, split at weight 650 so the existing 600 and 700 promotions select the right cut without a single rule being edited. The payload is byte-neutral to within a kilobyte: 161,096 bytes of Zain against the 159,900 the two families it replaced cost, a difference of 1,196 bytes.
@font-face {
font-family: 'Zain AB';
src: url('../assets/fonts/zain/regular.woff2') format('woff2');
font-weight: 100 500;
font-display: swap;
size-adjust: 112%;
unicode-range:
U+0600-06FF, U+0750-077F, U+0870-088E, U+08A0-08FF,
U+200F, U+FB50-FDFF, U+FE70-FEFF;
}
html[lang='ar'] body,
html[lang='ar'] body * {
font-family: 'Zain AB', Quicksand, Arial, sans-serif;
}
/* Zain LAST here, on purpose: figures and identifiers keep the mono */
html[lang='ar'] :is(code, pre, .cdx-code) {
font-family: ui-monospace, menlo, 'Zain AB', 'Courier New', monospace;
}
Adding a range immediately creates a class of codepoints the family may no longer paint, and the first of them is the space between two Arabic words. Every stack naming Zain now needs a family behind it covering everything the range excludes — and one of them did not have one. The display stack omitted Quicksand, so the space inside a two-word Arabic heading, the ampersand in another and any digit landed on Arial: two Arial glyphs inside the largest type on the page, found with CSS.getPlatformFontsForNode.
The measurement proving the range worked is the same measurement that found the hole it opened. After the fix, every Latin glyph on the Arabic pages resolves to Quicksand or Menlo and none of them to Zain.
The two size-adjust values in that listing, 112 per cent for prose and 103 per cent for display, come out of a separate measurement — the tooth-and-alef ratio.
P11-03 · found and fixed 2026-08-26
The opposite failure: an Arabic word with no Arabic face
The language switcher prints all three language names in their own languages on all three documents, and that is the point of it: a reader who cannot read the current page still recognises their own. The markup was already telling the truth. The Arabic anchor carries hreflang, lang and dir, and the string is declared once and listed as shared in all three content bundles, so no locale translates it.
What it did not have was a face. Every Arabic rule in the stylesheet was scoped on the document element, which is a fact about the page rather than about the run of text. On the English and German documents the label inherited the Latin label token instead — Quicksand Label, then Arial, then sans-serif — and Quicksand contains exactly zero codepoints in U+0600 to U+06FF. The browser exhausted the stack and ran installed-font fallback.
Measured on macOS in both WebKit and Chromium: the label rendered in the platform’s naskh beside four Quicksand labels, at a different weight, a different colour density and a different optical size. On a device with no Arabic system face it renders as tofu. Nothing about the run was wrong except the CSS, and the CSS had asked a question about the document when the question was about four characters.
- العربية
- The string itself, declared once and listed as shared in the English, German and Arabic bundles, so all three documents print it in Arabic rather than translating it. Correct from the start.
- the markup
- src/components/site-nav.html sets hreflang, lang and dir on the anchor, and asks the splitter for lines rather than words and characters. Correct from the start.
- the stylesheet
- Every rule that could have supplied an Arabic face was scoped on the document element. On the two Latin documents not one of them matched, and the run fell out of the stack entirely.
It survived a purpose-built RTL gate, a trilingual content register and a build that enforces parity across three locales, because none of those asks the English page a question about Arabic. It was found by reading the file’s own header and disbelieving it.
The switcher’s Arabic anchor leans on the user-agent stylesheet for its bidi handling on the Latin pages, which is the shape of gap that logical properties do not close.
utilities beats components, no !important
Scope on the language, not the document
The fix is two rules, and the second half of the first one is load-bearing rather than belt and braces. The family goes on the language attribute and on its descendants, because components/button.css sets font-family from a token on the span inside the anchor — and inheritance never beats a declaration on the element itself. A rule on the anchor alone would lose on the only element that holds the ink.
It wins without !important because of layer order. src/styles/order.json declares reset, tokens, base, layout, components, sections, utilities, overrides; locale/arabic.css sits in utilities and button.css in components, two layers earlier, and a later layer beats any specificity an earlier one can write. The Arabic blanket beside it does carry one !important, for letter-spacing, and it is the only justified blanket of its kind in the project. This rule needs none.
- Name the language, not the page Replace the document-scoped selector with one on the language attribute for anything that must apply to an Arabic run wherever it appears. The document scope stays for everything genuinely about the document, such as direction.
- Reach the element holding the ink Add the descendant half of the selector. A child declaring its own font-family will not inherit yours, so the rule has to match that child too.
- Let the layer settle it Put the rule in a layer after the one that declared the token. No specificity fight, no !important, and the same mechanism keeps a section sheet from overriding a locale correction.
- Zero the tracking The body sets letter-spacing to minus 0.005em and the label span declares none of its own, so the Arabic word inherited it on the Latin pages. Arabic never letter-spaces: tracking breaks the joined script, the letters stop touching and the word stops being a word.
- Open the line box and the clip window together The label is set at line-height 1 by the button component and clipped at 94 per cent of its box. Zain’s content box at size-adjust 112 per cent is 1.859em, so at line-height 1 the half-leading is negative and the ink runs past the cut. The rule sets 1.55 and moves the window to 114 per cent.
- Switch to the language pseudo-class for the metrics The metrics rule uses :lang(ar) rather than the attribute selector. Content language is inherited by descendants and matched by extended filtering, so it also covers ar-EG and ar-Arab-EG, which an exact attribute match does not.
The clip arithmetic reproduces exactly, which is how the numbers in that rule were chosen rather than guessed. Zain’s hhea gives ascent 869 and descent minus 459 over 800 units per em; at size-adjust 112 per cent that is 1.2166em and 0.6426em, a content box of 1.8592em. At line-height 1 the half-leading is minus 0.4296em and the baseline sits 0.7870em below the top of the line box, which puts the deepest ink of the label at 1.2505em against a cut at 0.9400em. At 1.55 with the window at 114 per cent the ink lands at 1.5716em, the window at 1.7670em and the hover twin at 1.8454em, in that order. Glyph depths are measured on isolated forms.
The file states the invariant as twin below window, window below ink, and it records what happens when it breaks. On the old site’s footer link the ink ran five pixels past the cut, a final yeh lost its dots and the word rendered as a different word — a misspelling of the owner’s own writing. It was fixed on the Arabic pages and had been live on the English and German ones ever since, on the single Arabic word those documents contain.
The 1.55 in that rule is a clip-window number rather than a collision number, and the difference between the two is the measured floor for Arabic line-height.
split-registry.js:121-190 · read once
The same mistake, one layer down, in the JavaScript
The split registry reads the document’s language exactly once, at boot, and its comment gives a decent reason: lang and dir are written by the build onto the document element and never change afterwards, so asking per element would be 55 readings of one fact and 55 chances to read it differently. It then uses that single reading to decide whether a run may be split into characters.
On the Arabic pages that is right. On the English and German ones it is the stylesheet’s error in another language: the document is en or de, so the Arabic label would be split per character. Character splitting is not cosmetic in a joined script. The reveal component makes each character an inline-block, which it must, because transform does not apply to a non-replaced inline box — and an atomic box per letter is a box the shaper cannot join across. The word comes out as isolated forms, which is to say spelled wrong.
The markup avoids it by hand. That one span asks the splitter for lines while the English and German labels ask for words and characters, and the module’s own comment concedes the point: the component file had already reached this conclusion by hand for the one label somebody noticed.
| Failure | Where | What the browser did | What it cost |
|---|---|---|---|
| Too much capture | the Arabic pages, Cairo first with no range | answered every Latin codepoint out of the Arabic file | 381 Latin runs in the wrong typeface |
| Too much capture, on download | the old English and German pages | kept two faces standing and picked one arbitrarily | 50,096 B on 11 of 11 routes |
| Too little capture | the English and German pages, every rule on the document | exhausted the stack and ran installed-font fallback | 1 word in the system naskh |
| Too little capture, in script | the English and German pages, the split registry | would split a joined script into atomic inline boxes | 1 word spelled wrong on screen |
The automated gate cannot see three of the four rows below. tools/golden/rtl.mjs walks every text node and asserts that every Arabic run asks for Zain and every Latin run keeps a Latin fallback — on Arabic routes only. It loads the English homepage as a control for the splitter, the header, the diptych and the rail, and never puts the font question to it. A grep across tests and contract checks still returns nothing for the new language-scoped rule. A purpose-built RTL gate ran green while the defect lived on the two most-visited documents in the product.
Why an inline-block per letter breaks an Arabic word, and what a splitter has to do instead, is the joined-script splitting problem.
glyphCount, not fontFamily
How do you prove which face actually won?
Computed style will not tell you. It reports the stack you authored, and this repository’s own gate says so in a comment on the branch where it gives up: a Latin-only run inside an Arabic document may legitimately ask for the Arabic family first, unicode-range is what routes it onward to Quicksand, and a computed style cannot see that. What the gate can assert is the weaker, still useful property — that no run asks for a family with no Latin fallback behind it.
CSS.getPlatformFontsForNode over the Chrome DevTools Protocol is what settles it. It returns, per node, the platform families that actually painted the text, whether each was downloaded or resolved locally, and how many glyphs each contributed. That is what found the two Arial glyphs inside a display heading, and what confirmed afterwards that no Latin glyph on the Arabic pages is Zain. By hand it is the Rendered Fonts block at the bottom of the Computed pane.
- CSS.getPlatformFontsForNode
- Returns familyName, isCustomFont and glyphCount for every platform font used to render a node’s text. The only one of the four that answers which face won.
- Rendered Fonts, Computed pane
- The same data by hand, at the bottom of the pane. Several families listed against one element means fallback happened inside it.
- the Network panel, filtered to Font
- A different question: whether the file was fetched at all. A file that was fetched may still have lost every codepoint to a family in front of it.
- document.fonts.check()
- A trap. MDN says it is not designed to verify whether a specific font style can be rendered, and its text argument defaults to a single space — against a face whose range excludes U+0020 the answer is vacuous. Pass the real string.
One consequence of this range has not been measured here, and it is worth a check on any site using ch for measure. The ch unit is the advance of U+0030 in the font used to render it, and U+0030 is outside the Zain declaration, so the browser must not use Zain for it and Quicksand supplies it instead. Going from Cairo to Quicksand moved 1ch by 0.09 per cent. Had Zain been left full-range, 1ch would have grown by 25.4 per cent, across more than twenty ch-based measures in this stylesheet.
The same clause has a quieter effect. The first available font — what ex, cap and lh resolve against — is defined as the first font for which U+0020 is not excluded by a unicode-range, and U+0020 is outside the Zain range too, so Zain can never be the first available font on any document here. The advances above are measured; the rendered column widths on the Arabic pages before and after are not. Compare them yourself before assuming it either way.
The trilingual build every one of these numbers was read out of is one of the ten concept sites.