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](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 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.
+
+ Upcoming as a single week: seven columns of days, each holding the tasks due that day. +
A week at a time — so a task can be dragged across a month boundary.
+
+
+ A task open in the detail drawer, with its description, due date, labels, sub-tasks and comments. +
A task, opened.
+
+
+ The settings page showing the five themes as swatches. +
Five themes, all of them measured for contrast.
+
+
+ The instance-wide history page: who did what, in which project, filtered by person and event. +
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 }; +}