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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ CI checks the code before it is deployed. These look at what is actually running

## 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** (`tests/e2e/layout-guard.ts`, automatic in `tests/e2e/fixtures.ts`; 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). 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:
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.
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ Also settled: the victim never gets clues; clues are rare, varied per person and

### Layout guard (added 2026-10-13)

- [x] A generic layout guard for all browser tests (`tests/e2e/layout-guard.ts`, 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] 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`).

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"husky": "^9.1.7",
"lighthouse": "^13.5.0",
"lint-staged": "^17.6.0",
"playwright-layout-guard": "0.2.0",
"prettier": "^3.9.10",
"prettier-plugin-astro": "^1.1.1",
"prettier-plugin-tailwindcss": "^0.8.1",
Expand Down
6 changes: 5 additions & 1 deletion playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import type { LayoutGuardOptions } from 'playwright-layout-guard';

// The ports: `astro dev` is 4321 (WebStorm or Claude, only one of them at a time), the browser tests
// start their own `astro preview` on 4322, Lighthouse uses 4323. A server that already stands on
Expand All @@ -7,7 +8,7 @@ import { defineConfig, devices } from '@playwright/test';
const port = 4322;
const isCI = Boolean(process.env.CI);

export default defineConfig({
export default defineConfig<{ layoutGuard: boolean | LayoutGuardOptions }>({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: isCI,
Expand All @@ -25,6 +26,9 @@ export default defineConfig({
reporter: isCI ? [['list'], ['github'], ['html', { open: 'never' }]] : [['list']],
use: {
baseURL: `http://localhost:${String(port)}`,
// On this site not even the height of a page may change, also not when it gets lower (the default
// of the layout guard is to count only a page that gets higher)
layoutGuard: { pageHeight: 'any' },
trace: 'on-first-retry',
// Fail fast instead of waiting minutes when the browser cannot start
launchOptions: { timeout: 60_000 },
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

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

4 changes: 4 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ 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
16 changes: 14 additions & 2 deletions src/content/notes/a-page-that-never-moves.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ tag: 'Site · Layout'
code: tests/e2e
group: site
order: 2
updated: '2026-10-14'
updated: '2026-10-24'
---

import Code from '@/components/notes/Code.astro';
import ExternalLink from '@/components/ExternalLink.astro';
import layoutGuardMoved from './snippets/layout-guard-moved.snippet?raw';
import layoutGuardReport from './snippets/layout-guard-report.snippet?raw';
import Steps from '@/components/notes/Steps.astro';
Expand Down Expand Up @@ -45,7 +46,7 @@ The first version was a tracker for a single game, written when a message under
- **The contact form pushed the page** when an error or the confirmation appeared.
- **A few details in the games**: the area for a random level in Root Cause and the width of two controls.

This is what a report looks like. It is a real one, from the contact form while I made it fail on purpose:
This is what a report looked like in the first version. It is a real one, from the contact form while I made it fail on purpose (the package names the element with a short path and says how far it moved, in pixels):

<Code lang="plaintext" code={layoutGuardReport} />

Expand Down Expand Up @@ -87,9 +88,20 @@ And one limit remains: the guard only sees states that a test triggers. A new dy

There is a second way a page can move that no state of a game causes: the fonts. A web font arrives after the first paint on a slow line and the text is set again. That has its own note, with the numbers: [Fonts that arrive late](/notes/fonts-that-arrive-late/).

## From a file to a package

The guard started as one file of this repository. On 24 October 2026 it became an open source package, <ExternalLink href="https://github.com/leonkrix/playwright-layout-guard">playwright-layout-guard</ExternalLink>, and this site now uses it from npm like anybody else would. Taking it out was only worth it if nothing got worse, so I checked that before I switched.

I wrote a test that puts the old guard and the package side by side on the same page and then makes the page move on purpose, after it has settled: a banner above everything, a block that grows, one that moves sideways, a text that wraps differently, something added at the end of the page, something removed from the end, and three things that are not shifts (one pixel, a `transform`, scrolling). That is 22 pages at desktop and phone size and 10 kinds of fault, 440 runs. Both guards found the same faults and stayed quiet about the same controls. One difference showed up: the old guard also reported a page that got **lower**, the package counted only a page that got higher. That became an option, `pageHeight: 'any'`, and this site switches it on.

Running the whole suite with the package found three bugs in the package that none of its own tests had found. A page that writes its document again made it throw. A resize a moment after the load made it report what the page did in reaction (eight tests of the Backbone map failed with up to 131 shifts). And on a phone, a shift that makes the page wider than the window was swallowed. All three have a test now, and the fixes are in version 0.2.0.

The guard is code that I do not look at every day, so I added a canary. `layout-guard-canary.spec.ts` makes the page move on purpose, and the guard has to fail the test. If the wiring ever breaks (a fixture without the guard, an option that switches it off, a package update that changes a default), the pages would be calm for the wrong reason, and the canary is the test that notices.

## What it protects

- Every browser test of every page, at desktop and phone size, from load to the end of the test.
- Every game from the loading state to a finished round, including a 320 pixel wide screen.
- The contact form with an error, with a failed sending and with the confirmation.
- The rule that an exception needs a reason, and that every browser test uses the fixture that has the guard.
- The guard itself, through the canary that moves a page on purpose.
6 changes: 3 additions & 3 deletions src/content/notes/snippets/layout-guard-moved.snippet
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
// right edge changed. One that only grows or shrinks did not move: what
// it pushes did, and that is reported at the thing that moved.
const moved =
Math.abs(top - oldTop) > 1 ||
(Math.abs(left - oldLeft) > 1 &&
Math.abs(left + width - (oldLeft + oldWidth)) > 1);
Math.abs(top - oldTop) > tolerance ||
(Math.abs(left - oldLeft) > tolerance &&
Math.abs(left + width - (oldLeft + oldWidth)) > tolerance);
2 changes: 1 addition & 1 deletion src/content/projects/website.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ stats:
label: automated tests (unit, build output, browser, accessibility)
highlights:
- Static portfolio built with Astro, TypeScript, React islands and Tailwind CSS, hosted on Cloudflare, with a command palette (Ctrl K) and a small terminal that load only when they are opened. Its content is typed YAML that is validated at build time, so a typo fails the build instead of shipping.
- Every change goes through a pull request and automated gates before it goes live, from linting, type checks and unit tests to build-output checks (security headers, CSP, legal pages), end-to-end tests with accessibility scans and a layout guard that fails a test when anything on any page moves or changes size, Lighthouse budgets, CodeQL, dependency review and OpenSSF Scorecard. After the deployment a live check of the published site (headers, redirects, legal pages, contact form, certificate, domain, mail DNS) runs every few hours, plus a weekly link check.
- Every change goes through a pull request and automated gates before it goes live, from linting, type checks and unit tests to build-output checks (security headers, CSP, legal pages), end-to-end tests with accessibility scans and a layout guard (my own open source package) that fails a test when anything on any page moves or changes size, Lighthouse budgets, CodeQL, dependency review and OpenSSF Scorecard. After the deployment a live check of the published site (headers, redirects, legal pages, contact form, certificate, domain, mail DNS) runs every few hours, plus a weekly link check.
- A contact form without any third-party service. A Cloudflare Function validates the message with the same rules as the browser, checks origin, a signed time token, a honeypot and a rate limit, and hands it to my own mailbox over SMTP.
- Privacy and security by design with a strict content security policy, HSTS, no cookies of its own, self-hosted fonts and obfuscated contact data.
- Five browser games in the [Playground](/#playground) further down this page, written as tested pure functions, playable with the keyboard, with nothing stored on your device. How they and this site are built is written up in [Behind the scenes](/notes/).
Expand Down
Loading
Loading