Skip to content

Commit d7e7d32

Browse files
RuitingMaclaude
andcommitted
Switch font-display to fallback + metric-matched fallback faces
Replace font-display: optional with fallback across dev and prod. Optional's 100ms race locks in system-font rendering for the whole page load on any cold cache — this is what hid the new Smoke-page characters on live for first-time visitors. To keep CLS at zero when the real webfont swaps in, emit a paired '<Family> Fallback' @font-face for each active Huiwen face. It wraps local('Songti SC'/'FangSong'/...) with ascent-override, descent- override, and line-gap-override read from the real TTF's OS/2 table, so the fallback occupies the same line box as the webfont. Cascade chains in tokens.css now place the Fallback face between the webfont and raw system fonts. - scripts/build-fonts.py: FALLBACK_SYSTEM_FONTS map, _metric_overrides reads OS/2 sTypoAscender/sTypoDescender/sTypoLineGap, emits CSS percentages; _fallback_face_css builds the proxy @font-face; both dev and prod pipelines include the Fallback faces. - src/styles/tokens.css: insert 'Huiwen Mincho Fallback' and 'Huiwen Fangsong Fallback' into the two fallback chains. - public/fonts-dev.css + public/fonts/dev/*.woff2: regenerated output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f44954b commit d7e7d32

5 files changed

Lines changed: 75 additions & 8 deletions

File tree

public/fonts-dev.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,19 @@
1010
@font-face {
1111
font-family: 'Huiwen Mincho';
1212
src: url('/fonts/dev/mincho.woff2') format('woff2');
13-
font-display: optional;
13+
font-display: fallback;
1414
font-weight: 400;
1515
font-style: normal;
1616
}
1717

18+
@font-face{font-family:'Huiwen Mincho Fallback';src:local('Songti SC'),local('STSong'),local('SimSun');ascent-override:83.10%;descent-override:22.10%;line-gap-override:0.00%}
19+
1820
@font-face {
1921
font-family: 'Huiwen Fangsong';
2022
src: url('/fonts/dev/fangsong.woff2') format('woff2');
21-
font-display: optional;
23+
font-display: fallback;
2224
font-weight: 400;
2325
font-style: normal;
2426
}
27+
28+
@font-face{font-family:'Huiwen Fangsong Fallback';src:local('FangSong'),local('STFangsong');ascent-override:83.10%;descent-override:22.10%;line-gap-override:0.00%}

public/fonts/dev/fangsong.woff2

-60 Bytes
Binary file not shown.

public/fonts/dev/mincho.woff2

-56 Bytes
Binary file not shown.

scripts/build-fonts.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@
6464
("huiwen-hkhei.ttf", "Huiwen HKHei", "hkhei"),
6565
]
6666

67+
# System fonts to wrap as the "<Family> Fallback" face — their metrics get
68+
# re-declared via ascent/descent/line-gap overrides so fallback and
69+
# webfont occupy the same line box. Result: when `font-display: fallback`
70+
# swaps in the webfont, the line heights don't jump (CLS ≈ 0). Order
71+
# matters only for first-match-wins `src: local()` resolution.
72+
FALLBACK_SYSTEM_FONTS = {
73+
"mincho": ["Songti SC", "STSong", "SimSun"],
74+
"fangsong": ["FangSong", "STFangsong"],
75+
"kaiti": ["Kaiti SC", "STKaiti", "KaiTi"],
76+
"hkhei": ["PingFang SC", "Heiti SC", "STHeiti", "Microsoft YaHei"],
77+
}
78+
6779

6880
def _active_faces():
6981
"""Return only the ALL_FACES entries whose family is actually
@@ -115,6 +127,41 @@ def _kb(n: int) -> str:
115127
return f"{n / 1024:>7.1f} KB"
116128

117129

130+
def _metric_overrides(face_bytes: bytes):
131+
"""Read typographic metrics from a TTF and express them as CSS
132+
percentage overrides. CSS ascent/descent/line-gap-override take
133+
non-negative percentages of the em box; OS/2 sTypoDescender is
134+
signed (negative), so we flip its sign."""
135+
f = _fresh_font(face_bytes)
136+
upem = f["head"].unitsPerEm
137+
os2 = f["OS/2"]
138+
asc = f"{os2.sTypoAscender / upem * 100:.2f}"
139+
desc = f"{-os2.sTypoDescender / upem * 100:.2f}"
140+
gap = f"{os2.sTypoLineGap / upem * 100:.2f}"
141+
return asc, desc, gap
142+
143+
144+
def _fallback_face_css(slug: str, family: str, face_bytes: bytes) -> str:
145+
"""Emit an @font-face that wraps system fonts but reports the real
146+
webfont's metrics. Named '<Family> Fallback' — referenced in
147+
tokens.css between the webfont and raw system fonts, so the
148+
cascade resolves to this face until the webfont arrives. When the
149+
webfont loads, line heights don't change because this face already
150+
declared matching metrics."""
151+
locals_ = FALLBACK_SYSTEM_FONTS.get(slug)
152+
if not locals_:
153+
return ""
154+
asc, desc, gap = _metric_overrides(face_bytes)
155+
src = ",".join(f"local('{name}')" for name in locals_)
156+
return (
157+
f"@font-face{{font-family:'{family} Fallback';"
158+
f"src:{src};"
159+
f"ascent-override:{asc}%;"
160+
f"descent-override:{desc}%;"
161+
f"line-gap-override:{gap}%}}"
162+
)
163+
164+
118165
# ---------------------------------------------------------------------------
119166
# `dev` subcommand
120167
# ---------------------------------------------------------------------------
@@ -142,11 +189,14 @@ def cmd_dev():
142189
f"@font-face {{\n"
143190
f" font-family: '{family}';\n"
144191
f" src: url('/fonts/dev/{slug}.woff2') format('woff2');\n"
145-
f" font-display: optional;\n"
192+
f" font-display: fallback;\n"
146193
f" font-weight: 400;\n"
147194
f" font-style: normal;\n"
148195
f"}}"
149196
)
197+
fb = _fallback_face_css(slug, family, data)
198+
if fb:
199+
css_blocks.append(fb)
150200

151201
# Regenerate public/fonts-dev.css so it matches the active face set.
152202
dev_css = ASTRO_ROOT / "public" / "fonts-dev.css"
@@ -251,7 +301,7 @@ def _face_css(family: str, url: str) -> str:
251301
return (
252302
f"@font-face{{font-family:'{family}';"
253303
f"src:url('{url}') format('woff2');"
254-
f"font-display:optional;"
304+
f"font-display:fallback;"
255305
f"font-weight:400;font-style:normal}}"
256306
)
257307

@@ -307,10 +357,16 @@ def cmd_pages(dist_dir_str: str):
307357
if fonts_out.exists():
308358
shutil.rmtree(fonts_out)
309359

310-
# 1. Build common subsets, one per face.
360+
# 1. Build common subsets, one per face. Also emit one fallback
361+
# @font-face per face — it has no src file (wraps local system fonts
362+
# with metric overrides) but logically belongs in the common pool
363+
# since every page references it identically via tokens.css.
311364
common_css = []
312365
common_urls = []
313366
for slug, family in face_families.items():
367+
fb = _fallback_face_css(slug, family, face_bytes[slug])
368+
if fb:
369+
common_css.append(fb)
314370
cps = {ord(c) for c in common} & face_cmaps[slug]
315371
if not cps:
316372
continue

src/styles/tokens.css

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,18 @@
3636
/* Default accent: no visible effect until a tint-* class overrides. */
3737
--accent: var(--fg);
3838

39-
/* === Fonts === */
39+
/* === Fonts ===
40+
* Each Huiwen face has a sibling '<Family> Fallback' declared by
41+
* build-fonts.py — that face wraps local system fonts and re-declares
42+
* the webfont's ascent/descent/line-gap, so the fallback occupies the
43+
* same line box. When the real webfont arrives and `font-display:
44+
* fallback` swaps it in, line heights don't jump. The raw system-font
45+
* names at the tail cover machines missing both the webfont and the
46+
* declared local() fallbacks. */
4047
/* display: site name & all headings — 明朝 */
41-
--font-display: 'Huiwen Mincho', 'Source Han Serif SC', 'Songti SC', 'SimSun', serif;
48+
--font-display: 'Huiwen Mincho', 'Huiwen Mincho Fallback', 'Source Han Serif SC', 'Songti SC', 'SimSun', serif;
4249
/* body: long-form reading & quiet prose — 仿宋 */
43-
--font-body: 'Huiwen Fangsong', 'FangSong', 'STFangsong', serif;
50+
--font-body: 'Huiwen Fangsong', 'Huiwen Fangsong Fallback', 'FangSong', 'STFangsong', serif;
4451
/* mono: dates, code, metadata — system monospace */
4552
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Cascadia Code', monospace;
4653
}

0 commit comments

Comments
 (0)