Skip to content

Commit e0ab40d

Browse files
authored
docs(readme): automate app screenshot (#23)
## What changed - add a credential-free development-only screenshot fixture - add `bun run screenshot` to capture the real UI deterministically - refresh the README screenshot from the selected-subnet fixture - harden menu focus when the toolbar changes scope ## Stack Depends on the UI refresh PR. Review this PR by commit or with its base branch. ## Verification - two captures produced the same SHA-256 - screenshot is 2240x1312 (1120x656 at DPR 2) - `bun run check` - `bun run build` - full Rust fmt, clippy, and 78 library tests
1 parent 61deade commit e0ab40d

10 files changed

Lines changed: 183 additions & 5 deletions

File tree

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ cargo clippy --locked --all-targets --manifest-path src-tauri/Cargo.toml -- -D w
102102
cargo test --locked --lib --manifest-path src-tauri/Cargo.toml
103103
```
104104

105+
Run `bun run screenshot` to refresh `docs/assets/screenshot.png` from the real
106+
page using its credential-free `?screenshot=1` fixture; the command starts and
107+
stops its own Vite server and browser. The 2240 by 1312 image captures the
108+
1120 by 656 webview content at 2x; the remaining 32px of the 1120 by 688 Tauri
109+
window is native title chrome and is not captured.
110+
105111
## Commits
106112

107113
- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):

bun.lock

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/assets/screenshot.png

-6.5 KB
Loading

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"preview": "vite preview",
1010
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
1111
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
12+
"screenshot": "bun scripts/capture-screenshot.ts",
1213
"tauri": "tauri"
1314
},
1415
"license": "MIT",
@@ -27,6 +28,7 @@
2728
"@tailwindcss/vite": "^4.3.3",
2829
"@tauri-apps/cli": "^2",
2930
"@types/node": "^26.3.0",
31+
"playwright": "1.58.2",
3032
"svelte": "^5.0.0",
3133
"svelte-check": "^4.0.0",
3234
"tailwindcss": "^4.3.3",

scripts/capture-screenshot.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mkdir } from "node:fs/promises";
2+
import { dirname, resolve } from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { chromium } from "playwright";
5+
6+
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
7+
const projectRoot = resolve(scriptDirectory, "..");
8+
const port = 1430;
9+
const url = `http://127.0.0.1:${port}/?screenshot=1`;
10+
const output = resolve(projectRoot, "docs/assets/screenshot.png");
11+
const playwrightCli = resolve(projectRoot, "node_modules/playwright/cli.js");
12+
const viteCli = resolve(projectRoot, "node_modules/vite/bin/vite.js");
13+
14+
let server: Bun.Subprocess | undefined;
15+
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
16+
let stopping: Promise<void> | undefined;
17+
18+
async function waitForServer() {
19+
const deadline = Date.now() + 30_000;
20+
while (Date.now() < deadline) {
21+
try {
22+
const response = await fetch(url);
23+
if (response.ok) {
24+
return;
25+
}
26+
} catch {}
27+
await Bun.sleep(100);
28+
}
29+
throw new Error(`Vite did not become ready at ${url}`);
30+
}
31+
32+
async function run(command: string[]) {
33+
const process = Bun.spawn(command, { cwd: projectRoot, stdout: "inherit", stderr: "inherit" });
34+
if ((await process.exited) !== 0) {
35+
throw new Error(`Command failed: ${command.join(" ")}`);
36+
}
37+
}
38+
39+
async function stopServer() {
40+
if (!server || server.exitCode !== null) {
41+
return;
42+
}
43+
server.kill("SIGTERM");
44+
await Promise.race([server.exited.then(() => {}), Bun.sleep(5_000)]);
45+
if (server.exitCode === null) {
46+
server.kill("SIGKILL");
47+
await server.exited;
48+
}
49+
}
50+
51+
function cleanup() {
52+
stopping ??= (async () => {
53+
await browser?.close();
54+
await stopServer();
55+
})();
56+
return stopping;
57+
}
58+
59+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
60+
process.once(signal, () => {
61+
void cleanup().finally(() => process.exit(128));
62+
});
63+
}
64+
65+
try {
66+
await run([process.execPath, playwrightCli, "install", "chromium"]);
67+
server = Bun.spawn(
68+
[process.execPath, viteCli, "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
69+
{ cwd: projectRoot, stdout: "inherit", stderr: "inherit" },
70+
);
71+
await waitForServer();
72+
browser = await chromium.launch();
73+
const context = await browser.newContext({
74+
viewport: { width: 1120, height: 656 },
75+
deviceScaleFactor: 2,
76+
locale: "en-US",
77+
timezoneId: "UTC",
78+
});
79+
const page = await context.newPage();
80+
await page.goto(url, { waitUntil: "domcontentloaded" });
81+
await page.waitForSelector('html[data-screenshot-ready="true"]');
82+
await page.evaluate(async () => {
83+
await document.fonts.ready;
84+
});
85+
await page.addStyleTag({
86+
content: "*, *::before, *::after { animation: none !important; caret-color: transparent !important; transition: none !important; }",
87+
});
88+
await mkdir(dirname(output), { recursive: true });
89+
await page.screenshot({ path: output, animations: "disabled" });
90+
await context.close();
91+
} finally {
92+
await cleanup();
93+
}

src/app.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
declare const __SCREENSHOT_VERSION__: string;

src/lib/screenshot.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { SubnetRow } from "./import";
2+
3+
const probedAt = Date.UTC(2026, 7, 24, 14, 30);
4+
5+
const countries = ["US", "GB", "DE", "CA", "NL", "FR", "JP", "SG", "AU", null];
6+
const tagSets = [
7+
["residential"],
8+
["mobile"],
9+
["checkout"],
10+
[],
11+
["backup"],
12+
["monitoring"],
13+
["europe"],
14+
["retail"],
15+
["stable"],
16+
[],
17+
];
18+
19+
export const screenshotRows: SubnetRow[] = Array.from({ length: 36 }, (_, index) => {
20+
const state = index % 6;
21+
const measured = state !== 5;
22+
const hasMetrics = measured && state !== 4;
23+
const quantity = 24 + ((index * 13) % 77);
24+
const ok = state === 4 ? 0 : measured ? quantity - ((index * 3) % 9) : null;
25+
const base = state === 0 ? 180 : state === 1 ? 720 : state === 2 ? 1840 : 420;
26+
27+
return {
28+
cidr: `198.18.${index}.0/24`,
29+
country: countries[index % countries.length],
30+
quantity,
31+
tags: tagSets[index % tagSets.length],
32+
ok,
33+
connectP50: hasMetrics ? base : null,
34+
connectP95: hasMetrics ? base + 90 : null,
35+
ttfbP50: hasMetrics ? base + 140 : null,
36+
ttfbP95: hasMetrics ? base + 330 : null,
37+
lastRunAt: measured ? probedAt : null,
38+
};
39+
});
40+
41+
export const screenshotSelectedCidrs = new Set(
42+
screenshotRows.slice(0, 3).map((row) => row.cidr),
43+
);

src/lib/ui/MenuSurface.svelte

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { onDestroy, tick, type Snippet } from "svelte";
2+
import { tick, type Snippet } from "svelte";
33
44
let {
55
children,
@@ -34,8 +34,6 @@
3434
});
3535
});
3636
37-
onDestroy(restoreFocus);
38-
3937
function moveFocus(event: KeyboardEvent) {
4038
const items = [...(root?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [])];
4139
const current = items.indexOf(document.activeElement as HTMLButtonElement);

src/routes/+page.svelte

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { open, save } from "@tauri-apps/plugin-dialog";
66
import { openUrl } from "@tauri-apps/plugin-opener";
77
import { getCurrentWebview } from "@tauri-apps/api/webview";
8-
import { onMount } from "svelte";
8+
import { onMount, tick } from "svelte";
99
import type { ImportResult, Progress, RunResult, SubnetRow } from "$lib/import";
1010
import { emptyMetrics, withMetrics } from "$lib/import";
1111
import SubnetTable from "$lib/SubnetTable.svelte";
@@ -46,6 +46,8 @@
4646
});
4747
let noticeTimer: ReturnType<typeof setTimeout> | null = null;
4848
49+
const screenshotMode = import.meta.env.DEV && new URLSearchParams(window.location.search).get("screenshot") === "1";
50+
4951
const locked = $derived(busy || running || updating);
5052
const selectedScope = $derived(selectedCidrs.size > 0 ? [...selectedCidrs] : null);
5153
@@ -57,6 +59,12 @@
5759
}
5860
});
5961
62+
$effect(() => {
63+
if (selectedCidrs.size > 0) {
64+
exportMenuOpen = false;
65+
}
66+
});
67+
6068
function showNotice(message: string, isError: boolean) {
6169
if (noticeTimer) {
6270
clearTimeout(noticeTimer);
@@ -82,6 +90,10 @@
8290
}
8391
8492
onMount(() => {
93+
if (screenshotMode) {
94+
void loadScreenshot();
95+
return;
96+
}
8597
let disposed = false;
8698
let stopDrop = () => {};
8799
let stopProgress = () => {};
@@ -155,6 +167,16 @@
155167
};
156168
});
157169
170+
async function loadScreenshot() {
171+
const { screenshotRows, screenshotSelectedCidrs } = await import("$lib/screenshot");
172+
rows = screenshotRows;
173+
draft = Object.fromEntries(screenshotRows.map((row) => [row.cidr, ""]));
174+
selectedCidrs = new Set(screenshotSelectedCidrs);
175+
version = __SCREENSHOT_VERSION__;
176+
await tick();
177+
document.documentElement.dataset.screenshotReady = "true";
178+
}
179+
158180
function applyProgress(progress: Progress) {
159181
done = progress.done;
160182
total = progress.total;

vite.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { sveltekit } from "@sveltejs/kit/vite";
22
import tailwindcss from "@tailwindcss/vite";
33
import { defineConfig } from "vite";
4+
import packageJson from "./package.json" with { type: "json" };
45

56
const host = process.env.TAURI_DEV_HOST;
67

78
export default defineConfig(async () => ({
89
plugins: [tailwindcss(), sveltekit()],
10+
define: {
11+
__SCREENSHOT_VERSION__: JSON.stringify(packageJson.version),
12+
},
913
clearScreen: false,
1014
server: {
1115
port: 1420,

0 commit comments

Comments
 (0)