# Technical notes The detail that didn't belong in the README: the CI pipeline, the gates it runs, how to stand up a second deployment, and a couple of smaller implementation notes. See [README.md](./README.md) for everything else. ## CI CI is two jobs, and which of them runs is decided by whether the pull request is a draft. | | Runs | Takes | | ---------------------------- | --------------------------------------------------------------------------------------------- | ----- | | Draft pull request | `check` — gates, types, lint, unit | ~40s | | Ready for review, and `main` | `check` **and** `full` — the above plus Playwright, the bundle budget and `pnpm audit --prod` | ~90s | So open a pull request as a draft and leave it there while you work. Every push gets the short answer, which is the one that catches the ordinary mistake: a type error, a lint failure, a broken gate, a unit test. Mark it ready for review when you want it merged, and the whole suite runs before it can be. Marking it ready is not optional politeness — `full` is the only thing that runs Playwright, so a pull request that never leaves draft has never had its end-to-end tests run. `main` always runs both, which is the net under all of this. The full suite can also be asked for by hand from the Actions tab (`workflow_dispatch`) without marking anything ready. Two more things decide whether CI runs at all. A commit touching only `.md` files is skipped, since prose cannot break a build — so a README-only change shows no checks, and that is correct rather than stuck. And a newer push cancels the run still going for the older one, because it says everything the older one did. ## The gates `scripts/gates.sh` fails the build on things that would otherwise only be written down: any route from a string to markup (`{@html}`, `innerHTML`, `eval(`, and the rest), an API route importing from `src/lib/crypto`, a `PUBLIC_` environment variable whose name looks like a secret, a committed raster asset or non-woff2 font, and shadows, background-images or `` elements anywhere in `src`. The PWA's raster icons are the one exception, and they are drawn by `scripts/icons.ts` at build time from the same primitives as everything else on the sheet. The gate exempts `static/icons` only while that directory is gitignored — otherwise the exemption would become the place to hide an image. ## The typeface Graphe Alpha, the one face used everywhere in the app, is licensed under the [SIL Open Font License](https://heracl.es/graphe/) — see that page for the font file and the licence text. Being OFL, it can be embedded and redistributed, forks included. Swapping it for a different face is a two-line change: drop a woff2 into `static/fonts`, point the single `@font-face` in `src/app.css` at it, and update the preload in `src/app.html`. A stale preload fails the prerender rather than shipping a dead link, so the second line can't be forgotten quietly. Expect to retune `size-adjust` in that `@font-face` too. It exists so a face drawn on a different body can be dropped in without rewriting every size in the stylesheet, and its current value is calibrated to Graphe. Known gap: Graphe has no `[`, `]`, `\` or `Ϋ`. Those fall back to the generic cursive stack. The first three reach the screen only in text a person types — the markdown in the IMPORT box flips to a summary as soon as it parses — so the visible case is a task titled something like `Deposit [urgent]`. `Ϋ` is the capital of `ΰ`, so it appears only when a word containing that letter is shown in caps. ## Greek capitalisation The sheet is set in caps in CSS, and uppercasing Greek is language-dependent: Greek drops the tonos in capitals — ΚΑΦΕΣ, not ΚΑΦΈΣ. Browsers apply that rule only when told the text is Greek, so `src/lib/doc/lang.ts` marks any text containing a Greek letter `lang="el"`. Without it the result is not just unidiomatic but broken — Chrome renders μαΐστρος as ΜΑΪ́ΣΤΡΟΣ, dialytika plus a stranded combining acute. The accents themselves are untouched: what is stored, exported and read aloud keeps exactly what was typed, as with the uppercase itself. ## Deployment The Vercel side stands up in one sitting. What it needs: 1. **Import the repository** into a new Vercel project on the Hobby plan. Framework preset SvelteKit; the build command and output directory are detected. Production deploys from `main`, previews from pull requests. 2. **Create a Blob store** and connect it to the project. This injects `BLOB_READ_WRITE_TOKEN`. Keep the store **private** — the app reads and writes with `access: 'private'`, and nothing but the function ever needs to read it. A public store would leave the ciphertext one request away from anyone who can guess a room id, which is derived from the code. Preview and production should ideally get **separate stores**. If they share one, they are still isolated: `src/lib/server/env.ts` reads Vercel's own `VERCEL_ENV` and prefixes blob paths with `preview/` or `dev/`, leaving production unprefixed. Nothing to configure. Note that Vercel runs crons in production only, so preview blobs are never swept and accumulate — which is the argument for separate stores rather than against the prefix. **Check it before trusting it.** A deployment with no store connected fails in a way that reads as healthy from the outside — the app says it cannot reach the list, which sounds like a network problem, and a `GET` answers a perfectly ordinary 404. The write path is the one that tells the truth: ``` ROOM=$(openssl rand -hex 16) curl -i https:///api/room/$ROOM curl -i -X PUT https:///api/room/$ROOM \ -H 'Content-Type: application/json' -d '{"baseV":0,"blob":"AA=="}' ``` Healthy is `404` then `200 {"v":1}`, both carrying `Cache-Control: no-store`. `404` then `500` means the store is not connected. A `404` without the `no-store` header is Vercel's own not-found, not ours — the route did not deploy. 3. **Add `CRON_SECRET`** as a private environment variable, and only that one. Generate it with `openssl rand -hex 32` and add it under Project Settings → Environment Variables with exactly that name — Vercel looks for it by name and sends `Authorization: Bearer ` on every cron invocation. Until it is set, `/api/cron/sweep` returns 401 and the daily sweep deletes nothing. `BLOB_READ_WRITE_TOKEN` is not set by hand: connecting the Blob store in step 2 injects it. If you find yourself typing it in, the store is not connected. There are no `PUBLIC_` variables in this project, by design — the browser never learns the blob host, and `pnpm gates` fails the build if a secret-shaped one appears. See `.env.example`; for local development, `vercel link` then `vercel env pull .env.local`. 4. **Confirm the free-tier numbers** at `vercel.com/docs/limits` before relying on them. Hobby is personal, non-commercial use only; if this ever earns money it moves to Pro. Exceeding a Blob limit pauses Blob for about 30 days rather than billing you. 5. **Protect `main`**: green CI required, squash merges, conventional commit titles. 6. **Check the deployed preview** for the things only a deploy can show: the response headers from `vercel.json` (asserted against the file in `tests/headers.spec.ts`, but not live until deployed), Lighthouse on mobile, and its installability audit. The cron entry for the daily sweep is already in `vercel.json`; it needs `CRON_SECRET` set before it will do anything but return 401. The sweep itself (`/api/cron/sweep`, one run a day, guarded by a constant-time `CRON_SECRET` check) removes lists that have gone six months without an edit. ## Known limits - **Lose the code, lose the list.** No account, no email, no recovery. EXPORT is the only backup. - **A shared code cannot be taken back.** Anyone holding it has full read and write access, and LEAVE only clears your own device. - **An unsynced edit reaches nobody** and is lost with the device. - **A list nobody edits for six months is removed from the server.** - **End-to-end encryption protects the data at rest**, not against the origin serving the JavaScript. - **Hobby is non-commercial.** The moment this has a paid tier or ads it moves to Pro.