summaryrefslogtreecommitdiff
path: root/internal/generator
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-10 09:22:06 +0300
committerPaul Buetow <paul@buetow.org>2026-04-10 09:22:06 +0300
commitf3155cbc866a1b261a0aafe61683da1e3c25b1ef (patch)
treededad4708adf0b377dd06c11c1bc957bc1bfe302 /internal/generator
parent3e61d09873065f5342efc414ee3ea0d5fdc4c767 (diff)
add WebGL scenes to all themes, sounds, image sizing, new themes
- All 11 themes now have unique Three.js WebGL backgrounds: aurora: flowing sine-wave ribbon meshes with additive blending brutalist: harsh rotating white/red wireframe boxes glass: drifting crystal icosahedron shards, semi-transparent matrix: digital rain particle columns with per-vertex colour fade ocean: animated vertex-displaced wave surface with orbiting light retro: amber demo-scene cube with orbiting octahedrons synthwave: sunset sphere with scan-line rings and perspective grid terminal: green icosahedron wireframe with orbiting torus particles neon: existing Three.js orb and rings (unchanged) plasma (replaces minimal): drifting additive-blend colour blobs volcano (replaces paper): rising ember particles with lifecycle - shared.go: distinct sounds for j/k nav (220Hz beep), Enter (ascending triangle chime), and Esc (descending sine sweep) - shared.go: images are now thumbnails (max-height:220px) in list view and expand to full width inside the modal (CSS override in navmodal) - main.go: --list-themes flag prints all theme names and exits; --theme random picks a random theme at all generation time - themes.go: updated registry with plasma/volcano replacing minimal/paper Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/generator')
-rw-r--r--internal/generator/shared.go52
-rw-r--r--internal/generator/theme_aurora.go109
-rw-r--r--internal/generator/theme_brutalist.go68
-rw-r--r--internal/generator/theme_glass.go114
-rw-r--r--internal/generator/theme_matrix.go102
-rw-r--r--internal/generator/theme_minimal.go180
-rw-r--r--internal/generator/theme_ocean.go91
-rw-r--r--internal/generator/theme_paper.go202
-rw-r--r--internal/generator/theme_retro.go87
-rw-r--r--internal/generator/theme_synthwave.go110
-rw-r--r--internal/generator/theme_terminal.go78
-rw-r--r--internal/generator/themes.go4
12 files changed, 1006 insertions, 191 deletions
diff --git a/internal/generator/shared.go b/internal/generator/shared.go
index eed4de3..12a91f4 100644
--- a/internal/generator/shared.go
+++ b/internal/generator/shared.go
@@ -3,13 +3,12 @@ package generator
// navDefs is appended to every theme template when parsing.
// It defines three named sub-templates shared across all themes:
// - "navhints" — keyboard shortcut hint bar HTML
-// - "navmodal" — full-screen expanded-post modal HTML
-// - "navscript" — keyboard navigation JavaScript
+// - "navmodal" — full-screen expanded-post modal HTML + image-sizing CSS
+// - "navscript" — keyboard navigation JavaScript with distinct sounds per action
//
// Each theme calls {{template "navhints" .}}, {{template "navmodal" .}}, and
// {{template "navscript" .}} at the appropriate points in its HTML.
-// All CSS for these elements (colours, borders, backdrop) lives in each theme
-// so themes remain self-contained and independently styled.
+// All theme-specific CSS lives in each theme file so themes stay self-contained.
const navDefs = `
{{define "navhints"}}
<div class="nav-hints" aria-label="keyboard shortcuts">
@@ -21,6 +20,12 @@ const navDefs = `
{{end}}
{{define "navmodal"}}
+<style>
+/* Thumbnail sizing in list view; modal overrides to full width so images
+ appear larger when a post is expanded with Enter. */
+.post-image { max-height:220px; max-width:100%; object-fit:cover; cursor:pointer; }
+#post-modal .post-image { max-height:none; width:100%; max-width:100%; object-fit:contain; cursor:default; }
+</style>
<div class="post-modal" id="post-modal">
<div class="modal-inner">
<button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button>
@@ -51,8 +56,7 @@ const navDefs = `
playNavSound();
}
- // playNavSound generates a short beep via the Web Audio API.
- // A fresh AudioContext per call avoids state issues across navigations.
+ // playNavSound: short low beep for post selection (j/k navigation).
function playNavSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
@@ -60,21 +64,55 @@ const navDefs = `
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 220; osc.type = 'sine';
- gain.gain.setValueAtTime(0.15, ctx.currentTime);
+ gain.gain.setValueAtTime(0.12, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.08);
osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.08);
} catch (_) {}
}
+ // playOpenSound: bright ascending chime when modal opens (Enter key).
+ function playOpenSound() {
+ try {
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.type = 'triangle';
+ osc.frequency.setValueAtTime(440, ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(880, ctx.currentTime + 0.14);
+ gain.gain.setValueAtTime(0.10, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.20);
+ osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.20);
+ } catch (_) {}
+ }
+
+ // playCloseSound: descending sweep when modal closes (Esc key).
+ function playCloseSound() {
+ try {
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.connect(gain); gain.connect(ctx.destination);
+ osc.type = 'sine';
+ osc.frequency.setValueAtTime(440, ctx.currentTime);
+ osc.frequency.exponentialRampToValueAtTime(110, ctx.currentTime + 0.15);
+ gain.gain.setValueAtTime(0.10, ctx.currentTime);
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
+ osc.start(ctx.currentTime); osc.stop(ctx.currentTime + 0.18);
+ } catch (_) {}
+ }
+
function openModal() {
if (currentIndex < 0) return;
document.getElementById('modal-content').innerHTML =
posts[currentIndex].querySelector('.post-text').innerHTML;
document.getElementById('post-modal').classList.add('active');
+ playOpenSound();
}
function closeModal() {
document.getElementById('post-modal').classList.remove('active');
+ playCloseSound();
}
document.addEventListener('keydown', function(e) {
diff --git a/internal/generator/theme_aurora.go b/internal/generator/theme_aurora.go
index 9475320..4fa85f6 100644
--- a/internal/generator/theme_aurora.go
+++ b/internal/generator/theme_aurora.go
@@ -1,32 +1,21 @@
package generator
-// auroraTemplate is a dark navy theme with a CSS-animated aurora borealis
-// effect — shifting green/purple/teal gradients across the background sky.
+// auroraTemplate is a dark navy theme with a WebGL aurora borealis effect —
+// six wide ribbon meshes whose vertices are animated with overlapping sine waves,
+// rendered with additive blending to create the characteristic glow.
const auroraTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snonux.foo ✦ AURORA</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --green:#00ffb3; --teal:#00cfe8; --purple:#c084fc; --navy:#050d1a; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--navy);
color:#e0f8f0; overflow:hidden; height:100vh; }
- /* Animated aurora bands */
- @keyframes aurora1 { 0%,100%{opacity:0.18;transform:scaleX(1) translateY(0)} 50%{opacity:0.28;transform:scaleX(1.15) translateY(-14px)} }
- @keyframes aurora2 { 0%,100%{opacity:0.12;transform:scaleX(1) translateY(0)} 50%{opacity:0.22;transform:scaleX(0.88) translateY(10px)} }
- @keyframes aurora3 { 0%,100%{opacity:0.10;transform:scaleX(1) skewY(0deg)} 50%{opacity:0.18;transform:scaleX(1.08) skewY(2deg)} }
- .aurora-bg { position:fixed; inset:0; z-index:0; overflow:hidden; }
- .aurora-bg::before { content:''; position:absolute; left:-20%; top:5%; width:140%; height:45%;
- background:radial-gradient(ellipse,rgba(0,255,179,0.38) 0%,rgba(0,207,232,0.22) 40%,transparent 70%);
- filter:blur(40px); animation:aurora1 12s ease-in-out infinite; }
- .aurora-bg::after { content:''; position:absolute; left:10%; top:20%; width:120%; height:55%;
- background:radial-gradient(ellipse,rgba(192,132,252,0.28) 0%,rgba(0,255,179,0.18) 45%,transparent 70%);
- filter:blur(50px); animation:aurora2 16s ease-in-out infinite; }
- .aurora-band3 { position:fixed; left:-10%; top:35%; width:130%; height:40%; z-index:0;
- background:radial-gradient(ellipse,rgba(0,207,232,0.22) 0%,rgba(192,132,252,0.14) 50%,transparent 75%);
- filter:blur(45px); animation:aurora3 20s ease-in-out infinite; }
+ #three-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:1; }
.overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; }
header { padding:16px 28px; background:rgba(5,13,26,0.78); backdrop-filter:blur(14px);
border-bottom:1px solid rgba(0,255,179,0.25); display:flex; align-items:center; justify-content:space-between; }
@@ -38,8 +27,7 @@ const auroraTemplate = `<!DOCTYPE html>
.logo-title .subtitle a { color:var(--green); text-decoration:none; }
.logo-title .subtitle a:hover { text-shadow:0 0 8px var(--green); }
.transmit-btn { border:1px solid var(--teal); color:var(--teal); padding:9px 20px;
- border-radius:20px; text-decoration:none; font-size:0.85rem;
- transition:all 0.2s; }
+ border-radius:20px; text-decoration:none; font-size:0.85rem; transition:all 0.2s; }
.transmit-btn:hover { background:var(--teal); color:var(--navy); }
.nav-hints { background:rgba(5,13,26,0.6); border-bottom:1px solid rgba(0,255,179,0.15);
color:rgba(224,248,240,0.45); padding:5px 28px; display:flex; gap:18px;
@@ -63,7 +51,6 @@ const auroraTemplate = `<!DOCTYPE html>
.post-text { line-height:1.65; font-size:0.95rem; }
.post-text a { color:var(--green); text-decoration:none; }
.post-text a:hover { text-shadow:0 0 8px var(--green); }
- .post-image { max-width:100%; border-radius:8px; margin-top:10px; }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
background:rgba(5,13,26,0.95); backdrop-filter:blur(20px);
@@ -78,8 +65,7 @@ const auroraTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
- <div class="aurora-bg"></div>
- <div class="aurora-band3"></div>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -109,6 +95,87 @@ const auroraTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Aurora WebGL: six wide ribbon meshes whose top-row vertices are animated
+ // with overlapping sine waves, rendered with additive blending so they glow
+ // against the dark navy sky like real aurora curtains.
+ (function() {
+ var RIBBON_COUNT = 6;
+ var SEG_W = 60; // horizontal segments per ribbon
+ var ribbonColors = [0x00ffb3, 0x00cfe8, 0xc084fc, 0x00ffb3, 0x48e8d0, 0xa855f7];
+ var ribbonY = [-10, -4, 2, 8, 14, 20];
+ var ribbonZ = [-40, -30, -22, -15, -10, -5];
+ var ribbonFreq = [0.6, 0.9, 0.7, 1.1, 0.5, 0.8];
+ var ribbonPhase = [0.0, 1.2, 2.4, 0.8, 3.1, 1.7];
+ var ribbonAmp = [3.0, 2.5, 2.0, 3.5, 2.2, 2.8];
+
+ var scene, camera, renderer, clock;
+ var ribbons = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x050d1a);
+ scene.fog = new THREE.Fog(0x050d1a, 40, 120);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
+ camera.position.set(0, 5, 30);
+ camera.lookAt(0, 5, 0);
+
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: true });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+
+ clock = new THREE.Clock();
+
+ for (var r = 0; r < RIBBON_COUNT; r++) {
+ // Wide shallow plane; we animate the top row of vertices
+ var geo = new THREE.PlaneGeometry(120, 8, SEG_W, 1);
+ var mat = new THREE.MeshBasicMaterial({
+ color: ribbonColors[r], transparent: true, opacity: 0.32,
+ side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false
+ });
+ var mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(0, ribbonY[r], ribbonZ[r]);
+ scene.add(mesh);
+ ribbons.push({ mesh: mesh, geo: geo, freq: ribbonFreq[r],
+ phase: ribbonPhase[r], amp: ribbonAmp[r] });
+ }
+
+ window.addEventListener('resize', onResize);
+ animate();
+ }
+
+ function onResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ }
+
+ function animate() {
+ requestAnimationFrame(animate);
+ var t = clock.getElapsedTime();
+
+ for (var r = 0; r < ribbons.length; r++) {
+ var rb = ribbons[r];
+ var pos = rb.geo.attributes.position;
+ var count = pos.count;
+ // PlaneGeometry vertices: (SEG_W+1)*2 total; top row is every other vertex
+ for (var i = 0; i < count; i++) {
+ var x = pos.getX(i);
+ // Only animate top row (y > 0 in local space) for the waving top edge
+ if (pos.getY(i) > 0) {
+ pos.setY(i, rb.amp * Math.sin(t * rb.freq + x * 0.08 + rb.phase)
+ + rb.amp * 0.4 * Math.cos(t * rb.freq * 0.7 + x * 0.05));
+ }
+ }
+ pos.needsUpdate = true;
+ }
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_brutalist.go b/internal/generator/theme_brutalist.go
index 214c103..aa0729d 100644
--- a/internal/generator/theme_brutalist.go
+++ b/internal/generator/theme_brutalist.go
@@ -1,19 +1,22 @@
package generator
// brutalistTemplate is a raw brutalist theme — pure black, thick white borders,
-// Impact font, red as the only accent. No rounded corners anywhere.
+// Impact font, red as the only accent. WebGL scene: harsh slowly-rotating boxes,
+// wireframe red and solid white, no fog — brutal clarity.
const brutalistTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SNONUX.FOO</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --red:#ff2200; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:Impact,'Arial Narrow',Arial,sans-serif;
background:#000; color:#fff; overflow:hidden; height:100vh; }
- .overlay { height:100vh; display:flex; flex-direction:column; }
+ #three-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:1; }
+ .overlay { height:100vh; display:flex; flex-direction:column; position:relative; z-index:10; }
header { padding:14px 24px; background:#000; border-bottom:4px solid #fff;
display:flex; align-items:center; justify-content:space-between; }
.logo { display:flex; align-items:center; gap:16px; }
@@ -49,7 +52,6 @@ const brutalistTemplate = `<!DOCTYPE html>
.post-time { color:#aaa; font-family:'Courier New',monospace; font-size:0.82rem; }
.post-text { font-family:'Arial',sans-serif; font-size:1rem; line-height:1.5; }
.post-text a { color:var(--red); text-decoration:underline; }
- .post-image { max-width:100%; margin-top:10px; border:3px solid #fff; }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
background:rgba(0,0,0,0.98); overflow-y:auto; padding:40px 20px; }
@@ -63,6 +65,7 @@ const brutalistTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -92,6 +95,65 @@ const brutalistTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Brutalist WebGL: harsh slowly-rotating boxes — solid white and wireframe red.
+ // No fog, no softness. Pure geometric violence against the black void.
+ (function() {
+ var scene, camera, renderer, clock;
+ var boxes = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x000000);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
+ camera.position.set(0, 0, 40);
+
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: false });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ clock = new THREE.Clock();
+
+ // Box configurations: [size, posX, posY, posZ, rotSpeedX, rotSpeedY, wireframe, color]
+ var configs = [
+ [10, 0, 0, 0, 0.002, 0.005, false, 0xffffff],
+ [6, 18, -6, -8, 0.004, 0.003, true, 0xff2200],
+ [7, -16, 5, -10, 0.003, 0.006, true, 0xff2200],
+ [5, 8, 12, -5, 0.006, 0.002, false, 0xff2200],
+ [4, -10,-10, -3, 0.005, 0.004, false, 0xffffff],
+ ];
+
+ configs.forEach(function(c) {
+ var geo = new THREE.BoxGeometry(c[0], c[0], c[0]);
+ var mat = new THREE.MeshBasicMaterial({ color: c[7], wireframe: c[6] });
+ var mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(c[1], c[2], c[3]);
+ boxes.push({ mesh: mesh, rx: c[4], ry: c[5] });
+ scene.add(mesh);
+ });
+
+ window.addEventListener('resize', onResize);
+ animate();
+ }
+
+ function onResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ }
+
+ function animate() {
+ requestAnimationFrame(animate);
+ boxes.forEach(function(b) {
+ b.mesh.rotation.x += b.rx;
+ b.mesh.rotation.y += b.ry;
+ });
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_glass.go b/internal/generator/theme_glass.go
index 520f9b0..598a2f3 100644
--- a/internal/generator/theme_glass.go
+++ b/internal/generator/theme_glass.go
@@ -1,30 +1,21 @@
package generator
-// glassTemplate is a glassmorphism theme — semi-transparent frosted panels
-// using backdrop-filter:blur over a blurred gradient background.
-// Light mode with subtle purple/blue gradient blobs and white glass cards.
+// glassTemplate is a glassmorphism theme — semi-transparent frosted panels over
+// a WebGL background of slowly drifting crystal icosahedron shards.
+// CSS gradient blobs are replaced by the WebGL scene.
const glassTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snonux.foo · glass</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --blue:#6366f1; --purple:#a855f7; --pink:#ec4899; --text:#1e1b4b; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Segoe UI',system-ui,sans-serif; overflow:hidden; height:100vh;
background:#f0f4ff; color:var(--text); }
- /* Blurred gradient blobs that sit behind all glass panels */
- .bg-blobs { position:fixed; inset:0; z-index:0; overflow:hidden; }
- .bg-blobs::before { content:''; position:absolute; top:-20%; left:-10%; width:60%; height:70%;
- border-radius:50%; background:radial-gradient(circle,rgba(99,102,241,0.35),rgba(168,85,247,0.2),transparent 70%);
- filter:blur(60px); }
- .bg-blobs::after { content:''; position:absolute; bottom:-10%; right:-10%; width:65%; height:65%;
- border-radius:50%; background:radial-gradient(circle,rgba(236,72,153,0.28),rgba(99,102,241,0.18),transparent 70%);
- filter:blur(70px); }
- .blob3 { position:fixed; top:40%; left:30%; width:40%; height:50%; z-index:0;
- border-radius:60% 40% 70% 30%; background:radial-gradient(circle,rgba(168,85,247,0.18),transparent 65%);
- filter:blur(50px); }
+ #three-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:1; }
.overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; }
header { padding:16px 28px; background:rgba(255,255,255,0.55); backdrop-filter:blur(20px);
border-bottom:1px solid rgba(255,255,255,0.6); display:flex; align-items:center; justify-content:space-between;
@@ -39,8 +30,7 @@ const glassTemplate = `<!DOCTYPE html>
.logo-title .subtitle a:hover { text-decoration:underline; }
.transmit-btn { border:1px solid rgba(99,102,241,0.4); color:var(--blue); padding:9px 20px;
border-radius:20px; text-decoration:none; font-size:0.85rem;
- background:rgba(255,255,255,0.5); backdrop-filter:blur(8px);
- transition:all 0.2s; }
+ background:rgba(255,255,255,0.5); backdrop-filter:blur(8px); transition:all 0.2s; }
.transmit-btn:hover { background:var(--blue); color:#fff; border-color:var(--blue); }
.nav-hints { background:rgba(255,255,255,0.35); backdrop-filter:blur(10px);
border-bottom:1px solid rgba(255,255,255,0.5); color:#6b7280;
@@ -54,16 +44,12 @@ const glassTemplate = `<!DOCTYPE html>
border-radius:20px; text-decoration:none; font-size:0.82rem;
background:rgba(255,255,255,0.45); backdrop-filter:blur(8px); }
.page-nav a:hover { background:var(--blue); color:#fff; }
- /* Glass card */
.post { background:rgba(255,255,255,0.45); backdrop-filter:blur(18px);
border:1px solid rgba(255,255,255,0.6); border-radius:14px;
padding:22px; margin-bottom:14px; cursor:pointer;
- box-shadow:0 4px 20px rgba(99,102,241,0.08);
- transition:all 0.25s; }
- .post:hover { background:rgba(255,255,255,0.6); box-shadow:0 8px 30px rgba(99,102,241,0.18);
- transform:translateY(-2px); }
- .post-active { border-color:var(--blue) !important;
- background:rgba(238,240,255,0.75) !important;
+ box-shadow:0 4px 20px rgba(99,102,241,0.08); transition:all 0.25s; }
+ .post:hover { background:rgba(255,255,255,0.6); box-shadow:0 8px 30px rgba(99,102,241,0.18); transform:translateY(-2px); }
+ .post-active { border-color:var(--blue) !important; background:rgba(238,240,255,0.75) !important;
box-shadow:0 0 0 2px rgba(99,102,241,0.3),0 8px 30px rgba(99,102,241,0.2),
inset 3px 0 0 var(--blue) !important; }
.post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; }
@@ -71,8 +57,6 @@ const glassTemplate = `<!DOCTYPE html>
.post-text { line-height:1.65; font-size:0.95rem; }
.post-text a { color:var(--blue); text-decoration:none; }
.post-text a:hover { text-decoration:underline; }
- .post-image { max-width:100%; border-radius:10px; margin-top:10px;
- border:1px solid rgba(255,255,255,0.5); }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
background:rgba(240,244,255,0.85); backdrop-filter:blur(28px);
@@ -87,8 +71,7 @@ const glassTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
- <div class="bg-blobs"></div>
- <div class="blob3"></div>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -118,6 +101,83 @@ const glassTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Glass WebGL: 8 crystal icosahedron shards, each rendered as a semi-transparent
+ // solid mesh with a wireframe overlay, drifting and rotating slowly in the light bg.
+ // alpha:true so the light body background (#f0f4ff) shows through the canvas.
+ (function() {
+ var scene, camera, renderer, clock;
+ var shards = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
+ camera.position.set(0, 0, 40);
+
+ renderer = new THREE.WebGLRenderer({
+ canvas: document.getElementById('three-canvas'),
+ antialias: true, alpha: true
+ });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setClearColor(0xf0f4ff, 1);
+ clock = new THREE.Clock();
+
+ scene.add(new THREE.AmbientLight(0xffffff, 0.8));
+ var pl = new THREE.PointLight(0x7c3aed, 2, 80);
+ pl.position.set(10, 10, 10);
+ scene.add(pl);
+
+ var colors = [0x6366f1, 0xa855f7, 0xec4899, 0x6366f1, 0x818cf8, 0xc084fc, 0xf472b6, 0x6366f1];
+ var sizes = [5, 3.5, 2.5, 4, 3, 2, 4.5, 2.2];
+ var details = [2, 1, 0, 2, 1, 0, 1, 2];
+ var positions = [
+ [-12, 6,-8], [14,-4,-12], [0,12,-6], [-8,-10,-15],
+ [16, 8,-4], [-14,2,-10], [6,-8,-5], [-4,10,-14]
+ ];
+
+ for (var i = 0; i < 8; i++) {
+ var geo = new THREE.IcosahedronGeometry(sizes[i], details[i]);
+ var solid = new THREE.Mesh(geo, new THREE.MeshPhongMaterial({
+ color: colors[i], transparent: true, opacity: 0.12, side: THREE.DoubleSide
+ }));
+ var wire = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
+ color: colors[i], wireframe: true, transparent: true, opacity: 0.28
+ }));
+ solid.position.set(positions[i][0], positions[i][1], positions[i][2]);
+ wire.position.copy(solid.position);
+
+ var rx = (Math.random() - 0.5) * 0.004;
+ var ry = (Math.random() - 0.5) * 0.006;
+ var rz = (Math.random() - 0.5) * 0.003;
+ shards.push({ solid: solid, wire: wire, rx: rx, ry: ry, rz: rz });
+ scene.add(solid);
+ scene.add(wire);
+ }
+
+ window.addEventListener('resize', onResize);
+ animate();
+ }
+
+ function onResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ }
+
+ function animate() {
+ requestAnimationFrame(animate);
+ shards.forEach(function(s) {
+ s.solid.rotation.x += s.rx; s.solid.rotation.y += s.ry; s.solid.rotation.z += s.rz;
+ s.wire.rotation.copy(s.solid.rotation);
+ });
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_matrix.go b/internal/generator/theme_matrix.go
index 6d8b531..8d35d7c 100644
--- a/internal/generator/theme_matrix.go
+++ b/internal/generator/theme_matrix.go
@@ -3,22 +3,27 @@ package generator
// matrixTemplate is a hacker-style theme inspired by The Matrix — black
// background, bright matrix-green (#00ff41) text, monospace throughout,
// no decorations beyond a faint scanline overlay.
+// WebGL scene: digital rain particle columns that simulate the iconic falling
+// green characters, with vertex-colour fading from bright head to dark tail.
const matrixTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snonux.foo // MATRIX</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --g:#00ff41; --g2:#008f11; --g3:#003b00; --bg:#000; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--g);
overflow:hidden; height:100vh; }
- /* scanline overlay */
+ /* scanline overlay sits above WebGL */
body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none;
background:repeating-linear-gradient(0deg,transparent,transparent 3px,
rgba(0,0,0,0.08) 3px,rgba(0,0,0,0.08) 4px); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
+ /* WebGL background canvas */
+ #three-canvas { position:fixed; top:0; left:0; width:100%; height:100%; z-index:1; }
.overlay { position:relative; z-index:10; height:100vh; display:flex; flex-direction:column; }
header { padding:12px 24px; background:#000; border-bottom:1px solid var(--g2);
display:flex; align-items:center; justify-content:space-between; }
@@ -68,6 +73,7 @@ const matrixTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -97,6 +103,100 @@ const matrixTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Matrix WebGL scene: 80 columns of falling particles with per-vertex colour.
+ // Each column has a "head" that falls at a random speed; particles near the head
+ // are bright green and fade to near-black further behind, simulating digital rain.
+ (function() {
+ var NUM_COLS = 80; // number of rain columns
+ var COL_LEN = 25; // particles per column
+ var SPACING = 2.2; // vertical gap between particles in a column
+ var Y_TOP = 30; // world-space top of the rain field
+ var Y_BOTTOM = -30; // world-space bottom
+
+ var scene, camera, renderer;
+ var points;
+ var posArr, colArr;
+ // Per-column state: x position, head y, and fall speed
+ var colX = [], headY = [], speed = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x000000);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
+ camera.position.set(0, 0, 50);
+
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: false });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+
+ var totalPts = NUM_COLS * COL_LEN;
+ posArr = new Float32Array(totalPts * 3);
+ colArr = new Float32Array(totalPts * 3);
+
+ // Spread columns across x: -50..50; initialise heads at random y positions
+ for (var c = 0; c < NUM_COLS; c++) {
+ colX[c] = -50 + (c / (NUM_COLS - 1)) * 100;
+ headY[c] = Y_TOP - Math.random() * (Y_TOP - Y_BOTTOM);
+ speed[c] = 0.08 + Math.random() * 0.07; // 0.08–0.15 units per frame
+ }
+
+ var geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
+ geo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
+
+ var mat = new THREE.PointsMaterial({ size: 0.35, vertexColors: true });
+ points = new THREE.Points(geo, mat);
+ scene.add(points);
+
+ window.addEventListener('resize', onResize);
+ animate();
+ }
+
+ function onResize() {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ }
+
+ function animate() {
+ requestAnimationFrame(animate);
+
+ for (var c = 0; c < NUM_COLS; c++) {
+ // Advance the head downward each frame
+ headY[c] -= speed[c];
+ // When the head exits the bottom, reset to a random point above the top
+ if (headY[c] < Y_BOTTOM - COL_LEN * SPACING) {
+ headY[c] = Y_TOP + Math.random() * 20;
+ }
+
+ var base = c * COL_LEN;
+ for (var p = 0; p < COL_LEN; p++) {
+ var i = base + p;
+ var y = headY[c] + p * SPACING; // particles trail upward from head
+ posArr[i * 3] = colX[c];
+ posArr[i * 3 + 1] = y;
+ posArr[i * 3 + 2] = 0;
+
+ // Brightness falls off with distance behind the head:
+ // p=0 is the head (bright), p=COL_LEN-1 is the tail (dim)
+ var bright = Math.max(0, 1 - p / (COL_LEN * 0.7));
+ // Head particle: #00ff41, tail: #003b00
+ colArr[i * 3] = 0;
+ colArr[i * 3 + 1] = bright * (p === 0 ? 1.0 : 0.88);
+ colArr[i * 3 + 2] = bright * (p === 0 ? 0.255 : 0.04);
+ }
+ }
+
+ points.geometry.attributes.position.needsUpdate = true;
+ points.geometry.attributes.color.needsUpdate = true;
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_minimal.go b/internal/generator/theme_minimal.go
index ebad091..00e9a17 100644
--- a/internal/generator/theme_minimal.go
+++ b/internal/generator/theme_minimal.go
@@ -1,67 +1,72 @@
package generator
-// minimalTemplate is a clean white theme — system font, subtle borders,
-// no animations or decorations. Maximum readability.
-const minimalTemplate = `<!DOCTYPE html>
+// plasmaTemplate is a dark psychedelic plasma theme — overlapping translucent
+// spheres with additive blending drift on sine paths, creating lava-lamp-like
+// colour blobs against a near-black background.
+const plasmaTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>snonux.foo</title>
+ <title>snonux.foo ◈ PLASMA</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
- :root { --accent:#0066cc; --border:#e2e2e2; --muted:#666; }
+ :root { --cyan:#00f0ff; --magenta:#ff00e0; --yellow:#ffee00; --bg:#050008; }
* { margin:0; padding:0; box-sizing:border-box; }
- body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
<