summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--PLAN.md85
-rw-r--r--cmd/snonux/main.go18
-rw-r--r--integrationtests/integration_test.go4
-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
15 files changed, 1110 insertions, 194 deletions
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..3190323
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,85 @@
+# Plan
+
+This is the plan for the microblog of snonux.foo
+
+## Style
+
+The style should be the same as in ./design.html
+
+* also link to foo.zone, my normal blog, at the top of every page of the microblog.
+* dont have an archive link like in the design.html example.
+* the "transmit to nexus" should just link to mailto:paul@nospan.buetow.org
+* every microblog entry should have date/timestamp linke in the design.html exampl
+* dont have the likes (hearts), comments, and rebroadcast buttons per blog post.
+* we want to be able to navigat between the blog posts with cursor up-down keys and jk (vi style up-down keys)
+* we also want to navigate with the left-right cursor keys to previous, next page (if any) and also hl(vi style up-down keys)
+* when navigating, we want to highlight the active entry (with a border or background), so it's clear which post is selected.and also play a sound.
+* when hit enter on a blog post, open it in a larger view. when hit ESC there, return.
+
+## Input dir
+
+I want to create a microblog. It should have an input directory, where i can put multiple source file formats:
+
+* Plain .txt
+* Markdown .md
+* Images (.png/.jpg/.gif)
+* Audio (.mp3 files)
+
+And the blog should automatically generate a post out of it. Furthermore, it should also support a Markdown with a reference to an image file in the same directory.
+
+Once we invoke the microblog generator, it should process all files in the input dir into blog assets.
+
+### Plain text input
+
+Just add this as a plain text as a blog post
+
+### Markdown .md
+
+Add this as a formatted HTML entry with styling etc. For this, we need to convert the Markdown to HTML.
+
+### Images
+
+Just have the image displayed as its own entry.
+
+### Audio
+
+Just have a playback button for the audio in the resulting entry
+
+### After being processed
+
+After an input was procssed, remove the files from the input dir.
+
+## Output dir
+
+The output dir should only contain static assets. That the directory to be published via a webbrowser.
+
+We expect one directory per blog post. The microblog generator then combines all of them together into multiple pages.
+
+So we need to keep the individual directories per blog post since the pages need to be re-generated according to the total blog post count and same for the atom.xml feed, so we need the directories as intermediate formats. We can link to images directly to them, though. So the output directory format will be like this:
+
+
+./outdir/index.html # Generated main page
+./outdir/pageN.html # Older pages
+./ourdir/posts/YYYY-MM-DD-HHmmss/ # Microblog post asset(s)
+
+Downscale any images to a maximum width of 1024px and compress to 80% JPEG quality
+
+## Page size limit
+
+Have max 42 entries on the single HTML page. Once more, allow paging (e.g. go to next 42 pages, etc).
+
+* Next page (if any) is only on the bottom of the page.
+* Previous page button (if any) is at the top of the page.
+
+## atom.xml feed
+
+We should have an atom.xml feed with the last 42 entries generated every run.
+
+## Comprehensive testing
+
+* All features should have integration tests.
+
+## Technologies used
+
+* Implemented in Google Go
+* Follow everything from the auditing-code-quality skill
diff --git a/cmd/snonux/main.go b/cmd/snonux/main.go
index 5a9c9d3..58d6cb5 100644
--- a/cmd/snonux/main.go
+++ b/cmd/snonux/main.go
@@ -11,8 +11,10 @@ import (
"flag"
"fmt"
"log"
+ "math/rand"
"os"
"path/filepath"
+ "strings"
"codeberg.org/snonux/snonux/internal/config"
"codeberg.org/snonux/snonux/internal/generator"
@@ -31,15 +33,29 @@ func main() {
}
// parseFlags reads CLI flags and returns a validated Config.
+// Special theme value "random" picks a theme at random from the registry.
func parseFlags() (*config.Config, error) {
cfg := &config.Config{}
+ listThemes := flag.Bool("list-themes", false, "print all available theme names and exit")
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: aurora, brutalist, glass, matrix, minimal, neon, ocean, paper, retro, synthwave, terminal")
+ flag.StringVar(&cfg.Theme, "theme", "neon", "visual theme name, or \"random\" to pick one at random")
flag.Parse()
+ if *listThemes {
+ fmt.Println(strings.Join(generator.ListThemes(), "\n"))
+ os.Exit(0)
+ }
+
+ // Resolve the special "random" value before any further validation.
+ if cfg.Theme == "random" {
+ themes := generator.ListThemes()
+ cfg.Theme = themes[rand.Intn(len(themes))]
+ log.Printf("random theme selected: %s", cfg.Theme)
+ }
+
var err error
cfg.InputDir, err = expandHome(cfg.InputDir)
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go
index 634971e..6d355b1 100644
--- a/integrationtests/integration_test.go
+++ b/integrationtests/integration_test.go
@@ -323,8 +323,8 @@ 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", "minimal",
- "neon", "ocean", "paper", "retro", "synthwave", "terminal",
+ "aurora", "brutalist", "glass", "matrix", "neon",
+ "ocean", "plasma", "retro", "synthwave", "terminal", "volcano",
}
for _, theme := range themes {
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,