summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-27 08:06:44 +0300
committerPaul Buetow <paul@buetow.org>2026-04-27 08:06:44 +0300
commit371c54cb5ad3793cf4b61e7451b0710d317021d6 (patch)
treec8ecc544ef01ee864cfdfb870ba790d6c87e112b /internal
parenteb6e4851110fc6a9ae336793fd87a2ba6fd48a5b (diff)
Add blank mode toggle (b key) and theme-hot-swap + Nukem sounds rework
- New blank mode hides all UI except the WebGL canvas; toggled via the ‘blank’ nav button or the b key. - Theme switching is now in-place (no page reload), avoiding the Web Audio context reset that used to kill ambient music. - Nukem ambient/riff rebuilt around the classic Grabbag riff (E5 E5 G5 A5 …).
Diffstat (limited to 'internal')
-rw-r--r--internal/generator/templates/shared/nav.tmpl1
-rw-r--r--internal/generator/templates/shared/shared.css21
-rw-r--r--internal/generator/templates/shared/shared.js133
-rw-r--r--internal/generator/theme_sounds.go32
4 files changed, 162 insertions, 25 deletions
diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl
index 4f620a5..b04b160 100644
--- a/internal/generator/templates/shared/nav.tmpl
+++ b/internal/generator/templates/shared/nav.tmpl
@@ -58,6 +58,7 @@
<button type="button" class="nav-fx-button" data-sno-fx="wild" aria-pressed="false" aria-label="Toggle wild mode"><kbd>w</kbd> wild</button>
<button type="button" class="nav-fx-button" data-sno-fx="crt" aria-pressed="false" aria-label="Toggle CRT effect"><kbd>c</kbd> crt</button>
<button type="button" class="nav-fx-button" data-sno-fx="ghost" aria-pressed="false" aria-label="Toggle ghost mode"><kbd>g</kbd> ghost</button>
+ <button type="button" class="nav-fx-button" data-sno-fx="blank" aria-pressed="false" aria-label="Toggle blank mode"><kbd>b</kbd> blank</button>
<button type="button" class="nav-fx-button" data-sno-fx="ambient" aria-pressed="false" aria-label="Start or pause ambient music"><kbd>p</kbd> music</button>
<button type="button" class="nav-fx-button" data-sno-fx="flash" aria-label="Trigger flash effect"><kbd>f</kbd> flash</button>
<button type="button" class="nav-fx-button" data-sno-fx="scatter" aria-label="Trigger scatter effect"><kbd>x</kbd> scatter</button>
diff --git a/internal/generator/templates/shared/shared.css b/internal/generator/templates/shared/shared.css
index 87e981e..5fab9ac 100644
--- a/internal/generator/templates/shared/shared.css
+++ b/internal/generator/templates/shared/shared.css
@@ -850,6 +850,27 @@ body.sno-ghost-mode .post.post-active { opacity:1 !important; box-shadow:0 0 28p
body.sno-ghost-mode header,
body.sno-ghost-mode .page-nav-footer,
body.sno-ghost-mode .nav-hints { opacity:0.25 !important; transition:opacity 0.4s ease; }
+/* Blank mode — hide everything except WebGL background */
+body.sno-blank-mode .overlay { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; }
+body.sno-blank-mode #sno-crt-overlay { opacity:0 !important; }
+/* Hide nav hints, pagination footer, header, post modal and splash overlay in blank mode */
+body.sno-blank-mode .nav-hints { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; }
+body.sno-blank-mode .page-nav-footer { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; }
+body.sno-blank-mode header { opacity:0 !important; pointer-events:none !important; transition:opacity 0.3s ease; }
+/* Keep splash hidden too if it happens to still be around */
+body.sno-blank-mode .splash-overlay { opacity:0 !important; pointer-events:none !important; }
+@keyframes sno-lightning-flash {
+ 0% { opacity:0; }
+ 8% { opacity:0.9; }
+ 12% { opacity:0.2; }
+ 18% { opacity:0.85; }
+ 24% { opacity:0.1; }
+ 30% { opacity:0.7; }
+ 100% { opacity:0; }
+}
+.sno-lightning { position:fixed; inset:0; z-index:9999; pointer-events:none;
+ background:radial-gradient(ellipse at 50% 30%, rgba(255,255,255,0.95), rgba(200,220,255,0.6) 40%, transparent 70%);
+ animation:sno-lightning-flash 0.5s ease-out both; }
/* Post transition animations */
@keyframes sno-enter-from-below { 0%{transform:translateY(22px) scale(0.97);opacity:0.55} 100%{transform:translateY(0) scale(1);opacity:1} }
@keyframes sno-enter-from-above { 0%{transform:translateY(-22px) scale(0.97);opacity:0.55} 100%{transform:translateY(0) scale(1);opacity:1} }
diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js
index 70b97e0..5a287c7 100644
--- a/internal/generator/templates/shared/shared.js
+++ b/internal/generator/templates/shared/shared.js
@@ -161,13 +161,95 @@
return 'neon';
}
- // snonuxSwitchTheme persists the user's choice and reloads.
- // The shell's boot script picks it up on the next load.
+ // snonuxSwitchTheme swaps every per-theme asset in place — stylesheet,
+ // meta markup, sounds.json, splash WebGL — without a page reload. We used
+ // to call location.reload() here, but that destroys the AudioContext and
+ // browsers can't auto-resume Web Audio without a fresh user gesture, so
+ // toggling themes with 't' silently killed the music until the next page.
function snonuxSwitchTheme(theme) {
var all = (typeof window !== 'undefined' && window.SNONUX_ALL_THEMES) || [];
if (all.indexOf(theme) < 0) return;
+ if (theme === window.SNONUX_CURRENT_THEME) return;
try { localStorage.setItem('snonuxTheme', theme); } catch (_) {}
- location.reload();
+
+ // Tear down the previous theme's splash WebGL animation so we don't
+ // leak its requestAnimationFrame loop and renderer when theme.js
+ // re-runs against a fresh canvas.
+ if (typeof window._snonuxSplashWebGLCleanup === 'function') {
+ try { window._snonuxSplashWebGLCleanup(); } catch (_) {}
+ }
+
+ var prev = window.SNONUX_CURRENT_THEME;
+ window.SNONUX_CURRENT_THEME = theme;
+ document.documentElement.setAttribute('data-sno-theme', theme);
+
+ var splashOverlay = document.getElementById('splash-overlay');
+ if (splashOverlay) {
+ if (prev) splashOverlay.classList.remove('splash-' + prev);
+ splashOverlay.classList.add('splash-' + theme);
+ }
+
+ // Swap the theme stylesheet by injecting the new <link> next to the
+ // old one and removing the old once the new one has loaded — that
+ // avoids a flash-of-default-theme between unstyled and restyled.
+ var bust = (window.SNONUX_BUILD || '') + '-' + Date.now();
+ var oldLink = document.getElementById('sno-theme-css') ||
+ document.querySelector('link[rel="stylesheet"][href*="/theme.css"]');
+ var newLink = document.createElement('link');
+ newLink.rel = 'stylesheet';
+ newLink.id = 'sno-theme-css';
+ newLink.href = 'themes/' + theme + '/theme.css?b=' + encodeURIComponent(bust);
+ if (oldLink && oldLink.parentNode) {
+ oldLink.parentNode.insertBefore(newLink, oldLink.nextSibling);
+ var dropOld = function() { if (oldLink && oldLink !== newLink) oldLink.remove(); };
+ newLink.addEventListener('load', dropOld);
+ newLink.addEventListener('error', dropOld);
+ } else {
+ document.head.appendChild(newLink);
+ }
+
+ // Fetch meta + sounds with cache-bust so the browser cannot serve a
+ // stale per-theme JSON across an in-page swap.
+ fetch('themes/' + theme + '/meta.json?b=' + encodeURIComponent(bust))
+ .then(function (r) { return r.json(); })
+ .then(function (m) {
+ if (m.title) document.title = m.title;
+ var headerEl = document.querySelector('header');
+ if (headerEl && m.header_html) headerEl.innerHTML = m.header_html;
+ if (splashOverlay && m.splash_inner_html) splashOverlay.innerHTML = m.splash_inner_html;
+ var prevA = document.getElementById('sno-prev-page');
+ if (prevA && m.prev_page_text) prevA.innerHTML = m.prev_page_text;
+ var nextA = document.getElementById('sno-next-page');
+ if (nextA && m.next_page_text) nextA.innerHTML = m.next_page_text;
+ if (typeof window._snonuxRebindHeader === 'function') window._snonuxRebindHeader();
+ })
+ .catch(function () {});
+
+ fetch('themes/' + theme + '/sounds.json?b=' + encodeURIComponent(bust))
+ .then(function (r) { return r.json(); })
+ .then(function (s) {
+ window.SNONUX_SOUNDS = s;
+ SNONUX_SOUNDS = s;
+ if (window.snonuxAmbientSyncPreset) window.snonuxAmbientSyncPreset();
+ })
+ .catch(function () {});
+
+ // Refresh wild mode preset so banner/ticker/colour-wash come from the
+ // new theme if wild is currently active.
+ if (window._snoWildActive && typeof snonuxApplyWildPreset === 'function') {
+ snonuxApplyWildPreset(theme);
+ }
+
+ // Replace theme.js so the splash WebGL is rebuilt against the new
+ // theme's settings. Removing the old <script> tag is cosmetic — its
+ // side effects already ran — but it keeps the DOM tidy across many
+ // 't' presses in a session.
+ var oldScript = document.getElementById('sno-theme-js');
+ if (oldScript) oldScript.remove();
+ var newScript = document.createElement('script');
+ newScript.id = 'sno-theme-js';
+ newScript.src = 'themes/' + theme + '/theme.js?b=' + encodeURIComponent(bust);
+ document.head.appendChild(newScript);
}
function snonuxRandomTheme() {
@@ -841,9 +923,11 @@
if (!isPlaying) return;
stopAll();
currentPreset = newPreset;
+ melodyIndex = 0;
startDrones(newPreset);
startNoise(newPreset);
schedulePulse();
+ scheduleDrums(newPreset, 0);
var targetGain = 1.0;
fadeMasterTo(targetGain, 0.5);
}, 350);
@@ -857,13 +941,20 @@
snonuxAmbientPause();
return;
}
+ // stopAll resets every voice (including drums) — we then have to
+ // re-schedule each one or the new theme plays only the melody and
+ // we keep hearing whatever was cached as silence on top of it.
stopAll();
currentPreset = preset;
+ melodyIndex = 0;
startDrones(preset);
startNoise(preset);
schedulePulse();
- var targetGain = preset.gain != null ? preset.gain : 0.08;
- fadeMasterTo(targetGain, 0.5);
+ scheduleDrums(preset, 0);
+ // masterGain is the binary on/off gate (per-note volumes carry
+ // preset.gain). Targeting preset.gain here would scale the gate
+ // by ~0.03 and silence the new theme until the user toggles 'p'.
+ fadeMasterTo(1.0, 0.3);
};
window.snonuxAmbientIsPlaying = function() {
@@ -1477,6 +1568,8 @@
if (crtButton) crtButton.setAttribute('aria-pressed', document.body.classList.contains('sno-crt-on') ? 'true' : 'false');
var ghostButton = getFxButton('ghost');
if (ghostButton) ghostButton.setAttribute('aria-pressed', document.body.classList.contains('sno-ghost-mode') ? 'true' : 'false');
+ var blankButton = getFxButton('blank');
+ if (blankButton) blankButton.setAttribute('aria-pressed', document.body.classList.contains('sno-blank-mode') ? 'true' : 'false');
var ambientButton = getFxButton('ambient');
if (ambientButton) ambientButton.setAttribute('aria-pressed', (window.snonuxAmbientIsPlaying && window.snonuxAmbientIsPlaying()) ? 'true' : 'false');
}
@@ -1531,6 +1624,20 @@
syncFxButtonStates();
}
+ function snonuxLightningFlash() {
+ var d = document.createElement('div');
+ d.className = 'sno-lightning';
+ document.body.appendChild(d);
+ setTimeout(function() { d.remove(); }, 520);
+ }
+
+ function toggleBlankMode() {
+ snonuxLightningFlash();
+ document.body.classList.toggle('sno-blank-mode');
+ pulseFxButton('blank');
+ syncFxButtonStates();
+ }
+
function snonuxAmbientSavePreference(enabled) {
try { localStorage.setItem('snonuxAmbientEnabled', enabled ? '1' : '0'); } catch (_) {}
}
@@ -1560,6 +1667,7 @@
wild: function() { toggleWildMode(); },
crt: toggleCrtMode,
ghost: toggleGhostMode,
+ blank: toggleBlankMode,
ambient: toggleAmbientMode,
flash: triggerFlashEffect,
scatter: triggerScatterEffect
@@ -1671,6 +1779,9 @@
} else if (e.key === 'g' && !e.repeat) {
e.preventDefault();
toggleGhostMode();
+ } else if (e.key === 'b' && !e.repeat) {
+ e.preventDefault();
+ toggleBlankMode();
} else if (e.key === 't' && !e.repeat) {
e.preventDefault();
var pick = snonuxRandomTheme();
@@ -1746,6 +1857,9 @@
case 'g':
toggleGhostMode();
e.preventDefault(); break;
+ case 'b':
+ toggleBlankMode();
+ e.preventDefault(); break;
case 'f':
triggerFlashEffect();
e.preventDefault(); break;
@@ -1866,6 +1980,7 @@
function loadThemeJS() {
var s = document.createElement('script');
+ s.id = 'sno-theme-js';
s.src = 'themes/' + current + '/theme.js';
document.head.appendChild(s);
}
@@ -1880,12 +1995,16 @@
// its splash WebGL attaches to the final canvas.
var pending = 2;
function done() { if (--pending === 0) loadThemeJS(); }
- fetch('themes/' + current + '/meta.json')
+ // Cache-bust both fetches: per-page templated SNONUX_BUILD or fallback
+ // to a per-load timestamp. Otherwise theme-switch can serve a stale
+ // sounds.json and the user keeps hearing the previous theme's music.
+ var bust = (window.SNONUX_BUILD || '') + '-' + Date.now();
+ fetch('themes/' + current + '/meta.json?b=' + encodeURIComponent(bust))
.then(function (r) { return r.json(); })
.then(applyMeta)
.catch(function () {})
.finally(done);
- fetch('themes/' + current + '/sounds.json')
+ fetch('themes/' + current + '/sounds.json?b=' + encodeURIComponent(bust))
.then(function (r) { return r.json(); })
.then(function (s) { window.SNONUX_SOUNDS = s; SNONUX_SOUNDS = s; if (window.snonuxAmbientSyncPreset) window.snonuxAmbientSyncPreset(); })
.catch(function () {})
diff --git a/internal/generator/theme_sounds.go b/internal/generator/theme_sounds.go
index 6b638bc..20ffacc 100644
--- a/internal/generator/theme_sounds.go
+++ b/internal/generator/theme_sounds.go
@@ -1174,35 +1174,31 @@ func soundsNukem() themeSounds {
s.Close.Wave, s.Close.Start, s.Close.End, s.Close.Dur, s.Close.Gain = "square", 659.25, 164.81, 0.16, 0.1
s.Bounce.Wave, s.Bounce.Start, s.Bounce.End, s.Bounce.Dur, s.Bounce.Gain = "sawtooth", 220, 110, 0.1, 0.1
- // Duke's action-hero theme — aggressive E-minor power riffs with
- // fanfare stabs for the heroic hook. Heavy palm-muted chugging bass,
- // driving rock drums, distorted sawtooth edge.
+ // THE Grabbag riff (Lee Jackson) — E5 E5 G5 A5 | E5 E5 G5 A5 Bb5 A5 G5 E5
+ // Heavier sawtooth rendition at 140 BPM with palm-muted bass chugging.
beat := 0.429 // 140 BPM hard rock
const (
- E2, B2 = 82.41, 123.47
- E3, G3, A3 = 164.81, 196.00, 220.00
- B3 = 246.94
+ E2, E3, B3 = 82.41, 164.81, 246.94
E4, G4, A4 = 329.63, 392.00, 440.00
- B4, D5, E5 = 493.88, 587.33, 659.25
+ Bb4, B4 = 466.16, 493.88
)
+ riff := []float64{
+ E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5, // bar 1a
+ E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5, // bar 1b
+ B4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5, // bar 2 — climbs then drops
+ Bb4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5,
+ }
mel := concat(
- palmMute(E2, beat), fanfareStab(minor(E4), 2, beat),
- hook(beat, E4, 0.5, E4, 0.5, G4, 0.5, A4, 0.5,
- B4, 0.5, A4, 0.5, G4, 0.5, E4, 0.5),
- palmMute(B2, beat), fanfareStab(major(B3), 2, beat),
- hook(beat, B4, 0.5, D5, 0.5, E5, 0.5, D5, 0.5,
- B4, 0.5, A4, 0.5, G4, 0.5, A4, 0.5),
- palmMute(E2, beat), padHold(minor(E3), 4, beat),
- hook(beat, E5, 1.0, D5, 0.5, B4, 0.5,
- A4, 0.5, G4, 0.5, E4, 1.0),
+ palmMute(E2, beat), padHold([3]float64{E3, B3, E4}, 4, beat),
+ hook(beat, riff...),
)
wbeat := 0.333 // 180 BPM thrash
wmel := concat(
palmMute(E3, wbeat),
hook(wbeat, E4*2, 0.25, E4*2, 0.25, G4*2, 0.25, A4*2, 0.25,
+ E4*2, 0.25, E4*2, 0.25, G4*2, 0.25, A4*2, 0.25,
B4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25,
- B4*2, 0.25, D5*2, 0.25, E5*2, 0.25, D5*2, 0.25,
- B4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25),
+ Bb4*2, 0.25, A4*2, 0.25, G4*2, 0.25, E4*2, 0.25),
)
s.Ambient.Normal = ambientPreset{
Gain: 0.035, BPM: 140, Wave: "sawtooth",