Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ just types # Generate frontend/api.d.ts from OpenAPI schema
| `just format-frontend` | Format frontend code with Prettier |
| `just dev` | Run Datasette on port 8004 |
| `just dev-with-hmr` | Run Datasette with HMR + auto-restart on file changes |
| `just shots-fixture` | (Re)build the demo FEC database used for doc screenshots |
| `just shots [names...]` | Regenerate committed doc screenshots → `docs/screenshots/*.png` (Playwright drives a throwaway Datasette against the fixture db) |

## Key Files

Expand Down
14 changes: 14 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@ format:
just format-backend
just format-frontend

# (Re)build the small demo FEC database the doc screenshots run against.
shots-fixture:
bash frontend/scripts/build-fixture.sh

# Regenerate committed doc screenshots → docs/screenshots/*.png. Self-contained:
# builds the frontend, boots a throwaway datasette against the fixture db, drives
# Playwright, then tears it down. Run all, or a subset by name:
# `just shots` / `just shots index contest`.
shots *names:
just frontend
npm --prefix frontend install
npm --prefix frontend exec -- playwright install chromium
node frontend/scripts/screenshots.mjs {{names}}

format-check:
just format-backend-check
just format-frontend-check
Expand Down
Binary file added docs/screenshots/alerts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/candidate.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/committee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/contest.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/filing-day.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/filing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/import.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/index.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/rss.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 4 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,7 @@ dist-ssr
*.sw?

# Vite
.vite
.vite

# Generated screenshot fixture database (rebuild with `just shots-fixture`)
scripts/shot-data/
64 changes: 64 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"format:check": "prettier --check 'src/**/*.{ts,svelte}'"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tsconfig/svelte": "^5.0.6",
"@types/d3": "^7.4.3",
Expand Down
35 changes: 35 additions & 0 deletions frontend/scripts/build-fixture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Build the deterministic demo database the doc screenshots run against.
#
# Unlike the other plugins in the screenshot family (which seed via internal
# plugin APIs), datasette-libfec browses real FEC data, so the "seed" is a small
# fixed SQLite file produced by `libfec export`. We pin a marquee 2024 race plus
# a couple of big PACs, cover-only (no itemizations) so it stays small and fast.
#
# Network + the libfec cache are used the first time; subsequent runs are quick.
# Output is NOT committed (see frontend/.gitignore); regenerate with `just shots-fixture`.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="${1:-$HERE/shot-data/libfec.db}"
mkdir -p "$(dirname "$OUT")"

# Keep downloaded .fec files around so re-runs don't re-fetch.
export LIBFEC_CACHE_DIRECTORY="${LIBFEC_CACHE_DIRECTORY:-$HOME/.cache/libfec}"
mkdir -p "$LIBFEC_CACHE_DIRECTORY"

echo "Building fixture → $OUT"

# 2024 Ohio Senate: Sherrod Brown (D, incumbent) vs Bernie Moreno (R) and the
# rest of the field — gives candidate, contest, and filing pages real content.
echo " • OH-S 2024 (candidates + committees + F3 covers)"
libfec export OH-S --cycle 2024 --cover-only --clobber -o "$OUT"

# Big 2024 PACs — committee pages with F3X covers.
echo " • Fairshake (C00835959) — crypto super PAC"
libfec export C00835959 --cycle 2024 --cover-only -o "$OUT"
echo " • Senate Majority PAC (C00484642)"
libfec export C00484642 --cycle 2024 --cover-only -o "$OUT"

echo "Done. Tables:"
sqlite3 "$OUT" "SELECT ' ' || name FROM sqlite_master WHERE type='table' AND name LIKE 'libfec%';"
89 changes: 89 additions & 0 deletions frontend/scripts/screenshots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Programmatic doc screenshots of datasette-libfec → docs/screenshots/*.png.
//
// SELF-CONTAINED: boots its own throwaway datasette on a fixed port, pointed at
// a small pre-built FEC database (frontend/scripts/build-fixture.sh), drives
// Playwright through the plugin's pages, then tears the server down. One command,
// reproducible — so the committed PNGs only change when the UI actually changes.
//
// Output is committed; the README embeds these, so re-run + commit when the UI
// look changes: `just shots` (or a subset, e.g. `just shots index contest`).
//
// This file is a THIN RUNNER. The harness lives in shots/ (matching the
// datasette-paper / datasette-places convention):
// * shots/config.mjs — constants + out(name)
// * shots/server.mjs — boot/teardown of the throwaway datasette
// * shots/helpers.mjs — STABILITY_CSS / freezeVolatile / makeContext
// * shots/defineShot.mjs — per-shot context → goto → wait → freeze → capture
// * shots/defs/<name>.mjs — ONE FILE PER SHOT, auto-discovered below.
//
// libfec's pages are anonymous, read-only views over the fixture db — no cookies
// or seeding, just open the access gate and browse.
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { mkdir, readdir } from 'node:fs/promises';
import { BASE, OUT, out } from './shots/config.mjs';
import { startServer, stopServer } from './shots/server.mjs';

const HERE = dirname(fileURLToPath(import.meta.url));

// Auto-discover the shots: every shots/defs/<name>.mjs default-exports a
// defineShot() descriptor. The file name IS the shot id — asserted below so a
// typo can't silently mis-name an output PNG. No central registry to edit.
async function discoverShots() {
const dir = resolve(HERE, 'shots/defs');
const files = (await readdir(dir)).filter((f) => f.endsWith('.mjs'));
const shots = [];
for (const f of files) {
const name = f.replace(/\.mjs$/, '');
const mod = await import(resolve(dir, f));
const run = mod.default;
if (typeof run !== 'function') {
throw new Error(`shots/defs/${f} must default-export a defineShot() descriptor`);
}
if (run.shotName !== name) {
throw new Error(`shots/defs/${f}: declared name "${run.shotName}" != file name "${name}"`);
}
shots.push({ name, run, order: run.order ?? 0 });
}
shots.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
return shots;
}

async function main() {
const requested = new Set(process.argv.slice(2));

const shots = await discoverShots();
const names = shots.map((s) => s.name);
const unknown = [...requested].filter((n) => !names.includes(n));
if (unknown.length) {
throw new Error(`unknown shot(s): ${unknown.join(', ')} (have: ${names.join(', ')})`);
}
const todo = requested.size ? shots.filter((s) => requested.has(s.name)) : shots;

await mkdir(OUT, { recursive: true });
console.log(`booting datasette on ${BASE} …`);
const server = await startServer();
const onSignal = () => {
stopServer(server);
process.exit(130);
};
process.once('SIGINT', onSignal);
process.once('SIGTERM', onSignal);

const browser = await chromium.launch();
try {
for (const { name, run } of todo) {
await run(browser, {});
console.log(`✓ ${name} → ${out(name)}`);
}
} finally {
await browser.close();
stopServer(server);
}
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
35 changes: 35 additions & 0 deletions frontend/scripts/shots/config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Shared constants for the doc-screenshot harness. Imported by every other
// shots/*.mjs module and by each shot in shots/defs/.
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

// Free high port unique to this plugin (others in the family: paper 8486,
// sheets 8487, town 8489, skill-default 8490).
export const PORT = Number(process.env.SHOTS_PORT || 8491);
export const BASE = `http://localhost:${PORT}`;
// datasette database name == the data-db filename stem.
export const DB_NAME = 'data';
// The plugin's base UI path; each shot builds its per-page URL off this.
export const APP = `${BASE}/${DB_NAME}/-/libfec`;

// Fixed --secret for the throwaway server; stable so runs stay reproducible.
export const SECRET = 'screenshots-secret-not-for-prod';

const HERE = dirname(fileURLToPath(import.meta.url)); // frontend/scripts/shots
// Pre-built demo FEC database (run `just shots-fixture` to (re)generate). Unlike
// the other plugins in this family, libfec's data is a real FEC fixture .db, not
// something a startup plugin can seed — so this is the "seed".
export const FIXTURE_DB = resolve(HERE, '../shot-data/libfec.db');
export const DATA_DIR = '/tmp/datasette-libfec-shots-data'; // stem == DB_NAME
export const DATA_DB = `${DATA_DIR}/${DB_NAME}.db`;
export const OUT = resolve(HERE, '../../../docs/screenshots');

export const VIEWPORT = { width: 1200, height: 900 };

// --- Concrete demo entities (from the fixture: 2024 Ohio Senate + big PACs) ---
export const SHERROD_BROWN = 'S6OH00163'; // candidate id
export const FAIRSHAKE = 'C00835959'; // crypto super PAC committee id
export const BROWN_YE_FILING = '1870297'; // Friends of Sherrod Brown, F3 YE 2024

// Absolute path of a shot's output PNG.
export const out = (name) => resolve(OUT, `${name}.png`);
52 changes: 52 additions & 0 deletions frontend/scripts/shots/defineShot.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// defineShot turns a declarative descriptor into the `async (browser, ids) =>
// {…}` the runner calls, owning the per-shot boilerplate: open a fresh context →
// navigate → wait for readiness → interact → freeze → capture → close.
//
// Descriptor fields:
// name (required) — output PNG base name; MUST equal the shot's file name
// (asserted by the runner).
// order — run sequence (ascending, then by name). libfec shots are
// independent, so this just fixes a stable order.
// url (required) — (ids) => string, the page to screenshot.
// viewport — context viewport (default VIEWPORT).
// ready — a selector waited on after navigation (the common case).
// prepare — async (page, {ids}) for custom waits / interaction.
// capture — async (page, file, {ids}); default = full-page screenshot.
// freeze — default true; runs freezeVolatile before capture.
import { makeContext, freezeVolatile } from './helpers.mjs';
import { VIEWPORT, out } from './config.mjs';

export function defineShot(desc) {
const {
name,
order = 0,
url,
viewport = VIEWPORT,
ready,
prepare,
capture,
freeze = true,
} = desc;
if (!name) throw new Error('defineShot: missing `name`');
if (typeof url !== 'function') {
throw new Error(`defineShot(${name}): \`url\` must be a function`);
}

const run = async (browser, ids) => {
const ctx = await makeContext(browser, { viewport });
try {
const page = await ctx.newPage();
await page.goto(url(ids), { waitUntil: 'networkidle' });
if (ready) await page.locator(ready).first().waitFor({ timeout: 15_000 });
if (prepare) await prepare(page, { ids });
if (freeze) await freezeVolatile(page);
if (capture) await capture(page, out(name), { ids });
else await page.screenshot({ path: out(name) });
} finally {
await ctx.close();
}
};
run.shotName = name;
run.order = order;
return run;
}
10 changes: 10 additions & 0 deletions frontend/scripts/shots/defs/alerts.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineShot } from '../defineShot.mjs';
import { APP } from '../config.mjs';

// Alerts: build watchlists that notify on new filings / contributors.
export default defineShot({
name: 'alerts',
order: 9,
url: () => `${APP}/alerts`,
ready: '.create-section form',
});
10 changes: 10 additions & 0 deletions frontend/scripts/shots/defs/candidate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineShot } from '../defineShot.mjs';
import { APP, SHERROD_BROWN } from '../config.mjs';

// A candidate: Sherrod Brown.
export default defineShot({
name: 'candidate',
order: 3,
url: () => `${APP}/candidate/${SHERROD_BROWN}?cycle=2024`,
ready: '.candidate-page .filings-table',
});
10 changes: 10 additions & 0 deletions frontend/scripts/shots/defs/committee.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineShot } from '../defineShot.mjs';
import { APP, FAIRSHAKE } from '../config.mjs';

// A committee: Fairshake super PAC.
export default defineShot({
name: 'committee',
order: 4,
url: () => `${APP}/committee/${FAIRSHAKE}?cycle=2024`,
ready: '.committee-page .filings-table',
});
10 changes: 10 additions & 0 deletions frontend/scripts/shots/defs/contest.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineShot } from '../defineShot.mjs';
import { APP } from '../config.mjs';

// A race: 2024 Ohio Senate (Brown vs Moreno + field).
export default defineShot({
name: 'contest',
order: 2,
url: () => `${APP}/contest?state=OH&office=S&cycle=2024`,
ready: '.candidates-table tbody tr',
});
Loading
Loading