diff --git a/client/src/components/Studio/StudioShell.tsx b/client/src/components/Studio/StudioShell.tsx index 3d5a93e0e..20dd4ce41 100644 --- a/client/src/components/Studio/StudioShell.tsx +++ b/client/src/components/Studio/StudioShell.tsx @@ -18,6 +18,7 @@ import { PeerBadges } from './PeerBadges' import { TrimField } from './TrimField' import '../../styles/dashboard.css' import '../../styles/studio.css' +import './bookFontFaces' /** * The Studio shell: top bar, page rail, workbench, inspector. diff --git a/client/src/components/Studio/bookFontFaces.ts b/client/src/components/Studio/bookFontFaces.ts new file mode 100644 index 000000000..8d9b4bf15 --- /dev/null +++ b/client/src/components/Studio/bookFontFaces.ts @@ -0,0 +1,43 @@ +/** + * The @font-face rules behind the book's typefaces. + * + * bookFonts.ts names the seven families; this is where the faces they need + * arrive. Poppins comes with the app chrome in main.tsx and is not repeated + * here, and neither are MuseoModerno's 400 and 700: importing a face twice only + * duplicates every rule. Its 500 and 600 are the book's alone, which is why + * they are down here rather than up there. + * + * Pulled in by StudioShell rather than by main.tsx: nothing outside the editor + * is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and + * cost the app's first paint nothing. Once that chunk is loaded they are + * ordinary stylesheets on the document, which is what lets printSheets copy + * them into the print frame with everything else. + * + * Upright only, as everywhere else in the app: the editor's italic toggle has + * always been the browser's synthetic slant, and a second file per weight is a + * lot of download to change that. + */ +import '@fontsource/inter/400.css' +import '@fontsource/inter/500.css' +import '@fontsource/inter/600.css' +import '@fontsource/inter/700.css' + +import '@fontsource/lora/400.css' +import '@fontsource/lora/500.css' +import '@fontsource/lora/600.css' +import '@fontsource/lora/700.css' + +import '@fontsource/eb-garamond/400.css' +import '@fontsource/eb-garamond/500.css' +import '@fontsource/eb-garamond/600.css' +import '@fontsource/eb-garamond/700.css' + +import '@fontsource/playfair-display/400.css' +import '@fontsource/playfair-display/500.css' +import '@fontsource/playfair-display/600.css' +import '@fontsource/playfair-display/700.css' + +import '@fontsource/museomoderno/500.css' +import '@fontsource/museomoderno/600.css' + +import '@fontsource/bebas-neue/400.css' diff --git a/client/src/components/Studio/bookFonts.ts b/client/src/components/Studio/bookFonts.ts index 093ece0ef..43c5b321f 100644 --- a/client/src/components/Studio/bookFonts.ts +++ b/client/src/components/Studio/bookFonts.ts @@ -13,6 +13,10 @@ * every stack ends in a generic rather than in another named font nobody * shipped. What the editor draws is what the renderer has. * + * The faces themselves are loaded next door in bookFontFaces.ts. A family named + * here and not imported there falls back to exactly the system font this file + * was written against, which is how five of the seven shipped (#2183). + * * ── Why these seven ─────────────────────────────────────────────────────── * * A photo book needs more than one voice and fewer than a menu. Two workhorse diff --git a/client/tests/unit/studio/bookFontFaces.test.ts b/client/tests/unit/studio/bookFontFaces.test.ts new file mode 100644 index 000000000..11becb2a2 --- /dev/null +++ b/client/tests/unit/studio/bookFontFaces.test.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, it, expect } from 'vitest' +import { BOOK_FONTS_IDS } from '@trek/shared' +import { BOOK_FONTS, type BookFontId } from '../../../src/components/Studio/bookFonts' +// Loaded for real as well as read: a weight a family does not ship fails to +// resolve here rather than in the build. +import '../../../src/components/Studio/bookFontFaces' + +/** + * The faces behind the stacks (#2183). + * + * The stacks were right while five of the seven families were never loaded at + * all, so Lora, EB Garamond and Playfair Display each came out as Georgia and + * picking between them changed nothing on screen or on paper. The checks read + * the imports themselves rather than a list of names copied out of them, which + * is the kind of second copy that let this go a release unnoticed. + */ + +const APP = 'src/main.tsx' +const STUDIO = 'src/components/Studio/bookFontFaces.ts' + +// Vitest runs with the client package as its root, so cwd is stable here. +const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8') + +/** Every `@fontsource//.css` a file imports. */ +function facesIn(rel: string): string[] { + return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)] + .map(m => `${m[1]}/${m[2]}`) +} + +/** @fontsource names its packages after the family, lowercased and hyphenated. */ +function faceOf(id: BookFontId, weight: number): string { + return `${BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-')}/${weight}` +} + +describe('the bundled faces', () => { + it('loads every family the picker offers, in the weights it offers', () => { + const loaded = [...facesIn(APP), ...facesIn(STUDIO)] + for (const id of BOOK_FONTS_IDS) { + for (const weight of BOOK_FONTS[id].weights) { + expect(loaded, `${BOOK_FONTS[id].name} ${weight}`).toContain(faceOf(id, weight)) + } + } + }) + + /* A weight nothing can ask for is a download nobody wanted. */ + it('loads no weight the registry does not declare', () => { + const declared = BOOK_FONTS_IDS.flatMap(id => BOOK_FONTS[id].weights.map(w => faceOf(id, w))) + for (const face of facesIn(STUDIO)) { + expect(declared, face).toContain(face) + } + }) + + /* + * The book's faces are a few hundred kilobytes that only the editor uses, so + * they belong to the lazy Studio chunk. Loading them from main.tsx would put + * them in front of every first paint, including the ones that never open a + * book. + */ + it('rides the Studio chunk rather than the app shell', () => { + expect(read('src/components/Studio/StudioShell.tsx')).toContain("import './bookFontFaces'") + for (const face of facesIn(STUDIO)) { + expect(facesIn(APP), face).not.toContain(face) + } + }) +})