docs: screenshots that regenerate themselves, and eleven of them

They were many rounds behind — no settings, no task detail, no themes, no groups,
no colours, no calendar, no admin pages. The reason is in how they were made:
`tools/shots` needs a running instance and a session cookie pasted in by hand, so
it ran about as often as somebody felt like doing that.

`node scripts/generate-screenshots.js` starts the real binary, creates its own
account, fills it with Danish demo data and photographs eleven surfaces into both
`site/screenshots` and `docs/screenshots`. Nothing to set up and nothing to
remember.

A script rather than a test, for the same reason `generate-icons.js` is one: CI
must never rewrite a tracked binary file.

`tools/shots` stays. It is still the way to photograph an instance that is
already running rather than a fresh one.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-18 09:05:36 +02:00
co-authored by Claude Opus 5
parent 01d498be20
commit 1a6adfd9d3
25 changed files with 312 additions and 1 deletions
+8 -1
View File
@@ -118,8 +118,15 @@ accented `o` and a Danish-first app that cannot do this is broken for its own us
|---|---|
| ![Board view](site/screenshots/board.png) | ![Light theme](site/screenshots/today-light.png) |
| Board view — one column per section. | The light theme. |
| ![A week on Upcoming](site/screenshots/week.png) | ![A task, opened](site/screenshots/detail.png) |
| A week at a time, so a task can be dragged across a month boundary. | A task, opened. |
| ![The five themes](site/screenshots/themes.png) | ![Instance history](site/screenshots/history.png) |
| 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+40
View File
@@ -466,6 +466,46 @@
/>
<figcaption>The light theme.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/week.png"
alt="Upcoming as a single week: seven columns of days, each holding the tasks due that day."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A week at a time — so a task can be dragged across a month boundary.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/detail.png"
alt="A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>A task, opened.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/themes.png"
alt="The settings page showing the five themes as swatches."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>Five themes, all of them measured for contrast.</figcaption>
</figure>
<figure class="shot">
<img
src="screenshots/history.png"
alt="The instance-wide history page: who did what, in which project, filtered by person and event."
width="1400"
height="900"
loading="lazy"
/>
<figcaption>What has been done on the server, across every project.</figcaption>
</figure>
</div>
</section>
Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

+264
View File
@@ -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: '[email protected]', password: 'et langt kodeord' };
// 1400×900 is what the landing page's <img> 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 };
}