website

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

commit 2ee7df5049ad48d222efe9140e44799180aef1a9
parent 1d8aa56a6889eba3c4fd4a35cdae794b63dc7985
Author: Michael Neuper <michael@michaelneuper.com>
Date:   Sun, 25 Oct 2026 13:30:11 +0200

create new hero section

Diffstat:
MREADME.md | 32+++++++++++++++-----------------
Msrc/components/home/Hero.astro | 45++++++++++++++++++---------------------------
Dsrc/data/pins.js | 22----------------------
Asrc/data/wall.js | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/data/wall/cube.c | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/data/wall/layerkey.c | 324+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/data/wall/life.c | 305++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/scripts/home/coffee.js | 2+-
Dsrc/scripts/home/pinout.js | 119-------------------------------------------------------------------------------
Asrc/scripts/home/wall.js | 569+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/scripts/util.js | 2+-
Msrc/styles/home/hero.css | 32++++++++++++++++++++++++--------
Dsrc/styles/home/pinout.css | 203-------------------------------------------------------------------------------
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; - } -}