Add per-pond fonts: catalog, build, application, and admin UI (#66)
All checks were successful
CD / Build and push images (push) Successful in 3m24s
CI / Lint, typecheck, test (push) Successful in 3m6s
CI / Auth e2e pack (push) Successful in 4m8s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s

Self-hosted Google Fonts with per-pond selection (ADR 0016), the GDPR
"zero external requests" posture (security.md, CSP `font-src 'self'`).

- Catalog: a curated 15-family OFL/Apache list in shared (family, weights,
  category, license, google-webfonts-helper id). `deploy/fonts/build-fonts.mjs`
  validates every entry has license info (fails the build otherwise),
  downloads the WOFF2 weights into apps/web/public/fonts/ (gitignored), and
  generates the @font-face stylesheet — run at image build time from the web
  Dockerfile (with retries), never from a visitor's browser.
- Application: PondFontScope sets --font-heading/body/mono (+ weights) from
  pond.settings.fonts on the editor + read view; the existing global CSS
  already reads those custom properties, so headings/body/code re-resolve to
  the pond's fonts. A pond with no settings arrives with the defaulted values
  (Roboto 400 / Roboto 200 / Fira Code), so the vision defaults always render.
- Admin UI: pond-settings 'Appearance' section — three slots (family + weight)
  with a live preview, Pond-Admin-gated (fonts added to updatePondInputSchema
  and merged in PondsService.update); a font catalog attribution page (/fonts)
  listing families and licenses. New `font` i18n namespace (de+en).
- CSP: strict Content-Security-Policy in nginx.conf (default-src 'self';
  font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; …) —
  the app's scripts are all external files, inline styles cover CSS variables.
- Tests: shared catalog-integrity unit test (the invariant the build enforces);
  e2e fonts pack — no request leaves the origin when rendering a pond (the GDPR
  network assertion), a font choice applies to a page and persists, and a pond
  without settings renders the defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-10 11:16:29 +02:00
parent 699c003d04
commit f500198c5d
24 changed files with 889 additions and 50 deletions

View File

@ -261,6 +261,17 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/export.spec.ts
- name: Reset login rate limit before fonts pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
# Pond fonts (#66): the GDPR no-off-origin assertion + apply/persist.
- name: Run fonts pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/fonts.spec.ts
- name: Reset login rate limit before offline pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

4
.gitignore vendored
View File

@ -10,3 +10,7 @@ coverage/
test-results/
# Local upload storage for native (non-Docker) dev runs (UPLOADS_DIR default).
apps/api/data/
# Font catalog WOFF2 + generated stylesheet — fetched at build time
# (ADR 0016, deploy/fonts/build-fonts.mjs), never committed.
apps/web/public/fonts/

View File

@ -146,10 +146,16 @@ export class PondsService {
async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException();
// Stored settings hold only deviations from the defaults; merge in whichever
// of `sidebarSort` / `fonts` this request changes (issue #26 / #66).
const settings =
input.sidebarSort === undefined
input.sidebarSort === undefined && input.fonts === undefined
? undefined
: { ...(pond.settings as object), sidebarSort: input.sidebarSort };
: {
...(pond.settings as object),
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
};
const updated = await this.prisma.pond.update({
where: { id },
data: { name: input.name, description: input.description, settings },

View File

@ -8,8 +8,13 @@ RUN npm install -g pnpm@11
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY packages/shared ./packages/shared
COPY apps/web ./apps/web
COPY deploy/fonts ./deploy/fonts
# Build shared, then download the catalog fonts into the web app (ADR 0016:
# self-hosted, baked into the image — never fetched from a visitor's browser),
# then build. The font step fails the image build if a family lacks license info.
RUN pnpm install --frozen-lockfile --filter @dorfteich/web... \
&& pnpm --filter @dorfteich/shared build \
&& node deploy/fonts/build-fonts.mjs \
&& VITE_APP_VERSION=${APP_VERSION} pnpm --filter @dorfteich/web build
# nginx-unprivileged runs as uid 101 and listens on 8080 — no root needed.

101
apps/web/e2e/fonts.spec.ts Normal file
View File

@ -0,0 +1,101 @@
import { expect, test } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Pond fonts pack (issue #66, ADR 0016): the GDPR "zero external requests"
* guarantee, and that a pond's font choice applies to its pages and persists.
* Selectors are language-neutral (CSS classes, not button text).
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const ORIGIN = new URL(BASE_URL).host;
const DEFAULT_FONTS = {
heading: { family: 'Roboto', weight: 400 },
body: { family: 'Roboto', weight: 200 },
mono: { family: 'Fira Code', weight: 400 },
};
async function personalPond(
context: Awaited<ReturnType<typeof contextForUser>>,
): Promise<{ id: string; slug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
return { id: pond.id, slug: pond.slug };
}
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
pondId: string,
title: string,
): Promise<string> {
const created = await context.request.post(`/api/v1/ponds/${pondId}/pages`, { data: { title } });
return (await created.json()).slug;
}
test('renders a pond without any request leaving the origin (GDPR)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Net ${Date.now()}`);
const page = await context.newPage();
const offOrigin: string[] = [];
page.on('request', (request) => {
const url = request.url();
if (url.startsWith('data:') || url.startsWith('blob:')) return;
if (new URL(url).host !== ORIGIN) offOrigin.push(url);
});
await page.goto(`/p/${pond.slug}/${slug}`);
await page.locator('.ProseMirror').waitFor();
// Give any late asset/font/websocket request a chance to fire.
await page.waitForTimeout(750);
expect(offOrigin, `off-origin requests: ${offOrigin.join(', ')}`).toEqual([]);
await context.close();
});
test('a pond font choice applies to its pages and persists', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Apply ${Date.now()}`);
const page = await context.newPage();
try {
await page.goto(`/p/${pond.slug}/settings`);
// Set the body font to a distinctive serif, then save.
await page.locator('.appearance__slot--body select').first().selectOption('Merriweather');
await page.locator('.appearance__actions button').click();
await page.goto(`/p/${pond.slug}/${slug}`);
const font = await page
.locator('.ProseMirror')
.evaluate((el) => getComputedStyle(el).fontFamily);
expect(font).toContain('Merriweather');
// Persisted across a reload.
await page.reload();
const afterReload = await page
.locator('.ProseMirror')
.evaluate((el) => getComputedStyle(el).fontFamily);
expect(afterReload).toContain('Merriweather');
} finally {
// Restore defaults so reruns / other tests see a clean pond.
await context.request.patch(`/api/v1/ponds/${pond.id}`, { data: { fonts: DEFAULT_FONTS } });
await context.close();
}
});
test('a pond with no font settings renders the vision defaults', async ({ browser }) => {
// fixture-editor's personal pond is never reconfigured by these tests.
const context = await contextForUser(browser, BASE_URL, 'fixture-editor');
const pond = await personalPond(context);
const slug = await createPage(context, pond.id, `Fonts Default ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
const font = await page.locator('.ProseMirror').evaluate((el) => getComputedStyle(el).fontFamily);
// Default body family is Roboto (ADR 0016).
expect(font).toContain('Roboto');
await context.close();
});

View File

@ -4,6 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dorfteich</title>
<!-- Self-hosted catalog @font-face rules (ADR 0016), baked into the image
by deploy/fonts/build-fonts.mjs. Absent in a plain dev build → the app
falls back to system fonts; never references a third-party origin. -->
<link rel="stylesheet" href="/fonts/catalog.css" />
</head>
<body>
<div id="root"></div>

View File

@ -23,6 +23,13 @@ server {
location / {
add_header Cache-Control "no-cache";
# Strict CSP (security.md, ADR 0016): everything self-hosted, zero
# third-party origins fonts, scripts, styles, and XHR/WebSocket all
# from this origin only (the GDPR "zero external requests" posture).
# `style-src 'unsafe-inline'` covers the app's inline style attributes
# (CSS custom properties, layout); scripts are all external files.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri /index.html;
}
}

View File

@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom';
import { RequireAnonymous, RequireAuth, RequireSiteAdmin } from './auth/guards';
import { AppLayout } from './layout/AppLayout';
import { AdminSettingsPage } from './pages/AdminSettingsPage';
import { FontCatalogPage } from './pages/FontCatalogPage';
import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage';
@ -36,6 +37,7 @@ export function App(): React.JSX.Element {
<Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} />
<Route path="fonts" element={<FontCatalogPage />} />
<Route path="p/:pondSlug" element={<PondHomePage />} />
{/* Static segment "trash" wins react-router's ranking over the
dynamic :pageSlug sibling below a page slugged "trash"

View File

@ -0,0 +1,123 @@
import { FONT_CATALOG, FontCategory, PondFonts, fontEntry, fontStack } from '@dorfteich/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { FormError } from '../components/forms';
import { apiPatch } from '../lib/api';
const SLOTS: (keyof PondFonts)[] = ['heading', 'body', 'mono'];
const CATEGORIES: FontCategory[] = ['sans-serif', 'serif', 'monospace'];
/**
* Pond settings 'Appearance' section (issue #66, ADR 0016): pick the heading /
* body / mono font (family + weight) from the self-hosted catalog, with a live
* preview in the chosen font, and save. Pond-Admin-gated in the api.
*/
export function AppearanceManager({
pondId,
pondSlug,
fonts,
}: {
pondId: string;
pondSlug: string;
fonts: PondFonts;
}): React.JSX.Element {
const { t } = useTranslation('font');
const queryClient = useQueryClient();
const [draft, setDraft] = useState<PondFonts>(fonts);
const [status, setStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [error, setError] = useState<unknown>(null);
function chooseFamily(slot: keyof PondFonts, family: string): void {
const weights = fontEntry(family)?.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 } }));
setStatus('idle');
}
function chooseWeight(slot: keyof PondFonts, weight: number): void {
setDraft((prev) => ({ ...prev, [slot]: { ...prev[slot], weight } }));
setStatus('idle');
}
async function save(): Promise<void> {
setStatus('saving');
setError(null);
try {
await apiPatch(`/ponds/${pondId}`, { fonts: draft });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setStatus('saved');
} catch (err) {
setError(err);
setStatus('idle');
}
}
return (
<div className="appearance">
<p className="appearance__intro">{t('intro')}</p>
<FormError error={error} />
{SLOTS.map((slot) => {
const value = draft[slot];
const weights = fontEntry(value.family)?.weights ?? [value.weight];
return (
<div className={`appearance__slot appearance__slot--${slot}`} key={slot}>
<span className="appearance__slot-label">{t(`slots.${slot}`)}</span>
<label className="appearance__field">
{t('family')}
<select
value={value.family}
onChange={(event) => chooseFamily(slot, event.target.value)}
>
{CATEGORIES.map((category) => (
<optgroup key={category} label={t(`category.${category}`)}>
{FONT_CATALOG.filter((font) => font.category === category).map((font) => (
<option key={font.family} value={font.family}>
{font.family}
</option>
))}
</optgroup>
))}
</select>
</label>
<label className="appearance__field">
{t('weight')}
<select
value={value.weight}
onChange={(event) => chooseWeight(slot, Number(event.target.value))}
>
{weights.map((weight) => (
<option key={weight} value={weight}>
{weight}
</option>
))}
</select>
</label>
<p
className="appearance__preview"
style={{ fontFamily: fontStack(value.family), fontWeight: value.weight }}
>
{t('preview')}
</p>
</div>
);
})}
<div className="appearance__actions">
<button
type="button"
className="button"
onClick={() => void save()}
disabled={status === 'saving'}
>
{status === 'saved' ? t('saved') : t('save')}
</button>
<Link to="/fonts" className="linklike">
{t('catalogLink')}
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { PondSettings, fontStack } from '@dorfteich/shared';
import type { CSSProperties, ReactNode } from 'react';
/** 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 {
// 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-weight-heading': String(fonts.heading.weight),
'--font-body': fontStack(fonts.body.family),
'--font-weight-body': String(fonts.body.weight),
'--font-mono': fontStack(fonts.mono.family),
'--font-weight-mono': String(fonts.mono.weight),
} as CSSProperties;
}
/**
* Applies a pond's fonts to its content (ADR 0016): a wrapper that sets the
* `--font-*` custom properties from `pond.settings.fonts`, so the editor and
* read view render in the pond's chosen heading/body/mono families immediately.
* A pond with no saved settings arrives with the defaulted values (Roboto 400 /
* Roboto 200 / Fira Code), so the defaults always render.
*/
export function PondFontScope({
fonts,
children,
}: {
fonts: PondSettings['fonts'];
children: ReactNode;
}): React.JSX.Element {
return (
<div className="pond-font-scope" style={pondFontVariables(fonts)}>
{children}
</div>
);
}

View File

@ -5,6 +5,7 @@ import deEditor from '@dorfteich/shared/i18n/de/editor.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deExport from '@dorfteich/shared/i18n/de/export.json';
import deFiles from '@dorfteich/shared/i18n/de/files.json';
import deFont from '@dorfteich/shared/i18n/de/font.json';
import deImport from '@dorfteich/shared/i18n/de/import.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json';
@ -21,6 +22,7 @@ import enEditor from '@dorfteich/shared/i18n/en/editor.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enExport from '@dorfteich/shared/i18n/en/export.json';
import enFiles from '@dorfteich/shared/i18n/en/files.json';
import enFont from '@dorfteich/shared/i18n/en/font.json';
import enImport from '@dorfteich/shared/i18n/en/import.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json';
@ -54,6 +56,7 @@ void i18n
editor: enEditor,
export: enExport,
files: enFiles,
font: enFont,
import: enImport,
labels: enLabels,
links: enLinks,
@ -72,6 +75,7 @@ void i18n
editor: deEditor,
export: deExport,
files: deFiles,
font: deFont,
import: deImport,
labels: deLabels,
links: deLinks,

View File

@ -0,0 +1,41 @@
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
import { useTranslation } from 'react-i18next';
/**
* 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 {
const { t } = useTranslation('font');
return (
<div className="font-catalog">
<h1>{t('catalog.title')}</h1>
<p>{t('catalog.intro')}</p>
<table className="font-catalog__table">
<thead>
<tr>
<th>{t('catalog.family')}</th>
<th>{t('catalog.category')}</th>
<th>{t('catalog.weights')}</th>
<th>{t('catalog.license')}</th>
</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>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { DEFAULT_FONTS } from '@dorfteich/shared';
import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { Collaboration } from '@tiptap/extension-collaboration';
@ -17,6 +18,7 @@ import { BacklinksPanel } from '../links/BacklinksPanel';
import { collaborationCaretFor } from '../editor/collaboration-caret';
import { documentExtensions } from '../editor/document-extensions';
import { DocumentExportMenu } from '../export/DocumentExportMenu';
import { PondFontScope } from '../fonts/PondFontScope';
import { ImageUpload } from '../editor/image-upload';
import { PresenceStrip } from '../editor/PresenceStrip';
import { Toolbar } from '../editor/Toolbar';
@ -27,6 +29,13 @@ import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
import { recallPage, rememberPage } from '../offline/page-cache';
// A pond loaded offline (no settings) still renders the vision defaults.
const DEFAULT_POND_FONTS = {
heading: DEFAULT_FONTS.heading,
body: DEFAULT_FONTS.body,
mono: DEFAULT_FONTS.mono,
};
type Mode = 'view' | 'edit';
/** The minimal page identity the editor needs available from the API online
@ -312,46 +321,50 @@ export function PageEditorPage(): React.JSX.Element {
}
return (
<div className="editor-page">
<div className="editor-page__header">
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
<button
type="button"
className="button editor-page__mode-toggle"
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
>
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button>
<PageMenu
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
onToggleHistory={() => setShowHistory((open) => !open)}
onToggleLabels={() => setShowLabels((open) => !open)}
/>
</div>
<div className="editor-page__body">
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
<div className="editor-page">
<div className="editor-page__header">
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
)}
{showHistory && <HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />}
<button
type="button"
className="button editor-page__mode-toggle"
onClick={() => setMode(mode === 'edit' ? 'view' : 'edit')}
>
{mode === 'edit' ? t('mode.view') : t('mode.edit')}
</button>
<PageMenu
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
onToggleHistory={() => setShowHistory((open) => !open)}
onToggleLabels={() => setShowLabels((open) => !open)}
/>
</div>
<div className="editor-page__body">
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} />
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && (
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
{/* "Linked from" appears below the content in read mode (issue #48). */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
</div>
{/* "Linked from" appears below the content in read mode (issue #48). */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
</div>
</PondFontScope>
);
}

View File

@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { AppearanceManager } from '../fonts/AppearanceManager';
import { LabelManager } from '../labels/LabelManager';
import { PhantomPagesView } from '../links/PhantomPagesView';
import { AccessRulesManager } from '../access/AccessRulesManager';
@ -28,6 +29,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files');
const { t: tExport } = useTranslation('export');
const { t: tFont } = useTranslation('font');
const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth();
@ -72,6 +74,16 @@ export function PondSettingsPage(): React.JSX.Element {
<PondFileManager pondId={pond.data.id} />
</section>
)}
{canModify && (
<section className="appearance-section">
<h2>{tFont('heading')}</h2>
<AppearanceManager
pondId={pond.data.id}
pondSlug={pondSlug}
fonts={pond.data.settings.fonts}
/>
</section>
)}
<section className="pond-export">
<h2>{tExport('pond.heading')}</h2>
<p className="pond-export__hint">{tExport('pond.hint')}</p>

View File

@ -31,6 +31,15 @@ h4 {
code,
pre {
font-family: var(--font-mono);
font-weight: var(--font-weight-mono);
}
/* A pond's content root (ADR 0016): sets --font-* (via PondFontScope) so its
* body text uses the pond's body font; headings and code re-resolve the same
* variables through the global rules above. */
.pond-font-scope {
font-family: var(--font-body);
font-weight: var(--font-weight-body);
}
a {
@ -501,6 +510,56 @@ button {
margin-bottom: var(--space-2);
}
/* Pond appearance (fonts) settings — issue #66 */
.appearance__intro {
color: var(--color-text-muted);
}
.appearance__slot {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
padding: var(--space-3) 0;
border-top: 1px solid var(--color-border);
}
.appearance__slot-label {
flex-basis: 100%;
font-weight: 600;
}
.appearance__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.appearance__preview {
flex: 1 1 16rem;
margin: 0;
font-size: 1.1rem;
}
.appearance__actions {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-3);
}
.font-catalog__table {
border-collapse: collapse;
width: 100%;
}
.font-catalog__table th,
.font-catalog__table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border);
}
.editor-shell {
border: 1px solid var(--color-border);
border-radius: var(--radius);

View File

@ -10,6 +10,7 @@
--font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-weight-heading: 400;
--font-weight-body: 300;
--font-weight-mono: 400;
/* Color palette: calm neutrals with one pond-green accent. */
--color-text: #1f2933;

View File

@ -0,0 +1,114 @@
#!/usr/bin/env node
// Font catalog build step (ADR 0016): download each catalog family's WOFF2
// weights from google-webfonts-helper (upstream gstatic) and bake them into the
// web app under public/fonts/, plus generate the @font-face stylesheet. Run at
// image build time (the web Dockerfile) — no runtime download, no third-party
// request from a visitor's browser (CSP `font-src 'self'`).
//
// node deploy/fonts/build-fonts.mjs
//
// Requires the shared package to be built (it owns the catalog). The build
// FAILS if any catalog entry lacks license info — attribution is mandatory.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
// Import from the built shared package directly — this script runs outside any
// workspace package, so `@dorfteich/shared` isn't resolvable by name here.
const { FONT_CATALOG, fontSlug } = await import(
join(ROOT, 'packages', 'shared', 'dist', 'index.mjs')
);
const OUT_DIR = join(ROOT, 'apps', 'web', 'public', 'fonts');
const GWFH = 'https://gwfh.mranftl.com/api/fonts';
const SUBSETS = 'latin,latin-ext';
/** Reject a catalog that would ship a font without attribution (AC). */
function validateCatalog() {
const bad = FONT_CATALOG.filter(
(e) => !e.license || !e.licenseUrl || !e.family || e.weights.length === 0,
);
if (bad.length > 0) {
throw new Error(
`Font catalog entries missing license/weights: ${bad.map((e) => e.family).join(', ')}`,
);
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** GET with a few retries — the image build depends on upstream (gwfh /
* gstatic), so a transient hiccup must not fail the whole build. */
async function get(url) {
let lastError;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url}${res.status}`);
return res;
} catch (error) {
lastError = error;
if (attempt < 4) await sleep(attempt * 1000);
}
}
throw lastError;
}
async function fetchJson(url) {
return (await get(url)).json();
}
async function fetchBytes(url) {
return Buffer.from(await (await get(url)).arrayBuffer());
}
async function build() {
validateCatalog();
await mkdir(OUT_DIR, { recursive: true });
const faces = [];
let total = 0;
for (const entry of FONT_CATALOG) {
const slug = fontSlug(entry.family);
const meta = await fetchJson(`${GWFH}/${entry.id}?subsets=${SUBSETS}`);
const byWeight = new Map(
meta.variants.filter((v) => v.fontStyle === 'normal').map((v) => [Number(v.fontWeight), v]),
);
await mkdir(join(OUT_DIR, slug), { recursive: true });
for (const weight of entry.weights) {
const variant = byWeight.get(weight);
if (!variant) throw new Error(`${entry.family}: upstream has no weight ${weight}`);
const bytes = await fetchBytes(variant.woff2);
const file = `${slug}/${slug}-${weight}.woff2`;
await writeFile(join(OUT_DIR, file), bytes);
faces.push(
`@font-face {\n` +
` font-family: '${entry.family}';\n` +
` font-style: normal;\n` +
` font-weight: ${weight};\n` +
` font-display: swap;\n` +
` src: url('/fonts/${file}') format('woff2');\n` +
`}`,
);
total += bytes.length;
console.log(
` ${entry.family} ${weight}${file} (${(bytes.length / 1024).toFixed(1)} KiB)`,
);
}
}
const header =
'/* Generated by deploy/fonts/build-fonts.mjs (ADR 0016) — do not edit.\n' +
' Self-hosted catalog fonts; served only from this origin. */\n\n';
await writeFile(join(OUT_DIR, 'catalog.css'), header + faces.join('\n\n') + '\n');
console.log(
`\nWrote ${faces.length} @font-face rules for ${FONT_CATALOG.length} families ` +
`(${(total / 1024 / 1024).toFixed(2)} MiB) to ${OUT_DIR}`,
);
}
build().catch((error) => {
console.error(`font build failed: ${error.message}`);
process.exit(1);
});

View File

@ -19,8 +19,8 @@ export default tseslint.config(
...tseslint.configs.recommended,
prettier,
{
// Plain-Node maintenance scripts (no TypeScript, no bundler).
files: ['scripts/**/*.mjs'],
// Plain-Node maintenance/build scripts (no TypeScript, no bundler).
files: ['scripts/**/*.mjs', 'deploy/**/*.mjs'],
languageOptions: {
globals: {
console: 'readonly',
@ -28,6 +28,7 @@ export default tseslint.config(
URL: 'readonly',
fetch: 'readonly',
Buffer: 'readonly',
setTimeout: 'readonly',
},
},
},

View File

@ -0,0 +1,28 @@
{
"heading": "Darstellung",
"intro": "Wähle die Schriften für Überschriften, Fließtext und Code dieses Teichs. Die Schriften werden ausschließlich von dieser Instanz ausgeliefert.",
"slots": {
"heading": "Überschriften",
"body": "Fließtext",
"mono": "Code"
},
"family": "Schrift",
"weight": "Schnitt",
"preview": "Franz jagt im komplett verwahrlosten Taxi quer durch Bayern. 0123",
"save": "Schriften speichern",
"saved": "Gespeichert",
"catalogLink": "Schrift-Lizenzen",
"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.",
"family": "Schrift",
"category": "Stil",
"weights": "Schnitte",
"license": "Lizenz"
},
"category": {
"sans-serif": "Serifenlos",
"serif": "Serif",
"monospace": "Dicktengleich"
}
}

View File

@ -0,0 +1,28 @@
{
"heading": "Appearance",
"intro": "Choose the fonts for this pond's headings, body text, and code. Fonts are served from this instance only.",
"slots": {
"heading": "Headings",
"body": "Body text",
"mono": "Code"
},
"family": "Font",
"weight": "Weight",
"preview": "The quick brown fox jumps over the lazy dog. 0123",
"save": "Save fonts",
"saved": "Saved",
"catalogLink": "Font licenses",
"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.",
"family": "Font",
"category": "Style",
"weights": "Weights",
"license": "License"
},
"category": {
"sans-serif": "Sans-serif",
"serif": "Serif",
"monospace": "Monospace"
}
}

View File

@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_FONTS, FONT_CATALOG, fontEntry, fontSlug, fontStack } from './fonts';
describe('font catalog (issue #66, ADR 0016)', () => {
it('is non-trivial and has unique families', () => {
expect(FONT_CATALOG.length).toBeGreaterThanOrEqual(15);
const families = FONT_CATALOG.map((f) => f.family);
expect(new Set(families).size).toBe(families.length);
});
it('every entry carries license info and weights (what the build step enforces)', () => {
for (const entry of FONT_CATALOG) {
expect(entry.family, entry.id).toBeTruthy();
expect(entry.license, entry.family).toMatch(/OFL-1\.1|Apache-2\.0/);
expect(entry.licenseUrl, entry.family).toMatch(/^https:\/\//);
expect(entry.weights.length, entry.family).toBeGreaterThan(0);
expect(entry.id, entry.family).toMatch(/^[a-z0-9-]+$/);
}
});
it('the vision defaults exist in the catalog at their weights', () => {
for (const slot of ['heading', 'body', 'mono'] as const) {
const { family, weight } = DEFAULT_FONTS[slot];
const entry = fontEntry(family);
expect(entry, `${slot} default family`).toBeDefined();
expect(entry!.weights, `${family} @ ${weight}`).toContain(weight);
}
});
it('builds a stack with the family ahead of a category fallback', () => {
expect(fontStack('Roboto')).toBe("'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif");
expect(fontStack('Merriweather')).toContain("'Merriweather'");
expect(fontStack('Fira Code')).toContain('monospace');
// An unknown family falls back to the sans system stack, no bogus family.
expect(fontStack('Nonexistent Font')).not.toContain('Nonexistent');
});
it('slugs families to the on-disk /fonts layout', () => {
expect(fontSlug('Source Serif 4')).toBe('source-serif-4');
expect(fontSlug('IBM Plex Mono')).toBe('ibm-plex-mono');
});
});

View File

@ -0,0 +1,188 @@
/**
* The curated self-hosted font catalog (ADR 0016). One maintained list drives
* everything: the build step downloads these families' WOFF2 subsets into the
* web image, the pond-settings Appearance UI offers them, and the attribution
* page lists their licenses. Adding a font is a change here + an image rebuild
* there is no runtime font management (deliberately small surface).
*
* Fonts are served only from the instance itself (`font-src 'self'`); a
* visitor's browser makes zero third-party requests (the GDPR guarantee,
* security.md).
*/
export type FontCategory = 'sans-serif' | 'serif' | 'monospace';
export type FontLicense = 'OFL-1.1' | 'Apache-2.0';
export interface FontCatalogEntry {
/** google-webfonts-helper id — the download key for the build step. */
id: string;
/** CSS `font-family` name. */
family: string;
category: FontCategory;
/** Weights downloaded and offered (a subset of what upstream provides). */
weights: number[];
license: FontLicense;
licenseUrl: string;
}
/** Every catalog entry MUST carry license info the build step fails otherwise
* (ADR 0016). Roboto ships under Apache-2.0; the rest under the SIL OFL 1.1. */
export const FONT_CATALOG: readonly FontCatalogEntry[] = [
{
id: 'roboto',
family: 'Roboto',
category: 'sans-serif',
weights: [200, 300, 400, 500, 700],
license: 'Apache-2.0',
licenseUrl: 'https://www.apache.org/licenses/LICENSE-2.0',
},
{
id: 'open-sans',
family: 'Open Sans',
category: 'sans-serif',
weights: [300, 400, 600, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'lato',
family: 'Lato',
category: 'sans-serif',
weights: [300, 400, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'montserrat',
family: 'Montserrat',
category: 'sans-serif',
weights: [300, 400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'inter',
family: 'Inter',
category: 'sans-serif',
weights: [300, 400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'nunito',
family: 'Nunito',
category: 'sans-serif',
weights: [300, 400, 600, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'pt-sans',
family: 'PT Sans',
category: 'sans-serif',
weights: [400, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'merriweather',
family: 'Merriweather',
category: 'serif',
weights: [300, 400, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'lora',
family: 'Lora',
category: 'serif',
weights: [400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'source-serif-4',
family: 'Source Serif 4',
category: 'serif',
weights: [300, 400, 600, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'playfair-display',
family: 'Playfair Display',
category: 'serif',
weights: [400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'fira-code',
family: 'Fira Code',
category: 'monospace',
weights: [300, 400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'ibm-plex-mono',
family: 'IBM Plex Mono',
category: 'monospace',
weights: [300, 400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'jetbrains-mono',
family: 'JetBrains Mono',
category: 'monospace',
weights: [400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
{
id: 'source-code-pro',
family: 'Source Code Pro',
category: 'monospace',
weights: [400, 500, 700],
license: 'OFL-1.1',
licenseUrl: 'https://openfontlicense.org',
},
];
/** Per-vision defaults (ADR 0016) a pond with no saved fonts renders these.
* Kept in step with `pondSettingsSchema`'s `fonts` defaults. */
export const DEFAULT_FONTS = {
heading: { family: 'Roboto', weight: 400 },
body: { family: 'Roboto', weight: 200 },
mono: { family: 'Fira Code', weight: 400 },
} as const;
/** System fallback per category — used while a WOFF2 loads and if it is absent. */
export const FONT_FALLBACKS: Readonly<Record<FontCategory, string>> = {
'sans-serif': "system-ui, -apple-system, 'Segoe UI', sans-serif",
serif: "Georgia, 'Times New Roman', serif",
monospace: "ui-monospace, 'SFMono-Regular', Menlo, monospace",
};
/** URL/file-safe slug for a family (matches the on-disk `/fonts/<slug>/` layout). */
export function fontSlug(family: string): string {
return family
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
export function fontEntry(family: string): FontCatalogEntry | undefined {
return FONT_CATALOG.find((entry) => entry.family === family);
}
/**
* The `font-family` stack for a chosen family: the family itself (when it is in
* the catalog) ahead of the category's system fallback, so text stays readable
* before the WOFF2 loads or if the catalog font is unknown.
*/
export function fontStack(family: string): string {
const entry = fontEntry(family);
const fallback = FONT_FALLBACKS[entry?.category ?? 'sans-serif'];
return entry ? `'${family}', ${fallback}` : fallback;
}

View File

@ -6,6 +6,7 @@ export * from './editor-schema';
export * from './env';
export * from './conversion';
export * from './files';
export * from './fonts';
export * from './health';
export * from './i18n-tools';
export * from './labels';

View File

@ -18,6 +18,14 @@ const fontSlotSchema = z.object({
weight: z.number().int().min(100).max(900),
});
/** The three font slots, each defaulted to the vision fonts (ADR 0016). */
export const pondFontsSchema = z.object({
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
});
export type PondFonts = z.infer<typeof pondFontsSchema>;
/**
* Pond `settings` jsonb. Parsing `{}` yields the documented defaults
* (sidebar sort `alpha`; fonts Roboto 400 / Roboto 200 / Fira Code,
@ -26,13 +34,7 @@ const fontSlotSchema = z.object({
*/
export const pondSettingsSchema = z.object({
sidebarSort: z.enum(SIDEBAR_SORT_MODES).default('alpha'),
fonts: z
.object({
heading: fontSlotSchema.default({ family: 'Roboto', weight: 400 }),
body: fontSlotSchema.default({ family: 'Roboto', weight: 200 }),
mono: fontSlotSchema.default({ family: 'Fira Code', weight: 400 }),
})
.default({}),
fonts: pondFontsSchema.default({}),
});
export type PondSettings = z.infer<typeof pondSettingsSchema>;
@ -53,6 +55,7 @@ export const updatePondInputSchema = z
name: pondNameSchema,
description: z.string().trim().max(500, 'validation.tooLong'),
sidebarSort: z.enum(SIDEBAR_SORT_MODES),
fonts: pondFontsSchema,
})
.partial();
export type UpdatePondInput = z.infer<typeof updatePondInputSchema>;