diff options
Diffstat (limited to 'internal/generator/templates')
71 files changed, 5219 insertions, 5745 deletions
diff --git a/internal/generator/templates/embed.go b/internal/generator/templates/embed.go index 91ba07f..ac3efbd 100644 --- a/internal/generator/templates/embed.go +++ b/internal/generator/templates/embed.go @@ -1,8 +1,8 @@ -// Package templates exposes the embedded HTML theme and shared sub-templates -// used by the generator. Themes and nav/HTML fragments live as separate files -// under templates/themes/*.tmpl and templates/shared/*.tmpl so they can be -// edited without recompiling logic; they are compiled into the binary via -// //go:embed so snonux still ships as a single self-contained executable. +// Package templates exposes the embedded HTML shell, shared CSS/JS bundles, +// and per-theme assets used by the generator. Themes live as directories +// under templates/themes/<name>/ containing theme.css, theme.js, and meta.json. +// All assets are compiled into the binary via //go:embed so snonux still ships +// as a single self-contained executable. package templates import ( @@ -11,24 +11,24 @@ import ( "io/fs" "path" "sort" - "strings" ) -//go:embed themes/*.tmpl shared/*.tmpl +//go:embed shell.tmpl shared/*.tmpl shared/shared.css shared/shared.js themes/*/theme.css themes/*/theme.js themes/*/meta.json var FS embed.FS -// Theme reads the raw HTML template body for a named theme. -// The returned string is the outer page template; shared sub-templates -// (navhints, navmodal, navscript, etc.) are obtained via Shared(). -func Theme(name string) (string, error) { - b, err := FS.ReadFile(path.Join("themes", name+".tmpl")) +// Shell returns the body of shell.tmpl — the single page template used for +// every generated HTML page. Theme-specific markup is injected at gen time +// from the default theme's meta.json (and at runtime by shared.js for any +// other selected theme). +func Shell() (string, error) { + b, err := FS.ReadFile("shell.tmpl") if err != nil { - return "", fmt.Errorf("read theme %q: %w", name, err) + return "", fmt.Errorf("read shell.tmpl: %w", err) } return string(b), nil } -// Shared reads a named shared sub-template file from shared/*.tmpl. +// Shared reads a named shared sub-template (currently only "nav"). func Shared(name string) (string, error) { b, err := FS.ReadFile(path.Join("shared", name+".tmpl")) if err != nil { @@ -37,8 +37,35 @@ func Shared(name string) (string, error) { return string(b), nil } +// SharedCSS returns the bundled CSS that every page links via shared.css. +// Used by the generator to write dist/shared.css. +func SharedCSS() ([]byte, error) { + return FS.ReadFile("shared/shared.css") +} + +// SharedJS returns the bundled JS that every page references via shared.js. +// Used by the generator to write dist/shared.js. +func SharedJS() ([]byte, error) { + return FS.ReadFile("shared/shared.js") +} + +// ThemeCSS returns the per-theme stylesheet bytes for the named theme. +func ThemeCSS(name string) ([]byte, error) { + return FS.ReadFile(path.Join("themes", name, "theme.css")) +} + +// ThemeJS returns the per-theme script bytes for the named theme. +func ThemeJS(name string) ([]byte, error) { + return FS.ReadFile(path.Join("themes", name, "theme.js")) +} + +// ThemeMeta returns the per-theme meta.json bytes for the named theme. +func ThemeMeta(name string) ([]byte, error) { + return FS.ReadFile(path.Join("themes", name, "meta.json")) +} + // ThemeNames returns a sorted list of available theme names derived from the -// files present under templates/themes/. +// directories present under templates/themes/. func ThemeNames() ([]string, error) { entries, err := fs.ReadDir(FS, "themes") if err != nil { @@ -47,14 +74,10 @@ func ThemeNames() ([]string, error) { names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { - continue - } - n := e.Name() - if !strings.HasSuffix(n, ".tmpl") { + if !e.IsDir() { continue } - names = append(names, strings.TrimSuffix(n, ".tmpl")) + names = append(names, e.Name()) } sort.Strings(names) diff --git a/internal/generator/templates/shared/favicon_head.tmpl b/internal/generator/templates/shared/favicon_head.tmpl deleted file mode 100644 index e35bd5d..0000000 --- a/internal/generator/templates/shared/favicon_head.tmpl +++ /dev/null @@ -1,2 +0,0 @@ - - <link rel="icon" href="favicon.ico" sizes="any"> diff --git a/internal/generator/templates/shared/nav.tmpl b/internal/generator/templates/shared/nav.tmpl index 346294a..9794f2f 100644 --- a/internal/generator/templates/shared/nav.tmpl +++ b/internal/generator/templates/shared/nav.tmpl @@ -45,852 +45,27 @@ {{define "navhints"}} <div class="nav-hints" role="region" aria-label="Keyboard shortcuts"> - <span><kbd>j</kbd><kbd>k</kbd> or <kbd>↑</kbd><kbd>↓</kbd> select post</span> - <span><kbd>PgUp</kbd><kbd>PgDn</kbd> scroll</span> - <span><kbd>Enter</kbd> or click post to expand</span> - <span><kbd>Esc</kbd> close</span> - <span><kbd>h</kbd><kbd>l</kbd> or <kbd>←</kbd><kbd>→</kbd> change page</span> - <span><kbd>w</kbd> wild mode</span> + <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> + </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="flash" aria-label="Trigger flash effect"><kbd>p</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> + <select id="sno-theme-select" class="nav-fx-button" aria-label="Theme"></select> + </div> </div> {{end}} -{{define "navSharedCSSInner"}} -/* Non-active posts are translucent so the WebGL background shows through. - The active/highlighted post snaps to full opacity for clear reading. - Hover on a non-active post partially reveals it before selection. */ -.post { position:relative; } -.post:not(.post-active) { opacity: 0.55; transition: opacity 0.25s ease; } -.post:not(.post-active):hover { opacity: 0.85; } -.post.post-active { opacity: 1 !important; transition: opacity 0.15s ease; } -/* Thumbnail sizing in list view; modal overrides to full width. */ -.post-image { max-height:220px; max-width:100%; object-fit:cover; cursor:pointer; } -#post-modal .post-image { max-height:none; width:100%; max-width:100%; object-fit:contain; cursor:default; } -/* Semi-transparent modal backdrop so the WebGL scene stays visible behind - the expanded post. Theme-specific modal-inner keeps its own background. */ -.post-modal { background:rgba(0,0,0,0.55) !important; backdrop-filter:blur(6px) !important; } -#post-modal.active { display:flex !important; align-items:center; justify-content:center; } -#post-modal .modal-inner { width:fit-content; max-width:min(100%, 90vw); max-height:calc(100vh - 80px); overflow:auto; margin:0 auto !important; will-change:transform; } -/* Content area max-width across all themes */ -.overlay { max-width:1200px; margin-left:auto; margin-right:auto; } -/* Pagination: newer + older in a footer bar (below scrollable posts, like the header) */ -.page-nav-dual { display:flex; justify-content:center; align-items:center; flex-wrap:wrap; - 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 .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; } -/* 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} } -@keyframes sno-modal-fly-up { 0%{transform:translateY(60px) scale(0.92);opacity:0} 70%{transform:translateY(-4px) scale(1.01);opacity:1} 100%{transform:translateY(0) scale(1);opacity:1} } -@keyframes sno-modal-slide-in { 0%{transform:translateX(-40px) scale(0.96);opacity:0} 65%{transform:translateX(4px) scale(1.005);opacity:1} 100%{transform:translateX(0) scale(1);opacity:1} } -@keyframes sno-modal-expand { 0%{transform:scale(0.05) rotate(-4deg);opacity:0} 55%{transform:scale(1.04) rotate(0.5deg);opacity:1} 100%{transform:scale(1) rotate(0);opacity:1} } -.sno-modal-zoom .modal-inner { animation:sno-modal-zoom 0.36s cubic-bezier(0.22,1,0.36,1) both !important; } -.sno-modal-fly .modal-inner { animation:sno-modal-fly-up 0.34s cubic-bezier(0.22,1,0.36,1) both !important; } -.sno-modal-slide .modal-inner { animation:sno-modal-slide-in 0.32s cubic-bezier(0.22,1,0.36,1) both !important; } -.sno-modal-expand .modal-inner { animation:sno-modal-expand 0.4s cubic-bezier(0.22,1,0.36,1) both !important; } -@keyframes sno-shake { 0%,100%{transform:translate(0)} 14%{transform:translate(-7px,4px)} 28%{transform:translate(7px,-5px)} 42%{transform:translate(-5px,6px)} 56%{transform:translate(6px,-4px)} 70%{transform:translate(-4px,3px)} 86%{transform:translate(4px,-2px)} } -@keyframes sno-zoom-fwd { 0%{transform:scale(1)} 40%{transform:scale(1.05)} 100%{transform:scale(1)} } -@keyframes sno-glitch { 0%,100%{transform:translate(0) skewX(0)} 20%{transform:translate(-5px,0) skewX(-4deg)} 40%{transform:translate(5px,0) skewX(4deg)} 60%{transform:translate(-3px,0)} 80%{transform:translate(3px,0)} } -@keyframes sno-bounce-left { 0%{transform:translateX(0)} 25%{transform:translateX(-18px)} 50%{transform:translateX(6px)} 75%{transform:translateX(-3px)} 100%{transform:translateX(0)} } -@keyframes sno-bounce-right { 0%{transform:translateX(0)} 25%{transform:translateX(18px)} 50%{transform:translateX(-6px)} 75%{transform:translateX(3px)} 100%{transform:translateX(0)} } -@keyframes sno-bounce-left-wild { 0%{transform:translateX(0) rotate(0)} 15%{transform:translateX(-48px) rotate(-3deg)} 35%{transform:translateX(22px) rotate(2deg)} 55%{transform:translateX(-12px) rotate(-1.2deg)} 75%{transform:translateX(6px) rotate(0.5deg)} 100%{transform:translateX(0) rotate(0)} } -@keyframes sno-bounce-right-wild { 0%{transform:translateX(0) rotate(0)} 15%{transform:translateX(48px) rotate(3deg)} 35%{transform:translateX(-22px) rotate(-2deg)} 55%{transform:translateX(12px) rotate(1.2deg)} 75%{transform:translateX(-6px) rotate(-0.5deg)} 100%{transform:translateX(0) rotate(0)} } -.sno-fx-bounce-left { animation:sno-bounce-left 0.35s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-right { animation:sno-bounce-right 0.35s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-left-wild { animation:sno-bounce-left-wild 0.5s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-right-wild { animation:sno-bounce-right-wild 0.5s cubic-bezier(.36,.07,.19,.97) both !important; } -/* Vertical boundary bounce (top/bottom post list) */ -@keyframes sno-bounce-up { 0%{transform:translateY(0)} 25%{transform:translateY(-14px)} 50%{transform:translateY(5px)} 75%{transform:translateY(-2px)} 100%{transform:translateY(0)} } -@keyframes sno-bounce-down { 0%{transform:translateY(0)} 25%{transform:translateY(14px)} 50%{transform:translateY(-5px)} 75%{transform:translateY(2px)} 100%{transform:translateY(0)} } -@keyframes sno-bounce-up-wild { 0%{transform:translateY(0) rotate(0)} 15%{transform:translateY(-40px) rotate(-2.5deg)} 35%{transform:translateY(18px) rotate(1.5deg)} 55%{transform:translateY(-9px) rotate(-0.8deg)} 75%{transform:translateY(4px) rotate(0.3deg)} 100%{transform:translateY(0) rotate(0)} } -@keyframes sno-bounce-down-wild { 0%{transform:translateY(0) rotate(0)} 15%{transform:translateY(40px) rotate(2.5deg)} 35%{transform:translateY(-18px) rotate(-1.5deg)} 55%{transform:translateY(9px) rotate(0.8deg)} 75%{transform:translateY(-4px) rotate(-0.3deg)} 100%{transform:translateY(0) rotate(0)} } -.sno-fx-bounce-up { animation:sno-bounce-up 0.35s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-down { animation:sno-bounce-down 0.35s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-up-wild { animation:sno-bounce-up-wild 0.5s cubic-bezier(.36,.07,.19,.97) both !important; } -.sno-fx-bounce-down-wild { animation:sno-bounce-down-wild 0.5s cubic-bezier(.36,.07,.19,.97) both !important; } -/* Post hover ripple — radial ring emanates from center on hover */ -@keyframes sno-hover-ripple { 0%{transform:translate(-50%,-50%) scale(0);opacity:0.5} 100%{transform:translate(-50%,-50%) scale(1);opacity:0} } -.post:not(.post-active)::before { content:''; position:absolute; top:50%; left:50%; width:120%; height:120%; - border-radius:50%; border:2px solid currentColor; pointer-events:none; - transform:translate(-50%,-50%) scale(0); opacity:0; z-index:0; } -.post:not(.post-active):hover::before { animation:sno-hover-ripple 0.6s ease-out forwards; } -/* Modal scroll-end flash — brief glow at bottom of modal-inner when scrolled to end */ -@keyframes sno-scroll-end-flash { 0%{opacity:0.7} 100%{opacity:0} } -.modal-inner .sno-scroll-end { position:sticky; bottom:0; left:0; right:0; height:3px; pointer-events:none; - background:linear-gradient(90deg, transparent, currentColor, transparent); opacity:0; } -.modal-inner .sno-scroll-end.sno-scroll-end-active { animation:sno-scroll-end-flash 0.5s ease-out forwards; } -/* Idle breathing — gentle glow pulse on active post after inactivity */ -@keyframes sno-idle-breathe { 0%,100%{box-shadow:inherit} 50%{box-shadow:0 0 18px 4px currentColor} } -.post-active.sno-idle-breathe { animation:sno-idle-breathe 3s ease-in-out infinite; } -/* First-visit particle burst */ -@keyframes sno-particle-fly { 0%{transform:translate(0,0) scale(1);opacity:1} 100%{transform:translate(var(--px),var(--py)) scale(0);opacity:0} } -#sno-burst { position:fixed; inset:0; z-index:9999; pointer-events:none; } -#sno-burst span { position:absolute; width:6px; height:6px; border-radius:50%; background:currentColor; - animation:sno-particle-fly var(--pdur) ease-out forwards; animation-delay:var(--pdel); opacity:0; } -/* Cursor sparkle trail (normal mode) */ -@keyframes sno-sparkle { 0%{transform:scale(1);opacity:0.8} 100%{transform:scale(0);opacity:0} } -.sno-sparkle { position:fixed; pointer-events:none; z-index:9990; border-radius:50%; } -/* Post afterimage — ghost of previously selected post */ -@keyframes sno-afterimage { 0%{opacity:0.4;transform:scale(1)} 100%{opacity:0;transform:scale(0.97)} } -.sno-afterimage { position:absolute; inset:0; pointer-events:none; z-index:0; - border:1px solid currentColor; border-radius:inherit; opacity:0; } -.sno-afterimage-active { animation:sno-afterimage 0.5s ease-out forwards; } -/* Wild flying emoji */ -@keyframes sno-fly-lr { 0%{transform:translateX(-60px) translateY(var(--fy,0)) rotate(0)} 100%{transform:translateX(calc(100vw + 60px)) translateY(var(--fy,0)) rotate(var(--frot,360deg))} } -@keyframes sno-fly-rl { 0%{transform:translateX(calc(100vw + 60px)) translateY(var(--fy,0)) rotate(0)} 100%{transform:translateX(-60px) translateY(var(--fy,0)) rotate(var(--frot,-360deg))} } -#sno-flyzone { position:fixed; inset:0; z-index:9985; pointer-events:none; overflow:hidden; } -#sno-flyzone span { position:absolute; top:var(--ftop,50%); font-size:clamp(1.2rem,2.5vw,2rem); - animation-timing-function:linear; animation-fill-mode:forwards; } -/* Wild random post flip */ -@keyframes sno-flip-post { 0%{transform:scaleY(1)} 25%{transform:scaleY(-1)} 75%{transform:scaleY(-1)} 100%{transform:scaleY(1)} } -.sno-fx-flip { animation:sno-flip-post 1.4s ease-in-out both !important; transform-origin:center; } -/* Wild hue drift on body */ -@keyframes sno-hue-drift { 0%{filter:hue-rotate(0)} 100%{filter:hue-rotate(360deg)} } -body.sno-wild-hue { animation:sno-hue-drift 12s linear infinite; } -@keyframes sno-wild-pulse { 0%,100%{opacity:1} 50%{opacity:0.6} } -/* Storm overlay that flickers like distant lightning while wild mode is on */ -@keyframes sno-wild-flicker { 0%,84%,87%,91%,94%,100%{opacity:0} 85%,90%{opacity:0.75} 86%,92%{opacity:0.35} } -.sno-fx-shake { animation:sno-shake 0.38s cubic-bezier(.36,.07,.19,.97) both !important; transform-origin:center; } -.sno-fx-zoom { animation:sno-zoom-fwd 0.32s ease both !important; } -.sno-fx-glitch { animation:sno-glitch 0.3s ease both !important; } -/* Wild mode badge — only visible (opacity:1) when .sno-wild-on is present */ -#sno-wild-badge { position:fixed; top:10px; right:12px; z-index:5000; padding:3px 12px; - font-size:0.64rem; letter-spacing:0.2em; text-transform:uppercase; border-radius:2px; - pointer-events:none; opacity:0; transition:opacity 0.4s; - background:rgba(220,0,0,0.92); color:#fff; border:1px solid rgba(255,100,100,0.8); - font-family:monospace; } -/* Animation only runs when wild is actually on, preventing invisible badge pulse */ -#sno-wild-badge.sno-wild-on { opacity:1; animation:sno-wild-pulse 0.9s ease-in-out infinite; } -#sno-wild-root { position:fixed; inset:0; z-index:18; pointer-events:none; opacity:0; - transition:opacity 0.35s ease; overflow:hidden; isolation:isolate; } -body.sno-wild-active #sno-wild-root { opacity:1; } -#sno-wild-root .sno-wild-layer, -#sno-wild-root #sno-wild-scraps, -#sno-wild-root #sno-wild-banner, -#sno-wild-root #sno-wild-ticker { position:absolute; inset:0; } -#sno-wild-colorwash { - background-image:var(--sno-wild-colorwash, none); - background-size:var(--sno-wild-colorwash-size, cover); - background-position:center; - mix-blend-mode:screen; - opacity:var(--sno-wild-colorwash-opacity, 0.55); - animation:sno-wild-drift 9s ease-in-out infinite; -} -#sno-wild-rain { - background-image:var(--sno-wild-rain, none); - background-size:var(--sno-wild-rain-size, 220px 220px); - background-repeat:repeat; - background-position:0 0; - mix-blend-mode:screen; - opacity:var(--sno-wild-rain-opacity, 0.18); - animation:sno-wild-rainfall var(--sno-wild-rain-speed, 1.2s) linear infinite; -} -#sno-wild-wave { - inset:auto 0 -8vh 0; - height:70vh; - background:var(--sno-wild-wave, none); - opacity:var(--sno-wild-wave-opacity, 0.32); - transform-origin:50% 100%; - animation:sno-wild-surge 6.4s ease-in-out infinite; -} -#sno-wild-beacon { - background:var(--sno-wild-beacon, none); - opacity:var(--sno-wild-beacon-opacity, 0.38); - filter:blur(var(--sno-wild-beacon-blur, 0px)); - mix-blend-mode:screen; - animation:sno-wild-lens 4.5s ease-in-out infinite; -} -#sno-wild-noise { - background-image: - linear-gradient(90deg, rgba(255,255,255,0.08) 0 1px, transparent 1px 100%), - linear-gradient(180deg, rgba(255,255,255,0.07) 0 1px, transparent 1px 100%), - repeating-linear-gradient(180deg, - rgba(255,255,255,0.08) 0 2px, - rgba(0,0,0,0.02) 2px 4px, - transparent 4px 8px); - background-size:3px 3px, 100% 3px, 100% 8px; - mix-blend-mode:overlay; - opacity:var(--sno-wild-noise-opacity, 0.12); - animation:sno-wild-static-shift 0.22s steps(2) infinite; -} -#sno-wild-banner { - inset:18px auto auto 50%; - transform:translateX(-50%); - width:max-content; - max-width:min(92vw, 920px); - height:auto; - padding:10px 18px; - border:1px solid var(--sno-wild-accent, rgba(255,255,255,0.8)); - background:var(--sno-wild-banner-bg, rgba(0,0,0,0.82)); - color:var(--sno-wild-banner-color, #fff); - font:700 clamp(0.75rem, 1vw + 0.55rem, 1.15rem)/1.1 monospace; - letter-spacing:0.38em; - text-transform:uppercase; - text-align:center; - box-shadow:0 0 28px var(--sno-wild-banner-glow, rgba(255,255,255,0.25)); - text-shadow:0 0 18px var(--sno-wild-banner-glow, rgba(255,255,255,0.25)); - white-space:nowrap; - animation:sno-wild-banner-flicker 1.2s steps(2) infinite; -} -#sno-wild-ticker { - inset:auto 0 12px 0; - height:30px; - overflow:hidden; - opacity:0.92; - color:var(--sno-wild-accent, #fff); - font:700 0.76rem/30px monospace; - letter-spacing:0.32em; - text-transform:uppercase; - text-shadow:0 0 14px var(--sno-wild-banner-glow, rgba(255,255,255,0.28)); -} -#sno-wild-ticker span { - position:absolute; - left:0; - top:0; - display:inline-block; - white-space:nowrap; - padding-left:100%; - animation:sno-wild-marquee 13s linear infinite; -} -#sno-wild-scraps span { - position:absolute; - left:var(--x); - top:var(--y); - transform:rotate(var(--rot)); - color:var(--sno-wild-scrap-color, rgba(255,255,255,0.82)); - font:700 clamp(0.6rem, 0.45rem + 0.45vw, 0.94rem)/1 monospace; |
