summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-05 10:40:25 +0300
committerPaul Buetow <paul@buetow.org>2026-07-05 10:40:25 +0300
commitf61490a3911479cf064063077751447127e14654 (patch)
tree21e19da6d90225adc816ac63752857fa1c6c8003
parent9c4c280d829f4fa91e7bb548410caa9c2eab2f8c (diff)
Add splash screen choice to enter with or without background music (js0)
Injects an explicit "With music" / "Without music" choice into the splash overlay at runtime (uniformly across all themes) instead of only letting visitors discover the 'p' ambient shortcut after they're already inside. Clicking a button saves the preference, starts/pauses the ambient engine, and dismisses the splash. Re-injected after the two spots that replace #splash-overlay's innerHTML wholesale (theme switch / theme-meta swap) so the buttons survive. Also fixes a keyboard-focus bug this introduced: the splash's document-level Enter/Space handler was swallowing native button activation for a Tab-focused choice button. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--integrationtests/integration_test.go234
-rw-r--r--internal/generator/templates/shared/shared.css15
-rw-r--r--internal/generator/templates/shared/shared.js72
3 files changed, 319 insertions, 2 deletions
diff --git a/integrationtests/integration_test.go b/integrationtests/integration_test.go
index 6a4d437..52c4081 100644
--- a/integrationtests/integration_test.go
+++ b/integrationtests/integration_test.go
@@ -396,6 +396,240 @@ func TestKeyboardNavJS(t *testing.T) {
assertContains(t, index, "<kbd>f</kbd><span class=\"sno-btn-text\">flash</span>", "index.html nav hint f=flash")
}
+// TestSplashMusicChoiceMarkup verifies (task js0) that the generated shared.js
+// injects an explicit "with music" / "without music" choice into the splash
+// overlay, and that shared.css styles it. The buttons are added at runtime
+// (identically for every theme) rather than baked into each theme's
+// splash_inner_html, so this asserts on the generated JS/CSS rather than on
+// index.html markup.
+func TestSplashMusicChoiceMarkup(t *testing.T) {
+ inputDir, outputDir := makeDirs(t)
+
+ if err := os.WriteFile(filepath.Join(inputDir, "splash-music.txt"), []byte("splash music choice test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ runPipeline(t, inputDir, outputDir)
+
+ sharedJS := readFile(t, filepath.Join(outputDir, "shared.js"))
+ assertContains(t, sharedJS, "function snonuxRenderSplashMusicChoice()", "shared.js defines the splash music choice injector")
+ assertContains(t, sharedJS, "splash-music-choice", "shared.js references the splash-music-choice container class")
+ assertContains(t, sharedJS, `data-sno-music="on"`, "shared.js with-music button markup")
+ assertContains(t, sharedJS, `data-sno-music="off"`, "shared.js without-music button markup")
+ assertContains(t, sharedJS, "snonuxAmbientSavePreference(withMusic)", "shared.js saves the chosen preference")
+ // Re-injected after both places that replace #splash-overlay's innerHTML
+ // wholesale (theme switch at runtime, and theme-meta application on load
+ // when a visitor's saved theme differs from the page's baked-in default),
+ // otherwise those choice buttons would silently disappear again. Assert
+ // the exact count (not just presence) so a regression that drops one of
+ // the two call sites doesn't hide behind the other still matching.
+ reinjectLine := "if (typeof snonuxRenderSplashMusicChoice === 'function') snonuxRenderSplashMusicChoice();"
+ if got := strings.Count(sharedJS, reinjectLine); got != 2 {
+ t.Errorf("shared.js re-injects splash music choice at %d call sites; want 2 (snonuxSwitchTheme + snonuxApplyThemeMeta)", got)
+ }
+
+ sharedCSS := readFile(t, filepath.Join(outputDir, "shared.css"))
+ assertContains(t, sharedCSS, ".splash-music-choice", "shared.css styles the splash music choice container")
+ assertContains(t, sharedCSS, ".splash-music-btn", "shared.css styles the splash music choice buttons")
+}
+
+// TestSplashMusicChoiceBrowser drives a real headless browser to verify (task
+// js0) that clicking the splash's "without music" button dismisses the splash,
+// persists the decline, and actually leaves the ambient engine stopped; that
+// "with music" persists acceptance and actually starts the ambient engine;
+// and that the choice buttons survive (and keep working after) something
+// wiping #splash-overlay's innerHTML wholesale — the exact hazard
+// snonuxSwitchTheme() and snonuxApplyThemeMeta() pose, since both replace it
+// on the fly when a visitor's saved theme differs from the page default.
+func TestSplashMusicChoiceBrowser(t *testing.T) {
+ chromium, ok := findChromium()
+ if !ok {
+ t.Skip("Chromium executable not found; skipping browser splash-music-choice test")
+ }
+
+ inputDir, outputDir := makeDirs(t)
+ if err := os.WriteFile(filepath.Join(inputDir, "splash-music-browser.txt"), []byte("splash music choice browser test"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ runPipeline(t, inputDir, outputDir)
+ writeSplashMusicChoiceBrowserHarness(t, outputDir)
+
+ testHTML := filepath.Join(outputDir, "splash-music-choice-test.html")
+ pageURL := url.URL{Scheme: "file", Path: testHTML}
+ out, err := exec.Command(
+ chromium,
+ "--headless",
+ "--disable-gpu",
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-background-networking",
+ "--allow-file-access-from-files",
+ "--window-size=900,700",
+ "--virtual-time-budget=4000",
+ "--dump-dom",
+ pageURL.String(),
+ ).CombinedOutput()
+ if err != nil {
+ t.Fatalf("run headless Chromium: %v\n%s", err, string(out))
+ }
+
+ dom := string(out)
+ if !strings.Contains(dom, `data-splash-music-test="pass"`) {
+ t.Fatalf("splash music choice behavior failed; DOM dump tail:\n%s", dom[max(0, len(dom)-3000):])
+ }
+}
+
+// writeSplashMusicChoiceBrowserHarness clones index.html (stripping CDN
+// assets unreachable from a file:// context, same as the scroll-selection
+// harness) and appends a script that exercises both splash music choice
+// buttons in sequence, recording pass/fail on document.body.
+func writeSplashMusicChoiceBrowserHarness(t *testing.T, outputDir string) {
+ t.Helper()
+
+ indexPath := filepath.Join(outputDir, "index.html")
+ html := readFile(t, indexPath)
+ html = strings.ReplaceAll(html, `<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>`, "")
+ html = strings.ReplaceAll(html, `<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">`, "")
+ html = strings.Replace(html,
+ `<script src="shared.js" defer></script>`,
+ `<script src="shared.js" defer></script>`+"\n"+
+ ` <script src="splash-music-choice-test.js" defer></script>`,
+ 1,
+ )
+ if err := os.WriteFile(filepath.Join(outputDir, "splash-music-choice-test.html"), []byte(html), 0o644); err != nil {
+ t.Fatalf("write splash-music-choice-test.html: %v", err)
+ }
+
+ harness := `
+(function () {
+ function finish(status, detail) {
+ document.body.setAttribute('data-splash-music-test', status);
+ var pre = document.createElement('pre');
+ pre.id = 'splash-music-test-result';
+ pre.textContent = detail;
+ document.body.appendChild(pre);
+ }
+
+ function ambientPref() {
+ try { return localStorage.getItem('snonuxAmbientEnabled'); } catch (_) { return null; }
+ }
+
+ function ambientPlaying() {
+ return !!(window.snonuxAmbientIsPlaying && window.snonuxAmbientIsPlaying());
+ }
+
+ function findButtons(overlay) {
+ return {
+ on: overlay.querySelector('.splash-music-btn[data-sno-music="on"]'),
+ off: overlay.querySelector('.splash-music-btn[data-sno-music="off"]')
+ };
+ }
+
+ function run() {
+ var overlay = document.getElementById('splash-overlay');
+ var btns = overlay && findButtons(overlay);
+ if (!overlay || !btns.on || !btns.off) {
+ finish('fail', 'missing splash overlay or music-choice buttons');
+ return;
+ }
+
+ // Negative path: decline music. Should dismiss the splash, persist
+ // '0', and actually leave the ambient engine stopped (not just the
+ // localStorage side effect).
+ try { localStorage.removeItem('snonuxAmbientEnabled'); } catch (_) {}
+ btns.off.click();
+ var declinedDismissed = overlay.classList.contains('splash--dismissed');
+ var declinedPref = ambientPref();
+ var declinedPlaying = ambientPlaying();
+
+ // Re-open the splash (buttons must still be attached and working)
+ // so the positive path starts from a clean slate.
+ if (window._snonuxShowSplash) window._snonuxShowSplash();
+ var reopened = !overlay.classList.contains('splash--dismissed');
+
+ // Positive path: accept music. Should dismiss the splash, persist
+ // '1', and actually start the ambient engine.
+ btns.on.click();
+ var acceptedDismissed = overlay.classList.contains('splash--dismissed');
+ var acceptedPref = ambientPref();
+ var acceptedPlaying = ambientPlaying();
+
+ // Simulate the hazard the re-injection fix guards against:
+ // snonuxSwitchTheme()/snonuxApplyThemeMeta() replace #splash-overlay's
+ // entire innerHTML with a fresh theme's markup on the fly, which wipes
+ // out anything appended to it — including these choice buttons. Drop
+ // just the choice element (same end state as that wholesale replace)
+ // and confirm the exported re-injector rebuilds a single, live copy.
+ var inner = overlay.querySelector('.splash-inner');
+ var choiceBeforeWipe = inner && inner.querySelector('.splash-music-choice');
+ if (choiceBeforeWipe) choiceBeforeWipe.remove();
+ var wiped = !overlay.querySelector('.splash-music-choice');
+ if (window.snonuxRenderSplashMusicChoice) window.snonuxRenderSplashMusicChoice();
+ var rebuilt = overlay.querySelectorAll('.splash-music-choice').length === 1;
+
+ // The rebuilt buttons must be freshly wired, not stale references —
+ // re-fetch them and exercise the decline path once more.
+ try { localStorage.removeItem('snonuxAmbientEnabled'); } catch (_) {}
+ if (window._snonuxShowSplash) window._snonuxShowSplash();
+ var rebuiltBtns = findButtons(overlay);
+ var rebuiltBtnsFound = !!(rebuiltBtns.on && rebuiltBtns.off);
+ if (rebuiltBtnsFound) rebuiltBtns.off.click();
+ var rebuiltDismissed = overlay.classList.contains('splash--dismissed');
+ var rebuiltPref = ambientPref();
+ // The prior scenario left the engine playing (acceptedPlaying), so this
+ // decline must flip it back off too — not just the localStorage side effect.
+ var rebuiltPlaying = ambientPlaying();
+
+ // Regression check for the keyboard-focus fix: the document-level
+ // splash keydown handler used to unconditionally preventDefault()
+ // and dismiss on Enter/Space whenever the splash was open, which
+ // swallows the native "activate the focused button" default action
+ // a keyboard-only visitor relies on to actually press one of these
+ // buttons. A trusted, browser-synthesized key press can't be faked
+ // from page script, but we can verify the property our fix controls
+ // directly: with a music button focused, our handler must leave the
+ // event un-prevented and must not itself dismiss the splash, so the
+ // browser is free to run its native button-activation default action.
+ if (window._snonuxShowSplash) window._snonuxShowSplash();
+ rebuiltBtns.off.focus();
+ var focusedButtonIsActive = document.activeElement === rebuiltBtns.off;
+ var enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
+ rebuiltBtns.off.dispatchEvent(enterEvent);
+ var keydownPreventedByUs = enterEvent.defaultPrevented;
+ var dismissedByGenericKeydown = overlay.classList.contains('splash--dismissed');
+
+ var detail = 'declinedDismissed=' + declinedDismissed + ' declinedPref=' + declinedPref +
+ ' declinedPlaying=' + declinedPlaying +
+ ' reopened=' + reopened +
+ ' acceptedDismissed=' + acceptedDismissed + ' acceptedPref=' + acceptedPref +
+ ' acceptedPlaying=' + acceptedPlaying +
+ ' wiped=' + wiped + ' rebuilt=' + rebuilt +
+ ' rebuiltBtnsFound=' + rebuiltBtnsFound +
+ ' rebuiltDismissed=' + rebuiltDismissed + ' rebuiltPref=' + rebuiltPref +
+ ' rebuiltPlaying=' + rebuiltPlaying +
+ ' focusedButtonIsActive=' + focusedButtonIsActive +
+ ' keydownPreventedByUs=' + keydownPreventedByUs +
+ ' dismissedByGenericKeydown=' + dismissedByGenericKeydown;
+
+ var pass = declinedDismissed && declinedPref === '0' && !declinedPlaying &&
+ reopened &&
+ acceptedDismissed && acceptedPref === '1' && acceptedPlaying &&
+ wiped && rebuilt &&
+ rebuiltBtnsFound && rebuiltDismissed && rebuiltPref === '0' && !rebuiltPlaying &&
+ focusedButtonIsActive && !keydownPreventedByUs && !dismissedByGenericKeydown;
+ finish(pass ? 'pass' : 'fail', detail);
+ }
+
+ if (document.readyState === 'complete') run();
+ else window.addEventListener('load', run);
+})();
+`
+ if err := os.WriteFile(filepath.Join(outputDir, "splash-music-choice-test.js"), []byte(harness), 0o644); err != nil {
+ t.Fatalf("write splash-music-choice-test.js: %v", err)
+ }
+}
+
// TestScrollDrivenPostSelection verifies the generated page behavior in a real
// browser: scrolling the post container moves .post-active to the article
// nearest the container center.
diff --git a/internal/generator/templates/shared/shared.css b/internal/generator/templates/shared/shared.css
index 4af2d94..e6f6520 100644
--- a/internal/generator/templates/shared/shared.css
+++ b/internal/generator/templates/shared/shared.css
@@ -822,6 +822,21 @@ a.header-feed-link:hover { opacity:1; text-decoration:underline; }
.splash-controls kbd { display:inline-block; background:rgba(255,255,255,0.08);
border:1px solid rgba(255,255,255,0.2); border-radius:3px; padding:0 4px;
font-family:monospace; font-size:0.62rem; margin:0 1px; }
+/* Splash music choice (task js0): explicit "with"/"without music" buttons so
+ visitors decide before ambient audio ever starts, instead of only finding
+ the 'p' shortcut afterward. Injected by shared.js into every theme's
+ .splash-inner, so kept theme-neutral here (ghost/outline style over the
+ frosted splash panel background). */
+.splash-music-choice { margin-top:0.85rem; display:flex; gap:0.6rem; justify-content:center; flex-wrap:wrap; }
+.splash-music-btn {
+ appearance:none; border:1px solid rgba(255,255,255,0.35); border-radius:999px;
+ background:rgba(255,255,255,0.06); color:inherit; font:inherit; font-size:0.72rem;
+ letter-spacing:0.04em; padding:0.45rem 0.9rem; margin:0; cursor:pointer;
+ display:inline-flex; align-items:center; gap:0.4rem; -webkit-tap-highlight-color:transparent;
+ transition:background 0.18s ease, transform 0.18s ease, border-color 0.18s ease; }
+.splash-music-btn:hover { background:rgba(255,255,255,0.16); border-color:rgba(255,255,255,0.6); }
+.splash-music-btn:active { transform:scale(0.96); }
+.splash-music-btn:focus-visible { outline:1px solid currentColor; outline-offset:3px; }
#splash-overlay.splash-brutalist .splash-inner.splash-frame {
padding: clamp(1.4rem, 4.5vw, 2.25rem) clamp(1.1rem, 3.5vw, 1.9rem); background: rgba(0, 0, 0, 0.78); }
html.sno-splash-skip #splash-overlay { display:none !important; visibility:hidden !important; pointer-events:none !important; }
diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js
index 3f90d4a..5b55d01 100644
--- a/internal/generator/templates/shared/shared.js
+++ b/internal/generator/templates/shared/shared.js
@@ -216,7 +216,13 @@
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;
+ // Replacing splash_inner_html wipes out the music-choice buttons
+ // injected earlier (snonuxRenderSplashMusicChoice runs once on
+ // load), so re-inject them against the freshly-swapped markup.
+ if (splashOverlay && m.splash_inner_html) {
+ splashOverlay.innerHTML = m.splash_inner_html;
+ if (typeof snonuxRenderSplashMusicChoice === 'function') snonuxRenderSplashMusicChoice();
+ }
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');
@@ -1991,6 +1997,52 @@
hint.appendChild(extra);
})();
+ // Splash music choice (task js0): give visitors an explicit "with music" /
+ // "without music" choice on the splash screen itself, instead of only
+ // discovering the 'p' ambient shortcut after they are already inside.
+ // Purely additive -- clicking elsewhere on the splash, or Enter/Space/Escape,
+ // still dismisses it without changing whichever ambient preference (if any)
+ // was already restored from localStorage on this load.
+ //
+ // A named function declaration (not an IIFE) so it is hoisted and can be
+ // re-invoked wherever #splash-overlay's innerHTML gets replaced wholesale
+ // -- snonuxSwitchTheme() and snonuxApplyThemeMeta() both do this when the
+ // visitor's saved theme differs from the one baked into this page, which
+ // would otherwise silently wipe the choice buttons out again.
+ function snonuxRenderSplashMusicChoice() {
+ var overlay = document.getElementById('splash-overlay');
+ var inner = overlay && overlay.querySelector('.splash-inner');
+ // Nothing to attach to (unexpected theme markup), or already injected.
+ if (!inner || inner.querySelector('.splash-music-choice')) return;
+ var choice = document.createElement('div');
+ choice.className = 'splash-music-choice';
+ choice.innerHTML =
+ '<button type="button" class="splash-music-btn" data-sno-music="on" aria-label="Enter with background music">' +
+ '<i class="fas fa-music" aria-hidden="true"></i> With music</button>' +
+ '<button type="button" class="splash-music-btn" data-sno-music="off" aria-label="Enter without background music">' +
+ '<i class="fas fa-volume-mute" aria-hidden="true"></i> Without music</button>';
+ inner.appendChild(choice);
+ choice.addEventListener('click', function(e) {
+ var btn = e.target.closest('.splash-music-btn');
+ if (!btn) return;
+ // Stop the click from also bubbling to the overlay's own
+ // click-to-dismiss handler: we want our preference set first,
+ // then dismiss explicitly ourselves, as a single clear path.
+ e.stopPropagation();
+ var withMusic = btn.getAttribute('data-sno-music') === 'on';
+ snonuxAmbientSavePreference(withMusic);
+ if (withMusic) {
+ if (window.snonuxAmbientStart) window.snonuxAmbientStart('splash-choice');
+ } else if (window.snonuxAmbientPause) {
+ window.snonuxAmbientPause('splash-choice');
+ }
+ if (window._snonuxPlaySplashChime) window._snonuxPlaySplashChime();
+ if (window._snonuxDismissSplash) window._snonuxDismissSplash();
+ syncFxButtonStates();
+ });
+ }
+ snonuxRenderSplashMusicChoice();
+
function openPostAt(index, scrollIntoView) {
if (posts.length === 0) return;
setActiveHighlight(index, false, !!scrollIntoView);
@@ -2090,6 +2142,15 @@
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
var splash = document.getElementById('splash-overlay');
if (splash && !splash.classList.contains('splash--dismissed')) {
+ // A Tab-focused .splash-music-btn must keep native Enter/Space
+ // button activation (which fires the click handler that sets the
+ // chosen preference) instead of falling into the generic
+ // dismiss-without-choice branch below, which would silently
+ // strand keyboard-only visitors: they'd never be able to
+ // actually pick "with" or "without" music via the keyboard.
+ if ((e.key === 'Enter' || e.key === ' ') && e.target.closest && e.target.closest('.splash-music-btn')) {
+ return;
+ }
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (window._snonuxPlaySplashChime) window._snonuxPlaySplashChime();
@@ -2304,7 +2365,14 @@
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;
+ // Same re-injection concern as snonuxSwitchTheme(): this runs on
+ // initial load when the visitor's saved theme differs from the
+ // one baked into the page, so the choice buttons must be rebuilt
+ // against the swapped-in splash markup.
+ if (splashOverlay && m.splash_inner_html) {
+ splashOverlay.innerHTML = m.splash_inner_html;
+ if (typeof snonuxRenderSplashMusicChoice === 'function') snonuxRenderSplashMusicChoice();
+ }
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');