From a942daa233fe762d9667112a5d49da60872b6c14 Mon Sep 17 00:00:00 2001 From: James Morton Date: Tue, 30 Jun 2026 08:44:03 +0100 Subject: [PATCH] fix(fonts): self-host branding fonts so no Google Fonts is emitted The portal/widget rendered a fonts.googleapis.com stylesheet for the selected branding font. On a Cloudflare zone with Cloudflare Fonts enabled, the edge rewrites that link into an inline @font-face block, so the served DOM stops matching the client render and hydration fails (React #418) on every portal page. Bundle every font referenced by the branding picker (FONT_OPTIONS) and the theme presets (presets.ts) via @fontsource and remove the Google Fonts plumbing: GOOGLE_FONT_MAP, getGoogleFontsUrl, the portal/widget/auth-shell links, the theme-preview copy, and ALL_FONTS_URL. Geist ships as the "Geist Sans" family, Lato as 400/700, and Nunito is preset-only. Legacy branding configs saved with the bare "Geist" value are normalised to "Geist Sans" at read time (normalizeFontSans, applied in generateThemeCSS and the admin picker) so they keep rendering, no data migration needed. Pin the web app's zod to 4.3.6: adding the font deps re-resolved the lockfile and hoisted zod 4.4.3 to the top, which is type-incompatible with the MCP SDK used in mcp/tools.ts. The exact pin keeps the app on 4.3.6; the TanStack build tooling keeps 4.4.3. --- apps/web/package.json | 18 ++++- .../admin/settings/branding/theme-preview.tsx | 68 ++++-------------- .../settings/branding/use-branding-state.ts | 30 ++------ .../src/components/auth/portal-auth-shell.tsx | 7 +- apps/web/src/globals.css | 72 ++++++++++++++++++- .../theme/__tests__/normalize-font.test.ts | 37 ++++++++++ apps/web/src/lib/shared/theme/generator.ts | 59 +++++---------- apps/web/src/lib/shared/theme/index.ts | 2 +- apps/web/src/lib/shared/theme/presets.ts | 4 +- apps/web/src/routes/__tests__/_portal.test.ts | 1 - apps/web/src/routes/_portal.tsx | 8 +-- .../src/routes/admin/settings.branding.tsx | 3 - apps/web/src/routes/widget.tsx | 15 +--- bun.lock | 72 ++++++++++++++++--- 14 files changed, 233 insertions(+), 163 deletions(-) create mode 100644 apps/web/src/lib/shared/theme/__tests__/normalize-font.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index 42f471cca..abf698f77 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -26,6 +26,22 @@ "@dnd-kit/utilities": "^3.2.2", "@floating-ui/dom": "^1.7.6", "@fontsource-variable/inter": "^5.2.8", + "@fontsource/dm-sans": "^5.2.8", + "@fontsource/fira-code": "^5.2.7", + "@fontsource/geist-sans": "^5.2.5", + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/lato": "^5.2.7", + "@fontsource/lora": "^5.2.8", + "@fontsource/manrope": "^5.2.8", + "@fontsource/merriweather": "^5.2.11", + "@fontsource/nunito": "^5.2.7", + "@fontsource/open-sans": "^5.2.7", + "@fontsource/playfair-display": "^5.2.8", + "@fontsource/plus-jakarta-sans": "^5.2.8", + "@fontsource/poppins": "^5.2.7", + "@fontsource/roboto": "^5.2.10", + "@fontsource/space-grotesk": "^5.2.10", "@heroicons/react": "^2.2.0", "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "^1.29.0", @@ -118,7 +134,7 @@ "tw-animate-css": "^1.4.0", "typeid-js": "^1.2.0", "yaml": "^2.8.4", - "zod": "^4.3.6", + "zod": "4.3.6", "zod-openapi": "^5.4.6", "zustand": "^5.0.12" }, diff --git a/apps/web/src/components/admin/settings/branding/theme-preview.tsx b/apps/web/src/components/admin/settings/branding/theme-preview.tsx index f6e22625a..47002ceb3 100644 --- a/apps/web/src/components/admin/settings/branding/theme-preview.tsx +++ b/apps/web/src/components/admin/settings/branding/theme-preview.tsx @@ -13,42 +13,6 @@ import { import type { ParsedCssVariables } from '@/lib/shared/theme' import { cn } from '@/lib/shared/utils' -/** Map font family names to Google Fonts URL */ -const GOOGLE_FONT_MAP: Record = { - '"Inter"': 'Inter', - '"Roboto"': 'Roboto', - '"Open Sans"': 'Open+Sans', - '"Lato"': 'Lato', - '"Montserrat"': 'Montserrat', - '"Poppins"': 'Poppins', - '"Nunito"': 'Nunito', - '"DM Sans"': 'DM+Sans', - '"Plus Jakarta Sans"': 'Plus+Jakarta+Sans', - '"Geist"': 'Geist', - '"Work Sans"': 'Work+Sans', - '"Raleway"': 'Raleway', - '"Source Sans 3"': 'Source+Sans+3', - '"Outfit"': 'Outfit', - '"Manrope"': 'Manrope', - '"Space Grotesk"': 'Space+Grotesk', - '"Playfair Display"': 'Playfair+Display', - '"Merriweather"': 'Merriweather', - '"Lora"': 'Lora', - '"Crimson Text"': 'Crimson+Text', - '"Fira Code"': 'Fira+Code', - '"JetBrains Mono"': 'JetBrains+Mono', -} - -function getGoogleFontsUrl(fontFamily: string | undefined): string | null { - if (!fontFamily) return null - for (const [cssName, googleName] of Object.entries(GOOGLE_FONT_MAP)) { - if (fontFamily.includes(cssName)) { - return `https://fonts.googleapis.com/css2?family=${googleName}:wght@400;500;600;700&display=swap` - } - } - return null -} - interface ThemePreviewProps { previewMode: 'light' | 'dark' /** Parsed CSS variables from the theme CSS (source of truth) */ @@ -72,26 +36,22 @@ export function ThemePreview({ previewMode, cssVariables }: ThemePreviewProps) { const cssVars = useMemo(() => ({ ...modeVars, ...COMPONENT_ALIASES }), [modeVars]) const fontFamily = modeVars['--font-sans'] || DEFAULT_FONT - const googleFontsUrl = useMemo(() => getGoogleFontsUrl(fontFamily), [fontFamily]) return ( - <> - {googleFontsUrl && } -
- -
- +
+ +
) } diff --git a/apps/web/src/components/admin/settings/branding/use-branding-state.ts b/apps/web/src/components/admin/settings/branding/use-branding-state.ts index f44fdfa62..340a0a53e 100644 --- a/apps/web/src/components/admin/settings/branding/use-branding-state.ts +++ b/apps/web/src/components/admin/settings/branding/use-branding-state.ts @@ -7,6 +7,7 @@ import { generateReadableCSS, parseCssToMinimal, replaceCssVar, + normalizeFontSans, type ThemeConfig, type MinimalThemeVariables, type ThemeMode, @@ -14,127 +15,108 @@ import { } from '@/lib/shared/theme' import { useSaveBrandingTheme } from '@/lib/client/mutations/settings' +// Each `value` family must be self-hosted in globals.css (matching the @fontsource +// @font-face family name), or the selection falls back to the generic stack. export const FONT_OPTIONS = [ { id: 'inter', name: 'Inter', value: '"Inter", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Inter', }, { id: 'system', name: 'System UI', value: 'ui-sans-serif, system-ui, -apple-system, sans-serif', category: 'System', - googleName: null, }, { id: 'roboto', name: 'Roboto', value: '"Roboto", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Roboto', }, { id: 'open-sans', name: 'Open Sans', value: '"Open Sans", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Open+Sans', }, { id: 'lato', name: 'Lato', value: '"Lato", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Lato', }, { id: 'poppins', name: 'Poppins', value: '"Poppins", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Poppins', }, { id: 'dm-sans', name: 'DM Sans', value: '"DM Sans", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'DM+Sans', }, { id: 'jakarta', name: 'Plus Jakarta Sans', value: '"Plus Jakarta Sans", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Plus+Jakarta+Sans', }, { id: 'geist', name: 'Geist', - value: '"Geist", ui-sans-serif, system-ui, sans-serif', + // @fontsource publishes Geist as the "Geist Sans" family (see globals.css). + value: '"Geist Sans", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Geist', }, { id: 'manrope', name: 'Manrope', value: '"Manrope", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Manrope', }, { id: 'space-grotesk', name: 'Space Grotesk', value: '"Space Grotesk", ui-sans-serif, system-ui, sans-serif', category: 'Sans Serif', - googleName: 'Space+Grotesk', }, { id: 'playfair', name: 'Playfair Display', value: '"Playfair Display", ui-serif, Georgia, serif', category: 'Serif', - googleName: 'Playfair+Display', }, { id: 'merriweather', name: 'Merriweather', value: '"Merriweather", ui-serif, Georgia, serif', category: 'Serif', - googleName: 'Merriweather', }, { id: 'lora', name: 'Lora', value: '"Lora", ui-serif, Georgia, serif', category: 'Serif', - googleName: 'Lora', }, { id: 'fira-code', name: 'Fira Code', value: '"Fira Code", ui-monospace, monospace', category: 'Monospace', - googleName: 'Fira+Code', }, { id: 'jetbrains', name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace', category: 'Monospace', - googleName: 'JetBrains+Mono', }, ] as const -export const ALL_FONTS_URL = `https://fonts.googleapis.com/css2?family=${FONT_OPTIONS.filter( - (f) => f.googleName -) - .map((f) => f.googleName) - .join('&family=')}&display=swap` - const DEFAULT_FONT = '"Inter", ui-sans-serif, system-ui, sans-serif' const DEFAULT_RADIUS = 0.625 @@ -247,7 +229,7 @@ export function useBrandingState(options: UseBrandingStateOptions): BrandingStat ) const currentFontId = useMemo( - () => FONT_OPTIONS.find((f) => f.value === font)?.id || 'inter', + () => FONT_OPTIONS.find((f) => f.value === normalizeFontSans(font))?.id || 'inter', [font] ) diff --git a/apps/web/src/components/auth/portal-auth-shell.tsx b/apps/web/src/components/auth/portal-auth-shell.tsx index 8401e5c2a..cef35cc38 100644 --- a/apps/web/src/components/auth/portal-auth-shell.tsx +++ b/apps/web/src/components/auth/portal-auth-shell.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { useRouteContext } from '@tanstack/react-router' import { PortalBrandMark } from './portal-brand-mark' -import { generateThemeCSS, getGoogleFontsUrl } from '@/lib/shared/theme' +import { generateThemeCSS } from '@/lib/shared/theme' import type { BrandingConfig } from '@/lib/server/domains/settings/settings.types' interface PortalAuthShellProps { @@ -38,14 +38,9 @@ export function PortalAuthShell({ heading, subheading, children, footer }: Porta const hasThemeConfig = brandingConfig.light || brandingConfig.dark return hasThemeConfig ? generateThemeCSS(brandingConfig) : '' }, [brandingConfig]) - const googleFontsUrl = useMemo( - () => (brandingConfig ? getGoogleFontsUrl(brandingConfig) : null), - [brandingConfig] - ) return (
- {googleFontsUrl && } {themeStyles &&