commit 2ee7df5049ad48d222efe9140e44799180aef1a9
parent 1d8aa56a6889eba3c4fd4a35cdae794b63dc7985
Author: Michael Neuper <michael@michaelneuper.com>
Date: Sun, 25 Oct 2026 13:30:11 +0200
create new hero section
Diffstat:
13 files changed, 1442 insertions(+), 398 deletions(-)
diff --git a/README.md b/README.md
@@ -1,5 +1,3 @@
-# michaelneuper.com
-
My personal site built with [Astro](https://astro.build).
## Running it
@@ -20,21 +18,21 @@ npm run preview # serves dist/ at http://localhost:4321
## Where things are
-| 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*`, and `Buffer`, a section with its winbar |
-| `src/components/home/` | the home page's sections: hero (on a circuit board routed from the chip's pins), about, projects, posts, contact, and the coffee |
-| `src/components/post/` | a post as an Org buffer: keywords, text, the `*Ilist*` outline, comments |
-| `src/components/missing/` | the 404 page: its status code in binary, and the `*dungeon*` text adventure |
-| `src/content/posts/` | the posts, in Markdown |
-| `src/content/projects/` | the projects on the wheel, 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 |
+| 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*`, and `Buffer`, a section with its winbar |
+| `src/components/home/` | the home page's sections: hero (my name in front of a wall of my C), about, projects, posts, contact, and the coffee |
+| `src/components/post/` | a post as an Org buffer: keywords, text, the `*Ilist*` outline, comments |
+| `src/components/missing/` | the 404 page: its status code in binary, and the `*dungeon*` text adventure |
+| `src/content/posts/` | the posts, in Markdown |
+| `src/content/projects/` | the projects on the wheel, one Markdown file each |
+| `src/data/wall.js` | the C on the wall behind the name, copied into `src/data/wall/` from my repos |
+| `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/`.
diff --git a/src/components/home/Hero.astro b/src/components/home/Hero.astro
@@ -1,42 +1,33 @@
---
// 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";
+// (src/scripts/home/decode.js), in front of a wall of my C code
+// (src/scripts/home/wall.js, with the code in src/data/wall.js). data-file
+// follows the window the wall's cursor is in.
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"></div>
- <figcaption>Figure 1. MN-2026, DIP-16 package, top view.<span class="pin-hint"> Point at a pin to read its function.</span></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>
+<section class="hero" id="top" data-file="cube.c" data-mode="C" aria-labelledby="hero-name">
+ <canvas class="hero-wall" aria-hidden="true"></canvas>
+ <div class="hero-in">
+ <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>
</section>
<script>
import { initDecode } from "../../scripts/home/decode.js";
- import { initPinout } from "../../scripts/home/pinout.js";
+ import { initWall } from "../../scripts/home/wall.js";
initDecode().catch((err) => {
console.error(err);
document.documentElement.classList.remove("boot");
});
- initPinout();
+ initWall().catch((err) => console.error(err));
</script>
diff --git a/src/data/pins.js b/src/data/pins.js
@@ -1,22 +0,0 @@
-// 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/data/wall.js b/src/data/wall.js
@@ -0,0 +1,43 @@
+// The code wall behind the name in the landing section (src/scripts/home/wall.js):
+// three of my C programs, one per window, as committed on GitHub. Each file in
+// wall/ starts below the file's header comment, which has my email address in
+// it: start is the line it starts on and rev the commit it was copied from, so
+// the line numbers match the file on GitHub. To refresh one, copy it again from
+// that line and update rev:
+//
+// git -C ~/Developer/life show HEAD:life.c | tail -n +8 > src/data/wall/life.c
+//
+// top is the line the window opens at, and edits are what the ghost cursor may
+// change: the word in the first line containing line, and what it types instead.
+
+import life from "./wall/life.c?raw";
+import cube from "./wall/cube.c?raw";
+import layerkey from "./wall/layerkey.c?raw";
+
+export const WALL = [
+ {
+ repo: "life", path: "life.c", rev: "c80091d4748c63f34b4320d8fcd395a85ab7fba2", start: 8, text: life, top: 6,
+ edits: [
+ { line: "#define SLEEP_MSEC 225", word: "225", text: "200" },
+ { line: "rnd > 0.8 ? ALIVE", word: "0.8", text: "0.75" },
+ { line: "init_pair(CURS_COL, COLOR_BLUE", word: "COLOR_BLUE", text: "COLOR_GREEN" },
+ { line: "Something terrible has happened.", word: "terrible", text: "unexpected" },
+ ],
+ },
+ {
+ repo: "cube", path: "src/cube.c", rev: "f62e55bd04e7e924e2e39d2e84f1a9116b918be3", start: 9, text: cube, top: 33,
+ edits: [
+ { line: "cam_distance = 50.0", word: "50.0", text: "40.0" },
+ { line: "zoom = 500.0", word: "500.0", text: "420.0" },
+ { line: "scale_x = 2.0", word: "2.0", text: "2.2" },
+ ],
+ },
+ {
+ repo: "layerkey", path: "layerkey.c", rev: "78b12073b405c26cb6b5dd2ede4f2bb251928959", start: 12, text: layerkey, top: 30,
+ edits: [
+ { line: "bsearch(&ch, keys, 31,", word: "31", text: "32" },
+ { line: "Unable to open config file.", word: "Unable", text: "Failed" },
+ { line: "Symbol Layer", word: "Symbol", text: "Symbols" },
+ ],
+ },
+];
diff --git a/src/data/wall/cube.c b/src/data/wall/cube.c
@@ -0,0 +1,142 @@
+#include <math.h>
+#include <ncurses.h>
+#include <stdlib.h>
+
+#include "cube.h"
+#include "debug.h"
+#include "ncdraw.h"
+
+static const double cam_distance = 50.0;
+static const double zoom = 500.0;
+static const double scale_x = 2.0;
+static const double scale_y = 1.0;
+
+static inline double trans_x(double x) {
+ return x + getmaxx(stdscr) / 2.0;
+}
+static inline double trans_y(double y) {
+ return y + getmaxy(stdscr) / 2.0;
+}
+
+/**
+ * Represents a projected point in 2D screen-space.
+ */
+typedef struct {
+ double x, y;
+} Point2D;
+
+/** Converts a projected 2D point to an ncurses screen pixel. */
+static Pixel point_to_pixel(Point2D p);
+/** Applies perspective projection to a 3D point, returning its 2D screen
+ * position. */
+static Point2D project_point(Point3D p);
+/** Rotates a 3D point around the given axis by angle radians. */
+static Point3D rotate_point(Point3D p, Axis3D axis, double angle);
+
+Cube *create_cube() {
+ Cube *cube = malloc(sizeof(Cube));
+
+ if (!cube) return NULL;
+
+ // clang-format off
+ *cube = (Cube){
+ .vertices = {
+ { -1, -1, -1 }, // 1-------3
+ { -1, -1, 1 }, // /| /|
+ { -1, 1, -1 }, // / | / |
+ { -1, 1, 1 }, // 5-------7 |
+ { 1, -1, -1 }, // | 0----|--2
+ { 1, -1, 1 }, // | / | /
+ { 1, 1, -1 }, // |/ |/
+ { 1, 1, 1 }, // 4-------6
+ },
+ .edges = {
+ { 0, 1 }, { 0, 2 }, { 0, 4 },
+ { 1, 3 }, { 1, 5 },
+ { 2, 3 }, { 2, 6 },
+ { 3, 7 }, // top line
+ { 4, 5 },
+ { 4, 6 },
+ { 5, 7 },
+ { 6, 7 },
+ },
+ };
+ // clang-format on
+
+ return cube;
+}
+
+void destroy_cube(Cube *cube) {
+ free(cube);
+ endwin();
+}
+
+void rotate_cube(Cube *cube, Axis3D axis, double angle) {
+ ASSERT_NOT_NULL(cube);
+
+ for (size_t i = 0; i < CUBE_VERTICES; i++) {
+ cube->vertices[i] = rotate_point(cube->vertices[i], axis, angle);
+ }
+}
+
+void draw_cube(const Cube *cube) {
+ ASSERT_NOT_NULL(cube);
+
+ for (size_t i = 0; i < CUBE_EDGES; i++) {
+ Edge e = cube->edges[i];
+
+ Point2D p1 = project_point(cube->vertices[e.v1]);
+ Point2D p2 = project_point(cube->vertices[e.v2]);
+
+ draw_line(point_to_pixel(p1), point_to_pixel(p2));
+ }
+}
+
+static Pixel point_to_pixel(Point2D p) {
+ float x = trans_x(scale_x * p.x);
+ float y = trans_y(scale_y * -p.y);
+
+ if (x < 0) x = 0;
+ if (y < 0) y = 0;
+ if (x >= getmaxx(stdscr)) x = getmaxx(stdscr) - 1;
+ if (y >= getmaxy(stdscr)) y = getmaxy(stdscr) - 1;
+
+ return (Pixel){
+ .row = y,
+ .col = x,
+ };
+}
+
+static Point2D project_point(Point3D p) {
+ double depth = p.z + cam_distance;
+
+ return (Point2D){
+ .x = zoom * p.x / depth,
+ .y = zoom * p.y / depth,
+ };
+}
+
+static Point3D rotate_point(Point3D p, Axis3D axis, double angle) {
+ switch (axis) {
+ case X:
+ return (Point3D){
+ .x = p.x,
+ .y = p.y * cos(angle) - p.z * sin(angle),
+ .z = p.y * sin(angle) + p.z * cos(angle),
+ };
+
+ case Y:
+ return (Point3D){
+ .x = p.x * cos(angle) + p.z * sin(angle),
+ .y = p.y,
+ .z = -p.x * sin(angle) + p.z * cos(angle),
+ };
+
+ case Z:
+ return (Point3D){
+ .x = p.x * cos(angle) - p.y * sin(angle),
+ .y = p.x * sin(angle) + p.y * cos(angle),
+ .z = p.z,
+ };
+ }
+}
diff --git a/src/data/wall/layerkey.c b/src/data/wall/layerkey.c
@@ -0,0 +1,324 @@
+#include <assert.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* --- preprocessor directives ---------------------------------------------- */
+
+#define ALPHA_LEN 36
+#define ERR_EXIT(msg) \
+ do { \
+ fprintf(stderr, "%s\n", (msg)); \
+ exit(EXIT_FAILURE); \
+ } while (0)
+
+/* --- types ---------------------------------------------------------------- */
+
+typedef struct {
+ int index;
+ char key;
+} KbKey;
+
+typedef struct {
+ const char key;
+ const char *kcode;
+ const bool shift;
+} Key;
+
+/* --- global variables ----------------------------------------------------- */
+
+/** output file: `~/.config/layer.json` */
+FILE *outp;
+
+// TODO read keyboard from config file
+const char *keyboard =
+ ",---,---,---,---,---,---,---,---,---,---,---,---,---,-------,\n"
+ "| | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 0 | | | |\n"
+ "|---'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-'-,-----|\n"
+ "| | q | w | e | r | t | y | u | i | o | p | | | |\n"
+ "|-----',--',--',--',--',--',--',--',--',--',--',--',--'-----|\n"
+ "| | a | s | d | f | g | h | j | k | l | | | |\n"
+ "|------'-,-'-,-'-,-'-+-'-,-'-,-'-+-'-,-'-,-'-,-'-,-'------,-'\n"
+ "| | z | x | c | v | b | n | m | | | | |\n"
+ "|---,----',--'--,'---'---'---'---'---',--'--,'---'------,-'\n"
+ "| | | | | | | |\n"
+ "'---'-----'-----'---------------------'-----'----'------'\n";
+
+/** index of each key in keyboard */
+KbKey indices[ALPHA_LEN];
+
+/** karabiner key codes sorted in lexicographic order */
+// TODO add custom field for length
+const Key keys[] = {
+ {'!', "1", true},
+ {'"', "quote", true},
+ {'#', "3", true},
+ {'$', "4", true},
+ {'%', "5", true},
+ {'&', "7", true},
+ {'(', "9", true},
+ {')', "0", true},
+ {'*', "8", true},
+ {'+', "equal_sign", true},
+ {',', "comma", false},
+ {'-', "hyphen", false},
+ {'.', "period", false},
+ {'/', "slash", false},
+ {':', "semicolon", true},
+ {';', "semicolon", false},
+ {'<', "comma", true},
+ {'=', "equal_sign", false},
+ {'>', "period", true},
+ {'?', "slash", true},
+ {'@', "2", true},
+ {'[', "open_bracket", false},
+ {'\'', "quote", false},
+ {'\\', "backslash", false},
+ {']', "close_bracket", false},
+ {'^', "6", true},
+ {'_', "hyphen", true},
+ {'`', "grave_accent_and_tilde", false},
+ {'{', "open_bracket", true},
+ {'|', "backslash", true},
+ {'}', "close_bracket", true},
+ {'~', "grave_accent_and_tilde", true},
+};
+
+/* --- function prototypes -------------------------------------------------- */
+
+/**
+ * Writes the karabiner rules to a buffer.
+ *
+ * @param keycode the key code of the key that activates the symbol layer
+ */
+void write_krules(const char *keycode);
+
+/**
+ * Writes the karabiner rule for an individual key.
+ *
+ * @param from the key that gets remapped, e.g., 'f'
+ * @param to the key that `from` should be remapped to
+ */
+void write_krule(const char from, const Key *to);
+
+/**
+ * Writes the karabiner rule for toggling the layer.
+ *
+ * @param keycode the key code of the key that activates the symbol layer
+ */
+void write_ktoggle(const char *keycode);
+
+/** builds the indices array */
+void build_key_indices();
+
+/** print json to file */
+void print_json(const char *, ...);
+
+/** compares two key codes */
+int keycmp(const void *, const void *);
+
+/* --- main routine --------------------------------------------------------- */
+
+int main(int argc, char **argv)
+{
+ if (argc != 2) {
+ ERR_EXIT("Usage: `layerkey <keycode>`\n"
+ "where `<keycode>` is the keycode of the key that should "
+ "activate the layer.");
+ }
+
+ // TODO handle error
+ outp = fopen("layer.json", "w");
+
+ build_key_indices();
+
+ write_krules(argv[1]);
+
+ return EXIT_SUCCESS;
+}
+
+/* --- helper routines ------------------------------------------------------ */
+
+void write_krules(const char *keycode)
+{
+ FILE *configp;
+
+ print_json("{\n"
+ " \"description\": \"Symbol Layer\",\n"
+ " \"manipulators\": [\n");
+
+ write_ktoggle(keycode);
+
+ // see
+ // <https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFPreferences/Tasks/UsingHighAPI.html>
+ configp = fopen("config", "r");
+
+ if (!configp) {
+ fprintf(stderr, "Unable to open config file.");
+ goto error;
+ }
+
+ for (size_t i = 0; i < ALPHA_LEN; ++i) {
+ // TODO handle errors
+ fseek(configp, indices[i].index, SEEK_SET);
+ int ch = fgetc(configp);
+
+ assert(isprint(ch));
+
+ // only create an entry if the key was changed
+ if (ch != indices[i].key) {
+ // FIXME magic number
+ // TODO handle errors
+ Key *key = bsearch(&ch, keys, 31, sizeof(Key), keycmp);
+
+ write_krule(indices[i].key, key);
+ }
+ }
+
+ // TODO remove final trailing comma
+ print_json(" ]\n"
+ "}\n");
+
+ fclose(configp);
+ return;
+
+error:
+ if (configp) fclose(configp);
+ exit(1);
+}
+
+void write_krule(const char from, const Key *to)
+{
+ const char *json =
+ " {\n"
+ " \"conditions\": [\n"
+ " {\n"
+ " \"name\": \"layer\",\n"
+ " \"type\": \"variable_if\",\n"
+ " \"value\": 0\n"
+ " }\n"
+ " ],\n"
+ " \"from\": {\n"
+ " \"key_code\": \"%c\",\n" // from->key
+ " \"modifiers\": {\n"
+ " \"optional\": [\n"
+ " \"shift\",\n"
+ " \"command\",\n"
+ " \"control\",\n"
+ " \"option\",\n"
+ " \"fn\"\n"
+ " ]\n"
+ " }\n"
+ " },\n"
+ " },\n"
+ " \"to\": [\n"
+ " {\n"
+ " \"key_code\": \"%s\",\n" // to->kcode
+ " %s\n" // to->shift (modifier)
+ " }\n"
+ " ],\n"
+ " \"type\": \"basic\"\n"
+ " },\n";
+
+ const char *modifier = to->shift
+ ? "\"modifiers\": [\"shift\"]"
+ // FIXME remove trailing comma
+ : "\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b\b";
+
+ print_json(json, from, to->kcode, modifier);
+}
+
+void write_ktoggle(const char *keycode)
+{
+ print_json(
+ " {\n"
+ " \"conditions\": [\n"
+ " {\n"
+ " \"identifiers\": [\n"
+ " {\n"
+ " \"description\": \"Built-in keyboard\",\n"
+ " \"is_built_in_keyboard\": true\n"
+ " }\n"
+ " ],\n"
+ " \"type\": \"device_if\"\n"
+ " },\n"
+ " {\n"
+ " \"name\": \"layer\",\n"
+ " \"type\": \"variable_if\",\n"
+ " \"value\": 0\n"
+ " }\n"
+ " ],\n"
+ " \"from\": {\n"
+ " \"key_code\": \"%s\",\n" // toggle key
+ " \"modifiers\": {\n"
+ " \"optional\": [\n"
+ " \"shift\",\n"
+ " \"command\",\n"
+ " \"control\",\n"
+ " \"option\",\n"
+ " \"fn\"\n"
+ " ]\n"
+ " }\n"
+ " },\n"
+ " \"to\": [\n"
+ " {\n"
+ " \"set_variable\": {\n"
+ " \"name\": \"layer\",\n"
+ " \"value\": 1\n"
+ " }\n"
+ " }\n"
+ " ],\n"
+ " \"to_after_key_up\": [\n"
+ " {\n"
+ " \"set_variable\": {\n"
+ " \"name\": \"layer\",\n"
+ " \"value\": 0\n"
+ " }\n"
+ " }\n"
+ " ],\n"
+ " \"type\": \"basic\"\n"
+ " },\n",
+ keycode);
+}
+
+void build_key_indices()
+{
+ const size_t kblen = strlen(keyboard);
+ size_t k = ALPHA_LEN;
+
+ // TODO skip whitespace
+ for (size_t i = 0; i < kblen; ++i) {
+ if (!isalnum(keyboard[i])) continue;
+
+ indices[ALPHA_LEN - (k--)] = (KbKey){
+ .index = i,
+ .key = keyboard[i],
+ };
+ }
+
+#ifdef DEBUG
+ printf("key\tindex\n");
+ for (int i = 0; i < ALPHA_LEN; ++i) {
+ printf("%d\t%c\n", indices[i].index, indices[i].key);
+ }
+#endif
+}
+
+void print_json(const char *fmt, ...)
+{
+ va_list args;
+ va_start(args, fmt);
+ vfprintf(outp, fmt, args);
+ va_end(args);
+}
+
+int keycmp(const void *keycode1p, const void *keycode2p)
+{
+ const int key1 = ((Key *)keycode1p)->key;
+ const int key2 = ((Key *)keycode2p)->key;
+
+ return (key1 < key2) ? -1 : (key1 > key2);
+}
diff --git a/src/data/wall/life.c b/src/data/wall/life.c
@@ -0,0 +1,305 @@
+#include <ncurses.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+#define CURS_COL 1
+#define SLEEP_MSEC 225
+
+/* --- type definitions ----------------------------------------------------- */
+
+typedef enum {
+ DEAD = 0,
+ ALIVE = 1,
+} State;
+
+typedef struct {
+ size_t rows, cols;
+ State **cells;
+} Grid;
+
+/* --- helper macros -------------------------------------------------------- */
+
+#define MSEC_TO_NSEC(nsec) ((nsec) * 1000000)
+#define TOGGLE(a) ((a) = !(a))
+#define MIN(a, b) ((a) < (b) ? (a) : (b))
+#define MAX(a, b) ((a) > (b) ? (a) : (b))
+
+/* --- function prototypes: game logic -------------------------------------- */
+
+void game_loop();
+void tick(const Grid *cur, Grid *next);
+size_t count_neighbours(const Grid *grid, size_t row, size_t col);
+
+/* --- function prototypes: drawing ----------------------------------------- */
+
+void init_screen(void);
+void cleanup(void);
+void print_char(size_t row, size_t col, char ch, int color_pair);
+
+/* --- function prototypes: grid -------------------------------------------- */
+
+Grid *init_grid(size_t rows, size_t cols);
+void free_grid(Grid *grid);
+void print_grid(const Grid *grid);
+void set_grid(Grid *grid);
+void randomize_grid(Grid *grid);
+
+/* --- main routine --------------------------------------------------------- */
+
+int main(void) {
+ init_screen();
+ game_loop();
+ cleanup();
+
+ return EXIT_SUCCESS;
+}
+
+/* -------------------------------------------------------------------------- */
+
+void game_loop() {
+ int key;
+ bool paused = false;
+ size_t rows = getmaxy(stdscr) + 1;
+ size_t cols = getmaxx(stdscr) + 1;
+ Grid *cur = init_grid(rows, cols);
+ Grid *next = init_grid(rows, cols);
+
+ if (!(cur && next)) {
+ cleanup();
+ if (cur) free_grid(cur);
+ if (next) free_grid(next);
+ fprintf(stderr, "Something terrible has happened.\n");
+ exit(1);
+ }
+
+ struct timespec ts;
+ ts.tv_sec = 0;
+ ts.tv_nsec = MSEC_TO_NSEC(SLEEP_MSEC);
+
+ set_grid(cur);
+
+ while ((key = getch()) != 'q') {
+ if (key == 'e') {
+ paused = true;
+ set_grid(cur);
+ print_grid(cur);
+ }
+
+ if (key == ' ' || key == 'p') TOGGLE(paused);
+ if (key == '-' || key == '_') ts.tv_nsec *= 2; // decrease tick speed
+ if (key == '+' || key == '=') ts.tv_nsec /= 2; // increase tick speed
+
+ if (paused && key != 'n') continue;
+
+ tick(cur, next);
+
+ // swap the current grid with the next
+ Grid *temp = cur;
+ cur = next;
+ next = temp;
+
+ print_grid(cur);
+
+ if (!paused) nanosleep(&ts, NULL);
+ }
+
+ free_grid(cur);
+ free_grid(next);
+}
+
+void tick(const Grid *cur, Grid *next) {
+ for (size_t i = 0; i < cur->rows; i++) {
+ for (size_t j = 0; j < cur->cols; j++) {
+ size_t n = count_neighbours(cur, i, j);
+
+ if (cur->cells[i][j]) { // live cell
+ if (n < 2 || n > 3) {
+ next->cells[i][j] = DEAD;
+ } else {
+ next->cells[i][j] = ALIVE;
+ }
+ } else { // dead cell
+ if (n == 3) {
+ next->cells[i][j] = ALIVE;
+ } else {
+ next->cells[i][j] = DEAD;
+ }
+ }
+ }
+ }
+}
+
+size_t count_neighbours(const Grid *grid, size_t row, size_t col) {
+ size_t count = 0;
+
+ for (size_t i = MAX(0, row - 1); i < MIN(row + 1, grid->rows - 1) + 1;
+ i++) {
+ for (size_t j = MAX(0, col - 1); j < MIN(col + 1, grid->cols - 1) + 1;
+ j++) {
+ count += grid->cells[i][j];
+ }
+ }
+
+ return count - grid->cells[row][col];
+}
+
+void init_screen(void) {
+ initscr();
+ noecho();
+ start_color();
+ cbreak(); // disable line buffering
+ curs_set(0); // invisible cursor
+ nodelay(stdscr, TRUE); // make `getch()` non-blocking
+ keypad(stdscr, TRUE);
+
+ attron(A_BOLD);
+}
+
+void cleanup(void) {
+ attroff(A_BOLD);
+ endwin();
+}
+
+void print_char(size_t row, size_t col, char ch, int color_pair) {
+ attron(COLOR_PAIR(color_pair));
+ mvprintw(row, col, "%c", ch);
+ attroff(COLOR_PAIR(color_pair));
+}
+
+Grid *init_grid(size_t rows, size_t cols) {
+ State **cells = malloc(rows * sizeof(State *));
+ Grid *grid = malloc(sizeof(Grid));
+
+ if (!(grid && cells)) {
+ if (cells) free(cells);
+ if (grid) free(grid);
+
+ return NULL;
+ }
+
+ for (size_t i = 0; i < rows; i++) {
+ cells[i] = malloc(cols * sizeof(State));
+
+ if (!cells[i]) {
+ for (size_t j = 0; j < i; j++)
+ free(cells[i]);
+ free(cells);
+ free(grid);
+
+ return NULL;
+ }
+
+ // initialize all cells to DEAD
+ for (size_t j = 0; j < cols; j++) {
+ cells[i][j] = DEAD;
+ }
+ }
+
+ grid->cells = cells;
+ grid->rows = rows;
+ grid->cols = cols;
+
+ return grid;
+}
+
+void free_grid(Grid *grid) {
+ for (size_t i = 0; i < grid->rows; i++) {
+ free(grid->cells[i]);
+ }
+
+ free(grid->cells);
+ free(grid);
+}
+
+void print_grid(const Grid *grid) {
+ for (size_t i = 0; i < grid->rows; i++) {
+ for (size_t j = 0; j < grid->cols; j++) {
+ // print ' ' for dead cells to avoid having to clear the screen
+ print_char(i, j, grid->cells[i][j] ? '#' : ' ', 0);
+ }
+ }
+
+ refresh();
+}
+
+void set_grid(Grid *grid) {
+ bool paused = true;
+
+ struct {
+ size_t row, col;
+ } curs = {grid->rows / 2, grid->cols / 2};
+
+ init_pair(CURS_COL, COLOR_BLUE, COLOR_BLACK);
+ print_char(curs.row, curs.col, '*', CURS_COL);
+
+ while (paused) {
+ int key = getch();
+
+ switch (key) {
+ case KEY_UP:
+ case 'k':
+ case 'w':
+ curs.row--;
+ break;
+
+ case KEY_LEFT:
+ case 'h':
+ case 'a':
+ curs.col--;
+ break;
+
+ case KEY_DOWN:
+ case 'j':
+ case 's':
+ curs.row++;
+ break;
+
+ case KEY_RIGHT:
+ case 'l':
+ case 'd':
+ curs.col++;
+ break;
+
+ case 't':
+ TOGGLE(grid->cells[curs.row][curs.col]);
+ break;
+
+ case 'r':
+ randomize_grid(grid);
+ break;
+
+ case 'q':
+ free_grid(grid);
+ cleanup();
+ exit(0);
+
+ case '\n':
+ case '\r':
+ case ' ':
+ case 'e':
+ paused = false;
+
+ default:
+ continue;
+ }
+
+ // wrap cursor around screen
+ curs.row = (curs.row + grid->rows) % grid->rows;
+ curs.col = (curs.col + grid->cols) % grid->cols;
+
+ // re-render the screen
+ print_grid(grid);
+ print_char(curs.row, curs.col, '*', CURS_COL);
+ }
+}
+
+void randomize_grid(Grid *grid) {
+ for (size_t i = 0; i < grid->rows; i++) {
+ for (size_t j = 0; j < grid->cols; j++) {
+ double rnd = (double)rand() / RAND_MAX;
+
+ grid->cells[i][j] = rnd > 0.8 ? ALIVE : DEAD;
+ }
+ }
+}
diff --git a/src/scripts/home/coffee.js b/src/scripts/home/coffee.js
@@ -1,5 +1,5 @@
// 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
+// VCC (my coffee supply) 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";
diff --git a/src/scripts/home/pinout.js b/src/scripts/home/pinout.js
@@ -1,119 +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. VCC is the pin
-// shown to start with, and the readout is kept as tall as the longest pin's
-// text, so pointing at another pin never moves the page.
-
-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 btns = [];
-
- 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);
- }
- // Pointing shows a pin only with a mouse. A tap is a click: an iPhone takes a
- // tap that changes the page on hover as the hover, and needs a second for the click.
- btn.addEventListener("pointerenter", (e) => { if (e.pointerType === "mouse") show(p, btn); });
- btn.addEventListener("focus", () => show(p, btn));
- btn.addEventListener("click", () => show(p, btn));
- btns.push(btn);
- if (i < half) left.append(btn);
- else right.prepend(btn);
- });
-
- $(".chip-wrap", fig).append(chip);
- fig.classList.add("is-built");
-
- let current = pins[0];
- function show(p, btn) {
- $$(".pin.is-active", chip).forEach((b) => b.classList.remove("is-active"));
- btn.classList.add("is-active");
- current = p;
- render(p);
- }
-
- // Each pin's text in turn, measured at the readout's width; the tallest sets its height.
- let width = 0;
- function fit() {
- if (readout.clientWidth === width) return;
- width = readout.clientWidth;
- readout.style.minHeight = "";
- let tallest = 0;
- for (const p of pins) {
- render(p);
- tallest = Math.max(tallest, readout.offsetHeight);
- }
- readout.style.minHeight = `${tallest}px`;
- render(current);
- }
-
- show(pins[0], btns[0]);
- fit();
- new ResizeObserver(fit).observe(readout);
- // announced from now on, not the VCC text shown on load
- readout.setAttribute("aria-live", "polite");
-
- function render(p) {
- 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/wall.js b/src/scripts/home/wall.js
@@ -0,0 +1,569 @@
+// The code wall behind the name: three Emacs windows of my C (src/data/wall.js),
+// after the wall on hancore.dev. Each window scrolls at its own pace, and now
+// and then a ghost evil cursor changes a word, makes a typo, fixes it and undoes
+// the change. With a mouse, pointing at a line lights it. The wall is only
+// decoration: the canvas is aria-hidden, and without JavaScript the landing
+// section is the name and lede on their own.
+
+import { $, clamp, ease, on, reducedMotion, themeColors, frameWhileVisible, fitCanvas, fontsReady } from "../util.js";
+import { setSegment } from "../emacs/index.js";
+import { WALL } from "../../data/wall.js";
+
+const FONT_PX = 12;
+const FONT = `${FONT_PX}px "JetBrains Mono", ui-monospace, monospace`;
+const LH = 17;
+const NUM_W = 5; // the line numbers, display-line-numbers-width 4 and a space
+const GAP = 4; // blank lines between the end of a file and its start again
+const NARROW = 920; // one window below the width where the nav becomes a menu
+const FEATHER = 160; // how far the calm patch around the name fades out
+const CALM = 0.03; // what's left of the wall behind the name
+const TAB = 4; // the files' tab-width
+
+// doom-gruvbox's font-lock, faint: [colour, alpha]
+const KIND = {
+ keyword: ["red", 0.5], string: ["green", 0.46], number: ["magenta", 0.5], constant: ["magenta", 0.46],
+ function: ["yellow", 0.46], type: ["blue", 0.46], ident: ["fg", 0.36], member: ["fg", 0.3], punct: ["fg", 0.24],
+ comment: ["dim", 0.42],
+};
+
+// ── reading C, a line at a time ───────────────────────────────────────
+
+const KEYWORDS = new Set(("if else for while do switch case default break continue return goto sizeof " +
+ "typedef struct enum union static const extern inline volatile register").split(" "));
+const TYPES = new Set("void char short int long float double signed unsigned bool size_t FILE".split(" "));
+const CONSTANTS = new Set("true false NULL TRUE FALSE".split(" "));
+
+// inBlock: the line starts inside a /* */ comment (see blockComments).
+function tokenize(line, inBlock) {
+ const out = [];
+ let rest = line;
+ if (inBlock) {
+ const end = line.indexOf("*/");
+ if (end < 0) return [[line, "comment"]];
+ out.push([line.slice(0, end + 2), "comment"]);
+ rest = line.slice(end + 2);
+ }
+ const pre = !inBlock && rest.match(/^(\s*)(#\s*\w+)(\s*)(<[^>]*>)?(.*)$/);
+ if (pre) {
+ if (pre[1]) out.push([pre[1], "space"]);
+ out.push([pre[2], "keyword"]);
+ if (pre[3]) out.push([pre[3], "space"]);
+ if (pre[4]) out.push([pre[4], "string"]);
+ rest = pre[5];
+ }
+ const re = /(\/\/.*$|\/\*.*?(?:\*\/|$))|("(?:[^"\\]|\\.)*"?|'(?:[^'\\]|\\.)*'?)|(\b0x[0-9a-fA-F]+\b|\b\d+(?:\.\d+)?[fFuUlL]*\b)|([A-Za-z_]\w*)|(\s+)|([^\s])/g;
+ let m;
+ while ((m = re.exec(rest))) {
+ if (m[1]) out.push([m[1], "comment"]);
+ else if (m[2]) out.push([m[2], "string"]);
+ else if (m[3]) out.push([m[3], "number"]);
+ else if (m[4]) {
+ const w = m[4];
+ let kind = "ident";
+ if (KEYWORDS.has(w)) kind = "keyword";
+ else if (TYPES.has(w) || /_t$/.test(w)) kind = "type";
+ else if (CONSTANTS.has(w)) kind = "constant";
+ else if (/^\s*\(/.test(rest.slice(re.lastIndex))) kind = "function";
+ else if (/(\.|->)$/.test(rest.slice(0, m.index))) kind = "member";
+ else if (/^[A-Z][A-Z0-9_]+$/.test(w)) kind = "constant";
+ else if (/^[A-Z]/.test(w)) kind = "type";
+ out.push([w, kind]);
+ } else if (m[5]) out.push([m[5], "space"]);
+ else out.push([m[6], "punct"]);
+ }
+ return out;
+}
+
+// For each line, whether it starts inside a /* */ comment.
+function blockComments(lines) {
+ let open = false;
+ return lines.map((line) => {
+ const start = open;
+ let i = 0;
+ while (i < line.length) {
+ if (open) {
+ const end = line.indexOf("*/", i);
+ if (end < 0) break;
+ open = false;
+ i = end + 2;
+ } else {
+ const m = line.slice(i).match(/\/\*|\/\/|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/);
+ if (!m || m[0] === "//") break;
+ i += m.index + m[0].length;
+ if (m[0] === "/*") open = true;
+ }
+ }
+ return start;
+ });
+}
+
+// Tabs to spaces, to the next tab stop.
+function untab(line) {
+ let out = "";
+ for (const ch of line) out += ch === "\t" ? " ".repeat(TAB - (out.length % TAB)) : ch;
+ return out;
+}
+
+// ── typos ─────────────────────────────────────────────────────────────
+
+const KEY_ROWS = ["1234567890-=", "qwertyuiop[]", "asdfghjkl;'", "zxcvbnm,./"];
+
+// A key next to ch on the keyboard, the kind of key a finger slips onto.
+function nearKey(ch) {
+ const lower = ch.toLowerCase();
+ for (const row of KEY_ROWS) {
+ const i = row.indexOf(lower);
+ if (i < 0) continue;
+ const opts = [row[i - 1], row[i + 1]].filter(Boolean);
+ const k = opts[Math.floor(Math.random() * opts.length)];
+ return ch === lower ? k : k.toUpperCase();
+ }
+ return null;
+}
+
+const mod = (a, n) => ((a % n) + n) % n;
+const smooth = (x) => {
+ const t = clamp(x, 0, 1);
+ return t * t * (3 - 2 * t);
+};
+
+export async function initWall() {
+ const hero = $(".hero");
+ const canvas = $(".hero-wall");
+ const id = $(".hero-id");
+ if (!hero || !canvas || !id) return;
+
+ // While the landing section is the buffer in the modeline, its file is the one
+ // in the window pointed at, or the one the ghost is in. Listen before waiting
+ // for the fonts, so the first section event isn't missed.
+ let current = $(".ml-buf")?.textContent === hero.dataset.file;
+ on("section", (sec) => {
+ current = sec === "top";
+ });
+
+ await fontsReady();
+ const ctx = canvas.getContext("2d");
+ ctx.font = FONT;
+ const cw = ctx.measureText("M").width;
+ const rand = Math.random;
+
+ // One view per file: its text (which the ghost edits), tokens and scroll.
+ const views = WALL.map((w, n) => {
+ const lines = w.text.replace(/\n+$/, "").split("\n").map(untab);
+ const src = {
+ lines,
+ start: w.start,
+ file: w.path.split("/").pop(),
+ path: `~/Developer/${w.repo}/${w.path}`,
+ inBlock: blockComments(lines),
+ };
+ const edits = w.edits.flatMap((e) => {
+ const idx = lines.findIndex((l) => l.includes(e.line));
+ if (idx < 0) return [];
+ return [{ idx, col: lines[idx].indexOf(e.line) + e.line.indexOf(e.word), len: e.word.length, text: e.text }];
+ });
+ return {
+ src,
+ edits,
+ text: [...lines],
+ tokens: lines.map((l, i) => tokenize(l, src.inBlock[i])),
+ len: lines.length,
+ top: w.top,
+ anim: null,
+ period: 2.3 + n * 0.65,
+ next: 1.5 + n * 0.9,
+ point: w.top + 4,
+ col: 0,
+ mode: "N",
+ };
+ });
+
+ let W = 0;
+ let H = 0;
+ let wins = [];
+ let calm = { l: 0, t: 0, r: 0, b: 0 };
+ let wall = 1; // how strong the wall is: fainter with one window, and in light themes
+ let hover = null; // { win, k, idx }
+ let dirty = true;
+
+ // Each window has a modeline at the bottom, except the one window on a small
+ // screen, where it would sit right on top of the page's own.
+ const bodyTop = () => LH + 6;
+ const bodyBottom = () => (wins.length > 1 ? H - LH - 2 : H);
+ const bodyRows = () => Math.max(1, Math.floor((bodyBottom() - bodyTop()) / LH));
+
+ // How much of the wall shows at (x, y): almost none behind the name and lede.
+ function fadeAt(x, y) {
+ const dx = Math.max(calm.l - x, 0, x - calm.r);
+ const dy = Math.max(calm.t - y, 0, y - calm.b);
+ return CALM + (1 - CALM) * smooth(Math.hypot(dx, dy) / FEATHER);
+ }
+
+ function layout() {
+ W = canvas.clientWidth;
+ H = canvas.clientHeight;
+ fitCanvas(canvas);
+ const c = canvas.getBoundingClientRect();
+ const h = id.getBoundingClientRect();
+ calm = { l: h.left - c.left - 16, t: h.top - c.top - 12, r: h.right - c.left + 12, b: h.bottom - c.top + 12 };
+ const n = W < NARROW ? 1 : 3;
+ const w = (W - (n - 1)) / n;
+ wins = Array.from({ length: n }, (_, i) => ({ x: Math.round(i * (w + 1)), w: Math.round(w), view: views[i] }));
+ for (const win of wins) keepPointInView(win.view);
+ dirty = true;
+ }
+
+ function keepPointInView(v) {
+ const first = Math.floor(v.top);
+ v.point = clamp(v.point, first + 1, first + bodyRows() - 2);
+ }
+
+ // ── drawing ─────────────────────────────────────────────────────────
+
+ // Text in short pieces, so each fades by where it sits.
+ function run(text, x, y, [colour, a]) {
+ ctx.fillStyle = themeColors()[colour];
+ for (let i = 0; i < text.length; i += 4) {
+ const piece = text.slice(i, i + 4);
+ if (!piece.trim()) continue;
+ const px = x + i * cw;
+ const f = a * wall * fadeAt(px + piece.length * cw / 2, y);
+ if (f < 0.015) continue;
+ ctx.globalAlpha = f;
+ ctx.fillText(piece, px, y);
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ // A band of colour, faded by where it sits.
+ function band(x, y, w, h, colour, a) {
+ ctx.fillStyle = themeColors()[colour];
+ for (let bx = x; bx < x + w; bx += 16) {
+ const bw = Math.min(16, x + w - bx);
+ const f = a * fadeAt(bx + bw / 2, y + h / 2);
+ if (f < 0.015) continue;
+ ctx.globalAlpha = f;
+ ctx.fillRect(bx, y, bw, h);
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ function drawWindow(win) {
+ const v = win.view;
+ const x0 = win.x + cw;
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(win.x, 0, win.w, H);
+ ctx.clip();
+
+ // the header line: the file
+ run(v.src.path, x0, LH / 2 + 3, ["dim", 0.55]);
+
+ // the text, with relative line numbers as in my config
+ const top = bodyTop();
+ const bottom = bodyBottom();
+ ctx.save();
+ ctx.beginPath();
+ ctx.rect(win.x, top, win.w, bottom - top);
+ ctx.clip();
+ const first = Math.floor(v.top);
+ const off = (v.top - first) * LH;
+ const rows = bodyRows();
+ for (let row = -1; row <= rows + 1; row++) {
+ const k = first + row;
+ const y = top + row * LH - off + LH / 2;
+ const idx = mod(k, v.len + GAP);
+ if (hover && hover.win === win && hover.k === k) {
+ ctx.fillStyle = themeColors().hl;
+ ctx.fillRect(win.x, y - LH / 2, win.w, LH);
+ }
+ if (idx >= v.len) continue;
+ const num = k === v.point ? String(v.src.start + idx) : String(Math.abs(k - v.point));
+ run(num.padStart(NUM_W - 1), x0, y, k === v.point ? ["fg", 0.55] : ["dim", 0.32]);
+ let col = NUM_W;
+ for (const [t, kind] of v.tokens[idx]) {
+ if (kind !== "space") run(t, x0 + col * cw, y, KIND[kind]);
+ col += t.length;
+ }
+ if (ghost.view === v && k === v.point) drawCursor(v, x0 + (NUM_W + v.col) * cw, y, v.text[idx]);
+ }
+ ctx.restore();
+ if (bottom === H) {
+ ctx.restore();
+ return;
+ }
+
+ // the window's modeline: the line pointed at, or the cursor's
+ band(win.x, H - LH, win.w, LH, "bgAlt", 0.7);
+ const y = H - LH / 2;
+ const pointed = hover?.win === win;
+ const line = v.src.start + (pointed ? hover.idx : Math.min(mod(v.point, v.len + GAP), v.len - 1));
+ const busy = ghost.view === v;
+ run(`<${busy ? v.mode : "N"}>`, x0, y, [busy && v.mode === "I" ? "yellow" : "green", 0.6]);
+ run(v.src.file, x0 + 5 * cw, y, ["fg", 0.55]);
+ run(`${line}:${busy && !pointed ? v.col : 0}`, x0 + (6 + v.src.file.length) * cw, y, ["dim", 0.5]);
+ run("C", win.x + win.w - 3 * cw, y, ["blue", 0.55]);
+ ctx.restore();
+ }
+
+ function drawCursor(v, x, y, text) {
+ const f = fadeAt(x, y);
+ const c = themeColors();
+ ctx.fillStyle = c.fg;
+ if (v.mode === "I") {
+ ctx.globalAlpha = 0.8 * f;
+ ctx.fillRect(x, y - LH / 2 + 2, 2, LH - 4);
+ ctx.globalAlpha = 1;
+ return;
+ }
+ ctx.globalAlpha = 0.75 * f;
+ ctx.fillRect(x, y - LH / 2 + 1, cw, LH - 2);
+ ctx.globalAlpha = 1;
+ const ch = text[v.col];
+ if (ch && ch !== " ") {
+ ctx.fillStyle = c.bg;
+ ctx.fillText(ch, x, y);
+ }
+ }
+
+ function draw() {
+ dirty = false;
+ // Dark text on a light page reads more strongly at the same alpha.
+ wall = (wins.length === 1 ? 0.5 : 0.62) * (themeColors().light ? 0.75 : 1);
+ ctx.clearRect(0, 0, W, H);
+ ctx.font = FONT;
+ ctx.textBaseline = "middle";
+ for (const win of wins) drawWindow(win);
+ // the dividers between windows
+ ctx.fillStyle = themeColors().rule;
+ for (const win of wins.slice(1)) {
+ for (let y = 0; y < H; y += 8) {
+ ctx.globalAlpha = fadeAt(win.x - 1, y + 4);
+ ctx.fillRect(win.x - 1, y, 1, 8);
+ }
+ }
+ ctx.globalAlpha = 1;
+ }
+
+ // ── the ghost: one evil cursor that edits a line and undoes it ─────────
+
+ const ghost = { view: null, queue: [], wait: 0, last: null };
+
+ const retokenize = (v, idx) => {
+ v.tokens[idx] = tokenize(v.text[idx], v.src.inBlock[idx]);
+ };
+
+ // The visible row where the wall shows most, nearest the middle first.
+ function goodRow(win) {
+ const rows = bodyRows();
+ let best = Math.floor(rows / 2);
+ let bestScore = -1;
+ for (let row = 2; row < rows - 2; row++) {
+ const y = bodyTop() + row * LH + LH / 2;
+ const score = fadeAt(win.x + (NUM_W + 12) * cw, y) - Math.abs(row - rows / 2) * 0.004;
+ if (score > bestScore) {
+ bestScore = score;
+ best = row;
+ }
+ }
+ return { row: best, score: bestScore };
+ }
+
+ // Queue one edit: to the line, w to the word, cw, the new word with a near
+ // miss in it, Esc, a look, and u.
+ function plan() {
+ const scored = wins.map((win) => ({ win, ...goodRow(win) }));
+ let pool = scored.filter((s) => s.score > 0.6 && s.win.view.edits.length);
+ if (!pool.length) pool = scored.filter((s) => s.win.view.edits.length).sort((a, b) => b.score - a.score).slice(0, 1);
+ if (pool.length > 1) pool = pool.filter((s) => s.win.view !== ghost.last);
+ const pick = pool[Math.floor(rand() * pool.length)];
+ if (!pick) return;
+ const { win, row } = pick;
+ const v = win.view;
+ const period = v.len + GAP;
+ const first = Math.floor(v.top);
+ const want = first + row;
+ // One of the two edits nearest to where the wall reads, so the window
+ // seldom has to scroll far.
+ const near = v.edits.map((e) => {
+ const ahead = want + mod(e.idx - want, period);
+ const k = ahead - want <= want - (ahead - period) ? ahead : ahead - period;
+ return { e, k, d: Math.abs(k - want) };
+ }).sort((a, b) => a.d - b.d);
+ const { e, k } = near.length > 1 && near[1].d <= bodyRows() && rand() < 0.5 ? near[1] : near[0];
+ const q = [];
+ const at = (delay, fn) => q.push({ delay, fn });
+
+ ghost.view = v;
+ ghost.last = v;
+ v.mode = "N";
+ v.col = 0;
+ keepPointInView(v);
+
+ // Bring the line to where it reads, if it isn't there already.
+ const rowNow = k - first;
+ if (rowNow < 2 || rowNow > bodyRows() - 3 || fadeAt(win.x + (NUM_W + 12) * cw, bodyTop() + rowNow * LH + LH / 2) < 0.6) {
+ const to = k - row;
+ const dur = clamp(0.4 + Math.abs(to - v.top) * 0.012, 0.5, 1.2);
+ at(dur + 0.1, () => scrollTo(v, to, now, dur));
+ at(0.3, () => keepPointInView(v));
+ }
+
+ // j and k to the line, or a jump when it's far
+ at(0.3, () => {
+ const steps = k - v.point;
+ if (Math.abs(steps) > 10) {
+ v.point = k;
+ return;
+ }
+ const dir = Math.sign(steps);
+ for (let i = 0; i < Math.abs(steps); i++) ghost.queue.unshift({ delay: 0.07 + rand() * 0.05, fn: () => { v.point += dir; } });
+ });
+ // w to the word
+ at(0.25, () => {
+ const starts = [...v.text[e.idx].matchAll(/[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+/g)].map((m) => m.index).filter((s) => s <= e.col);
+ ghost.queue.unshift(...starts.map((s) => ({ delay: 0.12 + rand() * 0.06, fn: () => { v.col = s; } })));
+ });
+ // cw
+ at(0.3, () => {
+ v.col = e.col;
+ v.mode = "I";
+ const t = v.text[e.idx];
+ v.text[e.idx] = t.slice(0, e.col) + t.slice(e.col + e.len);
+ retokenize(v, e.idx);
+ });
+ const type = (ch) => () => {
+ const t = v.text[e.idx];
+ v.text[e.idx] = t.slice(0, v.col) + ch + t.slice(v.col);
+ v.col++;
+ retokenize(v, e.idx);
+ };
+ const backspace = () => {
+ const t = v.text[e.idx];
+ v.text[e.idx] = t.slice(0, v.col - 1) + t.slice(v.col);
+ v.col--;
+ retokenize(v, e.idx);
+ };
+ // the new word, with a wrong key somewhere that's noticed a key or two later
+ const typoAt = rand() < 0.85 ? Math.floor(rand() * e.text.length) : -1;
+ for (let i = 0; i < e.text.length; i++) {
+ const wrong = i === typoAt ? nearKey(e.text[i]) : null;
+ if (wrong) {
+ at(0.09 + rand() * 0.08, type(wrong));
+ const extra = i + 1 < e.text.length && rand() < 0.6;
+ if (extra) at(0.09 + rand() * 0.08, type(e.text[i + 1]));
+ at(0.45, backspace);
+ if (extra) at(0.07, backspace);
+ }
+ at(0.09 + rand() * 0.08, type(e.text[i]));
+ }
+ // Esc, a look, then u
+ at(0.35, () => {
+ v.mode = "N";
+ v.col = Math.max(0, v.col - 1);
+ });
+ at(1.8 + rand() * 0.8, () => {
+ v.text[e.idx] = v.src.lines[e.idx];
+ v.col = e.col;
+ retokenize(v, e.idx);
+ });
+ at(1.4, () => { v.col = 0; });
+ at(2.4 + rand() * 2, () => { ghost.view = null; });
+ ghost.queue = q;
+ ghost.wait = 0.8;
+ }
+
+ function scrollTo(v, to, t = now, dur = 0.6) {
+ v.anim = { from: v.top, to, t0: t, dur };
+ }
+
+ // ── the loop ────────────────────────────────────────────────────────
+
+ let now = 0;
+ function tick(t, dt) {
+ now = t;
+ for (const win of wins) {
+ const v = win.view;
+ if (v.anim) {
+ const p = (t - v.anim.t0) / v.anim.dur;
+ v.top = v.anim.from + (v.anim.to - v.anim.from) * ease(p);
+ if (p >= 1) {
+ v.top = v.anim.to;
+ v.anim = null;
+ }
+ if (ghost.view !== v) keepPointInView(v);
+ dirty = true;
+ } else if (ghost.view !== v && t >= v.next) {
+ scrollTo(v, Math.round(v.top) + (rand() < 0.15 ? 3 : 1), t);
+ v.next = t + v.period * (0.8 + rand() * 0.4);
+ }
+ }
+ if (ghost.view || ghost.queue.length) {
+ ghost.wait -= dt;
+ while (ghost.wait <= 0 && ghost.queue.length) {
+ const a = ghost.queue.shift();
+ a.fn();
+ ghost.wait += a.delay;
+ dirty = true;
+ }
+ } else {
+ ghost.wait -= dt;
+ if (ghost.wait <= -2) plan();
+ }
+ select();
+ if (dirty) draw();
+ }
+
+ // ── the modeline ────────────────────────────────────────────────────
+
+ function select() {
+ const v = hover?.win.view ?? ghost.view ?? ghost.last ?? wins[Math.floor(wins.length / 2)]?.view;
+ if (!v || hero.dataset.file === v.src.file) return;
+ hero.dataset.file = v.src.file;
+ if (current) setSegment("buf", v.src.file);
+ }
+
+ // ── pointer ─────────────────────────────────────────────────────────
+
+ function lineAt(e) {
+ if (e.target.closest(".hero-id")) return null;
+ const c = canvas.getBoundingClientRect();
+ const x = e.clientX - c.left;
+ const y = e.clientY - c.top;
+ const win = wins.find((w) => x >= w.x && x < w.x + w.w);
+ if (!win || y < bodyTop() || y > bodyBottom()) return null;
+ const v = win.view;
+ const first = Math.floor(v.top);
+ const off = (v.top - first) * LH;
+ const k = first + Math.floor((y - bodyTop() + off) / LH);
+ const idx = mod(k, v.len + GAP);
+ return idx < v.len ? { win, k, idx } : null;
+ }
+
+ function point(h) {
+ if (h?.win === hover?.win && h?.k === hover?.k) return;
+ hover = h;
+ select();
+ draw();
+ }
+
+ // Only a mouse points: on a phone the wall is just something to look at.
+ hero.addEventListener("pointermove", (e) => {
+ if (e.pointerType === "mouse") point(lineAt(e));
+ });
+ hero.addEventListener("pointerleave", () => point(null));
+
+ // ── start ───────────────────────────────────────────────────────────
+
+ const ro = new ResizeObserver(() => {
+ layout();
+ select();
+ draw();
+ });
+ ro.observe(canvas);
+ ro.observe(id);
+ on("theme", () => draw());
+
+ // Under reduced motion the wall holds still, and pointing still works.
+ if (!reducedMotion.matches) frameWhileVisible(canvas, tick);
+}
diff --git a/src/scripts/util.js b/src/scripts/util.js
@@ -27,7 +27,7 @@ export function themeColors() {
const cs = getComputedStyle(document.documentElement);
const v = (n) => cs.getPropertyValue(n).trim();
colors = {
- bg: v("--bg"), fg: v("--fg"), dim: v("--dim"), faint: v("--faint"), rule: v("--rule"), hl: v("--hl"),
+ bg: v("--bg"), bgAlt: v("--bg-alt"), fg: v("--fg"), dim: v("--dim"), faint: v("--faint"), rule: v("--rule"), hl: v("--hl"),
red: v("--red"), orange: v("--orange"), yellow: v("--yellow"), green: v("--green"),
cyan: v("--cyan"), blue: v("--blue"), magenta: v("--magenta"),
light: v("--glow") === "0",
diff --git a/src/styles/home/hero.css b/src/styles/home/hero.css
@@ -1,14 +1,31 @@
-/* The landing section: the name (which decodes itself on load), a lede and the colour key */
+/* The landing section: the name (which decodes itself on load), a lede and the
+ colour key, in front of a wall of code */
-/* The section fills the screen, but its contents hang from a shared top line in the
- upper part of it: the name and the chip start level, and the space is left below. */
+/* The section fills the screen, and the wall fills it edge to edge between the top
+ line and the modeline (src/scripts/home/wall.js). */
.hero {
+ position: relative;
+ min-height: 100vh;
+ min-height: 100svh;
+}
+
+.hero-wall {
+ position: absolute;
+ top: var(--top-h);
+ left: 0;
+ display: block;
+ width: 100%;
+ height: calc(100% - var(--top-h) - var(--frame-h));
+}
+
+/* The name hangs from the upper part of the screen in the left column, and the
+ right one is left to the wall. */
+.hero-in {
+ position: relative;
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: 32px clamp(32px, 4vw, 64px);
align-items: start;
- min-height: 100vh;
- min-height: 100svh;
padding: calc(var(--top-h) + clamp(32px, 12vh, 136px)) var(--gutter) calc(var(--frame-h) + 32px);
max-width: 1440px;
margin: 0 auto;
@@ -88,9 +105,8 @@
}
@media (max-width: 1100px) {
- .hero {
+ .hero-in {
grid-template-columns: minmax(0, 1fr);
- align-items: start;
}
.hero-id {
@@ -99,7 +115,7 @@
}
@media (max-width: 640px) {
- .hero {
+ .hero-in {
padding-top: calc(var(--top-h) + 28px);
}
diff --git a/src/styles/home/pinout.css b/src/styles/home/pinout.css
@@ -1,203 +0,0 @@
-/* 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;
-}
-
-/* the readout's height is set by src/scripts/home/pinout.js to fit the longest pin;
- without JavaScript the pin list below says it all, and there is nothing to point at */
-.pinout:not(.is-built) .pin-readout,
-.pinout:not(.is-built) .pin-hint {
- display: none;
-}
-
-.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.is-active {
- color: var(--fg);
-}
-
-@media (hover: hover) {
- .pin:hover {
- 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 {
- 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;
- }
-}
-
-/* on a phone the chip is about two thirds of the width, with the readout full width under it */
-@media (max-width: 640px) {
- .hero-chip .chip-wrap {
- width: 72%;
- margin-inline: auto;
- }
-
- .hero-chip .chip {
- grid-template-columns: minmax(0, 1fr) 84px minmax(0, 1fr);
- font-size: 12px;
- }
-
- .hero-chip .pin {
- height: 30px;
- }
-}