a tool for shared writing and social publishing

more fonts!!

+590 -120
+7 -2
app/[leaflet_id]/Leaflet.tsx
··· 18 18 token: PermissionToken; 19 19 initialFacts: Fact<Attribute>[]; 20 20 leaflet_id: string; 21 - initialFontId?: string; 21 + initialHeadingFontId?: string; 22 + initialBodyFontId?: string; 22 23 }) { 23 24 return ( 24 25 <ReplicacheProvider ··· 30 31 <EntitySetProvider 31 32 set={props.token.permission_token_rights[0].entity_set} 32 33 > 33 - <ThemeProvider entityID={props.leaflet_id} initialFontId={props.initialFontId}> 34 + <ThemeProvider 35 + entityID={props.leaflet_id} 36 + initialHeadingFontId={props.initialHeadingFontId} 37 + initialBodyFontId={props.initialBodyFontId} 38 + > 34 39 <ThemeBackgroundProvider entityID={props.leaflet_id}> 35 40 <UpdateLeafletTitle entityID={props.leaflet_id} /> 36 41 <AddLeafletToHomepage />
+6 -5
app/[leaflet_id]/page.tsx
··· 14 14 import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; 15 15 import { NotFoundLayout } from "components/PageLayouts/NotFoundLayout"; 16 16 import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData"; 17 - import { FontLoader, extractFontFromFacts } from "components/FontLoader"; 17 + import { FontLoader, extractFontsFromFacts } from "components/FontLoader"; 18 18 19 19 export const preferredRegion = ["sfo1"]; 20 20 export const dynamic = "force-dynamic"; ··· 50 50 ]); 51 51 let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 52 52 53 - // Extract font setting from facts for server-side font loading 54 - const fontId = extractFontFromFacts(initialFacts as any, rootEntity); 53 + // Extract font settings from facts for server-side font loading 54 + const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); 55 55 56 56 return ( 57 57 <> 58 58 {/* Server-side font loading with preload and @font-face */} 59 - <FontLoader fontId={fontId} /> 59 + <FontLoader headingFontId={headingFontId} bodyFontId={bodyFontId} /> 60 60 <PageSWRDataProvider 61 61 rsvp_data={rsvp_data} 62 62 poll_data={poll_data} ··· 67 67 initialFacts={initialFacts} 68 68 leaflet_id={rootEntity} 69 69 token={res.data} 70 - initialFontId={fontId} 70 + initialHeadingFontId={headingFontId} 71 + initialBodyFontId={bodyFontId} 71 72 /> 72 73 </PageSWRDataProvider> 73 74 </>
+7
app/globals.css
··· 158 158 } 159 159 160 160 /* START FONT STYLING */ 161 + h1, 162 + h2, 163 + h3, 164 + h4 { 165 + font-family: var(--theme-heading-font, var(--theme-font)); 166 + } 167 + 161 168 h1 { 162 169 @apply text-2xl; 163 170 @apply font-bold;
+12 -10
app/layout.tsx
··· 36 36 const quattro = localFont({ 37 37 src: [ 38 38 { 39 - path: "../public/fonts/iAWriterQuattroV.ttf", 39 + path: "../public/fonts/iaw-quattro-vf.woff2", 40 40 style: "normal", 41 41 }, 42 42 { 43 - path: "../public/fonts/iAWriterQuattroV-Italic.ttf", 43 + path: "../public/fonts/iaw-quattro-vf-Italic.woff2", 44 44 style: "italic", 45 45 }, 46 46 ], ··· 48 48 variable: "--font-quattro", 49 49 }); 50 50 51 - export default async function RootLayout( 52 - { 53 - children, 54 - }: { 55 - children: React.ReactNode; 56 - } 57 - ) { 51 + export default async function RootLayout({ 52 + children, 53 + }: { 54 + children: React.ReactNode; 55 + }) { 58 56 let headersList = await headers(); 59 57 let ipLocation = headersList.get("X-Vercel-IP-Country"); 60 58 let acceptLanguage = headersList.get("accept-language"); ··· 80 78 <InitialPageLoad> 81 79 <PopUpProvider> 82 80 <IdentityProviderServer> 83 - <RequestHeadersProvider country={ipLocation} language={acceptLanguage} timezone={ipTimezone}> 81 + <RequestHeadersProvider 82 + country={ipLocation} 83 + language={acceptLanguage} 84 + timezone={ipTimezone} 85 + > 84 86 <ViewportSizeLayout>{children}</ViewportSizeLayout> 85 87 <RouteUIStateManager /> 86 88 </RequestHeadersProvider>
+4
app/lish/createPub/updatePublication.ts
··· 273 273 showPageBackground: boolean; 274 274 accentBackground: Color; 275 275 accentText: Color; 276 + headingFont?: string; 277 + bodyFont?: string; 276 278 }; 277 279 }): Promise<UpdatePublicationResult> { 278 280 return withPublicationUpdate(uri, async ({ normalizedPub, existingBasePath, publicationType, agent }) => { ··· 312 314 accentText: { 313 315 ...theme.accentText, 314 316 }, 317 + headingFont: theme.headingFont, 318 + bodyFont: theme.bodyFont, 315 319 }; 316 320 317 321 // Derive basicTheme from the theme colors for site.standard.publication
+3 -3
components/Blocks/TextBlock/index.tsx
··· 30 30 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 31 32 32 const HeadingStyle = { 33 - 1: "text-xl font-bold", 34 - 2: "text-lg font-bold", 35 - 3: "text-base font-bold text-secondary ", 33 + 1: "text-xl font-bold [font-family:var(--theme-heading-font)]", 34 + 2: "text-lg font-bold [font-family:var(--theme-heading-font)]", 35 + 3: "text-base font-bold text-secondary [font-family:var(--theme-heading-font)]", 36 36 } as { [level: number]: string }; 37 37 38 38 export function TextBlock(
+83 -18
components/FontLoader.tsx
··· 1 1 // Server-side font loading component 2 - // Replicates next/font behavior: preload links, @font-face, and CSS variable 3 - // This is rendered on the server and the link/style tags are hoisted to <head> 2 + // Following Google's best practices: https://web.dev/articles/font-best-practices 3 + // - Preconnect to font origins for early connection 4 + // - Use font-display: swap (shows fallback immediately, swaps when ready) 5 + // - Don't block rendering - some FOUT is acceptable and better UX than invisible text 4 6 5 - import { getFontConfig, generateFontFaceCSS, getFontPreloadLinks, FontConfig } from "src/fonts"; 7 + import { 8 + getFontConfig, 9 + generateFontFaceCSS, 10 + getFontPreloadLinks, 11 + getGoogleFontsUrl, 12 + getFontFamilyValue, 13 + } from "src/fonts"; 6 14 7 15 type FontLoaderProps = { 8 - fontId: string | undefined; 16 + headingFontId: string | undefined; 17 + bodyFontId: string | undefined; 9 18 }; 10 19 11 - export function FontLoader({ fontId }: FontLoaderProps) { 12 - const font = getFontConfig(fontId); 13 - const preloadLinks = getFontPreloadLinks(font); 14 - const fontFaceCSS = generateFontFaceCSS(font); 20 + export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { 21 + const headingFont = getFontConfig(headingFontId); 22 + const bodyFont = getFontConfig(bodyFontId); 23 + 24 + // Collect all unique fonts to load 25 + const fontsToLoad = headingFont.id === bodyFont.id 26 + ? [headingFont] 27 + : [headingFont, bodyFont]; 28 + 29 + // Collect preload links (deduplicated) 30 + const preloadLinksSet = new Set<string>(); 31 + const preloadLinks: { href: string; type: string }[] = []; 32 + for (const font of fontsToLoad) { 33 + for (const link of getFontPreloadLinks(font)) { 34 + if (!preloadLinksSet.has(link.href)) { 35 + preloadLinksSet.add(link.href); 36 + preloadLinks.push(link); 37 + } 38 + } 39 + } 40 + 41 + // Collect font-face CSS 42 + const fontFaceCSS = fontsToLoad 43 + .map((font) => generateFontFaceCSS(font)) 44 + .filter(Boolean) 45 + .join("\n\n"); 46 + 47 + // Collect Google Fonts URLs (deduplicated) 48 + const googleFontsUrls = [...new Set( 49 + fontsToLoad 50 + .map((font) => getGoogleFontsUrl(font)) 51 + .filter((url): url is string => url !== null) 52 + )]; 53 + 54 + const headingFontValue = getFontFamilyValue(headingFont); 55 + const bodyFontValue = getFontFamilyValue(bodyFont); 15 56 16 - // Generate CSS that sets the font family via CSS variable 57 + // Generate CSS that sets the font family via CSS variables 58 + // --theme-font is used for body text (keeps backwards compatibility) 59 + // --theme-heading-font is used for headings 17 60 const fontVariableCSS = ` 18 61 :root { 19 - --theme-font: '${font.fontFamily}', ${font.fallback.join(", ")}; 62 + --theme-heading-font: ${headingFontValue}; 63 + --theme-font: ${bodyFontValue}; 20 64 } 21 65 `.trim(); 22 66 23 67 return ( 24 68 <> 25 - {/* Preload font files - these get hoisted to <head> by React/Next.js */} 69 + {/* 70 + Google Fonts best practice: preconnect to both origins 71 + - fonts.googleapis.com serves the CSS 72 + - fonts.gstatic.com serves the font files (needs crossorigin for CORS) 73 + Place these as early as possible in <head> 74 + */} 75 + {googleFontsUrls.length > 0 && ( 76 + <> 77 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 78 + <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> 79 + {googleFontsUrls.map((url) => ( 80 + <link key={url} rel="stylesheet" href={url} /> 81 + ))} 82 + </> 83 + )} 84 + {/* Preload local font files for early discovery */} 26 85 {preloadLinks.map((link) => ( 27 86 <link 28 87 key={link.href} ··· 33 92 crossOrigin="anonymous" 34 93 /> 35 94 ))} 36 - {/* @font-face declarations and CSS variable */} 95 + {/* @font-face declarations (for local fonts) and CSS variable */} 37 96 <style 38 97 dangerouslySetInnerHTML={{ 39 98 __html: `${fontFaceCSS}\n\n${fontVariableCSS}`, ··· 43 102 ); 44 103 } 45 104 46 - // Helper to extract font from facts array (for server-side use) 47 - export function extractFontFromFacts( 105 + // Helper to extract fonts from facts array (for server-side use) 106 + export function extractFontsFromFacts( 48 107 facts: Array<{ entity: string; attribute: string; data: { value: string } }>, 49 108 rootEntity: string 50 - ): string | undefined { 51 - const fontFact = facts.find( 52 - (f) => f.entity === rootEntity && f.attribute === "theme/font" 109 + ): { headingFontId: string | undefined; bodyFontId: string | undefined } { 110 + const headingFontFact = facts.find( 111 + (f) => f.entity === rootEntity && f.attribute === "theme/heading-font" 53 112 ); 54 - return fontFact?.data?.value; 113 + const bodyFontFact = facts.find( 114 + (f) => f.entity === rootEntity && f.attribute === "theme/body-font" 115 + ); 116 + return { 117 + headingFontId: headingFontFact?.data?.value, 118 + bodyFontId: bodyFontFact?.data?.value, 119 + }; 55 120 }
+137
components/ThemeManager/Pickers/TextPickers.tsx
··· 1 + "use client"; 2 + 3 + import { Color } from "react-aria-components"; 4 + import { Input } from "components/Input"; 5 + import { useState } from "react"; 6 + import { useEntity, useReplicache } from "src/replicache"; 7 + import { Menu } from "components/Menu"; 8 + import { pickers } from "../ThemeSetter"; 9 + import { ColorPicker } from "./ColorPicker"; 10 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 11 + import { useIsMobile } from "src/hooks/isMobile"; 12 + import { fonts, defaultFontId, FontConfig } from "src/fonts"; 13 + 14 + export const TextColorPicker = (props: { 15 + openPicker: pickers; 16 + setOpenPicker: (thisPicker: pickers) => void; 17 + value: Color; 18 + setValue: (c: Color) => void; 19 + }) => { 20 + return ( 21 + <ColorPicker 22 + label="Text" 23 + value={props.value} 24 + setValue={props.setValue} 25 + thisPicker={"text"} 26 + openPicker={props.openPicker} 27 + setOpenPicker={props.setOpenPicker} 28 + closePicker={() => props.setOpenPicker("null")} 29 + /> 30 + ); 31 + }; 32 + 33 + type FontAttribute = "theme/heading-font" | "theme/body-font"; 34 + 35 + export const FontPicker = (props: { 36 + label: string; 37 + entityID: string; 38 + attribute: FontAttribute; 39 + }) => { 40 + let isMobile = useIsMobile(); 41 + let { rep } = useReplicache(); 42 + let [searchValue, setSearchValue] = useState(""); 43 + let currentFont = useEntity(props.entityID, props.attribute); 44 + let fontId = currentFont?.data.value || defaultFontId; 45 + let font = fonts[fontId] || fonts[defaultFontId]; 46 + 47 + let fontList = Object.values(fonts); 48 + let filteredFonts = fontList 49 + .filter((f) => { 50 + const matchesSearch = f.displayName 51 + .toLocaleLowerCase() 52 + .includes(searchValue.toLocaleLowerCase()); 53 + return matchesSearch; 54 + }) 55 + .sort((a, b) => { 56 + return a.displayName.localeCompare(b.displayName); 57 + }); 58 + 59 + return ( 60 + <Menu 61 + asChild 62 + trigger={ 63 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 64 + <div 65 + className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 66 + > 67 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 68 + Aa 69 + </div> 70 + </div> 71 + <div className="font-bold shrink-0">{props.label}</div> 72 + <div className="truncate">{font.displayName}</div> 73 + </button> 74 + } 75 + side={isMobile ? "bottom" : "right"} 76 + align="start" 77 + className="w-[250px] !gap-0 !outline-none max-h-72 " 78 + > 79 + <Input 80 + value={searchValue} 81 + className="px-3 pb-1 appearance-none !outline-none bg-transparent" 82 + placeholder="search..." 83 + onChange={(e) => { 84 + setSearchValue(e.currentTarget.value); 85 + }} 86 + /> 87 + <hr className="mx-2 border-border" /> 88 + <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 89 + {filteredFonts.map((fontOption) => { 90 + return ( 91 + <FontOption 92 + key={fontOption.id} 93 + onSelect={() => { 94 + rep?.mutate.assertFact({ 95 + entity: props.entityID, 96 + attribute: props.attribute, 97 + data: { type: "string", value: fontOption.id }, 98 + }); 99 + }} 100 + font={fontOption} 101 + selected={fontOption.id === fontId} 102 + /> 103 + ); 104 + })} 105 + </div> 106 + </Menu> 107 + ); 108 + }; 109 + 110 + const FontOption = (props: { 111 + onSelect: () => void; 112 + font: FontConfig; 113 + selected: boolean; 114 + }) => { 115 + return ( 116 + <DropdownMenu.RadioItem 117 + value={props.font.id} 118 + onSelect={props.onSelect} 119 + className={` 120 + fontOption 121 + z-10 px-1 py-0.5 122 + text-left text-secondary 123 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 124 + hover:bg-border-light hover:text-secondary 125 + outline-none 126 + cursor-pointer 127 + 128 + `} 129 + > 130 + <div 131 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 132 + > 133 + {props.font.displayName} 134 + </div> 135 + </DropdownMenu.RadioItem> 136 + ); 137 + };
+106
components/ThemeManager/PubPickers/PubFontPicker.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { Menu } from "components/Menu"; 5 + import { Input } from "components/Input"; 6 + import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; 7 + import { useIsMobile } from "src/hooks/isMobile"; 8 + import { fonts, defaultFontId, FontConfig } from "src/fonts"; 9 + 10 + export const PubFontPicker = (props: { 11 + label: string; 12 + value: string | undefined; 13 + onChange: (fontId: string) => void; 14 + }) => { 15 + let isMobile = useIsMobile(); 16 + let [searchValue, setSearchValue] = useState(""); 17 + let fontId = props.value || defaultFontId; 18 + let font = fonts[fontId] || fonts[defaultFontId]; 19 + 20 + let fontList = Object.values(fonts); 21 + let filteredFonts = fontList 22 + .filter((f) => { 23 + const matchesSearch = f.displayName 24 + .toLocaleLowerCase() 25 + .includes(searchValue.toLocaleLowerCase()); 26 + return matchesSearch; 27 + }) 28 + .sort((a, b) => { 29 + return a.displayName.localeCompare(b.displayName); 30 + }); 31 + 32 + return ( 33 + <Menu 34 + asChild 35 + trigger={ 36 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 37 + <div 38 + className={`w-6 h-6 rounded-md border border-border relative text-sm bg-bg-page shrink-0 ${props.label === "Heading" ? "font-bold" : "text-secondary"}`} 39 + > 40 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 41 + Aa 42 + </div> 43 + </div> 44 + <div className="font-bold shrink-0">{props.label}</div> 45 + <div className="truncate">{font.displayName}</div> 46 + </button> 47 + } 48 + side={isMobile ? "bottom" : "right"} 49 + align="start" 50 + className="w-[250px] !gap-0 !outline-none max-h-72 " 51 + > 52 + <Input 53 + value={searchValue} 54 + className="px-3 pb-1 appearance-none !outline-none bg-transparent" 55 + placeholder="search..." 56 + onChange={(e) => { 57 + setSearchValue(e.currentTarget.value); 58 + }} 59 + /> 60 + <hr className="mx-2 border-border" /> 61 + <div className="flex flex-col h-full overflow-auto gap-0 pt-1"> 62 + {filteredFonts.map((fontOption) => { 63 + return ( 64 + <FontOption 65 + key={fontOption.id} 66 + onSelect={() => { 67 + props.onChange(fontOption.id); 68 + }} 69 + font={fontOption} 70 + selected={fontOption.id === fontId} 71 + /> 72 + ); 73 + })} 74 + </div> 75 + </Menu> 76 + ); 77 + }; 78 + 79 + const FontOption = (props: { 80 + onSelect: () => void; 81 + font: FontConfig; 82 + selected: boolean; 83 + }) => { 84 + return ( 85 + <DropdownMenu.RadioItem 86 + value={props.font.id} 87 + onSelect={props.onSelect} 88 + className={` 89 + fontOption 90 + z-10 px-1 py-0.5 91 + text-left text-secondary 92 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 93 + hover:bg-border-light hover:text-secondary 94 + outline-none 95 + cursor-pointer 96 + 97 + `} 98 + > 99 + <div 100 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 101 + > 102 + {props.font.displayName} 103 + </div> 104 + </DropdownMenu.RadioItem> 105 + ); 106 + };
-14
components/ThemeManager/PubPickers/PubTextPickers.tsx
··· 26 26 openPicker={props.openPicker} 27 27 setOpenPicker={props.setOpenPicker} 28 28 /> 29 - {/* FONT PICKERS HIDDEN FOR NOW */} 30 - {/* <hr className="border-border-light" /> 31 - <div className="flex gap-2"> 32 - <div className="w-6 h-6 font-bold text-center rounded-md bg-border-light"> 33 - Aa 34 - </div> 35 - <div className="font-bold">Header</div> <div>iA Writer</div> 36 - </div> 37 - <div className="flex gap-2"> 38 - <div className="w-6 h-6 place-items-center text-center rounded-md bg-border-light"> 39 - Aa 40 - </div>{" "} 41 - <div className="font-bold">Body</div> <div>iA Writer</div> 42 - </div> */} 43 29 </div> 44 30 ); 45 31 };
+17
components/ThemeManager/PubThemeSetter.tsx
··· 20 20 import { useToaster } from "components/Toast"; 21 21 import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; 22 22 import { PubPageWidthSetter } from "./PubPickers/PubPageWidthSetter"; 23 + import { PubFontPicker } from "./PubPickers/PubFontPicker"; 23 24 24 25 export type ImageState = { 25 26 src: string; ··· 60 61 let [pageWidth, setPageWidth] = useState<number>( 61 62 record?.theme?.pageWidth || 624, 62 63 ); 64 + let [headingFont, setHeadingFont] = useState<string | undefined>(record?.theme?.headingFont); 65 + let [bodyFont, setBodyFont] = useState<string | undefined>(record?.theme?.bodyFont); 63 66 let pubBGImage = image?.src || null; 64 67 let leafletBGRepeat = image?.repeat || null; 65 68 let toaster = useToaster(); ··· 85 88 primary: ColorToRGB(localPubTheme.primary), 86 89 accentBackground: ColorToRGB(localPubTheme.accent1), 87 90 accentText: ColorToRGB(localPubTheme.accent2), 91 + headingFont: headingFont, 92 + bodyFont: bodyFont, 88 93 }, 89 94 }); 90 95 ··· 189 194 setOpenPicker={(pickers) => setOpenPicker(pickers)} 190 195 hasPageBackground={showPageBackground} 191 196 /> 197 + <div className="bg-bg-page p-2 rounded-md border border-primary shadow-[0_0_0_1px_rgb(var(--bg-page))] flex flex-col gap-1"> 198 + <PubFontPicker 199 + label="Heading" 200 + value={headingFont} 201 + onChange={setHeadingFont} 202 + /> 203 + <PubFontPicker 204 + label="Body" 205 + value={bodyFont} 206 + onChange={setBodyFont} 207 + /> 208 + </div> 192 209 <PubAccentPickers 193 210 accent1={localPubTheme.accent1} 194 211 setAccent1={(color) => {
+68 -16
components/ThemeManager/ThemeProvider.tsx
··· 22 22 PublicationThemeProvider, 23 23 } from "./PublicationThemeProvider"; 24 24 import { getColorDifference } from "./themeUtils"; 25 - import { getFontConfig, defaultFontId } from "src/fonts"; 25 + import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue } from "src/fonts"; 26 26 27 27 // define a function to set an Aria Color to a CSS Variable in RGB 28 28 function setCSSVariableToColor( ··· 39 39 local?: boolean; 40 40 children: React.ReactNode; 41 41 className?: string; 42 - initialFontId?: string; 42 + initialHeadingFontId?: string; 43 + initialBodyFontId?: string; 43 44 }) { 44 45 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 45 46 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; ··· 58 59 entityID: string | null; 59 60 local?: boolean; 60 61 children: React.ReactNode; 61 - initialFontId?: string; 62 + initialHeadingFontId?: string; 63 + initialBodyFontId?: string; 62 64 }) { 63 65 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 64 66 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); ··· 79 81 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 80 82 81 83 let pageWidth = useEntity(props.entityID, "theme/page-width"); 82 - // Use initialFontId as fallback until Replicache syncs 83 - let fontId = useEntity(props.entityID, "theme/font")?.data.value ?? props.initialFontId; 84 + // Use initial font IDs as fallback until Replicache syncs 85 + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 86 + let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 84 87 85 88 return ( 86 89 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 97 100 showPageBackground={showPageBackground} 98 101 pageWidth={pageWidth?.data.value} 99 102 hasBackgroundImage={hasBackgroundImage} 100 - fontId={fontId} 103 + headingFontId={headingFontId} 104 + bodyFontId={bodyFontId} 101 105 > 102 106 {props.children} 103 107 </BaseThemeProvider> ··· 119 123 showPageBackground, 120 124 pageWidth, 121 125 hasBackgroundImage, 122 - fontId, 126 + headingFontId, 127 + bodyFontId, 123 128 children, 124 129 }: { 125 130 local?: boolean; ··· 134 139 highlight2: AriaColor; 135 140 highlight3: AriaColor; 136 141 pageWidth?: number; 137 - fontId?: string; 142 + headingFontId?: string; 143 + bodyFontId?: string; 138 144 children: React.ReactNode; 139 145 }) => { 140 146 // When showPageBackground is false and there's no background image, ··· 175 181 accentContrast = sortedAccents[0]; 176 182 } 177 183 178 - // Get font config for CSS variable 179 - const fontConfig = getFontConfig(fontId); 180 - const themeFontValue = `'${fontConfig.fontFamily}', ${fontConfig.fallback.join(", ")}`; 184 + // Get font configs for CSS variables 185 + const headingFontConfig = getFontConfig(headingFontId); 186 + const bodyFontConfig = getFontConfig(bodyFontId); 187 + const headingFontValue = getFontFamilyValue(headingFontConfig); 188 + const bodyFontValue = getFontFamilyValue(bodyFontConfig); 189 + const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 190 + const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 191 + 192 + // Dynamically load Google Fonts when fonts change 193 + useEffect(() => { 194 + const loadGoogleFont = (url: string | null, fontFamily: string) => { 195 + if (!url) return; 196 + 197 + // Check if this font stylesheet is already in the document 198 + const existingLink = document.querySelector(`link[href="${url}"]`); 199 + if (existingLink) return; 200 + 201 + // Add preconnect hints if not present 202 + if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 203 + const preconnect1 = document.createElement("link"); 204 + preconnect1.rel = "preconnect"; 205 + preconnect1.href = "https://fonts.googleapis.com"; 206 + document.head.appendChild(preconnect1); 207 + 208 + const preconnect2 = document.createElement("link"); 209 + preconnect2.rel = "preconnect"; 210 + preconnect2.href = "https://fonts.gstatic.com"; 211 + preconnect2.crossOrigin = "anonymous"; 212 + document.head.appendChild(preconnect2); 213 + } 214 + 215 + // Load the Google Font stylesheet 216 + const link = document.createElement("link"); 217 + link.rel = "stylesheet"; 218 + link.href = url; 219 + document.head.appendChild(link); 220 + 221 + // Wait for the font to actually load before it gets applied 222 + if (document.fonts?.load) { 223 + document.fonts.load(`1em "${fontFamily}"`); 224 + } 225 + }; 226 + 227 + loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 228 + loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 229 + }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 181 230 182 231 useEffect(() => { 183 232 if (local) return; ··· 228 277 (pageWidth || 624).toString(), 229 278 ); 230 279 231 - // Set theme font CSS variable 232 - el?.style.setProperty("--theme-font", themeFontValue); 280 + // Set theme font CSS variables 281 + el?.style.setProperty("--theme-heading-font", headingFontValue); 282 + el?.style.setProperty("--theme-font", bodyFontValue); 233 283 }, [ 234 284 local, 235 285 bgLeaflet, ··· 242 292 accent2, 243 293 accentContrast, 244 294 pageWidth, 245 - themeFontValue, 246 - ]); 295 + headingFontValue, 296 + bodyFontValue, 297 + ]); // bodyFontValue sets --theme-font 247 298 return ( 248 299 <div 249 300 className="leafletWrapper w-full text-primary h-full min-h-fit flex flex-col bg-center items-stretch " ··· 265 316 "--page-width-setting": pageWidth || 624, 266 317 "--page-width-unitless": pageWidth || 624, 267 318 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 268 - "--theme-font": themeFontValue, 319 + "--theme-heading-font": headingFontValue, 320 + "--theme-font": bodyFontValue, 269 321 } as CSSProperties 270 322 } 271 323 >
+8 -31
components/ThemeManager/ThemeSetter.tsx
··· 22 22 import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 23 23 import { useIsMobile } from "src/hooks/isMobile"; 24 24 import { Toggle } from "components/Toggle"; 25 - import { fonts, defaultFontId } from "src/fonts"; 25 + import { FontPicker } from "./Pickers/TextPickers"; 26 26 27 27 export type pickers = 28 28 | "null" ··· 158 158 openPicker={openPicker} 159 159 setOpenPicker={(pickers) => setOpenPicker(pickers)} 160 160 /> 161 + {!props.home && ( 162 + <div className="flex flex-col gap-1 bg-bg-page p-2 rounded-md border border-primary -mt-2"> 163 + <FontPicker label="Heading" entityID={props.entityID} attribute="theme/heading-font" /> 164 + <FontPicker label="Body" entityID={props.entityID} attribute="theme/body-font" /> 165 + </div> 166 + )} 161 167 <div className="flex flex-col -gap-[6px]"> 162 168 <div className={`flex flex-col z-10 -mb-[6px] `}> 163 169 <AccentPickers ··· 185 191 /> 186 192 </div> 187 193 {!props.home && <WatermarkSetter entityID={props.entityID} />} 188 - {!props.home && <FontPicker entityID={props.entityID} />} 189 194 </div> 190 195 ); 191 196 }; 192 197 193 - function FontPicker(props: { entityID: string }) { 194 - let { rep } = useReplicache(); 195 - let currentFont = useEntity(props.entityID, "theme/font"); 196 - 197 - return ( 198 - <div className="flex flex-col gap-1 mt-2"> 199 - <label className="font-bold text-sm">Font</label> 200 - <select 201 - className="input-with-border w-full" 202 - value={currentFont?.data.value || defaultFontId} 203 - onChange={(e) => { 204 - rep?.mutate.assertFact({ 205 - entity: props.entityID, 206 - attribute: "theme/font", 207 - data: { type: "string", value: e.target.value }, 208 - }); 209 - }} 210 - > 211 - {Object.values(fonts).map((font) => ( 212 - <option key={font.id} value={font.id}> 213 - {font.displayName} 214 - </option> 215 - ))} 216 - </select> 217 - </div> 218 - ); 219 - } 220 - 221 198 function WatermarkSetter(props: { entityID: string }) { 222 199 let { rep } = useReplicache(); 223 200 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 331 308 onClick={() => { 332 309 props.setOpenPicker("text"); 333 310 }} 334 - className="cursor-pointer font-bold w-fit" 311 + className="cursor-pointer font-bold w-fit [font-family:var(--theme-heading-font)]" 335 312 > 336 313 Hello! 337 314 </p>
+8
lexicons/api/lexicons.ts
··· 1896 1896 'lex:pub.leaflet.theme.color#rgb', 1897 1897 ], 1898 1898 }, 1899 + headingFont: { 1900 + type: 'string', 1901 + maxLength: 100, 1902 + }, 1903 + bodyFont: { 1904 + type: 'string', 1905 + maxLength: 100, 1906 + }, 1899 1907 }, 1900 1908 }, 1901 1909 },
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 76 76 | $Typed<PubLeafletThemeColor.Rgba> 77 77 | $Typed<PubLeafletThemeColor.Rgb> 78 78 | { $type: string } 79 + headingFont?: string 80 + bodyFont?: string 79 81 } 80 82 81 83 const hashTheme = 'theme'
+8
lexicons/pub/leaflet/publication.json
··· 112 112 "pub.leaflet.theme.color#rgba", 113 113 "pub.leaflet.theme.color#rgb" 114 114 ] 115 + }, 116 + "headingFont": { 117 + "type": "string", 118 + "maxLength": 100 119 + }, 120 + "bodyFont": { 121 + "type": "string", 122 + "maxLength": 100 115 123 } 116 124 } 117 125 }
+2
lexicons/src/publication.ts
··· 49 49 showPageBackground: { type: "boolean", default: false }, 50 50 accentBackground: ColorUnion, 51 51 accentText: ColorUnion, 52 + headingFont: { type: "string", maxLength: 100 }, 53 + bodyFont: { type: "string", maxLength: 100 }, 52 54 }, 53 55 }, 54 56 },
public/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/AtkinsonHyperlegibleNext-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-Italic-VariableFont.ttf

This is a binary file and will not be displayed.

public/fonts/Lora-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/Lora-VariableFont.ttf

This is a binary file and will not be displayed.

public/fonts/NotoSans-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/NotoSans-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Italic-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/SourceSans3-Variable.woff2

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV-Italic.ttf

This is a binary file and will not be displayed.

public/fonts/iAWriterQuattroV.ttf

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf-Italic.woff2

This is a binary file and will not be displayed.

public/fonts/iaw-quattro-vf.woff2

This is a binary file and will not be displayed.

+107 -20
src/fonts.ts
··· 1 - // Font configuration for self-hosted custom fonts 1 + // Font configuration for self-hosted and Google Fonts 2 2 // This replicates what next/font does but allows dynamic selection per-leaflet 3 3 4 4 export type FontConfig = { 5 5 id: string; 6 6 displayName: string; 7 7 fontFamily: string; 8 - files: { 9 - path: string; 10 - style: "normal" | "italic"; 11 - weight?: string; 12 - }[]; 13 8 fallback: string[]; 14 - }; 9 + } & ( 10 + | { 11 + // Self-hosted fonts with local files 12 + type: "local"; 13 + files: { 14 + path: string; 15 + style: "normal" | "italic"; 16 + weight?: string; 17 + }[]; 18 + } 19 + | { 20 + // Google Fonts loaded via CDN 21 + type: "google"; 22 + googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700" 23 + } 24 + | { 25 + // System fonts (no loading required) 26 + type: "system"; 27 + } 28 + ); 15 29 16 30 export const fonts: Record<string, FontConfig> = { 31 + // Self-hosted variable fonts (WOFF2) 17 32 quattro: { 18 33 id: "quattro", 19 - displayName: "Quattro", 34 + displayName: "iA Writer Quattro", 20 35 fontFamily: "iA Writer Quattro V", 36 + type: "local", 21 37 files: [ 22 - { path: "/fonts/iAWriterQuattroV.ttf", style: "normal" }, 23 - { path: "/fonts/iAWriterQuattroV-Italic.ttf", style: "italic" }, 38 + { path: "/fonts/iaw-quattro-vf.woff2", style: "normal", weight: "400 700" }, 39 + { path: "/fonts/iaw-quattro-vf-Italic.woff2", style: "italic", weight: "400 700" }, 24 40 ], 25 41 fallback: ["system-ui", "sans-serif"], 26 42 }, ··· 28 44 id: "lora", 29 45 displayName: "Lora", 30 46 fontFamily: "Lora", 47 + type: "local", 31 48 files: [ 32 - { path: "/fonts/Lora-VariableFont.ttf", style: "normal", weight: "400 700" }, 33 - { path: "/fonts/Lora-Italic-VariableFont.ttf", style: "italic", weight: "400 700" }, 49 + { path: "/fonts/Lora-Variable.woff2", style: "normal", weight: "400 700" }, 50 + { path: "/fonts/Lora-Italic-Variable.woff2", style: "italic", weight: "400 700" }, 34 51 ], 35 52 fallback: ["Georgia", "serif"], 36 53 }, 54 + "source-sans": { 55 + id: "source-sans", 56 + displayName: "Source Sans", 57 + fontFamily: "Source Sans 3", 58 + type: "local", 59 + files: [ 60 + { path: "/fonts/SourceSans3-Variable.woff2", style: "normal", weight: "200 900" }, 61 + { path: "/fonts/SourceSans3-Italic-Variable.woff2", style: "italic", weight: "200 900" }, 62 + ], 63 + fallback: ["system-ui", "sans-serif"], 64 + }, 65 + "atkinson-hyperlegible": { 66 + id: "atkinson-hyperlegible", 67 + displayName: "Atkinson Hyperlegible", 68 + fontFamily: "Atkinson Hyperlegible Next", 69 + type: "local", 70 + files: [ 71 + { path: "/fonts/AtkinsonHyperlegibleNext-Variable.woff2", style: "normal", weight: "200 800" }, 72 + { path: "/fonts/AtkinsonHyperlegibleNext-Italic-Variable.woff2", style: "italic", weight: "200 800" }, 73 + ], 74 + fallback: ["system-ui", "sans-serif"], 75 + }, 76 + "noto-sans": { 77 + id: "noto-sans", 78 + displayName: "Noto Sans", 79 + fontFamily: "Noto Sans", 80 + type: "local", 81 + files: [ 82 + { path: "/fonts/NotoSans-Variable.woff2", style: "normal", weight: "100 900" }, 83 + { path: "/fonts/NotoSans-Italic-Variable.woff2", style: "italic", weight: "100 900" }, 84 + ], 85 + fallback: ["Arial", "sans-serif"], 86 + }, 87 + 88 + // Google Fonts (no variable version available) 89 + "alegreya-sans": { 90 + id: "alegreya-sans", 91 + displayName: "Alegreya Sans", 92 + fontFamily: "Alegreya Sans", 93 + type: "google", 94 + googleFontsFamily: "Alegreya+Sans:ital,wght@0,400;0,700;1,400;1,700", 95 + fallback: ["system-ui", "sans-serif"], 96 + }, 97 + "space-mono": { 98 + id: "space-mono", 99 + displayName: "Space Mono", 100 + fontFamily: "Space Mono", 101 + type: "google", 102 + googleFontsFamily: "Space+Mono:ital,wght@0,400;0,700;1,400;1,700", 103 + fallback: ["monospace"], 104 + }, 37 105 }; 38 106 39 107 export const defaultFontId = "quattro"; ··· 42 110 return fonts[fontId || defaultFontId] || fonts[defaultFontId]; 43 111 } 44 112 45 - // Generate @font-face CSS for a font 113 + // Generate @font-face CSS for a local font 46 114 export function generateFontFaceCSS(font: FontConfig): string { 115 + if (font.type !== "local") return ""; 47 116 return font.files 48 - .map( 49 - (file) => ` 117 + .map((file) => { 118 + const format = file.path.endsWith(".woff2") ? "woff2" : "truetype"; 119 + return ` 50 120 @font-face { 51 121 font-family: '${font.fontFamily}'; 52 - src: url('${file.path}') format('truetype'); 122 + src: url('${file.path}') format('${format}'); 53 123 font-style: ${file.style}; 54 124 font-weight: ${file.weight || "normal"}; 55 125 font-display: swap; 56 - }`.trim() 57 - ) 126 + }`.trim(); 127 + }) 58 128 .join("\n\n"); 59 129 } 60 130 61 - // Generate preload link attributes for a font 131 + // Generate preload link attributes for a local font 62 132 export function getFontPreloadLinks(font: FontConfig): { href: string; type: string }[] { 133 + if (font.type !== "local") return []; 63 134 return font.files.map((file) => ({ 64 135 href: file.path, 65 - type: "font/ttf", 136 + type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf", 66 137 })); 67 138 } 139 + 140 + // Get Google Fonts URL for a font 141 + // Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready 142 + // This is better UX than blocking text rendering (display=block) 143 + export function getGoogleFontsUrl(font: FontConfig): string | null { 144 + if (font.type !== "google") return null; 145 + return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`; 146 + } 147 + 148 + // Get the CSS font-family value with fallbacks 149 + export function getFontFamilyValue(font: FontConfig): string { 150 + const family = font.fontFamily.includes(" ") 151 + ? `'${font.fontFamily}'` 152 + : font.fontFamily; 153 + return [family, ...font.fallback].join(", "); 154 + }
+5 -1
src/replicache/attributes.ts
··· 187 187 } as const; 188 188 189 189 export const ThemeAttributes = { 190 - "theme/font": { 190 + "theme/heading-font": { 191 + type: "string", 192 + cardinality: "one", 193 + }, 194 + "theme/body-font": { 191 195 type: "string", 192 196 cardinality: "one", 193 197 },