feat: add end-of-list animation on homepage (#379)

- Show whimsical wave animation when scrolling to end of track list
- Three waves with trailing ghost effects, 120° phase offset each
- Colors derived from user's accent color using color-mix()
- Delicate, ephemeral aesthetic inspired by points of light

🤖 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 d901f3dd 94a88d16

Changed files
+130
frontend
src
lib
components
routes
+128
frontend/src/lib/components/EndOfList.svelte
···
··· 1 + <script lang="ts"> 2 + import { fade } from "svelte/transition"; 3 + 4 + interface Props { 5 + message?: string; 6 + } 7 + 8 + let { message = "you've reached the end!" }: Props = $props(); 9 + 10 + const count = 16; 11 + const ghostCount = 12; // trailing ghosts per dot 12 + </script> 13 + 14 + <div class="end-of-list" in:fade={{ duration: 400 }}> 15 + <div class="canvas"> 16 + {#each Array(count) as _, i} 17 + {#each Array(ghostCount) as _, g} 18 + <i 19 + style="--i:{i}; --g:{g}" 20 + class:ghost={g > 0} 21 + ></i> 22 + {/each} 23 + {/each} 24 + <!-- second wave: 1/3 phase offset --> 25 + {#each Array(count) as _, i} 26 + {#each Array(ghostCount) as _, g} 27 + <i 28 + style="--i:{i}; --g:{g}" 29 + class:ghost={g > 0} 30 + class="phase2" 31 + ></i> 32 + {/each} 33 + {/each} 34 + <!-- third wave: 2/3 phase offset --> 35 + {#each Array(count) as _, i} 36 + {#each Array(ghostCount) as _, g} 37 + <i 38 + style="--i:{i}; --g:{g}" 39 + class:ghost={g > 0} 40 + class="phase3" 41 + ></i> 42 + {/each} 43 + {/each} 44 + </div> 45 + <p class="message">{message}</p> 46 + </div> 47 + 48 + <style> 49 + .end-of-list { 50 + display: flex; 51 + flex-direction: column; 52 + align-items: center; 53 + gap: 1rem; 54 + padding: 2rem 1rem; 55 + margin-top: 1rem; 56 + } 57 + 58 + .canvas { 59 + position: relative; 60 + width: 100px; 61 + height: 32px; 62 + } 63 + 64 + i { 65 + --wave-color: var(--accent); 66 + position: absolute; 67 + left: calc(var(--i) * 6px); 68 + top: 50%; 69 + width: 2px; 70 + height: 2px; 71 + background: var(--wave-color); 72 + border-radius: 50%; 73 + opacity: 0.6; 74 + box-shadow: 0 0 2px var(--wave-color); 75 + animation: drift 2s ease-in-out infinite; 76 + /* base delay for wave + ghost offset for trail */ 77 + animation-delay: calc((var(--i) * -0.15s) + (var(--g) * 0.07s)); 78 + } 79 + 80 + i.ghost { 81 + opacity: calc(0.35 - (var(--g) * 0.025)); 82 + width: 1px; 83 + height: 1px; 84 + border-radius: 50%; 85 + filter: blur(calc(var(--g) * 0.2px)); 86 + box-shadow: none; 87 + } 88 + 89 + @keyframes drift { 90 + 0%, 91 + 100% { 92 + transform: translateY(8px); 93 + } 94 + 50% { 95 + transform: translateY(-8px); 96 + } 97 + } 98 + 99 + /* 2s duration, so 0.667s = 1/3 phase, 1.333s = 2/3 phase */ 100 + /* each wave gets a color variation mixed from the accent */ 101 + i.phase2 { 102 + --wave-color: color-mix(in oklch, var(--accent) 70%, #fff); 103 + animation-delay: calc( 104 + (var(--i) * -0.15s) + (var(--g) * 0.07s) - 0.667s 105 + ); 106 + } 107 + 108 + i.phase3 { 109 + --wave-color: color-mix(in oklch, var(--accent) 60%, #000); 110 + animation-delay: calc( 111 + (var(--i) * -0.15s) + (var(--g) * 0.07s) - 1.333s 112 + ); 113 + } 114 + 115 + .message { 116 + margin: 0; 117 + color: var(--text-tertiary); 118 + font-size: 0.8rem; 119 + letter-spacing: 0.02em; 120 + } 121 + 122 + @media (prefers-reduced-motion: reduce) { 123 + i { 124 + animation: none; 125 + opacity: 0.5; 126 + } 127 + } 128 + </style>
+2
frontend/src/routes/+page.svelte
··· 4 import TrackItem from '$lib/components/TrackItem.svelte'; 5 import Header from '$lib/components/Header.svelte'; 6 import WaveLoading from '$lib/components/WaveLoading.svelte'; 7 import { player } from '$lib/player.svelte'; 8 import { queue } from '$lib/queue.svelte'; 9 import { tracksCache } from '$lib/tracks.svelte'; ··· 106 /> 107 {/each} 108 </div> 109 {/if} 110 </section> 111 </main>
··· 4 import TrackItem from '$lib/components/TrackItem.svelte'; 5 import Header from '$lib/components/Header.svelte'; 6 import WaveLoading from '$lib/components/WaveLoading.svelte'; 7 + import EndOfList from '$lib/components/EndOfList.svelte'; 8 import { player } from '$lib/player.svelte'; 9 import { queue } from '$lib/queue.svelte'; 10 import { tracksCache } from '$lib/tracks.svelte'; ··· 107 /> 108 {/each} 109 </div> 110 + <EndOfList /> 111 {/if} 112 </section> 113 </main>