website

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

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:
Mastro.config.mjs | 3+++
Msrc/components/frame/Analytics.astro | 29++++++++++++++++++++---------
Msrc/components/home/ProjectThumb.astro | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/scripts/demos/computer/board.js | 50+++++++++++---------------------------------------
Msrc/scripts/demos/computer/commands.js | 58+++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/scripts/home/wall.js | 5+++--
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;