feat: redesign header with UserMenu dropdown and even spacing (#691)

* feat: redesign header for cleaner desktop layout

- add UserMenu dropdown (handle + portal/settings/logout)
- simplify desktop nav: search | library | upload | user menu
- move social links and stats to LinksMenu only (mobile)
- remove unused SearchTrigger and SettingsMenu components
- update CLAUDE.md to reflect component changes

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: increase nav item spacing for better visual rhythm

* style: flatten header structure for even space-between distribution

* fix: restore social links (bluesky, status, tangled) to desktop header

* fix: move social links to left of logo (original position)

* feat: add social links to header left margin

Restores Bluesky, status page, and Tangled links in absolutely
positioned left margin area, outside the main header content flow.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: hide social links on narrow desktop screens

Adds media query to hide margin-left when viewport is under 1000px,
preventing overflow when there's insufficient margin space.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove nav spacers for proper redistribution

Nav items now naturally redistribute when library/upload links are
hidden on their respective pages, instead of leaving awkward gaps.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 3af4e1ec de4250fb

Changed files
+370 -751
frontend
+1 -1
frontend/CLAUDE.md
··· 12 12 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`) 13 13 - toast positioning: bottom-left above player footer (not top-right) 14 14 - queue sync: uses BroadcastChannel for cross-tab, not SSE 15 - - preferences: managed in SettingsMenu component, not dedicated state file 15 + - preferences: managed in UserMenu (desktop) and ProfileMenu (mobile) components, not dedicated state file 16 16 - keyboard shortcuts: handled in root layout (+layout.svelte), with context-aware filtering
+164 -273
frontend/src/lib/components/Header.svelte
··· 1 1 <script lang="ts"> 2 2 import { page } from '$app/stores'; 3 3 import type { User } from '$lib/types'; 4 - import SettingsMenu from './SettingsMenu.svelte'; 5 4 import LinksMenu from './LinksMenu.svelte'; 6 5 import ProfileMenu from './ProfileMenu.svelte'; 7 - import PlatformStats from './PlatformStats.svelte'; 8 - import SearchTrigger from './SearchTrigger.svelte'; 6 + import UserMenu from './UserMenu.svelte'; 9 7 import { search } from '$lib/search.svelte'; 10 8 import { APP_NAME, APP_TAGLINE, APP_STAGE } from '$lib/branding'; 11 9 ··· 19 17 </script> 20 18 21 19 <header> 22 - <!-- Stats and search together in left margin, centered as a group --> 20 + <!-- Social links in left margin, outside main content flow --> 23 21 <div class="margin-left desktop-only"> 24 - <PlatformStats variant="header" /> 25 - <SearchTrigger /> 22 + <a 23 + href="https://bsky.app/profile/plyr.fm" 24 + target="_blank" 25 + rel="noopener noreferrer" 26 + class="social-link" 27 + title="follow @plyr.fm on bluesky" 28 + > 29 + <svg width="18" height="18" viewBox="0 0 600 530" fill="currentColor"> 30 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 31 + </svg> 32 + </a> 33 + <a 34 + href="https://status.zzstoatzz.io/@plyr.fm" 35 + target="_blank" 36 + rel="noopener noreferrer" 37 + class="social-link" 38 + title="view status page" 39 + > 40 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 41 + <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 42 + </svg> 43 + </a> 44 + <a 45 + href="https://tangled.org/@zzstoatzz.io/plyr.fm" 46 + target="_blank" 47 + rel="noopener noreferrer" 48 + class="social-link" 49 + title="view source on tangled" 50 + > 51 + <img src="https://cdn.bsky.app/img/avatar/plain/did:plc:wshs7t2adsemcrrd4snkeqli/bafkreif6z53z4ukqmdgwstspwh5asmhxheblcd2adisoccl4fflozc3kva@jpeg" alt="Tangled" width="18" height="18" class="tangled-icon" /> 52 + </a> 26 53 </div> 27 - <!-- Logout positioned on far right, centered in right margin --> 28 - {#if isAuthenticated} 29 - <div class="logout-right desktop-only"> 30 - <button onclick={onLogout} class="btn-logout-outer" title="log out">logout</button> 31 - </div> 32 - {/if} 33 - <div class="header-content"> 34 - <div class="left-section"> 35 - <!-- desktop: show icons inline --> 36 - <a 37 - href="https://bsky.app/profile/plyr.fm" 38 - target="_blank" 39 - rel="noopener noreferrer" 40 - class="bluesky-link desktop-only" 41 - title="Follow @plyr.fm on Bluesky" 42 - > 43 - <svg 44 - width="20" 45 - height="20" 46 - viewBox="0 0 600 530" 47 - fill="currentColor" 48 - xmlns="http://www.w3.org/2000/svg" 49 - > 50 - <path 51 - d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" 52 - /> 53 - </svg> 54 - </a> 55 - <a 56 - href="https://status.zzstoatzz.io/@plyr.fm" 57 - target="_blank" 58 - rel="noopener noreferrer" 59 - class="status-link desktop-only" 60 - title="View status page" 61 - > 62 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 63 - <polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline> 64 - </svg> 65 - </a> 66 - <a 67 - href="https://tangled.org/@zzstoatzz.io/plyr.fm" 68 - target="_blank" 69 - rel="noopener noreferrer" 70 - class="tangled-link desktop-only" 71 - title="View source on Tangled" 72 - > 73 - <img src="https://cdn.bsky.app/img/avatar/plain/did:plc:wshs7t2adsemcrrd4snkeqli/bafkreif6z53z4ukqmdgwstspwh5asmhxheblcd2adisoccl4fflozc3kva@jpeg" alt="Tangled" width="20" height="20" class="tangled-icon" /> 74 - </a> 54 + 55 + <!-- desktop: logo left, nav items evenly spaced right --> 56 + <div class="header-content desktop-only"> 57 + <a href="/" class="brand"> 58 + <h1>{APP_NAME}{#if APP_STAGE}<sup class="stage-badge">{APP_STAGE}</sup>{/if}</h1> 59 + <p>{APP_TAGLINE}</p> 60 + </a> 61 + 62 + <button class="nav-link" onclick={() => search.open()} title="search (Cmd+K)"> 63 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 64 + <circle cx="11" cy="11" r="8"></circle> 65 + <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 66 + </svg> 67 + <span>search</span> 68 + </button> 69 + 70 + {#if isAuthenticated} 71 + {#if !$page.url.pathname.startsWith('/library')} 72 + <a href="/library" class="nav-link" title="library"> 73 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 74 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 75 + </svg> 76 + <span>library</span> 77 + </a> 78 + {/if} 79 + 80 + {#if $page.url.pathname !== '/upload'} 81 + <a href="/upload" class="nav-link upload-link" title="upload a track"> 82 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 83 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 84 + <polyline points="17 8 12 3 7 8"></polyline> 85 + <line x1="12" y1="3" x2="12" y2="15"></line> 86 + </svg> 87 + <span>upload</span> 88 + </a> 89 + {/if} 75 90 76 - <!-- mobile: show menu button --> 77 - <div class="mobile-only"> 78 - <LinksMenu /> 79 - </div> 91 + <UserMenu {user} {onLogout} /> 92 + {:else} 93 + <a href="/login" class="btn-primary">log in</a> 94 + {/if} 95 + </div> 80 96 97 + <!-- mobile: original nested structure --> 98 + <div class="header-content-mobile mobile-only"> 99 + <div class="left-section"> 100 + <LinksMenu /> 81 101 <a href="/" class="brand"> 82 102 <h1>{APP_NAME}{#if APP_STAGE}<sup class="stage-badge">{APP_STAGE}</sup>{/if}</h1> 83 103 <p>{APP_TAGLINE}</p> 84 104 </a> 85 105 </div> 86 106 87 - <!-- Mobile: navigation icons with flex spacer --> 88 - <div class="mobile-center mobile-only"> 89 - <button class="nav-icon" onclick={() => search.open()} title="search (⌘K)"> 107 + <div class="mobile-center"> 108 + <button class="nav-icon" onclick={() => search.open()} title="search (Cmd+K)"> 90 109 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 91 110 <circle cx="11" cy="11" r="8"></circle> 92 111 <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 93 112 </svg> 94 113 </button> 95 - {#if $page.url.pathname !== '/'} 96 - <a href="/" class="nav-icon" title="go to feed"> 97 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 98 - <circle cx="12" cy="12" r="10"></circle> 99 - <line x1="2" y1="12" x2="22" y2="12"></line> 100 - <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 101 - </svg> 102 - </a> 103 - {/if} 104 114 {#if isAuthenticated && !$page.url.pathname.startsWith('/library')} 105 - <a href="/library" class="nav-icon" title="go to library"> 115 + <a href="/library" class="nav-icon" title="library"> 106 116 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 107 117 <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 108 118 </svg> ··· 110 120 {/if} 111 121 </div> 112 122 113 - <nav> 114 - {#if isAuthenticated} 115 - <!-- Desktop nav --> 116 - <div class="desktop-nav desktop-only"> 117 - {#if $page.url.pathname !== '/'} 118 - <a href="/" class="nav-link" title="go to feed"> 119 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 120 - <circle cx="12" cy="12" r="10"></circle> 121 - <line x1="2" y1="12" x2="22" y2="12"></line> 122 - <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path> 123 - </svg> 124 - <span>feed</span> 125 - </a> 126 - {/if} 127 - {#if !$page.url.pathname.startsWith('/library')} 128 - <a href="/library" class="nav-link" title="go to library"> 129 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 130 - <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path> 131 - </svg> 132 - <span>library</span> 133 - </a> 134 - {/if} 135 - {#if $page.url.pathname !== '/upload'} 136 - <a href="/upload" class="nav-link upload-link" title="upload a track"> 137 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 138 - <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 139 - <polyline points="17 8 12 3 7 8"></polyline> 140 - <line x1="12" y1="3" x2="12" y2="15"></line> 141 - </svg> 142 - <span>upload</span> 143 - </a> 144 - {/if} 145 - {#if $page.url.pathname !== '/portal'} 146 - <a href="/portal" class="user-handle" title="go to portal">@{user?.handle}</a> 147 - {/if} 148 - <SettingsMenu /> 149 - </div> 150 - 151 - <!-- Mobile nav: just ProfileMenu --> 152 - <div class="mobile-only"> 153 - <ProfileMenu {user} {onLogout} /> 154 - </div> 155 - {:else} 156 - <a href="/login" class="btn-primary">log in</a> 157 - {/if} 158 - </nav> 123 + {#if isAuthenticated} 124 + <ProfileMenu {user} {onLogout} /> 125 + {:else} 126 + <a href="/login" class="btn-primary">log in</a> 127 + {/if} 159 128 </div> 160 129 </header> 161 130 ··· 171 140 -webkit-backdrop-filter: var(--glass-blur, none); 172 141 } 173 142 143 + .margin-left { 144 + position: absolute; 145 + left: 0; 146 + top: 50%; 147 + transform: translateY(-50%); 148 + display: flex; 149 + align-items: center; 150 + justify-content: flex-end; 151 + width: calc((100vw - var(--queue-width, 0px) - 800px) / 2); 152 + padding: 0 1rem; 153 + gap: 0.5rem; 154 + } 155 + 156 + /* desktop: flat structure with space-between */ 174 157 .header-content { 175 158 max-width: 800px; 176 159 margin: 0 auto; ··· 178 161 display: flex; 179 162 justify-content: space-between; 180 163 align-items: center; 181 - gap: 1rem; 182 - position: relative; 183 164 } 184 165 185 - .left-section { 166 + /* mobile: nested structure */ 167 + .header-content-mobile { 168 + padding: 0.75rem; 186 169 display: flex; 170 + justify-content: space-between; 187 171 align-items: center; 188 - gap: 0.5rem; 189 - } 190 - 191 - .brand { 192 - text-decoration: none; 193 - color: inherit; 194 - display: flex; 195 - flex-direction: column; 196 - gap: 0.25rem; 197 - flex-shrink: 0; 198 - margin-left: 1.5rem; 199 - } 200 - 201 - .brand:hover h1 { 202 - color: var(--accent); 172 + gap: 0.75rem; 203 173 } 204 174 205 175 .desktop-only { ··· 210 180 display: none; 211 181 } 212 182 213 - .desktop-nav { 183 + .left-section { 214 184 display: flex; 215 185 align-items: center; 216 - gap: 0.75rem; 186 + gap: 0.5rem; 217 187 } 218 188 219 - .mobile-center { 220 - flex: 1; 221 - justify-content: space-evenly; 189 + .desktop-nav { 190 + display: flex; 222 191 align-items: center; 192 + gap: 1rem; 223 193 } 224 194 225 - .nav-icon { 226 - display: flex; 227 - align-items: center; 228 - justify-content: center; 229 - width: 44px; 230 - height: 44px; 231 - border-radius: var(--radius-md); 232 - background: transparent; 233 - border: none; 234 - color: var(--text-secondary); 195 + .brand { 235 196 text-decoration: none; 236 - cursor: pointer; 237 - font-family: inherit; 238 - transition: all 0.15s; 239 - -webkit-tap-highlight-color: transparent; 240 - } 241 - 242 - .nav-icon:hover { 243 - color: var(--accent); 244 - background: var(--bg-tertiary); 245 - } 246 - 247 - .nav-icon:active { 248 - transform: scale(0.94); 249 - } 250 - 251 - .margin-left { 252 - position: absolute; 253 - left: 0; 254 - top: 50%; 255 - transform: translateY(-50%); 256 - transition: width 0.3s ease; 197 + color: inherit; 257 198 display: flex; 258 - align-items: center; 259 - justify-content: space-evenly; 260 - /* Fill the left margin area */ 261 - width: calc((100vw - var(--queue-width, 0px) - 800px) / 2); 262 - padding: 0 1rem; 263 - } 264 - 265 - .logout-right { 266 - position: absolute; 267 - right: calc((100vw - var(--queue-width, 0px) - 800px) / 4); 268 - top: 50%; 269 - transform: translate(50%, -50%); 270 - transition: right 0.3s ease; 271 - } 272 - 273 - .btn-logout-outer { 274 - background: transparent; 275 - border: 1px solid var(--border-emphasis); 276 - color: var(--text-secondary); 277 - padding: 0.5rem 1rem; 278 - border-radius: var(--radius-base); 279 - font-size: var(--text-base); 280 - font-family: inherit; 281 - cursor: pointer; 282 - transition: all 0.2s; 283 - white-space: nowrap; 199 + flex-direction: column; 200 + gap: 0.25rem; 201 + flex-shrink: 0; 284 202 } 285 203 286 - .btn-logout-outer:hover { 287 - border-color: var(--accent); 204 + .brand:hover h1 { 288 205 color: var(--accent); 289 206 } 290 207 291 - .bluesky-link, 292 - .status-link, 293 - .tangled-link { 208 + .social-link { 294 209 display: flex; 295 210 align-items: center; 296 211 justify-content: center; 297 212 color: var(--text-secondary); 298 - transition: color 0.2s, opacity 0.2s; 299 213 text-decoration: none; 300 - flex-shrink: 0; 214 + transition: color 0.15s; 301 215 } 302 216 303 - .bluesky-link:hover { 304 - color: #1185fe; 217 + .social-link:hover { 218 + color: var(--accent); 305 219 } 306 220 307 - .status-link:hover { 221 + .social-link:hover svg { 308 222 color: var(--accent); 309 223 } 310 224 311 225 .tangled-icon { 312 226 border-radius: var(--radius-sm); 313 227 opacity: 0.7; 314 - transition: opacity 0.2s, box-shadow 0.2s; 228 + transition: opacity 0.15s; 315 229 } 316 230 317 - .tangled-link:hover .tangled-icon { 231 + .social-link:hover .tangled-icon { 318 232 opacity: 1; 319 - box-shadow: 0 0 0 2px var(--accent); 320 233 } 321 234 322 235 h1 { ··· 342 255 letter-spacing: 0.02em; 343 256 } 344 257 345 - nav { 258 + .mobile-center { 259 + flex: 1; 260 + display: flex; 261 + justify-content: space-evenly; 262 + align-items: center; 263 + } 264 + 265 + .nav-icon { 346 266 display: flex; 347 267 align-items: center; 348 - gap: 0.75rem; 349 - flex-wrap: wrap; 350 - justify-content: flex-end; 268 + justify-content: center; 269 + width: 44px; 270 + height: 44px; 271 + border-radius: var(--radius-md); 272 + background: transparent; 273 + border: none; 274 + color: var(--text-secondary); 275 + text-decoration: none; 276 + cursor: pointer; 277 + font-family: inherit; 278 + transition: all 0.15s; 279 + -webkit-tap-highlight-color: transparent; 280 + } 281 + 282 + .nav-icon:hover { 283 + color: var(--accent); 284 + background: var(--bg-tertiary); 285 + } 286 + 287 + .nav-icon:active { 288 + transform: scale(0.94); 351 289 } 352 290 353 291 .nav-link { 354 292 color: var(--text-secondary); 355 293 text-decoration: none; 356 294 font-size: var(--text-base); 357 - transition: all 0.2s; 295 + font-family: inherit; 296 + transition: all 0.15s; 358 297 white-space: nowrap; 359 298 display: flex; 360 299 align-items: center; 361 300 gap: 0.4rem; 362 - padding: 0.4rem 0.75rem; 301 + padding: 0.4rem 0.65rem; 363 302 border-radius: var(--radius-base); 364 303 border: 1px solid transparent; 304 + background: transparent; 305 + cursor: pointer; 365 306 } 366 307 367 308 .nav-link:hover { ··· 383 324 .nav-link svg { 384 325 width: 16px; 385 326 height: 16px; 327 + flex-shrink: 0; 386 328 } 387 329 388 - .user-handle { 389 - color: var(--text-secondary); 390 - text-decoration: none; 391 - font-size: var(--text-base); 392 - padding: 0.4rem 0.75rem; 393 - background: var(--bg-tertiary); 394 - border-radius: var(--radius-base); 395 - border: 1px solid var(--border-default); 396 - transition: all 0.2s; 397 - white-space: nowrap; 398 - } 399 - 400 - .user-handle:hover { 401 - border-color: var(--accent); 402 - color: var(--accent); 403 - background: var(--bg-hover); 404 - } 405 330 406 331 .btn-primary { 407 332 background: transparent; ··· 411 336 border-radius: var(--radius-base); 412 337 font-size: var(--text-base); 413 338 text-decoration: none; 414 - transition: all 0.2s; 339 + transition: all 0.15s; 415 340 cursor: pointer; 416 341 white-space: nowrap; 417 342 } ··· 421 346 color: var(--bg-primary); 422 347 } 423 348 424 - /* header mobile breakpoint - see $lib/breakpoints.ts 425 - switch to mobile before margin elements crowd each other */ 426 - @media (max-width: 1300px) { 427 - .margin-left, 428 - .logout-right { 349 + /* Hide margin-left when viewport is too narrow for left margin */ 350 + @media (max-width: 1000px) { 351 + .margin-left { 429 352 display: none !important; 430 353 } 354 + } 431 355 356 + @media (max-width: 768px) { 432 357 .desktop-only { 433 358 display: none !important; 434 359 } ··· 437 362 display: flex; 438 363 } 439 364 440 - .brand { 441 - margin-left: 0; 442 - } 443 - } 444 - 445 - /* mobile breakpoint - see $lib/breakpoints.ts */ 446 - @media (max-width: 768px) { 447 - .header-content { 448 - padding: 0.75rem 0.75rem; 449 - gap: 0.75rem; 450 - } 451 - 452 - .left-section { 453 - gap: 0.5rem; 454 - } 455 - 456 365 .brand h1 { 457 366 font-size: 1.15rem; 458 367 } 459 368 460 369 .brand p { 461 370 font-size: 0.55rem; 462 - } 463 - 464 - nav { 465 - gap: 0.4rem; 466 - } 467 - 468 - .nav-link { 469 - padding: 0.3rem 0.5rem; 470 - font-size: var(--text-sm); 471 - } 472 - 473 - .nav-link span { 474 - display: none; 475 - } 476 - 477 - .user-handle { 478 - font-size: var(--text-sm); 479 - padding: 0.3rem 0.5rem; 480 371 } 481 372 482 373 .btn-primary {
-39
frontend/src/lib/components/SearchTrigger.svelte
··· 1 - <script lang="ts"> 2 - import { search } from '$lib/search.svelte'; 3 - </script> 4 - 5 - <button class="search-trigger" onclick={() => search.open()} title="search (⌘K)"> 6 - <svg 7 - width="16" 8 - height="16" 9 - viewBox="0 0 24 24" 10 - fill="none" 11 - stroke="currentColor" 12 - stroke-width="2" 13 - stroke-linecap="round" 14 - stroke-linejoin="round" 15 - > 16 - <circle cx="11" cy="11" r="8"></circle> 17 - <line x1="21" y1="21" x2="16.65" y2="16.65"></line> 18 - </svg> 19 - </button> 20 - 21 - <style> 22 - .search-trigger { 23 - background: transparent; 24 - border: 1px solid var(--border-default); 25 - color: var(--text-secondary); 26 - padding: 0.5rem; 27 - border-radius: var(--radius-sm); 28 - cursor: pointer; 29 - transition: all 0.2s; 30 - display: flex; 31 - align-items: center; 32 - justify-content: center; 33 - } 34 - 35 - .search-trigger:hover { 36 - color: var(--accent); 37 - border-color: var(--accent); 38 - } 39 - </style>
-438
frontend/src/lib/components/SettingsMenu.svelte
··· 1 - <script lang="ts"> 2 - import { onMount } from 'svelte'; 3 - import { queue } from '$lib/queue.svelte'; 4 - import { preferences, type Theme } from '$lib/preferences.svelte'; 5 - 6 - let showSettings = $state(false); 7 - 8 - const presetColors = [ 9 - { name: 'blue', value: '#6a9fff' }, 10 - { name: 'purple', value: '#a78bfa' }, 11 - { name: 'pink', value: '#f472b6' }, 12 - { name: 'green', value: '#4ade80' }, 13 - { name: 'orange', value: '#fb923c' }, 14 - { name: 'red', value: '#ef4444' } 15 - ]; 16 - 17 - const themes: { value: Theme; label: string; icon: string }[] = [ 18 - { value: 'dark', label: 'dark', icon: 'moon' }, 19 - { value: 'light', label: 'light', icon: 'sun' }, 20 - { value: 'system', label: 'system', icon: 'auto' } 21 - ]; 22 - 23 - // derive from preferences store 24 - let currentColor = $derived(preferences.accentColor ?? '#6a9fff'); 25 - let autoAdvance = $derived(preferences.autoAdvance); 26 - let currentTheme = $derived(preferences.theme); 27 - 28 - // apply color when it changes 29 - $effect(() => { 30 - if (currentColor) { 31 - applyColorLocally(currentColor); 32 - } 33 - }); 34 - 35 - // sync auto-advance with queue 36 - $effect(() => { 37 - queue.setAutoAdvance(autoAdvance); 38 - }); 39 - 40 - onMount(() => { 41 - // apply initial color from localStorage while waiting for preferences 42 - const savedAccent = localStorage.getItem('accentColor'); 43 - if (savedAccent) { 44 - applyColorLocally(savedAccent); 45 - } 46 - }); 47 - 48 - function toggleSettings() { 49 - showSettings = !showSettings; 50 - } 51 - 52 - function applyColorLocally(color: string) { 53 - document.documentElement.style.setProperty('--accent', color); 54 - 55 - const r = parseInt(color.slice(1, 3), 16); 56 - const g = parseInt(color.slice(3, 5), 16); 57 - const b = parseInt(color.slice(5, 7), 16); 58 - const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`; 59 - document.documentElement.style.setProperty('--accent-hover', hover); 60 - } 61 - 62 - async function applyColor(color: string) { 63 - applyColorLocally(color); 64 - localStorage.setItem('accentColor', color); 65 - await preferences.update({ accent_color: color }); 66 - } 67 - 68 - function handleColorInput(event: Event) { 69 - const input = event.target as HTMLInputElement; 70 - applyColor(input.value); 71 - } 72 - 73 - function selectPreset(color: string) { 74 - applyColor(color); 75 - } 76 - 77 - async function handleAutoAdvanceToggle(event: Event) { 78 - const input = event.target as HTMLInputElement; 79 - const value = input.checked; 80 - queue.setAutoAdvance(value); 81 - localStorage.setItem('autoAdvance', value ? '1' : '0'); 82 - await preferences.update({ auto_advance: value }); 83 - } 84 - 85 - function selectTheme(theme: Theme) { 86 - preferences.setTheme(theme); 87 - } 88 - </script> 89 - 90 - <div class="settings-menu"> 91 - <button class="settings-toggle" onclick={toggleSettings} title="open settings"> 92 - <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 93 - <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path> 94 - <circle cx="12" cy="12" r="3"></circle> 95 - </svg> 96 - </button> 97 - 98 - {#if showSettings} 99 - <div class="settings-panel"> 100 - <div class="panel-header"> 101 - <span>settings</span> 102 - <button class="close-btn" onclick={toggleSettings}>×</button> 103 - </div> 104 - 105 - <section class="settings-section"> 106 - <h3>theme</h3> 107 - <div class="theme-buttons"> 108 - {#each themes as theme} 109 - <button 110 - class="theme-btn" 111 - class:active={currentTheme === theme.value} 112 - onclick={() => selectTheme(theme.value)} 113 - title={theme.label} 114 - > 115 - {#if theme.icon === 'moon'} 116 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 117 - <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> 118 - </svg> 119 - {:else if theme.icon === 'sun'} 120 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 121 - <circle cx="12" cy="12" r="5" /> 122 - <path d="M12 1v2m0 18v2M4.22 4.22l1.42 1.42m12.72 12.72 1.42 1.42M1 12h2m18 0h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /> 123 - </svg> 124 - {:else} 125 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 126 - <circle cx="12" cy="12" r="9" /> 127 - <path d="M12 3v18" /> 128 - <path d="M12 3a9 9 0 0 1 0 18" fill="currentColor" opacity="0.3" /> 129 - </svg> 130 - {/if} 131 - <span>{theme.label}</span> 132 - </button> 133 - {/each} 134 - </div> 135 - </section> 136 - 137 - <section class="settings-section"> 138 - <h3>accent color</h3> 139 - <div class="color-picker-row"> 140 - <input type="color" value={currentColor} oninput={handleColorInput} class="color-input" /> 141 - <span class="color-value">{currentColor}</span> 142 - </div> 143 - 144 - <div class="preset-grid"> 145 - {#each presetColors as preset} 146 - <button 147 - class="preset-btn" 148 - class:active={currentColor.toLowerCase() === preset.value.toLowerCase()} 149 - style="background: {preset.value}" 150 - onclick={() => selectPreset(preset.value)} 151 - title={preset.name} 152 - ></button> 153 - {/each} 154 - </div> 155 - </section> 156 - 157 - <section class="settings-section"> 158 - <h3>playback</h3> 159 - <label class="toggle"> 160 - <input type="checkbox" checked={autoAdvance} oninput={handleAutoAdvanceToggle} /> 161 - <span class="toggle-indicator"></span> 162 - <span class="toggle-text">auto-play next track</span> 163 - </label> 164 - <p class="toggle-hint">when a track ends, start the next item in your queue</p> 165 - </section> 166 - 167 - <a href="/settings" class="all-settings-link" onclick={toggleSettings}> 168 - all settings → 169 - </a> 170 - </div> 171 - {/if} 172 - </div> 173 - 174 - <style> 175 - .settings-menu { 176 - position: relative; 177 - } 178 - 179 - .settings-toggle { 180 - background: transparent; 181 - border: 1px solid var(--border-default); 182 - color: var(--text-secondary); 183 - padding: 0.5rem; 184 - border-radius: var(--radius-sm); 185 - cursor: pointer; 186 - transition: all 0.2s; 187 - display: flex; 188 - align-items: center; 189 - justify-content: center; 190 - } 191 - 192 - .settings-toggle:hover { 193 - color: var(--accent); 194 - border-color: var(--accent); 195 - } 196 - 197 - .settings-panel { 198 - position: absolute; 199 - top: calc(100% + 0.5rem); 200 - right: 0; 201 - background: var(--bg-secondary); 202 - border: 1px solid var(--border-default); 203 - border-radius: var(--radius-md); 204 - padding: 1.25rem; 205 - min-width: 280px; 206 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); 207 - z-index: 1000; 208 - display: flex; 209 - flex-direction: column; 210 - gap: 1.25rem; 211 - } 212 - 213 - .panel-header { 214 - display: flex; 215 - justify-content: space-between; 216 - align-items: center; 217 - color: var(--text-primary); 218 - font-weight: 600; 219 - font-size: var(--text-base); 220 - } 221 - 222 - .close-btn { 223 - background: transparent; 224 - border: none; 225 - color: var(--text-secondary); 226 - font-size: 1.4rem; 227 - cursor: pointer; 228 - width: 28px; 229 - height: 28px; 230 - display: flex; 231 - align-items: center; 232 - justify-content: center; 233 - transition: color 0.2s; 234 - } 235 - 236 - .close-btn:hover { 237 - color: var(--text-primary); 238 - } 239 - 240 - .settings-section { 241 - display: flex; 242 - flex-direction: column; 243 - gap: 0.75rem; 244 - } 245 - 246 - .settings-section h3 { 247 - margin: 0; 248 - font-size: var(--text-sm); 249 - text-transform: uppercase; 250 - letter-spacing: 0.08em; 251 - color: var(--text-tertiary); 252 - } 253 - 254 - .theme-buttons { 255 - display: flex; 256 - gap: 0.5rem; 257 - } 258 - 259 - .theme-btn { 260 - flex: 1; 261 - display: flex; 262 - flex-direction: column; 263 - align-items: center; 264 - gap: 0.35rem; 265 - padding: 0.6rem 0.5rem; 266 - background: var(--bg-tertiary); 267 - border: 1px solid var(--border-default); 268 - border-radius: var(--radius-base); 269 - color: var(--text-secondary); 270 - cursor: pointer; 271 - transition: all 0.2s; 272 - } 273 - 274 - .theme-btn:hover { 275 - border-color: var(--accent); 276 - color: var(--accent); 277 - } 278 - 279 - .theme-btn.active { 280 - background: color-mix(in srgb, var(--accent) 15%, transparent); 281 - border-color: var(--accent); 282 - color: var(--accent); 283 - } 284 - 285 - .theme-btn svg { 286 - width: 18px; 287 - height: 18px; 288 - } 289 - 290 - .theme-btn span { 291 - font-size: var(--text-xs); 292 - text-transform: uppercase; 293 - letter-spacing: 0.05em; 294 - } 295 - 296 - .color-picker-row { 297 - display: flex; 298 - align-items: center; 299 - gap: 0.75rem; 300 - } 301 - 302 - .color-input { 303 - width: 48px; 304 - height: 32px; 305 - border: 1px solid var(--border-default); 306 - border-radius: var(--radius-sm); 307 - cursor: pointer; 308 - background: transparent; 309 - } 310 - 311 - .color-input::-webkit-color-swatch-wrapper { 312 - padding: 2px; 313 - } 314 - 315 - .color-input::-webkit-color-swatch { 316 - border-radius: 2px; 317 - border: none; 318 - } 319 - 320 - .color-value { 321 - font-family: monospace; 322 - font-size: var(--text-sm); 323 - color: var(--text-secondary); 324 - } 325 - 326 - .preset-grid { 327 - display: grid; 328 - grid-template-columns: repeat(6, 1fr); 329 - gap: 0.5rem; 330 - } 331 - 332 - .preset-btn { 333 - width: 32px; 334 - height: 32px; 335 - border-radius: var(--radius-sm); 336 - border: 2px solid transparent; 337 - cursor: pointer; 338 - transition: all 0.2s; 339 - padding: 0; 340 - } 341 - 342 - .preset-btn:hover { 343 - transform: scale(1.08); 344 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 345 - } 346 - 347 - .preset-btn.active { 348 - border-color: var(--text-primary); 349 - box-shadow: 0 0 0 1px var(--bg-secondary), 0 2px 8px rgba(0, 0, 0, 0.35); 350 - } 351 - 352 - .toggle { 353 - display: flex; 354 - align-items: center; 355 - gap: 0.65rem; 356 - color: var(--text-primary); 357 - font-size: var(--text-base); 358 - } 359 - 360 - .toggle input { 361 - appearance: none; 362 - width: 42px; 363 - height: 22px; 364 - border-radius: var(--radius-full); 365 - background: var(--border-default); 366 - position: relative; 367 - cursor: pointer; 368 - transition: background 0.2s, border 0.2s; 369 - border: 1px solid var(--border-default); 370 - flex-shrink: 0; 371 - } 372 - 373 - .toggle input::after { 374 - content: ''; 375 - position: absolute; 376 - top: 2px; 377 - left: 2px; 378 - width: 16px; 379 - height: 16px; 380 - border-radius: var(--radius-full); 381 - background: var(--text-secondary); 382 - transition: transform 0.2s, background 0.2s; 383 - } 384 - 385 - .toggle input:checked { 386 - background: color-mix(in srgb, var(--accent) 65%, transparent); 387 - border-color: var(--accent); 388 - } 389 - 390 - .toggle input:checked::after { 391 - transform: translateX(20px); 392 - background: var(--accent); 393 - } 394 - 395 - .toggle-indicator { 396 - display: none; 397 - } 398 - 399 - .toggle-text { 400 - white-space: nowrap; 401 - } 402 - 403 - .toggle-hint { 404 - margin: 0; 405 - color: var(--text-tertiary); 406 - font-size: var(--text-sm); 407 - line-height: 1.3; 408 - } 409 - 410 - .all-settings-link { 411 - display: block; 412 - text-align: center; 413 - padding: 0.75rem; 414 - margin-top: 0.5rem; 415 - border-top: 1px solid var(--border-subtle); 416 - color: var(--text-secondary); 417 - text-decoration: none; 418 - font-size: var(--text-sm); 419 - transition: color 0.15s; 420 - } 421 - 422 - .all-settings-link:hover { 423 - color: var(--accent); 424 - } 425 - 426 - @media (max-width: 768px) { 427 - .settings-panel { 428 - position: fixed; 429 - top: auto; 430 - bottom: calc(var(--player-height, 0px) + 1rem); 431 - right: 1rem; 432 - left: 1rem; 433 - min-width: auto; 434 - max-height: 70vh; 435 - overflow-y: auto; 436 - } 437 - } 438 - </style>
+205
frontend/src/lib/components/UserMenu.svelte
··· 1 + <script lang="ts"> 2 + import type { User } from '$lib/types'; 3 + 4 + interface Props { 5 + user: User | null; 6 + onLogout: () => Promise<void>; 7 + } 8 + 9 + let { user, onLogout }: Props = $props(); 10 + let showMenu = $state(false); 11 + let menuRef = $state<HTMLDivElement | null>(null); 12 + 13 + function toggleMenu() { 14 + showMenu = !showMenu; 15 + } 16 + 17 + function closeMenu() { 18 + showMenu = false; 19 + } 20 + 21 + async function handleLogout() { 22 + closeMenu(); 23 + await onLogout(); 24 + } 25 + 26 + function handleClickOutside(event: MouseEvent) { 27 + if (menuRef && !menuRef.contains(event.target as Node)) { 28 + closeMenu(); 29 + } 30 + } 31 + 32 + $effect(() => { 33 + if (showMenu) { 34 + document.addEventListener('click', handleClickOutside); 35 + return () => document.removeEventListener('click', handleClickOutside); 36 + } 37 + }); 38 + </script> 39 + 40 + <div class="user-menu" bind:this={menuRef}> 41 + <button class="menu-trigger" onclick={toggleMenu} title="account menu"> 42 + <span class="handle">@{user?.handle}</span> 43 + <svg 44 + class="chevron" 45 + class:open={showMenu} 46 + width="12" 47 + height="12" 48 + viewBox="0 0 24 24" 49 + fill="none" 50 + stroke="currentColor" 51 + stroke-width="2" 52 + stroke-linecap="round" 53 + stroke-linejoin="round" 54 + > 55 + <polyline points="6 9 12 15 18 9"></polyline> 56 + </svg> 57 + </button> 58 + 59 + {#if showMenu} 60 + <div class="dropdown"> 61 + <a href="/portal" class="dropdown-item" onclick={closeMenu}> 62 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 63 + <rect x="3" y="3" width="7" height="7"></rect> 64 + <rect x="14" y="3" width="7" height="7"></rect> 65 + <rect x="14" y="14" width="7" height="7"></rect> 66 + <rect x="3" y="14" width="7" height="7"></rect> 67 + </svg> 68 + <span>portal</span> 69 + </a> 70 + <a href="/settings" class="dropdown-item" onclick={closeMenu}> 71 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 72 + <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path> 73 + <circle cx="12" cy="12" r="3"></circle> 74 + </svg> 75 + <span>settings</span> 76 + </a> 77 + <div class="dropdown-divider"></div> 78 + <button class="dropdown-item logout" onclick={handleLogout}> 79 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 80 + <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> 81 + <polyline points="16 17 21 12 16 7"></polyline> 82 + <line x1="21" y1="12" x2="9" y2="12"></line> 83 + </svg> 84 + <span>logout</span> 85 + </button> 86 + </div> 87 + {/if} 88 + </div> 89 + 90 + <style> 91 + .user-menu { 92 + position: relative; 93 + } 94 + 95 + .menu-trigger { 96 + display: flex; 97 + align-items: center; 98 + gap: 0.35rem; 99 + padding: 0.4rem 0.6rem; 100 + background: var(--bg-tertiary); 101 + border: 1px solid var(--border-default); 102 + border-radius: var(--radius-base); 103 + color: var(--text-secondary); 104 + font-family: inherit; 105 + font-size: var(--text-base); 106 + cursor: pointer; 107 + transition: all 0.15s; 108 + white-space: nowrap; 109 + } 110 + 111 + .menu-trigger:hover { 112 + border-color: var(--accent); 113 + color: var(--accent); 114 + background: var(--bg-hover); 115 + } 116 + 117 + .handle { 118 + max-width: 160px; 119 + overflow: hidden; 120 + text-overflow: ellipsis; 121 + } 122 + 123 + .chevron { 124 + flex-shrink: 0; 125 + transition: transform 0.15s; 126 + } 127 + 128 + .chevron.open { 129 + transform: rotate(180deg); 130 + } 131 + 132 + .dropdown { 133 + position: absolute; 134 + top: calc(100% + 0.5rem); 135 + right: 0; 136 + min-width: 180px; 137 + background: var(--bg-secondary); 138 + border: 1px solid var(--border-default); 139 + border-radius: var(--radius-md); 140 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); 141 + z-index: 100; 142 + overflow: hidden; 143 + animation: dropdownIn 0.12s ease-out; 144 + } 145 + 146 + @keyframes dropdownIn { 147 + from { 148 + opacity: 0; 149 + transform: translateY(-4px); 150 + } 151 + to { 152 + opacity: 1; 153 + transform: translateY(0); 154 + } 155 + } 156 + 157 + .dropdown-item { 158 + display: flex; 159 + align-items: center; 160 + gap: 0.75rem; 161 + width: 100%; 162 + padding: 0.75rem 1rem; 163 + background: transparent; 164 + border: none; 165 + color: var(--text-primary); 166 + font-family: inherit; 167 + font-size: var(--text-base); 168 + text-decoration: none; 169 + cursor: pointer; 170 + transition: background 0.12s; 171 + text-align: left; 172 + } 173 + 174 + .dropdown-item:hover { 175 + background: var(--bg-hover); 176 + } 177 + 178 + .dropdown-item svg { 179 + flex-shrink: 0; 180 + color: var(--text-secondary); 181 + transition: color 0.12s; 182 + } 183 + 184 + .dropdown-item:hover svg { 185 + color: var(--accent); 186 + } 187 + 188 + .dropdown-item.logout:hover { 189 + background: color-mix(in srgb, var(--error) 10%, transparent); 190 + } 191 + 192 + .dropdown-item.logout:hover svg { 193 + color: var(--error); 194 + } 195 + 196 + .dropdown-item.logout:hover span { 197 + color: var(--error); 198 + } 199 + 200 + .dropdown-divider { 201 + height: 1px; 202 + background: var(--border-subtle); 203 + margin: 0.25rem 0; 204 + } 205 + </style>