commit 0dba4351c12b63f00661528fa6438cbbfc636565
parent 06683f589386b8681992e4a73382a50211b6b7d1
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 18:02:11 +0200
add 404 page
Diffstat:
16 files changed, 407 insertions(+), 78 deletions(-)
diff --git a/src/components/home/About.astro b/src/components/home/About.astro
@@ -1,16 +1,11 @@
---
-// About, as a datasheet for the MN-2026 part, with experience as the output of
-// git log --graph (src/scripts/home/gitlog.js colours it and expands commits).
+// About me, with experience as the output of git log --graph (src/scripts/home/gitlog.js colours it and expands commits).
import Buffer from "../frame/Buffer.astro";
import "../../styles/home/about.css";
---
<Buffer id="about" file="whoami.pdf" mode="PDFView" meta="revised June 2026">
- <header class="ds-head">
- <p class="ds-part">MN-2026</p>
- <h2 class="ds-title">About</h2>
- </header>
-
- <p class="prose ds-desc">I study computer science at Stellenbosch University and have been a teaching assistant for introductory computer science and computer architecture. I like understanding things all the way down, from a line of C to the gates that run it, and I’m happiest when that ends in something I built: a scheduler, a compiler, or a computer on breadboards. Away from that I’m a linguistics nerd and a free-software advocate.</p>
+ <h2 class="h2">About me</h2>
+ <p class="prose">I study computer science at Stellenbosch University and have been a teaching assistant for introductory computer science and computer architecture. I like understanding things all the way down, from a line of C to the gates that run it, and I’m happiest when that ends in something I built: a scheduler, a compiler, or a computer on breadboards. Away from that I’m a linguistics nerd and a free-software advocate.</p>
{/* Experience, as the one-line git log that would print it: "<date> - (refs) <subject> <author>".
data-lanes colours each graph character by branch: m main, u undergrad, t teaching-assistant,
diff --git a/src/components/home/Contact.astro b/src/components/home/Contact.astro
@@ -11,7 +11,7 @@ import "../../styles/home/contact.css";
<div class="contact">
{/* The address is only ever put together in JavaScript (src/scripts/home/contact.js), so scrapers
reading this HTML don't see it. The link and the copy button read data-user and data-host. */}
- <a class="mail" href="#contact" data-user="michael" data-host="michaelneuper.com">michael<span class="at"> <at> </span>michaelneuper.com</a>
+ <a class="mail" href="#contact" data-user="michael" data-host="michaelneuper.com">michael<span class="at"> [at] </span>michaelneuper.com</a>
<button type="button" class="btn" data-email="copy">copy address</button>
</div>
diff --git a/src/components/home/Posts.astro b/src/components/home/Posts.astro
@@ -9,6 +9,7 @@ import "../../styles/home/posts.css";
const posts = await publishedPosts();
---
<Buffer id="posts" file="posts.org" mode="Org" metaAttrs={{ "data-org": "meta" }}>
+ <h2 class="h2">Posts</h2>
<p class="org-p">From my blog at michaelneuper.com, shown the way I read them. <kbd>TAB</kbd> on a heading folds it, <kbd>S-TAB</kbd> cycles the whole buffer, and clicking a heading’s stars does the same.</p>
{/* data-tags are space separated; each tag gets its colour from TAG_COLORS in src/scripts/tags.js. */}
diff --git a/src/components/home/Projects.astro b/src/components/home/Projects.astro
@@ -13,7 +13,7 @@ const projects = await Promise.all(
.map(async (p) => ({ ...p.data, Body: (await render(p)).Content })),
);
---
-<Buffer id="projects" file="memory.map" mode="Fundamental" metaAttrs={{ "data-mm": "meta" }}>
+<Buffer id="projects" file="projects.map" mode="Fundamental" metaAttrs={{ "data-mm": "meta" }}>
<h2 class="h2">Projects</h2>
<p class="lede">Laid out like a linker map: each project is a region in memory holding its one-line summary, and the hexdump shows exactly those bytes. Select one to read about it and try it out.</p>
diff --git a/src/components/missing/Dungeon.astro b/src/components/missing/Dungeon.astro
@@ -0,0 +1,33 @@
+---
+// A *dungeon* buffer on the 404 page, after M-x dunnet, the text adventure that
+// ships with Emacs and starts at a dead end. Here the dead end is the missing
+// page; digging turns up posts, closest to the requested address first
+// (src/scripts/missing/dungeon.js).
+import Winbar from "../frame/Winbar.astro";
+import { publishedPosts } from "../../lib/posts.js";
+import "../../styles/missing/dungeon.css";
+
+const posts = (await publishedPosts()).map((p) => ({ title: p.data.title, slug: p.id, href: `/posts/${p.id}/` }));
+---
+<div class="dun">
+ <Winbar file="*dungeon*" meta="M-x dunnet" />
+ <div class="dun-log" aria-live="polite"></div>
+ <form class="dun-prompt" autocomplete="off">
+ <label><span aria-hidden="true">></span><span class="sr-only">Command</span><input type="text" name="cmd" spellcheck="false" autocapitalize="off" enterkeyhint="send"></label>
+ </form>
+ <p class="dun-keys">
+ <button type="button" class="btn" data-cmd="look">look</button>
+ <button type="button" class="btn" data-cmd="take shovel">take shovel</button>
+ <button type="button" class="btn" data-cmd="dig">dig</button>
+ <button type="button" class="btn" data-cmd="inventory">inventory</button>
+ <button type="button" class="btn" data-cmd="east">east</button>
+ </p>
+ <noscript><p class="dun-noscript">The dungeon needs JavaScript. The path east leads <a href="/">home</a>.</p></noscript>
+ <script type="application/json" class="dun-posts" is:inline set:html={JSON.stringify(posts)}></script>
+</div>
+
+<script>
+ import { initDungeon } from "../../scripts/missing/dungeon.js";
+
+ initDungeon();
+</script>
diff --git a/src/components/missing/StatusBits.astro b/src/components/missing/StatusBits.astro
@@ -0,0 +1,32 @@
+---
+// The 404 page's status code in binary, each bit under its weight. On load the
+// bits arrive with a few flipped and are corrected one at a time
+// (src/scripts/missing/status-bits.js).
+import "../../styles/missing/status-bits.css";
+
+interface Props {
+ code: number;
+}
+
+const { code } = Astro.props;
+const BITS = 9;
+const weights = Array.from({ length: BITS }, (_, i) => 2 ** (BITS - 1 - i));
+// a gap before each nibble, counting from the right: 1 1001 0100
+const nibbleStart = (i: number) => i > 0 && (BITS - i) % 4 === 0;
+---
+<div class="sb" data-code={code}>
+ <p class="sb-bits" aria-label={`${code} in binary: ${code.toString(2)}`}>
+ {weights.map((w, i) => (
+ <span class:list={["sb-bit", { "sb-nibble": nibbleStart(i), "is-on": (code & w) !== 0 }]} data-w={w} aria-hidden="true">
+ <span class="w">{w}</span>
+ <span class="d">{(code & w) !== 0 ? 1 : 0}</span>
+ </span>
+ ))}
+ </p>
+</div>
+
+<script>
+ import { initStatusBits } from "../../scripts/missing/status-bits.js";
+
+ initStatusBits();
+</script>
diff --git a/src/pages/404.astro b/src/pages/404.astro
@@ -1,22 +1,29 @@
---
import Base from "../layouts/Base.astro";
import Buffer from "../components/frame/Buffer.astro";
+import StatusBits from "../components/missing/StatusBits.astro";
+import Dungeon from "../components/missing/Dungeon.astro";
---
<Base title="Not found - Michael Neuper" buf="*Messages*" mode="Messages" skip="#missing">
+ <Fragment slot="head">
+ <script is:inline>
+ // Hide the bits until src/scripts/missing/status-bits.js has flipped some, so 404 never shows whole first.
+ // CSS shows them anyway after a moment in case the script never runs.
+ try {
+ if (!matchMedia("(prefers-reduced-motion: reduce)").matches) document.documentElement.classList.add("sb-boot");
+ } catch (e) {}
+ </script>
+ </Fragment>
+ <Fragment slot="help">
+ <div><dt><code>dunnet</code></dt><dd>play the text adventure at this dead end</dd></div>
+ </Fragment>
+
<main>
<Buffer id="missing" file="*Messages*" mode="Messages" meta="404" labelledby="missing-title">
- <h1 class="h2" id="missing-title">No such file</h1>
- <p class="lede">Nothing lives at this address. If it was a page on my old site, it didn't make the move, but every post did, at the same address as before.</p>
- <p class="links"><a href="/">Home</a> <a href="/#posts">Posts</a></p>
+ <h1 class="sr-only" id="missing-title">404: nothing here</h1>
+ <StatusBits code={404} />
+ <p class="lede">Nothing lives at this address.</p>
+ <Dungeon />
</Buffer>
</main>
</Base>
-
-<style>
- .links {
- display: flex;
- gap: 3ch;
- margin: 0;
- font-size: 13px;
- }
-</style>
diff --git a/src/pages/giscus/[theme].css.js b/src/pages/giscus/[theme].css.js
@@ -45,19 +45,7 @@ const css = (tokens, light) => `/* giscus theme for michaelneuper.com, made from
font-display: swap;
}
-@font-face {
- font-family: "Computer Modern";
- src: url("/fonts/cm-regular.woff2") format("woff2");
- font-weight: 400;
- font-display: swap;
-}
-@font-face {
- font-family: "Computer Modern";
- src: url("/fonts/cm-italic.woff2") format("woff2");
- font-style: italic;
- font-display: swap;
-}
main {
color-scheme: ${light ? "light" : "dark"};
@@ -172,15 +160,10 @@ main *::after {
box-shadow: none !important;
}
-/* what people write reads in the serif, like the posts */
+/* comments are mono too, like the rest of the Emacs around them */
main .markdown {
- font-family: "Computer Modern", "Latin Modern Roman", Georgia, serif;
- font-size: 17px;
- line-height: 1.55;
-}
-
-main .markdown :is(code, pre, kbd, tt) {
- font-family: "JetBrains Mono", ui-monospace, monospace;
+ font-family: "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
font-size: 13px;
+ line-height: 1.7;
}
`;
diff --git a/src/scripts/home/contact.js b/src/scripts/home/contact.js
@@ -1,4 +1,4 @@
-// The page only shows "michael <at> michaelneuper.com"; the address is put
+// The page only shows "michael [at] michaelneuper.com"; the address is put
// together here so a scraper reading the HTML never sees it whole.
import { $ } from "../util.js";
diff --git a/src/scripts/home/posts.js b/src/scripts/home/posts.js
@@ -28,14 +28,13 @@ export function initPosts() {
};
const org = el("div", "org");
- const kw = (tag, key, value, cls) => {
- const p = el(tag, "org-kw");
+ const kw = (key, value, cls) => {
+ const p = el("p", "org-kw");
const k = el("span", "org-key", `#+${key}: `);
- if (tag === "h2") k.setAttribute("aria-hidden", "true");
p.append(k, el("span", cls, value));
return p;
};
- org.append(kw("h2", "title", "Posts", "org-title"), kw("p", "author", "Michael Neuper", "v"), kw("p", "startup", "content", "v"));
+ org.append(kw("title", "Posts", "org-title"), kw("author", "Michael Neuper", "v"), kw("startup", "content", "v"));
if (intro) org.append(intro);
const tree = el("ul", "org-tree");
diff --git a/src/scripts/missing/dungeon.js b/src/scripts/missing/dungeon.js
@@ -0,0 +1,118 @@
+// A one-room text adventure on the 404 page, after M-x dunnet, which also
+// starts at a dead end. Take the shovel and dig, and posts come up out of the
+// dirt, the ones most like the address that was asked for first. East goes home.
+
+import { $, $$ } from "../util.js";
+import { defcommand } from "../emacs/index.js";
+
+const POINTS = 10; // per post dug up
+
+const words = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
+
+// How much a post looks like the requested path: shared words count double,
+// and words that start the same (org/org-roam, linux/linuxes) count once.
+function likeness(path, post) {
+ const want = words(decodeURIComponent(path));
+ let score = 0;
+ for (const w of words(`${post.slug} ${post.title}`)) {
+ if (want.includes(w)) score += 2;
+ else if (want.some((x) => x.length > 2 && w.length > 2 && (x.startsWith(w) || w.startsWith(x)))) score += 1;
+ }
+ return score;
+}
+
+export function initDungeon() {
+ const root = $(".dun");
+ if (!root) return;
+ const log = $(".dun-log", root);
+ const input = $(".dun-prompt input", root);
+ const posts = JSON.parse($(".dun-posts", root).textContent);
+ // the posts most like the address that brought you here first, then the newest
+ const buried = posts.map((p, i) => ({ ...p, i, score: likeness(location.pathname, p) }))
+ .sort((a, b) => b.score - a.score || a.i - b.i);
+
+ let shovelHere = true;
+ let carrying = false;
+ let found = 0;
+ const history = [];
+ let back = 0;
+
+ const say = (...parts) => {
+ const p = document.createElement("p");
+ p.append(...parts);
+ log.append(p);
+ log.scrollTop = log.scrollHeight;
+ return p;
+ };
+ const link = (text, href) => Object.assign(document.createElement("a"), { textContent: text, href });
+
+ function look() {
+ say(Object.assign(document.createElement("strong"), { textContent: "Dead end" }));
+ say("You are at a dead end of a URL. The path goes back east, toward the home page. Old links lie half buried in the dirt.");
+ if (shovelHere) say("There is a shovel here.");
+ }
+
+ const DIRECTIONS = /^(n|s|w|ne|nw|se|sw|u|d|north|south|west|northeast|northwest|southeast|southwest|up|down)$/;
+
+ function run(line) {
+ const [verb = "", ...rest] = line.trim().toLowerCase().split(/\s+/);
+ const obj = rest.filter((w) => w !== "the").join(" ");
+ if (!verb) return;
+ if (verb === "go" && obj) return run(obj);
+
+ if (verb === "look" || verb === "l") look();
+ else if (verb === "e" || verb === "east") {
+ say("You follow the path east, back to the home page.");
+ setTimeout(() => location.assign("/"), 700);
+ } else if (DIRECTIONS.test(verb)) say("You can't go that way.");
+ else if (verb === "take" || verb === "get") {
+ if (obj !== "shovel") say(obj ? "I don't see that here." : "Take what?");
+ else if (carrying) say("You already have that.");
+ else { shovelHere = false; carrying = true; say("Taken."); }
+ } else if (verb === "drop") {
+ if (obj !== "shovel" || !carrying) say("You don't have that.");
+ else { shovelHere = true; carrying = false; say("Dropped."); }
+ } else if (verb === "i" || verb === "inventory") {
+ say(carrying ? "You currently have: a shovel." : "You don't have anything.");
+ } else if (verb === "dig") {
+ if (!carrying) say("You have nothing with which to dig.");
+ else if (found >= buried.length) say("Digging here reveals nothing.");
+ else {
+ const post = buried[found++];
+ say("Digging here reveals a post: ", link(post.title, post.href));
+ }
+ } else if (verb === "score") {
+ say(`You have scored ${found * POINTS} out of a possible ${buried.length * POINTS} points.`);
+ } else if (verb === "help" || verb === "?") {
+ say("Try look, take shovel, dig, inventory, score, or a direction such as east.");
+ } else if (verb === "quit" || verb === "q") {
+ say("There is no quitting a dead end, but the path east leads home.");
+ } else say("I don't understand that.");
+ }
+
+ function enter(line) {
+ say(Object.assign(document.createElement("span"), { className: "dun-echo", textContent: `>${line}` }));
+ if (line.trim()) history.push(line);
+ back = history.length;
+ run(line);
+ }
+
+ $(".dun-prompt", root).addEventListener("submit", (e) => {
+ e.preventDefault();
+ enter(input.value);
+ input.value = "";
+ });
+ input.addEventListener("keydown", (e) => {
+ // M-p and M-n in comint; the arrows do it here
+ if (e.key === "ArrowUp" && back > 0) { e.preventDefault(); input.value = history[--back]; }
+ else if (e.key === "ArrowDown" && back < history.length) { e.preventDefault(); back++; input.value = history[back] ?? ""; }
+ });
+ for (const b of $$("[data-cmd]", root)) b.addEventListener("click", () => enter(b.dataset.cmd));
+
+ defcommand("dunnet", () => {
+ root.scrollIntoView({ block: "center" });
+ input.focus({ preventScroll: true });
+ }, { doc: "Play the text adventure at this dead end" });
+
+ look();
+}
diff --git a/src/scripts/missing/status-bits.js b/src/scripts/missing/status-bits.js
@@ -0,0 +1,47 @@
+// The 404 page's status code in binary. On load the bits arrive with three or
+// four flipped and are corrected one at a time, like the name on the home page,
+// and the buffer's header line follows the code as it changes.
+
+import { $, $$, reducedMotion } from "../util.js";
+import { message } from "../emacs/index.js";
+
+const rand = (n) => Math.floor(Math.random() * n);
+
+export function initStatusBits() {
+ const root = $(".sb");
+ if (!root) return;
+ const home = Number(root.dataset.code);
+ const bits = $$(".sb-bit", root);
+ const meta = $("#missing .winbar .meta");
+
+ let code = home;
+ function show(next) {
+ for (const b of bits) {
+ const was = (code & Number(b.dataset.w)) !== 0;
+ const on = (next & Number(b.dataset.w)) !== 0;
+ b.querySelector(".d").textContent = on ? "1" : "0";
+ b.classList.toggle("is-on", on);
+ if (was !== on) {
+ // restart the flip
+ b.classList.remove("is-flipping");
+ void b.offsetWidth;
+ b.classList.add("is-flipping");
+ }
+ }
+ code = next;
+ if (meta) meta.textContent = String(code).padStart(3, "0");
+ }
+
+ const html = document.documentElement;
+ if (reducedMotion.matches || !html.classList.contains("sb-boot")) {
+ html.classList.remove("sb-boot");
+ return;
+ }
+ const order = bits.map((b) => Number(b.dataset.w)).sort(() => Math.random() - 0.5).slice(0, 3 + rand(2));
+ show(order.reduce((c, w) => c ^ w, home));
+ html.classList.remove("sb-boot");
+ order.forEach((w, i) => setTimeout(() => {
+ show(code ^ w);
+ if (i === order.length - 1) message(`Corrected ${order.length} flipped bits.`, "ok", 4000);
+ }, 200 + i * 130));
+}
diff --git a/src/styles/home/about.css b/src/styles/home/about.css
@@ -1,29 +1,4 @@
-/* About, as a datasheet, with experience as git log --graph output */
-
-.ds-head {
- display: grid;
- grid-template-columns: 14ch minmax(0, 1fr);
- gap: 8px 2ch;
- align-items: baseline;
- padding-top: 16px;
- border-top: 2px solid var(--fg);
-}
-
-.ds-part {
- margin: 0;
- font-weight: 600;
- font-size: 15px;
-}
-
-.ds-title {
- max-width: 19em;
- margin: 0;
- font: 400 clamp(28px, 3.8vw, 44px)/1.1 var(--serif);
-}
-
-.ds-desc {
- margin: 40px 0 0;
-}
+/* About me, with experience as git log --graph output */
/* experience as git log --graph --color */
@@ -119,10 +94,6 @@
}
@media (max-width: 640px) {
- .ds-head {
- grid-template-columns: minmax(0, 1fr);
- }
-
.gitlog {
padding: 12px;
font-size: 11.5px;
diff --git a/src/styles/home/contact.css b/src/styles/home/contact.css
@@ -9,7 +9,7 @@
}
.mail {
- font: 400 clamp(26px, 4.4vw, 52px)/1.1 var(--serif);
+ font: 400 clamp(24px, 3.8vw, 44px)/1.1 var(--serif);
overflow-wrap: anywhere;
}
diff --git a/src/styles/missing/dungeon.css b/src/styles/missing/dungeon.css
@@ -0,0 +1,69 @@
+/* The *dungeon* buffer on the 404 page: a transcript, a > prompt, and buttons
+ for the usual commands so it plays on a phone too. */
+
+.dun {
+ margin: 72px 0 0;
+ font-size: 13px;
+ line-height: 1.7;
+}
+
+.dun .winbar {
+ margin-bottom: 16px;
+}
+
+/* the winbar spans the buffer like *Messages*'s; the text keeps a readable width */
+.dun-log,
+.dun-prompt,
+.dun-keys,
+.dun-noscript {
+ max-width: 76ch;
+}
+
+.dun-log {
+ max-height: 18em;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+
+.dun-log p {
+ margin: 0;
+}
+
+.dun-log strong {
+ font-weight: 600;
+}
+
+.dun-echo {
+ color: var(--dim);
+}
+
+.dun-prompt label {
+ display: flex;
+ align-items: baseline;
+}
+
+.dun-prompt input {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ outline: none;
+ background: none;
+ caret-color: var(--fg);
+}
+
+.dun-prompt:focus-within {
+ background: var(--hl);
+}
+
+.dun-keys {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: 16px 0 0;
+}
+
+.dun-noscript {
+ margin: 0;
+ color: var(--dim);
+}
diff --git a/src/styles/missing/status-bits.css b/src/styles/missing/status-bits.css
@@ -0,0 +1,74 @@
+/* The 404 page's status code in binary: each bit under its weight, 1s lit and
+ 0s dim. */
+
+.sb {
+ margin: 0 0 56px;
+}
+
+.sb-bits {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.06em;
+ margin: 0;
+ font: 400 clamp(52px, 12.5vw, 150px)/1 var(--serif);
+}
+
+.sb-bit {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.14em;
+ padding: 0 0.04em;
+ color: var(--faint);
+ font-variant-numeric: tabular-nums;
+ transition: color 120ms ease;
+}
+
+.sb-bit.sb-nibble {
+ margin-left: 0.22em;
+}
+
+.sb-bit.is-on {
+ color: var(--fg);
+}
+
+.sb-bit .w {
+ color: var(--faint);
+ font: 400 clamp(9px, 1.2vw, 12px)/1 var(--mono);
+}
+
+.sb-bit .d {
+ display: block;
+}
+
+.sb-bit.is-flipping .d {
+ animation: sb-flip 140ms ease-out;
+}
+
+@keyframes sb-flip {
+ from {
+ transform: perspective(400px) rotateX(90deg);
+ }
+
+ to {
+ transform: none;
+ }
+}
+
+/* Before the script corrects the bits, they stay hidden (CSS shows them after a moment anyway). */
+.sb-boot .sb-bit .d {
+ visibility: hidden;
+ animation: sb-failsafe 0s 2s forwards;
+}
+
+@keyframes sb-failsafe {
+ to {
+ visibility: visible;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .sb-bit.is-flipping .d {
+ animation: none;
+ }
+}