summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-10 09:32:07 +0300
committerPaul Buetow <paul@buetow.org>2026-04-10 09:32:07 +0300
commit762589bd807c4888f4d7dcac32606b157216a049 (patch)
treec57505973bc31ec9007f5876610412248064b6f1
parentf3155cbc866a1b261a0aafe61683da1e3c25b1ef (diff)
enhance WebGL scenes, modal transparency, new cosmos theme, UX polish
Themes: - glass → cosmos: ringed planet with asteroid belt, nebula clouds, 2500 stars - ocean: sea rock spires, bioluminescent jellyfish, rising bubbles, whale - volcano: glowing lava floor with vertex animation, molten boulders, smoke plume particles, underground furnace glow sphere UX / shared: - Modal background changed to rgba(0,0,0,0.55) so WebGL stays visible behind expanded post view (all themes) - Max-width 1200px applied to all themes via shared navmodal CSS - Default theme changed from "neon" to "random" Sample content: - 120 example entries processed into dist/ for pagination testing (4 pages of 42 posts each) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--cmd/snonux/main.go2
-rw-r--r--integrationtests/integration_test.go2
-rw-r--r--internal/generator/shared.go8
-rw-r--r--internal/generator/theme_glass.go250
-rw-r--r--internal/generator/theme_ocean.go164
-rw-r--r--internal/generator/theme_paper.go209
-rw-r--r--internal/generator/themes.go2
7 files changed, 442 insertions, 195 deletions
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go
index 58d6cb5..d3f8064 100644
--- a/cmd/snonux/main.go
+++ b/cmd/snonux/main.go
@@ -41,7 +41,7 @@ func parseFlags() (*config.Config, error) {
flag.StringVar(&cfg.InputDir, "input", "./inbox", "directory containing new source files to process")
flag.StringVar(&cfg.OutputDir, "output", "~/git/snonux.foo/dist", "root directory for generated static site output")
flag.StringVar(&cfg.BaseURL, "base-url", "https://snonux.foo", "canonical base URL used in Atom feed links")
- flag.StringVar(&cfg.Theme, "theme", "neon", "visual theme name, or \"random\" to pick one at random")
+ flag.StringVar(&cfg.Theme, "theme", "random", "visual theme name, or \"random\" to pick one at random")
flag.Parse()
if *listThemes {
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go
index 6d355b1..8d24d8d 100644
--- a/integrationtests/integration_test.go
+++ b/integrationtests/integration_test.go
@@ -323,7 +323,7 @@ func TestKeyboardNavJS(t *testing.T) {
// index.html containing core structural elements (post text, nav script).
func TestThemeSelection(t *testing.T) {
themes := []string{
- "aurora", "brutalist", "glass", "matrix", "neon",
+ "aurora", "brutalist", "cosmos", "matrix", "neon",
"ocean", "plasma", "retro", "synthwave", "terminal", "volcano",
}
diff --git a/internal/generator/shared.go b/internal/generator/shared.go
index 12a91f4..a34c5a5 100644
--- a/internal/generator/shared.go
+++ b/internal/generator/shared.go
@@ -21,10 +21,14 @@ const navDefs = `
{{define "navmodal"}}
<style>
-/* Thumbnail sizing in list view; modal overrides to full width so images
- appear larger when a post is expanded with Enter. */
+/* Thumbnail sizing in list view; modal overrides to full width. */
.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; }
+/* Semi-transparent modal backdrop so the WebGL scene stays visible behind
+ the expanded post. Theme-specific modal-inner keeps its own background. */
+.post-modal { background:rgba(0,0,0,0.55) !important; backdrop-filter:blur(6px) !important; }
+/* Content area max-width across all themes */
+.overlay { max-width:1200px; margin-left:auto; margin-right:auto; }
</style>
<div class="post-modal" id="post-modal">
<div class="modal-inner">
diff --git a/internal/generator/theme_glass.go b/internal/generator/theme_glass.go
index 598a2f3..3fc951b 100644
--- a/internal/generator/theme_glass.go
+++ b/internal/generator/theme_glass.go
@@ -1,72 +1,66 @@
package generator
-// 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>
+// cosmosTemplate is a deep-space theme — a large ringed planet dominates the
+// background, surrounded by swirling nebula clouds, an asteroid belt, and
+// thousands of stars. Dark navy-black palette with golden/purple accents.
+const cosmosTemplate = `<!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>
+ <title>snonux.foo ✧ COSMOS</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; }
+ :root { --gold:#ffd166; --purple:#9b5de5; --blue:#4cc9f0; --bg:#020214; }
* { 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); }
+ body { font-family:'Segoe UI',system-ui,sans-serif; background:var(--bg);
+ color:#d4e8ff; 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(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;
- box-shadow:0 2px 12px rgba(99,102,241,0.08); }
+ header { padding:16px 28px; background:rgba(2,2,20,0.78); backdrop-filter:blur(14px);
+ border-bottom:1px solid rgba(255,209,102,0.2); display:flex; align-items:center; justify-content:space-between; }
.logo { display:flex; align-items:center; gap:14px; }
.logo-mark { font-size:2rem; font-weight:800;
- background:linear-gradient(135deg,var(--blue),var(--purple));
+ background:linear-gradient(90deg,var(--gold),var(--purple));
-webkit-background-clip:text; -webkit-text-fill-color:transparent; }
- .logo-title h1 { font-size:1.5rem; font-weight:700; color:var(--text); }
- .logo-title .subtitle { font-size:0.75rem; color:#6b7280; margin-top:1px; }
- .logo-title .subtitle a { color:var(--blue); text-decoration:none; }
- .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; }
- .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;
- padding:4px 28px; display:flex; gap:18px; font-size:0.68rem; flex-wrap:wrap; }
- .nav-hints kbd { background:rgba(255,255,255,0.7); border:1px solid rgba(99,102,241,0.25);
- color:var(--blue); border-radius:4px; padding:0 5px; margin:0 2px; font-size:0.68rem; }
+ .logo-title h1 { font-size:1.5rem; font-weight:700; color:#d4e8ff; }
+ .logo-title .subtitle { font-size:0.75rem; color:rgba(212,232,255,0.5); margin-top:2px; }
+ .logo-title .subtitle a { color:var(--gold); text-decoration:none; }
+ .logo-title .subtitle a:hover { text-shadow:0 0 8px var(--gold); }
+ .transmit-btn { border:1px solid var(--gold); color:var(--gold); padding:9px 20px;
+ border-radius:20px; text-decoration:none; font-size:0.85rem; transition:all 0.2s; }
+ .transmit-btn:hover { background:var(--gold); color:var(--bg); }
+ .nav-hints { background:rgba(2,2,20,0.6); border-bottom:1px solid rgba(255,209,102,0.12);
+ color:rgba(212,232,255,0.4); padding:5px 28px; display:flex; gap:18px;
+ font-size:0.68rem; flex-wrap:wrap; }
+ .nav-hints kbd { background:rgba(255,209,102,0.1); border:1px solid rgba(255,209,102,0.3);
+ color:var(--gold); border-radius:3px; padding:0 5px; margin:0 2px; }
.content { flex:1; overflow-y:auto; padding:20px 28px;
- scrollbar-width:thin; scrollbar-color:rgba(99,102,241,0.4) transparent; }
+ scrollbar-width:thin; scrollbar-color:var(--purple) var(--bg); }
.page-nav { display:flex; justify-content:center; margin:14px 0; }
- .page-nav a { border:1px solid rgba(99,102,241,0.35); color:var(--blue); padding:8px 20px;
- 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; }
- .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 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; }
+ .page-nav a { border:1px solid var(--purple); color:var(--purple); padding:8px 20px;
+ border-radius:20px; text-decoration:none; font-size:0.82rem; }
+ .page-nav a:hover { background:var(--purple); color:#fff; }
+ .post { background:rgba(5,5,30,0.72); border:1px solid rgba(155,93,229,0.22); border-radius:10px;
+ padding:20px; margin-bottom:14px; cursor:pointer;
+ transition:all 0.25s; backdrop-filter:blur(6px); }
+ .post:hover { border-color:var(--gold); box-shadow:0 0 22px rgba(255,209,102,0.18); transform:translateY(-2px); }
+ .post-active { border-color:var(--gold) !important; background:rgba(10,5,35,0.9) !important;
+ box-shadow:0 0 28px rgba(255,209,102,0.35),inset 3px 0 0 var(--gold) !important; }
.post-header { display:flex; justify-content:space-between; margin-bottom:12px; font-size:0.88rem; }
- .post-time { color:#9ca3af; font-family:monospace; font-size:0.8rem; }
+ .post-time { color:var(--blue); font-family:monospace; font-size:0.8rem; }
.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-text a:hover { text-shadow:0 0 8px var(--blue); }
.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);
overflow-y:auto; padding:40px 20px; }
.post-modal.active { display:block; }
- .modal-inner { max-width:760px; margin:0 auto; background:rgba(255,255,255,0.7);
- backdrop-filter:blur(24px); border:1px solid rgba(255,255,255,0.75);
- border-radius:16px; box-shadow:0 20px 60px rgba(99,102,241,0.18); padding:40px; }
- .modal-close { float:right; background:none; border:none; color:#9ca3af;
- font-size:0.9rem; cursor:pointer; }
+ .modal-inner { max-width:760px; margin:0 auto; background:rgba(5,5,30,0.92);
+ border:1px solid var(--gold); border-radius:12px;
+ box-shadow:0 0 60px rgba(255,209,102,0.25); padding:40px; backdrop-filter:blur(16px); }
+ .modal-close { float:right; background:none; border:none; color:var(--gold);
+ 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>
@@ -102,59 +96,118 @@ const glassTemplate = `<!DOCTYPE html>
</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.
+ // Cosmos WebGL: ringed planet, swirling nebula blobs, asteroid belt, and stars.
+ // The planet sits at lower-right and slowly rotates; asteroids orbit it;
+ // nebula clouds drift with additive blending for a deep-space glow.
(function() {
var scene, camera, renderer, clock;
- var shards = [];
+ var planet, planetRings = [];
+ var asteroids = [];
+ var ASTEROID_COUNT = 300;
+ var asteroidAngles, asteroidRadii, asteroidSpeeds, asteroidY;
+
+ function buildPlanet() {
+ // Planet body — warm golden tone
+ planet = new THREE.Mesh(
+ new THREE.SphereGeometry(14, 48, 48),
+ new THREE.MeshPhongMaterial({
+ color: 0xc8853a, emissive: 0x3a1800, emissiveIntensity: 0.4, shininess: 60
+ })
+ );
+ planet.position.set(28, -18, -55);
+ scene.add(planet);
+
+ // Ring system — 5 tilted torus rings in gold/purple
+ var ringCols = [0xffd166, 0xc07c30, 0xffd166, 0x9b5de5, 0xffd166];
+ for (var i = 0; i < 5; i++) {
+ var ring = new THREE.Mesh(
+ new THREE.TorusGeometry(18 + i * 2.5, 0.5 - i * 0.06, 8, 128),
+ new THREE.MeshBasicMaterial({ color: ringCols[i], transparent: true, opacity: 0.55 - i * 0.08, side: THREE.DoubleSide })
+ );
+ ring.position.copy(planet.position);
+ ring.rotation.x = Math.PI / 2.4;
+ ring.rotation.z = 0.2;
+ scene.add(ring);
+ planetRings.push(ring);
+ }
+ }
+
+ function buildNebula() {
+ // Large translucent additive blobs for the nebula cloud
+ var nCols = [0x9b5de5, 0x4cc9f0, 0x7b2fff, 0x4cc9f0, 0x9b5de5];
+ var nPos = [[-30,20,-80],[-10,-10,-90],[20,30,-70],[-20,-25,-95],[10,15,-85]];
+ nCols.forEach(function(c, i) {
+ var mesh = new THREE.Mesh(
+ new THREE.SphereGeometry(22 + i * 4, 16, 16),
+ new THREE.MeshBasicMaterial({
+ color: c, transparent: true, opacity: 0.09,
+ blending: THREE.AdditiveBlending, depthWrite: false
+ })
+ );
+ mesh.position.set(nPos[i][0], nPos[i][1], nPos[i][2]);
+ scene.add(mesh);
+ });
+ }
+
+ function buildAsteroids() {
+ asteroidAngles = new Float32Array(ASTEROID_COUNT);
+ asteroidRadii = new Float32Array(ASTEROID_COUNT);
+ asteroidSpeeds = new Float32Array(ASTEROID_COUNT);
+ asteroidY = new Float32Array(ASTEROID_COUNT);
+
+ var geo = new THREE.BufferGeometry();
+ var pos = new Float32Array(ASTEROID_COUNT * 3);
+ for (var i = 0; i < ASTEROID_COUNT; i++) {
+ asteroidAngles[i] = Math.random() * Math.PI * 2;
+ asteroidRadii[i] = 20 + Math.random() * 12;
+ asteroidSpeeds[i] = 0.003 + Math.random() * 0.004;
+ asteroidY[i] = (Math.random() - 0.5) * 3;
+ pos[i*3] = pos[i*3+1] = pos[i*3+2] = 0;
+ }
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ asteroids = new THREE.Points(geo, new THREE.PointsMaterial({
+ color: 0xaaaaaa, size: 0.3, transparent: true, opacity: 0.7
+ }));
+ // Asteroids orbit the planet — they live in planet-relative coords
+ planet.add(asteroids);
+ }
+
+ function buildStars() {
+ var pos = new Float32Array(2500 * 3);
+ for (var i = 0; i < 2500 * 3; i += 3) {
+ var r = 100 + Math.random() * 80, t = Math.random() * Math.PI * 2, p = Math.acos(2 * Math.random() - 1);
+ pos[i] = r * Math.sin(p) * Math.cos(t);
+ pos[i+1] = r * Math.sin(p) * Math.sin(t);
+ pos[i+2] = r * Math.cos(p);
+ }
+ var geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
+ scene.add(new THREE.Points(geo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.18, transparent: true, opacity: 0.85 })));
+ }
function initThree() {
scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x020214);
+ scene.fog = new THREE.Fog(0x020214, 80, 200);
- camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
- camera.position.set(0, 0, 40);
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 300);
+ camera.position.set(0, 6, 38);
+ camera.lookAt(0, 0, 0);
- renderer = new THREE.WebGLRenderer({
- canvas: document.getElementById('three-canvas'),
- antialias: true, alpha: true
- });
+ renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: 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);
- }
+ scene.add(new THREE.AmbientLight(0x4cc9f0, 0.4));
+ var sun = new THREE.PointLight(0xffd166, 3, 300);
+ sun.position.set(-60, 40, 30);
+ scene.add(sun);
+
+ buildPlanet();
+ buildNebula();
+ buildAsteroids();
+ buildStars();
window.addEventListener('resize', onResize);
animate();
@@ -168,10 +221,23 @@ const glassTemplate = `<!DOCTYPE html>
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);
- });
+ var t = clock.getElapsedTime();
+
+ planet.rotation.y += 0.0015;
+
+ // Update asteroid belt positions
+ var pos = asteroids.geometry.attributes.position;
+ for (var i = 0; i < ASTEROID_COUNT; i++) {
+ asteroidAngles[i] += asteroidSpeeds[i];
+ var a = asteroidAngles[i], r = asteroidRadii[i];
+ pos.setXYZ(i, Math.cos(a) * r, asteroidY[i], Math.sin(a) * r);
+ }
+ pos.needsUpdate = true;
+
+ // Camera orbits gently
+ camera.position.x = Math.sin(t * 0.06) * 6;
+ camera.position.y = 6 + Math.sin(t * 0.04) * 2;
+
renderer.render(scene, camera);
}
diff --git a/internal/generator/theme_ocean.go b/internal/generator/theme_ocean.go
index 12b2151..af4c80b 100644
--- a/internal/generator/theme_ocean.go
+++ b/internal/generator/theme_ocean.go
@@ -1,7 +1,8 @@
package generator
-// oceanTemplate is a deep-ocean theme — dark navy/midnight blue background,
-// WebGL animated wave surface with per-vertex sine displacement, teal/aqua accents.
+// oceanTemplate is a deep-ocean theme — dramatic animated wave surface with
+// sea rock formations, bioluminescent jellyfish, rising bubble particles,
+// and a whale silhouette cruising through the depths.
const oceanTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
@@ -51,11 +52,10 @@ const oceanTemplate = `<!DOCTYPE html>
.post-text a:hover { text-shadow:0 0 8px var(--teal); }
.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);
overflow-y:auto; padding:40px 20px; }
.post-modal.active { display:block; }
- .modal-inner { max-width:760px; margin:0 auto; background:rgba(2,30,80,0.98);
- border:1px solid var(--teal); border-radius:12px;
+ .modal-inner { max-width:760px; margin:0 auto; background:rgba(2,30,80,0.92);
+ border:1px solid var(--teal); border-radius:12px; backdrop-filter:blur(16px);
box-shadow:0 0 60px rgba(0,180,216,0.3); padding:40px; }
.modal-close { float:right; background:none; border:none; color:var(--teal);
font-size:0.9rem; cursor:pointer; letter-spacing:1px; }
@@ -94,19 +94,96 @@ const oceanTemplate = `<!DOCTYPE html>
</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.
+ // Ocean WebGL: dramatic wave surface + sea rock spires + bioluminescent
+ // jellyfish + rising bubbles + a slow whale cruising the deep.
(function() {
var scene, camera, renderer, clock;
- var waveMesh, waveGeo, pointLight;
+ var waveGeo, waveMesh, sunLight;
+ var whale, jellyfish = [];
+ var BUBBLE_COUNT = 600;
+ var bubblePos, bubbleVY;
+
+ function buildWaves() {
+ // High-density plane for smooth vertex displacement
+ waveGeo = new THREE.PlaneGeometry(300, 300, 100, 100);
+ waveMesh = new THREE.Mesh(waveGeo, new THREE.MeshPhongMaterial({
+ color: 0x0077b6, emissive: 0x023e8a, emissiveIntensity: 0.25,
+ transparent: true, opacity: 0.88, side: THREE.DoubleSide, shininess: 80
+ }));
+ waveMesh.rotation.x = -Math.PI / 2;
+ waveMesh.position.y = 0;
+ scene.add(waveMesh);
+ }
+
+ function buildRocks() {
+ // 5 jagged sea rock spires poking above the wave baseline
+ var rockPositions = [[-30,0,-30],[20,-2,-20],[-15,2,-45],[35,-1,-35],[-45,1,-25]];
+ rockPositions.forEach(function(p) {
+ var h = 8 + Math.random() * 10;
+ var rock = new THREE.Mesh(
+ new THREE.ConeGeometry(2 + Math.random(), h, 6),
+ new THREE.MeshPhongMaterial({ color: 0x023e8a, emissive: 0x00b4d8, emissiveIntensity: 0.15 })
+ );
+ rock.position.set(p[0], p[1] + h / 2 - 3, p[2]);
+ scene.add(rock);
+ });
+ }
+
+ function buildJellyfish() {
+ // Bioluminescent jellyfish: torus body + cone cap, additive blending
+ var jPos = [[-12, 6,-15],[18,10,-22],[-25,4,-18],[8,8,-30]];
+ jPos.forEach(function(p) {
+ var body = new THREE.Mesh(
+ new THREE.TorusGeometry(2.2, 0.5, 12, 24),
+ new THREE.MeshBasicMaterial({ color: 0x48cae4, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false })
+ );
+ var cap = new THREE.Mesh(
+ new THREE.SphereGeometry(2.2, 12, 8, 0, Math.PI * 2, 0, Math.PI / 2),
+ new THREE.MeshBasicMaterial({ color: 0x00b4d8, transparent: true, opacity: 0.35, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide })
+ );
+ cap.position.y = 0.5;
+ body.add(cap);
+ body.position.set(p[0], p[1], p[2]);
+ jellyfish.push({ mesh: body, baseY: p[1], phase: Math.random() * Math.PI * 2 });
+ scene.add(body);
+ });
+ }
+
+ function buildWhale() {
+ // Dark elongated flattened sphere — whale silhouette in the deep
+ var geo = new THREE.SphereGeometry(1, 16, 8);
+ whale = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: 0x011f40, transparent: true, opacity: 0.7 }));
+ whale.scale.set(12, 3, 5);
+ whale.position.set(-60, -8, -20);
+ scene.add(whale);
+ }
+
+ function buildBubbles() {
+ bubblePos = new Float32Array(BUBBLE_COUNT * 3);
+ bubbleVY = new Float32Array(BUBBLE_COUNT);
+ for (var i = 0; i < BUBBLE_COUNT; i++) {
+ bubblePos[i*3] = (Math.random() - 0.5) * 100;
+ bubblePos[i*3+1] = -15 - Math.random() * 15;
+ bubblePos[i*3+2] = (Math.random() - 0.5) * 60 - 10;
+ bubbleVY[i] = 0.04 + Math.random() * 0.06;
+ }
+ var geo = new THREE.BufferGeometry();
+ geo.setAttribute('position', new THREE.BufferAttribute(bubblePos, 3));
+ scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
+ color: 0xcaf0f8, size: 0.18, transparent: true, opacity: 0.6
+ })));
+ return geo;
+ }
+
+ var bubbleGeo;
function initThree() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x03045e);
- scene.fog = new THREE.Fog(0x03045e, 30, 120);
+ scene.fog = new THREE.Fog(0x03045e, 40, 130);
- camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 200);
- camera.position.set(0, 25, 50);
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 220);
+ camera.position.set(0, 20, 55);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('three-canvas'), antialias: true });
@@ -114,21 +191,19 @@ const oceanTemplate = `<!DOCTYPE html>
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);
+ scene.add(new THREE.AmbientLight(0x023e8a, 0.5));
+ sunLight = new THREE.PointLight(0x48cae4, 2.5, 100);
+ sunLight.position.set(0, 30, 10);
+ scene.add(sunLight);
+ var deepLight = new THREE.PointLight(0x0077b6, 1.5, 60);
+ deepLight.position.set(0, -10, 0);
+ scene.add(deepLight);
- // 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));
+ buildWaves();
+ buildRocks();
+ buildJellyfish();
+ buildWhale();
+ bubbleGeo = buildBubbles();
window.addEventListener('resize', onResize);
animate();
@@ -145,21 +220,44 @@ const oceanTemplate = `<!DOCTYPE html>
var t = clock.getElapsedTime();
var pos = waveGeo.attributes.position;
- // Two overlapping sine waves produce realistic ocean surface chop
+ // Dramatic overlapping waves — larger amplitude than before
for (var i = 0; i < pos.count; i++) {
- var x = pos.getX(i);
- var z = pos.getZ(i);
+ var x = pos.getX(i), 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
+ Math.sin(x * 0.04 + t * 1.1) * 3.2 +
+ Math.cos(z * 0.06 + t * 0.85) * 2.4 +
+ Math.sin((x + z) * 0.025 + t * 0.6) * 1.5
);
}
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;
+ // Jellyfish bob and slowly drift horizontally
+ jellyfish.forEach(function(j) {
+ j.mesh.position.y = j.baseY + Math.sin(t * 0.8 + j.phase) * 1.2;
+ j.mesh.position.x += 0.005 * Math.sin(t * 0.3 + j.phase);
+ j.mesh.rotation.y += 0.006;
+ });
+
+ // Whale cruises across at depth, wraps around
+ whale.position.x += 0.04;
+ if (whale.position.x > 80) whale.position.x = -80;
+ whale.position.y = -8 + Math.sin(t * 0.15) * 2;
+
+ // Rising bubbles — reset when they reach the surface
+ var bp = bubbleGeo.attributes.position;
+ for (var bi = 0; bi < BUBBLE_COUNT; bi++) {
+ bubblePos[bi*3+1] += bubbleVY[bi];
+ if (bubblePos[bi*3+1] > 8) {
+ bubblePos[bi*3] = (Math.random() - 0.5) * 100;
+ bubblePos[bi*3+1] = -15 - Math.random() * 10;
+ }
+ }
+ bp.needsUpdate = true;
+
+ // Sunlight orbits above
+ sunLight.position.x = Math.cos(t * 0.2) * 35;
+ sunLight.position.z = Math.sin(t * 0.2) * 35;
renderer.render(scene, camera);
}
diff --git a/internal/generator/theme_paper.go b/internal/generator/theme_paper.go
index ebf6211..9ed40a2 100644
--- a/internal/generator/theme_paper.go
+++ b/internal/generator/theme_paper.go
@@ -1,8 +1,8 @@
package generator
-// 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.
+// volcanoTemplate is a dark volcanic theme — rising ember particles, a glowing
+// lava plane at the base, molten rock boulders, smoke plumes, and a deep
+// underground furnace glow on the horizon.
const volcanoTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
@@ -52,11 +52,10 @@ const volcanoTemplate = `<!DOCTYPE html>
.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(13,8,2,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:rgba(20,8,2,0.98);
- border:1px solid var(--lava); border-radius:10px;
+ .modal-inner { max-width:760px; margin:0 auto; background:rgba(20,8,2,0.92);
+ border:1px solid var(--lava); border-radius:10px; backdrop-filter:blur(16px);
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; }
@@ -95,63 +94,128 @@ const volcanoTemplate = `<!DOCTYPE html>
</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.
+ // Volcano WebGL: glowing lava floor, molten rock boulders, smoke plumes,
+ // underground furnace glow sphere, and 3000 rising ember particles.
(function() {
- var N = 2000;
+ var N_EMBER = 3000;
+ var N_SMOKE = 800;
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
+ var emberPoints, smokePoints;
+ var ePosArr, eColArr, sPosArr;
+ var ePX, ePY, ePZ, eVX, eVY, eLife, eMaxLife;
+ var sPX, sPY, sPZ, sSVY, sSLife, sSMaxLife;
+ var lavaGeo, lavaFloor;
+
+ function resetEmber(i) {
+ ePX[i] = (Math.random() - 0.5) * 70;
+ ePY[i] = -22 + (Math.random() - 0.5) * 4;
+ ePZ[i] = (Math.random() - 0.5) * 30 - 5;
+ eVX[i] = (Math.random() - 0.5) * 0.08;
+ eVY[i] = 0.07 + Math.random() * 0.14;
+ eMaxLife[i] = 0.4 + Math.random() * 0.6;
+ eLife[i] = Math.random();
+ }
+
+ function resetSmoke(i) {
+ sPX[i] = (Math.random() - 0.5) * 40;
+ sPY[i] = -18 + Math.random() * 5;
+ sPZ[i] = (Math.random() - 0.5) * 20 - 5;
+ sSVY[i] = 0.015 + Math.random() * 0.025;
+ sSMaxLife[i] = 1.5 + Math.random() * 2.0;
+ sSLife[i] = Math.random();
+ }
+
+ function buildLavaFloor() {
+ lavaGeo = new THREE.PlaneGeometry(200, 200, 60, 60);
+ lavaFloor = new THREE.Mesh(lavaGeo, new THREE.MeshPhongMaterial({
+ color: 0x8b1000, emissive: 0xff2200, emissiveIntensity: 0.6,
+ shininess: 120
+ }));
+ lavaFloor.rotation.x = -Math.PI / 2;
+ lavaFloor.position.y = -22;
+ scene.add(lavaFloor);
+ }
+
+ function buildBoulders() {
+ // Molten rock boulders with glowing emissive cores
+ var boulderData = [
+ [-18,-16,-15, 5], [20,-15,-20, 7], [-8,-14,-30, 4],
+ [30,-16,-12, 6], [-28,-15,-25, 5]
+ ];
+ boulderData.forEach(function(b) {
+ var mesh = new THREE.Mesh(
+ new THREE.IcosahedronGeometry(b[3], 1),
+ new THREE.MeshPhongMaterial({ color: 0x1a0500, emissive: 0xff4400, emissiveIntensity: 0.7, shininess: 20 })
+ );
+ mesh.position.set(b[0], b[1], b[2]);
+ mesh.rotation.set(Math.random(), Math.random(), Math.random());
+ scene.add(mesh);
+ });
+ }
+
+ function buildFurnaceGlow() {
+ // Underground furnace: massive low-opacity emissive sphere below the lava
+ var glow = new THREE.Mesh(
+ new THREE.SphereGeometry(45, 16, 16),
+ new THREE.MeshBasicMaterial({ color: 0xff3300, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false })
+ );
+ glow.position.set(0, -60, -30);
+ scene.add(glow);
+ }
+
+ function buildParticles() {
+ ePX = new Float32Array(N_EMBER); ePY = new Float32Array(N_EMBER);
+ ePZ = new Float32Array(N_EMBER); eVX = new Float32Array(N_EMBER);
+ eVY = new Float32Array(N_EMBER); eLife = new Float32Array(N_EMBER);
+ eMaxLife = new Float32Array(N_EMBER);
+ ePosArr = new Float32Array(N_EMBER * 3);
+ eColArr = new Float32Array(N_EMBER * 3);
+ for (var i = 0; i < N_EMBER; i++) resetEmber(i);
+ var eGeo = new THREE.BufferGeometry();
+ eGeo.setAttribute('position', new THREE.BufferAttribute(ePosArr, 3));
+ eGeo.setAttribute('color', new THREE.BufferAttribute(eColArr, 3));
+ emberPoints = new THREE.Points(eGeo, new THREE.PointsMaterial({
+ size: 0.3, vertexColors: true,
+ transparent: true, opacity: 0.95, blending: THREE.AdditiveBlending, depthWrite: false
+ }));
+ scene.add(emberPoints);
+
+ sPX = new Float32Array(N_SMOKE); sPY = new Float32Array(N_SMOKE);
+ sPZ = new Float32Array(N_SMOKE); sSVY = new Float32Array(N_SMOKE);
+ sSLife = new Float32Array(N_SMOKE); sSMaxLife = new Float32Array(N_SMOKE);
+ sPosArr = new Float32Array(N_SMOKE * 3);
+ for (var j = 0; j < N_SMOKE; j++) resetSmoke(j);
+ var sGeo = new THREE.BufferGeometry();
+ sGeo.setAttribute('position', new THREE.BufferAttribute(sPosArr, 3));
+ smokePoints = new THREE.Points(sGeo, new THREE.PointsMaterial({
+ color: 0x444444, size: 1.8, transparent: true, opacity: 0.15, depthWrite: false
+ }));
+ scene.add(smokePoints);
}
function initThree() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0d0802);
- scene.fog = new THREE.Fog(0x0d0802, 30, 80);
+ scene.fog = new THREE.Fog(0x0d0802, 35, 100);
- camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 120);
- camera.position.set(0, 0, 45);
+ camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 150);
+ camera.position.set(0, 8, 50);
+ camera.lookAt(0, -5, 0);
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);
+ scene.add(new THREE.AmbientLight(0x220800, 1.0));
+ var lavaLight = new THREE.PointLight(0xff4400, 4, 80);
+ lavaLight.position.set(0, -15, 0);
+ scene.add(lavaLight);
- 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);
+ buildLavaFloor();
+ buildBoulders();
+ buildFurnaceGlow();
+ buildParticles();
window.addEventListener('resize', onResize);
animate();
@@ -166,29 +230,44 @@ const volcanoTemplate = `<!DOCTYPE html>
function animate() {
requestAnimationFrame(animate);
var dt = clock.getDelta();
+ var t = clock.getElapsedTime();
- for (var i = 0; i < N; i++) {
- life[i] += dt / maxLife[i];
- if (life[i] > 1.0) resetParticle(i);
+ // Pulse the lava floor emissive intensity
+ lavaFloor.material.emissiveIntensity = 0.4 + 0.25 * Math.sin(t * 1.8);
- py[i] += vy[i];
- px[i] += vx[i];
+ // Animate lava floor vertices
+ var lp = lavaGeo.attributes.position;
+ for (var i = 0; i < lp.count; i++) {
+ var lx = lp.getX(i), lz = lp.getZ(i);
+ lp.setY(i, Math.sin(lx * 0.08 + t * 0.7) * 0.8 + Math.cos(lz * 0.1 + t * 0.5) * 0.6);
+ }
+ lp.needsUpdate = true;
- var idx = i * 3;
- posArr[idx] = px[i];
- posArr[idx+1] = py[i];
- posArr[idx+2] = pz[i];
+ // Embers
+ for (var ei = 0; ei < N_EMBER; ei++) {
+ eLife[ei] += dt / eMaxLife[ei];
+ if (eLife[ei] > 1.0) resetEmber(ei);
+ ePY[ei] += eVY[ei];
+ ePX[ei] += eVX[ei];
+ var idx = ei * 3, te = eLife[ei];
+ ePosArr[idx] = ePX[ei]; ePosArr[idx+1] = ePY[ei]; ePosArr[idx+2] = ePZ[ei];
+ var fade = Math.max(0, 1 - te * 1.3);
+ eColArr[idx] = fade; eColArr[idx+1] = fade * Math.max(0, 1 - te * 2.2); eColArr[idx+2] = 0;
+ }
+ emberPoints.geometry.attributes.position.needsUpdate = true;
+ emberPoints.geometry.attributes.color.needsUpdate = true;
- // 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
+ // Smoke
+ for (var si = 0; si < N_SMOKE; si++) {
+ sSLife[si] += dt / sSMaxLife[si];
+ if (sSLife[si] > 1.0) resetSmoke(si);
+ sPY[si] += sSVY[si];
+ sPX[si] += (Math.random() - 0.5) * 0.04;
+ var si3 = si * 3;
+ sPosArr[si3] = sPX[si]; sPosArr[si3+1] = sPY[si]; sPosArr[si3+2] = sPZ[si];
}
+ smokePoints.geometry.attributes.position.needsUpdate = true;
- points.geometry.attributes.position.needsUpdate = true;
- points.geometry.attributes.color.needsUpdate = true;
renderer.render(scene, camera);
}
diff --git a/internal/generator/themes.go b/internal/generator/themes.go
index 1156982..7100471 100644
--- a/internal/generator/themes.go
+++ b/internal/generator/themes.go
@@ -14,7 +14,7 @@ var themeRegistry = map[string]string{
"matrix": matrixTemplate,
"ocean": oceanTemplate,
"retro": retroTemplate,
- "glass": glassTemplate,
+ "cosmos": cosmosTemplate, // replaced "glass" — ringed planet, nebula, asteroids
}
// getTheme returns the HTML template string for the given theme name.