diff --git a/README.md b/README.md
index ae8cace..abe4979 100644
--- a/README.md
+++ b/README.md
@@ -118,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|---|---|
|  |  |
| Board view — one column per section. | The light theme. |
+|  |  |
+| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
+|  |  |
+| Five themes, all measured for contrast. | What has been done on the server. |
-Regenerate these with `go run ./tools/shots` against a running instance.
+Regenerate these with `node scripts/generate-screenshots.js` from `web/`. It starts
+the real binary, creates its own account, fills it with demo data and photographs
+eleven surfaces — nothing to set up and nothing to remember. (`go run ./tools/shots`
+still works, and is the way to photograph an instance that is already running.)
## Running it
diff --git a/docs/screenshots/admin.png b/docs/screenshots/admin.png
new file mode 100644
index 0000000..fbf80e9
Binary files /dev/null and b/docs/screenshots/admin.png differ
diff --git a/docs/screenshots/board.png b/docs/screenshots/board.png
index 0177166..113fcf3 100644
Binary files a/docs/screenshots/board.png and b/docs/screenshots/board.png differ
diff --git a/docs/screenshots/delegated.png b/docs/screenshots/delegated.png
new file mode 100644
index 0000000..2fc477b
Binary files /dev/null and b/docs/screenshots/delegated.png differ
diff --git a/docs/screenshots/detail.png b/docs/screenshots/detail.png
new file mode 100644
index 0000000..e0dd930
Binary files /dev/null and b/docs/screenshots/detail.png differ
diff --git a/docs/screenshots/history.png b/docs/screenshots/history.png
new file mode 100644
index 0000000..cc231c8
Binary files /dev/null and b/docs/screenshots/history.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
new file mode 100644
index 0000000..8720738
Binary files /dev/null and b/docs/screenshots/settings.png differ
diff --git a/docs/screenshots/themes.png b/docs/screenshots/themes.png
new file mode 100644
index 0000000..985b60b
Binary files /dev/null and b/docs/screenshots/themes.png differ
diff --git a/docs/screenshots/today-light.png b/docs/screenshots/today-light.png
index dbe7e58..c88f348 100644
Binary files a/docs/screenshots/today-light.png and b/docs/screenshots/today-light.png differ
diff --git a/docs/screenshots/today.png b/docs/screenshots/today.png
index 3ac99b8..d69e4a8 100644
Binary files a/docs/screenshots/today.png and b/docs/screenshots/today.png differ
diff --git a/docs/screenshots/upcoming.png b/docs/screenshots/upcoming.png
index 455ca93..d9514a8 100644
Binary files a/docs/screenshots/upcoming.png and b/docs/screenshots/upcoming.png differ
diff --git a/docs/screenshots/week.png b/docs/screenshots/week.png
new file mode 100644
index 0000000..86c6da4
Binary files /dev/null and b/docs/screenshots/week.png differ
diff --git a/site/index.html b/site/index.html
index c91b058..22adbc3 100644
--- a/site/index.html
+++ b/site/index.html
@@ -466,6 +466,46 @@
/>
The light theme.
+
+
+ A week at a time — so a task can be dragged across a month boundary.
+
+
+
+ A task, opened.
+
+
+
+ Five themes, all of them measured for contrast.
+
+
+
+ What has been done on the server, across every project.
+
diff --git a/site/screenshots/admin.png b/site/screenshots/admin.png
new file mode 100644
index 0000000..fbf80e9
Binary files /dev/null and b/site/screenshots/admin.png differ
diff --git a/site/screenshots/board.png b/site/screenshots/board.png
index 0177166..113fcf3 100644
Binary files a/site/screenshots/board.png and b/site/screenshots/board.png differ
diff --git a/site/screenshots/delegated.png b/site/screenshots/delegated.png
new file mode 100644
index 0000000..2fc477b
Binary files /dev/null and b/site/screenshots/delegated.png differ
diff --git a/site/screenshots/detail.png b/site/screenshots/detail.png
new file mode 100644
index 0000000..e0dd930
Binary files /dev/null and b/site/screenshots/detail.png differ
diff --git a/site/screenshots/history.png b/site/screenshots/history.png
new file mode 100644
index 0000000..cc231c8
Binary files /dev/null and b/site/screenshots/history.png differ
diff --git a/site/screenshots/settings.png b/site/screenshots/settings.png
new file mode 100644
index 0000000..8720738
Binary files /dev/null and b/site/screenshots/settings.png differ
diff --git a/site/screenshots/themes.png b/site/screenshots/themes.png
new file mode 100644
index 0000000..985b60b
Binary files /dev/null and b/site/screenshots/themes.png differ
diff --git a/site/screenshots/today-light.png b/site/screenshots/today-light.png
index dbe7e58..c88f348 100644
Binary files a/site/screenshots/today-light.png and b/site/screenshots/today-light.png differ
diff --git a/site/screenshots/today.png b/site/screenshots/today.png
index 3ac99b8..d69e4a8 100644
Binary files a/site/screenshots/today.png and b/site/screenshots/today.png differ
diff --git a/site/screenshots/upcoming.png b/site/screenshots/upcoming.png
index 455ca93..d9514a8 100644
Binary files a/site/screenshots/upcoming.png and b/site/screenshots/upcoming.png differ
diff --git a/site/screenshots/week.png b/site/screenshots/week.png
new file mode 100644
index 0000000..86c6da4
Binary files /dev/null and b/site/screenshots/week.png differ
diff --git a/web/scripts/generate-screenshots.js b/web/scripts/generate-screenshots.js
new file mode 100644
index 0000000..2b38066
--- /dev/null
+++ b/web/scripts/generate-screenshots.js
@@ -0,0 +1,264 @@
+/**
+ * Captures the screenshots the landing page, the README and the docs use.
+ *
+ * node scripts/generate-screenshots.js
+ *
+ * A script rather than a test, for the same reason as generate-icons.js: CI must
+ * never rewrite a tracked binary file. Run it when the interface changes.
+ *
+ * It replaces the hand-driven half of `go run ./tools/shots`, which needs a
+ * running instance and a session cookie pasted in by hand — and which therefore
+ * ran rarely enough that the images fell many rounds behind the app they show.
+ * This one starts the real binary, creates its own account, fills it with the
+ * demo data, and takes the pictures. Nothing to set up and nothing to remember.
+ *
+ * The binary, not the dev server: the screenshots should show what ships. That
+ * means building the frontend and copying it into cmd/verdande/webbuild first,
+ * exactly as the smoke tests do — a capture of a stale embedded build would be a
+ * picture of an app nobody is running.
+ */
+import { chromium } from '@playwright/test';
+import { spawn, execSync } from 'node:child_process';
+import { mkdirSync, rmSync, writeFileSync, copyFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const web = join(here, '..');
+const repo = join(web, '..');
+const dataDir = join(web, '.screenshot-data');
+
+const PORT = 8098;
+const baseURL = `http://127.0.0.1:${PORT}`;
+const USER = { name: 'Kristian', email: 'kristian@example.dk', password: 'et langt kodeord' };
+
+// 1400×900 is what the landing page's declares. A capture at another size
+// would be resampled by the browser and look soft next to the text beside it.
+const WIDTH = 1400;
+const HEIGHT = 900;
+
+const OUT = [join(repo, 'site', 'screenshots'), join(repo, 'docs', 'screenshots')];
+
+/**
+ * The pictures, and why each one is here.
+ *
+ * Every view that carries a claim the prose makes. A screenshot set that stops at
+ * "the task list" is a set that cannot show anything the app grew since.
+ */
+const SHOTS = [
+ { name: 'today', path: '/', theme: 'dark' },
+ { name: 'today-light', path: '/', theme: 'light' },
+ { name: 'upcoming', path: '/upcoming', theme: 'dark', prepare: showMonth },
+ { name: 'week', path: '/upcoming', theme: 'dark', prepare: showWeek },
+ { name: 'board', path: (ids) => `/projekt/${ids.work}`, theme: 'dark' },
+ { name: 'detail', path: '/', theme: 'dark', prepare: openFirstTask },
+ { name: 'settings', path: '/indstillinger/integrationer', theme: 'dark' },
+ { name: 'themes', path: '/indstillinger', theme: 'dark' },
+ { name: 'admin', path: '/indstillinger/brugere', theme: 'dark' },
+ { name: 'history', path: '/indstillinger/historik', theme: 'dark' },
+ { name: 'delegated', path: '/uddelegeret', theme: 'dark' }
+];
+
+async function showMonth(page) {
+ await page.getByRole('button', { name: 'Måned', exact: true }).click();
+ await page.waitForTimeout(800);
+}
+
+async function showWeek(page) {
+ await page.getByRole('button', { name: 'Uge', exact: true }).click();
+ await page.waitForTimeout(800);
+}
+
+async function openFirstTask(page) {
+ await page.locator('.row').first().click();
+ await page.waitForTimeout(600);
+}
+
+console.log('building the frontend and embedding it…');
+execSync('npm run build', { cwd: web, stdio: 'inherit' });
+rmSync(join(repo, 'cmd', 'verdande', 'webbuild'), { recursive: true, force: true });
+execSync(`cp -r ${JSON.stringify(join(web, 'build'))} ${JSON.stringify(join(repo, 'cmd', 'verdande', 'webbuild'))}`);
+
+// A clean instance: the first screen is "create the first account", which only
+// appears on an empty database.
+rmSync(dataDir, { recursive: true, force: true });
+
+console.log('starting the binary…');
+// `detached`, so the whole process group can be killed at the end. `go run`
+// compiles the binary and runs it as a *child*: killing `go run` leaves the server
+// itself holding the port, and the next run then dies on "address already in use"
+// with no clue as to what is holding it.
+const server = spawn('go', ['run', '-tags', 'embedweb', './cmd/verdande'], {
+ cwd: repo,
+ detached: true,
+ stdio: ['ignore', 'inherit', 'inherit'],
+ env: {
+ ...process.env,
+ VERDANDE_ADDR: `:${PORT}`,
+ VERDANDE_BASE_URL: baseURL,
+ VERDANDE_DATA_DIR: dataDir,
+ // An update check here would reach out to GitHub to take a screenshot.
+ VERDANDE_UPDATE_CHECK: 'false'
+ }
+});
+
+let browser;
+try {
+ await waitForHealth();
+
+ browser = await chromium.launch();
+ const page = await browser.newPage({
+ viewport: { width: WIDTH, height: HEIGHT },
+ // The same pinning the smoke tests use, and for the same reason: the sign-up
+ // form sends navigator.language as the account's locale, and every due date
+ // is resolved in the account's zone.
+ locale: 'da-DK',
+ timezoneId: 'Europe/Copenhagen',
+ deviceScaleFactor: 2
+ });
+
+ await page.goto(baseURL);
+ await page.getByLabel(/Navn/).fill(USER.name);
+ await page.getByLabel(/E-mail/).fill(USER.email);
+ await page.getByLabel(/Adgangskode/).fill(USER.password);
+ await page.getByRole('button', { name: 'Opret konto' }).click();
+ await page.getByRole('navigation', { name: 'Hovedmenu' }).waitFor();
+
+ console.log('seeding…');
+ const ids = await page.evaluate(seed);
+
+ mkdirSync(OUT[0], { recursive: true });
+ mkdirSync(OUT[1], { recursive: true });
+
+ for (const shot of SHOTS) {
+ const path = typeof shot.path === 'function' ? shot.path(ids) : shot.path;
+ await page.goto(baseURL + path);
+ // A fixed settle rather than a load event: the interesting part is after
+ // hydration and after the first fetch, and the load event says nothing
+ // about either.
+ await page.waitForTimeout(1200);
+ if (shot.prepare) await shot.prepare(page);
+ if (shot.theme) {
+ await page.evaluate((t) => (document.documentElement.dataset.theme = t), shot.theme);
+ await page.waitForTimeout(400);
+ }
+ const png = await page.screenshot();
+ writeFileSync(join(OUT[0], `${shot.name}.png`), png);
+ copyFileSync(join(OUT[0], `${shot.name}.png`), join(OUT[1], `${shot.name}.png`));
+ console.log(` ${shot.name}.png`);
+ }
+} finally {
+ await browser?.close();
+ // The group, not the process: see the spawn above.
+ try {
+ process.kill(-server.pid, 'SIGKILL');
+ } catch {
+ // Already gone, which is the outcome this was for.
+ }
+ rmSync(dataDir, { recursive: true, force: true });
+}
+
+async function waitForHealth() {
+ for (let i = 0; i < 240; i++) {
+ try {
+ const r = await fetch(`${baseURL}/healthz`);
+ if (r.ok) return;
+ } catch {
+ // Not up yet. The first run compiles the binary, which takes a while.
+ }
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ throw new Error('the server never came up');
+}
+
+/**
+ * The demo data, created through the app's own API from inside the page.
+ *
+ * From the page rather than from node, so the session cookie and the same-origin
+ * header the API insists on come along without being reconstructed here. Written
+ * as one function because it is passed to the browser as source.
+ *
+ * The content is ordinary Danish household and work tasks. Screenshots of "Task
+ * 1, Task 2, Task 3" show the layout and hide whether the app is any good at the
+ * thing it is for.
+ */
+async function seed() {
+ const api = async (path, body, method = 'POST') => {
+ const r = await fetch(`/api/v1${path}`, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined
+ });
+ if (!r.ok) throw new Error(`${method} ${path} → ${r.status}`);
+ return r.status === 204 ? null : r.json();
+ };
+
+ const day = (offset) => {
+ const d = new Date();
+ d.setDate(d.getDate() + offset);
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(
+ d.getDate()
+ ).padStart(2, '0')}`;
+ };
+
+ // Two headings over the projects, which is what the sidebar grew for.
+ const arbejde = await api('/project-groups', { name: 'Arbejde' });
+ const privat = await api('/project-groups', { name: 'Privat' });
+ await api(`/project-groups/${arbejde.id}`, { color: 'indigo' }, 'PATCH');
+ await api(`/project-groups/${privat.id}`, { color: 'green' }, 'PATCH');
+
+ const work = await api('/projects', {
+ name: 'Sæsonstart',
+ color: 'indigo',
+ view_mode: 'board',
+ group_id: arbejde.id
+ });
+ const house = await api('/projects', { name: 'Huset', color: 'amber', group_id: privat.id });
+ const garden = await api('/projects', { name: 'Haven', color: 'green', group_id: privat.id });
+
+ const planning = await api(`/projects/${work.id}/sections`, { name: 'Planlægning' });
+ const inFlight = await api(`/projects/${work.id}/sections`, { name: 'I gang' });
+ const done = await api(`/projects/${work.id}/sections`, { name: 'Til gennemsyn' });
+
+ const tasks = [
+ // Today, in the Inbox and across the projects: the Today view is the first
+ // picture and it has to look like a real day.
+ { content: 'Ring til tømreren om taget', due_date: day(0), priority: 1, project_id: house.id },
+ { content: 'Aflever cyklen til service', due_date: day(0), priority: 3, labels: ['ærinder'] },
+ {
+ content: 'Skriv oplæg til sæsonmødet',
+ due_date: day(0),
+ priority: 2,
+ project_id: work.id,
+ section_id: planning.id,
+ duration_min: 90
+ },
+ { content: 'Vand drivhuset', due_date: day(0), recurrence_rule: 'FREQ=DAILY', project_id: garden.id },
+ { content: 'Hent pakken på posthuset', due_date: day(0), labels: ['ærinder'] },
+
+ // The next couple of weeks, so Kommende and the month grid have something in
+ // them rather than a row of empty cells.
+ { content: 'Book håndværker til badeværelset', due_date: day(1), priority: 2, project_id: house.id },
+ { content: 'Beskær den grønne hæk', due_date: day(2), project_id: garden.id },
+ { content: 'Gennemgå budgettet', due_date: day(3), priority: 1, project_id: work.id, section_id: inFlight.id },
+ { content: 'Bestil løg til efteråret', due_date: day(5), project_id: garden.id },
+ { content: 'Skift dæk', due_date: day(8), priority: 3 },
+ { content: 'Send referat ud', due_date: day(9), project_id: work.id, section_id: done.id },
+ { content: 'Male vindueskarmene', due_date: day(12), project_id: house.id, priority: 2 },
+ { content: 'Så vinterurter', due_date: day(15), project_id: garden.id },
+ { content: 'Kvartalsopfølgning', due_date: day(21), priority: 1, project_id: work.id, section_id: planning.id },
+ { content: 'Rens tagrenderne', due_date: day(26), project_id: house.id },
+
+ // A few without dates, so the board has cards that are not all deadlines.
+ { content: 'Find ny leverandør af emballage', project_id: work.id, section_id: planning.id, priority: 3 },
+ { content: 'Skriv stillingsopslag', project_id: work.id, section_id: inFlight.id },
+ { content: 'Opdater prislisten', project_id: work.id, section_id: done.id, labels: ['gennemsyn'] }
+ ];
+ for (const task of tasks) await api('/tasks', task);
+
+ // A saved filter, because the sidebar shows them and an empty heading reads as
+ // a feature that does not work.
+ await api('/filters', { name: 'Vigtigt i denne uge', query: 'p1 & 7 days' });
+
+ return { work: work.id, house: house.id, garden: garden.id };
+}