diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-16 09:35:31 +0300 |
| commit | 1b24076788868f50812468b1bf4bb0ae86214e8c (patch) | |
| tree | 389fd715b5c27e0fe036f6c123b8d09e3872da1c /internal/generator | |
| parent | fbac3d13a575a410c2ee3f49418bf4b462544b4f (diff) | |
Add theme background musicv0.16.0
Diffstat (limited to 'internal/generator')
86 files changed, 654 insertions, 82 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">·</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; + if (inherited.volume == null && ambient.normal.volume != null) inherited.volume = ambient.normal.volume; + base = inherited; |
