commit 48203d204b42c9f4463b239bf803f728606f37ba
parent 2bf76e2ee6f1e03a1c2f0dfa8411924082a6660a
Author: Michael Neuper <michael@michaelneuper.com>
Date: Mon, 12 Oct 2026 16:41:58 +0200
split into astro components
Diffstat:
101 files changed, 6228 insertions(+), 5722 deletions(-)
diff --git a/README.md b/README.md
@@ -20,15 +20,29 @@ npm run preview # serves dist/ at http://localhost:4321
## Where things are
-| Path | What it is |
-|--------------------------------|------------------------------------------------------------------------------|
-| `src/pages/index.astro` | the home page |
-| `src/pages/posts/[slug].astro` | the page every post uses |
-| `src/layouts/Base.astro` | head, top line, modeline and `*Help*`, shared by all pages |
-| `src/content/posts/` | the posts, in Markdown |
-| `src/scripts/` | the JavaScript: `main.js` for the home page, `page.js` for the others |
-| `src/styles/style.css` | all the styles, including the themes |
-| `public/` | files served as they are: fonts, favicon, GPG key, `robots.txt`, post videos |
+| Path | What it is |
+|----------------------------|-------------------------------------------------------------------------------|
+| `src/pages/` | the pages: home, `posts/[slug]` for every post, 404, the RSS feeds and the giscus themes |
+| `src/layouts/Base.astro` | the frame every page shares |
+| `src/components/frame/` | the Emacs around each page: top line, modeline, `*Help*`, footer, and `Buffer`, a section with its winbar |
+| `src/components/home/` | the home page's sections: hero, about, projects, posts, contact, and the coffee |
+| `src/components/post/` | a post as an Org buffer: keywords, text, the `*Ilist*` outline, comments |
+| `src/content/posts/` | the posts, in Markdown |
+| `src/content/projects/` | the projects in the memory map, one Markdown file each |
+| `src/data/pins.js` | the pins on the MN-2026 chip |
+| `src/scripts/emacs/` | M-x, the minibuffer, which-key, the modeline and load-theme |
+| `src/scripts/home/`, `post/`, `demos/` | what each component and demo does in the browser |
+| `src/styles/` | the styles, laid out like the components; `themes.css` has the colours |
+| `public/` | files served as they are: fonts, favicon, GPG key, `robots.txt`, post videos |
+
+Each component imports its own styles and starts its own script, so a section's
+markup, CSS and JavaScript are named after it in `components/`, `styles/` and `scripts/`.
+
+## Adding a project
+
+Add `src/content/projects/<name>.md`. The front matter has its place in the list
+(`order`), colour (`cat`), one-line `summary` (its size in the memory map),
+and optionally a `demo` from `src/scripts/demos/`; the text is the write-up.
## Writing a post
diff --git a/astro.config.mjs b/astro.config.mjs
@@ -24,7 +24,7 @@ export default defineConfig({
devToolbar: { enabled: false },
markdown: {
processor: satteri({ features: { math: true }, mdastPlugins: [renderMath] }),
- // Code takes its colours from the current Emacs theme (see .astro-code in style.css).
+ // Code takes its colours from the current Emacs theme (the --astro-code-* variables in src/styles/post/org.css).
shikiConfig: { theme: "css-variables" },
},
// Pages from the Hugo site that now live on the home page.
diff --git a/netlify.toml b/netlify.toml
@@ -18,10 +18,11 @@ for = "/_astro/*"
Cache-Control = "public, max-age=31536000, immutable"
# The comments' frame on giscus.app loads its stylesheet and our fonts from here.
+# The stylesheets are open to any origin, so `npm run dev` can use them too.
[[headers]]
for = "/giscus/*"
[headers.values]
- Access-Control-Allow-Origin = "https://giscus.app"
+ Access-Control-Allow-Origin = "*"
[[headers]]
for = "/fonts/*"
diff --git a/src/components/frame/Analytics.astro b/src/components/frame/Analytics.astro
@@ -0,0 +1,16 @@
+---
+// Google Analytics, as on the Hugo site: production builds only, so local work
+// isn't counted.
+const GA_ID = "G-BLX24KP7CS";
+---
+{import.meta.env.PROD && (
+ <Fragment>
+ <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}></script>
+ <script is:inline define:vars={{ GA_ID }}>
+ window.dataLayer = window.dataLayer || [];
+ function gtag() { dataLayer.push(arguments); }
+ gtag("js", new Date());
+ gtag("config", GA_ID);
+ </script>
+ </Fragment>
+)}
diff --git a/src/components/frame/Buffer.astro b/src/components/frame/Buffer.astro
@@ -0,0 +1,24 @@
+---
+// A buffer: one section of a page, with a winbar on top. Its file name and
+// major mode go in the modeline while it's on screen (src/scripts/emacs/buffers.js).
+import Winbar from "./Winbar.astro";
+import "../../styles/frame/buffer.css";
+
+interface Props {
+ id: string;
+ file: string;
+ mode: string;
+ meta?: string;
+ class?: string;
+ /** The id of the heading that names the section. */
+ labelledby?: string;
+ fileId?: string;
+ metaAttrs?: Record<string, string>;
+}
+
+const { id, file, mode, meta, class: className, labelledby, fileId, metaAttrs } = Astro.props;
+---
+<section class:list={["sec", className]} id={id} data-file={file} data-mode={mode} aria-labelledby={labelledby}>
+ <Winbar file={file} meta={meta} fileId={fileId} metaAttrs={metaAttrs} />
+ <slot />
+</section>
diff --git a/src/components/frame/Footer.astro b/src/components/frame/Footer.astro
@@ -0,0 +1,8 @@
+---
+import "../../styles/frame/footer.css";
+
+const year = new Date().getFullYear();
+---
+<footer class="foot">
+ <p>© {year} Michael Neuper</p>
+</footer>
diff --git a/src/components/frame/Help.astro b/src/components/frame/Help.astro
@@ -0,0 +1,28 @@
+---
+// *Help* (? or SPC h h): the keys every page has, then the page's own in the slot.
+import Winbar from "./Winbar.astro";
+import "../../styles/frame/help.css";
+---
+<dialog class="help" aria-labelledby="help-title">
+ <Winbar file="*Help*" fileId="help-title" meta="q or esc to close" />
+ <div class="help-cols">
+ <div>
+ <h3>Keys</h3>
+ <dl>
+ <div><dt><kbd>SPC</kbd></dt><dd>leader key: shows every command</dd></div>
+ <div><dt><kbd>M-x</kbd></dt><dd>run a command by name (Alt+x or Option+x)</dd></div>
+ <div><dt><kbd>SPC h t</kbd></dt><dd>load a theme, with live preview</dd></div>
+ <div><dt><kbd>:</kbd></dt><dd>evil ex commands, like <code>:q</code></dd></div>
+ <div><dt><kbd>C-g</kbd></dt><dd>cancel (so does <kbd>esc</kbd>)</dd></div>
+ </dl>
+ </div>
+ <div>
+ <h3>On this page</h3>
+ <dl>
+ <slot />
+ <div><dt><code>submit-flag</code></dt><dd>check a CTF flag</dd></div>
+ </dl>
+ </div>
+ </div>
+ <form method="dialog"><button class="btn" type="submit">close</button></form>
+</dialog>
diff --git a/src/components/frame/Modeline.astro b/src/components/frame/Modeline.astro
@@ -0,0 +1,43 @@
+---
+// The bottom of the frame: which-key, doom-modeline, the minibuffer and the echo
+// area. Its script starts the Emacs (src/scripts/emacs) and the CTF.
+import "../../styles/frame/modeline.css";
+
+interface Props {
+ /** Buffer name and major mode until a section scrolls into view. */
+ buf: string;
+ mode: string;
+}
+
+const { buf, mode } = Astro.props;
+---
+<div class="which-key" hidden aria-live="polite"></div>
+<div class="frame-bottom">
+ <div class="modeline">
+ <span class="ml-evil" title="evil state"><N></span>
+ <span class="ml-buf">{buf}</span>
+ <span class="ml-pos" aria-hidden="true">1:0</span>
+ <span class="ml-pct" aria-hidden="true">Top</span>
+ <span class="ml-spacer"></span>
+ <span class="ml-misc" aria-live="polite"></span>
+ <span class="ml-flags" aria-hidden="true"></span>
+ <span class="ml-mode">{mode}</span>
+ <span class="ml-enc" aria-hidden="true">LF UTF-8</span>
+ <span class="ml-vc" aria-hidden="true">master</span>
+ <span class="ml-time" aria-hidden="true"></span>
+ </div>
+ <div class="mini" hidden>
+ <form class="mini-prompt" autocomplete="off">
+ <label><span class="mini-count"></span><span class="mini-ps"></span><input type="text" spellcheck="false" autocapitalize="off" role="combobox" aria-expanded="true" aria-controls="mini-list" aria-autocomplete="list"></label>
+ </form>
+ <ul class="mini-list" id="mini-list" role="listbox"></ul>
+ </div>
+ <div class="echo" role="status"></div>
+</div>
+
+<script>
+ import "../../scripts/emacs/index.js";
+ import { initCtf } from "../../scripts/ctf.js";
+
+ initCtf();
+</script>
diff --git a/src/components/frame/TopBar.astro b/src/components/frame/TopBar.astro
@@ -0,0 +1,27 @@
+---
+// The top line: the name (back to the top), links to the home page's sections,
+// load-theme, and the menu button that shows the links on phones.
+import "../../styles/frame/topbar.css";
+
+interface Props {
+ /** On the home page the links scroll; elsewhere they go back to it. */
+ home?: boolean;
+}
+
+const { home = false } = Astro.props;
+const at = home ? "" : "/";
+---
+<header class="top">
+ <a class="brand" href={home ? "#top" : "/"} aria-label={home ? "Michael Neuper, back to top" : "Michael Neuper, home"}>
+ <svg class="logo" viewBox="0 0 32 16" aria-hidden="true"><path d="M1 14H3V2H9V14H11V2H17V14H20V2H26V14H31"/></svg>
+ <span>michael neuper</span>
+ </a>
+ <nav class="nav" id="nav" aria-label="Sections">
+ <a href={`${at}#about`}>about</a>
+ <a href={`${at}#projects`}>projects</a>
+ <a href={`${at}#posts`}>posts</a>
+ <a href={`${at}#contact`}>contact</a>
+ </nav>
+ <button class="theme-key" type="button" title="SPC h t">load-theme</button>
+ <button class="menu-key" type="button" aria-expanded="false" aria-controls="nav">menu</button>
+</header>
diff --git a/src/components/frame/Winbar.astro b/src/components/frame/Winbar.astro
@@ -0,0 +1,16 @@
+---
+// An Emacs window's header line: the buffer's file name, a rule, and a note.
+import "../../styles/frame/buffer.css";
+
+interface Props {
+ file: string;
+ meta?: string;
+ /** An id for the file name, for whatever it labels. */
+ fileId?: string;
+ /** Attributes for the note, when a script fills it in. */
+ metaAttrs?: Record<string, string>;
+}
+
+const { file, meta = "", fileId, metaAttrs = {} } = Astro.props;
+---
+<p class="winbar"><span class="file" id={fileId}>{file}</span><span class="rule" aria-hidden="true"></span><span class="meta" {...metaAttrs}>{meta}</span></p>
diff --git a/src/components/home/About.astro b/src/components/home/About.astro
@@ -0,0 +1,60 @@
+---
+// 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).
+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>
+
+ {/* 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,
+ b bbd-software, i mwr-internship (the colours are set in src/styles/home/about.css). data-cont is the graph that continues below a commit, and a
+ .gl-desc becomes its expandable description. data-branch colours the date; data-tip makes the
+ * bold for a branch that's still going; data-today swaps in the current month when the page loads.
+ Without JavaScript this shows as plain text. */}
+ <h3 class="ds-h gitlog-cmd"><span class="prompt" aria-hidden="true">$ </span>git log --graph --all --color \
+ --date=format:'%Y-%m' \
+ --format='%ad -%C(auto)%d%C(reset) %s %C(blue)<%an>%C(reset)'</h3>
+ <div class="gitlog" aria-label="Experience, newest first">
+<div class="gl-line" data-lanes=" b u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="m" data-tip data-today>* | | | 2026-10 - (HEAD -> main)</div>
+<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b" data-tip>| * | | 2026-06 - (bbd-software) Vac Week 2026 <BBD Software><div class="gl-desc">Built an online foosball game: players steer the rods by
+tilting their phones, using the gyroscope. Designed and
+presented as a full-stack web app with a small team.</div></div>
+<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t" data-tip>| | | * 2026-06 - (teaching-assistant) Teach computer architecture as a TA <Stellenbosch University></div>
+<div class="gl-line" data-lanes="m b u t">| | | |</div>
+<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t">| | | * 2026-02 - Teach introductory computer science as a TA <Stellenbosch University></div>
+<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-06 - Vac Week 2025 <BBD Software><div class="gl-desc">Built an online laser tag game: computer vision finds players
+in the camera feed and tags them. Designed and presented as a
+full-stack web app with a small team.</div></div>
+<div class="gl-line" data-lanes="m b u t">| | | |</div>
+<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-01 - Join BBD Software as a bursar <BBD Software><div class="gl-desc">Part of the Bursary & Beyond Programme, which includes the
+yearly Vac Weeks.</div></div>
+<div class="gl-line" data-lanes="mb u t">|/ | |</div>
+<div class="gl-line" data-lanes="m u t">| | |</div>
+<div class="gl-line" data-lanes="mi u t">|\ | |</div>
+<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m i u t " data-branch="i">| * | | 2024-12 - (mwr-internship) Complete a cybersecurity consultant internship <MWR CyberSec><div class="gl-desc">- Completed cybersecurity training with labs on TryHackMe.
+- Assessed a web application end to end, documenting each
+ vulnerability with its risk and how to fix it.
+- Wrote a professional report and presented it, client-style,
+ to a panel of industry professionals.</div></div>
+<div class="gl-line" data-lanes="mi u t">|/ | |</div>
+<div class="gl-line" data-lanes="m ut">| |/</div>
+<div class="gl-line" data-lanes="m " data-cont="| | " data-cont-lanes="m u " data-branch="u" data-tip>| * 2024-01 - (undergrad) Start an undergraduate degree in computer science <Stellenbosch University></div>
+<div class="gl-line" data-lanes="m u">| /</div>
+<div class="gl-line" data-lanes="mu">|/</div>
+<div class="gl-line" data-lanes=" " data-cont=" " data-cont-lanes=" " data-branch="m">* 2005-06 - Initial commit <Michael Neuper></div>
+ </div>
+ <p class="gitlog-hint dim">Click an underlined commit message to read the rest of it.</p>
+</Buffer>
+
+<script>
+ import { initGitlog } from "../../scripts/home/gitlog.js";
+
+ initGitlog();
+</script>
diff --git a/src/components/home/Coffee.astro b/src/components/home/Coffee.astro
@@ -0,0 +1,22 @@
+---
+// M-x coffee-refill (my GitHub bio, made to do something): a mug fills while
+// VCC climbs back to 5 V, then the 8-bit computer runs four times as fast.
+import Winbar from "../frame/Winbar.astro";
+import "../../styles/home/coffee.css";
+---
+<div class="coffee" hidden role="dialog" aria-label="Coffee refill">
+ <pre class="mug" aria-hidden="true"></pre>
+ <div class="coffee-info">
+ <Winbar file="*coffee*" meta="q to close" />
+ <p class="vcc"><span class="dim">VCC</span><span class="vbar" aria-hidden="true"></span><span class="volts">2.6 V</span></p>
+ <p class="coffee-msg" aria-live="polite">brewing…</p>
+ <p class="coffee-links"><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">That one was simulated. Buy me an actual coffee</a></p>
+ </div>
+ <button type="button" class="coffee-close" aria-label="Close">×</button>
+</div>
+
+<script>
+ import { initCoffee } from "../../scripts/home/coffee.js";
+
+ initCoffee();
+</script>
diff --git a/src/components/home/Contact.astro b/src/components/home/Contact.astro
@@ -0,0 +1,48 @@
+---
+// Contact, as an *unsent mail* buffer: the address, the GPG key, links
+// elsewhere and the CTF.
+import Buffer from "../frame/Buffer.astro";
+import "../../styles/home/contact.css";
+---
+<Buffer id="contact" file="*unsent mail*" mode="Message" meta="draft">
+ <h2 class="h2">Say hello</h2>
+ <p class="lede">Email works best. For anything private, encrypt it with my GPG key below.</p>
+
+ <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>
+ <button type="button" class="btn" data-email="copy">copy address</button>
+ </div>
+
+ <div class="contact-grid">
+ <div class="gpg">
+ <h3 class="ds-h">Encrypt it</h3>
+ <p class="gpg-fp"><span class="dim">fingerprint</span><code>4E7B F800 0D86 182D F484 5C37 508B CF84 373C 8930</code></p>
+ <div class="gpg-cmd">
+ <pre><code>curl -sL https://michaelneuper.com/michaelneuper.gpg | gpg --import</code></pre>
+ <button type="button" class="btn" data-copy=".gpg-cmd code">copy</button>
+ </div>
+ </div>
+ <div>
+ <h3 class="ds-h">Elsewhere</h3>
+ <ul class="elsewhere">
+ <li><a href="https://github.com/michaelneuper">GitHub</a></li>
+ <li><a href="https://www.linkedin.com/in/michaelneuper/">LinkedIn</a></li>
+ <li><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">Buy me a coffee</a></li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="ctf">
+ <h3 class="ds-h">Capture the flag</h3>
+ <p>Three flags are hidden on this site, the kind you would find in a beginner CTF. They look like <code>{"flag{…}"}</code>. Found one? Run <kbd>M-x</kbd> <code>submit-flag</code>, or use the button.</p>
+ <p class="ctf-row"><button type="button" class="btn" data-ctf="submit">submit a flag</button><span class="ctf-progress" aria-live="polite">0 of 3 found</span></p>
+ </div>
+</Buffer>
+
+<script>
+ import { initContact } from "../../scripts/home/contact.js";
+
+ initContact();
+</script>
diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro
@@ -0,0 +1,42 @@
+---
+// The landing section: the name, which decodes itself bit by bit on load
+// (src/scripts/home/decode.js), and the MN-2026 chip, whose pins are in
+// src/data/pins.js.
+import { PINS } from "../../data/pins.js";
+import "../../styles/home/hero.css";
+import "../../styles/home/pinout.css";
+---
+<section class="hero" id="top" data-file="mn-2026.kicad_sym" data-mode="Lisp Data" aria-labelledby="hero-name">
+ <div class="hero-id">
+ <h1 class="hero-name" id="hero-name">Michael Neuper</h1>
+ <p class="hero-lede">Computer science student at Stellenbosch University. I like low-level software, electronics and the maths underneath both, and I do almost all of it in Emacs.</p>
+ <ul class="legend" aria-label="Colour key used across the site">
+ <li data-cat="hw">hardware</li>
+ <li data-cat="sw">software</li>
+ <li data-cat="math">maths</li>
+ <li data-cat="sec">security</li>
+ </ul>
+ </div>
+
+ <div class="hero-chip">
+ <figure class="pinout">
+ <div class="chip-wrap"></div>
+ <div class="pin-readout" aria-live="polite"><p class="dim">Point at a pin to read its function.</p></div>
+ <figcaption>Figure 1. MN-2026, DIP-16 package, top view.</figcaption>
+ <ol class="pin-list">
+ {PINS.map((p) => <li data-name={p.name} data-type={p.type} data-cat={p.cat}>{p.text}</li>)}
+ </ol>
+ </figure>
+ </div>
+</section>
+
+<script>
+ import { initDecode } from "../../scripts/home/decode.js";
+ import { initPinout } from "../../scripts/home/pinout.js";
+
+ initDecode().catch((err) => {
+ console.error(err);
+ document.documentElement.classList.remove("boot");
+ });
+ initPinout();
+</script>
diff --git a/src/components/home/Posts.astro b/src/components/home/Posts.astro
@@ -0,0 +1,26 @@
+---
+// The posts on the home page, as an Org buffer: src/scripts/home/posts.js turns
+// this list into years and posts with tags and property drawers that fold.
+import Buffer from "../frame/Buffer.astro";
+import { publishedPosts, ymd } from "../../lib/posts.js";
+import "../../styles/org.css";
+import "../../styles/home/posts.css";
+
+const posts = await publishedPosts();
+---
+<Buffer id="posts" file="posts.org" mode="Org" metaAttrs={{ "data-org": "meta" }}>
+ <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. */}
+ <ol class="posts">
+ {posts.map((p) => (
+ <li data-date={ymd(p.data.date)} data-tags={p.data.tags.join(" ")} data-cat={p.data.cat}><a href={`/posts/${p.id}/`}>{p.data.title}</a></li>
+ ))}
+ </ol>
+</Buffer>
+
+<script>
+ import { initPosts } from "../../scripts/home/posts.js";
+
+ initPosts();
+</script>
diff --git a/src/components/home/Projects.astro b/src/components/home/Projects.astro
@@ -0,0 +1,54 @@
+---
+// Projects, laid out as a linker memory map (src/scripts/home/memmap.js builds
+// the map, the rows and the hexdump from this list). Each project is a file in
+// src/content/projects; its demo is src/scripts/demos/<demo>.js.
+import { getCollection, render } from "astro:content";
+import Buffer from "../frame/Buffer.astro";
+import "../../styles/home/projects.css";
+import "../../styles/demos/demo.css";
+
+const projects = await Promise.all(
+ (await getCollection("projects"))
+ .sort((a, b) => a.data.order - b.data.order)
+ .map(async (p) => ({ ...p.data, Body: (await render(p)).Content })),
+);
+---
+<Buffer id="projects" file="memory.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>
+
+ <div class="mm">
+ <div class="mm-bar" aria-hidden="true"></div>
+ <div class="mm-cols">
+ <div class="mm-table">
+ <p class="mm-head" aria-hidden="true"><span>origin</span><span>length</span><span>name</span><span>source</span></p>
+ <ol class="mm-list">
+ {projects.map((p) => (
+ <li class="proj" data-cat={p.cat} data-src={p.src} data-demo={p.demo} data-wide={p.wide || undefined}>
+ <h3 class="proj-name">{p.name}</h3>
+ <p class="proj-sum">{p.summary}</p>
+ <div class="proj-body">
+ <p.Body />
+ {p.stack && <p class="proj-stack">{p.stack}</p>}
+ {p.links.length > 0 && <p class="proj-links">{p.links.map((l) => <a href={l.href}>{l.text}</a>)}</p>}
+ {p.note && <p class="proj-links dim">{p.note}</p>}
+ </div>
+ </li>
+ ))}
+ </ol>
+ </div>
+ <article class="mm-detail" aria-live="polite"></article>
+ </div>
+ </div>
+</Buffer>
+
+<script>
+ import { fontsReady } from "../../scripts/util.js";
+ import { initMemmap } from "../../scripts/home/memmap.js";
+ // the 8-bit computer's M-x commands; its project mounts the board as a demo
+ import "../../scripts/demos/computer/commands.js";
+
+ // The hexdump measures character widths, so wait for the mono font.
+ await fontsReady();
+ initMemmap();
+</script>
diff --git a/src/components/post/Comments.astro b/src/components/post/Comments.astro
@@ -0,0 +1,16 @@
+---
+// The comments under a post, from GitHub Discussions through giscus
+// (src/scripts/post/comments.js loads them as you scroll near).
+import Buffer from "../frame/Buffer.astro";
+import "../../styles/post/comments.css";
+---
+<Buffer id="comments" class="post-comments" file="*comments*" mode="giscus" meta="GitHub Discussions" labelledby="comments-title" fileId="comments-title">
+ <div class="giscus"></div>
+ <p class="dim giscus-note">Comments load from GitHub Discussions through giscus.<noscript> They need JavaScript.</noscript></p>
+</Buffer>
+
+<script>
+ import { initComments } from "../../scripts/post/comments.js";
+
+ initComments();
+</script>
diff --git a/src/components/post/OrgBody.astro b/src/components/post/OrgBody.astro
@@ -0,0 +1,17 @@
+---
+// A post's text. Its headings get stars that fold them, as in Org
+// (src/scripts/post/folding.js), and M-x imenu jumps between them.
+import "../../styles/org.css";
+import "../../styles/post/org.css";
+---
+<article class="post-body">
+ <slot />
+</article>
+
+<script>
+ import { initFolding } from "../../scripts/post/folding.js";
+ import { initImenu } from "../../scripts/post/imenu.js";
+
+ initFolding();
+ initImenu();
+</script>
diff --git a/src/components/post/OrgHeader.astro b/src/components/post/OrgHeader.astro
@@ -0,0 +1,26 @@
+---
+// A post's #+ keywords, as at the top of an Org file: the title (big), then the
+// author, date and tags.
+import { ymd, weekday } from "../../lib/posts.js";
+import { TAG_COLORS } from "../../scripts/tags.js";
+import "../../styles/org.css";
+import "../../styles/post/org.css";
+
+interface Props {
+ title: string;
+ date: Date;
+ tags: string[];
+}
+
+const { title, date, tags } = Astro.props;
+---
+<header class="org-head">
+ <h1 class="post-title" id="post-title"><span class="org-key" aria-hidden="true">#+title:</span> {title}</h1>
+ <p class="org-kw"><span class="org-key">#+author:</span> <span class="v">Michael Neuper</span></p>
+ <p class="org-kw"><span class="org-key">#+date:</span> <time class="org-ts" datetime={ymd(date)}><{ymd(date)} {weekday(date)}></time></p>
+ {tags.length > 0 && (
+ <p class="org-kw"><span class="org-key">#+filetags:</span> <span class="org-filetags">:{tags.map((t) => (
+ <Fragment><span class="t" style={`--tag: ${TAG_COLORS[t] ?? "var(--dim)"}`}>{t}</span>:</Fragment>
+ ))}</span></p>
+ )}
+</header>
diff --git a/src/components/post/Outline.astro b/src/components/post/Outline.astro
@@ -0,0 +1,26 @@
+---
+// imenu-list: the post's headings in an *Ilist* window, beside the text on wide
+// screens and above it on narrow ones. It marks the heading you're reading.
+import Winbar from "../frame/Winbar.astro";
+import "../../styles/post/outline.css";
+
+interface Props {
+ headings: { depth: number; slug: string; text: string }[];
+}
+
+const { headings } = Astro.props;
+---
+<aside class="post-toc" aria-labelledby="toc-title">
+ <Winbar file="*Ilist*" fileId="toc-title" meta="contents" />
+ <ol>
+ {headings.map((h) => (
+ <li class={`d${h.depth}`}><a href={`#${h.slug}`}><span class="mk" aria-hidden="true">{"*".repeat(h.depth - 1)}</span> {h.text}</a></li>
+ ))}
+ </ol>
+</aside>
+
+<script>
+ import { initOutline } from "../../scripts/post/outline.js";
+
+ initOutline();
+</script>
diff --git a/src/components/post/PostNav.astro b/src/components/post/PostNav.astro
@@ -0,0 +1,17 @@
+---
+// The older and newer posts, and a way back to the list.
+import "../../styles/post/nav.css";
+
+type Link = { id: string; data: { title: string } } | undefined;
+interface Props {
+ older: Link;
+ newer: Link;
+}
+
+const { older, newer } = Astro.props;
+---
+<nav class="post-nav" aria-label="More posts">
+ {older ? <a class="older" href={`/posts/${older.id}/`}><span class="dim">older</span>{older.data.title}</a> : <span></span>}
+ {newer && <a class="newer" href={`/posts/${newer.id}/`}><span class="dim">newer</span>{newer.data.title}</a>}
+</nav>
+<p class="post-all"><a href="/#posts">All posts</a></p>
diff --git a/src/content.config.ts b/src/content.config.ts
@@ -18,4 +18,26 @@ const posts = defineCollection({
}),
});
-export const collections = { posts };
+// Projects in the memory map on the home page, one file each, listed by order.
+// Each one's region holds its summary as UTF-8, so the summary sets its size.
+const projects = defineCollection({
+ loader: glob({ pattern: "*.md", base: "./src/content/projects" }),
+ schema: z.object({
+ name: z.string(),
+ order: z.number(),
+ cat: z.enum(["hw", "sw", "math", "sec"]),
+ // Shown in the source column: built, private, a repo...
+ src: z.string(),
+ summary: z.string(),
+ // computer, scheduler, bytecode, othello or rbudp (src/scripts/demos/<demo>.js)
+ demo: z.string().optional(),
+ // The demo gets the full width under the list.
+ wide: z.boolean().default(false),
+ stack: z.string().optional(),
+ links: z.array(z.object({ text: z.string(), href: z.string() })).default([]),
+ // A line in place of links, like "Source private (coursework)".
+ note: z.string().optional(),
+ }),
+});
+
+export const collections = { posts, projects };
diff --git a/src/content/projects/8-bit-computer.md b/src/content/projects/8-bit-computer.md
@@ -0,0 +1,17 @@
+---
+name: 8-bit-computer
+order: 1
+cat: hw
+src: built
+demo: computer
+wide: true
+summary: "Ben Eater's 8-bit breadboard computer, built chip by chip."
+stack: "74HC logic, EEPROMs, breadboards, a lot of jumper wire"
+links:
+ - text: "Ben Eater’s series"
+ href: "https://eater.net/8bit"
+---
+
+My most recent finished project: a programmable computer on breadboards, built from 74-series logic chips following Ben Eater’s video series. It has 16 bytes of RAM, an 8-bit bus, and microcode in EEPROMs that drives sixteen control lines.
+
+The simulation below runs the same instruction set and microcode, so you can step through a program one control word at a time.
diff --git a/src/content/projects/chat-voip-p2p.md b/src/content/projects/chat-voip-p2p.md
@@ -0,0 +1,19 @@
+---
+name: chat-voip-p2p
+order: 5
+cat: sw
+src: private
+demo: rbudp
+summary: "Four networking projects in Java: chat, fast UDP file transfer, voice calls and P2P sharing."
+stack: "Java, JavaFX, TCP and UDP sockets, Java Sound, RSA, AES-GCM"
+note: "Source private (coursework, group project)"
+---
+
+A series of networking projects built with my group for a university course:
+
+- A chat server that handles many clients at once, with a JavaFX client, load-tested with dozens of simultaneous users.
+- File transfer over Reliable Blast UDP, measured against TCP across packet sizes, loss rates and file sizes from 1 to 100 MB.
+- The chat extended with voice calls and group calls streamed over UDP, plus voice notes.
+- Peer-to-peer file sharing: a server brokers searches, then peers send files to each other directly over TCP, encrypted with AES-GCM keys exchanged using RSA, with pause and resume. We tested it across four machines over Tailscale.
+
+Below is the RBUDP transfer in miniature: blast every packet over UDP, ask over TCP which ones arrived, and resend the gaps.
diff --git a/src/content/projects/jvm-compiler.md b/src/content/projects/jvm-compiler.md
@@ -0,0 +1,12 @@
+---
+name: jvm-compiler
+order: 4
+cat: sw
+src: private
+demo: bytecode
+summary: "A compiler in C for a custom language that targets the JVM."
+stack: "C, JVM bytecode"
+note: "Source private (computer architecture coursework)"
+---
+
+I wrote it in C for a computer architecture course. It reads a small custom programming language and produces bytecode the Java virtual machine can run. The source stays private, but the last stage is easy to show: the JVM is a stack machine, so an expression compiles to instructions that push and pop an operand stack. Type one below to see the bytecode and run it.
diff --git a/src/content/projects/os-scheduler.md b/src/content/projects/os-scheduler.md
@@ -0,0 +1,11 @@
+---
+name: os-scheduler
+order: 2
+cat: sw
+src: private
+demo: scheduler
+summary: "A multicore operating system scheduler, for a concurrency course."
+note: "Source private (concurrency coursework)"
+---
+
+The part of an operating system that decides which process runs on which core, and for how long. I wrote it for a concurrency course, so the source stays private, but the idea fits in a small simulation: processes arrive, wait in a shared ready queue, and get dispatched to cores under first come, first served, round robin or preemptive priority scheduling.
diff --git a/src/content/projects/othello-mcts.md b/src/content/projects/othello-mcts.md
@@ -0,0 +1,16 @@
+---
+name: othello-mcts
+order: 3
+cat: sw
+src: private
+demo: othello
+summary: "An Othello player in C that runs Monte Carlo tree search across MPI processes."
+stack: "C, MPI, Docker"
+note: "Source private (concurrency coursework)"
+---
+
+The player picks a move by running thousands of random games from the current position and keeping the move that was explored most, using UCT to balance trying new moves against improving promising ones. Each move gets one second.
+
+The project was for a concurrency course, so the next step was to parallelise it with MPI, which I did in three ways: independent search trees combined at the end with `MPI_Allreduce`, trees that share their statistics every so often, and the same sharing done asynchronously with non-blocking sends. In ten-game matches on four processes, each version beat the one before it, except the asynchronous version, which won half its games against the synchronous one.
+
+You can play a serial version, rewritten in JavaScript for this page, below. While it thinks, each legal move is shaded by how often the search tried it.
diff --git a/src/data/pins.js b/src/data/pins.js
@@ -0,0 +1,22 @@
+// The MN-2026 chip's pins on the landing section, in pin order: 1–8 run down the
+// left side and 9–16 back up the right. cat is the legend colour (hw, sw, math
+// or sec, or none). "M-x some-command" in a description becomes a button.
+
+export const PINS = [
+ { name: "VCC", type: "power", text: "Supply input: coffee. Top it up with M-x coffee-refill." },
+ { name: "C", type: "I/O", cat: "sw", text: "Most of what I write, from a compiler to terminal toys like a spinning cube and the Game of Life." },
+ { name: "ASM", type: "I/O", cat: "sw", text: "Assembly, starting with the 8-bit computer’s eleven instructions." },
+ { name: "JVM", type: "output", cat: "sw", text: "The target of my compiler, so I read javap output for fun." },
+ { name: "LINUX", type: "I/O", cat: "sw", text: "Daily driver. Dotfiles on GitHub, and posts on packaging formats and turning a window manager into a desktop." },
+ { name: "TTL", type: "I/O", cat: "hw", text: "74-series logic. The 8-bit computer is dozens of these chips on breadboards." },
+ { name: "SEC", type: "input", cat: "sec", text: "Security: a consulting internship, a web application assessment, and the habit of asking how things break." },
+ { name: "GND", type: "power", text: "Return path. Sleep, when the config is finished (it is never finished)." },
+ { name: "RST", type: "input", text: "Active low. Asserted by a walk without a laptop." },
+ { name: "TA", type: "output", cat: "hw", text: "Teaching assistant for introductory computer science and computer architecture." },
+ { name: "TEX", type: "output", cat: "math", text: "Notes, templates and maths typeset in LaTeX, usually from Emacs." },
+ { name: "ORG", type: "I/O", cat: "sw", text: "Org mode and org-roam for notes, literate configs, and replacing Jupyter." },
+ { name: "EMACS", type: "I/O", cat: "sw", text: "Doom Emacs, with a literate config of over a thousand lines. Location: ~/.config/emacs/." },
+ { name: "MATH", type: "input", cat: "math", text: "Project Euler, discrete maths, and modelling things in Python." },
+ { name: "CAN", type: "I/O", text: "CAN bus, the network inside every car. Cars are my one interest that isn’t a computer, mostly." },
+ { name: "NC", type: "no connect", text: "Reserved for the next obsession." },
+];
diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro
@@ -1,8 +1,15 @@
---
-// The Emacs frame every page shares: head, top line, modeline, minibuffer and
-// the *Help* window. Pages fill the default slot with <main>, add page scripts
-// to the "head" slot and their keys to the "help" slot.
-import "../styles/style.css";
+// The Emacs frame every page shares: the head, the top line, the footer, the
+// modeline and minibuffer, and *Help*. Pages fill the default slot with <main>,
+// add to <head> through the "head" slot and list their own keys in "help".
+import TopBar from "../components/frame/TopBar.astro";
+import Footer from "../components/frame/Footer.astro";
+import Modeline from "../components/frame/Modeline.astro";
+import Help from "../components/frame/Help.astro";
+import Analytics from "../components/frame/Analytics.astro";
+import "../styles/fonts.css";
+import "../styles/themes.css";
+import "../styles/base.css";
interface Props {
title?: string;
@@ -24,10 +31,6 @@ const {
mode,
skip,
} = Astro.props;
-const at = home ? "" : "/";
-// Google Analytics, as on the Hugo site: production builds only, so local work isn't counted.
-const GA_ID = "G-BLX24KP7CS";
-const year = new Date().getFullYear();
---
<!doctype html>
<html lang="en" data-theme="doom-gruvbox">
@@ -50,91 +53,16 @@ const year = new Date().getFullYear();
if (t) document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
-{import.meta.env.PROD && (
- <Fragment>
- <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}></script>
- <script is:inline define:vars={{ GA_ID }}>
- window.dataLayer = window.dataLayer || [];
- function gtag() { dataLayer.push(arguments); }
- gtag("js", new Date());
- gtag("config", GA_ID);
- </script>
- </Fragment>
-)}
+<Analytics />
<slot name="head" />
</head>
<!-- Reading the source? Good instinct. flag{view_source_is_recon} -->
<body>
<a class="skip" href={skip}>Skip to content</a>
-
-<header class="top">
- <a class="brand" href={home ? "#top" : "/"} aria-label={home ? "Michael Neuper, back to top" : "Michael Neuper, home"}>
- <svg class="logo" viewBox="0 0 32 16" aria-hidden="true"><path d="M1 14H3V2H9V14H11V2H17V14H20V2H26V14H31"/></svg>
- <span>michael neuper</span>
- </a>
- <nav class="nav" id="nav" aria-label="Sections">
- <a href={`${at}#about`}>about</a>
- <a href={`${at}#projects`}>projects</a>
- <a href={`${at}#posts`}>posts</a>
- <a href={`${at}#contact`}>contact</a>
- </nav>
- <button class="theme-key" type="button" title="SPC h t">load-theme</button>
- <button class="menu-key" type="button" aria-expanded="false" aria-controls="nav">menu</button>
-</header>
-
+<TopBar home={home} />
<slot />
-
-<footer class="foot">
- <p>© {year} Michael Neuper</p>
-</footer>
-
-<!-- ───────────────────────── modeline and minibuffer ───────────────────────── -->
-<div class="which-key" hidden aria-live="polite"></div>
-<div class="frame-bottom">
- <div class="modeline">
- <span class="ml-evil" title="evil state"><N></span>
- <span class="ml-buf">{buf}</span>
- <span class="ml-pos" aria-hidden="true">1:0</span>
- <span class="ml-pct" aria-hidden="true">Top</span>
- <span class="ml-spacer"></span>
- <span class="ml-misc" aria-live="polite"></span>
- <span class="ml-flags" aria-hidden="true"></span>
- <span class="ml-mode">{mode}</span>
- <span class="ml-enc" aria-hidden="true">LF UTF-8</span>
- <span class="ml-vc" aria-hidden="true">master</span>
- <span class="ml-time" aria-hidden="true"></span>
- </div>
- <div class="mini" hidden>
- <form class="mini-prompt" autocomplete="off">
- <label><span class="mini-count"></span><span class="mini-ps"></span><input type="text" spellcheck="false" autocapitalize="off" role="combobox" aria-expanded="true" aria-controls="mini-list" aria-autocomplete="list"></label>
- </form>
- <ul class="mini-list" id="mini-list" role="listbox"></ul>
- </div>
- <div class="echo" role="status"></div>
-</div>
-
-<dialog class="help" aria-labelledby="help-title">
- <p class="winbar"><span class="file" id="help-title">*Help*</span><span class="rule" aria-hidden="true"></span><span class="meta">q or esc to close</span></p>
- <div class="help-cols">
- <div>
- <h3>Keys</h3>
- <dl>
- <div><dt><kbd>SPC</kbd></dt><dd>leader key: shows every command</dd></div>
- <div><dt><kbd>M-x</kbd></dt><dd>run a command by name (Alt+x or Option+x)</dd></div>
- <div><dt><kbd>SPC h t</kbd></dt><dd>load a theme, with live preview</dd></div>
- <div><dt><kbd>:</kbd></dt><dd>evil ex commands, like <code>:q</code></dd></div>
- <div><dt><kbd>C-g</kbd></dt><dd>cancel (so does <kbd>esc</kbd>)</dd></div>
- </dl>
- </div>
- <div>
- <h3>On this page</h3>
- <dl>
- <slot name="help" />
- <div><dt><code>submit-flag</code></dt><dd>check a CTF flag</dd></div>
- </dl>
- </div>
- </div>
- <form method="dialog"><button class="btn" type="submit">close</button></form>
-</dialog>
+<Footer />
+<Modeline buf={buf} mode={mode} />
+<Help><slot name="help" /></Help>
</body>
</html>
diff --git a/src/pages/404.astro b/src/pages/404.astro
@@ -1,17 +1,22 @@
---
import Base from "../layouts/Base.astro";
+import Buffer from "../components/frame/Buffer.astro";
---
<Base title="Not found - Michael Neuper" buf="*Messages*" mode="Messages" skip="#missing">
- <Fragment slot="head">
- <script src="../scripts/page.js"></script>
- </Fragment>
-
<main>
- <section class="sec" id="missing" data-file="*Messages*" data-mode="Messages" aria-labelledby="missing-title">
- <p class="winbar"><span class="file">*Messages*</span><span class="rule" aria-hidden="true"></span><span class="meta">404</span></p>
+ <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="missing-links"><a href="/">Home</a> <a href="/#posts">Posts</a></p>
- </section>
+ <p class="links"><a href="/">Home</a> <a href="/#posts">Posts</a></p>
+ </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
@@ -0,0 +1,186 @@
+// giscus themes made from the site's own colours: /giscus/<theme>.css for each
+// Emacs theme, so the comments under a post match whichever one is loaded.
+// The colours are read from themes.css, so a theme only has to be defined there.
+// giscus loads this (and the fonts it names) from inside its own frame, so
+// netlify.toml allows giscus.app to fetch both.
+
+import styles from "../../styles/themes.css?raw";
+import { THEMES, DEFAULT_THEME } from "../../scripts/themes.js";
+
+export function getStaticPaths() {
+ return THEMES.map((t) => ({ params: { theme: t.id } }));
+}
+
+// The declarations in the first block that matches, as { name: value }.
+function block(selector) {
+ const m = styles.match(new RegExp(`${selector}\\s*\\{([^}]*)\\}`));
+ return Object.fromEntries([...(m?.[1] ?? "").matchAll(/(--[\w-]+|color-scheme):\s*([^;]+);/g)].map((d) => [d[1], d[2].trim()]));
+}
+
+const TOKENS = ["--bg", "--bg-alt", "--fg", "--red", "--orange", "--yellow", "--green", "--cyan", "--blue", "--magenta"];
+
+export function GET({ params }) {
+ const base = block(":root");
+ const theme = params.theme === DEFAULT_THEME ? base : { ...base, ...block(`:root\\[data-theme="${params.theme}"\\]`) };
+ const light = theme["color-scheme"] === "light";
+ const tokens = TOKENS.map((t) => ` ${t}: ${theme[t]};`).join("\n");
+ return new Response(css(tokens, light), { headers: { "Content-Type": "text/css; charset=utf-8" } });
+}
+
+const mix = (a, pct, b = "transparent") => `color-mix(in srgb, var(${a}) ${pct}%, ${b === "transparent" ? b : `var(${b})`})`;
+
+const css = (tokens, light) => `/* giscus theme for michaelneuper.com, made from the site's colours. */
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-regular.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-semibold.woff2") format("woff2");
+ font-weight: 600;
+ 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"};
+${tokens}
+ --dim: ${mix("--fg", 64, "--bg")};
+ --faint: ${mix("--fg", 34, "--bg")};
+ --rule: ${mix("--fg", 15, "--bg")};
+ --hl: ${mix("--fg", 10, "--bg")};
+
+ --color-prettylights-syntax-comment: var(--dim);
+ --color-prettylights-syntax-constant: var(--orange);
+ --color-prettylights-syntax-entity: var(--blue);
+ --color-prettylights-syntax-storage-modifier-import: var(--fg);
+ --color-prettylights-syntax-entity-tag: var(--green);
+ --color-prettylights-syntax-keyword: var(--magenta);
+ --color-prettylights-syntax-string: var(--green);
+ --color-prettylights-syntax-variable: var(--orange);
+ --color-prettylights-syntax-brackethighlighter-unmatched: var(--red);
+ --color-prettylights-syntax-invalid-illegal-text: var(--bg);
+ --color-prettylights-syntax-invalid-illegal-bg: var(--red);
+ --color-prettylights-syntax-carriage-return-text: var(--bg);
+ --color-prettylights-syntax-carriage-return-bg: var(--red);
+ --color-prettylights-syntax-string-regexp: var(--cyan);
+ --color-prettylights-syntax-markup-list: var(--yellow);
+ --color-prettylights-syntax-markup-heading: var(--blue);
+ --color-prettylights-syntax-markup-italic: var(--fg);
+ --color-prettylights-syntax-markup-bold: var(--fg);
+ --color-prettylights-syntax-markup-deleted-text: var(--red);
+ --color-prettylights-syntax-markup-deleted-bg: ${mix("--red", 15)};
+ --color-prettylights-syntax-markup-inserted-text: var(--green);
+ --color-prettylights-syntax-markup-inserted-bg: ${mix("--green", 15)};
+ --color-prettylights-syntax-markup-changed-text: var(--yellow);
+ --color-prettylights-syntax-markup-changed-bg: ${mix("--yellow", 15)};
+ --color-prettylights-syntax-markup-ignored-text: var(--dim);
+ --color-prettylights-syntax-markup-ignored-bg: var(--hl);
+ --color-prettylights-syntax-meta-diff-range: var(--magenta);
+ --color-prettylights-syntax-brackethighlighter-angle: var(--dim);
+ --color-prettylights-syntax-sublimelinter-gutter-mark: var(--faint);
+ --color-prettylights-syntax-constant-other-reference-link: var(--cyan);
+
+ --color-btn-text: var(--fg);
+ --color-btn-bg: transparent;
+ --color-btn-border: var(--faint);
+ --color-btn-shadow: none;
+ --color-btn-inset-shadow: none;
+ --color-btn-hover-bg: var(--hl);
+ --color-btn-hover-border: var(--fg);
+ --color-btn-active-bg: var(--hl);
+ --color-btn-active-border: var(--fg);
+ --color-btn-selected-bg: var(--hl);
+ --color-btn-primary-text: var(--bg);
+ --color-btn-primary-bg: var(--fg);
+ --color-btn-primary-border: var(--fg);
+ --color-btn-primary-shadow: none;
+ --color-btn-primary-inset-shadow: none;
+ --color-btn-primary-hover-bg: ${mix("--fg", 85, "--bg")};
+ --color-btn-primary-hover-border: ${mix("--fg", 85, "--bg")};
+ --color-btn-primary-selected-bg: var(--fg);
+ --color-btn-primary-selected-shadow: none;
+ --color-btn-primary-disabled-text: var(--bg);
+ --color-btn-primary-disabled-bg: var(--faint);
+ --color-btn-primary-disabled-border: var(--faint);
+ --color-action-list-item-default-hover-bg: var(--hl);
+ --color-segmented-control-bg: var(--hl);
+ --color-segmented-control-button-bg: var(--bg);
+ --color-segmented-control-button-selected-border: var(--faint);
+
+ --color-fg-default: var(--fg);
+ --color-fg-muted: var(--dim);
+ --color-fg-subtle: var(--faint);
+ --color-canvas-default: transparent;
+ --color-canvas-overlay: var(--bg-alt);
+ --color-canvas-inset: var(--hl);
+ --color-canvas-subtle: var(--hl);
+ --color-border-default: var(--rule);
+ --color-border-muted: var(--rule);
+ --color-neutral-muted: var(--hl);
+ --color-accent-fg: var(--blue);
+ --color-accent-emphasis: var(--blue);
+ --color-accent-muted: ${mix("--blue", 40)};
+ --color-accent-subtle: ${mix("--blue", 12)};
+ --color-success-fg: var(--green);
+ --color-attention-fg: var(--yellow);
+ --color-attention-muted: ${mix("--yellow", 40)};
+ --color-attention-subtle: ${mix("--yellow", 15)};
+ --color-danger-fg: var(--red);
+ --color-danger-muted: ${mix("--red", 40)};
+ --color-danger-subtle: ${mix("--red", 12)};
+ --color-primer-shadow-inset: none;
+ --color-scale-gray-7: var(--hl);
+ --color-scale-blue-8: ${mix("--blue", 30, "--bg")};
+ --color-social-reaction-bg-hover: var(--color-scale-gray-7);
+ --color-social-reaction-bg-reacted-hover: var(--color-scale-blue-8);
+
+ font-family: "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
+ font-size: 13px;
+}
+
+main .pagination-loader-container {
+ background-image: url(https://github.com/images/modules/pulls/progressive-disclosure-line${light ? "" : "-dark"}.svg);
+}
+
+main .gsc-loading-image {
+ background-image: url(https://github.githubassets.com/images/mona-loading-${light ? "default" : "dark"}.gif);
+}
+
+/* square corners and no shadows, like the rest of the site */
+main *,
+main *::before,
+main *::after {
+ border-radius: 0 !important;
+ box-shadow: none !important;
+}
+
+/* what people write reads in the serif, like the posts */
+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-size: 13px;
+}
+`;
diff --git a/src/pages/index.astro b/src/pages/index.astro
@@ -1,264 +1,37 @@
---
import Base from "../layouts/Base.astro";
-import { publishedPosts, ymd } from "../lib/posts.js";
-
-const posts = await publishedPosts();
+import Hero from "../components/home/Hero.astro";
+import About from "../components/home/About.astro";
+import Projects from "../components/home/Projects.astro";
+import Posts from "../components/home/Posts.astro";
+import Contact from "../components/home/Contact.astro";
+import Coffee from "../components/home/Coffee.astro";
---
<Base home buf="sap-1.asm" mode="Asm" skip="#about">
-<Fragment slot="head">
-<script is:inline>
- // Hide the name until src/scripts/decode.js has split it into letters, so it never flashes whole first.
- // CSS shows it anyway after a moment in case the script never runs.
- try {
- if (!matchMedia("(prefers-reduced-motion: reduce)").matches && (!location.hash || location.hash === "#top")) {
- document.documentElement.classList.add("boot");
- }
- } catch (e) {}
-</script>
-<script src="../scripts/main.js"></script>
-</Fragment>
-<Fragment slot="help">
- <div><dt><kbd>c</kbd> <kbd>n</kbd> <kbd>r</kbd></dt><dd>computer: run or pause, step, reset</dd></div>
- <div><dt><kbd>TAB</kbd></dt><dd>fold the Org heading under focus</dd></div>
- <div><dt><kbd>S-TAB</kbd></dt><dd>cycle the whole Org buffer</dd></div>
- <div><dt><code>coffee-refill</code></dt><dd>top up VCC and overclock the computer</dd></div>
-</Fragment>
-
-<main>
-
- <!-- ─────────────────────────── landing ─────────────────────────── -->
- <section class="hero" id="top" data-file="mn-2026.kicad_sym" data-mode="Lisp Data" aria-labelledby="hero-name">
- <div class="hero-id">
- <h1 class="hero-name" id="hero-name">Michael Neuper</h1>
- <p class="hero-lede">Computer science student at Stellenbosch University. I like low-level software, electronics and the maths underneath both, and I do almost all of it in Emacs.</p>
- <ul class="legend" aria-label="Colour key used across the site">
- <li data-cat="hw">hardware</li>
- <li data-cat="sw">software</li>
- <li data-cat="math">maths</li>
- <li data-cat="sec">security</li>
- </ul>
- </div>
-
- <div class="hero-chip">
- <figure class="pinout">
- <div class="chip-wrap"><!-- built from the list below --></div>
- <div class="pin-readout" aria-live="polite"><p class="dim">Point at a pin to read its function.</p></div>
- <figcaption>Figure 1. MN-2026, DIP-16 package, top view.</figcaption>
- <!-- Edit the pins here. data-cat: hw, sw, math or sec. "M-x some-command" in a description becomes a button. -->
- <ol class="pin-list">
- <li data-name="VCC" data-type="power">Supply input: coffee. Top it up with M-x coffee-refill.</li>
- <li data-name="C" data-type="I/O" data-cat="sw">Most of what I write, from a compiler to terminal toys like a spinning cube and the Game of Life.</li>
- <li data-name="ASM" data-type="I/O" data-cat="sw">Assembly, starting with the 8-bit computer’s eleven instructions.</li>
- <li data-name="JVM" data-type="output" data-cat="sw">The target of my compiler, so I read javap output for fun.</li>
- <li data-name="LINUX" data-type="I/O" data-cat="sw">Daily driver. Dotfiles on GitHub, and posts on packaging formats and turning a window manager into a desktop.</li>
- <li data-name="TTL" data-type="I/O" data-cat="hw">74-series logic. The 8-bit computer is dozens of these chips on breadboards.</li>
- <li data-name="SEC" data-type="input" data-cat="sec">Security: a consulting internship, a web application assessment, and the habit of asking how things break.</li>
- <li data-name="GND" data-type="power">Return path. Sleep, when the config is finished (it is never finished).</li>
- <li data-name="RST" data-type="input">Active low. Asserted by a walk without a laptop.</li>
- <li data-name="TA" data-type="output" data-cat="hw">Teaching assistant for introductory computer science and computer architecture.</li>
- <li data-name="TEX" data-type="output" data-cat="math">Notes, templates and maths typeset in LaTeX, usually from Emacs.</li>
- <li data-name="ORG" data-type="I/O" data-cat="sw">Org mode and org-roam for notes, literate configs, and replacing Jupyter.</li>
- <li data-name="EMACS" data-type="I/O" data-cat="sw">Doom Emacs, with a literate config of over a thousand lines. Location: ~/.config/emacs/.</li>
- <li data-name="MATH" data-type="input" data-cat="math">Project Euler, discrete maths, and modelling things in Python.</li>
- <li data-name="CAN" data-type="I/O">CAN bus, the network inside every car. Cars are my one interest that isn’t a computer, mostly.</li>
- <li data-name="NC" data-type="no connect">Reserved for the next obsession.</li>
- </ol>
- </figure>
- </div>
- </section>
-
- <!-- ───────────────────────────── datasheet ───────────────────────────── -->
- <section class="sec" id="about" data-file="whoami.pdf" data-mode="PDFView">
- <p class="winbar"><span class="file">whoami.pdf</span><span class="rule" aria-hidden="true"></span><span class="meta">revised June 2026</span></p>
-
- <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>
-
- <!-- 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,
- b bbd-software, i mwr-internship (the colours are set in src/styles/style.css). data-cont is the graph that continues below a commit, and a
- .gl-desc becomes its expandable description. data-branch colours the date; data-tip makes the
- * bold for a branch that's still going; data-today swaps in the current month when the page loads.
- Without JavaScript this shows as plain text. -->
- <h3 class="ds-h gitlog-cmd"><span class="prompt" aria-hidden="true">$ </span>git log --graph --all --color \
- --date=format:'%Y-%m' \
- --format='%ad -%C(auto)%d%C(reset) %s %C(blue)<%an>%C(reset)'</h3>
- <div class="gitlog" aria-label="Experience, newest first">
-<div class="gl-line" data-lanes=" b u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="m" data-tip data-today>* | | | 2026-10 - (HEAD -> main)</div>
-<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b" data-tip>| * | | 2026-06 - (bbd-software) Vac Week 2026 <BBD Software><div class="gl-desc">Built an online foosball game: players steer the rods by
-tilting their phones, using the gyroscope. Designed and
-presented as a full-stack web app with a small team.</div></div>
-<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t" data-tip>| | | * 2026-06 - (teaching-assistant) Teach computer architecture as a TA <Stellenbosch University></div>
-<div class="gl-line" data-lanes="m b u t">| | | |</div>
-<div class="gl-line" data-lanes="m b u " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="t">| | | * 2026-02 - Teach introductory computer science as a TA <Stellenbosch University></div>
-<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-06 - Vac Week 2025 <BBD Software><div class="gl-desc">Built an online laser tag game: computer vision finds players
-in the camera feed and tags them. Designed and presented as a
-full-stack web app with a small team.</div></div>
-<div class="gl-line" data-lanes="m b u t">| | | |</div>
-<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m b u t " data-branch="b">| * | | 2025-01 - Join BBD Software as a bursar <BBD Software><div class="gl-desc">Part of the Bursary & Beyond Programme, which includes the
-yearly Vac Weeks.</div></div>
-<div class="gl-line" data-lanes="mb u t">|/ | |</div>
-<div class="gl-line" data-lanes="m u t">| | |</div>
-<div class="gl-line" data-lanes="mi u t">|\ | |</div>
-<div class="gl-line" data-lanes="m u t " data-cont="| | | | " data-cont-lanes="m i u t " data-branch="i">| * | | 2024-12 - (mwr-internship) Complete a cybersecurity consultant internship <MWR CyberSec><div class="gl-desc">- Completed cybersecurity training with labs on TryHackMe.
-- Assessed a web application end to end, documenting each
- vulnerability with its risk and how to fix it.
-- Wrote a professional report and presented it, client-style,
- to a panel of industry professionals.</div></div>
-<div class="gl-line" data-lanes="mi u t">|/ | |</div>
-<div class="gl-line" data-lanes="m ut">| |/</div>
-<div class="gl-line" data-lanes="m " data-cont="| | " data-cont-lanes="m u " data-branch="u" data-tip>| * 2024-01 - (undergrad) Start an undergraduate degree in computer science <Stellenbosch University></div>
-<div class="gl-line" data-lanes="m u">| /</div>
-<div class="gl-line" data-lanes="mu">|/</div>
-<div class="gl-line" data-lanes=" " data-cont=" " data-cont-lanes=" " data-branch="m">* 2005-06 - Initial commit <Michael Neuper></div>
- </div>
- <p class="gitlog-hint dim">Click an underlined commit message to read the rest of it.</p>
-
- </section>
-
- <!-- ──────────────────────────── memory map ──────────────────────────── -->
- <section class="sec" id="projects" data-file="memory.map" data-mode="Fundamental">
- <p class="winbar"><span class="file">memory.map</span><span class="rule" aria-hidden="true"></span><span class="meta" data-mm="meta"></span></p>
- <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>
-
- <div class="mm">
- <div class="mm-bar" aria-hidden="true"></div>
- <div class="mm-cols">
- <div class="mm-table">
- <p class="mm-head" aria-hidden="true"><span>origin</span><span>length</span><span>name</span><span>source</span></p>
- <!-- Edit projects here. data-cat: hw, sw, math or sec. data-src: shown in the source column.
- data-demo: computer, scheduler, bytecode, othello or rbudp (optional); data-wide gives the
- demo the full width under the list. -->
- <ol class="mm-list">
- <li class="proj" data-cat="hw" data-src="built" data-demo="computer" data-wide>
- <h3 class="proj-name">8-bit-computer</h3>
- <p class="proj-sum">Ben Eater's 8-bit breadboard computer, built chip by chip.</p>
- <div class="proj-body">
- <p>My most recent finished project: a programmable computer on breadboards, built from 74-series logic chips following Ben Eater’s video series. It has 16 bytes of RAM, an 8-bit bus, and microcode in EEPROMs that drives sixteen control lines.</p>
- <p>The simulation below runs the same instruction set and microcode, so you can step through a program one control word at a time.</p>
- <p class="proj-stack">74HC logic, EEPROMs, breadboards, a lot of jumper wire</p>
- <p class="proj-links"><a href="https://eater.net/8bit">Ben Eater’s series</a></p>
- </div>
- </li>
- <li class="proj" data-cat="sw" data-src="private" data-demo="scheduler">
- <h3 class="proj-name">os-scheduler</h3>
- <p class="proj-sum">A multicore operating system scheduler, for a concurrency course.</p>
- <div class="proj-body">
- <p>The part of an operating system that decides which process runs on which core, and for how long. I wrote it for a concurrency course, so the source stays private, but the idea fits in a small simulation: processes arrive, wait in a shared ready queue, and get dispatched to cores under first come, first served, round robin or preemptive priority scheduling.</p>
- <p class="proj-links dim">Source private (concurrency coursework)</p>
- </div>
- </li>
- <li class="proj" data-cat="sw" data-src="private" data-demo="othello">
- <h3 class="proj-name">othello-mcts</h3>
- <p class="proj-sum">An Othello player in C that runs Monte Carlo tree search across MPI processes.</p>
- <div class="proj-body">
- <p>The player picks a move by running thousands of random games from the current position and keeping the move that was explored most, using UCT to balance trying new moves against improving promising ones. Each move gets one second.</p>
- <p>The project was for a concurrency course, so the next step was to parallelise it with MPI, which I did in three ways: independent search trees combined at the end with <code>MPI_Allreduce</code>, trees that share their statistics every so often, and the same sharing done asynchronously with non-blocking sends. In ten-game matches on four processes, each version beat the one before it, except the asynchronous version, which won half its games against the synchronous one.</p>
- <p>You can play a serial version, rewritten in JavaScript for this page, below. While it thinks, each legal move is shaded by how often the search tried it.</p>
- <p class="proj-stack">C, MPI, Docker</p>
- <p class="proj-links dim">Source private (concurrency coursework)</p>
- </div>
- </li>
- <li class="proj" data-cat="sw" data-src="private" data-demo="bytecode">
- <h3 class="proj-name">jvm-compiler</h3>
- <p class="proj-sum">A compiler in C for a custom language that targets the JVM.</p>
- <div class="proj-body">
- <p>I wrote it in C for a computer architecture course. It reads a small custom programming language and produces bytecode the Java virtual machine can run. The source stays private, but the last stage is easy to show: the JVM is a stack machine, so an expression compiles to instructions that push and pop an operand stack. Type one below to see the bytecode and run it.</p>
- <p class="proj-stack">C, JVM bytecode</p>
- <p class="proj-links dim">Source private (computer architecture coursework)</p>
- </div>
- </li>
- <li class="proj" data-cat="sw" data-src="private" data-demo="rbudp">
- <h3 class="proj-name">chat-voip-p2p</h3>
- <p class="proj-sum">Four networking projects in Java: chat, fast UDP file transfer, voice calls and P2P sharing.</p>
- <div class="proj-body">
- <p>A series of networking projects built with my group for a university course:</p>
- <ul>
- <li>A chat server that handles many clients at once, with a JavaFX client, load-tested with dozens of simultaneous users.</li>
- <li>File transfer over Reliable Blast UDP, measured against TCP across packet sizes, loss rates and file sizes from 1 to 100 MB.</li>
- <li>The chat extended with voice calls and group calls streamed over UDP, plus voice notes.</li>
- <li>Peer-to-peer file sharing: a server brokers searches, then peers send files to each other directly over TCP, encrypted with AES-GCM keys exchanged using RSA, with pause and resume. We tested it across four machines over Tailscale.</li>
- </ul>
- <p>Below is the RBUDP transfer in miniature: blast every packet over UDP, ask over TCP which ones arrived, and resend the gaps.</p>
- <p class="proj-stack">Java, JavaFX, TCP and UDP sockets, Java Sound, RSA, AES-GCM</p>
- <p class="proj-links dim">Source private (coursework, group project)</p>
- </div>
- </li>
- </ol>
- </div>
- <article class="mm-detail" aria-live="polite"></article>
- </div>
- </div>
- </section>
-
- <!-- ────────────────────────────── posts ────────────────────────────── -->
- <section class="sec" id="posts" data-file="posts.org" data-mode="Org">
- <p class="winbar"><span class="file">posts.org</span><span class="rule" aria-hidden="true"></span><span class="meta" data-org="meta"></span></p>
- <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>
-
- <!-- Built from the posts in src/content/posts. data-tags are space separated; each tag gets its own colour (see TAG_COLORS in src/scripts/tags.js). -->
- <ol class="posts">
- {posts.map((p) => (
- <li data-date={ymd(p.data.date)} data-tags={p.data.tags.join(" ")} data-cat={p.data.cat}><a href={`/posts/${p.id}/`}>{p.data.title}</a></li>
- ))}
- </ol>
- </section>
-
- <!-- ───────────────────────────── contact ───────────────────────────── -->
- <section class="sec" id="contact" data-file="*unsent mail*" data-mode="Message">
- <p class="winbar"><span class="file">*unsent mail*</span><span class="rule" aria-hidden="true"></span><span class="meta">draft</span></p>
- <h2 class="h2">Say hello</h2>
- <p class="lede">Email works best. For anything private, encrypt it with my GPG key below.</p>
-
- <div class="contact">
- <!-- The address is only ever put together in JavaScript, 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>
- <button type="button" class="btn" data-email="copy">copy address</button>
- </div>
-
- <div class="contact-grid">
- <div class="gpg">
- <h3 class="ds-h">Encrypt it</h3>
- <p class="gpg-fp"><span class="dim">fingerprint</span><code>4E7B F800 0D86 182D F484 5C37 508B CF84 373C 8930</code></p>
- <div class="gpg-cmd">
- <pre><code>curl -sL https://michaelneuper.com/michaelneuper.gpg | gpg --import</code></pre>
- <button type="button" class="btn" data-copy=".gpg-cmd code">copy</button>
- </div>
- </div>
- <div>
- <h3 class="ds-h">Elsewhere</h3>
- <ul class="elsewhere">
- <li><a href="https://github.com/michaelneuper">GitHub</a></li>
- <li><a href="https://www.linkedin.com/in/michaelneuper/">LinkedIn</a></li>
- <li><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">Buy me a coffee</a></li>
- </ul>
- </div>
- </div>
-
- <div class="ctf">
- <h3 class="ds-h">Capture the flag</h3>
- <p>Three flags are hidden on this site, the kind you would find in a beginner CTF. They look like <code>{"flag{…}"}</code>. Found one? Run <kbd>M-x</kbd> <code>submit-flag</code>, or use the button.</p>
- <p class="ctf-row"><button type="button" class="btn" data-ctf="submit">submit a flag</button><span class="ctf-progress" aria-live="polite">0 of 3 found</span></p>
- </div>
- </section>
-
-</main>
-
-<!-- ───────────────────────── coffee ───────────────────────── -->
-<div class="coffee" hidden role="dialog" aria-label="Coffee refill">
- <pre class="mug" aria-hidden="true"></pre>
- <div class="coffee-info">
- <p class="winbar"><span class="file">*coffee*</span><span class="rule" aria-hidden="true"></span><span class="meta">q to close</span></p>
- <p class="vcc"><span class="dim">VCC</span><span class="vbar" aria-hidden="true"></span><span class="volts">2.6 V</span></p>
- <p class="coffee-msg" aria-live="polite">brewing…</p>
- <p class="coffee-links"><a href="https://buymeacoffee.com/michaelneuper" target="_blank" rel="noopener">That one was simulated. Buy me an actual coffee</a></p>
- </div>
- <button type="button" class="coffee-close" aria-label="Close">×</button>
-</div>
+ <Fragment slot="head">
+ <script is:inline>
+ // Hide the name until src/scripts/home/decode.js has split it into letters, so it never flashes whole first.
+ // CSS shows it anyway after a moment in case the script never runs.
+ try {
+ if (!matchMedia("(prefers-reduced-motion: reduce)").matches && (!location.hash || location.hash === "#top")) {
+ document.documentElement.classList.add("boot");
+ }
+ } catch (e) {}
+ </script>
+ </Fragment>
+ <Fragment slot="help">
+ <div><dt><kbd>c</kbd> <kbd>n</kbd> <kbd>r</kbd></dt><dd>computer: run or pause, step, reset</dd></div>
+ <div><dt><kbd>TAB</kbd></dt><dd>fold the Org heading under focus</dd></div>
+ <div><dt><kbd>S-TAB</kbd></dt><dd>cycle the whole Org buffer</dd></div>
+ <div><dt><code>coffee-refill</code></dt><dd>top up VCC and overclock the computer</dd></div>
+ </Fragment>
+
+ <main>
+ <Hero />
+ <About />
+ <Projects />
+ <Posts />
+ <Contact />
+ </main>
+ <Coffee />
</Base>
diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro
@@ -1,13 +1,18 @@
---
// One post, shown as an Org buffer: #+ keywords, then the text under starred
-// headings that fold like Org's, with an imenu-list window of the headings
-// beside it when the post asks for a table of contents, and the giscus
-// comments from the old site below. The posts are Markdown underneath.
+// headings that fold, with an *Ilist* window of its headings when the post asks
+// for a table of contents, and the giscus comments from the old site below.
+// The posts themselves are Markdown in src/content/posts.
import { render } from "astro:content";
import "katex/dist/katex.min.css";
import Base from "../../layouts/Base.astro";
-import { publishedPosts, summary, wordCount, weekday, ymd } from "../../lib/posts.js";
-import { TAG_COLORS } from "../../scripts/tags.js";
+import Buffer from "../../components/frame/Buffer.astro";
+import OrgHeader from "../../components/post/OrgHeader.astro";
+import OrgBody from "../../components/post/OrgBody.astro";
+import Outline from "../../components/post/Outline.astro";
+import PostNav from "../../components/post/PostNav.astro";
+import Comments from "../../components/post/Comments.astro";
+import { publishedPosts, summary, wordCount } from "../../lib/posts.js";
export async function getStaticPaths() {
const posts = await publishedPosts();
@@ -27,9 +32,6 @@ const minutes = Math.max(1, Math.round(words / 230));
const outline = toc ? headings.filter((h) => (h.depth === 2 || h.depth === 3) && h.slug !== "footnote-label") : [];
---
<Base title={`${title} - Michael Neuper`} description={summary(post.body ?? "")} buf={file} mode="Org" skip="#post">
- <Fragment slot="head">
- <script src="../../scripts/page.js"></script>
- </Fragment>
<Fragment slot="help">
<div><dt><kbd>TAB</kbd></dt><dd>fold the heading whose stars you clicked</dd></div>
<div><dt><kbd>S-TAB</kbd></dt><dd>cycle the whole buffer: overview, contents, show all</dd></div>
@@ -38,48 +40,14 @@ const outline = toc ? headings.filter((h) => (h.depth === 2 || h.depth === 3) &&
</Fragment>
<main>
- <section class="sec post" id="post" data-file={file} data-mode="Org" aria-labelledby="post-title">
- <p class="winbar"><span class="file">{file}</span><span class="rule" aria-hidden="true"></span><span class="meta">{words.toLocaleString("en")} words, {minutes} min</span></p>
-
- <header class="org-head">
- <h1 class="post-title" id="post-title"><span class="org-key" aria-hidden="true">#+title:</span> {title}</h1>
- <p class="org-kw"><span class="org-key">#+author:</span> <span class="v">Michael Neuper</span></p>
- <p class="org-kw"><span class="org-key">#+date:</span> <time class="org-ts" datetime={ymd(date)}><{ymd(date)} {weekday(date)}></time></p>
- {tags.length > 0 && (
- <p class="org-kw"><span class="org-key">#+filetags:</span> <span class="org-filetags">:{tags.map((t) => (
- <Fragment><span class="t" style={`--tag: ${TAG_COLORS[t] ?? "var(--dim)"}`}>{t}</span>:</Fragment>
- ))}</span></p>
- )}
- </header>
-
+ <Buffer id="post" class="post" file={file} mode="Org" meta={`${words.toLocaleString("en")} words, ${minutes} min`} labelledby="post-title">
+ <OrgHeader title={title} date={date} tags={tags} />
<div class:list={["post-cols", { "has-toc": outline.length > 0 }]}>
- {outline.length > 0 && (
- <aside class="post-toc" aria-labelledby="toc-title">
- <p class="winbar"><span class="file" id="toc-title">*Ilist*</span><span class="rule" aria-hidden="true"></span><span class="meta">contents</span></p>
- <ol>
- {outline.map((h) => (
- <li class={`d${h.depth}`}><a href={`#${h.slug}`}><span class="mk" aria-hidden="true">{"*".repeat(h.depth - 1)}</span> {h.text}</a></li>
- ))}
- </ol>
- </aside>
- )}
- <article class="post-body">
- <Content />
- </article>
+ {outline.length > 0 && <Outline headings={outline} />}
+ <OrgBody><Content /></OrgBody>
</div>
-
- <nav class="post-nav" aria-label="More posts">
- {older ? <a class="older" href={`/posts/${older.id}/`}><span class="dim">older</span>{older.data.title}</a> : <span></span>}
- {newer && <a class="newer" href={`/posts/${newer.id}/`}><span class="dim">newer</span>{newer.data.title}</a>}
- </nav>
- <p class="post-all"><a href="/#posts">All posts</a></p>
- </section>
-
- <section class="sec post-comments" id="comments" data-file="*comments*" data-mode="giscus" aria-labelledby="comments-title">
- <p class="winbar"><span class="file" id="comments-title">*comments*</span><span class="rule" aria-hidden="true"></span><span class="meta">GitHub Discussions</span></p>
- <div class="giscus"></div>
- <p class="dim giscus-note">Comments load from GitHub Discussions through giscus.<noscript> They need JavaScript.</noscript></p>
- </section>
-
+ <PostNav older={older} newer={newer} />
+ </Buffer>
+ <Comments />
</main>
</Base>
diff --git a/src/scripts/coffee.js b/src/scripts/coffee.js
@@ -1,148 +0,0 @@
-// M-x coffee-refill: my GitHub bio, made to do something. A mug fills while
-// VCC (the chip's coffee supply pin) climbs back to 5 V, then the 8-bit
-// computer in Projects runs at four times its clock for 30 seconds.
-
-import { $, emit, reducedMotion, isTyping } from "./util.js";
-import { defcommand, message } from "./shell.js";
-import { showComputer } from "./computer.js";
-
-const ROWS = 5;
-const WIDTH = 10;
-const V_LOW = 2.6;
-const V_FULL = 5.0;
-const BOOST = { factor: 4, seconds: 30 };
-const STEAM = [
- [" ( ) ( ", " ) ( ) "],
- [" ) ( ) ", " ( ) ( "],
-];
-
-const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
-
-function mug(level, frame) {
- const filled = Math.round(level * ROWS);
- const row = (i) => {
- const fromBottom = ROWS - 1 - i;
- if (fromBottom >= filled) return " ".repeat(WIDTH);
- const top = fromBottom === filled - 1;
- return `<span class="${top ? "crema" : "brew"}">${(top ? "~" : "▒").repeat(WIDTH)}</span>`;
- };
- const steam = filled ? STEAM[frame % 2] : ["", ""];
- const handle = ["|--.", "| |", "| |", "|--'", "| "];
- return [
- `<span class="steam">${esc(steam[0])}</span>`,
- `<span class="steam">${esc(steam[1])}</span>`,
- " .----------.",
- ...Array.from({ length: ROWS }, (_, i) => ` |${row(i)}${handle[i]}`),
- " '----------'",
- ].join("\n");
-}
-
-function cupsToday(increment = false) {
- const key = `coffee:${new Date().toISOString().slice(0, 10)}`;
- let n = 0;
- try {
- n = Number(localStorage.getItem(key)) || 0;
- if (increment) localStorage.setItem(key, String(++n));
- } catch {
- if (increment) n = 1;
- }
- return n;
-}
-
-export function initCoffee() {
- const panel = $(".coffee");
- if (!panel) return;
- const pre = $(".mug", panel);
- const bar = $(".vbar", panel);
- const volts = $(".volts", panel);
- const msg = $(".coffee-msg", panel);
- const links = $(".coffee-links", panel);
- const closeBtn = $(".coffee-close", panel);
-
- const watch = document.createElement("button");
- watch.type = "button";
- watch.className = "linkish";
- watch.textContent = "watch the computer";
- watch.hidden = true;
- watch.addEventListener("click", () => showComputer());
- links.append(" ", watch);
-
- let raf = 0;
- let steamTimer = 0;
- let returnFocus = null;
-
- const meter = (v, over = false) => {
- const cells = 12;
- const n = Math.max(0, Math.min(cells, Math.round(((v - V_LOW) / (V_FULL - V_LOW)) * cells)));
- bar.textContent = `▕${"█".repeat(n)}${"░".repeat(cells - n)}▏`;
- volts.textContent = `${v.toFixed(1)} V`;
- panel.classList.toggle("is-over", over);
- };
-
- function close() {
- if (panel.hidden) return;
- cancelAnimationFrame(raf);
- clearInterval(steamTimer);
- panel.hidden = true;
- if (returnFocus && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
- }
-
- function refill() {
- cancelAnimationFrame(raf);
- clearInterval(steamTimer);
- returnFocus = document.activeElement;
- panel.hidden = false;
- watch.hidden = true;
- msg.textContent = "brewing…";
- closeBtn.focus({ preventScroll: true });
-
- const finish = () => {
- const n = cupsToday(true);
- let frame = 0;
- steamTimer = setInterval(() => { pre.innerHTML = mug(1, ++frame); }, 450);
- pre.innerHTML = mug(1, 0);
- if (n > 5) {
- meter(5.6, true);
- msg.textContent = `Cup ${n} today. Absolute maximum rating exceeded: stresses beyond it may cause permanent damage, so no overclock this time. Have some water.`;
- message("VCC above absolute maximum rating", "error");
- return;
- }
- meter(V_FULL);
- const note = n === 1 ? "VCC is back to 5.0 V."
- : n <= 3 ? `Cup ${n} today, within the typical range.`
- : n === 4 ? "Cup 4 today, above typical."
- : "Cup 5 today: that’s the absolute maximum rating.";
- msg.textContent = `${note} The 8-bit computer in Projects now runs at ${BOOST.factor}× clock for ${BOOST.seconds} seconds.`;
- watch.hidden = false;
- emit("caffeine", BOOST);
- message(`Refilled. VCC 5.0 V, ${BOOST.factor}× clock for ${BOOST.seconds}s`, "ok");
- };
-
- if (reducedMotion.matches) return finish();
- const t0 = performance.now();
- const step = (now) => {
- const t = Math.min(1, (now - t0) / 2400);
- const e = 1 - Math.pow(1 - t, 2);
- pre.innerHTML = mug(e, Math.floor(now / 450));
- meter(V_LOW + (V_FULL - V_LOW) * e);
- if (t < 1) raf = requestAnimationFrame(step);
- else finish();
- };
- meter(V_LOW);
- raf = requestAnimationFrame(step);
- }
-
- closeBtn.addEventListener("click", close);
- addEventListener("keydown", (e) => {
- if (panel.hidden || e.metaKey || e.altKey || isTyping(e)) return;
- if (e.key === "q" || e.key === "Escape" || (e.ctrlKey && e.key === "g")) {
- e.preventDefault();
- e.stopPropagation();
- close();
- }
- }, true);
-
- defcommand("coffee-refill", refill, { doc: "Top up VCC and overclock the computer for a while", key: "SPC r" });
- pre.innerHTML = mug(0, 0);
- meter(V_LOW);
-}
diff --git a/src/scripts/computer.js b/src/scripts/computer.js
@@ -1,608 +0,0 @@
-// Ben Eater's 8-bit breadboard computer, simulated one clock cycle at a time.
-//
-// Same architecture as the build: a 4-bit address space with 16 bytes of RAM,
-// an 8-bit bus, A and B registers, an adder/subtractor, an output register on
-// a 7-segment display, carry and zero flags, and microcode that drives sixteen
-// control lines over five steps per instruction.
-
-import { $, $$, hex, reducedMotion, onFrame, isTyping, on, emit } from "./util.js";
-import { defcommand, message, completingRead, setSegment } from "./shell.js";
-
-// Control lines, in the order the control-word LEDs show them.
-const LINES = ["HLT", "MI", "RI", "RO", "IO", "II", "AI", "AO", "EO", "SU", "BI", "OI", "CE", "CO", "J", "FI"];
-const L = Object.fromEntries(LINES.map((n, i) => [n, 1 << (15 - i)]));
-// How the lines are printed on the board: the sum-out line is ΣO, as on the build.
-const LABEL = { EO: "ΣO" };
-
-const OPS = { 0: "NOP", 1: "LDA", 2: "ADD", 3: "SUB", 4: "STA", 5: "LDI", 6: "JMP", 7: "JC", 8: "JZ", 14: "OUT", 15: "HLT" };
-const CODE = Object.fromEntries(Object.entries(OPS).map(([k, v]) => [v, +k]));
-const TAKES_ARG = new Set(["LDA", "ADD", "SUB", "STA", "LDI", "JMP", "JC", "JZ"]);
-
-// Microcode. Every instruction starts with the same two fetch steps.
-const FETCH = [L.CO | L.MI, L.RO | L.II | L.CE];
-const MICRO = {
- LDA: [L.IO | L.MI, L.RO | L.AI, 0],
- ADD: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.FI],
- SUB: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.SU | L.FI],
- STA: [L.IO | L.MI, L.AO | L.RI, 0],
- LDI: [L.IO | L.AI, 0, 0],
- JMP: [L.IO | L.J, 0, 0],
- JC: [L.IO | L.J, 0, 0],
- JZ: [L.IO | L.J, 0, 0],
- OUT: [L.AO | L.OI, 0, 0],
- HLT: [L.HLT, 0, 0],
-};
-
-function controlWord(s) {
- if (s.step < 2) return FETCH[s.step];
- const op = OPS[s.ir >> 4];
- if (!MICRO[op]) return 0;
- if ((op === "JC" && !s.cf) || (op === "JZ" && !s.zf)) return 0;
- return MICRO[op][s.step - 2];
-}
-
-function alu(s, cw) {
- const raw = cw & L.SU ? s.a + ((~s.b & 0xff) + 1) : s.a + s.b;
- return { sum: raw & 0xff, carry: raw > 0xff ? 1 : 0, zero: (raw & 0xff) === 0 ? 1 : 0 };
-}
-
-function busValue(s, cw, sum) {
- if (cw & L.CO) return s.pc;
- if (cw & L.RO) return s.ram[s.mar];
- if (cw & L.IO) return s.ir & 0x0f;
- if (cw & L.AO) return s.a;
- if (cw & L.EO) return sum;
- return 0; // pull-down resistors
-}
-
-// One rising clock edge: everything with an "in" line latches the bus.
-function tick(s) {
- if (s.halted) return;
- const cw = controlWord(s);
- const r = alu(s, cw);
- const v = busValue(s, cw, r.sum);
- if (cw & L.MI) s.mar = v & 0x0f;
- if (cw & L.RI) s.ram[s.mar] = v;
- if (cw & L.II) s.ir = v;
- if (cw & L.AI) s.a = v;
- if (cw & L.BI) s.b = v;
- if (cw & L.OI) s.out = v;
- if (cw & L.FI) { s.cf = r.carry; s.zf = r.zero; }
- if (cw & L.J) s.pc = v & 0x0f;
- if (cw & L.CE) s.pc = (s.pc + 1) & 0x0f;
- s.step = (s.step + 1) % 5;
- s.cycles++;
- // HLT gates the clock as soon as its control word appears.
- if (controlWord(s) & L.HLT) s.halted = true;
-}
-
-function resetState(s) {
- Object.assign(s, { a: 0, b: 0, ir: 0, mar: 0, pc: 0, out: 0, step: 0, cf: 0, zf: 0, halted: false, cycles: 0 });
-}
-
-const disasm = (byte) => {
- const op = OPS[byte >> 4] ?? "???";
- return TAKES_ARG.has(op) ? `${op} ${byte & 15}` : op;
-};
-
-// ── programs ──────────────────────────────────────────────────────────
-
-const PROGRAMS = {
- fibonacci: {
- title: "fibonacci",
- code: [["LDI", 1], ["STA", 15], ["LDI", 0], ["OUT"], ["ADD", 15], ["JC", 0], ["STA", 14], ["LDA", 15],
- ["STA", 13], ["LDA", 14], ["STA", 15], ["LDA", 13], ["JMP", 3]],
- data: { 13: ["z", 0], 14: ["t", 0], 15: ["y", 0] },
- },
- multiply: {
- title: "multiply 7 × 6",
- code: [["LDA", 14], ["SUB", 12], ["JC", 6], ["LDA", 13], ["OUT"], ["HLT"], ["STA", 14], ["LDA", 13],
- ["ADD", 15], ["STA", 13], ["JMP", 0]],
- data: { 12: ["one", 1], 13: ["prod", 0], 14: ["y", 6], 15: ["x", 7] },
- },
- count: {
- title: "count up and down",
- code: [["OUT"], ["ADD", 15], ["JC", 4], ["JMP", 0], ["SUB", 15], ["OUT"], ["JZ", 0], ["JMP", 4]],
- data: { 15: ["step", 5] },
- },
- add: {
- title: "add 28 + 14",
- code: [["LDA", 14], ["ADD", 15], ["OUT"], ["HLT"]],
- data: { 14: ["a", 28], 15: ["b", 14] },
- },
-};
-
-function assemble(p) {
- const ram = new Uint8Array(16);
- p.code.forEach(([op, arg = 0], i) => { ram[i] = (CODE[op] << 4) | (arg & 15); });
- for (const [addr, [, v]] of Object.entries(p.data)) ram[addr] = v;
- return ram;
-}
-
-// ── 7-segment digits ──────────────────────────────────────────────────
-
-const H = (y, x1, x2) => `M${x1} ${y}l1.6 -1.6H${x2 - 1.6}l1.6 1.6l-1.6 1.6H${x1 + 1.6}z`;
-const V = (x, y1, y2) => `M${x} ${y1}l1.6 1.6V${y2 - 1.6}l-1.6 1.6l-1.6 -1.6V${y1 + 1.6}z`;
-const SEGS = { a: H(2, 3, 19), b: V(20, 3, 18.4), c: V(20, 19.6, 35), d: H(36, 3, 19), e: V(2, 19.6, 35), f: V(2, 3, 18.4), g: H(19, 3, 19) };
-const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acdfg", 6: "acdefg", 7: "abc", 8: "abcdefg", 9: "abcdfg", "-": "g", " ": "" };
-
-// ── UI ────────────────────────────────────────────────────────────────
-
-const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
-
-// State that outlives any one mounted board: the coffee overclock, and which
-// board is on the page (the 8-bit project's demo mounts and unmounts it).
-let boost = 1;
-let boostTimer = 0;
-let current = null;
-
-const TEMPLATE = `
- <div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>
- <div class="sim-bar">
- <button type="button" class="btn" data-sim="run" aria-pressed="false">run</button>
- <button type="button" class="btn" data-sim="step">step</button>
- <button type="button" class="btn" data-sim="reset">reset</button>
- <label class="sim-field"><span>program</span>
- <select data-sim="program">
- <option value="fibonacci">fibonacci</option>
- <option value="multiply">multiply 7 × 6</option>
- <option value="count">count up and down</option>
- <option value="add">add 28 + 14</option>
- </select>
- </label>
- <label class="sim-field"><span>clock</span>
- <input type="range" min="0" max="100" value="43" data-sim="hz" aria-label="Clock speed">
- <output>10 Hz</output>
- </label>
- </div>
- <p class="sim-note">Simulated one clock cycle at a time. <kbd>c</kbd> runs or pauses, <kbd>n</kbd> steps one cycle, <kbd>r</kbd> resets. Click a bit in RAM to change the program, or the display to switch to signed.</p>`;
-
-export function mount(host) {
- host.innerHTML = TEMPLATE;
- const board = $(".board", host);
- const runBtn = $('[data-sim="run"]', host);
- const progSel = $('[data-sim="program"]', host);
- const hzIn = $('[data-sim="hz"]', host);
- const hzOut = $("output", host);
-
- const s = { ram: new Uint8Array(16) };
- resetState(s);
- let program = PROGRAMS.fibonacci;
- let running = false;
- let hz = 10;
- let acc = 0;
- let phase = 0;
- let signed = false;
- let dirty = true;
- let lastBoost = boost;
- const effHz = () => Math.min(500, hz * boost);
-
- // ── build the board ────────────────────────────────────────────────
- const el = (tag, cls, text) => {
- const e = document.createElement(tag);
- if (cls) e.className = cls;
- if (text != null) e.textContent = text;
- return e;
- };
- const mods = {};
- const mod = (key, name, sigs = []) => {
- const m = el("section", `mod mod-${key}`);
- const head = el("div", "mod-head");
- head.append(el("span", "mod-name", name));
- for (const sig of sigs) {
- const t = el("span", "sig", LABEL[sig] ?? sig);
- t.dataset.sig = sig;
- head.append(t);
- }
- const body = el("div", "mod-body");
- m.append(head, body);
- mods[key] = { m, body, sigs: $$(".sig", head), val: null, leds: [] };
- return mods[key];
- };
- const leds = (target, n, cls = "", gapAt = -1, tag = "span") => {
- const w = el("div", "leds");
- for (let i = 0; i < n; i++) {
- if (i === gapAt) w.append(el("span", "gap"));
- const l = el(tag, `led ${cls}`);
- w.append(l);
- target.leds.push(l);
- }
- return w;
- };
- const value = (target, text = "") => {
- target.val = el("span", "mod-val", text);
- return target.val;
- };
-
- const left = el("div", "col col-l");
- const right = el("div", "col col-r");
-
- const clk = mod("clk", "clock", ["HLT"]);
- clk.body.append(leds(clk, 1, "b"), value(clk));
- const mar = mod("mar", "memory address", ["MI"]);
- mar.body.append(leds(mar, 4, "y"), value(mar));
-
- const ram = mod("ram", "RAM", ["RI", "RO"]);
- const rows = el("div", "ram-rows");
- const ramRows = [];
- for (let a = 0; a < 16; a++) {
- const row = el("div", "ram-row");
- const pcMark = el("span", "pc");
- const addr = el("span", "addr", hex(a, 1));
- const bits = el("span", "leds");
- const cells = [];
- for (let b = 7; b >= 0; b--) {
- const btn = el("button", "led");
- btn.type = "button";
- btn.tabIndex = a === 0 && b === 7 ? 0 : -1;
- btn.dataset.addr = a;
- btn.dataset.bit = b;
- btn.setAttribute("aria-label", `address ${a}, bit ${b}`);
- bits.append(btn);
- cells.push(btn);
- }
- const dis = el("span", "dis");
- row.append(pcMark, addr, bits, dis);
- rows.append(row);
- ramRows.push({ row, pcMark, cells, dis });
- }
- ram.body.replaceWith(rows);
-
- const ir = mod("ir", "instruction", ["II", "IO"]);
- ir.body.append(leds(ir, 8, "", 4), value(ir));
- ir.leds.forEach((l, i) => l.classList.add(i < 4 ? "b" : "y"));
- left.append(clk.m, mar.m, ram.m, ir.m);
-
- const pc = mod("pc", "program counter", ["CO", "J", "CE"]);
- pc.body.append(leds(pc, 4, "g"), value(pc));
- const a = mod("a", "A register", ["AI", "AO"]);
- a.body.append(leds(a, 8), value(a));
- const sum = mod("sum", "ALU Σ", ["EO", "SU"]);
- sum.body.append(leds(sum, 8), value(sum));
- const b = mod("b", "B register", ["BI"]);
- b.body.append(leds(b, 8), value(b));
- const out = mod("out", "output", ["OI"]);
- const seg = el("div", "seg7");
- const digits = [];
- for (let d = 0; d < 4; d++) {
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
- svg.setAttribute("viewBox", "0 0 22 38");
- svg.setAttribute("aria-hidden", "true");
- const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
- g.setAttribute("transform", "skewX(-6) translate(2 0)");
- const segs = {};
- for (const [k, dpath] of Object.entries(SEGS)) {
- const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
- p.setAttribute("d", dpath);
- g.append(p);
- segs[k] = p;
- }
- svg.append(g);
- seg.append(svg);
- digits.push(segs);
- }
- const outLabel = el("span", "sr-only");
- out.body.append(seg, value(out), outLabel);
- out.body.title = "Click to switch between unsigned and signed";
- const flags = mod("flags", "flags", ["FI"]);
- const fl = el("div", "leds");
- const cfLed = el("span", "led g");
- const zfLed = el("span", "led g");
- fl.append(cfLed, el("span", "dim", "C"), el("span", "gap"), zfLed, el("span", "dim", "Z"));
- fl.style.alignItems = "center";
- flags.body.append(fl, value(flags));
- right.append(pc.m, a.m, sum.m, b.m, out.m, flags.m);
-
- const bus = el("div", "bus");
- const busHead = el("div", "mod-head");
- busHead.append(el("span", "mod-name", "bus"));
- const busVal = el("span", "bus-val mod-val", "0");
- const lanesWrap = el("div", "lanes");
- const lanes = [];
- for (let i = 0; i < 8; i++) {
- const l = el("span", "lane");
- lanesWrap.append(l);
- lanes.push(l);
- }
- bus.append(busHead, busVal, lanesWrap);
-
- const ctrl = mod("ctrl", "control word", []);
- ctrl.m.classList.add("ctrl");
- const ctrlRow = el("div", "ctrl-row");
- const stepWrap = el("div", "cw");
- const stepLeds = [];
- for (let i = 0; i < 5; i++) {
- const bit = el("span", "cw-bit");
- const l = el("span", "led");
- bit.append(l, el("span", "", `T${i}`));
- stepWrap.append(bit);
- stepLeds.push([bit, l]);
- }
- const cwWrap = el("div", "cw");
- const cwBits = LINES.map((n) => {
- const bit = el("span", "cw-bit");
- const l = el("span", "led b");
- bit.append(l, el("span", "", LABEL[n] ?? n));
- cwWrap.append(bit);
- return [bit, l];
- });
- ctrlRow.append(stepWrap, cwWrap);
- const ustep = el("p", "ustep");
- ctrl.body.replaceWith(ctrlRow);
- ctrl.m.append(ustep);
-
- board.replaceChildren(left, bus, right, ctrl.m);
-
- // ── render ─────────────────────────────────────────────────────────
- const setBits = (list, v, n = list.length) => {
- for (let i = 0; i < n; i++) list[i].classList.toggle("on", ((v >> (n - 1 - i)) & 1) === 1);
- };
- const setText = (node, text) => {
- if (node.textContent !== text) node.textContent = text;
- };
-
- let lastRam = "";
- function render() {
- const cw = controlWord(s);
- const r = alu(s, cw);
- const v = busValue(s, cw, r.sum);
-
- // registers
- setBits(mar.leds, s.mar); setText(mar.val, String(s.mar));
- setBits(pc.leds, s.pc); setText(pc.val, String(s.pc));
- setBits(a.leds, s.a); setText(a.val, String(s.a));
- setBits(b.leds, s.b); setText(b.val, String(s.b));
- setBits(sum.leds, r.sum); setText(sum.val, `${cw & L.SU ? "A−B" : "A+B"} ${r.sum}`);
- setBits(ir.leds, s.ir); setText(ir.val, s.step < 2 && s.cycles === 0 ? "" : disasm(s.ir));
- cfLed.classList.toggle("on", !!s.cf);
- zfLed.classList.toggle("on", !!s.zf);
- setText(flags.val, "");
- setBits(lanes, v); setText(busVal, String(v));
-
- // clock
- clk.leds[0].classList.toggle("on", running && !s.halted && (effHz() > 24 || phase < 0.5));
- setText(clk.val, s.halted ? "halted" : running ? (boost > 1 ? `${fmtHz(effHz())} (${boost}×)` : fmtHz(hz)) : "paused");
-
- // output display
- const o = signed && s.out > 127 ? -(256 - s.out) : s.out;
- const txt = (o < 0 ? "-" : " ") + String(Math.abs(o)).padStart(3, " ");
- txt.split("").forEach((ch, i) => {
- const on = GLYPH[ch] ?? "";
- for (const [k, p] of Object.entries(digits[i])) p.classList.toggle("on", on.includes(k));
- });
- setText(out.val, signed ? "signed" : "");
- setText(outLabel, `output ${o}`);
-
- // control word and step
- cwBits.forEach(([bit, l], i) => {
- const on = (cw >> (15 - i)) & 1;
- bit.classList.toggle("on", !!on);
- l.classList.toggle("on", !!on);
- });
- stepLeds.forEach(([bit, l], i) => {
- bit.classList.toggle("on", i === s.step);
- l.classList.toggle("on", i === s.step);
- });
- const d = describe(s, cw);
- const html = `T${s.step} <b>${d.label}</b> ${d.text}`;
- if (ustep.innerHTML !== html) ustep.innerHTML = html;
-
- // which modules drive the bus, which latch it, and their control lines
- const outs = { pc: L.CO, ram: L.RO, ir: L.IO, a: L.AO, sum: L.EO };
- const ins = { mar: L.MI, ram: L.RI, ir: L.II, a: L.AI, b: L.BI, out: L.OI, pc: L.J | L.CE, flags: L.FI, clk: L.HLT };
- for (const [key, m] of Object.entries(mods)) {
- m.m.classList.toggle("is-out", !!(cw & (outs[key] ?? 0)));
- m.m.classList.toggle("is-in", !!(cw & (ins[key] ?? 0)) && !(cw & (outs[key] ?? 0)));
- for (const t of m.sigs) t.classList.toggle("on", !!(cw & L[t.dataset.sig]));
- }
-
- // RAM matrix
- const key = `${s.ram.join(",")}|${s.mar}|${s.pc}`;
- if (key !== lastRam) {
- lastRam = key;
- ramRows.forEach((rr, addr) => {
- setBits(rr.cells, s.ram[addr]);
- rr.row.classList.toggle("is-mar", addr === s.mar);
- setText(rr.pcMark, addr === s.pc ? "▸" : "");
- const data = program.data[addr];
- setText(rr.dis, data ? `${data[0]} = ${s.ram[addr]}` : disasm(s.ram[addr]));
- });
- }
- }
-
- function describe(st, cw) {
- if (st.halted) return { label: "HLT", text: "clock stopped; reset to run again" };
- const label = st.step < 2 ? "fetch" : OPS[st.ir >> 4] ?? "???";
- if (!cw) {
- if (label === "JC") return { label, text: "carry clear, so no jump" };
- if (label === "JZ") return { label, text: "zero clear, so no jump" };
- return { label, text: "nothing to do this step" };
- }
- const src = [[L.CO, "PC"], [L.RO, "RAM"], [L.IO, "operand"], [L.AO, "A"], [L.EO, cw & L.SU ? "A − B" : "A + B"]].find(([bit]) => cw & bit);
- const dst = [[L.MI, "MAR"], [L.RI, "RAM"], [L.II, "IR"], [L.AI, "A"], [L.BI, "B"], [L.OI, "output"], [L.J, "PC"]].filter(([bit]) => cw & bit).map(([, n]) => n);
- const parts = [];
- if (src && dst.length) parts.push(`${src[1]} → ${dst.join(", ")}`);
- if (cw & L.CE) parts.push("PC + 1");
- if (cw & L.FI) parts.push("latch flags");
- return { label, text: parts.join(", ") };
- }
-
-
- // ── simulation loop ────────────────────────────────────────────────
- function frame(t, dt) {
- if (boost !== lastBoost) {
- lastBoost = boost;
- dirty = true;
- }
- if (running && !s.halted) {
- acc += dt * effHz();
- phase = (phase + dt * effHz()) % 1;
- let n = Math.floor(acc);
- acc -= n;
- n = Math.min(n, 400);
- while (n-- > 0 && !s.halted) tick(s);
- if (s.halted) halted();
- dirty = true;
- }
- if (dirty) {
- render();
- dirty = false;
- }
- }
-
- function halted() {
- running = false;
- paintRun();
- message(`HLT: ${program.title} finished with ${s.out} on the display. Press r to reset.`, "", 6000);
- }
-
- function paintRun() {
- runBtn.setAttribute("aria-pressed", String(running));
- runBtn.textContent = running ? "pause" : "run";
- }
-
- // ── controls ───────────────────────────────────────────────────────
- function run(want = !running) {
- if (want && s.halted) return message("Halted. Press r (or the reset button) to start again.");
- running = want;
- acc = 0;
- paintRun();
- dirty = true;
- }
- function step() {
- if (running) run(false);
- if (s.halted) return message("Halted. Press r to reset.");
- tick(s);
- if (s.halted) halted();
- dirty = true;
- }
- function reset() {
- resetState(s);
- dirty = true;
- }
- function load(key) {
- program = PROGRAMS[key] ?? PROGRAMS.fibonacci;
- progSel.value = key;
- s.ram = assemble(program);
- resetState(s);
- lastRam = "";
- dirty = true;
- }
- function setHz(v) {
- hz = 0.5 * Math.pow(1000, v / 100);
- hzOut.value = fmtHz(hz);
- dirty = true;
- }
-
- runBtn.addEventListener("click", () => run());
- $('[data-sim="step"]', host).addEventListener("click", step);
- $('[data-sim="reset"]', host).addEventListener("click", reset);
- progSel.addEventListener("change", () => {
- load(progSel.value);
- if (!reducedMotion.matches) run(true);
- });
- hzIn.addEventListener("input", () => setHz(+hzIn.value));
- out.body.addEventListener("click", () => {
- signed = !signed;
- dirty = true;
- message(signed ? "Output shows signed two’s complement" : "Output shows unsigned");
- });
-
- // Edit RAM by clicking bits; arrow keys move around the matrix.
- rows.addEventListener("click", (e) => {
- const btn = e.target.closest(".led");
- if (!btn) return;
- s.ram[+btn.dataset.addr] ^= 1 << +btn.dataset.bit;
- focusCell(+btn.dataset.addr, +btn.dataset.bit);
- dirty = true;
- });
- rows.addEventListener("keydown", (e) => {
- const btn = e.target.closest(".led");
- const d = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, 1], ArrowRight: [0, -1] }[e.key];
- if (!btn || !d) return;
- e.preventDefault();
- focusCell((+btn.dataset.addr + d[0] + 16) % 16, Math.min(7, Math.max(0, +btn.dataset.bit + d[1])));
- });
- function focusCell(addr, bit) {
- for (const rr of ramRows) for (const c of rr.cells) c.tabIndex = -1;
- const c = ramRows[addr].cells[7 - bit];
- c.tabIndex = 0;
- c.focus({ preventScroll: true });
- }
-
- // c, n and r while the computer is on screen
- let visible = false;
- let off = null;
- const io = new IntersectionObserver(([e]) => {
- visible = e.intersectionRatio >= 0.25;
- if (e.isIntersecting && !off) off = onFrame(frame);
- else if (!e.isIntersecting && off) { off(); off = null; }
- }, { threshold: [0, 0.25] });
- io.observe(board);
- const onKey = (e) => {
- if (!visible || e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
- if (document.querySelector("dialog[open]")) return;
- const act = { c: () => run(), n: step, r: reset }[e.key];
- if (!act) return;
- e.preventDefault();
- act();
- };
- document.addEventListener("keydown", onKey);
-
- load("fibonacci");
- setHz(+hzIn.value);
- run(!reducedMotion.matches || boost > 1);
- const api = { run, step, reset, load, board, halted: () => s.halted, running: () => running };
- current = api;
- return () => {
- io.disconnect();
- off?.();
- document.removeEventListener("keydown", onKey);
- if (current === api) current = null;
- };
-}
-
-// The 8-bit project's demo, opened and scrolled to if it isn't on the page yet.
-export async function showComputer() {
- if (!current) {
- emit("project", "8-bit-computer");
- for (let i = 0; i < 90 && !current; i++) await new Promise((r) => requestAnimationFrame(r));
- }
- current?.board.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "center" });
- return current;
-}
-
-const withComputer = (fn) => async () => {
- const c = current ?? (await showComputer());
- if (c) fn(c);
-};
-defcommand("computer-run", withComputer((c) => c.run()), { doc: "Run or pause the 8-bit computer", key: "c" });
-defcommand("computer-step", withComputer((c) => c.step()), { doc: "Advance the clock by one cycle", key: "n" });
-defcommand("computer-reset", withComputer((c) => c.reset()), { doc: "Reset the computer (RAM is kept)", key: "r" });
-defcommand("computer-load-program", async () => {
- const key = await completingRead("Load program: ", Object.entries(PROGRAMS).map(([k, p]) => ({ value: k, ann: p.title })));
- if (!key) return;
- const c = await showComputer();
- c?.load(key);
- c?.run(true);
-}, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
-
-// M-x coffee-refill: four times the clock for a while.
-on("caffeine", ({ factor, seconds }) => {
- boost = factor;
- if (current) {
- if (current.halted()) current.reset();
- if (!current.running()) current.run(true);
- }
- let left = seconds;
- clearInterval(boostTimer);
- setSegment("misc", `${factor}× clock ${left}s`);
- boostTimer = setInterval(() => {
- left--;
- if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
- clearInterval(boostTimer);
- boost = 1;
- setSegment("misc", "");
- message("The caffeine wore off. The clock is back to normal.");
- }, 1000);
-});
diff --git a/src/scripts/contact.js b/src/scripts/contact.js
@@ -1,28 +0,0 @@
-// 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";
-import { message } from "./shell.js";
-
-export function initContact() {
- const sec = $("#contact");
- if (!sec) return;
- const mail = $(".mail", sec);
- const address = `${mail.dataset.user}@${mail.dataset.host}`;
- const arm = () => { mail.href = `mailto:${address}`; };
- for (const ev of ["pointerenter", "focus", "touchstart"]) mail.addEventListener(ev, arm, { passive: true });
- mail.addEventListener("click", arm);
-
- const copy = $('[data-email="copy"]', sec);
- copy?.addEventListener("click", async () => {
- try {
- await navigator.clipboard.writeText(address);
- copy.textContent = "copied";
- message(`Copied ${address}`, "ok", 3000);
- } catch {
- message(address, "", 8000);
- copy.textContent = "shown below";
- }
- setTimeout(() => { copy.textContent = "copy address"; }, 2000);
- });
-}
diff --git a/src/scripts/ctf.js b/src/scripts/ctf.js
@@ -2,7 +2,7 @@
// SHA-256 hashes, so reading it shows how flags are checked without giving them away.
import { $ } from "./util.js";
-import { defcommand, message, completingRead, setSegment, call } from "./shell.js";
+import { defcommand, message, completingRead, setSegment, call } from "./emacs/index.js";
const HASHES = [
"7a80f308e7d3c86589ac38394f24154bf36149b8ad833f728c9e0ddeed29693e",
diff --git a/src/scripts/datasheet.js b/src/scripts/datasheet.js
@@ -1,86 +0,0 @@
-// Builds the DIP-16 pinout from the ordered list of pins in the HTML.
-// Pins 1–8 run down the left side, 9–16 run back up the right.
-
-import { $, $$ } from "./util.js";
-import { call } from "./shell.js";
-
-export function initDatasheet() {
- const fig = $(".pinout");
- if (!fig) return;
- const pins = $$(".pin-list li", fig).map((li, i) => ({
- num: i + 1,
- name: li.dataset.name,
- type: li.dataset.type,
- ch: li.dataset.cat,
- text: li.textContent.trim(),
- }));
- const half = pins.length / 2;
- const readout = $(".pin-readout", fig);
-
- const chip = document.createElement("div");
- chip.className = "chip";
- const left = side("left");
- const right = side("right");
- const body = document.createElement("div");
- body.className = "chip-body";
- body.innerHTML = "<span>MN-2026</span>";
- chip.append(left, body, right);
-
- pins.forEach((p, i) => {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "pin";
- if (p.ch) btn.dataset.cat = p.ch;
- btn.setAttribute("aria-label", `Pin ${p.num}, ${p.name}`);
- const parts = { name: p.name, num: p.num, leg: "" };
- const order = i < half ? ["name", "num", "leg"] : ["leg", "num", "name"];
- for (const k of order) {
- const s = document.createElement("span");
- s.className = k;
- s.textContent = parts[k];
- btn.append(s);
- }
- btn.addEventListener("pointerenter", () => show(p, btn));
- btn.addEventListener("focus", () => show(p, btn));
- btn.addEventListener("click", () => show(p, btn));
- if (i < half) left.append(btn);
- else right.prepend(btn);
- });
-
- $(".chip-wrap", fig).append(chip);
- fig.classList.add("is-built");
-
- function show(p, btn) {
- $$(".pin.is-active", chip).forEach((b) => b.classList.remove("is-active"));
- btn.classList.add("is-active");
- if (p.ch) readout.dataset.cat = p.ch;
- else delete readout.dataset.cat;
- readout.innerHTML = "";
- const head = document.createElement("p");
- head.className = "pr-head";
- head.innerHTML = `<span class="dim">pin ${p.num}</span><span class="pr-name"></span><span class="dim"></span>`;
- head.children[1].textContent = p.name;
- head.children[2].textContent = p.type;
- // "M-x some-command" in a pin description becomes a button that runs it.
- const text = document.createElement("p");
- for (const part of p.text.split(/(M-x [\w-]+)/)) {
- if (!part.startsWith("M-x ")) {
- text.append(part);
- continue;
- }
- const b = document.createElement("button");
- b.type = "button";
- b.className = "linkish";
- b.textContent = part;
- b.addEventListener("click", () => call(part.slice(4)));
- text.append(b);
- }
- readout.append(head, text);
- }
-}
-
-function side(cls) {
- const d = document.createElement("div");
- d.className = `chip-side ${cls}`;
- return d;
-}
diff --git a/src/scripts/decode.js b/src/scripts/decode.js
@@ -1,164 +0,0 @@
-// The name arrives as bytes with a few bits flipped, so each letter starts out
-// as some other character. The flipped bits are corrected one at a time, and
-// every correction changes the letter to the character one bit closer, until
-// it lands on the right one. The echo area shows the bytes while that happens.
-//
-// Afterwards, pointing at a letter flips a couple of its bits again, and
-// M-x flip-bits replays the whole thing.
-
-import { $, onFrame, reducedMotion } from "./util.js";
-import { message, defcommand, goto } from "./shell.js";
-
-const rand = (n) => Math.floor(Math.random() * n);
-const bin = (n) => n.toString(2).padStart(8, "0");
-
-// Characters a letter may pass through: printable, and in the serif font
-// (it has no backtick).
-const printable = (v) => v >= 0x20 && v < 0x7f && v !== 0x60;
-
-function shuffle(a) {
- for (let i = a.length - 1; i > 0; i--) {
- const j = rand(i + 1);
- [a[i], a[j]] = [a[j], a[i]];
- }
- return a;
-}
-
-// Pick k bits to flip and the order to correct them in, so that every byte on
-// the way back is printable. Letters keep bit 6 (they stay in 0x40-0x7f) and
-// the space keeps bits 5 and 6 (it stays in 0x20-0x3f).
-function corrupt(code, k) {
- const pool = code === 0x20 ? [0, 1, 2, 3, 4] : [0, 1, 2, 3, 4, 5];
- for (let tries = 0; tries < 50; tries++) {
- const order = shuffle([...pool]).slice(0, k);
- let v = order.reduce((x, b) => x ^ (1 << b), code);
- const path = [v];
- for (const b of order) path.push((v ^= 1 << b));
- if (path.every(printable)) return path; // path[0] corrupted ... path[k] === code
- }
- return [code];
-}
-
-function build(h1) {
- const text = h1.textContent.trim();
- h1.setAttribute("aria-label", text);
- h1.textContent = "";
- // Measure on one line, so the space between the words keeps its width.
- h1.style.whiteSpace = "nowrap";
- let word = null;
- const cells = [...text].map((ch) => {
- // Each word's letters sit in a span that doesn't wrap: the browser would
- // otherwise break the line between any two of them.
- if (!word) {
- word = document.createElement("span");
- word.className = "bc-word";
- h1.append(word);
- }
- const el = document.createElement("span");
- el.className = "bc";
- const glyph = document.createElement("span");
- glyph.className = "bc-ch";
- glyph.textContent = ch;
- el.append(glyph);
- word.append(el);
- if (ch === " ") word = null; // the line may break after the space
- return { el, glyph, code: ch.charCodeAt(0), path: [], busy: false };
- });
- // Fix each cell at its final letter's width, so the other characters a letter
- // passes through never push the rest of the name around.
- const fs = parseFloat(getComputedStyle(h1).fontSize);
- const widths = cells.map((c) => c.el.getBoundingClientRect().width);
- cells.forEach((c, i) => { c.el.style.width = `${(widths[i] / fs).toFixed(4)}em`; });
- h1.style.whiteSpace = "";
- h1.classList.add("is-cells");
- return cells;
-}
-
-function show(c, v) {
- c.glyph.textContent = String.fromCharCode(v);
- c.el.classList.toggle("is-wrong", v !== c.code);
-}
-
-// Step every cell along its path on its own schedule until all are correct.
-// onStep runs after any change, for the echo area.
-function run(cells, onStep) {
- const t0 = performance.now();
- return new Promise((resolve) => {
- const off = onFrame(() => {
- const t = performance.now() - t0;
- let changed = false;
- let left = 0;
- for (const c of cells) {
- while (c.plan.length && c.plan[0].t <= t) {
- show(c, c.plan.shift().v);
- changed = true;
- }
- left += c.plan.length;
- }
- if (changed) onStep?.();
- if (!left) {
- off();
- resolve();
- }
- });
- });
-}
-
-export async function initDecode() {
- const root = document.documentElement;
- const h1 = $(".hero-name");
- const reveal = () => root.classList.remove("boot");
- if (!h1 || reducedMotion.matches) return reveal();
-
- let cells;
- try {
- // The cells are measured in the serif, so wait for it (briefly).
- await Promise.race([document.fonts.load(`400 64px "Computer Modern"`, "Michael Neuper"), new Promise((r) => setTimeout(r, 1500))]);
- cells = build(h1);
- } catch (err) {
- console.error(err);
- return reveal();
- }
- // the byte each cell shows right now
- const current = (c) => c.glyph.textContent.charCodeAt(0);
-
- async function decode() {
- if (cells.some((c) => c.busy)) return;
- let flipped = 0;
- cells.forEach((c, j) => {
- c.busy = true;
- const path = corrupt(c.code, 3 + rand(3));
- flipped += path.length - 1;
- show(c, path[0]);
- let t = 150 + j * 55;
- c.plan = path.slice(1).map((v) => ({ v, t: (t += 70 + Math.random() * 50) }));
- });
- const echo = () => message(cells.map((c) => bin(current(c))).join(" "), "", 0);
- reveal();
- echo();
- await run(cells, echo);
- cells.forEach((c) => { c.busy = false; });
- message(`Corrected ${flipped} flipped bits in ${cells.length} bytes.`, "ok", 4000);
- }
-
- // Point at a letter to flip a couple of its bits.
- for (const c of cells) {
- if (c.code === 0x20) continue;
- c.el.addEventListener("pointerenter", () => {
- if (c.busy || reducedMotion.matches) return;
- c.busy = true;
- const path = corrupt(c.code, 2);
- show(c, path[0]);
- c.plan = path.slice(1).map((v, n) => ({ v, t: 110 + n * 90 }));
- run([c]).then(() => { c.busy = false; });
- });
- }
-
- defcommand("flip-bits", () => {
- goto("top", true);
- setTimeout(decode, 400);
- }, { doc: "Flip random bits in my name and watch them get corrected" });
-
- if (root.classList.contains("boot")) await decode();
- else reveal();
-}
diff --git a/src/scripts/demos/bytecode.js b/src/scripts/demos/bytecode.js
@@ -2,6 +2,7 @@
// with Java's int semantics (wrap-around, truncating division, / by zero).
import { reducedMotion } from "../util.js";
+import "../../styles/demos/bytecode.css";
const LOCALS = { x: 6, y: 7, z: -2 }; // slots 0, 1, 2 of a static method
const SLOT = { x: 0, y: 1, z: 2 };
diff --git a/src/scripts/demos/computer.js b/src/scripts/demos/computer.js
@@ -1 +1 @@
-export { mount } from "../computer.js";
+export { mount } from "./computer/board.js";
diff --git a/src/scripts/demos/computer/board.js b/src/scripts/demos/computer/board.js
@@ -0,0 +1,474 @@
+// The 8-bit computer's board: Ben Eater's layout drawn from the simulated
+// machine in machine.js, with RAM you can edit, the bus, the control word and a
+// 7-segment display. The 8-bit project mounts it as its demo.
+
+import { $, $$, hex, reducedMotion, onFrame, isTyping, on } from "../../util.js";
+import { message, setSegment } from "../../emacs/index.js";
+import { LINES, L, LABEL, OPS, PROGRAMS, controlWord, alu, busValue, tick, resetState, disasm, assemble } from "./machine.js";
+import "../../../styles/demos/computer.css";
+
+// ── 7-segment digits ──────────────────────────────────────────────────
+
+const H = (y, x1, x2) => `M${x1} ${y}l1.6 -1.6H${x2 - 1.6}l1.6 1.6l-1.6 1.6H${x1 + 1.6}z`;
+const V = (x, y1, y2) => `M${x} ${y1}l1.6 1.6V${y2 - 1.6}l-1.6 1.6l-1.6 -1.6V${y1 + 1.6}z`;
+const SEGS = { a: H(2, 3, 19), b: V(20, 3, 18.4), c: V(20, 19.6, 35), d: H(36, 3, 19), e: V(2, 19.6, 35), f: V(2, 3, 18.4), g: H(19, 3, 19) };
+const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acdfg", 6: "acdefg", 7: "abc", 8: "abcdefg", 9: "abcdfg", "-": "g", " ": "" };
+
+// ── UI ────────────────────────────────────────────────────────────────
+
+const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
+
+// State that outlives any one mounted board: the coffee overclock, and which
+// board is on the page (the 8-bit project's demo mounts and unmounts it).
+let boost = 1;
+let boostTimer = 0;
+let current = null;
+
+const TEMPLATE = `
+ <div class="board" role="group" aria-label="Simulated 8-bit breadboard computer"></div>
+ <div class="sim-bar">
+ <button type="button" class="btn" data-sim="run" aria-pressed="false">run</button>
+ <button type="button" class="btn" data-sim="step">step</button>
+ <button type="button" class="btn" data-sim="reset">reset</button>
+ <label class="sim-field"><span>program</span>
+ <select data-sim="program">
+ <option value="fibonacci">fibonacci</option>
+ <option value="multiply">multiply 7 × 6</option>
+ <option value="count">count up and down</option>
+ <option value="add">add 28 + 14</option>
+ </select>
+ </label>
+ <label class="sim-field"><span>clock</span>
+ <input type="range" min="0" max="100" value="43" data-sim="hz" aria-label="Clock speed">
+ <output>10 Hz</output>
+ </label>
+ </div>
+ <p class="sim-note">Simulated one clock cycle at a time. <kbd>c</kbd> runs or pauses, <kbd>n</kbd> steps one cycle, <kbd>r</kbd> resets. Click a bit in RAM to change the program, or the display to switch to signed.</p>`;
+
+export function mount(host) {
+ host.innerHTML = TEMPLATE;
+ const board = $(".board", host);
+ const runBtn = $('[data-sim="run"]', host);
+ const progSel = $('[data-sim="program"]', host);
+ const hzIn = $('[data-sim="hz"]', host);
+ const hzOut = $("output", host);
+
+ const s = { ram: new Uint8Array(16) };
+ resetState(s);
+ let program = PROGRAMS.fibonacci;
+ let running = false;
+ let hz = 10;
+ let acc = 0;
+ let phase = 0;
+ let signed = false;
+ let dirty = true;
+ let lastBoost = boost;
+ const effHz = () => Math.min(500, hz * boost);
+
+ // ── build the board ────────────────────────────────────────────────
+ const el = (tag, cls, text) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ };
+ const mods = {};
+ const mod = (key, name, sigs = []) => {
+ const m = el("section", `mod mod-${key}`);
+ const head = el("div", "mod-head");
+ head.append(el("span", "mod-name", name));
+ for (const sig of sigs) {
+ const t = el("span", "sig", LABEL[sig] ?? sig);
+ t.dataset.sig = sig;
+ head.append(t);
+ }
+ const body = el("div", "mod-body");
+ m.append(head, body);
+ mods[key] = { m, body, sigs: $$(".sig", head), val: null, leds: [] };
+ return mods[key];
+ };
+ const leds = (target, n, cls = "", gapAt = -1, tag = "span") => {
+ const w = el("div", "leds");
+ for (let i = 0; i < n; i++) {
+ if (i === gapAt) w.append(el("span", "gap"));
+ const l = el(tag, `led ${cls}`);
+ w.append(l);
+ target.leds.push(l);
+ }
+ return w;
+ };
+ const value = (target, text = "") => {
+ target.val = el("span", "mod-val", text);
+ return target.val;
+ };
+
+ const left = el("div", "col col-l");
+ const right = el("div", "col col-r");
+
+ const clk = mod("clk", "clock", ["HLT"]);
+ clk.body.append(leds(clk, 1, "b"), value(clk));
+ const mar = mod("mar", "memory address", ["MI"]);
+ mar.body.append(leds(mar, 4, "y"), value(mar));
+
+ const ram = mod("ram", "RAM", ["RI", "RO"]);
+ const rows = el("div", "ram-rows");
+ const ramRows = [];
+ for (let a = 0; a < 16; a++) {
+ const row = el("div", "ram-row");
+ const pcMark = el("span", "pc");
+ const addr = el("span", "addr", hex(a, 1));
+ const bits = el("span", "leds");
+ const cells = [];
+ for (let b = 7; b >= 0; b--) {
+ const btn = el("button", "led");
+ btn.type = "button";
+ btn.tabIndex = a === 0 && b === 7 ? 0 : -1;
+ btn.dataset.addr = a;
+ btn.dataset.bit = b;
+ btn.setAttribute("aria-label", `address ${a}, bit ${b}`);
+ bits.append(btn);
+ cells.push(btn);
+ }
+ const dis = el("span", "dis");
+ row.append(pcMark, addr, bits, dis);
+ rows.append(row);
+ ramRows.push({ row, pcMark, cells, dis });
+ }
+ ram.body.replaceWith(rows);
+
+ const ir = mod("ir", "instruction", ["II", "IO"]);
+ ir.body.append(leds(ir, 8, "", 4), value(ir));
+ ir.leds.forEach((l, i) => l.classList.add(i < 4 ? "b" : "y"));
+ left.append(clk.m, mar.m, ram.m, ir.m);
+
+ const pc = mod("pc", "program counter", ["CO", "J", "CE"]);
+ pc.body.append(leds(pc, 4, "g"), value(pc));
+ const a = mod("a", "A register", ["AI", "AO"]);
+ a.body.append(leds(a, 8), value(a));
+ const sum = mod("sum", "ALU Σ", ["EO", "SU"]);
+ sum.body.append(leds(sum, 8), value(sum));
+ const b = mod("b", "B register", ["BI"]);
+ b.body.append(leds(b, 8), value(b));
+ const out = mod("out", "output", ["OI"]);
+ const seg = el("div", "seg7");
+ const digits = [];
+ for (let d = 0; d < 4; d++) {
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("viewBox", "0 0 22 38");
+ svg.setAttribute("aria-hidden", "true");
+ const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
+ g.setAttribute("transform", "skewX(-6) translate(2 0)");
+ const segs = {};
+ for (const [k, dpath] of Object.entries(SEGS)) {
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
+ p.setAttribute("d", dpath);
+ g.append(p);
+ segs[k] = p;
+ }
+ svg.append(g);
+ seg.append(svg);
+ digits.push(segs);
+ }
+ const outLabel = el("span", "sr-only");
+ out.body.append(seg, value(out), outLabel);
+ out.body.title = "Click to switch between unsigned and signed";
+ const flags = mod("flags", "flags", ["FI"]);
+ const fl = el("div", "leds");
+ const cfLed = el("span", "led g");
+ const zfLed = el("span", "led g");
+ fl.append(cfLed, el("span", "dim", "C"), el("span", "gap"), zfLed, el("span", "dim", "Z"));
+ fl.style.alignItems = "center";
+ flags.body.append(fl, value(flags));
+ right.append(pc.m, a.m, sum.m, b.m, out.m, flags.m);
+
+ const bus = el("div", "bus");
+ const busHead = el("div", "mod-head");
+ busHead.append(el("span", "mod-name", "bus"));
+ const busVal = el("span", "bus-val mod-val", "0");
+ const lanesWrap = el("div", "lanes");
+ const lanes = [];
+ for (let i = 0; i < 8; i++) {
+ const l = el("span", "lane");
+ lanesWrap.append(l);
+ lanes.push(l);
+ }
+ bus.append(busHead, busVal, lanesWrap);
+
+ const ctrl = mod("ctrl", "control word", []);
+ ctrl.m.classList.add("ctrl");
+ const ctrlRow = el("div", "ctrl-row");
+ const stepWrap = el("div", "cw");
+ const stepLeds = [];
+ for (let i = 0; i < 5; i++) {
+ const bit = el("span", "cw-bit");
+ const l = el("span", "led");
+ bit.append(l, el("span", "", `T${i}`));
+ stepWrap.append(bit);
+ stepLeds.push([bit, l]);
+ }
+ const cwWrap = el("div", "cw");
+ const cwBits = LINES.map((n) => {
+ const bit = el("span", "cw-bit");
+ const l = el("span", "led b");
+ bit.append(l, el("span", "", LABEL[n] ?? n));
+ cwWrap.append(bit);
+ return [bit, l];
+ });
+ ctrlRow.append(stepWrap, cwWrap);
+ const ustep = el("p", "ustep");
+ ctrl.body.replaceWith(ctrlRow);
+ ctrl.m.append(ustep);
+
+ board.replaceChildren(left, bus, right, ctrl.m);
+
+ // ── render ─────────────────────────────────────────────────────────
+ const setBits = (list, v, n = list.length) => {
+ for (let i = 0; i < n; i++) list[i].classList.toggle("on", ((v >> (n - 1 - i)) & 1) === 1);
+ };
+ const setText = (node, text) => {
+ if (node.textContent !== text) node.textContent = text;
+ };
+
+ let lastRam = "";
+ function render() {
+ const cw = controlWord(s);
+ const r = alu(s, cw);
+ const v = busValue(s, cw, r.sum);
+
+ // registers
+ setBits(mar.leds, s.mar); setText(mar.val, String(s.mar));
+ setBits(pc.leds, s.pc); setText(pc.val, String(s.pc));
+ setBits(a.leds, s.a); setText(a.val, String(s.a));
+ setBits(b.leds, s.b); setText(b.val, String(s.b));
+ setBits(sum.leds, r.sum); setText(sum.val, `${cw & L.SU ? "A−B" : "A+B"} ${r.sum}`);
+ setBits(ir.leds, s.ir); setText(ir.val, s.step < 2 && s.cycles === 0 ? "" : disasm(s.ir));
+ cfLed.classList.toggle("on", !!s.cf);
+ zfLed.classList.toggle("on", !!s.zf);
+ setText(flags.val, "");
+ setBits(lanes, v); setText(busVal, String(v));
+
+ // clock
+ clk.leds[0].classList.toggle("on", running && !s.halted && (effHz() > 24 || phase < 0.5));
+ setText(clk.val, s.halted ? "halted" : running ? (boost > 1 ? `${fmtHz(effHz())} (${boost}×)` : fmtHz(hz)) : "paused");
+
+ // output display
+ const o = signed && s.out > 127 ? -(256 - s.out) : s.out;
+ const txt = (o < 0 ? "-" : " ") + String(Math.abs(o)).padStart(3, " ");
+ txt.split("").forEach((ch, i) => {
+ const on = GLYPH[ch] ?? "";
+ for (const [k, p] of Object.entries(digits[i])) p.classList.toggle("on", on.includes(k));
+ });
+ setText(out.val, signed ? "signed" : "");
+ setText(outLabel, `output ${o}`);
+
+ // control word and step
+ cwBits.forEach(([bit, l], i) => {
+ const on = (cw >> (15 - i)) & 1;
+ bit.classList.toggle("on", !!on);
+ l.classList.toggle("on", !!on);
+ });
+ stepLeds.forEach(([bit, l], i) => {
+ bit.classList.toggle("on", i === s.step);
+ l.classList.toggle("on", i === s.step);
+ });
+ const d = describe(s, cw);
+ const html = `T${s.step} <b>${d.label}</b> ${d.text}`;
+ if (ustep.innerHTML !== html) ustep.innerHTML = html;
+
+ // which modules drive the bus, which latch it, and their control lines
+ const outs = { pc: L.CO, ram: L.RO, ir: L.IO, a: L.AO, sum: L.EO };
+ const ins = { mar: L.MI, ram: L.RI, ir: L.II, a: L.AI, b: L.BI, out: L.OI, pc: L.J | L.CE, flags: L.FI, clk: L.HLT };
+ for (const [key, m] of Object.entries(mods)) {
+ m.m.classList.toggle("is-out", !!(cw & (outs[key] ?? 0)));
+ m.m.classList.toggle("is-in", !!(cw & (ins[key] ?? 0)) && !(cw & (outs[key] ?? 0)));
+ for (const t of m.sigs) t.classList.toggle("on", !!(cw & L[t.dataset.sig]));
+ }
+
+ // RAM matrix
+ const key = `${s.ram.join(",")}|${s.mar}|${s.pc}`;
+ if (key !== lastRam) {
+ lastRam = key;
+ ramRows.forEach((rr, addr) => {
+ setBits(rr.cells, s.ram[addr]);
+ rr.row.classList.toggle("is-mar", addr === s.mar);
+ setText(rr.pcMark, addr === s.pc ? "▸" : "");
+ const data = program.data[addr];
+ setText(rr.dis, data ? `${data[0]} = ${s.ram[addr]}` : disasm(s.ram[addr]));
+ });
+ }
+ }
+
+ function describe(st, cw) {
+ if (st.halted) return { label: "HLT", text: "clock stopped; reset to run again" };
+ const label = st.step < 2 ? "fetch" : OPS[st.ir >> 4] ?? "???";
+ if (!cw) {
+ if (label === "JC") return { label, text: "carry clear, so no jump" };
+ if (label === "JZ") return { label, text: "zero clear, so no jump" };
+ return { label, text: "nothing to do this step" };
+ }
+ const src = [[L.CO, "PC"], [L.RO, "RAM"], [L.IO, "operand"], [L.AO, "A"], [L.EO, cw & L.SU ? "A − B" : "A + B"]].find(([bit]) => cw & bit);
+ const dst = [[L.MI, "MAR"], [L.RI, "RAM"], [L.II, "IR"], [L.AI, "A"], [L.BI, "B"], [L.OI, "output"], [L.J, "PC"]].filter(([bit]) => cw & bit).map(([, n]) => n);
+ const parts = [];
+ if (src && dst.length) parts.push(`${src[1]} → ${dst.join(", ")}`);
+ if (cw & L.CE) parts.push("PC + 1");
+ if (cw & L.FI) parts.push("latch flags");
+ return { label, text: parts.join(", ") };
+ }
+
+
+ // ── simulation loop ────────────────────────────────────────────────
+ function frame(t, dt) {
+ if (boost !== lastBoost) {
+ lastBoost = boost;
+ dirty = true;
+ }
+ if (running && !s.halted) {
+ acc += dt * effHz();
+ phase = (phase + dt * effHz()) % 1;
+ let n = Math.floor(acc);
+ acc -= n;
+ n = Math.min(n, 400);
+ while (n-- > 0 && !s.halted) tick(s);
+ if (s.halted) halted();
+ dirty = true;
+ }
+ if (dirty) {
+ render();
+ dirty = false;
+ }
+ }
+
+ function halted() {
+ running = false;
+ paintRun();
+ message(`HLT: ${program.title} finished with ${s.out} on the display. Press r to reset.`, "", 6000);
+ }
+
+ function paintRun() {
+ runBtn.setAttribute("aria-pressed", String(running));
+ runBtn.textContent = running ? "pause" : "run";
+ }
+
+ // ── controls ───────────────────────────────────────────────────────
+ function run(want = !running) {
+ if (want && s.halted) return message("Halted. Press r (or the reset button) to start again.");
+ running = want;
+ acc = 0;
+ paintRun();
+ dirty = true;
+ }
+ function step() {
+ if (running) run(false);
+ if (s.halted) return message("Halted. Press r to reset.");
+ tick(s);
+ if (s.halted) halted();
+ dirty = true;
+ }
+ function reset() {
+ resetState(s);
+ dirty = true;
+ }
+ function load(key) {
+ program = PROGRAMS[key] ?? PROGRAMS.fibonacci;
+ progSel.value = key;
+ s.ram = assemble(program);
+ resetState(s);
+ lastRam = "";
+ dirty = true;
+ }
+ function setHz(v) {
+ hz = 0.5 * Math.pow(1000, v / 100);
+ hzOut.value = fmtHz(hz);
+ dirty = true;
+ }
+
+ runBtn.addEventListener("click", () => run());
+ $('[data-sim="step"]', host).addEventListener("click", step);
+ $('[data-sim="reset"]', host).addEventListener("click", reset);
+ progSel.addEventListener("change", () => {
+ load(progSel.value);
+ if (!reducedMotion.matches) run(true);
+ });
+ hzIn.addEventListener("input", () => setHz(+hzIn.value));
+ out.body.addEventListener("click", () => {
+ signed = !signed;
+ dirty = true;
+ message(signed ? "Output shows signed two’s complement" : "Output shows unsigned");
+ });
+
+ // Edit RAM by clicking bits; arrow keys move around the matrix.
+ rows.addEventListener("click", (e) => {
+ const btn = e.target.closest(".led");
+ if (!btn) return;
+ s.ram[+btn.dataset.addr] ^= 1 << +btn.dataset.bit;
+ focusCell(+btn.dataset.addr, +btn.dataset.bit);
+ dirty = true;
+ });
+ rows.addEventListener("keydown", (e) => {
+ const btn = e.target.closest(".led");
+ const d = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, 1], ArrowRight: [0, -1] }[e.key];
+ if (!btn || !d) return;
+ e.preventDefault();
+ focusCell((+btn.dataset.addr + d[0] + 16) % 16, Math.min(7, Math.max(0, +btn.dataset.bit + d[1])));
+ });
+ function focusCell(addr, bit) {
+ for (const rr of ramRows) for (const c of rr.cells) c.tabIndex = -1;
+ const c = ramRows[addr].cells[7 - bit];
+ c.tabIndex = 0;
+ c.focus({ preventScroll: true });
+ }
+
+ // c, n and r while the computer is on screen
+ let visible = false;
+ let off = null;
+ const io = new IntersectionObserver(([e]) => {
+ visible = e.intersectionRatio >= 0.25;
+ if (e.isIntersecting && !off) off = onFrame(frame);
+ else if (!e.isIntersecting && off) { off(); off = null; }
+ }, { threshold: [0, 0.25] });
+ io.observe(board);
+ const onKey = (e) => {
+ if (!visible || e.defaultPrevented || e.repeat || isTyping(e) || e.metaKey || e.ctrlKey || e.altKey) return;
+ if (document.querySelector("dialog[open]")) return;
+ const act = { c: () => run(), n: step, r: reset }[e.key];
+ if (!act) return;
+ e.preventDefault();
+ act();
+ };
+ document.addEventListener("keydown", onKey);
+
+ load("fibonacci");
+ setHz(+hzIn.value);
+ run(!reducedMotion.matches || boost > 1);
+ const api = { run, step, reset, load, board, halted: () => s.halted, running: () => running };
+ current = api;
+ return () => {
+ io.disconnect();
+ off?.();
+ document.removeEventListener("keydown", onKey);
+ if (current === api) current = null;
+ };
+}
+
+/** The board on the page, if one is mounted. */
+export const currentBoard = () => current;
+
+// M-x coffee-refill: four times the clock for a while.
+on("caffeine", ({ factor, seconds }) => {
+ boost = factor;
+ if (current) {
+ if (current.halted()) current.reset();
+ if (!current.running()) current.run(true);
+ }
+ let left = seconds;
+ clearInterval(boostTimer);
+ setSegment("misc", `${factor}× clock ${left}s`);
+ boostTimer = setInterval(() => {
+ left--;
+ if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
+ clearInterval(boostTimer);
+ boost = 1;
+ setSegment("misc", "");
+ message("The caffeine wore off. The clock is back to normal.");
+ }, 1000);
+});
diff --git a/src/scripts/demos/computer/commands.js b/src/scripts/demos/computer/commands.js
@@ -0,0 +1,33 @@
+// M-x commands for the 8-bit computer (c, n and r while it's on screen, or
+// SPC c). They open the 8-bit project first if its demo isn't showing.
+
+import { reducedMotion, emit } from "../../util.js";
+import { defcommand, completingRead } from "../../emacs/index.js";
+import { PROGRAMS } from "./machine.js";
+import { currentBoard } from "./board.js";
+
+// The 8-bit project's demo, opened and scrolled to if it isn't on the page yet.
+export async function showComputer() {
+ if (!currentBoard()) {
+ emit("project", "8-bit-computer");
+ for (let i = 0; i < 90 && !currentBoard(); i++) await new Promise((r) => requestAnimationFrame(r));
+ }
+ const current = currentBoard();
+ current?.board.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "center" });
+ return current;
+}
+
+const withComputer = (fn) => async () => {
+ const c = currentBoard() ?? (await showComputer());
+ if (c) fn(c);
+};
+defcommand("computer-run", withComputer((c) => c.run()), { doc: "Run or pause the 8-bit computer", key: "c" });
+defcommand("computer-step", withComputer((c) => c.step()), { doc: "Advance the clock by one cycle", key: "n" });
+defcommand("computer-reset", withComputer((c) => c.reset()), { doc: "Reset the computer (RAM is kept)", key: "r" });
+defcommand("computer-load-program", async () => {
+ const key = await completingRead("Load program: ", Object.entries(PROGRAMS).map(([k, p]) => ({ value: k, ann: p.title })));
+ if (!key) return;
+ const c = await showComputer();
+ c?.load(key);
+ c?.run(true);
+}, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
diff --git a/src/scripts/demos/computer/machine.js b/src/scripts/demos/computer/machine.js
@@ -0,0 +1,120 @@
+// Ben Eater's 8-bit breadboard computer, simulated one clock cycle at a time.
+//
+// Same architecture as the build: a 4-bit address space with 16 bytes of RAM,
+// an 8-bit bus, A and B registers, an adder/subtractor, an output register on
+// a 7-segment display, carry and zero flags, and microcode that drives sixteen
+// control lines over five steps per instruction.
+//
+// This is the machine itself, with no page attached: board.js draws it and
+// commands.js drives it from M-x.
+
+// Control lines, in the order the control-word LEDs show them.
+export const LINES = ["HLT", "MI", "RI", "RO", "IO", "II", "AI", "AO", "EO", "SU", "BI", "OI", "CE", "CO", "J", "FI"];
+export const L = Object.fromEntries(LINES.map((n, i) => [n, 1 << (15 - i)]));
+// How the lines are printed on the board: the sum-out line is ΣO, as on the build.
+export const LABEL = { EO: "ΣO" };
+
+export const OPS = { 0: "NOP", 1: "LDA", 2: "ADD", 3: "SUB", 4: "STA", 5: "LDI", 6: "JMP", 7: "JC", 8: "JZ", 14: "OUT", 15: "HLT" };
+const CODE = Object.fromEntries(Object.entries(OPS).map(([k, v]) => [v, +k]));
+const TAKES_ARG = new Set(["LDA", "ADD", "SUB", "STA", "LDI", "JMP", "JC", "JZ"]);
+
+// Microcode. Every instruction starts with the same two fetch steps.
+const FETCH = [L.CO | L.MI, L.RO | L.II | L.CE];
+const MICRO = {
+ LDA: [L.IO | L.MI, L.RO | L.AI, 0],
+ ADD: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.FI],
+ SUB: [L.IO | L.MI, L.RO | L.BI, L.EO | L.AI | L.SU | L.FI],
+ STA: [L.IO | L.MI, L.AO | L.RI, 0],
+ LDI: [L.IO | L.AI, 0, 0],
+ JMP: [L.IO | L.J, 0, 0],
+ JC: [L.IO | L.J, 0, 0],
+ JZ: [L.IO | L.J, 0, 0],
+ OUT: [L.AO | L.OI, 0, 0],
+ HLT: [L.HLT, 0, 0],
+};
+
+export function controlWord(s) {
+ if (s.step < 2) return FETCH[s.step];
+ const op = OPS[s.ir >> 4];
+ if (!MICRO[op]) return 0;
+ if ((op === "JC" && !s.cf) || (op === "JZ" && !s.zf)) return 0;
+ return MICRO[op][s.step - 2];
+}
+
+export function alu(s, cw) {
+ const raw = cw & L.SU ? s.a + ((~s.b & 0xff) + 1) : s.a + s.b;
+ return { sum: raw & 0xff, carry: raw > 0xff ? 1 : 0, zero: (raw & 0xff) === 0 ? 1 : 0 };
+}
+
+export function busValue(s, cw, sum) {
+ if (cw & L.CO) return s.pc;
+ if (cw & L.RO) return s.ram[s.mar];
+ if (cw & L.IO) return s.ir & 0x0f;
+ if (cw & L.AO) return s.a;
+ if (cw & L.EO) return sum;
+ return 0; // pull-down resistors
+}
+
+// One rising clock edge: everything with an "in" line latches the bus.
+export function tick(s) {
+ if (s.halted) return;
+ const cw = controlWord(s);
+ const r = alu(s, cw);
+ const v = busValue(s, cw, r.sum);
+ if (cw & L.MI) s.mar = v & 0x0f;
+ if (cw & L.RI) s.ram[s.mar] = v;
+ if (cw & L.II) s.ir = v;
+ if (cw & L.AI) s.a = v;
+ if (cw & L.BI) s.b = v;
+ if (cw & L.OI) s.out = v;
+ if (cw & L.FI) { s.cf = r.carry; s.zf = r.zero; }
+ if (cw & L.J) s.pc = v & 0x0f;
+ if (cw & L.CE) s.pc = (s.pc + 1) & 0x0f;
+ s.step = (s.step + 1) % 5;
+ s.cycles++;
+ // HLT gates the clock as soon as its control word appears.
+ if (controlWord(s) & L.HLT) s.halted = true;
+}
+
+export function resetState(s) {
+ Object.assign(s, { a: 0, b: 0, ir: 0, mar: 0, pc: 0, out: 0, step: 0, cf: 0, zf: 0, halted: false, cycles: 0 });
+}
+
+export const disasm = (byte) => {
+ const op = OPS[byte >> 4] ?? "???";
+ return TAKES_ARG.has(op) ? `${op} ${byte & 15}` : op;
+};
+
+// ── programs ──────────────────────────────────────────────────────────
+
+export const PROGRAMS = {
+ fibonacci: {
+ title: "fibonacci",
+ code: [["LDI", 1], ["STA", 15], ["LDI", 0], ["OUT"], ["ADD", 15], ["JC", 0], ["STA", 14], ["LDA", 15],
+ ["STA", 13], ["LDA", 14], ["STA", 15], ["LDA", 13], ["JMP", 3]],
+ data: { 13: ["z", 0], 14: ["t", 0], 15: ["y", 0] },
+ },
+ multiply: {
+ title: "multiply 7 × 6",
+ code: [["LDA", 14], ["SUB", 12], ["JC", 6], ["LDA", 13], ["OUT"], ["HLT"], ["STA", 14], ["LDA", 13],
+ ["ADD", 15], ["STA", 13], ["JMP", 0]],
+ data: { 12: ["one", 1], 13: ["prod", 0], 14: ["y", 6], 15: ["x", 7] },
+ },
+ count: {
+ title: "count up and down",
+ code: [["OUT"], ["ADD", 15], ["JC", 4], ["JMP", 0], ["SUB", 15], ["OUT"], ["JZ", 0], ["JMP", 4]],
+ data: { 15: ["step", 5] },
+ },
+ add: {
+ title: "add 28 + 14",
+ code: [["LDA", 14], ["ADD", 15], ["OUT"], ["HLT"]],
+ data: { 14: ["a", 28], 15: ["b", 14] },
+ },
+};
+
+export function assemble(p) {
+ const ram = new Uint8Array(16);
+ p.code.forEach(([op, arg = 0], i) => { ram[i] = (CODE[op] << 4) | (arg & 15); });
+ for (const [addr, [, v]] of Object.entries(p.data)) ram[addr] = v;
+ return ram;
+}
diff --git a/src/scripts/demos/othello.js b/src/scripts/demos/othello.js
@@ -3,6 +3,8 @@
// at the end of the think time. While it thinks, each candidate move is shaded
// by its share of the search.
+import "../../styles/demos/othello.css";
+
const C = 1;
const DIRS = [[-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1]];
const BLACK = 1; // you
diff --git a/src/scripts/demos/rbudp.js b/src/scripts/demos/rbudp.js
@@ -3,6 +3,7 @@
// TRANSFER_DONE), and blast just the missing ones until nothing is missing.
import { fitCanvas, themeColors, onFrame, reducedMotion } from "../util.js";
+import "../../styles/demos/rbudp.css";
const PACKETS = 64;
const FLIGHT = 0.7; // seconds for a datagram to cross
diff --git a/src/scripts/emacs/buffers.js b/src/scripts/emacs/buffers.js
@@ -0,0 +1,61 @@
+// Every <section> in <main> is a buffer: scrolling to one puts its file name
+// and major mode in the modeline and marks its link in the top line. goto()
+// jumps to one, and find-file picks one by name.
+
+import { $, $$, emit, reducedMotion } from "../util.js";
+import { message } from "./echo.js";
+import { defcommand } from "./commands.js";
+import { setSegment } from "./modeline.js";
+import { completingRead } from "./minibuffer.js";
+
+export const sections = $$("main > section");
+const navLinks = $$(".nav a");
+const sectionIO = new IntersectionObserver((entries) => {
+ for (const e of entries) {
+ if (!e.isIntersecting) continue;
+ setSegment("buf", e.target.dataset.file);
+ setSegment("mode", e.target.dataset.mode);
+ for (const a of navLinks) a.setAttribute("aria-current", String(a.hash === `#${e.target.id}`));
+ emit("section", e.target.id);
+ }
+}, { rootMargin: "-40% 0px -55% 0px" });
+sections.forEach((s) => sectionIO.observe(s));
+
+export function goto(id, quiet = false) {
+ const s = document.getElementById(id);
+ if (!s) return;
+ // Sections snap to their header line, so scroll to that rather than the padding above it.
+ ($(".winbar", s) ?? s).scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
+ history.replaceState(null, "", id === "top" ? location.pathname : `#${id}`);
+ if (!quiet) message(`"${s.dataset.file}"`);
+}
+
+document.addEventListener("click", (e) => {
+ const a = e.target.closest('a[href^="#"]');
+ if (!a || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
+ const id = a.hash.slice(1);
+ if (!sections.some((s) => s.id === id)) return;
+ e.preventDefault();
+ goto(id, true);
+});
+
+export function findSection(word = "") {
+ const w = word.toLowerCase();
+ return sections.find((s) => s.id === w || s.dataset.file === w) || sections.find((s) => s.id.startsWith(w) || s.dataset.file.startsWith(w));
+}
+
+defcommand("find-file", async () => {
+ const id = await completingRead("Find file: ", sections.map((s) => ({ value: s.dataset.file, ann: s.querySelector("h1, h2")?.textContent ?? "" })));
+ const s = id && sections.find((x) => x.dataset.file === id);
+ if (s) goto(s.id);
+}, { doc: "Jump to a section by its file name", key: "SPC ." });
+
+for (const s of sections) {
+ defcommand(`goto-${s.id}`, () => goto(s.id), { hidden: true });
+}
+
+// The home page's sections, which the SPC keys jump to: on other pages those
+// keys go back to the home page instead.
+for (const id of ["top", "about", "projects", "posts", "contact"]) {
+ if (!sections.some((s) => s.id === id)) defcommand(`goto-${id}`, () => location.assign(`/#${id}`), { hidden: true });
+}
diff --git a/src/scripts/emacs/builtins.js b/src/scripts/emacs/builtins.js
@@ -0,0 +1,17 @@
+// The commands every page has: help, quit, restart and the coffee link.
+
+import { $ } from "../util.js";
+import { message } from "./echo.js";
+import { defcommand } from "./commands.js";
+
+const help = $(".help");
+
+defcommand("help", () => { if (!help.open) help.showModal(); }, { doc: "Show keys and commands", key: "SPC h h" });
+defcommand("save-buffers-kill-terminal", () => message("This is a website: close it with C-w. Glad you stayed, though."), { doc: "Quit Emacs", key: "SPC q q" });
+defcommand("restart-emacs", () => location.reload(), { doc: "Restart Emacs (reloads the page)", key: "SPC q r" });
+defcommand("buy-me-a-coffee", () => {
+ window.open("https://buymeacoffee.com/michaelneuper", "_blank", "noopener");
+ message("Opened buymeacoffee.com/michaelneuper");
+}, { doc: "Open my Buy Me a Coffee page", key: "SPC b" });
+defcommand("keyboard-quit", () => message("Quit"), { hidden: true });
+help.addEventListener("keydown", (e) => { if (e.key === "q") help.close(); });
diff --git a/src/scripts/emacs/commands.js b/src/scripts/emacs/commands.js
@@ -0,0 +1,16 @@
+// Commands, as M-x knows them. Other modules add theirs with defcommand().
+
+import { message } from "./echo.js";
+
+const commands = new Map();
+export function defcommand(name, run, { doc = "", key = "", hidden = false } = {}) {
+ commands.set(name, { name, run, doc, key, hidden });
+}
+export function call(name, ...args) {
+ const c = commands.get(name);
+ if (!c) return message(`Symbol’s function definition is void: ${name}`, "error");
+ return c.run(...args);
+}
+
+export const hasCommand = (name) => commands.has(name);
+export const listCommands = () => [...commands.values()];
diff --git a/src/scripts/emacs/echo.js b/src/scripts/emacs/echo.js
@@ -0,0 +1,19 @@
+// The echo area under the modeline: one line of messages, which go back to the
+// idle hint after a while.
+
+import { $, finePointer } from "../util.js";
+
+const echo = $(".echo");
+
+let echoTimer = 0;
+const idleText = () => (finePointer.matches ? "Press SPC for commands, or M-x to run one by name." : "");
+export function message(text, kind = "", ms = 5000) {
+ echo.textContent = text;
+ echo.className = kind ? `echo is-${kind}` : "echo";
+ clearTimeout(echoTimer);
+ if (ms) echoTimer = setTimeout(() => message(idleText(), "", 0), ms);
+}
+
+/** Back to the idle hint. */
+export const idle = () => message(idleText(), "", 0);
+idle();
diff --git a/src/scripts/emacs/index.js b/src/scripts/emacs/index.js
@@ -0,0 +1,13 @@
+// The Emacs around every page: doom-modeline, the echo area, a minibuffer with
+// vertico-style completion (M-x, evil's :), which-key on the SPC leader, and
+// load-theme with live preview. Import from here to add commands or talk to it.
+
+import "./builtins.js";
+import "./keys.js";
+
+export { message } from "./echo.js";
+export { defcommand, call } from "./commands.js";
+export { setSegment } from "./modeline.js";
+export { completingRead } from "./minibuffer.js";
+export { goto } from "./buffers.js";
+export { setTheme } from "./load-theme.js";
diff --git a/src/scripts/emacs/keys.js b/src/scripts/emacs/keys.js
@@ -0,0 +1,132 @@
+// The keys: SPC opens which-key on the leader map, M-x (Alt+x) and : open the
+// minibuffer, ? opens *Help* and C-g quits. Keys typed into a field are left alone.
+
+import { $, isTyping } from "../util.js";
+import { message, idle } from "./echo.js";
+import { call, hasCommand } from "./commands.js";
+import { mx, ex } from "./mx.js";
+import { toggleMenu, menuOpen } from "./menu.js";
+
+const wk = $(".which-key");
+const help = $(".help");
+const mini = $(".mini");
+
+const KEYMAP = [
+ { key: "SPC", desc: "find file", cmd: "find-file" },
+ { key: ":", desc: "M-x", cmd: "execute-extended-command" },
+ { key: "c", desc: "+computer", map: [
+ { key: "c", desc: "run or pause", cmd: "computer-run" },
+ { key: "n", desc: "step one cycle", cmd: "computer-step" },
+ { key: "r", desc: "reset", cmd: "computer-reset" },
+ { key: "l", desc: "load program", cmd: "computer-load-program" },
+ ] },
+ { key: "a", desc: "about", cmd: "goto-about" },
+ { key: "p", desc: "projects", cmd: "goto-projects" },
+ { key: "n", desc: "posts", cmd: "goto-posts" },
+ { key: "m", desc: "mail me", cmd: "goto-contact" },
+ { key: "i", desc: "imenu", cmd: "imenu" },
+ { key: "r", desc: "refill coffee", cmd: "coffee-refill" },
+ { key: "b", desc: "buy me a coffee", cmd: "buy-me-a-coffee" },
+ { key: "h", desc: "+help", map: [
+ { key: "t", desc: "load-theme", cmd: "load-theme" },
+ { key: "h", desc: "keys and commands", cmd: "help" },
+ ] },
+ { key: "s", desc: "+security", map: [
+ { key: "f", desc: "submit-flag", cmd: "submit-flag" },
+ { key: "g", desc: "copy GPG fingerprint", cmd: "copy-gpg-fingerprint" },
+ ] },
+ { key: "q", desc: "+quit", map: [
+ { key: "q", desc: "quit", cmd: "save-buffers-kill-terminal" },
+ { key: "r", desc: "restart", cmd: "restart-emacs" },
+ ] },
+];
+
+let leader = null; // { map, prefix }
+let wkTimer = 0;
+
+// Only the bindings whose command this page defines (posts have no computer).
+const bound = (map) => map.filter((b) => (b.map ? bound(b.map).length : hasCommand(b.cmd)));
+
+function startLeader() {
+ leader = { map: bound(KEYMAP), prefix: "SPC" };
+ message("SPC-", "", 0);
+ clearTimeout(wkTimer);
+ wkTimer = setTimeout(showWhichKey, 300);
+}
+
+function showWhichKey() {
+ if (!leader) return;
+ const title = document.createElement("p");
+ title.className = "wk-title";
+ title.textContent = `${leader.prefix}-`;
+ wk.replaceChildren(title, ...leader.map.map((b) => {
+ const item = document.createElement("button");
+ item.type = "button";
+ item.className = "wk-item";
+ item.style.cssText = "border:0;background:none;padding:0;text-align:left;font:inherit;color:inherit";
+ item.innerHTML = `<span class="wk-key"></span><span class="wk-arrow">→</span><span class="wk-desc${b.map ? " group" : ""}"></span>`;
+ item.children[0].textContent = b.key;
+ item.children[2].textContent = b.desc;
+ item.addEventListener("mousedown", (e) => e.preventDefault());
+ item.addEventListener("click", () => press(b.key));
+ return item;
+ }));
+ wk.hidden = false;
+}
+
+function endLeader(text = null) {
+ leader = null;
+ clearTimeout(wkTimer);
+ wk.hidden = true;
+ if (text !== null) message(text, text.endsWith("undefined") ? "error" : "", 3000);
+ else idle();
+}
+
+function press(key) {
+ const b = leader.map.find((x) => x.key === key);
+ if (!b) return endLeader(`${leader.prefix} ${key} is undefined`);
+ if (b.map) {
+ leader = { map: bound(b.map), prefix: `${leader.prefix} ${key}` };
+ message(`${leader.prefix}-`, "", 0);
+ showWhichKey();
+ return;
+ }
+ endLeader();
+ call(b.cmd);
+}
+
+// ── keys ──────────────────────────────────────────────────────────────
+
+addEventListener("keydown", (e) => {
+ if (help.open) return;
+ if (leader) {
+ if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.key === "Escape" || (e.ctrlKey && e.key === "g")) endLeader("Quit");
+ else press(e.key === " " ? "SPC" : e.key);
+ return;
+ }
+ if (!mini.hidden || isTyping(e)) return;
+
+ if (e.altKey && e.code === "KeyX" && !e.ctrlKey && !e.metaKey) {
+ e.preventDefault();
+ mx();
+ } else if (e.ctrlKey && e.key === "g") {
+ e.preventDefault();
+ call("keyboard-quit");
+ } else if (e.metaKey || e.ctrlKey || e.altKey) {
+ // leave browser shortcuts alone
+ } else if (e.key === " " && !e.target.closest?.("button, a, select, summary, [tabindex]")) {
+ e.preventDefault();
+ startLeader();
+ } else if (e.key === ":") {
+ e.preventDefault();
+ ex();
+ } else if (e.key === "?") {
+ e.preventDefault();
+ call("help");
+ } else if (e.key === "Escape" && menuOpen()) {
+ toggleMenu(false);
+ }
+}, true);
diff --git a/src/scripts/emacs/load-theme.js b/src/scripts/emacs/load-theme.js
@@ -0,0 +1,36 @@
+// M-x load-theme (SPC h t, or the button in the top line), with a live preview
+// as you move through the list. The choice is kept in localStorage, and the
+// inline script in src/layouts/Base.astro applies it before the first paint.
+
+import { $, emit } from "../util.js";
+import { THEMES, DEFAULT_THEME } from "../themes.js";
+import { message } from "./echo.js";
+import { defcommand } from "./commands.js";
+import { completingRead } from "./minibuffer.js";
+
+const root = document.documentElement;
+
+const currentTheme = () => root.dataset.theme || DEFAULT_THEME;
+function applyTheme(id) {
+ if (root.dataset.theme === id) return;
+ root.dataset.theme = id;
+ emit("theme", id);
+}
+export function setTheme(id) {
+ if (!THEMES.some((t) => t.id === id)) return message(`Unable to find theme file for ‘${id}’`, "error");
+ applyTheme(id);
+ try { localStorage.setItem("theme", id); } catch { /* private mode */ }
+ message(`Loaded theme ${id}`);
+}
+
+async function loadTheme() {
+ const saved = currentTheme();
+ const id = await completingRead("Load custom theme: ", THEMES.map((t) => ({ value: t.id, ann: t.desc })), {
+ select: saved,
+ onPreview: applyTheme,
+ });
+ if (id) setTheme(id);
+ else applyTheme(saved);
+}
+defcommand("load-theme", loadTheme, { doc: "Load a theme, previewing each one", key: "SPC h t" });
+$(".theme-key").addEventListener("click", loadTheme);
diff --git a/src/scripts/emacs/menu.js b/src/scripts/emacs/menu.js
@@ -0,0 +1,18 @@
+// The top line's menu button, which shows the section links on narrow screens.
+
+import { $ } from "../util.js";
+
+const nav = $(".nav");
+const menuKey = $(".menu-key");
+
+export const menuOpen = () => nav.classList.contains("is-open");
+
+export function toggleMenu(open = !nav.classList.contains("is-open")) {
+ nav.classList.toggle("is-open", open);
+ menuKey.setAttribute("aria-expanded", String(open));
+}
+menuKey.addEventListener("click", () => toggleMenu());
+nav.addEventListener("click", (e) => { if (e.target.closest("a")) toggleMenu(false); });
+document.addEventListener("click", (e) => {
+ if (nav.classList.contains("is-open") && !e.target.closest(".nav, .menu-key")) toggleMenu(false);
+});
diff --git a/src/scripts/emacs/minibuffer.js b/src/scripts/emacs/minibuffer.js
@@ -0,0 +1,168 @@
+// The minibuffer: completing-read with vertico-style candidates, used by M-x,
+// evil's :, load-theme and anything else that asks for a choice.
+
+import { $ } from "../util.js";
+import { message } from "./echo.js";
+import { evilState } from "./modeline.js";
+
+const mini = $(".mini");
+const form = $(".mini-prompt");
+const input = $("input", form);
+const psEl = $(".mini-ps");
+const countEl = $(".mini-count");
+const list = $(".mini-list");
+
+let pending = null;
+
+// Resolves to the chosen string, or null if cancelled. `candidates` is an array of
+// { value, ann, key } (or strings), or a function of the input returning one.
+export function completingRead(prompt, candidates, opts = {}) {
+ const { initial = "", requireMatch = true, select = null, onPreview = null } = opts;
+ if (pending) pending.finish(null);
+ const returnFocus = document.activeElement;
+
+ return new Promise((resolve) => {
+ let items = [];
+ let sel = 0;
+ const norm = (c) => (typeof c === "string" ? { value: c } : c);
+
+ const render = () => {
+ const q = input.value.trim().toLowerCase();
+ const all = (typeof candidates === "function" ? candidates(input.value) : candidates).map(norm);
+ const terms = q.split(/\s+/).filter(Boolean);
+ items = typeof candidates === "function" ? all : all.filter((c) => terms.every((t) => c.value.toLowerCase().includes(t)));
+ // candidates that start with what you typed come first
+ if (terms.length && typeof candidates !== "function") {
+ const starts = (c) => c.value.toLowerCase().startsWith(terms[0]);
+ items = [...items.filter(starts), ...items.filter((c) => !starts(c))];
+ }
+ sel = Math.min(sel, Math.max(0, items.length - 1));
+ countEl.textContent = items.length ? `${sel + 1}/${items.length}` : "0/0";
+ list.replaceChildren(...items.map((c, i) => {
+ const li = document.createElement("li");
+ li.setAttribute("role", "option");
+ li.setAttribute("aria-selected", String(i === sel));
+ li.id = `mini-opt-${i}`;
+ const cand = document.createElement("span");
+ cand.className = "cand";
+ cand.append(...highlight(c.value, terms));
+ li.append(cand);
+ if (c.ann) {
+ const a = document.createElement("span");
+ a.className = "ann";
+ a.textContent = c.ann;
+ li.append(a);
+ }
+ if (c.key) {
+ const k = document.createElement("span");
+ k.className = "key";
+ k.textContent = c.key;
+ li.append(k);
+ }
+ li.addEventListener("mousedown", (e) => e.preventDefault());
+ li.addEventListener("click", () => { sel = i; accept(); });
+ return li;
+ }));
+ input.setAttribute("aria-activedescendant", items.length ? `mini-opt-${sel}` : "");
+ list.children[sel]?.scrollIntoView({ block: "nearest" });
+ };
+
+ const move = (d) => {
+ if (!items.length) return;
+ sel = (sel + d + items.length) % items.length;
+ render();
+ onPreview?.(items[sel].value);
+ };
+
+ const accept = () => {
+ const raw = input.value.trim();
+ const hit = items[sel]?.value;
+ if (hit !== undefined && (requireMatch || typeof candidates !== "function" || raw === "")) finish(hit);
+ else if (!requireMatch) finish(raw || hit || null);
+ else if (hit !== undefined) finish(hit);
+ else message("[No match]", "error", 1500);
+ };
+
+ const onKey = (e) => {
+ const ctrl = e.ctrlKey && !e.metaKey;
+ if (e.key === "ArrowDown" || (ctrl && e.key === "n")) { e.preventDefault(); move(1); }
+ else if (e.key === "ArrowUp" || (ctrl && e.key === "p")) { e.preventDefault(); move(-1); }
+ else if (e.key === "Enter") { e.preventDefault(); accept(); }
+ else if (e.key === "Escape" || (ctrl && e.key === "g")) { e.preventDefault(); finish(null); }
+ else if (e.key === "Tab") {
+ e.preventDefault();
+ if (items[sel]) {
+ input.value = items[sel].value;
+ render();
+ }
+ }
+ };
+ const onInput = () => {
+ sel = 0;
+ render();
+ if (items.length) onPreview?.(items[0].value);
+ };
+ const onBlur = () => setTimeout(() => { if (pending?.finish === finish && document.activeElement !== input) finish(null); }, 0);
+ const onSubmit = (e) => e.preventDefault();
+
+ function finish(value) {
+ if (pending?.finish !== finish) return;
+ pending = null;
+ input.removeEventListener("keydown", onKey);
+ input.removeEventListener("input", onInput);
+ input.removeEventListener("blur", onBlur);
+ form.removeEventListener("submit", onSubmit);
+ mini.hidden = true;
+ list.replaceChildren();
+ if (returnFocus && returnFocus !== input && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
+ else input.blur();
+ evilState();
+ resolve(value);
+ }
+
+ pending = { finish };
+ psEl.textContent = prompt;
+ input.value = initial;
+ mini.hidden = false;
+ input.addEventListener("keydown", onKey);
+ input.addEventListener("input", onInput);
+ input.addEventListener("blur", onBlur);
+ form.addEventListener("submit", onSubmit);
+ if (select) {
+ const all = (typeof candidates === "function" ? candidates("") : candidates).map(norm);
+ sel = Math.max(0, all.findIndex((c) => c.value === select));
+ }
+ render();
+ input.focus({ preventScroll: true });
+ evilState();
+ });
+}
+
+function highlight(text, terms) {
+ if (!terms.length) return [text];
+ const lower = text.toLowerCase();
+ const marks = new Array(text.length).fill(false);
+ for (const t of terms) {
+ const i = lower.indexOf(t);
+ if (i >= 0) for (let k = i; k < i + t.length; k++) marks[k] = true;
+ }
+ const out = [];
+ let buf = "";
+ let on = false;
+ for (let i = 0; i <= text.length; i++) {
+ const m = i < text.length && marks[i];
+ if (i === text.length || m !== on) {
+ if (buf) {
+ if (on) {
+ const mk = document.createElement("mark");
+ mk.textContent = buf;
+ out.push(mk);
+ } else out.push(buf);
+ }
+ buf = "";
+ on = m;
+ }
+ if (i < text.length) buf += text[i];
+ }
+ return out;
+}
diff --git a/src/scripts/emacs/modeline.js b/src/scripts/emacs/modeline.js
@@ -0,0 +1,45 @@
+// doom-modeline: evil state, buffer name, position, major mode and a clock.
+// Other modules fill the misc and flags segments with setSegment().
+
+import { $, isTyping } from "../util.js";
+
+const root = document.documentElement;
+const modeline = $(".modeline");
+const ml = {
+ evil: $(".ml-evil"), buf: $(".ml-buf"), pos: $(".ml-pos"), pct: $(".ml-pct"),
+ flags: $(".ml-flags"), misc: $(".ml-misc"), mode: $(".ml-mode"), time: $(".ml-time"),
+};
+const mini = $(".mini");
+
+export function setSegment(name, text) {
+ if (ml[name]) ml[name].textContent = text;
+}
+
+export function evilState() {
+ const a = document.activeElement;
+ let s = "N";
+ if (!mini.hidden || (a && isTyping({ target: a }))) s = "I";
+ else if (String(getSelection()).length) s = "V";
+ modeline.dataset.evil = s;
+ ml.evil.textContent = `<${s}>`;
+}
+document.addEventListener("focusin", evilState);
+document.addEventListener("focusout", () => setTimeout(evilState));
+document.addEventListener("selectionchange", evilState);
+
+function onScroll() {
+ const max = root.scrollHeight - innerHeight;
+ const y = scrollY;
+ ml.pct.textContent = y <= 2 ? "Top" : y >= max - 2 ? "Bot" : `${Math.round((y / max) * 100)}%`;
+ ml.pos.textContent = `${Math.floor(y / 21) + 1}:0`;
+ root.classList.toggle("is-scrolled", y > 24);
+}
+addEventListener("scroll", onScroll, { passive: true });
+onScroll();
+
+function clock() {
+ const d = new Date();
+ ml.time.textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
+}
+clock();
+setInterval(clock, 15000);
diff --git a/src/scripts/emacs/mx.js b/src/scripts/emacs/mx.js
@@ -0,0 +1,39 @@
+// M-x, and evil's ex line (:) with :colorscheme, :edit, :help, :q and :w.
+
+import { $ } from "../util.js";
+import { THEMES } from "../themes.js";
+import { message } from "./echo.js";
+import { defcommand, call, listCommands } from "./commands.js";
+import { completingRead } from "./minibuffer.js";
+import { sections, goto, findSection } from "./buffers.js";
+import { setTheme } from "./load-theme.js";
+
+export async function mx() {
+ const cands = listCommands().filter((c) => !c.hidden).sort((a, b) => a.name.localeCompare(b.name))
+ .map((c) => ({ value: c.name, ann: c.doc, key: c.key }));
+ const name = await completingRead("M-x ", cands);
+ if (name) call(name);
+}
+defcommand("execute-extended-command", mx, { hidden: true });
+
+const EX = ["colorscheme", "edit", "help", "quit", "write", "wq"];
+export async function ex() {
+ const line = await completingRead(":", (q) => {
+ const [cmd, arg = ""] = q.trim().split(/\s+/);
+ if (/^colo/.test(cmd || "") && q.includes(" ")) return THEMES.filter((t) => t.id.startsWith(arg)).map((t) => ({ value: `colorscheme ${t.id}`, ann: t.desc }));
+ if (/^e(dit)?$/.test(cmd || "") && q.includes(" ")) return sections.filter((s) => s.id.startsWith(arg)).map((s) => ({ value: `edit ${s.id}`, ann: s.dataset.file }));
+ return EX.filter((c) => c.startsWith(cmd || "")).map((c) => ({ value: c }));
+ }, { requireMatch: false });
+ if (line) runEx(line);
+}
+
+function runEx(line) {
+ const [cmd, arg] = line.trim().split(/\s+/);
+ if (/^q(uit|a)?!?$/.test(cmd)) call("save-buffers-kill-terminal");
+ else if (/^(w|write)$/.test(cmd)) message(`"${$(".ml-buf").textContent}" written`);
+ else if (/^(wq|x)!?$/.test(cmd)) message(`"${$(".ml-buf").textContent}" written. Still here, though.`);
+ else if (/^colo(r(scheme)?)?$/.test(cmd)) arg ? setTheme(arg) : call("load-theme");
+ else if (/^e(dit)?$/.test(cmd)) goto(findSection(arg)?.id ?? "top");
+ else if (/^h(elp)?$/.test(cmd)) call("help");
+ else message(`E492: Not an editor command: ${line}`, "error");
+}
diff --git a/src/scripts/gitlog.js b/src/scripts/gitlog.js
@@ -1,226 +0,0 @@
-// Experience as one-line git log --graph output, the "git lg" way:
-// <date> - (refs) <subject> <author>
-// with the date standing in for the hash and the company as the author.
-//
-// The rows are plain text in src/pages/index.astro, so they read fine without JavaScript.
-// This colours the graph by branch (data-lanes), colours the date, refs and
-// author, and turns subjects that have a .gl-desc into buttons that expand it.
-// Lanes: m main, u undergrad, t teaching-assistant, b bbd-software, i mwr-internship.
-//
-// When the log is wider than its box (on a phone), it wraps the way a narrow
-// terminal would: at word boundaries, with each wrapped line starting with the
-// commit's lane pipes and a short hanging indent.
-
-import { $, $$ } from "./util.js";
-
-const LANE_CLASS = { m: "lm", u: "lu", t: "lt", b: "lb", i: "li" };
-const ROW = /^([|*/\\ ]*?)(\d{4}-\d{2}(?:-\d{2})?) - (?:\(([^)]*)\) ?)?(.*?)(?: ?<([^>]*)>)?$/;
-const HANG = 2;
-
-function el(tag, cls, text) {
- const e = document.createElement(tag);
- if (cls) e.className = cls;
- if (text != null) e.textContent = text;
- return e;
-}
-
-// The graph characters, coloured by lane and hidden from screen readers.
-// `tip` makes the commit's * bold.
-function graph(text, lanes = "", tip = false) {
- const g = el("span", "gl-graph");
- g.setAttribute("aria-hidden", "true");
- [...text].forEach((ch, i) => {
- const lane = LANE_CLASS[(lanes[i] || " ").trim()];
- if (ch === "*" && tip) g.append(el("span", "gl-tip", ch));
- else g.append(lane && ch.trim() ? el("span", lane, ch) : ch);
- });
- return g;
-}
-
-// The HEAD commit is dated with the month the page is viewed in.
-function today() {
- const d = new Date();
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
-}
-
-function parse(div) {
- const descEl = $(".gl-desc", div);
- const body = descEl ? descEl.textContent.replace(/^\n+|\s+$/g, "").split("\n") : null;
- descEl?.remove();
- const text = div.textContent.replace(/\s+$/, "");
- const m = ROW.exec(text);
- const row = {
- graph: m ? m[1] : text, lanes: div.dataset.lanes, cont: div.dataset.cont ?? "", contLanes: div.dataset.contLanes,
- tip: "tip" in div.dataset, branch: div.dataset.branch,
- };
- if (!m) return row;
- return {
- ...row,
- date: "today" in div.dataset ? today() : m[2],
- refs: m[3] ? m[3].split(/,\s*/).map((r) => (r.startsWith("HEAD -> ") ? { head: true, name: r.slice(8) } : { name: r })) : [],
- subject: m[4],
- author: m[5],
- body,
- };
-}
-
-const refsText = (refs) => `(${refs.map((r) => (r.head ? "HEAD -> " : "") + r.name).join(", ")})`;
-
-// A commit line as words, so it can be wrapped. Each word knows how to draw itself.
-function words(row) {
- const w = [{ text: row.date, cls: `gl-date ${LANE_CLASS[row.branch] ?? ""}` }, { text: "-" }];
- if (row.refs.length) w.push({ text: refsText(row.refs), refs: row.refs });
- if (row.subject) for (const s of row.subject.split(" ")) w.push({ text: s, subj: true });
- if (row.author) w.push({ text: `<${row.author}>`, cls: "gl-author" }); // kept on one line
- return w;
-}
-
-// Greedy word wrap. Returns lines of words; a word longer than the width gets a line to itself.
-function wrap(list, width, hang) {
- const lines = [[]];
- let used = 0;
- for (const w of list) {
- const line = lines[lines.length - 1];
- const need = (line.length ? 1 : 0) + w.text.length;
- if (line.length && used + need > width) {
- lines.push([w]);
- used = hang + w.text.length;
- } else {
- line.push(w);
- used += need;
- }
- }
- return lines;
-}
-
-// Re-flow a description to a width, keeping "- " bullets with a hanging indent.
-function reflow(body, width) {
- const blocks = [];
- for (const line of body) {
- if (line.startsWith("- ") || !blocks.length || !line.startsWith(" ")) blocks.push(line.trim());
- else blocks[blocks.length - 1] += ` ${line.trim()}`;
- }
- // plain paragraphs that were hard-wrapped become one block again
- const merged = [];
- for (const b of blocks) {
- if (merged.length && !b.startsWith("- ") && !merged[merged.length - 1].startsWith("- ")) merged[merged.length - 1] += ` ${b}`;
- else merged.push(b);
- }
- const out = [];
- for (const b of merged) {
- const bullet = b.startsWith("- ");
- const lines = wrap(b.split(" ").map((text) => ({ text })), width, bullet ? 2 : 0);
- lines.forEach((l, i) => out.push((i && bullet ? " " : "") + l.map((w) => w.text).join(" ")));
- }
- return out;
-}
-
-export function initGitlog() {
- const host = $(".gitlog");
- if (!host) return;
- const rows = $$(":scope > .gl-line", host).map(parse);
- const open = new Set();
- let lastCols = 0;
-
- function measureCols() {
- const probe = el("span", "", "0".repeat(20));
- probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre";
- host.append(probe);
- const ch = probe.getBoundingClientRect().width / 20 || 7;
- probe.remove();
- const cs = getComputedStyle(host);
- const inner = host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
- return Math.max(24, Math.floor(inner / ch));
- }
-
- function render(focusRow = -1) {
- const cols = measureCols();
- lastCols = cols;
- const lineLen = (r) => r.graph.length + words(r).reduce((n, w, i) => n + (i ? 1 : 0) + w.text.length, 0);
- const wide = rows.every((r) => !r.date || lineLen(r) <= cols);
- const pipesOnly = (r) => r && !r.date && /^[| ]+$/.test(r.graph);
- const frag = document.createDocumentFragment();
-
- rows.forEach((row, n) => {
- if (!row.date) {
- const line = el("div", "gl-line");
- line.append(graph(row.graph, row.lanes));
- frag.append(line);
- return;
- }
-
- const isOpen = open.has(n);
- const lines = wrap(words(row), cols - row.graph.length, HANG);
- let subjSeen = false;
- lines.forEach((ws, li) => {
- const line = el("div", "gl-line");
- line.append(li ? graph(row.cont, row.contLanes) : graph(row.graph, row.lanes, row.tip));
- if (li) line.append(" ".repeat(HANG));
- // consecutive subject words on a line become one button (or span) so the underline is unbroken
- for (let k = 0; k < ws.length; k++) {
- if (k) line.append(" ");
- const w = ws[k];
- if (w.subj) {
- let text = w.text;
- while (ws[k + 1]?.subj) text += ` ${ws[++k].text}`;
- if (row.body) {
- const b = el("button", "gl-subj", text);
- b.type = "button";
- b.dataset.row = n;
- b.setAttribute("aria-expanded", String(isOpen));
- if (subjSeen) b.tabIndex = -1; // one tab stop per commit, even when its subject wraps
- line.append(b);
- } else {
- line.append(el("span", "gl-text", text));
- }
- subjSeen = true;
- } else if (w.refs) {
- const refs = el("span", "gl-refs");
- refs.append("(");
- w.refs.forEach((r, i) => {
- if (i) refs.append(", ");
- if (r.head) refs.append(el("span", "gl-head", "HEAD -> "));
- refs.append(el("span", "gl-branch", r.name));
- });
- refs.append(")");
- line.append(refs);
- } else {
- line.append(w.cls ? el("span", w.cls, w.text) : w.text);
- }
- }
- frag.append(line);
- });
-
- if (row.body && isOpen) {
- const refsLen = row.refs.length ? refsText(row.refs).length + 1 : 0;
- // under the subject when there's room; otherwise a little deeper than wrapped lines
- const indent = wide ? row.date.length + 3 + refsLen : HANG + 2;
- const text = wide ? row.body : reflow(row.body, cols - row.cont.length - indent);
- const tail = pipesOnly(rows[n + 1]) ? [] : [""];
- for (const t of [...text, ...tail]) {
- const b = el("div", "gl-line gl-body");
- b.append(graph(row.cont, row.contLanes), t ? " ".repeat(indent) + t : "");
- frag.append(b);
- }
- }
- });
-
- host.replaceChildren(frag);
- host.classList.add("is-built");
- if (focusRow >= 0) host.querySelector(`.gl-subj[data-row="${focusRow}"]`)?.focus({ preventScroll: true });
- }
-
- host.addEventListener("click", (e) => {
- const b = e.target.closest(".gl-subj");
- if (!b) return;
- const n = +b.dataset.row;
- if (open.has(n)) open.delete(n);
- else open.add(n);
- render(n);
- });
-
- render();
- new ResizeObserver(() => {
- if (measureCols() !== lastCols) render();
- }).observe(host);
-}
diff --git a/src/scripts/home/coffee.js b/src/scripts/home/coffee.js
@@ -0,0 +1,148 @@
+// M-x coffee-refill: my GitHub bio, made to do something. A mug fills while
+// VCC (the chip's coffee supply pin) climbs back to 5 V, then the 8-bit
+// computer in Projects runs at four times its clock for 30 seconds.
+
+import { $, emit, reducedMotion, isTyping } from "../util.js";
+import { defcommand, message } from "../emacs/index.js";
+import { showComputer } from "../demos/computer/commands.js";
+
+const ROWS = 5;
+const WIDTH = 10;
+const V_LOW = 2.6;
+const V_FULL = 5.0;
+const BOOST = { factor: 4, seconds: 30 };
+const STEAM = [
+ [" ( ) ( ", " ) ( ) "],
+ [" ) ( ) ", " ( ) ( "],
+];
+
+const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
+
+function mug(level, frame) {
+ const filled = Math.round(level * ROWS);
+ const row = (i) => {
+ const fromBottom = ROWS - 1 - i;
+ if (fromBottom >= filled) return " ".repeat(WIDTH);
+ const top = fromBottom === filled - 1;
+ return `<span class="${top ? "crema" : "brew"}">${(top ? "~" : "▒").repeat(WIDTH)}</span>`;
+ };
+ const steam = filled ? STEAM[frame % 2] : ["", ""];
+ const handle = ["|--.", "| |", "| |", "|--'", "| "];
+ return [
+ `<span class="steam">${esc(steam[0])}</span>`,
+ `<span class="steam">${esc(steam[1])}</span>`,
+ " .----------.",
+ ...Array.from({ length: ROWS }, (_, i) => ` |${row(i)}${handle[i]}`),
+ " '----------'",
+ ].join("\n");
+}
+
+function cupsToday(increment = false) {
+ const key = `coffee:${new Date().toISOString().slice(0, 10)}`;
+ let n = 0;
+ try {
+ n = Number(localStorage.getItem(key)) || 0;
+ if (increment) localStorage.setItem(key, String(++n));
+ } catch {
+ if (increment) n = 1;
+ }
+ return n;
+}
+
+export function initCoffee() {
+ const panel = $(".coffee");
+ if (!panel) return;
+ const pre = $(".mug", panel);
+ const bar = $(".vbar", panel);
+ const volts = $(".volts", panel);
+ const msg = $(".coffee-msg", panel);
+ const links = $(".coffee-links", panel);
+ const closeBtn = $(".coffee-close", panel);
+
+ const watch = document.createElement("button");
+ watch.type = "button";
+ watch.className = "linkish";
+ watch.textContent = "watch the computer";
+ watch.hidden = true;
+ watch.addEventListener("click", () => showComputer());
+ links.append(" ", watch);
+
+ let raf = 0;
+ let steamTimer = 0;
+ let returnFocus = null;
+
+ const meter = (v, over = false) => {
+ const cells = 12;
+ const n = Math.max(0, Math.min(cells, Math.round(((v - V_LOW) / (V_FULL - V_LOW)) * cells)));
+ bar.textContent = `▕${"█".repeat(n)}${"░".repeat(cells - n)}▏`;
+ volts.textContent = `${v.toFixed(1)} V`;
+ panel.classList.toggle("is-over", over);
+ };
+
+ function close() {
+ if (panel.hidden) return;
+ cancelAnimationFrame(raf);
+ clearInterval(steamTimer);
+ panel.hidden = true;
+ if (returnFocus && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
+ }
+
+ function refill() {
+ cancelAnimationFrame(raf);
+ clearInterval(steamTimer);
+ returnFocus = document.activeElement;
+ panel.hidden = false;
+ watch.hidden = true;
+ msg.textContent = "brewing…";
+ closeBtn.focus({ preventScroll: true });
+
+ const finish = () => {
+ const n = cupsToday(true);
+ let frame = 0;
+ steamTimer = setInterval(() => { pre.innerHTML = mug(1, ++frame); }, 450);
+ pre.innerHTML = mug(1, 0);
+ if (n > 5) {
+ meter(5.6, true);
+ msg.textContent = `Cup ${n} today. Absolute maximum rating exceeded: stresses beyond it may cause permanent damage, so no overclock this time. Have some water.`;
+ message("VCC above absolute maximum rating", "error");
+ return;
+ }
+ meter(V_FULL);
+ const note = n === 1 ? "VCC is back to 5.0 V."
+ : n <= 3 ? `Cup ${n} today, within the typical range.`
+ : n === 4 ? "Cup 4 today, above typical."
+ : "Cup 5 today: that’s the absolute maximum rating.";
+ msg.textContent = `${note} The 8-bit computer in Projects now runs at ${BOOST.factor}× clock for ${BOOST.seconds} seconds.`;
+ watch.hidden = false;
+ emit("caffeine", BOOST);
+ message(`Refilled. VCC 5.0 V, ${BOOST.factor}× clock for ${BOOST.seconds}s`, "ok");
+ };
+
+ if (reducedMotion.matches) return finish();
+ const t0 = performance.now();
+ const step = (now) => {
+ const t = Math.min(1, (now - t0) / 2400);
+ const e = 1 - Math.pow(1 - t, 2);
+ pre.innerHTML = mug(e, Math.floor(now / 450));
+ meter(V_LOW + (V_FULL - V_LOW) * e);
+ if (t < 1) raf = requestAnimationFrame(step);
+ else finish();
+ };
+ meter(V_LOW);
+ raf = requestAnimationFrame(step);
+ }
+
+ closeBtn.addEventListener("click", close);
+ addEventListener("keydown", (e) => {
+ if (panel.hidden || e.metaKey || e.altKey || isTyping(e)) return;
+ if (e.key === "q" || e.key === "Escape" || (e.ctrlKey && e.key === "g")) {
+ e.preventDefault();
+ e.stopPropagation();
+ close();
+ }
+ }, true);
+
+ defcommand("coffee-refill", refill, { doc: "Top up VCC and overclock the computer for a while", key: "SPC r" });
+ pre.innerHTML = mug(0, 0);
+ meter(V_LOW);
+}
diff --git a/src/scripts/home/contact.js b/src/scripts/home/contact.js
@@ -0,0 +1,28 @@
+// 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";
+import { message } from "../emacs/index.js";
+
+export function initContact() {
+ const sec = $("#contact");
+ if (!sec) return;
+ const mail = $(".mail", sec);
+ const address = `${mail.dataset.user}@${mail.dataset.host}`;
+ const arm = () => { mail.href = `mailto:${address}`; };
+ for (const ev of ["pointerenter", "focus", "touchstart"]) mail.addEventListener(ev, arm, { passive: true });
+ mail.addEventListener("click", arm);
+
+ const copy = $('[data-email="copy"]', sec);
+ copy?.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(address);
+ copy.textContent = "copied";
+ message(`Copied ${address}`, "ok", 3000);
+ } catch {
+ message(address, "", 8000);
+ copy.textContent = "shown below";
+ }
+ setTimeout(() => { copy.textContent = "copy address"; }, 2000);
+ });
+}
diff --git a/src/scripts/home/decode.js b/src/scripts/home/decode.js
@@ -0,0 +1,164 @@
+// The name arrives as bytes with a few bits flipped, so each letter starts out
+// as some other character. The flipped bits are corrected one at a time, and
+// every correction changes the letter to the character one bit closer, until
+// it lands on the right one. The echo area shows the bytes while that happens.
+//
+// Afterwards, pointing at a letter flips a couple of its bits again, and
+// M-x flip-bits replays the whole thing.
+
+import { $, onFrame, reducedMotion } from "../util.js";
+import { message, defcommand, goto } from "../emacs/index.js";
+
+const rand = (n) => Math.floor(Math.random() * n);
+const bin = (n) => n.toString(2).padStart(8, "0");
+
+// Characters a letter may pass through: printable, and in the serif font
+// (it has no backtick).
+const printable = (v) => v >= 0x20 && v < 0x7f && v !== 0x60;
+
+function shuffle(a) {
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = rand(i + 1);
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+// Pick k bits to flip and the order to correct them in, so that every byte on
+// the way back is printable. Letters keep bit 6 (they stay in 0x40-0x7f) and
+// the space keeps bits 5 and 6 (it stays in 0x20-0x3f).
+function corrupt(code, k) {
+ const pool = code === 0x20 ? [0, 1, 2, 3, 4] : [0, 1, 2, 3, 4, 5];
+ for (let tries = 0; tries < 50; tries++) {
+ const order = shuffle([...pool]).slice(0, k);
+ let v = order.reduce((x, b) => x ^ (1 << b), code);
+ const path = [v];
+ for (const b of order) path.push((v ^= 1 << b));
+ if (path.every(printable)) return path; // path[0] corrupted ... path[k] === code
+ }
+ return [code];
+}
+
+function build(h1) {
+ const text = h1.textContent.trim();
+ h1.setAttribute("aria-label", text);
+ h1.textContent = "";
+ // Measure on one line, so the space between the words keeps its width.
+ h1.style.whiteSpace = "nowrap";
+ let word = null;
+ const cells = [...text].map((ch) => {
+ // Each word's letters sit in a span that doesn't wrap: the browser would
+ // otherwise break the line between any two of them.
+ if (!word) {
+ word = document.createElement("span");
+ word.className = "bc-word";
+ h1.append(word);
+ }
+ const el = document.createElement("span");
+ el.className = "bc";
+ const glyph = document.createElement("span");
+ glyph.className = "bc-ch";
+ glyph.textContent = ch;
+ el.append(glyph);
+ word.append(el);
+ if (ch === " ") word = null; // the line may break after the space
+ return { el, glyph, code: ch.charCodeAt(0), path: [], busy: false };
+ });
+ // Fix each cell at its final letter's width, so the other characters a letter
+ // passes through never push the rest of the name around.
+ const fs = parseFloat(getComputedStyle(h1).fontSize);
+ const widths = cells.map((c) => c.el.getBoundingClientRect().width);
+ cells.forEach((c, i) => { c.el.style.width = `${(widths[i] / fs).toFixed(4)}em`; });
+ h1.style.whiteSpace = "";
+ h1.classList.add("is-cells");
+ return cells;
+}
+
+function show(c, v) {
+ c.glyph.textContent = String.fromCharCode(v);
+ c.el.classList.toggle("is-wrong", v !== c.code);
+}
+
+// Step every cell along its path on its own schedule until all are correct.
+// onStep runs after any change, for the echo area.
+function run(cells, onStep) {
+ const t0 = performance.now();
+ return new Promise((resolve) => {
+ const off = onFrame(() => {
+ const t = performance.now() - t0;
+ let changed = false;
+ let left = 0;
+ for (const c of cells) {
+ while (c.plan.length && c.plan[0].t <= t) {
+ show(c, c.plan.shift().v);
+ changed = true;
+ }
+ left += c.plan.length;
+ }
+ if (changed) onStep?.();
+ if (!left) {
+ off();
+ resolve();
+ }
+ });
+ });
+}
+
+export async function initDecode() {
+ const root = document.documentElement;
+ const h1 = $(".hero-name");
+ const reveal = () => root.classList.remove("boot");
+ if (!h1 || reducedMotion.matches) return reveal();
+
+ let cells;
+ try {
+ // The cells are measured in the serif, so wait for it (briefly).
+ await Promise.race([document.fonts.load(`400 64px "Computer Modern"`, "Michael Neuper"), new Promise((r) => setTimeout(r, 1500))]);
+ cells = build(h1);
+ } catch (err) {
+ console.error(err);
+ return reveal();
+ }
+ // the byte each cell shows right now
+ const current = (c) => c.glyph.textContent.charCodeAt(0);
+
+ async function decode() {
+ if (cells.some((c) => c.busy)) return;
+ let flipped = 0;
+ cells.forEach((c, j) => {
+ c.busy = true;
+ const path = corrupt(c.code, 3 + rand(3));
+ flipped += path.length - 1;
+ show(c, path[0]);
+ let t = 150 + j * 55;
+ c.plan = path.slice(1).map((v) => ({ v, t: (t += 70 + Math.random() * 50) }));
+ });
+ const echo = () => message(cells.map((c) => bin(current(c))).join(" "), "", 0);
+ reveal();
+ echo();
+ await run(cells, echo);
+ cells.forEach((c) => { c.busy = false; });
+ message(`Corrected ${flipped} flipped bits in ${cells.length} bytes.`, "ok", 4000);
+ }
+
+ // Point at a letter to flip a couple of its bits.
+ for (const c of cells) {
+ if (c.code === 0x20) continue;
+ c.el.addEventListener("pointerenter", () => {
+ if (c.busy || reducedMotion.matches) return;
+ c.busy = true;
+ const path = corrupt(c.code, 2);
+ show(c, path[0]);
+ c.plan = path.slice(1).map((v, n) => ({ v, t: 110 + n * 90 }));
+ run([c]).then(() => { c.busy = false; });
+ });
+ }
+
+ defcommand("flip-bits", () => {
+ goto("top", true);
+ setTimeout(decode, 400);
+ }, { doc: "Flip random bits in my name and watch them get corrected" });
+
+ if (root.classList.contains("boot")) await decode();
+ else reveal();
+}
diff --git a/src/scripts/home/gitlog.js b/src/scripts/home/gitlog.js
@@ -0,0 +1,226 @@
+// Experience as one-line git log --graph output, the "git lg" way:
+// <date> - (refs) <subject> <author>
+// with the date standing in for the hash and the company as the author.
+//
+// The rows are plain text in src/pages/index.astro, so they read fine without JavaScript.
+// This colours the graph by branch (data-lanes), colours the date, refs and
+// author, and turns subjects that have a .gl-desc into buttons that expand it.
+// Lanes: m main, u undergrad, t teaching-assistant, b bbd-software, i mwr-internship.
+//
+// When the log is wider than its box (on a phone), it wraps the way a narrow
+// terminal would: at word boundaries, with each wrapped line starting with the
+// commit's lane pipes and a short hanging indent.
+
+import { $, $$ } from "../util.js";
+
+const LANE_CLASS = { m: "lm", u: "lu", t: "lt", b: "lb", i: "li" };
+const ROW = /^([|*/\\ ]*?)(\d{4}-\d{2}(?:-\d{2})?) - (?:\(([^)]*)\) ?)?(.*?)(?: ?<([^>]*)>)?$/;
+const HANG = 2;
+
+function el(tag, cls, text) {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+}
+
+// The graph characters, coloured by lane and hidden from screen readers.
+// `tip` makes the commit's * bold.
+function graph(text, lanes = "", tip = false) {
+ const g = el("span", "gl-graph");
+ g.setAttribute("aria-hidden", "true");
+ [...text].forEach((ch, i) => {
+ const lane = LANE_CLASS[(lanes[i] || " ").trim()];
+ if (ch === "*" && tip) g.append(el("span", "gl-tip", ch));
+ else g.append(lane && ch.trim() ? el("span", lane, ch) : ch);
+ });
+ return g;
+}
+
+// The HEAD commit is dated with the month the page is viewed in.
+function today() {
+ const d = new Date();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
+}
+
+function parse(div) {
+ const descEl = $(".gl-desc", div);
+ const body = descEl ? descEl.textContent.replace(/^\n+|\s+$/g, "").split("\n") : null;
+ descEl?.remove();
+ const text = div.textContent.replace(/\s+$/, "");
+ const m = ROW.exec(text);
+ const row = {
+ graph: m ? m[1] : text, lanes: div.dataset.lanes, cont: div.dataset.cont ?? "", contLanes: div.dataset.contLanes,
+ tip: "tip" in div.dataset, branch: div.dataset.branch,
+ };
+ if (!m) return row;
+ return {
+ ...row,
+ date: "today" in div.dataset ? today() : m[2],
+ refs: m[3] ? m[3].split(/,\s*/).map((r) => (r.startsWith("HEAD -> ") ? { head: true, name: r.slice(8) } : { name: r })) : [],
+ subject: m[4],
+ author: m[5],
+ body,
+ };
+}
+
+const refsText = (refs) => `(${refs.map((r) => (r.head ? "HEAD -> " : "") + r.name).join(", ")})`;
+
+// A commit line as words, so it can be wrapped. Each word knows how to draw itself.
+function words(row) {
+ const w = [{ text: row.date, cls: `gl-date ${LANE_CLASS[row.branch] ?? ""}` }, { text: "-" }];
+ if (row.refs.length) w.push({ text: refsText(row.refs), refs: row.refs });
+ if (row.subject) for (const s of row.subject.split(" ")) w.push({ text: s, subj: true });
+ if (row.author) w.push({ text: `<${row.author}>`, cls: "gl-author" }); // kept on one line
+ return w;
+}
+
+// Greedy word wrap. Returns lines of words; a word longer than the width gets a line to itself.
+function wrap(list, width, hang) {
+ const lines = [[]];
+ let used = 0;
+ for (const w of list) {
+ const line = lines[lines.length - 1];
+ const need = (line.length ? 1 : 0) + w.text.length;
+ if (line.length && used + need > width) {
+ lines.push([w]);
+ used = hang + w.text.length;
+ } else {
+ line.push(w);
+ used += need;
+ }
+ }
+ return lines;
+}
+
+// Re-flow a description to a width, keeping "- " bullets with a hanging indent.
+function reflow(body, width) {
+ const blocks = [];
+ for (const line of body) {
+ if (line.startsWith("- ") || !blocks.length || !line.startsWith(" ")) blocks.push(line.trim());
+ else blocks[blocks.length - 1] += ` ${line.trim()}`;
+ }
+ // plain paragraphs that were hard-wrapped become one block again
+ const merged = [];
+ for (const b of blocks) {
+ if (merged.length && !b.startsWith("- ") && !merged[merged.length - 1].startsWith("- ")) merged[merged.length - 1] += ` ${b}`;
+ else merged.push(b);
+ }
+ const out = [];
+ for (const b of merged) {
+ const bullet = b.startsWith("- ");
+ const lines = wrap(b.split(" ").map((text) => ({ text })), width, bullet ? 2 : 0);
+ lines.forEach((l, i) => out.push((i && bullet ? " " : "") + l.map((w) => w.text).join(" ")));
+ }
+ return out;
+}
+
+export function initGitlog() {
+ const host = $(".gitlog");
+ if (!host) return;
+ const rows = $$(":scope > .gl-line", host).map(parse);
+ const open = new Set();
+ let lastCols = 0;
+
+ function measureCols() {
+ const probe = el("span", "", "0".repeat(20));
+ probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre";
+ host.append(probe);
+ const ch = probe.getBoundingClientRect().width / 20 || 7;
+ probe.remove();
+ const cs = getComputedStyle(host);
+ const inner = host.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ return Math.max(24, Math.floor(inner / ch));
+ }
+
+ function render(focusRow = -1) {
+ const cols = measureCols();
+ lastCols = cols;
+ const lineLen = (r) => r.graph.length + words(r).reduce((n, w, i) => n + (i ? 1 : 0) + w.text.length, 0);
+ const wide = rows.every((r) => !r.date || lineLen(r) <= cols);
+ const pipesOnly = (r) => r && !r.date && /^[| ]+$/.test(r.graph);
+ const frag = document.createDocumentFragment();
+
+ rows.forEach((row, n) => {
+ if (!row.date) {
+ const line = el("div", "gl-line");
+ line.append(graph(row.graph, row.lanes));
+ frag.append(line);
+ return;
+ }
+
+ const isOpen = open.has(n);
+ const lines = wrap(words(row), cols - row.graph.length, HANG);
+ let subjSeen = false;
+ lines.forEach((ws, li) => {
+ const line = el("div", "gl-line");
+ line.append(li ? graph(row.cont, row.contLanes) : graph(row.graph, row.lanes, row.tip));
+ if (li) line.append(" ".repeat(HANG));
+ // consecutive subject words on a line become one button (or span) so the underline is unbroken
+ for (let k = 0; k < ws.length; k++) {
+ if (k) line.append(" ");
+ const w = ws[k];
+ if (w.subj) {
+ let text = w.text;
+ while (ws[k + 1]?.subj) text += ` ${ws[++k].text}`;
+ if (row.body) {
+ const b = el("button", "gl-subj", text);
+ b.type = "button";
+ b.dataset.row = n;
+ b.setAttribute("aria-expanded", String(isOpen));
+ if (subjSeen) b.tabIndex = -1; // one tab stop per commit, even when its subject wraps
+ line.append(b);
+ } else {
+ line.append(el("span", "gl-text", text));
+ }
+ subjSeen = true;
+ } else if (w.refs) {
+ const refs = el("span", "gl-refs");
+ refs.append("(");
+ w.refs.forEach((r, i) => {
+ if (i) refs.append(", ");
+ if (r.head) refs.append(el("span", "gl-head", "HEAD -> "));
+ refs.append(el("span", "gl-branch", r.name));
+ });
+ refs.append(")");
+ line.append(refs);
+ } else {
+ line.append(w.cls ? el("span", w.cls, w.text) : w.text);
+ }
+ }
+ frag.append(line);
+ });
+
+ if (row.body && isOpen) {
+ const refsLen = row.refs.length ? refsText(row.refs).length + 1 : 0;
+ // under the subject when there's room; otherwise a little deeper than wrapped lines
+ const indent = wide ? row.date.length + 3 + refsLen : HANG + 2;
+ const text = wide ? row.body : reflow(row.body, cols - row.cont.length - indent);
+ const tail = pipesOnly(rows[n + 1]) ? [] : [""];
+ for (const t of [...text, ...tail]) {
+ const b = el("div", "gl-line gl-body");
+ b.append(graph(row.cont, row.contLanes), t ? " ".repeat(indent) + t : "");
+ frag.append(b);
+ }
+ }
+ });
+
+ host.replaceChildren(frag);
+ host.classList.add("is-built");
+ if (focusRow >= 0) host.querySelector(`.gl-subj[data-row="${focusRow}"]`)?.focus({ preventScroll: true });
+ }
+
+ host.addEventListener("click", (e) => {
+ const b = e.target.closest(".gl-subj");
+ if (!b) return;
+ const n = +b.dataset.row;
+ if (open.has(n)) open.delete(n);
+ else open.add(n);
+ render(n);
+ });
+
+ render();
+ new ResizeObserver(() => {
+ if (measureCols() !== lastCols) render();
+ }).observe(host);
+}
diff --git a/src/scripts/home/memmap.js b/src/scripts/home/memmap.js
@@ -0,0 +1,206 @@
+// Projects as a linker memory map. Each project's region holds its one-line
+// summary as UTF-8, so a region is exactly as long as that text, origins are
+// cumulative, and the hexdump shows the region's actual bytes. Projects with a
+// data-demo attribute mount a small interactive demo under their write-up.
+
+import { $, $$, hex, reducedMotion, on } from "../util.js";
+
+const GLYPHS = "░▒▓/\\<>+=*#%&@$0123456789ABCDEF";
+
+export function initMemmap() {
+ const mm = $(".mm");
+ if (!mm) return;
+ const bar = $(".mm-bar", mm);
+ const detail = $(".mm-detail", mm);
+ const narrow = matchMedia("(max-width: 920px)");
+ const enc = new TextEncoder();
+
+ let origin = 0;
+ const projects = $$(".proj", mm).map((li) => {
+ const sum = $(".proj-sum", li).textContent.trim();
+ const p = {
+ li,
+ cat: li.dataset.cat,
+ src: li.dataset.src || "",
+ demo: li.dataset.demo || "",
+ wide: "wide" in li.dataset,
+ name: $(".proj-name", li).textContent.trim(),
+ sum,
+ bytes: enc.encode(sum),
+ body: $(".proj-body", li),
+ origin,
+ };
+ origin += p.bytes.length;
+ return p;
+ });
+ const meta = $('[data-mm="meta"]');
+ if (meta) meta.textContent = `${projects.length} regions, 0x${hex(origin, 4)} bytes`;
+
+ projects.forEach((p, i) => {
+ const h = $(".proj-name", p.li);
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "proj-row";
+ btn.dataset.cat = p.cat;
+ btn.setAttribute("aria-pressed", "false");
+ btn.innerHTML = `<span class="o">0x${hex(p.origin, 4)}</span><span class="l">0x${hex(p.bytes.length, 2)}</span><span class="n"></span><span class="y"></span>`;
+ const n = $(".n", btn);
+ n.textContent = p.name;
+ const s = document.createElement("span");
+ s.className = "s";
+ s.textContent = p.sum;
+ n.append(s);
+ $(".y", btn).textContent = p.src;
+ h.replaceChildren(btn);
+ btn.addEventListener("click", () => select(i, true));
+ p.btn = btn;
+
+ const seg = document.createElement("div");
+ seg.className = "mm-seg";
+ seg.dataset.cat = p.cat;
+ seg.style.flexGrow = p.bytes.length;
+ seg.textContent = p.name;
+ seg.title = `${p.name}: 0x${hex(p.origin, 4)}, ${p.bytes.length} bytes`;
+ seg.addEventListener("click", () => select(i, true));
+ bar.append(seg);
+ p.seg = seg;
+ });
+ mm.classList.add("is-built");
+
+ let current = -1;
+ let anim = 0;
+ let unmount = null;
+ let wideHost = null;
+
+ async function select(i, user = false) {
+ const p = projects[i];
+ if (user && i === current && narrow.matches) {
+ close();
+ return;
+ }
+ current = i;
+ projects.forEach((q, j) => {
+ q.btn.setAttribute("aria-pressed", String(i === j));
+ q.seg.classList.toggle("is-active", i === j);
+ });
+ unmount?.();
+ unmount = null;
+ wideHost?.remove();
+ wideHost = null;
+ detail.dataset.cat = p.cat;
+
+ const dump = document.createElement("pre");
+ dump.className = "hex";
+ dump.setAttribute("aria-hidden", "true");
+ const title = document.createElement("h3");
+ title.textContent = p.name;
+ const body = p.body.cloneNode(true);
+ detail.replaceChildren(dump, title, body);
+ place();
+ hexdump(dump, p);
+
+ if (p.demo) {
+ const host = document.createElement("div");
+ host.className = p.wide ? "demo demo-wide" : "demo";
+ if (p.wide) wideHost = host;
+ placeDemo(host, p);
+ try {
+ const mod = await import(`../demos/${p.demo}.js`);
+ if (current === i && host.isConnected) unmount = mod.mount(host) ?? null;
+ } catch (err) {
+ console.error(err);
+ host.remove();
+ }
+ }
+ }
+
+ function close() {
+ unmount?.();
+ unmount = null;
+ wideHost?.remove();
+ wideHost = null;
+ projects[current]?.btn.setAttribute("aria-pressed", "false");
+ projects[current]?.seg.classList.remove("is-active");
+ detail.replaceChildren();
+ current = -1;
+ }
+
+ // On narrow screens the write-up opens under its row instead of beside the list.
+ function place() {
+ const p = projects[current];
+ if (!p) return;
+ if (narrow.matches) p.li.append(detail);
+ else $(".mm-cols", mm).append(detail);
+ if (wideHost) placeDemo(wideHost, p);
+ }
+
+ // Most demos sit under the write-up; a wide one gets the full width under the list.
+ function placeDemo(host, p) {
+ if (p.wide && !narrow.matches) mm.append(host);
+ else detail.append(host);
+ }
+
+ // Other parts of the page can open a project by name (the computer commands do).
+ on("project", (name) => {
+ const i = projects.findIndex((q) => q.name === name);
+ if (i >= 0 && i !== current) select(i);
+ });
+ narrow.addEventListener("change", place);
+
+ function hexdump(pre, p) {
+ // 16 bytes a line like hexdump -C when its 78 columns fit, otherwise 8
+ const probe = document.createElement("span");
+ probe.textContent = "0".repeat(10);
+ pre.append(probe);
+ const cols = Math.floor(pre.clientWidth / (probe.getBoundingClientRect().width / 10 || 7));
+ probe.remove();
+ const per = cols >= 78 ? 16 : 8;
+ const lines = [];
+ for (let off = 0; off < p.bytes.length; off += per) {
+ const chunk = [...p.bytes.slice(off, off + per)];
+ const hx = chunk.map((b) => hex(b)).concat(Array(per - chunk.length).fill(" "));
+ const ascii = chunk.map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")).join("");
+ lines.push({
+ addr: hex(p.origin + off, 8),
+ hex: per === 16 ? `${hx.slice(0, 8).join(" ")} ${hx.slice(8).join(" ")}` : hx.join(" "),
+ ascii: ascii.padEnd(per, " "),
+ });
+ }
+ const render = (reveal) => {
+ pre.replaceChildren();
+ lines.forEach((ln, li) => {
+ const k = reveal - li * 0.08;
+ const a = document.createElement("span");
+ a.className = "addr";
+ a.textContent = ln.addr;
+ const asc = document.createElement("span");
+ asc.className = "a";
+ asc.textContent = `|${scramble(ln.ascii, k)}|`;
+ pre.append(a, ` ${scramble(ln.hex, k)} `, asc, li < lines.length - 1 ? "\n" : "");
+ });
+ };
+ cancelAnimationFrame(anim);
+ if (reducedMotion.matches) return render(9);
+ const t0 = performance.now();
+ const frame = (now) => {
+ const k = (now - t0) / 480;
+ render(k);
+ if (k < 1 + lines.length * 0.08) anim = requestAnimationFrame(frame);
+ };
+ anim = requestAnimationFrame(frame);
+ }
+
+ select(0);
+}
+
+// Characters left of the reveal point are final; to the right they flicker.
+function scramble(str, k) {
+ if (k >= 1) return str;
+ const cut = Math.max(0, Math.floor(str.length * k));
+ let out = str.slice(0, cut);
+ for (let i = cut; i < str.length; i++) {
+ const c = str[i];
+ out += k < 0 ? " " : c === " " ? c : GLYPHS[(Math.random() * GLYPHS.length) | 0];
+ }
+ return out;
+}
diff --git a/src/scripts/home/pinout.js b/src/scripts/home/pinout.js
@@ -0,0 +1,86 @@
+// Builds the DIP-16 pinout from the ordered list of pins in the HTML.
+// Pins 1–8 run down the left side, 9–16 run back up the right.
+
+import { $, $$ } from "../util.js";
+import { call } from "../emacs/index.js";
+
+export function initPinout() {
+ const fig = $(".pinout");
+ if (!fig) return;
+ const pins = $$(".pin-list li", fig).map((li, i) => ({
+ num: i + 1,
+ name: li.dataset.name,
+ type: li.dataset.type,
+ ch: li.dataset.cat,
+ text: li.textContent.trim(),
+ }));
+ const half = pins.length / 2;
+ const readout = $(".pin-readout", fig);
+
+ const chip = document.createElement("div");
+ chip.className = "chip";
+ const left = side("left");
+ const right = side("right");
+ const body = document.createElement("div");
+ body.className = "chip-body";
+ body.innerHTML = "<span>MN-2026</span>";
+ chip.append(left, body, right);
+
+ pins.forEach((p, i) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "pin";
+ if (p.ch) btn.dataset.cat = p.ch;
+ btn.setAttribute("aria-label", `Pin ${p.num}, ${p.name}`);
+ const parts = { name: p.name, num: p.num, leg: "" };
+ const order = i < half ? ["name", "num", "leg"] : ["leg", "num", "name"];
+ for (const k of order) {
+ const s = document.createElement("span");
+ s.className = k;
+ s.textContent = parts[k];
+ btn.append(s);
+ }
+ btn.addEventListener("pointerenter", () => show(p, btn));
+ btn.addEventListener("focus", () => show(p, btn));
+ btn.addEventListener("click", () => show(p, btn));
+ if (i < half) left.append(btn);
+ else right.prepend(btn);
+ });
+
+ $(".chip-wrap", fig).append(chip);
+ fig.classList.add("is-built");
+
+ function show(p, btn) {
+ $$(".pin.is-active", chip).forEach((b) => b.classList.remove("is-active"));
+ btn.classList.add("is-active");
+ if (p.ch) readout.dataset.cat = p.ch;
+ else delete readout.dataset.cat;
+ readout.innerHTML = "";
+ const head = document.createElement("p");
+ head.className = "pr-head";
+ head.innerHTML = `<span class="dim">pin ${p.num}</span><span class="pr-name"></span><span class="dim"></span>`;
+ head.children[1].textContent = p.name;
+ head.children[2].textContent = p.type;
+ // "M-x some-command" in a pin description becomes a button that runs it.
+ const text = document.createElement("p");
+ for (const part of p.text.split(/(M-x [\w-]+)/)) {
+ if (!part.startsWith("M-x ")) {
+ text.append(part);
+ continue;
+ }
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "linkish";
+ b.textContent = part;
+ b.addEventListener("click", () => call(part.slice(4)));
+ text.append(b);
+ }
+ readout.append(head, text);
+ }
+}
+
+function side(cls) {
+ const d = document.createElement("div");
+ d.className = `chip-side ${cls}`;
+ return d;
+}
diff --git a/src/scripts/home/posts.js b/src/scripts/home/posts.js
@@ -0,0 +1,156 @@
+// Renders the posts section as an Org buffer: keywords, a paragraph, years as
+// "*" headings and posts as "**" headings with tags and a property drawer.
+// TAB folds the heading at point; S-TAB cycles OVERVIEW, CONTENTS and SHOW ALL.
+
+import { $, $$ } from "../util.js";
+import { message } from "../emacs/index.js";
+import { TAG_COLORS } from "../tags.js";
+
+const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
+
+export function initPosts() {
+ const sec = $("#posts");
+ const src = sec && $(".posts", sec);
+ if (!src) return;
+ const intro = $(".org-p", sec);
+
+ const posts = $$("li", src).map((li) => {
+ const a = $("a", li);
+ return { date: li.dataset.date, tags: (li.dataset.tags || "").split(/\s+/).filter(Boolean), title: a.textContent.trim(), href: a.href };
+ });
+ const years = [...new Set(posts.map((p) => p.date.slice(0, 4)))].sort().reverse();
+
+ const el = (tag, cls, text) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ };
+
+ const org = el("div", "org");
+ const kw = (tag, key, value, cls) => {
+ const p = el(tag, "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"));
+ if (intro) org.append(intro);
+
+ const tree = el("ul", "org-tree");
+ tree.setAttribute("role", "tree");
+ tree.setAttribute("aria-label", "Posts by year");
+ const headings = [];
+
+ function heading(li, level) {
+ const h = el("div", "org-h");
+ h.tabIndex = headings.length === 0 ? 0 : -1;
+ const stars = el("button", "org-stars", "*".repeat(level));
+ stars.type = "button";
+ stars.tabIndex = -1;
+ stars.setAttribute("aria-label", "Fold or unfold");
+ stars.addEventListener("click", () => { li._fold(!li._open()); h.focus({ preventScroll: true }); });
+ h.append(stars);
+ li.prepend(h);
+ headings.push(h);
+ return h;
+ }
+
+ for (const y of years) {
+ const items = posts.filter((p) => p.date.startsWith(y));
+ const li = el("li", "org-h1");
+ li.setAttribute("role", "treeitem");
+ const h = heading(li, 1);
+ h.append(el("span", "org-text", y), el("span", "count", `[${items.length}]`));
+ const ell = el("span", "org-ellipsis", "...");
+ h.append(ell);
+
+ const kids = el("ul", "org-tree");
+ kids.setAttribute("role", "group");
+ for (const p of items) {
+ const pl = el("li", "org-h2");
+ pl.setAttribute("role", "treeitem");
+ const ph = heading(pl, 2);
+ const a = el("a", "org-text", p.title);
+ a.href = p.href;
+ a.tabIndex = -1;
+ ph.append(a);
+ if (p.tags.length) {
+ const tags = el("span", "org-tags");
+ tags.append(":");
+ for (const t of p.tags) {
+ const tg = el("span", "t", t);
+ tg.style.setProperty("--tag", TAG_COLORS[t] ?? "var(--dim)");
+ tags.append(tg, ":");
+ }
+ ph.append(tags);
+ }
+ const pell = el("span", "org-ellipsis", "...");
+ ph.append(pell);
+
+ const d = new Date(`${p.date}T12:00:00Z`);
+ const drawer = el("div", "org-drawer");
+ drawer.innerHTML = `<span class="k">:PROPERTIES:</span>\n<span class="k">:DATE:</span> <span class="ts"><${p.date} ${DAYS[d.getUTCDay()]}></span>\n<span class="k">:URL:</span> <a href="${p.href}" tabindex="-1">${p.href.replace(/^https?:\/\//, "")}</a>\n<span class="k">:END:</span>`;
+ pl.append(drawer);
+ pl._fold = (open) => {
+ drawer.hidden = !open;
+ pell.hidden = open;
+ pl.setAttribute("aria-expanded", String(open));
+ };
+ pl._open = () => !drawer.hidden;
+ pl._fold(false);
+ kids.append(pl);
+ }
+ li.append(kids);
+ li._fold = (open) => {
+ kids.hidden = !open;
+ ell.hidden = open;
+ li.setAttribute("aria-expanded", String(open));
+ };
+ li._open = () => !kids.hidden;
+ li._fold(true);
+ tree.append(li);
+ }
+ org.append(tree);
+ src.replaceWith(org);
+
+ const meta = $('[data-org="meta"]', sec);
+ if (meta) meta.textContent = `${posts.length} posts`;
+
+ // Global visibility cycling, like S-TAB in Org.
+ const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
+ let global = 1;
+ function globalCycle() {
+ global = (global + 1) % 3;
+ for (const y of $$(".org-h1", tree)) {
+ y._fold(global > 0);
+ for (const p of $$(".org-h2", y)) p._fold(global === 2);
+ }
+ message(STATES[global]);
+ }
+
+ const visible = () => headings.filter((h) => h.offsetParent !== null);
+ tree.addEventListener("keydown", (e) => {
+ const h = e.target.closest(".org-h");
+ if (!h) return;
+ const li = h.parentElement;
+ const vis = visible();
+ const i = vis.indexOf(h);
+ const move = (j) => {
+ const t = vis[Math.max(0, Math.min(vis.length - 1, j))];
+ for (const x of headings) x.tabIndex = -1;
+ t.tabIndex = 0;
+ t.focus();
+ };
+ if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); globalCycle(); }
+ else if (e.key === "Tab") { e.preventDefault(); li._fold(!li._open()); }
+ else if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); move(i + 1); }
+ else if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); move(i - 1); }
+ else if (e.key === "Enter") {
+ const a = h.querySelector("a");
+ if (a) a.click();
+ else li._fold(!li._open());
+ }
+ });
+}
diff --git a/src/scripts/main.js b/src/scripts/main.js
@@ -1,27 +0,0 @@
-import { fontsReady } from "./util.js";
-import "./shell.js";
-import "./computer.js"; // registers the computer commands; the 8-bit project mounts it
-import { initDecode } from "./decode.js";
-import { initDatasheet } from "./datasheet.js";
-import { initGitlog } from "./gitlog.js";
-import { initMemmap } from "./memmap.js";
-import { initOrg } from "./org.js";
-import { initContact } from "./contact.js";
-import { initCtf } from "./ctf.js";
-import { initCoffee } from "./coffee.js";
-
-function safely(fn) {
- try { fn(); } catch (err) { console.error(err); }
-}
-
-initDecode().catch((err) => { console.error(err); document.documentElement.classList.remove("boot"); });
-safely(initDatasheet);
-safely(initGitlog);
-safely(initOrg);
-safely(initContact);
-safely(initCtf);
-safely(initCoffee);
-
-// The hexdump measures character widths, so wait for the mono font.
-await fontsReady();
-safely(initMemmap);
diff --git a/src/scripts/memmap.js b/src/scripts/memmap.js
@@ -1,206 +0,0 @@
-// Projects as a linker memory map. Each project's region holds its one-line
-// summary as UTF-8, so a region is exactly as long as that text, origins are
-// cumulative, and the hexdump shows the region's actual bytes. Projects with a
-// data-demo attribute mount a small interactive demo under their write-up.
-
-import { $, $$, hex, reducedMotion, on } from "./util.js";
-
-const GLYPHS = "░▒▓/\\<>+=*#%&@$0123456789ABCDEF";
-
-export function initMemmap() {
- const mm = $(".mm");
- if (!mm) return;
- const bar = $(".mm-bar", mm);
- const detail = $(".mm-detail", mm);
- const narrow = matchMedia("(max-width: 920px)");
- const enc = new TextEncoder();
-
- let origin = 0;
- const projects = $$(".proj", mm).map((li) => {
- const sum = $(".proj-sum", li).textContent.trim();
- const p = {
- li,
- cat: li.dataset.cat,
- src: li.dataset.src || "",
- demo: li.dataset.demo || "",
- wide: "wide" in li.dataset,
- name: $(".proj-name", li).textContent.trim(),
- sum,
- bytes: enc.encode(sum),
- body: $(".proj-body", li),
- origin,
- };
- origin += p.bytes.length;
- return p;
- });
- const meta = $('[data-mm="meta"]');
- if (meta) meta.textContent = `${projects.length} regions, 0x${hex(origin, 4)} bytes`;
-
- projects.forEach((p, i) => {
- const h = $(".proj-name", p.li);
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = "proj-row";
- btn.dataset.cat = p.cat;
- btn.setAttribute("aria-pressed", "false");
- btn.innerHTML = `<span class="o">0x${hex(p.origin, 4)}</span><span class="l">0x${hex(p.bytes.length, 2)}</span><span class="n"></span><span class="y"></span>`;
- const n = $(".n", btn);
- n.textContent = p.name;
- const s = document.createElement("span");
- s.className = "s";
- s.textContent = p.sum;
- n.append(s);
- $(".y", btn).textContent = p.src;
- h.replaceChildren(btn);
- btn.addEventListener("click", () => select(i, true));
- p.btn = btn;
-
- const seg = document.createElement("div");
- seg.className = "mm-seg";
- seg.dataset.cat = p.cat;
- seg.style.flexGrow = p.bytes.length;
- seg.textContent = p.name;
- seg.title = `${p.name}: 0x${hex(p.origin, 4)}, ${p.bytes.length} bytes`;
- seg.addEventListener("click", () => select(i, true));
- bar.append(seg);
- p.seg = seg;
- });
- mm.classList.add("is-built");
-
- let current = -1;
- let anim = 0;
- let unmount = null;
- let wideHost = null;
-
- async function select(i, user = false) {
- const p = projects[i];
- if (user && i === current && narrow.matches) {
- close();
- return;
- }
- current = i;
- projects.forEach((q, j) => {
- q.btn.setAttribute("aria-pressed", String(i === j));
- q.seg.classList.toggle("is-active", i === j);
- });
- unmount?.();
- unmount = null;
- wideHost?.remove();
- wideHost = null;
- detail.dataset.cat = p.cat;
-
- const dump = document.createElement("pre");
- dump.className = "hex";
- dump.setAttribute("aria-hidden", "true");
- const title = document.createElement("h3");
- title.textContent = p.name;
- const body = p.body.cloneNode(true);
- detail.replaceChildren(dump, title, body);
- place();
- hexdump(dump, p);
-
- if (p.demo) {
- const host = document.createElement("div");
- host.className = p.wide ? "demo demo-wide" : "demo";
- if (p.wide) wideHost = host;
- placeDemo(host, p);
- try {
- const mod = await import(`./demos/${p.demo}.js`);
- if (current === i && host.isConnected) unmount = mod.mount(host) ?? null;
- } catch (err) {
- console.error(err);
- host.remove();
- }
- }
- }
-
- function close() {
- unmount?.();
- unmount = null;
- wideHost?.remove();
- wideHost = null;
- projects[current]?.btn.setAttribute("aria-pressed", "false");
- projects[current]?.seg.classList.remove("is-active");
- detail.replaceChildren();
- current = -1;
- }
-
- // On narrow screens the write-up opens under its row instead of beside the list.
- function place() {
- const p = projects[current];
- if (!p) return;
- if (narrow.matches) p.li.append(detail);
- else $(".mm-cols", mm).append(detail);
- if (wideHost) placeDemo(wideHost, p);
- }
-
- // Most demos sit under the write-up; a wide one gets the full width under the list.
- function placeDemo(host, p) {
- if (p.wide && !narrow.matches) mm.append(host);
- else detail.append(host);
- }
-
- // Other parts of the page can open a project by name (the computer commands do).
- on("project", (name) => {
- const i = projects.findIndex((q) => q.name === name);
- if (i >= 0 && i !== current) select(i);
- });
- narrow.addEventListener("change", place);
-
- function hexdump(pre, p) {
- // 16 bytes a line like hexdump -C when its 78 columns fit, otherwise 8
- const probe = document.createElement("span");
- probe.textContent = "0".repeat(10);
- pre.append(probe);
- const cols = Math.floor(pre.clientWidth / (probe.getBoundingClientRect().width / 10 || 7));
- probe.remove();
- const per = cols >= 78 ? 16 : 8;
- const lines = [];
- for (let off = 0; off < p.bytes.length; off += per) {
- const chunk = [...p.bytes.slice(off, off + per)];
- const hx = chunk.map((b) => hex(b)).concat(Array(per - chunk.length).fill(" "));
- const ascii = chunk.map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")).join("");
- lines.push({
- addr: hex(p.origin + off, 8),
- hex: per === 16 ? `${hx.slice(0, 8).join(" ")} ${hx.slice(8).join(" ")}` : hx.join(" "),
- ascii: ascii.padEnd(per, " "),
- });
- }
- const render = (reveal) => {
- pre.replaceChildren();
- lines.forEach((ln, li) => {
- const k = reveal - li * 0.08;
- const a = document.createElement("span");
- a.className = "addr";
- a.textContent = ln.addr;
- const asc = document.createElement("span");
- asc.className = "a";
- asc.textContent = `|${scramble(ln.ascii, k)}|`;
- pre.append(a, ` ${scramble(ln.hex, k)} `, asc, li < lines.length - 1 ? "\n" : "");
- });
- };
- cancelAnimationFrame(anim);
- if (reducedMotion.matches) return render(9);
- const t0 = performance.now();
- const frame = (now) => {
- const k = (now - t0) / 480;
- render(k);
- if (k < 1 + lines.length * 0.08) anim = requestAnimationFrame(frame);
- };
- anim = requestAnimationFrame(frame);
- }
-
- select(0);
-}
-
-// Characters left of the reveal point are final; to the right they flicker.
-function scramble(str, k) {
- if (k >= 1) return str;
- const cut = Math.max(0, Math.floor(str.length * k));
- let out = str.slice(0, cut);
- for (let i = cut; i < str.length; i++) {
- const c = str[i];
- out += k < 0 ? " " : c === " " ? c : GLYPHS[(Math.random() * GLYPHS.length) | 0];
- }
- return out;
-}
diff --git a/src/scripts/org.js b/src/scripts/org.js
@@ -1,156 +0,0 @@
-// Renders the posts section as an Org buffer: keywords, a paragraph, years as
-// "*" headings and posts as "**" headings with tags and a property drawer.
-// TAB folds the heading at point; S-TAB cycles OVERVIEW, CONTENTS and SHOW ALL.
-
-import { $, $$ } from "./util.js";
-import { message } from "./shell.js";
-import { TAG_COLORS } from "./tags.js";
-
-const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
-
-export function initOrg() {
- const sec = $("#posts");
- const src = sec && $(".posts", sec);
- if (!src) return;
- const intro = $(".org-p", sec);
-
- const posts = $$("li", src).map((li) => {
- const a = $("a", li);
- return { date: li.dataset.date, tags: (li.dataset.tags || "").split(/\s+/).filter(Boolean), title: a.textContent.trim(), href: a.href };
- });
- const years = [...new Set(posts.map((p) => p.date.slice(0, 4)))].sort().reverse();
-
- const el = (tag, cls, text) => {
- const e = document.createElement(tag);
- if (cls) e.className = cls;
- if (text != null) e.textContent = text;
- return e;
- };
-
- const org = el("div", "org");
- const kw = (tag, key, value, cls) => {
- const p = el(tag, "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"));
- if (intro) org.append(intro);
-
- const tree = el("ul", "org-tree");
- tree.setAttribute("role", "tree");
- tree.setAttribute("aria-label", "Posts by year");
- const headings = [];
-
- function heading(li, level) {
- const h = el("div", "org-h");
- h.tabIndex = headings.length === 0 ? 0 : -1;
- const stars = el("button", "org-stars", "*".repeat(level));
- stars.type = "button";
- stars.tabIndex = -1;
- stars.setAttribute("aria-label", "Fold or unfold");
- stars.addEventListener("click", () => { li._fold(!li._open()); h.focus({ preventScroll: true }); });
- h.append(stars);
- li.prepend(h);
- headings.push(h);
- return h;
- }
-
- for (const y of years) {
- const items = posts.filter((p) => p.date.startsWith(y));
- const li = el("li", "org-h1");
- li.setAttribute("role", "treeitem");
- const h = heading(li, 1);
- h.append(el("span", "org-text", y), el("span", "count", `[${items.length}]`));
- const ell = el("span", "org-ellipsis", "...");
- h.append(ell);
-
- const kids = el("ul", "org-tree");
- kids.setAttribute("role", "group");
- for (const p of items) {
- const pl = el("li", "org-h2");
- pl.setAttribute("role", "treeitem");
- const ph = heading(pl, 2);
- const a = el("a", "org-text", p.title);
- a.href = p.href;
- a.tabIndex = -1;
- ph.append(a);
- if (p.tags.length) {
- const tags = el("span", "org-tags");
- tags.append(":");
- for (const t of p.tags) {
- const tg = el("span", "t", t);
- tg.style.setProperty("--tag", TAG_COLORS[t] ?? "var(--dim)");
- tags.append(tg, ":");
- }
- ph.append(tags);
- }
- const pell = el("span", "org-ellipsis", "...");
- ph.append(pell);
-
- const d = new Date(`${p.date}T12:00:00Z`);
- const drawer = el("div", "org-drawer");
- drawer.innerHTML = `<span class="k">:PROPERTIES:</span>\n<span class="k">:DATE:</span> <span class="ts"><${p.date} ${DAYS[d.getUTCDay()]}></span>\n<span class="k">:URL:</span> <a href="${p.href}" tabindex="-1">${p.href.replace(/^https?:\/\//, "")}</a>\n<span class="k">:END:</span>`;
- pl.append(drawer);
- pl._fold = (open) => {
- drawer.hidden = !open;
- pell.hidden = open;
- pl.setAttribute("aria-expanded", String(open));
- };
- pl._open = () => !drawer.hidden;
- pl._fold(false);
- kids.append(pl);
- }
- li.append(kids);
- li._fold = (open) => {
- kids.hidden = !open;
- ell.hidden = open;
- li.setAttribute("aria-expanded", String(open));
- };
- li._open = () => !kids.hidden;
- li._fold(true);
- tree.append(li);
- }
- org.append(tree);
- src.replaceWith(org);
-
- const meta = $('[data-org="meta"]', sec);
- if (meta) meta.textContent = `${posts.length} posts`;
-
- // Global visibility cycling, like S-TAB in Org.
- const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
- let global = 1;
- function globalCycle() {
- global = (global + 1) % 3;
- for (const y of $$(".org-h1", tree)) {
- y._fold(global > 0);
- for (const p of $$(".org-h2", y)) p._fold(global === 2);
- }
- message(STATES[global]);
- }
-
- const visible = () => headings.filter((h) => h.offsetParent !== null);
- tree.addEventListener("keydown", (e) => {
- const h = e.target.closest(".org-h");
- if (!h) return;
- const li = h.parentElement;
- const vis = visible();
- const i = vis.indexOf(h);
- const move = (j) => {
- const t = vis[Math.max(0, Math.min(vis.length - 1, j))];
- for (const x of headings) x.tabIndex = -1;
- t.tabIndex = 0;
- t.focus();
- };
- if (e.key === "Tab" && e.shiftKey) { e.preventDefault(); globalCycle(); }
- else if (e.key === "Tab") { e.preventDefault(); li._fold(!li._open()); }
- else if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); move(i + 1); }
- else if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); move(i - 1); }
- else if (e.key === "Enter") {
- const a = h.querySelector("a");
- if (a) a.click();
- else li._fold(!li._open());
- }
- });
-}
diff --git a/src/scripts/page.js b/src/scripts/page.js
@@ -1,211 +0,0 @@
-// Pages other than the home page get the same Emacs (modeline, M-x, themes,
-// which-key), plus, on posts: Org folding, imenu over the headings, the
-// *Ilist* window following along, and the giscus comments the old site had.
-
-import { $, $$, on, themeColors, reducedMotion } from "./util.js";
-import { defcommand, completingRead, message } from "./shell.js";
-import { initCtf } from "./ctf.js";
-import { DEFAULT_THEME } from "./themes.js";
-
-// SPC a, p, n and m still go to the home page's sections.
-for (const id of ["top", "about", "projects", "posts", "contact"]) {
- defcommand(`goto-${id}`, () => location.assign(`/#${id}`), { hidden: true });
-}
-
-try { initCtf(); } catch (err) { console.error(err); }
-
-const body = $(".post-body");
-const heads = $$(".post-body :is(h2, h3)[id]:not(.sr-only)");
-const titles = new Map(heads.map((h) => [h, h.textContent.trim()]));
-
-// ── folding, like Org ─────────────────────────────────────────────────
-// Each heading owns what follows it, up to the next heading at its level or
-// above. Clicking its stars folds that away. With the stars focused, TAB does
-// the same and S-TAB cycles the whole buffer (overview, contents, show all),
-// as in the posts list on the home page. Folding toggles a class rather than
-// moving anything, so the videos keep playing.
-
-const outline = []; // { h, level, folded, owners, stars, ell, hasContent }
-const owned = []; // [element, owners] for everything under a heading
-const nodeOf = new Map();
-let contentsOnly = false;
-
-if (body) {
- const levelOf = (el) => (/^H[234]$/.test(el.tagName) && !el.classList.contains("sr-only") ? +el.tagName[1] - 1 : 0);
- const open = []; // the headings we're inside, outermost first
- for (const el of body.children) {
- if (el.matches(".footnotes")) break;
- const level = levelOf(el);
- if (!level) {
- owned.push([el, [...open]]);
- if (open.length) open.at(-1).hasContent = true;
- continue;
- }
- while (open.length && open.at(-1).level >= level) open.pop();
- const node = { h: el, level, folded: false, owners: [...open], hasContent: false };
- node.stars = Object.assign(document.createElement("button"), { type: "button", className: "org-stars", tabIndex: -1, textContent: "*".repeat(level) });
- node.stars.setAttribute("aria-label", "Fold or unfold");
- node.ell = Object.assign(document.createElement("span"), { className: "org-ellipsis", textContent: "...", hidden: true });
- node.ell.setAttribute("aria-hidden", "true");
- el.prepend(node.stars);
- el.append(node.ell);
- el.classList.add("has-stars");
- node.stars.addEventListener("click", () => {
- toggle(node);
- node.stars.focus({ preventScroll: true });
- });
- node.stars.addEventListener("keydown", (e) => {
- if (e.key !== "Tab") return;
- e.preventDefault();
- if (e.shiftKey) cycle();
- else toggle(node);
- });
- outline.push(node);
- nodeOf.set(el, node);
- open.push(node);
- }
-}
-
-function update() {
- const away = (owners) => owners.some((o) => o.folded);
- for (const n of outline) {
- n.h.classList.toggle("is-folded-away", away(n.owners));
- n.stars.setAttribute("aria-expanded", String(!n.folded));
- n.ell.hidden = !(n.folded || (contentsOnly && n.hasContent));
- }
- for (const [el, owners] of owned) {
- el.classList.toggle("is-folded-away", away(owners) || (contentsOnly && owners.length > 0));
- }
-}
-
-function toggle(node) {
- if (contentsOnly) {
- // Leaving the contents view: keep only the path to this heading open.
- contentsOnly = false;
- const path = new Set([node, ...node.owners]);
- for (const n of outline) n.folded = !path.has(n);
- node.folded = false;
- } else {
- node.folded = !node.folded;
- }
- update();
-}
-
-const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
-let state = 2;
-function cycle() {
- state = (state + 1) % 3;
- contentsOnly = state === 1;
- for (const n of outline) n.folded = state === 0 && n.level === 1;
- update();
- message(STATES[state]);
-}
-
-// Unfold whatever hides a heading, before jumping to it.
-function reveal(h) {
- const node = nodeOf.get(h);
- if (!node) return;
- contentsOnly = false;
- for (const o of node.owners) o.folded = false;
- update();
-}
-
-if (outline.length) {
- defcommand("org-shifttab", cycle, { doc: "Cycle the whole post: overview, contents, show all" });
- // S-TAB with nothing focused cycles too, like in the home page's Org buffer.
- addEventListener("keydown", (e) => {
- if (e.key === "Tab" && e.shiftKey && document.activeElement === document.body) {
- e.preventDefault();
- cycle();
- }
- });
-}
-
-// ── imenu ─────────────────────────────────────────────────────────────
-
-if (heads.length) {
- defcommand("imenu", async () => {
- const pick = await completingRead("imenu: ", heads.map((h) => ({ value: titles.get(h), ann: h.tagName === "H3" ? "subsection" : "" })));
- const h = heads.find((x) => titles.get(x) === pick);
- if (!h) return;
- reveal(h);
- h.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
- history.replaceState(null, "", `#${h.id}`);
- }, { doc: "Jump to a heading in this post", key: "SPC i" });
-}
-
-// The *Ilist* window marks the heading you're reading: the last one above a
-// line 30% down the screen. A long list scrolls to keep that entry in view.
-const toc = $(".post-toc");
-const tocLinks = toc ? $$("a", toc) : [];
-if (tocLinks.length) {
- let raf = 0;
- const mark = () => {
- raf = 0;
- const line = innerHeight * 0.3;
- let cur = null;
- for (const h of heads) {
- if (h.offsetParent === null) continue; // folded away
- if (h.getBoundingClientRect().top > line) break;
- cur = h;
- }
- for (const a of tocLinks) {
- const on = !!cur && a.hash === `#${cur.id}`;
- if (on && a.getAttribute("aria-current") !== "true" && toc.scrollHeight > toc.clientHeight) {
- const y = a.offsetTop - toc.scrollTop;
- if (y < 40 || y > toc.clientHeight - 40) toc.scrollTop = a.offsetTop - toc.clientHeight / 2;
- }
- a.setAttribute("aria-current", String(on));
- }
- };
- addEventListener("scroll", () => { raf ||= requestAnimationFrame(mark); }, { passive: true });
- mark();
- for (const a of tocLinks) {
- a.addEventListener("click", () => reveal(document.getElementById(decodeURIComponent(a.hash.slice(1)))));
- }
-}
-
-// ── comments ──────────────────────────────────────────────────────────
-// Threads are GitHub Discussions matched by path, so the old site's comments
-// still show up. The script only loads once you scroll near the comments.
-
-// The comments use a stylesheet made from the current theme's colours
-// (src/pages/giscus/[theme].css.js). giscus fetches it from its own origin,
-// which only the deployed site allows, so the dev server uses a built-in look.
-const giscusTheme = () => {
- const id = document.documentElement.dataset.theme || DEFAULT_THEME;
- if (!import.meta.env.DEV) return `${location.origin}/giscus/${id}.css`;
- return themeColors().light ? "noborder_light" : "transparent_dark";
-};
-
-const box = $(".giscus");
-if (box) {
- const io = new IntersectionObserver(([e]) => {
- if (!e.isIntersecting) return;
- io.disconnect();
- const s = document.createElement("script");
- s.src = "https://giscus.app/client.js";
- s.async = true;
- s.crossOrigin = "anonymous";
- Object.assign(s.dataset, {
- repo: "michaelneuper/website",
- repoId: "R_kgDOIjPNaQ",
- category: "General",
- categoryId: "DIC_kwDOIjPNac4CaklA",
- mapping: "pathname",
- strict: "0",
- reactionsEnabled: "1",
- emitMetadata: "0",
- inputPosition: "top",
- theme: giscusTheme(),
- lang: "en",
- loading: "lazy",
- });
- box.append(s);
- }, { rootMargin: "600px" });
- io.observe(box);
-
- on("theme", () => {
- $("iframe.giscus-frame", box)?.contentWindow.postMessage({ giscus: { setConfig: { theme: giscusTheme() } } }, "https://giscus.app");
- });
-}
diff --git a/src/scripts/post/comments.js b/src/scripts/post/comments.js
@@ -0,0 +1,58 @@
+// The comments under a post: GitHub Discussions through giscus, matched by
+// path, so the old site's threads still show up. The script only loads once you
+// scroll near the comments, and follows M-x load-theme.
+
+import { $, on, themeColors } from "../util.js";
+import { DEFAULT_THEME } from "../themes.js";
+
+// The comments use a stylesheet made from the current theme's colours
+// (src/pages/giscus/[theme].css.js). giscus loads it from inside its frame on
+// giscus.app, and browsers don't let a public site reach a dev server on this
+// machine. So in dev the stylesheets come from the deployed site, once it has
+// them, and until then giscus keeps its own look.
+let themes = location.origin;
+const found = !import.meta.env.DEV
+ ? Promise.resolve()
+ : fetch(`${import.meta.env.SITE}/giscus/${DEFAULT_THEME}.css`, { method: "HEAD" })
+ .then((r) => { themes = r.ok ? import.meta.env.SITE : null; })
+ .catch(() => { themes = null; });
+
+const giscusTheme = () => {
+ const id = document.documentElement.dataset.theme || DEFAULT_THEME;
+ if (themes) return `${themes}/giscus/${id}.css`;
+ return themeColors().light ? "noborder_light" : "transparent_dark";
+};
+
+export function initComments() {
+ const box = $(".giscus");
+ if (!box) return;
+ const io = new IntersectionObserver(async ([e]) => {
+ if (!e.isIntersecting) return;
+ io.disconnect();
+ await found;
+ const s = document.createElement("script");
+ s.src = "https://giscus.app/client.js";
+ s.async = true;
+ s.crossOrigin = "anonymous";
+ Object.assign(s.dataset, {
+ repo: "michaelneuper/website",
+ repoId: "R_kgDOIjPNaQ",
+ category: "General",
+ categoryId: "DIC_kwDOIjPNac4CaklA",
+ mapping: "pathname",
+ strict: "0",
+ reactionsEnabled: "1",
+ emitMetadata: "0",
+ inputPosition: "top",
+ theme: giscusTheme(),
+ lang: "en",
+ loading: "lazy",
+ });
+ box.append(s);
+ }, { rootMargin: "600px" });
+ io.observe(box);
+
+ on("theme", () => {
+ $("iframe.giscus-frame", box)?.contentWindow.postMessage({ giscus: { setConfig: { theme: giscusTheme() } } }, "https://giscus.app");
+ });
+}
diff --git a/src/scripts/post/folding.js b/src/scripts/post/folding.js
@@ -0,0 +1,112 @@
+// Org folding for a post. Each heading owns what follows it, up to the next
+// heading at its level or above. Clicking its stars folds that away. With the
+// stars focused, TAB does the same and S-TAB cycles the whole buffer
+// (overview, contents, show all), as in the posts list on the home page.
+// Folding toggles a class rather than moving anything, so videos keep playing.
+
+import { $, $$ } from "../util.js";
+import { defcommand, message } from "../emacs/index.js";
+
+/** The post's headings that imenu and the *Ilist* window list. */
+export const headings = () => $$(".post-body :is(h2, h3)[id]:not(.sr-only)");
+const titles = new Map();
+/** A heading's text without the stars and ellipsis added here. */
+export const titleOf = (h) => titles.get(h) ?? h.textContent.trim();
+
+const outline = []; // { h, level, folded, owners, stars, ell, hasContent }
+const owned = []; // [element, owners] for everything under a heading
+const nodeOf = new Map();
+let contentsOnly = false;
+
+export function initFolding() {
+ const body = $(".post-body");
+ if (!body) return;
+ for (const h of headings()) titles.set(h, h.textContent.trim());
+ const levelOf = (el) => (/^H[234]$/.test(el.tagName) && !el.classList.contains("sr-only") ? +el.tagName[1] - 1 : 0);
+ const open = []; // the headings we're inside, outermost first
+ for (const el of body.children) {
+ if (el.matches(".footnotes")) break;
+ const level = levelOf(el);
+ if (!level) {
+ owned.push([el, [...open]]);
+ if (open.length) open.at(-1).hasContent = true;
+ continue;
+ }
+ while (open.length && open.at(-1).level >= level) open.pop();
+ const node = { h: el, level, folded: false, owners: [...open], hasContent: false };
+ node.stars = Object.assign(document.createElement("button"), { type: "button", className: "org-stars", tabIndex: -1, textContent: "*".repeat(level) });
+ node.stars.setAttribute("aria-label", "Fold or unfold");
+ node.ell = Object.assign(document.createElement("span"), { className: "org-ellipsis", textContent: "...", hidden: true });
+ node.ell.setAttribute("aria-hidden", "true");
+ el.prepend(node.stars);
+ el.append(node.ell);
+ el.classList.add("has-stars");
+ node.stars.addEventListener("click", () => {
+ toggle(node);
+ node.stars.focus({ preventScroll: true });
+ });
+ node.stars.addEventListener("keydown", (e) => {
+ if (e.key !== "Tab") return;
+ e.preventDefault();
+ if (e.shiftKey) cycle();
+ else toggle(node);
+ });
+ outline.push(node);
+ nodeOf.set(el, node);
+ open.push(node);
+ }
+
+ if (!outline.length) return;
+ defcommand("org-shifttab", cycle, { doc: "Cycle the whole post: overview, contents, show all" });
+ // S-TAB with nothing focused cycles too, like in the home page's Org buffer.
+ addEventListener("keydown", (e) => {
+ if (e.key === "Tab" && e.shiftKey && document.activeElement === document.body) {
+ e.preventDefault();
+ cycle();
+ }
+ });
+}
+
+function update() {
+ const away = (owners) => owners.some((o) => o.folded);
+ for (const n of outline) {
+ n.h.classList.toggle("is-folded-away", away(n.owners));
+ n.stars.setAttribute("aria-expanded", String(!n.folded));
+ n.ell.hidden = !(n.folded || (contentsOnly && n.hasContent));
+ }
+ for (const [el, owners] of owned) {
+ el.classList.toggle("is-folded-away", away(owners) || (contentsOnly && owners.length > 0));
+ }
+}
+
+function toggle(node) {
+ if (contentsOnly) {
+ // Leaving the contents view: keep only the path to this heading open.
+ contentsOnly = false;
+ const path = new Set([node, ...node.owners]);
+ for (const n of outline) n.folded = !path.has(n);
+ node.folded = false;
+ } else {
+ node.folded = !node.folded;
+ }
+ update();
+}
+
+const STATES = ["OVERVIEW", "CONTENTS", "SHOW ALL"];
+let state = 2;
+function cycle() {
+ state = (state + 1) % 3;
+ contentsOnly = state === 1;
+ for (const n of outline) n.folded = state === 0 && n.level === 1;
+ update();
+ message(STATES[state]);
+}
+
+/** Unfolds whatever hides a heading, before jumping to it. */
+export function reveal(h) {
+ const node = nodeOf.get(h);
+ if (!node) return;
+ contentsOnly = false;
+ for (const o of node.owners) o.folded = false;
+ update();
+}
diff --git a/src/scripts/post/imenu.js b/src/scripts/post/imenu.js
@@ -0,0 +1,18 @@
+// M-x imenu (SPC i): jump to a heading in the post by name.
+
+import { reducedMotion } from "../util.js";
+import { defcommand, completingRead } from "../emacs/index.js";
+import { headings, titleOf, reveal } from "./folding.js";
+
+export function initImenu() {
+ const heads = headings();
+ if (!heads.length) return;
+ defcommand("imenu", async () => {
+ const pick = await completingRead("imenu: ", heads.map((h) => ({ value: titleOf(h), ann: h.tagName === "H3" ? "subsection" : "" })));
+ const h = heads.find((x) => titleOf(x) === pick);
+ if (!h) return;
+ reveal(h);
+ h.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
+ history.replaceState(null, "", `#${h.id}`);
+ }, { doc: "Jump to a heading in this post", key: "SPC i" });
+}
diff --git a/src/scripts/post/outline.js b/src/scripts/post/outline.js
@@ -0,0 +1,37 @@
+// The *Ilist* window beside a post marks the heading you're reading: the last
+// one above a line 30% down the screen. A long list scrolls to keep that entry
+// in view, and following a link opens the heading if it's folded.
+
+import { $, $$ } from "../util.js";
+import { headings, reveal } from "./folding.js";
+
+export function initOutline() {
+ const toc = $(".post-toc");
+ const tocLinks = toc ? $$("a", toc) : [];
+ if (!tocLinks.length) return;
+ const heads = headings();
+ let raf = 0;
+ const mark = () => {
+ raf = 0;
+ const line = innerHeight * 0.3;
+ let cur = null;
+ for (const h of heads) {
+ if (h.offsetParent === null) continue; // folded away
+ if (h.getBoundingClientRect().top > line) break;
+ cur = h;
+ }
+ for (const a of tocLinks) {
+ const on = !!cur && a.hash === `#${cur.id}`;
+ if (on && a.getAttribute("aria-current") !== "true" && toc.scrollHeight > toc.clientHeight) {
+ const y = a.offsetTop - toc.scrollTop;
+ if (y < 40 || y > toc.clientHeight - 40) toc.scrollTop = a.offsetTop - toc.clientHeight / 2;
+ }
+ a.setAttribute("aria-current", String(on));
+ }
+ };
+ addEventListener("scroll", () => { raf ||= requestAnimationFrame(mark); }, { passive: true });
+ mark();
+ for (const a of tocLinks) {
+ a.addEventListener("click", () => reveal(document.getElementById(decodeURIComponent(a.hash.slice(1)))));
+ }
+}
diff --git a/src/scripts/shell.js b/src/scripts/shell.js
@@ -1,515 +0,0 @@
-// The Emacs around the page: doom-modeline, the echo area, a minibuffer with
-// vertico-style completion (M-x, evil's :), which-key on the SPC leader, and
-// load-theme with live preview. Other modules add commands with defcommand().
-
-import { $, $$, emit, isTyping, finePointer, reducedMotion } from "./util.js";
-import { THEMES, DEFAULT_THEME } from "./themes.js";
-
-const root = document.documentElement;
-const modeline = $(".modeline");
-const ml = {
- evil: $(".ml-evil"), buf: $(".ml-buf"), pos: $(".ml-pos"), pct: $(".ml-pct"),
- flags: $(".ml-flags"), misc: $(".ml-misc"), mode: $(".ml-mode"), time: $(".ml-time"),
-};
-const mini = $(".mini");
-const form = $(".mini-prompt");
-const input = $("input", form);
-const psEl = $(".mini-ps");
-const countEl = $(".mini-count");
-const list = $(".mini-list");
-const echo = $(".echo");
-const wk = $(".which-key");
-const help = $(".help");
-const nav = $(".nav");
-const menuKey = $(".menu-key");
-
-// ── echo area ─────────────────────────────────────────────────────────
-
-let echoTimer = 0;
-const idleText = () => (finePointer.matches ? "Press SPC for commands, or M-x to run one by name." : "");
-export function message(text, kind = "", ms = 5000) {
- echo.textContent = text;
- echo.className = kind ? `echo is-${kind}` : "echo";
- clearTimeout(echoTimer);
- if (ms) echoTimer = setTimeout(() => message(idleText(), "", 0), ms);
-}
-message(idleText(), "", 0);
-
-// ── modeline ──────────────────────────────────────────────────────────
-
-export function setSegment(name, text) {
- if (ml[name]) ml[name].textContent = text;
-}
-
-function evilState() {
- const a = document.activeElement;
- let s = "N";
- if (!mini.hidden || (a && isTyping({ target: a }))) s = "I";
- else if (String(getSelection()).length) s = "V";
- modeline.dataset.evil = s;
- ml.evil.textContent = `<${s}>`;
-}
-document.addEventListener("focusin", evilState);
-document.addEventListener("focusout", () => setTimeout(evilState));
-document.addEventListener("selectionchange", evilState);
-
-function onScroll() {
- const max = root.scrollHeight - innerHeight;
- const y = scrollY;
- ml.pct.textContent = y <= 2 ? "Top" : y >= max - 2 ? "Bot" : `${Math.round((y / max) * 100)}%`;
- ml.pos.textContent = `${Math.floor(y / 21) + 1}:0`;
- root.classList.toggle("is-scrolled", y > 24);
-}
-addEventListener("scroll", onScroll, { passive: true });
-onScroll();
-
-function clock() {
- const d = new Date();
- ml.time.textContent = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
-}
-clock();
-setInterval(clock, 15000);
-
-// ── sections ──────────────────────────────────────────────────────────
-
-const sections = $$("main > section");
-const navLinks = $$(".nav a");
-const sectionIO = new IntersectionObserver((entries) => {
- for (const e of entries) {
- if (!e.isIntersecting) continue;
- ml.buf.textContent = e.target.dataset.file;
- ml.mode.textContent = e.target.dataset.mode;
- for (const a of navLinks) a.setAttribute("aria-current", String(a.hash === `#${e.target.id}`));
- emit("section", e.target.id);
- }
-}, { rootMargin: "-40% 0px -55% 0px" });
-sections.forEach((s) => sectionIO.observe(s));
-
-export function goto(id, quiet = false) {
- const s = document.getElementById(id);
- if (!s) return;
- // Sections snap to their header line, so scroll to that rather than the padding above it.
- ($(".winbar", s) ?? s).scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
- history.replaceState(null, "", id === "top" ? location.pathname : `#${id}`);
- if (!quiet) message(`"${s.dataset.file}"`);
-}
-
-document.addEventListener("click", (e) => {
- const a = e.target.closest('a[href^="#"]');
- if (!a || e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey) return;
- const id = a.hash.slice(1);
- if (!sections.some((s) => s.id === id)) return;
- e.preventDefault();
- goto(id, true);
-});
-
-// ── commands ──────────────────────────────────────────────────────────
-
-const commands = new Map();
-export function defcommand(name, run, { doc = "", key = "", hidden = false } = {}) {
- commands.set(name, { name, run, doc, key, hidden });
-}
-export function call(name, ...args) {
- const c = commands.get(name);
- if (!c) return message(`Symbol’s function definition is void: ${name}`, "error");
- return c.run(...args);
-}
-
-// ── minibuffer: completing-read ───────────────────────────────────────
-
-let pending = null;
-
-// Resolves to the chosen string, or null if cancelled. `candidates` is an array of
-// { value, ann, key } (or strings), or a function of the input returning one.
-export function completingRead(prompt, candidates, opts = {}) {
- const { initial = "", requireMatch = true, select = null, onPreview = null } = opts;
- if (pending) pending.finish(null);
- const returnFocus = document.activeElement;
-
- return new Promise((resolve) => {
- let items = [];
- let sel = 0;
- const norm = (c) => (typeof c === "string" ? { value: c } : c);
-
- const render = () => {
- const q = input.value.trim().toLowerCase();
- const all = (typeof candidates === "function" ? candidates(input.value) : candidates).map(norm);
- const terms = q.split(/\s+/).filter(Boolean);
- items = typeof candidates === "function" ? all : all.filter((c) => terms.every((t) => c.value.toLowerCase().includes(t)));
- // candidates that start with what you typed come first
- if (terms.length && typeof candidates !== "function") {
- const starts = (c) => c.value.toLowerCase().startsWith(terms[0]);
- items = [...items.filter(starts), ...items.filter((c) => !starts(c))];
- }
- sel = Math.min(sel, Math.max(0, items.length - 1));
- countEl.textContent = items.length ? `${sel + 1}/${items.length}` : "0/0";
- list.replaceChildren(...items.map((c, i) => {
- const li = document.createElement("li");
- li.setAttribute("role", "option");
- li.setAttribute("aria-selected", String(i === sel));
- li.id = `mini-opt-${i}`;
- const cand = document.createElement("span");
- cand.className = "cand";
- cand.append(...highlight(c.value, terms));
- li.append(cand);
- if (c.ann) {
- const a = document.createElement("span");
- a.className = "ann";
- a.textContent = c.ann;
- li.append(a);
- }
- if (c.key) {
- const k = document.createElement("span");
- k.className = "key";
- k.textContent = c.key;
- li.append(k);
- }
- li.addEventListener("mousedown", (e) => e.preventDefault());
- li.addEventListener("click", () => { sel = i; accept(); });
- return li;
- }));
- input.setAttribute("aria-activedescendant", items.length ? `mini-opt-${sel}` : "");
- list.children[sel]?.scrollIntoView({ block: "nearest" });
- };
-
- const move = (d) => {
- if (!items.length) return;
- sel = (sel + d + items.length) % items.length;
- render();
- onPreview?.(items[sel].value);
- };
-
- const accept = () => {
- const raw = input.value.trim();
- const hit = items[sel]?.value;
- if (hit !== undefined && (requireMatch || typeof candidates !== "function" || raw === "")) finish(hit);
- else if (!requireMatch) finish(raw || hit || null);
- else if (hit !== undefined) finish(hit);
- else message("[No match]", "error", 1500);
- };
-
- const onKey = (e) => {
- const ctrl = e.ctrlKey && !e.metaKey;
- if (e.key === "ArrowDown" || (ctrl && e.key === "n")) { e.preventDefault(); move(1); }
- else if (e.key === "ArrowUp" || (ctrl && e.key === "p")) { e.preventDefault(); move(-1); }
- else if (e.key === "Enter") { e.preventDefault(); accept(); }
- else if (e.key === "Escape" || (ctrl && e.key === "g")) { e.preventDefault(); finish(null); }
- else if (e.key === "Tab") {
- e.preventDefault();
- if (items[sel]) {
- input.value = items[sel].value;
- render();
- }
- }
- };
- const onInput = () => {
- sel = 0;
- render();
- if (items.length) onPreview?.(items[0].value);
- };
- const onBlur = () => setTimeout(() => { if (pending?.finish === finish && document.activeElement !== input) finish(null); }, 0);
- const onSubmit = (e) => e.preventDefault();
-
- function finish(value) {
- if (pending?.finish !== finish) return;
- pending = null;
- input.removeEventListener("keydown", onKey);
- input.removeEventListener("input", onInput);
- input.removeEventListener("blur", onBlur);
- form.removeEventListener("submit", onSubmit);
- mini.hidden = true;
- list.replaceChildren();
- if (returnFocus && returnFocus !== input && document.contains(returnFocus)) returnFocus.focus({ preventScroll: true });
- else input.blur();
- evilState();
- resolve(value);
- }
-
- pending = { finish };
- psEl.textContent = prompt;
- input.value = initial;
- mini.hidden = false;
- input.addEventListener("keydown", onKey);
- input.addEventListener("input", onInput);
- input.addEventListener("blur", onBlur);
- form.addEventListener("submit", onSubmit);
- if (select) {
- const all = (typeof candidates === "function" ? candidates("") : candidates).map(norm);
- sel = Math.max(0, all.findIndex((c) => c.value === select));
- }
- render();
- input.focus({ preventScroll: true });
- evilState();
- });
-}
-
-function highlight(text, terms) {
- if (!terms.length) return [text];
- const lower = text.toLowerCase();
- const marks = new Array(text.length).fill(false);
- for (const t of terms) {
- const i = lower.indexOf(t);
- if (i >= 0) for (let k = i; k < i + t.length; k++) marks[k] = true;
- }
- const out = [];
- let buf = "";
- let on = false;
- for (let i = 0; i <= text.length; i++) {
- const m = i < text.length && marks[i];
- if (i === text.length || m !== on) {
- if (buf) {
- if (on) {
- const mk = document.createElement("mark");
- mk.textContent = buf;
- out.push(mk);
- } else out.push(buf);
- }
- buf = "";
- on = m;
- }
- if (i < text.length) buf += text[i];
- }
- return out;
-}
-
-// ── M-x and evil ex ───────────────────────────────────────────────────
-
-async function mx() {
- const cands = [...commands.values()].filter((c) => !c.hidden).sort((a, b) => a.name.localeCompare(b.name))
- .map((c) => ({ value: c.name, ann: c.doc, key: c.key }));
- const name = await completingRead("M-x ", cands);
- if (name) call(name);
-}
-defcommand("execute-extended-command", mx, { hidden: true });
-
-const EX = ["colorscheme", "edit", "help", "quit", "write", "wq"];
-async function ex() {
- const line = await completingRead(":", (q) => {
- const [cmd, arg = ""] = q.trim().split(/\s+/);
- if (/^colo/.test(cmd || "") && q.includes(" ")) return THEMES.filter((t) => t.id.startsWith(arg)).map((t) => ({ value: `colorscheme ${t.id}`, ann: t.desc }));
- if (/^e(dit)?$/.test(cmd || "") && q.includes(" ")) return sections.filter((s) => s.id.startsWith(arg)).map((s) => ({ value: `edit ${s.id}`, ann: s.dataset.file }));
- return EX.filter((c) => c.startsWith(cmd || "")).map((c) => ({ value: c }));
- }, { requireMatch: false });
- if (line) runEx(line);
-}
-
-function runEx(line) {
- const [cmd, arg] = line.trim().split(/\s+/);
- if (/^q(uit|a)?!?$/.test(cmd)) call("save-buffers-kill-terminal");
- else if (/^(w|write)$/.test(cmd)) message(`"${ml.buf.textContent}" written`);
- else if (/^(wq|x)!?$/.test(cmd)) message(`"${ml.buf.textContent}" written. Still here, though.`);
- else if (/^colo(r(scheme)?)?$/.test(cmd)) arg ? setTheme(arg) : call("load-theme");
- else if (/^e(dit)?$/.test(cmd)) goto(findSection(arg)?.id ?? "top");
- else if (/^h(elp)?$/.test(cmd)) call("help");
- else message(`E492: Not an editor command: ${line}`, "error");
-}
-
-function findSection(word = "") {
- const w = word.toLowerCase();
- return sections.find((s) => s.id === w || s.dataset.file === w) || sections.find((s) => s.id.startsWith(w) || s.dataset.file.startsWith(w));
-}
-
-// ── themes ────────────────────────────────────────────────────────────
-
-const currentTheme = () => root.dataset.theme || DEFAULT_THEME;
-function applyTheme(id) {
- if (root.dataset.theme === id) return;
- root.dataset.theme = id;
- emit("theme", id);
-}
-export function setTheme(id) {
- if (!THEMES.some((t) => t.id === id)) return message(`Unable to find theme file for ‘${id}’`, "error");
- applyTheme(id);
- try { localStorage.setItem("theme", id); } catch { /* private mode */ }
- message(`Loaded theme ${id}`);
-}
-
-async function loadTheme() {
- const saved = currentTheme();
- const id = await completingRead("Load custom theme: ", THEMES.map((t) => ({ value: t.id, ann: t.desc })), {
- select: saved,
- onPreview: applyTheme,
- });
- if (id) setTheme(id);
- else applyTheme(saved);
-}
-defcommand("load-theme", loadTheme, { doc: "Load a theme, previewing each one", key: "SPC h t" });
-$(".theme-key").addEventListener("click", loadTheme);
-
-// ── a few more commands ───────────────────────────────────────────────
-
-defcommand("find-file", async () => {
- const id = await completingRead("Find file: ", sections.map((s) => ({ value: s.dataset.file, ann: s.querySelector("h1, h2")?.textContent ?? "" })));
- const s = id && sections.find((x) => x.dataset.file === id);
- if (s) goto(s.id);
-}, { doc: "Jump to a section by its file name", key: "SPC ." });
-
-for (const s of sections) {
- defcommand(`goto-${s.id}`, () => goto(s.id), { hidden: true });
-}
-
-defcommand("help", () => { if (!help.open) help.showModal(); }, { doc: "Show keys and commands", key: "SPC h h" });
-defcommand("save-buffers-kill-terminal", () => message("This is a website: close it with C-w. Glad you stayed, though."), { doc: "Quit Emacs", key: "SPC q q" });
-defcommand("restart-emacs", () => location.reload(), { doc: "Restart Emacs (reloads the page)", key: "SPC q r" });
-defcommand("buy-me-a-coffee", () => {
- window.open("https://buymeacoffee.com/michaelneuper", "_blank", "noopener");
- message("Opened buymeacoffee.com/michaelneuper");
-}, { doc: "Open my Buy Me a Coffee page", key: "SPC b" });
-defcommand("keyboard-quit", () => message("Quit"), { hidden: true });
-help.addEventListener("keydown", (e) => { if (e.key === "q") help.close(); });
-
-// ── which-key on the SPC leader ───────────────────────────────────────
-
-const KEYMAP = [
- { key: "SPC", desc: "find file", cmd: "find-file" },
- { key: ":", desc: "M-x", cmd: "execute-extended-command" },
- { key: "c", desc: "+computer", map: [
- { key: "c", desc: "run or pause", cmd: "computer-run" },
- { key: "n", desc: "step one cycle", cmd: "computer-step" },
- { key: "r", desc: "reset", cmd: "computer-reset" },
- { key: "l", desc: "load program", cmd: "computer-load-program" },
- ] },
- { key: "a", desc: "about", cmd: "goto-about" },
- { key: "p", desc: "projects", cmd: "goto-projects" },
- { key: "n", desc: "posts", cmd: "goto-posts" },
- { key: "m", desc: "mail me", cmd: "goto-contact" },
- { key: "i", desc: "imenu", cmd: "imenu" },
- { key: "r", desc: "refill coffee", cmd: "coffee-refill" },
- { key: "b", desc: "buy me a coffee", cmd: "buy-me-a-coffee" },
- { key: "h", desc: "+help", map: [
- { key: "t", desc: "load-theme", cmd: "load-theme" },
- { key: "h", desc: "keys and commands", cmd: "help" },
- ] },
- { key: "s", desc: "+security", map: [
- { key: "f", desc: "submit-flag", cmd: "submit-flag" },
- { key: "g", desc: "copy GPG fingerprint", cmd: "copy-gpg-fingerprint" },
- ] },
- { key: "q", desc: "+quit", map: [
- { key: "q", desc: "quit", cmd: "save-buffers-kill-terminal" },
- { key: "r", desc: "restart", cmd: "restart-emacs" },
- ] },
-];
-
-let leader = null; // { map, prefix }
-let wkTimer = 0;
-
-// Only the bindings whose command this page defines (posts have no computer).
-const bound = (map) => map.filter((b) => (b.map ? bound(b.map).length : commands.has(b.cmd)));
-
-function startLeader() {
- leader = { map: bound(KEYMAP), prefix: "SPC" };
- message("SPC-", "", 0);
- clearTimeout(wkTimer);
- wkTimer = setTimeout(showWhichKey, 300);
-}
-
-function showWhichKey() {
- if (!leader) return;
- const title = document.createElement("p");
- title.className = "wk-title";
- title.textContent = `${leader.prefix}-`;
- wk.replaceChildren(title, ...leader.map.map((b) => {
- const item = document.createElement("button");
- item.type = "button";
- item.className = "wk-item";
- item.style.cssText = "border:0;background:none;padding:0;text-align:left;font:inherit;color:inherit";
- item.innerHTML = `<span class="wk-key"></span><span class="wk-arrow">→</span><span class="wk-desc${b.map ? " group" : ""}"></span>`;
- item.children[0].textContent = b.key;
- item.children[2].textContent = b.desc;
- item.addEventListener("mousedown", (e) => e.preventDefault());
- item.addEventListener("click", () => press(b.key));
- return item;
- }));
- wk.hidden = false;
-}
-
-function endLeader(text = null) {
- leader = null;
- clearTimeout(wkTimer);
- wk.hidden = true;
- if (text !== null) message(text, text.endsWith("undefined") ? "error" : "", 3000);
- else message(idleText(), "", 0);
-}
-
-function press(key) {
- const b = leader.map.find((x) => x.key === key);
- if (!b) return endLeader(`${leader.prefix} ${key} is undefined`);
- if (b.map) {
- leader = { map: bound(b.map), prefix: `${leader.prefix} ${key}` };
- message(`${leader.prefix}-`, "", 0);
- showWhichKey();
- return;
- }
- endLeader();
- call(b.cmd);
-}
-
-// ── keys ──────────────────────────────────────────────────────────────
-
-addEventListener("keydown", (e) => {
- if (help.open) return;
- if (leader) {
- if (["Shift", "Control", "Alt", "Meta"].includes(e.key)) return;
- e.preventDefault();
- e.stopPropagation();
- if (e.key === "Escape" || (e.ctrlKey && e.key === "g")) endLeader("Quit");
- else press(e.key === " " ? "SPC" : e.key);
- return;
- }
- if (!mini.hidden || isTyping(e)) return;
-
- if (e.altKey && e.code === "KeyX" && !e.ctrlKey && !e.metaKey) {
- e.preventDefault();
- mx();
- } else if (e.ctrlKey && e.key === "g") {
- e.preventDefault();
- call("keyboard-quit");
- } else if (e.metaKey || e.ctrlKey || e.altKey) {
- // leave browser shortcuts alone
- } else if (e.key === " " && !e.target.closest?.("button, a, select, summary, [tabindex]")) {
- e.preventDefault();
- startLeader();
- } else if (e.key === ":") {
- e.preventDefault();
- ex();
- } else if (e.key === "?") {
- e.preventDefault();
- call("help");
- } else if (e.key === "Escape" && nav.classList.contains("is-open")) {
- toggleMenu(false);
- }
-}, true);
-
-// ── mobile menu ───────────────────────────────────────────────────────
-
-function toggleMenu(open = !nav.classList.contains("is-open")) {
- nav.classList.toggle("is-open", open);
- menuKey.setAttribute("aria-expanded", String(open));
-}
-menuKey.addEventListener("click", () => toggleMenu());
-nav.addEventListener("click", (e) => { if (e.target.closest("a")) toggleMenu(false); });
-document.addEventListener("click", (e) => {
- if (nav.classList.contains("is-open") && !e.target.closest(".nav, .menu-key")) toggleMenu(false);
-});
-
-// ── copy buttons: <button data-copy="selector"> ───────────────────────
-
-document.addEventListener("click", async (e) => {
- const b = e.target.closest("[data-copy]");
- if (!b) return;
- const src = document.querySelector(b.dataset.copy);
- if (!src) return;
- const label = b.textContent;
- try {
- await navigator.clipboard.writeText(src.textContent.trim());
- b.textContent = "copied";
- message(`Copied: ${src.textContent.trim()}`, "ok", 3000);
- } catch {
- const r = document.createRange();
- r.selectNodeContents(src);
- getSelection().removeAllRanges();
- getSelection().addRange(r);
- b.textContent = "selected";
- }
- setTimeout(() => { b.textContent = label; }, 2000);
-});
diff --git a/src/scripts/themes.js b/src/scripts/themes.js
@@ -1,6 +1,6 @@
-// Themes for M-x load-theme. The colours live in src/styles/style.css under
+// Themes for M-x load-theme. The colours live in src/styles/themes.css under
// :root[data-theme="…"]; keep this list, that CSS and the inline script in
-// src/pages/index.astro in sync.
+// src/layouts/Base.astro in sync.
export const THEMES = [
{ id: "doom-gruvbox", desc: "Gruvbox, medium contrast. The theme my own Emacs uses" },
diff --git a/src/styles/base.css b/src/styles/base.css
@@ -0,0 +1,186 @@
+/* Base: page, links, keys, buttons and form controls */
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ background: var(--bg);
+ color: var(--fg);
+ scroll-behavior: smooth;
+ scroll-snap-type: y proximity;
+ scroll-padding-top: calc(var(--top-h) + 24px);
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ font: 400 13px/1.6 var(--mono);
+ font-variant-ligatures: none;
+ -webkit-font-smoothing: antialiased;
+ transition: background-color 200ms ease, color 200ms ease;
+}
+
+::selection {
+ background: color-mix(in srgb, var(--blue) 35%, var(--bg));
+ color: var(--fg);
+}
+
+a {
+ color: inherit;
+ text-decoration-line: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: var(--faint);
+ text-underline-offset: 0.2em;
+}
+
+a:hover {
+ text-decoration-color: currentColor;
+}
+
+button,
+input,
+output,
+select {
+ font: inherit;
+ color: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+:focus-visible {
+ outline: 1px solid var(--fg);
+ outline-offset: 2px;
+}
+
+kbd {
+ font: inherit;
+ font-size: 0.95em;
+ padding: 0 0.45ch;
+ border: 1px solid var(--faint);
+ border-radius: 3px;
+}
+
+code {
+ font-family: var(--mono);
+ font-size: 0.95em;
+}
+
+.dim {
+ color: var(--dim);
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.skip {
+ position: absolute;
+ left: 8px;
+ top: -100px;
+ z-index: 50;
+ padding: 6px 12px;
+ background: var(--fg);
+ color: var(--bg);
+}
+
+.skip:focus {
+ top: 8px;
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ min-height: 30px;
+ padding: 0 14px;
+ border: 1px solid var(--faint);
+ border-radius: 0;
+ background: none;
+ font-size: 12px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ border-color: var(--fg);
+}
+
+.btn[aria-pressed="true"] {
+ border-color: var(--fg);
+ background: var(--fg);
+ color: var(--bg);
+}
+
+/* form controls, as the demos use them */
+
+select {
+ min-height: 30px;
+ padding: 0 28px 0 10px;
+ border: 1px solid var(--faint);
+ border-radius: 0;
+ background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E") no-repeat right 10px center;
+ color: var(--fg);
+ font-size: 12px;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"] {
+ width: 120px;
+ height: 22px;
+ margin: 0;
+ background: transparent;
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"]::-webkit-slider-runnable-track {
+ height: 1px;
+ background: var(--faint);
+}
+
+input[type="range"]::-moz-range-track {
+ height: 1px;
+ background: var(--faint);
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ width: 9px;
+ height: 18px;
+ margin-top: -9px;
+ border: 0;
+ border-radius: 0;
+ background: var(--fg);
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 9px;
+ height: 18px;
+ border: 0;
+ border-radius: 0;
+ background: var(--fg);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ transition: none !important;
+ }
+}
diff --git a/src/styles/demos/bytecode.css b/src/styles/demos/bytecode.css
@@ -0,0 +1,89 @@
+/* jvm-compiler: an expression, its bytecode and the operand stack */
+
+.bc-input {
+ display: flex;
+ align-items: center;
+ gap: 1ch;
+ margin-bottom: 12px;
+}
+
+.bc-input input {
+ flex: 1;
+ min-width: 0;
+ min-height: 30px;
+ padding: 0 10px;
+ border: 1px solid var(--faint);
+ border-radius: 0;
+ background: none;
+ font-size: 13px;
+}
+
+.bc-input input:focus {
+ border-color: var(--fg);
+ outline: none;
+}
+
+.bc-cols {
+ display: grid;
+ grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
+ gap: 16px;
+}
+
+.bc-code {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-variant-numeric: tabular-nums;
+}
+
+.bc-code li {
+ display: grid;
+ grid-template-columns: 4ch 1fr;
+ gap: 1ch;
+ padding: 1px 6px;
+}
+
+.bc-code li.is-pc {
+ background: var(--hl);
+}
+
+.bc-code .off {
+ color: var(--dim);
+ text-align: right;
+}
+
+.bc-code .op {
+ color: var(--blue);
+}
+
+.bc-stack {
+ display: flex;
+ flex-direction: column-reverse;
+ justify-content: flex-start;
+ gap: 2px;
+ min-height: 120px;
+ padding: 6px;
+ border: 1px solid var(--rule);
+ border-top: 0;
+}
+
+.bc-stack span {
+ padding: 2px 8px;
+ border: 1px solid var(--faint);
+ text-align: right;
+}
+
+.bc-stack span:last-child {
+ border-color: var(--fg);
+}
+
+.bc-err {
+ color: var(--red);
+ white-space: pre;
+}
+
+@media (max-width: 640px) {
+ .bc-cols {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/src/styles/demos/computer.css b/src/styles/demos/computer.css
@@ -0,0 +1,334 @@
+/* The 8-bit computer: Ben Eater's layout, with its controls underneath */
+
+.sim-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px 10px;
+ margin-top: 16px;
+ font-size: 12px;
+}
+
+.sim-note {
+ max-width: 78ch;
+ margin: 12px 0 0;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--dim);
+}
+
+/* board: Ben Eater's layout, two columns of modules either side of the bus */
+
+.board {
+ --led: 10px;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
+ grid-template-areas:
+ "left bus right"
+ "ctrl ctrl ctrl";
+ gap: 10px;
+ font-size: 11.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.col {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.col-l { grid-area: left; }
+.col-r { grid-area: right; }
+
+.mod {
+ padding: 7px 10px 9px;
+ border: 1px solid var(--rule);
+ background: color-mix(in srgb, var(--bg) 92%, var(--fg));
+}
+
+.mod.is-out {
+ border-color: var(--red);
+}
+
+.mod.is-in {
+ border-color: var(--green);
+}
+
+.mod-head {
+ display: flex;
+ align-items: baseline;
+ gap: 1ch;
+ margin-bottom: 6px;
+ color: var(--dim);
+ white-space: nowrap;
+}
+
+.mod-name {
+ margin-right: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.sig {
+ padding: 0 0.4ch;
+ border-radius: 2px;
+ color: var(--faint);
+ font-size: 10.5px;
+}
+
+.sig.on {
+ background: var(--blue);
+ color: var(--bg);
+}
+
+.mod-body {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1ch;
+}
+
+.mod-val {
+ color: var(--fg);
+ white-space: nowrap;
+}
+
+.leds {
+ display: flex;
+ gap: calc(var(--led) * 0.45);
+}
+
+.led {
+ display: block;
+ flex: none;
+ width: var(--led);
+ height: var(--led);
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: var(--led-off);
+ --lc: var(--red);
+}
+
+.led.on {
+ background: var(--lc);
+ box-shadow: 0 0 calc(var(--glow) * 7px) calc(var(--glow) * 1px) color-mix(in srgb, var(--lc) 70%, transparent);
+}
+
+.led.g { --lc: var(--green); }
+.led.y { --lc: var(--yellow); }
+.led.b { --lc: var(--blue); }
+
+.leds .gap {
+ width: calc(var(--led) * 0.4);
+}
+
+/* RAM: the whole 16-byte memory as a matrix you can edit */
+
+.ram-rows {
+ display: grid;
+ gap: 1px;
+}
+
+.ram-row {
+ --led: 8px;
+ display: grid;
+ grid-template-columns: 1.5ch 2ch auto minmax(0, 1fr);
+ align-items: center;
+ gap: 0.8ch;
+ padding: 1px 4px;
+ margin: 0 -4px;
+ line-height: 14px;
+}
+
+.ram-row.is-mar {
+ background: var(--hl);
+}
+
+.ram-row .pc {
+ color: var(--green);
+}
+
+.ram-row .addr {
+ color: var(--dim);
+}
+
+.ram-row .dis {
+ overflow: hidden;
+ color: var(--dim);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.ram-row.is-mar .dis {
+ color: var(--fg);
+}
+
+.ram-row .led {
+ cursor: pointer;
+}
+
+.ram-row .led:hover {
+ outline: 1px solid var(--faint);
+ outline-offset: 1px;
+}
+
+/* the bus: eight lanes down the middle */
+
+.bus {
+ grid-area: bus;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 7px 0 9px;
+ border: 1px solid var(--rule);
+}
+
+.bus .mod-head {
+ margin-bottom: 4px;
+}
+
+.bus-val {
+ margin-bottom: 8px;
+}
+
+.lanes {
+ display: flex;
+ flex: 1;
+ gap: 4px;
+}
+
+.lane {
+ width: 2px;
+ background: var(--led-off);
+}
+
+.lane.on {
+ background: var(--red);
+ box-shadow: 0 0 calc(var(--glow) * 6px) color-mix(in srgb, var(--red) 70%, transparent);
+}
+
+/* output: a 7-segment display */
+
+.seg7 {
+ display: flex;
+ gap: 6px;
+}
+
+.seg7 svg {
+ width: 22px;
+ height: 38px;
+ overflow: visible;
+}
+
+.seg7 path {
+ fill: var(--led-off);
+}
+
+.seg7 path.on {
+ fill: var(--red);
+ filter: drop-shadow(0 0 calc(var(--glow) * 3px) color-mix(in srgb, var(--red) 80%, transparent));
+}
+
+.mod-out .mod-body {
+ cursor: pointer;
+}
+
+/* control word */
+
+.ctrl {
+ grid-area: ctrl;
+}
+
+.ctrl-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ gap: 8px 18px;
+}
+
+.cw {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.cw-bit {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ width: 3.4ch;
+ color: var(--faint);
+ font-size: 10px;
+}
+
+.cw-bit.on {
+ color: var(--fg);
+}
+
+.ustep {
+ margin-top: 8px;
+ color: var(--dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.ustep b {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+@media (max-width: 640px) {
+ .board {
+ --led: 8px;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-areas:
+ "bus bus"
+ "left right"
+ "ctrl ctrl";
+ font-size: 10.5px;
+ }
+
+ .bus {
+ flex-direction: row;
+ gap: 1.5ch;
+ padding: 6px 10px;
+ }
+
+ .bus .mod-head,
+ .bus-val {
+ margin: 0;
+ }
+
+ .lanes {
+ flex-direction: column;
+ justify-content: center;
+ height: 26px;
+ gap: 1px;
+ }
+
+ .lane {
+ width: auto;
+ height: 2px;
+ }
+
+ .mod {
+ padding: 6px 8px 8px;
+ }
+
+ .ram-row {
+ --led: 6px;
+ grid-template-columns: 1.5ch 2ch auto;
+ }
+
+ .ram-row .dis {
+ display: none;
+ }
+
+ .seg7 svg {
+ width: 16px;
+ height: 28px;
+ }
+}
diff --git a/src/styles/demos/demo.css b/src/styles/demos/demo.css
@@ -0,0 +1,64 @@
+/* Project demos: the frame each one mounts into, and its bar of controls */
+
+.demo-wide {
+ margin-top: 40px;
+ padding-top: 28px;
+ border-top: 1px solid var(--rule);
+}
+
+.sim-field {
+ display: inline-flex;
+ align-items: center;
+ gap: 1.2ch;
+ margin-left: 1.5ch;
+ color: var(--dim);
+}
+
+.sim-field output {
+ min-width: 7ch;
+ color: var(--fg);
+ font-variant-numeric: tabular-nums;
+}
+
+.demo {
+ margin-top: 22px;
+ padding-top: 18px;
+ border-top: 1px solid var(--rule);
+ font-size: 12px;
+}
+
+.demo-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+.demo-bar .dim {
+ margin-left: auto;
+}
+
+.demo canvas {
+ display: block;
+ width: 100%;
+}
+
+.demo pre {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.2;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.demo .cap {
+ margin: 10px 0 0;
+ color: var(--dim);
+}
+
+@media (max-width: 640px) {
+ .sim-field {
+ margin-left: 0;
+ }
+}
diff --git a/src/styles/demos/othello.css b/src/styles/demos/othello.css
@@ -0,0 +1,69 @@
+/* othello-mcts: the board, with each move shaded by how much the search tried it */
+
+.oth {
+ display: grid;
+ grid-template-columns: repeat(8, minmax(0, 1fr));
+ gap: 2px;
+ width: min(100%, 336px);
+ aspect-ratio: 1;
+ padding: 2px;
+ background: color-mix(in srgb, var(--fg) 25%, var(--bg));
+}
+
+.oth-cell {
+ position: relative;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: color-mix(in srgb, var(--green) 28%, var(--bg));
+ cursor: default;
+}
+
+.oth-cell.legal {
+ cursor: pointer;
+}
+
+.oth-cell.legal::after {
+ content: "";
+ position: absolute;
+ inset: 40%;
+ border-radius: 50%;
+ background: var(--fg);
+ opacity: 0.4;
+}
+
+.oth-cell.last {
+ box-shadow: inset 0 0 0 2px var(--yellow);
+}
+
+.oth-cell .disc {
+ position: absolute;
+ inset: 12%;
+ border-radius: 50%;
+}
+
+.oth-cell .disc.b {
+ background: #1b1b1b;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 0.35);
+}
+
+.oth-cell .disc.w {
+ background: #f1efe8;
+ box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
+}
+
+.oth-cell .heat {
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: color-mix(in srgb, var(--blue) var(--hp, 0%), transparent);
+ color: var(--fg);
+ font-size: 10px;
+}
+
+.oth-cell .heat:empty {
+ background: none;
+}
diff --git a/src/styles/demos/rbudp.css b/src/styles/demos/rbudp.css
@@ -0,0 +1,26 @@
+/* chat-voip-p2p: Reliable Blast UDP, packets crossing and the TCP log */
+
+.rb-canvas {
+ height: 200px;
+}
+
+.rb-log {
+ max-height: 9.8em;
+ margin: 10px 0 0;
+ padding: 8px 10px;
+ overflow-y: auto;
+ border: 1px solid var(--rule);
+ list-style: none;
+ font-size: 11.5px;
+ line-height: 1.6;
+}
+
+.rb-log li {
+ display: grid;
+ grid-template-columns: 8ch 5ch minmax(0, 1fr);
+ gap: 1ch;
+}
+
+.rb-proto {
+ color: var(--cyan);
+}
diff --git a/src/styles/fonts.css b/src/styles/fonts.css
@@ -0,0 +1,32 @@
+/* Fonts: JetBrains Mono for the Emacs, a serif for prose */
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-regular.woff2") format("woff2");
+ font-weight: 400;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "JetBrains Mono";
+ src: url("/fonts/jetbrains-mono-semibold.woff2") format("woff2");
+ font-weight: 600;
+ font-display: swap;
+}
+
+/* Computer Modern, via KaTeX's build of it: the same family as Latin Modern */
+@font-face {
+ font-family: "Computer Modern";
+ src: url("/fonts/cm-regular.woff2") format("woff2");
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: "Computer Modern";
+ src: url("/fonts/cm-italic.woff2") format("woff2");
+ font-weight: 400;
+ font-style: italic;
+ font-display: swap;
+}
diff --git a/src/styles/frame/buffer.css b/src/styles/frame/buffer.css
@@ -0,0 +1,78 @@
+/* A buffer: a section of the page with its winbar (file name, a rule, and a note) */
+
+.sec {
+ max-width: var(--col);
+ margin: 0 auto;
+ padding: 136px var(--gutter) 0;
+}
+
+.hero,
+.sec > .winbar {
+ scroll-snap-align: start;
+}
+
+.winbar {
+ display: flex;
+ align-items: center;
+ gap: 1.5ch;
+ margin: 0 0 56px;
+ font-size: 12px;
+ line-height: 1;
+ color: var(--dim);
+}
+
+.winbar .file {
+ color: var(--fg);
+}
+
+.winbar .rule {
+ flex: 1;
+ height: 1px;
+ background: var(--rule);
+}
+
+.h2 {
+ max-width: 17em;
+ margin: 0 0 20px;
+ font: 400 clamp(36px, 5.4vw, 62px)/1.04 var(--serif);
+ letter-spacing: -0.01em;
+}
+
+.lede,
+.prose {
+ max-width: 34em;
+ margin: 0 0 48px;
+ font: 400 19px/1.55 var(--serif);
+}
+
+.prose {
+ margin-bottom: 0;
+}
+
+.lede kbd {
+ font: 400 13px/1 var(--mono);
+ vertical-align: 0.15em;
+}
+
+/* a small heading inside a section, like a datasheet's */
+.ds-h {
+ margin: 48px 0 16px;
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+@media (max-width: 640px) {
+ .sec {
+ padding-top: 104px;
+ }
+
+ .winbar {
+ margin-bottom: 40px;
+ }
+
+ .lede,
+ .prose {
+ font-size: 17px;
+ }
+}
diff --git a/src/styles/frame/footer.css b/src/styles/frame/footer.css
@@ -0,0 +1,12 @@
+/* The footer at the end of every page */
+
+.foot {
+ max-width: var(--col);
+ margin: 0 auto;
+ padding: 160px var(--gutter) calc(var(--frame-h) + 48px);
+ font-size: 12px;
+}
+
+.foot p {
+ margin: 0 0 4px;
+}
diff --git a/src/styles/frame/help.css b/src/styles/frame/help.css
@@ -0,0 +1,62 @@
+/* *Help*: the keys and commands, opened with ? or SPC h h */
+
+.help {
+ width: min(780px, calc(100% - 32px));
+ max-height: calc(100% - 64px);
+ padding: 24px 28px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ color: var(--fg);
+ font-size: 12.5px;
+}
+
+.help::backdrop {
+ background: rgb(0 0 0 / 0.5);
+}
+
+.help .winbar {
+ margin-bottom: 28px;
+}
+
+.help-cols {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
+
+.help h3 {
+ margin: 0 0 12px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.help dl {
+ margin: 0;
+}
+
+.help dl div {
+ display: grid;
+ grid-template-columns: 13ch 1fr;
+ gap: 2ch;
+ padding: 3px 0;
+}
+
+.help dt {
+ color: var(--fg);
+}
+
+.help dd {
+ margin: 0;
+ color: var(--dim);
+}
+
+.help form {
+ margin-top: 28px;
+}
+
+@media (max-width: 640px) {
+ .help-cols {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 24px;
+ }
+}
diff --git a/src/styles/frame/modeline.css b/src/styles/frame/modeline.css
@@ -0,0 +1,247 @@
+/* The bottom of the frame: doom-modeline, the echo area, the minibuffer and which-key */
+
+.frame-bottom {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 30;
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+ background: var(--bg);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+.modeline {
+ display: flex;
+ align-items: stretch;
+ height: 24px;
+ line-height: 24px;
+ background: var(--bg-alt);
+ color: var(--fg);
+}
+
+/* doom-modeline's bar */
+.modeline::before {
+ content: "";
+ flex: none;
+ width: 4px;
+ background: var(--blue);
+}
+
+.modeline > span {
+ flex: none;
+ padding: 0 1ch;
+}
+
+.ml-evil {
+ color: var(--green);
+ font-weight: 600;
+}
+
+.modeline[data-evil="I"] .ml-evil { color: var(--yellow); }
+.modeline[data-evil="V"] .ml-evil { color: var(--magenta); }
+.modeline[data-evil="E"] .ml-evil { color: var(--cyan); }
+
+/* a long buffer name gives way first, so the mode and clock stay on screen */
+.modeline > .ml-buf {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-weight: 600;
+}
+
+.ml-pos,
+.ml-pct,
+.ml-enc,
+.ml-time {
+ color: var(--dim);
+}
+
+.modeline > .ml-spacer {
+ flex: 1;
+ padding: 0;
+}
+
+.ml-flags {
+ color: var(--green);
+}
+
+.ml-mode {
+ color: var(--blue);
+ font-weight: 600;
+}
+
+.ml-vc {
+ color: var(--green);
+}
+
+.echo {
+ height: 22px;
+ padding: 0 1ch;
+ overflow: hidden;
+ line-height: 22px;
+ color: var(--dim);
+ text-overflow: ellipsis;
+}
+
+.echo.is-error {
+ color: var(--red);
+}
+
+.echo.is-ok {
+ color: var(--green);
+}
+
+.mini:not([hidden]) ~ .echo {
+ display: none;
+}
+
+.mini-prompt {
+ margin: 0;
+}
+
+.mini-prompt label {
+ display: flex;
+ height: 22px;
+ padding: 0 1ch;
+ line-height: 22px;
+}
+
+.mini-count {
+ min-width: 6ch;
+ color: var(--dim);
+}
+
+.mini-ps {
+ color: var(--blue);
+ white-space: pre;
+}
+
+.mini-prompt input {
+ flex: 1;
+ min-width: 0;
+ padding: 0;
+ border: 0;
+ outline: 0;
+ background: none;
+ color: var(--fg);
+ caret-color: var(--fg);
+ font-size: 12px;
+ line-height: 22px;
+}
+
+.mini-list {
+ max-height: calc(10 * 21px);
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+}
+
+.mini-list:empty {
+ display: none;
+}
+
+.mini-list li {
+ display: flex;
+ gap: 2ch;
+ height: 21px;
+ padding: 0 1ch;
+ line-height: 21px;
+ cursor: pointer;
+}
+
+.mini-list li[aria-selected="true"] {
+ background: var(--hl);
+}
+
+.mini-list .cand {
+ flex: none;
+ min-width: 24ch;
+ color: var(--fg);
+}
+
+.mini-list .cand mark {
+ background: none;
+ color: var(--magenta);
+ font-weight: 600;
+}
+
+.mini-list .ann {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--dim);
+ text-overflow: ellipsis;
+}
+
+.mini-list .key {
+ flex: none;
+ color: var(--blue);
+}
+
+.which-key {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: var(--frame-h);
+ z-index: 29;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(26ch, 1fr));
+ gap: 2px 2ch;
+ padding: 10px 2ch 12px;
+ border-top: 1px solid var(--rule);
+ background: var(--bg-alt);
+ font-size: 12px;
+}
+
+.which-key[hidden] {
+ display: none;
+}
+
+.wk-title {
+ grid-column: 1 / -1;
+ margin-bottom: 4px;
+ color: var(--dim);
+}
+
+.wk-item {
+ display: flex;
+ gap: 1ch;
+ white-space: nowrap;
+}
+
+.wk-key {
+ min-width: 3ch;
+ color: var(--magenta);
+ font-weight: 600;
+}
+
+.wk-arrow {
+ color: var(--faint);
+}
+
+.wk-desc {
+ color: var(--fg);
+}
+
+.wk-desc.group {
+ color: var(--blue);
+}
+
+@media (max-width: 920px) {
+ .ml-enc,
+ .ml-vc,
+ .ml-time,
+ .ml-pos {
+ display: none;
+ }
+}
+
+@media (max-width: 640px) {
+ .ml-pct {
+ display: none;
+ }
+}
diff --git a/src/styles/frame/topbar.css b/src/styles/frame/topbar.css
@@ -0,0 +1,103 @@
+/* The top line: name, section links, load-theme and the menu button on phones */
+
+.top {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 3ch;
+ height: var(--top-h);
+ padding: 0 var(--gutter);
+ font-size: 12px;
+ border-bottom: 1px solid transparent;
+ transition: background-color 200ms ease, border-color 200ms ease;
+}
+
+.is-scrolled .top {
+ background: color-mix(in srgb, var(--bg) 90%, transparent);
+ border-bottom-color: var(--rule);
+ -webkit-backdrop-filter: blur(10px);
+ backdrop-filter: blur(10px);
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 1.5ch;
+ margin-right: auto;
+ text-decoration: none;
+}
+
+.logo {
+ width: 22px;
+ height: 11px;
+ overflow: visible;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.6;
+}
+
+.nav {
+ display: flex;
+ gap: 2.5ch;
+}
+
+.nav a {
+ color: var(--dim);
+ text-decoration: none;
+}
+
+.nav a:hover,
+.nav a[aria-current="true"] {
+ color: var(--fg);
+}
+
+.nav a[aria-current="true"] {
+ text-decoration: underline;
+ text-decoration-color: var(--fg);
+ text-underline-offset: 6px;
+}
+
+.theme-key,
+.menu-key {
+ padding: 8px 0;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ font-size: 12px;
+}
+
+.theme-key:hover,
+.menu-key:hover {
+ color: var(--fg);
+}
+
+.menu-key {
+ display: none;
+}
+
+@media (max-width: 920px) {
+ .nav {
+ display: none;
+ }
+
+ .nav.is-open {
+ position: absolute;
+ top: calc(var(--top-h) - 4px);
+ right: var(--gutter);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ min-width: 18ch;
+ padding: 14px 18px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ }
+
+ .menu-key {
+ display: block;
+ }
+}
diff --git a/src/styles/home/about.css b/src/styles/home/about.css
@@ -0,0 +1,134 @@
+/* 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;
+}
+
+/* experience as git log --graph --color */
+
+.gitlog-cmd {
+ margin-bottom: 12px;
+ font-weight: 400;
+ color: var(--fg);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.gitlog-cmd .prompt {
+ color: var(--green);
+}
+
+.gitlog {
+ padding: 16px 18px;
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+ font-size: 12px;
+ line-height: 1.7;
+ color: var(--fg);
+}
+
+.gl-line {
+ width: max-content;
+ white-space: pre;
+}
+
+/* one colour per branch */
+.gitlog .lm { color: var(--magenta); } /* main */
+.gitlog .lu { color: var(--green); } /* undergrad */
+.gitlog .lt { color: var(--yellow); } /* teaching-assistant */
+.gitlog .lb { color: var(--red); } /* bbd-software */
+.gitlog .li { color: var(--blue); } /* mwr-internship */
+
+.gl-tip {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+.gl-refs {
+ color: var(--yellow);
+}
+
+.gl-head {
+ color: var(--cyan);
+ font-weight: 600;
+}
+
+.gl-branch {
+ color: var(--green);
+ font-weight: 600;
+}
+
+.gl-author {
+ color: var(--blue);
+}
+
+.gl-subj {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ white-space: pre;
+ text-decoration: underline dotted;
+ text-decoration-color: var(--faint);
+ text-underline-offset: 0.25em;
+}
+
+.gl-subj:hover,
+.gl-subj[aria-expanded="true"] {
+ text-decoration-style: solid;
+ text-decoration-color: currentColor;
+}
+
+.gl-body,
+.gl-desc {
+ color: var(--dim);
+}
+
+/* before the script runs (or without it), descriptions show under their commit */
+.gl-desc {
+ display: block;
+ padding-left: 4ch;
+}
+
+.gitlog-hint {
+ margin: 8px 0 0;
+ font-size: 12px;
+}
+
+@media (max-width: 640px) {
+ .ds-head {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .gitlog {
+ padding: 12px;
+ font-size: 11.5px;
+ }
+
+ .gitlog-cmd {
+ font-size: 11.5px;
+ }
+}
diff --git a/src/styles/home/coffee.css b/src/styles/home/coffee.css
@@ -0,0 +1,106 @@
+/* M-x coffee-refill: a mug filling up while VCC climbs back to 5 V */
+
+.coffee {
+ position: fixed;
+ right: 2ch;
+ bottom: calc(var(--frame-h) + 12px);
+ z-index: 28;
+ display: flex;
+ gap: 3ch;
+ width: min(620px, calc(100% - 4ch));
+ padding: 16px 20px 18px;
+ border: 1px solid var(--faint);
+ background: var(--bg);
+ box-shadow: 0 12px 40px rgb(0 0 0 / 0.35);
+ font-size: 12px;
+}
+
+.coffee[hidden] {
+ display: none;
+}
+
+.mug {
+ flex: none;
+ margin: 0;
+ font-size: 12px;
+ line-height: 1.25;
+ color: var(--fg);
+}
+
+.mug .steam {
+ color: var(--dim);
+}
+
+.mug .brew {
+ color: var(--orange);
+}
+
+.mug .crema {
+ color: var(--yellow);
+}
+
+.coffee-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.coffee-info .winbar {
+ margin: 0 28px 14px 0;
+}
+
+.coffee-info p {
+ margin: 0 0 10px;
+}
+
+.vcc {
+ display: flex;
+ gap: 1.5ch;
+ font-variant-numeric: tabular-nums;
+}
+
+.vbar {
+ color: var(--green);
+ letter-spacing: -0.05em;
+}
+
+.coffee.is-over .vbar,
+.coffee.is-over .volts {
+ color: var(--red);
+}
+
+.coffee-msg {
+ min-height: 3.2em;
+}
+
+.coffee-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2ch;
+}
+
+.coffee-close {
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ padding: 2px 6px;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ font-size: 16px;
+ line-height: 1;
+}
+
+.coffee-close:hover {
+ color: var(--fg);
+}
+
+@media (max-width: 640px) {
+ .coffee {
+ right: 0;
+ left: 0;
+ width: auto;
+ margin: 0 8px;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
diff --git a/src/styles/home/contact.css b/src/styles/home/contact.css
@@ -0,0 +1,110 @@
+/* Contact, as an *unsent mail* buffer: the address, the GPG key and the CTF */
+
+.contact {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 16px 32px;
+ margin-bottom: 8px;
+}
+
+.mail {
+ font: 400 clamp(26px, 4.4vw, 52px)/1.1 var(--serif);
+ overflow-wrap: anywhere;
+}
+
+.mail .at {
+ font: 400 0.42em/1 var(--mono);
+ color: var(--dim);
+ vertical-align: 0.35em;
+ white-space: pre;
+}
+
+.linkish {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: var(--faint);
+ text-underline-offset: 0.2em;
+}
+
+.linkish:hover {
+ text-decoration-color: currentColor;
+}
+
+.contact-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
+ gap: 0 56px;
+}
+
+.gpg-fp {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2ch;
+ margin: 0 0 12px;
+}
+
+.gpg-fp code {
+ letter-spacing: 0.02em;
+}
+
+.gpg-cmd {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+}
+
+.gpg-cmd pre {
+ flex: 1;
+ min-width: 0;
+ margin: 0;
+ padding: 7px 12px;
+ overflow-x: auto;
+ border: 1px solid var(--rule);
+ background: var(--wash);
+ font-size: 12px;
+}
+
+.elsewhere {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 3ch;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ctf {
+ max-width: 70ch;
+ margin-top: 16px;
+}
+
+.ctf p {
+ margin: 0 0 12px;
+}
+
+.ctf-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px 2ch;
+}
+
+.ctf-progress {
+ color: var(--dim);
+}
+
+.ctf-progress.done {
+ color: var(--green);
+}
+
+@media (max-width: 920px) {
+ .contact-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
diff --git a/src/styles/home/hero.css b/src/styles/home/hero.css
@@ -0,0 +1,99 @@
+/* The landing section: the name (which decodes itself on load), a lede and the colour key */
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
+ gap: 32px clamp(32px, 4vw, 64px);
+ align-items: center;
+ min-height: 100vh;
+ min-height: 100svh;
+ padding: calc(var(--top-h) + 28px) var(--gutter) calc(var(--frame-h) + 32px);
+ max-width: 1440px;
+ margin: 0 auto;
+}
+
+.hero-name {
+ margin: 0 0 20px -0.04em;
+ font: 400 clamp(46px, 6.4vw, 96px)/0.94 var(--serif);
+ letter-spacing: -0.015em;
+}
+
+/* On load the name decodes itself (src/scripts/home/decode.js): each letter starts as a byte
+ with a few bits flipped and changes one bit at a time until it is right. */
+
+.boot .hero-name {
+ visibility: hidden;
+ animation: boot-failsafe 0s 2.5s forwards;
+}
+
+@keyframes boot-failsafe {
+ to { visibility: visible; }
+}
+
+/* each letter sits in a box as wide as the final letter, and whatever
+ character it shows is centred in it */
+.hero-name.is-cells .bc {
+ display: inline-block;
+ text-align: center;
+}
+
+.hero-name.is-cells .bc-ch {
+ display: inline-block;
+ margin-inline: -1em;
+ white-space: pre;
+}
+
+.bc-word {
+ white-space: nowrap;
+}
+
+/* a wrong byte shows as a raw character in the mono face */
+.bc.is-wrong {
+ color: var(--dim);
+}
+
+.bc.is-wrong .bc-ch {
+ font-family: var(--mono);
+ font-size: 0.78em;
+ letter-spacing: 0;
+}
+
+.hero-lede {
+ max-width: 44ch;
+ margin: 0 0 20px;
+ font-size: 14px;
+ line-height: 1.6;
+}
+
+.legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 2.5ch;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.legend li::before,
+.features li::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 1ch;
+ background: var(--c, var(--fg));
+ vertical-align: 0.06em;
+}
+
+@media (max-width: 1100px) {
+ .hero {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+ }
+
+ .hero-id {
+ max-width: 640px;
+ }
+}
diff --git a/src/styles/home/pinout.css b/src/styles/home/pinout.css
@@ -0,0 +1,179 @@
+/* The MN-2026 chip on the landing section: a DIP-16 pinout built from src/data/pins.js */
+
+.hero-chip {
+ justify-self: center;
+ width: min(100%, 460px);
+}
+
+.hero-chip .chip {
+ grid-template-columns: minmax(0, 1fr) 128px minmax(0, 1fr);
+ font-size: 13px;
+}
+
+.hero-chip .pin {
+ height: 34px;
+}
+
+.hero-chip .pin .leg {
+ height: 9px;
+}
+
+.hero-chip .pin-readout {
+ min-height: 6.5em;
+}
+
+.pinout {
+ margin: 0;
+}
+
+.chip {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 104px minmax(0, 1fr);
+ font-size: 12px;
+}
+
+.chip-side {
+ display: flex;
+ flex-direction: column;
+ padding: 10px 0;
+}
+
+.chip-body {
+ position: relative;
+ border: 1px solid var(--fg);
+ border-radius: 2px;
+}
+
+.chip-body::before {
+ content: "";
+ position: absolute;
+ top: -1px;
+ left: 50%;
+ width: 20px;
+ height: 10px;
+ border: 1px solid var(--fg);
+ border-top: 0;
+ border-radius: 0 0 10px 10px;
+ background: var(--bg);
+ transform: translateX(-50%);
+}
+
+.chip-body span {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--dim);
+ writing-mode: vertical-rl;
+ letter-spacing: 0.2em;
+}
+
+.pin {
+ display: grid;
+ align-items: center;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--dim);
+ text-align: left;
+}
+
+.chip-side.left .pin {
+ grid-template-columns: 1fr 3ch 16px;
+ text-align: right;
+}
+
+.chip-side.right .pin {
+ grid-template-columns: 16px 3ch 1fr;
+}
+
+.pin .num {
+ text-align: center;
+ font-size: 11px;
+}
+
+.pin .leg {
+ height: 8px;
+ border: 1px solid var(--faint);
+}
+
+.chip-side.left .leg {
+ border-right: 0;
+}
+
+.chip-side.right .leg {
+ border-left: 0;
+}
+
+.pin .name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pin:hover,
+.pin.is-active {
+ color: var(--fg);
+}
+
+.pin.is-active .name {
+ color: var(--c, var(--fg));
+ font-weight: 600;
+}
+
+.pin.is-active .leg {
+ border-color: var(--c, var(--fg));
+ background: var(--c, var(--fg));
+}
+
+.pin-readout {
+ min-height: 7.5em;
+ margin-top: 20px;
+ padding-top: 12px;
+ border-top: 1px solid var(--rule);
+ font-size: 12px;
+}
+
+.pin-readout p {
+ margin: 0;
+}
+
+.pin-readout .pr-head {
+ display: flex;
+ gap: 2ch;
+ margin-bottom: 6px;
+}
+
+.pin-readout .pr-name {
+ color: var(--c, var(--fg));
+ font-weight: 600;
+}
+
+.pinout figcaption {
+ margin-top: 8px;
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.pin-list {
+ margin: 16px 0 0;
+ padding-left: 3ch;
+ font-size: 12px;
+}
+
+.pinout.is-built .pin-list {
+ display: none;
+}
+
+.pin-list li::before {
+ content: attr(data-name) " (" attr(data-type) ") ";
+ font-weight: 600;
+}
+
+@media (max-width: 920px) {
+ .pinout {
+ max-width: 420px;
+ }
+}
diff --git a/src/styles/home/posts.css b/src/styles/home/posts.css
@@ -0,0 +1,104 @@
+/* The posts list on the home page, as an Org buffer */
+
+.org {
+ max-width: 880px;
+ font-size: 13px;
+ line-height: 1.75;
+}
+
+.org-title {
+ color: var(--fg);
+ font-weight: 600;
+}
+
+.org-p {
+ max-width: 76ch;
+ margin: 20px 0 0;
+ font-size: 13px;
+ line-height: 1.75;
+}
+
+.org > .org-tree {
+ margin-top: 20px;
+}
+
+.org-tree {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.org-h {
+ display: flex;
+ align-items: baseline;
+ gap: 1ch;
+}
+
+.org-h:focus-visible {
+ outline: none;
+ background: var(--hl);
+}
+
+.org-h1 > .org-h {
+ color: var(--blue);
+ font-weight: 600;
+}
+
+.org-h2 > .org-h {
+ color: var(--magenta);
+}
+
+.org-h2 a {
+ min-width: 0;
+ color: inherit;
+ text-decoration: none;
+}
+
+.org-h2 a:hover {
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+}
+
+.org-h .count {
+ color: var(--dim);
+ font-weight: 400;
+}
+
+.org-tags {
+ flex: none;
+ margin-left: auto;
+ padding-left: 2ch;
+ color: var(--dim);
+ white-space: nowrap;
+}
+
+.org-tags .t {
+ color: var(--tag, var(--dim));
+}
+
+.org-drawer {
+ margin: 0 0 6px 3ch;
+ overflow-x: auto;
+ color: var(--dim);
+ font-size: 12px;
+ line-height: 1.6;
+ white-space: pre;
+}
+
+.org-drawer .k {
+ color: var(--faint);
+}
+
+.org-drawer .ts {
+ color: var(--cyan);
+}
+
+.org-drawer a {
+ color: var(--fg);
+}
+
+@media (max-width: 640px) {
+ .org-tags {
+ display: none;
+ }
+}
diff --git a/src/styles/home/projects.css b/src/styles/home/projects.css
@@ -0,0 +1,205 @@
+/* Projects, as a linker memory map: a bar of regions, the list, and a hexdump of the one you pick */
+
+.mm {
+ --mm-cols: 8ch 8ch minmax(0, 1fr) 8ch;
+}
+
+.mm-bar {
+ display: flex;
+ height: 36px;
+ margin-bottom: 32px;
+ border: 1px solid var(--rule);
+}
+
+.mm-seg {
+ min-width: 3px;
+ padding: 0 6px;
+ overflow: hidden;
+ border-right: 1px solid var(--bg);
+ background: color-mix(in srgb, var(--c) 20%, transparent);
+ box-shadow: inset 0 2px 0 var(--c);
+ font-size: 11px;
+ line-height: 34px;
+ white-space: nowrap;
+ cursor: pointer;
+ transition: background-color 120ms ease;
+}
+
+.mm-seg:last-child {
+ border-right: 0;
+}
+
+.mm-seg:hover {
+ background: color-mix(in srgb, var(--c) 34%, transparent);
+}
+
+.mm-seg.is-active {
+ background: var(--c);
+ color: var(--bg);
+}
+
+.mm-cols {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
+ gap: 48px;
+ align-items: start;
+}
+
+.mm-head {
+ display: grid;
+ grid-template-columns: var(--mm-cols);
+ gap: 1.5ch;
+ margin: 0;
+ padding: 0 0 8px 12px;
+ border-bottom: 1px solid var(--rule);
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.mm-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.proj-name {
+ margin: 0;
+ font: inherit;
+}
+
+.proj-row {
+ display: grid;
+ grid-template-columns: var(--mm-cols);
+ gap: 1.5ch;
+ align-items: baseline;
+ width: 100%;
+ padding: 11px 0 11px 12px;
+ border: 0;
+ border-bottom: 1px solid var(--rule);
+ background: none;
+ text-align: left;
+ font-size: 13px;
+ font-variant-numeric: tabular-nums;
+}
+
+.proj-row:hover {
+ background: var(--wash);
+}
+
+.proj-row .o,
+.proj-row .l,
+.proj-row .y {
+ font-size: 12px;
+ color: var(--dim);
+}
+
+.proj-row .n {
+ font-weight: 600;
+}
+
+.proj-row .n::before {
+ content: "";
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ margin-right: 1.2ch;
+ background: var(--c);
+ vertical-align: 0.06em;
+}
+
+.proj-row .s {
+ display: block;
+ margin-top: 2px;
+ font-weight: 400;
+ color: var(--dim);
+}
+
+.proj-row[aria-pressed="true"] {
+ background: var(--wash);
+ box-shadow: inset 2px 0 0 var(--c);
+}
+
+.proj-row[aria-pressed="true"] .o,
+.proj-row[aria-pressed="true"] .l {
+ color: var(--c);
+}
+
+.mm.is-built .proj-sum,
+.mm.is-built .proj > .proj-body {
+ display: none;
+}
+
+.mm-detail {
+ min-height: 320px;
+ padding-left: 32px;
+ border-left: 1px solid var(--rule);
+}
+
+.mm-detail:empty {
+ display: none;
+}
+
+.hex {
+ margin: 0 0 22px;
+ overflow-x: auto;
+ font-size: 11.5px;
+ line-height: 1.55;
+ color: var(--dim);
+ white-space: pre;
+}
+
+.hex .a {
+ color: var(--fg);
+}
+
+.hex .addr {
+ color: var(--c);
+}
+
+.mm-detail h3 {
+ margin: 0 0 14px;
+ font: 400 32px/1.1 var(--serif);
+}
+
+.mm-detail .proj-body p {
+ margin: 0 0 14px;
+ font: 400 17px/1.55 var(--serif);
+}
+
+.mm-detail .proj-body .proj-stack,
+.mm-detail .proj-body .proj-links {
+ font: 400 12px/1.6 var(--mono);
+}
+
+.proj-stack {
+ color: var(--dim);
+}
+
+.proj-links a {
+ margin-right: 2ch;
+}
+
+@media (max-width: 920px) {
+ .mm-cols {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .mm-detail {
+ min-height: 0;
+ margin: 4px 0 8px;
+ padding: 20px 0 20px 12px;
+ border-left: 0;
+ border-bottom: 1px solid var(--rule);
+ }
+}
+
+@media (max-width: 640px) {
+ .mm {
+ --mm-cols: 7ch 6ch minmax(0, 1fr);
+ }
+
+ .mm-head span:last-child,
+ .proj-row .y {
+ display: none;
+ }
+}
diff --git a/src/styles/org.css b/src/styles/org.css
@@ -0,0 +1,26 @@
+/* Org basics shared by the posts list on the home page and the posts: #+keyword
+ lines, heading stars (buttons that fold) and the ellipsis on a folded heading. */
+
+.org-kw {
+ margin: 0;
+ font: inherit;
+ color: var(--dim);
+}
+
+.org-kw .v {
+ color: var(--fg);
+}
+
+.org-stars {
+ flex: none;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+}
+
+.org-ellipsis {
+ color: var(--dim);
+ font-weight: 400;
+}
diff --git a/src/styles/post/comments.css b/src/styles/post/comments.css
@@ -0,0 +1,10 @@
+/* The comments under a post (giscus, styled by src/pages/giscus/[theme].css.js) */
+
+.giscus {
+ max-width: 760px;
+}
+
+.giscus-note {
+ margin: 16px 0 0;
+ font-size: 12px;
+}
diff --git a/src/styles/post/nav.css b/src/styles/post/nav.css
@@ -0,0 +1,38 @@
+/* Older and newer posts, under a post */
+
+.post-nav {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ max-width: 680px;
+ margin: 96px 0 0;
+ padding-top: 20px;
+ border-top: 1px solid var(--rule);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.post-nav a {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ text-decoration: none;
+}
+
+.post-nav a:hover {
+ text-decoration: underline;
+}
+
+.post-nav .dim {
+ font-size: 12px;
+}
+
+.post-nav .newer {
+ grid-column: 2;
+ text-align: right;
+}
+
+.post-all {
+ margin: 20px 0 0;
+ font-size: 12px;
+}
diff --git a/src/styles/post/org.css b/src/styles/post/org.css
@@ -0,0 +1,375 @@
+/* A post is an Org buffer the way Doom shows one with mixed-pitch-mode: #+
+ keywords and markup in mono, the text in the serif, starred headings in
+ the org-level colours (the same as the posts list on the home page), and
+ code between #+begin_src and #+end_src. Code is coloured from the current
+ theme through the variables Shiki's css-variables theme reads. */
+
+:root {
+ --astro-code-foreground: var(--fg);
+ --astro-code-background: var(--hl);
+ --astro-code-token-comment: var(--dim);
+ --astro-code-token-keyword: var(--magenta);
+ --astro-code-token-string: var(--green);
+ --astro-code-token-string-expression: var(--green);
+ --astro-code-token-constant: var(--orange);
+ --astro-code-token-function: var(--blue);
+ --astro-code-token-parameter: var(--fg);
+ --astro-code-token-punctuation: var(--dim);
+ --astro-code-token-link: var(--cyan);
+}
+
+.sec.post {
+ padding-top: 112px;
+}
+
+@media (max-width: 640px) {
+ .sec.post {
+ padding-top: 104px;
+ }
+}
+
+/* post file names are long; on a phone they're cut short rather than wrapped */
+.post > .winbar .file {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.post > .winbar .meta {
+ white-space: nowrap;
+}
+
+.org-head {
+ margin: 0 0 56px;
+ font-size: 13px;
+ line-height: 1.75;
+}
+
+.org-head .org-key {
+ color: var(--faint);
+}
+
+.org-ts {
+ color: var(--cyan);
+}
+
+.org-filetags .t {
+ color: var(--tag, var(--dim));
+}
+
+.post-title {
+ max-width: 18em;
+ margin: 0 0 20px;
+ font: 400 clamp(34px, 4.8vw, 54px)/1.08 var(--serif);
+ letter-spacing: -0.01em;
+ text-wrap: balance;
+}
+
+.post-title .org-key {
+ margin-right: 0.3ch;
+ font: 400 15px/1 var(--mono);
+ letter-spacing: 0;
+ vertical-align: 0.5em;
+}
+
+.post-body {
+ max-width: 680px;
+ font: 400 19px/1.6 var(--serif);
+ /* long URLs break rather than push a phone's page sideways */
+ overflow-wrap: anywhere;
+}
+
+.post-body > :first-child {
+ margin-top: 0;
+}
+
+.post-body p,
+.post-body ul,
+.post-body ol,
+.post-body dl {
+ margin: 0 0 1em;
+}
+
+.post-body ul,
+.post-body ol {
+ padding-left: 1.3em;
+}
+
+.post-body ul {
+ list-style-type: "- ";
+}
+
+.post-body li {
+ margin: 0.25em 0;
+}
+
+.post-body li::marker {
+ color: var(--faint);
+ font: 400 15px var(--mono);
+}
+
+.post-body li > p {
+ margin: 0;
+}
+
+/* headings, coloured like org-level-1, -2 and -3 */
+.post-body h2,
+.post-body h3,
+.post-body h4 {
+ margin: 1.7em 0 0.5em;
+ font-family: var(--serif);
+ font-weight: 400;
+ line-height: 1.2;
+}
+
+.post-body h2 {
+ color: var(--blue);
+ font-size: 30px;
+}
+
+.post-body h3 {
+ color: var(--magenta);
+ font-size: 24px;
+}
+
+.post-body h4 {
+ color: var(--violet);
+ font-size: 20px;
+}
+
+/* The stars, in mono at the heading's own size, the way Org draws them.
+ src/scripts/post/folding.js swaps them for buttons that fold the heading. */
+.post-body :is(h2, h3, h4)::before,
+.post-body .org-stars {
+ margin-right: 0.35ch;
+ font-family: var(--mono);
+ font-size: 1em;
+ font-weight: 400;
+ line-height: inherit;
+ vertical-align: baseline;
+}
+
+.post-body h2::before { content: "*"; }
+.post-body h3::before { content: "**"; }
+.post-body h4::before { content: "***"; }
+
+.post-body .has-stars::before {
+ content: none;
+}
+
+.post-body .org-stars {
+ cursor: pointer;
+}
+
+.post-body .org-ellipsis {
+ margin-left: 0.3ch;
+}
+
+.post-body .is-folded-away {
+ display: none !important;
+}
+
+/* emphasis keeps its Org markers: ~code~ /italic/ *bold* +strike+ */
+.post-body :not(pre) > code {
+ color: var(--orange);
+ font-size: 0.74em;
+}
+
+.post-body :not(pre) > code::before,
+.post-body :not(pre) > code::after {
+ content: "~";
+}
+
+.post-body em::before,
+.post-body em::after {
+ content: "/";
+}
+
+.post-body strong::before,
+.post-body strong::after {
+ content: "*";
+}
+
+.post-body :is(s, del)::before,
+.post-body :is(s, del)::after {
+ content: "+";
+}
+
+.post-body :not(pre) > code::before,
+.post-body :not(pre) > code::after,
+.post-body :is(em, strong, s, del)::before,
+.post-body :is(em, strong, s, del)::after {
+ color: var(--faint);
+ font-family: var(--mono);
+ font-style: normal;
+ font-weight: 400;
+ text-decoration: none;
+}
+
+.post-body kbd {
+ font: 400 0.72em/1 var(--mono);
+ vertical-align: 0.1em;
+}
+
+/* #+begin_src lang ... #+end_src */
+.post-body pre {
+ margin: 1.4em 0;
+ padding: 0 16px;
+ overflow-x: auto;
+ background: var(--hl);
+ font: 400 13px/1.6 var(--mono);
+ tab-size: 4;
+}
+
+.post-body pre::before,
+.post-body pre::after {
+ display: block;
+ color: var(--faint);
+}
+
+.post-body pre::before {
+ content: "#+begin_src " attr(data-language);
+ padding-top: 8px;
+}
+
+.post-body pre[data-language="py"]::before {
+ content: "#+begin_src python";
+}
+
+.post-body pre::after {
+ content: "#+end_src";
+ padding-bottom: 8px;
+}
+
+.post-body pre code {
+ font-size: inherit;
+}
+
+.post-body blockquote {
+ margin: 1.4em 0;
+ color: var(--dim);
+}
+
+.post-body blockquote::before,
+.post-body blockquote::after {
+ display: block;
+ margin-left: -40px;
+ color: var(--faint);
+ font: 400 13px/1.6 var(--mono);
+}
+
+.post-body blockquote::before {
+ content: "#+begin_quote";
+}
+
+.post-body blockquote::after {
+ content: "#+end_quote";
+}
+
+.post-body img,
+.post-body video {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 1.4em 0;
+}
+
+.post-body hr {
+ margin: 2em 0;
+ border: 0;
+}
+
+.post-body hr::before {
+ content: "-----";
+ color: var(--faint);
+ font: 400 13px/1 var(--mono);
+}
+
+/* org tables: | a | b | */
+.post-body table {
+ display: block;
+ margin: 1.4em 0;
+ overflow-x: auto;
+ border-collapse: collapse;
+ font: 400 13px/1.5 var(--mono);
+}
+
+.post-body th,
+.post-body td {
+ padding: 4px 1ch;
+ border: 1px solid var(--rule);
+ text-align: left;
+ vertical-align: top;
+}
+
+.post-body th {
+ color: var(--blue);
+ font-weight: 400;
+}
+
+/* KaTeX's font is Computer Modern too, so it can match the text size. */
+.post-body .katex {
+ font-size: 1em;
+}
+
+.post-body .katex-display {
+ margin: 1.2em 0;
+ padding: 0.2em 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+/* [fn:1] references, and the notes under a * Footnotes heading */
+.post-body sup:has(> a[data-footnote-ref]) {
+ font: 400 13px/1 var(--mono);
+ vertical-align: baseline;
+}
+
+.post-body a[data-footnote-ref] {
+ color: var(--cyan);
+ text-decoration: none;
+}
+
+.post-body a[data-footnote-ref]::before {
+ content: "[fn:";
+}
+
+.post-body a[data-footnote-ref]::after {
+ content: "]";
+}
+
+.post-body .footnotes {
+ margin-top: 1.7em;
+ font-size: 17px;
+}
+
+.post-body .footnotes h2.sr-only {
+ position: static;
+ width: auto;
+ height: auto;
+ overflow: visible;
+ clip-path: none;
+ white-space: normal;
+}
+
+.post-body .footnotes ol {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ counter-reset: fn;
+}
+
+.post-body .footnotes li {
+ counter-increment: fn;
+}
+
+.post-body .footnotes li::before {
+ content: "[fn:" counter(fn) "] ";
+ color: var(--cyan);
+ font: 400 13px var(--mono);
+}
+
+.post-body .footnotes li > p {
+ display: inline;
+}
diff --git a/src/styles/post/outline.css b/src/styles/post/outline.css
@@ -0,0 +1,66 @@
+/* imenu-list: the post's headings in a *Ilist* window beside it */
+
+.post-toc {
+ margin: 0 0 48px;
+ font-size: 12px;
+ line-height: 1.5;
+}
+
+.post-toc .winbar {
+ margin-bottom: 14px;
+}
+
+.post-toc ol {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.post-toc li.d3 {
+ padding-left: 2ch;
+}
+
+.post-toc a {
+ display: block;
+ padding: 3px 0 3px 1ch;
+ border-left: 2px solid transparent;
+ color: var(--dim);
+ text-decoration: none;
+}
+
+.post-toc a:hover {
+ color: var(--fg);
+}
+
+.post-toc a[aria-current="true"] {
+ border-left-color: var(--blue);
+ color: var(--fg);
+}
+
+.post-toc .mk {
+ color: var(--faint);
+}
+
+@media (min-width: 1100px) {
+ .post-cols.has-toc {
+ display: grid;
+ grid-template-columns: minmax(0, 680px) minmax(180px, 1fr);
+ column-gap: 64px;
+ align-items: start;
+ }
+
+ .post-cols.has-toc .post-body {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ .post-cols.has-toc .post-toc {
+ position: sticky;
+ top: calc(var(--top-h) + 32px);
+ grid-row: 1;
+ grid-column: 2;
+ max-height: calc(100vh - var(--top-h) - var(--frame-h) - 64px);
+ margin: 0;
+ overflow-y: auto;
+ }
+}
diff --git a/src/styles/style.css b/src/styles/style.css
@@ -1,2927 +0,0 @@
-/* ───────────────────────────── fonts ───────────────────────────── */
-
-@font-face {
- font-family: "JetBrains Mono";
- src: url("/fonts/jetbrains-mono-regular.woff2") format("woff2");
- font-weight: 400;
- font-display: swap;
-}
-
-@font-face {
- font-family: "JetBrains Mono";
- src: url("/fonts/jetbrains-mono-semibold.woff2") format("woff2");
- font-weight: 600;
- font-display: swap;
-}
-
-/* Computer Modern, via KaTeX's build of it: the same family as Latin Modern */
-@font-face {
- font-family: "Computer Modern";
- src: url("/fonts/cm-regular.woff2") format("woff2");
- font-weight: 400;
- font-style: normal;
- font-display: swap;
-}
-
-@font-face {
- font-family: "Computer Modern";
- src: url("/fonts/cm-italic.woff2") format("woff2");
- font-weight: 400;
- font-style: italic;
- font-display: swap;
-}
-
-/* ───────────────────────────── themes ─────────────────────────────
- Real Emacs themes. Each sets a background, a modeline background,
- a foreground and seven accents; everything else is mixed from those.
- Keep the names in sync with src/scripts/themes.js and the script in src/pages/index.astro. */
-
-:root {
- color-scheme: dark;
- --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
- --serif: "Computer Modern", "Latin Modern Roman", "CMU Serif", Georgia, serif;
-
- --gutter: clamp(16px, 4.5vw, 56px);
- --top-h: 44px;
- --frame-h: 46px;
- --col: 1088px;
-
- --bg: #282828;
- --bg-alt: #3c3836;
- --fg: #ebdbb2;
- --red: #fb4934;
- --orange: #fe8019;
- --yellow: #fabd2f;
- --green: #b8bb26;
- --cyan: #8ec07c;
- --blue: #83a598;
- --magenta: #d3869b;
- --glow: 1;
-
- --dim: color-mix(in srgb, var(--fg) 64%, var(--bg));
- --faint: color-mix(in srgb, var(--fg) 34%, var(--bg));
- --rule: color-mix(in srgb, var(--fg) 15%, var(--bg));
- --hl: color-mix(in srgb, var(--fg) 10%, var(--bg));
- --wash: color-mix(in srgb, var(--fg) 5%, transparent);
- --led-off: color-mix(in srgb, var(--fg) 13%, var(--bg));
- --violet: color-mix(in oklch, var(--blue) 50%, var(--magenta));
-
- --c-hw: var(--yellow);
- --c-sw: var(--blue);
- --c-math: var(--magenta);
- --c-sec: var(--red);
-}
-
-:root[data-theme="doom-gruvbox-light"] {
- color-scheme: light;
- --bg: #fbf1c7; --bg-alt: #ebdbb2; --fg: #3c3836;
- --red: #9d0006; --orange: #af3a03; --yellow: #b57614; --green: #79740e;
- --cyan: #427b58; --blue: #076678; --magenta: #8f3f71;
- --glow: 0;
-}
-
-:root[data-theme="doom-one"] {
- --bg: #282c34; --bg-alt: #21242b; --fg: #bbc2cf;
- --red: #ff6c6b; --orange: #da8548; --yellow: #ecbe7b; --green: #98be65;
- --cyan: #46d9ff; --blue: #51afef; --magenta: #c678dd;
-}
-
-:root[data-theme="atom-one-dark"] {
- --bg: #282c34; --bg-alt: #21252b; --fg: #abb2bf;
- --red: #e06c75; --orange: #d19a66; --yellow: #e5c07b; --green: #98c379;
- --cyan: #56b6c2; --blue: #61afef; --magenta: #c678dd;
-}
-
-:root[data-theme="modus-vivendi"] {
- --bg: #000000; --bg-alt: #1e1e1e; --fg: #ffffff;
- --red: #ff5f59; --orange: #fec43f; --yellow: #d0bc00; --green: #44bc44;
- --cyan: #00d3d0; --blue: #2fafff; --magenta: #feacd0;
-}
-
-:root[data-theme="modus-operandi"] {
- color-scheme: light;
- --bg: #ffffff; --bg-alt: #f2f2f2; --fg: #000000;
- --red: #a60000; --orange: #884900; --yellow: #6f5500; --green: #006800;
- --cyan: #005e8b; --blue: #0031a9; --magenta: #721045;
- --glow: 0;
-}
-
-:root[data-theme="doom-dracula"] {
- --bg: #282a36; --bg-alt: #1e2029; --fg: #f8f8f2;
- --red: #ff5555; --orange: #ffb86c; --yellow: #f1fa8c; --green: #50fa7b;
- --cyan: #8be9fd; --blue: #61bfff; --magenta: #ff79c6;
-}
-
-:root[data-theme="doom-nord"] {
- --bg: #2e3440; --bg-alt: #3b4252; --fg: #eceff4;
- --red: #bf616a; --orange: #d08770; --yellow: #ebcb8b; --green: #a3be8c;
- --cyan: #88c0d0; --blue: #81a1c1; --magenta: #b48ead;
-}
-
-:root[data-theme="catppuccin"] {
- --bg: #1e1e2e; --bg-alt: #181825; --fg: #cdd6f4;
- --red: #f38ba8; --orange: #fab387; --yellow: #f9e2af; --green: #a6e3a1;
- --cyan: #94e2d5; --blue: #89b4fa; --magenta: #cba6f7;
-}
-
-:root[data-theme="doom-tokyo-night"] {
- --bg: #1a1b26; --bg-alt: #16161e; --fg: #c0caf5;
- --red: #f7768e; --orange: #ff9e64; --yellow: #e0af68; --green: #9ece6a;
- --cyan: #7dcfff; --blue: #7aa2f7; --magenta: #bb9af7;
-}
-
-:root[data-theme="doom-solarized-light"] {
- color-scheme: light;
- --bg: #fdf6e3; --bg-alt: #eee8d5; --fg: #556b72;
- --red: #dc322f; --orange: #cb4b16; --yellow: #b58900; --green: #859900;
- --cyan: #2aa198; --blue: #268bd2; --magenta: #d33682;
- --glow: 0;
-}
-
-[data-cat="hw"] { --c: var(--c-hw); }
-[data-cat="sw"] { --c: var(--c-sw); }
-[data-cat="math"] { --c: var(--c-math); }
-[data-cat="sec"] { --c: var(--c-sec); }
-
-/* ───────────────────────────── base ───────────────────────────── */
-
-*,
-*::before,
-*::after {
- box-sizing: border-box;
-}
-
-html {
- background: var(--bg);
- color: var(--fg);
- scroll-behavior: smooth;
- scroll-snap-type: y proximity;
- scroll-padding-top: calc(var(--top-h) + 24px);
- -webkit-text-size-adjust: 100%;
- text-size-adjust: 100%;
-}
-
-body {
- margin: 0;
- font: 400 13px/1.6 var(--mono);
- font-variant-ligatures: none;
- -webkit-font-smoothing: antialiased;
- transition: background-color 200ms ease, color 200ms ease;
-}
-
-::selection {
- background: color-mix(in srgb, var(--blue) 35%, var(--bg));
- color: var(--fg);
-}
-
-a {
- color: inherit;
- text-decoration-line: underline;
- text-decoration-thickness: 1px;
- text-decoration-color: var(--faint);
- text-underline-offset: 0.2em;
-}
-
-a:hover {
- text-decoration-color: currentColor;
-}
-
-button,
-input,
-output,
-select {
- font: inherit;
- color: inherit;
-}
-
-button {
- cursor: pointer;
-}
-
-:focus-visible {
- outline: 1px solid var(--fg);
- outline-offset: 2px;
-}
-
-kbd {
- font: inherit;
- font-size: 0.95em;
- padding: 0 0.45ch;
- border: 1px solid var(--faint);
- border-radius: 3px;
-}
-
-code {
- font-family: var(--mono);
- font-size: 0.95em;
-}
-
-.dim {
- color: var(--dim);
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
-}
-
-.skip {
- position: absolute;
- left: 8px;
- top: -100px;
- z-index: 50;
- padding: 6px 12px;
- background: var(--fg);
- color: var(--bg);
-}
-
-.skip:focus {
- top: 8px;
-}
-
-.btn {
- display: inline-flex;
- align-items: center;
- min-height: 30px;
- padding: 0 14px;
- border: 1px solid var(--faint);
- border-radius: 0;
- background: none;
- font-size: 12px;
- line-height: 1;
- white-space: nowrap;
-}
-
-.btn:hover {
- border-color: var(--fg);
-}
-
-.btn[aria-pressed="true"] {
- border-color: var(--fg);
- background: var(--fg);
- color: var(--bg);
-}
-
-/* ───────────────────────────── top line ───────────────────────────── */
-
-.top {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- z-index: 20;
- display: flex;
- align-items: center;
- gap: 3ch;
- height: var(--top-h);
- padding: 0 var(--gutter);
- font-size: 12px;
- border-bottom: 1px solid transparent;
- transition: background-color 200ms ease, border-color 200ms ease;
-}
-
-.is-scrolled .top {
- background: color-mix(in srgb, var(--bg) 90%, transparent);
- border-bottom-color: var(--rule);
- -webkit-backdrop-filter: blur(10px);
- backdrop-filter: blur(10px);
-}
-
-.brand {
- display: inline-flex;
- align-items: center;
- gap: 1.5ch;
- margin-right: auto;
- text-decoration: none;
-}
-
-.logo {
- width: 22px;
- height: 11px;
- overflow: visible;
- fill: none;
- stroke: currentColor;
- stroke-width: 1.6;
-}
-
-.nav {
- display: flex;
- gap: 2.5ch;
-}
-
-.nav a {
- color: var(--dim);
- text-decoration: none;
-}
-
-.nav a:hover,
-.nav a[aria-current="true"] {
- color: var(--fg);
-}
-
-.nav a[aria-current="true"] {
- text-decoration: underline;
- text-decoration-color: var(--fg);
- text-underline-offset: 6px;
-}
-
-.theme-key,
-.menu-key {
- padding: 8px 0;
- border: 0;
- background: none;
- color: var(--dim);
- font-size: 12px;
-}
-
-.theme-key:hover,
-.menu-key:hover {
- color: var(--fg);
-}
-
-.menu-key {
- display: none;
-}
-
-/* ───────────────────────────── hero: the computer ───────────────────────────── */
-
-.hero {
- display: grid;
- grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
- gap: 32px clamp(32px, 4vw, 64px);
- align-items: center;
- min-height: 100vh;
- min-height: 100svh;
- padding: calc(var(--top-h) + 28px) var(--gutter) calc(var(--frame-h) + 32px);
- max-width: 1440px;
- margin: 0 auto;
-}
-
-.hero-name {
- margin: 0 0 20px -0.04em;
- font: 400 clamp(46px, 6.4vw, 96px)/0.94 var(--serif);
- letter-spacing: -0.015em;
-}
-
-/* On load the name decodes itself (src/scripts/decode.js): each letter starts as a byte
- with a few bits flipped and changes one bit at a time until it is right. */
-
-.boot .hero-name {
- visibility: hidden;
- animation: boot-failsafe 0s 2.5s forwards;
-}
-
-@keyframes boot-failsafe {
- to { visibility: visible; }
-}
-
-/* each letter sits in a box as wide as the final letter, and whatever
- character it shows is centred in it */
-.hero-name.is-cells .bc {
- display: inline-block;
- text-align: center;
-}
-
-.hero-name.is-cells .bc-ch {
- display: inline-block;
- margin-inline: -1em;
- white-space: pre;
-}
-
-.bc-word {
- white-space: nowrap;
-}
-
-/* a wrong byte shows as a raw character in the mono face */
-.bc.is-wrong {
- color: var(--dim);
-}
-
-.bc.is-wrong .bc-ch {
- font-family: var(--mono);
- font-size: 0.78em;
- letter-spacing: 0;
-}
-
-.hero-lede {
- max-width: 44ch;
- margin: 0 0 20px;
- font-size: 14px;
- line-height: 1.6;
-}
-
-.legend {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 2.5ch;
- margin: 0;
- padding: 0;
- list-style: none;
- font-size: 12px;
- color: var(--dim);
-}
-
-.legend li::before,
-.features li::before {
- content: "";
- display: inline-block;
- width: 7px;
- height: 7px;
- margin-right: 1ch;
- background: var(--c, var(--fg));
- vertical-align: 0.06em;
-}
-
-/* landing: the MN-2026 pinout */
-
-.hero-chip {
- justify-self: center;
- width: min(100%, 460px);
-}
-
-.hero-chip .chip {
- grid-template-columns: minmax(0, 1fr) 128px minmax(0, 1fr);
- font-size: 13px;
-}
-
-.hero-chip .pin {
- height: 34px;
-}
-
-.hero-chip .pin .leg {
- height: 9px;
-}
-
-.hero-chip .pin-readout {
- min-height: 6.5em;
-}
-
-/* the 8-bit computer, as the project's demo */
-
-.demo-wide {
- margin-top: 40px;
- padding-top: 28px;
- border-top: 1px solid var(--rule);
-}
-
-.sim-bar {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 10px 10px;
- margin-top: 16px;
- font-size: 12px;
-}
-
-.sim-field {
- display: inline-flex;
- align-items: center;
- gap: 1.2ch;
- margin-left: 1.5ch;
- color: var(--dim);
-}
-
-.sim-field output {
- min-width: 7ch;
- color: var(--fg);
- font-variant-numeric: tabular-nums;
-}
-
-select {
- min-height: 30px;
- padding: 0 28px 0 10px;
- border: 1px solid var(--faint);
- border-radius: 0;
- background: var(--bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E") no-repeat right 10px center;
- color: var(--fg);
- font-size: 12px;
- -webkit-appearance: none;
- appearance: none;
-}
-
-input[type="range"] {
- width: 120px;
- height: 22px;
- margin: 0;
- background: transparent;
- -webkit-appearance: none;
- appearance: none;
-}
-
-input[type="range"]::-webkit-slider-runnable-track {
- height: 1px;
- background: var(--faint);
-}
-
-input[type="range"]::-moz-range-track {
- height: 1px;
- background: var(--faint);
-}
-
-input[type="range"]::-webkit-slider-thumb {
- width: 9px;
- height: 18px;
- margin-top: -9px;
- border: 0;
- border-radius: 0;
- background: var(--fg);
- -webkit-appearance: none;
- appearance: none;
-}
-
-input[type="range"]::-moz-range-thumb {
- width: 9px;
- height: 18px;
- border: 0;
- border-radius: 0;
- background: var(--fg);
-}
-
-.sim-note {
- max-width: 78ch;
- margin: 12px 0 0;
- font-size: 12px;
- line-height: 1.6;
- color: var(--dim);
-}
-
-/* board: Ben Eater's layout, two columns of modules either side of the bus */
-
-.board {
- --led: 10px;
- display: grid;
- grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr);
- grid-template-areas:
- "left bus right"
- "ctrl ctrl ctrl";
- gap: 10px;
- font-size: 11.5px;
- font-variant-numeric: tabular-nums;
-}
-
-.col {
- display: flex;
- flex-direction: column;
- gap: 10px;
-}
-
-.col-l { grid-area: left; }
-.col-r { grid-area: right; }
-
-.mod {
- padding: 7px 10px 9px;
- border: 1px solid var(--rule);
- background: color-mix(in srgb, var(--bg) 92%, var(--fg));
-}
-
-.mod.is-out {
- border-color: var(--red);
-}
-
-.mod.is-in {
- border-color: var(--green);
-}
-
-.mod-head {
- display: flex;
- align-items: baseline;
- gap: 1ch;
- margin-bottom: 6px;
- color: var(--dim);
- white-space: nowrap;
-}
-
-.mod-name {
- margin-right: auto;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sig {
- padding: 0 0.4ch;
- border-radius: 2px;
- color: var(--faint);
- font-size: 10.5px;
-}
-
-.sig.on {
- background: var(--blue);
- color: var(--bg);
-}
-
-.mod-body {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 1ch;
-}
-
-.mod-val {
- color: var(--fg);
- white-space: nowrap;
-}
-
-.leds {
- display: flex;
- gap: calc(var(--led) * 0.45);
-}
-
-.led {
- display: block;
- flex: none;
- width: var(--led);
- height: var(--led);
- padding: 0;
- border: 0;
- border-radius: 50%;
- background: var(--led-off);
- --lc: var(--red);
-}
-
-.led.on {
- background: var(--lc);
- box-shadow: 0 0 calc(var(--glow) * 7px) calc(var(--glow) * 1px) color-mix(in srgb, var(--lc) 70%, transparent);
-}
-
-.led.g { --lc: var(--green); }
-.led.y { --lc: var(--yellow); }
-.led.b { --lc: var(--blue); }
-
-.leds .gap {
- width: calc(var(--led) * 0.4);
-}
-
-/* RAM: the whole 16-byte memory as a matrix you can edit */
-
-.ram-rows {
- display: grid;
- gap: 1px;
-}
-
-.ram-row {
- --led: 8px;
- display: grid;
- grid-template-columns: 1.5ch 2ch auto minmax(0, 1fr);
- align-items: center;
- gap: 0.8ch;
- padding: 1px 4px;
- margin: 0 -4px;
- line-height: 14px;
-}
-
-.ram-row.is-mar {
- background: var(--hl);
-}
-
-.ram-row .pc {
- color: var(--green);
-}
-
-.ram-row .addr {
- color: var(--dim);
-}
-
-.ram-row .dis {
- overflow: hidden;
- color: var(--dim);
- white-space: nowrap;
- text-overflow: ellipsis;
-}
-
-.ram-row.is-mar .dis {
- color: var(--fg);
-}
-
-.ram-row .led {
- cursor: pointer;
-}
-
-.ram-row .led:hover {
- outline: 1px solid var(--faint);
- outline-offset: 1px;
-}
-
-/* the bus: eight lanes down the middle */
-
-.bus {
- grid-area: bus;
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 7px 0 9px;
- border: 1px solid var(--rule);
-}
-
-.bus .mod-head {
- margin-bottom: 4px;
-}
-
-.bus-val {
- margin-bottom: 8px;
-}
-
-.lanes {
- display: flex;
- flex: 1;
- gap: 4px;
-}
-
-.lane {
- width: 2px;
- background: var(--led-off);
-}
-
-.lane.on {
- background: var(--red);
- box-shadow: 0 0 calc(var(--glow) * 6px) color-mix(in srgb, var(--red) 70%, transparent);
-}
-
-/* output: a 7-segment display */
-
-.seg7 {
- display: flex;
- gap: 6px;
-}
-
-.seg7 svg {
- width: 22px;
- height: 38px;
- overflow: visible;
-}
-
-.seg7 path {
- fill: var(--led-off);
-}
-
-.seg7 path.on {
- fill: var(--red);
- filter: drop-shadow(0 0 calc(var(--glow) * 3px) color-mix(in srgb, var(--red) 80%, transparent));
-}
-
-.mod-out .mod-body {
- cursor: pointer;
-}
-
-/* control word */
-
-.ctrl {
- grid-area: ctrl;
-}
-
-.ctrl-row {
- display: flex;
- flex-wrap: wrap;
- align-items: flex-end;
- gap: 8px 18px;
-}
-
-.cw {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
-}
-
-.cw-bit {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 3px;
- width: 3.4ch;
- color: var(--faint);
- font-size: 10px;
-}
-
-.cw-bit.on {
- color: var(--fg);
-}
-
-.ustep {
- margin-top: 8px;
- color: var(--dim);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.ustep b {
- color: var(--fg);
- font-weight: 600;
-}
-
-/* ───────────────────────────── sections ───────────────────────────── */
-
-.sec {
- max-width: var(--col);
- margin: 0 auto;
- padding: 136px var(--gutter) 0;
-}
-
-.hero,
-.sec > .winbar {
- scroll-snap-align: start;
-}
-
-.winbar {
- display: flex;
- align-items: center;
- gap: 1.5ch;
- margin: 0 0 56px;
- font-size: 12px;
- line-height: 1;
- color: var(--dim);
-}
-
-.winbar .file {
- color: var(--fg);
-}
-
-.winbar .rule {
- flex: 1;
- height: 1px;
- background: var(--rule);
-}
-
-.h2 {
- max-width: 17em;
- margin: 0 0 20px;
- font: 400 clamp(36px, 5.4vw, 62px)/1.04 var(--serif);
- letter-spacing: -0.01em;
-}
-
-.lede,
-.prose {
- max-width: 34em;
- margin: 0 0 48px;
- font: 400 19px/1.55 var(--serif);
-}
-
-.prose {
- margin-bottom: 0;
-}
-
-.lede kbd {
- font: 400 13px/1 var(--mono);
- vertical-align: 0.15em;
-}
-
-.sub-h {
- margin: 72px 0 8px;
- font: 400 28px/1.15 var(--serif);
-}
-
-.sub-p {
- max-width: 34em;
- margin: 0 0 28px;
- font: 400 17px/1.55 var(--serif);
-}
-
-/* ───────────────────────────── datasheet ───────────────────────────── */
-
-.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-grid {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 400px);
- gap: 72px;
- align-items: start;
-}
-
-.ds-desc {
- margin: 40px 0 0;
-}
-
-.ds-h {
- margin: 48px 0 16px;
- font-size: 12px;
- font-weight: 600;
- line-height: 1.4;
-}
-
-.features {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.features li {
- max-width: 62ch;
- margin-bottom: 8px;
- padding-left: 3ch;
- text-indent: -3ch;
-}
-
-.features li::before {
- margin-right: 2ch;
- text-indent: 0;
-}
-
-.pinout {
- margin: 0;
-}
-
-.chip {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 104px minmax(0, 1fr);
- font-size: 12px;
-}
-
-.chip-side {
- display: flex;
- flex-direction: column;
- padding: 10px 0;
-}
-
-.chip-body {
- position: relative;
- border: 1px solid var(--fg);
- border-radius: 2px;
-}
-
-.chip-body::before {
- content: "";
- position: absolute;
- top: -1px;
- left: 50%;
- width: 20px;
- height: 10px;
- border: 1px solid var(--fg);
- border-top: 0;
- border-radius: 0 0 10px 10px;
- background: var(--bg);
- transform: translateX(-50%);
-}
-
-.chip-body span {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--dim);
- writing-mode: vertical-rl;
- letter-spacing: 0.2em;
-}
-
-.pin {
- display: grid;
- align-items: center;
- height: 28px;
- padding: 0;
- border: 0;
- background: none;
- color: var(--dim);
- text-align: left;
-}
-
-.chip-side.left .pin {
- grid-template-columns: 1fr 3ch 16px;
- text-align: right;
-}
-
-.chip-side.right .pin {
- grid-template-columns: 16px 3ch 1fr;
-}
-
-.pin .num {
- text-align: center;
- font-size: 11px;
-}
-
-.pin .leg {
- height: 8px;
- border: 1px solid var(--faint);
-}
-
-.chip-side.left .leg {
- border-right: 0;
-}
-
-.chip-side.right .leg {
- border-left: 0;
-}
-
-.pin .name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.pin:hover,
-.pin.is-active {
- color: var(--fg);
-}
-
-.pin.is-active .name {
- color: var(--c, var(--fg));
- font-weight: 600;
-}
-
-.pin.is-active .leg {
- border-color: var(--c, var(--fg));
- background: var(--c, var(--fg));
-}
-
-.pin-readout {
- min-height: 7.5em;
- margin-top: 20px;
- padding-top: 12px;
- border-top: 1px solid var(--rule);
- font-size: 12px;
-}
-
-.pin-readout p {
- margin: 0;
-}
-
-.pin-readout .pr-head {
- display: flex;
- gap: 2ch;
- margin-bottom: 6px;
-}
-
-.pin-readout .pr-name {
- color: var(--c, var(--fg));
- font-weight: 600;
-}
-
-.pinout figcaption {
- margin-top: 8px;
- font-size: 12px;
- color: var(--dim);
-}
-
-.pin-list {
- margin: 16px 0 0;
- padding-left: 3ch;
- font-size: 12px;
-}
-
-.pinout.is-built .pin-list {
- display: none;
-}
-
-.pin-list li::before {
- content: attr(data-name) " (" attr(data-type) ") ";
- font-weight: 600;
-}
-
-.ds-note {
- margin: -8px 0 14px;
- font-size: 12px;
- color: var(--dim);
-}
-
-.table-scroll {
- overflow-x: auto;
-}
-
-.ds-table {
- width: 100%;
- min-width: 560px;
- border-collapse: collapse;
- font-size: 12.5px;
- font-variant-numeric: tabular-nums;
-}
-
-.ds-table th,
-.ds-table td {
- padding: 8px 1.5ch 8px 0;
- border-bottom: 1px solid var(--rule);
- text-align: left;
- vertical-align: baseline;
-}
-
-.ds-table thead th {
- border-top: 1px solid var(--fg);
- border-bottom-color: var(--fg);
- font-weight: 600;
-}
-
-.ds-table tbody th {
- font-weight: 400;
-}
-
-.ds-table td:nth-child(2) {
- color: var(--dim);
-}
-
-.ds-table .num {
- text-align: right;
- padding-right: 3ch;
-}
-
-/* experience as git log --graph --color */
-
-.gitlog-cmd {
- margin-bottom: 12px;
- font-weight: 400;
- color: var(--fg);
- line-height: 1.5;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-.gitlog-cmd .prompt {
- color: var(--green);
-}
-
-.gitlog {
- padding: 16px 18px;
- overflow-x: auto;
- border: 1px solid var(--rule);
- font-size: 12px;
- line-height: 1.7;
- color: var(--fg);
-}
-
-.gl-line {
- width: max-content;
- white-space: pre;
-}
-
-/* one colour per branch */
-.gitlog .lm { color: var(--magenta); } /* main */
-.gitlog .lu { color: var(--green); } /* undergrad */
-.gitlog .lt { color: var(--yellow); } /* teaching-assistant */
-.gitlog .lb { color: var(--red); } /* bbd-software */
-.gitlog .li { color: var(--blue); } /* mwr-internship */
-
-.gl-tip {
- color: var(--fg);
- font-weight: 600;
-}
-
-.gl-refs {
- color: var(--yellow);
-}
-
-.gl-head {
- color: var(--cyan);
- font-weight: 600;
-}
-
-.gl-branch {
- color: var(--green);
- font-weight: 600;
-}
-
-.gl-author {
- color: var(--blue);
-}
-
-.gl-subj {
- padding: 0;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
- white-space: pre;
- text-decoration: underline dotted;
- text-decoration-color: var(--faint);
- text-underline-offset: 0.25em;
-}
-
-.gl-subj:hover,
-.gl-subj[aria-expanded="true"] {
- text-decoration-style: solid;
- text-decoration-color: currentColor;
-}
-
-.gl-body,
-.gl-desc {
- color: var(--dim);
-}
-
-/* before the script runs (or without it), descriptions show under their commit */
-.gl-desc {
- display: block;
- padding-left: 4ch;
-}
-
-.gitlog-hint {
- margin: 8px 0 0;
- font-size: 12px;
-}
-
-/* ───────────────────────────── memory map ───────────────────────────── */
-
-.mm {
- --mm-cols: 8ch 8ch minmax(0, 1fr) 8ch;
-}
-
-.mm-bar {
- display: flex;
- height: 36px;
- margin-bottom: 32px;
- border: 1px solid var(--rule);
-}
-
-.mm-seg {
- min-width: 3px;
- padding: 0 6px;
- overflow: hidden;
- border-right: 1px solid var(--bg);
- background: color-mix(in srgb, var(--c) 20%, transparent);
- box-shadow: inset 0 2px 0 var(--c);
- font-size: 11px;
- line-height: 34px;
- white-space: nowrap;
- cursor: pointer;
- transition: background-color 120ms ease;
-}
-
-.mm-seg:last-child {
- border-right: 0;
-}
-
-.mm-seg:hover {
- background: color-mix(in srgb, var(--c) 34%, transparent);
-}
-
-.mm-seg.is-active {
- background: var(--c);
- color: var(--bg);
-}
-
-.mm-cols {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
- gap: 48px;
- align-items: start;
-}
-
-.mm-head {
- display: grid;
- grid-template-columns: var(--mm-cols);
- gap: 1.5ch;
- margin: 0;
- padding: 0 0 8px 12px;
- border-bottom: 1px solid var(--rule);
- font-size: 12px;
- color: var(--dim);
-}
-
-.mm-list {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.proj-name {
- margin: 0;
- font: inherit;
-}
-
-.proj-row {
- display: grid;
- grid-template-columns: var(--mm-cols);
- gap: 1.5ch;
- align-items: baseline;
- width: 100%;
- padding: 11px 0 11px 12px;
- border: 0;
- border-bottom: 1px solid var(--rule);
- background: none;
- text-align: left;
- font-size: 13px;
- font-variant-numeric: tabular-nums;
-}
-
-.proj-row:hover {
- background: var(--wash);
-}
-
-.proj-row .o,
-.proj-row .l,
-.proj-row .y {
- font-size: 12px;
- color: var(--dim);
-}
-
-.proj-row .n {
- font-weight: 600;
-}
-
-.proj-row .n::before {
- content: "";
- display: inline-block;
- width: 7px;
- height: 7px;
- margin-right: 1.2ch;
- background: var(--c);
- vertical-align: 0.06em;
-}
-
-.proj-row .s {
- display: block;
- margin-top: 2px;
- font-weight: 400;
- color: var(--dim);
-}
-
-.proj-row[aria-pressed="true"] {
- background: var(--wash);
- box-shadow: inset 2px 0 0 var(--c);
-}
-
-.proj-row[aria-pressed="true"] .o,
-.proj-row[aria-pressed="true"] .l {
- color: var(--c);
-}
-
-.mm.is-built .proj-sum,
-.mm.is-built .proj > .proj-body {
- display: none;
-}
-
-.mm-detail {
- min-height: 320px;
- padding-left: 32px;
- border-left: 1px solid var(--rule);
-}
-
-.mm-detail:empty {
- display: none;
-}
-
-.hex {
- margin: 0 0 22px;
- overflow-x: auto;
- font-size: 11.5px;
- line-height: 1.55;
- color: var(--dim);
- white-space: pre;
-}
-
-.hex .a {
- color: var(--fg);
-}
-
-.hex .addr {
- color: var(--c);
-}
-
-.mm-detail h3 {
- margin: 0 0 14px;
- font: 400 32px/1.1 var(--serif);
-}
-
-.mm-detail .proj-body p {
- margin: 0 0 14px;
- font: 400 17px/1.55 var(--serif);
-}
-
-.mm-detail .proj-body .proj-stack,
-.mm-detail .proj-body .proj-links {
- font: 400 12px/1.6 var(--mono);
-}
-
-.proj-stack {
- color: var(--dim);
-}
-
-.proj-links a {
- margin-right: 2ch;
-}
-
-/* project demos */
-
-.demo {
- margin-top: 22px;
- padding-top: 18px;
- border-top: 1px solid var(--rule);
- font-size: 12px;
-}
-
-.demo-bar {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 8px;
- margin-bottom: 12px;
-}
-
-.demo-bar .dim {
- margin-left: auto;
-}
-
-.demo canvas {
- display: block;
- width: 100%;
-}
-
-.demo pre {
- margin: 0;
- font-size: 12px;
- line-height: 1.2;
- user-select: none;
- -webkit-user-select: none;
-}
-
-.demo .cap {
- margin: 10px 0 0;
- color: var(--dim);
-}
-
-.bc-input {
- display: flex;
- align-items: center;
- gap: 1ch;
- margin-bottom: 12px;
-}
-
-.bc-input input {
- flex: 1;
- min-width: 0;
- min-height: 30px;
- padding: 0 10px;
- border: 1px solid var(--faint);
- border-radius: 0;
- background: none;
- font-size: 13px;
-}
-
-.bc-input input:focus {
- border-color: var(--fg);
- outline: none;
-}
-
-.bc-cols {
- display: grid;
- grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
- gap: 16px;
-}
-
-.bc-code {
- margin: 0;
- padding: 0;
- list-style: none;
- font-variant-numeric: tabular-nums;
-}
-
-.bc-code li {
- display: grid;
- grid-template-columns: 4ch 1fr;
- gap: 1ch;
- padding: 1px 6px;
-}
-
-.bc-code li.is-pc {
- background: var(--hl);
-}
-
-.bc-code .off {
- color: var(--dim);
- text-align: right;
-}
-
-.bc-code .op {
- color: var(--blue);
-}
-
-.bc-stack {
- display: flex;
- flex-direction: column-reverse;
- justify-content: flex-start;
- gap: 2px;
- min-height: 120px;
- padding: 6px;
- border: 1px solid var(--rule);
- border-top: 0;
-}
-
-.bc-stack span {
- padding: 2px 8px;
- border: 1px solid var(--faint);
- text-align: right;
-}
-
-.bc-stack span:last-child {
- border-color: var(--fg);
-}
-
-.bc-err {
- color: var(--red);
- white-space: pre;
-}
-
-/* othello */
-
-.oth {
- display: grid;
- grid-template-columns: repeat(8, minmax(0, 1fr));
- gap: 2px;
- width: min(100%, 336px);
- aspect-ratio: 1;
- padding: 2px;
- background: color-mix(in srgb, var(--fg) 25%, var(--bg));
-}
-
-.oth-cell {
- position: relative;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: color-mix(in srgb, var(--green) 28%, var(--bg));
- cursor: default;
-}
-
-.oth-cell.legal {
- cursor: pointer;
-}
-
-.oth-cell.legal::after {
- content: "";
- position: absolute;
- inset: 40%;
- border-radius: 50%;
- background: var(--fg);
- opacity: 0.4;
-}
-
-.oth-cell.last {
- box-shadow: inset 0 0 0 2px var(--yellow);
-}
-
-.oth-cell .disc {
- position: absolute;
- inset: 12%;
- border-radius: 50%;
-}
-
-.oth-cell .disc.b {
- background: #1b1b1b;
- box-shadow: 0 0 0 1px rgb(0 0 0 / 0.35);
-}
-
-.oth-cell .disc.w {
- background: #f1efe8;
- box-shadow: 0 0 0 1px rgb(0 0 0 / 0.15);
-}
-
-.oth-cell .heat {
- position: absolute;
- inset: 0;
- z-index: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- background: color-mix(in srgb, var(--blue) var(--hp, 0%), transparent);
- color: var(--fg);
- font-size: 10px;
-}
-
-.oth-cell .heat:empty {
- background: none;
-}
-
-/* rbudp */
-
-.rb-canvas {
- height: 200px;
-}
-
-.rb-log {
- max-height: 9.8em;
- margin: 10px 0 0;
- padding: 8px 10px;
- overflow-y: auto;
- border: 1px solid var(--rule);
- list-style: none;
- font-size: 11.5px;
- line-height: 1.6;
-}
-
-.rb-log li {
- display: grid;
- grid-template-columns: 8ch 5ch minmax(0, 1fr);
- gap: 1ch;
-}
-
-.rb-proto {
- color: var(--cyan);
-}
-
-.sched-legend {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 12px;
- margin-top: 8px;
- color: var(--dim);
-}
-
-/* ───────────────────────────── posts: an Org buffer ───────────────────────────── */
-
-.org {
- max-width: 880px;
- font-size: 13px;
- line-height: 1.75;
-}
-
-.org-kw {
- margin: 0;
- font: inherit;
- color: var(--dim);
-}
-
-.org-kw .v {
- color: var(--fg);
-}
-
-.org-title {
- color: var(--fg);
- font-weight: 600;
-}
-
-.org-p {
- max-width: 76ch;
- margin: 20px 0 0;
- font-size: 13px;
- line-height: 1.75;
-}
-
-.org > .org-tree {
- margin-top: 20px;
-}
-
-.org-tree {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.org-h {
- display: flex;
- align-items: baseline;
- gap: 1ch;
-}
-
-.org-h:focus-visible {
- outline: none;
- background: var(--hl);
-}
-
-.org-stars {
- flex: none;
- padding: 0;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
-}
-
-.org-h1 > .org-h {
- color: var(--blue);
- font-weight: 600;
-}
-
-.org-h2 > .org-h {
- color: var(--magenta);
-}
-
-.org-h2 a {
- min-width: 0;
- color: inherit;
- text-decoration: none;
-}
-
-.org-h2 a:hover {
- text-decoration: underline;
- text-decoration-thickness: 1px;
-}
-
-.org-h .count,
-.org-ellipsis {
- color: var(--dim);
- font-weight: 400;
-}
-
-.org-tags {
- flex: none;
- margin-left: auto;
- padding-left: 2ch;
- color: var(--dim);
- white-space: nowrap;
-}
-
-.org-tags .t {
- color: var(--tag, var(--dim));
-}
-
-.org-drawer {
- margin: 0 0 6px 3ch;
- overflow-x: auto;
- color: var(--dim);
- font-size: 12px;
- line-height: 1.6;
- white-space: pre;
-}
-
-.org-drawer .k {
- color: var(--faint);
-}
-
-.org-drawer .ts {
- color: var(--cyan);
-}
-
-.org-drawer a {
- color: var(--fg);
-}
-
-/* ───────────────────────────── contact ───────────────────────────── */
-
-.contact {
- display: flex;
- flex-wrap: wrap;
- align-items: baseline;
- gap: 16px 32px;
- margin-bottom: 8px;
-}
-
-.mail {
- font: 400 clamp(26px, 4.4vw, 52px)/1.1 var(--serif);
- overflow-wrap: anywhere;
-}
-
-.mail .at {
- font: 400 0.42em/1 var(--mono);
- color: var(--dim);
- vertical-align: 0.35em;
- white-space: pre;
-}
-
-.linkish {
- padding: 0;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
- text-decoration: underline;
- text-decoration-thickness: 1px;
- text-decoration-color: var(--faint);
- text-underline-offset: 0.2em;
-}
-
-.linkish:hover {
- text-decoration-color: currentColor;
-}
-
-.contact-grid {
- display: grid;
- grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
- gap: 0 56px;
-}
-
-.gpg-fp {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 2ch;
- margin: 0 0 12px;
-}
-
-.gpg-fp code {
- letter-spacing: 0.02em;
-}
-
-.gpg-cmd {
- display: flex;
- align-items: stretch;
- gap: 8px;
-}
-
-.gpg-cmd pre {
- flex: 1;
- min-width: 0;
- margin: 0;
- padding: 7px 12px;
- overflow-x: auto;
- border: 1px solid var(--rule);
- background: var(--wash);
- font-size: 12px;
-}
-
-.elsewhere {
- display: flex;
- flex-wrap: wrap;
- gap: 8px 3ch;
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.ctf {
- max-width: 70ch;
- margin-top: 16px;
-}
-
-.ctf p {
- margin: 0 0 12px;
-}
-
-.ctf-row {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 12px 2ch;
-}
-
-.ctf-progress {
- color: var(--dim);
-}
-
-.ctf-progress.done {
- color: var(--green);
-}
-
-.foot {
- max-width: var(--col);
- margin: 0 auto;
- padding: 160px var(--gutter) calc(var(--frame-h) + 48px);
- font-size: 12px;
-}
-
-.foot p {
- margin: 0 0 4px;
-}
-
-/* ───────────────────────────── modeline, minibuffer, which-key ───────────────────────────── */
-
-.frame-bottom {
- position: fixed;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 30;
- padding-bottom: env(safe-area-inset-bottom, 0px);
- background: var(--bg);
- font-size: 12px;
- white-space: nowrap;
-}
-
-.modeline {
- display: flex;
- align-items: stretch;
- height: 24px;
- line-height: 24px;
- background: var(--bg-alt);
- color: var(--fg);
-}
-
-/* doom-modeline's bar */
-.modeline::before {
- content: "";
- flex: none;
- width: 4px;
- background: var(--blue);
-}
-
-.modeline > span {
- padding: 0 1ch;
-}
-
-.ml-evil {
- color: var(--green);
- font-weight: 600;
-}
-
-.modeline[data-evil="I"] .ml-evil { color: var(--yellow); }
-.modeline[data-evil="V"] .ml-evil { color: var(--magenta); }
-.modeline[data-evil="E"] .ml-evil { color: var(--cyan); }
-
-.ml-buf {
- font-weight: 600;
-}
-
-.ml-pos,
-.ml-pct,
-.ml-enc,
-.ml-time {
- color: var(--dim);
-}
-
-.ml-spacer {
- flex: 1;
- padding: 0 !important;
-}
-
-.ml-flags {
- color: var(--green);
-}
-
-.ml-mode {
- color: var(--blue);
- font-weight: 600;
-}
-
-.ml-vc {
- color: var(--green);
-}
-
-.echo {
- height: 22px;
- padding: 0 1ch;
- overflow: hidden;
- line-height: 22px;
- color: var(--dim);
- text-overflow: ellipsis;
-}
-
-.echo.is-error {
- color: var(--red);
-}
-
-.echo.is-ok {
- color: var(--green);
-}
-
-.mini:not([hidden]) ~ .echo {
- display: none;
-}
-
-.mini-prompt {
- margin: 0;
-}
-
-.mini-prompt label {
- display: flex;
- height: 22px;
- padding: 0 1ch;
- line-height: 22px;
-}
-
-.mini-count {
- min-width: 6ch;
- color: var(--dim);
-}
-
-.mini-ps {
- color: var(--blue);
- white-space: pre;
-}
-
-.mini-prompt input {
- flex: 1;
- min-width: 0;
- padding: 0;
- border: 0;
- outline: 0;
- background: none;
- color: var(--fg);
- caret-color: var(--fg);
- font-size: 12px;
- line-height: 22px;
-}
-
-.mini-list {
- max-height: calc(10 * 21px);
- margin: 0;
- padding: 0;
- overflow-y: auto;
- list-style: none;
-}
-
-.mini-list:empty {
- display: none;
-}
-
-.mini-list li {
- display: flex;
- gap: 2ch;
- height: 21px;
- padding: 0 1ch;
- line-height: 21px;
- cursor: pointer;
-}
-
-.mini-list li[aria-selected="true"] {
- background: var(--hl);
-}
-
-.mini-list .cand {
- flex: none;
- min-width: 24ch;
- color: var(--fg);
-}
-
-.mini-list .cand mark {
- background: none;
- color: var(--magenta);
- font-weight: 600;
-}
-
-.mini-list .ann {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- color: var(--dim);
- text-overflow: ellipsis;
-}
-
-.mini-list .key {
- flex: none;
- color: var(--blue);
-}
-
-.which-key {
- position: fixed;
- left: 0;
- right: 0;
- bottom: var(--frame-h);
- z-index: 29;
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(26ch, 1fr));
- gap: 2px 2ch;
- padding: 10px 2ch 12px;
- border-top: 1px solid var(--rule);
- background: var(--bg-alt);
- font-size: 12px;
-}
-
-.which-key[hidden] {
- display: none;
-}
-
-.wk-title {
- grid-column: 1 / -1;
- margin-bottom: 4px;
- color: var(--dim);
-}
-
-.wk-item {
- display: flex;
- gap: 1ch;
- white-space: nowrap;
-}
-
-.wk-key {
- min-width: 3ch;
- color: var(--magenta);
- font-weight: 600;
-}
-
-.wk-arrow {
- color: var(--faint);
-}
-
-.wk-desc {
- color: var(--fg);
-}
-
-.wk-desc.group {
- color: var(--blue);
-}
-
-/* ───────────────────────────── M-x coffee-refill ───────────────────────────── */
-
-.coffee {
- position: fixed;
- right: 2ch;
- bottom: calc(var(--frame-h) + 12px);
- z-index: 28;
- display: flex;
- gap: 3ch;
- width: min(620px, calc(100% - 4ch));
- padding: 16px 20px 18px;
- border: 1px solid var(--faint);
- background: var(--bg);
- box-shadow: 0 12px 40px rgb(0 0 0 / 0.35);
- font-size: 12px;
-}
-
-.coffee[hidden] {
- display: none;
-}
-
-.mug {
- flex: none;
- margin: 0;
- font-size: 12px;
- line-height: 1.25;
- color: var(--fg);
-}
-
-.mug .steam {
- color: var(--dim);
-}
-
-.mug .brew {
- color: var(--orange);
-}
-
-.mug .crema {
- color: var(--yellow);
-}
-
-.coffee-info {
- flex: 1;
- min-width: 0;
-}
-
-.coffee-info .winbar {
- margin: 0 28px 14px 0;
-}
-
-.coffee-info p {
- margin: 0 0 10px;
-}
-
-.vcc {
- display: flex;
- gap: 1.5ch;
- font-variant-numeric: tabular-nums;
-}
-
-.vbar {
- color: var(--green);
- letter-spacing: -0.05em;
-}
-
-.coffee.is-over .vbar,
-.coffee.is-over .volts {
- color: var(--red);
-}
-
-.coffee-msg {
- min-height: 3.2em;
-}
-
-.coffee-links {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 2ch;
-}
-
-.coffee-close {
- position: absolute;
- top: 6px;
- right: 8px;
- padding: 2px 6px;
- border: 0;
- background: none;
- color: var(--dim);
- font-size: 16px;
- line-height: 1;
-}
-
-.coffee-close:hover {
- color: var(--fg);
-}
-
-/* ───────────────────────────── help ───────────────────────────── */
-
-.help {
- width: min(780px, calc(100% - 32px));
- max-height: calc(100% - 64px);
- padding: 24px 28px;
- border: 1px solid var(--faint);
- background: var(--bg);
- color: var(--fg);
- font-size: 12.5px;
-}
-
-.help::backdrop {
- background: rgb(0 0 0 / 0.5);
-}
-
-.help .winbar {
- margin-bottom: 28px;
-}
-
-.help-cols {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 40px;
-}
-
-.help h3 {
- margin: 0 0 12px;
- font-size: 12px;
- font-weight: 600;
-}
-
-.help dl {
- margin: 0;
-}
-
-.help dl div {
- display: grid;
- grid-template-columns: 13ch 1fr;
- gap: 2ch;
- padding: 3px 0;
-}
-
-.help dt {
- color: var(--fg);
-}
-
-.help dd {
- margin: 0;
- color: var(--dim);
-}
-
-.help form {
- margin-top: 28px;
-}
-
-/* ───────────────────────────── posts ─────────────────────────────
- A post is an Org buffer the way Doom shows one with mixed-pitch-mode: #+
- keywords and markup in mono, the text in the serif, starred headings in
- the org-level colours (the same as the posts list on the home page), and
- code between #+begin_src and #+end_src. Code is coloured from the current
- theme through the variables Shiki's css-variables theme reads. */
-
-:root {
- --astro-code-foreground: var(--fg);
- --astro-code-background: var(--hl);
- --astro-code-token-comment: var(--dim);
- --astro-code-token-keyword: var(--magenta);
- --astro-code-token-string: var(--green);
- --astro-code-token-string-expression: var(--green);
- --astro-code-token-constant: var(--orange);
- --astro-code-token-function: var(--blue);
- --astro-code-token-parameter: var(--fg);
- --astro-code-token-punctuation: var(--dim);
- --astro-code-token-link: var(--cyan);
-}
-
-.post {
- padding-top: 112px;
-}
-
-/* post file names are long; on a phone they're cut short rather than wrapped */
-.post > .winbar .file {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.post > .winbar .meta {
- white-space: nowrap;
-}
-
-.org-head {
- margin: 0 0 56px;
- font-size: 13px;
- line-height: 1.75;
-}
-
-.org-head .org-key {
- color: var(--faint);
-}
-
-.org-ts {
- color: var(--cyan);
-}
-
-.org-filetags .t {
- color: var(--tag, var(--dim));
-}
-
-.post-title {
- max-width: 18em;
- margin: 0 0 20px;
- font: 400 clamp(34px, 4.8vw, 54px)/1.08 var(--serif);
- letter-spacing: -0.01em;
- text-wrap: balance;
-}
-
-.post-title .org-key {
- margin-right: 0.3ch;
- font: 400 15px/1 var(--mono);
- letter-spacing: 0;
- vertical-align: 0.5em;
-}
-
-.post-body {
- max-width: 680px;
- font: 400 19px/1.6 var(--serif);
-}
-
-.post-body > :first-child {
- margin-top: 0;
-}
-
-.post-body p,
-.post-body ul,
-.post-body ol,
-.post-body dl {
- margin: 0 0 1em;
-}
-
-.post-body ul,
-.post-body ol {
- padding-left: 1.3em;
-}
-
-.post-body ul {
- list-style-type: "- ";
-}
-
-.post-body li {
- margin: 0.25em 0;
-}
-
-.post-body li::marker {
- color: var(--faint);
- font: 400 15px var(--mono);
-}
-
-.post-body li > p {
- margin: 0;
-}
-
-/* headings, coloured like org-level-1, -2 and -3 */
-.post-body h2,
-.post-body h3,
-.post-body h4 {
- margin: 1.7em 0 0.5em;
- font-family: var(--serif);
- font-weight: 400;
- line-height: 1.2;
-}
-
-.post-body h2 {
- color: var(--blue);
- font-size: 30px;
-}
-
-.post-body h3 {
- color: var(--magenta);
- font-size: 24px;
-}
-
-.post-body h4 {
- color: var(--violet);
- font-size: 20px;
-}
-
-/* The stars. page.js swaps them for buttons that fold the heading. */
-.post-body :is(h2, h3, h4)::before,
-.post-body .org-stars {
- margin-right: 0.6ch;
- font: 400 15px/1 var(--mono);
- vertical-align: 0.2em;
-}
-
-.post-body h2::before { content: "*"; }
-.post-body h3::before { content: "**"; }
-.post-body h4::before { content: "***"; }
-
-.post-body .has-stars::before {
- content: none;
-}
-
-.post-body .org-stars {
- cursor: pointer;
-}
-
-.post-body .org-ellipsis {
- margin-left: 0.5ch;
- font: 400 15px/1 var(--mono);
-}
-
-.post-body .is-folded-away {
- display: none !important;
-}
-
-/* emphasis keeps its Org markers: ~code~ /italic/ *bold* +strike+ */
-.post-body :not(pre) > code {
- color: var(--orange);
- font-size: 0.74em;
-}
-
-.post-body :not(pre) > code::before,
-.post-body :not(pre) > code::after {
- content: "~";
-}
-
-.post-body em::before,
-.post-body em::after {
- content: "/";
-}
-
-.post-body strong::before,
-.post-body strong::after {
- content: "*";
-}
-
-.post-body :is(s, del)::before,
-.post-body :is(s, del)::after {
- content: "+";
-}
-
-.post-body :not(pre) > code::before,
-.post-body :not(pre) > code::after,
-.post-body :is(em, strong, s, del)::before,
-.post-body :is(em, strong, s, del)::after {
- color: var(--faint);
- font-style: normal;
- font-weight: 400;
- text-decoration: none;
-}
-
-.post-body kbd {
- font: 400 0.72em/1 var(--mono);
- vertical-align: 0.1em;
-}
-
-/* #+begin_src lang ... #+end_src */
-.post-body pre {
- margin: 1.4em 0;
- padding: 0 16px;
- overflow-x: auto;
- background: var(--hl);
- font: 400 13px/1.6 var(--mono);
- tab-size: 4;
-}
-
-.post-body pre::before,
-.post-body pre::after {
- display: block;
- color: var(--faint);
-}
-
-.post-body pre::before {
- content: "#+begin_src " attr(data-language);
- padding-top: 8px;
-}
-
-.post-body pre[data-language="py"]::before {
- content: "#+begin_src python";
-}
-
-.post-body pre::after {
- content: "#+end_src";
- padding-bottom: 8px;
-}
-
-.post-body pre code {
- font-size: inherit;
-}
-
-.post-body blockquote {
- margin: 1.4em 0;
- color: var(--dim);
-}
-
-.post-body blockquote::before,
-.post-body blockquote::after {
- display: block;
- margin-left: -40px;
- color: var(--faint);
- font: 400 13px/1.6 var(--mono);
-}
-
-.post-body blockquote::before {
- content: "#+begin_quote";
-}
-
-.post-body blockquote::after {
- content: "#+end_quote";
-}
-
-.post-body img,
-.post-body video {
- display: block;
- max-width: 100%;
- height: auto;
- margin: 1.4em 0;
-}
-
-.post-body hr {
- margin: 2em 0;
- border: 0;
-}
-
-.post-body hr::before {
- content: "-----";
- color: var(--faint);
- font: 400 13px/1 var(--mono);
-}
-
-/* org tables: | a | b | */
-.post-body table {
- display: block;
- margin: 1.4em 0;
- overflow-x: auto;
- border-collapse: collapse;
- font: 400 13px/1.5 var(--mono);
-}
-
-.post-body th,
-.post-body td {
- padding: 4px 1ch;
- border: 1px solid var(--rule);
- text-align: left;
- vertical-align: top;
-}
-
-.post-body th {
- color: var(--blue);
- font-weight: 400;
-}
-
-/* KaTeX's font is Computer Modern too, so it can match the text size. */
-.post-body .katex {
- font-size: 1em;
-}
-
-.post-body .katex-display {
- margin: 1.2em 0;
- padding: 0.2em 0;
- overflow-x: auto;
- overflow-y: hidden;
-}
-
-/* [fn:1] references, and the notes under a * Footnotes heading */
-.post-body sup:has(> a[data-footnote-ref]) {
- font: 400 13px/1 var(--mono);
- vertical-align: baseline;
-}
-
-.post-body a[data-footnote-ref] {
- color: var(--cyan);
- text-decoration: none;
-}
-
-.post-body a[data-footnote-ref]::before {
- content: "[fn:";
-}
-
-.post-body a[data-footnote-ref]::after {
- content: "]";
-}
-
-.post-body .footnotes {
- margin-top: 1.7em;
- font-size: 17px;
-}
-
-.post-body .footnotes h2.sr-only {
- position: static;
- width: auto;
- height: auto;
- overflow: visible;
- clip-path: none;
- white-space: normal;
-}
-
-.post-body .footnotes ol {
- margin: 0;
- padding: 0;
- list-style: none;
- counter-reset: fn;
-}
-
-.post-body .footnotes li {
- counter-increment: fn;
-}
-
-.post-body .footnotes li::before {
- content: "[fn:" counter(fn) "] ";
- color: var(--cyan);
- font: 400 13px var(--mono);
-}
-
-.post-body .footnotes li > p {
- display: inline;
-}
-
-/* imenu-list: the post's headings in a window beside it */
-.post-toc {
- margin: 0 0 48px;
- font-size: 12px;
- line-height: 1.5;
-}
-
-.post-toc .winbar {
- margin-bottom: 14px;
-}
-
-.post-toc ol {
- margin: 0;
- padding: 0;
- list-style: none;
-}
-
-.post-toc li.d3 {
- padding-left: 2ch;
-}
-
-.post-toc a {
- display: block;
- padding: 3px 0 3px 1ch;
- border-left: 2px solid transparent;
- color: var(--dim);
- text-decoration: none;
-}
-
-.post-toc a:hover {
- color: var(--fg);
-}
-
-.post-toc a[aria-current="true"] {
- border-left-color: var(--blue);
- color: var(--fg);
-}
-
-.post-toc .mk {
- color: var(--faint);
-}
-
-@media (min-width: 1100px) {
- .post-cols.has-toc {
- display: grid;
- grid-template-columns: minmax(0, 680px) minmax(180px, 1fr);
- column-gap: 64px;
- align-items: start;
- }
-
- .post-cols.has-toc .post-body {
- grid-row: 1;
- grid-column: 1;
- }
-
- .post-cols.has-toc .post-toc {
- position: sticky;
- top: calc(var(--top-h) + 32px);
- grid-row: 1;
- grid-column: 2;
- max-height: calc(100vh - var(--top-h) - var(--frame-h) - 64px);
- margin: 0;
- overflow-y: auto;
- }
-}
-
-.post-nav {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 24px;
- max-width: 680px;
- margin: 96px 0 0;
- padding-top: 20px;
- border-top: 1px solid var(--rule);
- font-size: 13px;
- line-height: 1.5;
-}
-
-.post-nav a {
- display: flex;
- flex-direction: column;
- gap: 2px;
- text-decoration: none;
-}
-
-.post-nav a:hover {
- text-decoration: underline;
-}
-
-.post-nav .dim {
- font-size: 12px;
-}
-
-.post-nav .newer {
- grid-column: 2;
- text-align: right;
-}
-
-.post-all {
- margin: 20px 0 0;
- font-size: 12px;
-}
-
-.giscus {
- max-width: 760px;
-}
-
-.giscus-note {
- margin: 16px 0 0;
- font-size: 12px;
-}
-
-.missing-links {
- display: flex;
- gap: 3ch;
- margin: 0;
- font-size: 13px;
-}
-
-/* ───────────────────────────── responsive ───────────────────────────── */
-
-@media (max-width: 1100px) {
- .hero {
- grid-template-columns: minmax(0, 1fr);
- align-items: start;
- }
-
- .hero-id {
- max-width: 640px;
- }
-}
-
-@media (max-width: 920px) {
- .nav {
- display: none;
- }
-
- .nav.is-open {
- position: absolute;
- top: calc(var(--top-h) - 4px);
- right: var(--gutter);
- display: flex;
- flex-direction: column;
- gap: 10px;
- min-width: 18ch;
- padding: 14px 18px;
- border: 1px solid var(--faint);
- background: var(--bg);
- }
-
- .menu-key {
- display: block;
- }
-
- .ds-grid,
- .mm-cols,
- .contact-grid {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .ds-grid {
- gap: 0;
- }
-
- .pinout {
- max-width: 420px;
- }
-
- .mm-detail {
- min-height: 0;
- margin: 4px 0 8px;
- padding: 20px 0 20px 12px;
- border-left: 0;
- border-bottom: 1px solid var(--rule);
- }
-
- .ml-enc,
- .ml-vc,
- .ml-time,
- .ml-pos {
- display: none;
- }
-}
-
-@media (max-width: 640px) {
- .board {
- --led: 8px;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
- grid-template-areas:
- "bus bus"
- "left right"
- "ctrl ctrl";
- font-size: 10.5px;
- }
-
- .bus {
- flex-direction: row;
- gap: 1.5ch;
- padding: 6px 10px;
- }
-
- .bus .mod-head,
- .bus-val {
- margin: 0;
- }
-
- .lanes {
- flex-direction: column;
- justify-content: center;
- height: 26px;
- gap: 1px;
- }
-
- .lane {
- width: auto;
- height: 2px;
- }
-
- .mod {
- padding: 6px 8px 8px;
- }
-
- .ram-row {
- --led: 6px;
- grid-template-columns: 1.5ch 2ch auto;
- }
-
- .ram-row .dis {
- display: none;
- }
-
- .seg7 svg {
- width: 16px;
- height: 28px;
- }
-
- .sim-field {
- margin-left: 0;
- }
-
- .sec {
- padding-top: 104px;
- }
-
- .winbar {
- margin-bottom: 40px;
- }
-
- .lede,
- .prose {
- font-size: 17px;
- }
-
- .ds-head {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .mm {
- --mm-cols: 7ch 6ch minmax(0, 1fr);
- }
-
- .mm-head span:last-child,
- .proj-row .y {
- display: none;
- }
-
- .bc-cols {
- grid-template-columns: minmax(0, 1fr);
- }
-
- .gitlog {
- padding: 12px;
- font-size: 11.5px;
- }
-
- .gitlog-cmd {
- font-size: 11.5px;
- }
-
- .org-tags {
- display: none;
- }
-
- .help-cols {
- grid-template-columns: minmax(0, 1fr);
- gap: 24px;
- }
-
- .ml-pct {
- display: none;
- }
-
- .coffee {
- right: 0;
- left: 0;
- width: auto;
- margin: 0 8px;
- flex-direction: column;
- gap: 8px;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- html {
- scroll-behavior: auto;
- }
-
- *,
- *::before,
- *::after {
- transition: none !important;
- }
-}
diff --git a/src/styles/themes.css b/src/styles/themes.css
@@ -0,0 +1,110 @@
+/* Themes: real Emacs themes. Each sets a background, a modeline background,
+ a foreground and seven accents; everything else is mixed from those.
+ Keep the names in sync with src/scripts/themes.js and the script in src/layouts/Base.astro. */
+
+:root {
+ color-scheme: dark;
+ --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
+ --serif: "Computer Modern", "Latin Modern Roman", "CMU Serif", Georgia, serif;
+
+ --gutter: clamp(16px, 4.5vw, 56px);
+ --top-h: 44px;
+ --frame-h: 46px;
+ --col: 1088px;
+
+ --bg: #282828;
+ --bg-alt: #3c3836;
+ --fg: #ebdbb2;
+ --red: #fb4934;
+ --orange: #fe8019;
+ --yellow: #fabd2f;
+ --green: #b8bb26;
+ --cyan: #8ec07c;
+ --blue: #83a598;
+ --magenta: #d3869b;
+ --glow: 1;
+
+ --dim: color-mix(in srgb, var(--fg) 64%, var(--bg));
+ --faint: color-mix(in srgb, var(--fg) 34%, var(--bg));
+ --rule: color-mix(in srgb, var(--fg) 15%, var(--bg));
+ --hl: color-mix(in srgb, var(--fg) 10%, var(--bg));
+ --wash: color-mix(in srgb, var(--fg) 5%, transparent);
+ --led-off: color-mix(in srgb, var(--fg) 13%, var(--bg));
+ --violet: color-mix(in oklch, var(--blue) 50%, var(--magenta));
+
+ --c-hw: var(--yellow);
+ --c-sw: var(--blue);
+ --c-math: var(--magenta);
+ --c-sec: var(--red);
+}
+
+:root[data-theme="doom-gruvbox-light"] {
+ color-scheme: light;
+ --bg: #fbf1c7; --bg-alt: #ebdbb2; --fg: #3c3836;
+ --red: #9d0006; --orange: #af3a03; --yellow: #b57614; --green: #79740e;
+ --cyan: #427b58; --blue: #076678; --magenta: #8f3f71;
+ --glow: 0;
+}
+
+:root[data-theme="doom-one"] {
+ --bg: #282c34; --bg-alt: #21242b; --fg: #bbc2cf;
+ --red: #ff6c6b; --orange: #da8548; --yellow: #ecbe7b; --green: #98be65;
+ --cyan: #46d9ff; --blue: #51afef; --magenta: #c678dd;
+}
+
+:root[data-theme="atom-one-dark"] {
+ --bg: #282c34; --bg-alt: #21252b; --fg: #abb2bf;
+ --red: #e06c75; --orange: #d19a66; --yellow: #e5c07b; --green: #98c379;
+ --cyan: #56b6c2; --blue: #61afef; --magenta: #c678dd;
+}
+
+:root[data-theme="modus-vivendi"] {
+ --bg: #000000; --bg-alt: #1e1e1e; --fg: #ffffff;
+ --red: #ff5f59; --orange: #fec43f; --yellow: #d0bc00; --green: #44bc44;
+ --cyan: #00d3d0; --blue: #2fafff; --magenta: #feacd0;
+}
+
+:root[data-theme="modus-operandi"] {
+ color-scheme: light;
+ --bg: #ffffff; --bg-alt: #f2f2f2; --fg: #000000;
+ --red: #a60000; --orange: #884900; --yellow: #6f5500; --green: #006800;
+ --cyan: #005e8b; --blue: #0031a9; --magenta: #721045;
+ --glow: 0;
+}
+
+:root[data-theme="doom-dracula"] {
+ --bg: #282a36; --bg-alt: #1e2029; --fg: #f8f8f2;
+ --red: #ff5555; --orange: #ffb86c; --yellow: #f1fa8c; --green: #50fa7b;
+ --cyan: #8be9fd; --blue: #61bfff; --magenta: #ff79c6;
+}
+
+:root[data-theme="doom-nord"] {
+ --bg: #2e3440; --bg-alt: #3b4252; --fg: #eceff4;
+ --red: #bf616a; --orange: #d08770; --yellow: #ebcb8b; --green: #a3be8c;
+ --cyan: #88c0d0; --blue: #81a1c1; --magenta: #b48ead;
+}
+
+:root[data-theme="catppuccin"] {
+ --bg: #1e1e2e; --bg-alt: #181825; --fg: #cdd6f4;
+ --red: #f38ba8; --orange: #fab387; --yellow: #f9e2af; --green: #a6e3a1;
+ --cyan: #94e2d5; --blue: #89b4fa; --magenta: #cba6f7;
+}
+
+:root[data-theme="doom-tokyo-night"] {
+ --bg: #1a1b26; --bg-alt: #16161e; --fg: #c0caf5;
+ --red: #f7768e; --orange: #ff9e64; --yellow: #e0af68; --green: #9ece6a;
+ --cyan: #7dcfff; --blue: #7aa2f7; --magenta: #bb9af7;
+}
+
+:root[data-theme="doom-solarized-light"] {
+ color-scheme: light;
+ --bg: #fdf6e3; --bg-alt: #eee8d5; --fg: #556b72;
+ --red: #dc322f; --orange: #cb4b16; --yellow: #b58900; --green: #859900;
+ --cyan: #2aa198; --blue: #268bd2; --magenta: #d33682;
+ --glow: 0;
+}
+
+[data-cat="hw"] { --c: var(--c-hw); }
+[data-cat="sw"] { --c: var(--c-sw); }
+[data-cat="math"] { --c: var(--c-math); }
+[data-cat="sec"] { --c: var(--c-sec); }