feat(ui): headings use self-hosted Jyotisha Serif SC, body stays sans (T2-T5)
- --font-display leads with "Jyotisha Serif SC", then exactly the body sans stack; no system 宋体 or generic serif (BUG-737 guards kept). - serif-sc.css imported from site-styles.ts beside globals.css (root layout also serves admin); swap, no preload. - Long-form report title (.personal-report-md-article h1) takes the heading face; deliberate sans headings pinned by a new contract test. - font-stack-loadable-contract: loadable now includes self-hosted @font-face with existing files; "no CJK serif" replaced by "leads with Jyotisha Serif SC, never a system 宋体" (brief T3). - DESIGN §3, BUG-737 note, CHANGELOG, device checklist, PROGRESS. TASK-serif-headings-20260928 T2-T5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
308f5502cb
commit
6c421219a1
+11
-5
@@ -227,12 +227,18 @@ is read through an external store so a change in one tab reaches the others.
|
||||
|
||||
### Font stacks
|
||||
|
||||
- Display: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif` — the same stack as body. Display rank comes from **weight and tracking, not family**: display and title rules run at weight 500 against body's 400.
|
||||
The old value led with `"Tiempos Headline", "Songti SC", STSong, …, serif`. Tiempos Headline is an Anthropic licensed face this app has never loaded (no `@font-face`, nothing in `public/`, `layout.tsx` vendors only Inter), so in production every CJK heading fell through to **Songti SC on Apple and SimSun on Windows** — 20 rules wide, including the `h2`/`h3` inside assistant answers. See BUG-737.
|
||||
A Latin-serif-first stack (`"Newsreader", "PingFang SC", …`) was measured and rejected: the Newsreader latin variable subset is 132 KB — 2.7× the entire Inter file — to serif one wordmark, and it splits a mixed heading such as “D10 事业盘怎么读” into two scripts, which reads as a font-loading failure. **CJK never takes a serif here.**
|
||||
- Display (headings): `"Jyotisha Serif SC", var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif` — the body stack with the self-hosted heading serif in front (2026-09-28, TASK-serif-headings-20260928). **CJK 标题用自托管 Jyotisha Serif SC;绝不落到系统宋体。**
|
||||
- The face is Noto Serif SC SemiBold (SIL OFL 1.1), renamed per the OFL Reserved Font Name clause and cut into 30 `unicode-range` woff2 slices under `src/app/fonts/serif-sc/` by `scripts/fonts/build_serif_slices.py`. Coverage: 《通用规范汉字表》 level 1 + 2 (6500 characters), Basic Latin, CJK / full-width punctuation, digits and common symbols. A character outside that set renders in the sans stack.
|
||||
- Slices are bundled by Next (`/_next/static/media/`, content-hashed, immutable), not served from `public/`, and never fetched from Google Fonts. The browser downloads only the slices whose characters appear in a heading on the page: measured 220–226 KB on `/login` and the signed-out home, ≈236 KB on a signed-in home greeting, ≈490 KB for a full long-form report (PROGRESS-serif-headings-20260928).
|
||||
- One weight ships. The `@font-face` rules declare `font-weight: 500 700`, so the existing display rules at 500 and 600 hit SemiBold without per-rule changes; do not add a second weight file.
|
||||
- `font-display: swap`, no preload: a heading paints in the sans stack first and swaps when its slice arrives. No waiting state, no hidden text.
|
||||
- Mixed headings stay in one face: the slice set carries Latin, so “D10 事业盘怎么读” is serif end to end (the reason Newsreader was rejected in BUG-737 does not apply).
|
||||
- The stack may name only loadable families (next/font/local, the serif-sc `@font-face` list, or the system-face whitelist) and must not contain `Songti SC`, `STSong`, `SimSun`, `Noto Serif CJK SC` or end in generic `serif` — pinned by `font-stack-loadable-contract.test.ts`. BUG-737 is why: an unloaded serif head sent every CJK heading to SimSun on Windows.
|
||||
- The `@font-face` list lives in its own file (`serif-sc.css`, ≈22 KB gzip of `unicode-range` data) imported by `src/app/site-styles.ts` next to `globals.css`, so admin never loads it.
|
||||
- Deliberate sans headings (pinned by `serif-headings-contract.test.ts`): the rectification narrative's `h2`/`h3` (`.conversational-narrative .message-markdown h2, h3`, body face 600), report chart-card titles (`.personal-report-chart-card h3`), all admin UI, and the root boundary pages below. Chat body, buttons, tables, inputs and labels are body text and stay sans.
|
||||
- Body/UI: `var(--font-inter, Inter), -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`. Inter is loaded with `next/font/local` from `src/app/fonts/InterVariable-latin.woff2` (`display: "swap"`, CSS variable `--font-inter`) so Windows/Linux no longer silently fall back past a never-requested Inter, and image builds do not call fonts.googleapis.com. `StyreneB` was removed from the head of this stack for the same reason as Tiempos: it never loaded, so it was dead configuration that made the stack look intentional.
|
||||
- Code/data: `"JetBrains Mono", "SFMono-Regular", Consolas, monospace`, exposed as `--font-mono`.
|
||||
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand.
|
||||
- Root boundary pages (`error.tsx`, `not-found.tsx`, `forbidden.tsx`, `global-error.tsx`) sit in the shared root layout segment and must not import `globals.css` — importing it would drag the chat stylesheet onto every admin route. They therefore cannot read `--font-mono` or any token, and inline their own values: a system stack for body text and `ui-monospace, SFMono-Regular, Menlo, monospace` for code. Keep those literals in step with the stacks above by hand. They do not load the heading serif either: their titles stay in the inline sans stack by design (TASK-serif-headings-20260928), not by omission.
|
||||
|
||||
### Scale
|
||||
|
||||
@@ -249,7 +255,7 @@ is read through an external store so a change in one tab reaches the others.
|
||||
| `--type-caption` | `13px` | 500 | 1.4 | 0 | Labels and metadata |
|
||||
| `--type-overline` | `12px` | 500 | 1.4 | `1.5px` | Eyebrows and badges |
|
||||
|
||||
Display headings use the serif stack at weight 400. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
|
||||
Display headings use `--font-display` (self-hosted Jyotisha Serif SC, sans fallback) at weight 500; the `--type-display-*` rows above list 400 from the earlier serif era, but every display rule now carries 500 or 600 (BUG-737), which the SemiBold face covers. Body copy never drops below 14px; 12–13px is reserved for short labels and metadata. No product UI text is smaller than `--type-overline` (12px). Product UI uses three font weights: 400 (display and body), 500 (UI titles, labels, buttons), and 600 (emphasis only). CJK text uses `text-wrap: pretty`; display text uses `text-wrap: balance`.
|
||||
|
||||
One documented exception: the thinking text inside a timeline step (`.consultation-run-timeline__thinking`) and the fallback thinking trace (`.message-thinking-body`) render at 13px. They are working notes shown on request inside a collapsed row, not reading copy; the answer itself never inherits that size.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user