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
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:
parent
699c003d04
commit
f500198c5d
@ -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
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
101
apps/web/e2e/fonts.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
123
apps/web/src/fonts/AppearanceManager.tsx
Normal file
123
apps/web/src/fonts/AppearanceManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/src/fonts/PondFontScope.tsx
Normal file
40
apps/web/src/fonts/PondFontScope.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
41
apps/web/src/pages/FontCatalogPage.tsx
Normal file
41
apps/web/src/pages/FontCatalogPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
114
deploy/fonts/build-fonts.mjs
Normal file
114
deploy/fonts/build-fonts.mjs
Normal 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);
|
||||
});
|
||||
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
28
packages/shared/i18n/de/font.json
Normal file
28
packages/shared/i18n/de/font.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
packages/shared/i18n/en/font.json
Normal file
28
packages/shared/i18n/en/font.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
packages/shared/src/fonts.test.ts
Normal file
43
packages/shared/src/fonts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
188
packages/shared/src/fonts.ts
Normal file
188
packages/shared/src/fonts.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user