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 <name>`, 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 `<meta>` 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 `<ClientRouter />` (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/<slug>` (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/<slug>/` ("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-<id>`). 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/<slug>/`): 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 (`<Code lang="ts" code={snippet} />`, where the code is a file `src/content/notes/snippets/<name>.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 `<Url>` (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 `<link rel="preload">` 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 <name>`. -- 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, `<details>`, 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/<slug>`, 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=<ids> 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/<slug>/` 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 `<dialog>` built with the DOM API (`src/scripts/palette-ui.ts`), groups Go to, Games, Notes, Projects, Actions, Links and Legal, search over titles, summaries, tags and keywords (`src/lib/palette.ts`, no fuzzy matching: the list is short), arrow keys, Enter, Esc, a screen reader announces the number of results. Actions: play a random game, copy the email address (from the obfuscated data, the address is never printed), open the terminal. Nothing is stored (no recent entries). Its box has a fixed size. -- **Terminal** (type `>` in the palette, or the terminal on the 404 page): the commands are one list, `commands` and `runCommand` in `src/lib/terminal.ts` (pure, tested without a browser; `help` is made from the list and a test runs every listed command), the screen is `src/scripts/terminal-ui.ts` (history in memory, Tab completes). Commands: help, about, projects, games, notes, neofetch (the short version of /uses), open, play, ls, cat, cd, pwd, git log (the last commits of the build, made when the site is built; a build without history prints a note), email, whoami, radar, clear, exit, and some that `help` does not tell (sudo hire leon, rm, vim, echo, ping). **A new command is an entry in `commands` (if it is listed) and a case in `runCommand`, with a test.** -- **The data** is `/palette.json` (`src/pages/palette.json.ts`, built by `src/lib/palette-data.ts` from the sections, the live games, the notes, the projects, the Uses summary and `git log`), so a new note, game or project shows up in the palette and in the terminal by itself. The address is base64 in it. A build-output test (`tests/build-output/palette.test.ts`) checks that every link in it exists and the live check fetches it. -- **Nothing of it loads with a page except a small script** (about 1 KB, the key listener, the Konami code and the console greeting). The palette, the terminal, the radar and their data and styles (about 10 KB) are fetched when it is first opened (the button starts the download when the pointer comes near). **Their styles must not be imported as CSS from a script:** Astro links the styles of every script of a page, also those it only loads later, so the palette CSS was on every page (found by a test on 2026-10-16). They are imported as an address (`?url`) and added by `src/scripts/styles.ts` when needed; `tests/build-output/palette.test.ts` fails when a page links them. The 404 page is the exception on purpose: the lines of its terminal are in the page, so it imports `terminal.css` itself. -- **Budgets:** the home page was at 9.7 KB of 10 KB scripts and 19.4 KB of 20 KB stylesheets after this (2026-10-16; the fifth tile picture, Backbone, brought it to 20.2 KB and the limit to 21 KB on 2026-10-18, and the limit of `/games/` to 28 KB; the home page stylesheet was 20.7 KB of 21 KB after the card of playwright-layout-guard and its picture (2026-10-24), so the next picture or global style needs room made first; the five game pages share one stylesheet that holds the CSS of every game, so since Backbone every game page has the 28 KB limit, 27.8 KB measured on 2026-10-20: the next game or a larger Backbone style needs the styles split per game page first); the notes styles (`src/styles/note-prose.css`) and the legal styles (`src/styles/legal.css`) were moved out of `global.css` into the layouts that use them to make room. Any new global style or script has to be paid for the same way; do not raise a budget without a reason. -- **Easter eggs** (none tracks or stores anything): a greeting in the console (`console.info`, with the link to the source), a greeting as a comment in the HTML of every page (before `<html>`; the charset must stay within the first 1024 bytes, a test checks it), the **radar** (Konami code, or the command `radar`; ignored while typing in a field), `/humans.txt` and `/.well-known/security.txt`. **The radar is useful, not only an animation** (`src/scripts/radar.ts`, `src/lib/radar.ts`, `src/styles/radar.css`): a beam turns once around the middle of the screen, every thing of the page that is worth a look and on the screen lights up with a frame and its label when the beam reaches it (the delay comes from its direction, `angleOf` and `pingDelay`), then a panel lists everything the page has as buttons that scroll there and light the thing up again (Escape or the button closes it, a second run replaces it). **What is worth a look is marked by hand** with `data-radar="Label"` on the element (the availability badge, the hero buttons, "What I am doing now", the key figures of a project and of a note, the games, the contact form and the direct contacts, the setup on the Uses page, the game itself, "Play" and "The code on GitHub" on a note, the terminal of the 404 page, and the source link and "How this site is built" in the footer, so no page is empty); nothing is guessed. **A new page or section with something the visitor should find gets a `data-radar` on it** (a label of a few words; the same label counts once). With reduced motion there is only the panel. The layers are fixed and ignore the pointer, so nothing moves. **A frame is never measured once:** it is put at the place of its element in every frame of the screen (the page scrolls smoothly to a chosen find, and a block that fades in is still rising when the page arrives), and after a click in the list it starts only when the element has stood still for six frames (`settled`); a test clicks six finds far apart and checks the frame against the element three times each (the first version measured 450 ms after the click and was up to 1000 px off). - -## Decisions and rationale - -- **Astro over plain React/Next.js**: static one-pager, minimal JS, React only as islands. No MERN/backend needed. -- **Cloudflare over Vercel**: free forever, unlimited bandwidth, commercial use allowed (Vercel Hobby is non-commercial only). Vercel is the fallback. -- **Git integration + branch protection over deploying from Actions**: no API tokens or deploy secrets, fewer moving parts; branch protection guarantees only CI-green code reaches `main`. -- **No Airbnb ESLint config**: unmaintained, incompatible with flat config / TypeScript / Astro. Modern strict rule sets instead. -- **No cookie banner, no analytics, no third-party embeds/CDNs**: keeps the site legally simple (self-hosted fonts, a contact form that needs no third-party service because the message goes through our own Cloudflare Function to our own mailbox). -- **All rights reserved instead of MIT**: texts, images and design should not be freely reusable. -- **Impressum address kept out of git**: injected via build variables; alternatives to a private address are a paid Impressum/c-o service (roughly 5-20 EUR/month); a fully anonymous Impressum is not legally possible (name and serviceable address are mandatory). -- **Mail stays at IONOS, DNS moves to Cloudflare**: copy the IONOS MX records before switching nameservers. -- **Games are optional fun, not the core**: MVP first. -- **CodeQL yes, SonarQube Cloud no**: CodeQL is free, GitHub-native and useful once games handle user input; Sonar would duplicate ESLint/TypeScript/CodeQL findings and adds an external service and a secret. -- **Single required check `CI passed`**: an aggregate job so new CI jobs never require changing the ruleset. -- **Live check instead of an uptime service**: `scripts/live-check.ts` looks at the published site from outside (see "Live checks"). It needs no account and no new processor, and it checks far more than "is it up". A separate uptime service was considered and not needed. -- **Private address in the Impressum, obfuscated**: see Legal. Legal pages come right after Phase 1, before design work. -- **Cloudflare baseline, not more**: Full (strict), Always HTTPS, min TLS 1.2, Bot Fight Mode (it also challenges GitHub's runners, see "Live checks and monitoring"), AI bot blocking. **Keep off**: Web Analytics (third-party script, breaks the privacy promise), Rocket Loader (rewrites scripts, conflicts with CSP), managed robots.txt (robots.txt lives in the repo). DNSSEC is deliberately not enabled (small benefit, outage risk). HSTS is sent from `public/_headers` (safe, because the whole `.dev` TLD is HSTS-preloaded anyway). Security headers live in the repo (`public/_headers`), not in the dashboard. -- **Workflow actions are pinned to full commit SHAs**: IDE inspections may flag their inputs as undefined (false positive, the IDE cannot resolve metadata for SHA refs). - -## Conventions - -- Small, focused commits with Conventional Commit messages. -- Never push to `main` directly: work on branches, open PRs, CI must pass, then merge. -- Prefer editing existing components over adding new abstractions. Keep components small and typed. -- Do not add third-party scripts, fonts, or embeds without checking the legal section above. -- Never put invisible or bidirectional control characters literally into source files; write them as escapes in strings (a repository guard test checks this). Beware: some tooling converts an escape sequence typed in a comment into the real character, so describe such characters in words (for example "U+202E"). -- Before opening a PR run `pnpm check` locally. -- **Test cadence while building a feature** (decided with Leon, 2026-10-15): the full suites take long (`pnpm check` about 1.5 minutes, `pnpm test:e2e` about three, `pnpm lighthouse` about five and a half, three runs on every page; on the CI the browser tests and Lighthouse are sharded, see the jobs above), and waiting for them several times per feature wastes the most time. So: build the feature, run only what is quick and relevant (the tests that were just written or touched, `pnpm exec vitest run <file>`, `pnpm exec playwright test <file>`, `pnpm lighthouse --page /path/` for one page, lint and typecheck), show it to Leon and take his feedback, build the changes. Only when Leon gives his final okay run **everything once** (`pnpm check`, `pnpm test:e2e`, `pnpm lighthouse`) and fix what the full run finds. Do not run the full suites after every round of feedback. Exception: a change that touches shared code (global styles, layouts, the layout guard, the page list) deserves one full run before the okay, because a failure there can be far from the change. -- **Keeping the checks fast (2026-10-16, without touching what they check):** the CI needed about eight minutes and the unit tests alone 88 seconds. What was done, and the rules that keep it that way: (1) the CI jobs for the browser tests (2 shards) and Lighthouse (3 shards) are matrices, and `tests/build-output/ci-shards.test.ts` fails when the matrix, the name and the argument of a job do not agree (a dropped shard would silently skip tests or pages while the CI stays green); a new sharded job needs the same guard. (2) A test file should take no more than about 20 seconds: vitest runs the files side by side and the tests of a file one after the other, so one slow file sets the time of the whole suite. The heavy tests of Root Cause (generating hard levels) are split into several files (`generator-*.test.ts`, `play-texts-*.test.ts`, helpers in `generator-test-helpers.ts` and `clue-text-checks.ts`); split a new heavy test the same way, never shorten it. The check that every kind of clue has a clean sentence runs on the levels of the pool (all 46 kinds, both skins, in 0.1 seconds) and the sentences on freshly generated levels are checked per skin in their own files. (3) Things that were looked at and not done, with the reason: Lighthouse with fewer than three runs (the median of three only just shows a flip such as the 98 / 74 of Word Radar), several Lighthouse runs on one machine (they disturb each other), more browser workers than cores (the timing tests become flaky), `eslint --cache` (with type-aware rules a changed type in one file does not invalidate the cache of the files that use it, so a local check could pass and the CI fail), and fewer or shorter tests. -- The PR title becomes the squash commit message: write it as a Conventional Commit (`feat: ...`, `fix: ...`, `chore: ...`, `docs: ...`, `ci: ...`). -- Commit locally as often as you like, but push a branch only when the feature is complete (each push builds a Cloudflare preview). -- New external links use the `ExternalLink` component (`target="_blank"`, `rel="noopener noreferrer"`). -- In the WebStorm commit dialog, "Analyze code" is off (lint-staged and CI already cover it); `dist/` and `.astro/` are marked as excluded. -- **Keep the portfolio in sync with the tooling:** when a new test or quality tool is added to this site (e.g. Playwright, axe, Lighthouse CI), add it to the technology registry in the "Code Quality" group and to the `technologies` of `src/content/projects/website.yaml`. Update the website project highlights or key figures when they become verifiable (e.g. a Lighthouse score). -- **Keep the whole portfolio current, not just tooling:** whenever the site gains or changes a feature, update in the same PR (1) the technology registry and the "Code Quality" group, (2) the project entries in `src/content/projects/` (highlights, technologies and key figures, e.g. the number of automated tests, Lighthouse score), (3) the About text and any claim such as "no third-party service", (4) the privacy policy and Impressum (they must match what the site does, see Legal), (5) README, PLAN.md and this file. If a new feature needs a decision (legal, costs, third parties), ask Leon before building it. diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index bab28b9..0000000 --- a/PLAN.md +++ /dev/null @@ -1,330 +0,0 @@ -# Roadmap - -Check items off as they are done. Each phase should end in a deployable state. CI must be green before anything is deployed (see CLAUDE.md, "CI/CD"). - -## Where we are (2026-10-20) and what comes next - -Phases 0 to 4b are done and merged, and so is the network game: tooling and CI, content, the contact form, the live check and the bilingual legal pages, the Playground with five games (Tech Words, Root Cause, Word Radar, Link Up, Backbone), the animated project cards, the layout guard and eleven notes in "Behind the scenes" (five about the games, six about the site). **Backbone was merged on 2026-10-20** (PR #65, after ten rounds of feedback). What is left is in Phase 2 (the thesis repositories, the About text) and the routine at the end of this file. - -Next, in this order (decided with Leon on 2026-10-14): - -1. ~~`/uses` page~~ **done 2026-10-15** (Phase 5). -2. ~~Word Radar in German~~ (4.7) **done and merged 2026-10-16**: a language switch on the same page, German data under CC BY-SA 4.0 (see 4.7). -3. ~~Command palette with a terminal mode, the easter eggs and `security.txt`~~ **built 2026-10-16** (Phase 5), waiting for Leon's look. -4. ~~Own OG images per page~~ **built 2026-10-17** (Phase 5); the Atom feed of the notes was **dropped** (see Phase 5). -5. ~~The network game~~ (4.5) **built 2026-10-18, merged 2026-10-20** (Backbone). - -Alongside: the legal texts are checked against a generator once (Phase 2, the item "Review the legal texts"), more notes when there is material, and the routine maintenance at the end of this file. - -## Phase 0 - Foundation and tooling - -- [x] Dev environment: Windows native (WebStorm) for now, Debian later; both must work -- [x] Install Node LTS and pnpm; add `.nvmrc`, `packageManager` field -- [x] Scaffold Astro (TypeScript strict) + React integration + Tailwind v4 -- [x] `.gitattributes` (LF), `.editorconfig`, `.gitignore` -- [x] ESLint flat config: typescript-eslint strict (type-aware), astro, react, react-hooks, jsx-a11y, import sorting -- [x] Prettier + astro and tailwind plugins, `eslint-config-prettier` -- [x] `tsconfig` strictest; `astro check` -- [x] Scripts: `lint`, `lint:fix`, `format`, `format:check`, `typecheck`, `test`, `check` -- [x] Husky + lint-staged pre-commit hooks (commitlint still undecided) -- [x] Vitest set up with a first test -- [x] Self-hosted fonts (Fontsource), design tokens (colors, fonts) in global CSS -- [x] Base layout, SEO component (meta, OG), favicon -- [x] `LICENSE` (All rights reserved, (c) 2026 Leon Krix), short `README.md` -- [x] `.env.example` (Impressum placeholders), `.env` gitignored -- [x] Verify pre-commit hook runs on the first commit (from WebStorm) -- [x] Commit and push Phase 0 to `main` (bootstrap: no branch protection yet) -- [x] Typed env access in Astro (`astro:env`) for the Impressum variables (done with the legal pages) - -Tooling notes: - -- TypeScript is pinned to **6.x** (`typescript@6`). TS 7 is not yet supported by typescript-eslint and `astro check`. Renovate/Dependabot must ignore TS major 7 until both support it. -- ESLint is **10.x**. `eslint-plugin-react` crashes on ESLint 10 (`getFilename is not a function`), so React linting uses `@eslint-react/eslint-plugin` plus `eslint-plugin-react-hooks` and `eslint-plugin-jsx-a11y`. Do not re-add `eslint-plugin-react`. -- `pnpm peers check` still shows one known, harmless warning: `eslint-plugin-jsx-a11y` declares peer support only up to ESLint 9, but it works on 10 (verified with test files). - -## Phase 1 - CI and first deploy - -- [x] `.github/workflows/ci.yml`: parallel jobs `quality`, `test`, `build` plus aggregate gate `ci-ok`; reusable setup action. First run green -- [x] Workflow hygiene: minimal `permissions`, actions pinned to SHA, concurrency, pnpm cache -- [x] Dependabot (npm + GitHub Actions), grouped weekly, ignoring TypeScript 7 and ESLint 11 majors -- [x] Repo settings: squash merge only, auto-delete head branches, secret scanning + push protection -- [x] Ruleset `protect-main`: PR required, required check **`CI passed`**, up to date, no force push, no deletion -- [x] Cloudflare project via Git integration; placeholder page deployed -- [x] Domain: DNS moved to Cloudflare (mail records kept), `leonkrix.dev` and `www` attached, `www` redirects to apex, HTTPS works -- [x] Cost check: Cloudflare Free, GitHub Free (public repo), no payment method -- [x] **PR 1 (`docs/record-decisions`)**: this docs update, the first PR through the whole flow (PR, CI, Cloudflare preview, squash merge, production deploy) -- [x] Verify the gate: open a throwaway PR that fails CI (e.g. a lint error) and confirm it cannot be merged; then close it without merging -- [x] Add the Impressum build variables in Cloudflare, Production only (`IMPRESSUM_STREET`, `IMPRESSUM_ZIP`, `IMPRESSUM_CITY`); never commit them. The production build now fails if they are missing -- [x] `hello@leonkrix.dev` mailbox at IONOS works (send and receive tested) -- [x] Cloudflare security baseline (Full strict, Always HTTPS, min TLS 1.2, Bot Fight Mode, AI bot blocking; Web Analytics and Rocket Loader stay off). DNSSEC deliberately skipped (small benefit, outage risk); HSTS is sent via `public/_headers` - -## Phase 1b - Security and CI extras (one branch per item, all via PR) - -- [x] `actionlint` job in CI (lint the workflow files), added to `needs` of `ci-ok` -- [x] `dependency-review` job on pull requests, added to `needs` of `ci-ok` -- [x] Security headers in `public/_headers` plus a strict CSP via Astro (`security.csp`), covered by `tests/build-output/security.test.ts` -- [x] Live headers checked: securityheaders.com grade A+ (HSTS from `public/_headers`) -- [x] `.github/workflows/codeql.yml` (`javascript-typescript`; PRs, `main`, weekly), pinned actions -- [x] `SECURITY.md` (private vulnerability reporting must be enabled in the GitHub repo settings) -- [x] Decided 2026-10-14: **no commitlint** (the single commits of a branch vanish in the squash merge, only the PR title reaches the history, so it would only get in the way of work in progress) and **no PR title check for now** (the Conventional Commit convention of CLAUDE.md is kept by hand; revisit if automatic changelogs are ever wanted) -- [x] OpenSSF Scorecard workflow and README badge (done in Phase 3A) - -## Phase 2 - MVP content - -Legal pages first (the site was already live), then the page content in small PRs. - -- [x] Typed env access (`astro:env`) for the Impressum variables, placeholders when unset, production build fails when missing -- [x] Shared obfuscated-contact component (CSS-rendered address/phone, assembled email, works without JS); used by Impressum and Datenschutz -- [x] `/impressum` and `/datenschutz` drafted in German with English notice, `noindex`, linked in the footer of every page (later replaced by the bilingual pages at `/legal-notice` and `/privacy-policy`, see 3D) -- [x] Test: address, placeholders and email are not present as plain text in `dist/`; no mailto link in the HTML (CI job `build` runs `pnpm test:dist`) -- [x] Review the legal texts against a generator (eRecht24 / IT-Recht Kanzlei) and adjust. **Done 2026-10-17** (the test documents of the eRecht24 generator, compared point by point with both languages of both texts; every mandatory point of the generator was already covered, in more detail): four changes, each with a test: (1) the copyright section of the legal notice now says that third-party content (fonts, libraries, data sets) and the German word lists of Word Radar (CC BY-SA 4.0) are not "all rights reserved"; (2) the right to object (Art. 21 GDPR) is a section of its own, because Art. 21(4) asks for it to be set apart; (3) one sentence that "Copy the email address" in the command palette writes to the clipboard on request and transmits nothing; (4) the legal norm of the Cloudflare cookie (section 25(2) no. 2 TDDDG). **Looked at and left out on purpose:** the person responsible under section 18(2) MStV (only for journalistic-editorial offers, and it would rather present the notes as such), the "objection to advertising e-mails" clause (legal effect disputed, noise), a phone number (e-mail and the contact form are the two channels; the remaining risk is that case law wants a quick answer), an overview "data protection at a glance", and the e-mail address in `security.txt` stays in plain text next to the GitHub reporting link (it is public in `SECURITY.md` anyway, `hello@` is a role address; change it if spam shows up). Next review: when the site starts to store or send something new, otherwise in about half a year (April 2027). Earlier note: **unblocked on 2026-10-14**: the games are done and store nothing, and nothing that concerns data has changed since the last review (the notes are static pages, the contact form is unchanged). Claude prepares a table: every statement of the privacy policy and the Impressum, in both languages, next to the test or the code that guarantees it; Leon compares the texts with the generator and decides on changes. The supervisory authority is kept generic on purpose (naming the state would reveal the address region). A German Word Radar (4.7) with share-alike data needs a look here too - -Content PRs (one branch each): - -- [x] PR 1 Layout: wordmark logo, sticky header with anchor navigation, scroll-spy and JS-free mobile menu, section scaffold, skip link, header tests -- [x] PR 2 Icons, hero and About: `Icon.astro` (lucide + simple-icons), hero with calls to action, About with technology groups -- [x] PR 3 Content collection `projects` (Zod) and the projects timeline: this website, Defuze, Master thesis, Bachelor thesis, Grade & Study Tracker; fields `kind`, `institution`, `grade`, `stats`, `code` (public, planned, none), technologies from the registry -- [x] PR 4 Content collection `experience` (Zod), Experience section (Education and Work as a compact list with icons, coursework, link to the related thesis) and the "Open to opportunities" availability badge in the hero -- [x] PR 5 Contact section (email, GitHub, LinkedIn, availability text), `robots.txt` (AI training crawlers blocked), dependency-free `sitemap.xml` (noindex pages excluded), JSON-LD `Person` (no email or address), `/404` in terminal style, and build checks for broken internal links and anchors - -Content decisions (done): no CV download, no certificates, no school (Abitur), no final degree grades (thesis grades 1.0 are shown on the project cards), no non-dev jobs, no Impressum-relevant private data. - -Content additions after the first live version (all done in one PR): - -- [x] About facts block (`AboutFacts.astro`): one box with "Now" (what is going on: the games section, polishing the site, and the job search while the availability badge is on; with the month it was updated and a breathing dot) and "Languages" (German native, English fluent). Data in `src/lib/site.ts` (`now`, `spokenLanguages`); **update the Now block whenever something changes**, a test only checks that the month is valid and not in the future -- [x] Footer link "Source on GitHub" (`siteConfig.repo`), also on the legal pages -- [x] Layout widths made consistent: running text stays at 672 px (about 70 characters per line, the readable range), everything structured uses the full container width (976 px): technologies, project cards, experience and now the facts box. The Contact section is two columns on desktop (text and direct contacts on the left, the form on the right at a comfortable 400 to 640 px; a form is harder to fill when the fields stretch across the whole page) and one column in the order text, form, direct contacts on small screens. The bullet points inside the project cards deliberately keep the full card width. Tests check the widths and the order -- [x] Technologies: new Network Protocols (bachelor and master thesis), Network Coding (bachelor thesis), NumPy & pandas as one entry in Machine Learning (master thesis), REST APIs (Defuze), Linux / Unix and AI-assisted development in Tooling (the website project lists AI-assisted development). Rule of thumb: one chip per skill, related libraries are combined (NumPy & pandas), a group should stay readable at about eight chips. Android Studio was removed again (an IDE, covered by the JetBrains IDEs entry); further tools may move to the `/uses` page later to thin out Tooling - -Open content follow-ups: - -- [ ] Refine the About text together with Leon after the first live version (Leon keeps an eye on it and on the "Now" block and adjusts them when needed, 2026-10-14) -- [ ] Optional: concrete evaluation numbers for the Bachelor thesis as key figures -- [ ] When the thesis repositories are public: switch `code` from `planned` to `public` (with URL) in the two thesis files -- [ ] Update the availability badge (`availability` in `src/lib/site.ts`) when Leon is no longer looking - -Phase 2 is complete. What remains is small content polish (see the open follow-ups above, to be done step by step whenever there is something new to show). - -## Phase 3 - Quality gates, contact form, design polish - -Order (decided): 3A quality gates first, then 3B the contact form (so its browser tests have the infrastructure), then 3C design polish and animations. - -### 3A Quality gates - -- [x] CI job `e2e` (part of `CI passed`): Playwright against the production build on desktop and mobile; smoke tests for navigation, mobile menu, skip link, email link, external links, legal pages, 404, no horizontal scrolling, reduced motion; every test also fails on console errors, CSP violations and HTTP errors -- [x] Privacy guard test (Playwright): only same-origin requests and no cookies on every page, including the 404 page (Cloudflare's `__cf_bm` only appears in production with bot protection; if it ever shows up in a test, allow exactly that cookie) -- [x] Accessibility: `@axe-core/playwright` scan of every page against WCAG 2.2 AA and best practices, currently no violations -- [x] CI job `lighthouse` (part of `CI passed`): `scripts/lighthouse.mjs` runs Lighthouse (mobile, simulated throttling) three times against the production build and takes the median; every category must reach 95 (measured on 2026-10-01 with the contact form built in: performance 98, accessibility 100, best practices 100, SEO 100), budgets for transferred bytes (scripts 10 KB, stylesheets 20 KB, total 200 KB; measured 4.5, 7.1 and 134 KB) and no request to another origin. The report is uploaded as an artifact. We use Lighthouse directly (13.x) and not `@lhci/cli`, which is barely maintained and would pull old dependencies into `pnpm audit` -- [x] CI job `audit` (part of `CI passed`): `pnpm audit --audit-level high` on every pull request and push (currently no known vulnerabilities); the same audit also runs weekly in `security.yml`. OpenSSF Scorecard runs in `.github/workflows/security.yml` (push to `main`, weekly, on branch protection changes) with published results and SARIF upload to the Security tab -- [x] First Scorecard run: 6.7 of 10. Everything we can influence is 10 (dangerous workflows, token permissions, vulnerabilities, update tool, security policy, pinned dependencies, binaries, CI tests). Expected to rise by itself: Maintained (needs a repository age of 90 days) and SAST (older commits leave the window). Not applicable and intentionally left: Code-Review (GitHub does not let authors approve their own pull requests, so a required approval would block every merge in a solo project), Branch-Protection (partly needs approvals), Fuzzing, CII Best Practices (needs an open source license), Contributors, Packaging, Signed-Releases. Dismiss the not-applicable code scanning alerts as "Won't fix" with a reason -- [x] The new jobs (e2e, lighthouse, audit) are in `needs` of `ci-ok`; no branch protection change was needed -- [x] Keep the portfolio in sync: Playwright, axe and Lighthouse are in the registry and the website project; the key figure "95+ Lighthouse in every category (enforced in CI)" is on the website card - -- [x] Stronger pre-commit hook: project-wide type check and related unit tests when code is staged (a type error slipped past the old hook to CI once); build warnings about Shiki and the CSP style directive removed (`markdown.syntaxHighlight: false`, style elements scoped with `kind: element`) -- [x] README rewritten: badges (CI, CodeQL, OpenSSF Scorecard, live site, license), quality gates table, scripts, project structure. Badges live in the README only, on the website they would be third-party requests. Keep it in sync when jobs or scripts are added (the Scorecard badge always shows the current score). A PR template with a self-check list was considered and rejected: solo project, a checklist only its author reads adds ceremony -- [x] The e2e job no longer hangs after the tests on GitHub (first runs hung inside `pnpm test:e2e` before the summary was printed, most likely while stopping the web server). Fixes: the server is a single process (`astro preview` directly), the build runs before Playwright, `gracefulShutdown`, an 8 minute global timeout, an 8 minute step timeout and the list reporter. Verified green. Plan B if it returns: start the preview server in the workflow as a background process with its output redirected to a file - -### 3B Contact form (decided: yes, free, with JavaScript) - -Decisions (all implemented, the form is **live in production**): fields name, email and message; requires JavaScript (progressive enhancement is not worth the extra surface); without JavaScript (CSS media feature `scripting: none`) the form is hidden and the section shows the row of buttons (Email, GitHub, LinkedIn). The Contact section is: short text, the form, and a uniform row of icon buttons; the large email card is gone. Calm card styling with clear states (empty, error, sending, sent). Details and configuration are in CLAUDE.md, "Contact form". - -- [x] Spike on a throwaway branch (done, see the next item): a Cloudflare Pages Function that sent one test mail through the existing IONOS mailbox via SMTP. Decided: the IONOS plan has exactly one mailbox, so `hello@leonkrix.dev` is both sender and recipient (Reply-To is the visitor). The mailbox password is a Cloudflare **Secret** (runtime, encrypted; NOT a build variable), locally in `.dev.vars` (gitignored, read by Wrangler); it gives access to the whole mailbox: long, unique, never logged, rotated if it may have leaked -- [x] Spike DONE, it works: SMTP through smtp.ionos.de:587 (STARTTLS, auth plain) with `worker-mailer`, locally (about 470 ms) and on a real Cloudflare preview deployment (about 420 ms, real Cloudflare colo), mail arrives in `hello@`. So IONOS accepts connections from Cloudflare and the Free plan CPU limit is enough. **Decision: the contact form uses the IONOS mailbox via SMTP, no third party.** Facts: Cloudflare **Pages** project `leonkrix-dev`; Functions in `functions/`, compatibility flag `nodejs_compat` (command line and dashboard, see the lesson below), non-secret mail settings as constants in `functions/_lib/config.ts`; secrets are per environment (production and preview are separate): set preview secrets with `wrangler pages secret put NAME --project-name leonkrix-dev --env preview` (value typed hidden), a new deployment is needed afterwards; preview deployments have no Impressum variables on purpose; `.dev.vars` is protected from Claude Code by a Read deny rule in the personal, untracked `.claude/settings.json` (content in CLAUDE.md, "Secrets and Claude Code") -- [x] Lesson from merging the foundation PR: the production build FAILED (the live site stayed on the last good deployment) because a `wrangler.jsonc` in the repository makes Cloudflare Pages ignore the dashboard variables at build time, including `IMPRESSUM_*`. Fixed by removing the file: the mail settings are constants in `functions/_lib/config.ts`, only `SMTP_PASSWORD` is a secret, compatibility flags go on the command line (scripts) and into the dashboard, and a guard test forbids any Wrangler config file in the repository -- [x] Fallback (Cloudflare Email Sending about 5 USD per month, or Brevo or Resend) is not needed; keep the mailer behind the small `sendMail` interface in `functions/_lib/smtp-mailer.ts` so it could be swapped -- [x] Foundation PR (branch `feat/contact-form-foundation`): spike endpoint removed, placeholder `POST /api/contact` (503 "not available yet") so `functions/` is valid and deployable, `functions/_lib/smtp-mailer.ts` with unit tests (mocked transport, config validation, always closes the connection), `pnpm typecheck` covers the Functions, Vitest runs `functions/**/*.test.ts`, CI job `build` runs `pnpm build:functions`, `pnpm dev:functions` for local runs, docs -- [x] Dashboard setting by Leon: compatibility flag `nodejs_compat` and compatibility date 2026-09-29 for Production and Preview -- [x] Spike cleanup: `SPIKE_TOKEN` secret deleted, branch `spike/ionos-smtp` is gone from the repository. Optional for Leon: remove the old spike preview deployments in the Cloudflare dashboard (harmless, they only clutter the list) -- [x] Shared validation rules in `src/lib/contact.ts` (plain TypeScript, no dependencies, no DOM or Workers types; used by the browser and the Function) with 140 unit tests: name 2-80 characters, at least one letter or digit, line breaks and tabs become spaces, NFC; email ASCII only, at most 254 and 64 before the @, procedural check in linear time (no regex backtracking), header injection attempts rejected; message 20-2000 characters counted as people see them (emoji = 1), trimmed, CRLF to LF, control characters and bidi spoofing characters rejected, joiners for emoji allowed; honeypot field `website`; errors have a code and a text for the visitor, all problems are reported at once. A repository guard test forbids hidden bidirectional or control characters in source files ("Trojan Source") -- [x] Endpoint PR (branch `feat/contact-form-endpoint`): `POST /api/contact` and `GET /api/contact-token`, logic in `functions/_lib/contact-handler.ts`, fully unit tested with a mocked mailer and an in-memory KV. Checks in this order: method (405), Origin must equal the own origin plus `Sec-Fetch-Site` (403, no CORS), switch and configuration (503), content type JSON (415), body at most 10 KB counted on the stream and valid UTF-8 (413), JSON object (400), honeypot (answers ok, sends nothing), HMAC time token (at least 3 s and at most 1 h old, 400), shared validation (422, all errors at once), rate limit in Cloudflare KV (3 per sender and hour, 30 in total, senders only as salted hashes, counters expire after 2 h, 429), then the mail with fixed sender and recipient and the visitor only as Reply-To (generic 502 on failure, only the error name is logged, never content). The endpoints stay switched off until the variable `CONTACT_ENABLED` is exactly `true`, so nothing is live before the UI and the privacy policy are ready -- [x] Dashboard settings by Leon for the endpoint: secret `FORM_SECRET`, KV binding `RATE_LIMIT` (namespace `leonkrix-contact-rate-limit`), Production and Preview. Preview no longer carries the old spike variables (`MAIL_FROM` and friends) -- [x] Contact form UI PR (branch `feat/contact-form-ui`): form in the Contact section (`ContactForm.astro`, browser logic in `src/lib/contact-client.ts` with unit tests), shared validation in the browser (errors on blur and submit, focus on the first problem, `aria-invalid`, live counter), sending and sent states, server errors (422, 429, 502, 503, network) shown without losing the text, honeypot field, time token requested on first interaction (no API call for visitors who only read), no `action` or `method` on the form (without JavaScript it is hidden and a submit could not leak text into the URL), icon row Email, GitHub, LinkedIn (the email button is a CSS-rendered span that JavaScript turns into a mailto link with real text). **Build switch:** the form is only built in with the build variable `PUBLIC_CONTACT_FORM=true` (Astro env schema, default off), so production shows no form until the privacy policy is updated. `pnpm build:form` builds with the switch on; `pnpm test:e2e` and `pnpm lighthouse` use it -- [x] Playwright and axe tests for the form (`tests/e2e/contact.spec.ts`, the API is answered by `page.route` because the browser tests run against the static build): validation, counter, honeypot, sending state, success, server errors, switched off, network failure, keyboard only, axe with visible errors. The real round trip is tested by hand on a preview (`CONTACT_ENABLED` only in Preview) -- [x] Switched on in production: `PUBLIC_CONTACT_FORM` and `CONTACT_ENABLED` set to `true`, redeployed, form visible and test messages arrive in `hello@` -- [x] Privacy policy PR (branch `feat/privacy-policy-contact-form`): new section for the contact form (data, hashed counter, purpose, legal basis, path of the message, recipients, storage period, no obligation), sections on cookies and storage on the device (section 25 TDDDG) and no automated decisions, objection notice (Art. 21), supervisory authority, extended email section (section 257 HGB, section 147 AO), Impressum mentions the form as second contact channel. The sections exist only when the form is built in. Website project, About text and form notice updated -- [x] Function tests run in CI through `pnpm test` and `pnpm build:functions` (job `build`), secrets and the KV binding are set in Cloudflare, local development with `pnpm dev:functions` and `.dev.vars` is documented -- [x] Decided 2026-10-14: no Turnstile for now (the honeypot, the time token and the rate limit are enough). Only if spam appears, together with a change of the CSP and of the privacy policy - -Phase 3A and 3B are complete. The next step is 3C. - -### 3C Design polish and animations - -- [x] Animated background (`src/components/Background.astro` and the "Animated background" block in `global.css`), reworked twice after Leon's feedback: grid lines behind reading text were distracting, and the cursor light felt like a searchlight, so it was removed again (the cursor stays a normal cursor). Result, **no script at all**: (1) every page has a calm fixed layer of two huge, very soft color fields that drift slowly (70 s and 90 s, static for reduced motion), (2) only the home page hero has the "hero light": soft light from the top and a fine grid that appears from the top right corner and fades out, scrolling away with the hero so text blocks never sit on grid lines (it also sets the hero apart from the rest), (3) legal pages and any page with `calm` get no grid. Hidden from assistive technology, ignores the pointer. Tuning values are at the top of the CSS block -- [x] Button effects (`.btn-primary` and `.btn-outline` in `global.css`): primary buttons lift 1 px, get a soft accent shadow and a light sheen that sweeps across on hover and are pressed (scale 0.97) on click; outline buttons get an accent border, a faint accent wash and a thin glow ring; icons nudge a little (arrow down, paper plane). Hover only on devices that can hover, no movement for reduced motion. Used by the hero, the contact form, project links, profile links and the 404 page -- [x] Card edge shimmer (`.edge` in `global.css`): every box (project cards, contact form and success box, 404 terminal, legal notice, experience icon tiles) has a faint, permanent gradient on its border (accent at the top left, cyan at the bottom right), weaker than the button hover. Use `edge` on every new box -- [x] Header navigation: the active section and hover show a thin accent-to-cyan underline that grows from the left; the text is no longer blue (active is white). Mobile menu: active entry has a thin accent bar on the left -- [x] Logo easter egg: while hovering the wordmark the cursor blinks fast and the chevron nudges to the right (CSS only, nothing for reduced motion) -- [x] Bug fix: browser back or forward during a smooth scroll now stops the running scroll and follows the address bar (`popstate` handler in `Header.astro`, tests in `tests/e2e/scroll.spec.ts`). **Keep this in mind for the scroll animations**: any scroll-driven effect must not fight the browser history or anchor navigation -- [x] Bug fix: the vertical line of the projects timeline now starts at the center of the first dot instead of sticking out above it -- [x] Availability badge (`AvailabilityBadge.astro`, `.badge-ring` and `.badge-glow`): a slow breathing ring around the green dot (3.2 s, instead of the fast ping), a faint green glow that pulses on the pill (4.5 s) and an arrow that slides in on hover to show that it leads to the contact section. Static for reduced motion -- [x] Timeline strip above the experience list (`ExperienceTimeline.astro`, layout in `src/lib/timeline.ts` with unit tests): bars for every experience entry on two lanes (education blue, work cyan) over whole years from the first start to the end of the last year, a faint line per year, and a highlighted band where a job ran next to studies (the teaching assistant job during the bachelor's degree). Calculated from the experience data, so a new entry appears by itself; an entry without end date runs until today and fades out at its end; overlapping entries of one type get their own rows. New optional field `short` in the experience schema for the label on the bar. The bars grow in from the left when the strip is revealed. Desktop only, hidden from assistive technology (the list carries the same information), the list stays alone on small screens -- [x] Hero entrance (`.hero-in` with `--i` per element): badge, greeting, name, role, intro, buttons and profile links rise in one after the other (about 650 ms each, 80 ms apart, CSS only). The animation uses fill mode "backwards", so after it ends the normal styles apply and button hover effects keep working. Lighthouse performance stays at 98 (the name is the largest paint, so the stagger stays short) -- [x] Scroll reveals (`.reveal`, `src/components/Reveal.astro`): blocks that start below the visible area (About paragraphs, technology groups, project cards, experience groups, the contact blocks, section headers) fade in and rise 36 px over about 0.7 s when they scroll into view, one time; blocks that arrive together follow each other 90 ms apart. First built as a CSS scroll-driven animation (`animation-timeline: view()`) and replaced after Leon's feedback: it was tied to the scroll position, happened at the very bottom edge of the screen and was practically invisible. The current version is time based (a scroll listener adds and removes `reveal-pending`), so it is clearly perceivable but still calm. Safety: without JavaScript or with reduced motion nothing is ever hidden; blocks scrolled past in one jump (End key, anchor link) are shown at once without effect; hiding below-the-fold blocks happens without a fade-out. It cannot fight browser history or anchor links because it only reacts to where blocks are, not to scroll animations. Tests: `tests/e2e/motion.spec.ts`; the axe scans run with reduced motion so contrast is measured on final colors -- [x] Page transitions: **tried and dropped.** Built with the native `@view-transition { navigation: auto }` (header and background kept in place, 200 ms cross-fade), but Chrome intermittently aborted the transition when navigating right after a page load and logged an unhandled rejection ("Transition was aborted because of invalid state. ViewTransition opt-in disabled"). It failed our console-error check in about half of the repeated runs, and handling the promises in `pageswap` and `pagereveal` did not help. The gain (a fade between the home page and the legal pages) is too small for a console error that visitors' developer tools would show. Retry when Chrome fixes this; the CSS was about ten lines -- [x] Reduced-motion handling and automated accessibility pass: every animation (hero entrance, reveals, background drift, button effects, logo easter egg) is switched off or static with `prefers-reduced-motion`, and tests check it; axe finds no violations on any page (also with form errors visible); the contact form is tested with the keyboard only; focus styles are visible; hover effects only on devices that can hover -- [ ] Optional, low priority (Leon 2026-10-14: probably not needed): one manual pass with the keyboard only and with a screen reader (NVDA on Windows), if ever, only for three flows (the navigation of the home page, the contact form with errors, one round of a game). The automatic tests already cover the keyboard for every game and the form, and axe covers the semantics; a manual pass would only judge the reading order and the wording -- [x] OG image (`public/og.png`, 1200 x 630, about 150 KB): wordmark, "Hello, I'm", the name, the role in the blue to cyan gradient, the domain, on the dark background with the hero light and grid. Made by `scripts/generate-og.mjs` (`pnpm og`): a screenshot of a small HTML page with the same fonts (Fontsource files), colors and wordmark as the site, rendered by the Chromium that Playwright already provides, so there is no extra dependency. The text comes from `siteConfig`. **The PNG is committed** (a production build must not depend on a browser); run `pnpm og` again after changing the name, role, wordmark or colors. Every page uses it as `og:image` and `twitter:image` (Twitter card summary_large_image, with width, height and alt text); build-output tests check the tags and that the file is a 1200 x 630 PNG below 500 KB - -### 3D Live checks and bilingual legal pages - -- [x] **Live check** (`scripts/live-check.ts`, workflow `live-check.yml`: every six hours, after every production deployment, by hand): 13 checks of the published site, among them security headers compared with `public/_headers`, redirects, legal pages without address placeholders, the contact form API configuration (honeypot probe, no mail sent), the TLS certificate, the domain registration (RDAP) and the IONOS mail DNS records. Verified against the real site (the two legal page checks failed there until this change was deployed, which showed that they work). Unit tested with a fake site (22 tests). Details in CLAUDE.md, "Live checks and monitoring" -- [x] **Weekly link check** (`scripts/check-links.ts`, workflow `link-check.yml`, Tuesdays): external links are read from the published pages, so new links are checked automatically; linkedin.com only warns (it blocks bots). Add pages with links to `PAGES`, links that are on no page to `EXTRA_LINKS` -- [x] No external uptime service (decided): the live check covers it without a new account or processor -- [x] **Lesson from the first manual run:** all content checks failed with HTTP 403 and the title "Just a moment...". Cloudflare's Bot Fight Mode challenges requests from data centers, and GitHub's runners are data centers (our own computer is not challenged, so local runs had been green). Fix: the live check and the link check detect the challenge, report the custom domain as reachable and read the content from `https://leonkrix-dev.pages.dev` (same deployment, no bot protection); TLS, DNS and the http and www redirects still work from the runner. Bot Fight Mode stays on. Unit tests simulate the challenge (fallback is used, redirects still checked on the custom domain, a broken domain is not hidden). To verify after merging: run "Live check" and "Link check" once by hand, the first line of the live check should say that the custom domain was challenged and the content was checked at pages.dev -- [x] **Bilingual legal pages**: `/legal-notice` ("Impressum") and `/privacy-policy` ("Datenschutzerklärung") replace `/impressum` and `/datenschutz`. Every page has the English version first and the German version second (the legally binding original), a note at the top that links to both, `lang` attributes per version, the German term in the title. Footer labels are English ("Legal notice", "Privacy policy"). The old addresses redirect permanently through `public/_redirects`. The text lives in `src/components/legal/` (four components), tests check that both languages have the same sections, numbering and providers, and the contact form section exists in both or in neither. The English text is a careful translation, not a certified legal text: include it when the texts are checked against a generator before the games -- [x] **Manual steps for Leon after merging (all done, confirmed by Leon on 2026-10-14):** (1) wait for the production deployment, then open the Actions tab and start "Live check" once with "Run workflow", it should be all green; (2) check that GitHub sends emails for failed workflow runs (GitHub, Settings, Notifications, Actions: "Send notifications for failed workflows only" on); (3) put the domain renewal date (28 September 2027, at IONOS) into your calendar, the live check also warns 60 days before; (4) after the first production deployment look at the Actions tab: a "Live check" run should start by itself (triggered by Cloudflare's deployment); if it does not, the six-hourly schedule still covers it - -## Phase 4 - Playground (games) - -A small section of games that say something about Leon (tech, software, networks, machine learning). The goal is not many games but a few polished ones, each with a personal angle and a short "how it is built" note. Full planning with Leon on 2026-10-08; the answers below are decisions, the open ones are listed at the end. - -### Decisions (settled) - -- **Where:** a new home page section after Experience, working name **Playground** (neutral, not only games): tiles with the name, a one-line description, a tag (Words, Logic, ML, Network) and a small animated preview, in the same calm style. Each game has its own page `/games/<slug>` (code-split) and `/games/` is the overview (same tiles, a bit more text). -- **Unlimited play, no daily mode, no saving:** every round is a new random word or a chosen level, all levels are unlocked. Nothing is stored on the device (no localStorage, sessionStorage, IndexedDB), so the privacy policy statement "nothing is stored on your device" stays true. The promise is made for the whole site, so the guard is **site-wide, not only for games**: (1) a source test scans every file in `src/` (and `functions/` for cookies) for `localStorage`, `sessionStorage`, `indexedDB`, `document.cookie` and `caches`, and (2) a build-output test scans all published JavaScript in `dist/` for the same names, with a short, commented allow-list for third-party libraries that mention them without using them, and (3) a Playwright test loads every page (and plays a round of every live game) and asserts afterwards that local storage, session storage, IndexedDB and cookies are empty. The runtime test is the real guarantee, the two scans are the early warning. Saving may come later only together with a privacy policy update. -- **English only** (changed from German and English): the tech vocabulary does not translate well. The rest of the site was English already. -- **Original names and art.** "Wordle" and "Connections" are trademarks of The New York Times, so we use our own names. The mechanics are free to use. The puzzle book that inspired the logic game is used for the idea of the rules only: our own levels, characters, texts and artwork, nothing copied. Names (confirmed by Leon on 2026-10-08): **Tech Words**, **Root Cause**, **Word Radar**, **Link Up**. Section: **Playground**, with its own nav entry (appears with the first live game). -- **Order:** Foundation, Tech Words, Root Cause, then (changed on 2026-10-11) Link Up before Word Radar, then a network game (see 4.5). -- **Structure:** every game in its own folder `src/games/<slug>/` with `README.md` (rules, design decisions, data sources and licenses, how to add words or levels, how to test), `logic/` (pure TypeScript, no DOM, no React), `data/`, `components/` (React island) and its tests next to the code. Shared code in `src/games/shared/` (seeded random numbers, tech terms, keyboard and accessibility helpers, game shell). Pages in `src/pages/games/`. A **registry** (`src/games/registry.ts`) lists every game with `status: 'hidden' | 'live'`; a game is merged as `hidden` while it is built and switched to `live` in the PR that completes it (code reviewed, no build variable needed). The Playground section and the nav entry only exist when at least one game is live. -- **Performance:** the home page gets no game JavaScript (tiles and previews are pure CSS and SVG). A game page loads React and its data only on its own page. Large data (Word Radar) is fetched when the game page opens. Each game page has its own size budget. -- **Accessibility and motion:** every game is fully playable with the keyboard, announces results through a live region, never relies on color alone (letters, marks and patterns as well), and honors reduced motion. Animations are part of the fun (tile flips, reveal sweeps, radar rings), always CSS or SVG, always off for reduced motion. -- **Tests (all run in the existing CI jobs, `pnpm test` and `pnpm test:e2e`):** every game has unit tests for its logic, data validation tests for its data (word lists, levels, vectors), Playwright tests that play a round with the keyboard, an axe scan and a reduced motion check. Shared pieces have their own tests. The live check gets the game pages added when a game goes live. `THIRD_PARTY.md` lists every data source and its license. - -### 4.0 Foundation (one PR) - -Goal: everything a game needs, with no game inside yet. - -- [x] Registry, `GameLayout`, game shell (title, short rules panel, "How it is built" disclosure, new round button, keyboard help), shared helpers (seeded random number generator, `TechTerm` type), `/games/` overview (only when a game is live) and the Playground section on the home page with the tile component (name, tag, description, animated preview slot, link), header nav entry that appears with the first live game, scroll-spy included -- [x] Site-wide storage guard (source scan, build-output scan, Playwright runtime check, see Decisions; the runtime check covers every page and later every game), size budgets per game page in the Lighthouse script (the existing budgets stay for the home page), `THIRD_PARTY.md` (fonts, icons, and later the game data), sitemap picks up live game pages, privacy policy checked (no change expected, the statement stays true) -- [x] CLAUDE.md, README and this plan updated; a template README for games (`src/games/README.md`) -- Done when: with no live game nothing changes on the site; a test game marked live in a test shows the section, the nav entry and the overview. Verified on 2026-10-09 with a throwaway demo game (build, build-output tests, all e2e tests including axe and the storage check on the new pages, Lighthouse for the home page, `/games/` and the game page all at 98 to 100); the demo was removed again, so the foundation merges with every game `hidden`. How it works is in `src/games/README.md`: `GamePage.astro` and `Preview.astro` are found by name, a live game needs an entry in `GAME_BUDGETS` in `scripts/lighthouse.mjs`. - -### 4.1 Tech Words (Wordle-like, tech vocabulary) - -Rules: guess a hidden tech term of 3, 4 or 5 letters (chosen before the round), 6 tries. Green = right letter in the right place, yellow = in the word but elsewhere, gray = not in the word; letters that are known to be absent are grayed out on the on-screen keyboard. **Only terms from our list are accepted as guesses** (an unknown word shakes the row and costs no try), so no third-party dictionary is needed. After the round a card explains the term in one sentence and shows its category. Optional category filter (languages, web, data, networking, hardware, OS, security, DevOps, ML, general computer science). Result can be copied as an emoji grid. - -- [x] **Data (a lot of care):** our own lists in `src/games/tech-words/data/`, each entry with the term (lowercase a to z), a category and a definition of at most 100 characters. Targets: at least **150 terms with 3 letters, 500 with 4 letters and 900 with 5 letters** (reached on 2026-10-09: about 285, 513 and 795; the five-letter list stopped short of 900 so that every entry stays a real, useful term; the tests enforce at least 780, so it can only grow, and more terms are welcome at any time, see the README of the game), no duplicates across the data, no offensive terms, product and language names are welcome when they are common tech vocabulary (rust, redis, linux, kafka; plain names of things, no logos, no claims of any connection, neutral one-sentence definitions of our own), acronyms allowed when they are used as words (api, cpu, dns). The data is only for this game (Link Up has its own, finer data, see 4.4) -- [x] Logic: evaluation of a guess including repeated letters (tested against known tricky cases), keyboard state, random term from the chosen length and category -- [x] UI: tile flip animation, shake, keyboard states, length selector, category filter, definition card, emoji share, physical and on-screen keyboard -- [x] Tests: logic (including repeated letters), data validation (format, length, duplicates, category and definition present), e2e round with the keyboard, axe, reduced motion -- Done when: a full round can be played in all three lengths with the keyboard and on a phone, all data tests pass, the game page stays under its size budget. - -### 4.2 Root Cause (logic puzzle in the style of a murder mystery book) - -Our own puzzles for a placement-and-deduction game. **The rules, the full clue catalog, the design decisions and the measurements live in `src/games/root-cause/README.md`; that file is the source of truth, this plan only keeps the summary.** - -- **Board and people:** a square N by N map (4 by 4 up to 9 by 9), N people (N minus 1 suspects and one victim), exactly one person in every row and every column. Shapes other than squares come later. -- **Rooms:** connected, at least 2 cells, compact; at most one corridor (a strip) per map. The number of rooms has a lower limit and a generous upper limit per size; the tier only leans (easy more rooms, hard fewer). Every room has a **type and a name** that fit the skin (several of a type allowed, numbered then); clues can speak about a type. The culprit is the suspect who is alone with the victim in a room. -- **Objects and windows:** occupiable objects (landmarks to stand on) and blocking objects, chosen to fit the room. Easy maps have more places to stand and more blocked cells (less free space, so it is clearer where people can be), hard maps have more open floor. Windows sit on walls (board edge or room boundary). -- **People:** Case file gives every person a random first name (never two the same, unique initials for the notes) and a job as role; Outage numbers devices (`sw-01`, `sw-02`, `fw-01`), the victim is a server, a database, a NAS or another device that no suspect shares a type with (`srv-01`, `db-01`), in Case file a founder, CEO, developer and so on (the plan sets the victim per level). Names and labels are unique within every level (tested, also for generated levels). -- **Clues:** rare, as few as needed, every suspect has at least one, **the victim never gets a clue and is never named**, kinds are mixed (no kind dominates, also on hard levels). About 45 types in three levels: about one suspect, combinations (and, or) and **neutral clues about the whole map** (0 to 3 per level, only where needed). Easy levels use level 1, medium 1 and 2, hard all three. "Next to" means the four neighbors inside the same room, a wall is the board edge or a room boundary. **Play-testing decides which types stay**, each type can be dropped in one list. -- **Marking:** the player places a person in a cell, crosses cells out, and writes **notes**: small, faint initials of the people who could be there, several per cell; a switch changes between place, note and cross mode. There is no instant feedback; the game recognizes a solution only when every person is placed correctly, then the culprit is revealed. -- **Skins** (one engine, two looks, both are built, decided by Leon on 2026-10-08): **Case file** (our own detectives, suspects and rooms in tech settings: server room, open office, break room) and **Outage** (network version: rooms are network zones, the victim is the failed device, for example always a server, the suspects are devices such as router, switch, firewall and NAS, and the root cause is the device that is alone with the failed one in a zone). Each skin has its own object set with its own names, icons and clue wording (Outage: racks, fans, radiators, UPS, cable trays and so on; which are occupiable is decided with the skin design). The logic never knows the skin: a skin maps abstract object kinds (occupiable, blocked, window) to its names and icons. The separate network game (4.5) comes later and is a different game. -- [x] 4.2a **Engine** in `src/games/root-cause/logic/` (done 2026-10-09, game still hidden; details in `src/games/root-cause/README.md`): board and maps (rooms with fitting names and types, several of a type allowed, a generous room count with only a lean by tier, compact rooms and at most one corridor, windows on walls, objects that fit the room), about 45 clue types in three levels (about a suspect, combinations with and/or, and neutral clues about the whole map, 0 to 3 per level depending on the tier) with checks that answer true, false or not decided yet, a **search solver**, a second **rule solver** that works the way a person does and doubles as the **rater** (reasoning level 1 to 3, level 4 is rejected as too hard), and a **generator** that adds clues until the rater can solve the puzzle at the wanted level and then removes every clue that is not needed (every suspect keeps one, kinds mixed: at least three kinds, none above a third, hard clues at most 40 percent). `problemsOf` lets both solvers verify every level. Tests: several hundred for this engine, including both solvers agreeing on 100+ clue combinations and verified puzzles of every tier in both skins. Measured: easy up to 7 by 7 and medium up to 9 by 9 in seconds, hard up to 9 by 9 in seconds to minutes (fine for an offline level pool); easy puzzles from 8 by 8 are rare. Shapes other than squares are not built yet. -- [x] 4.2b **Level pool** (done 2026-10-10) in `src/games/root-cause/data/levels.json`: **18 levels, six per tier** (easy 4, 4, 5, 5, 6, 6; medium 5, 6, 6, 7, 7, 7; hard 7, 7, 8, 8, 9, 9; skins alternate; the plan is `data/pool-plan.ts`), made by the engine with fixed seeds (`pnpm levels`, a script that runs through vitest, keeps existing levels and takes a few minutes), each with a title and a short story in `data/stories.ts`. The pool leaves out maps with too few rooms (`minRoomsOf`). Level gate in CI (`pool.test.ts`): the pool equals the plan, every level passes `problemsOf` (both solvers find exactly the stored solution), the rater puts it into its tier, no clue could be taken away, every level has a story, no two maps are equal, the file stays small. Shapes and random maps come later. -- [x] 4.2c **UI** (done 2026-10-10, game still hidden; details in `src/games/root-cause/README.md`, "The interface"): React island with the map as an accessible grid (room colors from the site's tokens, walls, windows, own drawings of the objects), people list, place, note and cross modes with keyboard shortcuts (a person crosses out their row and column by themselves and everything comes back when they leave; choosing a person leaves the cross mode), undo and redo, clear, clues that tick themselves while the people on the map satisfy them, a **page for the choice of a level** (the 18 levels as cards and the **random level**; the level is a page of its own with a way back), a legend of all pictures, room colors by kind of room (one color per room type), a result box of fixed height, and the **random level** (difficulty and size, generated in up to four Web Workers with a progress bar and estimate, every level checked by both solvers, titles and stories from templates), the result with the stamp and the culprit pointed out. The moves, the clue texts and the random job are pure TypeScript with tests. Hidden games get a page in the dev server only (`pnpm dev`, `/games/root-cause/`), never in a build, so the UI can be played before it goes live. Known: a hard 9 by 9 takes about half a minute; if that bothers, make the generator faster (most time goes into the search check inside `chooseClues`). -- [x] 4.2d **Skins, polish and going live** (done 2026-10-10): the object pictures are icons of the Lucide set that the site already uses (`data/object-icons.ts`, ISC, recorded in THIRD_PARTY.md), with our own drawings for a rug, a sofa, a desk and a cable tray (they cover two cells or Lucide has no picture); clue wording and legend follow the skin (people in Case file, devices in Outage; a chair is sat on, a rack mounted in); clues name only objects that are on the map; the game's tile (`Preview.astro`) shows the first level solved, drawn from the real level data, with CSS only; every level has a **Copy link** button, a random level's address holds its seed and settings and makes the very same level again (one thread, validated input); going live: `status: 'live'`, budget in `GAME_BUDGETS` (measured: scripts 100 KB, stylesheets 17.7 KB, total 212 KB, performance 98), Playwright and axe tests (`tests/e2e/root-cause.spec.ts`: choice page, solving with the keyboard, crosses and refused clicks, ticks and red clues, legend, random level and its link, storage, reduced motion), the live check picks the page up from the sitemap by itself, the portfolio (project entry, Now block, test count) and THIRD_PARTY.md are updated. The game goes live with the pull request that contains this line -- [x] 4.2e **Playtest rounds** (2026-10-10 and 2026-10-11, Leon played the pool and sent notes; every change is documented in `src/games/root-cause/README.md`). Round 1: objects are placed room by room (at least one per room, more in bigger rooms, half to stand on and half that block, a room with two has both, the typical object of a room first), room names fit the size of the room (minimum and maximum cells per kind of room), a server for Case file, a legend dialog, a line under the map that says what is on a cell, steadier progress and truer time estimates, the sofa as one piece over two cells. Round 2: sofas and chairs have their back to a wall, "the" instead of "a" for what there is only one of (rooms, objects, roles), windows sit on the wall and count for both sides (and are explained), a clue that says nothing or a part of a combination that follows from the other is never made (3 of 18 levels had one), two new clues (a corner of their room, a corner of a given room), numbered rows and columns with at most one row and one column clue per level, a gentle way in on a medium level (the clues alone place at least half of the people), a pencil after the name of a person with notes, the room count wording ("Nobody"), "is either … or …", the object "on another cell" wording, **a hint button** (three stages, counter, key H; always the next logical step, from `deduce`) and an even mix of clue kinds over the pool (several candidates per level, one chosen by `logic/balance.ts`). Round 3 (2026-10-11): an **easy level needs nothing but a person with one cell left** (`nakedSolved`, enforced by the generator and `pool.test.ts`; the six easy levels were made again, hidden singles and "bound to a row" now start with medium), a **"Why here?" dialog** at the last stage of a hint (`logic/explain.ts`), the clues marked by a hint now include the ones that limit the person without naming them, and "the room with the shelf" for an object or person there is only one of. Also: a row and a column clue never go to the same person (easy-1 and easy-2 made again, the others untouched), chairs are no longer turned (only sofas have their back to a wall), the hint counter keeps its place so the tools do not move, and a build-output test finds a missing space in front of or after an inline element (Astro drops a line break in front of an element at the start of a line). Round 4 (2026-10-11, branch `fix/root-cause-feedback`): an object is one unit (next to, directions and rows of a sofa or a cable tray, see the README; medium-2 and hard-5 made again), the roles are shown on small screens again (two columns), "in the same row or column as an object" counts the object under the person too (no "on another cell" any more; no level changed), the random level can be asked for in a look (Any look, Case file, Outage), one shared Playground text on the home page and on the overview. Open: Leon plays the new pool again and sends the next notes; ideas that were not asked for are not built. -- Done when: all levels are solvable on paper and in the game, the level gate passes, the game is playable with keyboard and touch, axe is clean. - -### 4.3 Word Radar (similarity game, working name for the Semantle and Contexto style game) - -Guess words, the game shows how close each is to a hidden word as a **rank** (1 is the hidden word) out of a vocabulary of about 30,000 common English words, with a color and a position on a radar-like display (closer means nearer to the centre). Unlimited rounds with a random hidden word, a hint button (reveals a word at a chosen rank), and "give up" (shows the nearest words). After the round a **map of the guesses** (a two-dimensional projection of the embedding space) shows where the guesses were relative to the hidden word, and a short "How it works" block explains GloVe vectors, PCA and cosine similarity. This is the game that talks about Leon's machine learning background. - -- [x] 4.3a **Data pipeline** (done 2026-10-13; changed to **Node and TypeScript** in `scripts/word-radar/` because Python is not installed here and Node runs the same everywhere; `pnpm word-radar-data`; the output is committed): GloVe 6B, 100 dimensions (public domain license) for the vocabulary and vectors, the first 30,000 words in frequency order, cleaned (letters only, no names, no stop words), reduced to 64 dimensions with PCA and stored as 8 bit integers (about 2 MB), plus a list of about 3,000 secret words (common nouns from WordNet, no offensive words). Manual step for Leon: download the GloVe zip once (about 800 MB) into a gitignored folder and run the script -- [x] 4.3b Logic in TypeScript (done): cosine similarity and ranking of all words in the browser at the start of a round (a few milliseconds), validation of guesses, hints, nearest words; tested with a small fixture and with data checks (sizes, sorted, secrets are in the vocabulary, a few sanity pairs such as king and queen) -- [x] 4.3c UI (done, set `live` in the registry on branch `feat/word-radar`, Leon plays it and gives the go): radar display with rings and pulses, ranked guess list with color bar, hint, give up, post-game map (SVG), "How it works" block. Data is fetched when the page opens and the page shows a loading state; size budget about 3 MB -- **Built 2026-10-13** (README: `src/games/word-radar/README.md`): GloVe 6B 100d and WordNet 3.0 downloaded by Leon (manual steps in the README), 30,000 words (the commonest ordinary words of WordNet, no stop words), vectors reduced to 64 numbers by our own PCA (82.5 percent of the variation) and stored as bytes (1.9 MB), 1,510 hidden words (everyday things in six WordNet kinds, a short blocklist of words that slip through), data tests with real meaning checks (king/queen/banana and more), the radar (distance is the log of the rank, direction from the plane of the 300 words nearest to the hidden word, a turning beam with a fading tail and dots that flash when it passes), list with bars and labels, hint (about half of the best rank, the first from rank 4,000 at most), give up (asks twice), the map and the nearest words after the round, per-frame layout test, axe, reduced motion, budget measured (scripts 78 KB, total 2.2 MB, performance 98). The word data is fetched when the page opens, started by a script of the page in parallel with React. -- Done when: a round can be played end to end, data tests pass, `THIRD_PARTY.md` credits GloVe and WordNet. (All true; open: Leon plays it and sends notes.) -- [ ] A German version: decided on 2026-10-14, see 4.7 (a language switch on the same page) - -### 4.4 Link Up (grouping game, Connections style) - -Sixteen terms, find the four groups of four. Unlimited random puzzles from a **pool of about 240 categories with more than 3,000 terms** (own data written for this game, `src/games/link-up/data/`, 7 to 20 terms per category: sorting algorithms, HTTP methods, databases, Git commands, protocols, ML models, Linux commands, fonts, Android versions, hardware and so on; the data is split in files and only those of the four categories of a puzzle are fetched). Every term belongs to exactly one category, a `conflicts` list keeps categories apart whose terms could be mistaken for each other (HTTP DELETE is also SQL), and the generator checks every puzzle with a separate solution counter (exactly one solution), the same idea as the solver of Root Cause. Four mistakes allowed. - -Decisions (2026-10-12): the fourth pick hands the group in by itself (no Submit button); "One away!" hint when three of four are right (still a mistake); the same wrong group again is free; the definition of a term shows under the board on hover, focus or tap; categories are only revealed once a group is found (or lost); a lost game reveals the missing groups one by one; the link of a puzzle (`?seed=`) opens the same puzzle. - -- [x] Data and generator with tests (no ambiguity, pool size, every category documented), UI with slide, shake, hop and reveal animations, keyboard play, e2e, axe, README -- [x] Budget measured and set (`GAME_BUDGETS`), the game is `live` in the registry -- [x] **Built, played and live (2026-10-12).** About 240 categories with more than 3,000 terms (7 to 20 each, own work, no filler; only fitting terms, a topic may stay small), found by going through the Tech Words lists and by topic: algorithms and theory, languages and frameworks, software engineering, the web, data and cloud, networking and security, Linux and operating systems, hardware, machine learning and AI, people. The data is split into 14 files and a catalog (generated by a test, `-u` updates it), a puzzle fetches only the files of its four categories, and a small script of the page starts the first puzzle while React loads (Lighthouse 96 on the game page, scripts about 110 KB). Playtest round with Leon: animated tile preview that plays the game (tiles light up, groups move into the next free row, the rest moves up), no layout shift at any moment (a test watches every frame, also on 320 px and with the eye used), a space reserved for the result and the definition (two lines, more on small phones), **an eye next to the definition line that blurs the definitions for a harder game** (default on, nothing stored). README: `src/games/link-up/README.md`. -- Done when: random puzzles are always valid and solvable and the game is playable with keyboard and touch. - -### 4.5 Network game: Backbone (built 2026-10-18, merged 2026-10-20) - -Decided with Leon on 2026-10-17 (the whole concept is in [docs/network-game-concept.md](docs/network-game-concept.md), the rules and design decisions in [src/games/backbone/README.md](src/games/backbone/README.md)): a **planning game that a simulation judges**. A level is a network, flows with service profiles (voice, control, transaction, video, web, bulk) and one to three situations. The player draws paths, a deterministic stress test runs the plan, an exact solver has proven the best plan before the level is shown. Stars compare the plan with shortest path, load balanced and the best plan. A fixed campaign of 13 chapters (one new rule each: capacity, sharing, splitting, latency, priorities, queues, failures and backup paths, building under a budget, loss and redundancy, moving links, zones and a firewall, an attack, everything) with 78 levels (six per chapter), plus random levels in five tiers made in background threads. Absolute score (100 is everything delivered in time; the first four chapters only admit levels whose best plan is perfect or nearly), best plan shown only after a level is solved, helpers (shortest for all, balanced) that earn no stars. - -- [x] Model, stress test, exact solver, generator and level pool (`pnpm levels-backbone`), interface, overview page, random levels, chapters 8 (building) and 11 (zones), the note `backbone`, browser tests, Lighthouse budget (script 109 KB, stylesheet 25.9 KB, performance 96), the CI browser tests now run in three shards -- [x] Leon's look and feedback on the game (ten rounds, 2026-10-18 to 2026-10-20: real packets that follow the delay, one queue per cable, radio links, firewall, the delay and loss labels, the test guide in `docs/backbone-test-guide.md`) -- [x] Go live: merged with the full run (`pnpm check`, `pnpm test:e2e`, `pnpm lighthouse`) on 2026-10-20 -- **Decided with Leon on 2026-10-18 (after the first look):** a new chapter **Queues** (finite buffers with a backlog that carries over from step to step; bursts are absorbed at the price of waiting time, a full buffer drops), a chapter **Under attack** (a flood from one node to another that a firewall filters), **hints** with an explanation, and **six levels per chapter** (13 chapters, 78 levels). Links stay one shared capacity for both directions (**no duplex**, a deliberate simplification). **Saving progress is not done** (it would need local storage and a change of the privacy promise, see the discussion of 2026-10-18); an **editor for your own networks (sandbox)** and a **save of progress**, perhaps as an opt-in button or a code the player keeps, are ideas **for later**. Sharing is a link (the address of a level, and of a random level with its seed), as in the other games. -- Ideas for later: the address plan (CIDR subnets) as a puzzle at the end of a level or a game of its own; real packets instead of dots; more settings and chapters as data only - -### 4.6 Playground polish and launch - -- [x] Playground polish: animated tile previews for all four games, Lighthouse budgets for every game page (and the notes), the live check reads the game pages from the sitemap, the READMEs and CLAUDE.md follow every game, the project card of this site points to the Playground and to the notes. Whether the About text should mention the Playground is Leon's call -- [x] Legal review of the whole site: see Phase 2 ("Review the legal texts"), done 2026-10-17 - -### 4.7 Word Radar in German (decided 2026-10-14, built 2026-10-15) - -Only Word Radar gets a German version, as a **language switch on the same page** (through the address, for example `?lang=de`, nothing is stored; English stays the default). The point is to show the same pipeline with a second dictionary and a second set of word vectors, and a note about what changes in another language: compound words, inflected forms, umlauts. The other games stay English only (the decision of 2026-10-08 stays, with this one exception). - -- [x] **Licences first** (done 2026-10-15, decided with Leon: share-alike for the German data folder only is acceptable). Result: the vectors are NLPL model 45 (German CoNLL17 corpus, **CC BY 4.0**, no share-alike); fastText `cc.de.300` is CC BY-SA 3.0, the German GloVe of deepset has no findable licence, devmount's model names MIT for the code only, so they were not used. The dictionary role is OdeNet 1.4 (**CC BY-SA 4.0**) plus the page titles of the German Wiktionary (CC BY-SA 4.0), because OdeNet alone lacks common words (Kuh, Honig, Spiegel, Schulter). So `data/de/vocabulary.txt` and `secrets.txt` are share-alike, the numbers are CC BY 4.0 inside a CC BY-SA 4.0 collection; the files carry a notice (`data/de/NOTICE.md`), `THIRD_PARTY.md` and `LICENSE` name the exception. **Still to do in the legal review:** look at this once. Original text of this item: Check them at the source and look for a source without share-alike. What is known: OdeNet (the open German WordNet) is CC BY-SA 4.0; GermaNet is not open and is out; the related fastText sets are CC BY-SA 3.0 but the licence of `cc.de.300` itself is not confirmed; the licence of deepset's German GloVe vectors was not found. Leon's wish: an open licence, or everything stays all rights reserved. If neither exists, **share-alike for the German data folder only** is acceptable (own licence line, attribution, change notice, entry in `THIRD_PARTY.md`; code, texts and design stay all rights reserved). Have it looked at in the legal review -- [x] **Data pipeline** for German (done, `pnpm word-radar-data-de`; 30,000 basic words, about 20,000 inflected forms as shortcuts to their word (typing Hunde counts as Hund), 1,150 hidden words of which 136 have synonyms that find the word (Hirn for Gehirn), the lists of blocked words and corrected synonyms read through by hand; see the README of the game for the steps and the known limits). Original plan: in `scripts/word-radar/`: a vocabulary of ordinary words in their basic form (a rule for inflected forms such as Hund and Hunde), the vectors reduced the same way (PCA to 64 numbers, bytes), the hidden words chosen from the dictionary relations (OdeNet has no WordNet style kinds, so they have to be derived from the hypernyms), a blocklist -- [x] **Game:** the switch (done; `?lang=de`, two plain links above the game, one component with `logic/texts.ts` for both languages). Original plan: (address and a link in the game), all texts of the interface in both languages (about 50 strings), input normalisation (umlauts, ß, case), the data fetched per language, skeleton and layout guard unchanged -- [x] **Tests** (done: unit tests of the pipeline, language and data tests, `tests/e2e/word-radar-de.spec.ts`). Original plan: data tests with German meaning checks (König closer to Königin than to Banane and so on), an end-to-end round in German, axe; the privacy test and the live check need nothing new (nothing is stored) -- [x] **Note** (done: a section "And in German" in the Word Radar note, with what broke). Original plan: extend the Word Radar note or write a second one about the two languages -- Estimate: one to two days, most of it for the list of hidden words. - -### Open decisions (to settle with Leon while building) - -Settled on 2026-10-08: clue catalog and per-person clues scaled by difficulty, rooms scaled by board size, wall (board edge or room boundary) and next-to (four neighbors, own room only), direction clues (exact and anywhere), random levels with verification, names, Playground with nav entry, complete squares first with 18 levels, both skins, Outage naming (`sw-02`, random unique person names), Outage objects as proposed (occupiable: rack slot, cable tray; blocked: radiator, fan, UPS, cabinet; a vent instead of a window), product names in Tech Words, **Tech Words accepts only terms from its own list** (a second dictionary may follow later), site-wide storage guard. Still open: - -- Nothing blocks the start. Decided while building and play-testing: which hard clue types stay, the exact room counts per level, the Outage device names and the role list for Case file. - -Also settled: the victim never gets clues; clues are rare, varied per person and pruned after play-testing; all clue types go in first and odd ones are removed later; Case file people have a **role** (developer, designer, admin and so on), Outage uses the device type, no gender attribute; room composition clues work with any number n. - -### Layout guard (added 2026-10-13) - -- [x] A generic layout guard for all browser tests (first `tests/e2e/layout-guard.ts`, since 2026-10-24 the open source package `playwright-layout-guard`, automatic through `fixtures.ts`): every element is watched on every frame from the moment the page has loaded, and a test fails when something moves or the page changes its height. It found real shifts on every game page (the placeholder of an island was a guess of the height: 170 to 540 pixels of jump below the game, at the very bottom of the page, so Lighthouse did not see it), in the contact form and a few details of the games. Fixed: every game has a skeleton (the same boxes, rendered on the server as the placeholder and shown while the game loads), the random level of Root Cause and the width of two controls got reserved space. Checked by taking a reserved space away: the tests fail. -- [x] **The contact form is calm too (2026-10-13, decided with Leon):** the form is in the page from the start (the script no longer shows it; hidden only for `scripting: none`), the messages of the fields are short one-line texts in the gap under the field (the gap grew by 4 pixels), the progress and the problem with sending take the place of the privacy note, and the confirmation lies on the form in one grid cell, so it is as high as the form. Its tests run with the guard on. Checked by putting a message back into the flow: the test fails. -- [x] **Every page is covered** (2026-10-13): `tests/e2e/pages.ts` is the one list of pages, `tests/e2e/layout.spec.ts` opens each page (and the 404 page), reads it to the end and fails when anything moves, a build-output test fails when a built page is not in the list or a browser test does not use the guarded fixture, and `layoutGuard: false` needs a comment. The 404 and privacy specs now use the guarded fixture too (`expectedPageStatus: 404`). - -## Phase 4b - Project animations and Behind the scenes (done, 2026-10-14) - -Both parts are done and merged. After them comes Phase 5 (see "Where we are" at the top for the order), and **last** the network game (4.5). - -- [x] **A small animation on every project card** (decided with Leon; done and merged): pure CSS and SVG, no JavaScript and no request, 5 to 10 seconds in a loop, calm and in the colors of the site, paused while off screen where that is possible, a still frame for reduced motion, hidden or small on narrow screens (the grade badge sits at the top right of the thesis cards, so the animation takes the right side of the card on desktop). **Pattern built (2026-10-12): the bachelor thesis card** (`src/components/project-art/`: `ProjectArt.astro` is the frame with the off-screen pause, one component per picture, the optional `art` field in the project YAML selects it; a build-output test and e2e tests guard it). Revised after Leon looked at it (2026-10-12): the sliding window covers all four packets from the start and does not move, the coded packet is sent in the rhythm of the packets (the fifth), the labels are capitalized, and the card has the title and the key figures in one column with the picture beside them over their whole height. Second revision: the receiver slots are right aligned, and a return channel carries the acks (one per arriving packet at the same speed; the copy in the sender window turns green when its ack arrives, the ack of the recovered packet 3 comes last, then the four copies are deleted and the loop ends). Done: the bachelor thesis (Tetrys) and the master thesis (routing: an irregular network of 13 nodes with different distances and at most three links per node; a title "UAV Swarm" in the corner and the phases "Joint Allocation", "Links Degrade Over Time" and "Weak Links Avoided" at the bottom; the nodes drift on the spot like drones, slowly and irregularly, and the links and the flows are fixed to them (an SVG animation from the same positions; the flow lines are parallel to the links at a constant distance); link colors green, amber and red for the quality; a short flow over the top and a long one around the bottom are routed once and at the same time, each drawn as a line beside its route in its own color; the links in the middle, which are on both shortest paths, degrade one after the other and turn red, while the mediocre amber links on the chosen routes stay amber: weak link avoidance and load balancing of the joint allocation). All pictures use the same coordinates (240 by 200) and the same label size (8.5 px mono), so text is equally big in every card (guarded by a test). Done: Defuze (two parts: a timeline of rounds with the predicted win chance of each round as a bar, then the picture zooms into one round and shows the replay from above, with the prediction bar that is made at the start of the round and changes at the bomb plant, as in the project). The numbers of the Defuze picture come from `scripts/generate-defuze-picture.mjs` (a test checks that the file is what the script makes); the card has a key figures box (300+ professional demos as the basis of the model, **Leon has to confirm the number from the training data before the push**; two predictions per round). Done: the Grade & Study Tracker (a phone with courses that come in one after the other, each with its grade and a bar for its ECTS credits; the average is calculated again with every course, weighted by the credits, and the bar at the bottom shows the credits of the year; the numbers are worked out in the component, so the average is always right; a card without key figures now has the highlights beside the picture). Done: leonkrix.dev (the six jobs of the CI turn grey, amber, green one after the other, the gate "CI passed" turns green, the deploy runs, and the page builds up in a browser window with the wordmark, the heading, the availability badge, the buttons and a gauge for Lighthouse 95+). **All five cards have their picture now and they are merged (PR 49).** What each shows, as agreed: - - Bachelor thesis (Tetrys): packets stream through, one is lost, a coded repair packet restores it; **with a sliding window** over the stream (the sliding window of coded packets is the main novelty of the protocol) - - Master thesis: a flying network of nodes; **the links are fixed and never fail**, the flows are routed once (jointly) and avoid the links that get weak over time and balance the load between the flows (see above for what was built) - - Defuze: a match minimap with player dots and a bar for the machine learning prediction (what the project does) - - Grade & Study Tracker: grade bars that grow and an average that adjusts - - leonkrix.dev: a CI pipeline whose steps turn green one after the other, plus a short nod to the portfolio itself -- [x] **Behind the scenes (decided with Leon 2026-10-14, done and merged):** `/notes/` (the overview, in two groups, "The site" and "The games") and `/notes/<slug>/` as a content collection of MDX files (CLAUDE.md, "Notes"); the panel "How it is built" of a game stays short and links to its note; the overview is linked in the footer; the project card of this site ends with a bullet that points to the Playground and to the notes. **Ten notes:** the four games (Root Cause set tone and depth, figures are made from the real data and functions of the game at build time) and six about the site: "How this site is built" (the overview), "A page that never moves" (the layout guard), "A contact form without a third party", "Watching the live site", "One page, four scores" and "Fonts that arrive late" (added 2026-10-17, from the real runs and measurements). The code of a note lives in snippet files, a column test and a list marks test protect the layout. **Possible later notes** (only with real material, nothing is planned; from 2026-10-16 also **a command palette that costs a kilobyte** (the budgets of the home page, the styles that Astro links to every page although a script only loads them later, found by a test; the terminal as one list of pure commands), the two from 2026-10-16 are good ones: ~~why a Lighthouse score flips between 98 and 73~~ (written 2026-10-17) and **the layout that jumped only on the build server** (a field wider than its column, labels that wrap in one language and not in the other, found by playing a round at 49 and then 270 widths)): the animated pictures of the project cards (generated and tested), the design tokens and their rules, Defuze and the theses once their code is public, Word Radar in German (4.7) - -## Phase 5 - Extras - -- [x] **`/uses` page** (done 2026-10-15, decided 2026-10-14): the personal side for developers, in the same calm style. A block in the style of `neofetch` at the top (operating system, editor, shell, terminal), then cards per category with icons: hardware, operating system, IDEs (PyCharm, WebStorm, CLion), terminal and shell, tools and services. It takes over detail that would clutter the technology overview of the home page (IDEs are not technologies, see CLAUDE.md) and gets a small animation. Linked from the footer. Leon supplies the list, Claude builds the page and the animation; it needs a privacy check only if it embeds anything external (it must not). **Built:** `src/pages/uses.astro`, content in `src/lib/uses.ts` (unit test), a terminal block in the style of `neofetch` (the window is there first, then the command is typed with a caret, then header, lines, colours and prompt appear one after the other; only opacity, transform and `clip-path`; still with reduced motion) and a card per category: hardware (basics only: the desktop with CPU, GPU and RAM, and the laptop; no peripherals), operating systems (Windows 11 and Debian 13, dual boot on both machines), terminal and shell, editors, browsers, AI tools (GitHub Copilot, Claude Code). Linked in the footer and from the About text (which no longer lists the IDEs), in the sitemap, in the live check (a new check) and in the Lighthouse run; `tests/e2e/uses.spec.ts` covers content, footer link, reduced motion and width -- [x] **Command palette** (built 2026-10-16, scope decided with Leon: navigation plus search over notes and projects, no full text; see CLAUDE.md, "Command palette, terminal and easter eggs") (Cmd/Ctrl+K): a centred overlay with a search field and a list (a native `<dialog>`, so nothing moves); arrow keys, Enter and Esc; groups Go to (the sections), Games, Notes, Projects, Links and Legal; search over notes and projects with an index made at build time; actions such as "Play a random game", "Open the source" and "Copy the email address" (with the existing obfuscation); a small "Ctrl K" hint in the header on wide screens and a search button on narrow ones. **Cost control:** only a tiny key listener loads with the page, everything else (about 10 KB) is fetched when the palette is opened first, so the Lighthouse budgets hold. Fully usable with the keyboard and a screen reader, nothing is stored (so no list of recent entries), reduced motion honoured. Tests: end-to-end with the keyboard, axe, a build-output check of the index. Scope (navigation only, or also search of the notes) is for Leon to confirm when it is built -- [x] **Easter eggs** (built 2026-10-16: all five, plus the command `radar` (**the radar finds what is worth a look on the page**: a beam, frames with labels on what is marked `data-radar`, and a list as buttons, see CLAUDE.md), `git log` with the real commits of the build, `neofetch` and a Tab completion in the terminal, which is also the 404 page; none of them tracks or stores anything): (1) a message in the browser console for developers, with a link to the repository; (2) a **terminal mode** in the palette (type `>`), with the same commands as the extended 404 terminal, from one list of commands: `help`, `about`, `projects`, `games`, `notes`, `whoami`, `sudo hire leon` ("Permission granted. Please send an email."); (3) the Konami code starts a short radar sweep over the page (only a message with reduced motion); (4) `/humans.txt` and `/.well-known/security.txt` (a real standard for where to report vulnerabilities, pointing to `SECURITY.md`); (5) a greeting as a comment in the HTML source -- [x] Dropped on 2026-10-14: the routing visualizer on the master thesis card. The card has its animation; the comparison it would have shown moves into the network game as the end-of-round comparison (4.5) -- [x] **Own OG images per page** (built 2026-10-17, scope decided with Leon: the notes, the live games, the overviews of the notes and the games and the Uses page, not the legal pages and not the 404 page): a card with the eyebrow (tag), the title, the first lines of the text and the address, 1200 x 630, made like `public/og.png` by a script with Chromium (`pnpm og`, only the cards that changed), **committed** (16 pictures of about 150 to 190 KB, below the 300 KB that WhatsApp allows), with fingerprints in `scripts/og-fingerprints.json` and a test that fails when a picture is out of date or missing (`tests/build-output/og.test.ts`; the Cloudflare build has no Chromium, so they cannot be made at build time); the live check asks for the picture of every note and game. Details in CLAUDE.md, "Brand" -- [x] ~~**Atom feed of the notes**~~ **dropped 2026-10-17** (decided with Leon): the notes are not a blog with regular posts, nobody subscribes to the feed of a portfolio, and a feed would need a `published` date on every note and its upkeep for no reader -- [ ] Periodic legal review (about twice a year, and whenever the site starts to store or send something new) - -## Open decisions - -- Decided: private address in the Impressum (obfuscated), Cloudflare Git integration, CodeQL yes, SonarQube Cloud no, availability badge "Open to opportunities" (no start date), experience as a compact list unlike the projects timeline -- Final palette tuning (see CLAUDE.md tokens; status green `#34d399` was added for the availability dot) -- Decided on 2026-10-08 (games): see Phase 4, "Decisions (settled)" and "Open decisions" -- Commitlint and PR title check: decided on 2026-10-14, not now (see Phase 1b) -- Licence of the German data files for Word Radar (4.7): **settled 2026-10-15**, CC BY-SA 4.0 for the two word lists (nothing freer exists for the dictionary role), CC BY 4.0 for the vectors; to look at in the legal review -- Decided 2026-10-16: scope of the command palette is navigation plus search over the titles, summaries and tags of notes and projects (no full text, the list is short) - -## Routine (not projects, but not to forget) - -- Dependabot opens its pull requests every Monday: grouped minor and patch updates are merged with "Squash and merge", then `git pull` and `pnpm install`; majors need a look. After every merge run `pnpm audit`. -- After a larger update of Playwright run `pnpm exec playwright install chromium` on the development machine (a missing browser makes every end-to-end test fail at once, which is not a problem of the code). -- **Overrides** in `pnpm-workspace.yaml`: 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. A new override needs a comment with the reason and a note for when to remove it; check with `pnpm why <name>` and `pnpm audit` after removing. -- Change of the site: when CI, the live check, the contact form or the layout guard change, update the matching note (and its `updated` date) in the same pull request. The number of tests on the project card and in the note "How this site is built" ("2,900+" on 2026-10-20: about 2,060 unit tests, 130 build-output tests and 800 browser tests) is raised when the suites grow by about a thousand. -- GitHub switches off scheduled workflows of a public repository after 60 days without activity: regular commits keep them alive (the live check and the link check). -- The domain renewal (28 September 2027, at IONOS) is in Leon's calendar; the live check warns 60 days before. diff --git a/package.json b/package.json index 38f6ecd..19edcfa 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "husky": "^9.1.7", "lighthouse": "^13.5.0", "lint-staged": "^17.6.0", - "playwright-layout-guard": "0.2.0", + "playwright-layout-guard": "0.3.0", "prettier": "^3.9.10", "prettier-plugin-astro": "^1.1.2", "prettier-plugin-tailwindcss": "^0.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf8bc0..c75d0f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,8 +267,8 @@ importers: specifier: ^17.6.0 version: 17.6.0 playwright-layout-guard: - specifier: 0.2.0 - version: 0.2.0(@playwright/test@1.64.1) + specifier: 0.3.0 + version: 0.3.0(@playwright/test@1.64.1) prettier: specifier: ^3.9.10 version: 3.9.10 @@ -1663,13 +1663,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.2': - resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.72.0': resolution: {integrity: sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1677,12 +1670,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.2': - resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.72.0': resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1693,20 +1680,10 @@ packages: resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.71.2': - resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.72.0': resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.2': - resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.72.0': resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1724,20 +1701,10 @@ packages: resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.71.2': - resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.72.0': resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.2': - resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.72.0': resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1755,10 +1722,6 @@ packages: resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.71.2': - resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.72.0': resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3300,8 +3263,8 @@ packages: engines: {node: '>=20'} hasBin: true - playwright-layout-guard@0.2.0: - resolution: {integrity: sha512-UmlGwQDtt1XNmOPADFhIInAc6DJaOClx5WlaMTRzRvTVcd3VBfg1pbPejBS/ykGeCV/Y8GfARqYtMbP0HF70cA==} + playwright-layout-guard@0.3.0: + resolution: {integrity: sha512-sg+0xZ9Pp5EHi7LYabSZOPfn8klL7sDLaNh4pt7rvLvi4ssBLb746PppZRga2/a3TFWghEOIt50SDmzQX5Nemw==} engines: {node: '>=20.19.0'} peerDependencies: '@playwright/test': '>=1.45.0' @@ -5498,18 +5461,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.2(eslint@10.13.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.71.2 - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/typescript-estree': 8.71.2(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.2 - debug: 4.4.3(supports-color@7.2.0) - eslint: 10.13.0(jiti@2.7.0)(supports-color@7.2.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.72.0(eslint@10.13.0(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/scope-manager': 8.72.0 @@ -5522,15 +5473,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.2(supports-color@7.2.0)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) - '@typescript-eslint/types': 8.72.0 - debug: 4.4.3(supports-color@7.2.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.72.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) @@ -5545,20 +5487,11 @@ snapshots: '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 - '@typescript-eslint/scope-manager@8.71.2': - dependencies: - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 - '@typescript-eslint/scope-manager@8.72.0': dependencies: '@typescript-eslint/types': 8.72.0 '@typescript-eslint/visitor-keys': 8.72.0 - '@typescript-eslint/tsconfig-utils@8.71.2(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/tsconfig-utils@8.72.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 @@ -5577,25 +5510,8 @@ snapshots: '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/types@8.71.2': {} - '@typescript-eslint/types@8.72.0': {} - '@typescript-eslint/typescript-estree@8.71.2(supports-color@7.2.0)(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.71.2(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.2(typescript@6.0.3) - '@typescript-eslint/types': 8.71.2 - '@typescript-eslint/visitor-keys': 8.71.2 - debug: 4.4.3(supports-color@7.2.0) - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.72.0(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.72.0(supports-color@7.2.0)(typescript@6.0.3) @@ -5627,11 +5543,6 @@ snapshots: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.71.2': - dependencies: - '@typescript-eslint/types': 8.71.2 - eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.72.0': dependencies: '@typescript-eslint/types': 8.72.0 @@ -7375,7 +7286,7 @@ snapshots: playwright-core@1.64.1: {} - playwright-layout-guard@0.2.0(@playwright/test@1.64.1): + playwright-layout-guard@0.3.0(@playwright/test@1.64.1): dependencies: '@playwright/test': 1.64.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8fccfc9..218421b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,3 @@ allowBuilds: # versions, so it does nothing once miniflare asks for a patched version itself: then delete it. overrides: 'undici@>=7.0.0 <7.30.1': 7.30.1 -# My own package (playwright-layout-guard, published by me): the minimum release age of pnpm does -# not apply to it. Remove the entry when the version is old enough or when it is updated. -minimumReleaseAgeExclude: - - playwright-layout-guard@0.2.0 diff --git a/scripts/check-links.test.ts b/scripts/check-links.test.ts index 018a113..af68fc8 100644 --- a/scripts/check-links.test.ts +++ b/scripts/check-links.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { checkLink, checkLinks, extractExternalLinks, fetchPage, summarize } from './check-links'; +import { + checkLink, + checkLinks, + extractExternalLinks, + fetchPage, + registryAddress, + summarize, +} from './check-links'; /** A fetch that answers by "METHOD url" with a status, or throws for the value "error". */ function fakeFetch(answers: Record<string, number | 'error'>): { @@ -47,6 +54,30 @@ describe('extractExternalLinks', () => { }); }); +describe('registryAddress', () => { + it('turns the page of a package on npmjs.com into its address in the registry', () => { + expect(registryAddress('https://www.npmjs.com/package/playwright-layout-guard')).toBe( + 'https://registry.npmjs.org/playwright-layout-guard', + ); + expect(registryAddress('https://www.npmjs.com/package/playwright-layout-guard/v/0.3.0')).toBe( + 'https://registry.npmjs.org/playwright-layout-guard', + ); + }); + + it('encodes the name of a scoped package, so the slash becomes %2F', () => { + expect(registryAddress('https://www.npmjs.com/package/@playwright/test')).toBe( + 'https://registry.npmjs.org/%40playwright%2Ftest', + ); + }); + + it('leaves everything else alone', () => { + expect(registryAddress('https://www.npmjs.com/~leonkrix')).toBeUndefined(); + expect(registryAddress('https://www.npmjs.com/package/')).toBeUndefined(); + expect(registryAddress('https://github.com/leonkrix')).toBeUndefined(); + expect(registryAddress('https://notnpmjs.com/package/x')).toBeUndefined(); + }); +}); + describe('checkLink', () => { it('passes a link that answers with success', async () => { const { fetch: fetchImpl } = fakeFetch({ 'HEAD https://github.com/leonkrix': 200 }); @@ -55,6 +86,31 @@ describe('checkLink', () => { }); }); + it('checks a package of npm in the registry, because npmjs.com refuses automated requests', async () => { + const { fetch: fetchImpl, calls } = fakeFetch({ + 'HEAD https://registry.npmjs.org/playwright-layout-guard': 200, + }); + const result = await checkLink( + 'https://www.npmjs.com/package/playwright-layout-guard', + fetchImpl, + 0, + ); + expect(result).toMatchObject({ + url: 'https://www.npmjs.com/package/playwright-layout-guard', + status: 'pass', + }); + expect(calls).toEqual(['HEAD https://registry.npmjs.org/playwright-layout-guard']); + }); + + it('fails a package that does not exist in the registry', async () => { + const { fetch: fetchImpl } = fakeFetch({ + 'HEAD https://registry.npmjs.org/no-such-package': 404, + }); + expect( + await checkLink('https://www.npmjs.com/package/no-such-package', fetchImpl, 0), + ).toMatchObject({ status: 'fail' }); + }); + it('asks again with GET when a server refuses HEAD', async () => { const { fetch: fetchImpl, calls } = fakeFetch({ 'HEAD https://example.org/': 405, diff --git a/scripts/check-links.ts b/scripts/check-links.ts index 15fd2fb..0e16d17 100644 --- a/scripts/check-links.ts +++ b/scripts/check-links.ts @@ -78,6 +78,27 @@ export function extractExternalLinks(html: string, ownHost: string): string[] { return [...links].sort(); } +/** + * npmjs.com shows a challenge to automated requests (HTTP 403), also for a package that exists. The + * registry is where the package really lives and answers everybody, so a link to a package page is + * checked there. Anything else on npmjs.com is checked as it is. + */ +export function registryAddress(url: string): string | undefined { + const { hostname, pathname } = new URL(url); + if (hostname !== 'www.npmjs.com' && hostname !== 'npmjs.com') { + return undefined; + } + const parts = pathname.split('/').filter((part) => part !== ''); + if (parts[0] !== 'package') { + return undefined; + } + // A scoped package is two parts, @scope/name; the whole name is encoded, so the slash becomes %2F + const name = parts[1]?.startsWith('@') === true ? parts.slice(1, 3).join('/') : parts[1]; + return name === undefined || name === '' + ? undefined + : `https://registry.npmjs.org/${encodeURIComponent(name)}`; +} + /** The domain itself or one of its subdomains, nothing that only ends with the same letters. */ const isBotWall = (url: string): boolean => { const { hostname } = new URL(url); @@ -106,15 +127,24 @@ export async function checkLink( retryDelay = 3000, ): Promise<LinkResult> { let detail = ''; + const registry = registryAddress(url); + const target = registry ?? url; for (let attempt = 0; attempt <= retries; attempt += 1) { try { - let status = await request(url, 'HEAD', fetchImpl); + let status = await request(target, 'HEAD', fetchImpl); // Some servers do not answer HEAD requests if ([400, 403, 405, 501].includes(status)) { - status = await request(url, 'GET', fetchImpl); + status = await request(target, 'GET', fetchImpl); } if (status < 400) { - return { url, status: 'pass', detail: `HTTP ${String(status)}` }; + return { + url, + status: 'pass', + detail: + registry === undefined + ? `HTTP ${String(status)}` + : `HTTP ${String(status)} in the npm registry (npmjs.com refuses automated requests)`, + }; } if (isBotWall(url) && [401, 403, 429, 999].includes(status)) { return { diff --git a/src/games/README.md b/src/games/README.md index 5001e75..22d293e 100644 --- a/src/games/README.md +++ b/src/games/README.md @@ -7,7 +7,7 @@ The games of the Playground section. - `registry.ts` lists every game (slug, name, tag, description, icon, `status`). A game is merged as `hidden` and set to `live` in the pull request that completes it. A hidden game with a `GamePage.astro` still gets its page in the dev server (`pnpm dev`, never in a build), so it can be played while it is built. Only live games get a page, a tile, a navigation entry and a sitemap entry. The home page shows at most four tiles: a game with `home: false` is left out there (`homeGames()`) and comes last on `/games/`, which shows all of them. With no live game the site looks as if the Playground did not exist. - `shared/` holds what several games use: seeded random numbers (`random.ts`, reproducible rounds and generators) and the tech vocabulary type (`tech-term.ts`). - `<slug>/` is one game (see below). -- The pages come from `src/pages/games/[...slug].astro` (overview and one page per live game), the home page section from `src/components/games/Playground.astro`, the page frame from `src/layouts/GameLayout.astro` (title, the game, and the panels "How to play", "Keyboard" and "How it is built" as native `<details>`, no script needed). The panel "How it is built" of a game stays short (two or three sentences) and **links to the longer write-up** when there is a note for the game: a file in `src/content/notes/` with `game: <slug>` in its front matter (see CLAUDE.md, "Notes"); nothing to wire by hand. +- The pages come from `src/pages/games/[...slug].astro` (overview and one page per live game), the home page section from `src/components/games/Playground.astro`, the page frame from `src/layouts/GameLayout.astro` (title, the game, and the panels "How to play", "Keyboard" and "How it is built" as native `<details>`, no script needed). The panel "How it is built" of a game stays short (two or three sentences) and **links to the longer write-up** when there is a note for the game: a file in `src/content/notes/` with `game: <slug>` in its front matter (the fields are in `src/lib/notes.ts`); nothing to wire by hand. ## A game folder diff --git a/src/games/backbone/README.md b/src/games/backbone/README.md index 4e2c276..68cec16 100644 --- a/src/games/backbone/README.md +++ b/src/games/backbone/README.md @@ -1,8 +1,8 @@ # Backbone -A planning game about networks: you route traffic through a small network, then a stress test judges the plan. Our own game, our own levels, texts and drawings. The concept (the reasoning behind every rule) is in [docs/network-game-concept.md](../../../docs/network-game-concept.md). +A planning game about networks: you route traffic through a small network, then a stress test judges the plan. Our own game, our own levels, texts and drawings. -Status: **built and live in this branch** (set to `live` in the registry; it is merged after Leon's okay). The model, the stress test, the exact solver, the generator, the campaign pool (13 chapters, 78 levels), the interface, the overview page, random levels in five tiers, the note, the browser tests and the Lighthouse budget are done. Open: polish (fonts, animation, real packets instead of dots), see PLAN.md, 4.5. +Status: **live** (set to `live` in the registry). The model, the stress test, the exact solver, the generator, the campaign pool (13 chapters, 78 levels), the interface, the overview page, random levels in five tiers, the note, the browser tests and the Lighthouse budget are done. Open: polish (fonts, animation, real packets instead of dots). ## Rules diff --git a/src/games/backbone/data/load.ts b/src/games/backbone/data/load.ts index 8b51fee..4a3c64e 100644 --- a/src/games/backbone/data/load.ts +++ b/src/games/backbone/data/load.ts @@ -4,8 +4,8 @@ let loaded: Promise<readonly LevelRecord[]> | undefined; /** * The page is drawn first; the level pool (a few hundred kilobytes of JSON) is fetched afterwards, - * so that it never competes with the first paint (see CLAUDE.md, "Fonts and layout shift", and the - * note "One page, four scores"). The wait ends when the page has loaded and its fonts are there, + * so that it never competes with the first paint (see the note "One page, four + * scores"). The wait ends when the page has loaded and its fonts are there, * plus a moment, or after four seconds at the latest. */ function afterPaint(): Promise<void> { diff --git a/src/games/link-up/README.md b/src/games/link-up/README.md index a0aec91..5cefe34 100644 --- a/src/games/link-up/README.md +++ b/src/games/link-up/README.md @@ -13,7 +13,7 @@ Sixteen tech terms belong to four hidden groups of four. Pick four that you thin - **What a term means:** pointing at a tile, focusing it with the keyboard or tapping it shows its definition in the line under the board. The terms in the bars of the found groups can be asked, too. - **The eye** next to the definition line blurs the definitions (a harder game) and shows them again. It is on at the start, it survives a new puzzle, and nothing is stored: a new visit starts with the definitions shown. With the eye off, the name of the term stays readable and the definition is hidden from screen readers, too. - Buttons: **Shuffle** (the tiles that are left in a new order, with a slide), **Deselect all** (or the Esc key), **New puzzle**, **Copy link to this puzzle**. -- Unlimited rounds, no daily puzzle, nothing saved (see the site-wide storage rule in `CLAUDE.md`). +- Unlimited rounds, no daily puzzle, nothing saved (a site-wide rule that tests enforce: nothing of the site is stored on the device). The page reserves the space of everything that appears later (the result, the line with the definition, the bars of the found groups), so **nothing on the page moves** while you play. An end-to-end test watches the layout on every frame of a won game, a lost game and a game on a 320 pixel wide screen (position and size of the bar, the message, the board, the definition line, the result, the buttons and the whole page, with the eye used and definitions of all lengths) and fails when something moves by more than half a pixel. It was checked by removing a reserved space: the test then fails. diff --git a/src/games/tech-words/README.md b/src/games/tech-words/README.md index ba34941..ea9c90d 100644 --- a/src/games/tech-words/README.md +++ b/src/games/tech-words/README.md @@ -9,7 +9,7 @@ Guess the hidden tech term in six tries. Like the well-known word game, but the - After a guess every tile shows: **correct** (green, check mark: right letter, right place), **present** (amber, circle: the letter is in the term, elsewhere), **absent** (grey, cross: no unused copy of the letter is left). Repeated letters are counted: correct tiles are handed out first, then present tiles from left to right, and a letter is only marked as often as it appears in the term. - Letters known to be absent are greyed out on the on-screen keyboard. A letter keeps the best result seen so far (correct, then present, then absent). - When the round ends the term, its category and its definition are shown. The result can be copied as colored squares. -- Unlimited rounds, no daily word, nothing saved (see the site-wide storage rule in `CLAUDE.md`). +- Unlimited rounds, no daily word, nothing saved (a site-wide rule that tests enforce: nothing of the site is stored on the device). The address can prepare a round: `?length=4`, `?category=security`, `?seed=anything`. The same setup always picks the same term (the end-to-end tests use this). A seed made of digits is the seed itself, any other text is turned into a seed. **The link in a copied result contains all three** (for example `?length=4&category=networking&seed=1234567890`), so whoever receives a result can play exactly that puzzle at once. `logic/link.ts` reads and writes these addresses and is unit tested, including the round trip. diff --git a/src/games/word-radar/README.md b/src/games/word-radar/README.md index 0e7a360..12aa3b5 100644 --- a/src/games/word-radar/README.md +++ b/src/games/word-radar/README.md @@ -12,7 +12,7 @@ A word is hidden. Guess words, and the game tells you how close each one is in * - **Hint:** a word that is closer than the best guess so far, at about half of its rank (rank 2,000, 1,000, 500, 250 and so on down to 2: small steps, so that a round lasts; a data test checks the steps). The first hint starts from rank 4,000 at the most. It is marked "hint" and counted. Never the hidden word. - **Give up** asks twice ("Really give up?", the question goes away after four seconds) and shows the word. - **When the round is over** the radar becomes a **map** (the hidden word, the guesses joined in the order they were made, and the nearest words) and the list shows the 40 **nearest words** (your own guesses marked). The two switches above the panels can show the radar and the guesses again. -- Unlimited rounds, no daily word, nothing saved (see the site-wide storage rule in `CLAUDE.md`). +- Unlimited rounds, no daily word, nothing saved (a site-wide rule that tests enforce: nothing of the site is stored on the device). The address can prepare a round: `?seed=anything`. The same seed always hides the same word (the end-to-end tests use this). Digits are the seed itself (that is what the link of the game contains), any other text is turned into a seed. @@ -22,7 +22,7 @@ The page reserves the space of everything that appears later (the message, the n - **Logic and interface are separate.** `logic/` is pure TypeScript without DOM and React: `data.ts` (the three files as one structure), `similarity.ts` (cosine similarity and the ranking of all words), `round.ts` (the round as an immutable value: guesses, hints, giving up), `radar.ts` (buckets, distance and direction on the radar), `map.ts` (the principal plane of some words, and the map after a round), `link.ts` (the seed in the address). `components/` only draws. - **The rank is computed at the start of a round**: the hidden word is compared with all 30,000 words (a dot product of 64 numbers each, about two million multiplications) and the words are sorted. That takes a few milliseconds, after which every guess is a lookup. -- **The data is fetched when the page opens, after it has been painted** (`afterPaint` in `data/load.ts`: loaded, fonts, the largest paint inside the game, two frames, 150 ms; guarded by `tests/e2e/word-radar-paint.spec.ts`; before, the fetch started in the same milliseconds as the first paint, and later between two largest paints, and Lighthouse scored the page 98 or 73 or 74; the message of the skeleton is the rule of the game from the start so that the largest paint does not depend on the download; the details are in `CLAUDE.md`, "Fonts and layout shift")** (about 2 MB: `data/vocabulary.txt`, `data/vectors.bin`, `data/secrets.txt`, imported with `?url` so that they are files with a fingerprint and cached for good). A small script of the game page starts the fetch as soon as the page has loaded, in parallel with React; the fetch has **low priority**, otherwise Lighthouse counts the 2 MB as part of the first paint (score 73 instead of 98, measured 2026-10-13); the game shows a loading hint in the field (the message above it is the rule of the game from the start) until the data is there; the placeholder that the page draws is in both languages and the one of the address is shown (`language-script.mjs`). The page budget in `GAME_BUDGETS` is accordingly large (total 2.6 MB), the scripts are 78 KB. +- **The data is fetched when the page opens, after it has been painted** (`afterPaint` in `data/load.ts`: loaded, fonts, the largest paint inside the game, two frames, 150 ms; guarded by `tests/e2e/word-radar-paint.spec.ts`; before, the fetch started in the same milliseconds as the first paint, and later between two largest paints, and Lighthouse scored the page 98 or 73 or 74; the message of the skeleton is the rule of the game from the start so that the largest paint does not depend on the download; the details are in the note "One page, four scores")** (about 2 MB: `data/vocabulary.txt`, `data/vectors.bin`, `data/secrets.txt`, imported with `?url` so that they are files with a fingerprint and cached for good). A small script of the game page starts the fetch as soon as the page has loaded, in parallel with React; the fetch has **low priority**, otherwise Lighthouse counts the 2 MB as part of the first paint (score 73 instead of 98, measured 2026-10-13); the game shows a loading hint in the field (the message above it is the rule of the game from the start) until the data is there; the placeholder that the page draws is in both languages and the one of the address is shown (`language-script.mjs`). The page budget in `GAME_BUDGETS` is accordingly large (total 2.6 MB), the scripts are 78 KB. - **The direction on the radar comes from the neighborhood of the hidden word.** At the start of a round the 300 words that are nearest to the hidden word are analysed (a principal component analysis in the browser, `logic/radar.ts` and `logic/map.ts`): the two directions in which these words differ most span the plane of the radar. The angle of a guess is the direction of its difference to the hidden word seen on that plane. So words that are related to the hidden word in similar ways (for the hidden word "volcano": a word about eruptions and a word about lava versus a word about tourism) tend to lie in similar directions, and so near each other. It is a flat picture of a 64 dimensional space, so it is only a tendency, never a promise, and the plane does not change while you play (a dot keeps its direction). The distance is the information that matters; the direction is for orientation. The map after the round is a principal component analysis of the words of that round (your guesses and the nearest words), a truer picture. - **The page is as high before the game as after it.** The page shows `WordRadarSkeleton` (the same boxes as the game, empty and without function) on the server while the game loads, and the game shows it while the words are fetched. See "Layout" in `src/games/README.md`. - **The hidden words are everyday things** (`scripts/word-radar/select.ts`): only nouns whose most common sense is an animal, an artifact, a part of the body, a food, an object, a natural phenomenon, a plant or a substance, that are among the 20,000 commonest words, have 3 to 10 letters, are no names and were found in a text collection. Whole categories with abstract or sensitive words (people, acts, feelings, ideas) are left out, which keeps the list free of offensive words without a long blacklist. A short list of words that slip through anyway (vague words such as "world" or "piece", names, drugs, weapons, body fluids) is in `scripts/word-radar/blocked-secrets.txt`; a data test checks that none is hidden.