fixture: serve static pages instead of the Tally app
The app's state existed only to power the click round-trip and `post's
visible effect; a client-side toggle and a stateless echo cover both, so
any static directory — a generated site, later — can stand in as the app.

Assisted-by: GLM-5.3 via pi
change ywmsvpxqyyrlttltrwmomkwqsmtxomnz
commit f3abb4644364f4396726a8f08c481451e2f904c9
author Alpha Chen <alpha@kejadlen.dev>
date
parent nwzvrnsw
diff --git a/Cargo.lock b/Cargo.lock
index 828fba6..5256fbd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -463,6 +463,12 @@ version = "0.3.34"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
 
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
 [[package]]
 name = "futures-task"
 version = "0.3.34"
@@ -554,6 +560,12 @@ dependencies = [
  "pin-project-lite",
 ]
 
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
 [[package]]
 name = "httparse"
 version = "1.10.1"
@@ -899,6 +911,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.9"
@@ -1057,12 +1079,11 @@ version = "0.1.0"
 dependencies = [
  "axum",
  "fs-err",
- "miette",
  "serde",
  "serde_json",
  "tempfile",
- "thiserror",
  "tokio",
+ "tower-http",
  "ureq",
 ]
 
@@ -1646,6 +1667,19 @@ dependencies = [
  "syn 3.0.6",
 ]
 
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
 [[package]]
 name = "tower"
 version = "0.5.3"
@@ -1662,6 +1696,31 @@ dependencies = [
  "tracing",
 ]
 
+[[package]]
+name = "tower-http"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+]
+
 [[package]]
 name = "tower-layer"
 version = "0.3.3"
@@ -1742,6 +1801,12 @@ version = "1.0.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
 
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.26"
diff --git a/Cargo.toml b/Cargo.toml
index 2d1b571..f963134 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,7 +33,7 @@ tempfile = "*"
 workspace = true
 
 [workspace]
-members = [".", "fixtures/app"]
+members = [".", "fixtures/serve"]
 
 [workspace.lints.clippy]
 self_named_module_files = "warn"
diff --git a/fixtures/app/src/lib.rs b/fixtures/app/src/lib.rs
deleted file mode 100644
index bce6c48..0000000
--- a/fixtures/app/src/lib.rs
+++ /dev/null
@@ -1,445 +0,0 @@
-//! A small task tracker that parallax's integration tests and demos run
-//! against. It speaks the handshake from the design doc and keeps its
-//! state in memory, so every process starts from the same seed.
-
-use std::fmt::Write as _;
-use std::io;
-use std::net::Ipv4Addr;
-use std::path::Path;
-use std::path::PathBuf;
-use std::sync::Arc;
-
-use axum::Json;
-use axum::Router;
-use axum::extract::Path as UrlPath;
-use axum::extract::Query;
-use axum::extract::State;
-use axum::http::StatusCode;
-use axum::response::Html;
-use axum::response::IntoResponse;
-use axum::response::Redirect;
-use axum::response::Response;
-use axum::routing::get;
-use axum::routing::post;
-use serde::Deserialize;
-use serde::Serialize;
-use serde_json::json;
-use tokio::net::TcpListener;
-use tokio::sync::Mutex;
-
-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-pub enum Error {
-    #[error("could not bind a local port")]
-    Bind(#[source] io::Error),
-
-    #[error("could not write the handshake")]
-    #[diagnostic(help("PARALLAX_HANDSHAKE must name a file in a writable directory"))]
-    Handshake(#[source] io::Error),
-
-    #[error("the server stopped")]
-    Serve(#[source] io::Error),
-}
-
-#[derive(Clone, Debug, Serialize)]
-pub struct Item {
-    pub id: u32,
-    pub title: String,
-    pub tag: String,
-    pub notes: String,
-    pub done: bool,
-}
-
-/// The id of the seeded item the handshake points flows at.
-const FEATURED: u32 = 2;
-
-fn seed() -> Vec<Item> {
-    let item = |id, title: &str, tag: &str, notes: &str, done| Item {
-        id,
-        title: title.to_owned(),
-        tag: tag.to_owned(),
-        notes: notes.to_owned(),
-        done,
-    };
-    vec![
-        item(
-            1,
-            "Water the ferns",
-            "home",
-            "The big one by the window drinks twice as much.",
-            false,
-        ),
-        item(
-            2,
-            "Sharpen the kitchen knives",
-            "home",
-            "Whetstone is under the sink. Coarse side first, then fine, then strop on the old belt.",
-            false,
-        ),
-        item(
-            3,
-            "Renew library card",
-            "errands",
-            "Bring proof of address.",
-            false,
-        ),
-        item(4, "Back up the photo library", "computer", "", true),
-        item(
-            5,
-            "Call about the bike tune-up",
-            "errands",
-            "Ask whether they have the 11-speed chain in stock.",
-            false,
-        ),
-    ]
-}
-
-type Items = Arc<Mutex<Vec<Item>>>;
-
-/// Binds an ephemeral port, reports it through the handshake file
-/// when one is given, and serves until the process is killed.
-pub async fn run(handshake: Option<PathBuf>) -> Result<(), Error> {
-    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
-        .await
-        .map_err(Error::Bind)?;
-    let addr = listener.local_addr().map_err(Error::Bind)?;
-    let url = format!("http://{addr}");
-    match handshake {
-        Some(path) => write_handshake(&path, &url).map_err(Error::Handshake)?,
-        None => println!("{url}"),
-    }
-    axum::serve(listener, router()).await.map_err(Error::Serve)
-}
-
-/// Writes through a sibling temporary file and renames it, so a reader
-/// polling for the path never sees a partial file.
-fn write_handshake(path: &Path, url: &str) -> io::Result<()> {
-    let body = json!({"url": url, "data": {"featured_item": FEATURED}});
-    let mut tmp = path.as_os_str().to_owned();
-    tmp.push(".tmp");
-    fs_err::write(&tmp, body.to_string())?;
-    fs_err::rename(&tmp, path)
-}
-
-pub fn router() -> Router {
-    let items: Items = Arc::new(Mutex::new(seed()));
-    Router::new()
-        .route("/", get(dashboard))
-        .route("/items/{id}", get(item_detail))
-        .route("/items/{id}/complete", post(toggle_item))
-        .route("/broken/{kind}", get(broken))
-        .route("/__fixtures/items", post(create_item))
-        .fallback(not_found)
-        .with_state(items)
-}
-
-#[derive(Deserialize)]
-struct DashboardQuery {
-    toggled: Option<u32>,
-}
-
-async fn dashboard(
-    State(items): State<Items>,
-    Query(query): Query<DashboardQuery>,
-) -> Html<String> {
-    let items = items.lock().await;
-    let mut body = String::new();
-
-    if let Some(item) = query
-        .toggled
-        .and_then(|id| items.iter().find(|i| i.id == id))
-    {
-        let verb = if item.done { "Completed" } else { "Reopened" };
-        let _ = write!(
-            body,
-            r#"<p class="flash-bar" role="status">{verb} “{}”.</p>"#,
-            escape(&item.title)
-        );
-    }
-
-    body.push_str(r#"<div class="dashboard"><section class="item-list" aria-label="Tasks"><ul>"#);
-    for item in items.iter() {
-        let (state, label) = if item.done {
-            ("done", "Reopen")
-        } else {
-            ("open", "Complete")
-        };
-        let _ = write!(
-            body,
-            r#"<li class="item {state}" data-id="{id}">
-<a href="/items/{id}">{title}</a>
-<span class="tag">{tag}</span>
-<form method="post" action="/items/{id}/complete"><button class="complete-btn">{label}</button></form>
-</li>"#,
-            id = item.id,
-            title = escape(&item.title),
-            tag = escape(&item.tag),
-        );
-    }
-    body.push_str("</ul></section>");
-
-    let open = items.iter().filter(|i| !i.done).count();
-    let done = items.iter().filter(|i| i.done).count();
-    let _ = write!(
-        body,
-        r#"<aside class="summary"><h2>Summary</h2><dl>
-<div><dt>Open</dt><dd>{open}</dd></div>
-<div><dt>Done</dt><dd>{done}</dd></div>
-</dl></aside></div>"#
-    );
-
-    page("Tasks", &body)
-}
-
-async fn item_detail(State(items): State<Items>, UrlPath(id): UrlPath<u32>) -> Response {
-    let items = items.lock().await;
-    let Some(item) = items.iter().find(|i| i.id == id) else {
-        return not_found().await.into_response();
-    };
-    let status = if item.done { "Done" } else { "Open" };
-    let notes = if item.notes.is_empty() {
-        r#"<p class="empty">No notes.</p>"#.to_owned()
-    } else {
-        format!("<p>{}</p>", escape(&item.notes))
-    };
-    let body = format!(
-        r#"<article class="item-detail">
-<p><a href="/">← All tasks</a></p>
-<h2>{title}</h2>
-<dl><div><dt>Status</dt><dd>{status}</dd></div><div><dt>Tag</dt><dd class="tag">{tag}</dd></div></dl>
-{notes}
-</article>"#,
-        title = escape(&item.title),
-        tag = escape(&item.tag),
-    );
-    page(&item.title, &body).into_response()
-}
-
-async fn toggle_item(State(items): State<Items>, UrlPath(id): UrlPath<u32>) -> Response {
-    let mut items = items.lock().await;
-    let Some(item) = items.iter_mut().find(|i| i.id == id) else {
-        return not_found().await.into_response();
-    };
-    item.done = !item.done;
-    Redirect::to(&format!("/?toggled={id}")).into_response()
-}
-
-/// Pages that misbehave on purpose, so tests can check that parallax
-/// fails a flow on each kind of page error.
-async fn broken(UrlPath(kind): UrlPath<String>) -> Response {
-    let script = match kind.as_str() {
-        "console-error" => r#"console.error("fixture: deliberate console error");"#,
-        "console-warning" => r#"console.warn("fixture: deliberate console warning");"#,
-        "exception" => r#"throw new Error("fixture: deliberate uncaught exception");"#,
-        _ => return not_found().await.into_response(),
-    };
-    let body = format!(
-        r#"<p class="broken">This page misbehaves on purpose.</p><script>{script}</script>"#
-    );
-    page("Broken", &body).into_response()
-}
-
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct NewItem {
-    title: String,
-    #[serde(default)]
-    tag: String,
-    #[serde(default)]
-    notes: String,
-}
-
-/// Lets flows add state outside the UI, standing in for the fixture
-/// routes a real app mounts for parallax's `post` helper.
-async fn create_item(State(items): State<Items>, Json(new): Json<NewItem>) -> Response {
-    if new.title.trim().is_empty() {
-        return (
-            StatusCode::UNPROCESSABLE_ENTITY,
-            Json(json!({"error": "title must not be empty"})),
-        )
-            .into_response();
-    }
-    let mut items = items.lock().await;
-    let Some(id) = items.iter().map(|i| i.id).max().unwrap_or(0).checked_add(1) else {
-        return (
-            StatusCode::INSUFFICIENT_STORAGE,
-            Json(json!({"error": "out of ids"})),
-        )
-            .into_response();
-    };
-    let item = Item {
-        id,
-        title: new.title,
-        tag: new.tag,
-        notes: new.notes,
-        done: false,
-    };
-    items.push(item.clone());
-    (StatusCode::CREATED, Json(item)).into_response()
-}
-
-async fn not_found() -> (StatusCode, Html<String>) {
-    (
-        StatusCode::NOT_FOUND,
-        page(
-            "Not found",
-            r#"<p class="not-found">Nothing lives here. <a href="/">Back to tasks</a>.</p>"#,
-        ),
-    )
-}
-
-fn page(title: &str, body: &str) -> Html<String> {
-    Html(format!(
-        r#"<!doctype html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{title} · Tally</title>
-<style>{STYLE}</style>
-</head>
-<body>
-<header class="masthead"><h1><a href="/">Tally</a></h1></header>
-<main>{body}</main>
-</body>
-</html>"#,
-        title = escape(title),
-    ))
-}
-
-fn escape(text: &str) -> String {
-    let mut out = String::with_capacity(text.len());
-    for c in text.chars() {
-        match c {
-            '&' => out.push_str("&amp;"),
-            '<' => out.push_str("&lt;"),
-            '>' => out.push_str("&gt;"),
-            '"' => out.push_str("&quot;"),
-            '\'' => out.push_str("&#39;"),
-            _ => out.push(c),
-        }
-    }
-    out
-}
-
-/// Type and space steps are Utopia's defaults: 18px at a 320px viewport
-/// with a 1.2 ratio, up to 20px at 1240px with a 1.25 ratio. System
-/// fonts only, so screenshots don't wait on a network.
-const STYLE: &str = r#"
-:root {
-  --step--1: clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem);
-  --step-0: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
-  --step-1: clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem);
-  --step-2: clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem);
-  --space-2xs: clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem);
-  --space-xs: clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem);
-  --space-s: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
-  --space-m: clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem);
-  --space-l: clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem);
-
-  --bg: #f7f5f0;
-  --surface: #ffffff;
-  --text: #1f1d1a;
-  --muted: #6b665e;
-  --border: #e2ddd3;
-  --accent: #2f6f4f;
-  --accent-text: #ffffff;
-  --flash: #e3f1e8;
-
-  color-scheme: light dark;
-}
-@media (prefers-color-scheme: dark) {
-  :root {
-    --bg: #181715;
-    --surface: #23211e;
-    --text: #ece8e1;
-    --muted: #a39d93;
-    --border: #3a3732;
-    --accent: #7cc49b;
-    --accent-text: #10251a;
-    --flash: #1f3a2b;
-  }
-}
-*, *::before, *::after { box-sizing: border-box; }
-body {
-  margin: 0;
-  background: var(--bg);
-  color: var(--text);
-  font: var(--step-0)/1.5 system-ui, sans-serif;
-}
-a { color: inherit; }
-.masthead {
-  padding: var(--space-s) var(--space-m);
-  border-bottom: 1px solid var(--border);
-}
-.masthead h1 { margin: 0; font-size: var(--step-2); letter-spacing: -0.02em; }
-.masthead a { text-decoration: none; }
-main { max-width: 64rem; margin: 0 auto; padding: var(--space-m); }
-h2 { font-size: var(--step-1); margin: 0 0 var(--space-xs); }
-.flash-bar {
-  margin: 0 0 var(--space-m);
-  padding: var(--space-xs) var(--space-s);
-  background: var(--flash);
-  border-radius: 0.5rem;
-}
-.dashboard { display: flex; flex-wrap: wrap; gap: var(--space-m); align-items: flex-start; }
-.item-list { flex: 999 1 28rem; }
-.summary { flex: 1 1 14rem; }
-.item-list ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2xs); }
-.item {
-  display: flex;
-  align-items: center;
-  gap: var(--space-xs);
-  padding: var(--space-xs) var(--space-s);
-  background: var(--surface);
-  border: 1px solid var(--border);
-  border-radius: 0.5rem;
-}
-.item > a { flex: 1; text-decoration: none; }
-.item.done > a { color: var(--muted); text-decoration: line-through; }
-.item form { margin: 0; }
-.tag {
-  font-size: var(--step--1);
-  color: var(--muted);
-}
-.complete-btn {
-  font: inherit;
-  font-size: var(--step--1);
-  padding: 0.25em 0.75em;
-  border: 1px solid var(--accent);
-  border-radius: 999px;
-  background: var(--accent);
-  color: var(--accent-text);
-  cursor: pointer;
-}
-.item.done .complete-btn { background: transparent; color: var(--accent); }
-.summary, .item-detail {
-  padding: var(--space-s);
-  background: var(--surface);
-  border: 1px solid var(--border);
-  border-radius: 0.5rem;
-}
-dl { margin: 0; display: grid; gap: var(--space-2xs); }
-dl > div { display: flex; justify-content: space-between; gap: var(--space-s); }
-dt { color: var(--muted); }
-dd { margin: 0; font-weight: 600; }
-.item-detail dl { margin-bottom: var(--space-s); }
-.empty { color: var(--muted); font-style: italic; }
-"#;
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn escapes_markup() {
-        assert_eq!(
-            escape(r#"<a href="x">'&'</a>"#),
-            "&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;"
-        );
-    }
-
-    #[test]
-    fn featured_item_is_seeded() {
-        assert!(seed().iter().any(|i| i.id == FEATURED));
-    }
-}
diff --git a/fixtures/app/src/main.rs b/fixtures/app/src/main.rs
deleted file mode 100644
index f69c610..0000000
--- a/fixtures/app/src/main.rs
+++ /dev/null
@@ -1,6 +0,0 @@
-#[tokio::main]
-async fn main() -> miette::Result<()> {
-    miette::set_panic_hook();
-    parallax_fixture::run(std::env::var_os("PARALLAX_HANDSHAKE").map(Into::into)).await?;
-    Ok(())
-}
diff --git a/fixtures/app/parallax.fnl b/fixtures/parallax.fnl
similarity index 76%
rename from fixtures/app/parallax.fnl
rename to fixtures/parallax.fnl
index c63989d..d1c1101 100644
--- a/fixtures/app/parallax.fnl
+++ b/fixtures/parallax.fnl
@@ -1,5 +1,5 @@
-;; Demo config for the fixture app. Run from anywhere in the workspace:
-;; parallax run --config fixtures/app/parallax.fnl
+;; Demo config for the static fixture site. Run from anywhere in the
+;; workspace: parallax run --config fixtures/parallax.fnl
 {:name "parallax-fixture"
  :variants {:desktop-light {:viewport [1280 900]}
             :desktop-dark {:viewport [1280 900] :scheme :dark}
@@ -16,8 +16,6 @@
          (wait-for ".flash-bar")
          (snap :dashboard-after-complete)
          (post "/__fixtures/items" {:title "Added by the flow" :tag "parallax"})
-         (goto "/")
-         (snap :dashboard-with-fixture-item)
-         (goto (.. "/items/" (. (app) :featured_item)))
+         (goto (.. "/items/" (. (app) :featured_item) ".html"))
          (wait-for ".item-detail")
          (snap :item-detail {:selector ".item-detail"}))}
diff --git a/fixtures/app/Cargo.toml b/fixtures/serve/Cargo.toml
similarity index 84%
rename from fixtures/app/Cargo.toml
rename to fixtures/serve/Cargo.toml
index b3685cc..7a5a281 100644
--- a/fixtures/app/Cargo.toml
+++ b/fixtures/serve/Cargo.toml
@@ -7,11 +7,10 @@ publish = false
 [dependencies]
 axum = "*"
 fs-err = "*"
-miette = { version = "*", features = ["fancy"] }
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
-thiserror = "*"
 tokio = { version = "*", features = ["full"] }
+tower-http = { version = "*", features = ["fs"] }
 
 [dev-dependencies]
 tempfile = "*"
diff --git a/fixtures/serve/src/main.rs b/fixtures/serve/src/main.rs
new file mode 100644
index 0000000..76b6d48
--- /dev/null
+++ b/fixtures/serve/src/main.rs
@@ -0,0 +1,97 @@
+//! Serves the committed pages in `fixtures/site` and speaks parallax's
+//! handshake, so parallax's tests and demos run against static files.
+
+use std::io;
+use std::net::Ipv4Addr;
+use std::path::Path;
+
+use axum::Json;
+use axum::Router;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use axum::response::Response;
+use axum::routing::post;
+use serde::Deserialize;
+use serde::Serialize;
+use serde_json::json;
+use tokio::net::TcpListener;
+use tower_http::services::ServeDir;
+
+/// Baked in at compile time, so the binary serves the pages no matter
+/// which directory `cargo run` starts it from.
+const SITE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../site");
+
+/// The item the handshake points flows at; `items/2.html` is its only
+/// committed detail page.
+const FEATURED: u32 = 2;
+
+/// The id the echo route hands back, next after the five listed items.
+const ECHOED_ID: u32 = 6;
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
+    let addr = listener.local_addr()?;
+    let url = format!("http://{addr}");
+    match std::env::var_os("PARALLAX_HANDSHAKE") {
+        Some(path) => write_handshake(Path::new(&path), &url)?,
+        None => println!("{url}"),
+    }
+    axum::serve(listener, router()).await?;
+    Ok(())
+}
+
+/// Writes through a sibling temporary file and renames it, so a reader
+/// polling for the path never sees a partial file.
+fn write_handshake(path: &Path, url: &str) -> io::Result<()> {
+    let body = json!({"url": url, "data": {"featured_item": FEATURED}});
+    let mut tmp = path.as_os_str().to_owned();
+    tmp.push(".tmp");
+    fs_err::write(&tmp, body.to_string())?;
+    fs_err::rename(&tmp, path)
+}
+
+fn router() -> Router {
+    Router::new()
+        .route("/__fixtures/items", post(create_item))
+        .fallback_service(ServeDir::new(SITE))
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct NewItem {
+    title: String,
+    #[serde(default)]
+    tag: String,
+    #[serde(default)]
+    notes: String,
+}
+
+#[derive(Serialize)]
+struct Item {
+    id: u32,
+    title: String,
+    tag: String,
+    notes: String,
+    done: bool,
+}
+
+/// Stands in for the fixture routes a real app mounts for parallax's
+/// `post` helper. Stateless, so every post echoes back with the same id.
+async fn create_item(Json(new): Json<NewItem>) -> Response {
+    if new.title.trim().is_empty() {
+        return (
+            StatusCode::UNPROCESSABLE_ENTITY,
+            Json(json!({"error": "title must not be empty"})),
+        )
+            .into_response();
+    }
+    let item = Item {
+        id: ECHOED_ID,
+        title: new.title,
+        tag: new.tag,
+        notes: new.notes,
+        done: false,
+    };
+    (StatusCode::CREATED, Json(item)).into_response()
+}
diff --git a/fixtures/app/tests/app.rs b/fixtures/serve/tests/serve.rs
similarity index 56%
rename from fixtures/app/tests/app.rs
rename to fixtures/serve/tests/serve.rs
index cc3c0ec..478d13b 100644
--- a/fixtures/app/tests/app.rs
+++ b/fixtures/serve/tests/serve.rs
@@ -1,12 +1,4 @@
-#![cfg_attr(
-    test,
-    allow(
-        clippy::arithmetic_side_effects,
-        clippy::expect_used,
-        clippy::indexing_slicing,
-        clippy::unwrap_used
-    )
-)]
+#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
 
 use std::path::Path;
 use std::process::Child;
@@ -89,89 +81,31 @@ fn wait_for(handshake: &Path) -> Value {
 }
 
 #[test]
-fn handshake_reports_a_reachable_url_and_the_featured_item() {
+fn handshake_serves_the_site() {
     let app = Fixture::start();
     assert_eq!(app.data, json!({"featured_item": 2}));
     let (status, body) = app.get("/");
     assert_eq!(status, 200);
     assert!(body.contains(r#"class="dashboard""#));
     assert!(body.contains("Water the ferns"));
-}
-
-#[test]
-fn completing_an_item_redirects_to_a_flash() {
-    let app = Fixture::start();
-    let mut response = app
-        .agent
-        .post(format!("{}/items/1/complete", app.url))
-        .send_empty()
-        .expect("post");
-    assert_eq!(response.status().as_u16(), 200);
-    let body = response.body_mut().read_to_string().expect("body");
-    assert!(
-        body.contains(r#"<p class="flash-bar" role="status">Completed “Water the ferns”.</p>"#)
-    );
-    assert!(body.contains(r#"<li class="item done" data-id="1">"#));
-}
-
-#[test]
-fn featured_item_has_a_detail_page() {
-    let app = Fixture::start();
-    let id = app.data["featured_item"].as_u64().expect("featured id");
-    let (status, body) = app.get(&format!("/items/{id}"));
+    let (status, body) = app.get("/items/2.html");
     assert_eq!(status, 200);
     assert!(body.contains(r#"class="item-detail""#));
-    assert!(body.contains("Sharpen the kitchen knives"));
 }
 
 #[test]
-fn fixture_route_adds_escaped_items() {
+fn fixture_route_round_trips() {
     let app = Fixture::start();
     let (status, item) = app.post_json(
         "/__fixtures/items",
-        json!({"title": "<b>Bold</b>", "tag": "test"}),
+        json!({"title": "Added by the flow", "tag": "parallax"}),
     );
     assert_eq!(status, 201);
-    assert_eq!(item["id"], 6);
-    let (_, body) = app.get("/");
-    assert!(body.contains("&lt;b&gt;Bold&lt;/b&gt;"));
-}
-
-#[test]
-fn fixture_route_rejects_bad_items() {
-    let app = Fixture::start();
+    assert_eq!(
+        item,
+        json!({"id": 6, "title": "Added by the flow", "tag": "parallax", "notes": "", "done": false}),
+    );
     let (status, body) = app.post_json("/__fixtures/items", json!({"title": "  "}));
     assert_eq!(status, 422);
     assert_eq!(body["error"], "title must not be empty");
 }
-
-#[test]
-fn each_process_starts_from_the_seed() {
-    let first = Fixture::start();
-    first.post_json("/__fixtures/items", json!({"title": "Only in the first"}));
-    let second = Fixture::start();
-    let (_, body) = second.get("/");
-    assert!(!body.contains("Only in the first"));
-}
-
-#[test]
-fn broken_pages_misbehave_by_kind() {
-    let app = Fixture::start();
-    for (kind, fragment) in [
-        ("console-error", "console.error("),
-        ("console-warning", "console.warn("),
-        ("exception", "throw new Error("),
-    ] {
-        let (status, body) = app.get(&format!("/broken/{kind}"));
-        assert_eq!(status, 200, "{kind}");
-        assert!(body.contains(fragment), "{kind}");
-    }
-    assert_eq!(app.get("/broken/nope").0, 404);
-}
-
-#[test]
-fn unknown_paths_are_not_found() {
-    let app = Fixture::start();
-    assert_eq!(app.get("/nope").0, 404);
-    assert_eq!(app.get("/items/99").0, 404);
-}
diff --git a/fixtures/site/broken/console-error.html b/fixtures/site/broken/console-error.html
new file mode 100644
index 0000000..2172d54
--- /dev/null
+++ b/fixtures/site/broken/console-error.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>console.error("fixture: deliberate console error");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/broken/console-warning.html b/fixtures/site/broken/console-warning.html
new file mode 100644
index 0000000..6a2e281
--- /dev/null
+++ b/fixtures/site/broken/console-warning.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>console.warn("fixture: deliberate console warning");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/broken/exception.html b/fixtures/site/broken/exception.html
new file mode 100644
index 0000000..3b3a837
--- /dev/null
+++ b/fixtures/site/broken/exception.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Broken · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<p class="broken">This page misbehaves on purpose.</p>
+<script>throw new Error("fixture: deliberate uncaught exception");</script>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/index.html b/fixtures/site/index.html
new file mode 100644
index 0000000..56189df
--- /dev/null
+++ b/fixtures/site/index.html
@@ -0,0 +1,62 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Tasks · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<div class="dashboard"><section class="item-list" aria-label="Tasks"><ul>
+<li class="item open" data-id="1">
+<a href="/items/1.html">Water the ferns</a>
+<span class="tag">home</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item open" data-id="2">
+<a href="/items/2.html">Sharpen the kitchen knives</a>
+<span class="tag">home</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item open" data-id="3">
+<a href="/items/3.html">Renew library card</a>
+<span class="tag">errands</span>
+<button class="complete-btn">Complete</button>
+</li>
+<li class="item done" data-id="4">
+<a href="/items/4.html">Back up the photo library</a>
+<span class="tag">computer</span>
+<button class="complete-btn">Reopen</button>
+</li>
+<li class="item open" data-id="5">
+<a href="/items/5.html">Call about the bike tune-up</a>
+<span class="tag">errands</span>
+<button class="complete-btn">Complete</button>
+</li>
+</ul></section>
+<aside class="summary"><h2>Summary</h2><dl>
+<div><dt>Open</dt><dd>4</dd></div>
+<div><dt>Done</dt><dd>1</dd></div>
+</dl></aside></div>
+</main>
+<script>
+// The fixture has no server state, so completing an item is a local
+// toggle that mimics the old form round-trip's flash bar.
+document.addEventListener("click", (event) => {
+  const button = event.target.closest(".complete-btn");
+  if (!button) return;
+  const item = button.closest(".item");
+  const done = item.classList.toggle("done");
+  button.textContent = done ? "Reopen" : "Complete";
+  const title = item.querySelector("a").textContent;
+  const flash = document.createElement("p");
+  flash.className = "flash-bar";
+  flash.setAttribute("role", "status");
+  flash.textContent = (done ? "Completed" : "Reopened") + " \u201C" + title + "\u201D.";
+  document.querySelector("main").prepend(flash);
+});
+</script>
+</body>
+</html>
diff --git a/fixtures/site/items/2.html b/fixtures/site/items/2.html
new file mode 100644
index 0000000..f48c0de
--- /dev/null
+++ b/fixtures/site/items/2.html
@@ -0,0 +1,20 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Sharpen the kitchen knives · Tally</title>
+<link rel="stylesheet" href="/tally.css">
+</head>
+<body>
+<header class="masthead"><h1><a href="/">Tally</a></h1></header>
+<main>
+<article class="item-detail">
+<p><a href="/">&#8592; All tasks</a></p>
+<h2>Sharpen the kitchen knives</h2>
+<dl><div><dt>Status</dt><dd>Open</dd></div><div><dt>Tag</dt><dd class="tag">home</dd></div></dl>
+<p>Whetstone is under the sink. Coarse side first, then fine, then strop on the old belt.</p>
+</article>
+</main>
+</body>
+</html>
diff --git a/fixtures/site/tally.css b/fixtures/site/tally.css
new file mode 100644
index 0000000..b0234f4
--- /dev/null
+++ b/fixtures/site/tally.css
@@ -0,0 +1,102 @@
+/* Tally's stylesheet: type and space steps are Utopia's defaults (18px
+   at a 320px viewport with a 1.2 ratio, up to 20px at 1240px with a
+   1.25 ratio). System fonts only, so screenshots don't wait on a
+   network. */
+:root {
+  --step--1: clamp(0.9375rem, 0.9158rem + 0.1087vw, 1rem);
+  --step-0: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
+  --step-1: clamp(1.35rem, 1.2761rem + 0.3696vw, 1.5625rem);
+  --step-2: clamp(1.62rem, 1.5041rem + 0.5793vw, 1.9531rem);
+  --space-2xs: clamp(0.5625rem, 0.5408rem + 0.1087vw, 0.625rem);
+  --space-xs: clamp(0.875rem, 0.8533rem + 0.1087vw, 0.9375rem);
+  --space-s: clamp(1.125rem, 1.0815rem + 0.2174vw, 1.25rem);
+  --space-m: clamp(1.6875rem, 1.6223rem + 0.3261vw, 1.875rem);
+  --space-l: clamp(2.25rem, 2.163rem + 0.4348vw, 2.5rem);
+
+  --bg: #f7f5f0;
+  --surface: #ffffff;
+  --text: #1f1d1a;
+  --muted: #6b665e;
+  --border: #e2ddd3;
+  --accent: #2f6f4f;
+  --accent-text: #ffffff;
+  --flash: #e3f1e8;
+
+  color-scheme: light dark;
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #181715;
+    --surface: #23211e;
+    --text: #ece8e1;
+    --muted: #a39d93;
+    --border: #3a3732;
+    --accent: #7cc49b;
+    --accent-text: #10251a;
+    --flash: #1f3a2b;
+  }
+}
+*, *::before, *::after { box-sizing: border-box; }
+body {
+  margin: 0;
+  background: var(--bg);
+  color: var(--text);
+  font: var(--step-0)/1.5 system-ui, sans-serif;
+}
+a { color: inherit; }
+.masthead {
+  padding: var(--space-s) var(--space-m);
+  border-bottom: 1px solid var(--border);
+}
+.masthead h1 { margin: 0; font-size: var(--step-2); letter-spacing: -0.02em; }
+.masthead a { text-decoration: none; }
+main { max-width: 64rem; margin: 0 auto; padding: var(--space-m); }
+h2 { font-size: var(--step-1); margin: 0 0 var(--space-xs); }
+.flash-bar {
+  margin: 0 0 var(--space-m);
+  padding: var(--space-xs) var(--space-s);
+  background: var(--flash);
+  border-radius: 0.5rem;
+}
+.dashboard { display: flex; flex-wrap: wrap; gap: var(--space-m); align-items: flex-start; }
+.item-list { flex: 999 1 28rem; }
+.summary { flex: 1 1 14rem; }
+.item-list ul { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2xs); }
+.item {
+  display: flex;
+  align-items: center;
+  gap: var(--space-xs);
+  padding: var(--space-xs) var(--space-s);
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 0.5rem;
+}
+.item > a { flex: 1; text-decoration: none; }
+.item.done > a { color: var(--muted); text-decoration: line-through; }
+.tag {
+  font-size: var(--step--1);
+  color: var(--muted);
+}
+.complete-btn {
+  font: inherit;
+  font-size: var(--step--1);
+  padding: 0.25em 0.75em;
+  border: 1px solid var(--accent);
+  border-radius: 999px;
+  background: var(--accent);
+  color: var(--accent-text);
+  cursor: pointer;
+}
+.item.done .complete-btn { background: transparent; color: var(--accent); }
+.summary, .item-detail {
+  padding: var(--space-s);
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 0.5rem;
+}
+dl { margin: 0; display: grid; gap: var(--space-2xs); }
+dl > div { display: flex; justify-content: space-between; gap: var(--space-s); }
+dt { color: var(--muted); }
+dd { margin: 0; font-weight: 600; }
+.item-detail dl { margin-bottom: var(--space-s); }
+.empty { color: var(--muted); font-style: italic; }