diff --git a/src/404.njk b/src/404.njk
new file mode 100644
index 0000000..4b1590c
--- /dev/null
+++ b/src/404.njk
@@ -0,0 +1,25 @@
+---
+layout: layouts/base.njk
+permalink: /404.html
+title: Page not found | River Rooks
+excerpt: The requested page could not be found.
+---
+
+
+
+
+
404_
+
+
REQUEST FAILED / NO MATCHING ROUTE
+
Page not found.
+
The address you requested doesn't point to a page here. Check the URL, or choose a known route below.
+
+ > Back to home
+ > Browse dispatches
+
+
+
+
diff --git a/src/assets/css/components/not-found.css b/src/assets/css/components/not-found.css
new file mode 100644
index 0000000..146ecb4
--- /dev/null
+++ b/src/assets/css/components/not-found.css
@@ -0,0 +1,86 @@
+/* Missing route: a compact diagnostic panel in the site's tiling vocabulary. */
+.not-found {
+ gap: 0;
+ padding: 20px 24px 28px;
+}
+
+.not-found-body {
+ display: grid;
+ grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr);
+ align-items: center;
+ gap: 24px;
+ min-height: 320px;
+}
+
+.not-found-code {
+ font-family: var(--font-mono);
+ font-size: clamp(5rem, 14vw, 10rem);
+ line-height: 1;
+ letter-spacing: -0.09em;
+ color: var(--accent-amber);
+ white-space: nowrap;
+}
+
+.not-found-cursor {
+ color: var(--text-muted);
+}
+
+.not-found-message {
+ border-left: 1px solid var(--border-color);
+ padding: 16px 0 16px 24px;
+ max-width: 48ch;
+}
+
+.not-found-label {
+ font: 500 0.75rem var(--font-mono);
+ letter-spacing: 0.06em;
+ color: var(--accent-amber);
+ margin-bottom: 12px;
+}
+
+.not-found-message h2 {
+ font: 700 clamp(1.3rem, 3vw, 2rem) / 1.25 var(--font-mono);
+ margin-bottom: 12px;
+}
+
+.not-found-message > p:not(.not-found-label) {
+ color: var(--text-muted);
+ max-width: 42ch;
+}
+
+.not-found-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 24px;
+ margin-top: 24px;
+ font: 500 0.85rem var(--font-mono);
+}
+
+.not-found-links a {
+ color: var(--accent-cyan);
+ text-decoration: none;
+}
+
+.not-found-links a:hover {
+ color: var(--bg-canvas);
+ background: var(--accent-cyan);
+}
+
+@media (max-width: 680px) {
+ .not-found {
+ padding: 16px;
+ }
+
+ .not-found-body {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ min-height: 0;
+ padding: 32px 0 8px;
+ }
+
+ .not-found-message {
+ border-left: 0;
+ border-top: 1px solid var(--border-color);
+ padding: 20px 0 0;
+ }
+}
diff --git a/src/assets/css/main.css b/src/assets/css/main.css
index cc44511..8c600fc 100644
--- a/src/assets/css/main.css
+++ b/src/assets/css/main.css
@@ -19,3 +19,4 @@
@import "./components/code-box.css";
@import "./components/article.css";
@import "./components/footer.css";
+@import "./components/not-found.css";
diff --git a/test/not-found.test.js b/test/not-found.test.js
new file mode 100644
index 0000000..c7c5038
--- /dev/null
+++ b/test/not-found.test.js
@@ -0,0 +1,43 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { execFileSync } from "node:child_process";
+import { createServer } from "../server.js";
+
+const siteDir = path.resolve("_site");
+
+test("unknown routes render the themed 404 page with a real 404 status", async () => {
+ execFileSync("npm", ["run", "build"], {
+ stdio: "pipe",
+ env: { ...process.env, SITE_DOMAIN: process.env.SITE_DOMAIN || "riverrooks.dev" }
+ });
+
+ const html = fs.readFileSync(path.join(siteDir, "404.html"), "utf8");
+ assert.match(html, /
Page not found/);
+ assert.match(html, /]*>[^<]*404/);
+ assert.match(html, /href="\/"/);
+ assert.match(html, /href="\/dispatches\/"/);
+ assert.match(html, /href="\/assets\/css\/main\.css/);
+
+ const css = fs.readFileSync(path.join(siteDir, "assets/css/main.css"), "utf8");
+ assert.match(css, /components\/not-found\.css/);
+ const pageCss = fs.readFileSync(path.join(siteDir, "assets/css/components/not-found.css"), "utf8");
+ assert.doesNotMatch(pageCss, /(?:backdrop-filter|box-shadow|text-shadow|linear-gradient|radial-gradient)\s*:/);
+
+ const server = createServer(siteDir, path.resolve("src/posts"), path.resolve("src/assets/images/posts"));
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ try {
+ const origin = `http://127.0.0.1:${server.address().port}`;
+ for (const route of ["/missing-page/", "/assets/missing.css"]) {
+ const response = await fetch(`${origin}${route}`);
+ assert.equal(response.status, 404);
+ assert.match(response.headers.get("content-type"), /text\/html/);
+ assert.match(await response.text(), /Page not found/);
+ }
+ const home = await fetch(origin);
+ assert.equal(home.status, 200);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+});