summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-08-02 08:59:11 +0300
committerPaul Buetow <paul@buetow.org>2026-08-02 08:59:11 +0300
commitccf28b078907a2ee89d84e635f1455914823e756 (patch)
tree24d767955a1448cd69d4ba2c2e1f33cdac0a5bef /internal
parenta1157e8f8b0623afc3bc4f37e7a939ad6879dde5 (diff)
Add arcade themes and release v0.19.0
Amp-Thread-ID: https://ampcode.com/threads/T-019fbeb1-4c14-7775-baa1-5cd8579b1dfd Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/generator/templates/themes/breakout/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/breakout/ambient.oggbin0 -> 1428440 bytes
-rw-r--r--internal/generator/templates/themes/breakout/meta.json7
-rw-r--r--internal/generator/templates/themes/breakout/sounds.json11
-rw-r--r--internal/generator/templates/themes/breakout/theme.css5
-rw-r--r--internal/generator/templates/themes/breakout/theme.js124
-rw-r--r--internal/generator/templates/themes/invaders/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/invaders/ambient.oggbin0 -> 148158 bytes
-rw-r--r--internal/generator/templates/themes/invaders/meta.json7
-rw-r--r--internal/generator/templates/themes/invaders/sounds.json11
-rw-r--r--internal/generator/templates/themes/invaders/theme.css1
-rw-r--r--internal/generator/templates/themes/invaders/theme.js230
-rw-r--r--internal/generator/templates/themes/pacmaze/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/pacmaze/ambient.oggbin0 -> 2313143 bytes
-rw-r--r--internal/generator/templates/themes/pacmaze/meta.json7
-rw-r--r--internal/generator/templates/themes/pacmaze/sounds.json11
-rw-r--r--internal/generator/templates/themes/pacmaze/theme.css12
-rw-r--r--internal/generator/templates/themes/pacmaze/theme.js139
-rw-r--r--internal/generator/templates/themes/pinball/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/pinball/ambient.oggbin0 -> 655778 bytes
-rw-r--r--internal/generator/templates/themes/pinball/meta.json7
-rw-r--r--internal/generator/templates/themes/pinball/sounds.json11
-rw-r--r--internal/generator/templates/themes/pinball/theme.css9
-rw-r--r--internal/generator/templates/themes/pinball/theme.js30
-rw-r--r--internal/version/version.go2
25 files changed, 651 insertions, 1 deletions
diff --git a/internal/generator/templates/themes/breakout/MUSIC_LICENSE.txt b/internal/generator/templates/themes/breakout/MUSIC_LICENSE.txt
new file mode 100644
index 0000000..7351736
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/MUSIC_LICENSE.txt
@@ -0,0 +1,7 @@
+Track: Insistent: background loop
+Author: yd
+License: CC0 1.0 Universal / public domain dedication
+License URL: https://creativecommons.org/publicdomain/zero/1.0/
+Source: https://opengameart.org/content/insistent-background-loop
+
+Bundled as ambient.ogg for the snonux breakout theme.
diff --git a/internal/generator/templates/themes/breakout/ambient.ogg b/internal/generator/templates/themes/breakout/ambient.ogg
new file mode 100644
index 0000000..4329605
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/ambient.ogg
Binary files differ
diff --git a/internal/generator/templates/themes/breakout/meta.json b/internal/generator/templates/themes/breakout/meta.json
new file mode 100644
index 0000000..916835f
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/meta.json
@@ -0,0 +1,7 @@
+{
+ "title": "snonux.foo // BREAKOUT",
+ "header_html": "<div class=\"logo\"><span class=\"ball-mark\" aria-hidden=\"true\"></span><div><h1>snonux.foo</h1><p class=\"subtitle\">NOTES FROM THE MIDNIGHT ARCADE · <a href=\"https://foo.zone\">FOO.ZONE</a> IS THE REAL BLOG</p><p class=\"logo-host\">Served by NetBSD on a Raspberry Pi 3</p></div></div><nav class=\"nav\" aria-label=\"Site\"><a href=\"atom.xml\" class=\"header-feed-link\" rel=\"alternate\" title=\"Atom feed\" type=\"application/atom+xml\">ATOM</a><a href=\"https://foo.zone/about\" class=\"transmit-btn\">SEND A NOTE</a></nav>",
+ "splash_inner_html": "<canvas class=\"splash-gl-canvas\" id=\"splash-gl-canvas\" aria-hidden=\"true\"></canvas><div class=\"splash-inner\"><div class=\"splash-bricks\" aria-hidden=\"true\"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div><div class=\"splash-ball\" aria-hidden=\"true\"></div><div class=\"splash-paddle\" aria-hidden=\"true\"></div><p class=\"splash-kicker\">PLAYER ONE · READY</p><div class=\"splash-title\">BREAKOUT</div><p class=\"splash-tag\">Clear a line. Leave a thought.</p><p class=\"splash-hint\">PRESS ENTER OR CLICK TO SERVE</p></div>",
+ "prev_page_text": "← NEWER ROUND",
+ "next_page_text": "OLDER ROUND →"
+}
diff --git a/internal/generator/templates/themes/breakout/sounds.json b/internal/generator/templates/themes/breakout/sounds.json
new file mode 100644
index 0000000..a360f72
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/sounds.json
@@ -0,0 +1,11 @@
+{
+ "splash": {"freqs":[220,330,440,660],"spacing":0.06,"gain":0.07,"wave":"triangle"},
+ "nav": {"freq":392,"wave":"sine","dur":0.045,"gain":0.045},
+ "open": {"wave":"triangle","start":180,"end":720,"dur":0.1,"gain":0.06},
+ "close": {"wave":"triangle","start":520,"end":130,"dur":0.09,"gain":0.05},
+ "bounce": {"wave":"square","start":180,"end":110,"dur":0.045,"gain":0.045},
+ "ambient": {
+ "normal": {"bpm":112,"gain":0.022,"wave":"triangle","droneFreqs":[55,110],"pulseFreqs":[220,277.18,329.63],"cutoffMin":700,"cutoffMax":2800,"attack":0.02,"release":0.12,"melody":[{"freq":220,"dur":0.12,"step":0.4},{"freq":277.18,"dur":0.1,"step":0.4},{"freq":329.63,"dur":0.12,"step":0.4}],"drums":["kick","","","snare","kick","","","snare"],"file":"ambient.ogg","volume":0.27},
+ "wild": {"bpm":184,"gain":0.05,"wave":"square","droneFreqs":[55,110,220],"pulseFreqs":[440,554.37,659.25],"cutoffMin":1500,"cutoffMax":6500,"attack":0.005,"release":0.05,"melody":[{"freq":440,"dur":0.07,"step":0.13},{"freq":659.25,"dur":0.07,"step":0.13},{"freq":554.37,"dur":0.07,"step":0.13}],"drums":["kick","","snare","kick","kick","","snare","kick"],"file":"ambient.ogg","volume":0.43}
+ }
+}
diff --git a/internal/generator/templates/themes/breakout/theme.css b/internal/generator/templates/themes/breakout/theme.css
new file mode 100644
index 0000000..0444ee9
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/theme.css
@@ -0,0 +1,5 @@
+:root{--field:#080b12;--panel:#10141d;--ink:#f2eddf;--muted:#96958f;--line:#303540;--c1:#c75c5c;--c2:#d98255;--c3:#d5aa58;--c4:#77a66e;--c5:#559ca0;--c6:#657eae;--c7:#8c6f9f}
+*{box-sizing:border-box;margin:0;padding:0}body{height:100vh;height:100dvh;overflow:hidden;background:var(--field);color:var(--ink);font-family:Inter,"Helvetica Neue",Arial,sans-serif}body::after{content:"";position:fixed;inset:0;z-index:2;pointer-events:none;background:linear-gradient(rgba(255,255,255,.018) 1px,transparent 1px);background-size:100% 34px}#three-canvas{position:fixed;inset:0;width:100%;height:100%;z-index:1}.overlay{position:relative;z-index:10;height:100%;display:flex;flex-direction:column}header{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,58px);background:rgba(8,11,18,.94);border-bottom:1px solid var(--line)}.logo{display:flex;align-items:center;gap:16px;min-width:0}.ball-mark{width:13px;height:13px;border-radius:50%;background:var(--ink);box-shadow:18px 13px 0 -4px var(--c3)}.logo h1{font:650 clamp(1.25rem,2.2vw,1.8rem)/1 Georgia,serif;letter-spacing:.04em}.subtitle,.logo-host{margin-top:5px;color:var(--muted);font:600 .62rem/1.3 ui-monospace,SFMono-Regular,monospace;letter-spacing:.1em}.subtitle a,.post-text a{color:var(--c5);text-underline-offset:3px}.nav{display:flex;align-items:center;gap:20px;flex:none}.header-feed-link,.transmit-btn,.page-nav a{color:var(--ink);font:650 .68rem ui-monospace,monospace;letter-spacing:.12em;text-decoration:none}.transmit-btn,.page-nav a{padding:9px 14px;border:1px solid #666961;background:rgba(16,20,29,.9)}a:hover{color:var(--c3)}a:focus-visible,button:focus-visible,.post:focus-visible{outline:3px solid var(--c3);outline-offset:4px}.nav-hints{color:var(--muted);background:rgba(12,15,22,.95);border-bottom:1px solid #202530;font:600 .62rem ui-monospace,monospace}.nav-hints-row{min-height:28px}.nav-hints kbd{color:var(--ink);background:transparent;border:1px solid #50545b}.nav-fx-button:hover,.nav-fx-button[aria-pressed=true]{color:var(--c3);background:#1b2029}.content{flex:1;min-height:0;overflow-y:auto;padding:28px clamp(18px,7vw,110px);scrollbar-color:var(--c4) var(--field);scrollbar-width:thin}.post{position:relative;max-width:1040px;margin:0 auto 12px;padding:20px 25px;background:rgba(16,20,29,.94);border:1px solid #292f39;border-left:10px solid var(--c1);cursor:pointer;transition:transform .16s ease,background .16s}.post:nth-child(7n+2){border-left-color:var(--c2)}.post:nth-child(7n+3){border-left-color:var(--c3)}.post:nth-child(7n+4){border-left-color:var(--c4)}.post:nth-child(7n+5){border-left-color:var(--c5)}.post:nth-child(7n+6){border-left-color:var(--c6)}.post:nth-child(7n){border-left-color:var(--c7)}.post:hover{background:#171c26;transform:translateX(4px)}.post-active{background:#1a202b!important;box-shadow:inset 0 0 0 1px var(--ink);transform:translateX(5px)}.post-header{display:grid;grid-template-columns:auto 1fr auto;gap:14px;align-items:center;margin-bottom:10px;color:var(--muted);font:600 .67rem ui-monospace,monospace;letter-spacing:.06em;text-transform:uppercase}.post-header strong{color:var(--ink)}.post-time{justify-self:end}.post-text{font:400 1.02rem/1.68 Georgia,"Times New Roman",serif;max-width:78ch}.post-image{margin-top:15px;max-width:100%;border:1px solid #424650}.post-audio{width:100%;margin-top:12px}.page-nav-footer{flex:none;padding:10px;background:rgba(8,11,18,.96);border-top:1px solid var(--line)}.page-nav{display:flex;justify-content:center}.post-modal{display:none;position:fixed;inset:0;z-index:100;padding:35px 18px;overflow:auto;background:rgba(4,6,10,.78)}.post-modal.active{display:block}.modal-inner{max-width:820px;margin:auto;padding:35px;background:var(--panel);border:1px solid #656861;box-shadow:0 24px 80px #000}.modal-close{float:right;padding:9px;color:var(--ink);background:transparent;border:1px solid #656861;cursor:pointer}
+[data-sno-theme=breakout] .splash-overlay{background:rgba(8,11,18,.97);font-family:ui-monospace,monospace}[data-sno-theme=breakout] .splash-inner{position:relative;z-index:2;width:min(82vw,620px);text-align:center}.splash-bricks{display:grid;grid-template-columns:repeat(7,1fr);gap:5px;margin-bottom:52px}.splash-bricks i{height:12px;background:var(--c1)}.splash-bricks i:nth-child(2){background:var(--c2)}.splash-bricks i:nth-child(3){background:var(--c3)}.splash-bricks i:nth-child(4){background:var(--c4)}.splash-bricks i:nth-child(5){background:var(--c5)}.splash-bricks i:nth-child(6){background:var(--c6)}.splash-bricks i:nth-child(7){background:var(--c7)}.splash-ball{position:absolute;top:38px;left:58%;width:13px;height:13px;border-radius:50%;background:var(--ink);animation:serve 1.8s ease-in-out infinite}.splash-paddle{width:100px;height:8px;margin:0 auto 31px;background:var(--ink)}@keyframes serve{50%{transform:translate(-90px,62px)}}.splash-kicker{color:var(--c3);font-size:.66rem;letter-spacing:.22em}.splash-title{margin:.18em 0;font:500 clamp(2.5rem,10vw,6rem)/1 Georgia,serif;letter-spacing:.08em}.splash-tag{color:var(--muted);font:400 clamp(.85rem,2vw,1rem) Georgia,serif}.splash-hint{margin-top:1.7rem;color:#777a78;font-size:.62rem;letter-spacing:.16em}
+@media(max-width:640px){header{padding:12px 14px}.logo-host,.subtitle,.header-feed-link{display:none}.nav{gap:7px}.transmit-btn{padding:9px 8px}.content{padding:14px 12px}.post{padding:16px 14px;border-left-width:7px}.post-text{font-size:.96rem}.post-header{gap:7px;font-size:.59rem}.nav-hints-label,.nav-hints-spacer,.nav-hints kbd{display:none}.nav-fx-button{min-height:44px;flex:1;justify-content:center}.modal-inner{padding:22px 15px}.modal-close{min-width:44px;min-height:44px}.splash-bricks{gap:3px}}
+@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}.post{transition:none}}
diff --git a/internal/generator/templates/themes/breakout/theme.js b/internal/generator/templates/themes/breakout/theme.js
new file mode 100644
index 0000000..fa4947c
--- /dev/null
+++ b/internal/generator/templates/themes/breakout/theme.js
@@ -0,0 +1,124 @@
+(function () {
+ 'use strict';
+ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var palette = [0xc75c5c, 0xd98255, 0xd5aa58, 0x77a66e, 0x559ca0, 0x657eae, 0x8c6f9f];
+
+ function buildScene(canvas, splash) {
+ if (!canvas || typeof THREE === 'undefined') return { wild: function () {}, hit: function () {}, destroy: function () {} };
+ var renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true, antialias: true});
+ renderer.setClearColor(0, 0); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+ var scene = new THREE.Scene();
+ var camera = new THREE.OrthographicCamera(-10, 10, 7, -7, .1, 50); camera.position.z = 12;
+ scene.add(new THREE.AmbientLight(0xffffff, 1.4));
+ var bricks = [], balls = [], rows = splash ? 3 : 6, cols = splash ? 7 : 11;
+ var brickGeo = new THREE.BoxGeometry(1.45, .42, .3);
+ for (var r = 0; r < rows; r++) for (var c = 0; c < cols; c++) {
+ var brick = new THREE.Mesh(brickGeo, new THREE.MeshLambertMaterial({color: palette[r % 7], transparent: true}));
+ brick.position.set((c - (cols - 1) / 2) * 1.65, 5.3 - r * .62, 0);
+ brick.userData.row = r; brick.userData.col = c;
+ brick.userData.homeX = brick.position.x; brick.userData.homeY = brick.position.y;
+ bricks.push(brick); scene.add(brick);
+ }
+ var paddle = new THREE.Mesh(new THREE.BoxGeometry(3.2, .28, .36), new THREE.MeshLambertMaterial({color: 0xf2eddf}));
+ paddle.position.y = -5.5; scene.add(paddle);
+ var ballGeo = new THREE.SphereGeometry(.22, 16, 12);
+ var ballMaterial = new THREE.MeshLambertMaterial({color: 0xf2eddf});
+ function addBall(seed) {
+ var ball = new THREE.Mesh(ballGeo, ballMaterial);
+ ball.position.set((seed - 1) * .6, -3 + seed * .25, .35);
+ ball.userData.vx = (seed % 2 ? 1 : -1) * (2.8 + (seed % 6) * .35);
+ ball.userData.vy = 3.4 + (seed % 7) * .25;
+ balls.push(ball); scene.add(ball);
+ }
+ addBall(0);
+ var maxBalls = splash ? 10 : 18, activeBalls = 1;
+ var isWild = false, raf = 0, last = performance.now(), nextRow = 0, dead = false;
+ function resize() {
+ var w = canvas.clientWidth || 2, h = canvas.clientHeight || 2, aspect = w / h;
+ camera.left = -7 * aspect; camera.right = 7 * aspect; camera.top = 7; camera.bottom = -7; camera.updateProjectionMatrix(); renderer.setSize(w, h, false);
+ }
+ function frame(now) {
+ if (dead) return;
+ var dt = Math.min((now - last) / 1000, .04) * (isWild ? 2.8 : 1); last = now;
+ balls.slice(0, activeBalls).forEach(function (b) {
+ b.position.x += b.userData.vx * dt; b.position.y += b.userData.vy * dt;
+ var edge = 6.8 * camera.right / 10;
+ if (b.position.x > edge || b.position.x < -edge) { b.position.x = Math.max(-edge, Math.min(edge, b.position.x)); b.userData.vx *= -1; }
+ if (b.position.y > 6.4) b.userData.vy = -Math.abs(b.userData.vy);
+ if (b.position.y < -5.15 && b.position.y > -5.8 && Math.abs(b.position.x - paddle.position.x) < 1.9) b.userData.vy = Math.abs(b.userData.vy);
+ if (b.position.y < -6.7) { b.position.set(0, -3, .35); b.userData.vy = Math.abs(b.userData.vy); }
+ });
+ paddle.position.x = isWild
+ ? (Math.sin(now * .0063) * 3.4 + Math.sin(now * .014) * 1.25)
+ : Math.sin(now * .00055) * 3.6;
+ if (isWild) {
+ bricks.forEach(function (b) {
+ var wave = now * .0048 + b.userData.col * .52 + b.userData.row * .76;
+ b.position.x = b.userData.homeX + Math.sin(now * .0017 + b.userData.row) * .55;
+ b.position.y = b.userData.homeY + Math.sin(wave) * .25;
+ b.scale.y = .65 + .55 * (1 + Math.sin(wave * 1.35)) / 2;
+ b.material.opacity = .22 + .78 * (1 + Math.sin(wave - b.userData.row)) / 2;
+ });
+ camera.position.x = Math.sin(now * .0021) * .7;
+ camera.position.y = Math.cos(now * .0016) * .38;
+ camera.rotation.z = Math.sin(now * .00135) * .022;
+ } else {
+ bricks.forEach(function (b) {
+ b.position.x = b.userData.homeX; b.position.y = b.userData.homeY; b.scale.y = 1;
+ });
+ camera.position.x = 0; camera.position.y = 0; camera.rotation.z = 0;
+ }
+ renderer.render(scene, camera); if (!reduced) raf = requestAnimationFrame(frame);
+ }
+ function hit() {
+ var row = nextRow++ % rows;
+ bricks.forEach(function (b) { if (b.userData.row === row) b.material.opacity = .08; });
+ window.setTimeout(function () { if (!dead) bricks.forEach(function (b) { if (b.userData.row === row) b.material.opacity = 1; }); }, 480);
+ }
+ function wild(on) {
+ isWild = on && !reduced;
+ if (isWild) {
+ while (balls.length < maxBalls) addBall(balls.length);
+ balls.forEach(function (ball) { if (ball.parent !== scene) scene.add(ball); });
+ activeBalls = maxBalls;
+ } else {
+ activeBalls = 1;
+ balls.forEach(function (ball, index) { if (index && ball.parent === scene) scene.remove(ball); });
+ bricks.forEach(function (b) { b.material.opacity = 1; });
+ }
+ }
+ function destroy() {
+ dead = true; window.removeEventListener('resize', resize); if (raf) cancelAnimationFrame(raf);
+ bricks.forEach(function (b) { b.material.dispose(); });
+ brickGeo.dispose(); paddle.geometry.dispose(); paddle.material.dispose(); ballGeo.dispose(); ballMaterial.dispose();
+ renderer.dispose(); if (window._snonuxSplashWebGLCleanup === destroy) window._snonuxSplashWebGLCleanup = null;
+ }
+ resize(); window.addEventListener('resize', resize); frame(last);
+ return {wild: wild, hit: hit, destroy: destroy};
+ }
+
+ if (!document.documentElement.classList.contains('sno-splash-skip')) {
+ var splashScene = buildScene(document.getElementById('splash-gl-canvas'), true);
+ window._snonuxSplashWebGLCleanup = splashScene.destroy;
+ }
+ var game = buildScene(document.getElementById('three-canvas'), false), wild = false;
+ function flash(color, vertical) {
+ var el = document.createElement('div');
+ el.style.cssText = 'position:fixed;z-index:9000;pointer-events:none;background:' + color + ';opacity:.72;transition:transform .2s ease,opacity .28s ease;' + (vertical ? 'top:0;bottom:0;width:5px;left:50%;transform:scaleY(0)' : 'left:0;right:0;height:7px;top:50%;transform:scaleX(0)');
+ document.body.appendChild(el); requestAnimationFrame(function () { el.style.transform = vertical ? 'scaleY(1)' : 'scaleX(1)'; });
+ setTimeout(function () { el.style.opacity = '0'; }, 180); setTimeout(function () { el.remove(); }, 500);
+ }
+ window.snonuxOpenEffect = function () { game.hit(); flash('#d5aa58', false); };
+ window.snonuxCloseEffect = function () { flash('#657eae', true); };
+ window.snonuxNavEffect = function () { game.hit(); flash('#559ca0', false); };
+ window.snonuxPageEffect = function () { game.hit(); flash('#8c6f9f', false); };
+ window.snonuxScrollEffect = function (direction) {
+ var post = document.querySelector('.post-active'); if (!post) return;
+ post.style.transform = 'translateX(' + (direction === 'down' ? '9px' : '-3px') + ')'; setTimeout(function () { post.style.transform = ''; }, 150);
+ };
+ window.snonuxWildToggle = function () {
+ wild = !wild; game.wild(wild);
+ var badge = document.getElementById('sno-wild-badge'); if (badge) badge.classList.toggle('sno-wild-on', wild);
+ document.documentElement.style.setProperty('--sno-wild-accent', wild ? '#c75c5c' : '#d5aa58');
+ };
+}());
diff --git a/internal/generator/templates/themes/invaders/MUSIC_LICENSE.txt b/internal/generator/templates/themes/invaders/MUSIC_LICENSE.txt
new file mode 100644
index 0000000..0f17db0
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/MUSIC_LICENSE.txt
@@ -0,0 +1,7 @@
+Track: Scifi City - Ambient Loop
+Author: TinyWorlds
+License: CC0 1.0 Universal / public domain dedication
+License URL: https://creativecommons.org/publicdomain/zero/1.0/
+Source: https://opengameart.org/content/scifi-city-ambient-loop
+
+Bundled as ambient.ogg for the snonux invaders theme.
diff --git a/internal/generator/templates/themes/invaders/ambient.ogg b/internal/generator/templates/themes/invaders/ambient.ogg
new file mode 100644
index 0000000..5813de3
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/ambient.ogg
Binary files differ
diff --git a/internal/generator/templates/themes/invaders/meta.json b/internal/generator/templates/themes/invaders/meta.json
new file mode 100644
index 0000000..64594f1
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/meta.json
@@ -0,0 +1,7 @@
+{
+ "title": "snonux.foo // FORMATION",
+ "header_html": "<div class=\"logo\"><span class=\"formation-mark\" aria-hidden=\"true\"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></span><div class=\"logo-title\"><h1>snonux.foo</h1><p class=\"subtitle\">COMMAND TRANSMISSIONS · <a href=\"https://foo.zone\">FOO.ZONE</a> IS THE REAL BLOG</p><p class=\"logo-host\">Served by NetBSD on a Raspberry Pi 3</p></div></div><div class=\"nav\"><a href=\"atom.xml\" class=\"header-feed-link\" rel=\"alternate\" title=\"Atom feed\" type=\"application/atom+xml\">SIGNAL FEED</a><a href=\"https://foo.zone/about\" class=\"transmit-btn\">OPEN CHANNEL</a></div>",
+ "splash_inner_html": "<canvas class=\"splash-gl-canvas\" id=\"splash-gl-canvas\" aria-hidden=\"true\"></canvas><div class=\"splash-inner\"><div class=\"descent\" aria-hidden=\"true\"><b></b><b></b><b></b><b></b><b></b><b></b><b></b><b></b><b></b></div><div class=\"splash-kicker\">FORMATION DETECTED</div><div class=\"splash-title\">SNONUX</div><div class=\"splash-tag\">Command channel ready</div><div class=\"splash-hint\">PRESS ENTER OR CLICK TO RESPOND</div></div>",
+ "prev_page_text": "◀ NEWER SIGNALS",
+ "next_page_text": "OLDER SIGNALS ▶"
+}
diff --git a/internal/generator/templates/themes/invaders/sounds.json b/internal/generator/templates/themes/invaders/sounds.json
new file mode 100644
index 0000000..3e608e5
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/sounds.json
@@ -0,0 +1,11 @@
+{
+ "splash": {"freqs":[164.81,196,246.94,329.63],"spacing":0.09,"gain":0.075,"wave":"square"},
+ "nav": {"freq":659.25,"wave":"square","dur":0.045,"gain":0.05},
+ "open": {"wave":"square","start":180,"end":720,"dur":0.1,"gain":0.065},
+ "close": {"wave":"triangle","start":520,"end":130,"dur":0.11,"gain":0.06},
+ "bounce": {"wave":"square","start":110,"end":70,"dur":0.07,"gain":0.06},
+ "ambient": {
+ "normal": {"bpm":112,"gain":0.025,"wave":"square","droneFreqs":[55,110],"pulseFreqs":[220,277.18,329.63],"cutoffMin":700,"cutoffMax":3200,"attack":0.01,"release":0.09,"melody":[{"freq":220,"dur":0.1,"step":0.27},{"freq":277.18,"dur":0.1,"step":0.27},{"freq":246.94,"dur":0.1,"step":0.27},{"freq":329.63,"dur":0.1,"step":0.27}],"drums":["kick","","","snare","kick","","","snare"],"file":"ambient.ogg","volume":0.3},
+ "wild": {"bpm":184,"gain":0.05,"wave":"square","droneFreqs":[55,110,220],"pulseFreqs":[440,554.37,659.25],"cutoffMin":1300,"cutoffMax":6500,"attack":0.005,"release":0.04,"melody":[{"freq":440,"dur":0.06,"step":0.13},{"freq":554.37,"dur":0.06,"step":0.13},{"freq":659.25,"dur":0.06,"step":0.13},{"freq":880,"dur":0.06,"step":0.13}],"drums":["kick","","snare","kick","kick","","snare","kick"],"file":"ambient.ogg","volume":0.44}
+ }
+}
diff --git a/internal/generator/templates/themes/invaders/theme.css b/internal/generator/templates/themes/invaders/theme.css
new file mode 100644
index 0000000..814d838
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/theme.css
@@ -0,0 +1 @@
+:root{--void:#030706;--panel:#09110f;--mint:#9fffd8;--white:#edfff8;--dim:#78988b;--line:#24473b;--alert:#ffcc70}*{box-sizing:border-box;margin:0;padding:0}body{height:100vh;height:100dvh;overflow:hidden;background:var(--void);color:var(--white);font-family:ui-monospace,"Cascadia Mono","Courier New",monospace}#three-canvas{position:fixed;inset:0;width:100%;height:100%;z-index:1}.overlay{position:relative;z-index:10;height:100vh;height:100dvh;display:flex;flex-direction:column}header{display:flex;align-items:center;justify-content:space-between;gap:22px;padding:15px 25px;background:rgba(3,7,6,.93);border-bottom:1px solid var(--mint)}.logo{display:flex;align-items:center;gap:16px;min-width:0}.formation-mark{width:39px;height:29px;display:grid;grid-template-columns:repeat(5,7px);grid-template-rows:repeat(4,7px);gap:1px}.formation-mark i{background:var(--mint)}.formation-mark i:nth-child(1){grid-column:2}.formation-mark i:nth-child(2){grid-column:4}.formation-mark i:nth-child(3){grid-column:1/6}.formation-mark i:nth-child(4){grid-column:2/5}.formation-mark i:nth-child(5){grid-column:1}.formation-mark i:nth-child(6){grid-column:3}.formation-mark i:nth-child(7){grid-column:5}.logo-title h1{font-size:clamp(1.2rem,2.5vw,1.8rem);letter-spacing:.13em}.subtitle{margin-top:5px;color:var(--dim);font-size:.65rem;letter-spacing:.08em}.subtitle a,.post-text a{color:var(--mint)}.nav{display:flex;align-items:center;gap:18px}.header-feed-link{color:var(--dim);font-size:.7rem}.transmit-btn,.page-nav a{padding:9px 15px;border:1px solid var(--mint);color:var(--mint);background:#07100d;text-decoration:none;font-size:.72rem;letter-spacing:.08em}.transmit-btn:hover,.page-nav a:hover{color:var(--void);background:var(--mint)}.nav-hints{color:var(--dim);background:rgba(3,7,6,.92);border-bottom:1px solid var(--line);font-size:.65rem}.nav-hints kbd{color:var(--mint);background:var(--panel);border:1px solid var(--line);border-radius:0}.nav-fx-button:hover,.nav-fx-button[aria-pressed=true]{color:var(--white);background:#10221c}.content{flex:1;min-height:0;overflow-y:auto;padding:22px 25px;scrollbar-color:var(--mint) var(--void);scrollbar-width:thin}.post{position:relative;max-width:960px;margin:0 auto 14px;padding:19px 21px;background:rgba(7,16,13,.92);border:1px solid var(--line);border-top-color:#3b6e5c;cursor:pointer;transition:transform .16s,border-color .16s,background .16s}.post::before{content:"TRANSMISSION";position:absolute;right:15px;top:-6px;padding:0 6px;background:var(--panel);color:var(--dim);font-size:.55rem;letter-spacing:.15em}.post:hover,.post-active{transform:translateX(4px);border-color:var(--mint);background:rgba(11,27,22,.96)}.post-active{box-shadow:inset 4px 0 var(--mint)}.post-header{display:flex;justify-content:space-between;gap:12px;margin-bottom:11px;color:var(--mint);font-size:.72rem;letter-spacing:.06em}.post-header strong::before{content:"◆ ";}.post-time{color:var(--dim)}.post-text{font-family:system-ui,-apple-system,sans-serif;font-size:1rem;line-height:1.65;color:#e5f4ee}.post-text a{text-underline-offset:3px}.post-image{margin-top:14px;border:1px solid var(--mint);border-radius:0}.post-audio{width:100%;margin-top:12px}.page-nav-footer{flex-shrink:0;padding:9px;background:rgba(3,7,6,.95);border-top:1px solid var(--line)}.page-nav{display:flex;justify-content:center}.post-modal{display:none;position:fixed;inset:0;z-index:100;padding:35px 18px;overflow:auto}.post-modal.active{display:block}.modal-inner{max-width:800px;margin:auto;padding:32px;background:#07100d;border:1px solid var(--mint);box-shadow:8px 8px 0 #18382d}.modal-close{float:right;padding:8px;color:var(--mint);background:transparent;border:1px solid var(--mint);cursor:pointer}a:focus-visible,button:focus-visible,.post:focus-visible{outline:3px solid var(--alert);outline-offset:4px}[data-sno-theme=invaders] .splash-overlay{background:#030706}[data-sno-theme=invaders] .splash-inner{position:relative;z-index:2;text-align:center}.descent{display:grid;grid-template-columns:repeat(3,25px);gap:10px 18px;width:max-content;margin:0 auto 25px}.descent b{width:25px;height:18px;background:var(--mint);clip-path:polygon(20% 0,40% 0,40% 20%,60% 20%,60% 0,80% 0,80% 20%,100% 20%,100% 80%,80% 80%,80% 100%,60% 100%,60% 80%,40% 80%,40% 100%,20% 100%,20% 80%,0 80%,0 20%,20% 20%)}.descent b:nth-child(n+4){transform:translateY(6px)}.descent b:nth-child(n+7){transform:translateY(12px)}.splash-kicker{color:var(--mint);font-size:.68rem;letter-spacing:.24em}.splash-title{margin:.18em 0;color:var(--white);font-size:clamp(2.7rem,10vw,6rem);font-weight:800;letter-spacing:.14em}.splash-tag{color:var(--dim);letter-spacing:.12em}.splash-hint{margin-top:1.5rem;color:var(--alert);font-size:.65rem;letter-spacing:.13em;animation:invBlink 1.2s steps(2) infinite}@keyframes invBlink{50%{opacity:.25}}@media(max-width:640px){header{padding:11px 13px}.logo-host,.subtitle,.header-feed-link{display:none}.formation-mark{transform:scale(.85)}.nav{gap:6px}.transmit-btn{padding:10px 8px;font-size:.62rem}.content{padding:13px}.post{padding:16px 14px}.post-text{font-size:.95rem}.nav-hints kbd,.nav-hints-label,.nav-hints-spacer{display:none}.nav-fx-button{min-height:44px;flex:1;justify-content:center}.modal-inner{padding:20px 14px}.modal-close{min-width:44px;min-height:44px}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}.post{transition:none}}
diff --git a/internal/generator/templates/themes/invaders/theme.js b/internal/generator/templates/themes/invaders/theme.js
new file mode 100644
index 0000000..c79975f
--- /dev/null
+++ b/internal/generator/templates/themes/invaders/theme.js
@@ -0,0 +1,230 @@
+(function () {
+ var reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var wild = false;
+ var patterns = [
+ [[1, 0], [3, 0], [0, 1], [1, 1], [2, 1], [3, 1], [4, 1], [0, 2], [2, 2], [4, 2], [1, 3], [3, 3]],
+ [[0, 0], [4, 0], [1, 1], [2, 1], [3, 1], [0, 2], [1, 2], [2, 2], [3, 2], [4, 2], [1, 3], [3, 3]]
+ ];
+
+ function unit(pattern, size, material) {
+ var group = new THREE.Group();
+ var geometry = new THREE.BoxGeometry(size, size, size * .35);
+ pattern.forEach(function (point) {
+ var mesh = new THREE.Mesh(geometry, material);
+ mesh.position.set((point[0] - 2) * size, (1.5 - point[1]) * size, 0);
+ group.add(mesh);
+ });
+ return group;
+ }
+
+ function scene(canvas, splash) {
+ if (!canvas || typeof THREE === 'undefined') return function () {};
+ var renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: false, alpha: true});
+ var world = new THREE.Scene();
+ var camera = new THREE.PerspectiveCamera(48, 1, .1, 100);
+ var formation = new THREE.Group();
+ var forms = [];
+ var projectiles = [];
+ var materials = [];
+ var raf = 0;
+ var dead = false;
+ var last = performance.now();
+ var elapsed = 0;
+ var marchClock = 0;
+ var marchX = 0;
+ var marchY = 0;
+ var direction = 1;
+ var localWild = false;
+ var rows = splash ? 3 : 5;
+ var cols = splash ? 5 : 8;
+ var spacingX = splash ? 1.45 : 2.4;
+ var spacingY = splash ? 1.15 : 1.8;
+
+ camera.position.z = splash ? 13 : 26;
+ world.add(formation);
+ world.add(new THREE.AmbientLight(0x79d9bd, .48));
+ var pressureLight = new THREE.PointLight(0xff486d, 0, 45);
+ pressureLight.position.set(0, 0, 8);
+ world.add(pressureLight);
+
+ for (var y = 0; y < rows; y++) {
+ var material = new THREE.MeshStandardMaterial({
+ color: 0x9fffd8,
+ emissive: 0x123c31,
+ emissiveIntensity: .18,
+ roughness: .7
+ });
+ materials.push(material);
+ for (var x = 0; x < cols; x++) {
+ var form = unit(patterns[y % 2], splash ? .11 : .18, material);
+ form.userData.homeX = (x - (cols - 1) / 2) * spacingX;
+ form.userData.homeY = (rows / 2 - y) * spacingY;
+ form.userData.row = y;
+ form.userData.col = x;
+ form.position.set(form.userData.homeX, form.userData.homeY, 0);
+ forms.push(form);
+ formation.add(form);
+ }
+ }
+
+ /* A fixed pool makes repeated wild-mode toggles allocation-free. */
+ if (!splash) {
+ var shotGeometry = new THREE.BoxGeometry(.075, .7, .075);
+ var shotMaterial = new THREE.MeshBasicMaterial({color: 0xffd477});
+ materials.push(shotMaterial);
+ for (var p = 0; p < 18; p++) {
+ var shot = new THREE.Mesh(shotGeometry, shotMaterial);
+ shot.userData.phase = p / 18;
+ shot.visible = false;
+ projectiles.push(shot);
+ world.add(shot);
+ }
+ }
+
+ function resize() {
+ var width = canvas.clientWidth || 2;
+ var height = canvas.clientHeight || 2;
+ renderer.setSize(width, height, false);
+ camera.aspect = width / height;
+ camera.updateProjectionMatrix();
+ }
+
+ function draw(now) {
+ if (dead) return;
+ var dt = Math.min((now - last) / 1000, .05);
+ last = now;
+ elapsed += dt;
+
+ if (localWild && !reduced) {
+ marchClock += dt;
+ if (marchClock >= .11) {
+ marchClock %= .11;
+ marchX += direction * .38;
+ if (Math.abs(marchX) > 2.2) {
+ direction *= -1;
+ marchX += direction * .38;
+ marchY -= .32;
+ if (marchY < -2.2) marchY = 1.25;
+ }
+ }
+ formation.position.x = marchX;
+ formation.position.y = marchY + Math.sin(elapsed * 3.8) * .16;
+ var scale = 1 + Math.sin(elapsed * 2.7) * .13;
+ formation.scale.set(scale, scale, scale);
+ formation.rotation.z = Math.sin(elapsed * 1.9) * .035;
+ camera.position.x = Math.sin(elapsed * 1.35) * 1.5;
+ camera.position.y = Math.cos(elapsed * 1.7) * .7;
+ camera.position.z = 24 + Math.sin(elapsed * 1.1) * 2.2;
+ camera.lookAt(0, 0, 0);
+ pressureLight.intensity = 1.2 + Math.sin(elapsed * 8) * .65;
+
+ forms.forEach(function (form) {
+ var row = form.userData.row;
+ var col = form.userData.col;
+ form.position.x = form.userData.homeX + Math.sin(elapsed * 5 + row * .8) * .28;
+ form.position.y = form.userData.homeY + Math.sin(elapsed * 6 + col * .58) * .23;
+ form.position.z = Math.sin(elapsed * 4.2 + col * .72 - row) * 2.25;
+ form.rotation.y = Math.sin(elapsed * 3.4 + col * .5) * .42;
+ });
+ materials.slice(0, rows).forEach(function (material, index) {
+ var pressure = (Math.sin(elapsed * 7 + index) + 1) / 2;
+ material.color.setHSL(.43 - pressure * .42, .9, .63);
+ material.emissive.setHSL(.43 - pressure * .4, 1, .18);
+ material.emissiveIntensity = .5 + pressure * 1.4;
+ });
+ projectiles.forEach(function (shot) {
+ var travel = (elapsed * 1.75 + shot.userData.phase) % 1;
+ var source = forms[Math.floor(shot.userData.phase * forms.length)];
+ shot.visible = true;
+ shot.position.x = source.position.x + formation.position.x;
+ shot.position.y = 8 - travel * 18;
+ shot.position.z = source.position.z + Math.sin(elapsed * 9 + shot.userData.phase * 20) * .7;
+ shot.scale.y = 1 + travel * 2.2;
+ });
+ } else {
+ formation.position.x += (0 - formation.position.x) * Math.min(1, dt * 3);
+ formation.position.y += (0 - formation.position.y) * Math.min(1, dt * 3);
+ formation.scale.set(1, 1, 1);
+ formation.rotation.z = 0;
+ camera.position.x = 0;
+ camera.position.y = 0;
+ camera.position.z = splash ? 13 : 26;
+ camera.lookAt(0, 0, 0);
+ pressureLight.intensity = 0;
+ forms.forEach(function (form) {
+ form.position.x = form.userData.homeX;
+ form.position.y = form.userData.homeY + (reduced ? 0 : Math.sin(elapsed * .7 + form.userData.col) * .035);
+ form.position.z = 0;
+ form.rotation.y = 0;
+ });
+ materials.slice(0, rows).forEach(function (material) {
+ material.color.setHex(0x9fffd8);
+ material.emissive.setHex(0x123c31);
+ material.emissiveIntensity = .18;
+ });
+ projectiles.forEach(function (shot) { shot.visible = false; });
+ }
+ renderer.render(world, camera);
+ if (!reduced) raf = requestAnimationFrame(draw);
+ }
+
+ function cleanup() {
+ dead = true;
+ window.removeEventListener('resize', resize);
+ if (raf) cancelAnimationFrame(raf);
+ world.traverse(function (object) {
+ if (object.geometry) object.geometry.dispose();
+ });
+ materials.forEach(function (material) { material.dispose(); });
+ renderer.dispose();
+ if (window._snonuxSplashWebGLCleanup === cleanup) window._snonuxSplashWebGLCleanup = null;
+ }
+
+ resize();
+ window.addEventListener('resize', resize);
+ draw(last);
+ return function (state) {
+ if (typeof state === 'boolean') {
+ localWild = state && !splash;
+ if (reduced) draw(performance.now());
+ } else {
+ cleanup();
+ }
+ };
+ }
+
+ if (!document.documentElement.classList.contains('sno-splash-skip')) {