dorfteich/apps/web/src/fonts/use-custom-fonts.ts
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

40 lines
1.5 KiB
TypeScript

import { CustomFontView, FontCatalogEntry, customFontEntries } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api';
export const CUSTOM_FONTS_KEY = ['fonts', 'custom'];
/**
* The instance's operator-uploaded font families (issues #303/#304).
*
* Every font-aware surface needs them: the pickers offer them, the licence
* page attributes them, `fontStack` needs them to NAME the family instead of
* falling through to the system stack, and `CustomFontFaces` turns them into
* `@font-face` rules. One query key, so they are fetched once per session and
* shared.
*
* Only fetched while signed in — the endpoint requires a session, and asking
* on the login screen would produce a 401 for nothing.
*/
export function useCustomFonts(): CustomFontView[] {
const { user } = useAuth();
const query = useQuery({
queryKey: CUSTOM_FONTS_KEY,
queryFn: () => apiGet<CustomFontView[]>('/fonts/custom'),
enabled: Boolean(user),
// Uploading a font is a rare Site-Admin act; the manager invalidates the
// key itself, so a long life here costs nothing.
staleTime: 5 * 60 * 1000,
});
return query.data ?? [];
}
/** The same families in the shape `fontEntry`/`fontStack` accept. */
export function useCustomFontEntries(): FontCatalogEntry[] {
const fonts = useCustomFonts();
return useMemo(() => customFontEntries(fonts), [fonts]);
}