Compare commits

..

4 Commits

Author SHA1 Message Date
76a5e92f2e An instance had no way to look like itself: the top bar said "Dorfteich"
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m10s
CI / Build container images (pull_request) Successful in 4m12s
CI / Auth e2e pack (pull_request) Successful in 9m33s
CI / Import/export fidelity gate (pull_request) Successful in 1m16s
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.

Where the line is drawn, and why:

- **The api never decodes an image.** Cropping, scaling and the conversion
  to PNG happen on a canvas in the browser; the api checks the PNG
  signature, reads the IHDR dimensions at their fixed offsets and enforces
  the caps. An image library would put a decoder in front of
  attacker-supplied bytes AND would have to be carried through the
  `--network none` offline build. Reading two big-endian integers is not
  decoding.
- **SVG is refused**, with its own error message rather than a generic
  "not a PNG": it can carry script, and serving it from our own origin
  would be a cross-site-scripting vector. An operator who tried one should
  learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
  cropper excludes keyboard and switch users outright; a number input is
  arrow-key operable and screen-reader readable without any custom aria.
  The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
  already resolved `data-theme` before first paint, so the correct logo is
  the one painted rather than the one that appears after a flash. Without a
  dark variant the LIGHT logo carries both themes — the operator's own
  asset shown unchanged beats one they did not choose (the rule #307
  extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
  static file and the api answers with the uploaded icon or a shipped
  default — that route must never 404, or the browser keeps its generic
  icon for good. The default is generated by a script from Node's own zlib
  (`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
  tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
  and the browser fetches the favicon before anyone signs in. The admin
  screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
  bytes on disk, and hand-writing it would claim an asset that is not
  there.

`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.

Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.

Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.
2026-08-01 19:28:11 +02:00
942f7b13d3 #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
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.
2026-08-01 19:05:18 +02:00
ee6a11f9b0 #304: declare the font-list route's access rule explicitly
Some checks failed
CI / Build container images (pull_request) Successful in 3m51s
CI / Lint, typecheck, test (pull_request) Successful in 6m35s
CI / Auth e2e pack (pull_request) Failing after 3m6s
CI / Import/export fidelity gate (pull_request) Has been skipped
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 that fence needs
a database. `@AuthenticatedOnly()` is the rule the route always meant — a
session, no further permission.

Re-verified with the FULL api suite against a fresh database: 103 files /
575 tests passed.
2026-08-01 18:46:47 +02:00
f8c241b11a #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
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
20 changed files with 980 additions and 47 deletions

View File

@ -23,6 +23,7 @@ import type { Response } from 'express';
import { SiteAdminGuard } from '../admin/site-admin.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { AuthenticatedOnly } from '../permissions/permission.decorators';
import { CustomFontStorageService } from './custom-font-storage.service';
import { CustomFontsService, WeightUpload } from './custom-fonts.service';
@ -106,7 +107,15 @@ export class CustomFontsAdminController {
}
/**
* Serving route. Unauthenticated on purpose: a font is referenced from CSS,
* Reading side of the uploaded fonts: the family list every signed-in user
* needs, and the bytes themselves.
*
* The listing is NOT site-admin-gated (issue #304): every signed-in user picks
* fonts in their pond's Appearance settings, reads the licence page, and needs
* the `@font-face` rules injected the admin list at `/admin/fonts` carries
* the same data, so gating this one would only force a second, admin-only UI.
*
* The file route is unauthenticated on purpose: a font is referenced from CSS,
* and the login screen carries the pond-independent chrome an authenticated
* font URL would simply not load. The bytes are branding, not content.
*/
@ -117,6 +126,15 @@ export class CustomFontsFileController {
private readonly fonts: CustomFontsService,
) {}
// Explicit access declaration, as every route needs (issue #52's fence
// `route-permissions.e2e.db.test.ts`): a session, no further permission —
// the list says which families exist, which is what the pickers offer.
@AuthenticatedOnly()
@Get()
list(): Promise<CustomFontView[]> {
return this.fonts.list();
}
@Public()
@Get(':slug/:file')
async serve(

View File

@ -155,6 +155,30 @@ describe.skipIf(!hasTestDb)('custom fonts (e2e, issue #303)', () => {
expect(res.body.code).toBe('font_woff2_missing');
});
/**
* Issue #304: an ordinary member picks fonts in their pond's Appearance
* settings and reads the licence page, so the family list cannot be
* Site-Admin-only only the management routes are.
*/
it('lets any signed-in user read the family list, but nobody anonymous', async () => {
await api()
.post('/api/v1/admin/fonts')
.set('Cookie', adminCookie)
.field('family', `Leseschrift ${suffix}`)
.field('category', 'monospace')
.field('licence', 'Read me')
.attach('woff2-500', woff2(), 'x.woff2')
.expect(201);
const listed = await api().get('/api/v1/fonts/custom').set('Cookie', plainCookie).expect(200);
const seen = (listed.body as { family: string; weights: number[] }[]).find(
(font) => font.family === `Leseschrift ${suffix}`,
);
expect(seen?.weights).toEqual([500]);
await api().get('/api/v1/fonts/custom').expect(401);
});
it('keeps every management route away from a non-admin', async () => {
await api().get('/api/v1/admin/fonts').set('Cookie', plainCookie).expect(403);
await api()

View File

@ -6,6 +6,7 @@ import {
ConversionJobView,
ExportFormat,
PondFonts,
customFontEntries,
fontSlug,
PageClassification,
classificationMarking,
@ -335,6 +336,9 @@ export class ExportService {
pondName: page.pond.name,
bodyHtml,
fonts,
// Both the rules and the stack need the uploaded families: embedding a
// face the stack never names would render the system font (issue #304).
customFonts: customFontEntries(await this.customFonts.list()),
fontFaceCss: await this.fontFaceCss(fonts),
// Styled sections keep their look in the PDF (#75); a pond without
// active style plugins contributes an empty string.

View File

@ -0,0 +1,51 @@
import { DEFAULT_FONTS, customFontEntries } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
import { buildPdfHtml } from './pdf-html';
const CUSTOM = customFontEntries([
{
id: 'f1',
family: 'Corporate Grotesk',
slug: 'corporate-grotesk',
category: 'sans-serif',
licence: 'Bought from Foundry X',
licenceUrl: null,
weights: [400, 700],
createdAt: '2026-08-01T00:00:00.000Z',
},
]);
function base(family: string): Parameters<typeof buildPdfHtml>[0] {
return {
title: 'T',
pondName: 'P',
bodyHtml: '<p>x</p>',
fonts: { ...DEFAULT_FONTS, body: { family, weight: 400 } },
fontFaceCss: `@font-face { font-family: '${family}'; src: url('data:font/woff2;base64,AA'); }`,
};
}
describe('buildPdfHtml font stacks (issues #303/#304)', () => {
it('names an operator-uploaded family in the CSS stack when it is known', () => {
const html = buildPdfHtml({ ...base('Corporate Grotesk'), customFonts: CUSTOM });
expect(html).toContain("--font-body: 'Corporate Grotesk',");
});
/**
* The regression this pins: the `@font-face` rule for a custom family was
* embedded, but `fontStack` not knowing the family produced the bare
* system fallback, so the rule was never referenced and the PDF rendered in
* the system font while everything reported success.
*/
it('would fall back to the system stack without the uploaded families', () => {
const html = buildPdfHtml(base('Corporate Grotesk'));
expect(html).not.toContain("'Corporate Grotesk',");
expect(html).toContain('--font-body: system-ui');
});
it('leaves catalog families working without any uploaded ones', () => {
const html = buildPdfHtml(base('Lora'));
expect(html).toContain("--font-body: 'Lora', Georgia");
});
});

View File

@ -1,4 +1,4 @@
import { PondFonts, fontStack } from '@dorfteich/shared';
import { FontCatalogEntry, PondFonts, fontStack } from '@dorfteich/shared';
export interface PdfHtmlParams {
title: string;
@ -8,6 +8,12 @@ export interface PdfHtmlParams {
fonts: PondFonts;
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
fontFaceCss: string;
/** The instance's operator-uploaded families (issue #303), so a pond set to
* one gets it NAMED in the `font-family` stack. Without them `fontStack`
* cannot tell a custom family from a typo and yields the bare system
* fallback the `@font-face` rule would then be embedded but never
* referenced, and the PDF would silently render in the system font. */
customFonts?: readonly FontCatalogEntry[];
/** The pond's active section-style plugin CSS (issue #75), already validated
* at install time (scoped selectors, no external fetches, no `</style>`).
* Sections of a disabled plugin render neutrally their class matches
@ -35,6 +41,7 @@ function escapeHtml(value: string): string {
*/
export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params;
const extra = params.customFonts ?? [];
return `<!doctype html>
<html lang="en">
<head>
@ -44,9 +51,9 @@ export function buildPdfHtml(params: PdfHtmlParams): string {
${params.fontFaceCss}
@page { size: A4; }
:root {
--font-heading: ${fontStack(fonts.heading.family)};
--font-body: ${fontStack(fonts.body.family)};
--font-mono: ${fontStack(fonts.mono.family)};
--font-heading: ${fontStack(fonts.heading.family, extra)};
--font-body: ${fontStack(fonts.body.family, extra)};
--font-mono: ${fontStack(fonts.mono.family, extra)};
}
html { font-size: 11pt; }
body {

View File

@ -88,6 +88,14 @@ for (const scheme of SCHEMES) {
await page.goto('/settings');
await page.waitForLoadState('networkidle');
await expectClean(page, `/settings (${scheme})`);
// Lizenzseite im selben Kontext (issue #304: sie trägt seit den
// eigenen Schriften zwei Tabellen samt Scroll-Regionen). Bewusst
// KEIN eigener Test — jeder zusätzliche Login im Pack bringt die
// CI zwei Packs später ans Rate-Limit (Lehre aus #301).
await page.goto('/fonts');
await page.waitForLoadState('networkidle');
await expectClean(page, `/fonts (${scheme})`);
await context.close();
});
@ -99,10 +107,11 @@ for (const scheme of SCHEMES) {
await page.waitForLoadState('networkidle');
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
await page.locator('.user-manager__table .user-row').first().waitFor();
// Branding-Abschnitt mitgeladen (issue #306) — sonst liefe der Scan
// auch dann grün, wenn er gar nicht rendert. Der Zuschnitt selbst ist
// erst nach Dateiwahl sichtbar; geprüft wird hier die Dateiauswahl mit
// ihrem sichtbaren Label.
// Schriftverwaltung mitgeladen (issue #304) — ohne diese Zusicherung
// liefe der Scan auch dann grün, wenn der Abschnitt gar nicht rendert.
await page.locator('.custom-fonts__upload input[type="file"]').first().waitFor();
// Dasselbe für den Branding-Abschnitt (issue #306). Der Zuschnitt ist
// erst nach Dateiwahl sichtbar; geprüft wird die Dateiauswahl.
await page.locator('.branding .crop-field input[type="file"]').first().waitFor();
await expectClean(page, `/admin (${scheme})`);
await context.close();

View File

@ -63,7 +63,11 @@ test('the admin form previews and publishes the privacy policy', async ({ browse
await expect(editor.locator('.legal-editor__preview strong')).toHaveText('only what is needed');
await page.getByRole('button', { name: /save legal pages|rechtsseiten speichern/i }).click();
await expect(page.getByRole('status')).toBeVisible();
// Auf den Abschnitt gescopet: seit der Schriftverwaltung (#304) hat /admin
// weitere Live-Regionen (Upload-Fortschritt), und ein seitenweites
// getByRole('status') wäre mehrdeutig. Gemeint war immer die
// Erfolgsmeldung DIESES Formulars.
await expect(page.locator('.legal-settings').getByRole('status')).toBeVisible();
await admin.close();
const anonymous = await browser.newContext({ baseURL: BASE_URL });

View File

@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import { FormError } from '../components/forms';
import { apiPatch } from '../lib/api';
import { useCustomFontEntries } from './use-custom-fonts';
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
@ -29,9 +30,12 @@ export function AppearanceManager({
const [draft, setDraft] = useState<PondFonts>(fonts);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [error, setError] = useState<unknown>(null);
// The operator's own families (issue #304) — offered next to the catalog,
// in their own labelled group, and resolvable by `fontEntry`/`fontStack`.
const custom = useCustomFontEntries();
function chooseFamily(slot: keyof PondFonts, family: string): void {
const weights = fontEntry(family)?.weights ?? [];
const weights = fontEntry(family, custom)?.weights ?? [];
// Keep the current weight if the new family offers it, else its first.
const weight = weights.includes(draft[slot].weight) ? draft[slot].weight : (weights[0] ?? 400);
setDraft((prev) => ({ ...prev, [slot]: { family, weight } }));
@ -62,7 +66,7 @@ export function AppearanceManager({
<FormError error={error} />
{SLOTS.map((slot) => {
const value = draft[slot];
const weights = fontEntry(value.family)?.weights ?? [value.weight];
const weights = fontEntry(value.family, custom)?.weights ?? [value.weight];
return (
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
@ -72,8 +76,18 @@ export function AppearanceManager({
value={value.family}
onChange={(event) => chooseFamily(slot, event.target.value)}
>
{/* Bundled and uploaded families are told apart by the group
they sit in, not by a badge (issue #304): the grouping is
then part of the control's semantics a screen reader
announces it on entering, and the native mobile select
keeps it. Within each source the category grouping of the
catalog is preserved, so a custom family appears under its
own category exactly like a bundled one. */}
{CATEGORIES.map((category) => (
<optgroup key={category} label={t(`category.${category}`)}>
<optgroup
key={category}
label={t('group.bundled', { category: t(`category.${category}`) })}
>
{FONT_CATALOG.filter((font) => font.category === category).map((font) => (
<option key={font.family} value={font.family}>
{font.family}
@ -81,6 +95,22 @@ export function AppearanceManager({
))}
</optgroup>
))}
{CATEGORIES.filter((category) =>
custom.some((font) => font.category === category),
).map((category) => (
<optgroup
key={`custom-${category}`}
label={t('group.custom', { category: t(`category.${category}`) })}
>
{custom
.filter((font) => font.category === category)
.map((font) => (
<option key={font.family} value={font.family}>
{font.family}
</option>
))}
</optgroup>
))}
</select>
</label>
<label className="appearance__field">
@ -98,7 +128,7 @@ export function AppearanceManager({
</label>
<p
className="appearance__preview"
style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }}
style={{ fontFamily: fontStack(value.family, custom), fontWeight: value.weight }}
>
{t('preview')}
</p>

View File

@ -0,0 +1,40 @@
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>;
}

View File

@ -1,19 +1,28 @@
import { PondSettings, fontStack } from '@dorfteich/shared';
import { FontCatalogEntry, PondSettings, fontStack } from '@dorfteich/shared';
import type { CSSProperties, ReactNode } from 'react';
import { useCustomFontEntries } from './use-custom-fonts';
/** The CSS custom properties a pond's font choice sets on its content root
* (ADR 0016). The content CSS reads these; a family that fails to load falls
* back to the category's system stack (`fontStack`). */
export function pondFontVariables(fonts: PondSettings['fonts']): CSSProperties {
* back to the category's system stack (`fontStack`).
*
* `custom` carries the operator-uploaded families (issue #304): `fontStack`
* cannot tell an uploaded family from a deleted one, so without them a pond
* set to its operator's own font would render in the system fallback. */
export function pondFontVariables(
fonts: PondSettings['fonts'],
custom: readonly FontCatalogEntry[] = [],
): CSSProperties {
// Overrides the same custom properties the app-wide CSS already reads
// (tokens.css), so headings, body, and code inside the scope re-resolve to
// the pond's fonts without any per-element rules.
return {
'--font-heading': fontStack(fonts.heading.family),
'--font-heading': fontStack(fonts.heading.family, custom),
'--font-weight-heading': String(fonts.heading.weight),
'--font-body': fontStack(fonts.body.family),
'--font-body': fontStack(fonts.body.family, custom),
'--font-weight-body': String(fonts.body.weight),
'--font-mono': fontStack(fonts.mono.family),
'--font-mono': fontStack(fonts.mono.family, custom),
'--font-weight-mono': String(fonts.mono.weight),
} as CSSProperties;
}
@ -32,8 +41,9 @@ export function PondFontScope({
fonts: PondSettings['fonts'];
children: ReactNode;
}): React.JSX.Element {
const custom = useCustomFontEntries();
return (
<div className="pond-font-scope" style={pondFontVariables(fonts)}>
<div className="pond-font-scope" style={pondFontVariables(fonts, custom)}>
{children}
</div>
);

View File

@ -0,0 +1,39 @@
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]);
}

View File

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { CustomFontFaces } from '../fonts/CustomFontFaces';
import { usePersistentState } from '../lib/use-persistent-state';
import { Footer } from './Footer';
import { PageActionsSlotContext } from './page-actions';
@ -55,6 +56,9 @@ export function AppLayout(): React.JSX.Element {
<SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}>
<div className="app">
{/* Declares the operator-uploaded families (#304) for every screen
below pickers, previews, editor and read view alike. */}
<CustomFontFaces />
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
<a className="skip-link" href="#main">
{t('layout.skipToContent')}

View File

@ -10,6 +10,7 @@ import { SettingsLayout } from '../components/SettingsLayout';
import { VsNfdHiddenNote, VsNfdMark, useVsNfdMarking } from '../components/vs-nfd';
import { apiGet, apiPatch } from '../lib/api';
import { BrandingManager } from './BrandingManager';
import { CustomFontManager } from './CustomFontManager';
import { PluginManager } from './PluginManager';
import { QuotaManager } from './QuotaManager';
import { UserManager } from './UserManager';
@ -182,6 +183,7 @@ export function AdminSettingsPage(): React.JSX.Element {
<LegalSettingsForm settings={settings.data} />
<BrandingManager />
<CustomFontManager />
<PluginManager />
<QuotaManager />
<UserManager />
@ -459,7 +461,10 @@ function LegalSettingsForm({ settings }: { settings: InstanceSettings }): React.
}
return (
<section className="settings-section">
// Named class so the e2e can scope its success-message assertion to this
// form: /admin has more than one live region since #304 (upload progress),
// and a page-wide getByRole('status') became ambiguous.
<section className="settings-section legal-settings">
<h2>{t('admin.title')}</h2>
<p className="field__hint">{t('admin.hint')}</p>
<form onSubmit={(event) => void onSubmit(event)} noValidate>

View File

@ -0,0 +1,425 @@
import {
CustomFontView,
FONT_CATEGORIES,
FONT_WEIGHTS,
FontCategory,
MAX_FONT_FILE_BYTES,
fontStack,
customFontEntries,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Field, FormError, FormSuccess } from '../components/forms';
import { CUSTOM_FONTS_KEY } from '../fonts/use-custom-fonts';
import { apiDelete, apiGet, apiPostForm } from '../lib/api';
/** One weight the operator is about to upload. `woff` is optional the api
* rejects a WOFF without its WOFF2 because the PDF path reads WOFF2 only. */
interface WeightDraft {
weight: number;
woff2: File | null;
woff: File | null;
}
const MAX_MIB = Math.round(MAX_FONT_FILE_BYTES / (1024 * 1024));
/** Preselects the weight the operator most likely wants next: Regular for the
* first row, then upwards from the heaviest one already chosen (Regular
* Bold is the usual second file), and only after that whatever is left. The
* selection stays free this is a starting point, not a rule. */
function emptyDraft(used: number[]): WeightDraft {
const free = FONT_WEIGHTS.filter((weight) => !used.includes(weight));
const heaviest = Math.max(0, ...used);
const next = free.includes(400) ? 400 : (free.find((w) => w > heaviest) ?? free[0] ?? 400);
return { weight: next, woff2: null, woff: null };
}
function appendWeight(form: FormData, draft: WeightDraft): void {
if (draft.woff2) form.append(`woff2-${draft.weight}`, draft.woff2);
if (draft.woff) form.append(`woff-${draft.weight}`, draft.woff);
}
/**
* Site-Admin management of operator-uploaded font families (issue #304,
* backend #303, ADR 0016 §#303).
*
* The whole cycle lives here: upload a family with its licence and one file
* per weight, see what is installed, add a weight later, and delete a family
* after being told how many ponds still use it.
*
* Deleting is never blocked (the api's decision): an unknown family falls back
* to the system stack, so the affected ponds change appearance rather than
* break. The confirmation therefore names the consequence in text instead of
* refusing.
*/
export function CustomFontManager(): React.JSX.Element {
const { t } = useTranslation('font');
const queryClient = useQueryClient();
const fonts = useQuery({
queryKey: ['admin', 'fonts'],
queryFn: () => apiGet<CustomFontView[]>('/admin/fonts'),
});
const installed = fonts.data ?? [];
const entries = customFontEntries(installed);
const invalidate = async (): Promise<void> => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'fonts'] });
// The pickers, the licence page and the injected `@font-face` rules read
// the non-admin list — without this they keep the pre-upload state.
await queryClient.invalidateQueries({ queryKey: CUSTOM_FONTS_KEY });
};
return (
<section className="settings-section custom-fonts">
<h2>{t('admin.title')}</h2>
<p>{t('admin.intro')}</p>
<UploadForm onUploaded={invalidate} />
<h3>{t('admin.installed')}</h3>
{installed.length === 0 ? (
<p>{t('admin.empty')}</p>
) : (
<ul className="custom-fonts__list">
{installed.map((font) => (
<FontRow key={font.id} font={font} entries={entries} onChanged={invalidate} />
))}
</ul>
)}
</section>
);
}
function UploadForm({ onUploaded }: { onUploaded: () => Promise<void> }): React.JSX.Element {
const { t } = useTranslation('font');
const [family, setFamily] = useState('');
const [category, setCategory] = useState<FontCategory>('sans-serif');
const [licence, setLicence] = useState('');
const [licenceUrl, setLicenceUrl] = useState('');
const [weights, setWeights] = useState<WeightDraft[]>([emptyDraft([])]);
const [done, setDone] = useState(false);
const upload = useMutation({
mutationFn: async () => {
const form = new FormData();
form.append('family', family.trim());
form.append('category', category);
form.append('licence', licence.trim());
if (licenceUrl.trim()) form.append('licenceUrl', licenceUrl.trim());
for (const draft of weights) appendWeight(form, draft);
return apiPostForm<CustomFontView>('/admin/fonts', form);
},
onSuccess: async () => {
setFamily('');
setLicence('');
setLicenceUrl('');
setWeights([emptyDraft([])]);
setDone(true);
await onUploaded();
},
});
const ready = family.trim() !== '' && licence.trim() !== '' && weights.some((w) => w.woff2);
return (
<form
className="custom-fonts__upload"
noValidate
onSubmit={(event) => {
event.preventDefault();
setDone(false);
upload.mutate();
}}
>
<h3>{t('admin.upload.title')}</h3>
<FormError error={upload.error} />
{/* role="status", so completion reaches assistive technology instead of
being a colour change in the corner. */}
<FormSuccess message={done ? t('admin.upload.done') : null} />
<Field label={t('admin.upload.family')} hint={t('admin.upload.familyHint')}>
<input
type="text"
value={family}
maxLength={80}
autoComplete="off"
onChange={(event) => setFamily(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.category')}>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FontCategory)}
>
{FONT_CATEGORIES.map((value) => (
<option key={value} value={value}>
{t(`category.${value}`)}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.licence')} hint={t('admin.upload.licenceHint')}>
<input
type="text"
value={licence}
maxLength={200}
onChange={(event) => setLicence(event.target.value)}
/>
</Field>
<Field label={t('admin.upload.licenceUrl')}>
<input
type="url"
value={licenceUrl}
maxLength={500}
placeholder="https://"
onChange={(event) => setLicenceUrl(event.target.value)}
/>
</Field>
<fieldset className="custom-fonts__weights">
<legend>{t('admin.upload.weights')}</legend>
{/* The accepted formats are stated up front, not only when a rejected
upload comes back an operator should not have to fail to learn
the requirement. */}
<p>{t('admin.upload.formatHint', { max: MAX_MIB })}</p>
{weights.map((draft, index) => (
<div className="custom-fonts__weight" key={index}>
<Field label={t('admin.upload.weight')}>
<select
value={draft.weight}
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, weight: Number(event.target.value) } : entry,
),
)
}
>
{FONT_WEIGHTS.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: draft.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff2: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: draft.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) =>
setWeights((prev) =>
prev.map((entry, i) =>
i === index ? { ...entry, woff: event.target.files?.[0] ?? null } : entry,
),
)
}
/>
</Field>
{weights.length > 1 && (
<button
type="button"
className="linklike"
onClick={() => setWeights((prev) => prev.filter((_, i) => i !== index))}
>
{t('admin.upload.removeWeight', { weight: draft.weight })}
</button>
)}
</div>
))}
<button
type="button"
className="button button--outline"
onClick={() =>
setWeights((prev) => [...prev, emptyDraft(prev.map((entry) => entry.weight))])
}
>
{t('admin.upload.addWeight')}
</button>
</fieldset>
<button type="submit" className="button" disabled={!ready || upload.isPending}>
{t('admin.upload.submit')}
</button>
{/* Announced, not just spun: an 8 MiB face over a slow link takes long
enough that silence reads as failure. */}
<p role="status" className="custom-fonts__status">
{upload.isPending ? t('admin.upload.uploading') : ''}
</p>
</form>
);
}
function FontRow({
font,
entries,
onChanged,
}: {
font: CustomFontView;
entries: ReturnType<typeof customFontEntries>;
onChanged: () => Promise<void>;
}): React.JSX.Element {
const { t } = useTranslation('font');
const [confirming, setConfirming] = useState(false);
const [adding, setAdding] = useState<WeightDraft | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const deleteRef = useRef<HTMLButtonElement | null>(null);
const usage = useQuery({
queryKey: ['admin', 'fonts', font.id, 'usage'],
queryFn: () => apiGet<{ pondsAffected: number }>(`/admin/fonts/${font.id}/usage`),
enabled: confirming,
});
// The confirmation appears below the button that opened it; without moving
// focus a keyboard user would have to hunt for it, and a screen reader would
// never learn it exists.
useEffect(() => {
if (confirming) confirmRef.current?.focus();
}, [confirming, usage.data]);
const addWeight = useMutation({
mutationFn: async (draft: WeightDraft) => {
const form = new FormData();
appendWeight(form, draft);
return apiPostForm<CustomFontView>(`/admin/fonts/${font.id}/weights`, form);
},
onSuccess: async () => {
setAdding(null);
await onChanged();
},
});
const remove = useMutation({
mutationFn: () => apiDelete(`/admin/fonts/${font.id}`),
onSuccess: async () => {
setConfirming(false);
await onChanged();
},
});
return (
<li className="custom-fonts__item" data-font-slug={font.slug}>
<p className="custom-fonts__name" style={{ fontFamily: fontStack(font.family, entries) }}>
{font.family}
</p>
<p className="custom-fonts__meta">
{t(`category.${font.category}`)} · {t('catalog.weights')}: {font.weights.join(', ')} ·{' '}
{font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
)}
</p>
<FormError error={remove.error ?? addWeight.error} />
<div className="custom-fonts__actions">
<button
type="button"
className="linklike"
onClick={() => setAdding((prev) => (prev ? null : emptyDraft(font.weights)))}
aria-expanded={adding !== null}
>
{t('admin.addWeight.toggle')}
</button>
<button
type="button"
className="linklike"
ref={deleteRef}
onClick={() => setConfirming(true)}
aria-expanded={confirming}
>
{t('admin.delete.start', { family: font.family })}
</button>
</div>
{adding && (
<form
className="custom-fonts__add-weight"
noValidate
onSubmit={(event) => {
event.preventDefault();
addWeight.mutate(adding);
}}
>
<Field label={t('admin.upload.weight')}>
<select
value={adding.weight}
onChange={(event) => setAdding({ ...adding, weight: Number(event.target.value) })}
>
{FONT_WEIGHTS.filter((weight) => !font.weights.includes(weight)).map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</Field>
<Field label={t('admin.upload.woff2', { weight: adding.weight })}>
<input
type="file"
accept=".woff2,font/woff2"
onChange={(event) => setAdding({ ...adding, woff2: event.target.files?.[0] ?? null })}
/>
</Field>
<Field label={t('admin.upload.woff', { weight: adding.weight })}>
<input
type="file"
accept=".woff,font/woff"
onChange={(event) => setAdding({ ...adding, woff: event.target.files?.[0] ?? null })}
/>
</Field>
<button type="submit" className="button" disabled={!adding.woff2 || addWeight.isPending}>
{t('admin.addWeight.submit')}
</button>
<p role="status">{addWeight.isPending ? t('admin.upload.uploading') : ''}</p>
</form>
)}
{confirming && (
<div className="custom-fonts__confirm">
{/* The count comes from the api; until it arrives the consequence is
still stated, so the text never reads as "nothing will happen". */}
<p>
{usage.data
? t('admin.delete.usage', { count: usage.data.pondsAffected })
: t('admin.delete.usageLoading')}
</p>
<p>{t('admin.delete.consequence')}</p>
<button
type="button"
className="button button--danger"
ref={confirmRef}
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('admin.delete.confirm')}
</button>
<button
type="button"
className="button button--outline"
onClick={() => {
setConfirming(false);
deleteRef.current?.focus();
}}
>
{t('admin.delete.cancel')}
</button>
</div>
)}
</li>
);
}

View File

@ -1,19 +1,29 @@
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
import { CustomFontView, FONT_CATALOG, FontCatalogEntry, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next';
import { useCustomFontEntries, useCustomFonts } from '../fonts/use-custom-fonts';
import { useDocumentTitle } from '../lib/use-document-title';
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*/
export function FontCatalogPage(): React.JSX.Element {
/** One licence table. Both sources carry the same four columns; only where the
* licence text comes from differs a catalog entry names a licence id we
* ship, an uploaded family whatever the operator typed. */
function LicenceTable({
label,
rows,
}: {
label: string;
rows: {
key: string;
family: string;
category: string;
weights: number[];
licence: React.ReactNode;
stack: string;
}[];
}): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<div className="table-scroll" tabIndex={0} role="region" aria-label={label}>
<table className="font-catalog__table">
<thead>
<tr>
@ -24,16 +34,12 @@ export function FontCatalogPage(): React.JSX.Element {
</tr>
</thead>
<tbody>
{FONT_CATALOG.map((font) => (
<tr key={font.family}>
<td style={{ fontFamily: fontStack(font.family) }}>{font.family}</td>
<td>{t(`category.${font.category}`)}</td>
<td>{font.weights.join(', ')}</td>
<td>
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
</td>
{rows.map((row) => (
<tr key={row.key}>
<td style={{ fontFamily: row.stack }}>{row.family}</td>
<td>{row.category}</td>
<td>{row.weights.join(', ')}</td>
<td>{row.licence}</td>
</tr>
))}
</tbody>
@ -41,3 +47,72 @@ export function FontCatalogPage(): React.JSX.Element {
</div>
);
}
/**
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
* family with its license, rendered in the font itself. The self-hosting is the
* GDPR guarantee this page is the human-readable attribution surface.
*
* Since issue #304 the operator's own uploaded families are listed too, with
* the licence label and link recorded at upload. That is what makes an
* attribution obligation satisfiable: many commercial font licences require
* naming the foundry or the licence, and an operator who cannot point at such
* a page cannot comply.
*/
export function FontCatalogPage(): React.JSX.Element {
const { t } = useTranslation('font');
useDocumentTitle(t('catalog.title'));
const customFonts: CustomFontView[] = useCustomFonts();
const customEntries: FontCatalogEntry[] = useCustomFontEntries();
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<h2>{t('catalog.bundledHeading')}</h2>
<p>{t('catalog.bundledIntro')}</p>
<LicenceTable
label={t('catalog.bundledHeading')}
rows={FONT_CATALOG.map((font) => ({
key: font.family,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family),
licence: (
<a href={font.licenseUrl} target="_blank" rel="noreferrer noopener">
{font.license}
</a>
),
}))}
/>
<h2>{t('catalog.customHeading')}</h2>
<p>{t('catalog.customIntro')}</p>
{customFonts.length === 0 ? (
<p>{t('catalog.customEmpty')}</p>
) : (
<LicenceTable
label={t('catalog.customHeading')}
rows={customFonts.map((font) => ({
key: font.id,
family: font.family,
category: t(`category.${font.category}`),
weights: font.weights,
stack: fontStack(font.family, customEntries),
// A licence URL is optional — without one the label stands alone
// rather than becoming a link to nowhere.
licence: font.licenceUrl ? (
<a href={font.licenceUrl} target="_blank" rel="noreferrer noopener">
{font.licence}
</a>
) : (
font.licence
),
}))}
/>
)}
</div>
);
}

View File

@ -4110,6 +4110,74 @@ ul[data-type='task_list'] li p:last-of-type {
color: var(--color-text-muted);
}
/* Site-Admin font management (issue #304). The layout stays a plain column so
the section reflows at 320px without its own rules (#301). */
.custom-fonts__list {
list-style: none;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.custom-fonts__item {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
}
.custom-fonts__name {
font-size: 1.25rem;
margin: 0;
}
.custom-fonts__meta {
color: var(--color-text-muted);
margin: var(--space-1) 0 var(--space-2);
}
.custom-fonts__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.custom-fonts__weights {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: var(--space-3);
margin: var(--space-3) 0;
min-width: 0;
}
.custom-fonts__weight {
border-top: 1px solid var(--color-border);
padding-top: var(--space-2);
margin-top: var(--space-2);
}
.custom-fonts__weight:first-of-type {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.custom-fonts__confirm,
.custom-fonts__add-weight {
border-top: 1px solid var(--color-border);
margin-top: var(--space-3);
padding-top: var(--space-3);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
/* A file input is as wide as its filename; without this it pushes the page
at 320px (issue #301's lesson, applied ahead of the fact). */
.custom-fonts input[type='file'] {
max-width: 100%;
}
/* Instance branding (issue #306). The logo sits above the pond name in the
sidebar; the two variants are both rendered and one is hidden here rather
than in JavaScript, so the right one is the one PAINTED theme-init.js has

View File

@ -115,6 +115,19 @@
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.",
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin).",
"font_family_reserved": "Dieser Schriftname gehört zum mitgelieferten Katalog und kann nicht überschrieben werden.",
"font_family_exists": "Eine eigene Schrift mit diesem Namen existiert bereits.",
"font_family_unusable": "Aus diesem Schriftnamen lässt sich kein Adressname bilden — bitte lateinische Buchstaben oder Ziffern verwenden.",
"font_file_empty": "Die Schriftdatei ist leer.",
"font_file_too_large": "Die Schriftdatei ist zu groß.",
"font_file_not_a_font": "Diese Datei ist keine WOFF2-/WOFF-Schriftdatei.",
"font_woff2_missing": "Zu jedem Schnitt gehört eine WOFF2-Datei; eine WOFF allein genügt nicht.",
"font_no_weights": "Es wurde keine Schriftdatei ausgewählt.",
"font_too_many_weights": "Diese Schrift hat bereits die höchstmögliche Zahl an Schnitten.",
"font_weight_exists": "Dieser Schnitt existiert für diese Schrift bereits.",
"font_weight_invalid": "Dieser Schnitt ist nicht zulässig.",
"font_one_weight_expected": "Es lässt sich nur ein Schnitt auf einmal ergänzen.",
"font_unexpected_field": "Die Anfrage enthält ein unerwartetes Feld.",
"branding_file_empty": "Die Bilddatei ist leer.",
"branding_file_too_large": "Die Bilddatei ist zu groß.",
"branding_file_missing": "Es wurde keine Bilddatei übermittelt.",

View File

@ -12,19 +12,66 @@
"save": "Schriften speichern",
"saved": "Gespeichert",
"catalogLink": "Schrift-Lizenzen",
"group": {
"bundled": "Mitgelieferte Schriften · {{category}}",
"custom": "Eigene Schriften · {{category}}"
},
"catalog": {
"title": "Schriftkatalog",
"intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO). Alle Schriften sind frei unter den angegebenen Lizenzen.",
"intro": "Jede Schrift wird selbst gehostet — beim Anzeigen eines Teichs wird keine Anfrage an Dritte gestellt (DSGVO).",
"family": "Schrift",
"category": "Stil",
"weights": "Schnitte",
"license": "Lizenz"
"license": "Lizenz",
"bundledHeading": "Mitgelieferte Schriften",
"bundledIntro": "Diese Schriften sind frei unter den angegebenen Lizenzen.",
"customHeading": "Eigene Schriften",
"customIntro": "Diese Schriften hat der Betreiber dieser Instanz hochgeladen; die Lizenzangabe stammt von ihm.",
"customEmpty": "Es wurden keine eigenen Schriften hochgeladen."
},
"category": {
"sans-serif": "Serifenlos",
"serif": "Serif",
"monospace": "Dicktengleich"
},
"admin": {
"title": "Eigene Schriften",
"intro": "Lade eigene, lizenzierte Schriften hoch. Sie stehen zusätzlich zu den mitgelieferten in allen Teichen zur Auswahl und erscheinen mit ihrer Lizenz auf der Seite „Schrift-Lizenzen“. Die Dateien werden gespeichert, aber nicht geöffnet oder ausgewertet.",
"installed": "Hochgeladene Schriften",
"empty": "Es wurden noch keine eigenen Schriften hochgeladen.",
"upload": {
"title": "Schrift hinzufügen",
"family": "Schriftname",
"familyHint": "Genau der Name, unter dem die Schrift ausgewählt werden soll. Ein Name aus dem mitgelieferten Katalog wird abgelehnt.",
"category": "Stil",
"licence": "Lizenz",
"licenceHint": "Freitext, z. B. „Desktop-Lizenz Foundry X, Rechnung 4711“. Erscheint auf der Lizenzseite.",
"licenceUrl": "Link zur Lizenz (optional)",
"weights": "Schnitte",
"formatHint": "Pro Schnitt eine WOFF2-Datei (Pflicht), optional zusätzlich WOFF. Andere Formate werden abgelehnt; höchstens {{max}} MiB je Datei.",
"weight": "Schnitt",
"woff2": "WOFF2-Datei für Schnitt {{weight}}",
"woff": "WOFF-Datei für Schnitt {{weight}} (optional)",
"removeWeight": "Schnitt {{weight}} wieder entfernen",
"addWeight": "Weiteren Schnitt hinzufügen",
"submit": "Schrift hochladen",
"uploading": "Die Schrift wird hochgeladen …",
"done": "Die Schrift wurde hochgeladen und steht jetzt zur Auswahl."
},
"addWeight": {
"toggle": "Schnitt ergänzen",
"submit": "Schnitt hochladen"
},
"delete": {
"start": "Schrift „{{family}}“ löschen",
"usage_one": "Ein Teich benutzt diese Schrift derzeit.",
"usage_other": "{{count}} Teiche benutzen diese Schrift derzeit.",
"usageLoading": "Es wird geprüft, wie viele Teiche diese Schrift benutzen …",
"consequence": "Nach dem Löschen fallen diese Teiche auf die Standard-Schrift zurück. Ihre Einstellung bleibt gespeichert: Wird dieselbe Schrift erneut hochgeladen, sehen sie wieder aus wie vorher.",
"confirm": "Endgültig löschen",
"cancel": "Abbrechen"
}
},
"pondTheme": {
"legend": "Akzentfarbe des Teichs",
"inherit": "Eigene Einstellung der Betrachtenden (Standard)",

View File

@ -115,6 +115,19 @@
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.",
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin).",
"font_family_reserved": "This font name belongs to the bundled catalog and cannot be overridden.",
"font_family_exists": "A custom font with this name already exists.",
"font_family_unusable": "No address name can be derived from this font name — please use Latin letters or digits.",
"font_file_empty": "The font file is empty.",
"font_file_too_large": "The font file is too large.",
"font_file_not_a_font": "This file is not a WOFF2/WOFF font file.",
"font_woff2_missing": "Every weight needs a WOFF2 file; a WOFF alone is not enough.",
"font_no_weights": "No font file was selected.",
"font_too_many_weights": "This font already has the maximum number of weights.",
"font_weight_exists": "This weight already exists for this font.",
"font_weight_invalid": "This weight is not allowed.",
"font_one_weight_expected": "Only one weight can be added at a time.",
"font_unexpected_field": "The request contains an unexpected field.",
"branding_file_empty": "The image file is empty.",
"branding_file_too_large": "The image file is too large.",
"branding_file_missing": "No image file was submitted.",

View File

@ -12,19 +12,66 @@
"save": "Save fonts",
"saved": "Saved",
"catalogLink": "Font licenses",
"group": {
"bundled": "Bundled fonts · {{category}}",
"custom": "Custom fonts · {{category}}"
},
"catalog": {
"title": "Font catalog",
"intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR). Fonts are free/libre under the licenses shown.",
"intro": "Every font below is self-hosted — rendering a pond makes no request to any third party (GDPR).",
"family": "Font",
"category": "Style",
"weights": "Weights",
"license": "License"
"license": "License",
"bundledHeading": "Bundled fonts",
"bundledIntro": "These fonts are free/libre under the licenses shown.",
"customHeading": "Custom fonts",
"customIntro": "These fonts were uploaded by this instance's operator; the licence details are theirs.",
"customEmpty": "No custom fonts have been uploaded."
},
"category": {
"sans-serif": "Sans-serif",
"serif": "Serif",
"monospace": "Monospace"
},
"admin": {
"title": "Custom fonts",
"intro": "Upload your own licensed font families. They become selectable in every pond alongside the bundled ones and are listed with their licence on the “Font licenses” page. The files are stored, never opened or parsed.",
"installed": "Uploaded fonts",
"empty": "No custom fonts have been uploaded yet.",
"upload": {
"title": "Add a font",
"family": "Font name",
"familyHint": "Exactly the name the font should be selectable under. A name from the bundled catalog is rejected.",
"category": "Style",
"licence": "Licence",
"licenceHint": "Free text, e.g. “Desktop licence, Foundry X, invoice 4711”. Shown on the licence page.",
"licenceUrl": "Link to the licence (optional)",
"weights": "Weights",
"formatHint": "One WOFF2 file per weight (required), optionally a WOFF as well. Other formats are rejected; at most {{max}} MiB per file.",
"weight": "Weight",
"woff2": "WOFF2 file for weight {{weight}}",
"woff": "WOFF file for weight {{weight}} (optional)",
"removeWeight": "Remove weight {{weight}} again",
"addWeight": "Add another weight",
"submit": "Upload font",
"uploading": "Uploading the font …",
"done": "The font was uploaded and can now be selected."
},
"addWeight": {
"toggle": "Add a weight",
"submit": "Upload weight"
},
"delete": {
"start": "Delete font “{{family}}”",
"usage_one": "One pond currently uses this font.",
"usage_other": "{{count}} ponds currently use this font.",
"usageLoading": "Checking how many ponds use this font …",
"consequence": "After deletion those ponds fall back to the default look. Their setting is kept: uploading the same font again restores their appearance.",
"confirm": "Delete permanently",
"cancel": "Cancel"
}
},
"pondTheme": {
"legend": "Pond accent color",
"inherit": "Each viewer's own setting (default)",