diff --git a/.changeset/preload-primary-font.md b/.changeset/preload-primary-font.md new file mode 100644 index 00000000..b9718eb6 --- /dev/null +++ b/.changeset/preload-primary-font.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Preload the configured font's regular face from the initial HTML so it can download before stylesheet discovery. Respect custom font files and leave other weights and fallback fonts on demand. Treat inherited object-property names as unknown font families rather than failing page loads. diff --git a/apps/web/e2e/fonts.e2e.ts b/apps/web/e2e/fonts.e2e.ts new file mode 100644 index 00000000..5d7f27ed --- /dev/null +++ b/apps/web/e2e/fonts.e2e.ts @@ -0,0 +1,42 @@ +import { expect, test } from './support/test.ts'; + +test.use({ javaScriptEnabled: false }); + +test('HTML preloads only the regular configured font and CSS reuses its request', async ({ + page +}) => { + const regularFont = '/assets/fonts/noto-sans/noto-sans-latin-400-normal.woff2'; + const fontRequests: string[] = []; + page.on('request', (request) => { + const path = new URL(request.url()).pathname; + if (path.endsWith('.woff2')) fontRequests.push(path); + }); + + const fontResponse = page.waitForResponse( + (response) => new URL(response.url()).pathname === regularFont + ); + await page.goto('/schedule/chat'); + expect((await fontResponse).ok()).toBe(true); + const preload = page.locator('head link[rel="preload"][as="font"]'); + await expect(preload).toHaveCount(1); + await expect(preload).toHaveAttribute('href', regularFont); + await expect(preload).toHaveAttribute('type', 'font/woff2'); + await expect(preload).toHaveAttribute('crossorigin', 'anonymous'); + const fontState = await page.evaluate(async () => { + await document.fonts.ready; + return { + bodyFontFamily: getComputedStyle(document.body).fontFamily, + regularFontLoaded: Array.from(document.fonts).some( + (face) => + face.family.replace(/["']/g, '') === 'Noto Sans' && + face.weight === '400' && + face.status === 'loaded' + ) + }; + }); + expect(fontState.bodyFontFamily).toMatch(/^["']?Noto Sans["']?(?:,|$)/); + expect(fontState.regularFontLoaded).toBe(true); + + expect(fontRequests.filter((path) => path === regularFont)).toHaveLength(1); + expect(fontRequests.every((path) => path.startsWith('/assets/fonts/noto-sans/'))).toBe(true); +}); diff --git a/apps/web/src/lib/server/fonts.test.ts b/apps/web/src/lib/server/fonts.test.ts index 33c011a1..e293d0c6 100644 --- a/apps/web/src/lib/server/fonts.test.ts +++ b/apps/web/src/lib/server/fonts.test.ts @@ -1,5 +1,11 @@ import { expect, test } from 'vitest'; -import { FALLBACK_FONT_NAME, bundledFontUrls, fontStack, isBundledFont } from './fonts'; +import { + FALLBACK_FONT_NAME, + bundledFontUrls, + fontStack, + isBundledFont, + primaryFontUrl +} from './fonts'; test('isBundledFont matches families case-insensitively', () => { expect(isBundledFont('Noto Sans')).toBe(true); @@ -20,6 +26,30 @@ test('bundledFontUrls derives per-weight urls from the family name', () => { expect(bundledFontUrls('Unknown')).toEqual([]); }); +test.each([ + ['Noto Sans', '/assets/fonts/noto-sans/noto-sans-latin-400-normal.woff2'], + ['Lato', '/assets/fonts/lato/lato-latin-400-normal.woff2'], + ['Outfit', '/assets/fonts/outfit/outfit-latin-400-normal.woff2'], + ['INTER', '/assets/fonts/inter/inter-latin-400-normal.woff2'], + ['My Font', null] +] as const)('primaryFontUrl preloads only the regular face for %s', (family, expected) => { + expect(primaryFontUrl(family)).toBe(expected); +}); + +test.each(['My Font', 'Noto Sans'])('a custom file takes precedence for %s', (family) => { + expect(primaryFontUrl(family, '/public/custom.woff2')).toBe('/public/custom.woff2'); +}); + +test.each(['constructor', '__proto__'])( + 'inherited object key %s is not a bundled family', + (family) => { + expect(isBundledFont(family)).toBe(false); + expect(bundledFontUrls(family)).toEqual([]); + expect(primaryFontUrl(family)).toBeNull(); + expect(fontStack(family)).toBe(`${family}, ${FALLBACK_FONT_NAME}`); + } +); + test('fontStack backs only custom fonts with the fallback', () => { expect(fontStack('Lato')).toBe('Lato'); expect(fontStack('My Font')).toBe(`My Font, ${FALLBACK_FONT_NAME}`); diff --git a/apps/web/src/lib/server/fonts.ts b/apps/web/src/lib/server/fonts.ts index 958d3906..d663cd13 100644 --- a/apps/web/src/lib/server/fonts.ts +++ b/apps/web/src/lib/server/fonts.ts @@ -9,10 +9,11 @@ const BUNDLED_FONT_WEIGHTS: Record = { }; export function isBundledFont(family: string): boolean { - return family.toLowerCase() in BUNDLED_FONT_WEIGHTS; + return Object.hasOwn(BUNDLED_FONT_WEIGHTS, family.toLowerCase()); } export function bundledFontUrls(family: string): string[] { + if (!isBundledFont(family)) return []; const key = family.toLowerCase(); const slug = key.replaceAll(' ', '-'); return (BUNDLED_FONT_WEIGHTS[key] ?? []).map( @@ -20,6 +21,11 @@ export function bundledFontUrls(family: string): string[] { ); } +/** Preload only the configured face; leave other weights and fallbacks on demand. */ +export function primaryFontUrl(family: string, customPath?: string): string | null { + return customPath ?? bundledFontUrls(family)[0] ?? null; +} + export function fontFamilies(family: string): string[] { return isBundledFont(family) ? [family] : [family, FALLBACK_FONT_NAME]; } diff --git a/apps/web/src/routes/(auth)/admin/health/page.test.ts b/apps/web/src/routes/(auth)/admin/health/page.test.ts index 6f6e4fb9..60dc2834 100644 --- a/apps/web/src/routes/(auth)/admin/health/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/health/page.test.ts @@ -12,6 +12,7 @@ const data: PageData = { session: { expires: '2026-05-01T12:00:00Z' }, preferredTimezone: null, appearance: validConfig.user.appearance, + fontPreloadUrl: null, ogImage: 'https://book.example.com/og.png', providers: [ { diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index ddad6844..a3e1155e 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,4 +1,5 @@ import { getConfig } from '#lib/server/state.js'; +import { primaryFontUrl } from '#lib/server/fonts.js'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = ({ cookies }) => { @@ -12,6 +13,7 @@ export const load: LayoutServerLoad = ({ cookies }) => { title: cfg.user.appearance.title, description: cfg.user.appearance.description }, + fontPreloadUrl: primaryFontUrl(cfg.user.appearance.font_name, cfg.user.appearance.font_path), ogImage: `${cfg.url.app}${cfg.user.appearance.opengraph_path}`, preferredTimezone: cookies.get('tz') ?? null }; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index fba2f10e..71d906dd 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -27,6 +27,15 @@ + {#if data.fontPreloadUrl} + + {/if}