a tool for shared writing and social publishing

Add custom fonts!

Squashed commit of the following:

commit 8b5625aa7bbdbc89ee3355f4b258ce00690b891f
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:11:02 2026 -0500

remove pub picker on subpage and fix font label

commit 4f9a2dc7c38e6abc56a76490ee77d728d5abb452
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 15:10:57 2026 -0500

support h4

commit e5d8f977bfdde8a6b97f561c09f320879f4baaa9
Merge: 4d41f6f4 43fa8794
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 13:57:36 2026 -0500

Merge branch 'main' into feature/fonts

commit 4d41f6f4b4454d565dcece8dc0663ece00e45287
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 11:56:35 2026 -0500

merge blockprops style better

commit 073e43d90f5bdf532b5192c712cf12f8bb6ee728
Author: Jared Pereira <jared@awarm.space>
Date: Fri Mar 6 11:12:01 2026 -0500

fix font sizes

commit 980fcd7d49beb6625ffd94f62dce7cfa0930b9f0
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 22:56:33 2026 -0500

handle header sizes and base size for fonts

commit 3e831c1f9ab1f545e5a153300790f29047631e4c
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 21:53:07 2026 -0500

fix undo manager

commit 025ac8d16765272a4fc354e3baf8ad468e922b8d
Author: Jared Pereira <jared@awarm.space>
Date: Thu Mar 5 21:43:46 2026 -0500

fix setting fonts

commit a1d30fc91a7b9874f3ef561c855837e5b224af29
Author: Jared Pereira <jared@awarm.space>
Date: Thu Feb 5 01:01:42 2026 +0000

add fonts

commit 83049703cc02efb4c5a4f94ad3dbfb43c50c07a3
Author: Jared Pereira <jared@awarm.space>
Date: Fri Jan 30 18:09:40 2026 -0500

wip support custom google fonts

commit 7235f59982a1a683a1f91da4f7db38dda3f9f73b
Author: Jared Pereira <jared@awarm.space>
Date: Thu Jan 22 17:10:18 2026 -0500

more fonts!!

commit 10da7354ddb83a15082b75e317c0b0e3cf971c9b
Author: Jared Pereira <jared@awarm.space>
Date: Thu Jan 22 13:49:15 2026 -0500

implemnt basic font logic

+1022 -65
+7 -1
app/[leaflet_id]/Leaflet.tsx
··· 18 18 token: PermissionToken; 19 19 initialFacts: Fact<Attribute>[]; 20 20 leaflet_id: string; 21 + initialHeadingFontId?: string; 22 + initialBodyFontId?: string; 21 23 }) { 22 24 return ( 23 25 <ReplicacheProvider ··· 29 31 <EntitySetProvider 30 32 set={props.token.permission_token_rights[0].entity_set} 31 33 > 32 - <ThemeProvider entityID={props.leaflet_id}> 34 + <ThemeProvider 35 + entityID={props.leaflet_id} 36 + initialHeadingFontId={props.initialHeadingFontId} 37 + initialBodyFontId={props.initialBodyFontId} 38 + > 33 39 <ThemeBackgroundProvider entityID={props.leaflet_id}> 34 40 <UpdateLeafletTitle entityID={props.leaflet_id} /> 35 41 <AddLeafletToHomepage />
+23 -12
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, extractFontsFromFacts } from "components/FontLoader"; 17 18 18 19 export const preferredRegion = ["sfo1"]; 19 20 export const dynamic = "force-dynamic"; ··· 48 49 getPollData(res.data.permission_token_rights.map((ptr) => ptr.entity_set)), 49 50 ]); 50 51 let initialFacts = (data as unknown as Fact<Attribute>[]) || []; 52 + 53 + // Extract font settings from facts for server-side font loading 54 + const { headingFontId, bodyFontId } = extractFontsFromFacts(initialFacts as any, rootEntity); 55 + 51 56 return ( 52 - <PageSWRDataProvider 53 - rsvp_data={rsvp_data} 54 - poll_data={poll_data} 55 - leaflet_id={res.data.id} 56 - leaflet_data={res} 57 - > 58 - <Leaflet 59 - initialFacts={initialFacts} 60 - leaflet_id={rootEntity} 61 - token={res.data} 62 - /> 63 - </PageSWRDataProvider> 57 + <> 58 + {/* Server-side font loading with preload and @font-face */} 59 + <FontLoader headingFontId={headingFontId} bodyFontId={bodyFontId} /> 60 + <PageSWRDataProvider 61 + rsvp_data={rsvp_data} 62 + poll_data={poll_data} 63 + leaflet_id={res.data.id} 64 + leaflet_data={res} 65 + > 66 + <Leaflet 67 + initialFacts={initialFacts} 68 + leaflet_id={rootEntity} 69 + token={res.data} 70 + initialHeadingFontId={headingFontId} 71 + initialBodyFontId={bodyFontId} 72 + /> 73 + </PageSWRDataProvider> 74 + </> 64 75 ); 65 76 } 66 77
+29 -4
app/globals.css
··· 193 193 @apply hover:underline; 194 194 } 195 195 196 - pre { 197 - font-family: var(--font-quattro); 198 - } 199 - 200 196 p { 201 197 font-size: inherit; 202 198 } ··· 204 200 ::placeholder { 205 201 @apply text-tertiary; 206 202 @apply italic; 203 + } 204 + 205 + /* Scope custom fonts to document content only (not sidebar/UI chrome) */ 206 + .pageScrollWrapper { 207 + font-family: var(--theme-font, var(--font-quattro)); 208 + font-size: var(--theme-font-base-size, 16px); 209 + } 210 + 211 + .pageScrollWrapper h1, 212 + .pageScrollWrapper h2, 213 + .pageScrollWrapper h3, 214 + .pageScrollWrapper h4 { 215 + font-family: var(--theme-heading-font, var(--theme-font, var(--font-quattro))); 216 + } 217 + 218 + /* Scale heading sizes relative to the custom base font size */ 219 + .pageScrollWrapper h1 { font-size: 2em; } 220 + .pageScrollWrapper h2 { font-size: 1.625em; } 221 + .pageScrollWrapper h3 { font-size: 1.125em; } 222 + .pageScrollWrapper h4 { font-size: 1em; } 223 + 224 + /* Scale text size classes relative to the custom base font size. 225 + Tailwind's text-sm/text-base/text-lg compile to fixed rem values 226 + which ignore the custom base size set on .pageScrollWrapper. */ 227 + .pageScrollWrapper .textSizeSmall { font-size: 0.875em; } 228 + .pageScrollWrapper .textSizeLarge { font-size: 1.125em; } 229 + 230 + .pageScrollWrapper pre { 231 + font-family: var(--theme-font, var(--font-quattro)); 207 232 } 208 233 /*END FONT STYLING*/ 209 234
+2 -2
app/layout.tsx
··· 39 39 const quattro = localFont({ 40 40 src: [ 41 41 { 42 - path: "../public/fonts/iAWriterQuattroV.ttf", 42 + path: "../public/fonts/iaw-quattro-vf.woff2", 43 43 style: "normal", 44 44 }, 45 45 { 46 - path: "../public/fonts/iAWriterQuattroV-Italic.ttf", 46 + path: "../public/fonts/iaw-quattro-vf-Italic.woff2", 47 47 style: "italic", 48 48 }, 49 49 ],
+1 -1
app/lish/[did]/[publication]/[rkey]/Blocks/PublishedPageBlock.tsx
··· 111 111 <div className="grow"> 112 112 {title && ( 113 113 <div 114 - className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold text-base" : ""}`} 114 + className={`pageBlockOne outline-none resize-none align-top gap-2 ${title.$type === "pub.leaflet.blocks.header" ? "font-bold" : ""}`} 115 115 > 116 116 <TextBlock 117 117 facets={title.facets}
+2
app/lish/[did]/[publication]/[rkey]/DocumentPageRenderer.tsx
··· 21 21 } from "src/utils/normalizeRecords"; 22 22 import { DocumentProvider } from "contexts/DocumentContext"; 23 23 import { LeafletContentProvider } from "contexts/LeafletContentContext"; 24 + import { FontLoader } from "components/FontLoader"; 24 25 import { mergePreferences } from "src/utils/mergePreferences"; 25 26 26 27 export async function DocumentPageRenderer({ ··· 122 123 return ( 123 124 <DocumentProvider value={document}> 124 125 <LeafletContentProvider value={{ pages }}> 126 + <FontLoader headingFontId={document.theme?.headingFont} bodyFontId={document.theme?.bodyFont} /> 125 127 <PublicationThemeProvider 126 128 theme={document.theme} 127 129 pub_creator={pub_creator}
+10 -8
app/lish/[did]/[publication]/[rkey]/PostContent.tsx
··· 33 33 import { PublishedPollBlock } from "./Blocks/PublishedPollBlock"; 34 34 import { PollData } from "./fetchPollData"; 35 35 import { ButtonPrimary } from "components/Buttons"; 36 + import { blockTextSize } from "src/utils/blockTextSize"; 36 37 import { PostNotAvailable } from "components/Blocks/BlueskyPostBlock/BlueskyEmbed"; 37 38 38 39 export function PostContent({ ··· 284 285 <div className="pt-2 pb-2 px-3 grow min-w-0"> 285 286 <div className="flex flex-col w-full min-w-0 h-full grow "> 286 287 <div 287 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 288 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 288 289 style={{ 289 290 overflow: "hidden", 290 291 textOverflow: "ellipsis", ··· 370 371 <p 371 372 className={`textBlock ${className} ${b.block.textSize === "small" ? "text-sm text-secondary" : b.block.textSize === "large" ? "text-lg" : ""}`} 372 373 {...blockProps} 374 + style={{ ...blockProps.style, fontSize: blockTextSize.p }} 373 375 > 374 376 <TextBlock 375 377 facets={b.block.facets} ··· 384 386 case PubLeafletBlocksHeader.isMain(b.block): { 385 387 if (b.block.level === 1) 386 388 return ( 387 - <h2 className={`h1Block ${className}`} {...blockProps}> 389 + <h1 className={`h1Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h1 }}> 388 390 <TextBlock 389 391 {...b.block} 390 392 index={index} 391 393 preview={preview} 392 394 pageId={pageId} 393 395 /> 394 - </h2> 396 + </h1> 395 397 ); 396 398 if (b.block.level === 2) 397 399 return ( 398 - <h3 className={`h2Block ${className}`} {...blockProps}> 400 + <h2 className={`h2Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h2 }}> 399 401 <TextBlock 400 402 {...b.block} 401 403 index={index} 402 404 preview={preview} 403 405 pageId={pageId} 404 406 /> 405 - </h3> 407 + </h2> 406 408 ); 407 409 if (b.block.level === 3) 408 410 return ( 409 - <h4 className={`h3Block ${className}`} {...blockProps}> 411 + <h3 className={`h3Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h3 }}> 410 412 <TextBlock 411 413 {...b.block} 412 414 index={index} 413 415 preview={preview} 414 416 pageId={pageId} 415 417 /> 416 - </h4> 418 + </h3> 417 419 ); 418 420 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 419 421 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 420 422 return ( 421 - <h6 className={`h6Block ${className}`} {...blockProps}> 423 + <h6 className={`h6Block ${className}`} {...blockProps} style={{ ...blockProps.style, fontSize: blockTextSize.h4 }}> 422 424 <TextBlock 423 425 {...b.block} 424 426 index={index}
+6 -5
app/lish/[did]/[publication]/[rkey]/StaticPostContent.tsx
··· 12 12 PubLeafletPagesLinearDocument, 13 13 } from "lexicons/api"; 14 14 import { blobRefToSrc } from "src/utils/blobRefToSrc"; 15 + import { blockTextSize } from "src/utils/blockTextSize"; 15 16 import { TextBlockCore, TextBlockCoreProps } from "./Blocks/TextBlockCore"; 16 17 import { StaticMathBlock } from "./Blocks/StaticMathBlock"; 17 18 import { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } from "shiki"; ··· 119 120 } 120 121 case PubLeafletBlocksText.isMain(b.block): 121 122 return ( 122 - <p> 123 + <p style={{ fontSize: blockTextSize.p }}> 123 124 <StaticBaseTextBlock 124 125 facets={b.block.facets} 125 126 plaintext={b.block.plaintext} ··· 130 131 case PubLeafletBlocksHeader.isMain(b.block): { 131 132 if (b.block.level === 1) 132 133 return ( 133 - <h1> 134 + <h1 style={{ fontSize: blockTextSize.h1 }}> 134 135 <StaticBaseTextBlock {...b.block} index={[]} /> 135 136 </h1> 136 137 ); 137 138 if (b.block.level === 2) 138 139 return ( 139 - <h2> 140 + <h2 style={{ fontSize: blockTextSize.h2 }}> 140 141 <StaticBaseTextBlock {...b.block} index={[]} /> 141 142 </h2> 142 143 ); 143 144 if (b.block.level === 3) 144 145 return ( 145 - <h3> 146 + <h3 style={{ fontSize: blockTextSize.h3 }}> 146 147 <StaticBaseTextBlock {...b.block} index={[]} /> 147 148 </h3> 148 149 ); 149 150 // if (b.block.level === 4) return <h4>{b.block.plaintext}</h4>; 150 151 // if (b.block.level === 5) return <h5>{b.block.plaintext}</h5>; 151 152 return ( 152 - <h6> 153 + <h6 style={{ fontSize: blockTextSize.h4 }}> 153 154 <StaticBaseTextBlock {...b.block} index={[]} /> 154 155 </h6> 155 156 );
+4
app/lish/createPub/updatePublication.ts
··· 319 319 showPageBackground: boolean; 320 320 accentBackground: Color; 321 321 accentText: Color; 322 + headingFont?: string; 323 + bodyFont?: string; 322 324 }; 323 325 }): Promise<UpdatePublicationResult> { 324 326 return withPublicationUpdate( ··· 361 363 accentText: { 362 364 ...theme.accentText, 363 365 }, 366 + headingFont: theme.headingFont, 367 + bodyFont: theme.bodyFont, 364 368 }; 365 369 366 370 // Derive basicTheme from the theme colors for site.standard.publication
+1 -1
components/Blocks/ExternalLinkBlock.tsx
··· 78 78 <div className="pt-2 pb-2 px-3 grow min-w-0"> 79 79 <div className="flex flex-col w-full min-w-0 h-full grow "> 80 80 <div 81 - className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 81 + className={`linkBlockTitle bg-transparent -mb-0.5 border-none font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`} 82 82 style={{ 83 83 overflow: "hidden", 84 84 textOverflow: "ellipsis",
+1 -1
components/Blocks/PageLinkBlock.tsx
··· 96 96 <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip "> 97 97 {leafletMetadata[0] && ( 98 98 <div 99 - className={`pageBlockOne outline-hidden resize-none align-top flex gap-2 ${leafletMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 99 + className={`pageBlockOne outline-hidden resize-none align-top flex gap-2 ${leafletMetadata[0].type === "heading" ? "font-bold" : ""}`} 100 100 > 101 101 {leafletMetadata[0].listData && ( 102 102 <ListMarker
+27 -10
components/Blocks/TextBlock/index.tsx
··· 25 25 import { DotLoader } from "components/utils/DotLoader"; 26 26 import { useMountProsemirror } from "./mountProsemirror"; 27 27 import { schema } from "./schema"; 28 + import { blockTextSize } from "src/utils/blockTextSize"; 28 29 29 30 import { Mention, MentionAutocomplete } from "components/Mention"; 30 31 import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror"; 31 32 32 33 const HeadingStyle = { 33 - 1: "text-xl font-bold", 34 - 2: "text-lg font-bold", 35 - 3: "text-base font-bold text-secondary ", 34 + 1: "font-bold [font-family:var(--theme-heading-font)]", 35 + 2: "font-bold [font-family:var(--theme-heading-font)]", 36 + 3: "font-bold text-secondary [font-family:var(--theme-heading-font)]", 37 + 4: "font-bold text-secondary [font-family:var(--theme-heading-font)]", 38 + } as { [level: number]: string }; 39 + 40 + const headingFontSize = { 41 + 1: blockTextSize.h1, 42 + 2: blockTextSize.h2, 43 + 3: blockTextSize.h3, 44 + 4: blockTextSize.h4, 36 45 } as { [level: number]: string }; 37 46 38 47 export function TextBlock( ··· 127 136 }[alignment]; 128 137 let textStyle = 129 138 textSize?.data.value === "small" 130 - ? "text-sm" 139 + ? "textSizeSmall" 131 140 : textSize?.data.value === "large" 132 - ? "text-lg" 141 + ? "textSizeLarge" 133 142 : ""; 134 143 let { permissions } = useEntitySetContext(); 135 144 ··· 158 167 } 159 168 return ( 160 169 <div 161 - style={{ wordBreak: "break-word" }} // better than tailwind break-all! 170 + style={{ 171 + wordBreak: "break-word", 172 + ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 173 + }} 162 174 className={` 163 175 ${alignmentClass} 164 176 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} ··· 190 202 }[alignment]; 191 203 let textStyle = 192 204 textSize?.data.value === "small" 193 - ? "text-sm text-secondary" 205 + ? "textSizeSmall text-secondary" 194 206 : textSize?.data.value === "large" 195 - ? "text-lg text-primary" 196 - : "text-base text-primary"; 207 + ? "textSizeLarge text-primary" 208 + : "text-primary"; 197 209 198 210 let editorState = useEditorStates( 199 211 (s) => s.editorStates[props.entityID], ··· 262 274 // unless we break *only* on urls, this is better than tailwind 'break-all' 263 275 // b/c break-all can cause breaks in the middle of words, but break-word still 264 276 // forces break if a single text string (e.g. a url) spans more than a full line 265 - style={{ wordBreak: "break-word" }} 277 + style={{ 278 + wordBreak: "break-word", 279 + fontFamily: props.type === "heading" ? "var(--theme-heading-font)" : "var(--theme-font)", 280 + ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), 281 + }} 266 282 className={` 267 283 ${alignmentClass} 268 284 grow resize-none align-top whitespace-pre-wrap bg-transparent ··· 286 302 props.nextBlock === null ? ( 287 303 // if this is the only block on the page and is empty or is a canvas, show placeholder 288 304 <div 305 + style={props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : undefined} 289 306 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 290 307 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} 291 308 `}
+1 -1
components/Blocks/TextBlock/inputRules.ts
··· 210 210 }), 211 211 212 212 //Header 213 - new InputRule(/^([#]{1,3})\s$/, (state, match) => { 213 + new InputRule(/^([#]{1,4})\s$/, (state, match) => { 214 214 let tr = state.tr; 215 215 tr.delete(0, match[0].length); 216 216 let headingLevel = match[1].length;
+5
components/Blocks/TextBlock/useHandlePaste.ts
··· 244 244 type = "heading"; 245 245 break; 246 246 } 247 + case "H4": { 248 + headingLevel = 4; 249 + type = "heading"; 250 + break; 251 + } 247 252 case "DIV": { 248 253 type = "card"; 249 254 break;
+121
components/FontLoader.tsx
··· 1 + // Server-side font loading component 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 6 + 7 + import { 8 + getFontConfig, 9 + generateFontFaceCSS, 10 + getFontPreloadLinks, 11 + getGoogleFontsUrl, 12 + getFontFamilyValue, 13 + getFontBaseSize, 14 + } from "src/fonts"; 15 + 16 + type FontLoaderProps = { 17 + headingFontId: string | undefined; 18 + bodyFontId: string | undefined; 19 + }; 20 + 21 + export function FontLoader({ headingFontId, bodyFontId }: FontLoaderProps) { 22 + const headingFont = getFontConfig(headingFontId); 23 + const bodyFont = getFontConfig(bodyFontId); 24 + 25 + // Collect all unique fonts to load 26 + const fontsToLoad = headingFont.id === bodyFont.id 27 + ? [headingFont] 28 + : [headingFont, bodyFont]; 29 + 30 + // Collect preload links (deduplicated) 31 + const preloadLinksSet = new Set<string>(); 32 + const preloadLinks: { href: string; type: string }[] = []; 33 + for (const font of fontsToLoad) { 34 + for (const link of getFontPreloadLinks(font)) { 35 + if (!preloadLinksSet.has(link.href)) { 36 + preloadLinksSet.add(link.href); 37 + preloadLinks.push(link); 38 + } 39 + } 40 + } 41 + 42 + // Collect font-face CSS 43 + const fontFaceCSS = fontsToLoad 44 + .map((font) => generateFontFaceCSS(font)) 45 + .filter(Boolean) 46 + .join("\n\n"); 47 + 48 + // Collect Google Fonts URLs (deduplicated) 49 + const googleFontsUrls = [...new Set( 50 + fontsToLoad 51 + .map((font) => getGoogleFontsUrl(font)) 52 + .filter((url): url is string => url !== null) 53 + )]; 54 + 55 + const headingFontValue = getFontFamilyValue(headingFont); 56 + const bodyFontValue = getFontFamilyValue(bodyFont); 57 + const bodyFontBaseSize = getFontBaseSize(bodyFont); 58 + 59 + // Set font CSS variables scoped to .leafletWrapper so they don't affect app UI 60 + const fontVariableCSS = ` 61 + .leafletWrapper { 62 + --theme-heading-font: ${headingFontValue}; 63 + --theme-font: ${bodyFontValue}; 64 + --theme-font-base-size: ${bodyFontBaseSize}px; 65 + } 66 + `.trim(); 67 + 68 + return ( 69 + <> 70 + {/* 71 + Google Fonts best practice: preconnect to both origins 72 + - fonts.googleapis.com serves the CSS 73 + - fonts.gstatic.com serves the font files (needs crossorigin for CORS) 74 + Place these as early as possible in <head> 75 + */} 76 + {googleFontsUrls.length > 0 && ( 77 + <> 78 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 79 + <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" /> 80 + {googleFontsUrls.map((url) => ( 81 + <link key={url} rel="stylesheet" href={url} /> 82 + ))} 83 + </> 84 + )} 85 + {/* Preload local font files for early discovery */} 86 + {preloadLinks.map((link) => ( 87 + <link 88 + key={link.href} 89 + rel="preload" 90 + href={link.href} 91 + as="font" 92 + type={link.type} 93 + crossOrigin="anonymous" 94 + /> 95 + ))} 96 + {/* @font-face declarations (for local fonts) and CSS variable */} 97 + <style 98 + dangerouslySetInnerHTML={{ 99 + __html: `${fontFaceCSS}\n\n${fontVariableCSS}`, 100 + }} 101 + /> 102 + </> 103 + ); 104 + } 105 + 106 + // Helper to extract fonts from facts array (for server-side use) 107 + export function extractFontsFromFacts( 108 + facts: Array<{ entity: string; attribute: string; data: { value: string } }>, 109 + rootEntity: string 110 + ): { headingFontId: string | undefined; bodyFontId: string | undefined } { 111 + const headingFontFact = facts.find( 112 + (f) => f.entity === rootEntity && f.attribute === "theme/heading-font" 113 + ); 114 + const bodyFontFact = facts.find( 115 + (f) => f.entity === rootEntity && f.attribute === "theme/body-font" 116 + ); 117 + return { 118 + headingFontId: headingFontFact?.data?.value, 119 + bodyFontId: bodyFontFact?.data?.value, 120 + }; 121 + }
+1
components/ThemeManager/PageThemeSetter.tsx
··· 66 66 entityID={props.entityID} 67 67 openPicker={openPicker} 68 68 setOpenPicker={(pickers) => setOpenPicker(pickers)} 69 + hideFonts 69 70 /> 70 71 </div> 71 72 <AccentPickers
+9
components/ThemeManager/Pickers/PageThemePickers.tsx
··· 21 21 import { ImageInput, ImageSettings } from "./ImagePicker"; 22 22 23 23 import { ColorPicker, thumbStyle } from "./ColorPicker"; 24 + import { FontPicker } from "./TextPickers"; 24 25 import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 25 26 import { Replicache } from "replicache"; 26 27 import { CanvasBackgroundPattern } from "components/Canvas"; ··· 31 32 entityID: string; 32 33 openPicker: pickers; 33 34 setOpenPicker: (thisPicker: pickers) => void; 35 + home?: boolean; 36 + hideFonts?: boolean; 34 37 }) => { 35 38 let { rep } = useReplicache(); 36 39 let set = useMemo(() => { ··· 57 60 openPicker={props.openPicker} 58 61 setOpenPicker={props.setOpenPicker} 59 62 /> 63 + {!props.home && !props.hideFonts && ( 64 + <> 65 + <FontPicker label="Heading" entityID={props.entityID} attribute="theme/heading-font" /> 66 + <FontPicker label="Body" entityID={props.entityID} attribute="theme/body-font" /> 67 + </> 68 + )} 60 69 </div> 61 70 ); 62 71 };
+214
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 { 13 + fonts, 14 + defaultFontId, 15 + FontConfig, 16 + isCustomFontId, 17 + parseGoogleFontInput, 18 + createCustomFontId, 19 + getFontConfig, 20 + } from "src/fonts"; 21 + 22 + export const TextColorPicker = (props: { 23 + openPicker: pickers; 24 + setOpenPicker: (thisPicker: pickers) => void; 25 + value: Color; 26 + setValue: (c: Color) => void; 27 + }) => { 28 + return ( 29 + <ColorPicker 30 + label="Text" 31 + value={props.value} 32 + setValue={props.setValue} 33 + thisPicker={"text"} 34 + openPicker={props.openPicker} 35 + setOpenPicker={props.setOpenPicker} 36 + closePicker={() => props.setOpenPicker("null")} 37 + /> 38 + ); 39 + }; 40 + 41 + type FontAttribute = "theme/heading-font" | "theme/body-font"; 42 + 43 + export const FontPicker = (props: { 44 + label: string; 45 + entityID: string; 46 + attribute: FontAttribute; 47 + }) => { 48 + let isMobile = useIsMobile(); 49 + let { rep } = useReplicache(); 50 + let [showCustomInput, setShowCustomInput] = useState(false); 51 + let [customFontValue, setCustomFontValue] = useState(""); 52 + let currentFont = useEntity(props.entityID, props.attribute); 53 + let fontId = currentFont?.data.value || defaultFontId; 54 + let font = getFontConfig(fontId); 55 + let isCustom = isCustomFontId(fontId); 56 + 57 + let fontList = Object.values(fonts).sort((a, b) => 58 + a.displayName.localeCompare(b.displayName), 59 + ); 60 + 61 + const handleCustomSubmit = () => { 62 + const parsed = parseGoogleFontInput(customFontValue); 63 + if (parsed) { 64 + const customId = createCustomFontId( 65 + parsed.fontName, 66 + parsed.googleFontsFamily, 67 + ); 68 + rep?.mutate.assertFact({ 69 + entity: props.entityID, 70 + attribute: props.attribute, 71 + data: { type: "string", value: customId }, 72 + }); 73 + setShowCustomInput(false); 74 + setCustomFontValue(""); 75 + } 76 + }; 77 + 78 + return ( 79 + <Menu 80 + asChild 81 + trigger={ 82 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 83 + <div 84 + 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"}`} 85 + > 86 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 87 + Aa 88 + </div> 89 + </div> 90 + <div className="font-bold shrink-0">{props.label}</div> 91 + <div className="truncate">{font.displayName}</div> 92 + </button> 93 + } 94 + side={isMobile ? "bottom" : "right"} 95 + align="start" 96 + className="w-[250px] !gap-0 !outline-none max-h-72 " 97 + > 98 + {showCustomInput ? ( 99 + <div className="p-2 flex flex-col gap-2"> 100 + <div className="text-sm text-secondary"> 101 + Paste a Google Font name 102 + </div> 103 + <Input 104 + value={customFontValue} 105 + className="w-full" 106 + placeholder="e.g. Roboto, Open Sans, Playfair Display" 107 + autoFocus 108 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 109 + onKeyDown={(e) => { 110 + if (e.key === "Enter") { 111 + e.preventDefault(); 112 + handleCustomSubmit(); 113 + } else if (e.key === "Escape") { 114 + setShowCustomInput(false); 115 + setCustomFontValue(""); 116 + } 117 + }} 118 + /> 119 + <div className="flex gap-2"> 120 + <button 121 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 122 + onClick={handleCustomSubmit} 123 + > 124 + Add Font 125 + </button> 126 + <button 127 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 128 + onClick={() => { 129 + setShowCustomInput(false); 130 + setCustomFontValue(""); 131 + }} 132 + > 133 + Cancel 134 + </button> 135 + </div> 136 + </div> 137 + ) : ( 138 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 139 + {fontList.map((fontOption) => { 140 + return ( 141 + <FontOption 142 + key={fontOption.id} 143 + onSelect={() => { 144 + rep?.mutate.assertFact({ 145 + entity: props.entityID, 146 + attribute: props.attribute, 147 + data: { type: "string", value: fontOption.id }, 148 + }); 149 + }} 150 + font={fontOption} 151 + selected={fontOption.id === fontId} 152 + /> 153 + ); 154 + })} 155 + {isCustom && ( 156 + <FontOption 157 + key={fontId} 158 + onSelect={() => {}} 159 + font={font} 160 + selected={true} 161 + /> 162 + )} 163 + <hr className="mx-2 my-1 border-border" /> 164 + <DropdownMenu.Item 165 + onSelect={(e) => { 166 + e.preventDefault(); 167 + setShowCustomInput(true); 168 + }} 169 + className={` 170 + fontOption 171 + z-10 px-1 py-0.5 172 + text-left text-secondary 173 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 174 + hover:bg-border-light hover:text-secondary 175 + outline-none 176 + cursor-pointer 177 + `} 178 + > 179 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 180 + </DropdownMenu.Item> 181 + </div> 182 + )} 183 + </Menu> 184 + ); 185 + }; 186 + 187 + const FontOption = (props: { 188 + onSelect: () => void; 189 + font: FontConfig; 190 + selected: boolean; 191 + }) => { 192 + return ( 193 + <DropdownMenu.RadioItem 194 + value={props.font.id} 195 + onSelect={props.onSelect} 196 + className={` 197 + fontOption 198 + z-10 px-1 py-0.5 199 + text-left text-secondary 200 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 201 + hover:bg-border-light hover:text-secondary 202 + outline-none 203 + cursor-pointer 204 + 205 + `} 206 + > 207 + <div 208 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 209 + > 210 + {props.font.displayName} 211 + </div> 212 + </DropdownMenu.RadioItem> 213 + ); 214 + };
+179
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 { 9 + fonts, 10 + defaultFontId, 11 + FontConfig, 12 + isCustomFontId, 13 + parseGoogleFontInput, 14 + createCustomFontId, 15 + getFontConfig, 16 + } from "src/fonts"; 17 + 18 + export const PubFontPicker = (props: { 19 + label: string; 20 + value: string | undefined; 21 + onChange: (fontId: string) => void; 22 + }) => { 23 + let isMobile = useIsMobile(); 24 + let [showCustomInput, setShowCustomInput] = useState(false); 25 + let [customFontValue, setCustomFontValue] = useState(""); 26 + let fontId = props.value || defaultFontId; 27 + let font = getFontConfig(fontId); 28 + let isCustom = isCustomFontId(fontId); 29 + 30 + let fontList = Object.values(fonts).sort((a, b) => 31 + a.displayName.localeCompare(b.displayName), 32 + ); 33 + 34 + const handleCustomSubmit = () => { 35 + const parsed = parseGoogleFontInput(customFontValue); 36 + if (parsed) { 37 + const customId = createCustomFontId( 38 + parsed.fontName, 39 + parsed.googleFontsFamily, 40 + ); 41 + props.onChange(customId); 42 + setShowCustomInput(false); 43 + setCustomFontValue(""); 44 + } 45 + }; 46 + 47 + return ( 48 + <Menu 49 + asChild 50 + trigger={ 51 + <button className="flex gap-2 items-center w-full !outline-none min-w-0"> 52 + <div 53 + 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"}`} 54 + > 55 + <div className="absolute top-1/2 left-1/2 -translate-y-1/2 -translate-x-1/2 "> 56 + Aa 57 + </div> 58 + </div> 59 + <div className="font-bold shrink-0">{props.label}</div> 60 + <div className="truncate">{font.displayName}</div> 61 + </button> 62 + } 63 + side={isMobile ? "bottom" : "right"} 64 + align="start" 65 + className="w-[250px] !gap-0 !outline-none max-h-72 " 66 + > 67 + {showCustomInput ? ( 68 + <div className="p-2 flex flex-col gap-2"> 69 + <div className="text-sm text-secondary"> 70 + Paste a Google Font name 71 + </div> 72 + <Input 73 + value={customFontValue} 74 + className="w-full" 75 + placeholder="e.g. Roboto, Open Sans, Playfair Display" 76 + autoFocus 77 + onChange={(e) => setCustomFontValue(e.currentTarget.value)} 78 + onKeyDown={(e) => { 79 + if (e.key === "Enter") { 80 + e.preventDefault(); 81 + handleCustomSubmit(); 82 + } else if (e.key === "Escape") { 83 + setShowCustomInput(false); 84 + setCustomFontValue(""); 85 + } 86 + }} 87 + /> 88 + <div className="flex gap-2"> 89 + <button 90 + className="flex-1 px-2 py-1 text-sm rounded-md bg-accent-1 text-accent-2 hover:opacity-80" 91 + onClick={handleCustomSubmit} 92 + > 93 + Add Font 94 + </button> 95 + <button 96 + className="px-2 py-1 text-sm rounded-md text-secondary hover:bg-border-light" 97 + onClick={() => { 98 + setShowCustomInput(false); 99 + setCustomFontValue(""); 100 + }} 101 + > 102 + Cancel 103 + </button> 104 + </div> 105 + </div> 106 + ) : ( 107 + <div className="flex flex-col h-full overflow-auto gap-0 py-1"> 108 + {fontList.map((fontOption) => { 109 + return ( 110 + <FontOption 111 + key={fontOption.id} 112 + onSelect={() => { 113 + props.onChange(fontOption.id); 114 + }} 115 + font={fontOption} 116 + selected={fontOption.id === fontId} 117 + /> 118 + ); 119 + })} 120 + {isCustom && ( 121 + <FontOption 122 + key={fontId} 123 + onSelect={() => {}} 124 + font={font} 125 + selected={true} 126 + /> 127 + )} 128 + <hr className="mx-2 my-1 border-border" /> 129 + <DropdownMenu.Item 130 + onSelect={(e) => { 131 + e.preventDefault(); 132 + setShowCustomInput(true); 133 + }} 134 + className={` 135 + fontOption 136 + z-10 px-1 py-0.5 137 + text-left text-secondary 138 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 139 + hover:bg-border-light hover:text-secondary 140 + outline-none 141 + cursor-pointer 142 + `} 143 + > 144 + <div className="px-2 py-0 rounded-md">Custom Google Font...</div> 145 + </DropdownMenu.Item> 146 + </div> 147 + )} 148 + </Menu> 149 + ); 150 + }; 151 + 152 + const FontOption = (props: { 153 + onSelect: () => void; 154 + font: FontConfig; 155 + selected: boolean; 156 + }) => { 157 + return ( 158 + <DropdownMenu.RadioItem 159 + value={props.font.id} 160 + onSelect={props.onSelect} 161 + className={` 162 + fontOption 163 + z-10 px-1 py-0.5 164 + text-left text-secondary 165 + data-[highlighted]:bg-border-light data-[highlighted]:text-secondary 166 + hover:bg-border-light hover:text-secondary 167 + outline-none 168 + cursor-pointer 169 + 170 + `} 171 + > 172 + <div 173 + className={`px-2 py-0 rounded-md ${props.selected && "bg-accent-1 text-accent-2"}`} 174 + > 175 + {props.font.displayName} 176 + </div> 177 + </DropdownMenu.RadioItem> 178 + ); 179 + };
-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(); ··· 92 95 primary: ColorToRGB(localPubTheme.primary), 93 96 accentBackground: ColorToRGB(localPubTheme.accent1), 94 97 accentText: ColorToRGB(localPubTheme.accent2), 98 + headingFont: headingFont, 99 + bodyFont: bodyFont, 95 100 }, 96 101 }); 97 102 ··· 196 201 setOpenPicker={(pickers) => setOpenPicker(pickers)} 197 202 hasPageBackground={showPageBackground} 198 203 /> 204 + <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"> 205 + <PubFontPicker 206 + label="Heading" 207 + value={headingFont} 208 + onChange={setHeadingFont} 209 + /> 210 + <PubFontPicker 211 + label="Body" 212 + value={bodyFont} 213 + onChange={setBodyFont} 214 + /> 215 + </div> 199 216 <PubAccentPickers 200 217 accent1={localPubTheme.accent1} 201 218 setAccent1={(color) => {
+5
components/ThemeManager/PublicationThemeProvider.tsx
··· 145 145 let highlight2 = useColorAttribute(null, "theme/highlight-2"); 146 146 let highlight3 = useColorAttribute(null, "theme/highlight-3"); 147 147 148 + let headingFontId = theme?.headingFont; 149 + let bodyFontId = theme?.bodyFont; 150 + 148 151 return { 149 152 bgLeaflet, 150 153 bgPage, ··· 156 159 highlight3, 157 160 showPageBackground, 158 161 pageWidth, 162 + headingFontId, 163 + bodyFontId, 159 164 }; 160 165 }; 161 166
+66
components/ThemeManager/ThemeProvider.tsx
··· 29 29 PublicationThemeProvider, 30 30 } from "./PublicationThemeProvider"; 31 31 import { getColorDifference } from "./themeUtils"; 32 + import { getFontConfig, getGoogleFontsUrl, getFontFamilyValue, generateFontFaceCSS, getFontBaseSize } from "src/fonts"; 32 33 33 34 // define a function to set an Aria Color to a CSS Variable in RGB 34 35 function setCSSVariableToColor( ··· 45 46 local?: boolean; 46 47 children: React.ReactNode; 47 48 className?: string; 49 + initialHeadingFontId?: string; 50 + initialBodyFontId?: string; 48 51 }) { 49 52 let { data: pub, normalizedPublication } = useLeafletPublicationData(); 50 53 if (!pub || !pub.publications) return <LeafletThemeProvider {...props} />; ··· 63 66 entityID: string | null; 64 67 local?: boolean; 65 68 children: React.ReactNode; 69 + initialHeadingFontId?: string; 70 + initialBodyFontId?: string; 66 71 }) { 67 72 let bgLeaflet = useColorAttribute(props.entityID, "theme/page-background"); 68 73 let bgPage = useColorAttribute(props.entityID, "theme/card-background"); ··· 83 88 let accent2 = useColorAttribute(props.entityID, "theme/accent-text"); 84 89 85 90 let pageWidth = useEntity(props.entityID, "theme/page-width"); 91 + // Use initial font IDs as fallback until Replicache syncs 92 + let headingFontId = useEntity(props.entityID, "theme/heading-font")?.data.value ?? props.initialHeadingFontId; 93 + let bodyFontId = useEntity(props.entityID, "theme/body-font")?.data.value ?? props.initialBodyFontId; 86 94 87 95 return ( 88 96 <CardBorderHiddenContext.Provider value={!!cardBorderHiddenValue}> ··· 100 108 showPageBackground={showPageBackground} 101 109 pageWidth={pageWidth?.data.value} 102 110 hasBackgroundImage={hasBackgroundImage} 111 + headingFontId={headingFontId} 112 + bodyFontId={bodyFontId} 103 113 > 104 114 {props.children} 105 115 </BaseThemeProvider> ··· 122 132 showPageBackground, 123 133 pageWidth, 124 134 hasBackgroundImage, 135 + headingFontId, 136 + bodyFontId, 125 137 className, 126 138 children, 127 139 }: { ··· 137 149 highlight2: AriaColor; 138 150 highlight3: AriaColor; 139 151 pageWidth?: number; 152 + headingFontId?: string; 153 + bodyFontId?: string; 140 154 className?: string; 141 155 children: React.ReactNode; 142 156 }) => { ··· 178 192 accentContrast = sortedAccents[0]; 179 193 } 180 194 195 + // Get font configs for CSS variables 196 + const headingFontConfig = getFontConfig(headingFontId); 197 + const bodyFontConfig = getFontConfig(bodyFontId); 198 + const headingFontValue = getFontFamilyValue(headingFontConfig); 199 + const bodyFontValue = getFontFamilyValue(bodyFontConfig); 200 + const bodyFontBaseSize = getFontBaseSize(bodyFontConfig); 201 + const headingGoogleFontsUrl = getGoogleFontsUrl(headingFontConfig); 202 + const bodyGoogleFontsUrl = getGoogleFontsUrl(bodyFontConfig); 203 + 204 + // Dynamically load Google Fonts when fonts change 205 + useEffect(() => { 206 + const loadGoogleFont = (url: string | null, fontFamily: string) => { 207 + if (!url) return; 208 + 209 + // Check if this font stylesheet is already in the document 210 + const existingLink = document.querySelector(`link[href="${url}"]`); 211 + if (existingLink) return; 212 + 213 + // Add preconnect hints if not present 214 + if (!document.querySelector('link[href="https://fonts.googleapis.com"]')) { 215 + const preconnect1 = document.createElement("link"); 216 + preconnect1.rel = "preconnect"; 217 + preconnect1.href = "https://fonts.googleapis.com"; 218 + document.head.appendChild(preconnect1); 219 + 220 + const preconnect2 = document.createElement("link"); 221 + preconnect2.rel = "preconnect"; 222 + preconnect2.href = "https://fonts.gstatic.com"; 223 + preconnect2.crossOrigin = "anonymous"; 224 + document.head.appendChild(preconnect2); 225 + } 226 + 227 + // Load the Google Font stylesheet 228 + const link = document.createElement("link"); 229 + link.rel = "stylesheet"; 230 + link.href = url; 231 + document.head.appendChild(link); 232 + 233 + // Wait for the font to actually load before it gets applied 234 + if (document.fonts?.load) { 235 + document.fonts.load(`1em "${fontFamily}"`); 236 + } 237 + }; 238 + 239 + loadGoogleFont(headingGoogleFontsUrl, headingFontConfig.fontFamily); 240 + loadGoogleFont(bodyGoogleFontsUrl, bodyFontConfig.fontFamily); 241 + }, [headingGoogleFontsUrl, bodyGoogleFontsUrl, headingFontConfig.fontFamily, bodyFontConfig.fontFamily]); 242 + 181 243 useEffect(() => { 182 244 if (local) return; 183 245 let el = document.querySelector(":root") as HTMLElement; ··· 226 288 "--page-width-setting", 227 289 (pageWidth || 624).toString(), 228 290 ); 291 + 229 292 }, [ 230 293 local, 231 294 bgLeaflet, ··· 260 323 "--page-width-setting": pageWidth || 624, 261 324 "--page-width-unitless": pageWidth || 624, 262 325 "--page-width-units": `min(${pageWidth || 624}px, calc(100vw - 12px))`, 326 + "--theme-heading-font": headingFontValue, 327 + "--theme-font": bodyFontValue, 328 + "--theme-font-base-size": `${bodyFontBaseSize}px`, 263 329 } as CSSProperties 264 330 } 265 331 >
+3 -1
components/ThemeManager/ThemeSetter.tsx
··· 156 156 entityID={props.entityID} 157 157 openPicker={openPicker} 158 158 setOpenPicker={(pickers) => setOpenPicker(pickers)} 159 + home={props.home} 159 160 /> 160 161 <div className="flex flex-col -gap-[6px]"> 161 162 <div className={`flex flex-col z-10 -mb-[6px] `}> ··· 187 188 </div> 188 189 ); 189 190 }; 191 + 190 192 function WatermarkSetter(props: { entityID: string }) { 191 193 let { rep } = useReplicache(); 192 194 let checked = useEntity(props.entityID, "theme/page-leaflet-watermark"); ··· 300 302 onClick={() => { 301 303 props.setOpenPicker("text"); 302 304 }} 303 - className="cursor-pointer font-bold w-fit" 305 + className="cursor-pointer font-bold w-fit [font-family:var(--theme-heading-font)]" 304 306 > 305 307 Hello! 306 308 </p>
+8
lexicons/api/lexicons.ts
··· 1995 1995 'lex:pub.leaflet.theme.color#rgb', 1996 1996 ], 1997 1997 }, 1998 + headingFont: { 1999 + type: 'string', 2000 + maxLength: 100, 2001 + }, 2002 + bodyFont: { 2003 + type: 'string', 2004 + maxLength: 100, 2005 + }, 1998 2006 }, 1999 2007 }, 2000 2008 },
+2
lexicons/api/types/pub/leaflet/publication.ts
··· 77 77 | $Typed<PubLeafletThemeColor.Rgba> 78 78 | $Typed<PubLeafletThemeColor.Rgb> 79 79 | { $type: string } 80 + headingFont?: string 81 + bodyFont?: string 80 82 } 81 83 82 84 const hashTheme = 'theme'
+8
lexicons/pub/leaflet/publication.json
··· 116 116 "pub.leaflet.theme.color#rgba", 117 117 "pub.leaflet.theme.color#rgb" 118 118 ] 119 + }, 120 + "headingFont": { 121 + "type": "string", 122 + "maxLength": 100 123 + }, 124 + "bodyFont": { 125 + "type": "string", 126 + "maxLength": 100 119 127 } 120 128 } 121 129 }
+2
lexicons/src/publication.ts
··· 50 50 showPageBackground: { type: "boolean", default: false }, 51 51 accentBackground: ColorUnion, 52 52 accentText: ColorUnion, 53 + headingFont: { type: "string", maxLength: 100 }, 54 + bodyFont: { type: "string", maxLength: 100 }, 53 55 }, 54 56 }, 55 57 },
public/fonts/Lora-Italic-Variable.woff2

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/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.

+252
src/fonts.ts
··· 1 + // Font configuration for self-hosted and Google Fonts 2 + // This replicates what next/font does but allows dynamic selection per-leaflet 3 + 4 + export type FontConfig = { 5 + id: string; 6 + displayName: string; 7 + fontFamily: string; 8 + fallback: string[]; 9 + baseSize?: number; // base font size in px for document content 10 + } & ( 11 + | { 12 + // Self-hosted fonts with local files 13 + type: "local"; 14 + files: { 15 + path: string; 16 + style: "normal" | "italic"; 17 + weight?: string; 18 + }[]; 19 + } 20 + | { 21 + // Google Fonts loaded via CDN 22 + type: "google"; 23 + googleFontsFamily: string; // e.g., "Open+Sans:ital,wght@0,400;0,700;1,400;1,700" 24 + } 25 + | { 26 + // System fonts (no loading required) 27 + type: "system"; 28 + } 29 + ); 30 + 31 + export const fonts: Record<string, FontConfig> = { 32 + // Self-hosted variable fonts (WOFF2) 33 + quattro: { 34 + id: "quattro", 35 + displayName: "iA Writer Quattro", 36 + fontFamily: "iA Writer Quattro V", 37 + baseSize: 16, 38 + type: "local", 39 + files: [ 40 + { 41 + path: "/fonts/iaw-quattro-vf.woff2", 42 + style: "normal", 43 + weight: "400 700", 44 + }, 45 + { 46 + path: "/fonts/iaw-quattro-vf-Italic.woff2", 47 + style: "italic", 48 + weight: "400 700", 49 + }, 50 + ], 51 + fallback: ["system-ui", "sans-serif"], 52 + }, 53 + lora: { 54 + id: "lora", 55 + displayName: "Lora", 56 + fontFamily: "Lora", 57 + baseSize: 17, 58 + type: "local", 59 + files: [ 60 + { 61 + path: "/fonts/Lora-Variable.woff2", 62 + style: "normal", 63 + weight: "400 700", 64 + }, 65 + { 66 + path: "/fonts/Lora-Italic-Variable.woff2", 67 + style: "italic", 68 + weight: "400 700", 69 + }, 70 + ], 71 + fallback: ["Georgia", "serif"], 72 + }, 73 + "atkinson-hyperlegible": { 74 + id: "atkinson-hyperlegible", 75 + displayName: "Atkinson Hyperlegible", 76 + fontFamily: "Atkinson Hyperlegible Next", 77 + baseSize: 18, 78 + type: "google", 79 + googleFontsFamily: 80 + "Atkinson+Hyperlegible+Next:ital,wght@0,200..800;1,200..800", 81 + fallback: ["system-ui", "sans-serif"], 82 + }, 83 + // Additional Google Fonts - Mono 84 + "sometype-mono": { 85 + id: "sometype-mono", 86 + displayName: "Sometype Mono", 87 + fontFamily: "Sometype Mono", 88 + baseSize: 17, 89 + type: "google", 90 + googleFontsFamily: "Sometype+Mono:ital,wght@0,400;0,700;1,400;1,700", 91 + fallback: ["monospace"], 92 + }, 93 + 94 + // Additional Google Fonts - Sans 95 + montserrat: { 96 + id: "montserrat", 97 + displayName: "Montserrat", 98 + fontFamily: "Montserrat", 99 + baseSize: 17, 100 + type: "google", 101 + googleFontsFamily: "Montserrat:ital,wght@0,400;0,700;1,400;1,700", 102 + fallback: ["system-ui", "sans-serif"], 103 + }, 104 + "source-sans": { 105 + id: "source-sans", 106 + displayName: "Source Sans 3", 107 + fontFamily: "Source Sans 3", 108 + baseSize: 18, 109 + type: "google", 110 + googleFontsFamily: "Source+Sans+3:ital,wght@0,400;0,700;1,400;1,700", 111 + fallback: ["system-ui", "sans-serif"], 112 + }, 113 + }; 114 + 115 + export const defaultFontId = "quattro"; 116 + export const defaultBaseSize = 16; 117 + 118 + // Parse a Google Fonts URL or string to extract the font name and family parameter 119 + // Supports various formats: 120 + // - Full URL: https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap 121 + // - Family param: Open+Sans:ital,wght@0,400;0,700 122 + // - Just font name: Open Sans 123 + export function parseGoogleFontInput(input: string): { 124 + fontName: string; 125 + googleFontsFamily: string; 126 + } | null { 127 + const trimmed = input.trim(); 128 + if (!trimmed) return null; 129 + 130 + // Try to parse as full URL 131 + try { 132 + const url = new URL(trimmed); 133 + const family = url.searchParams.get("family"); 134 + if (family) { 135 + // Extract font name from family param (before the colon if present) 136 + const fontName = family.split(":")[0].replace(/\+/g, " "); 137 + return { fontName, googleFontsFamily: family }; 138 + } 139 + } catch { 140 + // Not a valid URL, continue with other parsing 141 + } 142 + 143 + // Check if it's a family parameter with weight/style specifiers (contains : or @) 144 + if (trimmed.includes(":") || trimmed.includes("@")) { 145 + const fontName = trimmed.split(":")[0].replace(/\+/g, " "); 146 + // Ensure plus signs are used for spaces in the family param 147 + const googleFontsFamily = trimmed.includes("+") 148 + ? trimmed 149 + : trimmed.replace(/ /g, "+"); 150 + return { fontName, googleFontsFamily }; 151 + } 152 + 153 + // Treat as just a font name - construct a basic family param with common weights 154 + const fontName = trimmed.replace(/\+/g, " "); 155 + const googleFontsFamily = `${trimmed.replace(/ /g, "+")}:wght@400;700`; 156 + return { fontName, googleFontsFamily }; 157 + } 158 + 159 + // Custom font ID format: "custom:FontName:googleFontsFamily" 160 + export function createCustomFontId( 161 + fontName: string, 162 + googleFontsFamily: string, 163 + ): string { 164 + return `custom:${fontName}:${googleFontsFamily}`; 165 + } 166 + 167 + export function isCustomFontId(fontId: string): boolean { 168 + return fontId.startsWith("custom:"); 169 + } 170 + 171 + export function parseCustomFontId(fontId: string): { 172 + fontName: string; 173 + googleFontsFamily: string; 174 + } | null { 175 + if (!isCustomFontId(fontId)) return null; 176 + const parts = fontId.slice("custom:".length).split(":"); 177 + if (parts.length < 2) return null; 178 + const fontName = parts[0]; 179 + const googleFontsFamily = parts.slice(1).join(":"); 180 + return { fontName, googleFontsFamily }; 181 + } 182 + 183 + export function getFontConfig(fontId: string | undefined): FontConfig { 184 + if (!fontId) return fonts[defaultFontId]; 185 + 186 + // Check for custom font 187 + if (isCustomFontId(fontId)) { 188 + const parsed = parseCustomFontId(fontId); 189 + if (parsed) { 190 + return { 191 + id: fontId, 192 + displayName: parsed.fontName, 193 + fontFamily: parsed.fontName, 194 + type: "google", 195 + googleFontsFamily: parsed.googleFontsFamily, 196 + fallback: ["system-ui", "sans-serif"], 197 + }; 198 + } 199 + } 200 + 201 + return fonts[fontId] || fonts[defaultFontId]; 202 + } 203 + 204 + // Generate @font-face CSS for a local font 205 + export function generateFontFaceCSS(font: FontConfig): string { 206 + if (font.type !== "local") return ""; 207 + return font.files 208 + .map((file) => { 209 + const format = file.path.endsWith(".woff2") ? "woff2" : "truetype"; 210 + return ` 211 + @font-face { 212 + font-family: '${font.fontFamily}'; 213 + src: url('${file.path}') format('${format}'); 214 + font-style: ${file.style}; 215 + font-weight: ${file.weight || "normal"}; 216 + font-display: swap; 217 + }`.trim(); 218 + }) 219 + .join("\n\n"); 220 + } 221 + 222 + // Generate preload link attributes for a local font 223 + export function getFontPreloadLinks( 224 + font: FontConfig, 225 + ): { href: string; type: string }[] { 226 + if (font.type !== "local") return []; 227 + return font.files.map((file) => ({ 228 + href: file.path, 229 + type: file.path.endsWith(".woff2") ? "font/woff2" : "font/ttf", 230 + })); 231 + } 232 + 233 + // Get Google Fonts URL for a font 234 + // Using display=swap per Google's recommendation: shows fallback immediately, swaps when ready 235 + // This is better UX than blocking text rendering (display=block) 236 + export function getGoogleFontsUrl(font: FontConfig): string | null { 237 + if (font.type !== "google") return null; 238 + return `https://fonts.googleapis.com/css2?family=${font.googleFontsFamily}&display=swap`; 239 + } 240 + 241 + // Get the base font size for a font config 242 + export function getFontBaseSize(font: FontConfig): number { 243 + return font.baseSize ?? defaultBaseSize; 244 + } 245 + 246 + // Get the CSS font-family value with fallbacks 247 + export function getFontFamilyValue(font: FontConfig): string { 248 + const family = font.fontFamily.includes(" ") 249 + ? `'${font.fontFamily}'` 250 + : font.fontFamily; 251 + return [family, ...font.fallback].join(", "); 252 + }
+5 -1
src/replicache/attributes.ts
··· 199 199 } as const; 200 200 201 201 export const ThemeAttributes = { 202 - "theme/font": { 202 + "theme/heading-font": { 203 + type: "string", 204 + cardinality: "one", 205 + }, 206 + "theme/body-font": { 203 207 type: "string", 204 208 cardinality: "one", 205 209 },
+3 -2
src/undoManager.ts
··· 31 31 undo: () => undoManager.undo(), 32 32 redo: () => undoManager.redo(), 33 33 withUndoGroup: <T>(cb: () => T) => { 34 - if (!isGrouping) um.startGroup(); 34 + const wasGrouping = isGrouping; 35 + if (!wasGrouping) um.startGroup(); 35 36 const r = cb(); 36 - if (!isGrouping) um.endGroup(); 37 + if (!wasGrouping) um.endGroup(); 37 38 return r; 38 39 }, 39 40 };
+7
src/utils/blockTextSize.ts
··· 1 + export const blockTextSize = { 2 + p: "1em", 3 + h1: "2em", 4 + h2: "1.5em", 5 + h3: "1.25em", 6 + h4: "1.125em", 7 + } as const;
+1 -1
tailwind.config.js
··· 65 65 }, 66 66 67 67 fontFamily: { 68 - sans: ["var(--font-quattro)"], 68 + sans: ["var(--theme-font, var(--font-quattro))"], 69 69 serif: ["Garamond"], 70 70 }, 71 71 },