diff --git a/build.el b/build.el
index 53dd994..7c6414c 100644
--- a/build.el
+++ b/build.el
@@ -24,7 +24,7 @@
:publishing-function org-html-publish-to-html
:html-head-include-default-style nil
:html-head-include-scripts nil
- :html-head ""
+ :html-head ""
:html-preamble ,site-nav
:html-postamble nil
:with-author nil
diff --git a/static/animation.js b/static/animation.js
new file mode 100644
index 0000000..d1b877c
--- /dev/null
+++ b/static/animation.js
@@ -0,0 +1,205 @@
+// Conway's Game of Life background animation
+// 100×100 toroidal grid, 1cm cells with 0.1cm gaps, tiled to fill the viewport.
+// State persists in a cookie until midnight; cells fade in/out on state change.
+
+const COOKIE_STATE = 'cgol_state';
+const ROWS = 100;
+const COLS = 100;
+const CELL_CM = 1.0; // cell size in cm
+const PAD_CM = 0.1; // gap between cells in cm
+const FADE_SPEED = 4.0; // opacity units/sec → full transition in 0.25s
+const STEP_MS = 1000; // game step interval in ms
+const CELL_ALPHA = 0.03; // max cell opacity (keeps it a subtle background)
+
+let canvas, ctx;
+let cmPx = 38; // pixels per cm, measured at setup
+let cellColor = '#2f4f4f';
+let board = null; // Uint8Array ROWS*COLS — 0=dead, 1=alive
+let opacity = null; // Float32Array ROWS*COLS — current fade value [0,1]
+let animFrame = null;
+let lastStepTime = 0;
+let lastFrameTime = 0;
+
+// ── Board helpers ──────────────────────────────────────────────────────────
+
+function idx(r, c) { return r * COLS + c; }
+
+function randomBoard() {
+ const b = new Uint8Array(ROWS * COLS);
+ for (let i = 0; i < b.length; i++) b[i] = Math.random() < 0.3 ? 1 : 0;
+ return b;
+}
+
+function stepConway(b) {
+ const next = new Uint8Array(ROWS * COLS);
+ for (let r = 0; r < ROWS; r++) {
+ for (let c = 0; c < COLS; c++) {
+ let n = 0;
+ for (let dr = -1; dr <= 1; dr++) {
+ for (let dc = -1; dc <= 1; dc++) {
+ if (dr === 0 && dc === 0) continue;
+ n += b[idx((r + dr + ROWS) % ROWS, (c + dc + COLS) % COLS)];
+ }
+ }
+ const alive = b[idx(r, c)];
+ next[idx(r, c)] = alive
+ ? (n === 2 || n === 3 ? 1 : 0)
+ : (n === 3 ? 1 : 0);
+ }
+ }
+ return next;
+}
+
+// ── Serialisation (bit-packed → base64url, ~1667 chars for 100×100) ────────
+
+function serializeBoard(b) {
+ const bytes = new Uint8Array(Math.ceil(ROWS * COLS / 8));
+ for (let i = 0; i < ROWS * COLS; i++) {
+ if (b[i]) bytes[i >> 3] |= 1 << (i & 7);
+ }
+ let s = '';
+ for (const byte of bytes) s += String.fromCharCode(byte);
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+function deserializeBoard(b64) {
+ const s = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
+ const b = new Uint8Array(ROWS * COLS);
+ for (let i = 0; i < ROWS * COLS; i++) {
+ b[i] = (s.charCodeAt(i >> 3) >> (i & 7)) & 1;
+ }
+ return b;
+}
+
+// ── Cookie (expires at midnight tonight) ──────────────────────────────────
+
+function saveToCookie() {
+ if (!board) return;
+ const midnight = new Date();
+ midnight.setHours(24, 0, 0, 0);
+ const data = encodeURIComponent(serializeBoard(board));
+ document.cookie =
+ `${COOKIE_STATE}=${data};expires=${midnight.toUTCString()};path=/;SameSite=Lax`;
+}
+
+function loadFromCookie() {
+ const match = document.cookie.match(
+ new RegExp('(?:^|; )' + COOKIE_STATE + '=([^;]*)')
+ );
+ if (!match) return null;
+ try { return deserializeBoard(decodeURIComponent(match[1])); }
+ catch { return null; }
+}
+
+// ── Setup ──────────────────────────────────────────────────────────────────
+
+function measureCmPx() {
+ const el = document.createElement('div');
+ el.style.cssText = 'position:absolute;width:1cm;height:0;visibility:hidden';
+ document.body.appendChild(el);
+ const px = el.offsetWidth || 38;
+ document.body.removeChild(el);
+ return px;
+}
+
+function setup() {
+ cmPx = measureCmPx();
+ cellColor = getComputedStyle(document.documentElement)
+ .getPropertyValue('--fg').trim() || '#2f4f4f';
+
+ canvas = document.createElement('canvas');
+ canvas.id = 'bg-animation';
+ document.body.prepend(canvas);
+ ctx = canvas.getContext('2d');
+
+ resize();
+ window.addEventListener('resize', resize);
+
+ board = loadFromCookie() || randomBoard();
+ opacity = new Float32Array(ROWS * COLS);
+ // Initialise opacity to match loaded state (no fade-in on first load)
+ for (let i = 0; i < ROWS * COLS; i++) opacity[i] = board[i];
+
+ lastStepTime = performance.now();
+ lastFrameTime = performance.now();
+ animFrame = requestAnimationFrame(frame);
+}
+
+function resize() {
+ canvas.width = window.innerWidth;
+ canvas.height = window.innerHeight;
+}
+
+// ── Animation loop ─────────────────────────────────────────────────────────
+
+function frame(ts) {
+ const dt = Math.min((ts - lastFrameTime) / 1000, 0.1); // cap delta at 100ms
+ lastFrameTime = ts;
+
+ if (ts - lastStepTime >= STEP_MS) {
+ board = stepConway(board);
+ lastStepTime = ts;
+ }
+
+ // Nudge each cell's opacity toward its target (0 or 1)
+ const fade = FADE_SPEED * dt;
+ for (let i = 0; i < ROWS * COLS; i++) {
+ if (board[i]) {
+ if (opacity[i] < 1) opacity[i] = Math.min(1, opacity[i] + fade);
+ } else {
+ if (opacity[i] > 0) opacity[i] = Math.max(0, opacity[i] - fade);
+ }
+ }
+
+ draw();
+ animFrame = requestAnimationFrame(frame);
+}
+
+// ── Draw ───────────────────────────────────────────────────────────────────
+
+function draw() {
+ const w = canvas.width;
+ const h = canvas.height;
+ ctx.clearRect(0, 0, w, h);
+
+ const cellPx = cmPx * CELL_CM;
+ const stepPx = cmPx * (CELL_CM + PAD_CM);
+ const gridW = COLS * stepPx;
+ const gridH = ROWS * stepPx;
+
+ ctx.fillStyle = cellColor;
+
+ // Tile grid to fill canvas; start one full grid before origin to cover edges.
+ for (let tileY = -gridH; tileY < h; tileY += gridH) {
+ for (let tileX = -gridW; tileX < w; tileX += gridW) {
+ for (let r = 0; r < ROWS; r++) {
+ const y = tileY + r * stepPx;
+ if (y + cellPx < 0 || y > h) continue;
+ for (let c = 0; c < COLS; c++) {
+ const op = opacity[idx(r, c)];
+ if (op < 0.005) continue;
+ const x = tileX + c * stepPx;
+ if (x + cellPx < 0 || x > w) continue;
+ ctx.globalAlpha = op * CELL_ALPHA;
+ ctx.fillRect(x, y, cellPx, cellPx);
+ }
+ }
+ }
+ }
+ ctx.globalAlpha = 1;
+}
+
+// ── Teardown ───────────────────────────────────────────────────────────────
+
+function teardown() {
+ saveToCookie();
+ cancelAnimationFrame(animFrame);
+ window.removeEventListener('resize', resize);
+ if (canvas) canvas.remove();
+ canvas = ctx = board = opacity = null;
+}
+
+// ── Init ───────────────────────────────────────────────────────────────────
+
+document.addEventListener('DOMContentLoaded', setup);
+window.addEventListener('pagehide', teardown);
diff --git a/static/style.css b/static/style.css
index 576c636..151b2e5 100644
--- a/static/style.css
+++ b/static/style.css
@@ -22,6 +22,18 @@
}
}
+/* ── Background animation canvas ────────────────────── */
+#bg-animation {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: -1;
+ pointer-events: none;
+ will-change: transform;
+}
+
/* ── Reset ───────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-text-size-adjust: 100%; }