feat: bufo easter egg for tracks tagged with 'bufo' (#438)

when a track has the 'bufo' tag, semantically-matched toad GIFs
float across the track detail page. uses the track title as a
semantic search query against the find-bufo API.

- BufoEasterEgg component fetches toads based on track title
- results cached in localStorage for 1 week to reduce API calls
- TagEffects wrapper provides extensibility for future tag-based plugins
- animated toads drift across viewport with wobble effects
- respects prefers-reduced-motion
- fails gracefully if API is unavailable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 2e4c96be 180d58d7

Changed files
+326
frontend
src
lib
routes
track
+280
frontend/src/lib/components/BufoEasterEgg.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Props { 5 + query: string; 6 + } 7 + 8 + interface Bufo { 9 + id: string; 10 + url: string; 11 + name: string; 12 + score: number; 13 + } 14 + 15 + interface SpawnedBufo { 16 + id: string; 17 + url: string; 18 + style: string; 19 + animationClass: string; 20 + } 21 + 22 + let { query }: Props = $props(); 23 + 24 + let bufos = $state<Bufo[]>([]); 25 + let spawnedBufos = $state<SpawnedBufo[]>([]); 26 + let spawnInterval: number | null = null; 27 + 28 + const CACHE_KEY_PREFIX = 'bufo-cache:'; 29 + const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 1 week 30 + 31 + function getCached(key: string): Bufo[] | null { 32 + try { 33 + const raw = localStorage.getItem(CACHE_KEY_PREFIX + key); 34 + if (!raw) return null; 35 + const { results, timestamp } = JSON.parse(raw); 36 + if (Date.now() - timestamp > CACHE_TTL_MS) { 37 + localStorage.removeItem(CACHE_KEY_PREFIX + key); 38 + return null; 39 + } 40 + return results; 41 + } catch { 42 + return null; 43 + } 44 + } 45 + 46 + function setCache(key: string, results: Bufo[]) { 47 + try { 48 + localStorage.setItem( 49 + CACHE_KEY_PREFIX + key, 50 + JSON.stringify({ results, timestamp: Date.now() }) 51 + ); 52 + } catch { 53 + // localStorage full or unavailable, ignore 54 + } 55 + } 56 + 57 + async function fetchBufos() { 58 + // check cache first 59 + const cached = getCached(query); 60 + if (cached) { 61 + bufos = cached; 62 + return; 63 + } 64 + 65 + try { 66 + const params = new URLSearchParams({ 67 + query, 68 + top_k: '10', 69 + family_friendly: 'true' 70 + }); 71 + const response = await fetch(`https://find-bufo.fly.dev/api/search?${params}`); 72 + if (response.ok) { 73 + const data = await response.json(); 74 + bufos = data.results || []; 75 + if (bufos.length > 0) { 76 + setCache(query, bufos); 77 + } 78 + } 79 + } catch (e) { 80 + console.error('failed to fetch bufos:', e); 81 + } 82 + } 83 + 84 + function spawnBufo() { 85 + if (bufos.length === 0) return; 86 + 87 + const bufo = bufos[Math.floor(Math.random() * bufos.length)]; 88 + const size = 60 + Math.random() * 80; // 60-140px 89 + const startY = Math.random() * 70 + 10; // 10-80% from top 90 + const duration = 8 + Math.random() * 8; // 8-16 seconds 91 + const delay = Math.random() * 0.5; 92 + const direction = Math.random() > 0.5 ? 'left' : 'right'; 93 + const wobble = Math.random() > 0.5; 94 + 95 + const spawned: SpawnedBufo = { 96 + id: `${bufo.id}-${Date.now()}-${Math.random()}`, 97 + url: bufo.url, 98 + style: ` 99 + --size: ${size}px; 100 + --start-y: ${startY}vh; 101 + --duration: ${duration}s; 102 + --delay: ${delay}s; 103 + `, 104 + animationClass: `float-${direction}${wobble ? ' wobble' : ''}` 105 + }; 106 + 107 + spawnedBufos = [...spawnedBufos, spawned]; 108 + 109 + // remove after animation completes 110 + setTimeout(() => { 111 + spawnedBufos = spawnedBufos.filter(b => b.id !== spawned.id); 112 + }, (duration + delay + 1) * 1000); 113 + } 114 + 115 + onMount(() => { 116 + fetchBufos().then(() => { 117 + // initial burst of toads 118 + for (let i = 0; i < 3; i++) { 119 + setTimeout(() => spawnBufo(), i * 800); 120 + } 121 + // then spawn periodically 122 + spawnInterval = window.setInterval(spawnBufo, 3000); 123 + }); 124 + 125 + return () => { 126 + if (spawnInterval) window.clearInterval(spawnInterval); 127 + }; 128 + }); 129 + </script> 130 + 131 + <div class="bufo-container" aria-hidden="true"> 132 + {#each spawnedBufos as bufo (bufo.id)} 133 + <img 134 + src={bufo.url} 135 + alt="" 136 + class="bufo {bufo.animationClass}" 137 + style={bufo.style} 138 + /> 139 + {/each} 140 + </div> 141 + 142 + <style> 143 + .bufo-container { 144 + position: fixed; 145 + inset: 0; 146 + pointer-events: none; 147 + overflow: hidden; 148 + z-index: 1; 149 + } 150 + 151 + .bufo { 152 + position: absolute; 153 + width: var(--size); 154 + height: auto; 155 + top: var(--start-y); 156 + animation-duration: var(--duration); 157 + animation-delay: var(--delay); 158 + animation-timing-function: linear; 159 + animation-fill-mode: forwards; 160 + opacity: 0.9; 161 + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); 162 + } 163 + 164 + .float-left { 165 + right: -150px; 166 + animation-name: float-left; 167 + } 168 + 169 + .float-right { 170 + left: -150px; 171 + animation-name: float-right; 172 + } 173 + 174 + .wobble { 175 + animation-timing-function: ease-in-out; 176 + } 177 + 178 + @keyframes float-left { 179 + 0% { 180 + transform: translateX(0) translateY(0) rotate(0deg); 181 + opacity: 0; 182 + } 183 + 5% { 184 + opacity: 0.9; 185 + } 186 + 95% { 187 + opacity: 0.9; 188 + } 189 + 100% { 190 + transform: translateX(calc(-100vw - 200px)) translateY(20px) rotate(-10deg); 191 + opacity: 0; 192 + } 193 + } 194 + 195 + @keyframes float-right { 196 + 0% { 197 + transform: translateX(0) translateY(0) rotate(0deg); 198 + opacity: 0; 199 + } 200 + 5% { 201 + opacity: 0.9; 202 + } 203 + 95% { 204 + opacity: 0.9; 205 + } 206 + 100% { 207 + transform: translateX(calc(100vw + 200px)) translateY(20px) rotate(10deg); 208 + opacity: 0; 209 + } 210 + } 211 + 212 + /* add some vertical bounce for wobble variant */ 213 + .wobble.float-left { 214 + animation-name: float-left-wobble; 215 + } 216 + 217 + .wobble.float-right { 218 + animation-name: float-right-wobble; 219 + } 220 + 221 + @keyframes float-left-wobble { 222 + 0% { 223 + transform: translateX(0) translateY(0) rotate(0deg); 224 + opacity: 0; 225 + } 226 + 5% { 227 + opacity: 0.9; 228 + } 229 + 25% { 230 + transform: translateX(calc(-25vw - 50px)) translateY(-30px) rotate(-5deg); 231 + } 232 + 50% { 233 + transform: translateX(calc(-50vw - 100px)) translateY(30px) rotate(5deg); 234 + } 235 + 75% { 236 + transform: translateX(calc(-75vw - 150px)) translateY(-20px) rotate(-5deg); 237 + } 238 + 95% { 239 + opacity: 0.9; 240 + } 241 + 100% { 242 + transform: translateX(calc(-100vw - 200px)) translateY(10px) rotate(-10deg); 243 + opacity: 0; 244 + } 245 + } 246 + 247 + @keyframes float-right-wobble { 248 + 0% { 249 + transform: translateX(0) translateY(0) rotate(0deg); 250 + opacity: 0; 251 + } 252 + 5% { 253 + opacity: 0.9; 254 + } 255 + 25% { 256 + transform: translateX(calc(25vw + 50px)) translateY(-30px) rotate(5deg); 257 + } 258 + 50% { 259 + transform: translateX(calc(50vw + 100px)) translateY(30px) rotate(-5deg); 260 + } 261 + 75% { 262 + transform: translateX(calc(75vw + 150px)) translateY(-20px) rotate(5deg); 263 + } 264 + 95% { 265 + opacity: 0.9; 266 + } 267 + 100% { 268 + transform: translateX(calc(100vw + 200px)) translateY(10px) rotate(10deg); 269 + opacity: 0; 270 + } 271 + } 272 + 273 + /* respect reduced motion preference */ 274 + @media (prefers-reduced-motion: reduce) { 275 + .bufo { 276 + animation: none; 277 + opacity: 0; 278 + } 279 + } 280 + </style>
+42
frontend/src/lib/components/TagEffects.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * TagEffects - extensible tag-based visual effects system 4 + * 5 + * This component renders visual effects based on track tags. 6 + * Currently supports: 7 + * - "bufo": animated toad GIFs matched semantically to track title 8 + * 9 + * Future: This could be extended to support user-defined plugins 10 + * that register custom effects for arbitrary tags. 11 + */ 12 + import BufoEasterEgg from './BufoEasterEgg.svelte'; 13 + 14 + interface Props { 15 + tags: string[]; 16 + trackTitle: string; 17 + } 18 + 19 + let { tags, trackTitle }: Props = $props(); 20 + 21 + // registry of tag -> effect component 22 + // future: this could be loaded dynamically from user-defined plugins 23 + const hasBufo = $derived(tags.includes('bufo')); 24 + </script> 25 + 26 + {#if hasBufo} 27 + <BufoEasterEgg query={trackTitle} /> 28 + {/if} 29 + 30 + <!-- 31 + Future plugin system could look like: 32 + 33 + {#each activeEffects as effect} 34 + <svelte:component this={effect.component} {...effect.props} /> 35 + {/each} 36 + 37 + Where activeEffects is derived from matching tags against a plugin registry. 38 + Each plugin would define: 39 + - tag: string (the tag that triggers it) 40 + - component: SvelteComponent (the effect to render) 41 + - getProps: (track) => object (how to derive props from track data) 42 + -->
+4
frontend/src/routes/track/[id]/+page.svelte
··· 7 7 import Header from '$lib/components/Header.svelte'; 8 8 import LikeButton from '$lib/components/LikeButton.svelte'; 9 9 import ShareButton from '$lib/components/ShareButton.svelte'; 10 + import TagEffects from '$lib/components/TagEffects.svelte'; 10 11 import { player } from '$lib/player.svelte'; 11 12 import { queue } from '$lib/queue.svelte'; 12 13 import { auth } from '$lib/auth.svelte'; ··· 350 351 </svelte:head> 351 352 352 353 <div class="page-container"> 354 + {#if track.tags && track.tags.length > 0} 355 + <TagEffects tags={track.tags} trackTitle={track.title} /> 356 + {/if} 353 357 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} /> 354 358 355 359 <main>