diff --git a/.gitignore b/.gitignore
index 874921a..23e4177 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,7 @@ Thumbs.db
*.log
npm-debug.log*
pnpm-debug.log*
+
+# Local development documents (instructions and roadmap), kept on this machine only
+/CLAUDE.md
+/PLAN.md
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index bbd6bb5..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,310 +0,0 @@
-# leonkrix.dev
-
-Personal portfolio website of **Leon Krix, Software Engineer**. Live at https://leonkrix.dev.
-Goal: a modern, minimal, dark, best-practice developer portfolio with subtle animations, a small games section and a few easter eggs. Static site, no backend.
-
-The roadmap lives in [PLAN.md](PLAN.md). Work through it phase by phase; do not jump ahead into games or extras before the MVP is live.
-
-## Repository
-
-- GitHub: `leonkrix/leonkrix.dev` (owner `@leonkrix`), **public**.
-- License: **All rights reserved** (`LICENSE` file). Code, texts, images and design are not licensed for reuse. Third-party dependencies keep their own licenses (see `THIRD_PARTY.md` for assets/word lists). **One exception:** the word lists and numbers of the German Word Radar (`src/games/word-radar/data/de/`) are shared under CC BY-SA 4.0, because OdeNet and the German Wiktionary require it (notice next to the files, `THIRD_PARTY.md`, `LICENSE`).
-- Default branch `main`, protected by the GitHub ruleset `protect-main`: pull request required (0 approvals, solo project), squash merge only, required status check **`CI passed`**, branch must be up to date, no force pushes, no deletions, no bypass. Work only on feature branches and PRs; head branches are deleted automatically after merge.
-- Dependabot (weekly, grouped), secret scanning and push protection are enabled.
-- Because the repo is public and git history is permanent: **never commit** the private postal address, phone number, personal documents, tokens, or `.env` files.
-
-## Language
-
-- Website content and UI: **English**.
-- Games: **English only** (decided 2026-10-08; the tech vocabulary does not translate, and it keeps the games simple). **One exception** (decided 2026-10-14, built 2026-10-15): Word Radar also has a German version, chosen by `?lang=de` on its page (PLAN.md, 4.7, and the README of the game).
-- Code, comments, commits: English.
-
-## Development environment
-
-- Currently developed on **native Windows** with WebStorm (Node 24 LTS, pnpm via Corepack/npm). Debian (native or WSL2, repo on the Linux filesystem) is also supported; both must keep working.
-- CI and Cloudflare builds run on Linux, so avoid Windows-only assumptions (case-sensitive paths, LF line endings, no `rm -rf`/bash-only npm scripts).
-- Toolchain pins: **TypeScript 6.x** (typescript-eslint and `astro check` do not support TS 7 yet), **ESLint 10.x**. Do not re-add `eslint-plugin-react` (crashes on ESLint 10); React linting uses `@eslint-react/eslint-plugin`, `eslint-plugin-react-hooks` and `eslint-plugin-jsx-a11y` (one known harmless peer warning for jsx-a11y).
-- Pin the toolchain: `.nvmrc` (Node LTS) and the `packageManager` field in `package.json` (pnpm). CI reads the same versions.
-- **Stale content in the dev server:** `pnpm dev` and `pnpm build` first delete the saved content of the content collections (`scripts/clear-content-cache.mjs`). Astro reuses a saved entry as long as the digest of its YAML file is the same, so after a change of a schema in `src/lib/*.ts` (a new field) the old entries without the field were served, also after a restart (found with the `art` field of the projects, 2026-10-12). A second `astro dev` is refused while one runs for the project ("Another astro dev server is already running"): stop the old one first.
-- **Ports (decided 2026-10-19):** `astro dev` is **4321** and only one dev server can run for the project (WebStorm or Claude: whoever finds one running uses it and does not start another; `.claude/launch.json` starts it on 4321), the browser tests start their own `astro preview` on **4322** (`playwright.config.ts` never reuses a server that is already there, a dev server on that port made the tests run against the dev server), Lighthouse uses 4323. Never start a dev server on 4322 or 4323.
-- `.gitattributes`: `* text=auto eol=lf`. `.editorconfig` for basic editor settings.
-
-## Tech stack
-
-- **Astro** (static output) + **TypeScript** (strict) + **React** islands only where interactivity is needed (games, command palette).
-- **Tailwind CSS v4** with design tokens as CSS variables.
-- **Motion** (ex Framer Motion) for UI animation; optionally GSAP ScrollTrigger. Always honor `prefers-reduced-motion`.
-- **Astro Content Collections** (Zod-typed) for projects, experience and site data.
-- **pnpm** as package manager.
-- Fonts: **self-hosted** (Fontsource). Never load fonts or scripts from third-party CDNs (GDPR).
-
-## Code quality tooling (local + CI)
-
-The same checks run locally, in pre-commit hooks and in CI. If a check fails locally it must fail in CI too, and vice versa.
-
-- **ESLint 10 (flat config)** with `typescript-eslint` (strict + stylistic, type-aware rules), `eslint-plugin-astro`, `@eslint-react/eslint-plugin` + `eslint-plugin-react-hooks` (not `eslint-plugin-react`, see Toolchain pins), `eslint-plugin-jsx-a11y` (accessibility), and `eslint-plugin-simple-import-sort`. `eslint-plugin-unicorn` is not used so far.
- - Note on Airbnb: `eslint-config-airbnb` is effectively unmaintained and does not work well with flat config, TypeScript and Astro. Instead we replicate its intent (strict, consistent, opinionated) using the modern rule sets above. Do not add the Airbnb config.
-- **Prettier** (+ `prettier-plugin-astro`, `prettier-plugin-tailwindcss`) as the single formatter. ESLint does not do formatting (use `eslint-config-prettier` to avoid conflicts).
-- **TypeScript**: `astro check` / `tsc --noEmit` with `strict` (plus `noUncheckedIndexedAccess`).
-- **Pre-commit hook** (Husky + lint-staged, tasks run one after another, config in `.lintstagedrc.mjs`): on the staged files `eslint --fix` and `prettier --write`; when `.ts`, `.tsx` or `.astro` files are staged also the project-wide type check (`pnpm typecheck`, about 5 s) and `vitest related` for the changed modules. A commit with code takes about 15 seconds, documentation-only commits about 2 seconds. A type error blocks the commit (verified). Builds, build-output checks and e2e tests run in CI (and locally via `pnpm check` and `pnpm test:e2e`). For a WIP commit, `git commit --no-verify` skips the hook; CI stays the binding gate. Optional commit message lint (commitlint, Conventional Commits) is still undecided.
-- **Editor**: `.vscode/settings.json` / recommended extensions committed for format-on-save and ESLint.
-- **Scripts** (`package.json`): `dev`, `build`, `preview`, `lint`, `lint:fix`, `format`, `format:check`, `typecheck`, `test`, `test:watch`, `test:dist`, `build:form` (build with the contact form switched on), `test:e2e`, `lighthouse`, `build:functions`, `dev:functions`, `og`, `live-check`, `check-links`, `levels` (makes the Root Cause level pool), `levels-backbone` (makes the Backbone level pool), `word-radar-data` and `word-radar-data-de` (make the data of Word Radar), `check`. `check` (lint, format, typecheck, unit tests, build, build-output tests) does not include `test:e2e` and `lighthouse`: the first needs the Playwright browser (`pnpm exec playwright install chromium` once), the second a Chrome. Run `pnpm test:e2e` before opening a PR that changes pages, styles or the form, and `pnpm lighthouse` after larger visual changes (`pnpm lighthouse --page /uses/` tests only that page, repeatable). CI runs all of them.
-
-## CI/CD (GitHub Actions is mandatory)
-
-We always use **GitHub Actions**. **Nothing is deployed unless CI is green.** This is a hard rule, not a nice-to-have.
-
-### CI (`.github/workflows/ci.yml`)
-
-Runs on every pull request and on pushes to `main`. Structure: a reusable composite action `.github/actions/setup` (pnpm + Node from `package.json`/`.nvmrc`, cached, `pnpm install --frozen-lockfile`, `HUSKY=0`), parallel jobs, and one aggregate job `ci-ok` ("CI passed") that `needs` all others. **Branch protection requires only `ci-ok`**, so new jobs are added to its `needs` list without touching repository settings.
-
-Jobs (all live):
-
-1. `quality` (live): `pnpm lint`, `pnpm format:check`, `pnpm typecheck` (`astro check`)
-2. `test` (live): `pnpm test` (Vitest)
-3. `build` (live): `pnpm build` (without the contact form, like production before the switch), `pnpm test:dist` and `pnpm build:functions` (the Cloudflare Functions must bundle with the Workers settings)
-4. `site-checks` (live, part of the `build` job): `pnpm test:dist` runs Vitest checks against the build output in `dist/` (see Testing strategy)
-5. `e2e` (live): Playwright against the production build with the contact form built in (`pnpm build:form`; the API is answered by `page.route`) (desktop and mobile), see `tests/e2e/`: smoke tests, privacy guard (same-origin only, no cookies) and axe accessibility scans. The shared fixture fails every test on console errors, CSP violations and HTTP errors. On failure the HTML report is uploaded as an artifact. **The job runs as three shards at once** (`--shard=1/3` to `3/3`, a matrix, each on its own machine; the Playwright browser is cached per Playwright version, its system libraries are installed every time). The suite has about 700 tests and each shard runs on four workers with a limit of 15 minutes (`playwright.config.ts`; it took 8 minutes with two workers and was cut off at 527 of 704 tests on 2026-10-16, a local run takes about 3 minutes): when it needs more than two thirds of the limit, raise it or speed up the slow tests, and do not let a CI run be cut off silently
-6. `lighthouse` (live): `pnpm lighthouse` runs `scripts/lighthouse.mjs`: Lighthouse (mobile) three times against the production build with the contact form built in, median, every category at least 95, transfer budgets (scripts 10 KB, stylesheets 21 KB, total 200 KB, measured with the uncompressed preview server) and no request to another origin. It also tests `/games/` and every game page that exists in `dist/`; a game page uses its own budget from `GAME_BUDGETS` in the script (no entry, no run). Needs a Chrome (set `CHROME_PATH` if it is not found); the CI uses the runner's preinstalled one. The start of Chrome is tried up to three times (`scripts/launch-with-retry.mjs`), because on a runner it sometimes fails with "waiting for dynamic debugging port" (seen on 2026-10-20); a measurement that fails is never repeated, only the start. **The job runs as three shards at once** (`pnpm lighthouse --shard 2/3`, a matrix, each on its own machine; the pages are dealt out one after the other by `scripts/shard.mjs`, every page is in exactly one shard): a run is still alone on its machine, which matters, because Lighthouse runs that share a machine disturb each other (never run two at once on one machine), and the gate takes a third of the time. Lower a limit only with a reason, raise the budgets only deliberately
-7. `actionlint` (live): lints the workflow files; the binary is pinned by version and SHA-256 checksum (bump both together, Dependabot cannot update it)
-8. `dependency-review` (live, pull requests only, fails on high severity; skipped on pushes, which does not fail the gate)
-9. `audit` (live): `pnpm audit --audit-level high` fails on known high or critical vulnerabilities in any dependency. Because a newly published CVE can turn it red without any code change, it also runs weekly; an unfixable finding is recorded with a reason in pnpm's audit configuration instead of weakening the check
-
-Separate workflows (not part of the `ci-ok` gate at first, findings show as PR checks and in the Security tab): **CodeQL** (live: `codeql.yml`, requires that the repo setting "Code scanning default setup" is NOT enabled, otherwise the two conflict) (`javascript-typescript`, on PRs, `main` and weekly) in `.github/workflows/codeql.yml`, and **OpenSSF Scorecard** (live: `.github/workflows/security.yml`, on pushes to `main`, weekly and on branch protection changes; results are published for the README badge and uploaded to the Security tab; the same workflow runs the weekly audit). `SECURITY.md` and private vulnerability reporting are enabled.
-**SonarQube Cloud is deliberately not used**: it overlaps almost entirely with ESLint, strict TypeScript and CodeQL, does not create PRs, and needs an external account plus a token secret. Revisit only as an experiment.
-
-### Testing strategy
-
-Tests exist to protect what would actually hurt: broken pages, legal pages missing, accessibility/performance regressions, privacy leaks, and broken game logic. Not to hit coverage numbers.
-
-- **Unit (Vitest)**: pure game logic (Wordle evaluation, daily word from date, dictionary validation, Clueless ranking), utilities, content schemas. Word lists get data tests (length, lowercase, no duplicates, valid characters).
-- **Content validation**: Zod schemas on content collections so bad data fails the build (e.g. private projects must not carry a repo link).
-- **Build-output checks (Vitest over `dist/`)**: every page has `
`, meta description, canonical, `lang`; `/legal-notice` and `/privacy-policy` exist (both languages, same sections) and are linked from the footer of every page, and the old German addresses redirect; `robots.txt` and `sitemap.xml` exist; no broken internal links; no `http://` resources; Impressum placeholders are not left in production output.
-- **Privacy guard (Playwright)**: assert that page loads make requests only to our own origin (no third-party fonts/scripts/embeds), set no cookies and leave local storage, session storage, IndexedDB, cache storage and service workers empty. This protects the "no cookie banner" decision and the privacy policy statement that nothing is stored on the device. A build-output test scans our source and the published JavaScript for those APIs as early warning (allow-list for libraries in the test, with a reason).
-- **E2E smoke (Playwright)**: home loads, header anchors scroll to sections, mobile viewport, each game can be played with the keyboard, keyboard navigation, reduced-motion respected, no console errors.
-- **Accessibility**: `@axe-core/playwright` scan of every page, fail on violations.
-- **Performance/SEO**: Lighthouse CI budgets plus a JS bundle size budget.
-- **Security hygiene**: `pnpm audit`, dependency review, CodeQL, secret scanning (public repo, so GitHub secret scanning and push protection are enabled).
-- Not planned: visual regression snapshots (high maintenance for low value on a personal site), coverage thresholds.
-
-### Gating deployment behind CI
-
-Decision: **Cloudflare's Git integration deploys, GitHub Actions runs CI in parallel, and branch protection makes CI the gate.** We deliberately do NOT deploy from Actions (no API token, no wrangler-action): it is more moving parts and secrets for no benefit on a static site.
-
-- Cloudflare deploys only `main` to production and builds pull requests as (non-public) preview deployments.
-- **Branch protection on `main`** is what enforces the rule: pull requests required, all CI status checks required and green, branch must be up to date before merge, no force pushes, no direct pushes. Since only CI-verified code can reach `main`, production is effectively gated by CI.
-- Preview builds run in parallel with CI; that is fine, previews are not production. The CI checks still block the merge.
-- **Build budget**: production builds happen only when a PR is merged into `main`. Preview builds are triggered by pushes to branches/PRs and count against Cloudflare's free build allowance. So: commit as often as you like locally, but **push a feature branch when the feature is complete** (or open a draft PR), one branch per complete feature. Restrict preview builds in the Cloudflare project's branch control if needed. GitHub Actions minutes are unlimited for public repos, so CI runs are not a concern.
-- Dependabot PRs are grouped weekly to limit preview builds.
-- No Cloudflare API token or other deploy secrets are needed. Never commit secrets or tokens.
-- Workflow hygiene: minimal `permissions:` per workflow/job, pin third-party actions to a full commit SHA (Renovate/Dependabot keeps them updated), `concurrency` groups to cancel superseded runs, cache pnpm store.
-- **Dependency updates**: Dependabot opens PRs every Monday (npm and GitHub Actions): one grouped PR for all minor and patch updates, major updates alone, at most 5 open. Dependabot alerts and security updates are on (GitHub opens a fix PR as soon as a vulnerability is published). Merge rule: green CI is necessary, not sufficient. Merge grouped minor/patch PRs with "Squash and merge", then `git pull` and `pnpm install`. Majors need a look: they are ignored for `typescript` (no TS 7 support in the tooling yet), `eslint` and `@eslint/js` (plugins verified up to the current major) and `@types/node` (must match the Node major in `.nvmrc`; bump all together).
-- **A red audit after a merge is normal sometimes:** `pnpm audit` checks the lockfile against the advisory database _of that moment_. A PR can be green on Monday and the same files red on Tuesday, because an advisory was published in between (this happened twice). Fix: update the vulnerable package inside its allowed range (`pnpm update `, lockfile only), or, when a dependency pins the vulnerable version exactly and upstream has no fix yet, add an entry to `overrides` in `pnpm-workspace.yaml` with a comment saying why and when to remove it (none at the moment: the `sharp` override was removed on 2026-10-14, because miniflare inside wrangler asks for sharp 0.35.5 itself now). Overrides are written as a range of the bad versions (for example `sharp@<0.35.5`), so they apply only while a dependency still asks for a vulnerable version and become inert on their own once upstream moves on; a leftover entry is harmless dead configuration, not a risk. Clean up when convenient: when the package that needed it asks for a safe version itself (look at its `package.json` in `node_modules` or with `pnpm why`), delete the entry, run `pnpm install && pnpm audit`, and keep it deleted if the audit stays green. Check `pnpm audit` after every Dependabot merge.
-
-## Hosting
-
-- Code on GitHub (repo `leonkrix.dev`), `main` is production.
-- Hosting: **Cloudflare Pages** (project `leonkrix-dev`) via its Git integration (project connected to `leonkrix/leonkrix.dev`, production branch `main`). Free plan, no payment method on file, automatic SSL, global CDN. Fallback: Vercel (same setup: Git integration plus branch protection).
-- DNS is managed at Cloudflare (nameservers switched); the domain stays registered at IONOS. `leonkrix.dev` and `www` are attached to the project, `www` redirects to the apex domain. Mail DNS records (MX, SPF, DKIM, `autodiscover`, `_dmarc`) must stay untouched and set to "DNS only".
-- Costs: Cloudflare Free and GitHub Free (public repo) are not usage-billed. Only the IONOS domain and mailbox cost money (watch renewal price).
-- Domain `leonkrix.dev` is registered at IONOS. Mail (`hello@leonkrix.dev`) stays at IONOS: **keep the MX records** when changing DNS/nameservers. `.dev` requires HTTPS (HSTS preload).
-- Build: `pnpm build` (`astro build`), output `dist/`.
-
-## Security headers and CSP
-
-- HTTP security headers live in `public/_headers` (served by Cloudflare): HSTS (`max-age=31536000; includeSubDomains`), `nosniff`, `X-Frame-Options: DENY`, strict referrer policy, COOP, permissions policy, `frame-ancestors 'none'`, immutable caching for `/_astro/*`.
-- The Content Security Policy is generated by Astro (`security.csp` in `astro.config.mjs`) as a `` element with hashes: `default-src 'self'`, `object-src 'none'`, `frame-src 'none'`, no external hosts, no `unsafe-inline` for scripts or style elements. The only exception is `style-src-attr 'unsafe-inline'`, needed for the obfuscated contact text (inline `--t` custom property).
-- `vite.build.assetsInlineLimit: 0` keeps fonts and scripts as files (no `data:` URIs), which the CSP requires. The only inline scripts are the small runtime that Astro writes for a React island (Playground games) and the language script of Word Radar (`src/games/word-radar/language-script.mjs`, marked `is:inline`; Astro does not hash those, so its text lives in that one file, the page writes it with `set:html` and `astro.config.mjs` computes its hash for `scriptDirective.hashes`); the CSP of the page allows each of them by its hash and a build-output test checks that every inline script is covered. A new inline script follows the same pattern, and only when nothing else works (a module script runs after the first paint).
-- Consequences for new features: no third-party scripts, styles, fonts, images or embeds without deliberately extending the CSP and the privacy policy; no Astro `` (use the native View Transition API); no inline event handlers. CSP is not applied in `astro dev`: check with `pnpm build && pnpm preview` and the browser console (look for "violates the following Content Security Policy directive").
-- `tests/build-output/security.test.ts` guards headers and CSP; extend it when the policy changes.
-
-## Site structure
-
-- `/` one-page scroll layout: Hero, About, Projects, Experience, Contact (contact form), Playground (the games), Footer. Sticky header with anchor links and scroll-spy.
-- `/games/` and `/games/` (Phase 4; Tech Words, Root Cause and Link Up are live): the overview and one route per game (code-split, SEO friendly), plus a Playground section on the home page after Experience. See "Games / Playground".
-- `/notes/` and `/notes//` ("Behind the scenes", linked in the footer, the game pages and the project card of this site): the overview and one write-up per note, see "Notes" below. In the sitemap and in the live check.
-- `/api/contact` and `/api/contact-token`: the only server code (Cloudflare Pages Functions for the contact form, see "Contact form"). Not part of the sitemap.
-- `/legal-notice` and `/privacy-policy`: the legal notice ("Impressum") and the privacy policy ("Datenschutzerklärung"), each page in **English first and German second** (see Legal). Linked in the footer of every page with the English labels "Legal notice" and "Privacy policy", not disallowed in robots.txt, but with `noindex` meta (so search engines do not list them yet crawlers can still read the tag). The old German addresses `/impressum` and `/datenschutz` redirect permanently through `public/_redirects` (people and tools type them). Cloudflare Pages applies that file, and so does `pnpm dev:functions` (Wrangler); `astro dev` and `astro preview` do not, so a 404 on the old addresses there is expected. The live check verifies the redirects after every deployment.
-- `/uses/` ("Uses", linked in the footer, in the sitemap and in the live check): what I work with, see "Uses page" below.
-- `/palette.json`: the data of the command palette and the terminal (see "Command palette, terminal and easter eggs"). Not in the sitemap.
-- `/.well-known/security.txt` (RFC 9116, made by `src/pages/.well-known/security.txt.ts` from `src/lib/well-known.ts`: contact by mail and by GitHub's private reporting, policy `SECURITY.md`, **`Expires` is set at every build to 330 days later**, so nobody has to remember it; a build-output test and a live check warn when it comes close) and `/humans.txt` (`public/humans.txt`, who built the site, no date so it cannot go stale).
-- `/404`: terminal style, `noindex`. Also generated: `/robots.txt` (search engines allowed, AI training crawlers blocked, points to the sitemap) and `/sitemap.xml` (from `src/pages/**/*.astro`, dynamic routes and noindex pages excluded; games pages are picked up automatically once they exist as static pages, dynamic game routes need explicit handling).
-
-## Content model
-
-- **Experience** (content collection `experience`, one YAML file per entry in `src/content/experience/`, schema in `src/lib/experience.ts`): education and work, shown in two groups (Education, Work) as a **compact list with icons**, deliberately different from the projects timeline (which has dots and cards). Fields: `type`, `title`, `organization`, optional `unit`, `start`/`end` as `YYYY-MM` (no end means "Present"), `highlights` (1-4), optional `coursework`, optional `relatedProject` (id of a project, renders a link to its card `#project-`). Newest first. Not shown: school (Abitur), final degree grades, non-dev jobs, certificates, and no CV download. Never publish a document with personal contact data.
-- **Projects** (content collection `projects`, one YAML file per project in `src/content/projects/`, schema in `src/lib/projects.ts`), shown as a vertical timeline, newest first (by end year, then start year). Fields: `title`, `subtitle`, `kind` (project, package, bachelor-thesis, master-thesis; `package` is shown as "Open source package"), optional `institution` (shown next to the kind, e.g. a university), optional `grade` (small badge at the top right of the card, e.g. "1.0", explained as best possible in the German system for screen readers), `start`/`end` year, optional `stats` (1-3 project key figures such as "-47% mean delay", shown as a strip; a grade is not a key figure), `highlights` (1-5 bullets), `technologies` (ids from the registry), `code`, an optional `note` and an optional `npm` (page on npm, `https://www.npmjs.com/package/...`, second button next to GitHub) and an optional `art` (the small animated picture on the card, one of `projectArts` in `src/lib/projects.ts`; the pictures are pure CSS and SVG in `src/components/project-art/`, calm, a loop of about ten seconds, hidden from assistive technology, still with reduced motion, paused off screen by a tiny script, on the right of the card on wide screens and as a strip below the subtitle on narrow ones; next to the title and the key figures, or, when a project has no key figures, next to the title and the highlights; a new picture is a new component plus an entry in `projectArts`; every picture uses the viewBox `0 0 240 200` and labels of `font-size: 8.5px` in the mono font so that text is equally big in every card, a test checks it). The Defuze picture is generated: change `scripts/generate-defuze-picture.mjs`, run it, commit the result; a test fails if the file differs from what the script makes).
- - `code.status` is `public` (GitHub URL required and shown), `planned` ("Code coming soon") or `none` ("Private project"). A link is impossible for planned/none (the schema strips it), and a public project needs a github.com URL.
- - To publish a thesis later: change its `code` to `status: public` with the URL.
-- Start with placeholder data; real content is swapped in later via the collections only.
-- Current projects: this website (public), playwright-layout-guard (my open source package, public, on npm; the card shows the package kind, an animated picture and the npm button), Defuze (MERN + ML, public), Master thesis (GNN + RL for airborne networks, code planned), Bachelor thesis (Tetrys FEC protocol in C++, public), Grade & Study Tracker (2019 Android app, no longer available). Adding a project only needs a new YAML file.
-- Icons: build-time inline SVG from `@iconify-json/lucide` (general symbols) and `@iconify-json/simple-icons` (brands such as GitHub) through our own `Icon.astro`. No icon fonts, no CDN, no runtime JS. Icons appear next to external links (GitHub, LinkedIn, project links).
-- Hero: an availability badge ("Open to opportunities", green status dot, links to the contact section; controlled by `availability` in `src/lib/site.ts`, set `open` to false to hide it), a short intro with calls to action "View projects" and "Contact" (no CV download).
-- Timeline strip above the experience list: `ExperienceTimeline.astro` draws bars from the experience entries (layout in `src/lib/timeline.ts`), desktop only and hidden from assistive technology; the experience schema has an optional `short` label for the bar. Cross-document page transitions were tried and dropped (Chrome logs an unhandled rejection when a transition is aborted, see PLAN.md).
-- About facts: `AboutFacts.astro` shows "Now" (what is going on, with the month of the last update, the job search item only while `availability.open` is true) and "Languages" (German native, English fluent). Data lives in `src/lib/site.ts` (`now`, `spokenLanguages`). Keep the Now block current. The footer links to the source code (`siteConfig.repo`).
-- **Notes** (content collection `notes`, one MDX file per page in `src/content/notes/`, schema in `src/lib/notes.ts`, the file name is the address `/notes//`): short write-ups of how something on the site is made, one problem and how it is solved, with numbers, small figures and the tests that back it up; the page template is `src/pages/notes/[slug].astro`, the overview `src/pages/notes/index.astro`, the text styles `.note-prose` in `global.css`. Fields: `title`, `summary` (40 to 240 characters, the line on the overview and the page description), `tag` (small label such as "Logic · Root Cause"), `group` (`site` or `games`: the overview shows two parts, "The site" and "The games", and Next and Previous stay inside the group), optional `game` (slug of a live game: the "How it is built" panel of that game links to the note, and the note offers "Play"), optional `code` (a folder of the repository, shown as "Read the code" on GitHub), `order` (position inside the group, unique in the group, lowest first), `updated` (YYYY-MM-DD, set it when the text changes). A note is **one narrow column**: running text, figures, steps, key figures, code and tables all have the width of the text (`max-width: 42rem` for every child of `.note-prose`), so nothing sticks out. Building blocks for MDX in `src/components/notes/`: `Figure` (caption), `Steps` (a chain of boxes, `columns`), `Stats`, `DeduceFigure` (made for Root Cause); `Code` colours a block at build time (``, where the code is a file `src/content/notes/snippets/.snippet` imported with `?raw`, at most 72 characters per line so that it fits the box without scrolling, both checked by `tests/build-output/notes.test.ts`; code is not written in the MDX file itself because Prettier takes the indentation out of code in MDX when it contains a blank line; highlight.js, a dev dependency: the output is plain text with CSS classes such as `hljs-keyword`, coloured in `global.css`; no script, no inline style; an unknown language fails the build), and every plain Markdown fence goes through `CodeBlock` (monospace, focusable, for axe). Shiki and `rehype-highlight` are not used: Shiki writes inline styles and the Markdown processor of Astro 7 (Sätteri) does not run rehype plugins. **Write what is true and verifiable** (numbers from the code and the tests, real output of the game, not invented), in the first person, English. A note needs: a page in `tests/e2e/pages.ts` (automatic), its own entry in `PAGES` of `scripts/check-links.ts` (it links to GitHub) and `src/content/projects/website.yaml` should keep pointing to the overview. There is a note for every game (Root Cause set tone and depth; Backbone is order 5 in the group games) and six about the site: "How this site is built" (the overview: the journey of a change, the gate, what the checks look at, what broke), "A page that never moves" (the layout guard), "A contact form without a third party" and "Watching the live site" (the live check and the bot protection that locked it out) "One page, four scores" (why a Lighthouse score jumped between 98 and 73: the simulation, the rule about the requests that finished before the largest paint, a page whose biggest text depended on a download) and "Fonts that arrive late" (the preload, the fallback fonts with measured sizes, the test that holds every font back). Each of the six is in the group `site` and links to the others where they belong. **When the site changes in a way that one of these notes describes (a new CI job, a new check in the live check, a change in the contact form or in the layout guard), update the note in the same PR and its `updated` date.** Figures are made from the real data and functions of the site at build time where possible (`EvaluateFigure` calls the real `evaluateGuess`, `ConflictFigure` reads the real Link Up data and fails the build if it no longer matches, `RadarFigure` and `NearestTable` work out a real Word Radar round with the game's own code), so a picture cannot disagree with the game. A long address in a sentence goes into `` (it may break at any character, so lines stay full); short code in a sentence is plain inline code. The heading, the lines under it and every child of the text are at most as wide as the text (42rem), which `tests/e2e/notes.spec.ts` checks for every note on a wide and a narrow screen. Facts in a note come from the code, the READMEs and measurements, and every claim is checked against them before it is written.
-- **Uses page** (`/uses/`, `src/pages/uses.astro`, content in `src/lib/uses.ts`, checked by `uses.test.ts` and `tests/e2e/uses.spec.ts`): the short version in a terminal block (`neofetch` style: the window is there first, then the command is typed with a caret, then the header, the lines, the colours and the closing prompt appear one after the other, a blinking cursor at the end; only opacity, transform and `clip-path` change, so nothing moves, and reduced motion shows everything at once), then a card per category (hardware, operating systems, terminal and shell, editors, browsers, AI tools). Keep it short and true: basics only (no monitors, headset, keyboard, mouse or drive list; tools that were only used in some projects, such as TensorBoard, Docker or Android Studio, stay on the home page and are not listed here; the AI tools are described in general terms, not as something used on this site), one line per item; change `usesUpdated` whenever something changes. It is a plain text page: `preloadFonts`, normal Lighthouse budgets. A new item goes into `usesCategories` (and the terminal block `usesSummary` if it is worth the short version).
-- A highlight of a project can link to a page of this site, written `[text](/path/)` (internal links only, checked by the schema, see `src/lib/highlights.ts`).
-- Technology registry rule of thumb: one chip per skill, combine related libraries (for example "NumPy & pandas"), keep a group readable (about eight chips) and keep detail such as IDEs on the `/uses` page. IDEs are not technologies (Android Studio was removed, JetBrains IDEs covers it).
-- Links: GitHub and LinkedIn (`socialLinks` in `src/lib/site.ts`), more later.
-- Technologies live in one registry (`src/lib/technologies.ts`, id -> label + icon). The About section shows them in six groups (Languages, Web & Mobile, Machine Learning, Networking, Tooling, Code Quality); every technology must be in exactly one group (tested). Projects and theses reference the same ids to list the technologies they used. Topics without a brand icon (deep learning, GNNs, reinforcement learning) use lucide icons.
-- Navigation: sticky header with the wordmark, anchor links (About, Projects, Experience, Contact), scroll-spy and a JS-free mobile menu (native `popover`). Links use `/#section` so they also work from the legal pages.
-- Brand: wordmark derived from the favicon (terminal chevron `>` plus `leonkrix` and a blinking `_` cursor). Open Graph preview images (link cards, 1200 x 630 PNG, the picture that appears when a link is shared): `public/og.png` is the card of the home page and the default of every page without a card of its own (the legal pages, the 404 page), and **the notes, the live games, the overviews of the notes and the games and the Uses page have a card of their own** in `public/og/` (title, tag, the first lines of the text and the address, laid out by the page itself so that a long title gets a smaller size and a long text is cut after a whole word; `src/lib/og.ts` says which cards there are and what is on them, `scripts/og-cards.ts` reads the real content, `scripts/og-template.ts` is the HTML). They are made by `pnpm og` (`scripts/generate-og.mjs`: Chromium screenshot of an HTML page with the site fonts and colors, only the cards that changed, `--all` for every card) and **committed** (a build must not need a browser). **Run `pnpm og` and commit the pictures after changing a title or a summary of a note, the name, tag or description of a game, the name or role, or the template** (`scripts/og-template.ts`); `scripts/og-fingerprints.json` holds a fingerprint of every card and `tests/build-output/og.test.ts` fails when a picture is out of date, missing or has no card, is not 1200 x 630 or above 260 KB (WhatsApp shows nothing above about 300 KB), and checks that every page points to its own card with an alt text; the live check asks for the picture of every note and game. A new kind of page with its own card is an entry in `src/lib/og.ts` and `scripts/og-cards.ts` and the page passes `image` and `imageAlt` to `BaseLayout`. No Atom feed: the notes are not a blog that is read in a feed reader, and a feed would need a date on every note (decided 2026-10-17).
-- Contact: the Contact section has the contact form (built in only when the build switch is on, see "Contact form" below) and a row of buttons: Email (protected, CSS-rendered until JavaScript makes it a mailto link), GitHub, LinkedIn. Until the privacy policy covers the form, production builds without it and the section shows only the buttons. JSON-LD (`Person`) in the home page head never contains email or address. More links can be added later.
-- No photo for now.
-
-## Design guidelines
-
-- Dark, minimal, generous whitespace, strong typography. No bright/colorful noise.
-- **Palette**: cool blue accent family (blue -> cyan/teal as analogous colors) plus one restrained contrast accent (e.g. warm amber) used sparingly. Tokens (starting point, tune during design phase):
- - bg `#0a0d12`, surface `#11161d`, border `#1e2632`
- - text `#e6edf3`, muted `#8b98a9`
- - accent `#4f8cff`, accent-2 `#22d3ee`, contrast `#f5b642`
-- Subtle animation: the background is implemented (`Background.astro`, no script). Principle: **reading text never sits on lines or busy patterns**. Every page has two huge, very soft color fields drifting slowly (fixed, behind everything); only the home page hero adds soft top light and a fine grid from the top right corner that scrolls away with the hero. Pages with a lot of text pass `calm` to `BaseLayout` (legal pages do). **No custom cursor and no cursor light** (tried, felt like a searchlight). Interaction feedback lives on the buttons: `.btn-primary` and `.btn-outline` (lift, glow, sheen, press), use them for every new button or button-like link. The page color lives on `html` so the layers at `z-index: -1` show through, do not give `body` a background. Entry motion: the hero elements use `.hero-in` (staggered with `--i`), content blocks use `.reveal` (time based fade-in when scrolled into view, `Reveal.astro`, hidden only while JavaScript runs and motion is allowed). Put `.reveal` on new content blocks below the first screen; never hide content in a way that stays hidden without JavaScript. Animations that keep their end state (fill mode "forwards") would block hover transforms, so use "backwards" or a final keyframe that equals the normal style. Keep animation lightweight (CSS and a few lines of script), it must not hurt Lighthouse and must honor reduced motion.
-- Boxes (cards, forms, notices, icon tiles) get the class `edge`: a faint permanent gradient on the border, only to lift them off the background. Header links use an accent underline for the active section instead of blue text. Never put a hover effect on boxes that are not clickable.
-- Widths: running text is limited to `max-w-2xl` (about 70 characters per line, readable). Structured content (technology grid, project cards, experience, the About facts box) uses the full container width (`max-w-5xl`). Forms stay at a moderate width: the Contact section puts the form in a right column next to the text on desktop. New sections follow this rule: text narrow, structure wide, forms moderate.
-- Mobile-first, responsive, WCAG AA contrast, full keyboard navigation, visible focus styles.
-- Use design tokens; no magic colors or spacing values in components.
-
-## Quality bar
-
-- Lighthouse 95+ in all four categories on mobile (enforced in CI).
-- Minimal JS shipped: hydrate islands with `client:visible` / `client:idle`; games are lazy loaded.
-- Valid semantic HTML, meta tags, Open Graph images, `sitemap.xml`, JSON-LD (`Person`).
-- **Fonts and layout shift (every page, decided 2026-10-16):** two measures, both tested on every page. (1) **Every page requests its two fonts at once, before the stylesheet is read** (the two `` in `BaseLayout`, only the latin files of Inter and JetBrains Mono, `crossorigin` is needed or the file is fetched twice; no other preload, so the first requests go to what the page needs). It used to be a switch for the text pages only and the game pages were left out, because the preload made Lighthouse count the 2 MB of Word Radar into the first paint (75 instead of 98); that was not the preload's fault but a second problem it uncovered (see below), so there is **no exception** any more. (2) **Fallback fonts with the measures of the web fonts** (`Inter Fallback` and `JetBrains Mono Fallback` in `global.css`: Arial, Liberation Sans, Arimo and Courier New, Liberation Mono, Cousine, with `size-adjust`, `ascent-override` and `descent-override`, a regular and a bold face each, in the font stacks right after the web font). The preload makes the font arrive early, the fallback makes the swap harmless when it arrives late (a slow line): with the fonts delayed by 1.5 s the notes and the legal pages shifted by up to 0.17 (0.1 is the limit for good), with the measures nearly every page is at 0 (one paragraph that breaks one line earlier is about 0.03). The numbers were measured with the real font files on text of the site (Inter is 6.8 % wider than Arial at weight 400 and 1.5 % wider than Arial Bold at 600 to 700, JetBrains Mono is exactly as wide as Courier New); change them only by measuring again, and when the fonts change (a new Fontsource version). `tests/build-output/fonts.test.ts` checks the preload on every page (both fonts, before the first style, nothing else preloaded) and `tests/e2e/slow-fonts.spec.ts` opens every page with the fonts delayed by 1.2 s and limits the layout shift to 0.05 (it needs fonts with the measures of Arial and Courier New on the machine: Windows, macOS, and on Linux the Liberation fonts that Playwright and Chrome bring; a visitor without them gets the generic fallback as before). Lighthouse is sensitive to the order of requests on a page with a big fetch: after any change to what a game page requests, run `pnpm lighthouse` twice and look at the LCP. **Why a score flips (found 2026-10-16, Word Radar, 98 / 89 / 75 / 74 / 73 on the same page):** Lighthouse does not time the page, it simulates the run it saw on a slow phone (1.6 Mbit/s, CPU 4 times slower) and takes every request that had **finished before** the LAST largest paint of the recording (the recording is made on the fast machine, without slowing it down, so 2 MB from localhost take about ten milliseconds) as something that paint waits for, in the optimistic and in the pessimistic graph (`getFirstPaintBasedGraph` in the trace engine of Lighthouse: a request that ended or started after the paint is dropped; LCP is the mean of the two graphs); a request that ends after the paint does not count. The full story is the note "One page, four scores" (`src/content/notes/one-page-four-scores.mdx`). The 2 MB of Word Radar must therefore never start before, or be the cause of, the last largest paint. Two things made that fail: the fetch started in the same few milliseconds as the first paint (fixed by waiting for the paint, `afterPaint` in `src/games/word-radar/data/load.ts`, which waits for the load, the fonts and the largest paint inside the game, and starts the files after that; guarded by `tests/e2e/word-radar-paint.spec.ts`, which checks that the files start after the last largest paint and after the fonts), and the page had **two** largest paints, the second one (the message of the game, longer than "Loading ...") only after the 2 MB had arrived, so the paint itself depended on the download (LCP 12.3 s, score 74 as soon as the fonts were preloaded and the first paint came earlier; before, it depended on how close in size the two texts happened to be). The skeleton therefore shows the final message from the start and the field says that the words are loading (`WordRadarSkeleton`): **the biggest text of a page must not depend on a large download.** To look for such a flip run the page 8 to 16 times and print the metrics (FCP, LCP, TBT, CLS, SI) of every run; a median of three hides a flip that happens in one run of four. The weights of the score: TBT 30 %, LCP 25 %, CLS 25 %, FCP 10 %, SI 10 %.
-- TypeScript strict, no `any` without justification.
-- Unit tests for all game logic; e2e smoke tests for critical paths.
-
-## Legal (Germany) - must stay correct
-
-Not legal advice; have the final texts checked (e.g. eRecht24 / IT-Recht Kanzlei generator).
-
-- **Impressum**: full name, ladungsfaehige Anschrift (no P.O. box), email. Phone is not mandatory if a second fast contact channel exists. Do not cite outdated law (TMG); the current law is DDG. Decision: the **private postal address** is used. It is obfuscated against simple bots as far as possible while staying legally correct (human-readable, no JavaScript required, screen-reader friendly).
- - **Obfuscation approach** (one shared component used by Impressum and Datenschutz): address and phone are never emitted as continuous text in the HTML; they are split into parts and rendered via CSS (`::before { content: attr(data-...) }`), which works without JS and is read by screen readers. The email is assembled from parts (small progressive-enhancement script makes it a clickable link; without JS it stays visible through the CSS output). No images of text (accessibility). A build-output test asserts that the address does not appear as plain text in `dist/`. Additional protection at the edge: Cloudflare Bot Fight Mode, AI-bot blocking and email address obfuscation. Honest limit: a scraper that renders CSS/JS can still read it; this only stops simple crawlers and harvesters.
- - **The postal address is never committed to the repo** (public repo, permanent git history). The repo only contains placeholders plus `.env.example` (`IMPRESSUM_STREET`, `IMPRESSUM_ZIP`, `IMPRESSUM_CITY`, ...). Real values are set as **Cloudflare build variables** and in a local, gitignored `.env`, and are injected into the Impressum page at build time (typed/validated env access via Astro). CI builds use the placeholders. These are not secrets (the address is visible on the live page); the goal is only to keep it out of git history. Name and email may live in the repo.
-- Legal pages are **bilingual**: every page has an English version (first) and a German version (second) in one page, with a note at the top that links to both. The **German version is the legally binding original**, the English one is a translation to make it understandable (Art. 12 GDPR asks for clear language for the audience, and the site is English). The addresses are English (`/legal-notice`, `/privacy-policy`; the language of an address is not prescribed, the footer label must be easy to recognize) and the old German ones redirect. Both versions must stay in step: same sections in the same order, same providers. Build-output tests check section numbers, headings and providers in both languages. No phone number is published: the email address and the contact form (once built in) are the two contact channels required by section 5 DDG.
-- **Datenschutzerklaerung** (`src/pages/privacy-policy.astro`, text in `src/components/legal/PrivacyPolicyEn.astro` and `PrivacyPolicyDe.astro`, the legal notice in `LegalNoticeEn.astro` and `LegalNoticeDe.astro`; change both languages together): controller, hosting provider (Cloudflare, including third-country transfer note and retention), server logs / IP addresses, the contact form (data incl. the hashed rate-limit counter in Workers KV, purpose, legal basis Art. 6 (1) b/f, the path of the message through Cloudflare to IONOS, recipients, storage period, no obligation to provide data), contact by email, no cookies and nothing stored on the device (section 25 TDDDG), no analytics, external links, TLS, no automated decisions (Art. 22), user rights with the Art. 21 objection notice, supervisory authority (kept generic on purpose: naming the state would reveal the address region), date ("Stand"). Sections are numbered by a counter, and **the contact form section and the Impressum line about the form exist exactly when the form is built in** (`PUBLIC_CONTACT_FORM`), so the text always matches the site; build-output tests check both variants. Retention wording: as long as needed to handle the request, then deleted unless statutory retention applies (section 257 HGB, section 147 AO).
-- Cloudflare may set a technically necessary security cookie (`__cf_bm`) when bot protection is on; this is mentioned in the privacy policy. The site itself sets no cookies, so a privacy-guard test must only allow such Cloudflare security cookies.
-- **No cookie banner** as long as there are no non-essential cookies, trackers, embeds (YouTube, maps, social widgets) or third-party CDN resources. Adding any of these requires revisiting the privacy policy first.
-- No analytics initially. If added later: privacy-friendly and self-hosted (Plausible/Umami) and documented in the privacy policy.
-- `robots.txt`: disallow known AI crawlers (GPTBot, CCBot, ClaudeBot, Google-Extended, ...). This is a request, not protection. Real bot protection comes from Cloudflare settings.
-- Games: only use word lists, data and assets whose license permits it; record sources in `THIRD_PARTY.md`. The privacy policy says that nothing is stored on the device, so nothing on the site (games included) may use browser storage; a site-wide guard test enforces it.
-- **Reviewed against the eRecht24 generator on 2026-10-17** (see PLAN.md, Phase 2, for what was changed and what was left out on purpose and why: no section 18(2) MStV person, no clause against advertising e-mails, no phone number, the e-mail address stays in `security.txt`). Rules that came out of it: the copyright section of the legal notice must keep the exception for third-party content and the CC BY-SA word lists (a test checks it); the right to object stays a section of its own; every feature that writes to the clipboard or to the device needs a sentence in the policy.
-- **Revisit the legal texts whenever the site changes**: games (localStorage for game state, word lists), a contact form (data processing, processor agreement, Turnstile), analytics, embeds, or any new third-party service. The privacy policy must always match what the site actually does.
-
-## Cloudflare Functions (`functions/`)
-
-- The site is static, the only server code is the contact form API, in Cloudflare Pages Functions (`functions/api/contact.ts` and `contact-token.ts`, helpers in `functions/_lib/`). The Pages project is `leonkrix-dev`.
-- `functions/` has its own `tsconfig.json` with the Workers types (they conflict with the DOM types of the Astro project), and is excluded from the root `tsconfig.json`. `pnpm typecheck` runs `astro check` and `tsc -p functions/tsconfig.json`. ESLint and Vitest cover the folder (tests next to the code, `*.test.ts`). CI checks that the Functions bundle (`pnpm build:functions`, needs at least one route, otherwise Wrangler fails with "No routes found").
-- **There must be NO Wrangler configuration file** (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`) in the repository: with one, Cloudflare Pages uses it as the only source of settings and ignores the dashboard variables at build time, including the Impressum address, so the production build fails (a repository guard test checks this; we learned it the hard way, the failed production build left the live site on the last good deployment). Compatibility settings are passed on the command line in the `dev:functions` and `build:functions` scripts, and set in the dashboard for the deployed project. Non-secret mail settings (SMTP host, port, account, sender and recipient) are constants in `functions/_lib/config.ts`; the only secret is `SMTP_PASSWORD`.
-- Secrets are Cloudflare Secrets per environment; for preview deployments use `pnpm exec wrangler pages secret put NAME --project-name leonkrix-dev --env preview` (value typed hidden), and a new deployment is needed afterwards. Locally they live in `.dev.vars` (gitignored, protected from Claude Code by a Read deny rule in the personal, untracked `.claude/settings.json`, see "Secrets and Claude Code" below); `.dev.vars.example` lists the names.
-- Local run: `pnpm dev:functions` (builds, then `wrangler pages dev ./dist --port 8788`). The code that talks SMTP (`worker-mailer`) needs the Workers runtime, so unit tests mock it; the real round trip was verified against IONOS locally and on a Cloudflare preview.
-- In the Cloudflare dashboard (Settings, Runtime or Functions, compatibility flags) the flag `nodejs_compat` and the compatibility date (2026-09-29, the newest date the dashboard allowed) must be set for **both** Production and Preview before an endpoint that imports `worker-mailer` is deployed.
-
-## Secrets and Claude Code
-
-- Never read, print or ask for the content of `.dev.vars`, `.env` and similar files, and never put a password, token or the private postal address into chat, commits, logs or tests. Secrets are typed by Leon directly into Cloudflare or into these local files.
-- The protection is a personal setting, not part of the repository (`.claude` is in `.gitignore`, so CI cannot check it). On a new machine create `.claude/settings.json` with this content:
-
-```json
-{
- "permissions": {
- "deny": [
- "Read(./.dev.vars)",
- "Read(./.env)",
- "Read(./.env.local)",
- "Read(./.env.production)",
- "Read(./.env.development)"
- ]
- }
-}
-```
-
-- The rule blocks Claude's file tools and shell commands that name the file (verified: even `ls -l .dev.vars` is refused). It is not an operating system level sandbox. Testing or debugging a secret is done by Leon running a command that reads the file himself and pasting only the non-secret result.
-
-## Live checks and monitoring
-
-CI checks the code before it is deployed. These look at what is actually running. Neither is part of the "CI passed" gate (they look at the live site, not at a pull request); a failure shows as a failed workflow run and GitHub sends an email.
-
-- **Live check** (`scripts/live-check.ts`, `pnpm live-check`, workflow `.github/workflows/live-check.yml`): runs every six hours, after every successful production deployment (Cloudflare reports deployments to GitHub, the workflow reacts to `deployment_status` of the production environment) and by hand. It repeats a failed check a few times, because a new deployment needs a moment to reach every location. Checks: **custom domain** (reachable; see the bot protection note below), home page (status, title, sections), security headers (must equal the `/*` block of `public/_headers`, so there is one list), the CSP in the page, response time (warning only), stylesheet and script (served, immutable) and `og.png`, redirects (http to https, www to apex, the old German legal addresses to the new ones), legal pages (both languages, noindex, **no address placeholders, email and address not readable**), robots.txt and sitemap.xml, the custom 404, the **Uses page** (answers, has a heading, the home page links to it), **security.txt** (has a Contact line, fails when expired, warns below 60 days), the **palette data** (`/palette.json` has places, notes, projects and the Uses summary and shows no readable address), the **contact form API** (token endpoint, no CORS, cross-site and Origin-less requests refused, and a honeypot probe that proves secret, switch and KV binding are in place without sending a mail), the **TLS certificate** (fails below 14 days), the **domain registration** (RDAP lookup, fails below 30 days, warning below 60; a failed lookup is only a warning) and the **mail DNS records** (MX, SPF and DMARC must still point to IONOS). Each check returns pass, warn or fail, only a fail fails the run. A summary table is written to the workflow run page, and the README shows a badge for the live check and one for the link check (the latest run on the default branch, red when the live site has a problem). `pnpm live-check --base http://localhost:8788` checks a local `pnpm dev:functions` (without the infrastructure checks). The script has no dependencies, Node 24 runs the TypeScript file directly, and its logic is unit tested with a fake site (`scripts/live-check.test.ts`). **When a new feature changes what the live site must do (a new page, a new header, a new API endpoint), extend the live check in the same PR.**
-- **Bot protection and the GitHub runners (learned the hard way):** Cloudflare's Bot Fight Mode (on, see Decisions) shows a "Just a moment..." challenge (HTTP 403, header `cf-mitigated: challenge`) to requests from data centers, and GitHub's runners are data centers, so the first manual run saw only challenge pages from `leonkrix.dev` (our own computer is not challenged). The scripts therefore detect the challenge (`isChallenge`), report the custom domain as reachable (TLS, DNS and the http and www redirects still work from the runner) and read the page content from `https://leonkrix-dev.pages.dev`, the same deployment without the bot protection (`--fallback none` switches that off). The fallback is only used when the custom domain is challenged, so a run from your own machine checks the real domain. If pages.dev were ever challenged as well, the options are to turn Bot Fight Mode off or to give up the post-deployment content checks from GitHub; Bot Fight Mode cannot be skipped by WAF rules on the Free plan.
-- **Link check** (`scripts/check-links.ts`, `pnpm check-links`, workflow `.github/workflows/link-check.yml`): runs every Tuesday and finds every external link on the published pages (home page, both legal pages) and checks it (HEAD, then GET, retries, linkedin.com only warns because it blocks bots). Links are read from the pages, so new links in the content are checked automatically. A link that is on no page goes into `EXTRA_LINKS` in the script, and a new page with links goes into `PAGES` (every note, because it links to GitHub). `pnpm check-links --dir dist` checks a local build.
-- GitHub disables scheduled workflows of a public repository after 60 days without any repository activity. Regular commits keep them alive; if the site is ever left alone for two months, re-enable them in the Actions tab.
-- Not included on purpose: a real mail as test (it would fill the mailbox and use the rate limit), an external uptime service, log analysis. Weekly security scanning (audit, CodeQL, Scorecard) is described under CI.
-
-## Contact form (live in production)
-
-- Status: live. Free, no third party: the message goes through our own Cloudflare Pages Function to our own IONOS mailbox via SMTP (`smtp.ionos.de:587`, STARTTLS, `worker-mailer`, verified in a spike: about 420 ms). The IONOS plan has one mailbox, so `hello@leonkrix.dev` is sender and recipient; the visitor is Reply-To. Subject: `[leonkrix.dev] New message from `.
-- Why not "just Cloudflare": Email Routing (receiving) would take over the MX records and break the IONOS mailbox. Cloudflare Email Sending is Beta and paid (about 5 USD per month). Fallbacks if IONOS ever fails: Cloudflare Email Sending, Brevo or Resend, all behind the small `sendMail` interface in `functions/_lib/smtp-mailer.ts` (a new processor would need a privacy policy update).
-- Fields: name, email, message. Requires JavaScript; without it (`@media (scripting: none)`) the form is hidden by CSS and the section shows the row of buttons (Email, GitHub, LinkedIn; the address is rendered by CSS and becomes a mailto link with JavaScript). The form is in the page from the start and is not shown by the script, so nothing moves when the script is ready. **Nothing in the form moves either** (checked by the layout guard): the message of a field is one short line (the texts in `src/lib/contact.ts` are short on purpose, about 28 characters at most for name and email) that sits in the gap below the field, `position: absolute`, or in the row of the counter (message); the progress ("Sending") and a problem with sending take the place of the privacy note next to the button (one grid cell with an invisible copy of the longest text, which sets the height); the confirmation lies on top of the form in the same grid cell and is as high as the form. The form has no `action` or `method` on purpose (a submit without JavaScript must not put the message into the URL).
-- UI: `src/components/ContactForm.astro` (markup and page script), browser logic without DOM in `src/lib/contact-client.ts` (unit tested). The time token is requested when the visitor first focuses the form, never on page load. **Build switch:** the form (and the matching privacy policy and Impressum text) is built in only with the build variable `PUBLIC_CONTACT_FORM=true` (Astro env schema, default false). `pnpm build:form` builds with it on; the browser tests and Lighthouse use that build; plain `pnpm build` (CI build job) has no form. Production has both switches on.
-- Validation rules live in `src/lib/contact.ts`, one plain TypeScript module imported by the browser script and by the Function (relative import); the server is authoritative. Limits: name 2-80, email ASCII, valid and at most 254 (64 before the @), message 20-2000 characters (counted as people see them), no control or bidi spoofing characters. Add a test with the boundary cases whenever a rule changes.
-- Endpoint (`functions/_lib/contact-handler.ts`, wired in `functions/api/contact.ts` and `contact-token.ts`): `GET /api/contact-token` gives a signed time token, `POST /api/contact` checks in a fixed order (method, Origin and Sec-Fetch-Site, switch and configuration, content type, body size 10 KB, JSON, honeypot, token, validation, rate limit) and then sends one mail. No CORS, fixed recipient and sender, header injection prevented, no message content in logs, generic errors. New server behaviour needs a test in `contact-handler.test.ts`.
-- Rate limit: Cloudflare KV (binding `RATE_LIMIT`), 3 messages per sender and hour, 30 in total, senders only as salted hashes, counters expire after 2 hours.
-- Cloudflare configuration (set in the dashboard, there is no Wrangler file; Production and Preview are separate): Secrets `SMTP_PASSWORD` and `FORM_SECRET` (32+ random characters, signs the tokens and salts the hashes), KV binding `RATE_LIMIT` (namespace `leonkrix-contact-rate-limit`, shared by both environments), compatibility flag `nodejs_compat` and date 2026-09-29. Plain variables `PUBLIC_CONTACT_FORM=true` and `CONTACT_ENABLED=true` are set in Production and in Preview (without `CONTACT_ENABLED` the endpoints answer 503, without `PUBLIC_CONTACT_FORM` the build has no form). A changed build variable needs a new deployment. Production also has the three Impressum variables. Local: `.dev.vars` (`SMTP_PASSWORD`, `FORM_SECRET`, `CONTACT_ENABLED=true`) and `pnpm dev:functions`. The mailbox password gives access to the whole mailbox: long, unique, never logged, rotated if leaked.
-- Legal: the privacy policy describes the form exactly while it is built in (see Legal). The form plus the email address are the two contact channels for the Impressum. No Turnstile (it would load a third-party script); add only if spam appears, together with a CSP and privacy policy change.
-- Tests: unit tests for the shared rules, the client logic and every Function module (mocked mailer, in-memory KV), build-output tests for the switch and the legal texts, Playwright and axe tests for the UI (API mocked with `page.route`). The real mail round trip is tested by hand on a preview.
-
-## Games / Playground (Phase 4)
-
-Full plan in PLAN.md, Phase 4. The foundation (4.0) is built and **Tech Words (4.1) is live** (`src/games/tech-words/`, its README has the rules, the data format and how to add words; `?seed=`, `?length=` and `?category=` in the address prepare a round, which the end-to-end tests use); Root Cause (4.2), Link Up (4.4), Word Radar (4.3) and Backbone (4.5) are live too (the longer write-up of a game is a note, see "Notes"; the panel "How it is built" on the game page stays short and links to it): registry `src/games/registry.ts`, shared code in `src/games/shared/`, pages `src/pages/games/[...slug].astro`, section `src/components/games/Playground.astro`, frame `src/layouts/GameLayout.astro`, how-to in `src/games/README.md`. By convention a game folder has `GamePage.astro` and `Preview.astro` (found by name, tested), a live game needs a budget in `GAME_BUDGETS` in `scripts/lighthouse.mjs`, and a game going live is picked up by the live check from the sitemap (extend the check only when the game needs more, such as an API). **Nothing on any page may ever move** when something appears, changes or loads (at **every** width: a text wraps at another point on another operating system, so a layout that is calm on Windows can jump on the Linux build server; `tests/e2e/word-radar-widths.spec.ts` plays a round at twelve widths from 296 to 1100 px in both languages, and checks that the page is never wider than the screen. The server draws the skeleton in both languages and the page shows the one of the address (see Word Radar below), and the game replaces it, so a box must not depend on the length of its texts: reserve the room for the longest, or the same height for one and two lines). Enforced for every browser test by the **layout guard** (the package `playwright-layout-guard` from npm, my own open source package, repository `leonkrix/playwright-layout-guard`; since 2026-10-24 it replaces the guard that used to live in `tests/e2e/layout-guard.ts`; it is switched on for every test in `tests/e2e/fixtures.ts` with the option `pageHeight: 'any'`, so a page that gets lower fails as well; it fails a test when an element moves or the page changes its height, with exceptions marked `data-layout-fluid` and a few that are measured anew: resize, ``, address change; `tests/e2e/layout-guard-canary.spec.ts` makes pages move on purpose and expects the guard to fail, so a broken wiring cannot go unnoticed; a change of the guard itself is made in the package, released there, and then updated here). Every game renders a **skeleton** (the same boxes, empty) on the server as the placeholder of its island and while it loads, so the page is exactly as high before the game as after it; space for results and messages is reserved. Details and rules in `src/games/README.md`, "Layout: nothing moves". No page is left out: `tests/e2e/pages.ts` lists every page, `tests/e2e/layout.spec.ts` opens each one (the 404 page too), reads it down to the end and fails when anything moves, and `tests/build-output/e2e-coverage.test.ts` fails when a built page is missing from the list, when a browser test takes `test` from Playwright instead of `./fixtures` (no guard), or when `layoutGuard: false` has no comment with a reason. A new page or a new dynamic state therefore has to stay calm, and the guard only ignores what is not visible (`visibility: hidden`, `display: none`) or measured anew on purpose (see above). The rules that apply while building:
-
-- A home page section (working name **Playground**) with tiles, and one page per game under `/games/`, plus `/games/` as overview. The section and the nav entry exist only while at least one game is live (registry `src/games/registry.ts`, `status: 'hidden' | 'live'`). **The home page shows at most four tiles** (decided 2026-10-18, so the section stays short while the list grows): a game with `home: false` in the registry (Tech Words) is left out there and comes last on `/games/`, which shows every live game; `homeGames()` is the list of the home page, and the button "All games (N)" under the tiles leads to the overview. The order of the registry is the order of both lists (Root Cause, Backbone, Word Radar, Link Up, Tech Words).
-- **English only. Unlimited play, no daily mode, no saving.** Nothing is stored on the device (no localStorage, sessionStorage, IndexedDB or cookies), so the privacy policy statement "nothing is stored on your device" stays true. The guard is **site-wide**, not game-specific: a source scan of `src/`, a scan of the published JavaScript in `dist/` (small commented allow-list for libraries) and a Playwright runtime check that storage and cookies are empty after visiting every page and playing every game (`tests/build-output/storage-guard.test.ts` and `tests/e2e/privacy.spec.ts`; the e2e page list in `tests/e2e/fixtures.ts` includes the game pages automatically once a game is live). Adding any storage requires a privacy policy update first (both languages), see Legal.
-- **Root Cause (4.2) is live** (`src/games/root-cause/`). Its README is the source of truth for rules, clue catalog, interface and design decisions (read it before touching the game). Pure logic in `logic/` (maps with typed, named rooms, about 45 clue types including neutral clues, a search solver, a second rule solver that is also the rater, the generator, the moves of the player in `play.ts`, the clue texts in `describe.ts`); skins in `data/skins.ts` and `data/words.ts` (Case file, Outage; people vs devices); the level pool `data/levels.json` (18 levels, made by `pnpm levels`, plan in `data/pool-plan.ts`, stories in `data/stories.ts`, CI gate `pool.test.ts`; the victim of a level varies). Every level must pass `problemsOf` (both solvers find exactly the stored solution); the victim never gets a clue. Random levels are made in the browser in up to four Web Workers (`components/generate.worker.ts`, sizes by tier in `logic/random-level.ts`), and a random level's address (`?level=random&seed=...`) makes the same level again. The pictures of the objects come from Lucide (`data/object-icons.ts`). Clues: every part of a clue must say something (a combination whose part follows from the other is never made), at most one row and one column clue per level, a medium level has at least half of the people placed by the clues alone (`directlySolved`), anything there is only one of is "the". An easy level is solved completely by "this person has one cell left" (`nakedSolved`). Hints (`logic/hint.ts`, `deduce` in `logic/propagation.ts`) are always the next logical step in three stages with a counter, and the last stage can explain why (`logic/explain.ts`, "Why here?" dialog). The pool is chosen from several candidates per level for an even mix of clue kinds (`logic/balance.ts`, `LEVELS_CANDIDATES` and `LEVELS_SELECT` of `pnpm levels`). Changing the generator changes what a seed gives, so old shared links of random levels then give another (equally valid) level. An object is one unit (a sofa or a cable tray over two cells: next to means beside it, not on it; directions are taken from the whole object). Next: nothing planned for this game; ideas are collected in PLAN.md (Phase 4b, the project card animations, is done; Link Up is next to be released, then Word Radar).
-- **Link Up (4.4) is live** (`src/games/link-up/`, README is the source of truth). About 240 hand-written categories with more than 3,000 terms in `data/categories-*.ts` (7 to 20 terms each, every term in exactly one category, unique across the pool; definitions are our own words, at most 100 characters, without the term itself). The data is fetched in pieces: `data/catalog.ts` (generated, list of every category with its file; after a change run `pnpm exec vitest run src/games/link-up/data/catalog.test.ts -u`), `shards.ts`, `load.ts`, `generatePuzzleLazily`; `data/index.ts` imports everything and is for tests and tools only (never import it in the island: it would put all the data into the first script). A script of the game page starts the first puzzle early (`logic/first-game.ts`), in parallel with React; without that the page scores about 95 in Lighthouse instead of 96 to 98. The `conflicts` list in `data/conflicts.ts` names category pairs that must never share a puzzle (a new category whose terms could pass for another's needs an entry). `logic/puzzle.ts` makes puzzles and `countSolutions` (a separate counter) must find exactly one solution; `logic/game.ts` is the immutable game (auto-submit with the fourth pick, four mistakes, one away, a repeated wrong group is free); the React island only draws. Categories are shown only when found (or missing after a loss), term definitions appear on hover, focus or tap. `?seed=` makes the same puzzle again.
-- **Word Radar (4.3) is built, in English and in German** (`src/games/word-radar/`, README is the source of truth). **German** (`?lang=de`, `data/de/`, `pnpm word-radar-data-de`; CC BY-SA 4.0 for the two word lists): one component with the texts of the language in `logic/texts.ts` (a new text needs both languages), the language is read from the address in the island and in the prefetch script of the page, the two links of the switch are plain HTML on the game page (the language that is played is marked `aria-current` and is **not a link** any more: the script turns it into a `span`, because a click would load the same page again and an anchor without `href` is not crawlable (Lighthouse SEO fell to 92); the other one is a link, which loads the page in that language); the static page draws the placeholder of the game in **both languages** (`.wr-fallback`) and a tiny inline script at the top of the page writes the language of the address to `data-wr-language` on the html element, which the stylesheet uses to show the right one from the first frame (before, the English placeholder was seen until React had drawn the German one, 70 ms to over a second; a test blocks the game script and looks at the placeholder, and a unit test compares the script with `parseLanguage`); the words are written with capitals as OdeNet and the German Wiktionary write them (the vectors are all lower case), typing finds ä, ö, ü and ß also as ae, oe, ue and ss; the list has basic words only, inflected forms are entries of `data/de/forms.txt` that lead to their word (typing Hunde counts as Hund), and a hidden word can have synonyms after a tab in `secrets.txt` (`WordData.aliases`; guessing one finds the word with rank 1, a hint is never one; made from OdeNet and the closeness in the numbers, corrected by hand in `scripts/word-radar/synonyms-de.txt`). The data of English is as below. Data made by `pnpm word-radar-data` (`scripts/word-radar/`: GloVe 6B 100d and WordNet 3.0 from the gitignored `data-raw/word-radar/`, own PCA in TypeScript, 30,000 words, 64 numbers each as bytes, 1,510 hidden words, blocklist in `blocked-secrets.txt`); the three data files are committed and fetched when the page opens (about 2 MB, started by a page script in parallel with React, `data/load.ts`). The rank of every word is computed at the start of a round (`logic/similarity.ts`). GloVe and WordNet are credited in `THIRD_PARTY.md` (the WordNet notice must stay). Layout never moves (skeleton, reserved space, fixed-height list, checked by the generic layout guard).
-- **Backbone (4.5) is built** (`src/games/backbone/`, README is the source of truth, the concept is `docs/network-game-concept.md`, the write-up is the note `backbone`). A planning game judged by a deterministic stress test (`logic/simulate.ts`; `logic/fast.ts` is the same model on arrays for the solver, a test compares them). **An exact solver proves the best plan of every level** (`logic/solver.ts`, branch and bound, relies on the property "extra traffic never helps a flow already in the plan", tested; `solveDesign` also chooses the cables to build). Stars from shortest path, load balanced and the best plan (`logic/stars.ts`); the score is absolute, and the first four chapters only admit levels whose best plan is (nearly) 100 (`minBest` per chapter in `data/campaign-plan.ts`). **Levels are made, not drawn by hand:** `logic/generate.ts` from a spec and a seed (nodes on a lattice, `logic/lattice.ts`; drawing rules in `logic/geometry.ts`: no cable through a node or over another, angles, distances; names and marks placed by `logic/labels.ts`), the plan of the campaign is `data/campaign-plan.ts` (13 chapters, 78 levels, six per chapter, stories, settings in `data/settings.ts`), `pnpm levels-backbone` (`scripts/generate-backbone-levels.gen.ts`, runs as a "test" like the Root Cause generator, settings in its header) searches a seed per level, proves it, keeps only levels that pass every check and writes `data/levels.json` and the small `data/catalog.json` (the overview reads the catalog, the game fetches the pool when a level of the campaign is opened, after the first paint). `src/games/backbone/pool.test.ts` is the gate: every level, its best plan, the strategies, the thresholds and the drawing are checked again, cheap proofs are repeated, the catalog must match. **A change of the generator, the model or the settings changes the levels: run the script again and commit the pool** (the gate fails otherwise). **Delay is shown in milliseconds** (one unit of the model is 5 ms, `logic/latency.ts`): from the first level with traffic that must be fast, and in every level from chapter 5 on (`showsDelay`), the cables show it as a pill of its own under their load (`data-delay`, the label estimate in `logic/labels.ts` is taller then). **Every value of a cable has a pill or chip of its own**: load (and delay below it) on the cable, queue below, loss above (solid when the cable always loses, dashed when only in a situation). The label of a cable is a point (width 0) and its pills are centred with a transform, because the layout guard looks at the layout and a number that grows must not move anything; the text version of the network lists only the columns the player has learned (Delay in ms, Queue, Loss), the panel adds up the route being drawn against what the kind of traffic can take, and the result says when a flow was too slow (`LateFlow`). **Packets follow the delay** (`packetsAt`: one packet per unit of traffic and minute, a cable takes its delay times 0.15 minutes, lost packets stay and fade where they were lost, coded packets of redundancy are empty boxes). **Only what has been taught is said** (milliseconds in the result only when `showsDelay`, one queue per cable, as big as the cable (a bar and "Q 3/5" under every cable from the chapter about queues on), loss labels like "10 % lost" above lossy cables, a cable to build lights up while the route is drawn towards it). **A flow that cannot be held back must fit the network alone** (`maxFlow` in `logic/graph.ts`, checked by `problemsOfLevel`: over several paths if it has to, optional cables counted; bulk is exempt). **Lost packets pick their place** on the cable between 10 and 90 % of its length, per packet (`unit` in `logic/view.ts`); **packets that a full cable cannot take are dropped at the node where it starts**, never on it, and **packets that the queue holds stand at that node** for the minutes the queue needs (`through`, `dropped`, `waitMinutes` of `LinkStepResult`), so a cable only shows packets that really got on it. **The queue is empty on the map before the run** (the load says what the plan asks), and the result says from which minute the queue of the busiest cable was full. **Chapter 10 has radio links**: the event `range` makes a cable exist only in a window of a situation (`outOfRange`, `LinkStepResult.absent`, drawn dashed in amber, label "away"); the `moving` situation makes windows that meet in the middle, so one change of route (rule `stages`) is enough for the solver; **chapter 10 offers no backup path** (a backup would handle every window by itself and `stages` would never matter). A flow that has to pass a node (firewall) glows on the map until its route does, and a route that skips the node is refused. **The throttle slider and the solver use the same ten steps** (10 to 100 % in tens, `THROTTLE_STEPS` in `logic/options.ts`, decided 2026-10-19), so nothing the player can set beats "Best plan"; changing the steps changes the best plan of every level with bulk traffic: `LEVELS_RESOLVE=1 LEVELS= pnpm levels-backbone` keeps the levels and proves their best plan again (a level that then fails a check is listed and has to be made again). Three or more bulk flows in one level are too slow to prove with ten steps (c5-5 had to be reduced to two). Chapter 8 (building: optional cables with a cost and a budget, `logic/build.ts`) and chapter 11 (zones: a flow can have to pass a node, `Flow.via`) are part of the model. Random levels in five tiers (`logic/random-level.ts`, background threads, `?level=random&tier=..&seed=..` makes the same level again). The overview of the campaign is a page of its own (`?level=` in the address, the server draws it as the skeleton, so a level opened from a link starts at another height; tests open levels from the overview). Browser tests in `tests/e2e/backbone.spec.ts`; the browser tests of the CI run in three shards since this game. **Nothing is hidden before the run** (decided 2026-10-19): the panel lists every situation with its events, minute by minute, and marks the cables on the map (`forecastOf`, `riskyLinks`); the player counts in **minutes** (from 1) and never sees the word step. The building of cables (optional cables with a cost) is in chapter 8 and comes back in a few levels of the chapters after it (c9-5, c10-5, c11-4, c12-4, c13-2, c13-4, c13-6), because every chapter builds on the ones before it. **The labels on the map (names, marks, numbers of flows, loads) scale with the map and are measured by `tests/e2e/backbone-labels.spec.ts` for every level; after a change that moves them make only the levels that fail again.**
-- Random puzzles (Root Cause generator, Link Up) are only shown after an independent verification in the same run (exactly one solution); CI property-tests the generators with many seeds.
-- Games and their texts use product and technology names only as plain names (no logos, no claim of a connection); definitions are our own words.
-- Every game lives in `src/games//` with its own `README.md` (rules, design decisions, data sources and licenses, how to add words or levels, how to test), `logic/` (pure TypeScript, unit tested, no DOM and no React), `data/`, `components/` (React island) and tests next to the code. Shared code in `src/games/shared/`. Game logic never knows the UI or the skin.
-- Own names, own art, own texts (no trademarks such as "Wordle" or "Connections", nothing copied from puzzle books). Data sources are recorded with their license in `THIRD_PARTY.md`.
-- Performance: no game JavaScript on the home page (tiles and previews are CSS and SVG), React and data only on the game page, a size budget per game page. Large data is fetched when the game page opens.
-- **Label in Name (WCAG 2.5.3, axe 4.14 checks it, found 2026-10-20 when Dependabot bumped `@axe-core/playwright`):** whatever text a button or a grid cell shows has to be part of its accessible name, in order and without the words in brackets (axe ignores those). So a button gets its name from its text (the palette button has a hidden "Search and commands" and the visible key), an `aria-label` that stands in for visible text starts with or contains it (`Close, Esc`), and letters that only decorate an element with its own name (the numbers and the from/to marks on a node of Backbone, the letters on a cell of Root Cause) are drawn by the style (`::before { content: attr(data-text) }`), not written into it.
-- Accessibility: fully playable with the keyboard, results announced through a live region, never color alone, reduced motion honored. Animation is welcome (CSS or SVG), always off or static for reduced motion.
-- Tests per game: logic, data validation (word lists, levels with a solver, vectors), e2e round with the keyboard, axe, reduced motion. They run in the existing CI jobs.
-- Content rules that need a decision before building: ask Leon.
-
-## Command palette, terminal and easter eggs (built 2026-10-16)
-
-- **Command palette** (Ctrl or Cmd + K, or the button in the header; `src/components/PaletteTrigger.astro`): a native modal `