summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-16 09:35:31 +0300
committerPaul Buetow <paul@buetow.org>2026-05-16 09:35:31 +0300
commit1b24076788868f50812468b1bf4bb0ae86214e8c (patch)
tree389fd715b5c27e0fe036f6c123b8d09e3872da1c /internal
parentfbac3d13a575a410c2ee3f49418bf4b462544b4f (diff)
Add theme background musicv0.16.0
Diffstat (limited to 'internal')
-rw-r--r--internal/generator/generator.go31
-rw-r--r--internal/generator/generator_test.go24
-rw-r--r--internal/generator/templates/embed.go3
-rw-r--r--internal/generator/templates/shared/nav.tmpl61
-rw-r--r--internal/generator/templates/shared/shared.css53
-rw-r--r--internal/generator/templates/shared/shared.js224
-rw-r--r--internal/generator/templates/themes/aurora/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/aurora/ambient.oggbin0 -> 1396111 bytes
-rw-r--r--internal/generator/templates/themes/aurora/sounds.json8
-rw-r--r--internal/generator/templates/themes/aurora/theme.css2
-rw-r--r--internal/generator/templates/themes/biomech/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/biomech/ambient.oggbin0 -> 1317802 bytes
-rw-r--r--internal/generator/templates/themes/biomech/sounds.json8
-rw-r--r--internal/generator/templates/themes/biomech/theme.css2
-rw-r--r--internal/generator/templates/themes/brutalist/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/brutalist/ambient.oggbin0 -> 1428440 bytes
-rw-r--r--internal/generator/templates/themes/brutalist/sounds.json8
-rw-r--r--internal/generator/templates/themes/brutalist/theme.css2
-rw-r--r--internal/generator/templates/themes/cathedral/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/cathedral/ambient.oggbin0 -> 1048852 bytes
-rw-r--r--internal/generator/templates/themes/cathedral/sounds.json8
-rw-r--r--internal/generator/templates/themes/cathedral/theme.css2
-rw-r--r--internal/generator/templates/themes/cosmos/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/cosmos/ambient.oggbin0 -> 2706024 bytes
-rw-r--r--internal/generator/templates/themes/cosmos/sounds.json8
-rw-r--r--internal/generator/templates/themes/cosmos/theme.css2
-rw-r--r--internal/generator/templates/themes/dos/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/dos/ambient.oggbin0 -> 1039452 bytes
-rw-r--r--internal/generator/templates/themes/dos/sounds.json8
-rw-r--r--internal/generator/templates/themes/dos/theme.css2
-rw-r--r--internal/generator/templates/themes/matrix/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/matrix/ambient.oggbin0 -> 276371 bytes
-rw-r--r--internal/generator/templates/themes/matrix/sounds.json8
-rw-r--r--internal/generator/templates/themes/matrix/theme.css2
-rw-r--r--internal/generator/templates/themes/neon/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/neon/ambient.oggbin0 -> 3565740 bytes
-rw-r--r--internal/generator/templates/themes/neon/sounds.json8
-rw-r--r--internal/generator/templates/themes/noir/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/noir/ambient.oggbin0 -> 2440360 bytes
-rw-r--r--internal/generator/templates/themes/noir/sounds.json8
-rw-r--r--internal/generator/templates/themes/noir/theme.css2
-rw-r--r--internal/generator/templates/themes/nukem/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/nukem/ambient.oggbin0 -> 691008 bytes
-rw-r--r--internal/generator/templates/themes/nukem/sounds.json8
-rw-r--r--internal/generator/templates/themes/nukem/theme.css2
-rw-r--r--internal/generator/templates/themes/ocean/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/ocean/ambient.oggbin0 -> 2313143 bytes
-rw-r--r--internal/generator/templates/themes/ocean/sounds.json8
-rw-r--r--internal/generator/templates/themes/ocean/theme.css2
-rw-r--r--internal/generator/templates/themes/plasma/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/plasma/ambient.oggbin0 -> 1949772 bytes
-rw-r--r--internal/generator/templates/themes/plasma/sounds.json8
-rw-r--r--internal/generator/templates/themes/plasma/theme.css2
-rw-r--r--internal/generator/templates/themes/retro/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/retro/ambient.oggbin0 -> 1937080 bytes
-rw-r--r--internal/generator/templates/themes/retro/sounds.json8
-rw-r--r--internal/generator/templates/themes/retro/theme.css2
-rw-r--r--internal/generator/templates/themes/retrofuture/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/retrofuture/ambient.oggbin0 -> 500879 bytes
-rw-r--r--internal/generator/templates/themes/retrofuture/sounds.json8
-rw-r--r--internal/generator/templates/themes/retrofuture/theme.css2
-rw-r--r--internal/generator/templates/themes/spaceage/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/spaceage/ambient.oggbin0 -> 477361 bytes
-rw-r--r--internal/generator/templates/themes/spaceage/sounds.json8
-rw-r--r--internal/generator/templates/themes/spaceage/theme.css2
-rw-r--r--internal/generator/templates/themes/surveillance/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/surveillance/ambient.oggbin0 -> 148158 bytes
-rw-r--r--internal/generator/templates/themes/surveillance/sounds.json8
-rw-r--r--internal/generator/templates/themes/surveillance/theme.css2
-rw-r--r--internal/generator/templates/themes/synthwave/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/synthwave/ambient.oggbin0 -> 655778 bytes
-rw-r--r--internal/generator/templates/themes/synthwave/sounds.json8
-rw-r--r--internal/generator/templates/themes/synthwave/theme.css2
-rw-r--r--internal/generator/templates/themes/terminal/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/terminal/ambient.oggbin0 -> 888400 bytes
-rw-r--r--internal/generator/templates/themes/terminal/sounds.json8
-rw-r--r--internal/generator/templates/themes/terminal/theme.css2
-rw-r--r--internal/generator/templates/themes/tropicale/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/tropicale/ambient.oggbin0 -> 363392 bytes
-rw-r--r--internal/generator/templates/themes/tropicale/sounds.json8
-rw-r--r--internal/generator/templates/themes/tropicale/theme.css2
-rw-r--r--internal/generator/templates/themes/volcano/MUSIC_LICENSE.txt7
-rw-r--r--internal/generator/templates/themes/volcano/ambient.oggbin0 -> 977074 bytes
-rw-r--r--internal/generator/templates/themes/volcano/sounds.json8
-rw-r--r--internal/generator/templates/themes/volcano/theme.css2
-rw-r--r--internal/generator/theme_sounds.go2
-rw-r--r--internal/version/version.go2
87 files changed, 655 insertions, 83 deletions
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index aae4c97..feefe18 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -15,6 +15,7 @@ import (
"codeberg.org/snonux/snonux/internal/generator/atom"
"codeberg.org/snonux/snonux/internal/generator/templates"
"codeberg.org/snonux/snonux/internal/post"
+ "codeberg.org/snonux/snonux/internal/version"
)
// pageData holds the template variables for a single HTML page.
@@ -339,7 +340,7 @@ func writeThemeAsset(dir, name string) error {
return fmt.Errorf("write %s/theme.js: %w", name, err)
}
- meta, err := templates.ThemeMeta(name)
+ meta, err := themeMetaJSON(name)
if err != nil {
return fmt.Errorf("read %s/meta.json: %w", name, err)
}
@@ -376,9 +377,37 @@ func loadThemeMeta(name string) (themeMeta, error) {
if err := json.Unmarshal(b, &m); err != nil {
return m, fmt.Errorf("parse theme meta %q: %w", name, err)
}
+ m.HeaderHTML = headerHTMLWithVersion(m.HeaderHTML)
return m, nil
}
+func themeMetaJSON(name string) ([]byte, error) {
+ m, err := loadThemeMeta(name)
+ if err != nil {
+ return nil, err
+ }
+ b, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return nil, fmt.Errorf("marshal theme meta %q: %w", name, err)
+ }
+ return b, nil
+}
+
+func headerHTMLWithVersion(header string) string {
+ if strings.Contains(header, "sno-version") {
+ return header
+ }
+ versionHTML := ` <span class="sno-version-sep" aria-hidden="true">&middot;</span> <span class="sno-version">snonux v` + template.HTMLEscapeString(version.Version) + `</span>`
+ hostClass := `class="logo-host"`
+ if idx := strings.Index(header, hostClass); idx >= 0 {
+ if end := strings.Index(header[idx:], "</p>"); end >= 0 {
+ pos := idx + end
+ return header[:pos] + versionHTML + header[pos:]
+ }
+ }
+ return header + versionHTML
+}
+
// allThemesJSON returns a JS array literal of all theme names.
func allThemesJSON() (template.JS, error) {
names, err := templates.ThemeNames()
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7e2742b..a718c95 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -12,6 +12,7 @@ import (
"codeberg.org/snonux/snonux/internal/config"
"codeberg.org/snonux/snonux/internal/post"
+ "codeberg.org/snonux/snonux/internal/version"
)
var ctx = context.Background() //nolint:gochecknoglobals // test-only top-level helper used by every test in the file
@@ -94,6 +95,23 @@ func TestJSONStringOrNull(t *testing.T) {
}
}
+func TestLoadThemeMetaInjectsVersion(t *testing.T) {
+ t.Parallel()
+
+ for name := range getThemeSet() {
+ meta, err := loadThemeMeta(name)
+ if err != nil {
+ t.Fatalf("loadThemeMeta(%q): %v", name, err)
+ }
+ if !strings.Contains(meta.HeaderHTML, "snonux v"+version.Version) {
+ t.Errorf("theme %q header missing snonux version", name)
+ }
+ if strings.Count(meta.HeaderHTML, `class="sno-version"`) != 1 {
+ t.Errorf("theme %q header has duplicate version markup", name)
+ }
+ }
+}
+
func TestThemeSoundsJSONNonEmpty(t *testing.T) {
t.Parallel()
j := themeSoundsJSON("neon")
@@ -181,6 +199,12 @@ func TestThemeSoundPresetsAmbientValuesBounded(t *testing.T) {
if a.Gain <= 0 || a.Gain > 0.15 {
t.Errorf("theme %q ambient.%s gain=%f; want (0, 0.15]", name, mode, a.Gain)
}
+ if a.File != "ambient.ogg" {
+ t.Errorf("theme %q ambient.%s file=%q; want ambient.ogg", name, mode, a.File)
+ }
+ if a.Volume <= 0 || a.Volume > 0.8 {
+ t.Errorf("theme %q ambient.%s volume=%f; want (0, 0.8]", name, mode, a.Volume)
+ }
if a.BPM <= 0 || a.BPM > 400 {
t.Errorf("theme %q ambient.%s bpm=%f; want (0, 250]", name, mode, a.BPM)
}
diff --git a/internal/generator/templates/embed.go b/internal/generator/templates/embed.go
index 56fc673..191f4ad 100644
--- a/internal/generator/templates/embed.go
+++ b/internal/generator/templates/embed.go
@@ -20,6 +20,7 @@ import (
// theme.js — required, always present
// meta.json — required, always present
// sounds.json — required, always present
+// ambient.ogg — optional, background music loop
// <Family-Weight>.woff2 — optional, one or more self-hosted web fonts
// <Family-Weight>.woff — optional, fallback / non-woff2 web fonts
// FONT_LICENSE.txt — required iff any font file is present;
@@ -34,7 +35,7 @@ import (
// match at least one file. When you introduce a new font extension
// that no other theme uses yet, append the matching glob here.
//
-//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json themes/*/sounds.json themes/*/*.woff themes/*/*.woff2 themes/*/FONT_LICENSE.txt
+//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json themes/*/sounds.json themes/*/*.ogg themes/*/*.woff themes/*/*.woff2 themes/*/FONT_LICENSE.txt themes/*/MUSIC_LICENSE.txt
var FS embed.FS
// themeStandardFiles lists the per-theme files that have dedicated
diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl
index b04b160..9759761 100644
--- a/internal/generator/templates/shared/nav.tmpl
+++ b/internal/generator/templates/shared/nav.tmpl
@@ -44,38 +44,65 @@
{{end}}
{{define "navhints"}}
-<div class="nav-hints" role="region" aria-label="Keyboard shortcuts">
+<div class="nav-hints" role="region" aria-label="Controls and shortcuts">
<div class="nav-hints-row nav-hints-nav">
<span class="nav-hints-label">nav</span>
- <span><kbd>j</kbd><kbd>k</kbd><kbd>↑</kbd><kbd>↓</kbd> post</span>
- <span><kbd>h</kbd><kbd>l</kbd><kbd>←</kbd><kbd>→</kbd> page</span>
- <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span>
- <span><kbd>Enter</kbd> open</span>
- <span><kbd>Esc</kbd> close</span>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="prev-post" aria-label="Previous post">
+ <i class="fas fa-arrow-up" aria-hidden="true"></i><kbd>j</kbd><kbd>↑</kbd><span class="sno-btn-text">post</span>
+ </button>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="next-post" aria-label="Next post">
+ <i class="fas fa-arrow-down" aria-hidden="true"></i><kbd>k</kbd><kbd>↓</kbd><span class="sno-btn-text">post</span>
+ </button>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="prev-page" aria-label="Previous page">
+ <i class="fas fa-arrow-left" aria-hidden="true"></i><kbd>h</kbd><kbd>←</kbd><span class="sno-btn-text">page</span>
+ </button>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="next-page" aria-label="Next page">
+ <i class="fas fa-arrow-right" aria-hidden="true"></i><kbd>l</kbd><kbd>→</kbd><span class="sno-btn-text">page</span>
+ </button>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="open" aria-label="Open selected post">
+ <i class="fas fa-expand" aria-hidden="true"></i><kbd>Enter</kbd><span class="sno-btn-text">open</span>
+ </button>
+ <button type="button" class="nav-fx-button sno-nav-btn" data-sno-nav="close" aria-label="Close modal">
+ <i class="fas fa-compress" aria-hidden="true"></i><kbd>Esc</kbd><span class="sno-btn-text">close</span>
+ </button>
</div>
<div class="nav-hints-row nav-hints-fx">
<span class="nav-hints-label">fx</span>
- <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>
- <span class="nav-hints-spacer"><kbd>t</kbd> theme</span>
+ <button type="button" class="nav-fx-button" data-sno-fx="wild" aria-pressed="false" aria-label="Toggle wild mode">
+ <i class="fas fa-bolt" aria-hidden="true"></i><kbd>w</kbd><span class="sno-btn-text">wild</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="crt" aria-pressed="false" aria-label="Toggle CRT effect">
+ <i class="fas fa-tv" aria-hidden="true"></i><kbd>c</kbd><span class="sno-btn-text">crt</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="ghost" aria-pressed="false" aria-label="Toggle ghost mode">
+ <i class="fas fa-ghost" aria-hidden="true"></i><kbd>g</kbd><span class="sno-btn-text">ghost</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="blank" aria-pressed="false" aria-label="Toggle blank mode">
+ <i class="fas fa-eye-slash" aria-hidden="true"></i><kbd>b</kbd><span class="sno-btn-text">blank</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="ambient" aria-pressed="false" aria-label="Start or pause ambient music">
+ <i class="fas fa-music" aria-hidden="true"></i><kbd>p</kbd><span class="sno-btn-text">music</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="flash" aria-label="Trigger flash effect">
+ <i class="fas fa-camera" aria-hidden="true"></i><kbd>f</kbd><span class="sno-btn-text">flash</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="scatter" aria-label="Trigger scatter effect">
+ <i class="fas fa-wind" aria-hidden="true"></i><kbd>x</kbd><span class="sno-btn-text">scatter</span>
+ </button>
+ <button type="button" class="nav-fx-button" data-sno-fx="theme" aria-label="Random theme">
+ <i class="fas fa-palette" aria-hidden="true"></i><kbd>t</kbd><span class="sno-btn-text">theme</span>
+ </button>
<select id="sno-theme-select" class="nav-fx-button" aria-label="Theme"></select>
</div>
</div>
{{end}}
-
{{define "navmodal"}}
<div class="post-modal" id="post-modal" role="dialog" aria-modal="true" aria-label="Expanded post">
<div class="modal-inner">
- <button class="modal-close" onclick="closeModal()">[ ESC ] CLOSE</button>
+ <button class="modal-close" onclick="closeModal()" aria-label="Close post"><i class="fas fa-times" aria-hidden="true"></i> CLOSE</button>
<div id="modal-content"></div>
<div class="sno-scroll-end" id="sno-scroll-end" aria-hidden="true"></div>
</div>
</div>
{{end}}
-
diff --git a/internal/generator/templates/shared/shared.css b/internal/generator/templates/shared/shared.css
index 5fab9ac..35f4aef 100644
--- a/internal/generator/templates/shared/shared.css
+++ b/internal/generator/templates/shared/shared.css
@@ -21,10 +21,12 @@
gap:clamp(16px,4vw,48px); }
/* Flex column layout: let #post-content shrink so overflow-y scrolls; footer stays visible */
#post-content.content { min-height:0; }
-.page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; }
+ .page-nav-footer { flex-shrink:0; width:100%; box-sizing:border-box; }
.page-nav-footer .page-nav { margin:0; }
/* ~Half-height footer bar vs default .page-nav padding */
.page-nav-footer .page-nav a { padding-top:4px; padding-bottom:4px; }
+/* Reorganized nav-hints shared layout */
+.nav-hints { display:flex; flex-direction:column; gap:0; padding:0 !important; }
/* Shared nav FX keyframes — themes apply these classes for brief effects */
/* Modal open: post zooms/flies into the modal overlay */
@keyframes sno-modal-zoom { 0%{transform:scale(0.82) translateY(18px);opacity:0} 60%{transform:scale(1.02) translateY(-3px);opacity:1} 100%{transform:scale(1) translateY(0);opacity:1} }
@@ -947,9 +949,56 @@ body[data-sno-theme="matrix"] .post-text img,
body[data-sno-theme="terminal"] .post-text img,
body[data-sno-theme="dos"] .post-text img { animation:sno-matrix-decode 0.8s ease-out both; }
-/* Mobile touch-target and padding overrides */
+/* Mobile touch-target and layout overrides */
@media(max-width:640px) {
+ .nav-hints { padding:4px 14px !important; gap:0; position:sticky; top:0; z-index:25; }
+ /* Remove navigation row on mobile */
+ .nav-hints-nav { display:none !important; }
+ /* FX row: single horizontal line of icon-only tiles */
+ .nav-hints-fx { display:flex !important; flex-direction:row !important;
+ flex-wrap:nowrap; padding:0; gap:4px; overflow-x:auto;
+ -webkit-overflow-scrolling:touch; scrollbar-width:none; }
+ .nav-hints-fx::-webkit-scrollbar { display:none; }
+ .nav-hints-label, .nav-hints-spacer,
+ .nav-hints-row kbd, .sno-btn-text { display:none !important; }
+ .nav-fx-button {
+ flex:1 1 auto; min-height:44px; min-width:36px; max-width:none;
+ justify-content:center; align-items:center; gap:0; padding:0;
+ font-size:0; width:auto;
+ }
+ .nav-fx-button i, .nav-fx-button i.fas { font-size:1rem; display:inline-block; }
+ /* Suppress the theme select dropdown in the bar */
+ .nav-hints-fx #sno-theme-select { display:none !important; }
.modal-inner { padding:20px 14px !important; }
.transmit-btn { padding-top:11px !important; padding-bottom:11px !important; min-height:44px !important; }
.page-nav a { padding-top:10px !important; padding-bottom:10px !important; min-height:44px !important; }
+ /* Floating action button for quick one-handed control */
+ #sno-fab {
+ position:fixed; right:14px; bottom:14px; z-index:5001;
+ width:56px; height:56px; border-radius:50%;
+ border:0; background:rgba(0,0,0,0.75); color:#fff;
+ font-size:1.2rem; cursor:pointer; display:flex; align-items:center; justify-content:center;
+ box-shadow:0 4px 16px rgba(0,0,0,0.48); backdrop-filter:blur(6px); -webkit-backdrop-filter:blur(6px);
+ transition:transform 0.18s ease, box-shadow 0.18s ease;
+ -webkit-tap-highlight-color:transparent;
+ }
+ #sno-fab:focus-visible { outline:2px solid #fff; outline-offset:3px; }
+ #sno-fab:active { transform:scale(0.94); }
+ #sno-fab .sno-fab-menu {
+ position:absolute; bottom:64px; right:0;
+ display:flex; flex-direction:column; gap:10px;
+ opacity:0; pointer-events:none; transform:translateY(6px);
+ transition:opacity 0.2s ease, transform 0.2s ease;
+ }
+ #sno-fab.open .sno-fab-menu { opacity:1; pointer-events:auto; transform:translateY(0); }
+ #sno-fab .sno-fab-menu button {
+ width:48px; height:48px; border-radius:50%;
+ border:0; background:rgba(0,0,0,0.72); color:#fff;
+ font-size:1.05rem; display:flex; align-items:center; justify-content:center;
+ box-shadow:0 2px 10px rgba(0,0,0,0.35); backdrop-filter:blur(4px);
+ -webkit-tap-highlight-color:transparent;
+ }
+ #sno-fab .sno-fab-menu button:active { transform:scale(0.92); }
+ /* Swipe hint for first-time mobile visitors */
+ #sno-swipe-hint { display:flex; }
}
diff --git a/internal/generator/templates/shared/shared.js b/internal/generator/templates/shared/shared.js
index 09de580..1e29831 100644
--- a/internal/generator/templates/shared/shared.js
+++ b/internal/generator/templates/shared/shared.js
@@ -471,6 +471,9 @@
var melodyIndex = 0;
var drumTimer = null;
var drumIndex = 0;
+ var ambientAudio = null;
+ var ambientAudioSrc = '';
+ var ambientAudioFadeTimer = null;
function wildifyPreset(base) {
if (!base) return base;
@@ -493,9 +496,108 @@
var ambient = SNONUX_SOUNDS.ambient;
if (!ambient) return null;
var base = isWild ? (ambient.wild || ambient.normal) : ambient.normal;
+ if (base && !base.file && ambient.normal && ambient.normal.file) {
+ var inherited = {};
+ for (var k in base) inherited[k] = base[k];
+ inherited.file = ambient.normal.file;
+ i