diff --git a/.github/test-baseline.json b/.github/test-baseline.json
index bbdcaaf5..83ae77aa 100644
--- a/.github/test-baseline.json
+++ b/.github/test-baseline.json
@@ -1,5 +1,5 @@
{
"_comment": "Floor for how much testing this repo has. Raised by scripts/check-test-baseline.mjs when you add tests; lowering it is a deliberate, reviewable edit.",
"unit": 953,
- "e2e": 123
+ "e2e": 126
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 7dfa1bfb..20588809 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,6 +8,7 @@ Time-anchored Neapolitan pizza dough calculator. User picks **when to bake**; ev
- **One version literal per thing, and Renovate has to be able to see it.** The Node version lives in `.nvmrc` (its `nvm` manager updates that; a `node-version:` literal in a workflow is invisible to every manager it has), read by every job through the local `.github/actions/node-setup` composite action. `renovate.json` states no `constraints` — they come from `engines.node`, the floor the code must run on. `.github/actions/base-path` resolves `BASE_PATH` for the deploy and the PR preview alike.
- Static build (`@sveltejs/adapter-static`, `fallback: '404.html'`). CI on every PR and on pushes to `main` (the latter feeds the Codecov main baseline); push to `main` deploys to GitHub Pages.
- `BASE_PATH` env drives `svelte.config.js`. **All in-app links/assets must use `$app/paths` (`base`/`resolve()`) — never hard-code `/`.**
+- **One origin, and that is a contract.** The app fetches **nothing** from a host it is not served from: no backend, no analytics, no CDN, and — since v7.0.3 — no font server. It is a privacy promise before it is a performance one (a stylesheet fetched elsewhere tells that host who is baking) and it removes the last third party standing in front of first paint. The two faces are self-hosted: `@fontsource/anton` and `@fontsource-variable/archivo` are dependencies, so the version stays a literal Renovate can see, and `app.css` declares the `@font-face` rules itself — the packages name the variable family `Archivo Variable`, and the design calls it Archivo. Vite hashes the `.woff2` files and emits them relative, so `BASE_PATH` needs no help. **The one outbound request in the app is the TRMNL webhook**, on an explicit click, to a URL the user typed. Anything else — a `` in `app.html`, an `@import` in `app.css`, an `` or an iframe in a component, a dependency that pulls its own stylesheet — breaks the contract. Enforced by `e2e/self-hosted.spec.ts`, which watches the actual network across all three views and the print sheet: grepping the source cannot settle this, only what the browser fetches can. It also asserts both faces really load, so falling back to the system stack everywhere cannot pass as compliance.
## Domain model
@@ -201,7 +202,7 @@ The page ground carries a **halftone speckle and the faintest laid line**, both
### Type
-**Anton and Archivo, off one press.** Anton is the sign painter: one weight, very heavy, very narrow, only ever used large or short — the masthead, the header bands, the question, and every figure the reader treats as data. Archivo is the workhorse, and its **width axis** supplies the condensed caps every label is set in, so no third family is needed. Both fall back to a narrow system face; the faces load as a preload that promotes itself to a stylesheet, so a slow font server can never block first paint or hydration (see `src/app.html`).
+**Anton and Archivo, off one press.** Anton is the sign painter: one weight, very heavy, very narrow, only ever used large or short — the masthead, the header bands, the question, and every figure the reader treats as data. Archivo is the workhorse, and its **width axis** supplies the condensed caps every label is set in, so no third family is needed. Both fall back to a narrow system face, and both are **served from this origin** — `@font-face` in `app.css` against the Fontsource files, `font-display: swap`, Latin and Latin Extended only (the five locales need no more). `app.html` links no font at all now, and must not learn to again: see the one-origin contract above.
`label-caps` is a `@utility`, not a component class, so the component classes can `@apply` it and a caller can still override it with one utility. `.question` is the one loud piece of type — 72 px on a desktop, still 40 px on a phone — and it retunes the base rule's tracking, which only works because the base rule is inside `@layer base` (pinned in `e2e/cascade.spec.ts`).
@@ -277,7 +278,7 @@ Math/schedule bugs are silent until a dough overproofs. **Coverage is a hard gat
- **UI components are not in the coverage target.** `.svelte` and `.svelte.ts` are excluded — vitest has no Svelte plugin, so those modules cannot even be imported by a unit test (`$state` is undefined). They are covered by the browser suite instead.
- **Browser tests live in `e2e/`** (Playwright, Chromium only) and run as their own CI job. `npm run test:e2e`; `npm run test:e2e:ui` for the debugger. They build and serve the real static output, because the app ships as prerendered HTML that hydrates and only then decodes the URL — reading before that swap sees build-time defaults, which is how a check can pass against numbers that were never on screen. Every spec waits for the decoded recipe (`waitForHydration`).
- The clock is pinned (`page.clock.install`) and so are `timezoneId` and `locale`: the whole app is wall-clock arithmetic, so a real clock makes assertions drift by the hour and fail overnight.
- - **What belongs here**: rules that live in a control rather than in `src/lib/dough/` — the window slider's clamping and marker geometry, the re-pick triggers, `startAt ≤ readyBy`, view-mode and verbosity resolution, recipe memory (issue #201), storage being blocked outright (issue #195), legacy share-link fidelity, **which view a visitor lands on and that it survives a reload and the back button** (`views.spec.ts`), and one regression test per browser-only bug we have already shipped a fix for.
+ - **What belongs here**: rules that live in a control rather than in `src/lib/dough/` — the window slider's clamping and marker geometry, the re-pick triggers, `startAt ≤ readyBy`, view-mode and verbosity resolution, recipe memory (issue #201), storage being blocked outright (issue #195), legacy share-link fidelity, **which view a visitor lands on and that it survives a reload and the back button** (`views.spec.ts`), **that nothing is fetched from another origin** (`self-hosted.spec.ts` — the only place that rule can be checked at all), and one regression test per browser-only bug we have already shipped a fix for.
## Conventions
diff --git a/README.md b/README.md
index 57f33748..9aa6680f 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@ New in v4: a **beginner view** (just "how many, when, how you knead, and which f
Built with SvelteKit 5 + TypeScript + Tailwind v4. Fully client-side, five languages (EN / DE / IT / FR / NL), shareable recipes via URL.
+**Everything is served from one origin.** No backend, no analytics, no CDN — the two faces (Anton and Archivo, both SIL Open Font License 1.1, shipped via the Fontsource packages) are self-hosted alongside the app, so opening Knead Time tells nobody but your own browser that you are baking. The single outbound request in the whole app is the TRMNL webhook, and it happens only when you click **Send to TRMNL**. `e2e/self-hosted.spec.ts` fails if anything else ever reaches for another host, and the font notices are in [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md).
+
---
## Requirements
@@ -89,7 +91,7 @@ src/
├── app.css ← Tailwind v4 entrypoint: the press (ink / paper / accent tokens,
│ one authored set per theme) and the component layer every
│ surface, band, stamp and control is built from
-└── app.html ← shell (Google Fonts: Anton + Archivo, loaded non-blocking)
+└── app.html ← shell (theme boot; no third-party links — the faces are self-hosted)
e2e/ ← Playwright browser tests (the parts vitest cannot reach)
scripts/
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
new file mode 100644
index 00000000..764d6d85
--- /dev/null
+++ b/THIRD-PARTY-NOTICES.md
@@ -0,0 +1,24 @@
+# Third-party notices
+
+Knead Time itself is Apache-2.0 (see `LICENSE`). It ships two typefaces with the
+site — they are served from this origin rather than from a font CDN, so the
+build redistributes the font files and carries their notices here.
+
+## Anton
+
+Copyright 2020 The Anton Project Authors ()
+
+Licensed under the SIL Open Font License, Version 1.1
+(). The full text ships with the font package, at
+`node_modules/@fontsource/anton/LICENSE`.
+
+## Archivo
+
+Copyright 2020 The Archivo Project Authors ()
+
+Licensed under the SIL Open Font License, Version 1.1
+(). The full text ships with the font package, at
+`node_modules/@fontsource-variable/archivo/LICENSE`.
+
+Both are packaged by [Fontsource](https://fontsource.org/); only the Latin and
+Latin Extended subsets are declared in `src/app.css`.
diff --git a/e2e/self-hosted.spec.ts b/e2e/self-hosted.spec.ts
new file mode 100644
index 00000000..503b9c68
--- /dev/null
+++ b/e2e/self-hosted.spec.ts
@@ -0,0 +1,80 @@
+import { expect, test, type Page } from '@playwright/test';
+import { NOW, openLibrary, openQuestion, openRecipe, waitForHydration } from './helpers';
+
+const RECIPE =
+ 'v=7&n=6&b=280&h=70&s=3&y=f&t=22&ft=4&fw=265&r=2026-09-05T17%3A00%3A00.000Z&sa=2026-09-04T09%3A00%3A00.000Z';
+
+// The contract: nothing the reader did not ask for leaves this origin. No
+// backend, no analytics, no CDN — and, since the webfonts were self-hosted,
+// no font server either. It is a privacy promise as much as a performance one:
+// a stylesheet fetched from another host tells that host who is baking, and
+// it is also a single point of failure in front of first paint.
+//
+// The only outbound call in the whole app is the TRMNL webhook, which happens
+// on an explicit click, to a URL the user typed themselves — it is the feature,
+// not a subresource, and it is not reachable without that click.
+//
+// This lives in the browser suite because it is a fact about what the page
+// *fetches*, which no amount of grepping the source can settle: a font CDN can
+// come back through app.html, through a component's , through an @import
+// in app.css, or through a dependency's stylesheet. Only the network tells.
+
+/** Every request the page made to somewhere that is not this origin. */
+function foreignRequests(page: Page): string[] {
+ const foreign: string[] = [];
+ page.on('request', (request) => {
+ const url = request.url();
+ // data:/blob: are the page carrying its own bytes, not a fetch.
+ if (!/^https?:/.test(url)) return;
+ if (!url.startsWith('http://localhost:')) foreign.push(url);
+ });
+ return foreign;
+}
+
+test('the app fetches nothing from another origin', async ({ page }) => {
+ const foreign = foreignRequests(page);
+
+ await openRecipe(page, RECIPE);
+ await page.evaluate(() => document.fonts.ready);
+ // All three views, because each mounts a different tree: the library is the
+ // one carrying links to github.com, which must stay links and never become
+ // requests.
+ await openQuestion(page, 'when', RECIPE);
+ await openRecipe(page, RECIPE);
+ await openLibrary(page);
+ await page.evaluate(() => document.fonts.ready);
+
+ expect(foreign).toEqual([]);
+});
+
+test('the print sheet fetches nothing from another origin either', async ({ page }) => {
+ const foreign = foreignRequests(page);
+
+ await page.clock.install({ time: NOW });
+ await page.goto(`/print/en?${RECIPE}`);
+ await page.evaluate(() => document.fonts.ready);
+
+ expect(foreign).toEqual([]);
+});
+
+test('both faces are served from this origin, and both actually load', async ({ page }) => {
+ const fonts: string[] = [];
+ page.on('request', (request) => {
+ if (request.url().endsWith('.woff2')) fonts.push(request.url());
+ });
+
+ await openRecipe(page, RECIPE);
+ await waitForHydration(page);
+ await page.evaluate(() => document.fonts.ready);
+
+ // Not just "no CDN request": the faces have to be present, or this passes
+ // on a page that quietly fell back to the system stack everywhere.
+ const loaded = await page.evaluate(() => ({
+ anton: document.fonts.check('400 16px Anton'),
+ archivo: document.fonts.check('400 16px Archivo')
+ }));
+ expect(loaded).toEqual({ anton: true, archivo: true });
+
+ expect(fonts.length).toBeGreaterThan(0);
+ for (const url of fonts) expect(url).toMatch(/^http:\/\/localhost:/);
+});
diff --git a/package-lock.json b/package-lock.json
index 2d15064e..e4f1eab7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,14 +1,16 @@
{
"name": "knead-time",
- "version": "7.0.2",
+ "version": "7.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "knead-time",
- "version": "7.0.2",
+ "version": "7.0.3",
"license": "Apache-2.0",
"dependencies": {
+ "@fontsource-variable/archivo": "^5.3.0",
+ "@fontsource/anton": "^5.3.0",
"qrcode-generator": "^2.0.4"
},
"devDependencies": {
@@ -267,6 +269,24 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
+ "node_modules/@fontsource-variable/archivo": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/archivo/-/archivo-5.3.0.tgz",
+ "integrity": "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource/anton": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz",
+ "integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
diff --git a/package.json b/package.json
index eadf727f..c2c55ac9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "knead-time",
- "version": "7.0.2",
+ "version": "7.0.3",
"private": true,
"type": "module",
"license": "Apache-2.0",
@@ -60,6 +60,8 @@
]
},
"dependencies": {
+ "@fontsource-variable/archivo": "^5.3.0",
+ "@fontsource/anton": "^5.3.0",
"qrcode-generator": "^2.0.4"
}
}
diff --git a/src/app.css b/src/app.css
index c54d7b31..4f1d23bd 100644
--- a/src/app.css
+++ b/src/app.css
@@ -1,5 +1,72 @@
@import 'tailwindcss';
+/* The two faces are served from this origin, not from a font CDN: the app is
+ otherwise entirely local — no backend, no analytics, no third party — and a
+ stylesheet fetched from another host is a request the reader never asked to
+ make. The files come from the Fontsource packages (both OFL-1.1), so the
+ version is a literal in package.json that Renovate can see, and Vite hashes
+ and rebases them under BASE_PATH like any other asset.
+
+ The @font-face rules are ours rather than the packages' own CSS because
+ Fontsource names the variable family 'Archivo Variable'; declaring it here
+ keeps the family called what the design calls it, and keeps the subsets we
+ ship a deliberate choice. Latin and Latin Extended cover all five locales;
+ the Vietnamese subset the packages also carry is left out.
+
+ `font-display: swap` means a cold cache reads the recipe in the fallback
+ narrow system face rather than waiting on the network. */
+@font-face {
+ font-family: 'Anton';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('@fontsource/anton/files/anton-latin-400-normal.woff2') format('woff2');
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Anton';
+ font-style: normal;
+ font-weight: 400;
+ font-display: swap;
+ src: url('@fontsource/anton/files/anton-latin-ext-400-normal.woff2') format('woff2');
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
+ U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
+ U+A720-A7FF;
+}
+
+/* One file carries both axes the design uses: the weights the copy is set in
+ and the width axis `label-caps` reaches for at 84 %. */
+@font-face {
+ font-family: 'Archivo';
+ font-style: normal;
+ font-weight: 100 900;
+ font-stretch: 62% 125%;
+ font-display: swap;
+ src: url('@fontsource-variable/archivo/files/archivo-latin-wdth-normal.woff2')
+ format('woff2-variations');
+ unicode-range:
+ U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
+ U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Archivo';
+ font-style: normal;
+ font-weight: 100 900;
+ font-stretch: 62% 125%;
+ font-display: swap;
+ src: url('@fontsource-variable/archivo/files/archivo-latin-ext-wdth-normal.woff2')
+ format('woff2-variations');
+ unicode-range:
+ U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
+ U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
+ U+A720-A7FF;
+}
+
@custom-variant dark (&:where(.dark, .dark *));
@theme {
diff --git a/src/app.html b/src/app.html
index 72f2c3ae..64ef2924 100644
--- a/src/app.html
+++ b/src/app.html
@@ -10,29 +10,10 @@
-
-
-
-
-
+ label is set in, so no third family is needed. Both are served from
+ this origin (@font-face in app.css) and both fall back to a narrow
+ system face; there is no link to a font CDN here, and nothing in this
+ document may reintroduce one — the app makes no third-party request. -->