dorfteich/apps/web/src/fonts/CustomFontFaces.tsx
Claude Opus 5 f8c241b11a
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 6m26s
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Has been skipped
#304: custom fonts in the pickers, an admin screen, and the licence page
The backend from #303 could store an operator's font but nothing could
choose one: no list endpoint outside the Site-Admin routes, no @font-face
rules for a family that only exists at runtime, and no management UI.

Found while wiring it up — a real defect in #303, invisible to its tests:
`fontStack` cannot tell an uploaded family from a deleted one, so the PDF
exporter embedded the face and then never named it. Every export of a pond
using an operator font rendered in the system font while the job reported
success. Both `fontStack` call sites now take the uploaded families
(`buildPdfHtml`, `pondFontVariables`); `pdf-html.test.ts` pins the
regression from both sides. Verified against a real Gotenberg: with the
families the PDF embeds PlayfairDisplay-Bold, without them NotoSans-Bold —
that was the whole bug, in one diff of two PDFs.

- `GET /fonts/custom` is readable by any signed-in user, not Site Admins
  only: the pickers, the licence page and the injected `@font-face` rules
  all need it, and gating it would have forced a second, admin-only UI.
- Bundled and uploaded families are told apart by their `<optgroup>`, not
  by a badge — the grouping is then part of the control's semantics, so a
  screen reader announces it and the native mobile select keeps it. Within
  each source the catalog's category grouping is preserved.
- The delete confirmation names how many ponds use the family and what
  happens to them; focus moves to it and back on cancel. Deletion stays
  unblocked (the api's decision, #303) — the ponds degrade, they do not
  break.
- The licence page grew a second table. That is what makes an attribution
  obligation satisfiable: a commercial licence that requires naming the
  foundry needs a page to name it on.

Verified in the browser end to end (upload two weights → listed and
rendered in its own font → chosen in a pond → page renders in it → deleted
→ pond falls back): api suite for fonts/export 77 passed, a11y pack 11/11
locally in both schemes, lint/typecheck/i18n:check green.
2026-08-01 18:32:46 +02:00

41 lines
1.6 KiB
TypeScript

import { useCustomFonts } from './use-custom-fonts';
/**
* A family name is free text the operator typed. It ends up inside a CSS
* string, so quote and backslash are escaped and everything that could end
* the declaration, the rule or the `<style>` element is dropped. Site Admins
* are trusted with far more than this, but a rule that silently breaks the
* whole stylesheet on an apostrophe would be a bug either way.
*/
function cssFamily(family: string): string {
return family.replace(/[\\'<>{};\r\n]/g, '');
}
/**
* `@font-face` rules for the operator-uploaded families (issue #304).
*
* Catalog families are declared in the generated `public/fonts/catalog.css`,
* which the build writes and `index.html` links. Uploaded ones only exist at
* runtime, so their rules are injected here — same shape, same `swap`
* behaviour, bytes from the api's public font route.
*
* Without this the pickers would offer families the browser cannot resolve:
* `fontStack` names them, nothing declares them, and the text renders in the
* system fallback.
*/
export function CustomFontFaces(): React.JSX.Element | null {
const fonts = useCustomFonts();
if (fonts.length === 0) return null;
const css = fonts
.flatMap((font) =>
font.weights.map(
(weight) =>
`@font-face { font-family: '${cssFamily(font.family)}'; font-style: normal;` +
` font-weight: ${weight}; font-display: swap;` +
` src: url('/api/v1/fonts/custom/${font.slug}/${font.slug}-${weight}.woff2') format('woff2'); }`,
),
)
.join('\n');
return <style data-custom-fonts="">{css}</style>;
}