#304: custom fonts in the pickers, an admin screen, and the licence page #313

Merged
opus-5 merged 3 commits from issue-304-custom-font-ui into main 2026-08-01 19:25:30 +02:00
Contributor

Implements #304 on top of #303.

What it adds

  • GET /api/v1/fonts/custom - the family list for any signed-in user (the pickers, the licence page and the injected @font-face rules all need it; only the management routes stay Site-Admin-gated).
  • CustomFontFaces injects the @font-face rules for the uploaded families - catalog fonts come from the generated catalog.css, uploaded ones only exist at runtime.
  • Site-Admin screen: upload a family (name, category, licence, licence URL, one file per weight), list, add a weight, delete after being told how many ponds use it.
  • The three font pickers group bundled vs. uploaded families with <optgroup>, the catalog's category grouping preserved inside each source.
  • The licence page lists uploaded families with their licence label and link.

A defect from #303 that no test could see

fontStack cannot tell an uploaded family from a deleted one, so the PDF exporter embedded the @font-face and then never named the family in the stack. Every PDF of a pond using an operator font rendered in the system font, with a green job. Both call sites now receive the uploaded families; pdf-html.test.ts pins it from both sides.

Verified against a real Gotenberg - the same document rendered twice:

embedded font
with the uploaded families PlayfairDisplay-Bold
without them (state before this PR) NotoSans-Bold

That also closes the open point from #303 ("PDF export with a custom font not visually verified").

Verification

  • Full cycle walked in the browser: upload two weights -> listed and rendered in its own font -> chosen in a pond -> the page renders in it -> deleted -> the pond falls back to the default look.
  • api suite for fonts + import-export: 77 passed, 13 skipped (sidecar-dependent).
  • a11y pack 11/11 locally in both colour schemes; the scan now also covers /fonts and asserts the font section actually renders on /admin.
  • pnpm lint, pnpm typecheck, pnpm i18n:check green.

Accessibility

Visible localised labels on every file input, the format requirement stated in text rather than only on rejection, backend errors through the existing announced form-error path (new font_* codes in errors.json), upload progress and completion in a role="status" region, the delete confirmation keyboard-operable with focus moved to it and returned on cancel, and optgroup labels as real labels.

Closes #304

Implements #304 on top of #303. ## What it adds - `GET /api/v1/fonts/custom` - the family list for any signed-in user (the pickers, the licence page and the injected `@font-face` rules all need it; only the management routes stay Site-Admin-gated). - `CustomFontFaces` injects the `@font-face` rules for the uploaded families - catalog fonts come from the generated `catalog.css`, uploaded ones only exist at runtime. - Site-Admin screen: upload a family (name, category, licence, licence URL, one file per weight), list, add a weight, delete after being told how many ponds use it. - The three font pickers group bundled vs. uploaded families with `<optgroup>`, the catalog's category grouping preserved inside each source. - The licence page lists uploaded families with their licence label and link. ## A defect from #303 that no test could see `fontStack` cannot tell an uploaded family from a deleted one, so the PDF exporter **embedded the `@font-face` and then never named the family in the stack**. Every PDF of a pond using an operator font rendered in the system font, with a green job. Both call sites now receive the uploaded families; `pdf-html.test.ts` pins it from both sides. Verified against a real Gotenberg - the same document rendered twice: | | embedded font | |---|---| | with the uploaded families | `PlayfairDisplay-Bold` | | without them (state before this PR) | `NotoSans-Bold` | That also closes the open point from #303 ("PDF export with a custom font not visually verified"). ## Verification - Full cycle walked in the browser: upload two weights -> listed and rendered in its own font -> chosen in a pond -> the page renders in it -> deleted -> the pond falls back to the default look. - api suite for `fonts` + `import-export`: 77 passed, 13 skipped (sidecar-dependent). - a11y pack 11/11 locally in both colour schemes; the scan now also covers `/fonts` and asserts the font section actually renders on `/admin`. - `pnpm lint`, `pnpm typecheck`, `pnpm i18n:check` green. ## Accessibility Visible localised labels on every file input, the format requirement stated in text rather than only on rejection, backend errors through the existing announced form-error path (new `font_*` codes in `errors.json`), upload progress and completion in a `role="status"` region, the delete confirmation keyboard-operable with focus moved to it and returned on cancel, and `optgroup` labels as real labels. Closes #304
opus-5 added 1 commit 2026-08-01 18:33:35 +02:00
#304: custom fonts in the pickers, an admin screen, and the licence page
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
f8c241b11a
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.
fable-5 added 1 commit 2026-08-01 18:46:19 +02:00
#304: declare the font-list route's access rule explicitly
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 1m4s
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
7f0a86b84c
The route-permission fence (#52) failed in CI, not locally: I had run the
fonts and import-export suites, not the full api suite, and the fence needs
a database. `@AuthenticatedOnly()` is the rule the route always meant — a
session, no further permission.
fable-5 force-pushed issue-304-custom-font-ui from 7f0a86b84c to ee6a11f9b0 2026-08-01 18:46:50 +02:00 Compare
fable-5 added 1 commit 2026-08-01 19:05:21 +02:00
#304: scope the legal spec's status locator to its own form
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m31s
CI / Build container images (pull_request) Successful in 1m15s
CI / Auth e2e pack (pull_request) Successful in 8m57s
CI / Import/export fidelity gate (pull_request) Successful in 53s
CD / Build and push images (push) Successful in 36s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m24s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Failing after 7m12s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
942f7b13d3
The font manager's upload live regions made `getByRole('status')` ambiguous
on /admin, and legal.spec.ts — which asserts the legal form's success message
— started failing in the e2e pack. That is the documented trap in CLAUDE.md:
a new label or region makes an existing page-wide locator ambiguous, and the
fix is to scope the SPEC, not to drop the region a screen reader needs.

The section gets a named class for exactly that purpose.

Verified locally against the running stack: legal, fonts, admin-users,
admin-quotas and the a11y pack all pass.
opus-5 merged commit 942f7b13d3 into main 2026-08-01 19:25:30 +02:00
Sign in to join this conversation.
No description provided.