summaryrefslogtreecommitdiff
path: root/internal
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
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')
-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;
- background:#fff; color:#111; overflow:hidden; height:100vh; }
- .overlay { height:100vh; display:flex; flex-direction:column; max-width:860px;
- margin:0 auto; }
- header { padding:20px 32px; border-bottom:1px solid var(--border);
- display:flex; align-items:center; justify-content:space-between; }
+ body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg);
+ color:#e8e0ff; overflow:hidden; height:100vh; }
+ #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,0,8,0.8); backdrop-filter:blur(14px);
+ border-bottom:1px solid rgba(0,240,255,0.2); display:flex; align-items:center; justify-content:space-between; }
.logo { display:flex; align-items:center; gap:14px; }
- .logo-mark { font-size:1.5rem; font-weight:800; color:#111; letter-spacing:-1px; }
- .logo-title h1 { font-size:1.35rem; font-weight:700; color:#111; letter-spacing:-0.5px; }
- .logo-title .subtitle { font-size:0.78rem; color:var(--muted); margin-top:1px; }
- .logo-title .subtitle a { color:var(--accent); text-decoration:none; }
- .logo-title .subtitle a:hover { text-decoration:underline; }
- .transmit-btn { border:1px solid var(--accent); color:var(--accent); padding:8px 18px;
- border-radius:5px; text-decoration:none; font-size:0.88rem;
- font-weight:500; transition:all 0.15s; }
- .transmit-btn:hover { background:var(--accent); color:#fff; }
- .nav-hints { padding:6px 32px; border-bottom:1px solid var(--border);
- display:flex; gap:18px; font-size:0.72rem; color:var(--muted); flex-wrap:wrap; }
- .nav-hints kbd { background:#f5f5f5; border:1px solid #ccc; border-radius:3px;
- padding:1px 5px; font-size:0.72rem; color:#333; margin:0 2px; }
- .content { flex:1; overflow-y:auto; padding:0 32px;
- scrollbar-width:thin; scrollbar-color:#ccc #fff; }
- .page-nav { display:flex; justify-content:center; margin:16px 0; }
- .page-nav a { border:1px solid var(--border); color:var(--accent); padding:8px 20px;
- border-radius:5px; text-decoration:none; font-size:0.88rem; }
- .page-nav a:hover { background:var(--accent); color:#fff; border-color:var(--accent); }
- .post { border-bottom:1px solid var(--border); padding:22px 0; cursor:pointer;
- transition:background 0.12s; }
- .post:hover { background:#f8f8f8; padding-left:8px; }
- .post-active { background:#eef5ff !important; border-left:3px solid var(--accent) !important;
- padding-left:16px !important; }
- .post-header { display:flex; justify-content:space-between; margin-bottom:10px;
- font-size:0.88rem; }
- .post-time { color:var(--muted); font-size:0.82rem; }
- .post-text { line-height:1.65; font-size:1rem; }
- .post-text a { color:var(--accent); text-decoration:none; }
- .post-text a:hover { text-decoration:underline; }
- .post-image { max-width:100%; border-radius:6px; margin-top:10px; border:1px solid var(--border); }
+ .logo-mark { font-size:2rem; font-weight:800;
+ background:linear-gradient(90deg,var(--cyan),var(--magenta));
+ -webkit-background-clip:text; -webkit-text-fill-color:transparent; }
+ .logo-title h1 { font-size:1.5rem; font-weight:700; color:#e8e0ff; }
+ .logo-title .subtitle { font-size:0.75rem; color:rgba(232,224,255,0.5); margin-top:2px; }
+ .logo-title .subtitle a { color:var(--cyan); text-decoration:none; }
+ .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--cyan); }
+ .transmit-btn { border:1px solid var(--magenta); color:var(--magenta); padding:9px 20px;
+ border-radius:20px; text-decoration:none; font-size:0.85rem; transition:all 0.2s; }
+ .transmit-btn:hover { background:var(--magenta); color:var(--bg); }
+ .nav-hints { background:rgba(5,0,8,0.65); border-bottom:1px solid rgba(0,240,255,0.12);
+ color:rgba(232,224,255,0.4); padding:5px 28px; display:flex; gap:18px;
+ font-size:0.68rem; flex-wrap:wrap; }
+ .nav-hints kbd { background:rgba(0,240,255,0.1); border:1px solid rgba(0,240,255,0.3);
+ color:var(--cyan); border-radius:3px; padding:0 5px; margin:0 2px; }
+ .content { flex:1; overflow-y:auto; padding:20px 28px;
+ scrollbar-width:thin; scrollbar-color:var(--magenta) var(--bg); }
+ .page-nav { display:flex; justify-content:center; margin:14px 0; }
+ .page-nav a { border:1px solid var(--cyan); color:var(--cyan); padding:8px 20px;
+ border-radius:20px; text-decoration:none; font-size:0.82rem; }
+ .page-nav a:hover { background:var(--cyan); color:var(--bg); }
+ .post { background:rgba(10,0,20,0.75); border:1px solid rgba(0,240,255,0.18); border-radius:10px;
+ padding:20px; margin-bottom:14px; cursor:pointer;
+ transition:all 0.25s; backdrop-filter:blur(6px); }
+ .post:hover { border-color:var(--cyan); box-shadow:0 0 20px rgba(0,240,255,0.2); transform:translateY(-2px); }
+ .post-active { border-color:var(--magenta) !important; background:rgba(20,0,30,0.9) !important;
+ box-shadow:0 0 24px rgba(255,0,224,0.35),inset 3px 0 0 var(--magenta) !important; }
+ .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; }
+ .post-time { color:var(--cyan); font-family:monospace; font-size:0.8rem; }
+ .post-text { line-height:1.65; font-size:0.95rem; }
+ .post-text a { color:var(--cyan); text-decoration:none; }
+ .post-text a:hover { text-shadow:0 0 8px var(--cyan); }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
- background:rgba(255,255,255,0.97); overflow-y:auto; padding:40px 20px; }
+ background:rgba(5,0,8,0.96); backdrop-filter:blur(20px);
+ overflow-y:auto; padding:40px 20px; }
.post-modal.active { display:block; }
- .modal-inner { max-width:760px; margin:0 auto; background:#fff;
- border:1px solid var(--border); border-radius:6px;
- box-shadow:0 4px 24px rgba(0,0,0,0.1); padding:40px; }
- .modal-close { float:right; background:none; border:none; color:var(--muted);
- font-size:0.9rem; cursor:pointer; }
- @media(max-width:640px) { .nav-hints{display:none;} .overlay{max-width:100%;} header{padding:16px 20px;} .content{padding:0 20px;} }
+ .modal-inner { max-width:760px; margin:0 auto; background:rgba(10,0,25,0.98);
+ border:1px solid var(--magenta); border-radius:12px;
+ box-shadow:0 0 60px rgba(255,0,224,0.25); padding:40px; }
+ .modal-close { float:right; background:none; border:none; color:var(--cyan);
+ font-size:0.9rem; cursor:pointer; letter-spacing:1px; }
+ @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} }
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -72,7 +77,7 @@ const minimalTemplate = `<!DOCTYPE html>
</div>
</div>
<div class="nav">
- <a href="https://foo.zone/about" class="transmit-btn">Transmit to Nexus</a>
+ <a href="https://foo.zone/about" class="transmit-btn">Transmit</a>
</div>
</header>
{{template "navhints" .}}
@@ -91,6 +96,81 @@ const minimalTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Plasma WebGL: 12 large translucent spheres drifting on independent sine
+ // paths with additive blending — overlapping blobs mix colours and pulse
+ // like a lava lamp or plasma ball. Dark bg, cyan/magenta/yellow palette.
+ (function() {
+ var scene, camera, renderer, clock;
+ var blobs = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x050008);
+
+ 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 });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ clock = new THREE.Clock();
+
+ // Each blob: [color, radius, baseX, baseY, baseZ, ampX, ampY, freqX, freqY, phaseX, phaseY]
+ var cfg = [
+ [0x00f0ff, 10, -5, 0, -15, 8, 6, 0.30, 0.40, 0.0, 1.0],
+ [0xff00e0, 9, 8, -4, -18, 7, 8, 0.40, 0.30, 1.5, 0.5],
+ [0xffee00, 8, -8, 6, -20, 6, 7, 0.50, 0.20, 3.0, 2.0],
+ [0x00f0ff, 7, 4, 8, -12, 9, 5, 0.20, 0.50, 0.8, 3.5],
+ [0xff00e0, 9, -6, -8, -16, 8, 6, 0.35, 0.45, 2.2, 1.2],
+ [0xffee00, 11, 2, 2, -22, 7, 9, 0.25, 0.35, 4.0, 0.3],
+ [0x8800ff, 8,-12, 4, -14, 6, 7, 0.45, 0.25, 1.0, 2.5],
+ [0x00ff88, 7, 10, -6, -19, 8, 5, 0.30, 0.40, 3.5, 1.8],
+ [0xff4400, 9, 0, 10, -17, 7, 8, 0.40, 0.30, 0.5, 4.0],
+ [0x00f0ff, 6, -4, -4, -11, 5, 6, 0.55, 0.35, 2.8, 0.9],
+ [0xff00e0, 10, 6, 0, -25, 9, 5, 0.20, 0.50, 1.3, 3.2],
+ [0xffee00, 7,-10, -2, -13, 6, 8, 0.40, 0.30, 4.5, 1.5],
+ ];
+
+ cfg.forEach(function(c) {
+ var geo = new THREE.SphereGeometry(c[1], 24, 24);
+ var mat = new THREE.MeshBasicMaterial({
+ color: c[0], transparent: true, opacity: 0.18,
+ blending: THREE.AdditiveBlending, depthWrite: false
+ });
+ var mesh = new THREE.Mesh(geo, mat);
+ mesh.position.set(c[2], c[3], c[4]);
+ blobs.push({ mesh: mesh,
+ bx: c[2], by: c[3],
+ ax: c[5], ay: c[6],
+ fx: c[7], fy: c[8],
+ px: c[9], py: c[10] });
+ 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);
+ var t = clock.getElapsedTime();
+ blobs.forEach(function(b) {
+ b.mesh.position.x = b.bx + b.ax * Math.sin(t * b.fx + b.px);
+ b.mesh.position.y = b.by + b.ay * Math.cos(t * b.fy + b.py);
+ });
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_ocean.go b/internal/generator/theme_ocean.go
index 422cf0b..12b2151 100644
--- a/internal/generator/theme_ocean.go
+++ b/internal/generator/theme_ocean.go
@@ -1,25 +1,20 @@
package generator
// oceanTemplate is a deep-ocean theme — dark navy/midnight blue background,
-// teal/aqua/seafoam accents, subtle wave gradient at the bottom.
+// WebGL animated wave surface with per-vertex sine displacement, teal/aqua accents.
const oceanTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snonux.foo ~ OCEAN</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --teal:#00b4d8; --aqua:#48cae4; --deep:#023e8a; --navy:#03045e; --foam:#caf0f8; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--navy);
color:var(--foam); overflow:hidden; height:100vh; }
- /* Deep ocean gradient base */
- body { background:linear-gradient(180deg,#03045e 0%,#023e8a 60%,#0077b6 100%); }
- /* Animated wave shimmer at bottom */
- @keyframes wave { 0%,100%{transform:translateX(0)} 50%{transform:translateX(-30px)} }
- .wave-bottom { position:fixed; bottom:0; left:-5%; width:110%; height:120px; z-index:1;
- background:radial-gradient(ellipse 80% 60% at 50% 100%,rgba(0,180,216,0.22),transparent);
- animation:wave 8s 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(3,4,94,0.82); backdrop-filter:blur(12px);
border-bottom:1px solid rgba(0,180,216,0.3); display:flex; align-items:center; justify-content:space-between; }
@@ -30,8 +25,7 @@ const oceanTemplate = `<!DOCTYPE html>
.logo-title .subtitle a { color:var(--aqua); text-decoration:none; }
.logo-title .subtitle a:hover { text-shadow:0 0 8px var(--teal); }
.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(3,4,94,0.65); border-bottom:1px solid rgba(0,180,216,0.18);
color:rgba(202,240,248,0.45); padding:5px 28px; display:flex; gap:18px;
@@ -55,7 +49,6 @@ const oceanTemplate = `<!DOCTYPE html>
.post-text { line-height:1.65; font-size:0.95rem; }
.post-text a { color:var(--aqua); text-decoration:none; }
.post-text a:hover { text-shadow:0 0 8px var(--teal); }
- .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(3,4,94,0.96); backdrop-filter:blur(20px);
@@ -70,7 +63,7 @@ const oceanTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
- <div class="wave-bottom"></div>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -100,6 +93,80 @@ const oceanTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Ocean WebGL: a large PlaneGeometry wave surface whose vertices are displaced
+ // each frame by two overlapping sine functions, lit by a moving teal point light.
+ (function() {
+ var scene, camera, renderer, clock;
+ var waveMesh, waveGeo, pointLight;
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x03045e);
+ scene.fog = new THREE.Fog(0x03045e, 30, 120);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
+ camera.position.set(0, 25, 50);
+ camera.lookAt(0, 0, 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();
+
+ // Wave surface — high segment count so vertex displacement looks smooth
+ waveGeo = new THREE.PlaneGeometry(200, 200, 80, 80);
+ waveMesh = new THREE.Mesh(waveGeo, new THREE.MeshPhongMaterial({
+ color: 0x0077b6, emissive: 0x023e8a, emissiveIntensity: 0.3,
+ transparent: true, opacity: 0.85, side: THREE.DoubleSide
+ }));
+ waveMesh.rotation.x = -Math.PI / 2;
+ waveMesh.position.y = -5;
+ scene.add(waveMesh);
+
+ // Moving teal light circling above the wave
+ pointLight = new THREE.PointLight(0x48cae4, 2, 80);
+ pointLight.position.set(0, 20, 10);
+ scene.add(pointLight);
+ scene.add(new THREE.AmbientLight(0x023e8a, 0.6));
+
+ 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();
+ var pos = waveGeo.attributes.position;
+
+ // Two overlapping sine waves produce realistic ocean surface chop
+ for (var i = 0; i < pos.count; i++) {
+ var x = pos.getX(i);
+ var z = pos.getZ(i);
+ pos.setY(i,
+ Math.sin(x * 0.05 + t * 1.2) * 1.8 +
+ Math.cos(z * 0.07 + t * 0.9) * 1.4
+ );
+ }
+ pos.needsUpdate = true;
+ waveGeo.computeVertexNormals();
+
+ // Light orbits lazily
+ pointLight.position.x = Math.cos(t * 0.3) * 30;
+ pointLight.position.z = Math.sin(t * 0.3) * 30;
+
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_paper.go b/internal/generator/theme_paper.go
index 551e224..ebf6211 100644
--- a/internal/generator/theme_paper.go
+++ b/internal/generator/theme_paper.go
@@ -1,69 +1,70 @@
package generator
-// paperTemplate is a warm vintage newspaper theme — Georgia serif, sepia tones,
-// subtle texture simulation via CSS, no animations.
-const paperTemplate = `<!DOCTYPE html>
+// volcanoTemplate is a dark volcanic theme — ember and spark particles rise from
+// below the screen, glowing orange/red/yellow with additive blending, set against
+// a deep dark-rock background with a warm lava glow at the horizon.
+const volcanoTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>snonux.foo — the microblog</title>
+ <title>snonux.foo ▲ VOLCANO</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
- :root { --ink:#2c1810; --sepia:#c8a96e; --paper:#f5f0e8; --muted:#8b6f47; --accent:#7b2d00; }
+ :root { --lava:#ff4400; --ember:#ff8c00; --hot:#ffcc00; --bg:#0d0802; }
* { margin:0; padding:0; box-sizing:border-box; }
- body { font-family:Georgia,'Times New Roman',serif; background:var(--paper); color:var(--ink);
- overflow:hidden; height:100vh; }
- /* subtle paper grain simulation */
- body::after { content:''; position:fixed; inset:0; pointer-events:none; z-index:999;
- background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
- opacity:0.4; }
- .overlay { height:100vh; display:flex; flex-direction:column; max-width:800px; margin:0 auto; }
- header { padding:18px 28px 14px; border-bottom:3px double var(--ink);
- display:flex; align-items:center; justify-content:space-between; }
+ body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg);
+ color:#ffe8cc; overflow:hidden; height:100vh; }
+ #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(13,8,2,0.82); backdrop-filter:blur(12px);
+ border-bottom:1px solid rgba(255,68,0,0.3); display:flex; align-items:center; justify-content:space-between; }
.logo { display:flex; align-items:center; gap:14px; }
- .logo-mark { font-size:2.6rem; font-weight:700; color:var(--accent); line-height:1; font-style:italic; }
- .logo-title h1 { font-size:1.6rem; font-weight:700; color:var(--ink); letter-spacing:1px; font-variant:small-caps; }
- .logo-title .subtitle { font-size:0.78rem; color:var(--muted); margin-top:2px; font-style:italic; }
- .logo-title .subtitle a { color:var(--accent); text-decoration:none; }
- .logo-title .subtitle a:hover { text-decoration:underline; }
- .transmit-btn { border:2px solid var(--ink); color:var(--ink); padding:8px 16px;
- text-decoration:none; font-size:0.82rem; font-variant:small-caps; letter-spacing:1px;
- transition:all 0.15s; }
- .transmit-btn:hover { background:var(--ink); color:var(--paper); }
- .nav-hints { background:transparent; border-bottom:1px solid var(--sepia); color:var(--muted);
- padding:4px 28px; display:flex; gap:18px; font-size:0.68rem; font-family:monospace; flex-wrap:wrap; }
- .nav-hints kbd { background:transparent; border:1px solid var(--muted); color:var(--ink);
- padding:0 4px; font-size:0.68rem; margin:0 1px; }
- .content { flex:1; overflow-y:auto; padding:16px 28px;
- scrollbar-width:thin; scrollbar-color:var(--sepia) var(--paper); }
+ .logo-mark { font-size:2rem; font-weight:800; color:var(--ember); text-shadow:0 0 16px var(--lava); }
+ .logo-title h1 { font-size:1.5rem; font-weight:700; color:#ffe8cc; }
+ .logo-title .subtitle { font-size:0.75rem; color:rgba(255,232,204,0.5); margin-top:2px; }
+ .logo-title .subtitle a { color:var(--ember); text-decoration:none; }
+ .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--lava); }
+ .transmit-btn { border:1px solid var(--lava); color:var(--lava); padding:9px 20px;
+ border-radius:4px; text-decoration:none; font-size:0.85rem; transition:all 0.2s; }
+ .transmit-btn:hover { background:var(--lava); color:var(--bg); }
+ .nav-hints { background:rgba(13,8,2,0.7); border-bottom:1px solid rgba(255,68,0,0.15);
+ color:rgba(255,232,204,0.4); padding:5px 28px; display:flex; gap:18px;
+ font-size:0.68rem; flex-wrap:wrap; }
+ .nav-hints kbd { background:rgba(255,68,0,0.12); border:1px solid rgba(255,68,0,0.35);
+ color:var(--ember); border-radius:3px; padding:0 5px; margin:0 2px; }
+ .content { flex:1; overflow-y:auto; padding:20px 28px;
+ scrollbar-width:thin; scrollbar-color:var(--lava) var(--bg); }
.page-nav { display:flex; justify-content:center; margin:14px 0; }
- .page-nav a { border:1px solid var(--ink); color:var(--ink); padding:7px 20px;
- text-decoration:none; font-size:0.82rem; font-variant:small-caps; letter-spacing:1px; }
- .page-nav a:hover { background:var(--ink); color:var(--paper); }
- .post { border-bottom:1px solid var(--sepia); padding:18px 0; cursor:pointer;
- transition:background 0.12s; }
- .post:hover { background:#ede8dc; padding-left:8px; }
- .post-active { background:#e8e0cc !important; border-left:4px solid var(--accent) !important;
- padding-left:12px !important; }
- .post-header { display:flex; justify-content:space-between; margin-bottom:10px; font-size:0.85rem; }
- .post-time { color:var(--muted); font-family:monospace; font-size:0.78rem; }
- .post-text { line-height:1.7; font-size:1rem; }
- .post-text a { color:var(--accent); text-decoration:none; }
- .post-text a:hover { text-decoration:underline; }
- .post-image { max-width:100%; margin-top:10px; border:1px solid var(--sepia); }
+ .page-nav a { border:1px solid var(--ember); color:var(--ember); padding:8px 20px;
+ border-radius:4px; text-decoration:none; font-size:0.82rem; }
+ .page-nav a:hover { background:var(--lava); color:var(--bg); }
+ .post { background:rgba(20,8,2,0.72); border:1px solid rgba(255,68,0,0.2); border-radius:8px;
+ padding:20px; margin-bottom:14px; cursor:pointer;
+ transition:all 0.25s; backdrop-filter:blur(4px); }
+ .post:hover { border-color:var(--ember); box-shadow:0 0 20px rgba(255,68,0,0.25); transform:translateY(-2px); }
+ .post-active { border-color:var(--hot) !important; background:rgba(30,8,2,0.9) !important;
+ box-shadow:0 0 24px rgba(255,140,0,0.4),inset 3px 0 0 var(--hot) !important; }
+ .post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; }
+ .post-time { color:var(--ember); font-family:monospace; font-size:0.8rem; }
+ .post-text { line-height:1.65; font-size:0.95rem; }
+ .post-text a { color:var(--ember); text-decoration:none; }
+ .post-text a:hover { text-shadow:0 0 8px var(--lava); }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
- background:rgba(245,240,232,0.97); overflow-y:auto; padding:40px 20px; }
+ background:rgba(13,8,2,0.96); backdrop-filter:blur(20px);
+ overflow-y:auto; padding:40px 20px; }
.post-modal.active { display:block; }
- .modal-inner { max-width:720px; margin:0 auto; background:var(--paper);
- border:2px solid var(--ink); padding:40px;
- box-shadow:4px 4px 0 var(--sepia); }
- .modal-close { float:right; background:none; border:none; color:var(--muted);
- font-size:0.9rem; cursor:pointer; font-variant:small-caps; letter-spacing:1px; }
- @media(max-width:640px) { .nav-hints{display:none;} .overlay{max-width:100%;} header{padding:14px 16px;} .content{padding:12px 16px;} }
+ .modal-inner { max-width:760px; margin:0 auto; background:rgba(20,8,2,0.98);
+ border:1px solid var(--lava); border-radius:10px;
+ box-shadow:0 0 60px rgba(255,68,0,0.3); padding:40px; }
+ .modal-close { float:right; background:none; border:none; color:var(--ember);
+ font-size:0.9rem; cursor:pointer; letter-spacing:1px; }
+ @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} .content{padding:14px 18px;} }
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -93,6 +94,107 @@ const paperTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Volcano WebGL: 2000 ember particles emitted from the bottom, rising with
+ // drift and fade. Each particle has a randomised lifetime, speed, and hue
+ // shifting from hot yellow through orange to red as it rises and cools.
+ (function() {
+ var N = 2000;
+ var scene, camera, renderer, clock;
+ var points;
+ var posArr, colArr, alpArr;
+
+ // Per-particle state
+ var px = new Float32Array(N);
+ var py = new Float32Array(N);
+ var pz = new Float32Array(N);
+ var vx = new Float32Array(N); // horizontal drift
+ var vy = new Float32Array(N); // rise speed
+ var life = new Float32Array(N); // 0..1, resets at 0
+ var maxLife = new Float32Array(N);
+
+ function resetParticle(i) {
+ // Spawn along a wide base strip at the bottom
+ px[i] = (Math.random() - 0.5) * 60;
+ py[i] = -25 + (Math.random() - 0.5) * 4;
+ pz[i] = (Math.random() - 0.5) * 20 - 5;
+ vx[i] = (Math.random() - 0.5) * 0.06;
+ vy[i] = 0.06 + Math.random() * 0.12;
+ maxLife[i] = 0.5 + Math.random() * 0.5;
+ life[i] = Math.random(); // stagger initial phases
+ }
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x0d0802);
+ scene.fog = new THREE.Fog(0x0d0802, 30, 80);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 120);
+ camera.position.set(0, 0, 45);
+
+ 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();
+
+ posArr = new Float32Array(N * 3);
+ colArr = new Float32Array(N * 3);
+
+ for (var i = 0; i < N; i++) resetParticle(i);
+
+ var geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
+ geo.setAttribute('color', new THREE.BufferAttribute(colArr, 3));
+
+ points = new THREE.Points(geo, new THREE.PointsMaterial({
+ size: 0.25, vertexColors: true,
+ transparent: true, opacity: 0.9,
+ blending: THREE.AdditiveBlending, depthWrite: false
+ }));
+ 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);
+ var dt = clock.getDelta();
+
+ for (var i = 0; i < N; i++) {
+ life[i] += dt / maxLife[i];
+ if (life[i] > 1.0) resetParticle(i);
+
+ py[i] += vy[i];
+ px[i] += vx[i];
+
+ var idx = i * 3;
+ posArr[idx] = px[i];
+ posArr[idx+1] = py[i];
+ posArr[idx+2] = pz[i];
+
+ // Colour: young embers are yellow/hot, older ones shift to orange then red
+ var t = life[i];
+ var fade = Math.max(0, 1 - t * 1.4);
+ colArr[idx] = fade; // R: always full
+ colArr[idx+1] = fade * Math.max(0, 1 - t * 2); // G: fades fast
+ colArr[idx+2] = 0; // B: never
+ }
+
+ 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_retro.go b/internal/generator/theme_retro.go
index 82dc605..008fc92 100644
--- a/internal/generator/theme_retro.go
+++ b/internal/generator/theme_retro.go
@@ -3,24 +3,29 @@ package generator
// retroTemplate is an amber DOS terminal theme — black background, amber
// phosphor (#ffb000) text, monospace throughout, no decorations.
// Distinct from terminal.go (green) — this one evokes vintage PC monitors.
+// WebGL scene: spinning amber wireframe cube with orbiting octahedrons and
+// dim amber star particles for a retro demo-scene feel.
const retroTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SNONUX.FOO // RETRO</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --amber:#ffb000; --dim:#7a5200; --bg:#0a0800; --bg2:#050300; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--amber);
overflow:hidden; height:100vh; }
- /* Phosphor scanlines */
+ /* Phosphor scanlines overlay — sits above WebGL */
body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,
rgba(0,0,0,0.15) 2px,rgba(0,0,0,0.15) 4px); }
/* Subtle glow flicker */
@keyframes amber-flicker { 0%,100%{opacity:1} 94%{opacity:0.98} 96%{opacity:0.93} }
body { animation:amber-flicker 11s infinite; }
+ /* 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:var(--bg2); border-bottom:2px solid var(--amber);
display:flex; align-items:center; justify-content:space-between; }
@@ -71,6 +76,7 @@ const retroTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -100,6 +106,85 @@ const retroTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Retro WebGL scene: amber demo-scene cube + orbiting octahedrons + star particles.
+ // Evokes classic 80s/90s PC demo aesthetics with amber phosphor colours.
+ (function() {
+ var scene, camera, renderer;
+ var mainCube, orbiters = [];
+ var clock = new THREE.Clock();
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x0a0800);
+ scene.fog = new THREE.Fog(0x0a0800, 25, 90);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
+ camera.position.set(0, 0, 35);
+
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: true });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+
+ // Central amber wireframe cube — the demo-scene hero piece
+ var boxGeo = new THREE.BoxGeometry(8, 8, 8);
+ var boxMat = new THREE.MeshBasicMaterial({ color: 0xffb000, wireframe: true });
+ mainCube = new THREE.Mesh(boxGeo, boxMat);
+ scene.add(mainCube);
+
+ // 6 small octahedron wireframes evenly spaced on an orbital ring
+ var octoMat = new THREE.MeshBasicMaterial({ color: 0xffb000, wireframe: true });
+ for (var i = 0; i < 6; i++) {
+ var octoGeo = new THREE.OctahedronGeometry(1.5);
+ var octo = new THREE.Mesh(octoGeo, octoMat.clone());
+ var angle = (i / 6) * Math.PI * 2;
+ octo.position.set(Math.cos(angle) * 18, Math.sin(angle) * 5, Math.sin(angle) * 18);
+ orbiters.push({ mesh: octo, baseAngle: angle });
+ scene.add(octo);
+ }
+
+ // 800 dim amber background star particles
+ var starGeo = new THREE.BufferGeometry();
+ var starPos = new Float32Array(800 * 3);
+ for (var j = 0; j < 800 * 3; j++) {
+ starPos[j] = (Math.random() - 0.5) * 120;
+ }
+ starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
+ var starMat = new THREE.PointsMaterial({ color: 0x7a5200, size: 0.15 });
+ scene.add(new THREE.Points(starGeo, starMat));
+
+ 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();
+ // Main cube rotates on Y and X axes for classic demo-scene look
+ mainCube.rotation.y = t * 0.35;
+ mainCube.rotation.x = t * 0.18;
+ // Orbiters revolve around the central cube and spin individually
+ for (var i = 0; i < orbiters.length; i++) {
+ var o = orbiters[i];
+ var angle = o.baseAngle + t * 0.4;
+ o.mesh.position.x = Math.cos(angle) * 18;
+ o.mesh.position.z = Math.sin(angle) * 18;
+ o.mesh.position.y = Math.sin(angle * 0.7) * 4;
+ o.mesh.rotation.x = t * 0.9;
+ o.mesh.rotation.z = t * 0.6;
+ }
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_synthwave.go b/internal/generator/theme_synthwave.go
index 8798d18..366c5b5 100644
--- a/internal/generator/theme_synthwave.go
+++ b/internal/generator/theme_synthwave.go
@@ -1,7 +1,8 @@
package generator
-// synthwaveTemplate is the 80s retrowave theme — dark purple sky, CSS perspective
-// grid floor, hot pink/orange accents, Russo One font.
+// synthwaveTemplate is the 80s retrowave theme — dark purple sky, WebGL
+// perspective grid with a glowing sun and scan-line rings, hot pink/orange
+// accents, Russo One font. The CSS sky and grid-floor divs are replaced by WebGL.
const synthwaveTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
@@ -10,21 +11,13 @@ const synthwaveTemplate = `<!DOCTYPE html>
<title>snonux.foo ⊕ SYNTHWAVE</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Russo+One&family=Share+Tech+Mono&display=swap" rel="stylesheet">
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --pink:#ff2d78; --purple:#bf3fff; --orange:#ff6b2b; --bg:#0d0221; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Russo One','Arial Black',sans-serif; background:var(--bg);
color:#fff; overflow:hidden; height:100vh; }
- /* Sunset sky gradient */
- .sky { position:fixed; inset:0; z-index:0;
- background:linear-gradient(180deg,#0d0221 0%,#1a0533 45%,#4a0080 72%,#8b1070 88%,#c8365a 100%); }
- /* Perspective grid floor */
- .grid-floor { position:fixed; bottom:0; left:0; width:100%; height:46vh; z-index:1;
- background-image:linear-gradient(rgba(255,45,120,0.35) 1px,transparent 1px),
- linear-gradient(90deg,rgba(255,45,120,0.35) 1px,transparent 1px);
- background-size:44px 44px;
- transform:perspective(380px) rotateX(76deg); transform-origin:bottom;
- mask-image:linear-gradient(to top,rgba(0,0,0,0.85) 0%,transparent 100%); }
+ #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(13,2,33,0.82); backdrop-filter:blur(10px);
border-bottom:2px solid var(--pink); display:flex; align-items:center; justify-content:space-between; }
@@ -61,7 +54,6 @@ const synthwaveTemplate = `<!DOCTYPE html>
.post-time { color:var(--orange); font-family:'Share Tech Mono',monospace; font-size:0.85rem; }
.post-text { line-height:1.6; font-size:0.95rem; font-family:'Share Tech Mono',monospace; }
.post-text a { color:var(--pink); text-decoration:none; }
- .post-image { max-width:100%; border-radius:6px; margin-top:10px; }
.post-audio { width:100%; margin-top:10px; }
.post-modal { display:none; position:fixed; inset:0; z-index:100;
background:rgba(13,2,33,0.96); overflow-y:auto; padding:40px 20px; }
@@ -71,12 +63,11 @@ const synthwaveTemplate = `<!DOCTYPE html>
box-shadow:0 0 60px rgba(255,45,120,0.35); padding:38px; }
.modal-close { float:right; background:none; border:none; color:var(--orange);
font-family:'Russo One',sans-serif; font-size:0.9rem; cursor:pointer; letter-spacing:2px; }
- @media(max-width:640px) { .nav-hints{display:none;} .grid-floor{height:30vh;} header{padding:12px 18px;} }
+ @media(max-width:640px) { .nav-hints{display:none;} header{padding:12px 18px;} }
</style>
</head>
<body>
- <div class="sky"></div>
- <div class="grid-floor"></div>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -106,6 +97,93 @@ const synthwaveTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Synthwave WebGL: glowing sunset sphere with horizontal scan-line rings,
+ // a receding grid floor, and pink star particles. Replaces CSS sky/grid.
+ (function() {
+ var scene, camera, renderer, clock;
+ var sun, sunRings = [];
+
+ function initThree() {
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x0d0221);
+ scene.fog = new THREE.Fog(0x0d0221, 60, 180);
+
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 300);
+ camera.position.set(0, 10, 45);
+ camera.lookAt(0, -5, -10);
+
+ 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();
+
+ // Glowing orange sunset sphere
+ sun = new THREE.Mesh(
+ new THREE.SphereGeometry(12, 32, 16),
+ new THREE.MeshBasicMaterial({ color: 0xff6b2b })
+ );
+ sun.position.set(0, -8, -55);
+ scene.add(sun);
+
+ // Horizontal scan-line rings — alternating pink/purple, stacked on the sun
+ var ringColors = [0xff2d78, 0xbf3fff, 0xff2d78, 0xbf3fff, 0xff2d78, 0xbf3fff, 0xff2d78, 0xbf3fff];
+ for (var i = 0; i < 8; i++) {
+ var ring = new THREE.Mesh(
+ new THREE.TorusGeometry(13 + i * 1.2, 0.09, 8, 64),
+ new THREE.MeshBasicMaterial({ color: ringColors[i] })
+ );
+ ring.position.copy(sun.position);
+ ring.position.y += -4 + i * 1.1;
+ scene.add(ring);
+ sunRings.push(ring);
+ }
+
+ // Receding grid floor
+ var grid = new THREE.GridHelper(200, 40, 0xff2d78, 0x4a0060);
+ grid.position.set(0, -18, -30);
+ scene.add(grid);
+
+ // 1200 star particles scattered in a sphere shell
+ var starPos = new Float32Array(1200 * 3);
+ for (var j = 0; j < 1200 * 3; j += 3) {
+ var r = 80 + Math.random() * 40;
+ var theta = Math.random() * Math.PI * 2;
+ var phi = Math.acos(2 * Math.random() - 1);
+ starPos[j] = r * Math.sin(phi) * Math.cos(theta);
+ starPos[j+1] = r * Math.sin(phi) * Math.sin(theta);
+ starPos[j+2] = r * Math.cos(phi);
+ }
+ var starGeo = new THREE.BufferGeometry();
+ starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
+ scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({
+ color: 0xff88aa, size: 0.2, transparent: true, opacity: 0.7
+ })));
+
+ 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();
+ // Sun pulses subtly; camera drifts sideways for parallax
+ var pulse = 1 + 0.015 * Math.sin(t * 1.5);
+ sun.scale.setScalar(pulse);
+ sunRings.forEach(function(r) { r.scale.setScalar(pulse); });
+ camera.position.x = Math.sin(t * 0.08) * 4;
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/theme_terminal.go b/internal/generator/theme_terminal.go
index 075ec25..0503d79 100644
--- a/internal/generator/theme_terminal.go
+++ b/internal/generator/theme_terminal.go
@@ -2,24 +2,29 @@ package generator
// terminalTemplate is the green phosphor CRT terminal theme.
// Monospace throughout, scanline overlay via CSS, no external dependencies.
+// WebGL scene: large IcosahedronGeometry wireframe with orbiting torus particles,
+// giving a rotating phosphor-green 3D orb behind the terminal interface.
const terminalTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>snonux.foo // TERMINAL</title>
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<style>
:root { --p:#33ff33; --dim:#1a7a1a; --bg:#0a0a0a; --bg2:#050505; }
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Courier New',Courier,monospace; background:var(--bg); color:var(--p);
overflow:hidden; height:100vh; position:relative; }
- /* CRT scanlines */
+ /* CRT scanlines sit above the WebGL canvas */
body::before { content:''; position:fixed; inset:0; z-index:999; pointer-events:none;
background:repeating-linear-gradient(0deg,transparent,transparent 2px,
rgba(0,0,0,0.12) 2px,rgba(0,0,0,0.12) 4px); }
/* Subtle screen flicker */
@keyframes flicker { 0%,100%{opacity:1} 93%{opacity:0.97} 95%{opacity:0.91} 97%{opacity:0.98} }
body { animation:flicker 9s infinite; }
+ /* WebGL background canvas — fills the viewport behind everything */
+ #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:var(--bg2); border-bottom:2px solid var(--p);
display:flex; align-items:center; justify-content:space-between; }
@@ -67,6 +72,7 @@ const terminalTemplate = `<!DOCTYPE html>
</style>
</head>
<body>
+ <canvas id="three-canvas"></canvas>
<div class="overlay">
<header>
<div class="logo">
@@ -96,6 +102,76 @@ const terminalTemplate = `<!DOCTYPE html>
</div>
</div>
{{template "navmodal" .}}
+ <script>
+ // Terminal WebGL scene: phosphor-green icosahedron wireframe + torus particle ring.
+ // The scene sits behind the CRT scanline overlay (z-index:999) and the UI (z-index:10).
+ (function() {
+ var scene, camera, renderer, icosa, particles;
+ var clock = new THREE.Clock();
+
+ function initThree() {
+ // Scene with pure-black background and distance fog
+ scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x000000);
+ scene.fog = new THREE.Fog(0x000000, 20, 80);
+
+ // Perspective camera positioned in front of the orb
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200);
+ camera.position.set(0, 0, 30);
+
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: true });
+ renderer.setSize(window.innerWidth, window.innerHeight);
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+
+ // Large green phosphor wireframe icosahedron — the central CRT orb
+ var icoGeo = new THREE.IcosahedronGeometry(8, 2);
+ var icoMat = new THREE.MeshBasicMaterial({ color: 0x33ff33, wireframe: true });
+ icosa = new THREE.Mesh(icoGeo, icoMat);
+ scene.add(icosa);
+
+ // 400 dim particles arranged on a torus path around the icosahedron
+ var torusGeo = new THREE.TorusGeometry(14, 3, 16, 100);
+ var positions = torusGeo.attributes.position;
+ var ptGeo = new THREE.BufferGeometry();
+ var pts = new Float32Array(400 * 3);
+ for (var i = 0; i < 400; i++) {
+ // Sample vertices from the torus geometry to place particles on its surface
+ var idx = Math.floor(Math.random() * positions.count);
+ pts[i * 3] = positions.getX(idx);
+ pts[i * 3 + 1] = positions.getY(idx);
+ pts[i * 3 + 2] = positions.getZ(idx);
+ }
+ ptGeo.setAttribute('position', new THREE.BufferAttribute(pts, 3));
+ var ptMat = new THREE.PointsMaterial({ color: 0x1a7a1a, size: 0.18 });
+ particles = new THREE.Points(ptGeo, ptMat);
+ scene.add(particles);
+
+ 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();
+ // Slow multi-axis rotation for the phosphor orb
+ icosa.rotation.x = t * 0.12;
+ icosa.rotation.y = t * 0.18;
+ icosa.rotation.z = t * 0.07;
+ // Counter-rotate particles for visual contrast
+ particles.rotation.y = -t * 0.08;
+ particles.rotation.x = t * 0.04;
+ renderer.render(scene, camera);
+ }
+
+ initThree();
+ })();
+ </script>
{{template "navscript" .}}
</body>
</html>`
diff --git a/internal/generator/themes.go b/internal/generator/themes.go
index 8de6193..1156982 100644
--- a/internal/generator/themes.go
+++ b/internal/generator/themes.go
@@ -7,9 +7,9 @@ var themeRegistry = map[string]string{
"neon": neonTemplate,
"terminal": terminalTemplate,
"synthwave": synthwaveTemplate,
- "minimal": minimalTemplate,
+ "plasma": plasmaTemplate, // replaced "minimal" — psychedelic drifting blobs
"brutalist": brutalistTemplate,
- "paper": paperTemplate,
+ "volcano": volcanoTemplate, // replaced "paper" — rising ember particles
"aurora": auroraTemplate,
"matrix": matrixTemplate,
"ocean": oceanTemplate,