diff --git a/app/globals.css b/app/globals.css index 3ed4e0f32..dfb768ca0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,6 +3,23 @@ @import "./theme-inline.css"; +/* The app's font stack replaces Tailwind's default --font-sans, so the + font-sans utility and body use the same stack, and a theme's + typography.fontSans (compiled to --font-sans on :root) restyles both. */ +@theme { + --font-sans: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans Thai", + "Leelawadee UI", + Tahoma, + sans-serif; +} + :root { font-size: var(--font-size-base); @@ -142,17 +159,7 @@ body { background-color: var(--color-background); color: var(--color-foreground); - font-family: - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - "Helvetica Neue", - Arial, - "Noto Sans Thai", - "Leelawadee UI", - Tahoma, - sans-serif; + font-family: var(--font-sans); font-feature-settings: "rlig" 1, "calt" 1; diff --git a/lib/__tests__/font-sans.test.ts b/lib/__tests__/font-sans.test.ts new file mode 100644 index 000000000..6a9ddfe63 --- /dev/null +++ b/lib/__tests__/font-sans.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync } from 'fs'; +import os from 'os'; +import path from 'path'; +import type { AtRule, Root } from 'postcss'; + +/** + * A theme's typography.fontSans compiles to --font-sans on :root (#1278). + * Body has to read that variable, and the variable's default has to be the + * app's own stack, not Tailwind's, so nothing changes without a theme. + * Build globals.css with Tailwind to see the default it actually emits. + */ +let root: Root; + +beforeAll(async () => { + const postcss = (await import('postcss')).default; + const tailwind = (await import('@tailwindcss/postcss')).default; + const file = path.join(process.cwd(), 'app', 'globals.css'); + // Scan an empty folder: only the stylesheet's own rules matter here. + const base = mkdtempSync(path.join(os.tmpdir(), 'font-sans-')); + try { + root = (await postcss([tailwind({ base })]).process(readFileSync(file, 'utf8'), { from: file })).root; + } finally { + rmSync(base, { recursive: true, force: true }); + } +}, 60_000); + +/** + * The value of `prop` in the `selector` rule whose parent is `wrapper` + * (`@layer theme`), or that sits at the top level when `wrapper` is null. + */ +function declValue(selector: string, prop: string, wrapper: string | null): string | undefined { + let value: string | undefined; + root.walkRules(selector, (rule) => { + const at = rule.parent?.type === 'atrule' ? (rule.parent as AtRule) : null; + if (value !== undefined || (at ? `@${at.name} ${at.params}` : null) !== wrapper) return; + rule.walkDecls(prop, (decl) => { + value ??= decl.value.replace(/\s+/g, ' '); + }); + }); + return value; +} + +describe('theme font (#1278)', () => { + it('sets body in --font-sans', () => { + expect(declValue('body', 'font-family', null)).toBe('var(--font-sans)'); + }); + + it('defaults --font-sans to the app stack, Thai fallbacks included', () => { + expect(declValue(':root, :host', '--font-sans', '@layer theme')).toBe( + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif', + ); + }); +});