diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-10 09:22:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-10 09:22:06 +0300 |
| commit | f3155cbc866a1b261a0aafe61683da1e3c25b1ef (patch) | |
| tree | dedad4708adf0b377dd06c11c1bc957bc1bfe302 | |
| parent | 3e61d09873065f5342efc414ee3ea0d5fdc4c767 (diff) | |
add WebGL scenes to all themes, sounds, image sizing, new themes
- All 11 themes now have unique Three.js WebGL backgrounds:
aurora: flowing sine-wave ribbon meshes with additive blending
brutalist: harsh rotating white/red wireframe boxes
glass: drifting crystal icosahedron shards, semi-transparent
matrix: digital rain particle columns with per-vertex colour fade
ocean: animated vertex-displaced wave surface with orbiting light
retro: amber demo-scene cube with orbiting octahedrons
synthwave: sunset sphere with scan-line rings and perspective grid
terminal: green icosahedron wireframe with orbiting torus particles
neon: existing Three.js orb and rings (unchanged)
plasma (replaces minimal): drifting additive-blend colour blobs
volcano (replaces paper): rising ember particles with lifecycle
- shared.go: distinct sounds for j/k nav (220Hz beep), Enter (ascending
triangle chime), and Esc (descending sine sweep)
- shared.go: images are now thumbnails (max-height:220px) in list view
and expand to full width inside the modal (CSS override in navmodal)
- main.go: --list-themes flag prints all theme names and exits;
--theme random picks a random theme at all generation time
- themes.go: updated registry with plasma/volcano replacing minimal/paper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | PLAN.md | 85 | ||||
| -rw-r--r-- | cmd/snonux/main.go | 18 | ||||
| -rw-r--r-- | integrationtests/integration_test.go | 4 | ||||
| -rw-r--r-- | internal/generator/shared.go | 52 | ||||
| -rw-r--r-- | internal/generator/theme_aurora.go | 109 | ||||
| -rw-r--r-- | internal/generator/theme_brutalist.go | 68 | ||||
| -rw-r--r-- | internal/generator/theme_glass.go | 114 | ||||
| -rw-r--r-- | internal/generator/theme_matrix.go | 102 | ||||
| -rw-r--r-- | internal/generator/theme_minimal.go | 180 | ||||
| -rw-r--r-- | internal/generator/theme_ocean.go | 91 | ||||
| -rw-r--r-- | internal/generator/theme_paper.go | 202 | ||||
| -rw-r--r-- | internal/generator/theme_retro.go | 87 | ||||
| -rw-r--r-- | internal/generator/theme_synthwave.go | 110 | ||||
| -rw-r--r-- | internal/generator/theme_terminal.go | 78 | ||||
| -rw-r--r-- | internal/generator/themes.go | 4 |
15 files changed, 1110 insertions, 194 deletions
@@ -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:99 |
