diff --git a/.github/test-baseline.json b/.github/test-baseline.json index 83ae77aa..229be582 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": 126 + "e2e": 127 } diff --git a/CLAUDE.md b/CLAUDE.md index 20588809..8f55f9b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ Time-anchored Neapolitan pizza dough calculator. User picks **when to bake**; ev - SvelteKit 5 + TS + Tailwind v4. Vitest for units, Playwright (`e2e/`) for the browser. Node 22+. Husky pre-commit runs format/lint/test. - **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. +- **The app ships as one bundle** — `kit.output.bundleStrategy: 'single'` in `svelte.config.js`. Splitting produced eleven chunks that were all fetched on every visit anyway, six of them under a kilobyte, and it cost a wave: `app.js` had to arrive and parse before the last node chunk was discovered, which held the webfonts behind it. Single is 17 requests down to 5 for +2 kB gzip. The trade is cache granularity (any change re-downloads everything), which is close to theoretical on GitHub Pages — it serves every asset with `max-age=600` whatever `immutable` is in the path. It is one config line nothing else points at, so `e2e/self-hosted.spec.ts` counts the script and stylesheet responses; the app behaves identically without it and only the waterfall gets longer, which is exactly the kind of regression nobody notices. - `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. diff --git a/README.md b/README.md index 9aa6680f..7617f0e1 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Built with SvelteKit 5 + TypeScript + Tailwind v4. Fully client-side, five langu **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). +It also ships as **one bundle and one stylesheet** (`kit.output.bundleStrategy: 'single'`): a visit is five requests — the page, the bundle, the stylesheet and the two font subsets it actually needs. + --- ## Requirements diff --git a/e2e/self-hosted.spec.ts b/e2e/self-hosted.spec.ts index 503b9c68..c1a33af9 100644 --- a/e2e/self-hosted.spec.ts +++ b/e2e/self-hosted.spec.ts @@ -78,3 +78,23 @@ test('both faces are served from this origin, and both actually load', async ({ expect(fonts.length).toBeGreaterThan(0); for (const url of fonts) expect(url).toMatch(/^http:\/\/localhost:/); }); + +// The other half of "what does this page fetch": how many times. `bundleStrategy: +// 'single'` in svelte.config.js collapses the eleven split chunks into one, and it +// is a single config line with nothing else pointing at it — the kind of thing a +// SvelteKit upgrade or a well-meaning tidy removes without anyone noticing, since +// the app works exactly the same either way and only the waterfall gets longer. +// Counting the responses is the cheapest way to notice. +test('the whole app arrives as one script and one stylesheet', async ({ page }) => { + const served: string[] = []; + page.on('response', (response) => { + const type = response.request().resourceType(); + if (type === 'script' || type === 'stylesheet') served.push(type); + }); + + await openRecipe(page, RECIPE); + await page.evaluate(() => document.fonts.ready); + + expect(served.filter((t) => t === 'script')).toHaveLength(1); + expect(served.filter((t) => t === 'stylesheet')).toHaveLength(1); +}); diff --git a/package-lock.json b/package-lock.json index e4f1eab7..35f6aa28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "knead-time", - "version": "7.0.3", + "version": "7.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "knead-time", - "version": "7.0.3", + "version": "7.0.4", "license": "Apache-2.0", "dependencies": { "@fontsource-variable/archivo": "^5.3.0", diff --git a/package.json b/package.json index c2c55ac9..1148a5ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knead-time", - "version": "7.0.3", + "version": "7.0.4", "private": true, "type": "module", "license": "Apache-2.0", diff --git a/svelte.config.js b/svelte.config.js index 45886e52..eb88cd8c 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -13,6 +13,17 @@ const config = { paths: { base }, + // One bundle, not eleven. Every chunk the splitter produced was fetched on + // every visit anyway — six of them under a kilobyte, pure request overhead — + // and the split cost a wave: app.js had to arrive and parse before the last + // node chunk was even discovered, which held the webfonts behind it. Single + // takes the page from 17 requests to 5 for +2 kB gzip (Rollup loses a little + // cross-chunk dedup). The trade is cache granularity — any change + // re-downloads the whole bundle — which is close to theoretical on GitHub + // Pages, where every asset is served with max-age=600 whatever its path says. + output: { + bundleStrategy: 'single' + }, prerender: { handleHttpError: 'warn' }