fuzzyworld

when-am-i / commit

feat: Playwright screenshot tooling + checked-in screenshots

48a625b794f256e380f2cc41941f7e8736f0a39d · Pavel Safronov · 2026-06-18 17:01:11 -0700 · parents 4014e5e06f34

feat: Playwright screenshot tooling + checked-in screenshots

Add tsx-run .mts scripts that drive the app via the URL state hook for
deterministic, reproducible screenshots (reusing a running dev server or
starting one):

- npm run screenshot — documentation shots of key states (landing, Boston
  1700/1800) → screenshots/app/
- npm run screenshot:calendar -- <file.json> — pins a point inside the region
  and a representative date per era, capturing the answer panel + shaded map, so
  a calendar PR can show geometry/era/conversion issues visually →
  screenshots/calendars/<file>/

Document both in the README (Development + contributor workflow) and check in
the generated images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
 README.md                                          |  17 +
 package-lock.json                                  | 504 ++++++++++++++++++++-
 package.json                                       |   4 +
 screenshots/app/01-landing.png                     | Bin 0 -> 340620 bytes
 screenshots/app/02-boston-1700.png                 | Bin 0 -> 1666312 bytes
 screenshots/app/03-boston-1800.png                 | Bin 0 -> 1664908 bytes
 screenshots/calendars/boston/01-gregorian.png      | Bin 0 -> 1660225 bytes
 .../boston/02-algonquian-lunar-calendar.png        | Bin 0 -> 1661597 bytes
 scripts/generate-screenshots.mts                   |  56 +++
 scripts/lib/server.mts                             |  55 +++
 scripts/screenshot-calendar.mts                    | 114 +++++
 11 files changed, 747 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index a53e2be..6a80449 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,20 @@ npm test            # Vitest unit tests
 npm run test:watch  # Vitest in watch mode
 ```
 
+### Screenshots
+
+Screenshots are generated with Playwright, which forces deterministic app state
+via URL params (`?lat=&lng=&zoom=&year=&month=&day=&era=`). The scripts reuse a
+running dev server or start one automatically.
+
+```bash
+npm run screenshot                                   # app states → screenshots/app/
+npm run screenshot:calendar -- src/data/calendars/boston.json   # one calendar → screenshots/calendars/<file>/
+```
+
+`screenshot:calendar` pins a point inside the region and a representative date
+in each era, capturing the answer panel and shaded map per era.
+
 ## Reporting issues
 
 The app footer links to the [source repository](https://gitlab.com/PavelSafronov/what-year-is-it)
@@ -125,6 +139,9 @@ See `src/data/calendars/SCHEMA.json` for the schema and field documentation.
 - Add or edit files in `src/data/calendars/` via pull request.
 - Use [geojson.io](https://geojson.io/) or similar tools to draw and export region polygons.
 - Include sources/references for each era.
+- Run `npm run screenshot:calendar -- src/data/calendars/<your-file>.json` and
+  attach the images to your PR, so the region, eras, and conversions can be
+  reviewed visually.
 - The build process will validate and compile all data for use in the app.
 
 ## License
diff --git a/package-lock.json b/package-lock.json
index 5875ac2..c024883 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,8 @@
                 "eslint-plugin-react-hooks": "^7.1.1",
                 "eslint-plugin-react-refresh": "^0.5.3",
                 "globals": "^17.6.0",
+                "playwright": "^1.61.0",
+                "tsx": "^4.22.4",
                 "typescript": "^5.0.0",
                 "typescript-eslint": "^8.61.1",
                 "vite": "^5.0.0",
@@ -649,7 +651,6 @@
             "os": [
                 "netbsd"
             ],
-            "peer": true,
             "engines": {
                 "node": ">=18"
             }
@@ -684,7 +685,6 @@
             "os": [
                 "openbsd"
             ],
-            "peer": true,
             "engines": {
                 "node": ">=18"
             }
@@ -719,7 +719,6 @@
             "os": [
                 "openharmony"
             ],
-            "peer": true,
             "engines": {
                 "node": ">=18"
             }
@@ -3468,6 +3467,53 @@
                 "url": "https://github.com/sponsors/jonschlinkert"
             }
         },
+        "node_modules/playwright": {
+            "version": "1.61.0",
+            "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
+            "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
+            "dev": true,
+            "license": "Apache-2.0",
+            "dependencies": {
+                "playwright-core": "1.61.0"
+            },
+            "bin": {
+                "playwright": "cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "optionalDependencies": {
+                "fsevents": "2.3.2"
+            }
+        },
+        "node_modules/playwright-core": {
+            "version": "1.61.0",
+            "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
+            "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
+            "dev": true,
+            "license": "Apache-2.0",
+            "bin": {
+                "playwright-core": "cli.js"
+            },
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/playwright/node_modules/fsevents": {
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+            "dev": true,
+            "hasInstallScript": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
         "node_modules/postcss": {
             "version": "8.5.15",
             "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -3790,6 +3836,458 @@
             "license": "0BSD",
             "optional": true
         },
+        "node_modules/tsx": {
+            "version": "4.22.4",
+            "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+            "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "esbuild": "~0.28.0"
+            },
+            "bin": {
+                "tsx": "dist/cli.mjs"
+            },
+            "engines": {
+                "node": ">=18.0.0"
+            },
+            "optionalDependencies": {
+                "fsevents": "~2.3.3"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+            "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+            "cpu": [
+                "ppc64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "aix"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/android-arm": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+            "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/android-arm64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+            "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/android-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+            "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+            "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/darwin-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+            "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+            "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+            "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-arm": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+            "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+            "cpu": [
+                "arm"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-arm64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+            "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-ia32": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+            "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+            "cpu": [
+                "ia32"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-loong64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+            "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+            "cpu": [
+                "loong64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+            "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+            "cpu": [
+                "mips64el"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+            "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+            "cpu": [
+                "ppc64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+            "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+            "cpu": [
+                "riscv64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-s390x": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+            "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+            "cpu": [
+                "s390x"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/linux-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+            "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+            "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+            "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/sunos-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+            "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "sunos"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/win32-arm64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+            "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+            "cpu": [
+                "arm64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/win32-ia32": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+            "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+            "cpu": [
+                "ia32"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/@esbuild/win32-x64": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+            "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+            "cpu": [
+                "x64"
+            ],
+            "dev": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/tsx/node_modules/esbuild": {
+            "version": "0.28.1",
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+            "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+            "dev": true,
+            "hasInstallScript": true,
+            "license": "MIT",
+            "bin": {
+                "esbuild": "bin/esbuild"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "optionalDependencies": {
+                "@esbuild/aix-ppc64": "0.28.1",
+                "@esbuild/android-arm": "0.28.1",
+                "@esbuild/android-arm64": "0.28.1",
+                "@esbuild/android-x64": "0.28.1",
+                "@esbuild/darwin-arm64": "0.28.1",
+                "@esbuild/darwin-x64": "0.28.1",
+                "@esbuild/freebsd-arm64": "0.28.1",
+                "@esbuild/freebsd-x64": "0.28.1",
+                "@esbuild/linux-arm": "0.28.1",
+                "@esbuild/linux-arm64": "0.28.1",
+                "@esbuild/linux-ia32": "0.28.1",
+                "@esbuild/linux-loong64": "0.28.1",
+                "@esbuild/linux-mips64el": "0.28.1",
+                "@esbuild/linux-ppc64": "0.28.1",
+                "@esbuild/linux-riscv64": "0.28.1",
+                "@esbuild/linux-s390x": "0.28.1",
+                "@esbuild/linux-x64": "0.28.1",
+                "@esbuild/netbsd-arm64": "0.28.1",
+                "@esbuild/netbsd-x64": "0.28.1",
+                "@esbuild/openbsd-arm64": "0.28.1",
+                "@esbuild/openbsd-x64": "0.28.1",
+                "@esbuild/openharmony-arm64": "0.28.1",
+                "@esbuild/sunos-x64": "0.28.1",
+                "@esbuild/win32-arm64": "0.28.1",
+                "@esbuild/win32-ia32": "0.28.1",
+                "@esbuild/win32-x64": "0.28.1"
+            }
+        },
         "node_modules/type-check": {
             "version": "0.4.0",
             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/package.json b/package.json
index b3dafae..a7c8918 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
         "lint": "eslint .",
         "test": "vitest run",
         "test:watch": "vitest",
+        "screenshot": "tsx scripts/generate-screenshots.mts",
+        "screenshot:calendar": "tsx scripts/screenshot-calendar.mts",
         "gen:favicon": "sips -z 64 64 calendar-source.png --out src/assets/favicon.png",
         "copy:leaflet-icons": "mkdir -p public && cp node_modules/leaflet/dist/images/marker-icon.png public/ && cp node_modules/leaflet/dist/images/marker-icon-2x.png public/ && cp node_modules/leaflet/dist/images/marker-shadow.png public/",
         "postinstall": "npm run copy:leaflet-icons"
@@ -33,6 +35,8 @@
         "eslint-plugin-react-hooks": "^7.1.1",
         "eslint-plugin-react-refresh": "^0.5.3",
         "globals": "^17.6.0",
+        "playwright": "^1.61.0",
+        "tsx": "^4.22.4",
         "typescript": "^5.0.0",
         "typescript-eslint": "^8.61.1",
         "vite": "^5.0.0",
diff --git a/screenshots/app/01-landing.png b/screenshots/app/01-landing.png
new file mode 100644
index 0000000..cd22e0c
Binary files /dev/null and b/screenshots/app/01-landing.png differ
diff --git a/screenshots/app/02-boston-1700.png b/screenshots/app/02-boston-1700.png
new file mode 100644
index 0000000..83a4c59
Binary files /dev/null and b/screenshots/app/02-boston-1700.png differ
diff --git a/screenshots/app/03-boston-1800.png b/screenshots/app/03-boston-1800.png
new file mode 100644
index 0000000..3ce776f
Binary files /dev/null and b/screenshots/app/03-boston-1800.png differ
diff --git a/screenshots/calendars/boston/01-gregorian.png b/screenshots/calendars/boston/01-gregorian.png
new file mode 100644
index 0000000..81d39b6
Binary files /dev/null and b/screenshots/calendars/boston/01-gregorian.png differ
diff --git a/screenshots/calendars/boston/02-algonquian-lunar-calendar.png b/screenshots/calendars/boston/02-algonquian-lunar-calendar.png
new file mode 100644
index 0000000..f841f78
Binary files /dev/null and b/screenshots/calendars/boston/02-algonquian-lunar-calendar.png differ
diff --git a/scripts/generate-screenshots.mts b/scripts/generate-screenshots.mts
new file mode 100644
index 0000000..575af46
--- /dev/null
+++ b/scripts/generate-screenshots.mts
@@ -0,0 +1,56 @@
+// Generate documentation screenshots of key app states.
+//
+// Forces deterministic state via URL params (the same hook the app reads), so
+// shots are reproducible. Output: screenshots/app/. Run from the repo root:
+//   npm run screenshot
+
+import { chromium } from 'playwright';
+import { mkdir, rm } from 'node:fs/promises';
+import path from 'node:path';
+import { startDevServer, BASE_URL, REPO_ROOT } from './lib/server.mts';
+
+const OUT_DIR = path.join(REPO_ROOT, 'screenshots', 'app');
+
+const STATES = [
+    { name: '01-landing', query: '', desc: 'Landing (default 1700, no pin)' },
+    {
+        name: '02-boston-1700',
+        query: '?lat=42.30&lng=-71.06&zoom=10&year=1700&month=6&day=15',
+        desc: 'Boston, 1700 — Algonquian (descriptive-only)',
+    },
+    {
+        name: '03-boston-1800',
+        query: '?lat=42.30&lng=-71.06&zoom=10&year=1800&month=6&day=15',
+        desc: 'Boston, 1800 — Gregorian',
+    },
+];
+
+async function run() {
+    console.log('\n📸  Generating app screenshots…\n');
+    const server = await startDevServer();
+    try {
+        await rm(OUT_DIR, { recursive: true, force: true });
+        await mkdir(OUT_DIR, { recursive: true });
+
+        const browser = await chromium.launch();
+        const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 });
+
+        for (const state of STATES) {
+            await page.goto(`${BASE_URL}${state.query}`, { waitUntil: 'networkidle' });
+            await page.waitForTimeout(800); // let map tiles settle
+            const file = path.join(OUT_DIR, `${state.name}.png`);
+            await page.screenshot({ path: file });
+            console.log(`  ✅  ${state.name}  —  ${state.desc}`);
+        }
+
+        await browser.close();
+        console.log('\n✨  Done — screenshots/app/\n');
+    } finally {
+        server.stop();
+    }
+}
+
+run().catch((err) => {
+    console.error(err);
+    process.exit(1);
+});
diff --git a/scripts/lib/server.mts b/scripts/lib/server.mts
new file mode 100644
index 0000000..2d8af7d
--- /dev/null
+++ b/scripts/lib/server.mts
@@ -0,0 +1,55 @@
+// Dev-server lifecycle for the screenshot scripts: reuse a running Vite server
+// on 5173 if there is one, otherwise start one and stop it when finished.
+
+import { spawn, type ChildProcess } from 'node:child_process';
+import http from 'node:http';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+export const REPO_ROOT = path.resolve(__dirname, '../..');
+export const BASE_URL = 'http://127.0.0.1:5173/what-year-is-it/';
+
+function isUp(url: string): Promise<boolean> {
+    return new Promise((resolve) => {
+        const req = http.get(url, (res) => {
+            res.resume();
+            resolve(true);
+        });
+        req.on('error', () => resolve(false));
+        req.setTimeout(1000, () => {
+            req.destroy();
+            resolve(false);
+        });
+    });
+}
+
+const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+export interface DevServer {
+    stop: () => void;
+}
+
+export async function startDevServer(): Promise<DevServer> {
+    if (await isUp(BASE_URL)) {
+        console.log('  ℹ️   Reusing the dev server already running on :5173');
+        return { stop: () => {} };
+    }
+
+    console.log('  🚀  Starting Vite dev server…');
+    const proc: ChildProcess = spawn('npx', ['vite', '--port', '5173', '--strictPort'], {
+        cwd: REPO_ROOT,
+        stdio: 'ignore',
+    });
+
+    const start = Date.now();
+    while (Date.now() - start < 30_000) {
+        if (await isUp(BASE_URL)) {
+            console.log('  ✅  Dev server ready');
+            return { stop: () => proc.kill() };
+        }
+        await wait(500);
+    }
+    proc.kill();
+    throw new Error('Dev server did not start within 30s');
+}
diff --git a/scripts/screenshot-calendar.mts b/scripts/screenshot-calendar.mts
new file mode 100644
index 0000000..0749105
--- /dev/null
+++ b/scripts/screenshot-calendar.mts
@@ -0,0 +1,114 @@
+// Screenshot one calendar data file across its eras, for inclusion in a
+// calendar PR (so reviewers can see geometry/era/conversion issues visually).
+//
+//   npm run screenshot:calendar -- src/data/calendars/boston.json
+//
+// For each era it pins a point inside the region and a representative date in
+// that era, then captures the answer panel + shaded map. Output:
+//   screenshots/calendars/<file>/
+
+import { chromium } from 'playwright';
+import { readFile, mkdir, rm } from 'node:fs/promises';
+import path from 'node:path';
+import { startDevServer, BASE_URL, REPO_ROOT } from './lib/server.mts';
+
+interface Era {
+    name: string;
+    start: string | null;
+    end: string | null;
+    calendar: string;
+}
+interface CalendarRegion {
+    region: { name: string; geo: { type: string; coordinates: unknown } };
+    eras: Era[];
+}
+
+// Flatten any Polygon/MultiPolygon to its [lng, lat] positions.
+function positions(geo: { type: string; coordinates: unknown }): [number, number][] {
+    const coords = geo.coordinates as number[][][] | number[][][][];
+    return geo.type === 'Polygon'
+        ? (coords as number[][][]).flat() as [number, number][]
+        : (coords as number[][][][]).flat(2) as [number, number][];
+}
+
+function bboxCenterAndZoom(geo: { type: string; coordinates: unknown }) {
+    const pts = positions(geo);
+    const lngs = pts.map((p) => p[0]);
+    const lats = pts.map((p) => p[1]);
+    const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
+    const minLat = Math.min(...lats), maxLat = Math.max(...lats);
+    const center = { lng: (minLng + maxLng) / 2, lat: (minLat + maxLat) / 2 };
+    const span = Math.max(maxLng - minLng, maxLat - minLat, 0.01);
+    const zoom = Math.max(2, Math.min(14, Math.floor(Math.log2(360 / span)) - 1));
+    return { center, zoom };
+}
+
+function yearOf(value: string | null): number | null {
+    if (!value) return null;
+    const m = /^(-?\d+)/.exec(value);
+    return m ? parseInt(m[1], 10) : null;
+}
+
+// A representative astronomical year inside an era's range.
+function representativeYear(era: Era): number {
+    const start = yearOf(era.start);
+    const end = yearOf(era.end);
+    if (start !== null && end !== null) return Math.round((start + end) / 2);
+    if (start !== null) return start + 25;
+    if (end !== null) return end - 25;
+    return 1700;
+}
+
+function slug(value: string): string {
+    return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
+}
+
+async function run() {
+    const fileArg = process.argv[2];
+    if (!fileArg) {
+        console.error('usage: npm run screenshot:calendar -- <path/to/calendar.json>');
+        process.exit(1);
+    }
+
+    const absPath = path.resolve(fileArg);
+    const data = JSON.parse(await readFile(absPath, 'utf-8')) as CalendarRegion;
+    const { center, zoom } = bboxCenterAndZoom(data.region.geo);
+
+    const outDir = path.join(REPO_ROOT, 'screenshots', 'calendars', path.basename(fileArg, '.json'));
+    console.log(`\n📸  Screenshotting ${data.region.name} (${data.eras.length} eras)…\n`);
+
+    const server = await startDevServer();
+    try {
+        await rm(outDir, { recursive: true, force: true });
+        await mkdir(outDir, { recursive: true });
+
+        const browser = await chromium.launch();
+        const page = await browser.newPage({ viewport: { width: 1100, height: 920 }, deviceScaleFactor: 2 });
+
+        for (let i = 0; i < data.eras.length; i++) {
+            const era = data.eras[i];
+            const astroYear = representativeYear(era);
+            const year = astroYear > 0 ? astroYear : 1 - astroYear;
+            const eraParam = astroYear > 0 ? 'CE' : 'BCE';
+            const query =
+                `?lat=${center.lat}&lng=${center.lng}&zoom=${zoom}` +
+                `&year=${year}&month=7&day=1&era=${eraParam}`;
+
+            await page.goto(`${BASE_URL}${query}`, { waitUntil: 'networkidle' });
+            await page.waitForTimeout(800);
+            const name = `${String(i + 1).padStart(2, '0')}-${slug(era.calendar)}.png`;
+            await page.screenshot({ path: path.join(outDir, name) });
+            console.log(`  ✅  ${name}  —  ${era.name} (${year} ${eraParam})`);
+        }
+
+        await browser.close();
+        console.log(`\n✨  Done — ${path.relative(REPO_ROOT, outDir)}/\n`);
+    } finally {
+        server.stop();
+    }
+}
+
+run().catch((err) => {
+    console.error(err);
+    process.exit(1);
+});