diff options
| author | Paul Buetow <paul@buetow.org> | 2026-07-05 10:40:25 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-07-05 10:40:25 +0300 |
| commit | f61490a3911479cf064063077751447127e14654 (patch) | |
| tree | 21e19da6d90225adc816ac63752857fa1c6c8003 | |
| parent | 9c4c280d829f4fa91e7bb548410caa9c2eab2f8c (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.go | 234 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.css | 15 | ||||
| -rw-r--r-- | internal/generator/templates/shared/shared.js | 72 |
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'); |
