diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 09:32:07 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 09:32:07 +0300 |
| commit | 762589bd807c4888f4d7dcac32606b157216a049 (patch) | |
| tree | c57505973bc31ec9007f5876610412248064b6f1 | |
| parent | f3155cbc866a1b261a0aafe61683da1e3c25b1ef (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.go | 2 | ||||
| -rw-r--r-- | integrationtests/integration_test.go | 2 | ||||
| -rw-r--r-- | internal/generator/shared.go | 8 | ||||
| -rw-r--r-- | internal/generator/theme_glass.go | 250 | ||||
| -rw-r--r-- | internal/generator/theme_ocean.go | 164 | ||||
| -rw-r--r-- | internal/generator/theme_paper.go | 209 | ||||
| -rw-r--r-- | internal/generator/themes.go | 2 |
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. |
