Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/test-baseline.json
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link>` in `app.html`, an `@import` in `app.css`, an `<img>` 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

Expand Down Expand Up @@ -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`).

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down
24 changes: 24 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
@@ -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 (<https://github.com/googlefonts/AntonFont>)

Licensed under the SIL Open Font License, Version 1.1
(<https://openfontlicense.org>). The full text ships with the font package, at
`node_modules/@fontsource/anton/LICENSE`.

## Archivo

Copyright 2020 The Archivo Project Authors (<https://github.com/Omnibus-Type/Archivo>)

Licensed under the SIL Open Font License, Version 1.1
(<https://openfontlicense.org>). 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`.
80 changes: 80 additions & 0 deletions e2e/self-hosted.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <img>, 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:/);
});
24 changes: 22 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "knead-time",
"version": "7.0.2",
"version": "7.0.3",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down Expand Up @@ -60,6 +60,8 @@
]
},
"dependencies": {
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"qrcode-generator": "^2.0.4"
}
}
67 changes: 67 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
27 changes: 4 additions & 23 deletions src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,10 @@
<!-- Two faces off one press: Anton is the sign painter (masthead, banners,
the numbers that have to carry a room), Archivo the workhorse — a
variable grotesque whose 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 `display=swap` means a cold cache reads the
recipe in the fallback rather than waiting on the network. -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Loaded as a preload that promotes itself to a stylesheet, not as a
plain <link rel="stylesheet">: a render-blocking request to another
origin would hold up first paint — and hydration behind it — whenever
the font server is slow or unreachable, and this app is otherwise
entirely local. The <noscript> copy keeps the faces for a reader
without JS, where nothing is waiting on hydration anyway. -->
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Anton&amp;family=Archivo:wdth,wght@84..100,400..800&amp;display=swap"
onload="this.rel = 'stylesheet'"
/>
<noscript>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Anton&amp;family=Archivo:wdth,wght@84..100,400..800&amp;display=swap"
/>
</noscript>
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. -->
<script>
// Theme boot: apply dark before first paint. The legacy bare 'theme'
// slot is read as a fallback until loadStoredTheme migrates it to
Expand Down
Loading