commit 771b8c625e220fa9226c989a449ce71a065858da
parent e412fe35a0a8b126482c45676cdbeef96ece4fbc
Author: Michael Neuper <michael@michaelneuper.com>
Date: Sun, 25 Oct 2026 19:56:40 +0200
inline css and delay google analytics
Diffstat:
6 files changed, 146 insertions(+), 86 deletions(-)
diff --git a/astro.config.mjs b/astro.config.mjs
@@ -20,6 +20,9 @@ export default defineConfig({
// The default ("jsx") drops line breaks between text and inline elements, which
// glues words to links. true only removes whitespace that doesn't render.
compressHTML: true,
+ // Each page's CSS goes in its <head> instead of in files of its own, so the
+ // first paint doesn't wait for a second round of requests after the HTML.
+ build: { inlineStylesheets: "always" },
// The toolbar would sit on top of the modeline and echo area.
devToolbar: { enabled: false },
markdown: {
diff --git a/src/components/frame/Analytics.astro b/src/components/frame/Analytics.astro
@@ -4,13 +4,24 @@
const GA_ID = "G-BLX24KP7CS";
---
{import.meta.env.PROD && (
- <Fragment>
- <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}></script>
- <script is:inline define:vars={{ GA_ID }}>
- window.dataLayer = window.dataLayer || [];
- function gtag() { dataLayer.push(arguments); }
- gtag("js", new Date());
- gtag("config", GA_ID);
- </script>
- </Fragment>
+ <script is:inline define:vars={{ GA_ID }}>
+ window.dataLayer = window.dataLayer || [];
+ function gtag() { dataLayer.push(arguments); }
+ gtag("js", new Date());
+ gtag("config", GA_ID);
+ // gtag.js is about 180 KB compressed, several times the page's own scripts, so
+ // it's only fetched once the page has loaded and gone idle, rather than
+ // competing with the page's files for the network and the CPU.
+ // The calls above wait in dataLayer until then. A visit that leaves sooner
+ // isn't counted.
+ const load = () => {
+ const s = document.createElement("script");
+ s.async = true;
+ s.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`;
+ document.head.append(s);
+ };
+ const idle = () => (window.requestIdleCallback ? requestIdleCallback(load, { timeout: 2000 }) : setTimeout(load, 300));
+ if (document.readyState === "complete") idle();
+ else addEventListener("load", idle, { once: true });
+ </script>
)}
diff --git a/src/components/home/ProjectThumb.astro b/src/components/home/ProjectThumb.astro
@@ -2,6 +2,10 @@
// A small drawing of a project's demo for its card on the projects wheel, in
// the theme's colours: the computer's LEDs, the scheduler's cores, an Othello
// board mid-search, a compiled expression, and packets crossing over UDP.
+//
+// Shapes that look alike are joined into one path, so a card is a handful of
+// elements rather than a hundred: the page's first style and layout pass takes
+// longer the more elements there are, and Lighthouse counts it.
interface Props {
demo: string;
@@ -10,6 +14,22 @@ interface Props {
const { demo } = Astro.props;
const range = (n: number) => Array.from({ length: n }, (_, i) => i);
const bits = (byte: number) => range(8).map((k) => (byte >> (7 - k)) & 1);
+const num = (v: number) => +v.toFixed(3);
+
+// path data for circles [cx, cy, r], rectangles [x, y, w, h] and lines [x1, y1, x2, y2]
+const dots = (list: number[][]) => list.map(([cx, cy, r]) => `M${num(cx - r)} ${num(cy)}a${r} ${r} 0 1 0 ${num(2 * r)} 0a${r} ${r} 0 1 0 ${num(-2 * r)} 0`).join("");
+const boxes = (list: number[][]) => list.map(([x, y, w, h]) => `M${num(x)} ${num(y)}h${num(w)}v${num(h)}h${num(-w)}z`).join("");
+const lines = (list: number[][]) => list.map(([x1, y1, x2, y2]) => `M${num(x1)} ${num(y1)}L${num(x2)} ${num(y2)}`).join("");
+
+// Sort shapes into a list per class (or colour), keeping the order they're added in.
+function sorter() {
+ const by = new Map<string, number[][]>();
+ const add = (key: string, shape: number[]) => {
+ if (!by.has(key)) by.set(key, []);
+ by.get(key)!.push(shape);
+ };
+ return { by, add };
+}
// the first few bytes of the Fibonacci program the computer starts with
const RAM = [0x51, 0x4f, 0x50, 0xe0, 0x2f, 0x70, 0x4e];
@@ -37,25 +57,41 @@ const SHADE: Record<string, number> = { "1,2": 0.25, "2,6": 0.7, "6,3": 0.4, "5,
const CODE = ["iload_0", "iconst_2", "imul", "iconst_1", "iadd", "ireturn"];
// packets: 1 arrived, 0 lost and waiting to be resent
const GOT = [1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1];
+
+// the computer: its modules' outlines, the bus, and LEDs by colour
+const leds = sorter();
+const led = (on: number, lit: string, cx: number, cy: number, r = 2.2) => leds.add(on ? lit : "th-off", [cx, cy, r]);
+range(4).forEach((k) => led(1, "th-on", 12 + k * 7, 11.5));
+RAM.forEach((b, r) => bits(b).forEach((on, k) => led(on, "th-red", 12 + k * 7, 29.5 + r * 7)));
+[0, 1, 0, 1, 1, 1, 1, 0].forEach((on, k) => led(on, "th-on", 12 + k * 7, 86, 2));
+[0, 1, 1, 0].forEach((on, k) => led(on, "th-on", 99 + k * 7, 11.5));
+bits(0x2a).forEach((on, k) => led(on, "th-red", 99 + k * 7, 29.5));
+bits(0x15).forEach((on, k) => led(on, "th-red", 99 + k * 7, 47.5));
+const MODULES = [[4.5, 4.5, 64, 14], [4.5, 22.5, 64, 54], [4.5, 80.5, 64, 11], [74.5, 4.5, 11, 87],
+ [91.5, 4.5, 64, 14], [91.5, 22.5, 64, 14], [91.5, 40.5, 64, 14], [91.5, 58.5, 64, 33]];
+
+// the scheduler: each core's lane, then its blocks by colour
+const blocks = sorter();
+CORES.forEach((row, r) => row.forEach(([s, len, c]) => blocks.add(c as string, [64 + (s as number) * 7.67, 6 + r * 18, (len as number) * 7.67 - 1, 13])));
+
+// Othello: the grid, then the discs by side
+const discs = sorter();
+BOARD.forEach((row, r) => [...row].forEach((ch, c) => {
+ if (ch !== ".") discs.add(ch === "1" ? "th-disc" : "th-disc-o", [42.4 + c * 10.875, 10.4 + r * 10.875, 3.6]);
+}));
+const GRID = range(7).flatMap((k) => [[47.375 + k * 10.875, 5, 47.375 + k * 10.875, 91], [37, 15.375 + k * 10.875, 123, 15.375 + k * 10.875]]);
+
+// the packets: sent, then received or lost
+const cell = (x0: number, k: number) => [x0 + (k % 5) * 8, 14 + Math.floor(k / 5) * 8, 6, 6];
+const got = sorter();
+range(25).forEach((k) => got.add(GOT[k] ? "th-on" : "th-off", cell(114, k)));
---
<svg class="th" viewBox="0 0 160 96" aria-hidden="true">
{demo === "computer" && (
<g>
- <rect class="th-box" x="4.5" y="4.5" width="64" height="14" />
- {range(4).map((k) => <circle class="th-on" cx={12 + k * 7} cy="11.5" r="2.2" />)}
- <rect class="th-box" x="4.5" y="22.5" width="64" height="54" />
- {RAM.map((b, r) => bits(b).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={12 + k * 7} cy={29.5 + r * 7} r="2.2" />))}
- <rect class="th-box" x="4.5" y="80.5" width="64" height="11" />
- {[0, 1, 0, 1, 1, 1, 1, 0].map((on, k) => <circle class={on ? "th-on" : "th-off"} cx={12 + k * 7} cy="86" r="2" />)}
- <rect class="th-box" x="74.5" y="4.5" width="11" height="87" />
- {range(4).map((k) => <line class="th-line" x1={77 + k * 2} y1="8" x2={77 + k * 2} y2="88" />)}
- <rect class="th-box" x="91.5" y="4.5" width="64" height="14" />
- {[0, 1, 1, 0].map((on, k) => <circle class={on ? "th-on" : "th-off"} cx={99 + k * 7} cy="11.5" r="2.2" />)}
- <rect class="th-box" x="91.5" y="22.5" width="64" height="14" />
- {bits(0x2a).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={99 + k * 7} cy="29.5" r="2.2" />)}
- <rect class="th-box" x="91.5" y="40.5" width="64" height="14" />
- {bits(0x15).map((on, k) => <circle class={on ? "th-red" : "th-off"} cx={99 + k * 7} cy="47.5" r="2.2" />)}
- <rect class="th-box" x="91.5" y="58.5" width="64" height="33" />
+ <path class="th-box" d={boxes(MODULES)} />
+ <path class="th-line" d={lines(range(4).map((k) => [77 + k * 2, 8, 77 + k * 2, 88]))} />
+ {[...leds.by].map(([cls, list]) => <path class={cls} d={dots(list)} />)}
{/* the output, a seven-segment 42 */}
<path class="th-seg-off" d="M112 64h8M112 75h8M112 86h8M111 65v9M121 65v9M111 76v9M121 76v9M126 64h8M126 75h8M126 86h8M125 65v9M135 65v9M125 76v9M135 76v9" />
<path class="th-seg" d="M111 65v9M121 65v9M112 75h8M121 76v9M126 64h8M135 65v9M126 75h8M125 76v9M126 86h8" />
@@ -64,12 +100,8 @@ const GOT = [1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1
{demo === "scheduler" && (
<g>
- {CORES.map((blocks, r) => (
- <g>
- <rect class="th-off" x="4" y={6 + r * 18} width="152" height="13" />
- {blocks.map(([s, len, c]) => <rect x={64 + (s as number) * 7.67} y={6 + r * 18} width={(len as number) * 7.67 - 1} height="13" style={`fill: var(--${c})`} />)}
- </g>
- ))}
+ <path class="th-off" d={boxes(range(4).map((r) => [4, 6 + r * 18, 152, 13]))} />
+ {[...blocks.by].map(([c, list]) => <path d={boxes(list)} style={`fill: var(--${c})`} />)}
{QUEUE.map((c, k) => <rect class="th-q" x={4.5 + k * 22} y="80.5" width="18" height="10" style={`stroke: var(--${c})`} />)}
</g>
)}
@@ -77,15 +109,12 @@ const GOT = [1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1
{demo === "othello" && (
<g>
<rect class="th-box" x="36.5" y="4.5" width="87" height="87" />
- {range(7).map((k) => <line class="th-line" x1={47.375 + k * 10.875} y1="5" x2={47.375 + k * 10.875} y2="91" />)}
- {range(7).map((k) => <line class="th-line" x1="37" y1={15.375 + k * 10.875} x2="123" y2={15.375 + k * 10.875} />)}
+ <path class="th-line" d={lines(GRID)} />
{Object.entries(SHADE).map(([at, a]) => {
const [r, c] = at.split(",").map(Number);
return <rect class="th-on" x={37 + c * 10.875} y={5 + r * 10.875} width="10.4" height="10.4" style={`opacity: ${a}`} />;
})}
- {BOARD.map((row, r) => [...row].map((ch, c) => ch !== "." && (
- <circle class={ch === "1" ? "th-disc" : "th-disc-o"} cx={42.4 + c * 10.875} cy={10.4 + r * 10.875} r="3.6" />
- )))}
+ {[...discs.by].map(([cls, list]) => <path class={cls} d={dots(list)} />)}
</g>
)}
@@ -105,9 +134,9 @@ const GOT = [1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1
{demo === "rbudp" && (
<g>
- {range(25).map((k) => <rect class="th-dim" x={6 + (k % 5) * 8} y={14 + Math.floor(k / 5) * 8} width="6" height="6" />)}
- {range(25).map((k) => <rect class={GOT[k] ? "th-on" : "th-off"} x={114 + (k % 5) * 8} y={14 + Math.floor(k / 5) * 8} width="6" height="6" />)}
- {[[58, 30], [72, 22], [86, 42], [97, 34]].map(([x, y]) => <rect class="th-on" x={x} y={y} width="6" height="6" style="opacity: 0.6" />)}
+ <path class="th-dim" d={boxes(range(25).map((k) => cell(6, k)))} />
+ {[...got.by].map(([cls, list]) => <path class={cls} d={boxes(list)} />)}
+ <path class="th-on" d={boxes([[58, 30], [72, 22], [86, 42], [97, 34]].map(([x, y]) => [x, y, 6, 6]))} style="opacity: 0.6" />
<line class="th-line th-dash" x1="6" y1="70.5" x2="154" y2="70.5" />
<path class="th-line" d="M100 77l-6 3.5 6 3.5" />
</g>
diff --git a/src/scripts/demos/computer/board.js b/src/scripts/demos/computer/board.js
@@ -2,9 +2,10 @@
// machine in machine.js, with RAM you can edit, the bus, the control word and a
// 7-segment display. The 8-bit project mounts it as its demo.
-import { $, $$, hex, reducedMotion, onFrame, isTyping, on } from "../../util.js";
-import { message, setSegment } from "../../emacs/index.js";
+import { $, $$, hex, reducedMotion, onFrame, isTyping } from "../../util.js";
+import { message } from "../../emacs/index.js";
import { LINES, L, LABEL, OPS, PROGRAMS, controlWord, alu, busValue, tick, resetState, disasm, assemble } from "./machine.js";
+import { clockBoost, currentBoard, setCurrentBoard } from "./commands.js";
import "../../../styles/demos/computer.css";
// ── 7-segment digits ──────────────────────────────────────────────────
@@ -19,12 +20,6 @@ const GLYPH = { 0: "abcdef", 1: "bc", 2: "abdeg", 3: "abcdg", 4: "bcfg", 5: "acd
// with a non-breaking space, so the unit never wraps away from its number
const fmtHz = (f) => (f < 10 ? `${f.toFixed(1)} Hz` : `${Math.round(f)} Hz`);
-// State that outlives any one mounted board: the coffee overclock, and which
-// board is on the page (the 8-bit project's demo mounts and unmounts it).
-let boost = 1;
-let boostTimer = 0;
-let current = null;
-
// The controls go above the board so they're in sight with it; c, n and r are
// in the buttons' tooltips and in M-x (commands.js).
const TEMPLATE = `
@@ -66,8 +61,8 @@ export function mount(host) {
let touch = false; // a coarse pointer: RAM rows are the targets, not bits
let editing = -1; // the address open in the row editor
let dirty = true;
- let lastBoost = boost;
- const effHz = () => Math.min(500, hz * boost);
+ let lastBoost = clockBoost();
+ const effHz = () => Math.min(500, hz * clockBoost());
// ── build the board ────────────────────────────────────────────────
const el = (tag, cls, text) => {
@@ -301,7 +296,7 @@ export function mount(host) {
// clock
clk.leds[0].classList.toggle("on", running && !s.halted && (effHz() > 24 || phase < 0.5));
- setText(clk.val, s.halted ? "halted" : running ? (boost > 1 ? `${fmtHz(effHz())} (${boost}×)` : fmtHz(hz)) : "paused");
+ setText(clk.val, s.halted ? "halted" : running ? (clockBoost() > 1 ? `${fmtHz(effHz())} (${clockBoost()}×)` : fmtHz(hz)) : "paused");
// output display
const o = signed && s.out > 127 ? -(256 - s.out) : s.out;
@@ -388,8 +383,8 @@ export function mount(host) {
// ── simulation loop ────────────────────────────────────────────────
function frame(t, dt) {
- if (boost !== lastBoost) {
- lastBoost = boost;
+ if (clockBoost() !== lastBoost) {
+ lastBoost = clockBoost();
dirty = true;
}
if (running && !s.halted) {
@@ -623,12 +618,12 @@ export function mount(host) {
setHz(+hzIn.value);
setTouch(coarse.matches);
// It waits for run (or c) so it doesn't flicker beside the write-up, unless a coffee overclock is on.
- run(boost > 1);
+ run(clockBoost() > 1);
// Drawn once now, so the board is its full height before it scrolls into view
// (its values fill in, and the page under it would shift).
render();
const api = { run, step, reset, load, board, halted: () => s.halted, running: () => running };
- current = api;
+ setCurrentBoard(api);
return () => {
io.disconnect();
ro.disconnect();
@@ -636,29 +631,6 @@ export function mount(host) {
document.removeEventListener("keydown", onKey);
document.removeEventListener("pointerdown", onOutside);
coarse.removeEventListener("change", onPointer);
- if (current === api) current = null;
+ if (currentBoard() === api) setCurrentBoard(null);
};
}
-
-/** The board on the page, if one is mounted. */
-export const currentBoard = () => current;
-
-// M-x coffee-refill: four times the clock for a while.
-on("caffeine", ({ factor, seconds }) => {
- boost = factor;
- if (current) {
- if (current.halted()) current.reset();
- if (!current.running()) current.run(true);
- }
- let left = seconds;
- clearInterval(boostTimer);
- setSegment("misc", `${factor}× clock ${left}s`);
- boostTimer = setInterval(() => {
- left--;
- if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
- clearInterval(boostTimer);
- boost = 1;
- setSegment("misc", "");
- message("The caffeine wore off. The clock is back to normal.");
- }, 1000);
-});
diff --git a/src/scripts/demos/computer/commands.js b/src/scripts/demos/computer/commands.js
@@ -1,25 +1,49 @@
// M-x commands for the 8-bit computer (c, n and r with focus in its demo, or on
// nothing while it's on screen, or SPC c from anywhere). They open the 8-bit
// project first if its demo isn't showing.
+//
+// This module loads with the page, and the board (board.js) only when the
+// project is first shown, so the state that outlives a mounted board is kept
+// here: which board is on the page, and the coffee overclock.
-import { reducedMotion, emit } from "../../util.js";
-import { defcommand, completingRead } from "../../emacs/index.js";
+import { reducedMotion, emit, on } from "../../util.js";
+import { defcommand, completingRead, message, setSegment } from "../../emacs/index.js";
import { PROGRAMS } from "./machine.js";
-import { currentBoard } from "./board.js";
+
+let current = null;
+const waiting = [];
+let boost = 1;
+let boostTimer = 0;
+
+/** The board on the page, if one is mounted. */
+export const currentBoard = () => current;
+
+/** board.js calls this with a board's api when it mounts, and null when it goes. */
+export function setCurrentBoard(api) {
+ current = api;
+ if (api) waiting.splice(0).forEach((resolve) => resolve());
+}
+
+/** How many times faster the clock runs, while a coffee is in effect. */
+export const clockBoost = () => boost;
// The 8-bit project's demo, opened and scrolled to if it isn't on the page yet.
+// Its code may still have to load, so this waits up to 5 seconds for it.
export async function showComputer() {
- if (!currentBoard()) {
+ if (!current) {
+ const mounted = new Promise((resolve) => {
+ waiting.push(resolve);
+ setTimeout(resolve, 5000);
+ });
emit("project", "8-bit-computer");
- for (let i = 0; i < 90 && !currentBoard(); i++) await new Promise((r) => requestAnimationFrame(r));
+ await mounted;
}
- const current = currentBoard();
current?.board.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "center" });
return current;
}
const withComputer = (fn) => async () => {
- const c = currentBoard() ?? (await showComputer());
+ const c = current ?? (await showComputer());
if (c) fn(c);
};
defcommand("computer-run", withComputer((c) => c.run()), { doc: "Run or pause the 8-bit computer", key: "c" });
@@ -32,3 +56,23 @@ defcommand("computer-load-program", async () => {
c?.load(key);
c?.run(true);
}, { doc: "Load one of the example programs into RAM", key: "SPC c l" });
+
+// M-x coffee-refill: four times the clock for a while.
+on("caffeine", ({ factor, seconds }) => {
+ boost = factor;
+ if (current) {
+ if (current.halted()) current.reset();
+ if (!current.running()) current.run(true);
+ }
+ let left = seconds;
+ clearInterval(boostTimer);
+ setSegment("misc", `${factor}× clock ${left}s`);
+ boostTimer = setInterval(() => {
+ left--;
+ if (left > 0) return setSegment("misc", `${factor}× clock ${left}s`);
+ clearInterval(boostTimer);
+ boost = 1;
+ setSegment("misc", "");
+ message("The caffeine wore off. The clock is back to normal.");
+ }, 1000);
+});
diff --git a/src/scripts/home/wall.js b/src/scripts/home/wall.js
@@ -11,11 +11,11 @@
// applies while compositing; a change redraws only what it touched (a line, the
// line numbers, a window's modeline, or a whole window when it scrolls); nothing
// runs between changes, as a timer wakes the wall when the next one is due; and
-// it isn't even built until the page has loaded and gone quiet.
+// it isn't even built until the page has loaded and gone quiet. The C itself is
+// fetched then too, so it doesn't hold up the scripts the page needs first.
import { $, clamp, on, reducedMotion, themeColors, 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`;
@@ -161,6 +161,7 @@ export async function initWall() {
await fontsReady();
await settled();
+ const { WALL } = await import("../../data/wall.js");
const ctx = canvas.getContext("2d");
ctx.font = FONT;
const cw = ctx.measureText("M").width;