website

Personal website
git clone https://github.com/michaelneuper/website.git
README | Files | Log | Refs

commit 0dba4351c12b63f00661528fa6438cbbfc636565
parent 06683f589386b8681992e4a73382a50211b6b7d1
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Mon, 12 Oct 2026 18:02:11 +0200

add 404 page

Diffstat:
Msrc/components/home/About.astro | 11+++--------
Msrc/components/home/Contact.astro | 2+-
Msrc/components/home/Posts.astro | 1+
Msrc/components/home/Projects.astro | 2+-
Asrc/components/missing/Dungeon.astro | 33+++++++++++++++++++++++++++++++++
Asrc/components/missing/StatusBits.astro | 32++++++++++++++++++++++++++++++++
Msrc/pages/404.astro | 31+++++++++++++++++++------------
Msrc/pages/giscus/[theme].css.js | 23+++--------------------
Msrc/scripts/home/contact.js | 2+-
Msrc/scripts/home/posts.js | 7+++----
Asrc/scripts/missing/dungeon.js | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/scripts/missing/status-bits.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Msrc/styles/home/about.css | 31+------------------------------
Msrc/styles/home/contact.css | 2+-
Asrc/styles/missing/dungeon.css | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/styles/missing/status-bits.css | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"> &lt;at&gt; </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">&gt;</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; + } +}