diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b2c132..11e5a1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm
No changes recorded yet.
+## 1.20.1 - 2026-09-12
+
+### Changed
+
+- Refreshed the in-app Guide screenshot (`docs/screenshots/guide.jpg`) to show its current content (the new Schedule and Video guides sections, missing from the old capture) and added a new screenshot of Event Manager's tabbed layout (`race-manage-tabs.jpg`), linked from both READMEs. Added `scripts/capture-screenshots.mjs`, a reusable capture tool built on the same isolated-server pattern as the video/test tooling.
+
## 1.20.0 - 2026-09-11
### Added
diff --git a/README.md b/README.md
index 555720c..a0e9d97 100644
--- a/README.md
+++ b/README.md
@@ -80,7 +80,7 @@ Current layouts for race operation, live presentation, training and system confi
 In-app setup guide |
- |
+  Event Manager's Live & results tab |
diff --git a/README.sv.md b/README.sv.md
index 72914a1..7d20191 100644
--- a/README.sv.md
+++ b/README.sv.md
@@ -78,7 +78,7 @@ Aktuella layouter för racedrift, livevisning, träning och systemkonfiguration.
 Inbyggd uppstartsguide |
- |
+  Event Managers flik Live & resultat |
diff --git a/ROADMAP.md b/ROADMAP.md
index f27ed45..75aabd0 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -308,6 +308,16 @@ Requested 2026-09-10 by the user: "create screenshots and update the images in t
- [x] Redacted incidental local-machine details that don't belong in a public screenshot before capturing (the dev machine's resolved AMMC executable path, and the ephemeral test server's random localhost ports) by overwriting just those specific form fields/text nodes in the DOM right before each shot - cosmetic only, never saved back to any backend.
- [x] While building this data, found and fixed a real, previously-invisible formatting bug (see the entry directly above) purely as a side effect of finally exercising the app with real results for the first time.
+### Follow-up 2026-09-12: `guide.jpg` had gone stale again, and Event Manager's new tabs were never pictured
+
+Requested 2026-09-12 by the user alongside the documentation pass below: make sure the screenshots match the layouts shipped this session. Checked every screenshot against the current UI first rather than assuming - `decoder-settings.jpg`, `race-audio-settings.jpg` and `managed-ammc.jpg` (all Settings-tab shots) were already current, since Settings' tabs predate this session and were captured fresh for the entry above. Only two were actually stale/missing:
+
+- [x] `guide.jpg` still showed the pre-Schedule-feature, pre-video-guides Guide page (6 overview cards, no "Video guides" section) - recaptured.
+- [x] Event Manager's new tabbed layout (this session's own work) had never been pictured at all - added `race-manage-tabs.jpg`, scrolled to the tab bar with "Live & results" active (grid editor + qualifying standings visible), and a new row in both README screenshot tables.
+- [x] Built `scripts/capture-screenshots.mjs` (new, reusable) rather than a throwaway script this time, since screenshot refreshes are clearly a recurring need - same isolated-server-plus-headless-Chrome pattern as the video recorder and test suite, sharing one seeded demo dataset (`JMK RB`, "Klubbmästare Race 4", matching the existing screenshots' branding) across every shot so future captures stay visually consistent with each other.
+
+Verified 2026-09-12: visually inspected both new captures at full resolution (3200x1800, matching every other screenshot in the folder) - `guide.jpg` shows all 8 overview cards and the Video guides section with all 7 working links; `race-manage-tabs.jpg` clearly shows the 4-tab bar with real grid/standings content beneath it. Waited for the settings save the seeded decoder connection triggers to finish before capturing (`syncStatus` reaching `"saved"`), so neither shot shows a stray "Changes waiting to save" banner mid-capture the way an early attempt did.
+
## Race clock direction: counts down for timed sessions, up for open practice
Asked 2026-09-10 by the user: when running a race, should the clock count down from the target duration to 0 (not up from 0)? Only open practice should just roll on with no limit. Checked against the actual code (`getSessionTiming` in `src/timing_logic.js`, and its use in the Timing view, Race Day view, and all Overlay pages): already correct, no changes made.
diff --git a/docs/screenshots/guide.jpg b/docs/screenshots/guide.jpg
index 878f721..9ff16e3 100644
Binary files a/docs/screenshots/guide.jpg and b/docs/screenshots/guide.jpg differ
diff --git a/docs/screenshots/race-manage-tabs.jpg b/docs/screenshots/race-manage-tabs.jpg
new file mode 100644
index 0000000..0a7ec4d
Binary files /dev/null and b/docs/screenshots/race-manage-tabs.jpg differ
diff --git a/package-lock.json b/package-lock.json
index 151ee51..c3d42cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rc-racecore",
- "version": "1.20.0",
+ "version": "1.20.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rc-racecore",
- "version": "1.20.0",
+ "version": "1.20.1",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.11.1",
diff --git a/package.json b/package.json
index 3fdd85f..7a63234 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rc-racecore",
- "version": "1.20.0",
+ "version": "1.20.1",
"private": true,
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
"engines": {
diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs
new file mode 100644
index 0000000..9081e07
--- /dev/null
+++ b/scripts/capture-screenshots.mjs
@@ -0,0 +1,190 @@
+// Captures README screenshots (docs/screenshots/*.jpg) against a real, isolated instance
+// of the app - own temp SQLite dir, own headless Chrome profile, exactly like the
+// tests/browser_*.js suite and scripts/record-video-guides.mjs. Nothing here ever touches
+// a real installation's data.
+//
+// Usage: node scripts/capture-screenshots.mjs [shot-name ...]
+// With no arguments, captures every shot below. Pass one or more names (e.g. "guide")
+// to capture only those. Output overwrites docs/screenshots/.jpg.
+//
+// Captured at 1600x900 CSS with deviceScaleFactor 2 (3200x1800 JPEG), matching every
+// existing screenshot in that folder - a plain viewport capture, not a full-page stitch,
+// so a shot's setup should scroll to whatever should be visible before capturing.
+
+import { spawn, execFileSync } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
+import { createServer } from "node:net";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { startFakeDecoder } from "../tests/browser_fake_decoder.js";
+
+const projectDir = dirname(dirname(fileURLToPath(import.meta.url)));
+const outputDir = join(projectDir, "docs", "screenshots");
+const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && existsSync(p));
+if (!chrome) throw new Error("Chrome/Chromium not found. Set CHROME_PATH to the browser executable.");
+
+const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+async function waitFor(check, label, timeout = 15000) {
+ const until = Date.now() + timeout;
+ while (Date.now() < until) {
+ try { if (await check()) return; } catch {}
+ await pause(100);
+ }
+ throw new Error("Timed out: " + label);
+}
+
+async function freePort() {
+ const server = createServer();
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
+ const port = server.address().port;
+ await new Promise((resolve) => server.close(resolve));
+ return port;
+}
+
+// Demo data shared by every shot, so screenshots taken at different times still look like
+// one consistent club/session - matching the existing screenshots' "JMK RB" branding and
+// active-session header.
+function seedDemoState(state, decoderPort) {
+ state.settings.wsUrl = `ws://127.0.0.1:${decoderPort}`;
+ state.settings.autoReconnect = true;
+ const classId = "shot_class";
+ const driverA = { id: "shot_driver_a", name: "Alex Karlsson", classId, transponder: "7960001" };
+ const driverB = { id: "shot_driver_b", name: "Sam Nilsson", classId, transponder: "7960002" };
+ const driverC = { id: "shot_driver_c", name: "Jamie Lee", classId, transponder: "7960003" };
+ const driverD = { id: "shot_driver_d", name: "Robin Berg", classId, transponder: "7960004" };
+ const raceEvent = {
+ id: "shot_event", name: "Klubbmästare Race 4", classId, mode: "race",
+ raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id, driverC.id, driverD.id], teams: [], minLapMs: 3000, maxLapMs: 60000 },
+ };
+ const practice = { id: "shot_practice", eventId: raceEvent.id, name: "Practice 1", type: "practice", durationMin: 10, status: "finished", startedAt: Date.now() - 3600000, endedAt: Date.now() - 3000000 };
+ const qualifying = { id: "shot_quali", eventId: raceEvent.id, name: "Qualification 1", type: "qualification", startMode: "position", durationMin: 5, status: "finished", startedAt: Date.now() - 2400000, endedAt: Date.now() - 2100000 };
+ state.classes.push({ id: classId, name: "2WD Buggy" });
+ state.drivers.push(driverA, driverB, driverC, driverD);
+ state.events.push(raceEvent);
+ state.sessions.push(practice, qualifying);
+ state.activeSessionId = qualifying.id;
+ state.settings.theme = "light";
+ state.settings.clubName = "JMK RB";
+ state.settings.clubTagline = "Live Timing & Race Control";
+}
+
+async function captureShot(name, { setup }) {
+ const temporary = mkdtempSync(join(tmpdir(), "racecore-shot-"));
+ const children = [];
+ let socket;
+ const decoder = await startFakeDecoder();
+ const launch = (command, args, options = {}) => {
+ const child = spawn(command, args, { cwd: projectDir, stdio: "ignore", ...options });
+ children.push(child);
+ child.on("error", (e) => console.error(e.message));
+ return child;
+ };
+ try {
+ const port = await freePort();
+ const debugPort = await freePort();
+ const base = `http://127.0.0.1:${port}`;
+ launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } });
+ await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server");
+
+ const initial = {
+ classes: [], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {},
+ settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } },
+ training: {},
+ };
+ seedDemoState(initial, decoder.port);
+ await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) });
+
+ launch(chrome, [
+ "--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking",
+ ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []),
+ "--window-size=1600,900", `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank",
+ ]);
+ let target;
+ await waitFor(async () => {
+ target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find((p) => p.type === "page");
+ return target;
+ }, "headless browser");
+
+ socket = new WebSocket(target.webSocketDebuggerUrl);
+ await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); });
+ let nextId = 0;
+ const requests = new Map();
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(event.data);
+ if (message.id) {
+ const request = requests.get(message.id);
+ requests.delete(message.id);
+ if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result);
+ }
+ });
+ function send(method, params = {}) {
+ return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
+ }
+ async function evaluate(expression) {
+ const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || JSON.stringify(result.exceptionDetails));
+ return result.result.value;
+ }
+ async function navigate(view, readySelector) {
+ await send("Page.navigate", { url: base + "/?view=" + view });
+ await waitFor(async () =>
+ Boolean(await evaluate(`document.querySelector(${JSON.stringify(readySelector)})`)) &&
+ (await evaluate(`document.getElementById("syncStatusDetail")?.textContent`) || "").includes("Data loaded"),
+ view + " hydrated");
+ // Match the "Decoder Online" state every other screenshot already shows, and let any
+ // save the connection triggers finish so the sidebar doesn't show a stray "Changes
+ // waiting to save" banner in the capture.
+ await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
+ await waitFor(async () => (await evaluate('document.getElementById("syncStatus")?.dataset.state')) === "saved", "settings saved");
+ }
+ const click = (selector) => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
+ const scrollTo = (selector, block = "start") => evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block: ${JSON.stringify(block)}})`);
+
+ await send("Runtime.enable");
+ await send("Page.enable");
+ await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 2, mobile: false });
+
+ await setup({ evaluate, navigate, click, scrollTo, pause, waitFor });
+ await pause(300); // let any final CSS transition/re-render settle
+
+ const { data } = await send("Page.captureScreenshot", { format: "jpeg", quality: 90 });
+ mkdirSync(outputDir, { recursive: true });
+ const outputPath = join(outputDir, `${name}.jpg`);
+ writeFileSync(outputPath, Buffer.from(data, "base64"));
+ console.log(`[${name}] wrote ${outputPath}`);
+ } finally {
+ try { socket?.close(); } catch {}
+ for (const child of children.reverse()) { try { child.kill(); } catch {} }
+ await pause(300);
+ try { await decoder.close(); } catch {}
+ rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
+ }
+}
+
+const shots = {
+ guide: {
+ async setup({ navigate }) {
+ await navigate("guide", ".guide-list");
+ },
+ },
+ "race-manage-tabs": {
+ async setup({ navigate, click, scrollTo, waitFor: waitForFn, evaluate, pause: pauseFn }) {
+ await navigate("race_setup", "#raceWizardStepForm");
+ await click("#event-manage-shot_event");
+ await waitForFn(async () => Boolean(await evaluate('document.querySelector(".manage-tabs")')), "manage tabs rendered");
+ await click('[data-manage-tab="live"]');
+ await scrollTo(".manage-tabs");
+ await pauseFn(200);
+ },
+ },
+};
+
+const requested = process.argv.slice(2);
+const names = requested.length ? requested : Object.keys(shots);
+for (const name of names) {
+ const shot = shots[name];
+ if (!shot) { console.error(`Unknown shot "${name}". Available: ${Object.keys(shots).join(", ")}`); process.exitCode = 1; continue; }
+ await captureShot(name, shot);
+}