diff --git a/sass/_base.scss b/sass/_base.scss
index 215cdc3..e3537ec 100644
--- a/sass/_base.scss
+++ b/sass/_base.scss
@@ -8,13 +8,14 @@ html {
font-size: $font-size;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ background: $bg;
}
body {
font-family: $font-sans;
line-height: $line-height;
color: $text;
- background: $bg;
+ background: transparent;
min-height: 100vh;
}
diff --git a/static/background.js b/static/background.js
new file mode 100644
index 0000000..e0d7046
--- /dev/null
+++ b/static/background.js
@@ -0,0 +1,29 @@
+// Background dispatcher — picks a random renderer on each page load
+(function () {
+ 'use strict';
+
+ var backgrounds = [
+ 'mandelbrot.js',
+ 'bg-julia.js',
+ 'bg-reaction-diffusion.js',
+ 'bg-flowfield.js',
+ 'bg-lorenz.js',
+ 'bg-waves.js',
+ 'bg-contours.js',
+ 'bg-life.js',
+ 'bg-lissajous.js',
+ 'bg-sierpinski.js',
+ 'bg-domain.js',
+ ];
+
+ var pick = backgrounds[Math.floor(Math.random() * backgrounds.length)];
+
+ // Resolve path relative to this script's location
+ var scripts = document.getElementsByTagName('script');
+ var thisScript = scripts[scripts.length - 1];
+ var basePath = thisScript.src.substring(0, thisScript.src.lastIndexOf('/') + 1);
+
+ var script = document.createElement('script');
+ script.src = basePath + pick;
+ document.body.appendChild(script);
+})();
diff --git a/static/bg-contours.js b/static/bg-contours.js
new file mode 100644
index 0000000..51e2316
--- /dev/null
+++ b/static/bg-contours.js
@@ -0,0 +1,119 @@
+// Perlin noise contour lines — slowly morphing topographic map
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+
+ // Permutation table
+ const PERM = new Uint8Array(512);
+ for (let i = 0; i < 256; i++) PERM[i] = i;
+ for (let i = 255; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const tmp = PERM[i]; PERM[i] = PERM[j]; PERM[j] = tmp;
+ }
+ for (let i = 0; i < 256; i++) PERM[256 + i] = PERM[i];
+
+ function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
+ function lerp(a, b, t) { return a + (b - a) * t; }
+
+ function grad(hash, x, y) {
+ const h = hash & 3;
+ const u = h < 2 ? x : y;
+ const v = h < 2 ? y : x;
+ return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
+ }
+
+ function noise2d(x, y) {
+ const xi = Math.floor(x) & 255;
+ const yi = Math.floor(y) & 255;
+ const xf = x - Math.floor(x);
+ const yf = y - Math.floor(y);
+ const u = fade(xf);
+ const v = fade(yf);
+ const aa = PERM[PERM[xi] + yi];
+ const ab = PERM[PERM[xi] + yi + 1];
+ const ba = PERM[PERM[xi + 1] + yi];
+ const bb = PERM[PERM[xi + 1] + yi + 1];
+ return lerp(
+ lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u),
+ lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u),
+ v
+ );
+ }
+
+ function fbm(x, y, octaves) {
+ let val = 0, amp = 1, freq = 1, total = 0;
+ for (let i = 0; i < octaves; i++) {
+ val += noise2d(x * freq, y * freq) * amp;
+ total += amp;
+ amp *= 0.5;
+ freq *= 2;
+ }
+ return val / total;
+ }
+
+ let time = 0;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 80; // Slower — contours don't need fast updates
+ const numContours = 12;
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ const scale = 0.012;
+
+ for (let py = 0; py < H; py++) {
+ for (let px = 0; px < W; px++) {
+ const n = fbm(px * scale + time, py * scale, 4);
+ // Normalize to 0-1
+ const v = (n + 1) * 0.5;
+
+ // Create contour lines: sharp brightness at specific iso-values
+ const contourVal = v * numContours;
+ const frac = contourVal - Math.floor(contourVal);
+ // Thin contour line when frac is near 0 or 1
+ const edge = Math.min(frac, 1 - frac);
+ const line = edge < 0.06 ? 1.0 - edge / 0.06 : 0;
+
+ // Base fill between contours
+ const fill = v * 0.15;
+ const brightness = fill + line * 0.7;
+
+ const idx = (py * W + px) * 4;
+ buf[idx] = (10 + brightness * 98) | 0;
+ buf[idx + 1] = (13 + brightness * 127) | 0;
+ buf[idx + 2] = (20 + brightness * 235) | 0;
+ buf[idx + 3] = 255;
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+ time += 0.008;
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-domain.js b/static/bg-domain.js
new file mode 100644
index 0000000..72abfb0
--- /dev/null
+++ b/static/bg-domain.js
@@ -0,0 +1,101 @@
+// Domain coloring — complex function visualization with slowly morphing parameters
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+
+ let time = 0;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 50;
+
+ // Complex arithmetic helpers
+ function cmul(ar, ai, br, bi) { return [ar * br - ai * bi, ar * bi + ai * br]; }
+ function cabs(r, i) { return Math.sqrt(r * r + i * i); }
+ function carg(r, i) { return Math.atan2(i, r); }
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ const scale = 4;
+ const aspect = W / H;
+
+ // Morphing parameter for the complex function
+ const pr = Math.sin(time * 0.3) * 0.8;
+ const pi = Math.cos(time * 0.2) * 0.8;
+
+ for (let py = 0; py < H; py++) {
+ for (let px = 0; px < W; px++) {
+ // Map pixel to complex plane
+ let zr = (px / W - 0.5) * scale * aspect;
+ let zi = (py / H - 0.5) * scale;
+
+ // f(z) = z^3 + p*z + 1 (morphing cubic)
+ const z2 = cmul(zr, zi, zr, zi);
+ const z3 = cmul(z2[0], z2[1], zr, zi);
+ const pz = cmul(pr, pi, zr, zi);
+ const wr = z3[0] + pz[0] + 1;
+ const wi = z3[1] + pz[1];
+
+ const mag = cabs(wr, wi);
+ const arg = carg(wr, wi);
+
+ // Map argument (angle) to hue, magnitude to brightness
+ // Use site palette colors based on angle
+ const t = (arg / (Math.PI * 2) + 1) % 1;
+ const brightness = 1 - 1 / (1 + mag * 0.3);
+
+ // Contour lines on magnitude
+ const logMag = Math.log(mag + 1);
+ const contour = Math.abs(logMag - Math.round(logMag)) < 0.08 ? 0.3 : 0;
+
+ let r, g, b;
+ if (t < 0.33) {
+ const s = t / 0.33;
+ r = 10 + s * 98; g = 13 + s * 127; b = 20 + s * 235;
+ } else if (t < 0.66) {
+ const s = (t - 0.33) / 0.33;
+ r = 108 - s * 74; g = 140 + s * 71; b = 255 - s * 17;
+ } else {
+ const s = (t - 0.66) / 0.34;
+ r = 34 + s * 158; g = 211 - s * 79; b = 238 + s * 14;
+ }
+
+ const v = brightness + contour;
+ const idx = (py * W + px) * 4;
+ buf[idx] = Math.min(255, (r * v) | 0);
+ buf[idx + 1] = Math.min(255, (g * v) | 0);
+ buf[idx + 2] = Math.min(255, (b * v) | 0);
+ buf[idx + 3] = 255;
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+ time += 0.008;
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-flowfield.js b/static/bg-flowfield.js
new file mode 100644
index 0000000..9ff1731
--- /dev/null
+++ b/static/bg-flowfield.js
@@ -0,0 +1,141 @@
+// Flow field background — particles tracing through curl noise
+// Produces river-like streams that slowly shift
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ // Simple value noise (no dependencies)
+ const PERM = new Uint8Array(512);
+ for (let i = 0; i < 256; i++) PERM[i] = i;
+ for (let i = 255; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const tmp = PERM[i]; PERM[i] = PERM[j]; PERM[j] = tmp;
+ }
+ for (let i = 0; i < 256; i++) PERM[256 + i] = PERM[i];
+
+ function fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
+
+ function grad(hash, x, y) {
+ const h = hash & 3;
+ const u = h < 2 ? x : y;
+ const v = h < 2 ? y : x;
+ return ((h & 1) ? -u : u) + ((h & 2) ? -v : v);
+ }
+
+ function noise2d(x, y) {
+ const xi = x | 0;
+ const yi = y | 0;
+ const xf = x - xi;
+ const yf = y - yi;
+ const u = fade(xf);
+ const v = fade(yf);
+ const X = xi & 255;
+ const Y = yi & 255;
+ const aa = PERM[PERM[X] + Y];
+ const ab = PERM[PERM[X] + Y + 1];
+ const ba = PERM[PERM[X + 1] + Y];
+ const bb = PERM[PERM[X + 1] + Y + 1];
+ const x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u);
+ const x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u);
+ return lerp(x1, x2, v);
+ }
+
+ function lerp(a, b, t) { return a + (b - a) * t; }
+
+ // Particles
+ const NUM = 800;
+ const particles = [];
+ for (let i = 0; i < NUM; i++) {
+ particles.push({
+ x: Math.random() * W,
+ y: Math.random() * H,
+ age: Math.floor(Math.random() * 200),
+ maxAge: 150 + Math.floor(Math.random() * 150),
+ });
+ }
+
+ // Trail canvas — accumulates fading particle paths
+ const trailCanvas = document.createElement('canvas');
+ trailCanvas.width = W;
+ trailCanvas.height = H;
+ const tCtx = trailCanvas.getContext('2d');
+ tCtx.fillStyle = 'rgb(10, 13, 20)';
+ tCtx.fillRect(0, 0, W, H);
+
+ let time = Math.random() * 100;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 33; // ~30fps for smooth trails
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ // Fade trails slightly
+ tCtx.fillStyle = 'rgba(10, 13, 20, 0.02)';
+ tCtx.fillRect(0, 0, W, H);
+
+ const scale = 0.008;
+
+ for (let i = 0; i < NUM; i++) {
+ const p = particles[i];
+
+ // Get flow angle from noise
+ const angle = noise2d(p.x * scale, p.y * scale + time * 0.1) * Math.PI * 4;
+
+ const prevX = p.x;
+ const prevY = p.y;
+
+ p.x += Math.cos(angle) * 0.8;
+ p.y += Math.sin(angle) * 0.8;
+ p.age++;
+
+ // Fade in/out based on age
+ const life = p.age / p.maxAge;
+ const alpha = life < 0.1 ? life / 0.1 : life > 0.9 ? (1 - life) / 0.1 : 1;
+
+ // Draw trail segment
+ tCtx.strokeStyle = 'rgba(108, 140, 255, ' + (alpha * 0.6) + ')';
+ tCtx.lineWidth = 1;
+ tCtx.beginPath();
+ tCtx.moveTo(prevX, prevY);
+ tCtx.lineTo(p.x, p.y);
+ tCtx.stroke();
+
+ // Reset if out of bounds or too old
+ if (p.x < 0 || p.x >= W || p.y < 0 || p.y >= H || p.age >= p.maxAge) {
+ p.x = Math.random() * W;
+ p.y = Math.random() * H;
+ p.age = 0;
+ p.maxAge = 150 + Math.floor(Math.random() * 150);
+ }
+ }
+
+ time += 0.003;
+
+ // Copy trail canvas to main canvas
+ ctx.drawImage(trailCanvas, 0, 0);
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (let i = 0; i < 300; i++) render(i * frameInterval);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-julia.js b/static/bg-julia.js
new file mode 100644
index 0000000..566157b
--- /dev/null
+++ b/static/bg-julia.js
@@ -0,0 +1,136 @@
+// Julia set fractal background — animated parameter drift
+// Same rendering approach as Mandelbrot: low-res canvas, smooth coloring
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ // Palette — same scheme as Mandelbrot for visual consistency
+ const palette = new Array(256);
+ for (let i = 0; i < 256; i++) {
+ const t = i / 255;
+ let r, g, b;
+ if (t < 0.25) {
+ const s = t / 0.25;
+ r = lerp(8, 108, s); g = lerp(12, 140, s); b = lerp(28, 255, s);
+ } else if (t < 0.5) {
+ const s = (t - 0.25) / 0.25;
+ r = lerp(108, 34, s); g = lerp(140, 211, s); b = lerp(255, 238, s);
+ } else if (t < 0.75) {
+ const s = (t - 0.5) / 0.25;
+ r = lerp(34, 192, s); g = lerp(211, 132, s); b = lerp(238, 252, s);
+ } else {
+ const s = (t - 0.75) / 0.25;
+ r = lerp(192, 8, s); g = lerp(132, 12, s); b = lerp(252, 28, s);
+ }
+ palette[i] = [r | 0, g | 0, b | 0];
+ }
+
+ function lerp(a, b, t) { return a + (b - a) * t; }
+
+ // Interesting Julia constant orbits — parameter c drifts along these paths
+ const paths = [
+ // Orbit near the main cardioid boundary — produces spirals
+ function (t) {
+ const a = t * Math.PI * 2;
+ const r = 0.7885;
+ return { cr: r * Math.cos(a), ci: r * Math.sin(a) };
+ },
+ // Orbit near Douady rabbit → dendrite transition
+ function (t) {
+ const a = t * Math.PI * 2;
+ return { cr: -0.8 + 0.15 * Math.cos(a), ci: 0.156 + 0.05 * Math.sin(a) };
+ },
+ // Orbit near Siegel disc
+ function (t) {
+ const a = t * Math.PI * 2;
+ return { cr: -0.4 + 0.05 * Math.cos(a), ci: 0.6 + 0.05 * Math.sin(a) };
+ },
+ ];
+
+ let pathIdx = Math.floor(Math.random() * paths.length);
+ let time = Math.random(); // Start at random phase
+
+ const maxIter = 80;
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+
+ const zoom = 3.2;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 50; // ~20fps
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ const path = paths[pathIdx];
+ const c = path(time);
+ const cr = c.cr;
+ const ci = c.ci;
+
+ const aspect = W / H;
+ const halfW = zoom * 0.5;
+ const halfH = halfW / aspect;
+ const dx = zoom / W;
+ const dy = (zoom / aspect) / H;
+
+ for (let py = 0; py < H; py++) {
+ const y0 = -halfH + py * dy;
+ for (let px = 0; px < W; px++) {
+ const x0 = -halfW + px * dx;
+ let zr = x0, zi = y0;
+ let iter = 0;
+
+ while (zr * zr + zi * zi <= 4 && iter < maxIter) {
+ const tmp = zr * zr - zi * zi + cr;
+ zi = 2 * zr * zi + ci;
+ zr = tmp;
+ iter++;
+ }
+
+ const idx = (py * W + px) * 4;
+ if (iter === maxIter) {
+ buf[idx] = 10; buf[idx + 1] = 13; buf[idx + 2] = 20; buf[idx + 3] = 255;
+ } else {
+ const log2 = Math.log(2);
+ const nu = Math.log(Math.log(zr * zr + zi * zi) / log2) / log2;
+ const smooth = (iter + 1 - nu) / maxIter;
+ const ci2 = ((smooth * 255 * 3) | 0) % 256;
+ const col = palette[ci2];
+ buf[idx] = col[0]; buf[idx + 1] = col[1]; buf[idx + 2] = col[2]; buf[idx + 3] = 255;
+ }
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+
+ // Drift c parameter slowly
+ time += 0.0003;
+ if (time > 1.0) {
+ time = 0;
+ pathIdx = (pathIdx + 1) % paths.length;
+ }
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-life.js b/static/bg-life.js
new file mode 100644
index 0000000..24f79e4
--- /dev/null
+++ b/static/bg-life.js
@@ -0,0 +1,192 @@
+// Conway's Game of Life — new random seed every 10 seconds
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const CELL = 3; // Pixel size per cell
+ const W = Math.ceil(360 / CELL);
+ const H = Math.ceil(240 / CELL);
+ canvas.width = W * CELL;
+ canvas.height = H * CELL;
+
+ let grid = new Uint8Array(W * H);
+ let next = new Uint8Array(W * H);
+
+ // Seed patterns
+ function seedRandom(density) {
+ for (let i = 0; i < W * H; i++) {
+ grid[i] = Math.random() < density ? 1 : 0;
+ }
+ }
+
+ function seedSymmetric() {
+ // Generate left half randomly, mirror to right
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < Math.ceil(W / 2); x++) {
+ const alive = Math.random() < 0.3 ? 1 : 0;
+ grid[y * W + x] = alive;
+ grid[y * W + (W - 1 - x)] = alive;
+ }
+ }
+ }
+
+ function seedClusters() {
+ grid.fill(0);
+ const numClusters = 8 + Math.floor(Math.random() * 8);
+ for (let c = 0; c < numClusters; c++) {
+ const cx = Math.floor(Math.random() * W);
+ const cy = Math.floor(Math.random() * H);
+ const r = 5 + Math.floor(Math.random() * 10);
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (dx * dx + dy * dy <= r * r && Math.random() < 0.45) {
+ const x = (cx + dx + W) % W;
+ const y = (cy + dy + H) % H;
+ grid[y * W + x] = 1;
+ }
+ }
+ }
+ }
+ }
+
+ function seedSoup() {
+ grid.fill(0);
+ // Dense central soup
+ const sx = Math.floor(W * 0.3);
+ const sy = Math.floor(H * 0.3);
+ const sw = Math.floor(W * 0.4);
+ const sh = Math.floor(H * 0.4);
+ for (let y = sy; y < sy + sh; y++) {
+ for (let x = sx; x < sx + sw; x++) {
+ grid[y * W + x] = Math.random() < 0.4 ? 1 : 0;
+ }
+ }
+ }
+
+ const seedFns = [
+ function () { seedRandom(0.25); },
+ function () { seedRandom(0.35); },
+ seedSymmetric,
+ seedClusters,
+ seedSoup,
+ ];
+
+ let seedIdx = Math.floor(Math.random() * seedFns.length);
+ seedFns[seedIdx]();
+
+ // Track generation for reseed timing
+ let generation = 0;
+ let lastSeed = 0;
+ const reseedInterval = 10000; // 10 seconds
+
+ // Fade buffer for glow effect
+ const imgData = ctx.createImageData(W * CELL, H * CELL);
+ const buf = imgData.data;
+ // Age tracking for cell brightness
+ const age = new Float32Array(W * H);
+
+ function step() {
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ // Count neighbors (wrapping)
+ let count = 0;
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ if (dx === 0 && dy === 0) continue;
+ const nx = (x + dx + W) % W;
+ const ny = (y + dy + H) % H;
+ count += grid[ny * W + nx];
+ }
+ }
+
+ const idx = y * W + x;
+ const alive = grid[idx];
+
+ if (alive) {
+ next[idx] = (count === 2 || count === 3) ? 1 : 0;
+ } else {
+ next[idx] = (count === 3) ? 1 : 0;
+ }
+ }
+ }
+
+ // Swap
+ const tmp = grid; grid = next; next = tmp;
+ generation++;
+ }
+
+ function draw() {
+ // Update age: alive cells brighten, dead cells fade
+ for (let i = 0; i < W * H; i++) {
+ if (grid[i]) {
+ age[i] = Math.min(1, age[i] + 0.3);
+ } else {
+ age[i] = Math.max(0, age[i] - 0.02);
+ }
+ }
+
+ // Render cells
+ const cw = W * CELL;
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ const v = age[y * W + x];
+
+ // Color: dark background → accent blue when alive
+ const r = (10 + v * 98) | 0;
+ const g = (13 + v * 127) | 0;
+ const b = (20 + v * 235) | 0;
+
+ // Fill cell pixels
+ for (let cy = 0; cy < CELL; cy++) {
+ for (let cx = 0; cx < CELL; cx++) {
+ const idx = ((y * CELL + cy) * cw + (x * CELL + cx)) * 4;
+ buf[idx] = r;
+ buf[idx + 1] = g;
+ buf[idx + 2] = b;
+ buf[idx + 3] = 255;
+ }
+ }
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+ }
+
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 80; // ~12fps — deliberate, cellular automata look better slow
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ step();
+ draw();
+
+ // Reseed every 10 seconds
+ if (timestamp - lastSeed > reseedInterval) {
+ lastSeed = timestamp;
+ seedIdx = (seedIdx + 1) % seedFns.length;
+ seedFns[seedIdx]();
+ age.fill(0);
+ }
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (let i = 0; i < 50; i++) step();
+ draw();
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ lastSeed = performance.now();
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-lissajous.js b/static/bg-lissajous.js
new file mode 100644
index 0000000..e5f1924
--- /dev/null
+++ b/static/bg-lissajous.js
@@ -0,0 +1,76 @@
+// Lissajous curves — slowly drifting frequency ratios produce evolving knots
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ const trail = document.createElement('canvas');
+ trail.width = W;
+ trail.height = H;
+ const tCtx = trail.getContext('2d');
+ tCtx.fillStyle = 'rgb(10, 13, 20)';
+ tCtx.fillRect(0, 0, W, H);
+
+ // Multiple curves with slightly different parameters
+ const curves = [
+ { a: 3, b: 2, delta: 0, color: 'rgba(108, 140, 255, 0.4)', drift: 0.0007 },
+ { a: 5, b: 4, delta: Math.PI / 4, color: 'rgba(34, 211, 238, 0.3)', drift: 0.0005 },
+ { a: 7, b: 6, delta: Math.PI / 3, color: 'rgba(192, 132, 252, 0.3)', drift: 0.0009 },
+ ];
+
+ let time = 0;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 33;
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ tCtx.fillStyle = 'rgba(10, 13, 20, 0.008)';
+ tCtx.fillRect(0, 0, W, H);
+
+ for (let c = 0; c < curves.length; c++) {
+ const curve = curves[c];
+ const a = curve.a + Math.sin(time * curve.drift * 10) * 0.5;
+ const b = curve.b + Math.cos(time * curve.drift * 8) * 0.3;
+ const delta = curve.delta + time * 0.02;
+
+ tCtx.strokeStyle = curve.color;
+ tCtx.lineWidth = 1;
+ tCtx.beginPath();
+
+ for (let t = 0; t < Math.PI * 2; t += 0.01) {
+ const x = W / 2 + Math.sin(a * t + delta) * (W * 0.4);
+ const y = H / 2 + Math.sin(b * t) * (H * 0.4);
+ if (t === 0) tCtx.moveTo(x, y);
+ else tCtx.lineTo(x, y);
+ }
+ tCtx.stroke();
+ }
+
+ time += 0.015;
+ ctx.drawImage(trail, 0, 0);
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-lorenz.js b/static/bg-lorenz.js
new file mode 100644
index 0000000..3739368
--- /dev/null
+++ b/static/bg-lorenz.js
@@ -0,0 +1,111 @@
+// Lorenz strange attractor — glowing chaotic orbit traces
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ // Trail canvas
+ const trail = document.createElement('canvas');
+ trail.width = W;
+ trail.height = H;
+ const tCtx = trail.getContext('2d');
+ tCtx.fillStyle = 'rgb(10, 13, 20)';
+ tCtx.fillRect(0, 0, W, H);
+
+ // Lorenz parameters
+ const sigma = 10;
+ const rho = 28;
+ const beta = 8 / 3;
+ const dt = 0.005;
+
+ // Multiple traces for richer visual
+ const traces = [];
+ for (let i = 0; i < 3; i++) {
+ traces.push({
+ x: 1 + Math.random() * 0.1,
+ y: 1 + Math.random() * 0.1,
+ z: 1 + Math.random() * 0.1,
+ color: [
+ [108, 140, 255],
+ [34, 211, 238],
+ [192, 132, 252],
+ ][i],
+ });
+ }
+
+ // Project 3D → 2D (simple orthographic, rotated slowly)
+ let angle = 0;
+
+ function project(x, y, z) {
+ const ca = Math.cos(angle);
+ const sa = Math.sin(angle);
+ const rx = x * ca - y * sa;
+ const ry = x * sa + y * ca;
+ // Scale and center
+ return {
+ px: W / 2 + rx * 5.5,
+ py: H / 2 - z * 3.5 + 70,
+ };
+ }
+
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 33;
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ // Fade trails
+ tCtx.fillStyle = 'rgba(10, 13, 20, 0.015)';
+ tCtx.fillRect(0, 0, W, H);
+
+ for (let t = 0; t < traces.length; t++) {
+ const tr = traces[t];
+ const p1 = project(tr.x, tr.y, tr.z);
+
+ // Integrate 4 steps per frame
+ for (let s = 0; s < 4; s++) {
+ const dx = sigma * (tr.y - tr.x) * dt;
+ const dy = (tr.x * (rho - tr.z) - tr.y) * dt;
+ const dz = (tr.x * tr.y - beta * tr.z) * dt;
+ tr.x += dx;
+ tr.y += dy;
+ tr.z += dz;
+ }
+
+ const p2 = project(tr.x, tr.y, tr.z);
+
+ tCtx.strokeStyle = 'rgba(' + tr.color[0] + ',' + tr.color[1] + ',' + tr.color[2] + ', 0.5)';
+ tCtx.lineWidth = 1;
+ tCtx.beginPath();
+ tCtx.moveTo(p1.px, p1.py);
+ tCtx.lineTo(p2.px, p2.py);
+ tCtx.stroke();
+ }
+
+ angle += 0.0003;
+ ctx.drawImage(trail, 0, 0);
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (let i = 0; i < 500; i++) render(i * frameInterval);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-reaction-diffusion.js b/static/bg-reaction-diffusion.js
new file mode 100644
index 0000000..37cff7a
--- /dev/null
+++ b/static/bg-reaction-diffusion.js
@@ -0,0 +1,125 @@
+// Reaction-diffusion (Gray-Scott model) background
+// Slowly evolving Turing patterns — spots, stripes, coral
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 200;
+ const H = 133;
+ canvas.width = W;
+ canvas.height = H;
+
+ // Gray-Scott parameters — different presets give different patterns
+ const presets = [
+ { f: 0.0545, k: 0.062, name: 'spots' },
+ { f: 0.042, k: 0.063, name: 'stripes' },
+ { f: 0.035, k: 0.065, name: 'coral' },
+ { f: 0.025, k: 0.06, name: 'worms' },
+ ];
+
+ const preset = presets[Math.floor(Math.random() * presets.length)];
+ const f = preset.f;
+ const k = preset.k;
+ const Da = 1.0;
+ const Db = 0.5;
+
+ // Two chemical concentrations
+ const size = W * H;
+ let a = new Float32Array(size);
+ let b = new Float32Array(size);
+ let nextA = new Float32Array(size);
+ let nextB = new Float32Array(size);
+
+ // Initialize: all A=1, B=0, with a seeded region of B
+ for (let i = 0; i < size; i++) { a[i] = 1.0; b[i] = 0.0; }
+
+ // Seed several random spots of chemical B
+ for (let s = 0; s < 12; s++) {
+ const sx = 20 + Math.floor(Math.random() * (W - 40));
+ const sy = 20 + Math.floor(Math.random() * (H - 40));
+ const r = 3 + Math.floor(Math.random() * 4);
+ for (let dy = -r; dy <= r; dy++) {
+ for (let dx = -r; dx <= r; dx++) {
+ if (dx * dx + dy * dy <= r * r) {
+ const idx = ((sy + dy + H) % H) * W + ((sx + dx + W) % W);
+ b[idx] = 1.0;
+ }
+ }
+ }
+ }
+
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 50;
+
+ function step() {
+ for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ const idx = y * W + x;
+
+ // Laplacian with wrapping
+ const up = ((y - 1 + H) % H) * W + x;
+ const dn = ((y + 1) % H) * W + x;
+ const lt = y * W + ((x - 1 + W) % W);
+ const rt = y * W + ((x + 1) % W);
+
+ const lapA = a[up] + a[dn] + a[lt] + a[rt] - 4 * a[idx];
+ const lapB = b[up] + b[dn] + b[lt] + b[rt] - 4 * b[idx];
+
+ const aVal = a[idx];
+ const bVal = b[idx];
+ const abb = aVal * bVal * bVal;
+
+ nextA[idx] = aVal + Da * lapA - abb + f * (1.0 - aVal);
+ nextB[idx] = bVal + Db * lapB + abb - (k + f) * bVal;
+ }
+ }
+
+ // Swap buffers
+ const tmpA = a; a = nextA; nextA = tmpA;
+ const tmpB = b; b = nextB; nextB = tmpB;
+ }
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ // Run multiple simulation steps per frame for visible evolution
+ for (let s = 0; s < 8; s++) step();
+
+ // Render: map chemical B concentration to color
+ for (let i = 0; i < size; i++) {
+ const v = Math.min(1, Math.max(0, b[i]));
+ const idx = i * 4;
+
+ // Dark blue/cyan palette matching site colors
+ buf[idx] = (10 + v * 98) | 0; // R: 10 → 108
+ buf[idx + 1] = (13 + v * 127) | 0; // G: 13 → 140
+ buf[idx + 2] = (20 + v * 235) | 0; // B: 20 → 255
+ buf[idx + 3] = 255;
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (let i = 0; i < 2000; i++) step();
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-sierpinski.js b/static/bg-sierpinski.js
new file mode 100644
index 0000000..68ff138
--- /dev/null
+++ b/static/bg-sierpinski.js
@@ -0,0 +1,94 @@
+// Sierpinski triangle — chaos game with slowly rotating vertices
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+ // Accumulation buffer — counts how many times each pixel is hit
+ const accum = new Float32Array(W * H);
+
+ // Fill background
+ for (let i = 0; i < W * H * 4; i += 4) {
+ buf[i] = 10; buf[i + 1] = 13; buf[i + 2] = 20; buf[i + 3] = 255;
+ }
+
+ let angle = 0;
+ let px = W / 2;
+ let py = H / 2;
+
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 50;
+ let generation = 0;
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ // Three vertices of the triangle, slowly rotating
+ const cx = W / 2;
+ const cy = H / 2;
+ const r = Math.min(W, H) * 0.45;
+ const vertices = [];
+ for (let i = 0; i < 3; i++) {
+ const a = angle + (i * Math.PI * 2) / 3 - Math.PI / 2;
+ vertices.push({ x: cx + Math.cos(a) * r, y: cy + Math.sin(a) * r });
+ }
+
+ // Run chaos game iterations
+ for (let i = 0; i < 500; i++) {
+ const v = vertices[Math.floor(Math.random() * 3)];
+ px = (px + v.x) / 2;
+ py = (py + v.y) / 2;
+
+ const ix = Math.floor(px);
+ const iy = Math.floor(py);
+ if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
+ accum[iy * W + ix] += 0.15;
+ }
+ }
+
+ // Fade accumulation slowly
+ for (let i = 0; i < W * H; i++) {
+ accum[i] *= 0.998;
+ const v = Math.min(1, accum[i]);
+ const idx = i * 4;
+ buf[idx] = (10 + v * 98) | 0;
+ buf[idx + 1] = (13 + v * 127) | 0;
+ buf[idx + 2] = (20 + v * 235) | 0;
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+
+ angle += 0.001;
+ generation++;
+
+ // Periodically clear for fresh pattern
+ if (generation % 3000 === 0) {
+ accum.fill(0);
+ }
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ for (let i = 0; i < 100; i++) render(i * frameInterval);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/static/bg-waves.js b/static/bg-waves.js
new file mode 100644
index 0000000..6c13f17
--- /dev/null
+++ b/static/bg-waves.js
@@ -0,0 +1,88 @@
+// Wave interference — overlapping concentric sine waves creating moiré patterns
+(function () {
+ 'use strict';
+
+ const canvas = document.getElementById('fractal-bg');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+
+ const W = 360;
+ const H = 240;
+ canvas.width = W;
+ canvas.height = H;
+
+ const imgData = ctx.createImageData(W, H);
+ const buf = imgData.data;
+
+ // Wave sources — slowly drifting positions
+ const sources = [
+ { x: W * 0.3, y: H * 0.4, freq: 0.15, phase: 0, dx: 0.12, dy: 0.08 },
+ { x: W * 0.7, y: H * 0.6, freq: 0.18, phase: 1.5, dx: -0.09, dy: 0.11 },
+ { x: W * 0.5, y: H * 0.2, freq: 0.12, phase: 3.0, dx: 0.07, dy: -0.06 },
+ { x: W * 0.2, y: H * 0.8, freq: 0.20, phase: 4.5, dx: -0.05, dy: -0.10 },
+ ];
+
+ let time = 0;
+ let animId;
+ let lastFrame = 0;
+ const frameInterval = 50;
+
+ function render(timestamp) {
+ animId = requestAnimationFrame(render);
+ if (timestamp - lastFrame < frameInterval) return;
+ lastFrame = timestamp;
+
+ for (let py = 0; py < H; py++) {
+ for (let px = 0; px < W; px++) {
+ let sum = 0;
+
+ for (let s = 0; s < sources.length; s++) {
+ const src = sources[s];
+ const dx = px - src.x;
+ const dy = py - src.y;
+ const dist = Math.sqrt(dx * dx + dy * dy);
+ sum += Math.sin(dist * src.freq - time * 2 + src.phase);
+ }
+
+ // Normalize to 0-1
+ const v = (sum / sources.length + 1) * 0.5;
+ const idx = (py * W + px) * 4;
+
+ // Map to site palette
+ buf[idx] = (10 + v * 98) | 0;
+ buf[idx + 1] = (13 + v * 127) | 0;
+ buf[idx + 2] = (20 + v * 235) | 0;
+ buf[idx + 3] = 255;
+ }
+ }
+
+ ctx.putImageData(imgData, 0, 0);
+
+ // Drift sources slowly
+ for (let s = 0; s < sources.length; s++) {
+ const src = sources[s];
+ src.x += src.dx;
+ src.y += src.dy;
+ // Bounce off edges
+ if (src.x < 0 || src.x >= W) src.dx *= -1;
+ if (src.y < 0 || src.y >= H) src.dy *= -1;
+ src.x = Math.max(0, Math.min(W - 1, src.x));
+ src.y = Math.max(0, Math.min(H - 1, src.y));
+ }
+
+ time += 0.03;
+ }
+
+ function onVisibility() {
+ if (document.hidden) { cancelAnimationFrame(animId); }
+ else { animId = requestAnimationFrame(render); }
+ }
+
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ render(0);
+ cancelAnimationFrame(animId);
+ } else {
+ document.addEventListener('visibilitychange', onVisibility);
+ animId = requestAnimationFrame(render);
+ }
+})();
diff --git a/templates/base.html b/templates/base.html
index 787a923..b56211b 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -95,6 +95,6 @@
-
+